@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,369 @@
1
+ /**
2
+ * `--deep-test` execution for pure guard hooks (US-008).
3
+ *
4
+ * Default `hq doctor` proves a hook is *wired*. Deep test proves it *works*: for
5
+ * every pure-guard fixture (US-007 schema, {@link isPureGuard} classification)
6
+ * it replays each case through the REAL `hook-gate.sh` and checks the verdict
7
+ * against the fixture's expectation. Four rules make this trustworthy rather
8
+ * than theatre:
9
+ *
10
+ * 1. Route through the gate, never the hook directly. A hook can pass direct
11
+ * invocation and still silently no-op under the active gate profile — the
12
+ * documented `block-hq-root-git-mutation` incident did exactly that. So
13
+ * each case runs `hook-gate.sh <id> <script>` with a payload on stdin, and
14
+ * under ALL THREE profiles (minimal, standard, strict). A profile whose
15
+ * outcome differs from the expectation is a FAIL naming that profile: an
16
+ * expected block that no-ops to exit 0 under standard is caught here and
17
+ * nowhere else.
18
+ *
19
+ * 2. Sandbox, never the live tree. The gate and hooks are copied into a
20
+ * throwaway temp tree and every case executes there, so a diagnosis can
21
+ * never mutate the user's real setup. A test asserts the live tree is
22
+ * byte-identical before and after a run.
23
+ *
24
+ * 3. Time-bound every case. A hook that hangs must fail its case, not the
25
+ * command — each execution is spawned with a per-case timeout and a
26
+ * timeout is reported as FAIL, never left to block the run.
27
+ *
28
+ * 4. Never fire a hook in default mode. This module runs only when the caller
29
+ * asks for it (the command wires it behind `--deep-test`); the default
30
+ * registry does not include it. A test asserts no hook process is spawned
31
+ * without the flag.
32
+ *
33
+ * Expectation matching and the `expectedFailure` rule are owned by
34
+ * {@link outcomeMatches} / {@link classifyCaseStatus} in the fixture schema, so
35
+ * a case outcome means the same thing here as in the coverage tier. The Claude
36
+ * block protocol (a non-zero gate exit is a block) is the only one this story
37
+ * handles; Grok's stdout-`deny` protocol is US-010's parity replay.
38
+ */
39
+ import { spawnSync } from "node:child_process";
40
+ import * as fs from "node:fs";
41
+ import * as os from "node:os";
42
+ import * as path from "node:path";
43
+ import { GATE_PROFILES } from "../hook-gate-profiles.js";
44
+ import { discoverFixtures, registeredHookIds } from "../fixtures/discover.js";
45
+ import { classifyCaseStatus, outcomeMatches, } from "../fixtures/schema.js";
46
+ import { isPureGuard } from "./classify.js";
47
+ /** The id of the deep-test family the command appends under `--deep-test`. */
48
+ export const DEEP_FAMILY_ID = "deep";
49
+ /** Human title of the deep-test family. */
50
+ export const DEEP_FAMILY_TITLE = "Deep guard-hook tests";
51
+ /** Default per-case time bound. Deep test has no latency target, but no case
52
+ * may hang the command, so every execution is capped. */
53
+ export const DEFAULT_PER_CASE_TIMEOUT_MS = 10_000;
54
+ /**
55
+ * The tree entries copied into the sandbox. Deliberately a curated set, not the
56
+ * whole tree: the live `.claude/` carries worktrees and session state that can
57
+ * run to hundreds of megabytes, whereas the gate only needs the hook scripts,
58
+ * the settings a hook might read, the Codex/Grok mirrors, and `core/` (where the
59
+ * gate finds `hook-lib.sh` and hooks find their helpers). Missing entries are
60
+ * skipped, so a partial tree still sandboxes cleanly.
61
+ */
62
+ export const SANDBOX_COPY_RELPATHS = [
63
+ ".claude/hooks",
64
+ ".claude/settings.json",
65
+ ".claude/settings.local.json",
66
+ ".codex/hooks",
67
+ ".codex/hooks.json",
68
+ ".grok",
69
+ "core",
70
+ ];
71
+ /**
72
+ * The real runner: spawn `bash hook-gate.sh <id> <script>` with the payload on
73
+ * stdin, `HQ_HOOK_PROFILE` set, and a hard timeout. A timeout is detected by the
74
+ * `ETIMEDOUT` error code spawnSync reports; the process is killed with SIGKILL so
75
+ * a hook cannot ignore the bound.
76
+ */
77
+ export const defaultGateRunner = (input) => {
78
+ const result = spawnSync("bash", [input.gatePath, input.hookId, input.hookScriptPath], {
79
+ input: input.payload,
80
+ cwd: input.cwd,
81
+ timeout: input.timeoutMs,
82
+ killSignal: "SIGKILL",
83
+ encoding: "utf8",
84
+ maxBuffer: 8 * 1024 * 1024,
85
+ env: {
86
+ ...process.env,
87
+ HQ_HOOK_PROFILE: input.profile,
88
+ CLAUDE_PROJECT_DIR: input.cwd,
89
+ HQ_ROOT: input.cwd,
90
+ },
91
+ });
92
+ const errno = result.error?.code;
93
+ return {
94
+ timedOut: errno === "ETIMEDOUT",
95
+ exitCode: result.status,
96
+ stderr: result.stderr ?? "",
97
+ };
98
+ };
99
+ // --- sandbox lifecycle --------------------------------------------------------
100
+ const trackedSandboxes = new Set();
101
+ let sandboxExitHandlerRegistered = false;
102
+ /** Remove every sandbox still on disk. Safe to call repeatedly. */
103
+ export function cleanupAllDeepSandboxes() {
104
+ for (const root of trackedSandboxes)
105
+ removeTree(root);
106
+ trackedSandboxes.clear();
107
+ }
108
+ function ensureSandboxExitHandler() {
109
+ if (sandboxExitHandlerRegistered)
110
+ return;
111
+ sandboxExitHandlerRegistered = true;
112
+ // Fires on normal exit and after a crash alike, guaranteeing no sandbox
113
+ // survives even if a case throws or the process is torn down mid-run.
114
+ process.on("exit", cleanupAllDeepSandboxes);
115
+ }
116
+ function removeTree(root) {
117
+ try {
118
+ fs.rmSync(root, { recursive: true, force: true });
119
+ }
120
+ catch {
121
+ // Best-effort: cleanup must never mask a test's real failure.
122
+ }
123
+ }
124
+ /**
125
+ * Copy the curated {@link SANDBOX_COPY_RELPATHS} from the live tree into a fresh
126
+ * temp root. Pure guards do not write, so one sandbox is reused for the whole
127
+ * run; the live tree is only ever read from.
128
+ */
129
+ function createSandbox(hqRoot, relpaths) {
130
+ ensureSandboxExitHandler();
131
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "hq-doctor-deep-")));
132
+ trackedSandboxes.add(root);
133
+ for (const rel of relpaths) {
134
+ const segments = rel.split("/");
135
+ const src = path.join(hqRoot, ...segments);
136
+ if (!fs.existsSync(src))
137
+ continue;
138
+ const dest = path.join(root, ...segments);
139
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
140
+ // cpSync preserves mode bits, so the gate and hooks stay executable.
141
+ fs.cpSync(src, dest, { recursive: true });
142
+ }
143
+ return root;
144
+ }
145
+ // --- entry point --------------------------------------------------------------
146
+ /**
147
+ * Execute every pure-guard fixture's cases through the real gate under all three
148
+ * profiles and return the per-case results. Read-only against the live tree;
149
+ * all execution happens in a sandbox that is removed before returning. Returns
150
+ * an empty list — creating no sandbox — when there is nothing to run.
151
+ */
152
+ export async function runDeepGuardTests(context, options = {}) {
153
+ const hqRoot = context.hqRoot;
154
+ const timeoutMs = options.perCaseTimeoutMs ?? DEFAULT_PER_CASE_TIMEOUT_MS;
155
+ const runner = options.runner ?? defaultGateRunner;
156
+ const copyRelpaths = options.sandboxRelpaths ?? SANDBOX_COPY_RELPATHS;
157
+ const runnable = selectRunnableFixtures(hqRoot);
158
+ if (runnable.length === 0)
159
+ return [];
160
+ const sandbox = createSandbox(hqRoot, copyRelpaths);
161
+ try {
162
+ return runAllFixtures(runnable, { sandbox, timeoutMs, runner });
163
+ }
164
+ finally {
165
+ if (!options.keepSandbox) {
166
+ removeTree(sandbox);
167
+ trackedSandboxes.delete(sandbox);
168
+ }
169
+ }
170
+ }
171
+ /**
172
+ * The pure-guard fixtures worth executing: valid (US-007 already reports the
173
+ * invalid, unknown-version and orphan cases), registered, non-empty, and not a
174
+ * side-effecting hook (those are US-009's sandboxed effect tier).
175
+ */
176
+ function selectRunnableFixtures(hqRoot) {
177
+ const registered = new Set(registeredHookIds(hqRoot));
178
+ const discovery = discoverFixtures(hqRoot);
179
+ const runnable = [];
180
+ for (const discovered of discovery.fixtures) {
181
+ if (discovered.parsed.status !== "ok")
182
+ continue;
183
+ const fixture = discovered.parsed.fixture;
184
+ if (!registered.has(fixture.hookId))
185
+ continue;
186
+ if (fixture.cases.length === 0)
187
+ continue;
188
+ if (!isPureGuard(fixture))
189
+ continue;
190
+ runnable.push(fixture);
191
+ }
192
+ return runnable;
193
+ }
194
+ function runAllFixtures(fixtures, env) {
195
+ const results = [];
196
+ const gatePath = path.join(env.sandbox, ".claude", "hooks", "hook-gate.sh");
197
+ // No gate means the real gate path cannot be exercised at all — honest UNKNOWN
198
+ // for every fixture (never PASS), mirroring the tool's central safeguard.
199
+ if (!fs.existsSync(gatePath)) {
200
+ for (const fixture of fixtures) {
201
+ results.push({
202
+ status: "UNKNOWN",
203
+ checkId: "hooks.deep.gate-missing",
204
+ target: fixture.hookId,
205
+ message: `Cannot deep-test "${fixture.hookId}": .claude/hooks/hook-gate.sh is absent, so its cases cannot be run through the real gate.`,
206
+ });
207
+ }
208
+ return results;
209
+ }
210
+ for (const fixture of fixtures) {
211
+ const scriptPath = path.join(env.sandbox, ".claude", "hooks", `${fixture.hookId}.sh`);
212
+ if (!fs.existsSync(scriptPath)) {
213
+ results.push({
214
+ status: "UNKNOWN",
215
+ checkId: "hooks.deep.script-missing",
216
+ target: fixture.hookId,
217
+ message: `Cannot deep-test "${fixture.hookId}": its script .claude/hooks/${fixture.hookId}.sh is absent, so no case can be executed.`,
218
+ });
219
+ continue;
220
+ }
221
+ for (const testCase of fixture.cases) {
222
+ results.push(evaluateCase(fixture, testCase, gatePath, scriptPath, env));
223
+ }
224
+ }
225
+ return results;
226
+ }
227
+ /**
228
+ * Run one case under all three profiles and classify the aggregate. A timeout
229
+ * under any profile is a FAIL for timeout; otherwise a profile whose verdict
230
+ * differs from the expectation makes the case FAIL (or KNOWN-DEFECT under an
231
+ * `expectedFailure` marker) naming those profiles, their exit codes, and their
232
+ * stderr.
233
+ */
234
+ function evaluateCase(fixture, testCase, gatePath, scriptPath, env) {
235
+ const target = `${fixture.hookId}:${testCase.name}`;
236
+ const payload = buildPayload(testCase, env.sandbox);
237
+ const runs = [];
238
+ const timedOut = [];
239
+ for (const profile of GATE_PROFILES) {
240
+ const run = env.runner({
241
+ gatePath,
242
+ hookScriptPath: scriptPath,
243
+ hookId: fixture.hookId,
244
+ profile,
245
+ payload,
246
+ cwd: env.sandbox,
247
+ timeoutMs: env.timeoutMs,
248
+ });
249
+ if (run.timedOut) {
250
+ timedOut.push(profile);
251
+ runs.push({ profile, run, met: false });
252
+ continue;
253
+ }
254
+ const observed = {
255
+ blocked: run.exitCode !== 0,
256
+ stderr: run.stderr,
257
+ };
258
+ runs.push({ profile, run, met: outcomeMatches(testCase.expect, observed) });
259
+ }
260
+ // A hook that cannot answer within the bound fails its case rather than
261
+ // hanging the command — reported distinctly so a timeout is never confused
262
+ // with a wrong verdict.
263
+ if (timedOut.length > 0) {
264
+ return {
265
+ status: "FAIL",
266
+ checkId: "hooks.deep.timeout",
267
+ target,
268
+ message: `Case "${testCase.name}" for "${fixture.hookId}" exceeded the ${env.timeoutMs}ms per-case time bound under ${formatProfiles(timedOut)}; a hook that does not return within the bound is treated as a failure, not left to hang the command.`,
269
+ remediation: `Investigate why "${fixture.hookId}" does not return within ${env.timeoutMs}ms for case "${testCase.name}".`,
270
+ };
271
+ }
272
+ const failed = runs.filter((r) => !r.met).map((r) => r.profile);
273
+ const status = classifyCaseStatus(failed.length === 0, testCase.expectedFailure);
274
+ if (status === "PASS") {
275
+ return {
276
+ status,
277
+ checkId: "hooks.deep.case",
278
+ target,
279
+ message: `Case "${testCase.name}" for "${fixture.hookId}" produced the expected ${describeExpect(testCase.expect)} through the gate under all three profiles.`,
280
+ };
281
+ }
282
+ if (status === "WARN") {
283
+ // Marked as a pinned defect, yet it now passes everywhere — the marker is
284
+ // stale and should be removed (US-007 `expectedFailure` semantics).
285
+ return {
286
+ status,
287
+ checkId: "hooks.deep.stale-expected-failure",
288
+ target,
289
+ message: `Case "${testCase.name}" for "${fixture.hookId}" is marked expectedFailure (${testCase.expectedFailure?.reason ?? ""}) but now produces the expected ${describeExpect(testCase.expect)} under all three profiles; remove the stale marker.`,
290
+ remediation: `Delete the expectedFailure marker from case "${testCase.name}" in core/hook-tests/${fixture.hookId}.yaml.`,
291
+ };
292
+ }
293
+ // FAIL or KNOWN-DEFECT: name each failing profile with its exit code and stderr.
294
+ const detail = runs
295
+ .filter((r) => !r.met)
296
+ .map((r) => `${r.profile} (exit ${formatExit(r.run.exitCode)}${r.run.stderr.trim()
297
+ ? `, stderr: ${oneLine(r.run.stderr)}`
298
+ : ", no stderr"})`)
299
+ .join("; ");
300
+ const base = `Case "${testCase.name}" for "${fixture.hookId}" expected ${describeExpect(testCase.expect)} but the outcome differed under ${formatProfiles(failed)}: ${detail}.`;
301
+ if (status === "KNOWN-DEFECT") {
302
+ return {
303
+ status,
304
+ checkId: "hooks.deep.case",
305
+ target,
306
+ message: `${base} Tracked as a known defect: ${testCase.expectedFailure?.reason ?? ""}.`,
307
+ };
308
+ }
309
+ return {
310
+ status: "FAIL",
311
+ checkId: "hooks.deep.case",
312
+ target,
313
+ message: base,
314
+ remediation: remediationForExpect(testCase.expect, fixture.hookId, failed),
315
+ };
316
+ }
317
+ /**
318
+ * The Claude/Codex hook payload shape the gate delegates on stdin: the fixture's
319
+ * event, tool, and opaque input under the keys hooks read (`.hook_event_name`,
320
+ * `.tool_name`, `.tool_input`). `cwd`/`session_id` are set for hooks that key
321
+ * off them, pointing at the sandbox.
322
+ */
323
+ function buildPayload(testCase, cwd) {
324
+ return JSON.stringify({
325
+ hook_event_name: testCase.event,
326
+ tool_name: testCase.tool,
327
+ tool_input: testCase.input,
328
+ cwd,
329
+ session_id: "hq-doctor-deep-test",
330
+ });
331
+ }
332
+ // --- message helpers ----------------------------------------------------------
333
+ function describeExpect(expect) {
334
+ switch (expect.kind) {
335
+ case "block":
336
+ return "a block";
337
+ case "allow":
338
+ return "an allow (pass-through)";
339
+ case "stderr":
340
+ return `stderr matching /${expect.pattern}/`;
341
+ }
342
+ }
343
+ function remediationForExpect(expect, hookId, failed) {
344
+ if (expect.kind === "block") {
345
+ return `Ensure "${hookId}" blocks this input under ${formatProfiles(failed)} — most often it is missing from ${formatProfiles(failed)} in .claude/hooks/hook-gate.sh, so the gate no-ops it there.`;
346
+ }
347
+ if (expect.kind === "allow") {
348
+ return `"${hookId}" blocked an input the fixture expects to be allowed; verify the hook logic or correct the fixture case.`;
349
+ }
350
+ return `"${hookId}" did not emit the expected stderr; verify the hook or the fixture pattern.`;
351
+ }
352
+ /** Render a profile list as "minimal", "minimal and standard", or "a, b and c". */
353
+ function formatProfiles(profiles) {
354
+ const ordered = GATE_PROFILES.filter((p) => profiles.includes(p));
355
+ if (ordered.length <= 1)
356
+ return ordered.join("");
357
+ if (ordered.length === 2)
358
+ return `${ordered[0]} and ${ordered[1]}`;
359
+ return `${ordered.slice(0, -1).join(", ")} and ${ordered[ordered.length - 1]}`;
360
+ }
361
+ function formatExit(exitCode) {
362
+ return exitCode === null ? "none" : String(exitCode);
363
+ }
364
+ /** Collapse captured stderr to a single trimmed, length-capped line for a message. */
365
+ function oneLine(stderr) {
366
+ const collapsed = stderr.replace(/\s+/g, " ").trim();
367
+ return collapsed.length > 200 ? `${collapsed.slice(0, 197)}…` : collapsed;
368
+ }
369
+ //# sourceMappingURL=executor.js.map
@@ -0,0 +1,129 @@
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 type { CheckContext, CheckResult } from "../types.js";
43
+ import type { FixtureCase } from "../fixtures/schema.js";
44
+ import { type PlatformId } from "../payload-shapes.js";
45
+ /** The id of the parity family the command appends under `--deep-test`. */
46
+ export declare const PARITY_FAMILY_ID = "deep-parity";
47
+ /** Human title of the parity family. */
48
+ export declare const PARITY_FAMILY_TITLE = "Cross-platform parity replay";
49
+ /** Default per-case, per-platform time bound. */
50
+ export declare const DEFAULT_PARITY_TIMEOUT_MS = 10000;
51
+ /** The gate profile the replay runs each hook under. Strict is the broadest
52
+ * allowlist, so profile gating never suppresses the hook whose LOGIC is being
53
+ * compared across platforms — profile-specific behaviour is US-008's concern. */
54
+ export declare const PARITY_GATE_PROFILE = "strict";
55
+ /** The session id stamped onto every replay payload. */
56
+ export declare const PARITY_SESSION_ID = "hq-doctor-parity-replay";
57
+ /** One platform replay's inputs. */
58
+ export interface PlatformRunInput {
59
+ /** Which platform's adapter path to exercise. */
60
+ platform: PlatformId;
61
+ /** The sandbox root (also the payload `cwd`). */
62
+ sandbox: string;
63
+ /** The hook id under test. */
64
+ hookId: string;
65
+ /** The case being replayed. */
66
+ testCase: FixtureCase;
67
+ /** Per-platform time bound in milliseconds. */
68
+ timeoutMs: number;
69
+ }
70
+ /** One platform replay's observed output, before protocol interpretation. */
71
+ export interface PlatformRunOutput {
72
+ /** True when the platform could not be run at all (missing script/adapter). */
73
+ unavailable?: {
74
+ reason: string;
75
+ };
76
+ /** True when the execution exceeded {@link PlatformRunInput.timeoutMs}. */
77
+ timedOut: boolean;
78
+ /** Process exit code, or null when it did not exit normally. */
79
+ exitCode: number | null;
80
+ /** Captured stdout (empty when none). */
81
+ stdout: string;
82
+ /** Captured stderr (empty when none). */
83
+ stderr: string;
84
+ }
85
+ /** Executes one platform replay. Injectable so the comparison is unit-testable. */
86
+ export type PlatformRunner = (input: PlatformRunInput) => PlatformRunOutput;
87
+ /** The real runner set, keyed by platform. */
88
+ export type PlatformRunners = Record<PlatformId, PlatformRunner>;
89
+ /** The real Claude runner: the canonical script through the Claude gate. */
90
+ export declare const defaultClaudeRunner: PlatformRunner;
91
+ /** The real Codex runner: the duplicated mirror through the Codex gate. */
92
+ export declare const defaultCodexRunner: PlatformRunner;
93
+ /**
94
+ * The real Grok runner. Grok shares the canonical `.claude/hooks/` scripts with
95
+ * Claude — it has no mirror of its own — and its adapter's whole job is to read
96
+ * the camelCase payload, map the tool alias back to canonical, build the Claude
97
+ * JSON, and pipe it to the same `hook-gate.sh`. We reproduce that routing here
98
+ * (via {@link toGrokPayload} + {@link grokToSnakePayload}) rather than spawn the
99
+ * adapter directly, for two reasons: the live adapter runs a whole tool
100
+ * *sequence* where the replay needs one isolated hook, and its user-global
101
+ * bridge cannot be simulated from a project-local run (US-005 keeps bridge
102
+ * presence a wiring check). The Grok block protocol still applies — the gate's
103
+ * non-zero exit is what the adapter would translate into a stdout `deny`
104
+ * ({@link interpretGrokBlock} in verdictFor recognises both).
105
+ */
106
+ export declare const defaultGrokRunner: PlatformRunner;
107
+ /** The default real runner set wired to each platform's real adapter path. */
108
+ export declare const defaultPlatformRunners: PlatformRunners;
109
+ /** Options for {@link runParityReplay}. All injectable for testing. */
110
+ export interface ParityOptions {
111
+ /** Per-case, per-platform time bound. Default: {@link DEFAULT_PARITY_TIMEOUT_MS}. */
112
+ perCaseTimeoutMs?: number;
113
+ /** Tree entries copied into the sandbox. Default: {@link SANDBOX_COPY_RELPATHS}. */
114
+ sandboxRelpaths?: readonly string[];
115
+ /** Platform runners. Defaults to the real {@link defaultPlatformRunners}. */
116
+ runners?: Partial<PlatformRunners>;
117
+ /** Leave the sandbox on disk (debugging). Default: false. */
118
+ keepSandbox?: boolean;
119
+ }
120
+ /** Remove every parity sandbox still on disk. Safe to call repeatedly. */
121
+ export declare function cleanupAllParitySandboxes(): void;
122
+ /**
123
+ * Replay every pure-guard fixture case through all three platform adapters and
124
+ * report the per-case parity verdict. Read-only against the live tree; all
125
+ * execution happens in a sandbox that is removed before returning. Returns an
126
+ * empty list — creating no sandbox — when there is nothing to replay.
127
+ */
128
+ export declare function runParityReplay(context: CheckContext, options?: ParityOptions): Promise<CheckResult[]>;
129
+ //# sourceMappingURL=parity.d.ts.map