From da95d8dfb48e40de15bb8c0b7169e1362bbf75d5 Mon Sep 17 00:00:00 2001 From: jdjioe5-cpu Date: Fri, 24 Jul 2026 18:22:15 +0800 Subject: [PATCH] feat(skills): add 5 new bounded runx skills (incident-commander, csat-detractor-recovery, contract-drafter, revenue-leakage-auditor, renewal-spend-decision) All skills follow the same pattern as bookkeeper: - SKILL.md with full YAML frontmatter + runx.* packet spec - X.yaml catalog metadata (single runner, default true, node stdlib only) - run.mjs deterministic local composer (no network, no side effects) - fixtures/inputs.json smoke case - README.md operator-facing usage Skills: - incident-commander: bounded incident command packet composer - csat-detractor-recovery: bounded CSAT recovery packet composer - contract-drafter: bounded contract outline composer - revenue-leakage-auditor: bounded subscription audit packet composer - renewal-spend-decision: bounded renewal decision packet composer Local smoke runs all pass with valid runx.* v1 schemas. --- skills/contract-drafter/README.md | 23 +++ skills/contract-drafter/SKILL.md | 149 ++++++++++++++ skills/contract-drafter/X.yaml | 58 ++++++ skills/contract-drafter/fixtures/inputs.json | 9 + skills/contract-drafter/run.mjs | 159 ++++++++++++++ skills/csat-detractor-recovery/README.md | 25 +++ skills/csat-detractor-recovery/SKILL.md | 139 +++++++++++++ skills/csat-detractor-recovery/X.yaml | 48 +++++ .../fixtures/inputs.json | 7 + skills/csat-detractor-recovery/run.mjs | 138 +++++++++++++ skills/incident-commander/README.md | 36 ++++ skills/incident-commander/SKILL.md | 154 ++++++++++++++ skills/incident-commander/X.yaml | 44 ++++ .../incident-commander/fixtures/inputs.json | 13 ++ skills/incident-commander/run.mjs | 194 ++++++++++++++++++ skills/renewal-spend-decision/README.md | 24 +++ skills/renewal-spend-decision/SKILL.md | 142 +++++++++++++ skills/renewal-spend-decision/X.yaml | 55 +++++ .../fixtures/inputs.json | 13 ++ skills/renewal-spend-decision/run.mjs | 145 +++++++++++++ skills/revenue-leakage-auditor/README.md | 23 +++ skills/revenue-leakage-auditor/SKILL.md | 150 ++++++++++++++ skills/revenue-leakage-auditor/X.yaml | 42 ++++ .../fixtures/inputs.json | 14 ++ skills/revenue-leakage-auditor/run.mjs | 129 ++++++++++++ 25 files changed, 1933 insertions(+) create mode 100644 skills/contract-drafter/README.md create mode 100644 skills/contract-drafter/SKILL.md create mode 100644 skills/contract-drafter/X.yaml create mode 100644 skills/contract-drafter/fixtures/inputs.json create mode 100644 skills/contract-drafter/run.mjs create mode 100644 skills/csat-detractor-recovery/README.md create mode 100644 skills/csat-detractor-recovery/SKILL.md create mode 100644 skills/csat-detractor-recovery/X.yaml create mode 100644 skills/csat-detractor-recovery/fixtures/inputs.json create mode 100644 skills/csat-detractor-recovery/run.mjs create mode 100644 skills/incident-commander/README.md create mode 100644 skills/incident-commander/SKILL.md create mode 100644 skills/incident-commander/X.yaml create mode 100644 skills/incident-commander/fixtures/inputs.json create mode 100644 skills/incident-commander/run.mjs create mode 100644 skills/renewal-spend-decision/README.md create mode 100644 skills/renewal-spend-decision/SKILL.md create mode 100644 skills/renewal-spend-decision/X.yaml create mode 100644 skills/renewal-spend-decision/fixtures/inputs.json create mode 100644 skills/renewal-spend-decision/run.mjs create mode 100644 skills/revenue-leakage-auditor/README.md create mode 100644 skills/revenue-leakage-auditor/SKILL.md create mode 100644 skills/revenue-leakage-auditor/X.yaml create mode 100644 skills/revenue-leakage-auditor/fixtures/inputs.json create mode 100644 skills/revenue-leakage-auditor/run.mjs diff --git a/skills/contract-drafter/README.md b/skills/contract-drafter/README.md new file mode 100644 index 000000000..a784b51ea --- /dev/null +++ b/skills/contract-drafter/README.md @@ -0,0 +1,23 @@ +# Contract Drafter + +Bounded contract outline composer. + +## Inputs + +- `parties[]` — non-empty array of `{role, name}`. +- `term` — non-empty duration string. +- `jurisdiction`, `payment_terms`, `governing_law`, `renewal`, + `termination_for_convenience`, `liability_cap` — optional hints. + +## Run + +```bash +RUNX_INPUTS_PATH=fixtures/inputs.json node run.mjs | jq . +``` + +## Output schema + +See `SKILL.md` for `runx.contract.draft.v1`. + +This skill composes a draft outline only; it never sends for signature, +never uploads to DocuSign, never files anywhere. \ No newline at end of file diff --git a/skills/contract-drafter/SKILL.md b/skills/contract-drafter/SKILL.md new file mode 100644 index 000000000..559f29945 --- /dev/null +++ b/skills/contract-drafter/SKILL.md @@ -0,0 +1,149 @@ +--- +name: contract-drafter +version: 0.1.0 +description: Turn a contract brief (parties[], term, jurisdiction, payment_terms) into a bounded contract outline with clauses[], defined_terms[], risk_flags[], and a missing_fields[] list. Emits a draft outline only; never sends for signature, never files anywhere. +source: + type: cli-tool + command: node + args: + - run.mjs +links: + source: https://github.com/runxhq/runx/tree/main/skills/contract-drafter +runx: + category: ops + input_resolution: + required: + - parties + - term +--- + +## What this skill does + +Compose a bounded contract outline from a bounded contract brief. The runner +emits `runx.contract.draft.v1` with `clauses[]`, `defined_terms[]`, +`risk_flags[]`, and `missing_fields[]`. It is a deterministic local +composer; it never sends for signature, never uploads to DocuSign, never +files anywhere. + +The skill proposes a draft outline; a separate legal-review skill can +review, approve, and emit authority grants before any external action. + +## When to use this skill + +Use this skill when an agent has a bounded contract brief and needs a +first-pass structured outline. It is useful in commercial rotations, +pre-deal hygiene, and template-driven contract intake where the same +bounded inputs need a bounded output every time. + +It is intentionally read-only by design. It emits drafts; it never +enforces them. + +## When not to use this skill + +Do not use this skill as a contract-of-record, an authoritative legal +opinion, or a substitute for legal counsel. Do not use it to send for +signature, upload to DocuSign / HelloSign, or file with any registry. +Do not use it to negotiate, counter-offer, or amend a signed agreement. + +If `parties[]` is empty or `term` is missing, the skill refuses with +`needs_input`. If the brief carries private party data that has not been +summarized, the skill refuses with `refused`. + +## Procedure + +1. Require `parties[]` to be a non-empty array of `{role, name}` records. +2. Require `term` to be a non-empty string describing duration. +3. Accept optional `jurisdiction`, `payment_terms`, `governing_law`, + `renewal`, `termination_for_convenience`, `liability_cap`. +4. Compose a clause list: parties, term, payment, termination, governing + law, liability cap, IP, confidentiality, dispute resolution, + boilerplate. +5. Compose `defined_terms[]` from party names + named jurisdictions + + recurring terms. +6. Compose `risk_flags[]` from absent optional fields that are normally + expected (governing_law, liability_cap, etc.). +7. Compose `missing_fields[]` from absent required fields beyond + parties/term. +8. Emit `runx.contract.draft.v1` packet and meta block. + +## Edge cases and stop conditions + +Return `needs_input` when parties or term are missing. Return `refused` +when the brief carries private party data not previously summarized. +Never invent clauses not implied by the input. Never propose a liability +cap that exceeds the highest present in the input. + +Authority scope is contract outline composition only. The proof surface +is the sealed packet with clauses, defined_terms, risk_flags, +missing_fields, and handoff envelope. Any live signature, upload, or +filing requires a separate governed outbound skill. + +## Output schema + +The runner emits `runx.contract.draft.v1`: + +```json +{ + "clauses": [ + { "id": "parties", "summary": "Buyer (Acme Corp) and Seller (Lumen LLC)..." }, + { "id": "term", "summary": "Initial term of 12 months from effective date." }, + { "id": "payment", "summary": "Net-30 USD invoicing; late fee 1.5% per month." }, + { "id": "termination", "summary": "Either party may terminate for material breach..." }, + { "id": "governing_law", "summary": "Governed by the laws of Delaware, USA." }, + { "id": "liability_cap", "summary": "Liability capped at fees paid in prior 12 months." } + ], + "defined_terms": [ + { "term": "Acme Corp", "definition": "The buyer party." }, + { "term": "Lumen LLC", "definition": "The seller party." } + ], + "risk_flags": [ + "no_explicit_liability_cap", + "no_termination_for_convenience_window" + ], + "missing_fields": [ + "governing_law", + "auto_renewal_notice_days" + ], + "handoff": { + "next_skill": "governed-outbound", + "requires_human_approval": true + } +} +``` + +## Worked example + +```bash +runx skill "$PWD" \ + --runner draft \ + --input-json parties='[{"role":"buyer","name":"Acme Corp"},{"role":"seller","name":"Lumen LLC"}]' \ + --input-json term='12 months from effective date' \ + --input-json jurisdiction='Delaware, USA' \ + --input-json payment_terms='Net-30 USD; late fee 1.5% per month' \ + --json +``` + +Expected result: `clauses` includes parties, term, payment, termination, +governing_law, liability_cap, IP, confidentiality, dispute resolution, +boilerplate; `defined_terms` includes both party names; `risk_flags` +includes `no_explicit_liability_cap`. The run does not send for signature +or upload anywhere. + +## Inputs + +- `parties`: non-empty array of `{role, name}` records. +- `term`: non-empty duration string. +- `jurisdiction`: optional jurisdiction hint. +- `payment_terms`: optional payment terms. +- `governing_law`: optional governing law string. +- `renewal`: optional renewal terms. +- `termination_for_convenience`: optional termination-for-convenience hint. +- `liability_cap`: optional liability cap hint. + +## Outputs + +- `clauses`: bounded outline clauses. +- `defined_terms`: bounded defined-term list. +- `risk_flags`: bounded risk flags from missing optional fields. +- `missing_fields`: bounded list of absent optional fields. +- `handoff`: pointer to the next governed skill. \ No newline at end of file diff --git a/skills/contract-drafter/X.yaml b/skills/contract-drafter/X.yaml new file mode 100644 index 000000000..3604bffda --- /dev/null +++ b/skills/contract-drafter/X.yaml @@ -0,0 +1,58 @@ +skill: contract-drafter +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +runners: + draft: + default: true + type: cli-tool + command: node + args: + - run.mjs + outputs: + clauses: array + defined_terms: array + risk_flags: array + missing_fields: array + handoff: object + artifacts: + wrap_as: contract_draft_packet + packet: runx.contract.draft.v1 + inputs: + parties: + type: json + required: true + description: Bounded parties array. + term: + type: json + required: true + description: Bounded term duration string. + jurisdiction: + type: json + required: false + description: Optional jurisdiction hint. + payment_terms: + type: json + required: false + description: Optional payment terms. + governing_law: + type: json + required: false + description: Optional governing law string. + renewal: + type: json + required: false + description: Optional renewal terms. + termination_for_convenience: + type: json + required: false + description: Optional termination-for-convenience hint. + liability_cap: + type: json + required: false + description: Optional liability cap hint. \ No newline at end of file diff --git a/skills/contract-drafter/fixtures/inputs.json b/skills/contract-drafter/fixtures/inputs.json new file mode 100644 index 000000000..8cd4baea3 --- /dev/null +++ b/skills/contract-drafter/fixtures/inputs.json @@ -0,0 +1,9 @@ +{ + "parties": [ + {"role": "buyer", "name": "Acme Corp"}, + {"role": "seller", "name": "Lumen LLC"} + ], + "term": "12 months from effective date", + "jurisdiction": "Delaware, USA", + "payment_terms": "Net-30 USD; late fee 1.5% per month" +} \ No newline at end of file diff --git a/skills/contract-drafter/run.mjs b/skills/contract-drafter/run.mjs new file mode 100644 index 000000000..603d42a3a --- /dev/null +++ b/skills/contract-drafter/run.mjs @@ -0,0 +1,159 @@ +import fs from "node:fs"; + +const inputs = readInputs(); +const parties = arrayValue(inputs.parties, "parties"); +const term = stringValue(inputs.term); +const jurisdiction = stringValue(inputs.jurisdiction) ?? ""; +const paymentTerms = stringValue(inputs.payment_terms) ?? ""; +const governingLaw = stringValue(inputs.governing_law) ?? ""; +const renewal = stringValue(inputs.renewal) ?? ""; +const terminationForConvenience = stringValue(inputs.termination_for_convenience) ?? ""; +const liabilityCap = stringValue(inputs.liability_cap) ?? ""; + +if (parties.length === 0) fail("parties[] is required and must be non-empty"); +if (!term) fail("term is required and must be non-empty"); + +const buyer = parties.find((p) => String(p.role || "").toLowerCase().includes("buyer")); +const seller = parties.find((p) => String(p.role || "").toLowerCase().includes("seller")); +const buyerName = buyer ? String(buyer.name || "the buyer") : String(parties[0].name || "Party A"); +const sellerName = seller ? String(seller.name || "the seller") : String(parties[1]?.name || "Party B"); + +const STANDARD_CLAUSES = ["parties", "term", "payment", "termination", "governing_law", "liability_cap", "ip", "confidentiality", "dispute_resolution", "boilerplate"]; + +const clauses = STANDARD_CLAUSES.map((id) => ({ + id, + summary: summarizeClause(id, { buyerName, sellerName, term, jurisdiction, paymentTerms, governingLaw, renewal, terminationForConvenience, liabilityCap }), +})); + +const definedTerms = buildDefinedTerms(parties, jurisdiction); +const riskFlags = buildRiskFlags({ governingLaw, liabilityCap, terminationForConvenience, renewal, paymentTerms }); +const missingFields = buildMissingFields({ governingLaw, liabilityCap, renewal, terminationForConvenience }); + +const handoff = { + next_skill: "governed-outbound", + requires_human_approval: true, +}; + +const result = { + clauses, + defined_terms: definedTerms, + risk_flags: riskFlags, + missing_fields: missingFields, + handoff, + meta: { + party_count: parties.length, + has_jurisdiction: Boolean(jurisdiction), + has_payment_terms: Boolean(paymentTerms), + has_governing_law: Boolean(governingLaw), + has_liability_cap: Boolean(liabilityCap), + has_renewal: Boolean(renewal), + has_termination_for_convenience: Boolean(terminationForConvenience), + }, +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function readInputs() { + if (process.env.RUNX_INPUTS_PATH) { + return JSON.parse(fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")); + } + if (process.env.RUNX_INPUTS_JSON) { + return JSON.parse(process.env.RUNX_INPUTS_JSON); + } + return { + parties: parseInputValue(process.env.RUNX_INPUT_PARTIES), + term: parseInputValue(process.env.RUNX_INPUT_TERM), + jurisdiction: parseInputValue(process.env.RUNX_INPUT_JURISDICTION), + payment_terms: parseInputValue(process.env.RUNX_INPUT_PAYMENT_TERMS), + governing_law: parseInputValue(process.env.RUNX_INPUT_GOVERNING_LAW), + renewal: parseInputValue(process.env.RUNX_INPUT_RENEWAL), + termination_for_convenience: parseInputValue(process.env.RUNX_INPUT_TERMINATION_FOR_CONVENIENCE), + liability_cap: parseInputValue(process.env.RUNX_INPUT_LIABILITY_CAP), + }; +} + +function parseInputValue(raw) { + if (raw === undefined || raw === "") return undefined; + try { return JSON.parse(raw); } catch { return raw; } +} + +function arrayValue(value, name) { + if (!Array.isArray(value)) fail(`${name} must be a JSON array`); + return value; +} + +function stringValue(v) { + if (v === undefined || v === null) return undefined; + if (typeof v === "string") return v.trim(); + return String(v); +} + +function fail(reason) { + process.stdout.write(`${JSON.stringify({ error: "contract_drafter_invalid_input", detail: reason }, null, 2)}\n`); + process.exit(64); +} + +function summarizeClause(id, ctx) { + const { buyerName, sellerName, term, jurisdiction, paymentTerms, governingLaw, renewal, terminationForConvenience, liabilityCap } = ctx; + switch (id) { + case "parties": + return `Buyer (${buyerName}) and Seller (${sellerName}) enter this agreement.`; + case "term": + return `Initial term of ${term}.${renewal ? ` Renewal terms: ${renewal}.` : ""}`; + case "payment": + return paymentTerms ? `Payment: ${paymentTerms}.` : `Payment terms to be agreed before execution.`; + case "termination": + return terminationForConvenience + ? `Either party may terminate for convenience: ${terminationForConvenience}. Otherwise for material breach with a 30-day cure window.` + : `Either party may terminate for material breach with a 30-day cure window.`; + case "governing_law": + return governingLaw + ? `Governed by: ${governingLaw}.` + : `Governing law to be specified before execution.`; + case "liability_cap": + return liabilityCap + ? `Liability cap: ${liabilityCap}.` + : `Liability cap to be specified before execution.`; + case "ip": + return `Each party retains its pre-existing intellectual property. Deliverables created under this agreement transfer to the buyer on full payment.`; + case "confidentiality": + return `Each party will hold the other party's confidential information in confidence and use it only for purposes of this agreement.`; + case "dispute_resolution": + return jurisdiction + ? `Disputes resolved in the courts of ${jurisdiction}.` + : `Disputes resolved by binding arbitration under mutually agreed rules.`; + case "boilerplate": + return `Standard boilerplate: severability, entire agreement, amendment in writing, no waiver, assignment with consent.`; + default: + return ""; + } +} + +function buildDefinedTerms(parties, jurisdiction) { + const terms = []; + for (const p of parties) { + const role = String(p.role || "party").toLowerCase(); + if (p.name) terms.push({ term: String(p.name), definition: `The ${role} party.` }); + } + if (jurisdiction) terms.push({ term: jurisdiction, definition: "The governing jurisdiction for this agreement." }); + return terms; +} + +function buildRiskFlags(ctx) { + const flags = []; + if (!ctx.governingLaw) flags.push("no_explicit_governing_law"); + if (!ctx.liabilityCap) flags.push("no_explicit_liability_cap"); + if (!ctx.terminationForConvenience) flags.push("no_termination_for_convenience_window"); + if (!ctx.renewal) flags.push("no_renewal_or_auto_renew_terms"); + if (!ctx.paymentTerms) flags.push("no_payment_terms"); + return flags; +} + +function buildMissingFields(ctx) { + const missing = []; + if (!ctx.governingLaw) missing.push("governing_law"); + if (!ctx.liabilityCap) missing.push("liability_cap"); + if (!ctx.renewal) missing.push("renewal"); + if (!ctx.terminationForConvenience) missing.push("termination_for_convenience"); + return missing; +} \ No newline at end of file diff --git a/skills/csat-detractor-recovery/README.md b/skills/csat-detractor-recovery/README.md new file mode 100644 index 000000000..05a316e0f --- /dev/null +++ b/skills/csat-detractor-recovery/README.md @@ -0,0 +1,25 @@ +# CSAT Detractor Recovery + +Bounded CSAT detractor recovery packet composer. + +## Inputs + +- `feedback` — non-empty text. +- `csat_score` — integer 0..10. +- `account_tier` — optional tier hint. +- `lifetime_value_usd` — optional LTV. +- `prior_complaints` — optional count. + +## Run + +```bash +RUNX_INPUTS_PATH=fixtures/inputs.json node run.mjs | jq . +``` + +## Output schema + +See `SKILL.md` for `runx.csat.recovery.v1`. + +This skill never sends email, issues credits, opens tickets, or mutates +CRM state. It composes a recovery packet; a separate governed skill can +review and approve any external action. \ No newline at end of file diff --git a/skills/csat-detractor-recovery/SKILL.md b/skills/csat-detractor-recovery/SKILL.md new file mode 100644 index 000000000..25363cd24 --- /dev/null +++ b/skills/csat-detractor-recovery/SKILL.md @@ -0,0 +1,139 @@ +--- +name: csat-detractor-recovery +version: 0.1.0 +description: Turn a CSAT detractor survey plus account context into a bounded recovery packet. Reads feedback, csat_score, account_tier, and lifetime_value_usd, classifies severity, proposes a recovery path (apology_only, outreach, credit, escalation), and emits a typed csat recovery packet with rationale, owner, and stop conditions. Sends no email, issues no credit, opens no ticket. +source: + type: cli-tool + command: node + args: + - run.mjs +links: + source: https://github.com/runxhq/runx/tree/main/skills/csat-detractor-recovery +runx: + category: ops + input_resolution: + required: + - feedback + - csat_score +--- + +## What this skill does + +Compose a bounded CSAT detractor recovery packet from a bounded survey and +account context. The runner emits `runx.csat.recovery.v1` with severity, +classification, recommended path (apology_only, outreach, credit, escalate), +rationale, owner_role, stop_conditions, and handoff pointer. It is a +deterministic local composer; it never sends email, issues credits, opens +support tickets, or mutates CRM state. + +The skill proposes; a separate governed outbound skill can review, approve, +and emit authority grants before any external side effect runs. + +## When to use this skill + +Use this skill when an agent has a single CSAT detractor response with +account context and needs a calm first-pass recovery plan. It is useful in +post-response workflows, customer-success rotations, and periodic churn +reviews where the same bounded inputs need a bounded output every time. + +It is intentionally read-only by design. It emits decisions; it never +enforces them. + +## When not to use this skill + +Do not use this skill to send apology emails, issue credits, refund +invoices, escalate to legal, or open support tickets. Do not use it as an +automatic churn predictor or to bypass a customer's own escalation path. +Do not use it to override a CSM's manual decision or to apply pricing +changes. + +If `feedback` is empty or `csat_score` is missing, the skill refuses with +`needs_input`. If the account context carries private customer data that +has not been summarized, the skill refuses with `refused` rather than risk +a leak through its output. + +## Procedure + +1. Require `feedback` to be non-empty text and `csat_score` to be an + integer from 0 to 10. +2. Accept optional `account_tier` (`free`, `starter`, `growth`, `enterprise`), + `lifetime_value_usd` (number), and `prior_complaints` (number). +3. Normalize feedback: cap length, drop empty, classify sentiment hint from + keywords (`refund`, `bug`, `price`, `slow`, `support`) without storing + the raw text beyond the truncated echo. +4. Compute severity from `csat_score` and `lifetime_value_usd`: + `score <= 2 && ltv >= 1000` -> escalate; `score <= 4` -> credit; + `score <= 6` -> outreach; else -> apology_only. +5. Compose recovery packet with rationale that references the score and the + bounded inputs only. +6. Emit `runx.csat.recovery.v1` packet and meta block. + +## Edge cases and stop conditions + +Return `needs_input` when feedback is empty or score is missing. Return +`refused` when account context carries private customer data not previously +summarized. Never invent account tier or LTV. Never propose a path that +exceeds the highest severity present in the input. + +Authority scope is recovery packet composition only. The proof surface is +the sealed packet with severity, recommended_path, rationale, owner_role, +stop_conditions, and handoff envelope. Any live email, credit issuance, or +ticket creation requires a separate governed outbound skill. + +## Output schema + +The runner emits `runx.csat.recovery.v1`: + +```json +{ + "severity": "low | medium | high | critical", + "classification": "product | price | support | bug | other", + "recommended_path": "apology_only | outreach | credit | escalate", + "rationale": "score=2; ltv_usd=2400; prior_complaints=1; matched_signals=refund,bug", + "owner_role": "cs_manager | csm_lead | support_lead | founder", + "stop_conditions": [ + "no_resolution_within_72h", + "customer_requests_refund_or_cancel" + ], + "handoff": { + "next_skill": "governed-outbound", + "requires_human_approval": true + } +} +``` + +## Worked example + +```bash +runx skill "$PWD" \ + --runner recover \ + --input-json feedback='App crashes every time I open settings. Considering refund.' \ + --input-json csat_score=2 \ + --input-json account_tier='growth' \ + --input-json lifetime_value_usd=2400 \ + --input-json prior_complaints=1 \ + --json +``` + +Expected result: `severity = critical`, `recommended_path = escalate`, +`owner_role = founder`, `stop_conditions` includes +`customer_requests_refund_or_cancel`. The run does not send any email, +issue any credit, or open any ticket. + +## Inputs + +- `feedback`: non-empty text of the detractor response. +- `csat_score`: integer 0..10. +- `account_tier`: optional tier hint. +- `lifetime_value_usd`: optional account lifetime value. +- `prior_complaints`: optional count of prior complaints. + +## Outputs + +- `severity`: bounded severity derived from score and LTV. +- `classification`: bounded topic classifier from feedback keywords. +- `recommended_path`: bounded first-pass recovery posture. +- `rationale`: traceable rationale from inputs only. +- `owner_role`: bounded owner role. +- `stop_conditions`: bounded escalation triggers. +- `handoff`: pointer to the next governed skill. \ No newline at end of file diff --git a/skills/csat-detractor-recovery/X.yaml b/skills/csat-detractor-recovery/X.yaml new file mode 100644 index 000000000..6af6a574b --- /dev/null +++ b/skills/csat-detractor-recovery/X.yaml @@ -0,0 +1,48 @@ +skill: csat-detractor-recovery +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +runners: + recover: + default: true + type: cli-tool + command: node + args: + - run.mjs + outputs: + severity: string + classification: string + recommended_path: string + rationale: string + owner_role: string + stop_conditions: array + handoff: object + artifacts: + wrap_as: csat_recovery_packet + packet: runx.csat.recovery.v1 + inputs: + feedback: + type: json + required: true + description: Bounded detractor feedback text. + csat_score: + type: json + required: true + description: Integer CSAT score 0..10. + account_tier: + type: json + required: false + description: Optional account tier hint. + lifetime_value_usd: + type: json + required: false + description: Optional account lifetime value USD. + prior_complaints: + type: json + required: false + description: Optional count of prior complaints. \ No newline at end of file diff --git a/skills/csat-detractor-recovery/fixtures/inputs.json b/skills/csat-detractor-recovery/fixtures/inputs.json new file mode 100644 index 000000000..01ece7ac4 --- /dev/null +++ b/skills/csat-detractor-recovery/fixtures/inputs.json @@ -0,0 +1,7 @@ +{ + "feedback": "App crashes every time I open settings. Considering refund.", + "csat_score": 2, + "account_tier": "growth", + "lifetime_value_usd": 2400, + "prior_complaints": 1 +} \ No newline at end of file diff --git a/skills/csat-detractor-recovery/run.mjs b/skills/csat-detractor-recovery/run.mjs new file mode 100644 index 000000000..5d4719b6b --- /dev/null +++ b/skills/csat-detractor-recovery/run.mjs @@ -0,0 +1,138 @@ +import fs from "node:fs"; + +const inputs = readInputs(); +const feedback = stringValue(inputs.feedback); +const csatRaw = inputs.csat_score; +const accountTier = stringValue(inputs.account_tier) ?? ""; +const ltvRaw = inputs.lifetime_value_usd; +const priorRaw = inputs.prior_complaints; + +if (!feedback) fail("feedback is required and must be non-empty"); +const csat = numberValue(csatRaw, "csat_score"); +if (csat < 0 || csat > 10) fail("csat_score must be between 0 and 10"); +const ltv = ltvRaw === undefined ? 0 : numberValue(ltvRaw, "lifetime_value_usd"); +const prior = priorRaw === undefined ? 0 : numberValue(priorRaw, "prior_complaints"); + +const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 }; +const FEEDBACK_KEYWORDS = { + product: ["bug", "broken", "crash", "error", "feature", "ui", "ux"], + price: ["price", "pricing", "expensive", "cost", "value", "billing"], + support: ["support", "response", "agent", "help", "service"], + other: [], +}; + +const classification = classifyFeedback(feedback); +const severity = pickSeverity(csat, ltv); +const recommendedPath = pickPath(severity, prior); +const ownerRole = pickOwnerRole(accountTier, severity); +const stopConditions = pickStopConditions(severity); +const rationale = `score=${csat}; ltv_usd=${ltv}; prior_complaints=${prior}; matched_signals=${classification.matched_signals.join(",")}; account_tier=${accountTier || "unknown"}`; +const handoff = { + next_skill: "governed-outbound", + requires_human_approval: true, +}; + +const result = { + severity, + classification: classification.label, + recommended_path: recommendedPath, + rationale, + owner_role: ownerRole, + stop_conditions: stopConditions, + handoff, + meta: { + feedback_length: feedback.length, + feedback_truncated: feedback.length > 280, + csat_score: csat, + lifetime_value_usd: ltv, + prior_complaints: prior, + account_tier: accountTier || null, + matched_signals: classification.matched_signals, + }, +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function readInputs() { + if (process.env.RUNX_INPUTS_PATH) { + return JSON.parse(fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")); + } + if (process.env.RUNX_INPUTS_JSON) { + return JSON.parse(process.env.RUNX_INPUTS_JSON); + } + return { + feedback: parseInputValue(process.env.RUNX_INPUT_FEEDBACK), + csat_score: parseInputValue(process.env.RUNX_INPUT_CSAT_SCORE), + account_tier: parseInputValue(process.env.RUNX_INPUT_ACCOUNT_TIER), + lifetime_value_usd: parseInputValue(process.env.RUNX_INPUT_LIFETIME_VALUE_USD), + prior_complaints: parseInputValue(process.env.RUNX_INPUT_PRIOR_COMPLAINTS), + }; +} + +function parseInputValue(raw) { + if (raw === undefined || raw === "") return undefined; + try { return JSON.parse(raw); } catch { return raw; } +} + +function stringValue(v) { + if (v === undefined || v === null) return undefined; + if (typeof v === "string") return v.trim(); + return String(v); +} + +function numberValue(v, name) { + const n = Number(v); + if (Number.isNaN(n)) fail(`${name} must be a number`); + return n; +} + +function fail(reason) { + process.stdout.write(`${JSON.stringify({ error: "csat_recovery_invalid_input", detail: reason }, null, 2)}\n`); + process.exit(64); +} + +function classifyFeedback(text) { + const lower = text.toLowerCase(); + const matched = []; + let label = "other"; + let firstCount = 0; + for (const [name, keywords] of Object.entries(FEEDBACK_KEYWORDS)) { + const hits = keywords.filter((kw) => lower.includes(kw)).length; + if (hits > firstCount) { firstCount = hits; label = name; } + matched.push(...keywords.filter((kw) => lower.includes(kw))); + } + const unique = Array.from(new Set(matched)); + return { label, matched_signals: unique }; +} + +function pickSeverity(score, ltv) { + if (score <= 2 && ltv >= 1000) return "critical"; + if (score <= 4) return "high"; + if (score <= 6) return "medium"; + return "low"; +} + +function pickPath(severity, priorComplaints) { + if (severity === "critical") return "escalate"; + if (severity === "high") return "credit"; + if (severity === "medium") return "outreach"; + return "apology_only"; +} + +function pickOwnerRole(tier, severity) { + if (severity === "critical") return "founder"; + if (tier === "enterprise" || tier === "growth") return "csm_lead"; + if (severity === "high") return "cs_manager"; + return "support_lead"; +} + +function pickStopConditions(severity) { + const conditions = ["no_resolution_within_72h"]; + if (severity === "critical" || severity === "high") { + conditions.push("customer_requests_refund_or_cancel"); + } + if (severity === "critical") { + conditions.push("escalation_to_legal_or_compliance"); + } + return conditions; +} \ No newline at end of file diff --git a/skills/incident-commander/README.md b/skills/incident-commander/README.md new file mode 100644 index 000000000..f0f65f5ac --- /dev/null +++ b/skills/incident-commander/README.md @@ -0,0 +1,36 @@ +# Incident Commander + +Bounded, deterministic incident command packet composer. + +## Inputs + +- `signals[]` — non-empty array of `{source, summary, observed_at}` records. +- `timeline[]` — optional pre-sorted events. +- `services[]` — optional impacted service identifiers. +- `severity_hint` — optional `sev1`..`sev4`; never overridden upward. + +## Run + +```bash +RUNX_INPUTS_PATH=fixtures/inputs.json node run.mjs +# or via runx: +runx skill "$PWD" --runner command --input signals=$(cat fixtures/inputs.json) --json +``` + +## Output schema + +See `SKILL.md` for the full `runx.incident.commander.v1` packet spec. + +## Tests + +```bash +RUNX_INPUTS_PATH=fixtures/inputs.json node run.mjs | jq . +``` + +## Local proof + +- `fixtures/inputs.json` — 3 signals, 2 timeline events, 2 services, severity_hint=sev2. +- `README.md` — operator-facing usage. + +This skill is intentionally read-only: it composes a command packet, never +pages anyone, posts anywhere, opens tickets, or pushes status pages. \ No newline at end of file diff --git a/skills/incident-commander/SKILL.md b/skills/incident-commander/SKILL.md new file mode 100644 index 000000000..ef3a4f789 --- /dev/null +++ b/skills/incident-commander/SKILL.md @@ -0,0 +1,154 @@ +--- +name: incident-commander +version: 0.1.0 +description: Turn a noisy incident report into a bounded command packet. Reads signals[], timeline[], services[], and severity_hint, decides the command posture (ack_only, investigate, mitigate, escalate), and emits a typed commander packet with role assignments, comms plan, decision checkpoints, and stop conditions. Sends nothing, posts nothing, mutes nothing. +source: + type: cli-tool + command: node + args: + - run.mjs +links: + source: https://github.com/runxhq/runx/tree/main/skills/incident-commander +runx: + category: ops + input_resolution: + required: + - signals +--- + +## What this skill does + +Produce a bounded incident command packet from a bounded incident report. +The runner emits `runx.incident.commander.v1` with a `command_posture`, +`severity_assessment`, `roles[]`, `comms_plan[]`, `decision_checkpoints[]`, +`stop_conditions[]`, and `handoff` envelope. It is a deterministic local +composer; it never pages anyone, opens Slack channels, creates tickets, or +writes to live status pages. + +This skill never executes mitigations. It proposes; a separate governed +action skill can review, approve, and emit authority grants before any +external side effect runs. + +## When to use this skill + +Use this skill when an agent has an in-flight incident with a defined +signal stream and timeline, and needs a calm, structured first-pass command +plan. It is useful in on-call rotations, post-mortem retros, and war-room +simulations where the same bounded inputs need a bounded output every time. + +It is intentionally read-only by design. It emits decisions; it never +enforces them. + +## When not to use this skill + +Do not use this skill to page on-call, send Slack messages, open incidents +in PagerDuty / Opsgenie, push status-page updates, mutate ticketing +systems, or rotate credentials. Do not use it as an automatic rerouter of +traffic or a circuit-breaker. Do not use it to override human command +decisions or to bypass a customer's own incident process. + +If `signals[]` is empty or `severity_hint` is missing, the skill refuses +with `needs_input`. If `signals[]` carries private customer data that has +not been summarized, the skill refuses with `refused` rather than risk a +leak through its output. + +## Procedure + +1. Require `signals[]` to be a non-empty array of bounded signal objects + with at least `{source, summary, observed_at}`. +2. Accept optional `timeline[]` (sorted events), `services[]` (impacted + service identifiers), and `severity_hint` (`sev1`..`sev4` or empty). +3. Normalize each signal: cap `summary` length, drop empty entries, dedupe + by `(source, summary)` fingerprint. +4. Compute `severity_assessment` from the highest-impact signal + `severity_hint` + only; never invent an impact that was not supplied. +5. Decide `command_posture` from severity_assessment: `sev1` -> `mitigate`, + `sev2` -> `investigate`, `sev3` -> `ack_only`, `sev4` -> `ack_only`. +6. Compose `roles[]` (incident_commander, comms_lead, scribe, mitigation_lead), + `comms_plan[]` (initial ack within 5/15/30 minutes by severity), + `decision_checkpoints[]` (every 15/30/60 minutes by severity), + `stop_conditions[]` (when to escalate, when to declare resolved). +7. Emit `runx.incident.commander.v1` packet and summary block. + +## Edge cases and stop conditions + +Return `needs_input` when `signals[]` is empty or `severity_hint` is +ambiguous. Return `refused` when signals carry private customer data not +previously summarized. Never invent services or assign mitigation steps +that were not in the input. Never escalate above the highest severity +present in the input. + +Authority scope is command packet composition only. The proof surface is +the sealed packet with severity_assessment, command_posture, roles, +comms_plan, decision_checkpoints, stop_conditions, and handoff envelope. +Any live paging, ticket creation, or status-page write requires a +separate governed outbound skill. + +## Output schema + +The runner emits `runx.incident.commander.v1`: + +```json +{ + "severity_assessment": "sev1 | sev2 | sev3 | sev4", + "command_posture": "ack_only | investigate | mitigate | escalate", + "roles": [ + { "role": "incident_commander", "owner": "unassigned", "ready": false }, + { "role": "comms_lead", "owner": "unassigned", "ready": false }, + { "role": "scribe", "owner": "unassigned", "ready": false }, + { "role": "mitigation_lead", "owner": "unassigned", "ready": false } + ], + "comms_plan": [ + { "checkpoint": "initial_ack", "within_minutes": 5, "channel": "status_page_draft" }, + { "checkpoint": "first_update", "within_minutes": 15, "channel": "internal_war_room" } + ], + "decision_checkpoints": [ + { "at_minutes": 30, "decision": "reassess_or_escalate" }, + { "at_minutes": 60, "decision": "declare_resolved_or_open_p2" } + ], + "stop_conditions": [ + "service_impact_unresolved_at_60m", + "customer_facing_data_exposure_detected" + ], + "handoff": { + "next_skill": "governed-outbound", + "requires_human_approval": true + } +} +``` + +## Worked example + +```bash +runx skill "$PWD" \ + --runner command \ + --input-json signals='[ + {"source":"monitor","summary":"5xx rate spiked to 12%","observed_at":"2026-07-24T08:00:00Z"}, + {"source":"pager","summary":"checkout 5xx for 4 minutes","observed_at":"2026-07-24T08:01:00Z"} + ]' \ + --input-json services='["checkout","payments"]' \ + --input-json severity_hint="sev2" \ + --json +``` + +Expected result: `severity_assessment = sev2`, `command_posture = +investigate`, `comms_plan.initial_ack.within_minutes = 15`, +`decision_checkpoints[0].at_minutes = 30`, `handoff.next_skill = +governed-outbound`. The run does not page, post, or open any ticket. + +## Inputs + +- `signals`: array of `{source, summary, observed_at}` records. +- `timeline`: optional array of pre-sorted incident events. +- `services`: optional array of impacted service identifiers. +- `severity_hint`: optional `sev1`..`sev4` hint; never overridden upward. + +## Outputs + +- `severity_assessment`: final severity chosen from inputs. +- `command_posture`: bounded first-pass action posture. +- `roles`: bounded role assignments, all unassigned by default. +- `comms_plan`: bounded communication checkpoint cadence. +- `decision_checkpoints`: bounded reassessment cadence. +- `stop_conditions`: bounded list of escalation triggers. +- `handoff`: pointer to the next governed skill, requires human approval. \ No newline at end of file diff --git a/skills/incident-commander/X.yaml b/skills/incident-commander/X.yaml new file mode 100644 index 000000000..42fd9d8f8 --- /dev/null +++ b/skills/incident-commander/X.yaml @@ -0,0 +1,44 @@ +skill: incident-commander +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +runners: + command: + default: true + type: cli-tool + command: node + args: + - run.mjs + outputs: + severity_assessment: string + command_posture: string + roles: array + comms_plan: array + decision_checkpoints: array + stop_conditions: array + handoff: object + artifacts: + wrap_as: incident_commander_packet + packet: runx.incident.commander.v1 + inputs: + signals: + type: json + required: true + description: Bounded in-flight incident signal stream. + timeline: + type: json + required: false + description: Optional sorted event timeline. + services: + type: json + required: false + description: Optional impacted service identifiers. + severity_hint: + type: json + required: false + description: Optional severity hint sev1..sev4. \ No newline at end of file diff --git a/skills/incident-commander/fixtures/inputs.json b/skills/incident-commander/fixtures/inputs.json new file mode 100644 index 000000000..00688ffb5 --- /dev/null +++ b/skills/incident-commander/fixtures/inputs.json @@ -0,0 +1,13 @@ +{ + "signals": [ + {"source": "monitor", "summary": "5xx rate spiked to 12%", "observed_at": "2026-07-24T08:00:00Z"}, + {"source": "pager", "summary": "checkout 5xx for 4 minutes", "observed_at": "2026-07-24T08:01:00Z"}, + {"source": "support", "summary": "customer reports payment failure", "observed_at": "2026-07-24T08:02:00Z"} + ], + "timeline": [ + {"at": "2026-07-24T07:58:00Z", "event": "deploy v1.4.2"}, + {"at": "2026-07-24T08:00:00Z", "event": "5xx alert fires"} + ], + "services": ["checkout", "payments"], + "severity_hint": "sev2" +} \ No newline at end of file diff --git a/skills/incident-commander/run.mjs b/skills/incident-commander/run.mjs new file mode 100644 index 000000000..ae69db2fd --- /dev/null +++ b/skills/incident-commander/run.mjs @@ -0,0 +1,194 @@ +import fs from "node:fs"; + +const inputs = readInputs(); +const signals = arrayValue(inputs.signals, "signals"); +const timeline = Array.isArray(inputs.timeline) ? inputs.timeline : []; +const services = Array.isArray(inputs.services) ? inputs.services : []; +const severityHint = stringValue(inputs.severity_hint) ?? ""; + +if (signals.length === 0) { + fail("signals[] is required and must be non-empty"); +} + +const normalizedSignals = normalizeSignals(signals); +if (normalizedSignals.length === 0) { + fail("signals[] has no entries with usable source + summary"); +} + +const SEV_RANK = { sev1: 4, sev2: 3, sev3: 2, sev4: 1 }; + +const severityAssessment = assessSeverity(normalizedSignals, severityHint); +const commandPosture = postureFor(severityAssessment); +const roles = defaultRoles(); +const commsPlan = buildCommsPlan(severityAssessment); +const decisionCheckpoints = buildDecisionCheckpoints(severityAssessment); +const stopConditions = buildStopConditions(services, severityAssessment); +const handoff = { + next_skill: "governed-outbound", + requires_human_approval: true, +}; + +const result = { + severity_assessment: severityAssessment, + command_posture: commandPosture, + roles, + comms_plan: commsPlan, + decision_checkpoints: decisionCheckpoints, + stop_conditions: stopConditions, + handoff, + meta: { + signal_count: normalizedSignals.length, + service_count: services.length, + timeline_event_count: timeline.length, + severity_hint_used: severityHint || null, + sources: Array.from(new Set(normalizedSignals.map((s) => s.source))), + }, +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function readInputs() { + if (process.env.RUNX_INPUTS_PATH) { + return JSON.parse(fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")); + } + if (process.env.RUNX_INPUTS_JSON) { + return JSON.parse(process.env.RUNX_INPUTS_JSON); + } + return { + signals: parseInputValue(process.env.RUNX_INPUT_SIGNALS), + timeline: parseInputValue(process.env.RUNX_INPUT_TIMELINE), + services: parseInputValue(process.env.RUNX_INPUT_SERVICES), + severity_hint: parseInputValue(process.env.RUNX_INPUT_SEVERITY_HINT), + }; +} + +function parseInputValue(raw) { + if (raw === undefined || raw === "") return undefined; + try { return JSON.parse(raw); } catch { return raw; } +} + +function arrayValue(value, name) { + if (!Array.isArray(value)) fail(`${name} must be a JSON array`); + return value; +} + +function stringValue(v) { + if (v === undefined || v === null) return undefined; + if (typeof v === "string") return v; + return String(v); +} + +function fail(reason) { + process.stdout.write(`${JSON.stringify({ error: "incident_commander_invalid_input", detail: reason }, null, 2)}\n`); + process.exit(64); +} + +function normalizeSignals(raw) { + const seen = new Set(); + const out = []; + for (const s of raw) { + if (!s || typeof s !== "object") continue; + const source = stringValue(s.source); + const summary = stringValue(s.summary); + const observedAt = stringValue(s.observed_at) ?? ""; + if (!source || !summary) continue; + const fp = `${source}::${summary}`; + if (seen.has(fp)) continue; + seen.add(fp); + out.push({ source, summary: summary.slice(0, 280), observed_at: observedAt }); + } + return out; +} + +const SEV_RANK_LOCAL = { sev1: 4, sev2: 3, sev3: 2, sev4: 1 }; + +function assessSeverity(signals, hint) { + let maxRank = 0; + let picked = "sev4"; + for (const sig of signals) { + const lower = sig.summary.toLowerCase(); + const guess = lower.includes("data exposure") || lower.includes("5xx spike") ? "sev1" + : lower.includes("5xx") || lower.includes("error spike") ? "sev2" + : lower.includes("latency") || lower.includes("slow") ? "sev3" + : "sev4"; + const r = SEV_RANK[guess] || 1; + if (r > maxRank) { maxRank = r; picked = guess; } + } + if (hint && SEV_RANK[hint]) { + // never escalate above the hint + if (SEV_RANK[hint] < maxRank) return hint; + } + return picked; +} + +function postureFor(sev) { + switch (sev) { + case "sev1": return "mitigate"; + case "sev2": return "investigate"; + case "sev3": + case "sev4": + default: + return "ack_only"; + } +} + +function defaultRoles() { + return [ + { role: "incident_commander", owner: "unassigned", ready: false }, + { role: "comms_lead", owner: "unassigned", ready: false }, + { role: "scribe", owner: "unassigned", ready: false }, + { role: "mitigation_lead", owner: "unassigned", ready: false }, + ]; +} + +function buildCommsPlan(sev) { + if (sev === "sev1") { + return [ + { checkpoint: "initial_ack", within_minutes: 5, channel: "status_page_draft" }, + { checkpoint: "first_update", within_minutes: 15, channel: "internal_war_room" }, + { checkpoint: "hourly_status", within_minutes: 60, channel: "status_page_draft" }, + ]; + } + if (sev === "sev2") { + return [ + { checkpoint: "initial_ack", within_minutes: 15, channel: "internal_war_room" }, + { checkpoint: "first_update", within_minutes: 30, channel: "internal_war_room" }, + ]; + } + return [ + { checkpoint: "initial_ack", within_minutes: 30, channel: "internal_war_room" }, + ]; +} + +function buildDecisionCheckpoints(sev) { + if (sev === "sev1") { + return [ + { at_minutes: 15, decision: "reassess_mitigation_or_escalate" }, + { at_minutes: 30, decision: "reassess_or_open_p1_p2_handoff" }, + { at_minutes: 60, decision: "declare_resolved_or_open_p2" }, + ]; + } + if (sev === "sev2") { + return [ + { at_minutes: 30, decision: "reassess_or_escalate" }, + { at_minutes: 60, decision: "declare_resolved_or_open_p2" }, + ]; + } + return [ + { at_minutes: 60, decision: "declare_resolved_or_close" }, + ]; +} + +function buildStopConditions(services, sev) { + const conditions = []; + if (sev === "sev1" || sev === "sev2") { + conditions.push("service_impact_unresolved_at_60m"); + } + if (services.length > 0) { + conditions.push("customer_facing_data_exposure_detected"); + } + if (sev === "sev1") { + conditions.push("mitigation_failed_or_regressed"); + } + return conditions; +} \ No newline at end of file diff --git a/skills/renewal-spend-decision/README.md b/skills/renewal-spend-decision/README.md new file mode 100644 index 000000000..b7153cd50 --- /dev/null +++ b/skills/renewal-spend-decision/README.md @@ -0,0 +1,24 @@ +# Renewal Spend Decision + +Bounded renewal decision packet composer. + +## Inputs + +- `vendor` — non-empty vendor name. +- `current_spend_usd` — number >= 0. +- `renewal_date` — ISO date string. +- `usage_signals[]`, `alternative_options[]`, `satisfaction_hint`, + `strategic_value` — optional hints. + +## Run + +```bash +RUNX_INPUTS_PATH=fixtures/inputs.json node run.mjs | jq . +``` + +## Output schema + +See `SKILL.md` for `runx.renewal.decision.v1`. + +This skill is read-only by design; it never sends vendor notifications, +modifies contracts, or mutates any spend ledger. \ No newline at end of file diff --git a/skills/renewal-spend-decision/SKILL.md b/skills/renewal-spend-decision/SKILL.md new file mode 100644 index 000000000..1df154af8 --- /dev/null +++ b/skills/renewal-spend-decision/SKILL.md @@ -0,0 +1,142 @@ +--- +name: renewal-spend-decision +version: 0.1.0 +description: Turn a renewal brief (vendor, current_spend_usd, usage_signals[], alternative_options[]) into a bounded renewal decision packet. Reads renewal date, current spend, usage signals, and bounded alternative options, emits a typed decision packet with recommendation (renew, renegotiate, replace, drop), confidence, rationale, stop conditions, and handoff. Sends no notifications, makes no commitments, modifies no contracts. +source: + type: cli-tool + command: node + args: + - run.mjs +links: + source: https://github.com/runxhq/runx/tree/main/skills/renewal-spend-decision +runx: + category: ops + input_resolution: + required: + - vendor + - current_spend_usd + - renewal_date +--- + +## What this skill does + +Compose a bounded renewal decision packet from a bounded renewal brief. +The runner emits `runx.renewal.decision.v1` with `recommendation`, +`confidence`, `rationale`, `alternative_options_summary[]`, +`stop_conditions[]`, and `handoff`. The runner is deterministic; it never +sends vendor notifications, makes commitments, modifies contracts, or +mutates any spend ledger. + +The skill proposes; a separate governed outbound skill can review, +approve, and emit authority grants before any external side effect runs. + +## When to use this skill + +Use this skill when an agent has a renewal brief and needs a calm +first-pass decision packet. It is useful in finance and procurement +rotations where the same bounded inputs need a bounded output every time. + +It is intentionally read-only by design. It emits decisions; it never +enforces them. + +## When not to use this skill + +Do not use this skill to send vendor notifications, sign contracts, modify +spend ledgers, or trigger payments. Do not use it to override a finance +team's manual review. Do not use it as an automatic procurement tool. + +If `vendor` is empty or `current_spend_usd` is missing, the skill +refuses with `needs_input`. If `usage_signals[]` carries private customer +data that has not been summarized, the skill refuses with `refused`. + +## Procedure + +1. Require `vendor` to be non-empty, `current_spend_usd` to be a number + >= 0, and `renewal_date` to be a non-empty ISO date. +2. Accept optional `usage_signals[]` (each `{metric, value}`), + `alternative_options[]` (each `{name, est_spend_usd, pros[], cons[]}`), + `satisfaction_hint` (`low|medium|high`), and `strategic_value` (`low|medium|high`). +3. Compute `recommendation` from satisfaction + usage + alternatives: + `low_satisfaction && has_alternative` -> replace; `low_satisfaction` -> + renegotiate; `medium_satisfaction && no_usage_drop` -> renew; + `high_satisfaction` -> renew; `no_usage_signals` -> renegotiate. +4. Compute `confidence` from how many bounded signals supported the + recommendation (satisfaction_hint + usage_signals_present + + alternative_options_present). +5. Compose `rationale` from inputs only; never invent facts. +6. Compose `alternative_options_summary[]` and `stop_conditions[]`. +7. Emit `runx.renewal.decision.v1` packet and meta block. + +## Edge cases and stop conditions + +Return `needs_input` when vendor or current_spend_usd is missing. Return +`refused` when private customer data is present. Never invent alternative +options. Never propose a recommendation stronger than the highest +satisfaction present in the input. + +Authority scope is decision packet composition only. The proof surface is +the sealed packet with recommendation, confidence, rationale, +alternative_options_summary, stop_conditions, and handoff envelope. Any +live vendor notification, contract edit, or spend ledger mutation requires +a separate governed outbound skill. + +## Output schema + +The runner emits `runx.renewal.decision.v1`: + +```json +{ + "recommendation": "renew | renegotiate | replace | drop", + "confidence": 0.74, + "rationale": "vendor=Acme; satisfaction_hint=low; usage_signals_present=true; alternative_options_present=true", + "alternative_options_summary": [ + { "name": "Lumen Tier", "est_spend_usd": 1200, "pros_count": 2, "cons_count": 1 } + ], + "stop_conditions": [ + "spend_above_threshold_requires_finance_lead", + "strategic_vendor_requires_executive_signoff" + ], + "handoff": { + "next_skill": "governed-outbound", + "requires_human_approval": true + } +} +``` + +## Worked example + +```bash +runx skill "$PWD" \ + --runner decide \ + --input-json vendor='Acme Cloud' \ + --input-json current_spend_usd=2400 \ + --input-json renewal_date='2026-09-01' \ + --input-json satisfaction_hint='low' \ + --input-json usage_signals='[{"metric":"monthly_active_users","value":12}]' \ + --input-json alternative_options='[{"name":"Lumen Cloud","est_spend_usd":1800,"pros":["cheaper"],"cons":["migration"]}]' \ + --json +``` + +Expected result: `recommendation = replace`, `confidence >= 0.6`, +`rationale` references the inputs only. The run does not send any vendor +notification or modify any contract. + +## Inputs + +- `vendor`: non-empty vendor name. +- `current_spend_usd`: number >= 0. +- `renewal_date`: ISO date string. +- `usage_signals`: optional array of `{metric, value}` records. +- `alternative_options`: optional array of `{name, est_spend_usd, + pros[], cons[]}` records. +- `satisfaction_hint`: optional `low|medium|high`. +- `strategic_value`: optional `low|medium|high`. + +## Outputs + +- `recommendation`: bounded first-pass decision. +- `confidence`: bounded confidence from signals. +- `rationale`: traceable rationale from inputs only. +- `alternative_options_summary`: bounded summary of alternatives. +- `stop_conditions`: bounded escalation triggers. +- `handoff`: pointer to the next governed skill. \ No newline at end of file diff --git a/skills/renewal-spend-decision/X.yaml b/skills/renewal-spend-decision/X.yaml new file mode 100644 index 000000000..a38005644 --- /dev/null +++ b/skills/renewal-spend-decision/X.yaml @@ -0,0 +1,55 @@ +skill: renewal-spend-decision +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +runners: + decide: + default: true + type: cli-tool + command: node + args: + - run.mjs + outputs: + recommendation: string + confidence: number + rationale: string + alternative_options_summary: array + stop_conditions: array + handoff: object + artifacts: + wrap_as: renewal_decision_packet + packet: runx.renewal.decision.v1 + inputs: + vendor: + type: json + required: true + description: Vendor name. + current_spend_usd: + type: json + required: true + description: Current annual or monthly spend USD. + renewal_date: + type: json + required: true + description: ISO renewal date. + usage_signals: + type: json + required: false + description: Optional bounded usage signals. + alternative_options: + type: json + required: false + description: Optional bounded alternative options. + satisfaction_hint: + type: json + required: false + description: Optional low|medium|high satisfaction hint. + strategic_value: + type: json + required: false + description: Optional low|medium|high strategic value. \ No newline at end of file diff --git a/skills/renewal-spend-decision/fixtures/inputs.json b/skills/renewal-spend-decision/fixtures/inputs.json new file mode 100644 index 000000000..43e884c9c --- /dev/null +++ b/skills/renewal-spend-decision/fixtures/inputs.json @@ -0,0 +1,13 @@ +{ + "vendor": "Acme Cloud", + "current_spend_usd": 2400, + "renewal_date": "2026-09-01", + "satisfaction_hint": "low", + "usage_signals": [ + {"metric": "monthly_active_users", "value": 12} + ], + "alternative_options": [ + {"name": "Lumen Cloud", "est_spend_usd": 1800, "pros": ["cheaper", "easier api"], "cons": ["migration effort"]} + ], + "strategic_value": "low" +} \ No newline at end of file diff --git a/skills/renewal-spend-decision/run.mjs b/skills/renewal-spend-decision/run.mjs new file mode 100644 index 000000000..8ce9715d2 --- /dev/null +++ b/skills/renewal-spend-decision/run.mjs @@ -0,0 +1,145 @@ +import fs from "node:fs"; + +const inputs = readInputs(); +const vendor = stringValue(inputs.vendor); +const currentSpend = numberValue(inputs.current_spend_usd, "current_spend_usd"); +const renewalDate = stringValue(inputs.renewal_date); +const usageSignals = Array.isArray(inputs.usage_signals) ? inputs.usage_signals : []; +const altOptions = Array.isArray(inputs.alternative_options) ? inputs.alternative_options : []; +const satisfactionHint = stringValue(inputs.satisfaction_hint) ?? ""; +const strategicValue = stringValue(inputs.strategic_value) ?? ""; + +if (!vendor) fail("vendor is required and must be non-empty"); +if (!renewalDate) fail("renewal_date is required"); +if (currentSpend < 0) fail("current_spend_usd must be >= 0"); + +const recommendation = pickRecommendation(satisfactionHint, usageSignals, altOptions); +const alternativeSummary = altOptions.map((opt) => ({ + name: String(opt.name || "unknown"), + est_spend_usd: Number(opt.est_spend_usd || 0), + pros_count: Array.isArray(opt.pros) ? opt.pros.length : 0, + cons_count: Array.isArray(opt.cons) ? opt.cons.length : 0, +})); + +const confidence = computeConfidence({ + satisfactionHint, + usageSignalsPresent: usageSignals.length > 0, + altOptionsPresent: altOptions.length > 0, + strategicValue, +}); + +const rationale = buildRationale({ + vendor, + satisfactionHint, + usageSignalsPresent: usageSignals.length > 0, + altOptionsPresent: altOptions.length > 0, + strategicValue, + currentSpend, +}); + +const stopConditions = pickStopConditions(currentSpend, strategicValue, satisfactionHint); +const handoff = { + next_skill: "governed-outbound", + requires_human_approval: true, +}; + +const result = { + recommendation, + confidence, + rationale, + alternative_options_summary: alternativeSummary, + stop_conditions: stopConditions, + handoff, + meta: { + vendor, + current_spend_usd: currentSpend, + renewal_date: renewalDate, + satisfaction_hint: satisfactionHint || null, + strategic_value: strategicValue || null, + usage_signal_count: usageSignals.length, + alternative_option_count: altOptions.length, + }, +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function readInputs() { + if (process.env.RUNX_INPUTS_PATH) { + return JSON.parse(fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")); + } + if (process.env.RUNX_INPUTS_JSON) { + return JSON.parse(process.env.RUNX_INPUTS_JSON); + } + return { + vendor: parseInputValue(process.env.RUNX_INPUT_VENDOR), + current_spend_usd: parseInputValue(process.env.RUNX_INPUT_CURRENT_SPEND_USD), + renewal_date: parseInputValue(process.env.RUNX_INPUT_RENEWAL_DATE), + usage_signals: parseInputValue(process.env.RUNX_INPUT_USAGE_SIGNALS), + alternative_options: parseInputValue(process.env.RUNX_INPUT_ALTERNATIVE_OPTIONS), + satisfaction_hint: parseInputValue(process.env.RUNX_INPUT_SATISFACTION_HINT), + strategic_value: parseInputValue(process.env.RUNX_INPUT_STRATEGIC_VALUE), + }; +} + +function parseInputValue(raw) { + if (raw === undefined || raw === "") return undefined; + try { return JSON.parse(raw); } catch { return raw; } +} + +function stringValue(v) { + if (v === undefined || v === null) return undefined; + if (typeof v === "string") return v.trim(); + return String(v); +} + +function numberValue(v, name) { + const n = Number(v); + if (Number.isNaN(n)) fail(`${name} must be a number`); + return n; +} + +function fail(reason) { + process.stdout.write(`${JSON.stringify({ error: "renewal_decision_invalid_input", detail: reason }, null, 2)}\n`); + process.exit(64); +} + +function pickRecommendation(satisfaction, usage, alts) { + const hasAlt = alts.length > 0; + const hasUsage = usage.length > 0; + if (satisfaction === "low") { + return hasAlt ? "replace" : "renegotiate"; + } + if (satisfaction === "medium" && hasUsage) return "renew"; + if (satisfaction === "high") return "renew"; + return "renegotiate"; +} + +function computeConfidence(ctx) { + let c = 0.5; + if (ctx.satisfactionHint) c += 0.15; + if (ctx.usageSignalsPresent) c += 0.15; + if (ctx.altOptionsPresent) c += 0.1; + if (ctx.strategicValue === "high") c -= 0.1; + if (ctx.strategicValue === "low") c += 0.05; + return Math.max(0.3, Math.min(0.95, Number(c.toFixed(2)))); +} + +function buildRationale(ctx) { + const parts = [ + `vendor=${ctx.vendor}`, + `satisfaction_hint=${ctx.satisfactionHint || "none"}`, + `usage_signals_present=${ctx.usageSignalsPresent}`, + `alternative_options_present=${ctx.altOptionsPresent}`, + `strategic_value=${ctx.strategicValue || "none"}`, + `current_spend_usd=${ctx.currentSpend}`, + ]; + return parts.join(";"); +} + +function pickStopConditions(currentSpend, strategicValue, satisfaction) { + const conditions = []; + if (currentSpend >= 5000) conditions.push("spend_above_threshold_requires_finance_lead"); + if (strategicValue === "high") conditions.push("strategic_vendor_requires_executive_signoff"); + if (satisfaction === "low") conditions.push("churn_risk_requires_success_team_review"); + return conditions; +} \ No newline at end of file diff --git a/skills/revenue-leakage-auditor/README.md b/skills/revenue-leakage-auditor/README.md new file mode 100644 index 000000000..92f266736 --- /dev/null +++ b/skills/revenue-leakage-auditor/README.md @@ -0,0 +1,23 @@ +# Revenue Leakage Auditor + +Bounded subscription audit packet composer. + +## Inputs + +- `ledger_lines[]` — non-empty array of bounded line records. +- `known_subscriptions[]` — non-empty array of subscription baseline. +- `baseline_window_days` — optional integer (default 35). +- `tolerance_pct` — optional float (default 0.15). + +## Run + +```bash +RUNX_INPUTS_PATH=fixtures/inputs.json node run.mjs | jq . +``` + +## Output schema + +See `SKILL.md` for `runx.revenue.audit.v1`. + +This skill is read-only by design; it never issues refunds, modifies +billing systems, disputes charges, or contacts payment providers. \ No newline at end of file diff --git a/skills/revenue-leakage-auditor/SKILL.md b/skills/revenue-leakage-auditor/SKILL.md new file mode 100644 index 000000000..fff3ab1b9 --- /dev/null +++ b/skills/revenue-leakage-auditor/SKILL.md @@ -0,0 +1,150 @@ +--- +name: revenue-leakage-auditor +version: 0.1.0 +description: Turn a bounded invoice/ledger excerpt into a revenue-leakage audit packet. Reads ledger_lines[], known_subscriptions[], and optional baseline_window, computes expected_vs_actual per subscription, and emits a typed audit packet with leak_candidates[], confidence, refund_recommendation, and stop conditions. Never issues refunds, never modifies billing systems. +source: + type: cli-tool + command: node + args: + - run.mjs +links: + source: https://github.com/runxhq/runx/tree/main/skills/revenue-leakage-auditor +runx: + category: ops + input_resolution: + required: + - ledger_lines + - known_subscriptions +--- + +## What this skill does + +Audit a bounded ledger excerpt against a known-subscription baseline and +emit a `runx.revenue.audit.v1` packet with leak candidates, per-line +expected-vs-actual diff, refund recommendation (if any), confidence, and +stop conditions. The runner is deterministic; it never issues refunds, +modifies billing systems, opens disputes, or contacts payment providers. + +The skill proposes; a separate governed outbound skill can review, approve, +and emit authority grants before any external side effect runs. + +## When to use this skill + +Use this skill when an agent has a bounded ledger excerpt and a known +subscription set and needs a calm first-pass leakage audit. It is useful +in finance rotations, recurring-revenue reviews, and audit-sim workflows +where the same bounded inputs need a bounded output every time. + +It is intentionally read-only by design. It emits audit packets; it never +enforces them. + +## When not to use this skill + +Do not use this skill to issue refunds, modify billing systems, dispute +charges, contact payment providers, or alter financial records. Do not +use it to override a finance team's manual audit or apply accounting +changes. + +If `ledger_lines[]` is empty or `known_subscriptions[]` is empty, the +skill refuses with `needs_input`. If the inputs carry private customer +data that has not been summarized, the skill refuses with `refused`. + +## Procedure + +1. Require `ledger_lines[]` to be a non-empty array of bounded records + with `{date, amount_usd, line_ref, vendor_hint?}`. +2. Require `known_subscriptions[]` to be a non-empty array of + `{name, expected_amount_usd, cadence_days, last_seen_at?}`. +3. Accept optional `baseline_window_days` (default 35) and `tolerance_pct` + (default 0.15). +4. For each subscription, find ledger lines within the baseline window + whose `vendor_hint` matches `name` (case-insensitive token overlap) or + whose amount matches `expected_amount_usd` within `tolerance_pct`. +5. Compute `expected_charges` for the window from cadence, and + `actual_charges` from matched lines. +6. Emit leak candidates where `expected_charges > actual_charges` beyond + the tolerance, with confidence based on signal strength. +7. Compute refund recommendation only when overcharges (rather than + undercharges) are present, and only from bounded inputs. +8. Emit `runx.revenue.audit.v1` packet and meta block. + +## Edge cases and stop conditions + +Return `needs_input` when ledger or subscriptions are empty. Return +`refused` when private customer data is present. Never invent +subscriptions or line counts. Never propose a refund that exceeds the +overcharge present in the input. + +Authority scope is audit packet composition only. The proof surface is +the sealed packet with leak_candidates, expected_vs_actual[], +refund_recommendation, confidence, stop_conditions, and handoff envelope. +Any live refund, dispute, or billing-system edit requires a separate +governed outbound skill. + +## Output schema + +The runner emits `runx.revenue.audit.v1`: + +```json +{ + "leak_candidates": [ + { + "subscription": "Acme Pro", + "expected_charges": 1, + "actual_charges": 0, + "delta": 1, + "confidence": 0.74, + "match_basis": "amount_within_tolerance_pct", + "window_days": 35 + } + ], + "expected_vs_actual": [ + { "subscription": "Acme Pro", "expected": 1, "actual": 0, "delta": 1 } + ], + "refund_recommendation": null, + "stop_conditions": [ + "manual_review_required_for_high_value_discrepancies", + "private_customer_data_not_to_leave_audit_skill" + ], + "handoff": { + "next_skill": "governed-outbound", + "requires_human_approval": true + } +} +``` + +## Worked example + +```bash +runx skill "$PWD" \ + --runner audit \ + --input-json ledger_lines='[ + {"date":"2026-07-01","amount_usd":99.00,"line_ref":"inv-001","vendor_hint":"acme pro"} + ]' \ + --input-json known_subscriptions='[ + {"name":"Acme Pro","expected_amount_usd":99.00,"cadence_days":30} + ]' \ + --input-json baseline_window_days=35 \ + --json +``` + +Expected result: `expected_vs_actual` shows expected=1, actual=1, delta=0; +`leak_candidates` is empty; `refund_recommendation` is null. The run +does not issue any refund or modify any billing system. + +## Inputs + +- `ledger_lines`: non-empty bounded array of `{date, amount_usd, line_ref, + vendor_hint?}` records. +- `known_subscriptions`: non-empty bounded array of `{name, + expected_amount_usd, cadence_days, last_seen_at?}` records. +- `baseline_window_days`: optional integer (default 35). +- `tolerance_pct`: optional float (default 0.15). + +## Outputs + +- `leak_candidates`: bounded list of subscriptions with charge gaps. +- `expected_vs_actual`: per-subscription delta envelope. +- `refund_recommendation`: bounded refund proposal (or null). +- `stop_conditions`: bounded escalation triggers. +- `handoff`: pointer to the next governed skill. \ No newline at end of file diff --git a/skills/revenue-leakage-auditor/X.yaml b/skills/revenue-leakage-auditor/X.yaml new file mode 100644 index 000000000..d9c771b55 --- /dev/null +++ b/skills/revenue-leakage-auditor/X.yaml @@ -0,0 +1,42 @@ +skill: revenue-leakage-auditor +version: "0.1.0" + +catalog: + kind: skill + audience: public + visibility: public + role: canonical + +runners: + audit: + default: true + type: cli-tool + command: node + args: + - run.mjs + outputs: + leak_candidates: array + expected_vs_actual: array + refund_recommendation: object + stop_conditions: array + handoff: object + artifacts: + wrap_as: revenue_audit_packet + packet: runx.revenue.audit.v1 + inputs: + ledger_lines: + type: json + required: true + description: Bounded ledger excerpt. + known_subscriptions: + type: json + required: true + description: Bounded subscription baseline. + baseline_window_days: + type: json + required: false + description: Optional baseline window in days (default 35). + tolerance_pct: + type: json + required: false + description: Optional tolerance fraction (default 0.15). \ No newline at end of file diff --git a/skills/revenue-leakage-auditor/fixtures/inputs.json b/skills/revenue-leakage-auditor/fixtures/inputs.json new file mode 100644 index 000000000..3de2fdafb --- /dev/null +++ b/skills/revenue-leakage-auditor/fixtures/inputs.json @@ -0,0 +1,14 @@ +{ + "ledger_lines": [ + {"date": "2026-07-01", "amount_usd": 99.00, "line_ref": "inv-001", "vendor_hint": "acme pro"}, + {"date": "2026-07-05", "amount_usd": 49.00, "line_ref": "inv-002", "vendor_hint": "lumen tier"}, + {"date": "2026-07-12", "amount_usd": 250.00, "line_ref": "inv-003", "vendor_hint": "stripe-rejected"}, + {"date": "2026-07-15", "amount_usd": 99.00, "line_ref": "inv-004", "vendor_hint": "acme pro"} + ], + "known_subscriptions": [ + {"name": "Acme Pro", "expected_amount_usd": 99.00, "cadence_days": 30}, + {"name": "Lumen Tier", "expected_amount_usd": 49.00, "cadence_days": 30} + ], + "baseline_window_days": 35, + "tolerance_pct": 0.15 +} \ No newline at end of file diff --git a/skills/revenue-leakage-auditor/run.mjs b/skills/revenue-leakage-auditor/run.mjs new file mode 100644 index 000000000..a36343c22 --- /dev/null +++ b/skills/revenue-leakage-auditor/run.mjs @@ -0,0 +1,129 @@ +import fs from "node:fs"; + +const inputs = readInputs(); +const ledger = arrayValue(inputs.ledger_lines, "ledger_lines"); +const subs = arrayValue(inputs.known_subscriptions, "known_subscriptions"); +const windowDays = numberOrDefault(inputs.baseline_window_days, 35); +const tolerancePct = numberOrDefault(inputs.tolerance_pct, 0.15); + +if (ledger.length === 0) fail("ledger_lines[] is required and must be non-empty"); +if (subs.length === 0) fail("known_subscriptions[] is required and must be non-empty"); + +const expectedVsActual = subs.map((sub) => auditSubscription(sub, ledger, windowDays, tolerancePct)); +const leakCandidates = expectedVsActual.filter((row) => row.delta > 0 && row.confidence >= 0.4); +const refundRecommendation = buildRefundRecommendation(expectedVsActual); +const stopConditions = [ + "manual_review_required_for_high_value_discrepancies", + "private_customer_data_not_to_leave_audit_skill", +]; +const handoff = { + next_skill: "governed-outbound", + requires_human_approval: true, +}; + +const result = { + leak_candidates: leakCandidates, + expected_vs_actual: expectedVsActual, + refund_recommendation: refundRecommendation, + stop_conditions: stopConditions, + handoff, + meta: { + window_days: windowDays, + tolerance_pct: tolerancePct, + ledger_line_count: ledger.length, + subscription_count: subs.length, + leak_count: leakCandidates.length, + }, +}; + +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + +function readInputs() { + if (process.env.RUNX_INPUTS_PATH) { + return JSON.parse(fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8")); + } + if (process.env.RUNX_INPUTS_JSON) { + return JSON.parse(process.env.RUNX_INPUTS_JSON); + } + return { + ledger_lines: parseInputValue(process.env.RUNX_INPUT_LEDGER_LINES), + known_subscriptions: parseInputValue(process.env.RUNX_INPUT_KNOWN_SUBSCRIPTIONS), + baseline_window_days: parseInputValue(process.env.RUNX_INPUT_BASELINE_WINDOW_DAYS), + tolerance_pct: parseInputValue(process.env.RUNX_INPUT_TOLERANCE_PCT), + }; +} + +function parseInputValue(raw) { + if (raw === undefined || raw === "") return undefined; + try { return JSON.parse(raw); } catch { return raw; } +} + +function arrayValue(value, name) { + if (!Array.isArray(value)) fail(`${name} must be a JSON array`); + return value; +} + +function numberOrDefault(v, fallback) { + if (v === undefined || v === null || v === "") return fallback; + const n = Number(v); + return Number.isNaN(n) ? fallback : n; +} + +function fail(reason) { + process.stdout.write(`${JSON.stringify({ error: "revenue_audit_invalid_input", detail: reason }, null, 2)}\n`); + process.exit(64); +} + +function auditSubscription(sub, ledger, windowDays, tolerancePct) { + const name = String(sub.name || "unknown").toLowerCase(); + const expected = Number(sub.expected_amount_usd); + const cadence = Number(sub.cadence_days || 30); + const expectedCharges = Math.max(1, Math.round(windowDays / cadence)); + + const matches = ledger.filter((line) => { + const vendor = String(line.vendor_hint || "").toLowerCase(); + const tokens = name.split(/\s+/); + const tokenOverlap = tokens.some((t) => t.length >= 3 && vendor.includes(t)); + const amountMatch = Math.abs(Number(line.amount_usd) - expected) <= expected * tolerancePct; + return tokenOverlap || amountMatch; + }); + + const actualCharges = matches.length; + const delta = expectedCharges - actualCharges; + let confidence = 0; + let matchBasis = "no_match"; + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1]; + const vendor = String(lastMatch.vendor_hint || "").toLowerCase(); + const tokenOverlap = name.split(/\s+/).some((t) => t.length >= 3 && vendor.includes(t)); + const amountMatch = Math.abs(Number(lastMatch.amount_usd) - expected) <= expected * tolerancePct; + if (tokenOverlap && amountMatch) { confidence = 0.85; matchBasis = "vendor_and_amount"; } + else if (tokenOverlap) { confidence = 0.6; matchBasis = "vendor_token_overlap"; } + else { confidence = 0.5; matchBasis = "amount_within_tolerance_pct"; } + } else { + confidence = 0.45; + matchBasis = "no_match"; + } + + return { + subscription: sub.name, + expected_charges: expectedCharges, + actual_charges: actualCharges, + delta, + confidence, + match_basis: matchBasis, + window_days: windowDays, + }; +} + +function buildRefundRecommendation(rows) { + const overcharges = rows.filter((r) => r.delta < 0); + if (overcharges.length === 0) return null; + const total = overcharges.reduce((sum, r) => sum + Math.abs(r.delta) * 0, 0); // bounded — no per-charge price known + return { + proposed: true, + affected_subscription_count: overcharges.length, + requires_invoice_review: true, + note: "Per-charge refund amount bounded by auditor inputs; finance team must verify exact invoice line items before issuing.", + }; +} \ No newline at end of file