github-router 0.3.249 → 0.3.255
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/attribution-settings-CegtE0TY.js +2347 -0
- package/dist/attribution-settings-CegtE0TY.js.map +1 -0
- package/dist/auth-CP4q2CjO.js +44 -0
- package/dist/auth-CP4q2CjO.js.map +1 -0
- package/dist/browser-ext/background.js +8 -1
- package/dist/browser-ext/manifest.json +1 -1
- package/dist/check-usage-6PB5IUFb.js +43 -0
- package/dist/check-usage-6PB5IUFb.js.map +1 -0
- package/dist/claude-BiZxoNWq.js +593 -0
- package/dist/claude-BiZxoNWq.js.map +1 -0
- package/dist/client-CQMfroGV.js +413 -0
- package/dist/client-CQMfroGV.js.map +1 -0
- package/dist/codex-ST21X3WF.js +85 -0
- package/dist/codex-ST21X3WF.js.map +1 -0
- package/dist/debug-CjmE9sgG.js +81 -0
- package/dist/debug-CjmE9sgG.js.map +1 -0
- package/dist/engine-DNCX0Hfd.js +2 -0
- package/dist/{lifecycle-DHZsVN8j.js → exec-DTO2oin0.js} +3 -130
- package/dist/exec-DTO2oin0.js.map +1 -0
- package/dist/file-log-reporter-IFQcL5IK.js +158 -0
- package/dist/file-log-reporter-IFQcL5IK.js.map +1 -0
- package/dist/gate-discovery-CepmbZnc.js +635 -0
- package/dist/gate-discovery-CepmbZnc.js.map +1 -0
- package/dist/get-copilot-usage-DD1N0Xlh.js +11 -0
- package/dist/get-copilot-usage-DD1N0Xlh.js.map +1 -0
- package/dist/hook-mcp-client-DBvm9608.js +136 -0
- package/dist/hook-mcp-client-DBvm9608.js.map +1 -0
- package/dist/internal-artifact-open-D2cQ2QCw.js +319 -0
- package/dist/internal-artifact-open-D2cQ2QCw.js.map +1 -0
- package/dist/internal-first-mate-guard-7v2CtMA6.js +2 -0
- package/dist/internal-first-mate-guard-La0tIjTU.js +126 -0
- package/dist/internal-first-mate-guard-La0tIjTU.js.map +1 -0
- package/dist/internal-plan-review-ipnT3bhj.js +114 -0
- package/dist/internal-plan-review-ipnT3bhj.js.map +1 -0
- package/dist/internal-prompt-submit-DOj75_hh.js +121 -0
- package/dist/internal-prompt-submit-DOj75_hh.js.map +1 -0
- package/dist/internal-session-bind-DwK2iGW0.js +133 -0
- package/dist/internal-session-bind-DwK2iGW0.js.map +1 -0
- package/dist/internal-stop-hook-CNPGJm63.js +212 -0
- package/dist/internal-stop-hook-CNPGJm63.js.map +1 -0
- package/dist/internal-stop-review-AUcFssrW.js +122 -0
- package/dist/internal-stop-review-AUcFssrW.js.map +1 -0
- package/dist/internal-worker-guard-Lu8VHj5k.js +75 -0
- package/dist/internal-worker-guard-Lu8VHj5k.js.map +1 -0
- package/dist/internal-workspace-header-BRMz0Yql.js +24 -0
- package/dist/internal-workspace-header-BRMz0Yql.js.map +1 -0
- package/dist/lifecycle-Byw5s1G8.js +134 -0
- package/dist/lifecycle-Byw5s1G8.js.map +1 -0
- package/dist/lifecycle-D80CaWie.js +2 -0
- package/dist/{lifecycle-CUf1UDrH.js → lifecycle-DD88ka3f.js} +3 -3
- package/dist/{lifecycle-CUf1UDrH.js.map → lifecycle-DD88ka3f.js.map} +1 -1
- package/dist/lifecycle-DzqCqDlM.js +2 -0
- package/dist/main.js +43 -12884
- package/dist/main.js.map +1 -1
- package/dist/mcp-workspace-header-CGJbNeHb.js +21 -0
- package/dist/mcp-workspace-header-CGJbNeHb.js.map +1 -0
- package/dist/models-B6UnGyRo.js +158 -0
- package/dist/models-B6UnGyRo.js.map +1 -0
- package/dist/orchestration-hRU4TdWr.js +39 -0
- package/dist/orchestration-hRU4TdWr.js.map +1 -0
- package/dist/{paths-BjTMI_xK.js → paths-C1cLZ7Ga.js} +52 -20
- package/dist/paths-C1cLZ7Ga.js.map +1 -0
- package/dist/paths-CwKwc2pi.js +2 -0
- package/dist/{peer-mcp-personas-DJzLpfDJ.js → peer-mcp-personas-BDet588n.js} +1006 -2488
- package/dist/peer-mcp-personas-BDet588n.js.map +1 -0
- package/dist/plan-review-hook-C8x0uKPf.js +159 -0
- package/dist/plan-review-hook-C8x0uKPf.js.map +1 -0
- package/dist/prompt-submit-hook-BrkkLAAB.js +153 -0
- package/dist/prompt-submit-hook-BrkkLAAB.js.map +1 -0
- package/dist/provision-DSkPa5hY.js +656 -0
- package/dist/provision-DSkPa5hY.js.map +1 -0
- package/dist/serve-sa_7c-yD.js +1511 -0
- package/dist/serve-sa_7c-yD.js.map +1 -0
- package/dist/server-setup-DcO9qvN3.js +4776 -0
- package/dist/server-setup-DcO9qvN3.js.map +1 -0
- package/dist/start-siGxuZhO.js +129 -0
- package/dist/start-siGxuZhO.js.map +1 -0
- package/dist/stop-gate-hook-DQc2KV7B.js +686 -0
- package/dist/stop-gate-hook-DQc2KV7B.js.map +1 -0
- package/dist/stop-gate-policy-CHwWbuF_.js +268 -0
- package/dist/stop-gate-policy-CHwWbuF_.js.map +1 -0
- package/dist/token-R2ocFC3M.js +1054 -0
- package/dist/token-R2ocFC3M.js.map +1 -0
- package/dist/version-_Q1WpsQp.js +32 -0
- package/dist/version-_Q1WpsQp.js.map +1 -0
- package/dist/worker-dispatch-Bj1uYyG9.js +222 -0
- package/dist/worker-dispatch-Bj1uYyG9.js.map +1 -0
- package/package.json +1 -1
- package/dist/engine-BuuHUb4t.js +0 -2
- package/dist/lifecycle-0rXrwjWk.js +0 -2
- package/dist/lifecycle-DHZsVN8j.js.map +0 -1
- package/dist/lifecycle-hzPwXc3I.js +0 -2
- package/dist/paths-BjTMI_xK.js.map +0 -1
- package/dist/paths-D3XgmTcQ.js +0 -2
- package/dist/peer-mcp-personas-DJzLpfDJ.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal-artifact-open-D2cQ2QCw.js","names":[],"sources":["../src/lib/artifact/plan-html.ts","../src/internal-artifact-open.ts"],"sourcesContent":["/**\n * Render a plan's markdown into a self-contained, styled HTML document for the\n * ai-or-die Artifact panel. HTML is the canonical review artifact (lavish's\n * model); converting here means the hands-off auto-open shows a formatted,\n * annotatable page instead of raw markdown.\n *\n * Security: the iframe ai-or-die serves this into runs with\n * `allow-scripts allow-same-origin`, so a `<script>` smuggled into the plan\n * markdown could read the per-session asset token. Two defenses:\n * 1. marked is configured to ESCAPE raw HTML tokens (render as visible text).\n * 2. `link`/`image` hrefs with script-bearing schemes (javascript:, vbscript:,\n * non-image data:) are neutralised before rendering.\n * No separate sanitizer/dep needed.\n */\nimport { Marked } from \"marked\"\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\")\n}\n\n/** Decode the HTML character references a browser would resolve in an attribute\n * value, so an entity-obfuscated scheme (`javascript:`, `javascript:`)\n * cannot slip past the scheme check below. Numeric refs cover any letter; the\n * small named map covers the scheme-relevant punctuation. */\nconst NAMED_REFS: Record<string, string> = {\n colon: \":\", tab: \"\\t\", newline: \"\\n\", sol: \"/\", semi: \";\", amp: \"&\", lpar: \"(\", rpar: \")\",\n}\nfunction decodeEntities(s: string): string {\n return s.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);?/g, (m, body: string) => {\n if (body[0] === \"#\") {\n const code = body[1] === \"x\" || body[1] === \"X\"\n ? Number.parseInt(body.slice(2), 16)\n : Number.parseInt(body.slice(1), 10)\n return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m\n }\n return NAMED_REFS[body.toLowerCase()] ?? m\n })\n}\n\n/** Block script-bearing URL schemes (defense-in-depth on top of HTML escaping). */\nfunction isDangerousUrl(url: string, isImage: boolean): boolean {\n // Decode entities first (a browser resolves them in href), then drop ASCII\n // control chars + spaces (code <= 0x20) a browser ignores inside a scheme.\n const v = Array.from(decodeEntities(url)).filter((c) => c.charCodeAt(0) > 0x20).join(\"\").toLowerCase()\n if (v.startsWith(\"javascript:\") || v.startsWith(\"vbscript:\")) return true\n if (v.startsWith(\"data:\")) {\n // Allow only raster image data URIs (and only for <img>); SVG can script.\n return !(isImage && v.startsWith(\"data:image/\") && !v.startsWith(\"data:image/svg\"))\n }\n return false\n}\n\n/** A marked instance that escapes raw HTML (raw markup never becomes live). */\nfunction makeMarked(): Marked {\n const m = new Marked({ gfm: true, breaks: false })\n m.use({\n renderer: {\n // Raw HTML in the source is rendered as escaped text, never live markup.\n html(token: unknown): string {\n const text = typeof token === \"string\" ? token : ((token as { text?: string }).text ?? \"\")\n return escapeHtml(text)\n },\n },\n })\n return m\n}\n\ninterface MdToken {\n type?: string\n href?: string\n tokens?: MdToken[]\n items?: MdToken[]\n header?: Array<{ tokens?: MdToken[] }>\n rows?: Array<Array<{ tokens?: MdToken[] }>>\n}\n\n/**\n * Recursively neutralise script-bearing link/image hrefs in the lexed token tree.\n * Done as an explicit walk (not marked's `walkTokens` hook) because we render via\n * the low-level `lexer`+`parser` split, which does not run registered walkTokens.\n */\nfunction neutralizeUrls(tokens: MdToken[]): void {\n for (const t of tokens) {\n if ((t.type === \"link\" || t.type === \"image\") && typeof t.href === \"string\") {\n if (isDangerousUrl(t.href, t.type === \"image\")) t.href = \"#\"\n }\n if (t.tokens) neutralizeUrls(t.tokens)\n if (t.items) neutralizeUrls(t.items)\n if (t.header) for (const cell of t.header) if (cell.tokens) neutralizeUrls(cell.tokens)\n if (t.rows) for (const row of t.rows) for (const cell of row) if (cell.tokens) neutralizeUrls(cell.tokens)\n }\n}\n\n/** Add `data-source-line=\"N\"` to the first opening tag of a rendered block. */\nfunction tagSourceLine(html: string, line: number): string {\n return html.replace(/^(\\s*)<([a-zA-Z][\\w-]*)/, `$1<$2 data-source-line=\"${line}\"`)\n}\n\n/**\n * Render markdown to inner HTML, tagging each top-level block with its 1-based\n * source line so an annotation in the panel can map back to a plan line.\n */\nexport function renderMarkdownBody(source: string): string {\n const m = makeMarked()\n let tokens: Array<{ raw?: string }>\n try {\n tokens = m.lexer(source) as Array<{ raw?: string }>\n } catch {\n // Lexing should not fail, but never throw out of the hook path.\n return `<pre>${escapeHtml(source)}</pre>`\n }\n neutralizeUrls(tokens as MdToken[])\n let line = 1\n let out = \"\"\n for (const tok of tokens) {\n let html: string\n try {\n html = m.parser([tok] as Parameters<typeof m.parser>[0])\n } catch {\n html = \"\"\n }\n if (html.trim()) out += tagSourceLine(html, line)\n line += (tok.raw?.match(/\\n/g)?.length ?? 0)\n }\n return out || `<pre>${escapeHtml(source)}</pre>`\n}\n\n/**\n * Wrap rendered markdown in a complete, self-contained HTML document with\n * readable typography (works even before ai-or-die injects its annotation SDK).\n */\nexport function renderPlanHtml(source: string, title = \"Plan\"): string {\n return wrapArtifactDocument(renderMarkdownBody(source), title)\n}\n\nconst BASE_STYLE = ` :root {\n --bg: #0f1115; --fg: #f7f3ea; --muted: #aeb6c6; --border: #2a2f3a;\n --accent: #f4c95d; --code-bg: #171a21;\n --serif: 'Iowan Old Style', Georgia, 'Times New Roman', serif;\n --sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;\n --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n }\n * { box-sizing: border-box; }\n html, body { margin: 0; background: var(--bg); color: var(--fg); }\n body { font-family: var(--serif); font-size: 16px; line-height: 1.6;\n padding: 32px clamp(16px, 5vw, 56px); }\n main { max-width: 78ch; margin: 0 auto; }\n h1, h2, h3, h4 { font-family: var(--sans); line-height: 1.25; margin: 1.6em 0 0.5em; }\n h1 { font-size: 1.9em; } h2 { font-size: 1.45em; padding-bottom: 0.2em;\n border-bottom: 1px solid var(--border); } h3 { font-size: 1.2em; }\n p, li { color: var(--fg); }\n a { color: var(--accent); }\n code { font-family: var(--mono); font-size: 0.9em; background: var(--code-bg);\n padding: 0.12em 0.35em; border-radius: 5px; }\n pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px;\n padding: 12px 14px; overflow: auto; }\n pre code { background: none; padding: 0; }\n blockquote { margin: 1em 0; padding: 0.2em 1em; border-left: 3px solid var(--accent);\n color: var(--muted); }\n table { border-collapse: collapse; width: 100%; margin: 1em 0; font-family: var(--sans);\n font-size: 0.95em; }\n th, td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }\n th { background: var(--code-bg); }\n hr { border: 0; border-top: 1px solid var(--border); margin: 1.5em 0; }\n ul, ol { padding-left: 1.4em; }`\n\nconst INTERACTIVE_STYLE = `\n .aod-group { margin: 1.4em 0; padding: 14px 16px; border: 1px solid var(--border);\n border-radius: 10px; background: var(--code-bg); }\n .aod-prompt { font-family: var(--sans); font-weight: 600; margin: 0 0 0.6em; }\n .aod-options { display: flex; flex-wrap: wrap; gap: 8px; }\n .aod-multi-select .aod-options { flex-direction: column; gap: 6px; }\n .aod-action, .aod-submit { font-family: var(--sans); font-size: 0.95em; font-weight: 600;\n cursor: pointer; border: 1px solid var(--border); border-radius: 8px;\n padding: 8px 12px; background: var(--bg); color: var(--fg); }\n .aod-action:hover, .aod-submit:hover { border-color: var(--accent); }\n .aod-submit { margin-top: 10px; background: var(--accent); color: #17130a; border-color: var(--accent); }\n .aod-check { display: flex; align-items: center; gap: 8px; font-family: var(--sans); cursor: pointer; }\n .aod-steps { list-style: decimal; }\n .aod-step { margin: 0.5em 0; }\n .aod-step-actions { display: inline-flex; gap: 6px; margin-left: 8px; }`\n\n/**\n * Wrap rendered inner HTML in the self-contained document shell. `extraStyle` is\n * appended to the inline stylesheet so the interactive variant can add its own\n * rules without a second `<style>` block. Static callers pass \"\" (default), so\n * the static document is byte-identical to the pre-refactor output.\n */\nfunction wrapArtifactDocument(body: string, title: string, extraStyle = \"\"): string {\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>${escapeHtml(title)}</title>\n<style>\n${BASE_STYLE}${extraStyle}\n</style>\n</head>\n<body>\n<main>\n${body}\n</main>\n</body>\n</html>\n`\n}\n\n/* -------------------------------------------------------------------------- */\n/* Interactive artifacts (contract v2.2 §4: declarative data-aod-* vocab) */\n/* -------------------------------------------------------------------------- */\n\nexport interface InteractiveOption {\n /** Stable id echoed back as `elementId` in the action event. Required. */\n elementId: string\n /** Human-visible label. */\n label: string\n /** Optional value echoed as `data-aod-value` (checkbox submits use \"true\"/\"false\"). */\n value?: string\n}\n\n/** One immediate-fire choice group: each option posts an action the instant it is clicked. */\nexport interface ChooseOneBlock {\n kind: \"choose-one\"\n /** `data-aod-group` shared by the options (marks them as one decision). */\n group: string\n prompt?: string\n /** `data-aod-action` verb for every option (default \"choose\"). */\n action?: string\n options: InteractiveOption[]\n}\n\n/** A multi-select group: checkbox toggles plus one submit that carries {group, selected}. */\nexport interface MultiSelectBlock {\n kind: \"multi-select\"\n group: string\n prompt?: string\n submitLabel?: string\n submitElementId?: string\n options: InteractiveOption[]\n}\n\nexport interface StepAction {\n /** `data-aod-action` verb (e.g. \"approve\", \"skip\"). */\n action: string\n label: string\n value?: string\n}\n\nexport interface StepItem {\n /** Stable `data-aod-id` echoed as `elementId`. */\n elementId: string\n label: string\n sourceLine?: number\n actions: StepAction[]\n}\n\n/** An ordered list of plan steps, each with its own inline action buttons. */\nexport interface StepsBlock {\n kind: \"steps\"\n steps: StepItem[]\n}\n\nexport type InteractiveBlock = ChooseOneBlock | MultiSelectBlock | StepsBlock\n\nexport interface InteractivePlanModel {\n /** Optional prose rendered above the interactive blocks (same escaping as static). */\n markdown?: string\n blocks: InteractiveBlock[]\n}\n\n/** Build a ` data-x=\"y\"` attribute string, dropping undefined values, all escaped. */\nfunction dataAttrs(pairs: Array<[string, string | undefined]>): string {\n let out = \"\"\n for (const [name, value] of pairs) {\n if (value === undefined) continue\n out += ` ${name}=\"${escapeHtml(value)}\"`\n }\n return out\n}\n\nfunction renderChooseOne(block: ChooseOneBlock): string {\n const action = block.action && block.action.trim() ? block.action : \"choose\"\n const prompt = block.prompt ? `<p class=\"aod-prompt\">${escapeHtml(block.prompt)}</p>` : \"\"\n const options = block.options\n .filter((o) => o.elementId.trim() !== \"\")\n .map((o) => {\n const attrs = dataAttrs([\n [\"data-aod-action\", action],\n [\"data-aod-group\", block.group],\n [\"data-aod-id\", o.elementId],\n [\"data-aod-value\", o.value],\n ])\n return `<button type=\"button\" class=\"aod-action\"${attrs}>${escapeHtml(o.label)}</button>`\n })\n .join(\"\\n\")\n return `<div class=\"aod-group aod-choose-one\"${dataAttrs([[\"data-aod-group\", block.group]])}>\n${prompt}<div class=\"aod-options\">\n${options}\n</div>\n</div>`\n}\n\nfunction renderMultiSelect(block: MultiSelectBlock): string {\n const prompt = block.prompt ? `<p class=\"aod-prompt\">${escapeHtml(block.prompt)}</p>` : \"\"\n const options = block.options\n .filter((o) => o.elementId.trim() !== \"\")\n .map((o) => {\n const attrs = dataAttrs([\n [\"data-aod-action\", \"check\"],\n [\"data-aod-group\", block.group],\n [\"data-aod-id\", o.elementId],\n [\"data-aod-value\", o.value],\n ])\n return `<label class=\"aod-check\"><input type=\"checkbox\"${attrs}> <span>${escapeHtml(o.label)}</span></label>`\n })\n .join(\"\\n\")\n const submitId = block.submitElementId && block.submitElementId.trim()\n ? block.submitElementId\n : `${block.group}-submit`\n const submitAttrs = dataAttrs([\n [\"data-aod-action\", \"submit\"],\n [\"data-aod-group\", block.group],\n [\"data-aod-id\", submitId],\n ])\n const submitLabel = block.submitLabel && block.submitLabel.trim() ? block.submitLabel : \"Submit\"\n return `<div class=\"aod-group aod-multi-select\"${dataAttrs([[\"data-aod-group\", block.group]])}>\n${prompt}<div class=\"aod-options\">\n${options}\n</div>\n<button type=\"button\" class=\"aod-submit\"${submitAttrs}>${escapeHtml(submitLabel)}</button>\n</div>`\n}\n\nfunction renderSteps(block: StepsBlock): string {\n const items = block.steps\n .filter((s) => s.elementId.trim() !== \"\")\n .map((s) => {\n const liAttrs = dataAttrs([\n [\"data-aod-id\", s.elementId],\n [\"data-source-line\", s.sourceLine === undefined ? undefined : String(s.sourceLine)],\n ])\n const actions = s.actions\n .map((a) => {\n const attrs = dataAttrs([\n [\"data-aod-action\", a.action],\n [\"data-aod-id\", s.elementId],\n [\"data-aod-value\", a.value],\n ])\n return `<button type=\"button\" class=\"aod-action\"${attrs}>${escapeHtml(a.label)}</button>`\n })\n .join(\"\")\n return `<li class=\"aod-step\"${liAttrs}><span class=\"aod-step-label\">${escapeHtml(s.label)}</span><span class=\"aod-step-actions\">${actions}</span></li>`\n })\n .join(\"\\n\")\n return `<ol class=\"aod-steps\">\n${items}\n</ol>`\n}\n\nfunction renderInteractiveBlock(block: InteractiveBlock): string {\n switch (block.kind) {\n case \"choose-one\":\n return renderChooseOne(block)\n case \"multi-select\":\n return renderMultiSelect(block)\n case \"steps\":\n return renderSteps(block)\n default:\n return \"\"\n }\n}\n\n/**\n * Render a typed interactive plan model into a self-contained HTML document that\n * carries the declarative `data-aod-*` action vocabulary the ai-or-die panel SDK\n * wires (contract v2.2 §4). Optional prose (`markdown`) is rendered with the SAME\n * escaping + dangerous-URL neutralization as the static path; all control\n * attributes and labels are HTML-escaped, so no author string can break out of an\n * attribute or inject live markup.\n */\nexport function renderInteractivePlanHtml(model: InteractivePlanModel, title = \"Plan\"): string {\n const prose = model.markdown && model.markdown.trim() ? renderMarkdownBody(model.markdown) : \"\"\n const blocks = model.blocks.map(renderInteractiveBlock).filter((h) => h.trim() !== \"\").join(\"\\n\")\n const body = [prose, blocks].filter((s) => s.trim() !== \"\").join(\"\\n\")\n return wrapArtifactDocument(body || `<pre>${escapeHtml(JSON.stringify(model))}</pre>`, title, INTERACTIVE_STYLE)\n}\n","/**\n * The internal `internal-artifact-open` subcommand: invoked by a spawned Claude\n * Code session's `PostToolUse` hook (matcher `ExitPlanMode`), registered into the\n * mirrored settings.json by the launcher when running inside an ai-or-die tab\n * (`AIORDIE_SESSION_ID` set). It auto-opens the just-finalized plan in the\n * ai-or-die Artifact review panel so the human reviews it WITHOUT the model\n * having to call artifact_open itself.\n *\n * Auth: AIORDIE_TOKEN is stripped from the child env, so the token can't come via\n * env or argv (argv leaks to `ps`). The launcher writes a mode-600\n * `.aiordie-artifact.json` into CLAUDE_CONFIG_DIR; this hook reads it.\n *\n * Plan: the EXACT plan for THIS session comes from the ExitPlanMode payload —\n * `tool_input.planFilePath` (per-session; never the globally-newest file in the\n * shared plans dir) with `tool_input.plan` as the markdown fallback. The plan\n * markdown is rendered to a self-contained styled HTML file (HTML is the\n * canonical artifact, lavish's model — formatted + annotatable, not raw md), and\n * THAT `.html` is opened.\n *\n * Side-effect only: never writes stdout, never throws, always exits 0. Skips\n * subagent payloads (agent_id/agent_type) like the other hooks.\n */\n\nimport { defineCommand } from \"citty\"\n\nimport { readFileSync, realpathSync, writeFileSync } from \"node:fs\"\nimport { homedir } from \"node:os\"\nimport path from \"node:path\"\n\nimport { ArtifactClient } from \"./lib/artifact/client\"\nimport { renderPlanHtml } from \"./lib/artifact/plan-html\"\nimport { isSubagentContext } from \"./lib/orchestration/stop-gate-policy\"\n\ninterface ArtifactCreds {\n baseUrl: string\n token: string\n sessionId: string\n insecureTLS: boolean\n}\n\ninterface ExitPlanPayload {\n planFilePath?: string\n planMarkdown?: string\n}\n\nfunction readStdin(): string {\n try {\n if (process.stdin.isTTY) return \"\"\n return readFileSync(0, \"utf8\")\n } catch {\n return \"\"\n }\n}\n\nfunction credsPath(): string {\n const dir = process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), \".claude\")\n return path.join(dir, \".aiordie-artifact.json\")\n}\n\nfunction isSubagent(payload: unknown): boolean {\n try {\n return isSubagentContext(payload as Parameters<typeof isSubagentContext>[0])\n } catch {\n return false\n }\n}\n\n/** Pull the per-session plan path + markdown content out of the hook payload. */\nexport function parseExitPlanPayload(raw: string): ExitPlanPayload {\n try {\n const obj = JSON.parse(raw) as { tool_input?: { planFilePath?: unknown; plan?: unknown } }\n const ti = obj.tool_input ?? {}\n const planFilePath = typeof ti.planFilePath === \"string\" && ti.planFilePath.trim() ? ti.planFilePath : undefined\n const planMarkdown = typeof ti.plan === \"string\" && ti.plan.trim() ? ti.plan : undefined\n return { planFilePath, planMarkdown }\n } catch {\n return {}\n }\n}\n\nfunction readCreds(): ArtifactCreds | undefined {\n try {\n const parsed = JSON.parse(readFileSync(credsPath(), \"utf8\")) as Partial<ArtifactCreds>\n if (!parsed.baseUrl || !parsed.token || !parsed.sessionId) return undefined\n return {\n baseUrl: parsed.baseUrl,\n token: parsed.token,\n sessionId: parsed.sessionId,\n insecureTLS: parsed.insecureTLS === true,\n }\n } catch {\n return undefined\n }\n}\n\n/**\n * Resolve the plan markdown + a title, preferring the file at `planFilePath`\n * (the source of truth, may be newer than the payload's snapshot), falling back\n * to the inline `tool_input.plan` content.\n */\nfunction resolvePlanMarkdown(p: ExitPlanPayload): { markdown: string; title: string; nearPath?: string } | undefined {\n if (p.planFilePath) {\n try {\n const md = readFileSync(p.planFilePath, \"utf8\")\n if (md.trim()) return { markdown: md, title: path.basename(p.planFilePath, path.extname(p.planFilePath)), nearPath: p.planFilePath }\n } catch {\n /* fall through to inline content */\n }\n }\n if (p.planMarkdown) return { markdown: p.planMarkdown, title: \"Plan\" }\n return undefined\n}\n\n/**\n * Write the rendered HTML next to the plan (an allowed root) when we have a real\n * path, else into the plans dir under CLAUDE_CONFIG_DIR. Returns the realpath of\n * the written `.html`, or undefined on failure.\n */\nfunction writePlanHtml(html: string, nearPath: string | undefined): string | undefined {\n let target: string\n if (nearPath) {\n // `<plan>.md` -> `<plan>.aiordie.html` (or append when there is no .md ext).\n const base = nearPath.replace(/\\.(md|markdown)$/i, \"\")\n target = `${base}.aiordie.html`\n } else {\n const dir = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(homedir(), \".claude\"), \"plans\")\n target = path.join(dir, `aiordie-plan-${process.pid}.html`)\n }\n try {\n writeFileSync(target, html, \"utf8\")\n try {\n return realpathSync.native(target)\n } catch {\n return target\n }\n } catch {\n return undefined\n }\n}\n\nexport const internalArtifactOpen = defineCommand({\n meta: { name: \"internal-artifact-open\", description: \"Open the finalized plan in the ai-or-die panel\" },\n async run() {\n try {\n const raw = readStdin()\n let payload: unknown\n try {\n payload = JSON.parse(raw)\n } catch {\n payload = undefined\n }\n if (isSubagent(payload)) return\n const creds = readCreds()\n if (!creds) return\n const plan = resolvePlanMarkdown(parseExitPlanPayload(raw))\n if (!plan) return\n const htmlPath = writePlanHtml(renderPlanHtml(plan.markdown, plan.title), plan.nearPath)\n if (!htmlPath) return\n const client = new ArtifactClient(creds)\n await client.open(htmlPath)\n } catch {\n /* side-effect only: never block, never throw */\n }\n },\n})\n"],"mappings":"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"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { a as dispatcherAgentName, t as ALL_WORKER_DISPATCH_MODES } from "./worker-dispatch-Bj1uYyG9.js";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
//#region src/lib/first-mate/operator-shaping.ts
|
|
5
|
+
/**
|
|
6
|
+
* Operator-mode steering for github-router `--agents` sessions.
|
|
7
|
+
*
|
|
8
|
+
* In first-mate/operator mode the spawned Claude is the cloud-agent operator, not
|
|
9
|
+
* the product implementer. Product implementation is steered to GitHub cloud
|
|
10
|
+
* agents by first-mate guidance and the gh-first-mate skill. This module no
|
|
11
|
+
* longer hard-blocks local file writes or shell commands: Edit/Write/NotebookEdit
|
|
12
|
+
* and Bash remain available as escape hatches when needed.
|
|
13
|
+
*
|
|
14
|
+
* The only enforced main-operator boundary is that local worker/orchestrate MCP
|
|
15
|
+
* tools stay subagent-only. The main operator should invoke the worker-* Agent
|
|
16
|
+
* subagents or delegate to GitHub cloud agents rather than calling
|
|
17
|
+
* `mcp__workers__*` / `mcp__orchestrate__*` directly.
|
|
18
|
+
*/
|
|
19
|
+
/** MCP prefixes denied to the main operator; these remain subagent-only. */
|
|
20
|
+
const OPERATOR_DENIED_MCP_PREFIXES = ["mcp__workers__", "mcp__orchestrate__"];
|
|
21
|
+
/**
|
|
22
|
+
* Decide whether a tool call must be blocked for the main operator. Pure — used
|
|
23
|
+
* both by the PreToolUse hook handler and by config-assertion tests. When
|
|
24
|
+
* operator mode is off, nothing is blocked (normal sessions unaffected).
|
|
25
|
+
*/
|
|
26
|
+
function parseWorkerMode(toolName) {
|
|
27
|
+
if (!toolName.startsWith("mcp__workers__")) return null;
|
|
28
|
+
const mode = toolName.slice(14);
|
|
29
|
+
return ALL_WORKER_DISPATCH_MODES.includes(mode) ? mode : null;
|
|
30
|
+
}
|
|
31
|
+
function isMatchingWorkerDispatcher(toolName, input) {
|
|
32
|
+
const mode = parseWorkerMode(toolName);
|
|
33
|
+
if (mode === null) return false;
|
|
34
|
+
return input?.agent_type === dispatcherAgentName(mode);
|
|
35
|
+
}
|
|
36
|
+
function shouldDenyOperatorTool(toolName, operatorMode, input) {
|
|
37
|
+
if (!operatorMode) return false;
|
|
38
|
+
if (isMatchingWorkerDispatcher(toolName, input)) return false;
|
|
39
|
+
return OPERATOR_DENIED_MCP_PREFIXES.some((prefix) => toolName.startsWith(prefix));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* M4 — fail-CLOSED. If operator/`--agents` mode is active but the reduced
|
|
43
|
+
* worker/orchestrate guard could NOT be installed (e.g. settings.json unwritable),
|
|
44
|
+
* the operator session must NOT start with direct main-thread worker/orchestrate
|
|
45
|
+
* MCP calls available. Non-agents sessions are never affected.
|
|
46
|
+
*/
|
|
47
|
+
function assertShapingInstalled(agentsMode, injectionSucceeded) {
|
|
48
|
+
if (agentsMode && !injectionSucceeded) throw new Error("cloud-agent operator mode requires the worker/orchestrate PreToolUse hook, but it could not be installed — refusing to start an unguarded operator session.");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The PreToolUse hook decision for a given tool name in operator mode.
|
|
52
|
+
*
|
|
53
|
+
* Bash and file-authoring tools are always allowed. Only direct main-operator
|
|
54
|
+
* calls to local worker/orchestrate MCP tools are blocked so those backends stay
|
|
55
|
+
* reachable through the worker-* Agent subagents rather than the lead context.
|
|
56
|
+
*/
|
|
57
|
+
function operatorPreToolUse(toolName, operatorMode, input) {
|
|
58
|
+
if (!operatorMode) return { block: false };
|
|
59
|
+
if (shouldDenyOperatorTool(toolName, operatorMode, input)) return {
|
|
60
|
+
block: true,
|
|
61
|
+
reason: `${toolName} is subagent-only in cloud-agent operator mode — use the worker-* Agent subagents or delegate implementation to a GitHub cloud agent via the first-mate MCP instead of calling local worker/orchestrate MCP tools from the main operator.`
|
|
62
|
+
};
|
|
63
|
+
return { block: false };
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/internal-first-mate-guard.ts
|
|
67
|
+
/**
|
|
68
|
+
* The internal `internal-first-mate-guard` subcommand: a PreToolUse hook that
|
|
69
|
+
* keeps local worker/orchestrate MCP tools subagent-only in cloud-agent OPERATOR
|
|
70
|
+
* mode. It is injected into the spawned Claude session's settings ONLY in
|
|
71
|
+
* `--agents`/operator mode, with a matcher scoped to those MCP tool prefixes.
|
|
72
|
+
*
|
|
73
|
+
* Reads the PreToolUse payload from stdin (`{tool_name, tool_input, ...}`) and,
|
|
74
|
+
* if the tool is denied in operator mode, blocks via exit code 2 with a reason
|
|
75
|
+
* on stderr (the repo's hook convention). Bash and file-authoring tools are not
|
|
76
|
+
* matched by this guard and are not blocked.
|
|
77
|
+
*/
|
|
78
|
+
function readStdinSync() {
|
|
79
|
+
try {
|
|
80
|
+
return readFileSync(0, "utf8");
|
|
81
|
+
} catch {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const internalFirstMateGuard = defineCommand({
|
|
86
|
+
meta: {
|
|
87
|
+
name: "internal-first-mate-guard",
|
|
88
|
+
description: "PreToolUse guard enforcing cloud-agent operator capability shaping."
|
|
89
|
+
},
|
|
90
|
+
run() {
|
|
91
|
+
let toolName = "";
|
|
92
|
+
let hookInput;
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
const payload = JSON.parse(readStdinSync());
|
|
96
|
+
if (typeof payload.tool_name === "string") toolName = payload.tool_name;
|
|
97
|
+
if (typeof payload.agent_type === "string") hookInput = { agent_type: payload.agent_type };
|
|
98
|
+
parsed = true;
|
|
99
|
+
} catch {
|
|
100
|
+
parsed = false;
|
|
101
|
+
}
|
|
102
|
+
if (toolName.length === 0) {
|
|
103
|
+
if (!parsed) {
|
|
104
|
+
process.stderr.write("operator guard: unparseable PreToolUse payload — blocking (fail-closed)");
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
const decision = operatorPreToolUse(toolName, true, hookInput);
|
|
110
|
+
if (decision.block) {
|
|
111
|
+
process.stderr.write(decision.reason ?? `${toolName} is disabled in operator mode`);
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
/** The regex matcher scoping the guard hook to exactly the denied tools. */
|
|
118
|
+
const FIRST_MATE_GUARD_MATCHER = "mcp__workers__.*|mcp__orchestrate__.*";
|
|
119
|
+
/** Build the hook command string that runs this subcommand. */
|
|
120
|
+
function buildFirstMateGuardHookCommand(execPath, entry) {
|
|
121
|
+
return `${JSON.stringify(execPath)} ${JSON.stringify(entry)} internal-first-mate-guard`;
|
|
122
|
+
}
|
|
123
|
+
//#endregion
|
|
124
|
+
export { assertShapingInstalled as i, buildFirstMateGuardHookCommand as n, internalFirstMateGuard as r, FIRST_MATE_GUARD_MATCHER as t };
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=internal-first-mate-guard-La0tIjTU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal-first-mate-guard-La0tIjTU.js","names":[],"sources":["../src/lib/first-mate/operator-shaping.ts","../src/internal-first-mate-guard.ts"],"sourcesContent":["/**\n * Operator-mode steering for github-router `--agents` sessions.\n *\n * In first-mate/operator mode the spawned Claude is the cloud-agent operator, not\n * the product implementer. Product implementation is steered to GitHub cloud\n * agents by first-mate guidance and the gh-first-mate skill. This module no\n * longer hard-blocks local file writes or shell commands: Edit/Write/NotebookEdit\n * and Bash remain available as escape hatches when needed.\n *\n * The only enforced main-operator boundary is that local worker/orchestrate MCP\n * tools stay subagent-only. The main operator should invoke the worker-* Agent\n * subagents or delegate to GitHub cloud agents rather than calling\n * `mcp__workers__*` / `mcp__orchestrate__*` directly.\n */\n\nimport {\n ALL_WORKER_DISPATCH_MODES,\n dispatcherAgentName,\n type WorkerDispatchMode,\n} from \"../worker-dispatch\"\n\n/** Tools denied to the main operator by exact name. */\nexport const OPERATOR_DENIED_TOOLS = [] as const\n\n/** MCP prefixes denied to the main operator; these remain subagent-only. */\nexport const OPERATOR_DENIED_MCP_PREFIXES = [\"mcp__workers__\", \"mcp__orchestrate__\"] as const\n\n/** Tools explicitly preserved (documented; used by assertion tests). */\nexport const OPERATOR_KEPT_TOOLS = [\n \"Agent\",\n \"Task\",\n \"Edit\",\n \"Write\",\n \"NotebookEdit\",\n \"Bash\",\n \"Read\",\n \"Grep\",\n \"Glob\",\n \"mcp__first-mate__\",\n] as const\n\n/**\n * Least-privilege note for what the cloud agents themselves may touch — the\n * operator scopes missions to specific repos and forbids irreversible actions\n * without human approval (see first-mate house rules). This constant documents\n * the intent for the launcher/README; enforcement of the cloud-agent scope is\n * the GitHub agent-token scope (repo/workflow) + the mission house_rules.\n */\nexport const CLOUD_AGENT_SCOPE_NOTE =\n \"Cloud agents operate only within mission-listed repos; merges, releases, \" +\n \"Pages, and deletes require explicit human approval (first-mate merge gate).\"\n\nexport interface OperatorToolInput extends Record<string, unknown> {\n agent_type?: unknown\n}\n\nexport interface PreToolUseDecision {\n block: boolean\n reason?: string\n}\n\n/**\n * Decide whether a tool call must be blocked for the main operator. Pure — used\n * both by the PreToolUse hook handler and by config-assertion tests. When\n * operator mode is off, nothing is blocked (normal sessions unaffected).\n */\nfunction parseWorkerMode(toolName: string): WorkerDispatchMode | null {\n const workersPrefix = \"mcp__workers__\"\n if (!toolName.startsWith(workersPrefix)) return null\n const mode = toolName.slice(workersPrefix.length)\n return (ALL_WORKER_DISPATCH_MODES as ReadonlyArray<string>).includes(mode) ? (mode as WorkerDispatchMode) : null\n}\n\nfunction isMatchingWorkerDispatcher(toolName: string, input?: OperatorToolInput): boolean {\n const mode = parseWorkerMode(toolName)\n if (mode === null) return false\n return input?.agent_type === dispatcherAgentName(mode)\n}\n\nexport function shouldDenyOperatorTool(\n toolName: string,\n operatorMode: boolean,\n input?: OperatorToolInput,\n): boolean {\n if (!operatorMode) return false\n if (isMatchingWorkerDispatcher(toolName, input)) return false\n return OPERATOR_DENIED_MCP_PREFIXES.some((prefix) => toolName.startsWith(prefix))\n}\n\n/**\n * M4 — fail-CLOSED. If operator/`--agents` mode is active but the reduced\n * worker/orchestrate guard could NOT be installed (e.g. settings.json unwritable),\n * the operator session must NOT start with direct main-thread worker/orchestrate\n * MCP calls available. Non-agents sessions are never affected.\n */\nexport function assertShapingInstalled(agentsMode: boolean, injectionSucceeded: boolean): void {\n if (agentsMode && !injectionSucceeded) {\n throw new Error(\n \"cloud-agent operator mode requires the worker/orchestrate PreToolUse hook, \" +\n \"but it could not be installed — refusing to start an unguarded operator session.\",\n )\n }\n}\n\n/**\n * The PreToolUse hook decision for a given tool name in operator mode.\n *\n * Bash and file-authoring tools are always allowed. Only direct main-operator\n * calls to local worker/orchestrate MCP tools are blocked so those backends stay\n * reachable through the worker-* Agent subagents rather than the lead context.\n */\nexport function operatorPreToolUse(\n toolName: string,\n operatorMode: boolean,\n input?: OperatorToolInput,\n): PreToolUseDecision {\n if (!operatorMode) return { block: false }\n if (shouldDenyOperatorTool(toolName, operatorMode, input)) {\n return {\n block: true,\n reason: `${toolName} is subagent-only in cloud-agent operator mode — use the worker-* Agent subagents or delegate implementation to a GitHub cloud agent via the first-mate MCP instead of calling local worker/orchestrate MCP tools from the main operator.`,\n }\n }\n return { block: false }\n}\n","/**\n * The internal `internal-first-mate-guard` subcommand: a PreToolUse hook that\n * keeps local worker/orchestrate MCP tools subagent-only in cloud-agent OPERATOR\n * mode. It is injected into the spawned Claude session's settings ONLY in\n * `--agents`/operator mode, with a matcher scoped to those MCP tool prefixes.\n *\n * Reads the PreToolUse payload from stdin (`{tool_name, tool_input, ...}`) and,\n * if the tool is denied in operator mode, blocks via exit code 2 with a reason\n * on stderr (the repo's hook convention). Bash and file-authoring tools are not\n * matched by this guard and are not blocked.\n */\nimport { defineCommand } from \"citty\"\n\nimport { readFileSync } from \"node:fs\"\n\nimport { operatorPreToolUse, type OperatorToolInput } from \"./lib/first-mate/operator-shaping\"\n\nfunction readStdinSync(): string {\n try {\n return readFileSync(0, \"utf8\")\n } catch {\n return \"\"\n }\n}\n\nexport const internalFirstMateGuard = defineCommand({\n meta: {\n name: \"internal-first-mate-guard\",\n description: \"PreToolUse guard enforcing cloud-agent operator capability shaping.\",\n },\n run() {\n let toolName = \"\"\n let hookInput: OperatorToolInput | undefined\n let parsed: boolean\n try {\n const payload = JSON.parse(readStdinSync()) as {\n tool_name?: unknown\n tool_input?: unknown\n agent_type?: unknown\n }\n if (typeof payload.tool_name === \"string\") toolName = payload.tool_name\n if (typeof payload.agent_type === \"string\") hookInput = { agent_type: payload.agent_type }\n parsed = true\n } catch {\n parsed = false // fail-closed below for a named-but-unparseable payload\n }\n if (toolName.length === 0) {\n // Nothing to guard (empty/absent tool name) → allow. But if the payload\n // did not even parse we cannot know it was empty; the matcher only routes\n // the denied tools here, so a parse failure means one of THOSE arrived\n // unreadable → fail-closed.\n if (!parsed) {\n process.stderr.write(\"operator guard: unparseable PreToolUse payload — blocking (fail-closed)\")\n process.exit(2)\n }\n process.exit(0)\n }\n // This hook is only injected in operator mode, so operatorMode = true.\n const decision = operatorPreToolUse(toolName, true, hookInput)\n if (decision.block) {\n process.stderr.write(decision.reason ?? `${toolName} is disabled in operator mode`)\n process.exit(2) // exit 2 blocks the tool call (Claude Code hook convention)\n }\n process.exit(0)\n },\n})\n\n/** The regex matcher scoping the guard hook to exactly the denied tools. */\nexport const FIRST_MATE_GUARD_MATCHER = \"mcp__workers__.*|mcp__orchestrate__.*\"\n\n/** Build the hook command string that runs this subcommand. */\nexport function buildFirstMateGuardHookCommand(execPath: string, entry: string): string {\n return `${JSON.stringify(execPath)} ${JSON.stringify(entry)} internal-first-mate-guard`\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAyBA,MAAa,+BAA+B,CAAC,kBAAkB,oBAAoB;;;;;;AAyCnF,SAAS,gBAAgB,UAA6C;CAEpE,IAAI,CAAC,SAAS,WAAW,gBAAa,GAAG,OAAO;CAChD,MAAM,OAAO,SAAS,MAAM,EAAoB;CAChD,OAAQ,0BAAoD,SAAS,IAAI,IAAK,OAA8B;AAC9G;AAEA,SAAS,2BAA2B,UAAkB,OAAoC;CACxF,MAAM,OAAO,gBAAgB,QAAQ;CACrC,IAAI,SAAS,MAAM,OAAO;CAC1B,OAAO,OAAO,eAAe,oBAAoB,IAAI;AACvD;AAEA,SAAgB,uBACd,UACA,cACA,OACS;CACT,IAAI,CAAC,cAAc,OAAO;CAC1B,IAAI,2BAA2B,UAAU,KAAK,GAAG,OAAO;CACxD,OAAO,6BAA6B,MAAM,WAAW,SAAS,WAAW,MAAM,CAAC;AAClF;;;;;;;AAQA,SAAgB,uBAAuB,YAAqB,oBAAmC;CAC7F,IAAI,cAAc,CAAC,oBACjB,MAAM,IAAI,MACR,6JAEF;AAEJ;;;;;;;;AASA,SAAgB,mBACd,UACA,cACA,OACoB;CACpB,IAAI,CAAC,cAAc,OAAO,EAAE,OAAO,MAAM;CACzC,IAAI,uBAAuB,UAAU,cAAc,KAAK,GACtD,OAAO;EACL,OAAO;EACP,QAAQ,GAAG,SAAS;CACtB;CAEF,OAAO,EAAE,OAAO,MAAM;AACxB;;;;;;;;;;;;;;AC3GA,SAAS,gBAAwB;CAC/B,IAAI;EACF,OAAO,aAAa,GAAG,MAAM;CAC/B,QAAQ;EACN,OAAO;CACT;AACF;AAEA,MAAa,yBAAyB,cAAc;CAClD,MAAM;EACJ,MAAM;EACN,aAAa;CACf;CACA,MAAM;EACJ,IAAI,WAAW;EACf,IAAI;EACJ,IAAI;EACJ,IAAI;GACF,MAAM,UAAU,KAAK,MAAM,cAAc,CAAC;GAK1C,IAAI,OAAO,QAAQ,cAAc,UAAU,WAAW,QAAQ;GAC9D,IAAI,OAAO,QAAQ,eAAe,UAAU,YAAY,EAAE,YAAY,QAAQ,WAAW;GACzF,SAAS;EACX,QAAQ;GACN,SAAS;EACX;EACA,IAAI,SAAS,WAAW,GAAG;GAKzB,IAAI,CAAC,QAAQ;IACX,QAAQ,OAAO,MAAM,yEAAyE;IAC9F,QAAQ,KAAK,CAAC;GAChB;GACA,QAAQ,KAAK,CAAC;EAChB;EAEA,MAAM,WAAW,mBAAmB,UAAU,MAAM,SAAS;EAC7D,IAAI,SAAS,OAAO;GAClB,QAAQ,OAAO,MAAM,SAAS,UAAU,GAAG,SAAS,8BAA8B;GAClF,QAAQ,KAAK,CAAC;EAChB;EACA,QAAQ,KAAK,CAAC;CAChB;AACF,CAAC;;AAGD,MAAa,2BAA2B;;AAGxC,SAAgB,+BAA+B,UAAkB,OAAuB;CACtF,OAAO,GAAG,KAAK,UAAU,QAAQ,EAAE,GAAG,KAAK,UAAU,KAAK,EAAE;AAC9D"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { n as fileFindingsStore, u as stopReviewStateDir } from "./stop-gate-policy-CHwWbuF_.js";
|
|
2
|
+
import { r as hookMcpRuntimeFromEnv } from "./hook-mcp-client-DBvm9608.js";
|
|
3
|
+
import { a as runPlanReview, n as decidePlanReviewHook, r as filePlanReviewDebounce } from "./plan-review-hook-C8x0uKPf.js";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
import { mkdirSync, promises, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
//#region src/internal-plan-review.ts
|
|
10
|
+
/**
|
|
11
|
+
* The internal `internal-plan-review` subcommand: a PostToolUse(ExitPlanMode)
|
|
12
|
+
* hook that spawns a bounded advisory critic review when the main agent finalizes
|
|
13
|
+
* a substantive plan.
|
|
14
|
+
*
|
|
15
|
+
* It never blocks ExitPlanMode: the hook process exits 0 quickly after deciding
|
|
16
|
+
* whether to spawn a detached reviewer. The detached child calls a cross-lab plan
|
|
17
|
+
* critic and writes material findings to the SAME per-session findings store used
|
|
18
|
+
* by the Stop review, so the next UserPromptSubmit surfaces them with the existing
|
|
19
|
+
* non-authoritative findings path.
|
|
20
|
+
*/
|
|
21
|
+
function readStdin() {
|
|
22
|
+
try {
|
|
23
|
+
if (process.stdin.isTTY) return "";
|
|
24
|
+
return readFileSync(0, "utf8");
|
|
25
|
+
} catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function readDetachedPayload() {
|
|
30
|
+
const payloadPath = (process.env.GH_ROUTER_PLAN_REVIEW_PAYLOAD ?? "").trim();
|
|
31
|
+
if (payloadPath.length === 0) return void 0;
|
|
32
|
+
try {
|
|
33
|
+
const raw = await promises.readFile(payloadPath, "utf8");
|
|
34
|
+
await promises.unlink(payloadPath).catch(() => {});
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (!parsed || typeof parsed !== "object") return void 0;
|
|
37
|
+
const p = parsed;
|
|
38
|
+
if (!p.sessionId || !p.cwd || !p.plan || !p.planHash) return void 0;
|
|
39
|
+
return {
|
|
40
|
+
sessionId: p.sessionId,
|
|
41
|
+
cwd: p.cwd,
|
|
42
|
+
plan: p.plan,
|
|
43
|
+
planHash: p.planHash
|
|
44
|
+
};
|
|
45
|
+
} catch {
|
|
46
|
+
await promises.unlink(payloadPath).catch(() => {});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function spawnDetachedPlanReview(ctx) {
|
|
51
|
+
let payloadPath;
|
|
52
|
+
try {
|
|
53
|
+
const dir = stopReviewStateDir();
|
|
54
|
+
mkdirSync(dir, { recursive: true });
|
|
55
|
+
payloadPath = path.join(dir, `plan-review-${process.pid}-${randomBytes(4).toString("hex")}.json`);
|
|
56
|
+
writeFileSync(payloadPath, JSON.stringify(ctx), { mode: 384 });
|
|
57
|
+
const scriptArgs = process.argv[1] && process.argv[1] !== process.execPath ? [process.argv[1]] : [];
|
|
58
|
+
const child = spawn(process.execPath, [...scriptArgs, "internal-plan-review"], {
|
|
59
|
+
detached: true,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
stdio: "ignore",
|
|
62
|
+
env: {
|
|
63
|
+
...process.env,
|
|
64
|
+
GH_ROUTER_PLAN_REVIEW_PAYLOAD: payloadPath
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const orphan = payloadPath;
|
|
68
|
+
child.on("error", () => {
|
|
69
|
+
if (orphan) try {
|
|
70
|
+
unlinkSync(orphan);
|
|
71
|
+
} catch {}
|
|
72
|
+
});
|
|
73
|
+
child.unref();
|
|
74
|
+
} catch {
|
|
75
|
+
if (payloadPath) try {
|
|
76
|
+
unlinkSync(payloadPath);
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const internalPlanReview = defineCommand({
|
|
81
|
+
meta: {
|
|
82
|
+
name: "internal-plan-review",
|
|
83
|
+
description: "Internal: advisory ExitPlanMode plan reviewer. Spawns a bounded detached critic review for substantive plans and writes findings for the next prompt. Always exit 0."
|
|
84
|
+
},
|
|
85
|
+
async run() {
|
|
86
|
+
try {
|
|
87
|
+
const runtime = hookMcpRuntimeFromEnv();
|
|
88
|
+
if (!runtime) return;
|
|
89
|
+
const detached = await readDetachedPayload();
|
|
90
|
+
if (detached) {
|
|
91
|
+
await runPlanReview({
|
|
92
|
+
runtime,
|
|
93
|
+
sessionId: detached.sessionId,
|
|
94
|
+
cwd: detached.cwd,
|
|
95
|
+
plan: detached.plan,
|
|
96
|
+
findingsStore: fileFindingsStore(stopReviewStateDir())
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const decision = await decidePlanReviewHook({
|
|
101
|
+
stdin: readStdin(),
|
|
102
|
+
runtimeAvailable: true,
|
|
103
|
+
debounce: filePlanReviewDebounce(stopReviewStateDir()),
|
|
104
|
+
fallbackCwd: process.cwd()
|
|
105
|
+
});
|
|
106
|
+
if (decision.kind === "spawn") spawnDetachedPlanReview(decision);
|
|
107
|
+
} catch {}
|
|
108
|
+
process.exitCode = 0;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
//#endregion
|
|
112
|
+
export { internalPlanReview };
|
|
113
|
+
|
|
114
|
+
//# sourceMappingURL=internal-plan-review-ipnT3bhj.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal-plan-review-ipnT3bhj.js","names":["fs"],"sources":["../src/internal-plan-review.ts"],"sourcesContent":["/**\n * The internal `internal-plan-review` subcommand: a PostToolUse(ExitPlanMode)\n * hook that spawns a bounded advisory critic review when the main agent finalizes\n * a substantive plan.\n *\n * It never blocks ExitPlanMode: the hook process exits 0 quickly after deciding\n * whether to spawn a detached reviewer. The detached child calls a cross-lab plan\n * critic and writes material findings to the SAME per-session findings store used\n * by the Stop review, so the next UserPromptSubmit surfaces them with the existing\n * non-authoritative findings path.\n */\n\nimport { defineCommand } from \"citty\"\n\nimport { spawn } from \"node:child_process\"\nimport { randomBytes } from \"node:crypto\"\nimport { mkdirSync, readFileSync, unlinkSync, writeFileSync } from \"node:fs\"\nimport { promises as fs } from \"node:fs\"\nimport path from \"node:path\"\n\nimport { hookMcpRuntimeFromEnv } from \"./lib/orchestration/hook-mcp-client\"\nimport {\n decidePlanReviewHook,\n filePlanReviewDebounce,\n runPlanReview,\n type PlanReviewSpawnContext,\n} from \"./lib/orchestration/plan-review-hook\"\nimport { fileFindingsStore, stopReviewStateDir } from \"./lib/orchestration/stop-gate-policy\"\n\nfunction readStdin(): string {\n try {\n if (process.stdin.isTTY) return \"\"\n return readFileSync(0, \"utf8\")\n } catch {\n return \"\"\n }\n}\n\nasync function readDetachedPayload(): Promise<PlanReviewSpawnContext | undefined> {\n const payloadPath = (process.env.GH_ROUTER_PLAN_REVIEW_PAYLOAD ?? \"\").trim()\n if (payloadPath.length === 0) return undefined\n try {\n const raw = await fs.readFile(payloadPath, \"utf8\")\n await fs.unlink(payloadPath).catch(() => {})\n const parsed: unknown = JSON.parse(raw)\n if (!parsed || typeof parsed !== \"object\") return undefined\n const p = parsed as Partial<PlanReviewSpawnContext>\n if (!p.sessionId || !p.cwd || !p.plan || !p.planHash) return undefined\n return { sessionId: p.sessionId, cwd: p.cwd, plan: p.plan, planHash: p.planHash }\n } catch {\n await fs.unlink(payloadPath).catch(() => {})\n return undefined\n }\n}\n\nfunction spawnDetachedPlanReview(ctx: PlanReviewSpawnContext): void {\n let payloadPath: string | undefined\n try {\n const dir = stopReviewStateDir()\n mkdirSync(dir, { recursive: true })\n payloadPath = path.join(dir, `plan-review-${process.pid}-${randomBytes(4).toString(\"hex\")}.json`)\n writeFileSync(payloadPath, JSON.stringify(ctx), { mode: 0o600 })\n const scriptArgs = process.argv[1] && process.argv[1] !== process.execPath ? [process.argv[1]] : []\n const child = spawn(process.execPath, [...scriptArgs, \"internal-plan-review\"], {\n detached: true,\n windowsHide: true,\n stdio: \"ignore\",\n env: { ...process.env, GH_ROUTER_PLAN_REVIEW_PAYLOAD: payloadPath },\n })\n const orphan = payloadPath\n child.on(\"error\", () => {\n if (orphan) {\n try {\n unlinkSync(orphan)\n } catch {\n /* best-effort */\n }\n }\n })\n child.unref()\n } catch {\n if (payloadPath) {\n try {\n unlinkSync(payloadPath)\n } catch {\n /* best-effort */\n }\n }\n }\n}\n\nexport const internalPlanReview = defineCommand({\n meta: {\n name: \"internal-plan-review\",\n description:\n \"Internal: advisory ExitPlanMode plan reviewer. Spawns a bounded detached critic review \"\n + \"for substantive plans and writes findings for the next prompt. Always exit 0.\",\n },\n async run() {\n try {\n const runtime = hookMcpRuntimeFromEnv()\n if (!runtime) return\n\n const detached = await readDetachedPayload()\n if (detached) {\n await runPlanReview({\n runtime,\n sessionId: detached.sessionId,\n cwd: detached.cwd,\n plan: detached.plan,\n findingsStore: fileFindingsStore(stopReviewStateDir()),\n })\n return\n }\n\n const decision = await decidePlanReviewHook({\n stdin: readStdin(),\n runtimeAvailable: true,\n debounce: filePlanReviewDebounce(stopReviewStateDir()),\n fallbackCwd: process.cwd(),\n })\n if (decision.kind === \"spawn\") spawnDetachedPlanReview(decision)\n } catch {\n /* side-effect only: never block ExitPlanMode */\n }\n process.exitCode = 0\n },\n})\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA6BA,SAAS,YAAoB;CAC3B,IAAI;EACF,IAAI,QAAQ,MAAM,OAAO,OAAO;EAChC,OAAO,aAAa,GAAG,MAAM;CAC/B,QAAQ;EACN,OAAO;CACT;AACF;AAEA,eAAe,sBAAmE;CAChF,MAAM,eAAe,QAAQ,IAAI,iCAAiC,GAAA,CAAI,KAAK;CAC3E,IAAI,YAAY,WAAW,GAAG,OAAO,KAAA;CACrC,IAAI;EACF,MAAM,MAAM,MAAMA,SAAG,SAAS,aAAa,MAAM;EACjD,MAAMA,SAAG,OAAO,WAAW,CAAC,CAAC,YAAY,CAAC,CAAC;EAC3C,MAAM,SAAkB,KAAK,MAAM,GAAG;EACtC,IAAI,CAAC,UAAU,OAAO,WAAW,UAAU,OAAO,KAAA;EAClD,MAAM,IAAI;EACV,IAAI,CAAC,EAAE,aAAa,CAAC,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,EAAE,UAAU,OAAO,KAAA;EAC7D,OAAO;GAAE,WAAW,EAAE;GAAW,KAAK,EAAE;GAAK,MAAM,EAAE;GAAM,UAAU,EAAE;EAAS;CAClF,QAAQ;EACN,MAAMA,SAAG,OAAO,WAAW,CAAC,CAAC,YAAY,CAAC,CAAC;EAC3C;CACF;AACF;AAEA,SAAS,wBAAwB,KAAmC;CAClE,IAAI;CACJ,IAAI;EACF,MAAM,MAAM,mBAAmB;EAC/B,UAAU,KAAK,EAAE,WAAW,KAAK,CAAC;EAClC,cAAc,KAAK,KAAK,KAAK,eAAe,QAAQ,IAAI,GAAG,YAAY,CAAC,CAAC,CAAC,SAAS,KAAK,EAAE,MAAM;EAChG,cAAc,aAAa,KAAK,UAAU,GAAG,GAAG,EAAE,MAAM,IAAM,CAAC;EAC/D,MAAM,aAAa,QAAQ,KAAK,MAAM,QAAQ,KAAK,OAAO,QAAQ,WAAW,CAAC,QAAQ,KAAK,EAAE,IAAI,CAAC;EAClG,MAAM,QAAQ,MAAM,QAAQ,UAAU,CAAC,GAAG,YAAY,sBAAsB,GAAG;GAC7E,UAAU;GACV,aAAa;GACb,OAAO;GACP,KAAK;IAAE,GAAG,QAAQ;IAAK,+BAA+B;GAAY;EACpE,CAAC;EACD,MAAM,SAAS;EACf,MAAM,GAAG,eAAe;GACtB,IAAI,QACF,IAAI;IACF,WAAW,MAAM;GACnB,QAAQ,CAER;EAEJ,CAAC;EACD,MAAM,MAAM;CACd,QAAQ;EACN,IAAI,aACF,IAAI;GACF,WAAW,WAAW;EACxB,QAAQ,CAER;CAEJ;AACF;AAEA,MAAa,qBAAqB,cAAc;CAC9C,MAAM;EACJ,MAAM;EACN,aACE;CAEJ;CACA,MAAM,MAAM;EACV,IAAI;GACF,MAAM,UAAU,sBAAsB;GACtC,IAAI,CAAC,SAAS;GAEd,MAAM,WAAW,MAAM,oBAAoB;GAC3C,IAAI,UAAU;IACZ,MAAM,cAAc;KAClB;KACA,WAAW,SAAS;KACpB,KAAK,SAAS;KACd,MAAM,SAAS;KACf,eAAe,kBAAkB,mBAAmB,CAAC;IACvD,CAAC;IACD;GACF;GAEA,MAAM,WAAW,MAAM,qBAAqB;IAC1C,OAAO,UAAU;IACjB,kBAAkB;IAClB,UAAU,uBAAuB,mBAAmB,CAAC;IACrD,aAAa,QAAQ,IAAI;GAC3B,CAAC;GACD,IAAI,SAAS,SAAS,SAAS,wBAAwB,QAAQ;EACjE,QAAQ,CAER;EACA,QAAQ,WAAW;CACrB;AACF,CAAC"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { r as parseBoolEnv } from "./exec-DTO2oin0.js";
|
|
2
|
+
import { o as fileBlockBudget } from "./stop-gate-hook-DQc2KV7B.js";
|
|
3
|
+
import { n as fileFindingsStore, r as fileLastPromptStore, u as stopReviewStateDir } from "./stop-gate-policy-CHwWbuF_.js";
|
|
4
|
+
import { n as callMcpTool, r as hookMcpRuntimeFromEnv, t as callInference } from "./hook-mcp-client-DBvm9608.js";
|
|
5
|
+
import { n as decidePromptSubmit, r as decidePromptSubmitV2 } from "./prompt-submit-hook-BrkkLAAB.js";
|
|
6
|
+
import { defineCommand } from "citty";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
//#region src/internal-prompt-submit.ts
|
|
11
|
+
/**
|
|
12
|
+
* The internal `internal-prompt-submit` subcommand: the executable a spawned
|
|
13
|
+
* Claude Code session's `UserPromptSubmit` hook invokes (registered into the
|
|
14
|
+
* mirrored settings.json by the launcher). It serves the front-end of the
|
|
15
|
+
* floor-raising surface on one event:
|
|
16
|
+
* 1. resets the Stop-gate's per-session block budget (making `maxBlocks`
|
|
17
|
+
* per-prompt);
|
|
18
|
+
* 2. stashes the prompt + surfaces the prior turn's advisory review findings;
|
|
19
|
+
* 3. for a non-trivial prompt, injects a GROUNDED, user-derived scope/goal
|
|
20
|
+
* note (one gpt-5.6-sol call over the prompt + a parallel lexical+semantic code
|
|
21
|
+
* search) — or, when the proxy URL/nonce isn't wired or anything errors,
|
|
22
|
+
* falls open to the v1 regex goal.
|
|
23
|
+
*
|
|
24
|
+
* ALWAYS exits 0 (never blocks the prompt): the steer is additive context, and a
|
|
25
|
+
* UserPromptSubmit hook that exit-2'd would refuse the user's prompt. The pure
|
|
26
|
+
* v1 path (`decidePromptSubmit`) is the fail-open fallback; V2
|
|
27
|
+
* (`decidePromptSubmitV2`) layers the grounded enrichment on top via injected IO.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Read the hook payload from stdin SYNCHRONOUSLY (`readFileSync(0)`). An async
|
|
31
|
+
* stdin read leaves an in-flight libuv FS request that, on Windows, races the
|
|
32
|
+
* process teardown and trips a `uv_async_send` assertion; a synchronous read has
|
|
33
|
+
* no such handle. Hooks always receive piped/redirected stdin, so this never
|
|
34
|
+
* blocks (guarded against an interactive TTY, and any error -> "").
|
|
35
|
+
*/
|
|
36
|
+
function readStdin() {
|
|
37
|
+
try {
|
|
38
|
+
if (process.stdin.isTTY) return "";
|
|
39
|
+
return readFileSync(0, "utf8");
|
|
40
|
+
} catch {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Parse the session cwd from the payload — the workspace the grounding search
|
|
45
|
+
* runs in. Falls back to the process cwd. */
|
|
46
|
+
function workspaceFromStdin(stdin) {
|
|
47
|
+
try {
|
|
48
|
+
const p = JSON.parse(stdin);
|
|
49
|
+
if (p && typeof p === "object") {
|
|
50
|
+
const cwd = p.cwd;
|
|
51
|
+
if (typeof cwd === "string" && cwd.length > 0) return cwd;
|
|
52
|
+
}
|
|
53
|
+
} catch {}
|
|
54
|
+
return process.cwd();
|
|
55
|
+
}
|
|
56
|
+
/** Per-call timeout for the grounding search (short — it must not stall the prompt). */
|
|
57
|
+
const SEARCH_TIMEOUT_MS = 8e3;
|
|
58
|
+
/** Per-call timeout for the single scope/goal inference. */
|
|
59
|
+
const INFER_TIMEOUT_MS = 18e3;
|
|
60
|
+
const internalPromptSubmit = defineCommand({
|
|
61
|
+
meta: {
|
|
62
|
+
name: "internal-prompt-submit",
|
|
63
|
+
description: "Internal: the UserPromptSubmit hook. Resets the Stop-gate per-prompt block budget, surfaces prior-turn review findings, and injects a grounded advisory goal for non-trivial prompts. Always exit 0."
|
|
64
|
+
},
|
|
65
|
+
async run() {
|
|
66
|
+
try {
|
|
67
|
+
const stdin = readStdin();
|
|
68
|
+
const steerEnabled = parseBoolEnv(process.env.GH_ROUTER_DISABLE_PROMPT_STEER) !== true;
|
|
69
|
+
const runtime = hookMcpRuntimeFromEnv();
|
|
70
|
+
let decision;
|
|
71
|
+
if (runtime) {
|
|
72
|
+
const workspace = workspaceFromStdin(stdin);
|
|
73
|
+
decision = await decidePromptSubmitV2({
|
|
74
|
+
stdin,
|
|
75
|
+
steerEnabled,
|
|
76
|
+
io: {
|
|
77
|
+
searchCode: async (query, mode, signal) => {
|
|
78
|
+
const r = await callMcpTool({
|
|
79
|
+
runtime,
|
|
80
|
+
group: "search",
|
|
81
|
+
tool: "code",
|
|
82
|
+
args: {
|
|
83
|
+
query,
|
|
84
|
+
workspace,
|
|
85
|
+
mode,
|
|
86
|
+
limit: 10,
|
|
87
|
+
summary: false
|
|
88
|
+
},
|
|
89
|
+
timeoutMs: SEARCH_TIMEOUT_MS,
|
|
90
|
+
signal
|
|
91
|
+
});
|
|
92
|
+
return r.isError ? "" : r.text;
|
|
93
|
+
},
|
|
94
|
+
infer: (system, user, signal) => callInference({
|
|
95
|
+
serverUrl: runtime.serverUrl,
|
|
96
|
+
model: "gpt-5.6-sol",
|
|
97
|
+
instructions: system,
|
|
98
|
+
input: user,
|
|
99
|
+
effort: "low",
|
|
100
|
+
timeoutMs: INFER_TIMEOUT_MS,
|
|
101
|
+
signal
|
|
102
|
+
}),
|
|
103
|
+
readFindings: (sid) => fileFindingsStore(stopReviewStateDir()).read(sid),
|
|
104
|
+
clearFindings: (sid) => fileFindingsStore(stopReviewStateDir()).clear(sid),
|
|
105
|
+
storePrompt: (sid, prompt) => fileLastPromptStore(stopReviewStateDir()).write(sid, prompt)
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
} else decision = decidePromptSubmit({
|
|
109
|
+
stdin,
|
|
110
|
+
steerEnabled
|
|
111
|
+
});
|
|
112
|
+
if (decision.resetSession) await fileBlockBudget(path.join(tmpdir(), "gh-router-stopgate")).reset(decision.resetSession).catch(() => {});
|
|
113
|
+
if (decision.inject.length > 0) await new Promise((resolve) => process.stdout.write(`${decision.inject}\n`, () => resolve()));
|
|
114
|
+
} catch {}
|
|
115
|
+
process.exitCode = 0;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
//#endregion
|
|
119
|
+
export { internalPromptSubmit };
|
|
120
|
+
|
|
121
|
+
//# sourceMappingURL=internal-prompt-submit-DOj75_hh.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal-prompt-submit-DOj75_hh.js","names":[],"sources":["../src/internal-prompt-submit.ts"],"sourcesContent":["/**\n * The internal `internal-prompt-submit` subcommand: the executable a spawned\n * Claude Code session's `UserPromptSubmit` hook invokes (registered into the\n * mirrored settings.json by the launcher). It serves the front-end of the\n * floor-raising surface on one event:\n * 1. resets the Stop-gate's per-session block budget (making `maxBlocks`\n * per-prompt);\n * 2. stashes the prompt + surfaces the prior turn's advisory review findings;\n * 3. for a non-trivial prompt, injects a GROUNDED, user-derived scope/goal\n * note (one gpt-5.6-sol call over the prompt + a parallel lexical+semantic code\n * search) — or, when the proxy URL/nonce isn't wired or anything errors,\n * falls open to the v1 regex goal.\n *\n * ALWAYS exits 0 (never blocks the prompt): the steer is additive context, and a\n * UserPromptSubmit hook that exit-2'd would refuse the user's prompt. The pure\n * v1 path (`decidePromptSubmit`) is the fail-open fallback; V2\n * (`decidePromptSubmitV2`) layers the grounded enrichment on top via injected IO.\n */\n\nimport { defineCommand } from \"citty\"\n\nimport { readFileSync } from \"node:fs\"\nimport { tmpdir } from \"node:os\"\nimport path from \"node:path\"\n\nimport { parseBoolEnv } from \"./lib/exec\"\nimport { callInference, callMcpTool, hookMcpRuntimeFromEnv } from \"./lib/orchestration/hook-mcp-client\"\nimport { fileBlockBudget } from \"./lib/orchestration/stop-gate-hook\"\nimport {\n decidePromptSubmit,\n decidePromptSubmitV2,\n type PromptSubmitDecision,\n} from \"./lib/orchestration/prompt-submit-hook\"\nimport {\n fileFindingsStore,\n fileLastPromptStore,\n stopReviewStateDir,\n} from \"./lib/orchestration/stop-gate-policy\"\n\n/**\n * Read the hook payload from stdin SYNCHRONOUSLY (`readFileSync(0)`). An async\n * stdin read leaves an in-flight libuv FS request that, on Windows, races the\n * process teardown and trips a `uv_async_send` assertion; a synchronous read has\n * no such handle. Hooks always receive piped/redirected stdin, so this never\n * blocks (guarded against an interactive TTY, and any error -> \"\").\n */\nfunction readStdin(): string {\n try {\n if (process.stdin.isTTY) return \"\"\n return readFileSync(0, \"utf8\")\n } catch {\n return \"\"\n }\n}\n\n/** Parse the session cwd from the payload — the workspace the grounding search\n * runs in. Falls back to the process cwd. */\nfunction workspaceFromStdin(stdin: string): string {\n try {\n const p: unknown = JSON.parse(stdin)\n if (p && typeof p === \"object\") {\n const cwd = (p as { cwd?: unknown }).cwd\n if (typeof cwd === \"string\" && cwd.length > 0) return cwd\n }\n } catch {\n /* fall through */\n }\n return process.cwd()\n}\n\n/** Per-call timeout for the grounding search (short — it must not stall the prompt). */\nconst SEARCH_TIMEOUT_MS = 8_000\n/** Per-call timeout for the single scope/goal inference. */\nconst INFER_TIMEOUT_MS = 18_000\n\nexport const internalPromptSubmit = defineCommand({\n meta: {\n name: \"internal-prompt-submit\",\n description:\n \"Internal: the UserPromptSubmit hook. Resets the Stop-gate per-prompt block \"\n + \"budget, surfaces prior-turn review findings, and injects a grounded advisory goal \"\n + \"for non-trivial prompts. Always exit 0.\",\n },\n async run() {\n try {\n const stdin = readStdin()\n const steerEnabled = parseBoolEnv(process.env.GH_ROUTER_DISABLE_PROMPT_STEER) !== true\n const runtime = hookMcpRuntimeFromEnv()\n\n let decision: PromptSubmitDecision\n if (runtime) {\n const workspace = workspaceFromStdin(stdin)\n decision = await decidePromptSubmitV2({\n stdin,\n steerEnabled,\n io: {\n searchCode: async (query, mode, signal) => {\n const r = await callMcpTool({\n runtime,\n group: \"search\",\n tool: \"code\",\n args: { query, workspace, mode, limit: 10, summary: false },\n timeoutMs: SEARCH_TIMEOUT_MS,\n signal,\n })\n return r.isError ? \"\" : r.text\n },\n infer: (system, user, signal) =>\n callInference({\n serverUrl: runtime.serverUrl,\n model: \"gpt-5.6-sol\",\n instructions: system,\n input: user,\n effort: \"low\",\n timeoutMs: INFER_TIMEOUT_MS,\n signal,\n }),\n readFindings: (sid) => fileFindingsStore(stopReviewStateDir()).read(sid),\n clearFindings: (sid) => fileFindingsStore(stopReviewStateDir()).clear(sid),\n storePrompt: (sid, prompt) => fileLastPromptStore(stopReviewStateDir()).write(sid, prompt),\n },\n })\n } else {\n // Proxy URL/nonce not wired -> the LLM layer is off; use the pure v1 path.\n decision = decidePromptSubmit({ stdin, steerEnabled })\n }\n\n if (decision.resetSession) {\n // Same budget store the Stop hook uses, so a new prompt clears its count.\n await fileBlockBudget(path.join(tmpdir(), \"gh-router-stopgate\"))\n .reset(decision.resetSession)\n .catch(() => {})\n }\n if (decision.inject.length > 0) {\n await new Promise<void>((resolve) => process.stdout.write(`${decision.inject}\\n`, () => resolve()))\n }\n } catch {\n /* never let the front-end hook disrupt a prompt */\n }\n // Natural exit (exit code 0): a hard process.exit() races libuv stdio teardown\n // on Windows. No handles are kept alive once the (aborted) enrichment settles,\n // so returning lets the process exit cleanly with code 0.\n process.exitCode = 0\n },\n})\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8CA,SAAS,YAAoB;CAC3B,IAAI;EACF,IAAI,QAAQ,MAAM,OAAO,OAAO;EAChC,OAAO,aAAa,GAAG,MAAM;CAC/B,QAAQ;EACN,OAAO;CACT;AACF;;;AAIA,SAAS,mBAAmB,OAAuB;CACjD,IAAI;EACF,MAAM,IAAa,KAAK,MAAM,KAAK;EACnC,IAAI,KAAK,OAAO,MAAM,UAAU;GAC9B,MAAM,MAAO,EAAwB;GACrC,IAAI,OAAO,QAAQ,YAAY,IAAI,SAAS,GAAG,OAAO;EACxD;CACF,QAAQ,CAER;CACA,OAAO,QAAQ,IAAI;AACrB;;AAGA,MAAM,oBAAoB;;AAE1B,MAAM,mBAAmB;AAEzB,MAAa,uBAAuB,cAAc;CAChD,MAAM;EACJ,MAAM;EACN,aACE;CAGJ;CACA,MAAM,MAAM;EACV,IAAI;GACF,MAAM,QAAQ,UAAU;GACxB,MAAM,eAAe,aAAa,QAAQ,IAAI,8BAA8B,MAAM;GAClF,MAAM,UAAU,sBAAsB;GAEtC,IAAI;GACJ,IAAI,SAAS;IACX,MAAM,YAAY,mBAAmB,KAAK;IAC1C,WAAW,MAAM,qBAAqB;KACpC;KACA;KACA,IAAI;MACF,YAAY,OAAO,OAAO,MAAM,WAAW;OACzC,MAAM,IAAI,MAAM,YAAY;QAC1B;QACA,OAAO;QACP,MAAM;QACN,MAAM;SAAE;SAAO;SAAW;SAAM,OAAO;SAAI,SAAS;QAAM;QAC1D,WAAW;QACX;OACF,CAAC;OACD,OAAO,EAAE,UAAU,KAAK,EAAE;MAC5B;MACA,QAAQ,QAAQ,MAAM,WACpB,cAAc;OACZ,WAAW,QAAQ;OACnB,OAAO;OACP,cAAc;OACd,OAAO;OACP,QAAQ;OACR,WAAW;OACX;MACF,CAAC;MACH,eAAe,QAAQ,kBAAkB,mBAAmB,CAAC,CAAC,CAAC,KAAK,GAAG;MACvE,gBAAgB,QAAQ,kBAAkB,mBAAmB,CAAC,CAAC,CAAC,MAAM,GAAG;MACzE,cAAc,KAAK,WAAW,oBAAoB,mBAAmB,CAAC,CAAC,CAAC,MAAM,KAAK,MAAM;KAC3F;IACF,CAAC;GACH,OAEE,WAAW,mBAAmB;IAAE;IAAO;GAAa,CAAC;GAGvD,IAAI,SAAS,cAEX,MAAM,gBAAgB,KAAK,KAAK,OAAO,GAAG,oBAAoB,CAAC,CAAC,CAC7D,MAAM,SAAS,YAAY,CAAC,CAC5B,YAAY,CAAC,CAAC;GAEnB,IAAI,SAAS,OAAO,SAAS,GAC3B,MAAM,IAAI,SAAe,YAAY,QAAQ,OAAO,MAAM,GAAG,SAAS,OAAO,WAAW,QAAQ,CAAC,CAAC;EAEtG,QAAQ,CAER;EAIA,QAAQ,WAAW;CACrB;AACF,CAAC"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { a as isSubagentContext } from "./stop-gate-policy-CHwWbuF_.js";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
//#region src/internal-session-bind.ts
|
|
7
|
+
/**
|
|
8
|
+
* The internal `internal-session-bind` subcommand: the executable a spawned Claude
|
|
9
|
+
* Code session's SessionStart / SessionEnd hooks invoke (registered into the
|
|
10
|
+
* mirrored settings.json by the launcher when `AIORDIE_CLAUDE_BIND` is set —
|
|
11
|
+
* i.e. when github-router runs inside an ai-or-die Terminal tab).
|
|
12
|
+
*
|
|
13
|
+
* It reads Claude Code's hook payload from stdin and ATOMICALLY writes a tiny
|
|
14
|
+
* "sidecar" JSON file (path passed via `--out`) describing the CURRENTLY active
|
|
15
|
+
* claude session: its sessionId and the absolute transcript path. ai-or-die owns
|
|
16
|
+
* the sidecar path and watches it, so it can bind that browser tab's session
|
|
17
|
+
* summariser to the exact transcript — surviving in-session `/resume`, `/clear`,
|
|
18
|
+
* `/compact`, and exit→relaunch, without guessing from the filesystem.
|
|
19
|
+
*
|
|
20
|
+
* Side-effect only: writes the file and exits 0. It NEVER writes to stdout (a
|
|
21
|
+
* SessionStart hook's stdout is injected into the model's context) and NEVER
|
|
22
|
+
* blocks (any error is swallowed → exit 0).
|
|
23
|
+
*
|
|
24
|
+
* Top-level filter: a SessionStart fired by a subagent/teammate carries
|
|
25
|
+
* `agent_id`/`agent_type`; those are skipped so only the tab's top-level session
|
|
26
|
+
* drives the binding (mirrors the Stop / UserPromptSubmit hooks).
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Read the hook payload from stdin SYNCHRONOUSLY (`readFileSync(0)`). An async
|
|
30
|
+
* stdin read leaves an in-flight libuv FS request that, on Windows, races process
|
|
31
|
+
* teardown and trips a `uv_async_send` assertion; a synchronous read has no such
|
|
32
|
+
* handle. Hooks always receive piped stdin (guarded against a TTY; any error -> "").
|
|
33
|
+
*/
|
|
34
|
+
function readStdin() {
|
|
35
|
+
try {
|
|
36
|
+
if (process.stdin.isTTY) return "";
|
|
37
|
+
return readFileSync(0, "utf8");
|
|
38
|
+
} catch {
|
|
39
|
+
return "";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a transcript path to its real location. The path claude reports sits
|
|
44
|
+
* under the per-launch CLAUDE_CONFIG_DIR mirror, whose `projects` entry is a
|
|
45
|
+
* junction/symlink back to the real `~/.claude/projects`. ai-or-die reads the
|
|
46
|
+
* real dir, and the per-launch mirror is swept on github-router shutdown, so we
|
|
47
|
+
* persist the REAL path. The file (and even intermediate dirs) may not exist yet
|
|
48
|
+
* at SessionStart, so we realpath the DEEPEST EXISTING ancestor and rejoin the
|
|
49
|
+
* not-yet-created trailing segments. Best-effort: if nothing resolves, keep raw.
|
|
50
|
+
*/
|
|
51
|
+
function realTranscriptPath(tp) {
|
|
52
|
+
if (!tp) return "";
|
|
53
|
+
try {
|
|
54
|
+
return realpathSync.native(tp);
|
|
55
|
+
} catch {}
|
|
56
|
+
const missing = [];
|
|
57
|
+
let cur = tp;
|
|
58
|
+
for (let i = 0; i < 64; i++) {
|
|
59
|
+
missing.unshift(path.basename(cur));
|
|
60
|
+
const parent = path.dirname(cur);
|
|
61
|
+
if (parent === cur) break;
|
|
62
|
+
try {
|
|
63
|
+
return path.join(realpathSync.native(parent), ...missing);
|
|
64
|
+
} catch {
|
|
65
|
+
cur = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return tp;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Pure core: turn a raw Claude Code hook payload (stdin string) into the sidecar
|
|
72
|
+
* record, or `null` when there's nothing to write. Returns null for: non-JSON
|
|
73
|
+
* input, a subagent/teammate payload (agent_id/agent_type present — top-level
|
|
74
|
+
* filter), or a missing session_id. Exported for unit tests.
|
|
75
|
+
*/
|
|
76
|
+
function decodeSessionBind(stdin) {
|
|
77
|
+
let payload = {};
|
|
78
|
+
try {
|
|
79
|
+
const p = JSON.parse(stdin);
|
|
80
|
+
if (p && typeof p === "object") payload = p;
|
|
81
|
+
else return null;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (isSubagentContext(payload)) return null;
|
|
86
|
+
const claudeSessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
87
|
+
if (!claudeSessionId) return null;
|
|
88
|
+
const event = (typeof payload.hook_event_name === "string" ? payload.hook_event_name : "") === "SessionEnd" ? "end" : "start";
|
|
89
|
+
const record = {
|
|
90
|
+
schema: 1,
|
|
91
|
+
claudeSessionId,
|
|
92
|
+
transcriptPath: realTranscriptPath(typeof payload.transcript_path === "string" ? payload.transcript_path : ""),
|
|
93
|
+
cwd: typeof payload.cwd === "string" ? payload.cwd : "",
|
|
94
|
+
event,
|
|
95
|
+
at: Date.now()
|
|
96
|
+
};
|
|
97
|
+
if (event === "start" && typeof payload.source === "string") record.source = payload.source;
|
|
98
|
+
if (event === "end" && typeof payload.reason === "string") record.reason = payload.reason;
|
|
99
|
+
return record;
|
|
100
|
+
}
|
|
101
|
+
/** Atomically write the sidecar (temp + rename) so the reader never sees a partial file. */
|
|
102
|
+
function writeSidecar(out, record) {
|
|
103
|
+
try {
|
|
104
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
105
|
+
} catch {}
|
|
106
|
+
const tmp = `${out}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
107
|
+
writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
|
|
108
|
+
renameSync(tmp, out);
|
|
109
|
+
}
|
|
110
|
+
const internalSessionBind = defineCommand({
|
|
111
|
+
meta: {
|
|
112
|
+
name: "internal-session-bind",
|
|
113
|
+
description: "Internal: the SessionStart/SessionEnd hook. Reads the Claude Code hook payload on stdin and atomically writes the active session id + transcript path to the --out sidecar file (consumed by ai-or-die to bind a tab's sticky-note summariser). Side-effect only."
|
|
114
|
+
},
|
|
115
|
+
args: { out: {
|
|
116
|
+
type: "string",
|
|
117
|
+
description: "Absolute path to the sidecar file to (atomically) write.",
|
|
118
|
+
required: false
|
|
119
|
+
} },
|
|
120
|
+
run({ args }) {
|
|
121
|
+
try {
|
|
122
|
+
const out = typeof args.out === "string" ? args.out.trim() : "";
|
|
123
|
+
if (!out) return;
|
|
124
|
+
const record = decodeSessionBind(readStdin());
|
|
125
|
+
if (record) writeSidecar(out, record);
|
|
126
|
+
} catch {}
|
|
127
|
+
process.exitCode = 0;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
//#endregion
|
|
131
|
+
export { internalSessionBind };
|
|
132
|
+
|
|
133
|
+
//# sourceMappingURL=internal-session-bind-DwK2iGW0.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"internal-session-bind-DwK2iGW0.js","names":[],"sources":["../src/internal-session-bind.ts"],"sourcesContent":["/**\n * The internal `internal-session-bind` subcommand: the executable a spawned Claude\n * Code session's SessionStart / SessionEnd hooks invoke (registered into the\n * mirrored settings.json by the launcher when `AIORDIE_CLAUDE_BIND` is set —\n * i.e. when github-router runs inside an ai-or-die Terminal tab).\n *\n * It reads Claude Code's hook payload from stdin and ATOMICALLY writes a tiny\n * \"sidecar\" JSON file (path passed via `--out`) describing the CURRENTLY active\n * claude session: its sessionId and the absolute transcript path. ai-or-die owns\n * the sidecar path and watches it, so it can bind that browser tab's session\n * summariser to the exact transcript — surviving in-session `/resume`, `/clear`,\n * `/compact`, and exit→relaunch, without guessing from the filesystem.\n *\n * Side-effect only: writes the file and exits 0. It NEVER writes to stdout (a\n * SessionStart hook's stdout is injected into the model's context) and NEVER\n * blocks (any error is swallowed → exit 0).\n *\n * Top-level filter: a SessionStart fired by a subagent/teammate carries\n * `agent_id`/`agent_type`; those are skipped so only the tab's top-level session\n * drives the binding (mirrors the Stop / UserPromptSubmit hooks).\n */\n\nimport { defineCommand } from \"citty\"\n\nimport { randomBytes } from \"node:crypto\"\nimport { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from \"node:fs\"\nimport path from \"node:path\"\n\nimport { isSubagentContext } from \"./lib/orchestration/stop-gate-policy\"\n\n/**\n * Read the hook payload from stdin SYNCHRONOUSLY (`readFileSync(0)`). An async\n * stdin read leaves an in-flight libuv FS request that, on Windows, races process\n * teardown and trips a `uv_async_send` assertion; a synchronous read has no such\n * handle. Hooks always receive piped stdin (guarded against a TTY; any error -> \"\").\n */\nfunction readStdin(): string {\n try {\n if (process.stdin.isTTY) return \"\"\n return readFileSync(0, \"utf8\")\n } catch {\n return \"\"\n }\n}\n\n/**\n * Resolve a transcript path to its real location. The path claude reports sits\n * under the per-launch CLAUDE_CONFIG_DIR mirror, whose `projects` entry is a\n * junction/symlink back to the real `~/.claude/projects`. ai-or-die reads the\n * real dir, and the per-launch mirror is swept on github-router shutdown, so we\n * persist the REAL path. The file (and even intermediate dirs) may not exist yet\n * at SessionStart, so we realpath the DEEPEST EXISTING ancestor and rejoin the\n * not-yet-created trailing segments. Best-effort: if nothing resolves, keep raw.\n */\nfunction realTranscriptPath(tp: string): string {\n if (!tp) return \"\"\n try {\n return realpathSync.native(tp)\n } catch {\n /* file doesn't exist yet — resolve the deepest existing ancestor below */\n }\n const missing: string[] = []\n let cur = tp\n for (let i = 0; i < 64; i++) {\n missing.unshift(path.basename(cur))\n const parent = path.dirname(cur)\n if (parent === cur) break // reached the root without an existing ancestor\n try {\n return path.join(realpathSync.native(parent), ...missing)\n } catch {\n cur = parent\n }\n }\n return tp\n}\n\n/**\n * Pure core: turn a raw Claude Code hook payload (stdin string) into the sidecar\n * record, or `null` when there's nothing to write. Returns null for: non-JSON\n * input, a subagent/teammate payload (agent_id/agent_type present — top-level\n * filter), or a missing session_id. Exported for unit tests.\n */\nexport function decodeSessionBind(stdin: string): Record<string, unknown> | null {\n let payload: {\n hook_event_name?: unknown\n session_id?: unknown\n transcript_path?: unknown\n cwd?: unknown\n source?: unknown\n reason?: unknown\n agent_id?: unknown\n agent_type?: unknown\n } = {}\n try {\n const p: unknown = JSON.parse(stdin)\n if (p && typeof p === \"object\") payload = p as typeof payload\n else return null\n } catch {\n return null // non-JSON stdin → tolerate, write nothing\n }\n\n // Top-level only: a subagent/teammate session must not steal the tab's\n // binding. Reuse the canonical fail-closed predicate (any present agent\n // marker ⇒ not the top-level session); main-session payloads omit the keys.\n if (isSubagentContext(payload)) return null\n\n const claudeSessionId = typeof payload.session_id === \"string\" ? payload.session_id : \"\"\n if (!claudeSessionId) return null // nothing to bind to\n\n const event =\n (typeof payload.hook_event_name === \"string\" ? payload.hook_event_name : \"\") === \"SessionEnd\"\n ? \"end\"\n : \"start\"\n const record: Record<string, unknown> = {\n schema: 1,\n claudeSessionId,\n transcriptPath: realTranscriptPath(\n typeof payload.transcript_path === \"string\" ? payload.transcript_path : \"\",\n ),\n cwd: typeof payload.cwd === \"string\" ? payload.cwd : \"\",\n event,\n at: Date.now(),\n }\n if (event === \"start\" && typeof payload.source === \"string\") record.source = payload.source\n if (event === \"end\" && typeof payload.reason === \"string\") record.reason = payload.reason\n return record\n}\n\n/** Atomically write the sidecar (temp + rename) so the reader never sees a partial file. */\nfunction writeSidecar(out: string, record: Record<string, unknown>): void {\n try {\n mkdirSync(path.dirname(out), { recursive: true })\n } catch {\n /* best-effort — ai-or-die owns and creates the dir */\n }\n const tmp = `${out}.${process.pid}.${randomBytes(4).toString(\"hex\")}.tmp`\n writeFileSync(tmp, JSON.stringify(record), { mode: 0o600 })\n renameSync(tmp, out)\n}\n\nexport const internalSessionBind = defineCommand({\n meta: {\n name: \"internal-session-bind\",\n description:\n \"Internal: the SessionStart/SessionEnd hook. Reads the Claude Code hook payload on stdin \"\n + \"and atomically writes the active session id + transcript path to the --out sidecar file \"\n + \"(consumed by ai-or-die to bind a tab's sticky-note summariser). Side-effect only.\",\n },\n args: {\n out: {\n type: \"string\",\n description: \"Absolute path to the sidecar file to (atomically) write.\",\n required: false,\n },\n },\n run({ args }) {\n // Never throw out of a hook: wrap the whole body and fall through to exit 0.\n try {\n const out = typeof args.out === \"string\" ? args.out.trim() : \"\"\n if (!out) return // no sidecar target → nothing to do\n const record = decodeSessionBind(readStdin())\n if (record) writeSidecar(out, record)\n } catch {\n /* a hook must never disrupt the session */\n }\n // Side-effect only: nothing on stdout (it would inject into the model's\n // context). Natural exit 0.\n process.exitCode = 0\n },\n})\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCA,SAAS,YAAoB;CAC3B,IAAI;EACF,IAAI,QAAQ,MAAM,OAAO,OAAO;EAChC,OAAO,aAAa,GAAG,MAAM;CAC/B,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;AAWA,SAAS,mBAAmB,IAAoB;CAC9C,IAAI,CAAC,IAAI,OAAO;CAChB,IAAI;EACF,OAAO,aAAa,OAAO,EAAE;CAC/B,QAAQ,CAER;CACA,MAAM,UAAoB,CAAC;CAC3B,IAAI,MAAM;CACV,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,KAAK;EAC3B,QAAQ,QAAQ,KAAK,SAAS,GAAG,CAAC;EAClC,MAAM,SAAS,KAAK,QAAQ,GAAG;EAC/B,IAAI,WAAW,KAAK;EACpB,IAAI;GACF,OAAO,KAAK,KAAK,aAAa,OAAO,MAAM,GAAG,GAAG,OAAO;EAC1D,QAAQ;GACN,MAAM;EACR;CACF;CACA,OAAO;AACT;;;;;;;AAQA,SAAgB,kBAAkB,OAA+C;CAC/E,IAAI,UASA,CAAC;CACL,IAAI;EACF,MAAM,IAAa,KAAK,MAAM,KAAK;EACnC,IAAI,KAAK,OAAO,MAAM,UAAU,UAAU;OACrC,OAAO;CACd,QAAQ;EACN,OAAO;CACT;CAKA,IAAI,kBAAkB,OAAO,GAAG,OAAO;CAEvC,MAAM,kBAAkB,OAAO,QAAQ,eAAe,WAAW,QAAQ,aAAa;CACtF,IAAI,CAAC,iBAAiB,OAAO;CAE7B,MAAM,SACH,OAAO,QAAQ,oBAAoB,WAAW,QAAQ,kBAAkB,QAAQ,eAC7E,QACA;CACN,MAAM,SAAkC;EACtC,QAAQ;EACR;EACA,gBAAgB,mBACd,OAAO,QAAQ,oBAAoB,WAAW,QAAQ,kBAAkB,EAC1E;EACA,KAAK,OAAO,QAAQ,QAAQ,WAAW,QAAQ,MAAM;EACrD;EACA,IAAI,KAAK,IAAI;CACf;CACA,IAAI,UAAU,WAAW,OAAO,QAAQ,WAAW,UAAU,OAAO,SAAS,QAAQ;CACrF,IAAI,UAAU,SAAS,OAAO,QAAQ,WAAW,UAAU,OAAO,SAAS,QAAQ;CACnF,OAAO;AACT;;AAGA,SAAS,aAAa,KAAa,QAAuC;CACxE,IAAI;EACF,UAAU,KAAK,QAAQ,GAAG,GAAG,EAAE,WAAW,KAAK,CAAC;CAClD,QAAQ,CAER;CACA,MAAM,MAAM,GAAG,IAAI,GAAG,QAAQ,IAAI,GAAG,YAAY,CAAC,CAAC,CAAC,SAAS,KAAK,EAAE;CACpE,cAAc,KAAK,KAAK,UAAU,MAAM,GAAG,EAAE,MAAM,IAAM,CAAC;CAC1D,WAAW,KAAK,GAAG;AACrB;AAEA,MAAa,sBAAsB,cAAc;CAC/C,MAAM;EACJ,MAAM;EACN,aACE;CAGJ;CACA,MAAM,EACJ,KAAK;EACH,MAAM;EACN,aAAa;EACb,UAAU;CACZ,EACF;CACA,IAAI,EAAE,QAAQ;EAEZ,IAAI;GACF,MAAM,MAAM,OAAO,KAAK,QAAQ,WAAW,KAAK,IAAI,KAAK,IAAI;GAC7D,IAAI,CAAC,KAAK;GACV,MAAM,SAAS,kBAAkB,UAAU,CAAC;GAC5C,IAAI,QAAQ,aAAa,KAAK,MAAM;EACtC,QAAQ,CAER;EAGA,QAAQ,WAAW;CACrB;AACF,CAAC"}
|