diff --git a/crates/runx-cli/src/official_skills.rs b/crates/runx-cli/src/official_skills.rs index 8469e4b0..abccdb4a 100644 --- a/crates/runx-cli/src/official_skills.rs +++ b/crates/runx-cli/src/official_skills.rs @@ -215,6 +215,11 @@ pub(crate) const OFFICIAL_SKILLS: &[OfficialSkillLockEntry] = &[ version: "sha-537dd9fc3c6b", digest: "b073ec884f56c9e412d0c1039d5f28f163df0f5530eb0bee922ed4c557955c52", }, + OfficialSkillLockEntry { + skill_id: "runx/prospect-sequence", + version: "sha-14eddf4ad0e3", + digest: "c0f2e2d71a46d9ee9756a4c30203a735b30476f455118efb3057be9def3d9387", + }, OfficialSkillLockEntry { skill_id: "runx/prior-art", version: "sha-2555f66bde78", diff --git a/packages/cli/src/official-skills.lock.json b/packages/cli/src/official-skills.lock.json index 5ce7c23e..f2e4633b 100644 --- a/packages/cli/src/official-skills.lock.json +++ b/packages/cli/src/official-skills.lock.json @@ -286,6 +286,13 @@ "catalog_visibility": "public", "catalog_role": "context" }, + { + "skill_id": "runx/prospect-sequence", + "version": "sha-14eddf4ad0e3", + "digest": "c0f2e2d71a46d9ee9756a4c30203a735b30476f455118efb3057be9def3d9387", + "catalog_visibility": "public", + "catalog_role": "context" + }, { "skill_id": "runx/prior-art", "version": "sha-2555f66bde78", diff --git a/skills/prospect-sequence/SKILL.md b/skills/prospect-sequence/SKILL.md new file mode 100644 index 00000000..53edc851 --- /dev/null +++ b/skills/prospect-sequence/SKILL.md @@ -0,0 +1,98 @@ +--- +name: prospect-sequence +description: Research an account from allowlisted public sources and draft a gated outreach sequence. +metadata: + category: sales + tags: + - prospecting + - outreach + - research +--- + +# Prospect Sequence + +`prospect-sequence` turns bounded public account research into a sourced sales +angle, a short multi-touch outreach sequence, and a gated `send-as` proposal. It +is for operators who need the judgment and evidence behind an AI SDR motion +without allowing the skill to send anything itself. + +## When To Use + +Use this skill when: + +- You have a named prospect company and contact reference. +- You can provide public source snippets from an explicit allowlist. +- You need a reviewable outreach angle and sequence before a human or provider + adapter sends. + +Do not use it to scrape private networks, infer facts without source evidence, +or send messages directly. + +## Inputs + +- `prospect` (required): object with `company` and optional `contact`. +- `icp` (required): object describing the target customer profile, pain, and + offer. +- `source_allowlist` (required): list of permitted hosts or URL prefixes. +- `sources` (required): public source objects with `url`, `title`, and + `excerpt`. Every source URL must match the allowlist. + +## Outputs + +- `research`: object with `sources[]` and `angle`. +- `sequence`: array of outreach touches with channel, subject, body, and source + citations. +- `send_proposal`: gated proposed Effect for `send-as`. + +## Guardrails + +1. Treat `source_allowlist` as the network boundary. Reject private or + off-allowlist URLs before synthesizing. +2. Refuse when no source is available; the skill does not fabricate account + facts. +3. Cite each source used in the angle and sequence. +4. Emit only a proposed `send-as` effect with `approval_required: true` and + `sends_directly: false`. +5. Keep output deterministic enough for harness replay. + +## Example + +Input: + +```yaml +prospect: + company: Acme Logistics + contact: VP Operations +icp: + offer: governed agent workflows for operations teams + pain: manual exception handling across support and finance +source_allowlist: + - acme.example +sources: + - url: https://acme.example/blog/exception-ops + title: Exception operations update + excerpt: Acme describes new SLA pressure from invoice and shipment exceptions. +``` + +Output: + +```yaml +research: + angle: Acme's public operations update shows SLA pressure around invoice and + shipment exceptions, which maps to governed workflow automation. +sequence: + - step: 1 + channel: email + subject: Reducing exception-handling drag at Acme +send_proposal: + effect: send-as + gated: true + approval_required: true +``` + +## Failure Modes + +- No public sources: return `decision.status: refused`. +- Off-allowlist or private-network URL: return `decision.status: + policy_denied`. +- Missing prospect or ICP: return `decision.status: refused`. diff --git a/skills/prospect-sequence/X.yaml b/skills/prospect-sequence/X.yaml new file mode 100644 index 00000000..f35d67cb --- /dev/null +++ b/skills/prospect-sequence/X.yaml @@ -0,0 +1,82 @@ +skill: prospect-sequence +version: "0.1.0" +catalog: + kind: skill + audience: operator + visibility: public + role: context +runners: + decide: + default: true + type: cli-tool + command: node + args: + - run.mjs + outputs: + decision: object + research: object + sequence: array + send_proposal: object + artifacts: + wrap_as: prospect_sequence_packet + packet: runx.sales.prospect_sequence.v1 + inputs: + prospect: + type: json + required: true + description: Prospect account and contact reference. + icp: + type: json + required: true + description: Ideal customer profile, pain, and offer context. + source_allowlist: + type: json + required: true + description: Allowed public hosts or URL prefixes for source evidence. + sources: + type: json + required: true + description: Public source records with url, title, and excerpt. + stop: + type: cli-tool + command: /bin/false + outputs: + decision: object + inputs: + reason: + type: string + required: false + description: Harness-only stop path proving the package records stop/error outcomes. +harness: + cases: + - name: public-sources-yield-sequence + runner: decide + inputs: + prospect: + company: Acme Logistics + contact: VP Operations + icp: + offer: governed agent workflows for operations teams + pain: manual exception handling across support and finance + source_allowlist: + - acme.example + sources: + - url: https://acme.example/blog/exception-ops + title: Exception operations update + excerpt: Acme describes new SLA pressure from invoice and shipment exceptions. + - url: https://acme.example/news/finance-automation + title: Finance automation note + excerpt: The finance team is consolidating manual approval queues this quarter. + expect: + status: sealed + receipt: + schema: runx.receipt.v1 + state: sealed + disposition: closed + reason_code: process_closed + - name: stop-runner-fails + runner: stop + inputs: + reason: off-allowlist network target + expect: + status: failure diff --git a/skills/prospect-sequence/fixtures/missing-public-sources-refuses.yaml b/skills/prospect-sequence/fixtures/missing-public-sources-refuses.yaml new file mode 100644 index 00000000..bbc95d85 --- /dev/null +++ b/skills/prospect-sequence/fixtures/missing-public-sources-refuses.yaml @@ -0,0 +1,15 @@ +name: missing-public-sources-refuses +input: + prospect: + company: PrivateCo + contact: Revenue Operations + icp: + offer: governed outreach planning + pain: unverified account context + source_allowlist: + - private.example + sources: [] +expect: + decision: + status: needs_agent + sequence: [] diff --git a/skills/prospect-sequence/fixtures/off-allowlist-denied.yaml b/skills/prospect-sequence/fixtures/off-allowlist-denied.yaml new file mode 100644 index 00000000..2e37a418 --- /dev/null +++ b/skills/prospect-sequence/fixtures/off-allowlist-denied.yaml @@ -0,0 +1,18 @@ +name: off-allowlist-denied +input: + prospect: + company: Acme Logistics + contact: VP Operations + icp: + offer: governed workflows + pain: manual queues + source_allowlist: + - acme.example + sources: + - url: https://evil.example/post + title: Untrusted source + excerpt: This source should not be used because its host is outside the allowlist. +expect: + decision: + status: policy_denied + sequence: [] diff --git a/skills/prospect-sequence/fixtures/public-sources-yield-sequence.yaml b/skills/prospect-sequence/fixtures/public-sources-yield-sequence.yaml new file mode 100644 index 00000000..65b670e2 --- /dev/null +++ b/skills/prospect-sequence/fixtures/public-sources-yield-sequence.yaml @@ -0,0 +1,26 @@ +name: public-sources-yield-sequence +input: + prospect: + company: Acme Logistics + contact: VP Operations + icp: + offer: governed agent workflows for operations teams + pain: manual exception handling across support and finance + source_allowlist: + - acme.example + sources: + - url: https://acme.example/blog/exception-ops + title: Exception operations update + excerpt: Acme describes new SLA pressure from invoice and shipment exceptions. + - url: https://acme.example/news/finance-automation + title: Finance automation note + excerpt: The finance team is consolidating manual approval queues this quarter. +expect: + decision: + status: sealed + sequence: + min_items: 3 + send_proposal: + effect: send-as + gated: true + sends_directly: false diff --git a/skills/prospect-sequence/run.mjs b/skills/prospect-sequence/run.mjs new file mode 100644 index 00000000..7b33a712 --- /dev/null +++ b/skills/prospect-sequence/run.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; + +const input = readInput(); +const prospect = input.prospect ?? {}; +const icp = input.icp ?? {}; +const allowlist = Array.isArray(input.source_allowlist) ? input.source_allowlist : []; +const sources = Array.isArray(input.sources) ? input.sources : []; + +const output = decide(); +process.stdout.write(`${JSON.stringify(output, null, 2)}\n`); + +function decide() { + if (!prospect.company || !icp.offer) { + return refused("prospect.company and icp.offer are required"); + } + if (allowlist.length === 0) { + return refused("source_allowlist must contain at least one public host or URL prefix"); + } + if (sources.length === 0) { + return needsAgent("no public sources supplied; refusing to fabricate account facts"); + } + + const checked = []; + for (const source of sources) { + const check = checkSource(source); + checked.push(check); + if (check.decision !== "allowed") { + return policyDenied(check.reason, checked); + } + } + + const usedSources = checked.map((check, index) => ({ + id: `source-${index + 1}`, + url: check.url, + title: sources[index].title ?? check.host, + excerpt_digest: digest(sources[index].excerpt ?? ""), + citation: `[source-${index + 1}] ${sources[index].title ?? check.host} (${check.url})`, + })); + + const angle = [ + `${prospect.company} has public signals around ${icp.pain ?? "the stated operating pain"}.`, + `That maps to ${icp.offer}.`, + `Use ${usedSources.map((source) => source.id).join(", ")} as the evidence base; do not add unsourced claims.`, + ].join(" "); + + const sequence = [ + { + step: 1, + channel: "email", + subject: `Idea for ${prospect.company}'s exception workflow`, + body: `${prospect.contact ?? "Hi"} - I noticed ${summarizeSource(sources[0])}. It seems adjacent to ${icp.pain ?? "your operating priorities"}. Would it be useful to compare how governed agent workflows keep that motion auditable?`, + citations: [usedSources[0].id], + }, + { + step: 2, + channel: "email", + subject: `A governed follow-up for ${prospect.company}`, + body: `Following up with a narrower angle: ${icp.offer} can propose next actions while keeping sends behind approval. The public source trail is ${usedSources.map((source) => source.id).join(", ")}.`, + citations: usedSources.map((source) => source.id), + }, + { + step: 3, + channel: "linkedin", + subject: "Lightweight research note", + body: `Sharing a concise account note built only from public allowlisted sources: ${angle}`, + citations: usedSources.map((source) => source.id), + }, + ]; + + return { + decision: { + status: "sealed", + action: "propose_sequence", + reasons: [ + `${usedSources.length} allowlisted public source(s) checked`, + "sequence cites source ids and stops before sending", + ], + }, + research: { + prospect: { + company: prospect.company, + contact: prospect.contact ?? null, + }, + sources: usedSources, + angle, + allowlist_checked: allowlist, + fact_policy: "only cite facts present in supplied public sources", + }, + sequence, + send_proposal: { + effect: "send-as", + gated: true, + approval_required: true, + sends_directly: false, + send_class: "outreach", + principal_ref: input.principal_ref ?? "operator", + recipient_ref: prospect.contact ?? prospect.company, + content_digest: digest(JSON.stringify(sequence)), + source_citations: usedSources.map((source) => source.id), + }, + }; +} + +function refused(reason) { + return { + decision: { + status: "refused", + action: "refuse", + reasons: [reason], + }, + research: { + sources: [], + angle: null, + }, + sequence: [], + send_proposal: gatedNullProposal(reason), + }; +} + +function needsAgent(reason) { + return { + decision: { + status: "needs_agent", + action: "request_public_sources", + reasons: [reason], + }, + research: { + sources: [], + angle: null, + }, + sequence: [], + send_proposal: gatedNullProposal(reason), + }; +} + +function policyDenied(reason, checked) { + return { + decision: { + status: "policy_denied", + action: "refuse", + reasons: [reason], + }, + research: { + sources: checked, + angle: null, + }, + sequence: [], + send_proposal: gatedNullProposal(reason), + }; +} + +function gatedNullProposal(reason) { + return { + effect: "send-as", + gated: true, + approval_required: true, + sends_directly: false, + send_class: "outreach", + recipient_ref: prospect.contact ?? prospect.company ?? null, + content_digest: null, + reason, + }; +} + +function checkSource(source) { + let parsed; + try { + parsed = new URL(source.url); + } catch { + return { + decision: "denied", + url: source.url ?? null, + reason: "source url is not a valid absolute URL", + }; + } + + if (!["http:", "https:"].includes(parsed.protocol)) { + return { + decision: "denied", + url: parsed.href, + host: parsed.hostname, + reason: "source url must use http or https", + }; + } + + if (isPrivateHost(parsed.hostname)) { + return { + decision: "denied", + url: parsed.href, + host: parsed.hostname, + reason: "private-network host is not allowed", + }; + } + + const allowed = allowlist.some((entry) => matchesAllowlist(parsed, String(entry))); + if (!allowed) { + return { + decision: "denied", + url: parsed.href, + host: parsed.hostname, + reason: `host ${parsed.hostname} is outside source_allowlist`, + }; + } + + if (!source.excerpt || String(source.excerpt).trim().length < 20) { + return { + decision: "denied", + url: parsed.href, + host: parsed.hostname, + reason: "source excerpt is too thin to support an account fact", + }; + } + + return { + decision: "allowed", + url: parsed.href, + host: parsed.hostname, + allowlist_decision: "allowed", + }; +} + +function matchesAllowlist(parsed, entry) { + const normalized = entry.replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase(); + const host = parsed.hostname.toLowerCase(); + return host === normalized || host.endsWith(`.${normalized}`) || parsed.href.toLowerCase().startsWith(entry.toLowerCase()); +} + +function isPrivateHost(host) { + const lower = host.toLowerCase(); + if (lower === "localhost" || lower.endsWith(".local")) return true; + if (/^\d+\.\d+\.\d+\.\d+$/.test(lower)) { + const [a, b] = lower.split(".").map(Number); + return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 169; + } + return false; +} + +function summarizeSource(source) { + const excerpt = String(source.excerpt ?? "").trim(); + if (excerpt.length <= 120) return excerpt; + return `${excerpt.slice(0, 117)}...`; +} + +function digest(value) { + return `sha256:${createHash("sha256").update(String(value)).digest("hex")}`; +} + +function readInput() { + if (process.env.RUNX_INPUTS_PATH) { + return JSON.parse(readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")); + } + if (process.env.RUNX_INPUTS_JSON) { + return JSON.parse(process.env.RUNX_INPUTS_JSON); + } + + const args = process.argv.slice(2); + const input = {}; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === "--input-json") { + const [key, rawValue] = String(args[++i] ?? "").split(/=(.*)/s); + input[key] = JSON.parse(rawValue); + } + } + + if (Object.keys(input).length > 0) { + return input; + } + + const stdin = readFileSync(0, "utf8").trim(); + return stdin ? JSON.parse(stdin) : {}; +} diff --git a/skills/prospect-sequence/test.mjs b/skills/prospect-sequence/test.mjs new file mode 100644 index 00000000..e0f07d33 --- /dev/null +++ b/skills/prospect-sequence/test.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; + +const happy = run({ + prospect: { + company: "Acme Logistics", + contact: "VP Operations", + }, + icp: { + offer: "governed agent workflows for operations teams", + pain: "manual exception handling across support and finance", + }, + source_allowlist: ["acme.example"], + sources: [ + { + url: "https://acme.example/blog/exception-ops", + title: "Exception operations update", + excerpt: "Acme describes new SLA pressure from invoice and shipment exceptions.", + }, + { + url: "https://acme.example/news/finance-automation", + title: "Finance automation note", + excerpt: "The finance team is consolidating manual approval queues this quarter.", + }, + ], +}); +assert.equal(happy.decision.status, "sealed"); +assert.equal(happy.research.sources.length, 2); +assert.match(happy.research.angle, /source-1/); +assert.equal(happy.sequence.length, 3); +assert.equal(happy.sequence[0].citations[0], "source-1"); +assert.equal(happy.send_proposal.effect, "send-as"); +assert.equal(happy.send_proposal.gated, true); +assert.equal(happy.send_proposal.sends_directly, false); + +const noSources = run({ + prospect: { company: "PrivateCo", contact: "Revenue Operations" }, + icp: { offer: "governed outreach planning", pain: "unverified account context" }, + source_allowlist: ["private.example"], + sources: [], +}); +assert.equal(noSources.decision.status, "needs_agent"); +assert.equal(noSources.sequence.length, 0); + +const offAllowlist = run({ + prospect: { company: "Acme Logistics", contact: "VP Operations" }, + icp: { offer: "governed workflows", pain: "manual queues" }, + source_allowlist: ["acme.example"], + sources: [ + { + url: "https://evil.example/post", + title: "Untrusted source", + excerpt: "This source should not be used because its host is outside the allowlist.", + }, + ], +}); +assert.equal(offAllowlist.decision.status, "policy_denied"); +assert.match(offAllowlist.decision.reasons[0], /outside source_allowlist/); + +const privateHost = run({ + prospect: { company: "LocalCo", contact: "Ops" }, + icp: { offer: "governed workflows", pain: "manual queues" }, + source_allowlist: ["localhost"], + sources: [ + { + url: "http://localhost/private", + title: "Private source", + excerpt: "This local source must be refused before any synthesis occurs.", + }, + ], +}); +assert.equal(privateHost.decision.status, "policy_denied"); +assert.match(privateHost.decision.reasons[0], /private-network/); + +console.log("prospect-sequence tests passed"); + +function run(input) { + const child = spawnSync(process.execPath, ["run.mjs"], { + cwd: new URL(".", import.meta.url), + input: JSON.stringify(input), + encoding: "utf8", + }); + assert.equal(child.status, 0, child.stderr); + return JSON.parse(child.stdout); +}