@davesheffer/hunch 1.45.2 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +139 -32
- package/dist/cli/invocation.d.ts +14 -0
- package/dist/cli/invocation.js +23 -0
- 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.js +26 -6
- 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 +6 -1
- package/dist/core/delivery.js +71 -5
- package/dist/core/migrate.d.ts +10 -1
- package/dist/core/migrate.js +17 -6
- package/dist/core/projectDna.js +11 -1
- package/dist/core/relativeImports.js +6 -2
- package/dist/core/taskSelection.js +48 -6
- package/dist/core/testSelection.d.ts +122 -0
- package/dist/core/testSelection.js +588 -0
- package/dist/extractors/git.js +32 -31
- 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 +4 -2
- package/dist/integrations/uninstall.d.ts +10 -4
- package/dist/integrations/uninstall.js +22 -18
- package/dist/mcp/server.js +47 -8
- package/dist/serve/app.js +9 -1
- package/dist/serve/config.js +5 -0
- package/dist/serve/writelock.d.ts +3 -1
- package/dist/serve/writelock.js +17 -3
- package/dist/store/hunchStore.d.ts +2 -1
- package/dist/store/hunchStore.js +8 -5
- package/dist/store/jsonStore.d.ts +13 -3
- package/dist/store/jsonStore.js +37 -13
- 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
package/dist/core/agenthook.js
CHANGED
|
@@ -209,16 +209,32 @@ function explicitToolOutcome(response) {
|
|
|
209
209
|
return "success";
|
|
210
210
|
return "unknown";
|
|
211
211
|
}
|
|
212
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
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
|
@@ -128,7 +128,12 @@ export declare function lexicalTokens(value: string): Set<string>;
|
|
|
128
128
|
export declare function resetCommitReachabilityMemo(): void;
|
|
129
129
|
/** Test hook: number of git spawns issued by delivery provenance probes so far. */
|
|
130
130
|
export declare function commitReachabilityProbeCount(): number;
|
|
131
|
-
|
|
131
|
+
/** A per-build reachability probe. `prime` registers the commits the build may
|
|
132
|
+
* ask about; the first lookup then judges all of them in two batched spawns. */
|
|
133
|
+
export type LocalCommitReachability = ((commit: string) => CommitReachability) & {
|
|
134
|
+
prime: (commits: readonly string[]) => void;
|
|
135
|
+
};
|
|
136
|
+
export declare function localCommitReachability(root: string): LocalCommitReachability;
|
|
132
137
|
/** Validate the public receipt without trusting a caller-supplied identity. */
|
|
133
138
|
export declare function assertDeliveryEnvelope(envelope: DeliveryEnvelope): void;
|
|
134
139
|
/** The string a hash-based injection dedup (`injectionMode`'s `hashInput`) must
|
package/dist/core/delivery.js
CHANGED
|
@@ -312,12 +312,14 @@ function componentResolves(id, target, options) {
|
|
|
312
312
|
return !!component && component.paths.some((path) => anchorResolves(path, target, options));
|
|
313
313
|
}
|
|
314
314
|
let gitProbeCount = 0;
|
|
315
|
-
function gitProbe(root, args) {
|
|
315
|
+
function gitProbe(root, args, input) {
|
|
316
316
|
gitProbeCount++;
|
|
317
317
|
const result = spawnSync("git", ["-C", root, ...args], {
|
|
318
318
|
encoding: "utf8",
|
|
319
319
|
env: { ...process.env, GIT_NO_REPLACE_OBJECTS: "1" },
|
|
320
|
-
|
|
320
|
+
input,
|
|
321
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
322
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
|
|
321
323
|
timeout: 2_000,
|
|
322
324
|
windowsHide: true,
|
|
323
325
|
});
|
|
@@ -363,7 +365,60 @@ export function localCommitReachability(root) {
|
|
|
363
365
|
let initialized = false;
|
|
364
366
|
let branch = null;
|
|
365
367
|
let tip = null;
|
|
366
|
-
|
|
368
|
+
// #485: each hook call is a fresh process, so the memo alone still cost two
|
|
369
|
+
// spawns per capture commit. Primed commits are judged together on the first
|
|
370
|
+
// lookup — one `cat-file --batch-check` resolves them, one `rev-list` tests
|
|
371
|
+
// ancestry — and kept for this build only. A spawn failure (timeout, ENOBUFS)
|
|
372
|
+
// primes every pending commit "unknown", the verdict a failed per-commit probe
|
|
373
|
+
// gives, so a slow git costs one timeout rather than 2N more spawns. Unexpected
|
|
374
|
+
// output leaves a commit unprimed and the per-commit path below decides it, so
|
|
375
|
+
// within a build the verdicts match that path.
|
|
376
|
+
let expected = [];
|
|
377
|
+
const primed = new Map();
|
|
378
|
+
const batch = (commits) => {
|
|
379
|
+
const pending = [...new Set(commits)].filter((commit) => /^[0-9a-f]{7,64}$/i.test(commit) && !primed.has(commit) && !(tip && reachabilityMemo.has(`${root}\0${tip}\0${commit}`)));
|
|
380
|
+
if (!pending.length || !branch)
|
|
381
|
+
return;
|
|
382
|
+
const resolved = gitProbe(root, ["cat-file", "--batch-check=%(objectname)"], pending.map((commit) => `${commit}^{commit}\n`).join(""));
|
|
383
|
+
if (!resolved.available) {
|
|
384
|
+
for (const commit of pending)
|
|
385
|
+
primed.set(commit, "unknown");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const lines = resolved.stdout.split(/\r?\n/);
|
|
389
|
+
if (resolved.status !== 0 || lines.length !== pending.length)
|
|
390
|
+
return;
|
|
391
|
+
const shas = new Map();
|
|
392
|
+
for (const [i, commit] of pending.entries()) {
|
|
393
|
+
const line = lines[i];
|
|
394
|
+
if (/^[0-9a-f]{40,64}$/i.test(line))
|
|
395
|
+
shas.set(commit, line);
|
|
396
|
+
// A missing object is not memoized, as on the per-commit path.
|
|
397
|
+
else if (line === `${commit}^{commit} missing` || line === `${commit}^{commit} ambiguous`)
|
|
398
|
+
primed.set(commit, "unreachable");
|
|
399
|
+
else
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (!shas.size)
|
|
403
|
+
return;
|
|
404
|
+
// `rev-list <shas> ^<tip>` lists exactly the commits NOT reachable from the tip.
|
|
405
|
+
// It prints the whole history behind an unreachable capture commit (e.g.
|
|
406
|
+
// orphaned by a rewrite), so this probe costs O(|unreachable history|); a
|
|
407
|
+
// history big enough to time out primes "unknown" below.
|
|
408
|
+
const listed = gitProbe(root, ["rev-list", "--stdin"], `${[...new Set(shas.values())].join("\n")}\n^${tip ?? branch}\n`);
|
|
409
|
+
// A failed spawn or a nonzero exit is a merge-base answer outside 0/1: "unknown", never memoized.
|
|
410
|
+
if (!listed.available || listed.status !== 0) {
|
|
411
|
+
for (const commit of shas.keys())
|
|
412
|
+
primed.set(commit, "unknown");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const unreachable = new Set(listed.stdout.split(/\r?\n/).filter(Boolean));
|
|
416
|
+
for (const [commit, sha] of shas) {
|
|
417
|
+
const verdict = unreachable.has(sha) ? "unreachable" : "reachable";
|
|
418
|
+
primed.set(commit, tip ? memoizeReachability(`${root}\0${tip}\0${commit}`, verdict) : verdict);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
const lookup = (commit) => {
|
|
367
422
|
if (!initialized) {
|
|
368
423
|
// The default ref is looked up every build (origin/HEAD can be retargeted
|
|
369
424
|
// while the old ref still exists); only verdicts are shared across builds.
|
|
@@ -388,6 +443,14 @@ export function localCommitReachability(root) {
|
|
|
388
443
|
const memo = key ? reachabilityMemo.get(key) : undefined;
|
|
389
444
|
if (memo)
|
|
390
445
|
return memo;
|
|
446
|
+
if (expected.length && !primed.has(commit)) {
|
|
447
|
+
const commits = [...expected, commit];
|
|
448
|
+
expected = [];
|
|
449
|
+
batch(commits);
|
|
450
|
+
}
|
|
451
|
+
const batched = primed.get(commit);
|
|
452
|
+
if (batched)
|
|
453
|
+
return batched;
|
|
391
454
|
const remember = (verdict) => key ? memoizeReachability(key, verdict) : verdict;
|
|
392
455
|
const exact = gitProbe(root, ["rev-parse", "--verify", "--quiet", `${commit}^{commit}`]);
|
|
393
456
|
if (!exact.available)
|
|
@@ -404,6 +467,7 @@ export function localCommitReachability(root) {
|
|
|
404
467
|
return remember("unreachable");
|
|
405
468
|
return "unknown";
|
|
406
469
|
};
|
|
470
|
+
return Object.assign(lookup, { prime: (commits) => { expected.push(...commits); } });
|
|
407
471
|
}
|
|
408
472
|
function validationState(anchors, anchorsResolve, commit, reachability, requireCommit = false, canValidateAnchors = true) {
|
|
409
473
|
if (anchors.length && canValidateAnchors && !anchors.some(anchorsResolve)) {
|
|
@@ -563,8 +627,10 @@ export function buildDeliveryEnvelope(ctx, options = {}) {
|
|
|
563
627
|
const profile = options.profile ?? "builder";
|
|
564
628
|
const budget = Number.isFinite(ctx.budget_tokens) ? Math.max(0, Math.floor(ctx.budget_tokens)) : 1500;
|
|
565
629
|
const cap = budget * 4;
|
|
566
|
-
const
|
|
567
|
-
|
|
630
|
+
const local = !options.commitReachability && options.root ? localCommitReachability(options.root) : null;
|
|
631
|
+
// Only decisions carry a capture commit; register them so the first lookup batches them all (#485).
|
|
632
|
+
local?.prime(ctx.decisions.flatMap((decision) => decision.commit ? [decision.commit] : []));
|
|
633
|
+
const reachability = options.commitReachability ?? local ?? (() => "unknown");
|
|
568
634
|
const canValidateAnchors = options.root !== undefined || options.symbols !== undefined || options.components !== undefined;
|
|
569
635
|
const candidates = [];
|
|
570
636
|
const query = queryProfile(ctx.target, options);
|
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
|