@davesheffer/hunch 1.45.1 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +165 -33
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- package/dist/cli/serve.d.ts +2 -0
- package/dist/cli/serve.js +2 -1
- package/dist/cli/verify.d.ts +28 -0
- package/dist/cli/verify.js +265 -0
- package/dist/constitution/experiment.d.ts +1 -1
- package/dist/core/agenthook.d.ts +1 -0
- package/dist/core/agenthook.js +82 -26
- package/dist/core/boundedSpawn.d.ts +57 -0
- package/dist/core/boundedSpawn.js +321 -0
- package/dist/core/config.d.ts +6 -0
- package/dist/core/config.js +1 -0
- package/dist/core/delivery.d.ts +10 -0
- package/dist/core/delivery.js +117 -9
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/pipeline.d.ts +9 -2
- package/dist/core/pipeline.js +13 -3
- package/dist/core/procstart.d.ts +17 -3
- package/dist/core/procstart.js +26 -5
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.d.ts +1 -0
- package/dist/core/taskSelection.js +132 -11
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/core/whyRecord.d.ts +47 -0
- package/dist/core/whyRecord.js +105 -0
- package/dist/extractors/git.d.ts +32 -0
- package/dist/extractors/git.js +206 -94
- package/dist/extractors/indexer.js +42 -6
- package/dist/extractors/languages.d.ts +9 -0
- package/dist/extractors/languages.js +28 -0
- package/dist/extractors/parse.d.ts +20 -0
- package/dist/extractors/parse.js +81 -5
- package/dist/extractors/workspaces.js +3 -1
- package/dist/integrations/health.d.ts +6 -0
- package/dist/integrations/health.js +33 -12
- package/dist/integrations/hooks.d.ts +4 -2
- package/dist/integrations/hooks.js +12 -2
- package/dist/integrations/mergeDriver.js +5 -3
- package/dist/integrations/optout.d.ts +3 -0
- package/dist/integrations/optout.js +17 -0
- package/dist/integrations/providers.d.ts +6 -2
- package/dist/integrations/providers.js +19 -2
- package/dist/integrations/team.js +6 -4
- package/dist/integrations/uninstall.d.ts +17 -4
- package/dist/integrations/uninstall.js +35 -13
- package/dist/mcp/server.js +179 -30
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +6 -5
- package/dist/serve/writelock.js +26 -21
- package/dist/store/hunchStore.d.ts +12 -1
- package/dist/store/hunchStore.js +86 -4
- package/dist/store/jsonStore.d.ts +24 -4
- package/dist/store/jsonStore.js +75 -26
- package/dist/store/stateBinding.js +17 -4
- package/dist/synthesis/provider.js +26 -78
- package/dist/synthesis/synthesize.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
|
@@ -0,0 +1,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
|
package/dist/core/config.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/config.js
CHANGED
|
@@ -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 {
|
package/dist/core/delivery.d.ts
CHANGED
|
@@ -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
|
package/dist/core/delivery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
525
|
-
|
|
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);
|
package/dist/core/migrate.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/migrate.js
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
package/dist/core/pipeline.d.ts
CHANGED
|
@@ -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
|
-
|
|
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[];
|
package/dist/core/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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);
|