From 3515da8af03b62af769c192f10c76bcea375b021 Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Mon, 26 Oct 2026 11:50:57 -0500 Subject: [PATCH 1/3] fix(vibe): run one hook script per event - Register one PreToolUse command (pre-tool-use.mjs, matcher Bash|Agent|SendMessage) and one UserPromptSubmit command (user-prompt-submit.mjs) instead of seven per-guard commands - Turn each guard into a module the event script routes to by tool name; hook-dispatch.mjs merges results deny > defer > ask > allow, joins every winning reason and keeps every context - A throwing guard still fails open under its own name on stderr while the others decide; scripts always exit 0 - Keep kill-guard.mjs as the `own ` recorder only - Fail CI when any plugin registers more than one hook handler for an event (tools/plugin-manifests); the code plugin's two PreToolUse and two SubagentStop handlers are held at that count, shrink-only - Bump vibe to 1.0.82 Testing: node --test over every plugins test file (793/795; the two cl-sweep black-box failures were EPIPE under load and pass alone), plugin-manifests vitest and tsc, pytest plugins/, ruff, pyright, old vs new parity harness on 19 payloads Risks: guards now run sequentially in one process, so a gh pr merge check waits on gh before the others report --- .github/workflows/ci.yml | 3 +- AGENTS.md | 1 + CHANGELOG.md | 13 ++ plugins/vibe/.claude-plugin/plugin.json | 2 +- plugins/vibe/.codex-plugin/plugin.json | 2 +- plugins/vibe/README.md | 2 +- plugins/vibe/hooks/agent-guard.mjs | 19 +-- plugins/vibe/hooks/agent-guard.test.mjs | 10 +- plugins/vibe/hooks/away-protocol.mjs | 21 +-- plugins/vibe/hooks/away-protocol.test.mjs | 2 +- plugins/vibe/hooks/foreground-guard.mjs | 21 +-- plugins/vibe/hooks/foreground-guard.test.mjs | 2 +- plugins/vibe/hooks/hook-dispatch.mjs | 78 ++++++++++ plugins/vibe/hooks/hooks.json | 36 +---- plugins/vibe/hooks/kill-guard.mjs | 35 ++--- plugins/vibe/hooks/kill-guard.test.mjs | 9 +- plugins/vibe/hooks/merge-guard.mjs | 30 ++-- plugins/vibe/hooks/merge-guard.test.mjs | 10 +- plugins/vibe/hooks/pause-guard.mjs | 20 +-- plugins/vibe/hooks/pause-guard.test.mjs | 10 +- plugins/vibe/hooks/pre-tool-use.mjs | 36 +++++ plugins/vibe/hooks/pre-tool-use.test.mjs | 112 +++++++++++++++ plugins/vibe/hooks/shell-safety-guard.mjs | 29 ++-- .../vibe/hooks/shell-safety-guard.test.mjs | 9 +- plugins/vibe/hooks/user-prompt-submit.mjs | 19 +++ .../python/test_vibe_1076_lessons_contract.py | 7 +- .../python/test_vibe_1077_lessons_contract.py | 10 +- .../python/test_vibe_1078_lessons_contract.py | 7 +- .../python/test_vibe_1079_lessons_contract.py | 5 +- .../python/test_vibe_1081_lessons_contract.py | 11 +- ...t_vibe_1082_one_hook_per_event_contract.py | 25 ++++ .../vibe/tools/python/vibe_contract_text.py | 18 +++ .../src/hook-registrations.test.ts | 108 ++++++++++++++ .../src/hook-registrations.ts | 133 ++++++++++++++++++ 34 files changed, 644 insertions(+), 211 deletions(-) create mode 100644 plugins/vibe/hooks/hook-dispatch.mjs create mode 100644 plugins/vibe/hooks/pre-tool-use.mjs create mode 100644 plugins/vibe/hooks/pre-tool-use.test.mjs create mode 100644 plugins/vibe/hooks/user-prompt-submit.mjs create mode 100644 plugins/vibe/tools/python/test_vibe_1082_one_hook_per_event_contract.py create mode 100644 tools/plugin-manifests/src/hook-registrations.test.ts create mode 100644 tools/plugin-manifests/src/hook-registrations.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ad0f6ed..7790611b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,8 @@ jobs: cache-dependency-path: tools/plugin-manifests/package-lock.json - run: npm ci - run: npm run typecheck - # Fails when any plugin's .claude-plugin and .codex-plugin versions differ. + # Fails when any plugin's .claude-plugin and .codex-plugin versions differ, + # or when any plugin registers more than one hook command for an event. - run: npm test lint: diff --git a/AGENTS.md b/AGENTS.md index 40fa7b81..4e9963e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ This repository is a plugin monorepo under `plugins/`. - Test files use `test_.py`; functions use `snake_case`. - Plugin artifacts are explicit and frontmatter-driven (`agents`, `commands`, `skills` definitions in Markdown). - Use existing conventions in each plugin; avoid changing naming style in adjacent files (for example, slash command names and agent IDs remain kebab-case, e.g. `code-review`). +- A plugin registers one hook script per event, routing to its guards inside that script; `tools/plugin-manifests/src/hook-registrations.ts` fails CI on a second command for any event in any plugin's hook config. ## Testing Guidelines - Framework: `pytest`. diff --git a/CHANGELOG.md b/CHANGELOG.md index a21b7dab..ffe01cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to the claude-plugins project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`. +### vibe v1.0.82 + +Each hook event now runs one vibe script, which routes to every guard in a single process. + +#### Changed +- `hooks.json` registers one PreToolUse command, `hooks/pre-tool-use.mjs` (matcher `Bash|Agent|SendMessage`), and one UserPromptSubmit command, `hooks/user-prompt-submit.mjs`, in place of seven per-guard commands. The guards are modules the two scripts route to by tool name. +- Several guard results for one call merge into one output: the strongest decision wins (deny, then defer, ask, allow), carrying every reason given for it in guard order, and every guard's context is kept. A single guard's output is unchanged byte for byte. +- A guard that throws is still reported on stderr under its own name and contributes nothing, so the other guards still decide; unreadable hook input fails open. The scripts always exit 0, as each guard did. +- `kill-guard.mjs` remains the `own ` recorder; run without `own` it now prints its usage and exits 1. + +#### Added +- A repository check in `tools/plugin-manifests` fails CI when any plugin's hook configuration (each `hooks.json` in the plugin tree or a `hooks` field in either manifest) registers more than one handler for an event. The `code` plugin's existing two PreToolUse and two SubagentStop handlers are held at that count and may only shrink. + ### vibe v1.0.81 The 2026-10-23 and 2026-10-24 fleet lessons now have hooks, launcher behavior and evidence gates instead of prose alone. diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index 82eff09d..b42590d8 100644 --- a/plugins/vibe/.claude-plugin/plugin.json +++ b/plugins/vibe/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vibe", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", - "version": "1.0.81", + "version": "1.0.82", "dependencies": ["closedloop-core"], "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"} } diff --git a/plugins/vibe/.codex-plugin/plugin.json b/plugins/vibe/.codex-plugin/plugin.json index 6d5cb9d6..49dfcd61 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.81", + "version": "1.0.82", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}, "homepage": "https://github.com/closedloop-ai/claude-plugins", diff --git a/plugins/vibe/README.md b/plugins/vibe/README.md index 826c976a..36861571 100644 --- a/plugins/vibe/README.md +++ b/plugins/vibe/README.md @@ -523,7 +523,7 @@ harness switch instead of another worker doing its work. ## Runtime Files -Version `1.0.81` contains 12 skills, 16 agents, and one hook manifest +Version `1.0.82` contains 12 skills, 16 agents, and one hook manifest (`hooks/hooks.json`). It has no standalone commands, root-level shell scripts, or production Python tools under `tools/python/`; that directory contains the skill-contract test modules and diff --git a/plugins/vibe/hooks/agent-guard.mjs b/plugins/vibe/hooks/agent-guard.mjs index 22a507f8..2add1e43 100644 --- a/plugins/vibe/hooks/agent-guard.mjs +++ b/plugins/vibe/hooks/agent-guard.mjs @@ -1,12 +1,10 @@ -#!/usr/bin/env node // PreToolUse guard for Agent: an orchestrator starts a Claude vibe writer by // invoking the vibe:vibe skill, never by dispatching one of its internal // workers. Direct dispatch skips the session record, environment setup, // guardrails and adversarial review, PR preflight and SendMessage. -import { readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; import { inVibeSession } from "./foreground-guard.mjs"; +import { deny, eventCwd } from "./hook-dispatch.mjs"; const VIBE_WORKER = /^vibe:vibe-[a-z0-9-]+-worker$/; @@ -17,15 +15,8 @@ export function agentLaunchProblem(input) { return `do not launch ${subagent} directly: invoke \`vibe:vibe\` in a Claude agent so the writer gets its session record, environment setup, guardrails and adversarial review, PR preflight and SendMessage path`; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - const event = JSON.parse(readFileSync(0, "utf8") || "{}"); - const problem = event?.tool_name === "Agent" ? agentLaunchProblem(event.tool_input) : null; - const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); - if (problem && !inVibeSession(cwd)) { - process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem } })}\n`); - } - } catch (error) { - process.stderr.write(`agent-guard: ${error instanceof Error ? error.message : String(error)}\n`); - } +/** The PreToolUse result for an Agent call: a deny for a direct vibe-worker launch outside a vibe session, or null. */ +export function agentGuard(event) { + const problem = agentLaunchProblem(event?.tool_input); + return problem && !inVibeSession(eventCwd(event)) ? deny(problem) : null; } diff --git a/plugins/vibe/hooks/agent-guard.test.mjs b/plugins/vibe/hooks/agent-guard.test.mjs index e320c2a3..1d91aa37 100644 --- a/plugins/vibe/hooks/agent-guard.test.mjs +++ b/plugins/vibe/hooks/agent-guard.test.mjs @@ -1,14 +1,14 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { agentLaunchProblem } from "./agent-guard.mjs"; +import { agentGuard, agentLaunchProblem } from "./agent-guard.mjs"; +import { PRE_TOOL_USE_ROUTES } from "./pre-tool-use.mjs"; -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const GUARD = path.join(HERE, "agent-guard.mjs"); +const GUARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "pre-tool-use.mjs"); test("an orchestrator cannot launch a vibe worker directly outside a vibe session", (t) => { const dir = mkdtempSync(path.join(os.tmpdir(), "vibe-agent-guard-")); @@ -28,5 +28,5 @@ test("an orchestrator cannot launch a vibe worker directly outside a vibe sessio const gitDir = execFileSync("git", ["-C", dir, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" }).trim(); writeFileSync(path.join(gitDir, "vibe-session.json"), "{}\n"); assert.equal(run("vibe:vibe-change-worker", dir).stdout, "", "the vibe skill may dispatch its own worker inside a session"); - assert.ok(readFileSync(path.join(HERE, "hooks.json"), "utf8").includes('"matcher": "Agent"'), "the Agent hook is registered"); + assert.ok(PRE_TOOL_USE_ROUTES.Agent.some((guard) => guard.run === agentGuard), "the Agent guard is routed"); }); diff --git a/plugins/vibe/hooks/away-protocol.mjs b/plugins/vibe/hooks/away-protocol.mjs index 55f9fc19..a778a105 100644 --- a/plugins/vibe/hooks/away-protocol.mjs +++ b/plugins/vibe/hooks/away-protocol.mjs @@ -1,14 +1,9 @@ -#!/usr/bin/env node // UserPromptSubmit hook: when the operator says they are stepping away or are // back, put the fleet-orchestrator away or return checklist into context. An // orchestrator once answered "stepping away. brb." with a friendly summary // and ran none of the away protocol, though its skill required it. // -// Usage (hook): reads the UserPromptSubmit JSON on stdin; prints the checklist as -// additional context, or nothing. - -import { readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; +// Runs from user-prompt-submit.mjs. const AWAY = /\b(?:brb|be right back|afk|stepping away|step(?:ping)? out|going to bed|off to bed|heading (?:out|off|to bed|home)|signing off|back in (?:\d+|a (?:few|bit|while)|an hour|a minute|ten|twenty|thirty)\b)/i; const BACK = /(?:^|[.!?]\s*)(?:i'?m back|i am back|back now|back again|ok(?:ay)?,? back)\b|^\s*back[.!]*\s*$/i; @@ -34,14 +29,8 @@ export function checklistFor(prompt) { return null; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - const event = JSON.parse(readFileSync(0, "utf8") || "{}"); - const checklist = checklistFor(event?.prompt); - if (checklist) { - process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: checklist } })}\n`); - } - } catch (error) { - process.stderr.write(`away-protocol: ${error instanceof Error ? error.message : String(error)}\n`); - } +/** The UserPromptSubmit result for a prompt: its checklist as context, or null. */ +export function awayProtocol(event) { + const checklist = checklistFor(event?.prompt); + return checklist ? { context: checklist } : null; } diff --git a/plugins/vibe/hooks/away-protocol.test.mjs b/plugins/vibe/hooks/away-protocol.test.mjs index 05e63468..3ba30d57 100644 --- a/plugins/vibe/hooks/away-protocol.test.mjs +++ b/plugins/vibe/hooks/away-protocol.test.mjs @@ -5,7 +5,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { AWAY_CHECKLIST, BACK_CHECKLIST, checklistFor } from "./away-protocol.mjs"; -const HOOK = path.join(path.dirname(fileURLToPath(import.meta.url)), "away-protocol.mjs"); +const HOOK = path.join(path.dirname(fileURLToPath(import.meta.url)), "user-prompt-submit.mjs"); test("away and back phrases bring the checklist; ordinary prompts do not (brb with no away protocol)", () => { for (const prompt of ["stepping away. brb.", "brb", "afk for 20", "going to bed, see you tomorrow", "heading out for lunch", "back in 30", "back in a few", "ok I need to step out"]) { diff --git a/plugins/vibe/hooks/foreground-guard.mjs b/plugins/vibe/hooks/foreground-guard.mjs index 4073e2dd..9014066b 100644 --- a/plugins/vibe/hooks/foreground-guard.mjs +++ b/plugins/vibe/hooks/foreground-guard.mjs @@ -1,4 +1,3 @@ -#!/usr/bin/env node // PreToolUse guard for Bash in a vibe session's worktree: no foreground command // may wait longer than 5 minutes. A Claude pane teammate reads messages only // between turns, so writers blocked in long foreground runs kept re-asking @@ -6,12 +5,12 @@ // command runs in the background (`run_in_background`) and the turn ends while // it waits. // -// Usage (hook): reads the PreToolUse JSON on stdin; prints a deny decision or nothing. +// Runs from pre-tool-use.mjs. import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { deny, eventCwd } from "./hook-dispatch.mjs"; export const MAX_FOREGROUND_MS = 5 * 60 * 1000; @@ -33,14 +32,8 @@ export function foregroundProblem(input) { return `a foreground command may wait at most 5 minutes in a vibe session (timeout ${timeout} ms): run it with run_in_background and end the turn while it runs, so messages reach you between turns`; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - const event = JSON.parse(readFileSync(0, "utf8") || "{}"); - const problem = event?.tool_name === "Bash" ? foregroundProblem(event.tool_input) : null; - if (problem && inVibeSession(typeof event.cwd === "string" ? event.cwd : process.cwd())) { - process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem } })}\n`); - } - } catch (error) { - process.stderr.write(`foreground-guard: ${error instanceof Error ? error.message : String(error)}\n`); - } +/** The PreToolUse result for a Bash call: a deny for a long foreground wait inside a vibe session, or null. */ +export function foregroundGuard(event) { + const problem = foregroundProblem(event?.tool_input); + return problem && inVibeSession(eventCwd(event)) ? deny(problem) : null; } diff --git a/plugins/vibe/hooks/foreground-guard.test.mjs b/plugins/vibe/hooks/foreground-guard.test.mjs index bd875ebf..db9584f6 100644 --- a/plugins/vibe/hooks/foreground-guard.test.mjs +++ b/plugins/vibe/hooks/foreground-guard.test.mjs @@ -7,7 +7,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { foregroundProblem } from "./foreground-guard.mjs"; -const GUARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "foreground-guard.mjs"); +const GUARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "pre-tool-use.mjs"); test("a foreground wait over 5 minutes is refused; background runs and short waits pass (answers sent three times)", () => { assert.match(foregroundProblem({ command: "pnpm test", timeout: 600000 }), /at most 5 minutes/); diff --git a/plugins/vibe/hooks/hook-dispatch.mjs b/plugins/vibe/hooks/hook-dispatch.mjs new file mode 100644 index 00000000..35d33b80 --- /dev/null +++ b/plugins/vibe/hooks/hook-dispatch.mjs @@ -0,0 +1,78 @@ +// Shared by the vibe plugin's per-event hook scripts. hooks.json registers one +// command per event (pre-tool-use.mjs, user-prompt-submit.mjs); each reads stdin +// once, runs the guards routed to it, and prints the one JSON object the event's +// contract reads (code.claude.com/docs/en/hooks). +// +// A guard returns null, `{ decision, reason }` or `{ context }`. Every guard +// fails open on its own, as each did when it was a separate script: one that +// throws contributes nothing, is reported on stderr under its name, and the +// others still run. The script always exits 0. + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +/** PreToolUse precedence when several decisions disagree: "deny > defer > ask > allow" (hooks docs, PreToolUse decision control). */ +export const DECISION_PRECEDENCE = Object.freeze(["deny", "defer", "ask", "allow"]); + +/** A deny result carrying `reason`. */ +export function deny(reason) { + return { decision: "deny", reason }; +} + +/** The working directory a hook event names, or this process's. */ +export function eventCwd(event) { + return typeof event?.cwd === "string" ? event.cwd : process.cwd(); +} + +/** Whether the module at `metaUrl` is the script node was started with. */ +export function isMain(metaUrl) { + return Boolean(process.argv[1]) && metaUrl === pathToFileURL(process.argv[1]).href; +} + +/** The non-null results of `guards` for `event`; `report(line)` receives each throwing guard's error. */ +export function runGuards(guards, event, report) { + const results = []; + for (const { name, run } of guards) { + try { + const result = run(event); + if (result) results.push(result); + } catch (error) { + report(`${name}: ${errorText(error)}`); + } + } + return results; +} + +/** + * One hook output for `eventName` from guard results, or null when none fired. The + * highest-precedence decision wins and carries every reason given for it, one per + * line in guard order; every guard's context is kept, separated by a blank line. + */ +export function mergeResults(eventName, results) { + const decision = DECISION_PRECEDENCE.find((candidate) => results.some((result) => result.decision === candidate)); + const contexts = results.map((result) => result.context).filter((context) => typeof context === "string" && context !== ""); + if (decision === undefined && contexts.length === 0) return null; + const output = { hookEventName: eventName }; + if (decision !== undefined) { + output.permissionDecision = decision; + const reasons = results.filter((result) => result.decision === decision && result.reason).map((result) => result.reason); + if (reasons.length > 0) output.permissionDecisionReason = reasons.join("\n"); + } + if (contexts.length > 0) output.additionalContext = contexts.join("\n\n"); + return { hookSpecificOutput: output }; +} + +/** Reads the event on stdin, runs `dispatch(event, report)` and prints its output; never exits nonzero. */ +export function runHookScript(scriptName, dispatch) { + const report = (line) => process.stderr.write(`${line}\n`); + try { + const output = dispatch(JSON.parse(readFileSync(0, "utf8") || "{}"), report); + if (output) process.stdout.write(`${JSON.stringify(output)}\n`); + } catch (error) { + report(`${scriptName}: ${errorText(error)}`); + } +} + +function errorText(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/plugins/vibe/hooks/hooks.json b/plugins/vibe/hooks/hooks.json index 174303fe..851bcb6b 100644 --- a/plugins/vibe/hooks/hooks.json +++ b/plugins/vibe/hooks/hooks.json @@ -2,41 +2,11 @@ "hooks": { "PreToolUse": [ { - "matcher": "Bash", + "matcher": "Bash|Agent|SendMessage", "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kill-guard.mjs\"" - }, - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/foreground-guard.mjs\"" - }, - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/merge-guard.mjs\"" - }, - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/shell-safety-guard.mjs\"" - } - ] - }, - { - "matcher": "Agent", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-guard.mjs\"" - } - ] - }, - { - "matcher": "SendMessage", - "hooks": [ - { - "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pause-guard.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.mjs\"" } ] } @@ -46,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/away-protocol.mjs\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs\"" } ] } diff --git a/plugins/vibe/hooks/kill-guard.mjs b/plugins/vibe/hooks/kill-guard.mjs index 152c12c9..5349b156 100644 --- a/plugins/vibe/hooks/kill-guard.mjs +++ b/plugins/vibe/hooks/kill-guard.mjs @@ -10,15 +10,15 @@ // passes only for a pid recorded here as this session's own, still running the // process that was recorded. // -// Usage (hook): reads the PreToolUse JSON on stdin; prints a deny decision or nothing. -// record: kill-guard.mjs own ... (run from the session's working directory, for example with $!) +// The hook runs from pre-tool-use.mjs. +// Usage (record): kill-guard.mjs own ... (run from the session's working directory, for example with $!) import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { pathToFileURL } from "node:url"; import { commandSegments } from "./command-segments.mjs"; +import { deny, eventCwd, isMain } from "./hook-dispatch.mjs"; const SIGNAL_OPTION = /^-(?:[A-Za-z][A-Za-z0-9+-]*|\d+)$/; const SUBSTITUTED_KILL = /(?:^|[\s;&|(])kill\b[^;&|\n]*(?:\$\(|`)/; @@ -121,33 +121,22 @@ function recordOwn(pids, cwd, file = ledgerFile()) { writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`); } -function hook(input) { - const event = JSON.parse(input || "{}"); +/** The PreToolUse result for a Bash call: a deny naming every signal it may not send, or null. */ +export function killGuard(event) { const command = event?.tool_input?.command; - if (event?.tool_name !== "Bash" || typeof command !== "string") return null; + if (typeof command !== "string") return null; const ledger = readLedger(ledgerFile()); - const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); + const cwd = eventCwd(event); const problems = killProblems(command, (pid) => recordedOwn(ledger, pid, cwd)); - if (problems.length === 0) return null; - return { - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: `Signal only processes this session started and recorded: ${problems.join("; ")}`, - }, - }; + return problems.length === 0 ? null : deny(`Signal only processes this session started and recorded: ${problems.join("; ")}`); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isMain(import.meta.url)) { try { - if (process.argv[2] === "own") { - recordOwn(process.argv.slice(3), process.cwd()); - } else { - const decision = hook(readFileSync(0, "utf8")); - if (decision) process.stdout.write(`${JSON.stringify(decision)}\n`); - } + if (process.argv[2] !== "own") throw new Error("usage: kill-guard.mjs own ..."); + recordOwn(process.argv.slice(3), process.cwd()); } catch (error) { process.stderr.write(`kill-guard: ${error instanceof Error ? error.message : String(error)}\n`); - process.exit(process.argv[2] === "own" ? 1 : 0); + process.exit(1); } } diff --git a/plugins/vibe/hooks/kill-guard.test.mjs b/plugins/vibe/hooks/kill-guard.test.mjs index 950b2b38..cbb25658 100644 --- a/plugins/vibe/hooks/kill-guard.test.mjs +++ b/plugins/vibe/hooks/kill-guard.test.mjs @@ -12,7 +12,9 @@ import { killProblems, recordedOwn } from "./kill-guard.mjs"; // by-name scans, which read test text, never meet a live signal shape here. const sh = (...words) => words.join(" "); const byName = (variant) => (variant === "all" ? "kill" + "all" : "p" + "kill"); -const GUARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "kill-guard.mjs"); +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const GUARD = path.join(HERE, "pre-tool-use.mjs"); +const RECORDER = path.join(HERE, "kill-guard.mjs"); const OWN = 4242; const isOwn = (pid) => pid === OWN; @@ -74,7 +76,10 @@ test("the hook denies a refused Bash command and stays silent otherwise", (t) => assert.equal(run({ tool_name: "Bash", cwd: home, tool_input: { command: "kill 4242" } }).stdout.length > 0, true, "an unrecorded pid is denied"); assert.equal(run({ tool_name: "Bash", cwd: home, tool_input: { command: "ls -la" } }).stdout, ""); assert.equal(run({ tool_name: "Read", tool_input: { file_path: "x" } }).stdout, ""); - const refusedRecord = spawnSync(process.execPath, [GUARD, "own", String(1)], { encoding: "utf8", env: { ...process.env, HOME: home } }); + const refusedRecord = spawnSync(process.execPath, [RECORDER, "own", String(1)], { encoding: "utf8", env: { ...process.env, HOME: home } }); assert.equal(refusedRecord.status, 1); assert.match(refusedRecord.stderr, /only a pid above 1/); + const noMode = spawnSync(process.execPath, [RECORDER], { input: "{}", encoding: "utf8", env: { ...process.env, HOME: home } }); + assert.deepEqual([noMode.status, noMode.stdout], [1, ""]); + assert.match(noMode.stderr, /usage: kill-guard\.mjs own /); }); diff --git a/plugins/vibe/hooks/merge-guard.mjs b/plugins/vibe/hooks/merge-guard.mjs index 5fcdd4b2..7f5b5dcb 100644 --- a/plugins/vibe/hooks/merge-guard.mjs +++ b/plugins/vibe/hooks/merge-guard.mjs @@ -1,4 +1,3 @@ -#!/usr/bin/env node // PreToolUse guard for Bash: `gh pr merge` (a merge, an enqueue or armed // auto-merge) runs only on a pull request the signed-in account authored. An // orchestrator once enqueued a teammate's pull request under the operator's @@ -8,14 +7,12 @@ // it: rerun as `MERGE_AUTHOR_OK= gh pr merge ...`. When gh // cannot answer, the guard stays silent and says why on stderr. // -// Usage (hook): reads the PreToolUse JSON on stdin; prints a deny decision or nothing. -// MERGE_GUARD_GH overrides the gh binary. +// Runs from pre-tool-use.mjs. MERGE_GUARD_GH overrides the gh binary. import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import path from "node:path"; -import { pathToFileURL } from "node:url"; import { commandSegments } from "./command-segments.mjs"; +import { deny, eventCwd } from "./hook-dispatch.mjs"; const VALUE_FLAGS = new Set(["-R", "--repo", "-b", "--body", "-F", "--body-file", "-t", "--subject", "--match-head-commit", "-A", "--author-email"]); @@ -59,19 +56,12 @@ export function mergeProblem(command, gh) { return null; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - const event = JSON.parse(readFileSync(0, "utf8") || "{}"); - const command = event?.tool_input?.command; - if (event?.tool_name === "Bash" && typeof command === "string" && mergeCalls(command).length > 0) { - const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); - const gh = (args) => execFileSync(process.env.MERGE_GUARD_GH || "gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 }); - const problem = mergeProblem(command, gh); - if (problem) { - process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem } })}\n`); - } - } - } catch (error) { - process.stderr.write(`merge-guard: ${error instanceof Error ? error.message : String(error)}\n`); - } +/** The PreToolUse result for a Bash call: a deny for `gh pr merge` on another author's pull request, or null. Throws when gh cannot answer. */ +export function mergeGuard(event) { + const command = event?.tool_input?.command; + if (typeof command !== "string" || mergeCalls(command).length === 0) return null; + const cwd = eventCwd(event); + const gh = (args) => execFileSync(process.env.MERGE_GUARD_GH || "gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 }); + const problem = mergeProblem(command, gh); + return problem ? deny(problem) : null; } diff --git a/plugins/vibe/hooks/merge-guard.test.mjs b/plugins/vibe/hooks/merge-guard.test.mjs index 5114e193..94374bae 100644 --- a/plugins/vibe/hooks/merge-guard.test.mjs +++ b/plugins/vibe/hooks/merge-guard.test.mjs @@ -1,14 +1,14 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { mergeCalls, mergeProblem } from "./merge-guard.mjs"; +import { mergeCalls, mergeGuard, mergeProblem } from "./merge-guard.mjs"; +import { PRE_TOOL_USE_ROUTES } from "./pre-tool-use.mjs"; -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const GUARD = path.join(HERE, "merge-guard.mjs"); +const GUARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "pre-tool-use.mjs"); /** A gh stand-in: the signed-in login, and each PR number's author and branch. */ const fakeGh = (me, prs) => (args) => { @@ -44,5 +44,5 @@ test("the hook denies through the PreToolUse contract and stays silent when gh c const broken = run("gh pr merge 9169 --auto", path.join(dir, "missing-gh")); assert.equal(broken.stdout, ""); assert.match(broken.stderr, /^merge-guard: /); - assert.ok(readFileSync(path.join(HERE, "hooks.json"), "utf8").includes("hooks/merge-guard.mjs"), "the hook is registered"); + assert.ok(PRE_TOOL_USE_ROUTES.Bash.some((guard) => guard.run === mergeGuard), "the Bash guard is routed"); }); diff --git a/plugins/vibe/hooks/pause-guard.mjs b/plugins/vibe/hooks/pause-guard.mjs index 4561baad..f0239315 100644 --- a/plugins/vibe/hooks/pause-guard.mjs +++ b/plugins/vibe/hooks/pause-guard.mjs @@ -1,4 +1,3 @@ -#!/usr/bin/env node // PreToolUse reminder for SendMessage: a hold is TaskStop, not a message. A vibe // session was told HOLD twice (2026-10-23) and kept its writer running: a // message reaches a busy agent only at its next tool round, and never reaches @@ -8,10 +7,7 @@ // instructions ("push nothing more", "stop the investigation and report") are // not pauses and get nothing. // -// Usage (hook): reads the PreToolUse JSON on stdin; prints additional context or nothing. - -import { readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; +// Runs from pre-tool-use.mjs. /** Each pattern is a request that the recipient pause or hold itself. */ export const HOLD_PATTERNS = Object.freeze([ @@ -30,14 +26,8 @@ export function holdReminder(input) { return HOLD_PATTERNS.some((pattern) => pattern.test(message)) ? TASKSTOP_REMINDER : null; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - const event = JSON.parse(readFileSync(0, "utf8") || "{}"); - const reminder = event?.tool_name === "SendMessage" ? holdReminder(event.tool_input) : null; - if (reminder) { - process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: reminder } })}\n`); - } - } catch (error) { - process.stderr.write(`pause-guard: ${error instanceof Error ? error.message : String(error)}\n`); - } +/** The PreToolUse result for a SendMessage call: the TaskStop reminder as context, or null. It never refuses. */ +export function pauseGuard(event) { + const reminder = holdReminder(event?.tool_input); + return reminder ? { context: reminder } : null; } diff --git a/plugins/vibe/hooks/pause-guard.test.mjs b/plugins/vibe/hooks/pause-guard.test.mjs index 398c781e..b34f6dd5 100644 --- a/plugins/vibe/hooks/pause-guard.test.mjs +++ b/plugins/vibe/hooks/pause-guard.test.mjs @@ -1,13 +1,12 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { holdReminder, TASKSTOP_REMINDER } from "./pause-guard.mjs"; +import { holdReminder, pauseGuard, TASKSTOP_REMINDER } from "./pause-guard.mjs"; +import { PRE_TOOL_USE_ROUTES } from "./pre-tool-use.mjs"; -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const HOOK = path.join(HERE, "pause-guard.mjs"); +const HOOK = path.join(path.dirname(fileURLToPath(import.meta.url)), "pre-tool-use.mjs"); test("a message asking its recipient to pause or hold itself gets the TaskStop reminder (two ignored HOLDs, 2026-10-23)", () => { for (const message of ["HOLD: main is being reverted", "PAUSE until the revert lands", "Please pause your writer now.", @@ -32,6 +31,5 @@ test("the SendMessage hook adds the reminder as context, never refuses, and is r assert.equal(output.additionalContext, TASKSTOP_REMINDER); assert.equal(output.permissionDecision, undefined); assert.equal(run("Push nothing more to the branch.").stdout, ""); - const hooks = JSON.parse(readFileSync(path.join(HERE, "hooks.json"), "utf8")).hooks.PreToolUse; - assert.ok(hooks.some((entry) => entry.matcher === "SendMessage" && entry.hooks.some((hook) => hook.command.includes("hooks/pause-guard.mjs")))); + assert.ok(PRE_TOOL_USE_ROUTES.SendMessage.some((guard) => guard.run === pauseGuard), "the SendMessage guard is routed"); }); diff --git a/plugins/vibe/hooks/pre-tool-use.mjs b/plugins/vibe/hooks/pre-tool-use.mjs new file mode 100644 index 00000000..8304ad11 --- /dev/null +++ b/plugins/vibe/hooks/pre-tool-use.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// The vibe plugin's only PreToolUse hook. Each tool call is routed by tool_name +// to its guards, which run in this one process; their results merge as +// hook-dispatch.mjs describes. A new PreToolUse guard is a module added to a +// route here, never another hooks.json command. +// +// Usage (hook): reads the PreToolUse JSON on stdin; prints one decision or context object, or nothing. + +import { agentGuard } from "./agent-guard.mjs"; +import { foregroundGuard } from "./foreground-guard.mjs"; +import { isMain, mergeResults, runGuards, runHookScript } from "./hook-dispatch.mjs"; +import { killGuard } from "./kill-guard.mjs"; +import { mergeGuard } from "./merge-guard.mjs"; +import { pauseGuard } from "./pause-guard.mjs"; +import { shellSafetyGuard } from "./shell-safety-guard.mjs"; + +/** The guards each tool runs, in order; hooks.json's PreToolUse matcher names exactly these tools. */ +export const PRE_TOOL_USE_ROUTES = Object.freeze({ + Bash: Object.freeze([ + { name: "kill-guard", run: killGuard }, + { name: "foreground-guard", run: foregroundGuard }, + { name: "merge-guard", run: mergeGuard }, + { name: "shell-safety-guard", run: shellSafetyGuard }, + ]), + Agent: Object.freeze([{ name: "agent-guard", run: agentGuard }]), + SendMessage: Object.freeze([{ name: "pause-guard", run: pauseGuard }]), +}); + +/** The PreToolUse output for `event`, or null; `report(line)` receives each failing guard's error. */ +export function preToolUse(event, report) { + const tool = event?.tool_name; + const guards = typeof tool === "string" && Object.hasOwn(PRE_TOOL_USE_ROUTES, tool) ? PRE_TOOL_USE_ROUTES[tool] : []; + return mergeResults("PreToolUse", runGuards(guards, event, report)); +} + +if (isMain(import.meta.url)) runHookScript("pre-tool-use", preToolUse); diff --git a/plugins/vibe/hooks/pre-tool-use.test.mjs b/plugins/vibe/hooks/pre-tool-use.test.mjs new file mode 100644 index 00000000..03b0cdb7 --- /dev/null +++ b/plugins/vibe/hooks/pre-tool-use.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { agentLaunchProblem } from "./agent-guard.mjs"; +import { AWAY_CHECKLIST } from "./away-protocol.mjs"; +import { mergeResults } from "./hook-dispatch.mjs"; +import { killProblems } from "./kill-guard.mjs"; +import { TASKSTOP_REMINDER } from "./pause-guard.mjs"; +import { PRE_TOOL_USE_ROUTES } from "./pre-tool-use.mjs"; +import { forcePushProblem } from "./shell-safety-guard.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PRE_TOOL_USE = path.join(HERE, "pre-tool-use.mjs"); +const USER_PROMPT_SUBMIT = path.join(HERE, "user-prompt-submit.mjs"); +// Assembled from words so the repository's signal scans, which read test text, never meet a live shape. +const sh = (...words) => words.join(" "); +const BY_NAME_KILL = sh("p" + "kill", "-f", "vitest"); +const FORCE_PUSH = sh("git push", "-f", "origin fix/x"); + +/** The exact line one old guard script printed for a decision. */ +const line = (hookSpecificOutput) => `${JSON.stringify({ hookSpecificOutput })}\n`; +const denyLine = (reason) => line({ hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }); +const killReason = (command) => `Signal only processes this session started and recorded: ${killProblems(command, () => false).join("; ")}`; + +function sandbox(t) { + const dir = mkdtempSync(path.join(tmpdir(), "vibe-pre-tool-use-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const env = { ...process.env, HOME: dir, MERGE_GUARD_GH: path.join(dir, "missing-gh") }; + const run = (script, payload) => spawnSync(process.execPath, [script], { input: typeof payload === "string" ? payload : JSON.stringify(payload), encoding: "utf8", env }); + return { dir, run }; +} + +test("each tool's payload yields the exact stdout and exit code its old guard script printed", (t) => { + const { dir, run } = sandbox(t); + const bash = run(PRE_TOOL_USE, { hook_event_name: "PreToolUse", tool_name: "Bash", cwd: dir, tool_input: { command: FORCE_PUSH } }); + assert.deepEqual([bash.status, bash.stdout, bash.stderr], [0, denyLine(forcePushProblem(FORCE_PUSH)), ""]); + + const worker = "vibe:vibe-change-worker"; + const agent = run(PRE_TOOL_USE, { hook_event_name: "PreToolUse", tool_name: "Agent", cwd: dir, tool_input: { subagent_type: worker, prompt: "go" } }); + assert.deepEqual([agent.status, agent.stdout, agent.stderr], [0, denyLine(agentLaunchProblem({ subagent_type: worker })), ""]); + + const message = run(PRE_TOOL_USE, { hook_event_name: "PreToolUse", tool_name: "SendMessage", cwd: dir, tool_input: { to: "w", message: "HOLD everything" } }); + assert.deepEqual([message.status, message.stdout, message.stderr], [0, line({ hookEventName: "PreToolUse", additionalContext: TASKSTOP_REMINDER }), ""]); + + const prompt = run(USER_PROMPT_SUBMIT, { hook_event_name: "UserPromptSubmit", cwd: dir, prompt: "stepping away. brb." }); + assert.deepEqual([prompt.status, prompt.stdout, prompt.stderr], [0, line({ hookEventName: "UserPromptSubmit", additionalContext: AWAY_CHECKLIST }), ""]); +}); + +test("tools with no guard, and calls no guard refuses, print nothing", (t) => { + const { dir, run } = sandbox(t); + for (const payload of [ + { tool_name: "Read", cwd: dir, tool_input: { file_path: "x" } }, + { tool_name: "constructor", cwd: dir, tool_input: {} }, + { tool_name: "Bash", cwd: dir, tool_input: { command: "git status" } }, + { tool_name: "Agent", cwd: dir, tool_input: { subagent_type: "general-purpose" } }, + { tool_name: "SendMessage", cwd: dir, tool_input: { to: "w", message: "Push nothing more." } }, + ]) { + const result = run(PRE_TOOL_USE, { hook_event_name: "PreToolUse", ...payload }); + assert.deepEqual([result.status, result.stdout, result.stderr], [0, "", ""], payload.tool_name); + } + const prompt = run(USER_PROMPT_SUBMIT, { hook_event_name: "UserPromptSubmit", prompt: "go back to the PR" }); + assert.deepEqual([prompt.status, prompt.stdout], [0, ""]); +}); + +test("two guards refusing one command give one deny carrying both reasons in guard order", (t) => { + const { dir, run } = sandbox(t); + const command = `${BY_NAME_KILL} && ${FORCE_PUSH}`; + const result = run(PRE_TOOL_USE, { hook_event_name: "PreToolUse", tool_name: "Bash", cwd: dir, tool_input: { command } }); + assert.deepEqual([result.status, result.stdout, result.stderr], [0, denyLine(`${killReason(command)}\n${forcePushProblem(command)}`), ""]); +}); + +test("a throwing guard is reported under its name and the others still decide", (t) => { + const { dir, run } = sandbox(t); + const command = `gh pr merge 9169 --auto && ${FORCE_PUSH}`; + const result = run(PRE_TOOL_USE, { hook_event_name: "PreToolUse", tool_name: "Bash", cwd: dir, tool_input: { command } }); + assert.equal(result.status, 0); + assert.equal(result.stdout, denyLine(forcePushProblem(command))); + assert.match(result.stderr, /^merge-guard: /); + const alone = run(PRE_TOOL_USE, { hook_event_name: "PreToolUse", tool_name: "Bash", cwd: dir, tool_input: { command: "gh pr merge 9169 --auto" } }); + assert.deepEqual([alone.status, alone.stdout], [0, ""], "a guard that cannot answer fails open, as its own script did"); +}); + +test("unreadable stdin fails open with the script named on stderr", (t) => { + const { run } = sandbox(t); + for (const [script, name] of [[PRE_TOOL_USE, "pre-tool-use"], [USER_PROMPT_SUBMIT, "user-prompt-submit"]]) { + const result = run(script, "{not json"); + assert.deepEqual([result.status, result.stdout], [0, ""]); + assert.match(result.stderr, new RegExp(`^${name}: `)); + } +}); + +test("results merge as deny > defer > ask > allow and keep every context", () => { + assert.equal(mergeResults("PreToolUse", []), null); + assert.deepEqual(mergeResults("PreToolUse", [{ decision: "allow", reason: "a" }, { decision: "ask", reason: "b" }, { decision: "defer" }]).hookSpecificOutput, + { hookEventName: "PreToolUse", permissionDecision: "defer" }); + assert.deepEqual(mergeResults("PreToolUse", [{ decision: "ask", reason: "b" }, { context: "one" }, { decision: "deny", reason: "c" }, { context: "two" }, { decision: "deny", reason: "d" }]).hookSpecificOutput, + { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "c\nd", additionalContext: "one\n\ntwo" }); + assert.deepEqual(mergeResults("PreToolUse", [{ decision: "allow", reason: "a" }, { decision: "ask", reason: "b" }]).hookSpecificOutput.permissionDecisionReason, "b"); +}); + +test("hooks.json registers one command per event, and the PreToolUse matcher names exactly the routed tools", () => { + const { hooks } = JSON.parse(readFileSync(path.join(HERE, "hooks.json"), "utf8")); + const commands = (event) => hooks[event].flatMap((entry) => entry.hooks.map((hook) => hook.command)); + assert.deepEqual(Object.keys(hooks).sort(), ["PreToolUse", "UserPromptSubmit"]); + assert.deepEqual(commands("PreToolUse"), ['node "${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.mjs"']); + assert.deepEqual(commands("UserPromptSubmit"), ['node "${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs"']); + assert.deepEqual(hooks.PreToolUse[0].matcher.split("|").sort(), Object.keys(PRE_TOOL_USE_ROUTES).sort()); +}); diff --git a/plugins/vibe/hooks/shell-safety-guard.mjs b/plugins/vibe/hooks/shell-safety-guard.mjs index 3a35cce5..c361326a 100644 --- a/plugins/vibe/hooks/shell-safety-guard.mjs +++ b/plugins/vibe/hooks/shell-safety-guard.mjs @@ -1,4 +1,3 @@ -#!/usr/bin/env node // PreToolUse guard for Bash: workers may not invent credential routes to fetch // evidence or force-push any remote. Both mistakes can escape a written rule, // especially through an ssh command aimed at another machine. @@ -9,10 +8,11 @@ // unquoted parameter; and e2e-slot.sh reruns tested an old commit when they // started before the push landed, because the slot captured whatever HEAD was. -import { readFileSync } from "node:fs"; +// Runs from pre-tool-use.mjs. + import path from "node:path"; -import { pathToFileURL } from "node:url"; import { commandSegments } from "./command-segments.mjs"; +import { deny } from "./hook-dispatch.mjs"; const TOKEN = /(?:\b(?:gh_token|github_token|[a-z][a-z0-9_]*token)\b|\$\{?[a-z][a-z0-9_]*token\}?|--password(?:-stdin)?\b|(?:^|\s)-p(?:\s|$)|["']?auth["']?\s*:)/i; const CREDENTIAL_ROUTE = /(?:\bDOCKER_CONFIG\b|\bdocker\s+login\b|["']?auths["']?\s*:)/i; @@ -68,20 +68,11 @@ function invokes(words, script) { return name === script || (SHELLS.has(name) && path.basename(words[1] ?? "") === script); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - const event = JSON.parse(readFileSync(0, "utf8") || "{}"); - const command = event?.tool_input?.command; - if (event?.tool_name === "Bash" && typeof command === "string") { - const problem = credentialProblem(command) ?? forcePushProblem(command) ?? watcherLaunchProblem(event.tool_input) - ?? wordSplitProblem(command) ?? e2eSlotProblem(command); - if (problem) { - process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { - hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem, - } })}\n`); - } - } - } catch (error) { - process.stderr.write(`shell-safety-guard: ${error instanceof Error ? error.message : String(error)}\n`); - } +/** The PreToolUse result for a Bash call: a deny for the first unsafe shape it finds, or null. */ +export function shellSafetyGuard(event) { + const command = event?.tool_input?.command; + if (typeof command !== "string") return null; + const problem = credentialProblem(command) ?? forcePushProblem(command) ?? watcherLaunchProblem(event.tool_input) + ?? wordSplitProblem(command) ?? e2eSlotProblem(command); + return problem ? deny(problem) : null; } diff --git a/plugins/vibe/hooks/shell-safety-guard.test.mjs b/plugins/vibe/hooks/shell-safety-guard.test.mjs index 818b9d22..5646391c 100644 --- a/plugins/vibe/hooks/shell-safety-guard.test.mjs +++ b/plugins/vibe/hooks/shell-safety-guard.test.mjs @@ -1,13 +1,12 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { credentialProblem, e2eSlotProblem, forcePushProblem, watcherLaunchProblem, wordSplitProblem } from "./shell-safety-guard.mjs"; +import { PRE_TOOL_USE_ROUTES } from "./pre-tool-use.mjs"; +import { credentialProblem, e2eSlotProblem, forcePushProblem, shellSafetyGuard, watcherLaunchProblem, wordSplitProblem } from "./shell-safety-guard.mjs"; -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const GUARD = path.join(HERE, "shell-safety-guard.mjs"); +const GUARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "pre-tool-use.mjs"); test("credential workarounds are refused at the first auth failure", () => { const refused = [ @@ -44,7 +43,7 @@ test("the PreToolUse hook denies unsafe Bash and is registered", () => { assert.equal(JSON.parse(result.stdout).hookSpecificOutput.permissionDecision, "deny"); } assert.equal(run("gh run view 123 --log").stdout, ""); - assert.ok(readFileSync(path.join(HERE, "hooks.json"), "utf8").includes("hooks/shell-safety-guard.mjs")); + assert.ok(PRE_TOOL_USE_ROUTES.Bash.some((guard) => guard.run === shellSafetyGuard), "the Bash guard is routed"); }); test("the fleet watcher starts only as its own background call (started with & twice, 2026-10-24)", () => { diff --git a/plugins/vibe/hooks/user-prompt-submit.mjs b/plugins/vibe/hooks/user-prompt-submit.mjs new file mode 100644 index 00000000..262fd353 --- /dev/null +++ b/plugins/vibe/hooks/user-prompt-submit.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +// The vibe plugin's only UserPromptSubmit hook. Every prompt runs these guards in +// this one process; their context merges as hook-dispatch.mjs describes. A new +// UserPromptSubmit guard is a module added here, never another hooks.json command. +// +// Usage (hook): reads the UserPromptSubmit JSON on stdin; prints additional context or nothing. + +import { awayProtocol } from "./away-protocol.mjs"; +import { isMain, mergeResults, runGuards, runHookScript } from "./hook-dispatch.mjs"; + +/** The guards every prompt runs, in order. */ +export const USER_PROMPT_SUBMIT_GUARDS = Object.freeze([{ name: "away-protocol", run: awayProtocol }]); + +/** The UserPromptSubmit output for `event`, or null; `report(line)` receives each failing guard's error. */ +export function userPromptSubmit(event, report) { + return mergeResults("UserPromptSubmit", runGuards(USER_PROMPT_SUBMIT_GUARDS, event, report)); +} + +if (isMain(import.meta.url)) runHookScript("user-prompt-submit", userPromptSubmit); diff --git a/plugins/vibe/tools/python/test_vibe_1076_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1076_lessons_contract.py index 511e501a..d1b3ea8f 100644 --- a/plugins/vibe/tools/python/test_vibe_1076_lessons_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1076_lessons_contract.py @@ -2,7 +2,7 @@ from pathlib import Path -from vibe_contract_text import flat +from vibe_contract_text import flat, hook_guard_routed PLUGIN = Path(__file__).resolve().parents[2] @@ -69,10 +69,7 @@ def test_the_shepherd_stops_on_review_threads() -> None: def test_claude_sessions_run_the_kill_guard_on_every_bash_command() -> None: - import json - - hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text())["hooks"]["PreToolUse"] - assert any(entry.get("matcher") == "Bash" and any("kill-guard.mjs" in hook["command"] for hook in entry["hooks"]) for entry in hooks) + assert hook_guard_routed(PLUGIN, "PreToolUse", "kill-guard", tool="Bash") assert "kill-guard.mjs own " in _text(VIBE / "references" / "guardrails.md") diff --git a/plugins/vibe/tools/python/test_vibe_1077_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1077_lessons_contract.py index 99ef96ee..29f85ac6 100644 --- a/plugins/vibe/tools/python/test_vibe_1077_lessons_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1077_lessons_contract.py @@ -1,9 +1,8 @@ """Contract coverage for the vibe 1.0.77 release: question ids and acknowledgements, and the foreground guard.""" -import json from pathlib import Path -from vibe_contract_text import flat +from vibe_contract_text import flat, hook_guard_routed PLUGIN = Path(__file__).resolve().parents[2] @@ -22,9 +21,7 @@ def test_answers_echo_question_ids_and_unacknowledged_answers_are_flagged() -> N def test_vibe_worktrees_refuse_long_foreground_waits() -> None: - hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text())["hooks"]["PreToolUse"] - commands = [hook["command"] for entry in hooks if entry.get("matcher") == "Bash" for hook in entry["hooks"]] - assert any("foreground-guard.mjs" in command for command in commands) + assert hook_guard_routed(PLUGIN, "PreToolUse", "foreground-guard", tool="Bash") def test_every_pr_open_step_requires_the_lanes_ci_would_run() -> None: @@ -45,6 +42,5 @@ def test_the_watch_set_finds_every_open_pr_and_names_its_writer() -> None: def test_away_and_back_phrases_bring_the_away_protocol() -> None: - hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text())["hooks"] - assert any("away-protocol.mjs" in hook["command"] for entry in hooks["UserPromptSubmit"] for hook in entry["hooks"]) + assert hook_guard_routed(PLUGIN, "UserPromptSubmit", "away-protocol") assert "`away-protocol` hook" in _text(FLEET / "SKILL.md") diff --git a/plugins/vibe/tools/python/test_vibe_1078_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1078_lessons_contract.py index b9d1fd61..d57e7cfe 100644 --- a/plugins/vibe/tools/python/test_vibe_1078_lessons_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1078_lessons_contract.py @@ -1,10 +1,9 @@ """Contract coverage for the vibe 1.0.78 release.""" -import json import re from pathlib import Path -from vibe_contract_text import flat +from vibe_contract_text import flat, hook_guard_routed PLUGIN = Path(__file__).resolve().parents[2] @@ -57,9 +56,7 @@ def test_the_2026_10_22_open_lessons_are_wired() -> None: assert "--single-review-ruling" in flat((PLUGIN / "skills" / "handoff" / "SKILL.md").read_text()) assert "only while an operator's single-review ruling is in force" in flat((PLUGIN / "skills" / "vibe" / "SKILL.md").read_text()) assert "`prs-ignore.txt` or another program's `prs.txt`" in flat((MONITOR / "SKILL.md").read_text()) - hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text()) - commands = [hook["command"] for entry in hooks["hooks"]["PreToolUse"] for hook in entry["hooks"]] - assert any("hooks/merge-guard.mjs" in command for command in commands) + assert hook_guard_routed(PLUGIN, "PreToolUse", "merge-guard", tool="Bash") def test_green_watch_fixes_pass_vibe_gates_and_results_name_their_commit() -> None: diff --git a/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py index 346c364f..2bc99f42 100644 --- a/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1079_lessons_contract.py @@ -2,7 +2,7 @@ from pathlib import Path -from vibe_contract_text import flat +from vibe_contract_text import flat, hook_guard_routed PLUGIN = Path(__file__).resolve().parents[2] @@ -39,12 +39,11 @@ def test_handoff_compares_each_consumer_with_its_prototype_route() -> None: def test_worker_rules_forbid_credential_workarounds_and_force_push_everywhere() -> None: - hook_manifest = (PLUGIN / "hooks" / "hooks.json").read_text() worker_preamble = flat( (PLUGIN / "skills" / "fleet-orchestrator" / "references" / "templates.md").read_text() ) vibe = flat((VIBE / "SKILL.md").read_text()) - assert "shell-safety-guard.mjs" in hook_manifest + assert hook_guard_routed(PLUGIN, "PreToolUse", "shell-safety-guard", tool="Bash") for text in (worker_preamble, vibe): assert "first authentication failure" in text assert "CI logs or the API" in text diff --git a/plugins/vibe/tools/python/test_vibe_1081_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1081_lessons_contract.py index 9a71fde6..a3e77dfb 100644 --- a/plugins/vibe/tools/python/test_vibe_1081_lessons_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1081_lessons_contract.py @@ -2,9 +2,10 @@ from __future__ import annotations -import json from pathlib import Path +from vibe_contract_text import hook_guard_routed + ROOT = Path(__file__).resolve().parents[2] @@ -13,11 +14,6 @@ def read(path: str) -> str: return (ROOT / path).read_text() -def test_release_and_both_manifests_are_1081() -> None: - for manifest in (".claude-plugin/plugin.json", ".codex-plugin/plugin.json"): - assert json.loads(read(manifest))["version"] == "1.0.81" - - def test_targeted_tests_are_the_lanes_test_lanes_names() -> None: verify = read("agents/vibe-verify-worker.md") assert "each named lane by the command\n`pnpm test:lanes` prints" in verify @@ -35,8 +31,7 @@ def test_bot_threads_and_holds_have_owned_rules() -> None: assert "`source: bot` thread is fixed only when it reproduces" in handoff assert "vibe.reviewBots" in read("agents/vibe-environment-worker.md") assert "Hold an agent with TaskStop, never a message" in fleet - hooks = json.loads(read("hooks/hooks.json"))["hooks"]["PreToolUse"] - assert any(entry["matcher"] == "SendMessage" for entry in hooks) + assert hook_guard_routed(ROOT, "PreToolUse", "pause-guard", tool="SendMessage") def test_env_less_sessions_push_and_qa_env_uses_vibe_up() -> None: diff --git a/plugins/vibe/tools/python/test_vibe_1082_one_hook_per_event_contract.py b/plugins/vibe/tools/python/test_vibe_1082_one_hook_per_event_contract.py new file mode 100644 index 00000000..2cfa4900 --- /dev/null +++ b/plugins/vibe/tools/python/test_vibe_1082_one_hook_per_event_contract.py @@ -0,0 +1,25 @@ +"""Contracts for the vibe 1.0.82 release: one hook script per event.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from vibe_contract_text import hook_guard_routed + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_release_and_both_manifests_are_1082() -> None: + for manifest in (".claude-plugin/plugin.json", ".codex-plugin/plugin.json"): + assert json.loads((ROOT / manifest).read_text())["version"] == "1.0.82" + + +def test_every_guard_runs_from_its_events_one_script() -> None: + for guard in ("kill-guard", "foreground-guard", "merge-guard", "shell-safety-guard"): + assert hook_guard_routed(ROOT, "PreToolUse", guard, tool="Bash"), guard + assert hook_guard_routed(ROOT, "PreToolUse", "agent-guard", tool="Agent") + assert hook_guard_routed(ROOT, "PreToolUse", "pause-guard", tool="SendMessage") + assert hook_guard_routed(ROOT, "UserPromptSubmit", "away-protocol") + assert not hook_guard_routed(ROOT, "PreToolUse", "agent-guard", tool="Bash") diff --git a/plugins/vibe/tools/python/vibe_contract_text.py b/plugins/vibe/tools/python/vibe_contract_text.py index 1443f9d9..ead7686c 100644 --- a/plugins/vibe/tools/python/vibe_contract_text.py +++ b/plugins/vibe/tools/python/vibe_contract_text.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import re from pathlib import Path @@ -41,3 +42,20 @@ def paragraph(text: str, marker: str) -> str: def agent(name: str) -> str: """The raw text of the vibe agent definition `name`.""" return (AGENTS / f"{name}.md").read_text() + + +def hook_guard_routed(plugin: Path, event: str, guard: str, tool: str | None = None) -> bool: + """Whether hooks.json's one `event` command runs `guard` (for `tool`, when given, through its matcher and route).""" + entries = json.loads((plugin / "hooks" / "hooks.json").read_text())["hooks"][event] + commands = [(entry.get("matcher", ""), hook["command"]) for entry in entries for hook in entry["hooks"]] + if len(commands) != 1: + return False + matcher, command = commands[0] + script = re.search(r"hooks/([\w.-]+\.mjs)", command) + if script is None or (tool is not None and tool not in matcher.split("|")): + return False + source = (plugin / "hooks" / script.group(1)).read_text() + if tool is None: + return f'name: "{guard}"' in source + route = re.search(rf"\b{re.escape(tool)}: Object\.freeze\(\[(.*?)\]\)", source, re.DOTALL) + return route is not None and f'name: "{guard}"' in route.group(1) diff --git a/tools/plugin-manifests/src/hook-registrations.test.ts b/tools/plugin-manifests/src/hook-registrations.test.ts new file mode 100644 index 00000000..f55cbbf5 --- /dev/null +++ b/tools/plugin-manifests/src/hook-registrations.test.ts @@ -0,0 +1,108 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { findHookRegistrationProblems, formatHookProblem } from "./hook-registrations.js"; +import { CLAUDE_MANIFEST, CODEX_MANIFEST } from "./manifest-versions.js"; + +const REPO_PLUGINS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../../plugins"); + +let pluginsDir: string; + +beforeEach(() => { + pluginsDir = mkdtempSync(join(tmpdir(), "plugin-hooks-")); +}); + +afterEach(() => { + rmSync(pluginsDir, { recursive: true, force: true }); +}); + +function writeJson(plugin: string, file: string, body: unknown): void { + const path = join(pluginsDir, plugin, file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(body)); +} + +const command = (script: string) => ({ type: "command", command: `node "\${CLAUDE_PLUGIN_ROOT}/hooks/${script}"` }); + +describe("findHookRegistrationProblems", () => { + it("reports vibe 1.0.81's layout: six PreToolUse commands across three matchers", () => { + writeJson("vibe", "hooks/hooks.json", { + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: ["kill-guard.mjs", "foreground-guard.mjs", "merge-guard.mjs", "shell-safety-guard.mjs"].map(command) }, + { matcher: "Agent", hooks: [command("agent-guard.mjs")] }, + { matcher: "SendMessage", hooks: [command("pause-guard.mjs")] }, + ], + UserPromptSubmit: [{ hooks: [command("away-protocol.mjs")] }], + }, + }); + + expect(findHookRegistrationProblems(pluginsDir, {})).toEqual([ + { plugin: "vibe", event: "PreToolUse", count: 6, allowed: 1, sources: ["hooks/hooks.json"] }, + ]); + }); + + it("passes one command per event", () => { + writeJson("vibe", "hooks/hooks.json", { + hooks: { + PreToolUse: [{ matcher: "Bash|Agent|SendMessage", hooks: [command("pre-tool-use.mjs")] }], + UserPromptSubmit: [{ hooks: [command("user-prompt-submit.mjs")] }], + }, + }); + + expect(findHookRegistrationProblems(pluginsDir, {})).toEqual([]); + }); + + it("counts every plugin and every hook source, Codex and manifest hooks included", () => { + writeJson("alpha", "hooks/hooks.json", { hooks: { Stop: [{ hooks: [command("a.mjs")] }] } }); + writeJson("alpha", ".codex/hooks.json", { hooks: { Stop: [{ hooks: [command("b.mjs")] }] } }); + writeJson("beta", CLAUDE_MANIFEST, { version: "1.0.0", hooks: "./extra/hooks-config.json" }); + writeJson("beta", "extra/hooks-config.json", { hooks: { SessionStart: [{ hooks: [command("a.mjs")] }] } }); + writeJson("beta", CODEX_MANIFEST, { version: "1.0.0", hooks: { SessionStart: [{ hooks: [command("b.mjs")] }] } }); + + expect(findHookRegistrationProblems(pluginsDir, {})).toEqual([ + { plugin: "alpha", event: "Stop", count: 2, allowed: 1, sources: [".codex/hooks.json", "hooks/hooks.json"] }, + { plugin: "beta", event: "SessionStart", count: 2, allowed: 1, sources: [".codex-plugin/plugin.json (inline hooks)", "extra/hooks-config.json"] }, + ]); + }); + + it("counts a hooks.json named by a manifest once", () => { + writeJson("alpha", CLAUDE_MANIFEST, { version: "1.0.0", hooks: ["./hooks/hooks.json"] }); + writeJson("alpha", "hooks/hooks.json", { hooks: { Stop: [{ hooks: [command("a.mjs")] }] } }); + + expect(findHookRegistrationProblems(pluginsDir, {})).toEqual([]); + }); + + it("holds a grandfathered count exactly: more fails, and fewer fails until the entry shrinks", () => { + const twoStops = (scripts: string[]) => writeJson("code", "hooks/hooks.json", { hooks: { Stop: scripts.map((script) => ({ hooks: [command(script)] })) } }); + + twoStops(["a.sh", "b.sh"]); + expect(findHookRegistrationProblems(pluginsDir, { code: { Stop: 2 } })).toEqual([]); + + twoStops(["a.sh", "b.sh", "c.sh"]); + expect(findHookRegistrationProblems(pluginsDir, { code: { Stop: 2 } }).map(formatHookProblem)).toEqual([ + "plugins/code: Stop registers 3 hook handlers (hooks/hooks.json), allowed 2; register one script for the event and route to each guard inside it", + ]); + + twoStops(["a.sh"]); + expect(findHookRegistrationProblems(pluginsDir, { code: { Stop: 2 } }).map(formatHookProblem)).toEqual([ + "plugins/code: Stop now registers 1 hook handlers but GRANDFATHERED_HOOK_COUNTS allows 2; lower or delete the entry (it only shrinks)", + ]); + }); + + it("reports a grandfathered entry for a plugin that no longer exists", () => { + expect(findHookRegistrationProblems(pluginsDir, { gone: { Stop: 2 } })).toEqual([ + { plugin: "gone", event: "Stop", count: 0, allowed: 2, sources: [] }, + ]); + }); +}); + +describe("repository plugins", () => { + it("register at most one hook handler per event", () => { + const problems = findHookRegistrationProblems(REPO_PLUGINS_DIR).map(formatHookProblem); + + expect(problems, "one script per hook event; see AGENTS.md").toEqual([]); + }); +}); diff --git a/tools/plugin-manifests/src/hook-registrations.ts b/tools/plugin-manifests/src/hook-registrations.ts new file mode 100644 index 00000000..219bd048 --- /dev/null +++ b/tools/plugin-manifests/src/hook-registrations.ts @@ -0,0 +1,133 @@ +/** + * A plugin registers at most one hook command per event: one script per event + * that reads stdin once and routes to its guards internally. Separate commands + * for one event run in parallel, each paying its own process start, and their + * decisions are merged by Claude Code rather than by the plugin. CI fails while + * any plugin registers more than one handler for an event, counted across every + * matcher group and every hook source the plugin ships: each hooks.json in the + * plugin tree (Claude's hooks/hooks.json and any Codex hooks.json) and a `hooks` + * field in either plugin manifest, given as a path, a list of paths, or inline. + */ + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { CLAUDE_MANIFEST, CODEX_MANIFEST } from "./manifest-versions.js"; + +export const HOOKS_FILE = "hooks.json"; + +/** + * Plugins that registered several handlers for an event before this check + * existed, with the exact count each still has. Shrink-only: a count may only + * go down, and an entry that no longer matches the plugin fails until it is + * lowered or deleted. Never add an entry. + */ +export const GRANDFATHERED_HOOK_COUNTS: Readonly>>> = { + code: { PreToolUse: 2, SubagentStop: 2 }, +}; + +export interface HookRegistrationProblem { + plugin: string; + event: string; + count: number; + allowed: number; + sources: string[]; +} + +type EventCounts = Map }>; + +export function findHookRegistrationProblems( + pluginsDir: string, + grandfathered: Readonly>>> = GRANDFATHERED_HOOK_COUNTS, +): HookRegistrationProblem[] { + const problems: HookRegistrationProblem[] = []; + const plugins = readdirSync(pluginsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + for (const plugin of plugins) { + const counts = countHandlers(join(pluginsDir, plugin)); + const allowances = grandfathered[plugin] ?? {}; + const events = new Set([...counts.keys(), ...Object.keys(allowances)]); + for (const event of [...events].sort()) { + const found = counts.get(event); + const count = found?.count ?? 0; + const grandfatheredCount = allowances[event]; + const allowed = grandfatheredCount ?? 1; + if (count > allowed || (grandfatheredCount !== undefined && count !== grandfatheredCount)) { + problems.push({ plugin, event, count, allowed, sources: [...(found?.sources ?? [])].sort() }); + } + } + } + for (const plugin of Object.keys(grandfathered).sort()) { + if (plugins.includes(plugin)) continue; + for (const [event, allowed] of Object.entries(grandfathered[plugin] ?? {})) { + problems.push({ plugin, event, count: 0, allowed, sources: [] }); + } + } + return problems; +} + +export function formatHookProblem({ plugin, event, count, allowed, sources }: HookRegistrationProblem): string { + const where = sources.length > 0 ? ` (${sources.join(", ")})` : ""; + if (count > allowed) { + return `plugins/${plugin}: ${event} registers ${count} hook handlers${where}, allowed ${allowed}; register one script for the event and route to each guard inside it`; + } + return `plugins/${plugin}: ${event} now registers ${count} hook handlers but GRANDFATHERED_HOOK_COUNTS allows ${allowed}; lower or delete the entry (it only shrinks)`; +} + +function countHandlers(pluginDir: string): EventCounts { + const counts: EventCounts = new Map(); + const seen = new Set(); + const add = (config: unknown, source: string): void => { + for (const [event, groups] of Object.entries(eventMap(config))) { + const handlers = Array.isArray(groups) ? groups.reduce((sum, group) => sum + handlerCount(group), 0) : 0; + if (handlers === 0) continue; + const entry = counts.get(event) ?? { count: 0, sources: new Set() }; + entry.count += handlers; + entry.sources.add(source); + counts.set(event, entry); + } + }; + const addFile = (path: string): void => { + const absolute = resolve(path); + if (seen.has(absolute) || !existsSync(absolute)) return; + seen.add(absolute); + add(JSON.parse(readFileSync(absolute, "utf8")), relative(pluginDir, absolute)); + }; + for (const file of hookFiles(pluginDir)) addFile(file); + for (const manifest of [CLAUDE_MANIFEST, CODEX_MANIFEST]) { + const manifestPath = join(pluginDir, manifest); + if (!existsSync(manifestPath)) continue; + const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8")); + const hooks = isRecord(parsed) ? parsed.hooks : undefined; + const paths = typeof hooks === "string" ? [hooks] : Array.isArray(hooks) ? hooks : []; + for (const path of paths) if (typeof path === "string") addFile(join(pluginDir, path)); + if (isRecord(hooks)) add(hooks, `${manifest} (inline hooks)`); + } + return counts; +} + +function hookFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) files.push(...hookFiles(path)); + else if (entry.name === HOOKS_FILE) files.push(path); + } + return files.sort(); +} + +/** The event-to-groups map of a hook config: `{ hooks: { Event: [...] } }`, or the bare map when inline. */ +function eventMap(config: unknown): Record { + if (!isRecord(config)) return {}; + return isRecord(config.hooks) ? config.hooks : config; +} + +function handlerCount(group: unknown): number { + return isRecord(group) && Array.isArray(group.hooks) ? group.hooks.length : 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} From 4f91624f9b8017a3377f1b41c7f982821c3efbcf Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Mon, 26 Oct 2026 12:34:06 -0500 Subject: [PATCH 2/3] fix(code): run one hook script per event - Register pre-tool-use.sh for PreToolUse and subagent-stop.sh for SubagentStop instead of two commands each - pre-tool-use.sh runs pretooluse-hook.sh for Read, Bash, Write and Edit (its old matcher) and pre-tool-use-hook.sh for every tool, in parallel - subagent-stop.sh runs loop-stop-hook.sh, then subagent-stop-hook.sh, the order both scripts and the README already assumed - dispatch.sh passes one handler's output through unchanged and merges several: deny > defer > ask > allow or block wins with every reason, context and system message kept; exit 2 still blocks - Drop the repo check's grandfather entry; it has no exceptions - Bump code to 2.0.2 Testing: node --test plugins/code/hooks/dispatch.test.mjs (real payloads against each handler run alone, merge and failure cases), code hook pytest and shell suites, plugin-manifests vitest, full pytest plugins/, ruff, pyright Risks: about 25 ms more per tool call (50 to 70 ms before, 80 to 90 ms after, 20 runs on an idle machine) --- CHANGELOG.md | 11 +- CLAUDE.md | 2 +- plugins/code/.claude-plugin/plugin.json | 2 +- plugins/code/.codex-plugin/plugin.json | 2 +- plugins/code/README.md | 16 +- plugins/code/hooks/dispatch.sh | 144 +++++++++++++++ plugins/code/hooks/dispatch.test.mjs | 174 ++++++++++++++++++ plugins/code/hooks/hooks.json | 15 +- plugins/code/hooks/pre-tool-use.sh | 22 +++ plugins/code/hooks/subagent-stop.sh | 15 ++ .../src/hook-registrations.test.ts | 45 ++--- .../src/hook-registrations.ts | 44 +---- 12 files changed, 406 insertions(+), 86 deletions(-) create mode 100644 plugins/code/hooks/dispatch.sh create mode 100644 plugins/code/hooks/dispatch.test.mjs create mode 100755 plugins/code/hooks/pre-tool-use.sh create mode 100755 plugins/code/hooks/subagent-stop.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe01cd3..3929d3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to the claude-plugins project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`. +### code v2.0.2 + +PreToolUse and SubagentStop each run one registered script, which runs that event's handler scripts and merges their results. + +#### Changed +- `hooks.json` registers `hooks/pre-tool-use.sh` for PreToolUse and `hooks/subagent-stop.sh` for SubagentStop, in place of two commands each. `pre-tool-use.sh` runs `pretooluse-hook.sh` for Read, Bash, Write and Edit (its old matcher) and `pre-tool-use-hook.sh` for every tool; `subagent-stop.sh` runs `loop-stop-hook.sh` and then `subagent-stop-hook.sh`. +- The new `hooks/dispatch.sh` passes a single handler's output through unchanged. Several JSON outputs merge: the strongest permission decision (deny, defer, ask, allow) or a block wins with every reason joined, and every context and system message is kept. Plain text is printed when no handler produced JSON. A handler exiting 2 still blocks; one exiting with another code contributes nothing, and its exit code is kept when no other handler produced output. +- SubagentStop handlers now run in sequence, `loop-stop-hook.sh` first, so it always reads the `.agent-types` record before `subagent-stop-hook.sh` removes it. They previously ran in parallel. + ### vibe v1.0.82 Each hook event now runs one vibe script, which routes to every guard in a single process. @@ -15,7 +24,7 @@ Each hook event now runs one vibe script, which routes to every guard in a singl - `kill-guard.mjs` remains the `own ` recorder; run without `own` it now prints its usage and exits 1. #### Added -- A repository check in `tools/plugin-manifests` fails CI when any plugin's hook configuration (each `hooks.json` in the plugin tree or a `hooks` field in either manifest) registers more than one handler for an event. The `code` plugin's existing two PreToolUse and two SubagentStop handlers are held at that count and may only shrink. +- A repository check in `tools/plugin-manifests` fails CI when any plugin's hook configuration (each `hooks.json` in the plugin tree or a `hooks` field in either manifest) registers more than one handler for an event. It has no exceptions. ### vibe v1.0.81 diff --git a/CLAUDE.md b/CLAUDE.md index 006a14cf..f0c11844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ The orchestrator prompt at `plugins/code/prompts/prompt.md` coordinates 8 workfl ### Hooks -Registered in `plugins/code/hooks/hooks.json` across 5 lifecycle events: `SessionStart`, `SessionEnd`, `SubagentStart`, `SubagentStop`, `PreToolUse`. `SubagentStart` injects environment variables and relevant learning patterns. `SubagentStop` logs outcomes and telemetry. `PreToolUse` fires on `Read|Bash|Write|Edit` for just-in-time pattern injection. +Registered in `plugins/code/hooks/hooks.json`, one command per event (see AGENTS.md). `SubagentStart` injects environment variables and relevant learning patterns. `SubagentStop` (`subagent-stop.sh`) runs the loop and learning stop handlers and logs outcomes and telemetry. `PreToolUse` (`pre-tool-use.sh`) runs just-in-time pattern injection for `Read|Bash|Write|Edit` and the tool-call sentinel for every tool. ### TOON Format diff --git a/plugins/code/.claude-plugin/plugin.json b/plugins/code/.claude-plugin/plugin.json index 6b84f870..a7a13505 100644 --- a/plugins/code/.claude-plugin/plugin.json +++ b/plugins/code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "code", "description": "Code and planning framework plugin", - "version": "2.0.1", + "version": "2.0.2", "dependencies": ["closedloop-core"], "author": { "name": "ClosedLoop", diff --git a/plugins/code/.codex-plugin/plugin.json b/plugins/code/.codex-plugin/plugin.json index 6bfa2e41..f97d5c95 100644 --- a/plugins/code/.codex-plugin/plugin.json +++ b/plugins/code/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code", - "version": "2.0.1", + "version": "2.0.2", "description": "Code and planning framework plugin.", "author": { "name": "ClosedLoop", diff --git a/plugins/code/README.md b/plugins/code/README.md index 31387a3f..4d915678 100644 --- a/plugins/code/README.md +++ b/plugins/code/README.md @@ -330,7 +330,11 @@ Staged pipeline for inventorying a Claude Design export into reviewable findings ## Hooks -Hooks are shell scripts that fire at Claude Code lifecycle events. Registered in `hooks/hooks.json`. +Hooks are shell scripts that fire at Claude Code lifecycle events. Registered in `hooks/hooks.json`, one command per event. PreToolUse and SubagentStop each register one event script that runs that event's handler scripts below through `dispatch.sh`, which merges their results into one output. + +### `pre-tool-use.sh` and `subagent-stop.sh` (PreToolUse, SubagentStop) + +The registered commands for those two events. `pre-tool-use.sh` runs `pretooluse-hook.sh` for Read, Bash, Write and Edit, then `pre-tool-use-hook.sh` for every tool. `subagent-stop.sh` runs `loop-stop-hook.sh`, then `subagent-stop-hook.sh`. `dispatch.sh` passes a single handler's output through unchanged; when several produce JSON it keeps the strongest permission decision (deny, defer, ask, allow) or a block, with every reason, context and system message joined. ### `session-start-hook.sh` (SessionStart) @@ -352,7 +356,7 @@ Runs when any subagent starts. Performs three tasks: 2. **Agent type tracking**: Writes the agent type, short name, and start timestamp to `.agent-types/{agent_id}` so the stop hook can track timing and type. 3. **Learning injection**: Reads `~/.closedloop-ai/learnings/org-patterns.toon`, filters patterns matching the agent's name, sorts by category priority (mistake > convention > pattern > insight) and confidence, and injects up to 15 patterns into the agent's context via `additionalContext`. Also injects environment variables (`CLOSEDLOOP_WORKDIR`, `CLAUDE_PLUGIN_ROOT`, etc.) into every agent's context. -### `subagent-stop-hook.sh` (SubagentStop) +### `subagent-stop-hook.sh` (SubagentStop handler) Runs when any subagent exits. Performs: @@ -361,7 +365,7 @@ Runs when any subagent exits. Performs: 3. **Outcome logging**: Records which injected patterns were applied or merely injected to `outcomes.log` for success rate computation. 4. **Performance instrumentation**: Computes agent wall-clock duration from start timestamp and appends a timing event to `perf.jsonl`. -### `loop-stop-hook.sh` (SubagentStop, runs before subagent-stop-hook) +### `loop-stop-hook.sh` (SubagentStop handler, runs before subagent-stop-hook) Implements the validation loop for agents registered in `loop-agents.json`. When an agent exits: @@ -371,13 +375,13 @@ Implements the validation loop for agents registered in `loop-agents.json`. When 4. If validation passes, allows the agent to exit (returns nothing) 5. If validation fails or the promise is absent, increments the iteration counter and blocks the agent with feedback to continue -### `pretooluse-hook.sh` (PreToolUse, matches Read/Bash/Write/Edit) +### `pretooluse-hook.sh` (PreToolUse handler, for Read/Bash/Write/Edit) Injects tool-specific learnings just before tool execution. Filters `org-patterns.toon` by tool type (Bash patterns get build/test tags; Write/Edit patterns get language-specific tags based on file extension). Injects up to 10 matching patterns as `additionalContext`. Also auto-allows tool calls targeting `.closedloop-ai/` workspace paths without prompting. -### `pre-tool-use-hook.sh` (PreToolUse) +### `pre-tool-use-hook.sh` (PreToolUse handler, every tool) -Writes per-tool-call sentinel files to `{WORKDIR}/.tool-calls/` so post-tool handling can compute duration and attribution. After resolving the session workdir, it hydrates persisted native run metadata from `.closedloop-ai/config.env` so `tool` and `spawn` rows share the same run id, command, and iteration as the native command. Emits `spawn` perf events for `Agent` tool calls. Registered separately from `pretooluse-hook.sh` and designed to fail open. +Writes per-tool-call sentinel files to `{WORKDIR}/.tool-calls/` so post-tool handling can compute duration and attribution. After resolving the session workdir, it hydrates persisted native run metadata from `.closedloop-ai/config.env` so `tool` and `spawn` rows share the same run id, command, and iteration as the native command. Emits `spawn` perf events for `Agent` tool calls. Runs after `pretooluse-hook.sh` in `pre-tool-use.sh` and is designed to fail open. ### `post-tool-use-hook.sh` (PostToolUse) diff --git a/plugins/code/hooks/dispatch.sh b/plugins/code/hooks/dispatch.sh new file mode 100644 index 00000000..eb191c37 --- /dev/null +++ b/plugins/code/hooks/dispatch.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Shared library (sourced, not executed) for the code plugin's per-event hook +# scripts. hooks.json registers one command per event; that script reads stdin +# once and calls `dispatch_hooks [--parallel] "$INPUT" handler...`, which runs +# each handler script with the same input, one at a time in the order given +# (or all at once with --parallel, for handlers that share no state, as +# separate hooks.json commands ran), merges in handler order, and prints one +# result (code.claude.com/docs/en/hooks: on exit 0 stdout is read as JSON only +# when it is one object with nothing around it but whitespace). +# +# Merging the handlers that exit 0: +# - An empty object `{}` contributes nothing. When it is all there is, `{}` is printed. +# - A single contribution is printed byte for byte, so one handler firing looks +# exactly as it did when it was its own hooks.json command. +# - Several JSON objects merge: hookSpecificOutput.permissionDecision takes the +# highest of deny > defer > ask > allow (the order Claude Code applies across +# PreToolUse hooks) with every reason given for it joined; a top-level +# decision "block" wins over none with every reason joined; additionalContext +# and systemMessage keep every value; any other field comes from the first +# handler that set it. +# - Plain text (stdout that is not one JSON object) is printed as is when no +# handler produced JSON, and moved to stderr otherwise, so the one JSON object +# still parses. Claude Code reads plain stdout on these events into the debug +# log only, as it reads stderr on exit 0. +# +# Failures keep each handler's own effect: a handler exiting 2 blocks, so the +# dispatcher prints that handler's stderr (the block reason) and exits 2. A +# handler exiting with any other nonzero code contributes nothing (its stdout +# was never parsed before either); the dispatcher exits with that code when no +# handler produced a decision, context, message or text, so the non-blocking +# error notice still appears. Otherwise every handler's stderr is passed +# through in handler order. + +# shellcheck disable=SC2034 +DISPATCH_MERGE_JQ=' +def rank: {"deny": 4, "defer": 3, "ask": 2, "allow": 1}[.] // 0; +def joined($values; $separator): [$values[] | select(type == "string" and . != "")] | if length == 0 then null else join($separator) end; +. as $outs +| (reduce $outs[] as $out ({}; $out * .)) as $base +| ([$outs[] | .hookSpecificOutput.permissionDecision // empty] | max_by(rank)) as $decision +| (if $decision == null then $base + else $base + | .hookSpecificOutput.permissionDecision = $decision + | (joined([$outs[] | select(.hookSpecificOutput.permissionDecision == $decision) | .hookSpecificOutput.permissionDecisionReason]; "\n")) as $reason + | if $reason == null then del(.hookSpecificOutput.permissionDecisionReason) else .hookSpecificOutput.permissionDecisionReason = $reason end + end) +| (joined([$outs[] | .hookSpecificOutput.additionalContext]; "\n\n")) as $context +| (if $context == null then . else .hookSpecificOutput.additionalContext = $context end) +| (if any($outs[]; .decision == "block") then + .decision = "block" + | (joined([$outs[] | select(.decision == "block") | .reason]; "\n\n")) as $reason + | if $reason == null then del(.reason) else .reason = $reason end + else . end) +| (joined([$outs[] | .systemMessage]; "\n\n")) as $message +| (if $message == null then . else .systemMessage = $message end) +' + +dispatch_hooks() { + local parallel=0 + if [[ "${1:-}" == "--parallel" ]]; then + parallel=1 + shift + fi + local input="$1" + shift + local work + work=$(mktemp -d "${TMPDIR:-/tmp}/closedloop-hook.XXXXXX") || return 0 + local count=0 handler + for handler in "$@"; do + count=$((count + 1)) + if [[ $parallel -eq 1 ]]; then + _dispatch_run "$handler" "$input" "$work/$count" & + else + _dispatch_run "$handler" "$input" "$work/$count" + fi + done + wait + + local index status output first_failure=0 + local json_files=() text_files=() blocking_errors=() all_errors=() meaningful=() + for ((index = 1; index <= count; index++)); do + read -r status < "$work/$index.status" + all_errors+=("$work/$index.err") + if [[ $status -eq 2 ]]; then + blocking_errors+=("$work/$index.err") + continue + elif [[ $status -ne 0 ]]; then + [[ $first_failure -ne 0 ]] || first_failure=$status + continue + fi + output="" + IFS= read -r -d '' output < "$work/$index.out" || true + output="${output//[[:space:]]/}" + if [[ -z "$output" ]]; then + continue + elif [[ "$output" == "{}" ]]; then + json_files+=("$work/$index.out") + elif jq -se 'length == 1 and (.[0] | type == "object")' "$work/$index.out" > /dev/null 2>&1; then + json_files+=("$work/$index.out") + meaningful+=("$work/$index.out") + else + text_files+=("$work/$index.out") + fi + done + + if [[ ${#blocking_errors[@]} -gt 0 ]]; then + _dispatch_cat_to_stderr "${blocking_errors[@]}" + rm -rf "$work" + exit 2 + fi + _dispatch_cat_to_stderr ${all_errors[@]+"${all_errors[@]}"} + + local file + if [[ ${#meaningful[@]} -gt 0 ]]; then + _dispatch_cat_to_stderr ${text_files[@]+"${text_files[@]}"} + if [[ ${#meaningful[@]} -eq 1 ]]; then + cat "${meaningful[0]}" + else + jq -cs "$DISPATCH_MERGE_JQ" "${meaningful[@]}" + fi + elif [[ ${#text_files[@]} -gt 0 ]]; then + for file in "${text_files[@]}"; do cat "$file"; done + elif [[ $first_failure -ne 0 ]]; then + rm -rf "$work" + exit "$first_failure" + elif [[ ${#json_files[@]} -gt 0 ]]; then + cat "${json_files[0]}" + fi + rm -rf "$work" + exit 0 +} + +_dispatch_run() { + local status=0 + printf '%s' "$2" | "$1" > "$3.out" 2> "$3.err" || status=$? + printf '%s' "$status" > "$3.status" +} + +_dispatch_cat_to_stderr() { + local file + for file in "$@"; do + if [[ -s "$file" ]]; then cat "$file" >&2; fi + done +} diff --git a/plugins/code/hooks/dispatch.test.mjs b/plugins/code/hooks/dispatch.test.mjs new file mode 100644 index 00000000..27db0cf0 --- /dev/null +++ b/plugins/code/hooks/dispatch.test.mjs @@ -0,0 +1,174 @@ +// The code plugin registers one script per hook event (pre-tool-use.sh, +// subagent-stop.sh) that runs the handler scripts it used to register +// separately. Each real-payload case runs a handler alone, as its own hooks.json +// command did, and the event script on an identical fixture, and compares them. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const HOOKS = path.dirname(fileURLToPath(import.meta.url)); +const script = (name) => path.join(HOOKS, name); +const SESSION = "s-1"; +const AGENT = "agent-1"; + +function cleanEnv(home) { + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("CLOSEDLOOP_"))); + return { ...env, HOME: home, CLAUDE_SECURITY_OVERRIDES: path.join(home, "no-overrides.json") }; +} + +/** A fresh ClosedLoop session fixture: cwd mapped to a workdir, optional self-learning. */ +function fixture(t, { selfLearning = false } = {}) { + const root = mkdtempSync(path.join(tmpdir(), "code-hook-dispatch-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const cwd = path.join(root, "repo"); + const workdir = path.join(root, "work"); + mkdirSync(path.join(cwd, ".closedloop-ai"), { recursive: true }); + mkdirSync(path.join(workdir, ".closedloop-ai"), { recursive: true }); + writeFileSync(path.join(cwd, ".closedloop-ai", `session-${SESSION}.workdir`), workdir); + writeFileSync(path.join(workdir, ".closedloop-ai", "config.env"), `CLOSEDLOOP_SELF_LEARNING=${selfLearning}\nCLOSEDLOOP_RUN_ID=run-1\n`); + const run = (name, payload) => spawnSync(script(name), { input: JSON.stringify(payload), encoding: "utf8", env: cleanEnv(root) }); + return { root, cwd, workdir, run }; +} + +const result = (r) => ({ status: r.status, stdout: r.stdout }); + +test("PreToolUse: each handler's decision passes through unchanged, and both handlers still run for the tools they ran on", (t) => { + for (const [toolName, toolInput] of [ + ["Bash", { command: "cat ~/.ssh/id_" + "rsa" }], + ["Read", { file_path: "/tmp/x/.closedloop-ai/plan.json" }], + ["Write", { file_path: "src/app.ts" }], + ]) { + const old = fixture(t); + const payload = { hook_event_name: "PreToolUse", session_id: SESSION, cwd: old.cwd, tool_name: toolName, tool_input: toolInput, tool_use_id: "tu-1" }; + const alone = old.run("pretooluse-hook.sh", payload); + const sentinelAlone = old.run("pre-tool-use-hook.sh", payload); + const neu = fixture(t); + const dispatched = neu.run("pre-tool-use.sh", { ...payload, cwd: neu.cwd }); + assert.deepEqual(result(dispatched), { status: 0, stdout: alone.stdout }, toolName); + assert.deepEqual([alone.status, sentinelAlone.status, sentinelAlone.stdout], [0, 0, ""]); + const sentinel = (fx) => JSON.parse(readFileSync(path.join(fx.workdir, ".tool-calls", "tu-1"), "utf8")); + const { started_at: _old, ...oldSentinel } = sentinel(old); + const { started_at: _new, ...newSentinel } = sentinel(neu); + assert.deepEqual(newSentinel, oldSentinel, `${toolName} sentinel`); + } +}); + +test("PreToolUse: a tool outside the old matcher runs only the sentinel handler", (t) => { + const old = fixture(t); + const payload = { hook_event_name: "PreToolUse", session_id: SESSION, cwd: old.cwd, tool_name: "Agent", tool_input: { subagent_type: "code:plan-writer" }, tool_use_id: "tu-2" }; + old.run("pre-tool-use-hook.sh", payload); + const neu = fixture(t); + const dispatched = neu.run("pre-tool-use.sh", { ...payload, cwd: neu.cwd }); + assert.deepEqual(result(dispatched), { status: 0, stdout: "" }); + assert.ok(existsSync(path.join(neu.workdir, ".tool-calls", "tu-2"))); + const spawnEvent = (fx) => JSON.parse(readFileSync(path.join(fx.workdir, "perf.jsonl"), "utf8").trim()); + assert.equal(spawnEvent(neu).planned_subagent_type, spawnEvent(old).planned_subagent_type); + assert.equal(existsSync(path.join(neu.workdir, ".learnings")), false, "the learnings handler did not run for Agent"); +}); + +test("PreToolUse outside a ClosedLoop session: silent unless the security policy refuses", (t) => { + const fx = fixture(t); + const quiet = fx.run("pre-tool-use.sh", { hook_event_name: "PreToolUse", session_id: "other", cwd: fx.cwd, tool_name: "Bash", tool_input: { command: "ls" }, tool_use_id: "tu-3" }); + assert.deepEqual(result(quiet), { status: 0, stdout: "" }); + const refused = fx.run("pre-tool-use.sh", { hook_event_name: "PreToolUse", session_id: "other", cwd: fx.cwd, tool_name: "Read", tool_input: { file_path: "/home/u/.aws/credentials" } }); + assert.equal(JSON.parse(refused.stdout).hookSpecificOutput.permissionDecision, "deny"); +}); + +/** A loop agent that ended without its promise, with self-learning on and no acknowledgment. */ +function stoppingLoopAgent(t) { + const fx = fixture(t, { selfLearning: true }); + mkdirSync(path.join(fx.workdir, ".agent-types"), { recursive: true }); + writeFileSync(path.join(fx.workdir, ".agent-types", AGENT), "code:code-reviewer|code-reviewer|2026-10-26T00:00:00Z"); + writeFileSync(path.join(fx.workdir, ".closedloop-ai", "code-reviewer-loop.local.md"), "---\niteration: 0\nmax_iterations: 5\nprd_file: \"prd.md\"\nworkdir: \".\"\n---\nReview the diff again.\n"); + const transcript = path.join(fx.root, "agent.jsonl"); + writeFileSync(transcript, `${JSON.stringify({ role: "assistant", message: { content: [{ type: "text", text: "Done reviewing." }] } })}\n`); + const payload = { hook_event_name: "SubagentStop", session_id: SESSION, cwd: fx.cwd, agent_id: AGENT, agent_transcript_path: transcript, stop_hook_active: false }; + return { ...fx, payload }; +} + +test("SubagentStop: both handlers blocking give one block carrying both reasons and messages, loop handler first", (t) => { + const portable = (fx, stdout) => JSON.parse(stdout.replaceAll(fx.root, "")); + const loopOnly = stoppingLoopAgent(t); + const loop = portable(loopOnly, loopOnly.run("loop-stop-hook.sh", loopOnly.payload).stdout); + const learnOnly = stoppingLoopAgent(t); + const learn = portable(learnOnly, learnOnly.run("subagent-stop-hook.sh", learnOnly.payload).stdout); + assert.equal(loop.decision, "block"); + assert.equal(learn.decision, "block"); + + const fx = stoppingLoopAgent(t); + const dispatched = fx.run("subagent-stop.sh", fx.payload); + assert.equal(dispatched.status, 0); + assert.deepEqual(portable(fx, dispatched.stdout), { + decision: "block", + reason: `${loop.reason}\n\n${learn.reason}`, + systemMessage: `${loop.systemMessage}\n\n${learn.systemMessage}`, + }); + assert.equal(existsSync(path.join(fx.workdir, ".agent-types", AGENT)), true, "a blocked stop keeps the agent record for the next stop"); + assert.match(readFileSync(path.join(fx.workdir, ".closedloop-ai", "code-reviewer-loop.local.md"), "utf8"), /^iteration: 1$/m, "the loop handler advanced its iteration"); +}); + +test("SubagentStop: an agent outside the loop prints the stop handler's {} as before", (t) => { + const fx = fixture(t); + mkdirSync(path.join(fx.workdir, ".agent-types"), { recursive: true }); + writeFileSync(path.join(fx.workdir, ".agent-types", AGENT), "code:pre-explorer|pre-explorer|2026-10-26T00:00:00Z"); + const payload = { hook_event_name: "SubagentStop", session_id: SESSION, cwd: fx.cwd, agent_id: AGENT }; + assert.deepEqual(result(fx.run("subagent-stop.sh", payload)), { status: 0, stdout: "{}\n" }); + const outside = fixture(t); + assert.deepEqual(result(outside.run("subagent-stop.sh", { ...payload, session_id: "other", cwd: outside.cwd })), { status: 0, stdout: "" }); +}); + +/** Runs dispatch_hooks over stand-in handlers, each `{ stdout, stderr, status }`. */ +function dispatchStandIns(t, handlers) { + const dir = mkdtempSync(path.join(tmpdir(), "code-hook-standins-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const files = handlers.map(({ stdout = "", stderr = "", status = 0 }, index) => { + const file = path.join(dir, `h${index}.sh`); + writeFileSync(path.join(dir, `h${index}.out`), stdout); + writeFileSync(path.join(dir, `h${index}.err`), stderr); + writeFileSync(file, `#!/usr/bin/env bash\ncat > /dev/null\ncat "${dir}/h${index}.out"\ncat "${dir}/h${index}.err" >&2\nexit ${status}\n`); + chmodSync(file, 0o755); + return file; + }); + const quoted = files.map((file) => `"${file}"`).join(" "); + return spawnSync("/bin/bash", ["-c", `source "${script("dispatch.sh")}"; dispatch_hooks '{}' ${quoted}`], { encoding: "utf8" }); +} + +const pre = (permissionDecision, permissionDecisionReason, extra = {}) => + JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason, ...extra } }); + +test("dispatch_hooks merges as deny > defer > ask > allow and keeps every reason and context", (t) => { + const merged = dispatchStandIns(t, [{ stdout: pre("allow", "a") }, { stdout: pre("deny", "d1", { additionalContext: "c1" }) }, { stdout: pre("deny", "d2", { additionalContext: "c2" }) }]); + assert.equal(merged.status, 0); + assert.deepEqual(JSON.parse(merged.stdout).hookSpecificOutput, { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "d1\nd2", additionalContext: "c1\n\nc2" }); + assert.equal(JSON.parse(dispatchStandIns(t, [{ stdout: pre("allow", "a") }, { stdout: pre("ask", "q") }]).stdout).hookSpecificOutput.permissionDecision, "ask"); + const one = `${pre("allow", "only")}\n`; + assert.deepEqual(result(dispatchStandIns(t, [{ stdout: one }, { stdout: "{}\n" }, { stdout: "" }])), { status: 0, stdout: one }, "a single contribution passes through byte for byte"); +}); + +test("dispatch_hooks keeps plain text, empty objects and failures as each handler had them", (t) => { + assert.deepEqual(result(dispatchStandIns(t, [{ stdout: "Loop: Validation passed!\n" }, { stdout: "{}\n" }])), { status: 0, stdout: "Loop: Validation passed!\n" }); + const textAndBlock = dispatchStandIns(t, [{ stdout: "note\n" }, { stdout: '{"decision":"block","reason":"r"}' }]); + assert.deepEqual([textAndBlock.status, textAndBlock.stdout, textAndBlock.stderr], [0, '{"decision":"block","reason":"r"}', "note\n"]); + assert.deepEqual(result(dispatchStandIns(t, [{ stdout: "{}\n" }, { stdout: "{}\n" }])), { status: 0, stdout: "{}\n" }); + + const blocked = dispatchStandIns(t, [{ stderr: "quiet\n" }, { stderr: "blocked: reason\n", status: 2 }, { stdout: pre("allow", "a") }]); + assert.deepEqual([blocked.status, blocked.stdout, blocked.stderr], [2, "", "blocked: reason\n"]); + + const failedAlone = dispatchStandIns(t, [{ stderr: "boom\n", status: 1 }, { stdout: "{}\n" }]); + assert.deepEqual([failedAlone.status, failedAlone.stdout, failedAlone.stderr], [1, "", "boom\n"], "the non-blocking error notice still shows"); + const failedBesideDecision = dispatchStandIns(t, [{ stdout: pre("deny", "ignored"), stderr: "boom\n", status: 1 }, { stdout: pre("deny", "kept") }]); + assert.deepEqual([failedBesideDecision.status, JSON.parse(failedBesideDecision.stdout).hookSpecificOutput.permissionDecisionReason, failedBesideDecision.stderr], [0, "kept", "boom\n"]); +}); + +test("hooks.json registers one command per event, PreToolUse and SubagentStop through their event scripts", () => { + const { hooks } = JSON.parse(readFileSync(path.join(HOOKS, "hooks.json"), "utf8")); + for (const [event, groups] of Object.entries(hooks)) { + assert.equal(groups.flatMap((group) => group.hooks).length, 1, event); + } + assert.deepEqual(hooks.PreToolUse, [{ hooks: [{ type: "command", command: "${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.sh" }] }]); + assert.deepEqual(hooks.SubagentStop, [{ hooks: [{ type: "command", command: "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop.sh" }] }]); +}); diff --git a/plugins/code/hooks/hooks.json b/plugins/code/hooks/hooks.json index 308c9676..2367fd63 100644 --- a/plugins/code/hooks/hooks.json +++ b/plugins/code/hooks/hooks.json @@ -31,14 +31,8 @@ ], "PreToolUse": [ { - "matcher": "Read|Bash|Write|Edit", "hooks": [ - { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse-hook.sh" } - ] - }, - { - "hooks": [ - { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use-hook.sh" } + { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use.sh" } ] } ], @@ -52,12 +46,7 @@ "SubagentStop": [ { "hooks": [ - { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/loop-stop-hook.sh" } - ] - }, - { - "hooks": [ - { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop-hook.sh" } + { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-stop.sh" } ] } ] diff --git a/plugins/code/hooks/pre-tool-use.sh b/plugins/code/hooks/pre-tool-use.sh new file mode 100755 index 00000000..569c0c40 --- /dev/null +++ b/plugins/code/hooks/pre-tool-use.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# The code plugin's only PreToolUse hook: hooks.json registers one command per +# event. Routes each tool call to its handlers, which dispatch.sh runs and merges. +# A new PreToolUse handler is added here, never as another hooks.json command. + +HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./dispatch.sh +source "$HOOKS_DIR/dispatch.sh" + +INPUT="" +IFS= read -r -d '' INPUT || true +TOOL_NAME=$(jq -r '.tool_name // empty' <<< "$INPUT" 2> /dev/null || true) + +# Security policy, workspace auto-allow and tool learnings apply to these tools +# only (the matcher it was registered with); the tool-call sentinel applies to every tool. +HANDLERS=() +case "$TOOL_NAME" in + Read|Bash|Write|Edit) HANDLERS+=("$HOOKS_DIR/pretooluse-hook.sh") ;; +esac +HANDLERS+=("$HOOKS_DIR/pre-tool-use-hook.sh") + +dispatch_hooks --parallel "$INPUT" "${HANDLERS[@]}" diff --git a/plugins/code/hooks/subagent-stop.sh b/plugins/code/hooks/subagent-stop.sh new file mode 100755 index 00000000..e49f33ae --- /dev/null +++ b/plugins/code/hooks/subagent-stop.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# The code plugin's only SubagentStop hook: hooks.json registers one command per +# event. dispatch.sh runs the handlers in order and merges their results. A new +# SubagentStop handler is added here, never as another hooks.json command. +# +# loop-stop-hook.sh runs first: it reads the .agent-types/ record that +# subagent-stop-hook.sh deletes when it finishes. + +HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./dispatch.sh +source "$HOOKS_DIR/dispatch.sh" + +INPUT="" +IFS= read -r -d '' INPUT || true +dispatch_hooks "$INPUT" "$HOOKS_DIR/loop-stop-hook.sh" "$HOOKS_DIR/subagent-stop-hook.sh" diff --git a/tools/plugin-manifests/src/hook-registrations.test.ts b/tools/plugin-manifests/src/hook-registrations.test.ts index f55cbbf5..882e299c 100644 --- a/tools/plugin-manifests/src/hook-registrations.test.ts +++ b/tools/plugin-manifests/src/hook-registrations.test.ts @@ -39,8 +39,8 @@ describe("findHookRegistrationProblems", () => { }, }); - expect(findHookRegistrationProblems(pluginsDir, {})).toEqual([ - { plugin: "vibe", event: "PreToolUse", count: 6, allowed: 1, sources: ["hooks/hooks.json"] }, + expect(findHookRegistrationProblems(pluginsDir)).toEqual([ + { plugin: "vibe", event: "PreToolUse", count: 6, sources: ["hooks/hooks.json"] }, ]); }); @@ -52,7 +52,7 @@ describe("findHookRegistrationProblems", () => { }, }); - expect(findHookRegistrationProblems(pluginsDir, {})).toEqual([]); + expect(findHookRegistrationProblems(pluginsDir)).toEqual([]); }); it("counts every plugin and every hook source, Codex and manifest hooks included", () => { @@ -62,9 +62,9 @@ describe("findHookRegistrationProblems", () => { writeJson("beta", "extra/hooks-config.json", { hooks: { SessionStart: [{ hooks: [command("a.mjs")] }] } }); writeJson("beta", CODEX_MANIFEST, { version: "1.0.0", hooks: { SessionStart: [{ hooks: [command("b.mjs")] }] } }); - expect(findHookRegistrationProblems(pluginsDir, {})).toEqual([ - { plugin: "alpha", event: "Stop", count: 2, allowed: 1, sources: [".codex/hooks.json", "hooks/hooks.json"] }, - { plugin: "beta", event: "SessionStart", count: 2, allowed: 1, sources: [".codex-plugin/plugin.json (inline hooks)", "extra/hooks-config.json"] }, + expect(findHookRegistrationProblems(pluginsDir)).toEqual([ + { plugin: "alpha", event: "Stop", count: 2, sources: [".codex/hooks.json", "hooks/hooks.json"] }, + { plugin: "beta", event: "SessionStart", count: 2, sources: [".codex-plugin/plugin.json (inline hooks)", "extra/hooks-config.json"] }, ]); }); @@ -72,29 +72,22 @@ describe("findHookRegistrationProblems", () => { writeJson("alpha", CLAUDE_MANIFEST, { version: "1.0.0", hooks: ["./hooks/hooks.json"] }); writeJson("alpha", "hooks/hooks.json", { hooks: { Stop: [{ hooks: [command("a.mjs")] }] } }); - expect(findHookRegistrationProblems(pluginsDir, {})).toEqual([]); + expect(findHookRegistrationProblems(pluginsDir)).toEqual([]); }); - it("holds a grandfathered count exactly: more fails, and fewer fails until the entry shrinks", () => { - const twoStops = (scripts: string[]) => writeJson("code", "hooks/hooks.json", { hooks: { Stop: scripts.map((script) => ({ hooks: [command(script)] })) } }); - - twoStops(["a.sh", "b.sh"]); - expect(findHookRegistrationProblems(pluginsDir, { code: { Stop: 2 } })).toEqual([]); - - twoStops(["a.sh", "b.sh", "c.sh"]); - expect(findHookRegistrationProblems(pluginsDir, { code: { Stop: 2 } }).map(formatHookProblem)).toEqual([ - "plugins/code: Stop registers 3 hook handlers (hooks/hooks.json), allowed 2; register one script for the event and route to each guard inside it", - ]); - - twoStops(["a.sh"]); - expect(findHookRegistrationProblems(pluginsDir, { code: { Stop: 2 } }).map(formatHookProblem)).toEqual([ - "plugins/code: Stop now registers 1 hook handlers but GRANDFATHERED_HOOK_COUNTS allows 2; lower or delete the entry (it only shrinks)", - ]); - }); + it("reports the code plugin's 2.0.1 layout: two PreToolUse and two SubagentStop commands", () => { + const script = (name: string) => ({ type: "command", command: `\${CLAUDE_PLUGIN_ROOT}/hooks/${name}` }); + writeJson("code", "hooks/hooks.json", { + hooks: { + SessionStart: [{ hooks: [script("session-start-hook.sh")] }], + PreToolUse: [{ matcher: "Read|Bash|Write|Edit", hooks: [script("pretooluse-hook.sh")] }, { hooks: [script("pre-tool-use-hook.sh")] }], + SubagentStop: [{ hooks: [script("loop-stop-hook.sh")] }, { hooks: [script("subagent-stop-hook.sh")] }], + }, + }); - it("reports a grandfathered entry for a plugin that no longer exists", () => { - expect(findHookRegistrationProblems(pluginsDir, { gone: { Stop: 2 } })).toEqual([ - { plugin: "gone", event: "Stop", count: 0, allowed: 2, sources: [] }, + expect(findHookRegistrationProblems(pluginsDir).map(formatHookProblem)).toEqual([ + "plugins/code: PreToolUse registers 2 hook handlers (hooks/hooks.json), allowed 1; register one script for the event and route to each handler inside it", + "plugins/code: SubagentStop registers 2 hook handlers (hooks/hooks.json), allowed 1; register one script for the event and route to each handler inside it", ]); }); }); diff --git a/tools/plugin-manifests/src/hook-registrations.ts b/tools/plugin-manifests/src/hook-registrations.ts index 219bd048..c698f951 100644 --- a/tools/plugin-manifests/src/hook-registrations.ts +++ b/tools/plugin-manifests/src/hook-registrations.ts @@ -7,6 +7,7 @@ * matcher group and every hook source the plugin ships: each hooks.json in the * plugin tree (Claude's hooks/hooks.json and any Codex hooks.json) and a `hooks` * field in either plugin manifest, given as a path, a list of paths, or inline. + * There are no exceptions. */ import { existsSync, readdirSync, readFileSync } from "node:fs"; @@ -15,30 +16,16 @@ import { CLAUDE_MANIFEST, CODEX_MANIFEST } from "./manifest-versions.js"; export const HOOKS_FILE = "hooks.json"; -/** - * Plugins that registered several handlers for an event before this check - * existed, with the exact count each still has. Shrink-only: a count may only - * go down, and an entry that no longer matches the plugin fails until it is - * lowered or deleted. Never add an entry. - */ -export const GRANDFATHERED_HOOK_COUNTS: Readonly>>> = { - code: { PreToolUse: 2, SubagentStop: 2 }, -}; - export interface HookRegistrationProblem { plugin: string; event: string; count: number; - allowed: number; sources: string[]; } type EventCounts = Map }>; -export function findHookRegistrationProblems( - pluginsDir: string, - grandfathered: Readonly>>> = GRANDFATHERED_HOOK_COUNTS, -): HookRegistrationProblem[] { +export function findHookRegistrationProblems(pluginsDir: string): HookRegistrationProblem[] { const problems: HookRegistrationProblem[] = []; const plugins = readdirSync(pluginsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -46,33 +33,16 @@ export function findHookRegistrationProblems( .sort(); for (const plugin of plugins) { const counts = countHandlers(join(pluginsDir, plugin)); - const allowances = grandfathered[plugin] ?? {}; - const events = new Set([...counts.keys(), ...Object.keys(allowances)]); - for (const event of [...events].sort()) { - const found = counts.get(event); - const count = found?.count ?? 0; - const grandfatheredCount = allowances[event]; - const allowed = grandfatheredCount ?? 1; - if (count > allowed || (grandfatheredCount !== undefined && count !== grandfatheredCount)) { - problems.push({ plugin, event, count, allowed, sources: [...(found?.sources ?? [])].sort() }); - } - } - } - for (const plugin of Object.keys(grandfathered).sort()) { - if (plugins.includes(plugin)) continue; - for (const [event, allowed] of Object.entries(grandfathered[plugin] ?? {})) { - problems.push({ plugin, event, count: 0, allowed, sources: [] }); + for (const event of [...counts.keys()].sort()) { + const { count, sources } = counts.get(event) ?? { count: 0, sources: new Set() }; + if (count > 1) problems.push({ plugin, event, count, sources: [...sources].sort() }); } } return problems; } -export function formatHookProblem({ plugin, event, count, allowed, sources }: HookRegistrationProblem): string { - const where = sources.length > 0 ? ` (${sources.join(", ")})` : ""; - if (count > allowed) { - return `plugins/${plugin}: ${event} registers ${count} hook handlers${where}, allowed ${allowed}; register one script for the event and route to each guard inside it`; - } - return `plugins/${plugin}: ${event} now registers ${count} hook handlers but GRANDFATHERED_HOOK_COUNTS allows ${allowed}; lower or delete the entry (it only shrinks)`; +export function formatHookProblem({ plugin, event, count, sources }: HookRegistrationProblem): string { + return `plugins/${plugin}: ${event} registers ${count} hook handlers (${sources.join(", ")}), allowed 1; register one script for the event and route to each handler inside it`; } function countHandlers(pluginDir: string): EventCounts { From 1d0478757dcd276d8126bfd23d26cbdc37134b9f Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Mon, 26 Oct 2026 12:45:37 -0500 Subject: [PATCH 3/3] fix(code): feed dispatcher test payloads from a file - On Linux, node's spawnSync input is a socket, which pre-tool-use-hook.sh cannot open as /dev/stdin, so the handler run alone wrote no sentinel in CI Testing: node --test plugins/code/hooks/dispatch.test.mjs on macOS and in a node:22-bookworm container Risks: None identified --- CHANGELOG.md | 1 + plugins/code/hooks/dispatch.test.mjs | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3929d3b4..bb5f6322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ PreToolUse and SubagentStop each run one registered script, which runs that even #### Changed - `hooks.json` registers `hooks/pre-tool-use.sh` for PreToolUse and `hooks/subagent-stop.sh` for SubagentStop, in place of two commands each. `pre-tool-use.sh` runs `pretooluse-hook.sh` for Read, Bash, Write and Edit (its old matcher) and `pre-tool-use-hook.sh` for every tool; `subagent-stop.sh` runs `loop-stop-hook.sh` and then `subagent-stop-hook.sh`. - The new `hooks/dispatch.sh` passes a single handler's output through unchanged. Several JSON outputs merge: the strongest permission decision (deny, defer, ask, allow) or a block wins with every reason joined, and every context and system message is kept. Plain text is printed when no handler produced JSON. A handler exiting 2 still blocks; one exiting with another code contributes nothing, and its exit code is kept when no other handler produced output. +- Each handler reads the hook input from a pipe `dispatch.sh` writes, not from the stdin Claude Code opened for the event. - SubagentStop handlers now run in sequence, `loop-stop-hook.sh` first, so it always reads the `.agent-types` record before `subagent-stop-hook.sh` removes it. They previously ran in parallel. ### vibe v1.0.82 diff --git a/plugins/code/hooks/dispatch.test.mjs b/plugins/code/hooks/dispatch.test.mjs index 27db0cf0..a5938912 100644 --- a/plugins/code/hooks/dispatch.test.mjs +++ b/plugins/code/hooks/dispatch.test.mjs @@ -4,7 +4,7 @@ // command did, and the event script on an identical fixture, and compares them. import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; @@ -30,7 +30,18 @@ function fixture(t, { selfLearning = false } = {}) { mkdirSync(path.join(workdir, ".closedloop-ai"), { recursive: true }); writeFileSync(path.join(cwd, ".closedloop-ai", `session-${SESSION}.workdir`), workdir); writeFileSync(path.join(workdir, ".closedloop-ai", "config.env"), `CLOSEDLOOP_SELF_LEARNING=${selfLearning}\nCLOSEDLOOP_RUN_ID=run-1\n`); - const run = (name, payload) => spawnSync(script(name), { input: JSON.stringify(payload), encoding: "utf8", env: cleanEnv(root) }); + let calls = 0; + // stdin is a file: on Linux, node's `input` is a socket, which pre-tool-use-hook.sh cannot open as /dev/stdin. + const run = (name, payload) => { + const file = path.join(root, `payload-${(calls += 1)}.json`); + writeFileSync(file, JSON.stringify(payload)); + const stdin = openSync(file, "r"); + try { + return spawnSync(script(name), { stdio: [stdin, "pipe", "pipe"], encoding: "utf8", env: cleanEnv(root) }); + } finally { + closeSync(stdin); + } + }; return { root, cwd, workdir, run }; }