@davesheffer/hunch 1.45.2 → 1.47.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 (91) hide show
  1. package/dist/cli/index.js +290 -353
  2. package/dist/cli/invocation.d.ts +22 -13
  3. package/dist/cli/invocation.js +49 -29
  4. package/dist/cli/verify.d.ts +28 -0
  5. package/dist/cli/verify.js +265 -0
  6. package/dist/constitution/delta.js +2 -2
  7. package/dist/constitution/experiment.d.ts +1 -1
  8. package/dist/constitution/sourceMutation.js +28 -18
  9. package/dist/core/agenthook.js +26 -6
  10. package/dist/core/boundedSpawn.d.ts +57 -0
  11. package/dist/core/boundedSpawn.js +321 -0
  12. package/dist/core/commitrepair.d.ts +43 -138
  13. package/dist/core/commitrepair.js +76 -167
  14. package/dist/core/config.d.ts +6 -0
  15. package/dist/core/config.js +1 -0
  16. package/dist/core/correction.js +9 -9
  17. package/dist/core/delivery.d.ts +6 -1
  18. package/dist/core/delivery.js +71 -5
  19. package/dist/core/docanchors.js +9 -9
  20. package/dist/core/drift.js +22 -27
  21. package/dist/core/escalations.d.ts +16 -58
  22. package/dist/core/escalations.js +39 -121
  23. package/dist/core/glob.d.ts +7 -7
  24. package/dist/core/glob.js +12 -11
  25. package/dist/core/groundingLag.d.ts +2 -9
  26. package/dist/core/groundingLag.js +5 -11
  27. package/dist/core/groundingMerge.d.ts +2 -7
  28. package/dist/core/groundingMerge.js +35 -54
  29. package/dist/core/migrate.d.ts +10 -1
  30. package/dist/core/migrate.js +17 -6
  31. package/dist/core/paths.d.ts +17 -24
  32. package/dist/core/paths.js +37 -43
  33. package/dist/core/projectDna.js +11 -1
  34. package/dist/core/refrepair.d.ts +2 -1
  35. package/dist/core/refrepair.js +4 -4
  36. package/dist/core/relativeImports.js +6 -2
  37. package/dist/core/repairqueue.d.ts +17 -34
  38. package/dist/core/repairqueue.js +48 -98
  39. package/dist/core/taskSelection.js +48 -6
  40. package/dist/core/testSelection.d.ts +122 -0
  41. package/dist/core/testSelection.js +588 -0
  42. package/dist/core/types.d.ts +3 -3
  43. package/dist/core/types.js +1 -1
  44. package/dist/extractors/diff.d.ts +4 -4
  45. package/dist/extractors/diff.js +25 -29
  46. package/dist/extractors/git.d.ts +8 -23
  47. package/dist/extractors/git.js +77 -95
  48. package/dist/extractors/helm.d.ts +10 -0
  49. package/dist/extractors/helm.js +43 -59
  50. package/dist/extractors/indexer.d.ts +6 -0
  51. package/dist/extractors/indexer.js +168 -184
  52. package/dist/extractors/k8sManifest.d.ts +23 -80
  53. package/dist/extractors/k8sManifest.js +319 -690
  54. package/dist/extractors/languages.d.ts +37 -37
  55. package/dist/extractors/languages.js +83 -54
  56. package/dist/extractors/nativeTreeSitter.d.ts +4 -4
  57. package/dist/extractors/nativeTreeSitter.js +17 -14
  58. package/dist/extractors/parse.d.ts +27 -13
  59. package/dist/extractors/parse.js +120 -56
  60. package/dist/extractors/workspaces.js +3 -1
  61. package/dist/integrations/claudemd.js +1 -0
  62. package/dist/integrations/gitignore.js +3 -5
  63. package/dist/integrations/health.d.ts +6 -0
  64. package/dist/integrations/health.js +33 -12
  65. package/dist/integrations/hooks.d.ts +13 -16
  66. package/dist/integrations/hooks.js +31 -30
  67. package/dist/integrations/mergeDriver.js +18 -17
  68. package/dist/integrations/optout.d.ts +3 -0
  69. package/dist/integrations/optout.js +17 -0
  70. package/dist/integrations/providers.d.ts +6 -2
  71. package/dist/integrations/providers.js +19 -2
  72. package/dist/integrations/team.js +4 -2
  73. package/dist/integrations/uninstall.d.ts +10 -4
  74. package/dist/integrations/uninstall.js +22 -18
  75. package/dist/mcp/server.d.ts +4 -5
  76. package/dist/mcp/server.js +107 -81
  77. package/dist/serve/app.js +9 -1
  78. package/dist/serve/config.js +5 -0
  79. package/dist/serve/writelock.d.ts +3 -1
  80. package/dist/serve/writelock.js +17 -3
  81. package/dist/store/hunchStore.d.ts +2 -1
  82. package/dist/store/hunchStore.js +20 -23
  83. package/dist/store/jsonStore.d.ts +13 -3
  84. package/dist/store/jsonStore.js +37 -13
  85. package/dist/store/stateBinding.js +17 -4
  86. package/dist/synthesis/provider.d.ts +19 -21
  87. package/dist/synthesis/provider.js +93 -154
  88. package/dist/synthesis/synthesize.d.ts +8 -8
  89. package/dist/synthesis/synthesize.js +33 -50
  90. package/package.json +3 -3
  91. 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
@@ -1,5 +1,6 @@
1
1
  import type { Decision } from "./types.js";
2
2
  import type { CommitCandidate, CommitRepairStatus } from "../extractors/git.js";
3
+ /** One proposed provenance fix: decision `id` cites `from`, which should cite `to`. */
3
4
  export interface CommitRewrite {
4
5
  id: string;
5
6
  from: string;
@@ -8,14 +9,12 @@ export interface CommitRewrite {
8
9
  /** Bind review to the ordered queue and which targets are currently resolvable.
9
10
  * Queue order matters: duplicate decision ids use first-match selection. */
10
11
  export declare function commitRepairReviewHash(queued: readonly CommitRewrite[], withheld?: ReadonlySet<CommitRewrite>): string;
11
- /** A tombstone: the exact {id, from, to} rewrite a human explicitly rejected
12
- * via `--drop`. Keyed on the full triple, not just {id, from} — the human's
13
- * "no" answers the specific question the commit-repair-pending escalation
14
- * asked ("apply THIS proposed replacement, from -> to?"), not a blanket
15
- * refusal of every future replacement for this decision's still-orphaned
16
- * commit. A later merge proposing a genuinely different `to` for the same
17
- * {id, from} is a NEW proposal — not a repeat of the one that was rejected —
18
- * and surfaces normally. */
12
+ /**
13
+ * Tombstone for a rewrite a human explicitly rejected with `--drop`. The key is
14
+ * the full `{id, from, to}` triple, not just `{id, from}`: a later merge that
15
+ * proposes a DIFFERENT `to` for the same `{id, from}` is a new proposal and
16
+ * must surface normally.
17
+ */
19
18
  export interface DroppedRewrite {
20
19
  id: string;
21
20
  from: string;
@@ -23,12 +22,10 @@ export interface DroppedRewrite {
23
22
  }
24
23
  export interface CommitRepairPlan {
25
24
  rewrites: CommitRewrite[];
25
+ /** Distinct decision ids the rewrites touch. */
26
26
  records: string[];
27
27
  }
28
- /** Live decisions whose cited commit still exists here but is no longer an
29
- * ancestor of the current tip. Only "orphaned" qualifies: a commit that
30
- * doesn't resolve at all ("unresolvable") is out of repair scope, and a git
31
- * failure ("unknown") must never be mistaken for repair-eligible. */
28
+ /** Live decisions whose cited commit is orphaned (unresolvable/unknown/current are excluded). */
32
29
  export declare function orphanedCommitDecisions(decisions: readonly Decision[], status: (sha: string) => CommitRepairStatus): Decision[];
33
30
  /** Match each orphaned decision to exactly one candidate. A candidate with the
34
31
  * same `git patch-id --stable` as the orphaned commit (a rebase- or
@@ -39,139 +36,47 @@ export declare function orphanedCommitDecisions(decisions: readonly Decision[],
39
36
  * patch-id never equals any one of them, but weak: shared files like
40
37
  * CHANGELOG.md often have one wrong superset (#450). */
41
38
  export declare function planCommitRepair(orphaned: readonly Decision[], candidates: readonly CommitCandidate[], patchIdOf?: (sha: string) => string | null): CommitRepairPlan;
42
- /** Whether a queued rewrite is still worth ASKING about: the decision it
43
- * names still exists, still cites the commit the entry expects to replace (a
44
- * decision that already moved on would make repairDecisionCommit's own
45
- * stale-plan bail refuse it), and hasn't since been superseded/rejected —
46
- * same exclusion as orphanedCommitDecisions, since repairing dead history's
47
- * provenance isn't worth asking about. Backs the read-only escalation
48
- * surface only (commitRepairEscalations) — absence-means-not-askable is
49
- * safe there because it self-heals the moment visibility returns.
50
- * repair-provenance itself does NOT use this: it reads the full store and
51
- * may legitimately list (dry-run) or leave queued (apply) an entry this
52
- * predicate would call not-live, so the escalation surface and the CLI can
53
- * disagree about the same entry — see deadRewrites and resolvedRewriteIds
54
- * for the CLI's own, looser-on-purpose reasoning. */
39
+ /** Queued entries that are still answerable: the decision exists, still cites `from`, and is live. */
55
40
  export declare function liveRewrites(queued: readonly CommitRewrite[], decisions: readonly Decision[]): CommitRewrite[];
56
- /** Entries a decision has demonstrably outgrown: the decision IS present in
57
- * `decisions`, and it either moved on from `from` or has since been
58
- * superseded/rejected. An id this list can't see is deliberately NOT
59
- * included — absence is a property of the READER (a branch checkout that
60
- * predates the decision, an unmounted private overlay), not proof the match
61
- * is stale. This predicate backs a DESTRUCTIVE prune (repair-provenance
62
- * deletes what it returns from the queue file); repairqueue.ts's own
63
- * docstring is why that matters — the queue is the one durable record of a
64
- * match once ORIG_HEAD moves on and the matched-away commit can be gc'd, so
65
- * deleting an entry on a merely-absent id would destroy it unrecoverably.
66
- * Contrast liveRewrites, whose absence-means-not-askable rule is safe on
67
- * the read-only escalation surface, which self-heals once visibility
68
- * returns.
69
- *
70
- * Caveat: "demonstrably" is only as strong as this run's view of
71
- * `decisions`, which comes from the same git-tracked, branch-dependent
72
- * files that motivate treating ABSENCE as inconclusive. A decision whose
73
- * `commit`/`status` genuinely differ between branches (e.g. a repair
74
- * already applied and committed on one branch, checked out here from a
75
- * branch that predates it) could still read as "moved on" when it hasn't,
76
- * from this branch's perspective. Far narrower than the absence case this
77
- * function exists to fix, and not addressed here. */
41
+ /**
42
+ * Queued entries whose decision IS visible but moved on (different or null
43
+ * commit) or was superseded/rejected. An id absent from `decisions` is never
44
+ * dead: absence comes from the reader's view (a branch checkout, an unmounted
45
+ * private overlay) and the CLI deletes whatever this returns. Caveat: a decision
46
+ * that differs between branches could still read as "moved on".
47
+ */
78
48
  export declare function deadRewrites(queued: readonly CommitRewrite[], decisions: readonly Decision[]): CommitRewrite[];
79
- /** Among `toApply` (the queued/matched candidates this run is about to act
80
- * on), the ids repair-provenance may treat as RESOLVED once it's done: the
81
- * decision is present in `decisions` this run, whether or not
82
- * repairDecisionCommit actually changed it (a present decision that already
83
- * moved on is "resolved" too — repairDecisionCommit's own bail refused it,
84
- * and there is nothing more this run can do about it). An id whose decision
85
- * is NOT present is never resolved — same reasoning as deadRewrites:
86
- * absence is the reader's transient view, not proof the match is settled,
87
- * so it must stay queued for a run where the decision is visible again. */
49
+ /** Ids of `toApply` whose decision is visible, whether or not the rewrite changed it. */
88
50
  export declare function resolvedRewriteIds(toApply: readonly CommitRewrite[], decisions: readonly Decision[]): Set<string>;
89
- /** Remove any match whose exact {id, from, to} triple was already tombstoned
90
- * by an earlier `--drop` — otherwise a later merge that re-derives the
91
- * identical candidate would re-queue exactly what the human rejected. A
92
- * match sharing {id, from} but proposing a different `to` is unaffected: it
93
- * is a different proposal, not the rejected one.
94
- *
95
- * Preserves object identity for every surviving entry (a plain filter, never
96
- * a clone or a rebuild) — repair-provenance's own action relies on this to
97
- * diff its swept-vs-active queue by reference (`queue.filter(r =>
98
- * !active.includes(r))`) rather than re-spelling the {id, from, to} key a
99
- * third time. Do not change this to normalize or reconstruct entries
100
- * without updating that call site too. */
101
- export declare function withoutDropped(rewrites: readonly CommitRewrite[], dropped: readonly DroppedRewrite[]): CommitRewrite[];
102
- /** Append newly-dropped {id, from, to} triples to the tombstone list,
103
- * deduped — same append-only, dedupe-by-key shape as mergeRewrites. */
51
+ /** Drop rewrites whose exact {id, from, to} triple is tombstoned. A plain filter: survivors keep identity. */
52
+ export declare function withoutDropped<T extends CommitRewrite>(rewrites: readonly T[], dropped: readonly DroppedRewrite[]): T[];
53
+ /** `existing` followed by the entries of `newly` whose exact triple is not already tombstoned. */
104
54
  export declare function addDropped(newly: readonly DroppedRewrite[], existing: readonly DroppedRewrite[]): DroppedRewrite[];
105
- /** Combine a freshly-computed plan's rewrites with anything already queued
106
- * (src/core/repairqueue.ts) from an earlier detection. A fresh match —
107
- * computed just now, against the current range — overrides a queued one for
108
- * the same decision; queued entries no longer reachable are simply not
109
- * repeated by the fresh scan.
110
- *
111
- * Overrides by object identity, targeting exactly the one queued entry
112
- * firstFor (id) would pick — the same first-match rule --drop applies, and
113
- * the same identity-not-id targeting the dead-entry prune applies (#53; the
114
- * prune itself removes every provably-dead entry, not just a first match).
115
- * A corrupted queue file, a hand edit, or a bug upstream could otherwise
116
- * carry a SECOND entry sharing that id; overriding by id alone would
117
- * silently destroy that still-queued sibling too, with no tombstone and no
118
- * way to recover it, even though it was never the entry that would have
119
- * been applied. Any such sibling is left queued and untouched.
120
- *
121
- * How a survivor resolves: the dead-entry prune (deadRewrites), once
122
- * --apply has moved the decision past the sibling's `from`; or a --drop
123
- * whose tombstone matches the sibling's exact {id, from, to} — the same
124
- * --drop that dropped the fresh entry, when the sibling happens to share
125
- * its triple (the drop's own re-sweep, withoutDropped, catches it too), a
126
- * second explicit --drop otherwise. NOT a later fresh detection: by this
127
- * same first-match rule it always overrides the fresh entry sitting ahead
128
- * of it, never the sibling. */
55
+ /**
56
+ * Fresh detections first, then the queued entries they did not override. A fresh
57
+ * entry overrides only the FIRST queued entry with its id (see `firstFor`);
58
+ * later duplicate-id siblings survive untouched.
59
+ */
129
60
  export declare function mergeRewrites(fresh: readonly CommitRewrite[], queued: readonly CommitRewrite[]): CommitRewrite[];
130
- /** Partition the entries --apply is about to act on by whether their proposed
131
- * `to` commit actually exists in this repository. `existing` is
132
- * `commitsExist`'s result over every `to` in `toApply`: `null` means the
133
- * check itself failed to run (not a git repo, git missing, timeout) and is
134
- * treated as fail-open, same discipline as drift.ts's own use of
135
- * commitsExist — nothing is withheld just because the check couldn't run.
136
- *
137
- * A corrupted queue file, a hand-edited entry, or a bug upstream could
138
- * otherwise get a `to` that never resolves to a real commit written
139
- * straight into a decision's `commit` field and provenance evidence, then
140
- * auto-committed into shared team memory. `withheld` entries must stay
141
- * queued (never deleted, never passed to repairDecisionCommit) — the queue
142
- * file is the one durable record of the match, same reasoning as
143
- * deadRewrites/resolvedRewriteIds.
144
- *
145
- * Both returned arrays preserve object identity (never clones or rebuilds
146
- * an entry) — the CLI's queue sweep and its "still queued" reporting both
147
- * key a `Set` off the exact objects in `withheld`, by reference, precisely
148
- * because a corrupted queue file can carry two entries sharing an id (one
149
- * resolvable, one not): an id alone can't tell them apart. Do not change
150
- * this to normalize or reconstruct entries without updating those call
151
- * sites too — same caveat withoutDropped's own docstring carries. */
152
- export declare function withheldForUnresolvableTo(toApply: readonly CommitRewrite[], existing: Set<string> | null): {
61
+ /**
62
+ * Split entries by whether their proposed `to` resolves here. `existing === null`
63
+ * means the check could not run, so it fails open (everything applicable).
64
+ * Order and object references are preserved.
65
+ */
66
+ export declare function withheldForUnresolvableTo(toApply: readonly CommitRewrite[], existing: ReadonlySet<string> | null): {
153
67
  applicable: CommitRewrite[];
154
68
  withheld: CommitRewrite[];
155
69
  };
156
- /** The one rule for which entry wins when more than one shares a decision id
157
- * (a corrupted queue file, a hand edit, or a bug upstream): first match, by
158
- * construction. Every caller goes through here so no two can independently
159
- * drift on which entry "won" — this file's own duplicate-id queue handling
160
- * has drifted between call sites three times already (see withoutDropped's,
161
- * withheldForUnresolvableTo's, and mergeRewrites' own docstrings). */
162
- export declare function firstFor(entries: readonly CommitRewrite[], id: string): CommitRewrite | undefined;
163
- /** pickRewrite is firstFor scoped to a plan's own rewrites (planCommitRepair
164
- * itself never produces same-id duplicates, but a plan carrying them isn't
165
- * ruled out here — see firstFor). repairDecisionCommit uses this to decide
166
- * what to WRITE; a caller that needs to know which entry WOULD be picked
167
- * without writing anything (the CLI's --apply summary and queue sweep)
168
- * calls this same function, so the two can never independently drift on
169
- * which entry "won". */
70
+ /** The single first-match rule for duplicate decision ids. */
71
+ export declare function firstFor<T extends {
72
+ id: string;
73
+ }>(entries: readonly T[], id: string): T | undefined;
74
+ /** The plan's rewrite for `id` (first match). */
170
75
  export declare function pickRewrite(plan: CommitRepairPlan, id: string): CommitRewrite | undefined;
171
- /** Pure: rewrite `commit` for one decision, and its matching `commit:<sha>`
172
- * evidence entry too if one is present (replaceExact is a no-op when the
173
- * evidence array never cited the old sha) — or return the same reference
174
- * when the plan doesn't touch it. If the record moved on since the plan was
175
- * built (its commit no longer equals the plan's `from`), bail entirely
176
- * rather than applying a now-stale match. */
76
+ /**
77
+ * Pure: apply the plan's rewrite to a decision. Returns the same reference when
78
+ * there is no entry or the record moved on (`commit` no longer equals `from`);
79
+ * otherwise a copy with `commit` and the `commit:<from>` evidence moved to `to`
80
+ * (deduped if `to` was already cited).
81
+ */
177
82
  export declare function repairDecisionCommit(d: Decision, plan: CommitRepairPlan): Decision;