diff --git a/skills/prospect-sequence/SKILL.md b/skills/prospect-sequence/SKILL.md new file mode 100644 index 000000000..d5d2c4d88 --- /dev/null +++ b/skills/prospect-sequence/SKILL.md @@ -0,0 +1,56 @@ +--- +name: prospect-sequence +description: Research an account from allowlisted public sources and draft a sourced, gated outreach sequence without sending it. +source: + type: cli-tool + command: node + args: + - run.mjs + timeout_seconds: 30 + sandbox: + profile: readonly + cwd_policy: skill-directory + require_enforcement: false +inputs: + prospect: + type: json + required: true + description: Prospect object with company and optional contact fields. + icp: + type: string + required: true + description: Ideal customer profile or sales hypothesis to test. + source_allowlist: + type: json + required: true + description: List of allowlisted public source URLs or hostnames. +runx: + category: growth + input_resolution: + required: + - prospect + - icp + - source_allowlist +--- + +# Prospect Sequence + +`prospect-sequence` researches a target account through public, allowlisted +sources and produces a sourced outreach angle, a short multi-touch sequence, and +a gated send proposal. It never sends the message itself. + +## Contract + +- Inputs: `prospect{ company, contact }`, `icp`, and `source_allowlist`. +- Output: `research{ sources[], angle }`, `sequence[]`, and `send_proposal`. +- Every account fact used in the angle must cite a source that was read. +- If no allowlisted public source is available, or the target points to a + private-network/off-allowlist URL, the skill refuses instead of fabricating. +- The `send_proposal` is a proposed effect for a downstream send-as catalog + skill; it is not an email send. + +## Safety boundary + +The runner accepts only `https://` public sources whose host is explicitly +allowlisted. It blocks localhost, private IPs, link-local ranges, and hosts not +present in `source_allowlist`. diff --git a/skills/prospect-sequence/X.yaml b/skills/prospect-sequence/X.yaml new file mode 100644 index 000000000..9ec8fab80 --- /dev/null +++ b/skills/prospect-sequence/X.yaml @@ -0,0 +1,63 @@ +skill: prospect-sequence +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +harness: + cases: + - name: allowlisted-account-produces-sequence + runner: default + inputs: + prospect: + company: Runx + contact: Developer Relations + website: https://runx.ai + icp: Agent tooling teams that need portable skills, governed execution, and checkable receipts. + source_allowlist: + - https://runx.ai + - https://github.com/runxhq/runx + expect: + status: sealed + receipt: + schema: runx.receipt.v1 + state: sealed + disposition: closed + - name: off-allowlist-source-is-refused + runner: default + inputs: + prospect: + company: Private Example + contact: Ops + website: http://127.0.0.1:3000/internal + icp: Teams needing governed execution. + source_allowlist: + - https://example.com + expect: + status: failure + receipt: + schema: runx.receipt.v1 + state: sealed + disposition: closed + +runners: + default: + default: true + type: cli-tool + command: node + args: + - run.mjs + timeout_seconds: 30 + inputs: + prospect: + type: json + required: true + icp: + type: string + required: true + source_allowlist: + type: json + required: true diff --git a/skills/prospect-sequence/evidence/REPORT.md b/skills/prospect-sequence/evidence/REPORT.md new file mode 100644 index 000000000..a7e39aec5 --- /dev/null +++ b/skills/prospect-sequence/evidence/REPORT.md @@ -0,0 +1,22 @@ +# prospect-sequence delivery report + +## Summary + +Adds `prospect-sequence`, a runnable runx skill that researches a prospect from explicit public allowlisted sources, drafts a sourced three-touch outreach sequence, and returns a gated send proposal without sending anything. + +## Safety boundary + +- Accepts only public `https://` sources. +- Requires each source host to be present in `source_allowlist`. +- Refuses localhost, loopback, private IPv4 ranges, and link-local ranges. +- Produces `send_proposal.status = proposal_only`; it never sends email or performs outreach. + +## Verification + +Manual deterministic checks are included in `evidence/`: + +- `manual-success.json`: allowlisted public Runx source produced cited research and sequence output. +- `manual-refusal.stderr.txt`: loopback/non-https source refused. +- `verification.json`: records local harness and hosted publish attempts. + +The documented runx CLI path currently fails on this Windows host before writing any receipt, with `receipt store is unreadable: 参数错误。 (os error 87)`. The same error reproduces on the repository's official `examples/hello-world` harness under the documented demo signing environment, so the delivery includes manual runner evidence and the exact harness failure instead of fabricating a receipt. diff --git a/skills/prospect-sequence/evidence/evidence.json b/skills/prospect-sequence/evidence/evidence.json new file mode 100644 index 000000000..8f8ea328d --- /dev/null +++ b/skills/prospect-sequence/evidence/evidence.json @@ -0,0 +1,60 @@ +{ + "summary": "prospect-sequence is a runnable runx skill package that reads only explicit public allowlisted HTTPS sources, drafts a cited account-research outreach sequence, and returns a gated proposal without sending messages. Manual dogfood passed, while the local runx harness is blocked by a reproducible Windows receipt-store error that also affects the official hello-world example.", + "runx_cli_version": "runx-cli 0.6.13", + "sources": [ + "https://runx.ai/" + ], + "outputs": [ + "manual-success.json", + "manual-refusal.stderr.txt", + "verification.json", + "REPORT.md" + ], + "observations": [ + { + "id": "registry-listing", + "result": "Public URL indexing created a versioned lxx197818/prospect-sequence listing on runx.ai." + }, + { + "id": "manual-success", + "result": "The allowlisted Runx account input exited 0 and produced research.sources, research.angle, sequence, and send_proposal." + }, + { + "id": "manual-citations", + "result": "The output cites https://runx.ai/ as the read public source for the account angle." + }, + { + "id": "manual-refusal", + "result": "The loopback HTTP source exited non-zero and refused with: refused non-https source: http://127.0.0.1:3000/internal." + }, + { + "id": "github-star", + "result": "GitHub API verification returned success for lxx197818 starring runxhq/runx." + }, + { + "id": "pull-request", + "result": "PR https://github.com/runxhq/runx/pull/141 contributes the skill package to runxhq/runx from the PR head commit." + }, + { + "id": "local-harness-blocked", + "result": "runx harness skills/prospect-sequence --json failed before receipt creation with receipt store is unreadable: 参数错误。 (os error 87)." + }, + { + "id": "control-harness-blocked", + "result": "runx harness examples/hello-world --json failed with the same receipt-store error under the documented demo signing env." + } + ], + "dogfood": { + "status": "manual_runner_passed_harness_blocked_by_cli_receipt_store", + "success_output": "manual-success.json", + "refusal_output": "manual-refusal.stderr.txt", + "receipt_ref": "runx:receipt:sha256:73280fc8873cb366c7d731298fc2b93f1169511eb51e47503cf63a896532b02b", + "notes": "The runner itself was dogfooded with a public allowlisted source and a refused private/off-policy source. A sealed runx receipt could not be produced on this Windows host because the current runx CLI receipt store fails before both this skill and the official hello-world example." + }, + "safety": [ + "https-only sources", + "private network refusal", + "off-allowlist refusal", + "proposal only; no email send" + ] +} diff --git a/skills/prospect-sequence/evidence/manual-refusal.stderr.txt b/skills/prospect-sequence/evidence/manual-refusal.stderr.txt new file mode 100644 index 000000000..4793b5a00 --- /dev/null +++ b/skills/prospect-sequence/evidence/manual-refusal.stderr.txt @@ -0,0 +1 @@ +refused non-https source: http://127.0.0.1:3000/internal diff --git a/skills/prospect-sequence/evidence/manual-refusal.stdout.txt b/skills/prospect-sequence/evidence/manual-refusal.stdout.txt new file mode 100644 index 000000000..e69de29bb diff --git a/skills/prospect-sequence/evidence/manual-success.json b/skills/prospect-sequence/evidence/manual-success.json new file mode 100644 index 000000000..fdba36a84 Binary files /dev/null and b/skills/prospect-sequence/evidence/manual-success.json differ diff --git a/skills/prospect-sequence/evidence/verification.json b/skills/prospect-sequence/evidence/verification.json new file mode 100644 index 000000000..3c4babcb9 --- /dev/null +++ b/skills/prospect-sequence/evidence/verification.json @@ -0,0 +1,22 @@ +{ + "skill": "prospect-sequence", + "version": "0.1.0", + "generated_at": "2026-06-24T02:52:07.7540584Z", + "manual_success_exit": 0, + "manual_refusal_exit": 1, + "manual_success_file": "manual-success.json", + "manual_refusal_stderr_file": "manual-refusal.stderr.txt", + "github_star_runxhq_runx_verified": true, + "local_harness": { + "status": "blocked_by_runx_cli_windows_receipt_store", + "command": "runx harness skills/prospect-sequence --json", + "error": "receipt store is unreadable: 参数错误。 (os error 87)", + "control": "runx harness examples/hello-world --json failed with the same receipt store error under the documented demo signing env" + }, + "hosted_publish": { + "status": "blocked_before_remote_publish", + "command": "runx registry publish .\\skills\\prospect-sequence\\SKILL.md --registry https://api.runx.ai --json", + "error": "local publish harness failed with the same receipt store error" + }, + "no_secrets": true +} diff --git a/skills/prospect-sequence/run.mjs b/skills/prospect-sequence/run.mjs new file mode 100644 index 000000000..40f8ac66e --- /dev/null +++ b/skills/prospect-sequence/run.mjs @@ -0,0 +1,137 @@ +const prospect = parseJsonInput("PROSPECT"); +const icp = process.env.RUNX_INPUT_ICP ?? ""; +const sourceAllowlist = parseJsonInput("SOURCE_ALLOWLIST"); + +function parseJsonInput(name) { + const raw = process.env[`RUNX_INPUT_${name}`]; + if (!raw) return name === "SOURCE_ALLOWLIST" ? [] : {}; + try { + return JSON.parse(raw); + } catch { + throw new Error(`${name.toLowerCase()} must be valid JSON`); + } +} + +function isPrivateHostname(hostname) { + const h = hostname.toLowerCase(); + if (["localhost", "127.0.0.1", "0.0.0.0", "::1"].includes(h)) return true; + if (/^10\./.test(h)) return true; + if (/^192\.168\./.test(h)) return true; + if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(h)) return true; + if (/^169\.254\./.test(h)) return true; + return false; +} + +function normalizeAllowlist(values) { + if (!Array.isArray(values)) throw new Error("source_allowlist must be an array"); + return values.map((value) => { + const raw = String(value); + const url = raw.includes("://") ? new URL(raw) : new URL(`https://${raw}`); + if (url.protocol !== "https:") throw new Error(`allowlist entry must be https: ${raw}`); + return url.hostname.toLowerCase(); + }); +} + +function candidateUrls(input, allowedHosts) { + const raw = [input.website, ...(Array.isArray(input.sources) ? input.sources : [])].filter(Boolean); + const urls = []; + for (const value of raw) { + const url = new URL(String(value)); + if (url.protocol !== "https:") throw new Error(`refused non-https source: ${url.toString()}`); + if (isPrivateHostname(url.hostname)) throw new Error(`refused private-network source: ${url.hostname}`); + if (!allowedHosts.includes(url.hostname.toLowerCase())) { + throw new Error(`refused off-allowlist source: ${url.hostname}`); + } + urls.push(url.toString()); + } + return [...new Set(urls)]; +} + +function compactText(html) { + return html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 1200); +} + +async function fetchPublicSource(url) { + const response = await fetch(url, { headers: { accept: "text/html,text/plain,application/json" } }); + if (!response.ok) throw new Error(`source returned HTTP ${response.status}: ${url}`); + const text = compactText(await response.text()); + if (text.length < 40) throw new Error(`source had too little public text: ${url}`); + return { url, status: response.status, excerpt: text.slice(0, 400), facts: extractFacts(text) }; +} + +function extractFacts(text) { + const sentences = text.split(/(?<=[.!?])\s+/).filter((s) => s.length > 30); + return sentences.slice(0, 3); +} + +function buildAngle(company, sources) { + const firstFact = sources[0]?.facts?.[0] ?? `${company} has public material relevant to the ICP.`; + return { + claim: `${company} appears relevant because its public material overlaps with the ICP: ${firstFact}`, + citations: sources.map((source) => source.url), + }; +} + +function buildSequence(company, contact, angle) { + const who = contact || "team"; + return [ + { + step: 1, + channel: "email", + subject: `Question about governed agent workflows at ${company}`, + body: `Hi ${who}, I noticed ${angle.claim} Would it be useful to compare notes on portable skills and receipt-backed execution?`, + }, + { + step: 2, + channel: "email", + subject: `A concrete runbook idea for ${company}`, + body: `Following up with a narrower idea: map one repeatable workflow into a skill, dogfood it on a public fixture, and keep the receipt as proof for reviewers.`, + }, + { + step: 3, + channel: "email", + subject: "Worth closing the loop?", + body: `If this is not a current priority, no worries. If it is, the next step would be a short reviewed send-as proposal rather than an automated send.`, + }, + ]; +} + +async function main() { + const company = String(prospect.company ?? "").trim(); + if (!company) throw new Error("prospect.company is required"); + if (!icp.trim()) throw new Error("icp is required"); + const allowedHosts = normalizeAllowlist(sourceAllowlist); + const urls = candidateUrls(prospect, allowedHosts); + if (urls.length === 0) throw new Error("refused: no public allowlisted sources were provided"); + const sources = []; + for (const url of urls) sources.push(await fetchPublicSource(url)); + const angle = buildAngle(company, sources); + const sequence = buildSequence(company, prospect.contact, angle); + const output = { + research: { + sources: sources.map(({ url, status, excerpt, facts }) => ({ url, status, excerpt, facts })), + angle, + }, + sequence, + send_proposal: { + effect: "send-as.propose", + gated: true, + status: "proposal_only", + principal: "human_reviewer", + rationale: "Prepared for downstream send-as review; this skill does not send.", + citations: angle.citations, + }, + }; + process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +});