@davesheffer/hunch 1.45.1 → 1.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cli/index.js +165 -33
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/serve.d.ts +2 -0
  5. package/dist/cli/serve.js +2 -1
  6. package/dist/cli/verify.d.ts +28 -0
  7. package/dist/cli/verify.js +265 -0
  8. package/dist/constitution/experiment.d.ts +1 -1
  9. package/dist/core/agenthook.d.ts +1 -0
  10. package/dist/core/agenthook.js +82 -26
  11. package/dist/core/boundedSpawn.d.ts +57 -0
  12. package/dist/core/boundedSpawn.js +321 -0
  13. package/dist/core/config.d.ts +6 -0
  14. package/dist/core/config.js +1 -0
  15. package/dist/core/delivery.d.ts +10 -0
  16. package/dist/core/delivery.js +117 -9
  17. package/dist/core/migrate.d.ts +10 -1
  18. package/dist/core/migrate.js +17 -6
  19. package/dist/core/pipeline.d.ts +9 -2
  20. package/dist/core/pipeline.js +13 -3
  21. package/dist/core/procstart.d.ts +17 -3
  22. package/dist/core/procstart.js +26 -5
  23. package/dist/core/projectDna.js +11 -1
  24. package/dist/core/relativeImports.js +6 -2
  25. package/dist/core/taskSelection.d.ts +1 -0
  26. package/dist/core/taskSelection.js +132 -11
  27. package/dist/core/testSelection.d.ts +122 -0
  28. package/dist/core/testSelection.js +588 -0
  29. package/dist/core/whyRecord.d.ts +47 -0
  30. package/dist/core/whyRecord.js +105 -0
  31. package/dist/extractors/git.d.ts +32 -0
  32. package/dist/extractors/git.js +206 -94
  33. package/dist/extractors/indexer.js +42 -6
  34. package/dist/extractors/languages.d.ts +9 -0
  35. package/dist/extractors/languages.js +28 -0
  36. package/dist/extractors/parse.d.ts +20 -0
  37. package/dist/extractors/parse.js +81 -5
  38. package/dist/extractors/workspaces.js +3 -1
  39. package/dist/integrations/health.d.ts +6 -0
  40. package/dist/integrations/health.js +33 -12
  41. package/dist/integrations/hooks.d.ts +4 -2
  42. package/dist/integrations/hooks.js +12 -2
  43. package/dist/integrations/mergeDriver.js +5 -3
  44. package/dist/integrations/optout.d.ts +3 -0
  45. package/dist/integrations/optout.js +17 -0
  46. package/dist/integrations/providers.d.ts +6 -2
  47. package/dist/integrations/providers.js +19 -2
  48. package/dist/integrations/team.js +6 -4
  49. package/dist/integrations/uninstall.d.ts +17 -4
  50. package/dist/integrations/uninstall.js +35 -13
  51. package/dist/mcp/server.js +179 -30
  52. package/dist/serve/app.js +9 -1
  53. package/dist/serve/config.js +5 -0
  54. package/dist/serve/writelock.d.ts +6 -5
  55. package/dist/serve/writelock.js +26 -21
  56. package/dist/store/hunchStore.d.ts +12 -1
  57. package/dist/store/hunchStore.js +86 -4
  58. package/dist/store/jsonStore.d.ts +24 -4
  59. package/dist/store/jsonStore.js +75 -26
  60. package/dist/store/stateBinding.js +17 -4
  61. package/dist/synthesis/provider.js +26 -78
  62. package/dist/synthesis/synthesize.js +1 -1
  63. package/package.json +2 -2
  64. package/server.json +2 -2
@@ -0,0 +1,265 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
3
+ import { availableParallelism, tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, relative } from "node:path";
5
+ import { readConfig } from "../core/config.js";
6
+ import { writeFileAtomic } from "../core/io.js";
7
+ import { hunchPaths } from "../core/paths.js";
8
+ import { buildImportGraph, changedFiles, coverageFiles, discoverTests, listRepoFiles, loadImpactMap, selectTests, testDependencyModel, } from "../core/testSelection.js";
9
+ export const DEFAULT_IMPACT_MAP = ".hunch-cache/test-impact.json";
10
+ function testScript(root) {
11
+ try {
12
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
13
+ return typeof pkg.scripts?.test === "string" ? pkg.scripts.test : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** Split a command template into argv tokens; double/single quotes group. */
20
+ export function tokenize(template) {
21
+ const out = [];
22
+ for (const m of template.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g))
23
+ out.push(m[1] ?? m[2] ?? m[3]);
24
+ return out;
25
+ }
26
+ /** npm without a shell: run npm-cli.js under this Node; POSIX may exec `npm`. */
27
+ function npmArgv(args) {
28
+ const dir = dirname(process.execPath);
29
+ for (const cli of [join(dir, "node_modules/npm/bin/npm-cli.js"), join(dir, "../lib/node_modules/npm/bin/npm-cli.js")]) {
30
+ if (existsSync(cli))
31
+ return [process.execPath, cli, ...args];
32
+ }
33
+ if (process.platform === "win32")
34
+ throw new Error("cannot locate npm-cli.js; set verify.command in .hunch/config.json");
35
+ return ["npm", ...args];
36
+ }
37
+ /** The runner argv (never a shell). A configured template wins (`{files}` expands
38
+ * to one argv entry per file); otherwise this repo's runner when present, else
39
+ * `npm test` (full) / `npm test -- <files>`. */
40
+ export function runnerArgv(root, template, files, extra, full) {
41
+ if (template) {
42
+ const tokens = tokenize(template);
43
+ const argv = tokens.includes("{files}") ? tokens.flatMap((t) => t === "{files}" ? files : [t]) : [...tokens, ...files];
44
+ if (argv[0] === "node")
45
+ argv[0] = process.execPath;
46
+ return [...argv, ...extra];
47
+ }
48
+ if (full && testScript(root))
49
+ return npmArgv(["test", ...(extra.length ? ["--", ...extra] : [])]);
50
+ if (existsSync(join(root, "tooling/run-tests.mjs")))
51
+ return [process.execPath, "tooling/run-tests.mjs", ...files, ...extra];
52
+ return npmArgv(["test", "--", ...files, ...extra]);
53
+ }
54
+ const display = (argv) => argv.map((a, i) => i === 0 && a === process.execPath ? "node" : /\s/.test(a) ? JSON.stringify(a) : a).join(" ");
55
+ function printSelection(sel, total, note, hasMap) {
56
+ for (const line of note)
57
+ console.log(` note: ${line}`);
58
+ if (sel.mode === "none") {
59
+ console.log("✓ no tests selected — nothing the changes can affect.");
60
+ return;
61
+ }
62
+ if (sel.mode === "full") {
63
+ console.log(`▶ FULL suite (${total} tests): ${sel.fullReason}`);
64
+ if (sel.unattributed.length)
65
+ console.log(` unattributed: ${sel.unattributed.join(", ")}`);
66
+ // Only trusted map entries narrow spawning tests; triggers, non-code and unattributed changes stay FULL.
67
+ const spawnWide = /^selection too broad/.test(sel.fullReason ?? "") && Object.values(sel.reasons).some((r) => r.some((why) => why.includes("whole program")));
68
+ if (!hasMap && spawnWide)
69
+ console.log(" Run `hunch verify --refresh-map` once to enable narrow selection for CLI/MCP tests.");
70
+ return;
71
+ }
72
+ console.log(`▶ ${sel.tests.length} of ${total} tests selected:`);
73
+ for (const test of sel.tests)
74
+ console.log(` ${test} ← ${sel.reasons[test].join("; ")}`);
75
+ }
76
+ function headSha(root) {
77
+ try {
78
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).trim();
79
+ }
80
+ catch {
81
+ return "";
82
+ }
83
+ }
84
+ function readCoverageDir(dir) {
85
+ if (!existsSync(dir))
86
+ return [];
87
+ const reports = [];
88
+ for (const name of readdirSync(dir)) {
89
+ if (!name.endsWith(".json"))
90
+ continue;
91
+ try {
92
+ reports.push(JSON.parse(readFileSync(join(dir, name), "utf8")));
93
+ }
94
+ catch { /* partial write → skip */ }
95
+ }
96
+ return reports;
97
+ }
98
+ /** Run each test in its own runner process under NODE_V8_COVERAGE (inherited
99
+ * by spawned CLI/MCP children) and record the repo files each executed. An
100
+ * entry is `complete` only when the test file passed and produced coverage;
101
+ * failing tests still record their (partial) files. `only` merges into the
102
+ * old map, keeping each untouched entry's own commit stamp. */
103
+ export async function refreshImpactMap(opts) {
104
+ const { root, mapPath } = opts;
105
+ const log = opts.log ?? console.log;
106
+ const run = opts.only?.length ? opts.only.map((t) => t.replace(/\\/g, "/").replace(/^\.\//, "")) : opts.tests;
107
+ let previous = null;
108
+ if (opts.only?.length) {
109
+ try {
110
+ previous = loadImpactMap(mapPath);
111
+ }
112
+ catch {
113
+ previous = null;
114
+ }
115
+ }
116
+ const repoFiles = new Set(opts.files);
117
+ const commit = headSha(root);
118
+ const { closureHash } = testDependencyModel(root, opts.files, buildImportGraph(root, opts.files));
119
+ const covDir = mkdtempSync(join(tmpdir(), "hunch-verify-cov-"));
120
+ const map = { ...(previous?.map ?? {}) };
121
+ const failed = [];
122
+ const width = Math.max(1, opts.concurrency ?? availableParallelism() - 1);
123
+ const env = { ...process.env };
124
+ delete env.NODE_TEST_CONTEXT; // a parent test runner must not turn the child into a reporter
125
+ let next = 0;
126
+ const worker = async () => {
127
+ while (next < run.length) {
128
+ const index = next++;
129
+ const test = run[index];
130
+ const dir = join(covDir, String(index));
131
+ const argv = runnerArgv(root, opts.template, [test], [], false);
132
+ const status = await new Promise((done) => {
133
+ const child = spawn(argv[0], argv.slice(1), { cwd: root, stdio: "ignore", windowsHide: true, env: { ...env, NODE_V8_COVERAGE: dir } });
134
+ child.on("error", () => done(1));
135
+ child.on("close", (code) => done(code ?? 1));
136
+ });
137
+ const reports = readCoverageDir(dir);
138
+ if (status !== 0)
139
+ failed.push(test);
140
+ const files = coverageFiles(reports, root, repoFiles).filter((f) => f !== test);
141
+ map[test] = { files, complete: status === 0 && reports.length > 0, commit, closureHash };
142
+ log(` ${status === 0 ? "✓" : "✗"} ${test} (${files.length} files)`);
143
+ }
144
+ };
145
+ try {
146
+ await Promise.all(Array.from({ length: Math.min(width, run.length) }, worker));
147
+ }
148
+ finally {
149
+ // Coverage is scratch; a straggling child holding a handle must not fail the refresh.
150
+ try {
151
+ rmSync(covDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
152
+ }
153
+ catch { /* best effort */ }
154
+ }
155
+ const sortedMap = {};
156
+ for (const test of Object.keys(map).sort())
157
+ sortedMap[test] = map[test];
158
+ const result = { version: 2, commit, generatedAt: new Date().toISOString(), closureHash, map: sortedMap };
159
+ mkdirSync(dirname(mapPath), { recursive: true });
160
+ writeFileAtomic(mapPath, JSON.stringify(result, null, 2) + "\n");
161
+ return { map: result, failed: failed.sort() };
162
+ }
163
+ export function registerVerifyCommand(program) {
164
+ program
165
+ .command("verify")
166
+ .description("Run only the tests the current change can affect (fail-closed: anything unattributed runs the full suite).")
167
+ .argument("[runnerArgs...]", "extra arguments for the test runner (after --)")
168
+ .option("--staged", "select from staged changes only")
169
+ .option("--base <ref>", "select from merge-base(<ref>, HEAD)..HEAD plus the working tree")
170
+ .option("--list", "print the selection with reasons; run nothing")
171
+ .option("--full", "run the full suite")
172
+ .option("--ci", "run the full suite (CI)")
173
+ .option("--json", "print the selection as JSON; run nothing")
174
+ .option("--map <path>", `test impact map (default ${DEFAULT_IMPACT_MAP})`)
175
+ .option("--refresh-map", "rebuild the impact map: run each test under V8 coverage")
176
+ .option("--only <tests...>", "with --refresh-map: refresh only these tests, merging into the existing map")
177
+ .action(async (runnerArgs, opts) => {
178
+ let root;
179
+ try {
180
+ root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }).trim();
181
+ }
182
+ catch {
183
+ console.error("hunch verify must run inside a git repository");
184
+ process.exitCode = 1;
185
+ return;
186
+ }
187
+ const config = readConfig(hunchPaths(root)).verify ?? {};
188
+ const files = listRepoFiles(root);
189
+ const allTests = discoverTests(files, typeof config.tests === "string" ? config.tests : undefined);
190
+ const template = typeof config.command === "string" ? config.command : undefined;
191
+ const mapPath = opts.map ? (isAbsolute(opts.map) ? opts.map : join(root, opts.map)) : join(root, DEFAULT_IMPACT_MAP);
192
+ if (opts.refreshMap) {
193
+ const total = opts.only?.length ?? allTests.length;
194
+ console.log(`▶ refreshing impact map: ${total} test file(s) under V8 coverage`);
195
+ const { map, failed } = await refreshImpactMap({ root, tests: allTests, files, template, mapPath, only: opts.only });
196
+ const covered = new Set(Object.values(map.map).flatMap((e) => e.files)).size;
197
+ console.log(`✓ wrote ${opts.map ?? DEFAULT_IMPACT_MAP}: ${Object.keys(map.map).length} tests → ${covered} files @ ${map.commit.slice(0, 12) || "(no HEAD)"}`);
198
+ if (failed.length)
199
+ console.log(` ${failed.length} test file(s) failed (partial coverage recorded, not trusted): ${failed.join(", ")}`);
200
+ return;
201
+ }
202
+ const note = [];
203
+ let sel;
204
+ let hasMap = false;
205
+ if (opts.full || opts.ci) {
206
+ sel = { mode: "full", tests: allTests, reasons: {}, fullReason: opts.ci ? "--ci" : "--full", unattributed: [], mapUsed: [] };
207
+ }
208
+ else {
209
+ const mode = opts.base ? { kind: "base", ref: opts.base } : opts.staged ? { kind: "staged" } : { kind: "worktree" };
210
+ let impactMap;
211
+ try {
212
+ impactMap = loadImpactMap(mapPath) ?? undefined;
213
+ }
214
+ catch (e) {
215
+ note.push(`${e.message}; ignoring it`);
216
+ }
217
+ if (!impactMap && !note.length)
218
+ note.push(`no impact map at ${opts.map ?? DEFAULT_IMPACT_MAP}; using the import graph (build one: hunch verify --refresh-map)`);
219
+ const model = testDependencyModel(root, files, buildImportGraph(root, files));
220
+ // The map itself is a derived cache; a not-yet-ignored copy is never a code change.
221
+ const mapRel = relative(root, mapPath).replace(/\\/g, "/");
222
+ const changed = changedFiles(root, mode).filter((f) => f !== mapRel);
223
+ sel = selectTests({
224
+ changedFiles: changed, allTests, impactMap, importGraph: model.dependencies,
225
+ testText: model.testText, spawning: model.spawning, closureHash: model.closureHash,
226
+ wholeProgram: [...model.wholeProgram, ...changed.filter((f) => f.startsWith("src/"))], testNamers: model.testNamers,
227
+ });
228
+ hasMap = !!impactMap;
229
+ if (impactMap) {
230
+ const head = headSha(root);
231
+ const stale = sel.mapUsed.filter((t) => impactMap.map[t].commit !== head);
232
+ if (stale.length)
233
+ note.push(`${stale.length} impact-map entr${stale.length === 1 ? "y" : "ies"} used were recorded at another commit than HEAD ${head.slice(0, 12)} (${stale.join(", ")}); the map only adds tests`);
234
+ if (Object.values(impactMap.map).some((e) => e.closureHash !== model.closureHash)) {
235
+ note.push("CLI/MCP/tooling import closure changed since some entries were recorded; those spawning tests use the whole program");
236
+ }
237
+ }
238
+ }
239
+ if (opts.json) {
240
+ console.log(JSON.stringify({ ...sel, total: allTests.length }, null, 2));
241
+ return;
242
+ }
243
+ printSelection(sel, allTests.length, note, hasMap);
244
+ if (opts.list || sel.mode === "none")
245
+ return;
246
+ let argv;
247
+ try {
248
+ argv = runnerArgv(root, template, sel.tests, runnerArgs, sel.mode === "full");
249
+ }
250
+ catch (e) {
251
+ console.error(e.message);
252
+ process.exitCode = 1;
253
+ return;
254
+ }
255
+ console.log(`▶ running: ${display(argv)}\n`);
256
+ const run = spawnSync(argv[0], argv.slice(1), { cwd: root, stdio: "inherit", windowsHide: true });
257
+ if (run.error) {
258
+ console.error(run.error.message);
259
+ process.exitCode = 1;
260
+ return;
261
+ }
262
+ process.exitCode = run.status ?? 1;
263
+ });
264
+ }
265
+ //# sourceMappingURL=verify.js.map
@@ -237,8 +237,8 @@ export declare const ExperimentOutcomeSchema: z.ZodObject<{
237
237
  decision: z.ZodEnum<{
238
238
  rejected: "rejected";
239
239
  abandoned: "abandoned";
240
- uncompilable: "uncompilable";
241
240
  timeout: "timeout";
241
+ uncompilable: "uncompilable";
242
242
  cannot_decide: "cannot_decide";
243
243
  accepted_precise: "accepted_precise";
244
244
  accepted_edited: "accepted_edited";
@@ -8,6 +8,7 @@
8
8
  export declare const HOOK_PROVIDERS: readonly ["claude", "codex", "vscode", "windsurf", "antigravity", "cursor"];
9
9
  export type HookProvider = (typeof HOOK_PROVIDERS)[number];
10
10
  export declare function deliversPromptEvent(provider: HookProvider): boolean;
11
+ export declare function deliversCommandOutcome(provider: HookProvider): boolean;
11
12
  export type HunchHookEvent = "PreToolUse" | "PostToolUse" | "PostToolUseFailure" | "UserPromptSubmit" | "SessionStart" | "SubagentStart" | "PreCompact" | "Stop";
12
13
  /** One file section of a Codex `apply_patch` payload. */
13
14
  export interface HunchPatchFile {
@@ -17,6 +17,18 @@ const PROMPT_EVENT = {
17
17
  export function deliversPromptEvent(provider) {
18
18
  return PROMPT_EVENT[provider];
19
19
  }
20
+ /** Whether a provider's post-command hook carries the command's result (exit
21
+ * status or an explicit success/failure). Windsurf's post_run_command sends only
22
+ * the command line and cwd; Antigravity's installed hooks (PreInvocation,
23
+ * PreToolUse on edit tools, Stop) include no post-tool event at all. Evidence
24
+ * gates keyed on a command's result can never be satisfied there and must stay
25
+ * advisory. A Record, so a new provider must declare its answer. */
26
+ const COMMAND_OUTCOME = {
27
+ claude: true, codex: true, vscode: true, windsurf: false, cursor: true, antigravity: false,
28
+ };
29
+ export function deliversCommandOutcome(provider) {
30
+ return COMMAND_OUTCOME[provider];
31
+ }
20
32
  function obj(value) {
21
33
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
22
34
  }
@@ -197,16 +209,32 @@ function explicitToolOutcome(response) {
197
209
  return "success";
198
210
  return "unknown";
199
211
  }
200
- function normalizeToolOutcome(input, event) {
212
+ /** Cursor's postToolUse carries `tool_output` as a JSON-stringified result
213
+ * payload, e.g. `{"exitCode":0,"stdout":"..."}` (cursor.com/docs/agent/hooks, #496).
214
+ * Parsed, its exit code reaches the probe gate; text that is not a JSON object
215
+ * stays a bare string, which carries no status. */
216
+ function cursorToolOutput(value) {
217
+ if (typeof value !== "string")
218
+ return value;
219
+ try {
220
+ const parsed = JSON.parse(value);
221
+ return obj(parsed) ?? value;
222
+ }
223
+ catch {
224
+ return value;
225
+ }
226
+ }
227
+ function normalizeToolOutcome(input, event, provider) {
201
228
  if (event !== "PostToolUse" && event !== "PostToolUseFailure")
202
229
  return undefined;
203
- const response = input.tool_response ?? input.toolResponse ?? input.tool_result ?? input.toolResult;
230
+ const response = input.tool_response ?? input.toolResponse ?? input.tool_result ?? input.toolResult
231
+ ?? (provider === "cursor" ? cursorToolOutput(input.tool_output) ?? input.output : undefined);
204
232
  return {
205
233
  // Claude Code splits successful and failed calls into separate lifecycle
206
234
  // events. Providers without that split may expose an explicit result flag.
207
235
  status: event === "PostToolUseFailure" ? "failure" : explicitToolOutcome(response),
208
236
  output: event === "PostToolUseFailure"
209
- ? toolOutput(input.error ?? response)
237
+ ? toolOutput(input.error ?? (provider === "cursor" ? input.error_message : undefined) ?? response)
210
238
  : toolOutput(response),
211
239
  };
212
240
  }
@@ -235,12 +263,11 @@ function eventName(value, provider) {
235
263
  return "PostToolUse";
236
264
  }
237
265
  if (provider === "windsurf") {
238
- if (name === "pre_write_code" || name === "pre_run_command")
239
- return "PreToolUse";
240
- if (name === "post_write_code" || name === "post_run_command")
241
- return "PostToolUse";
242
266
  if (name === "pre_user_prompt")
243
267
  return "UserPromptSubmit";
268
+ const action = /^(pre|post)_(write_code|run_command|read_code|mcp_tool_use)$/.exec(name);
269
+ if (action)
270
+ return action[1] === "pre" ? "PreToolUse" : "PostToolUse";
244
271
  }
245
272
  // Antigravity's PreInvocation is the lifecycle point which can inject a
246
273
  // transient message before the model sees the turn. Internally it provides
@@ -256,6 +283,45 @@ export function hookProvider(value) {
256
283
  ? value.toLowerCase()
257
284
  : null;
258
285
  }
286
+ /** Windsurf Cascade (docs.windsurf.com/windsurf/cascade/hooks) names the event in
287
+ * top-level `agent_action_name` (`pre_write_code`, `post_run_command`, …) and
288
+ * carries per-event data in `tool_info`. The tool is implied by the event. Payloads
289
+ * written before #484 (`event` + `agent_action_name: "write_code"`) still parse. */
290
+ function normalizeWindsurfEvent(input) {
291
+ const action = stringAt(input, "agent_action_name")?.toLowerCase();
292
+ const legacy = typeof input.event === "string" ? input.event : typeof input.hook_event_name === "string" ? input.hook_event_name : undefined;
293
+ const name = eventName(action, "windsurf") ? action : legacy?.toLowerCase();
294
+ const event = eventName(name, "windsurf");
295
+ if (!name || !event)
296
+ return null;
297
+ const info = obj(input.tool_info) ?? obj(input.toolInput) ?? obj(input.tool_input) ?? {};
298
+ const kind = name.replace(/^(?:pre|post)_/, "");
299
+ const base = {
300
+ hook_event_name: event,
301
+ session_id: stringAt(input, "trajectory_id", "session_id", "sessionId"),
302
+ };
303
+ if (event === "UserPromptSubmit") {
304
+ return { ...base, prompt: stringAt(info, "user_prompt", "prompt") ?? stringAt(input, "prompt", "user_prompt", "userPrompt") };
305
+ }
306
+ const toolOutcome = normalizeToolOutcome(input, event);
307
+ const outcome = toolOutcome ? { tool_outcome: toolOutcome } : {};
308
+ if (kind === "run_command") {
309
+ const command = stringAt(info, "command_line", "command", "commandLine");
310
+ const cwd = stringAt(info, "cwd");
311
+ return { ...base, ...(cwd ? { cwd } : {}), tool_name: "Bash", tool_input: command ? { command } : undefined, ...outcome };
312
+ }
313
+ // Reads and MCP calls are observed only: their arguments never reach the
314
+ // pre-edit gate, which treats any file_path as an edit target.
315
+ if (kind === "read_code")
316
+ return { ...base, tool_name: "Read", ...outcome };
317
+ if (kind === "mcp_tool_use") {
318
+ const server = stringAt(info, "mcp_server_name", "server_name", "server");
319
+ const tool = stringAt(info, "mcp_tool_name", "tool_name", "tool");
320
+ return { ...base, tool_name: server && tool ? `mcp__${server}__${tool}` : tool ?? "mcp", ...outcome };
321
+ }
322
+ const toolInput = normalizeToolInput(info);
323
+ return { ...base, tool_name: kind === "write_code" ? "Edit" : hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}), tool_input: toolInput, ...outcome };
324
+ }
259
325
  /** Normalize a hook stdin payload. Unknown/malformed events return null and the
260
326
  * CLI exits successfully without output — the Never Block on Hook Failure rule. */
261
327
  export function normalizeHookEvent(raw, provider) {
@@ -276,28 +342,18 @@ export function normalizeHookEvent(raw, provider) {
276
342
  tool_input: toolInput,
277
343
  };
278
344
  }
279
- if (provider === "windsurf") {
280
- const event = eventName(input.event ?? input.hook_event_name, provider);
281
- if (!event)
282
- return null;
283
- const info = obj(input.tool_info) ?? obj(input.toolInput) ?? obj(input.tool_input);
284
- const toolInput = normalizeToolInput(info);
285
- const toolOutcome = normalizeToolOutcome(input, event);
286
- return {
287
- hook_event_name: event,
288
- session_id: stringAt(input, "trajectory_id", "session_id", "sessionId"),
289
- tool_name: hunchToolName(stringAt(input, "agent_action_name", "tool_name", "toolName"), toolInput ?? {}),
290
- tool_input: toolInput,
291
- ...(toolOutcome ? { tool_outcome: toolOutcome } : {}),
292
- prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
293
- };
294
- }
345
+ if (provider === "windsurf")
346
+ return normalizeWindsurfEvent(input);
295
347
  const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
296
348
  if (!event)
297
349
  return null;
298
- const rawToolName = stringAt(input, "tool_name", "toolName");
299
- const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput, provider === "codex" && /^(?:apply_patch|patch)$/i.test(rawToolName ?? ""));
300
- const toolOutcome = normalizeToolOutcome(input, event);
350
+ // Cursor's before/afterShellExecution carry the command at top level and no
351
+ // tool name. afterShellExecution has no exit status (only `output`), so its
352
+ // outcome stays unknown; the installed postToolUse hook delivers the status.
353
+ const cursorShell = provider === "cursor" && input.tool_name === undefined && input.tool_input === undefined && typeof input.command === "string";
354
+ const rawToolName = cursorShell ? "Shell" : stringAt(input, "tool_name", "toolName");
355
+ const toolInput = normalizeToolInput(cursorShell ? { command: input.command } : input.tool_input ?? input.toolInput, provider === "codex" && /^(?:apply_patch|patch)$/i.test(rawToolName ?? ""));
356
+ const toolOutcome = normalizeToolOutcome(input, event, provider);
301
357
  return {
302
358
  hook_event_name: event,
303
359
  session_id: stringAt(input, "session_id", "sessionId", "conversation_id", "conversationId"),
@@ -0,0 +1,57 @@
1
+ export interface BoundedSpawnOptions {
2
+ cwd?: string;
3
+ env?: NodeJS.ProcessEnv;
4
+ /** Written to the child's stdin, which is then closed. Omitted = no stdin. */
5
+ input?: string;
6
+ /** Supervisor deadline. Omitted or 0 = no deadline. */
7
+ timeoutMs?: number;
8
+ /** stdout bytes allowed before the tree is killed. Default 16 MB. */
9
+ maxBuffer?: number;
10
+ /** Launch through the platform shell (cmd.exe on Windows). `file` is then
11
+ * the whole command line and `args` should be empty. */
12
+ shell?: boolean;
13
+ /** POSIX: how long the tree gets after SIGTERM before SIGKILL. Default 1500. */
14
+ killGraceMs?: number;
15
+ }
16
+ export interface BoundedSpawnResult {
17
+ stdout: string;
18
+ stderr: string;
19
+ /** Exit status, or null when the child was killed or never started. */
20
+ status: number | null;
21
+ /** Signal that ended the child: its own exit signal, or on timeout/overflow
22
+ * the last signal the supervisor delivered to the tree. */
23
+ signal: NodeJS.Signals | null;
24
+ /** The supervisor's deadline fired and the process tree was killed. */
25
+ timedOut: boolean;
26
+ /** stdout exceeded maxBuffer and the process tree was killed. */
27
+ overflowed: boolean;
28
+ /** Spawn failure (e.g. ENOENT), or the supervisor itself failing. */
29
+ error?: NodeJS.ErrnoException;
30
+ }
31
+ /** Test seam: replace the ps binary used for the POSIX tree snapshot
32
+ * (null restores "ps"). Not for production callers. */
33
+ export declare function __setPsFileForTests(file: string | null): void;
34
+ /** Async bounded run. Always resolves; inspect `timedOut` / `error` / `status`. */
35
+ export declare function boundedSpawn(file: string, args: string[], opts?: BoundedSpawnOptions): Promise<BoundedSpawnResult>;
36
+ export interface BoundedSpawnSyncOptions extends Omit<BoundedSpawnOptions, "timeoutMs"> {
37
+ /** Supervisor deadline (required: this helper exists to bound the call). */
38
+ timeout: number;
39
+ /** stdout: captured ("pipe", the default), discarded, or written straight to
40
+ * an open file descriptor. */
41
+ stdout?: "pipe" | "ignore" | number;
42
+ }
43
+ /** Synchronous bounded run, spawnSync-shaped: never throws. */
44
+ export declare function boundedSpawnSync(file: string, args: string[], opts: BoundedSpawnSyncOptions): BoundedSpawnResult;
45
+ /** Error thrown by boundedExecFileSync, shaped like execFileSync's: `code`
46
+ * ETIMEDOUT / ENOBUFS / ENOENT, or `status` + `signal` for a failed exit. */
47
+ export interface BoundedExecError extends Error {
48
+ code?: string;
49
+ status?: number | null;
50
+ signal?: NodeJS.Signals | null;
51
+ stdout: string;
52
+ stderr: string;
53
+ }
54
+ /** Synchronous bounded exec: returns stdout (utf8) on exit 0, throws a
55
+ * BoundedExecError otherwise. On timeout the error carries
56
+ * `code: "ETIMEDOUT"` and the signal actually delivered to the tree. */
57
+ export declare function boundedExecFileSync(file: string, args: string[], opts: BoundedSpawnSyncOptions): string;