@indigoai-us/hq-cli 5.98.2 → 5.99.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 (70) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
  3. package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
  4. package/assets/scaffold/core/scripts/hq-session.sh +251 -0
  5. package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
  6. package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
  7. package/dist/commands/core.js +25 -5
  8. package/dist/commands/doctor.d.ts +97 -0
  9. package/dist/commands/doctor.js +228 -0
  10. package/dist/commands/scaffold-fast.d.ts +41 -0
  11. package/dist/commands/scaffold-fast.js +57 -0
  12. package/dist/fast-core.d.ts +16 -0
  13. package/dist/fast-core.js +47 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +10 -1
  16. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
  17. package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
  18. package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
  19. package/dist/lib/doctor/allowed-divergence.js +134 -0
  20. package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
  21. package/dist/lib/doctor/checks/claude-wiring.js +524 -0
  22. package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
  23. package/dist/lib/doctor/checks/codex-wiring.js +376 -0
  24. package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
  25. package/dist/lib/doctor/checks/grok-wiring.js +186 -0
  26. package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
  27. package/dist/lib/doctor/checks/runtime-probe.js +335 -0
  28. package/dist/lib/doctor/compat.d.ts +85 -0
  29. package/dist/lib/doctor/compat.js +102 -0
  30. package/dist/lib/doctor/deep/classify.d.ts +61 -0
  31. package/dist/lib/doctor/deep/classify.js +75 -0
  32. package/dist/lib/doctor/deep/effects.d.ts +107 -0
  33. package/dist/lib/doctor/deep/effects.js +229 -0
  34. package/dist/lib/doctor/deep/executor.d.ts +112 -0
  35. package/dist/lib/doctor/deep/executor.js +369 -0
  36. package/dist/lib/doctor/deep/parity.d.ts +129 -0
  37. package/dist/lib/doctor/deep/parity.js +355 -0
  38. package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
  39. package/dist/lib/doctor/deep/sandbox.js +572 -0
  40. package/dist/lib/doctor/fix/apply.d.ts +119 -0
  41. package/dist/lib/doctor/fix/apply.js +352 -0
  42. package/dist/lib/doctor/fix/backup.d.ts +40 -0
  43. package/dist/lib/doctor/fix/backup.js +64 -0
  44. package/dist/lib/doctor/fix/remediation.d.ts +71 -0
  45. package/dist/lib/doctor/fix/remediation.js +103 -0
  46. package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
  47. package/dist/lib/doctor/fixtures/discover.js +287 -0
  48. package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
  49. package/dist/lib/doctor/fixtures/schema.js +248 -0
  50. package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
  51. package/dist/lib/doctor/hook-gate-profiles.js +107 -0
  52. package/dist/lib/doctor/json-output.d.ts +90 -0
  53. package/dist/lib/doctor/json-output.js +76 -0
  54. package/dist/lib/doctor/payload-shapes.d.ts +170 -0
  55. package/dist/lib/doctor/payload-shapes.js +275 -0
  56. package/dist/lib/doctor/platform.d.ts +244 -0
  57. package/dist/lib/doctor/platform.js +490 -0
  58. package/dist/lib/doctor/registry.d.ts +49 -0
  59. package/dist/lib/doctor/registry.js +176 -0
  60. package/dist/lib/doctor/report.d.ts +87 -0
  61. package/dist/lib/doctor/report.js +164 -0
  62. package/dist/lib/doctor/types.d.ts +87 -0
  63. package/dist/lib/doctor/types.js +29 -0
  64. package/dist/main.js +6 -0
  65. package/dist/utils/hook-trust.d.ts +10 -13
  66. package/dist/utils/hook-trust.js +148 -27
  67. package/dist/utils/version-check.js +2 -2
  68. package/dist/utils/version-gate.d.ts +1 -1
  69. package/dist/utils/version-gate.js +1 -1
  70. package/package.json +2 -2
@@ -0,0 +1,355 @@
1
+ /**
2
+ * Cross-platform parity replay (US-010).
3
+ *
4
+ * The payoff of the declarative fixture format: one case, three executions,
5
+ * automatic drift detection. Under `--deep-test`, every pure-guard fixture case
6
+ * is normalised into the Claude, Codex, and Grok payload shapes (payload-shapes.ts)
7
+ * and replayed through each platform's REAL adapter path, and their verdicts are
8
+ * compared. Platform drift — a Codex mirror whose logic diverged from its Claude
9
+ * original — becomes a test result instead of a manual audit.
10
+ *
11
+ * The three "real adapter paths", matching how each platform is actually wired:
12
+ *
13
+ * - Claude runs the canonical `.claude/hooks/<id>.sh` through
14
+ * `.claude/hooks/hook-gate.sh`, exactly as `.claude/settings.json` registers
15
+ * it. A block is a non-zero gate exit.
16
+ * - Codex runs its DUPLICATED `.codex/hooks/<id>.sh` mirror through
17
+ * `.codex/hooks/hook-gate.sh`, exactly as `.codex/hooks.json` registers it.
18
+ * This is the entire drift surface: only Codex runs copies, so a mirror whose
19
+ * logic differs is caught precisely here. A block is a non-zero gate exit.
20
+ * - Grok drives `.grok/hooks/hq-grok-hook-adapter.sh` with the camelCase
21
+ * payload; the adapter maps the Grok tool alias back to canonical and routes
22
+ * through the same `.claude/` gate. A block is a stdout `deny` decision
23
+ * and/or a non-zero exit (payload-shapes `interpretGrokBlock`).
24
+ *
25
+ * Three rules keep the replay honest:
26
+ *
27
+ * 1. Disagreement is a FAIL that names every platform's verdict side by side,
28
+ * so a reviewer sees `claude=block, codex=allow, grok=block` rather than a
29
+ * bare failure.
30
+ * 2. Grok's passive events (everything but PreToolUse) cannot inject
31
+ * model-facing context or emit a decision, so a case on such an event is
32
+ * NA — never PASS or FAIL — with the platform limitation named.
33
+ * 3. A platform whose script/adapter is absent, or whose case timed out, is an
34
+ * honest UNKNOWN (never PASS): an exec failure must not be misread as a
35
+ * block, and a platform we could not run cannot be declared in agreement.
36
+ *
37
+ * Parity replay runs ONLY under `--deep-test`; the command appends it there, not
38
+ * in the default registry, so a plain `hq doctor` never fires a hook. It reuses
39
+ * the same pure-guard selection as US-008's executor and its own sandbox, so the
40
+ * live tree is only ever read from.
41
+ */
42
+ import { spawnSync } from "node:child_process";
43
+ import * as fs from "node:fs";
44
+ import * as os from "node:os";
45
+ import * as path from "node:path";
46
+ import { discoverFixtures, registeredHookIds } from "../fixtures/discover.js";
47
+ import { isPureGuard } from "./classify.js";
48
+ import { SANDBOX_COPY_RELPATHS } from "./executor.js";
49
+ import { grokCanBlock, grokToSnakePayload, interpretExitBlock, interpretGrokBlock, PARITY_PLATFORMS, toClaudePayload, toCodexPayload, toGrokPayload, } from "../payload-shapes.js";
50
+ /** The id of the parity family the command appends under `--deep-test`. */
51
+ export const PARITY_FAMILY_ID = "deep-parity";
52
+ /** Human title of the parity family. */
53
+ export const PARITY_FAMILY_TITLE = "Cross-platform parity replay";
54
+ /** Default per-case, per-platform time bound. */
55
+ export const DEFAULT_PARITY_TIMEOUT_MS = 10_000;
56
+ /** The gate profile the replay runs each hook under. Strict is the broadest
57
+ * allowlist, so profile gating never suppresses the hook whose LOGIC is being
58
+ * compared across platforms — profile-specific behaviour is US-008's concern. */
59
+ export const PARITY_GATE_PROFILE = "strict";
60
+ /** The session id stamped onto every replay payload. */
61
+ export const PARITY_SESSION_ID = "hq-doctor-parity-replay";
62
+ function spawnWithStdin(command, args, stdin, input) {
63
+ const result = spawnSync(command, args, {
64
+ input: stdin,
65
+ cwd: input.sandbox,
66
+ timeout: input.timeoutMs,
67
+ killSignal: "SIGKILL",
68
+ encoding: "utf8",
69
+ maxBuffer: 8 * 1024 * 1024,
70
+ env: {
71
+ ...process.env,
72
+ HQ_HOOK_PROFILE: PARITY_GATE_PROFILE,
73
+ CLAUDE_PROJECT_DIR: input.sandbox,
74
+ HQ_ROOT: input.sandbox,
75
+ },
76
+ });
77
+ const errno = result.error?.code;
78
+ return {
79
+ timedOut: errno === "ETIMEDOUT",
80
+ exitCode: result.status,
81
+ stdout: result.stdout ?? "",
82
+ stderr: result.stderr ?? "",
83
+ };
84
+ }
85
+ /** Run one hook through a platform's own gate + script copy (Claude/Codex). */
86
+ function runThroughGate(input, platformDir, payload) {
87
+ const gatePath = path.join(input.sandbox, platformDir, "hooks", "hook-gate.sh");
88
+ const scriptPath = path.join(input.sandbox, platformDir, "hooks", `${input.hookId}.sh`);
89
+ if (!fs.existsSync(gatePath)) {
90
+ return unavailable(`${platformDir}/hooks/hook-gate.sh is absent`);
91
+ }
92
+ if (!fs.existsSync(scriptPath)) {
93
+ return unavailable(`${input.platform} has no ${platformDir}/hooks/${input.hookId}.sh to run`);
94
+ }
95
+ const spawned = spawnWithStdin("bash", [gatePath, input.hookId, scriptPath], payload, input);
96
+ return { ...spawned };
97
+ }
98
+ /** The real Claude runner: the canonical script through the Claude gate. */
99
+ export const defaultClaudeRunner = (input) => runThroughGate(input, ".claude", JSON.stringify(toClaudePayload(input.testCase, {
100
+ cwd: input.sandbox,
101
+ sessionId: PARITY_SESSION_ID,
102
+ })));
103
+ /** The real Codex runner: the duplicated mirror through the Codex gate. */
104
+ export const defaultCodexRunner = (input) => runThroughGate(input, ".codex", JSON.stringify(toCodexPayload(input.testCase, {
105
+ cwd: input.sandbox,
106
+ sessionId: PARITY_SESSION_ID,
107
+ })));
108
+ /**
109
+ * The real Grok runner. Grok shares the canonical `.claude/hooks/` scripts with
110
+ * Claude — it has no mirror of its own — and its adapter's whole job is to read
111
+ * the camelCase payload, map the tool alias back to canonical, build the Claude
112
+ * JSON, and pipe it to the same `hook-gate.sh`. We reproduce that routing here
113
+ * (via {@link toGrokPayload} + {@link grokToSnakePayload}) rather than spawn the
114
+ * adapter directly, for two reasons: the live adapter runs a whole tool
115
+ * *sequence* where the replay needs one isolated hook, and its user-global
116
+ * bridge cannot be simulated from a project-local run (US-005 keeps bridge
117
+ * presence a wiring check). The Grok block protocol still applies — the gate's
118
+ * non-zero exit is what the adapter would translate into a stdout `deny`
119
+ * ({@link interpretGrokBlock} in verdictFor recognises both).
120
+ */
121
+ export const defaultGrokRunner = (input) => {
122
+ const grokPayload = toGrokPayload(input.testCase, {
123
+ cwd: input.sandbox,
124
+ sessionId: PARITY_SESSION_ID,
125
+ });
126
+ const snake = grokToSnakePayload(grokPayload);
127
+ return runThroughGate(input, ".claude", JSON.stringify(snake));
128
+ };
129
+ /** The default real runner set wired to each platform's real adapter path. */
130
+ export const defaultPlatformRunners = {
131
+ claude: defaultClaudeRunner,
132
+ codex: defaultCodexRunner,
133
+ grok: defaultGrokRunner,
134
+ };
135
+ function unavailable(reason) {
136
+ return { unavailable: { reason }, timedOut: false, exitCode: null, stdout: "", stderr: "" };
137
+ }
138
+ // --- sandbox lifecycle ---------------------------------------------------------
139
+ const trackedParitySandboxes = new Set();
140
+ let parityExitHandlerRegistered = false;
141
+ /** Remove every parity sandbox still on disk. Safe to call repeatedly. */
142
+ export function cleanupAllParitySandboxes() {
143
+ for (const root of trackedParitySandboxes)
144
+ removeTree(root);
145
+ trackedParitySandboxes.clear();
146
+ }
147
+ function ensureParityExitHandler() {
148
+ if (parityExitHandlerRegistered)
149
+ return;
150
+ parityExitHandlerRegistered = true;
151
+ process.on("exit", cleanupAllParitySandboxes);
152
+ }
153
+ function removeTree(root) {
154
+ try {
155
+ fs.rmSync(root, { recursive: true, force: true });
156
+ }
157
+ catch {
158
+ // Best-effort: cleanup must never mask a real test failure.
159
+ }
160
+ }
161
+ /**
162
+ * Copy the curated {@link SANDBOX_COPY_RELPATHS} from the live tree into a fresh
163
+ * temp root. Replay only reads through the adapters; the live tree is never
164
+ * written, so one sandbox serves the whole run.
165
+ */
166
+ function createParitySandbox(hqRoot, relpaths) {
167
+ ensureParityExitHandler();
168
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "hq-doctor-parity-")));
169
+ trackedParitySandboxes.add(root);
170
+ for (const rel of relpaths) {
171
+ const segments = rel.split("/");
172
+ const src = path.join(hqRoot, ...segments);
173
+ if (!fs.existsSync(src))
174
+ continue;
175
+ const dest = path.join(root, ...segments);
176
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
177
+ fs.cpSync(src, dest, { recursive: true });
178
+ }
179
+ return root;
180
+ }
181
+ // --- entry point ---------------------------------------------------------------
182
+ /**
183
+ * Replay every pure-guard fixture case through all three platform adapters and
184
+ * report the per-case parity verdict. Read-only against the live tree; all
185
+ * execution happens in a sandbox that is removed before returning. Returns an
186
+ * empty list — creating no sandbox — when there is nothing to replay.
187
+ */
188
+ export async function runParityReplay(context, options = {}) {
189
+ const hqRoot = context.hqRoot;
190
+ const timeoutMs = options.perCaseTimeoutMs ?? DEFAULT_PARITY_TIMEOUT_MS;
191
+ const runners = {
192
+ ...defaultPlatformRunners,
193
+ ...options.runners,
194
+ };
195
+ const copyRelpaths = options.sandboxRelpaths ?? SANDBOX_COPY_RELPATHS;
196
+ const runnable = selectRunnableFixtures(hqRoot);
197
+ if (runnable.length === 0)
198
+ return [];
199
+ const sandbox = createParitySandbox(hqRoot, copyRelpaths);
200
+ try {
201
+ const results = [];
202
+ for (const fixture of runnable) {
203
+ for (const testCase of fixture.cases) {
204
+ results.push(replayCase(fixture, testCase, { sandbox, timeoutMs, runners }));
205
+ }
206
+ }
207
+ return results;
208
+ }
209
+ finally {
210
+ if (!options.keepSandbox) {
211
+ removeTree(sandbox);
212
+ trackedParitySandboxes.delete(sandbox);
213
+ }
214
+ }
215
+ }
216
+ /**
217
+ * The pure-guard fixtures worth replaying: valid, registered, non-empty, and not
218
+ * side-effecting (those are US-009's sandboxed effect tier and must never be
219
+ * fired through an adapter). Mirrors US-008's executor selection so parity
220
+ * replays exactly the set `--deep-test` executes.
221
+ */
222
+ function selectRunnableFixtures(hqRoot) {
223
+ const registered = new Set(registeredHookIds(hqRoot));
224
+ const discovery = discoverFixtures(hqRoot);
225
+ const runnable = [];
226
+ for (const discovered of discovery.fixtures) {
227
+ if (discovered.parsed.status !== "ok")
228
+ continue;
229
+ const fixture = discovered.parsed.fixture;
230
+ if (!registered.has(fixture.hookId))
231
+ continue;
232
+ if (fixture.cases.length === 0)
233
+ continue;
234
+ if (!isPureGuard(fixture))
235
+ continue;
236
+ runnable.push(fixture);
237
+ }
238
+ return runnable;
239
+ }
240
+ /**
241
+ * Replay one case through all three platforms and classify the aggregate:
242
+ * - any platform NA (Grok passive event) -> NA, naming the limitation.
243
+ * - any platform error (missing/timeout) -> UNKNOWN, never PASS.
244
+ * - all comparable verdicts agree -> PASS.
245
+ * - they disagree -> FAIL, verdicts side by side.
246
+ */
247
+ function replayCase(fixture, testCase, env) {
248
+ const target = `${fixture.hookId}:${testCase.name}`;
249
+ const verdicts = PARITY_PLATFORMS.map((platform) => verdictFor(platform, fixture, testCase, env));
250
+ const sideBySide = verdicts.map((v) => `${v.platform}=${v.kind}`).join(", ");
251
+ // Grok passive-event limitation dominates: the case cannot be compared, so it
252
+ // is NA (never PASS or FAIL), and we name the limitation explicitly.
253
+ const na = verdicts.find((v) => v.kind === "na");
254
+ if (na) {
255
+ return {
256
+ status: "NA",
257
+ checkId: "hooks.parity.passive-event",
258
+ target,
259
+ message: `Case "${testCase.name}" for "${fixture.hookId}" runs under the ${testCase.event} event, ` +
260
+ `which is passive under Grok: the Grok adapter runs it but cannot inject model-facing context ` +
261
+ `or emit a block decision, so its verdict cannot be compared with Claude and Codex. ` +
262
+ `Observed: ${describeVerdicts(verdicts)}.`,
263
+ };
264
+ }
265
+ // A platform we could not run (missing script/adapter or timeout) cannot be
266
+ // declared in agreement — honest UNKNOWN rather than a false PASS.
267
+ const errors = verdicts.filter((v) => v.kind === "error");
268
+ if (errors.length > 0) {
269
+ return {
270
+ status: "UNKNOWN",
271
+ checkId: "hooks.parity.unavailable",
272
+ target,
273
+ message: `Case "${testCase.name}" for "${fixture.hookId}" could not be compared across all platforms — ` +
274
+ `${errors.map((e) => `${e.platform}: ${e.detail}`).join("; ")}. ` +
275
+ `Observed: ${describeVerdicts(verdicts)}.`,
276
+ remediation: `Ensure every platform has a runnable copy of "${fixture.hookId}" (Claude gate + hook, ` +
277
+ `Codex mirror, Grok adapter), then re-run the parity replay.`,
278
+ };
279
+ }
280
+ // Every platform gave a comparable block/allow verdict; compare them.
281
+ const kinds = new Set(verdicts.map((v) => v.kind));
282
+ if (kinds.size === 1) {
283
+ const agreed = verdicts[0].kind;
284
+ return {
285
+ status: "PASS",
286
+ checkId: "hooks.parity.case",
287
+ target,
288
+ message: `Case "${testCase.name}" for "${fixture.hookId}" agreed across all three platforms (${sideBySide}); every adapter ${agreed === "block" ? "blocked" : "allowed"} it.`,
289
+ };
290
+ }
291
+ // Disagreement: the drift this whole story exists to catch. Name each verdict.
292
+ return {
293
+ status: "FAIL",
294
+ checkId: "hooks.parity.case",
295
+ target,
296
+ message: `Platform drift on case "${testCase.name}" for "${fixture.hookId}": the three adapters disagree — ${sideBySide}. ` +
297
+ `${describeVerdicts(verdicts)}.`,
298
+ remediation: `Reconcile the diverging platform copies of "${fixture.hookId}" so all three adapters produce the same verdict ` +
299
+ `(most often a Codex mirror in .codex/hooks/ whose logic drifted from the .claude/hooks/ original).`,
300
+ };
301
+ }
302
+ /** Run one platform and interpret its block protocol into a {@link Verdict}. */
303
+ function verdictFor(platform, fixture, testCase, env) {
304
+ // Grok cannot emit a decision on a passive event — do not even run it.
305
+ if (platform === "grok" && !grokCanBlock(testCase.event)) {
306
+ return {
307
+ platform,
308
+ kind: "na",
309
+ detail: `${testCase.event} is a passive Grok event (no model-facing context injection or block decision)`,
310
+ };
311
+ }
312
+ const output = env.runners[platform]({
313
+ platform,
314
+ sandbox: env.sandbox,
315
+ hookId: fixture.hookId,
316
+ testCase,
317
+ timeoutMs: env.timeoutMs,
318
+ });
319
+ if (output.unavailable) {
320
+ return { platform, kind: "error", detail: output.unavailable.reason };
321
+ }
322
+ if (output.timedOut) {
323
+ return {
324
+ platform,
325
+ kind: "error",
326
+ detail: `timed out after ${env.timeoutMs}ms`,
327
+ };
328
+ }
329
+ const blocked = platform === "grok"
330
+ ? interpretGrokBlock({ exitCode: output.exitCode, stdout: output.stdout })
331
+ : interpretExitBlock(output.exitCode);
332
+ return {
333
+ platform,
334
+ kind: blocked ? "block" : "allow",
335
+ detail: describeRun(output),
336
+ };
337
+ }
338
+ // --- message helpers -----------------------------------------------------------
339
+ function describeRun(output) {
340
+ const exit = output.exitCode === null ? "none" : String(output.exitCode);
341
+ const err = output.stderr.trim() ? `, stderr: ${oneLine(output.stderr)}` : "";
342
+ const dec = output.stdout.trim() ? `, stdout: ${oneLine(output.stdout)}` : "";
343
+ return `exit ${exit}${dec}${err}`;
344
+ }
345
+ function describeVerdicts(verdicts) {
346
+ return verdicts
347
+ .map((v) => `${v.platform} ${v.kind} (${v.detail})`)
348
+ .join("; ");
349
+ }
350
+ /** Collapse text to a single trimmed, length-capped line for a message. */
351
+ function oneLine(text) {
352
+ const collapsed = text.replace(/\s+/g, " ").trim();
353
+ return collapsed.length > 160 ? `${collapsed.slice(0, 157)}…` : collapsed;
354
+ }
355
+ //# sourceMappingURL=parity.js.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Sandboxed execution for side-effecting hooks (US-009).
3
+ *
4
+ * US-008 proves a PURE GUARD works by firing it through the gate and reading the
5
+ * verdict. That is unsound for a hook that WRITES FILES or MAKES COMMITS: you
6
+ * cannot fire `hq-autocommit` at the user's real tree to see if it commits. So
7
+ * every side-effecting hook (classified in classify.ts: autocommit, checkpoint,
8
+ * journal, reindex, …) runs here instead, inside a THROWAWAY HQ tree, and its
9
+ * fixture cases assert OBSERVABLE EFFECTS (effects.ts) rather than exit codes.
10
+ *
11
+ * Four rules make this trustworthy rather than dangerous:
12
+ *
13
+ * 1. Never the live tree. Each case runs in a fresh temp tree seeded with the
14
+ * hook scripts, a real git repository, session metadata, and a minimum
15
+ * company scaffold. A commit or a file the hook makes lands in the sandbox
16
+ * and nowhere the user can see. A fresh sandbox PER CASE keeps one case's
17
+ * writes from being mistaken for the next case's effect.
18
+ *
19
+ * 2. Always torn down. Every sandbox is tracked and removed in a `finally`, and
20
+ * a process-exit handler sweeps any that a crash or interrupt skipped — so
21
+ * no temp tree survives, on success, failure, or SIGINT alike.
22
+ *
23
+ * 3. A thin sandbox reports UNKNOWN, never FAIL. If a case needs a precondition
24
+ * the sandbox did not seed (session metadata, a company, …), it is reported
25
+ * UNKNOWN naming that precondition — because a sandbox that manufactures a
26
+ * false FAIL for its own gap erodes trust in the doctor faster than no tool.
27
+ * A `commit-created` effect implicitly needs a git repo, so a sandbox with
28
+ * no git yields UNKNOWN for it, not a false failure.
29
+ *
30
+ * 4. One bad setup does not abort the run. If seeding one hook's sandbox
31
+ * throws, that hook is reported (UNKNOWN) and the remaining hooks still run.
32
+ *
33
+ * The effect data contract and before/after comparison live in effects.ts; this
34
+ * module owns the filesystem: seeding, discovery of side-effecting fixtures,
35
+ * running the hook, and turning the evaluation into {@link CheckResult}s.
36
+ */
37
+ import type { CheckContext, CheckResult } from "../types.js";
38
+ import { type ExpectedFailure } from "../fixtures/schema.js";
39
+ import { type EffectExpectation, type GitProbe } from "./effects.js";
40
+ /** Default per-case time bound. No latency target, but no case may hang. */
41
+ export declare const DEFAULT_PER_CASE_TIMEOUT_MS = 15000;
42
+ /**
43
+ * The preconditions a sandbox can seed. A fixture case names the ones it needs in
44
+ * its `requires:` list; anything a case needs that is not in the seeded set makes
45
+ * the case UNKNOWN (never FAIL). Open to extension as more side-effecting hooks
46
+ * come under sandboxed execution.
47
+ */
48
+ export type Precondition = "git-repo" | "session-metadata" | "company-scaffold";
49
+ /** Every precondition the default seeder knows how to provide. */
50
+ export declare const KNOWN_PRECONDITIONS: readonly Precondition[];
51
+ /**
52
+ * Tree entries copied from the live tree into every sandbox — the same curated
53
+ * set US-008 uses: the hook scripts and gate, the settings a hook may read, the
54
+ * Codex/Grok mirrors, and `core/` (helpers hooks source). Missing entries are
55
+ * skipped so a partial tree still sandboxes cleanly.
56
+ */
57
+ export declare const SANDBOX_SEED_RELPATHS: readonly string[];
58
+ /** Remove every side-effect sandbox still on disk. Safe to call repeatedly. */
59
+ export declare function cleanupAllSandboxes(): void;
60
+ /** The sandbox roots this process is still tracking (for tests). */
61
+ export declare function trackedSandboxRoots(): string[];
62
+ /** Remove one sandbox and stop tracking it. Idempotent. */
63
+ export declare function destroySandbox(root: string): void;
64
+ /** The outcome of seeding one sandbox. */
65
+ export interface SeedResult {
66
+ /** Absolute, realpath'd sandbox root under the OS temp dir. */
67
+ root: string;
68
+ /** The preconditions actually seeded (a subset of the intended set). */
69
+ seeded: Set<Precondition>;
70
+ /** A synthetic session id, seeded when `session-metadata` is provided. */
71
+ sessionId: string;
72
+ }
73
+ /** Options for {@link seedSandbox}. */
74
+ export interface SeedOptions {
75
+ /** Tree entries copied from the live tree. Default: {@link SANDBOX_SEED_RELPATHS}. */
76
+ seedRelpaths?: readonly string[];
77
+ /** Which preconditions to seed. Default: {@link KNOWN_PRECONDITIONS}. */
78
+ preconditions?: readonly Precondition[];
79
+ /** temp-dir name prefix. Default: `hq-doctor-sandbox-`. */
80
+ prefix?: string;
81
+ }
82
+ /**
83
+ * Seed a fresh throwaway HQ tree: copy the curated hook scripts, then provide the
84
+ * requested preconditions. Returns the root and the set of preconditions that
85
+ * were actually seeded (a precondition that could not be provided — e.g. git is
86
+ * unavailable — is simply absent from the set, which surfaces as UNKNOWN for any
87
+ * case needing it, never a false failure).
88
+ *
89
+ * On any failure AFTER the temp dir is created, the partial tree is removed and
90
+ * the error rethrown so a setup failure never leaks a directory.
91
+ */
92
+ export declare function seedSandbox(hqRoot: string, opts?: SeedOptions): SeedResult;
93
+ /** One hook execution's inputs. */
94
+ export interface HookRunInput {
95
+ /** Absolute path to the sandboxed hook script. */
96
+ hookScriptPath: string;
97
+ /** The JSON payload piped to the hook's stdin. */
98
+ payload: string;
99
+ /** Working directory (the sandbox root) the hook runs in. */
100
+ cwd: string;
101
+ /** The synthetic session id, exported to the hook's environment. */
102
+ sessionId: string;
103
+ /** The per-case time bound in milliseconds. */
104
+ timeoutMs: number;
105
+ }
106
+ /** One hook execution's observed result (exit code is diagnostic only). */
107
+ export interface HookRunResult {
108
+ /** True when the execution exceeded {@link HookRunInput.timeoutMs}. */
109
+ timedOut: boolean;
110
+ /** Exit code, or null when it did not exit normally (timeout/signal). */
111
+ exitCode: number | null;
112
+ /** Captured stderr (empty string when none). */
113
+ stderr: string;
114
+ }
115
+ /** Runs one hook. Injectable so the tier's aggregation is unit-testable. */
116
+ export type HookRunner = (input: HookRunInput) => HookRunResult;
117
+ /**
118
+ * The real runner: fire the hook script directly with the payload on stdin, its
119
+ * cwd and session env pointed at the sandbox, and a hard timeout. Unlike a pure
120
+ * guard (US-008), a side-effecting hook is run directly rather than through the
121
+ * gate — its exit code is irrelevant here; what matters is the effect it leaves
122
+ * on the sandbox, which effects.ts reads afterwards.
123
+ */
124
+ export declare const defaultHookRunner: HookRunner;
125
+ /** One parsed side-effecting fixture case (its own shape, not the US-007 schema). */
126
+ export interface SideEffectCase {
127
+ name: string;
128
+ event: string;
129
+ tool: string;
130
+ input: unknown;
131
+ /** The observable effects the case asserts (non-empty). */
132
+ effects: EffectExpectation[];
133
+ /** Preconditions the case needs the sandbox to seed. */
134
+ requires: string[];
135
+ /** Present only when the case pins a known, unfixed defect. */
136
+ expectedFailure?: ExpectedFailure;
137
+ }
138
+ /** A validated side-effecting fixture. */
139
+ export interface SideEffectFixture {
140
+ hookId: string;
141
+ relpath: string;
142
+ cases: SideEffectCase[];
143
+ }
144
+ /** A side-effecting fixture that could not be parsed. */
145
+ export interface SideEffectFixtureError {
146
+ hookId: string | null;
147
+ relpath: string;
148
+ message: string;
149
+ }
150
+ /** The outcome of discovering side-effecting fixtures. */
151
+ export interface SideEffectDiscovery {
152
+ fixtures: SideEffectFixture[];
153
+ invalid: SideEffectFixtureError[];
154
+ }
155
+ /**
156
+ * Discover and parse every side-effecting fixture under `core/hook-tests/`. Only
157
+ * fixtures whose hook id classifies as side-effecting are returned — pure guards
158
+ * are US-008's and are ignored here. Malformed side-effecting fixtures are
159
+ * collected separately so the tier can surface them without aborting.
160
+ */
161
+ export declare function discoverSideEffectFixtures(hqRoot: string): SideEffectDiscovery;
162
+ /** Options for {@link runSideEffectTests}. All injectable for testing. */
163
+ export interface SideEffectTestOptions {
164
+ /** Per-case time bound. Default: {@link DEFAULT_PER_CASE_TIMEOUT_MS}. */
165
+ perCaseTimeoutMs?: number;
166
+ /** Tree entries copied into each sandbox. Default: {@link SANDBOX_SEED_RELPATHS}. */
167
+ seedRelpaths?: readonly string[];
168
+ /** Preconditions the sandbox seeds. Default: {@link KNOWN_PRECONDITIONS}. */
169
+ preconditions?: readonly Precondition[];
170
+ /** Hook runner. Default: {@link defaultHookRunner}. */
171
+ runner?: HookRunner;
172
+ /** Git probe used for commit-count effects. Default: {@link defaultGitProbe}. */
173
+ gitProbe?: GitProbe;
174
+ /**
175
+ * Sandbox seeder override. Default seeds from `seedRelpaths` / `preconditions`.
176
+ * Injected in tests to model a seeding failure for one hook (which must not
177
+ * abort the run).
178
+ */
179
+ seeder?: (hqRoot: string, hookId: string) => SeedResult;
180
+ /** Leave each sandbox on disk (debugging). Default: false — every one is removed. */
181
+ keepSandbox?: boolean;
182
+ }
183
+ /**
184
+ * Execute every registered side-effecting hook's fixture cases in per-case
185
+ * sandboxes and return the effect verdicts. Read-only against the live tree; all
186
+ * writes and commits happen in throwaway trees that are always torn down.
187
+ * Returns an empty list when there is nothing side-effecting to run.
188
+ */
189
+ export declare function runSideEffectTests(context: CheckContext, options?: SideEffectTestOptions): Promise<CheckResult[]>;
190
+ //# sourceMappingURL=sandbox.d.ts.map