@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,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 {
@@ -124,6 +124,16 @@ export declare const SEVERITY: {
124
124
  export declare const PROFILE_BASE_SCORE: Record<DeliveryProfile, Record<DeliveryKind, number>>;
125
125
  export declare const TASK_STOP_WORDS: Set<string>;
126
126
  export declare function lexicalTokens(value: string): Set<string>;
127
+ /** Test hook: forget memoized commit verdicts. */
128
+ export declare function resetCommitReachabilityMemo(): void;
129
+ /** Test hook: number of git spawns issued by delivery provenance probes so far. */
130
+ export declare function commitReachabilityProbeCount(): number;
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;
127
137
  /** Validate the public receipt without trusting a caller-supplied identity. */
128
138
  export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
129
139
  /** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
@@ -311,11 +311,15 @@ function componentResolves(id, target, options) {
311
311
  const component = (options.components ?? []).find((candidate) => candidate.id === id && candidate.status === "active");
312
312
  return !!component && component.paths.some((path) => anchorResolves(path, target, options));
313
313
  }
314
- function gitProbe(root, args) {
314
+ let gitProbeCount = 0;
315
+ function gitProbe(root, args, input) {
316
+ gitProbeCount++;
315
317
  const result = spawnSync("git", ["-C", root, ...args], {
316
318
  encoding: "utf8",
317
319
  env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
318
- stdio: ["ignore", "pipe", "ignore"],
320
+ input,
321
+ maxBuffer: 64 * 1024 * 1024,
322
+ stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
319
323
  timeout: 2_000,
320
324
  windowsHide: true,
321
325
  });
@@ -336,32 +340,134 @@ function defaultBranchRef(root) {
336
340
  }
337
341
  return null;
338
342
  }
339
- function localCommitReachability(root) {
343
+ // Process-wide memo (#473): every delivery build used to re-probe git for the
344
+ // same capture commits. Verdicts are keyed by the default branch's TIP sha, so a
345
+ // long-lived process stays correct after new commits land. Only ancestry
346
+ // answers (merge-base exit 0/1) are cached; a missing object or a transient
347
+ // spawn failure is re-probed on the next call.
348
+ const REACHABILITY_MEMO_CAP = 10_000;
349
+ const reachabilityMemo = new Map();
350
+ /** Test hook: forget memoized commit verdicts. */
351
+ export function resetCommitReachabilityMemo() {
352
+ reachabilityMemo.clear();
353
+ }
354
+ /** Test hook: number of git spawns issued by delivery provenance probes so far. */
355
+ export function commitReachabilityProbeCount() {
356
+ return gitProbeCount;
357
+ }
358
+ function memoizeReachability(key, verdict) {
359
+ if (reachabilityMemo.size >= REACHABILITY_MEMO_CAP)
360
+ reachabilityMemo.clear();
361
+ reachabilityMemo.set(key, verdict);
362
+ return verdict;
363
+ }
364
+ export function localCommitReachability(root) {
340
365
  let initialized = false;
341
366
  let branch = null;
342
- return (commit) => {
367
+ let tip = null;
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) => {
343
422
  if (!initialized) {
423
+ // The default ref is looked up every build (origin/HEAD can be retargeted
424
+ // while the old ref still exists); only verdicts are shared across builds.
344
425
  branch = defaultBranchRef(root);
426
+ if (branch) {
427
+ // One spawn: the tip sha plus whether the repo is shallow. A shallow
428
+ // boundary can make a present commit look unreachable until a deepen,
429
+ // so a shallow build never memoizes. Unexpected output disables the memo.
430
+ const resolved = gitProbe(root, ["rev-parse", "--is-shallow-repository", "--verify", "--quiet", `${branch}^{commit}`]);
431
+ const [shallow, sha, ...rest] = resolved.stdout.split(/\r?\n/);
432
+ if (resolved.available && resolved.status === 0 && !rest.length && shallow === "false" && sha && /^[0-9a-f]{40,64}$/i.test(sha)) {
433
+ tip = sha;
434
+ }
435
+ }
345
436
  initialized = true;
346
437
  }
347
438
  if (!branch)
348
439
  return "unknown";
349
440
  if (!/^[0-9a-f]{7,64}$/i.test(commit))
350
441
  return "unreachable";
442
+ const key = tip ? `${root}\0${tip}\0${commit}` : null;
443
+ const memo = key ? reachabilityMemo.get(key) : undefined;
444
+ if (memo)
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;
454
+ const remember = (verdict) => key ? memoizeReachability(key, verdict) : verdict;
351
455
  const exact = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
352
456
  if (!exact.available)
353
457
  return "unknown";
458
+ // A missing object is not memoized: a later fetch/deepen can bring it in under the same tip.
354
459
  if (exact.status !== 0)
355
460
  return "unreachable";
356
- const ancestry = gitProbe(root, ["merge-base", "--is-ancestor", exact.stdout, branch]);
461
+ const ancestry = gitProbe(root, ["merge-base", "--is-ancestor", exact.stdout, tip ?? branch]);
357
462
  if (!ancestry.available)
358
463
  return "unknown";
359
464
  if (ancestry.status === 0)
360
- return "reachable";
465
+ return remember("reachable");
361
466
  if (ancestry.status === 1)
362
- return "unreachable";
467
+ return remember("unreachable");
363
468
  return "unknown";
364
469
  };
470
+ return Object.assign(lookup, { prime: (commits) => { expected.push(...commits); } });
365
471
  }
366
472
  function validationState(anchors, anchorsResolve, commit, reachability, requireCommit = false, canValidateAnchors = true) {
367
473
  if (anchors.length && canValidateAnchors && !anchors.some(anchorsResolve)) {
@@ -521,8 +627,10 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
521
627
  const profile = options.profile ?? "builder";
522
628
  const budget = Number.isFinite(ctx.budget_tokens) ? Math.max(0, Math.floor(ctx.budget_tokens)) : 1500;
523
629
  const cap = budget * 4;
524
- const reachability = options.commitReachability
525
- ?? (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");
526
634
  const canValidateAnchors = options.root !== undefined || options.symbols !== undefined || options.components !== undefined;
527
635
  const candidates = [];
528
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
@@ -231,11 +231,18 @@ export interface BeforeEditProbeVerdict {
231
231
  block: boolean;
232
232
  state: PipelineState;
233
233
  reason?: string;
234
+ /** The host cannot report command results, so the evidence is unverifiable:
235
+ * the gate is advisory and `reason` says why instead of denying. */
236
+ unverifiable?: boolean;
234
237
  }
235
238
  /** Before-edit evidence is useful only before implementation. At firm/strict,
236
239
  * deny at most two product edits per prompt until all such receipts exist;
237
- * then fail open so a broken probe or discriminator cannot deadlock the agent. */
238
- export declare function beforeEditProbeVerdict(state: PipelineState): BeforeEditProbeVerdict;
240
+ * then fail open so a broken probe or discriminator cannot deadlock the agent.
241
+ * A host whose hooks carry no command outcome (`commandOutcomes: false`) can
242
+ * never produce a receipt, so its gate is advisory: it never denies. */
243
+ export declare function beforeEditProbeVerdict(state: PipelineState, opts?: {
244
+ commandOutcomes?: boolean;
245
+ }): BeforeEditProbeVerdict;
239
246
  export declare function executionObligationBrief(state: PipelineState): string;
240
247
  /** Harness/orchestrator injection. Malformed input returns no obligations. */
241
248
  export declare function environmentExecutionObligations(value?: string | undefined): ExecutionObligation[];
@@ -704,10 +704,20 @@ export function pendingExecutionObligations(state) {
704
704
  }
705
705
  /** Before-edit evidence is useful only before implementation. At firm/strict,
706
706
  * deny at most two product edits per prompt until all such receipts exist;
707
- * then fail open so a broken probe or discriminator cannot deadlock the agent. */
708
- export function beforeEditProbeVerdict(state) {
707
+ * then fail open so a broken probe or discriminator cannot deadlock the agent.
708
+ * A host whose hooks carry no command outcome (`commandOutcomes: false`) can
709
+ * never produce a receipt, so its gate is advisory: it never denies. */
710
+ export function beforeEditProbeVerdict(state, opts = {}) {
709
711
  const pending = state.obligations.filter((item) => item.phase === "before-edit" && item.status !== "satisfied");
710
- if (!pending.length || state.probeBlocks >= 2)
712
+ if (!pending.length)
713
+ return { block: false, state };
714
+ if (opts.commandOutcomes === false) {
715
+ return {
716
+ block: false, state, unverifiable: true,
717
+ reason: `Hunch evidence gate is advisory here: this host's hooks do not report command results, so ${pending.length} pre-edit evidence obligation(s) cannot be verified and do not block edits. Run them anyway and check their output yourself.`,
718
+ };
719
+ }
720
+ if (state.probeBlocks >= 2)
711
721
  return { block: false, state };
712
722
  const instructions = pending.slice(0, 3).map((item) => {
713
723
  const command = item.probe?.command ?? firstCommand(item);