@davesheffer/hunch 1.45.2 → 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 (53) hide show
  1. package/dist/cli/index.js +139 -32
  2. package/dist/cli/invocation.d.ts +14 -0
  3. package/dist/cli/invocation.js +23 -0
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/experiment.d.ts +1 -1
  7. package/dist/core/agenthook.js +26 -6
  8. package/dist/core/boundedSpawn.d.ts +57 -0
  9. package/dist/core/boundedSpawn.js +321 -0
  10. package/dist/core/config.d.ts +6 -0
  11. package/dist/core/config.js +1 -0
  12. package/dist/core/delivery.d.ts +6 -1
  13. package/dist/core/delivery.js +71 -5
  14. package/dist/core/migrate.d.ts +10 -1
  15. package/dist/core/migrate.js +17 -6
  16. package/dist/core/projectDna.js +11 -1
  17. package/dist/core/relativeImports.js +6 -2
  18. package/dist/core/taskSelection.js +48 -6
  19. package/dist/core/testSelection.d.ts +122 -0
  20. package/dist/core/testSelection.js +588 -0
  21. package/dist/extractors/git.js +32 -31
  22. package/dist/extractors/indexer.js +42 -6
  23. package/dist/extractors/languages.d.ts +9 -0
  24. package/dist/extractors/languages.js +28 -0
  25. package/dist/extractors/parse.d.ts +20 -0
  26. package/dist/extractors/parse.js +81 -5
  27. package/dist/extractors/workspaces.js +3 -1
  28. package/dist/integrations/health.d.ts +6 -0
  29. package/dist/integrations/health.js +33 -12
  30. package/dist/integrations/hooks.d.ts +4 -2
  31. package/dist/integrations/hooks.js +12 -2
  32. package/dist/integrations/mergeDriver.js +5 -3
  33. package/dist/integrations/optout.d.ts +3 -0
  34. package/dist/integrations/optout.js +17 -0
  35. package/dist/integrations/providers.d.ts +6 -2
  36. package/dist/integrations/providers.js +19 -2
  37. package/dist/integrations/team.js +4 -2
  38. package/dist/integrations/uninstall.d.ts +10 -4
  39. package/dist/integrations/uninstall.js +22 -18
  40. package/dist/mcp/server.js +47 -8
  41. package/dist/serve/app.js +9 -1
  42. package/dist/serve/config.js +5 -0
  43. package/dist/serve/writelock.d.ts +3 -1
  44. package/dist/serve/writelock.js +17 -3
  45. package/dist/store/hunchStore.d.ts +2 -1
  46. package/dist/store/hunchStore.js +8 -5
  47. package/dist/store/jsonStore.d.ts +13 -3
  48. package/dist/store/jsonStore.js +37 -13
  49. package/dist/store/stateBinding.js +17 -4
  50. package/dist/synthesis/provider.js +26 -78
  51. package/dist/synthesis/synthesize.js +1 -1
  52. package/package.json +2 -2
  53. package/server.json +2 -2
@@ -209,16 +209,32 @@ function explicitToolOutcome(response) {
209
209
  return "success";
210
210
  return "unknown";
211
211
  }
212
- 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) {
213
228
  if (event !== "PostToolUse" && event !== "PostToolUseFailure")
214
229
  return undefined;
215
- 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);
216
232
  return {
217
233
  // Claude Code splits successful and failed calls into separate lifecycle
218
234
  // events. Providers without that split may expose an explicit result flag.
219
235
  status: event === "PostToolUseFailure" ? "failure" : explicitToolOutcome(response),
220
236
  output: event === "PostToolUseFailure"
221
- ? toolOutput(input.error ?? response)
237
+ ? toolOutput(input.error ?? (provider === "cursor" ? input.error_message : undefined) ?? response)
222
238
  : toolOutput(response),
223
239
  };
224
240
  }
@@ -331,9 +347,13 @@ export function normalizeHookEvent(raw, provider) {
331
347
  const event = eventName(input.hook_event_name ?? input.hookEventName ?? input.event, provider);
332
348
  if (!event)
333
349
  return null;
334
- const rawToolName = stringAt(input, "tool_name", "toolName");
335
- const toolInput = normalizeToolInput(input.tool_input ?? input.toolInput, provider === "codex" && /^(?:apply_patch|patch)$/i.test(rawToolName ?? ""));
336
- 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);
337
357
  return {
338
358
  hook_event_name: event,
339
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;
@@ -0,0 +1,321 @@
1
+ /** Run a child process under our own timer and kill its WHOLE tree on timeout
2
+ * (issues #44, #489, #498).
3
+ *
4
+ * Node's `timeout` option on spawn/execFile/execFileSync/spawnSync kills only
5
+ * the root process. On Windows that root is often a wrapper (cmd.exe for a
6
+ * `.cmd` shim, or Git for Windows' `cmd\git.exe`), so the real worker (the
7
+ * agent CLI, or `git.exe` / `git-remote-https.exe`) keeps running as an
8
+ * orphan. Running `taskkill /T` afterwards is too late: the root is gone, so
9
+ * the tree can no longer be reached by parent pid.
10
+ *
11
+ * Both forms here run the command under a small Node supervisor that owns the
12
+ * deadline and kills the tree while the root still exists:
13
+ * - Windows: `taskkill /pid <root> /T /F` runs to completion BEFORE the root
14
+ * is killed.
15
+ * - POSIX: the descendants are found by walking a `ps -A -o pid=,ppid=`
16
+ * snapshot (on Linux without ps, `/proc/<pid>/stat`) from the root, taken
17
+ * while the root is still alive. The tree gets
18
+ * SIGTERM first (git removes its ref/pack lock files on SIGTERM, not on
19
+ * SIGKILL), then SIGKILL for whatever is still alive after a short grace.
20
+ *
21
+ * The child is NOT detached: it stays in Hunch's process group, so a terminal
22
+ * Ctrl-C still reaches it directly. The supervisor's timer is always cleared,
23
+ * and a timeout is reported as a result (`timedOut`), never a hang.
24
+ *
25
+ * Cost: one extra Node startup (~30 ms) per call, so this is for network or
26
+ * long-running commands, not hot local git calls. */
27
+ import { spawn, spawnSync } from "node:child_process";
28
+ const RESULT_SENTINEL = "\n__HUNCH_BOUNDED_SPAWN_RESULT__";
29
+ const DEFAULT_KILL_GRACE_MS = 1_500;
30
+ /** Plain-JS supervisor run as `node -e`. Reads a JSON spec on stdin, runs the
31
+ * command, forwards its stdout/stderr, and appends one sentinel line with the
32
+ * outcome to stderr. */
33
+ const SUPERVISOR = `
34
+ const { spawn, execFile } = require("node:child_process");
35
+ const SENTINEL = ${JSON.stringify(RESULT_SENTINEL)};
36
+ const win = process.platform === "win32";
37
+ let raw = "";
38
+ process.stdin.setEncoding("utf8");
39
+ process.stdin.on("data", (d) => { raw += d; });
40
+ process.stdin.on("end", () => {
41
+ const spec = JSON.parse(raw);
42
+ const result = { status: null, signal: null, timedOut: false, overflowed: false, killSignal: null, error: null };
43
+ let child = null, timer = null, finished = false, killing = false, bytes = 0, errBytes = 0;
44
+ const finish = () => {
45
+ if (finished) return;
46
+ finished = true;
47
+ if (timer) { clearTimeout(timer); timer = null; }
48
+ if (child) {
49
+ if (child.stdout) child.stdout.destroy();
50
+ if (child.stderr) child.stderr.destroy();
51
+ child.unref();
52
+ }
53
+ process.stderr.write(SENTINEL + JSON.stringify(result) + "\\n");
54
+ };
55
+ const rootGone = () => child.exitCode !== null || child.signalCode !== null;
56
+ const alive = (pid) => {
57
+ if (pid === child.pid) return !rootGone();
58
+ try { process.kill(pid, 0); return true; } catch (e) { return false; }
59
+ };
60
+ // pid -> ppid for every process: ps, else (Linux without procps, e.g.
61
+ // busybox) /proc/*/stat, else an empty map (only the root gets signalled).
62
+ const snapshot = (cb) => {
63
+ execFile(spec.psFile || "ps", ["-A", "-o", "pid=,ppid="], { timeout: 2000, windowsHide: true }, (err, out) => {
64
+ const ppids = new Map();
65
+ if (!err) {
66
+ for (const line of String(out).split("\\n")) {
67
+ const f = line.trim().split(/\\s+/);
68
+ if (f.length >= 2) ppids.set(Number(f[0]), Number(f[1]));
69
+ }
70
+ return cb(ppids);
71
+ }
72
+ if (process.platform !== "linux") return cb(ppids);
73
+ try {
74
+ const fs = require("node:fs");
75
+ for (const d of fs.readdirSync("/proc")) {
76
+ if (!/^\\d+$/.test(d)) continue;
77
+ try {
78
+ // "pid (comm) state ppid ...": comm may hold spaces and ')', so
79
+ // parse past the LAST ')'.
80
+ const stat = fs.readFileSync("/proc/" + d + "/stat", "utf8");
81
+ const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
82
+ ppids.set(Number(d), Number(f[1]));
83
+ } catch (e) { /* exited mid-scan */ }
84
+ }
85
+ } catch (e) {}
86
+ cb(ppids);
87
+ });
88
+ };
89
+ // Every pid reachable from roots in a pid -> ppid snapshot (roots included).
90
+ const reach = (roots, ppids) => {
91
+ const kids = new Map();
92
+ for (const [pid, ppid] of ppids) {
93
+ if (!kids.has(ppid)) kids.set(ppid, []);
94
+ kids.get(ppid).push(pid);
95
+ }
96
+ const seen = new Set(roots), stack = roots.slice();
97
+ while (stack.length) {
98
+ for (const c of kids.get(stack.pop()) || []) {
99
+ if (c !== process.pid && !seen.has(c)) { seen.add(c); stack.push(c); }
100
+ }
101
+ }
102
+ return Array.from(seen);
103
+ };
104
+ const signalAll = (pids, sig) => {
105
+ // Descendants first, the root last: the root stays alive (and the tree
106
+ // reachable) for as long as possible.
107
+ for (const pid of pids) {
108
+ if (pid === child.pid) continue;
109
+ try { process.kill(pid, sig); } catch (e) {}
110
+ }
111
+ try { child.kill(sig); } catch (e) {}
112
+ };
113
+ const killTree = () => {
114
+ if (killing) return;
115
+ killing = true;
116
+ if (!child || !child.pid) return finish();
117
+ if (win) {
118
+ execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true, timeout: 10000 }, () => {
119
+ try { child.kill("SIGKILL"); } catch (e) {}
120
+ result.killSignal = "SIGKILL";
121
+ finish();
122
+ });
123
+ return;
124
+ }
125
+ snapshot((ppids) => {
126
+ const pids = reach([child.pid], ppids);
127
+ signalAll(pids, "SIGTERM");
128
+ result.killSignal = "SIGTERM";
129
+ const deadline = Date.now() + spec.killGraceMs;
130
+ const poll = () => {
131
+ const left = pids.filter(alive);
132
+ if (left.length === 0) return finish();
133
+ if (Date.now() < deadline) { setTimeout(poll, 50); return; }
134
+ // Re-walk from the survivors: anything they spawned during the grace.
135
+ snapshot((now) => {
136
+ const rest = reach(left, now);
137
+ for (const pid of rest) { try { process.kill(pid, "SIGKILL"); } catch (e) {} }
138
+ try { child.kill("SIGKILL"); } catch (e) {}
139
+ result.killSignal = "SIGKILL";
140
+ finish();
141
+ });
142
+ };
143
+ poll();
144
+ });
145
+ };
146
+ const hasInput = typeof spec.input === "string";
147
+ try {
148
+ child = spawn(spec.file, spec.args, {
149
+ cwd: spec.cwd || undefined,
150
+ env: spec.env || process.env,
151
+ stdio: [hasInput ? "pipe" : "ignore", spec.stdout, "pipe"],
152
+ shell: spec.shell === true,
153
+ windowsHide: true,
154
+ });
155
+ } catch (e) {
156
+ result.error = { code: e && e.code, message: String(e && e.message) };
157
+ return finish();
158
+ }
159
+ if (spec.timeoutMs > 0) {
160
+ timer = setTimeout(() => { timer = null; if (finished) return; result.timedOut = true; killTree(); }, spec.timeoutMs);
161
+ }
162
+ if (child.stdout) child.stdout.on("data", (c) => {
163
+ if (killing) return;
164
+ bytes += c.length;
165
+ if (bytes > spec.maxBuffer) {
166
+ result.overflowed = true;
167
+ if (timer) { clearTimeout(timer); timer = null; }
168
+ return killTree();
169
+ }
170
+ process.stdout.write(c);
171
+ });
172
+ child.stderr.on("data", (c) => {
173
+ if (killing || errBytes > spec.maxBuffer) return;
174
+ errBytes += c.length;
175
+ process.stderr.write(c);
176
+ });
177
+ child.on("error", (e) => { if (killing) return; result.error = { code: e && e.code, message: String(e && e.message) }; finish(); });
178
+ child.on("close", (code, signal) => {
179
+ if (killing) return;
180
+ result.status = code;
181
+ result.signal = signal;
182
+ finish();
183
+ });
184
+ if (hasInput) {
185
+ child.stdin.on("error", () => {});
186
+ child.stdin.end(spec.input);
187
+ }
188
+ });
189
+ `;
190
+ let psFileOverride = null;
191
+ /** Test seam: replace the ps binary used for the POSIX tree snapshot
192
+ * (null restores "ps"). Not for production callers. */
193
+ export function __setPsFileForTests(file) {
194
+ psFileOverride = file;
195
+ }
196
+ function supervisorRequest(file, args, opts) {
197
+ const maxBuffer = opts.maxBuffer ?? 16 * 1024 * 1024;
198
+ const spec = JSON.stringify({
199
+ file, args,
200
+ cwd: opts.cwd ?? null,
201
+ env: opts.env ?? process.env,
202
+ input: opts.input ?? null,
203
+ timeoutMs: opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0,
204
+ killGraceMs: opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS,
205
+ maxBuffer,
206
+ shell: opts.shell === true,
207
+ stdout: opts.stdout ?? "pipe",
208
+ psFile: psFileOverride,
209
+ });
210
+ // The supervisor itself must not inherit user NODE_OPTIONS (loaders, --inspect).
211
+ const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1" };
212
+ delete env.NODE_OPTIONS;
213
+ // Backstop only, if the supervisor itself wedges: its own timer is the real bound.
214
+ const backstopMs = opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs + (opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS) + 15_000 : undefined;
215
+ return { spec, env, maxBuffer, backstopMs };
216
+ }
217
+ function toResult(stdout, rawErr, failure) {
218
+ const at = rawErr.lastIndexOf(RESULT_SENTINEL);
219
+ const stderr = at >= 0 ? rawErr.slice(0, at) : rawErr;
220
+ let outcome = null;
221
+ if (at >= 0) {
222
+ try {
223
+ outcome = JSON.parse(rawErr.slice(at + RESULT_SENTINEL.length).trim());
224
+ }
225
+ catch {
226
+ outcome = null;
227
+ }
228
+ }
229
+ const result = { stdout, stderr, status: null, signal: null, timedOut: false, overflowed: false };
230
+ if (!outcome) {
231
+ const error = new Error(`bounded spawn supervisor ${failure.error ? `failed: ${failure.error.message}` : `exited ${failure.status ?? failure.signal} without a result`}`);
232
+ error.code = failure.error?.code ?? "ESUPERVISOR";
233
+ result.error = error;
234
+ result.signal = failure.signal;
235
+ return result;
236
+ }
237
+ if (outcome.error) {
238
+ const error = new Error(outcome.error.message);
239
+ if (outcome.error.code)
240
+ error.code = outcome.error.code;
241
+ result.error = error;
242
+ }
243
+ result.timedOut = outcome.timedOut;
244
+ result.overflowed = outcome.overflowed;
245
+ result.status = outcome.status;
246
+ result.signal = outcome.killSignal ?? outcome.signal;
247
+ return result;
248
+ }
249
+ /** Async bounded run. Always resolves; inspect `timedOut` / `error` / `status`. */
250
+ export function boundedSpawn(file, args, opts = {}) {
251
+ const { spec, env, maxBuffer, backstopMs } = supervisorRequest(file, args, opts);
252
+ return new Promise((resolve) => {
253
+ const out = [];
254
+ const err = [];
255
+ let outLen = 0;
256
+ let backstop = null;
257
+ let settled = false;
258
+ const supervisor = spawn(process.execPath, ["-e", SUPERVISOR], { env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
259
+ const settle = (failure) => {
260
+ if (settled)
261
+ return;
262
+ settled = true;
263
+ if (backstop) {
264
+ clearTimeout(backstop);
265
+ backstop = null;
266
+ }
267
+ resolve(toResult(Buffer.concat(out).toString("utf8"), Buffer.concat(err).toString("utf8"), failure));
268
+ };
269
+ if (backstopMs)
270
+ backstop = setTimeout(() => { backstop = null; supervisor.kill("SIGKILL"); }, backstopMs);
271
+ supervisor.stdout.on("data", (d) => { if (outLen <= maxBuffer) {
272
+ out.push(d);
273
+ outLen += d.length;
274
+ } });
275
+ supervisor.stderr.on("data", (d) => { err.push(d); });
276
+ supervisor.on("error", (error) => settle({ error, status: null, signal: null }));
277
+ supervisor.on("close", (status, signal) => settle({ status, signal }));
278
+ supervisor.stdin.on("error", () => { });
279
+ supervisor.stdin.end(spec);
280
+ });
281
+ }
282
+ /** Synchronous bounded run, spawnSync-shaped: never throws. */
283
+ export function boundedSpawnSync(file, args, opts) {
284
+ const stdoutMode = opts.stdout ?? "pipe";
285
+ const { spec, env, maxBuffer, backstopMs } = supervisorRequest(file, args, {
286
+ ...opts,
287
+ timeoutMs: opts.timeout,
288
+ maxBuffer: opts.maxBuffer ?? 64 * 1024 * 1024,
289
+ stdout: typeof stdoutMode === "number" ? "inherit" : stdoutMode,
290
+ });
291
+ const run = spawnSync(process.execPath, ["-e", SUPERVISOR], {
292
+ input: spec,
293
+ stdio: ["pipe", typeof stdoutMode === "number" ? stdoutMode : "pipe", "pipe"],
294
+ env,
295
+ windowsHide: true,
296
+ maxBuffer: maxBuffer * 2 + 1024 * 1024,
297
+ timeout: backstopMs,
298
+ });
299
+ return toResult(run.stdout ? run.stdout.toString("utf8") : "", run.stderr ? run.stderr.toString("utf8") : "", run);
300
+ }
301
+ /** Synchronous bounded exec: returns stdout (utf8) on exit 0, throws a
302
+ * BoundedExecError otherwise. On timeout the error carries
303
+ * `code: "ETIMEDOUT"` and the signal actually delivered to the tree. */
304
+ export function boundedExecFileSync(file, args, opts) {
305
+ const r = boundedSpawnSync(file, args, opts);
306
+ const fail = (message, fields) => {
307
+ const error = new Error(message);
308
+ Object.assign(error, { stdout: r.stdout, stderr: r.stderr, status: r.status, signal: r.signal }, fields);
309
+ throw error;
310
+ };
311
+ if (r.timedOut)
312
+ return fail(`${file} timed out after ${opts.timeout}ms`, { code: "ETIMEDOUT" });
313
+ if (r.overflowed)
314
+ return fail(`${file}: stdout exceeded maxBuffer`, { code: "ENOBUFS" });
315
+ if (r.error)
316
+ return fail(`${file}: ${r.error.message}`, { code: r.error.code ?? "ESPAWN" });
317
+ if (r.status !== 0)
318
+ return fail(`${file} exited ${r.status ?? r.signal}`, {});
319
+ return r.stdout;
320
+ }
321
+ //# sourceMappingURL=boundedSpawn.js.map
@@ -28,6 +28,12 @@ export interface HunchConfig {
28
28
  * (see src/mcp/toolset.ts). Undefined = decide from the root's contents. */
29
29
  mcp_tools?: string;
30
30
  workspaces?: Partial<WorkspacesConfig>;
31
+ /** `hunch verify` knobs: `tests` discovery glob, `command` runner template
32
+ * (`{files}` is replaced by the selected test paths). */
33
+ verify?: {
34
+ tests?: string;
35
+ command?: string;
36
+ };
31
37
  }
32
38
  /** The effective workspace config: every field present, unknown values ignored. */
33
39
  export declare function workspacesConfig(config: HunchConfig): WorkspacesConfig;
@@ -35,6 +35,7 @@ export function readConfig(paths) {
35
35
  firmness: isFirmness(raw.firmness) ? raw.firmness : DEFAULT_FIRMNESS,
36
36
  ...(typeof raw.mcp_tools === "string" && raw.mcp_tools.trim() ? { mcp_tools: raw.mcp_tools.trim() } : {}),
37
37
  ...(raw.workspaces && typeof raw.workspaces === "object" && !Array.isArray(raw.workspaces) ? { workspaces: raw.workspaces } : {}),
38
+ ...(raw.verify && typeof raw.verify === "object" && !Array.isArray(raw.verify) ? { verify: raw.verify } : {}),
38
39
  };
39
40
  }
40
41
  catch {
@@ -128,7 +128,12 @@ export declare function lexicalTokens(value: string): Set<string>;
128
128
  export declare function resetCommitReachabilityMemo(): void;
129
129
  /** Test hook: number of git spawns issued by delivery provenance probes so far. */
130
130
  export declare function commitReachabilityProbeCount(): number;
131
- export declare function localCommitReachability(root: string): (commit: string) => CommitReachability;
131
+ /** A per-build reachability probe. `prime` registers the commits the build may
132
+ * ask about; the first lookup then judges all of them in two batched spawns. */
133
+ export type LocalCommitReachability = ((commit: string) => CommitReachability) & {
134
+ prime: (commits: readonly string[]) => void;
135
+ };
136
+ export declare function localCommitReachability(root: string): LocalCommitReachability;
132
137
  /** Validate the public receipt without trusting a caller-supplied identity. */
133
138
  export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
134
139
  /** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
@@ -312,12 +312,14 @@ function componentResolves(id, target, options) {
312
312
  return !!component && component.paths.some((path) => anchorResolves(path, target, options));
313
313
  }
314
314
  let gitProbeCount = 0;
315
- function gitProbe(root, args) {
315
+ function gitProbe(root, args, input) {
316
316
  gitProbeCount++;
317
317
  const result = spawnSync("git", ["-C", root, ...args], {
318
318
  encoding: "utf8",
319
319
  env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
320
- stdio: ["ignore", "pipe", "ignore"],
320
+ input,
321
+ maxBuffer: 64 * 1024 * 1024,
322
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
321
323
  timeout: 2_000,
322
324
  windowsHide: true,
323
325
  });
@@ -363,7 +365,60 @@ export function localCommitReachability(root) {
363
365
  let initialized = false;
364
366
  let branch = null;
365
367
  let tip = null;
366
- return (commit) => {
368
+ // #485: each hook call is a fresh process, so the memo alone still cost two
369
+ // spawns per capture commit. Primed commits are judged together on the first
370
+ // lookup — one `cat-file --batch-check` resolves them, one `rev-list` tests
371
+ // ancestry — and kept for this build only. A spawn failure (timeout, ENOBUFS)
372
+ // primes every pending commit "unknown", the verdict a failed per-commit probe
373
+ // gives, so a slow git costs one timeout rather than 2N more spawns. Unexpected
374
+ // output leaves a commit unprimed and the per-commit path below decides it, so
375
+ // within a build the verdicts match that path.
376
+ let expected = [];
377
+ const primed = new Map();
378
+ const batch = (commits) => {
379
+ const pending = [...new Set(commits)].filter((commit) => /^[0-9a-f]{7,64}$/i.test(commit) && !primed.has(commit) && !(tip && reachabilityMemo.has(`${root}\0${tip}\0${commit}`)));
380
+ if (!pending.length || !branch)
381
+ return;
382
+ const resolved = gitProbe(root, ["cat-file", "--batch-check=%(objectname)"], pending.map((commit) => `${commit}^{commit}\n`).join(""));
383
+ if (!resolved.available) {
384
+ for (const commit of pending)
385
+ primed.set(commit, "unknown");
386
+ return;
387
+ }
388
+ const lines = resolved.stdout.split(/\r?\n/);
389
+ if (resolved.status !== 0 || lines.length !== pending.length)
390
+ return;
391
+ const shas = new Map();
392
+ for (const [i, commit] of pending.entries()) {
393
+ const line = lines[i];
394
+ if (/^[0-9a-f]{40,64}$/i.test(line))
395
+ shas.set(commit, line);
396
+ // A missing object is not memoized, as on the per-commit path.
397
+ else if (line === `${commit}^{commit} missing` || line === `${commit}^{commit} ambiguous`)
398
+ primed.set(commit, "unreachable");
399
+ else
400
+ return;
401
+ }
402
+ if (!shas.size)
403
+ return;
404
+ // `rev-list <shas> ^<tip>` lists exactly the commits NOT reachable from the tip.
405
+ // It prints the whole history behind an unreachable capture commit (e.g.
406
+ // orphaned by a rewrite), so this probe costs O(|unreachable history|); a
407
+ // history big enough to time out primes "unknown" below.
408
+ const listed = gitProbe(root, ["rev-list", "--stdin"], `${[...new Set(shas.values())].join("\n")}\n^${tip ?? branch}\n`);
409
+ // A failed spawn or a nonzero exit is a merge-base answer outside 0/1: "unknown", never memoized.
410
+ if (!listed.available || listed.status !== 0) {
411
+ for (const commit of shas.keys())
412
+ primed.set(commit, "unknown");
413
+ return;
414
+ }
415
+ const unreachable = new Set(listed.stdout.split(/\r?\n/).filter(Boolean));
416
+ for (const [commit, sha] of shas) {
417
+ const verdict = unreachable.has(sha) ? "unreachable" : "reachable";
418
+ primed.set(commit, tip ? memoizeReachability(`${root}\0${tip}\0${commit}`, verdict) : verdict);
419
+ }
420
+ };
421
+ const lookup = (commit) => {
367
422
  if (!initialized) {
368
423
  // The default ref is looked up every build (origin/HEAD can be retargeted
369
424
  // while the old ref still exists); only verdicts are shared across builds.
@@ -388,6 +443,14 @@ export function localCommitReachability(root) {
388
443
  const memo = key ? reachabilityMemo.get(key) : undefined;
389
444
  if (memo)
390
445
  return memo;
446
+ if (expected.length && !primed.has(commit)) {
447
+ const commits = [...expected, commit];
448
+ expected = [];
449
+ batch(commits);
450
+ }
451
+ const batched = primed.get(commit);
452
+ if (batched)
453
+ return batched;
391
454
  const remember = (verdict) => key ? memoizeReachability(key, verdict) : verdict;
392
455
  const exact = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
393
456
  if (!exact.available)
@@ -404,6 +467,7 @@ export function localCommitReachability(root) {
404
467
  return remember("unreachable");
405
468
  return "unknown";
406
469
  };
470
+ return Object.assign(lookup, { prime: (commits) => { expected.push(...commits); } });
407
471
  }
408
472
  function validationState(anchors, anchorsResolve, commit, reachability, requireCommit = false, canValidateAnchors = true) {
409
473
  if (anchors.length && canValidateAnchors && !anchors.some(anchorsResolve)) {
@@ -563,8 +627,10 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
563
627
  const profile = options.profile ?? "builder";
564
628
  const budget = Number.isFinite(ctx.budget_tokens) ? Math.max(0, Math.floor(ctx.budget_tokens)) : 1500;
565
629
  const cap = budget * 4;
566
- const reachability = options.commitReachability
567
- ?? (options.root ? localCommitReachability(options.root) : (() => "unknown"));
630
+ const local = !options.commitReachability && options.root ? localCommitReachability(options.root) : null;
631
+ // Only decisions carry a capture commit; register them so the first lookup batches them all (#485).
632
+ local?.prime(ctx.decisions.flatMap((decision) => decision.commit ? [decision.commit] : []));
633
+ const reachability = options.commitReachability ?? local ?? (() => "unknown");
568
634
  const canValidateAnchors = options.root !== undefined || options.symbols !== undefined || options.components !== undefined;
569
635
  const candidates = [];
570
636
  const query = queryProfile(ctx.target, options);
@@ -10,6 +10,9 @@ export interface Migration {
10
10
  /** The version this migration PRODUCES (i.e. it upgrades version-1 → `version`). */
11
11
  version: number;
12
12
  description: string;
13
+ /** The kinds `up` changes. Omitted = it may touch any kind (conservative: a
14
+ * failed record of any kind then holds the manifest version). */
15
+ kinds?: EntityKind[];
13
16
  /** Transform one raw record (untrusted JSON) of `kind` to the next shape. */
14
17
  up(kind: EntityKind, raw: Record<string, unknown>): Record<string, unknown>;
15
18
  }
@@ -24,5 +27,11 @@ export declare function readManifest(paths: HunchPaths): Manifest;
24
27
  /** Write `.hunch/manifest.json` at `version` (default: the current SCHEMA_VERSION). */
25
28
  export declare function writeManifest(paths: HunchPaths, version?: number): void;
26
29
  /** Apply every migration in (fromVersion, toVersion] to a single raw record. Skips
27
- * non-object input untouched (the loader's Zod pass will reject it). */
30
+ * non-object input untouched (the loader's Zod pass will reject it). All-or-nothing:
31
+ * the steps run on a deep copy, so `raw` is never mutated and a caller that rejects
32
+ * the result (or a step that throws) keeps the original record intact — never a
33
+ * half-migrated one. */
28
34
  export declare function migrateRaw(kind: EntityKind, raw: unknown, fromVersion: number, migrations?: Migration[], toVersion?: number): unknown;
35
+ /** Whether some migration in (fromVersion, toVersion] may change a record of
36
+ * `kind` — i.e. whether re-running migrations could ever fix a failed one. */
37
+ export declare function hasPendingMigration(kind: EntityKind, fromVersion: number, migrations?: Migration[], toVersion?: number): boolean;
@@ -31,6 +31,7 @@ export const MIGRATIONS = [
31
31
  // losslessly — no record is dropped, and `valid_from` is populated BEFORE the
32
32
  // Zod pass. Defensive: input is untrusted JSON.
33
33
  version: 2,
34
+ kinds: ["decisions", "constraints"],
34
35
  description: "Add valid_from/valid_to/superseded_by/retired (decisions) and status/valid_from/valid_to (constraints)",
35
36
  up(kind, raw) {
36
37
  if (kind === "decisions") {
@@ -64,6 +65,7 @@ export const MIGRATIONS = [
64
65
  // relationship schema is validated; newly written resource relationships use
65
66
  // hunch.resource-relationship/1 and keep their currentness/metadata.
66
67
  version: 3,
68
+ kinds: ["edges"],
67
69
  description: "Version existing graph edges before adding Engineering Landscape resources and relationships",
68
70
  up(kind, raw) {
69
71
  if (kind === "edges") {
@@ -98,15 +100,24 @@ export function writeManifest(paths, version = SCHEMA_VERSION) {
98
100
  writeFileAtomic(paths.manifest, JSON.stringify({ schema_version: version }, null, 2) + "\n");
99
101
  }
100
102
  /** Apply every migration in (fromVersion, toVersion] to a single raw record. Skips
101
- * non-object input untouched (the loader's Zod pass will reject it). */
103
+ * non-object input untouched (the loader's Zod pass will reject it). All-or-nothing:
104
+ * the steps run on a deep copy, so `raw` is never mutated and a caller that rejects
105
+ * the result (or a step that throws) keeps the original record intact — never a
106
+ * half-migrated one. */
102
107
  export function migrateRaw(kind, raw, fromVersion, migrations = MIGRATIONS, toVersion = SCHEMA_VERSION) {
103
108
  if (raw === null || typeof raw !== "object" || Array.isArray(raw))
104
109
  return raw;
105
- let rec = raw;
106
- for (const m of [...migrations].sort((a, b) => a.version - b.version)) {
107
- if (m.version > fromVersion && m.version <= toVersion)
108
- rec = m.up(kind, rec);
109
- }
110
+ const steps = migrations.filter((m) => m.version > fromVersion && m.version <= toVersion);
111
+ if (steps.length === 0)
112
+ return raw; // nothing to apply: no copy needed
113
+ let rec = structuredClone(raw);
114
+ for (const m of steps.sort((a, b) => a.version - b.version))
115
+ rec = m.up(kind, rec);
110
116
  return rec;
111
117
  }
118
+ /** Whether some migration in (fromVersion, toVersion] may change a record of
119
+ * `kind` — i.e. whether re-running migrations could ever fix a failed one. */
120
+ export function hasPendingMigration(kind, fromVersion, migrations = MIGRATIONS, toVersion = SCHEMA_VERSION) {
121
+ return migrations.some((m) => m.version > fromVersion && m.version <= toVersion && (!m.kinds || m.kinds.includes(kind)));
122
+ }
112
123
  //# sourceMappingURL=migrate.js.map