@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.
- package/dist/cli/index.js +165 -33
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +82 -26
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +10 -0
- package/dist/core/delivery.js +117 -9
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/pipeline.d.ts +9 -2
- package/dist/core/pipeline.js +13 -3
- package/dist/core/procstart.d.ts +17 -3
- package/dist/core/procstart.js +26 -5
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +132 -11
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/whyRecord.d.ts +47 -0
- package/dist/core/whyRecord.js +105 -0
- package/dist/extractors/git.d.ts +32 -0
- package/dist/extractors/git.js +206 -94
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +6 -4
- package/dist/integrations/uninstall.d.ts +17 -4
- package/dist/integrations/uninstall.js +35 -13
- package/dist/mcp/server.js +179 -30
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +6 -5
- package/dist/serve/writelock.js +26 -21
- package/dist/store/hunchStore.d.ts +12 -1
- package/dist/store/hunchStore.js +86 -4
- package/dist/store/jsonStore.d.ts +24 -4
- package/dist/store/jsonStore.js +75 -26
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- 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";
|
package/dist/core/agenthook.d.ts
CHANGED
|
@@ -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 {
|
package/dist/core/agenthook.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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;
|