@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,572 @@
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 { spawnSync } from "node:child_process";
38
+ import * as fs from "node:fs";
39
+ import * as os from "node:os";
40
+ import * as path from "node:path";
41
+ import * as yaml from "js-yaml";
42
+ import { classifyCaseStatus, looksLikeFixture, } from "../fixtures/schema.js";
43
+ import { HOOK_TESTS_RELDIR, registeredHookIds } from "../fixtures/discover.js";
44
+ import { isSideEffectingHookId } from "./classify.js";
45
+ import { captureEffectBaseline, describeEffect, defaultGitProbe, evaluateEffects, parseEffectExpectations, } from "./effects.js";
46
+ /** Default per-case time bound. No latency target, but no case may hang. */
47
+ export const DEFAULT_PER_CASE_TIMEOUT_MS = 15_000;
48
+ /** Every precondition the default seeder knows how to provide. */
49
+ export const KNOWN_PRECONDITIONS = [
50
+ "git-repo",
51
+ "session-metadata",
52
+ "company-scaffold",
53
+ ];
54
+ /**
55
+ * Tree entries copied from the live tree into every sandbox — the same curated
56
+ * set US-008 uses: the hook scripts and gate, the settings a hook may read, the
57
+ * Codex/Grok mirrors, and `core/` (helpers hooks source). Missing entries are
58
+ * skipped so a partial tree still sandboxes cleanly.
59
+ */
60
+ export const SANDBOX_SEED_RELPATHS = [
61
+ ".claude/hooks",
62
+ ".claude/settings.json",
63
+ ".claude/settings.local.json",
64
+ ".codex/hooks",
65
+ ".codex/hooks.json",
66
+ ".grok",
67
+ "core",
68
+ ];
69
+ /** A slug for the throwaway company the `company-scaffold` precondition seeds. */
70
+ const SANDBOX_COMPANY_SLUG = "sandbox-co";
71
+ /** `*.yaml` files under core/hook-tests that are configuration, not fixtures. */
72
+ const NON_FIXTURE_FILES = new Set(["allowed-divergence.yaml"]);
73
+ // --- sandbox lifecycle --------------------------------------------------------
74
+ const trackedSandboxes = new Set();
75
+ let sandboxExitHandlerRegistered = false;
76
+ /** Remove every side-effect sandbox still on disk. Safe to call repeatedly. */
77
+ export function cleanupAllSandboxes() {
78
+ for (const root of trackedSandboxes)
79
+ removeTree(root);
80
+ trackedSandboxes.clear();
81
+ }
82
+ /** The sandbox roots this process is still tracking (for tests). */
83
+ export function trackedSandboxRoots() {
84
+ return [...trackedSandboxes];
85
+ }
86
+ function ensureSandboxExitHandler() {
87
+ if (sandboxExitHandlerRegistered)
88
+ return;
89
+ sandboxExitHandlerRegistered = true;
90
+ // Fires on normal exit and after a crash/interrupt alike — the guarantee that
91
+ // no sandbox survives even when a case throws or the process is torn down.
92
+ process.on("exit", cleanupAllSandboxes);
93
+ }
94
+ function removeTree(root) {
95
+ try {
96
+ fs.rmSync(root, { recursive: true, force: true });
97
+ }
98
+ catch {
99
+ // Best-effort: cleanup must never mask a test's real failure.
100
+ }
101
+ }
102
+ /** Remove one sandbox and stop tracking it. Idempotent. */
103
+ export function destroySandbox(root) {
104
+ removeTree(root);
105
+ trackedSandboxes.delete(root);
106
+ }
107
+ /**
108
+ * Seed a fresh throwaway HQ tree: copy the curated hook scripts, then provide the
109
+ * requested preconditions. Returns the root and the set of preconditions that
110
+ * were actually seeded (a precondition that could not be provided — e.g. git is
111
+ * unavailable — is simply absent from the set, which surfaces as UNKNOWN for any
112
+ * case needing it, never a false failure).
113
+ *
114
+ * On any failure AFTER the temp dir is created, the partial tree is removed and
115
+ * the error rethrown so a setup failure never leaks a directory.
116
+ */
117
+ export function seedSandbox(hqRoot, opts = {}) {
118
+ ensureSandboxExitHandler();
119
+ const relpaths = opts.seedRelpaths ?? SANDBOX_SEED_RELPATHS;
120
+ const intended = new Set(opts.preconditions ?? KNOWN_PRECONDITIONS);
121
+ const prefix = opts.prefix ?? "hq-doctor-sandbox-";
122
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
123
+ trackedSandboxes.add(root);
124
+ try {
125
+ copyTreeEntries(hqRoot, root, relpaths);
126
+ const seeded = new Set();
127
+ const sessionId = `hq-doctor-sandbox-${path.basename(root)}`;
128
+ if (intended.has("session-metadata")) {
129
+ seedSessionMetadata(root, sessionId);
130
+ seeded.add("session-metadata");
131
+ }
132
+ if (intended.has("company-scaffold")) {
133
+ seedCompanyScaffold(root);
134
+ seeded.add("company-scaffold");
135
+ }
136
+ // git LAST, so the initial commit captures everything else that was seeded.
137
+ if (intended.has("git-repo") && seedGitRepo(root)) {
138
+ seeded.add("git-repo");
139
+ }
140
+ return { root, seeded, sessionId };
141
+ }
142
+ catch (error) {
143
+ destroySandbox(root);
144
+ throw error;
145
+ }
146
+ }
147
+ function copyTreeEntries(hqRoot, root, relpaths) {
148
+ for (const rel of relpaths) {
149
+ const segments = rel.split("/");
150
+ const src = path.join(hqRoot, ...segments);
151
+ if (!fs.existsSync(src))
152
+ continue;
153
+ const dest = path.join(root, ...segments);
154
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
155
+ // cpSync preserves mode bits, so hooks and the gate stay executable.
156
+ fs.cpSync(src, dest, { recursive: true });
157
+ }
158
+ }
159
+ function seedSessionMetadata(root, sessionId) {
160
+ // The on-disk shape a session-aware hook expects: a state dir plus a workspace
161
+ // scaffold. The session id itself is carried in the payload and the run env.
162
+ fs.mkdirSync(path.join(root, ".claude", "state"), { recursive: true });
163
+ fs.mkdirSync(path.join(root, "workspace", "threads"), { recursive: true });
164
+ fs.writeFileSync(path.join(root, ".claude", "state", `session-${sessionId}`), `${sessionId}\n`);
165
+ }
166
+ function seedCompanyScaffold(root) {
167
+ const companiesDir = path.join(root, "companies");
168
+ fs.mkdirSync(path.join(companiesDir, SANDBOX_COMPANY_SLUG), { recursive: true });
169
+ fs.writeFileSync(path.join(companiesDir, "manifest.yaml"), `companies:\n - slug: ${SANDBOX_COMPANY_SLUG}\n name: Sandbox Co\n`);
170
+ }
171
+ /**
172
+ * Initialise a git repo with a single baseline commit, so a hook that commits
173
+ * advances the count from a known 1. Returns false (git-repo not seeded) when git
174
+ * is unavailable or a step fails, rather than throwing — an absent repo surfaces
175
+ * as UNKNOWN for a commit case, not a false failure.
176
+ */
177
+ function seedGitRepo(root) {
178
+ if (git(root, ["init", "-q", "-b", "main"]) !== 0)
179
+ return false;
180
+ // Identity straight into .git/config, matching the reference harness — no
181
+ // dependency on the runner's global git config.
182
+ fs.appendFileSync(path.join(root, ".git", "config"), "[user]\n\temail = hq-doctor-sandbox@example.com\n\tname = hq-doctor-sandbox\n" +
183
+ "[commit]\n\tgpgsign = false\n");
184
+ // Guarantee there is always something to commit, even with an empty seed set.
185
+ fs.writeFileSync(path.join(root, ".hq-sandbox-baseline"), "seeded\n");
186
+ if (git(root, ["add", "-A"]) !== 0)
187
+ return false;
188
+ return git(root, ["commit", "-q", "-m", "sandbox baseline"]) === 0;
189
+ }
190
+ function git(root, args) {
191
+ const res = spawnSync("git", args, { cwd: root, encoding: "utf8" });
192
+ return res.status;
193
+ }
194
+ /**
195
+ * The real runner: fire the hook script directly with the payload on stdin, its
196
+ * cwd and session env pointed at the sandbox, and a hard timeout. Unlike a pure
197
+ * guard (US-008), a side-effecting hook is run directly rather than through the
198
+ * gate — its exit code is irrelevant here; what matters is the effect it leaves
199
+ * on the sandbox, which effects.ts reads afterwards.
200
+ */
201
+ export const defaultHookRunner = (input) => {
202
+ const result = spawnSync("bash", [input.hookScriptPath], {
203
+ input: input.payload,
204
+ cwd: input.cwd,
205
+ timeout: input.timeoutMs,
206
+ killSignal: "SIGKILL",
207
+ encoding: "utf8",
208
+ maxBuffer: 8 * 1024 * 1024,
209
+ env: {
210
+ ...process.env,
211
+ CLAUDE_PROJECT_DIR: input.cwd,
212
+ HQ_ROOT: input.cwd,
213
+ CLAUDE_SESSION_ID: input.sessionId,
214
+ HQ_SESSION_ID: input.sessionId,
215
+ },
216
+ });
217
+ const errno = result.error?.code;
218
+ return {
219
+ timedOut: errno === "ETIMEDOUT",
220
+ exitCode: result.status,
221
+ stderr: result.stderr ?? "",
222
+ };
223
+ };
224
+ /**
225
+ * Discover and parse every side-effecting fixture under `core/hook-tests/`. Only
226
+ * fixtures whose hook id classifies as side-effecting are returned — pure guards
227
+ * are US-008's and are ignored here. Malformed side-effecting fixtures are
228
+ * collected separately so the tier can surface them without aborting.
229
+ */
230
+ export function discoverSideEffectFixtures(hqRoot) {
231
+ const dir = path.join(hqRoot, HOOK_TESTS_RELDIR);
232
+ const fixtures = [];
233
+ const invalid = [];
234
+ let names;
235
+ try {
236
+ names = fs.readdirSync(dir);
237
+ }
238
+ catch {
239
+ return { fixtures, invalid };
240
+ }
241
+ for (const name of names.sort()) {
242
+ if (!/\.ya?ml$/i.test(name))
243
+ continue;
244
+ if (NON_FIXTURE_FILES.has(name))
245
+ continue;
246
+ const relpath = path.join(HOOK_TESTS_RELDIR, name);
247
+ const defaultHookId = name.replace(/\.ya?ml$/i, "");
248
+ let doc;
249
+ try {
250
+ doc = yaml.load(fs.readFileSync(path.join(dir, name), "utf8"));
251
+ }
252
+ catch (error) {
253
+ // A YAML syntax error is only ours to report if the file is meant to be a
254
+ // side-effecting fixture — which we cannot tell without parsing it. The
255
+ // stem is the best hint; if it names a side-effecting hook, flag it.
256
+ if (isSideEffectingHookId(defaultHookId)) {
257
+ invalid.push({
258
+ hookId: defaultHookId,
259
+ relpath,
260
+ message: `not valid YAML: ${error.message}`,
261
+ });
262
+ }
263
+ continue;
264
+ }
265
+ if (!looksLikeFixture(doc))
266
+ continue;
267
+ const hookId = resolveHookId(doc, defaultHookId);
268
+ if (!isSideEffectingHookId(hookId))
269
+ continue;
270
+ const parsed = parseSideEffectCases(doc);
271
+ if (typeof parsed === "string") {
272
+ invalid.push({ hookId, relpath, message: parsed });
273
+ continue;
274
+ }
275
+ fixtures.push({ hookId, relpath, cases: parsed });
276
+ }
277
+ return { fixtures, invalid };
278
+ }
279
+ function resolveHookId(doc, defaultHookId) {
280
+ if (isRecord(doc)) {
281
+ const declared = doc.hookId;
282
+ if (typeof declared === "string" && declared.trim() !== "")
283
+ return declared;
284
+ }
285
+ return defaultHookId;
286
+ }
287
+ /** Parse a fixture doc's `cases` into side-effect cases, or an error string. */
288
+ function parseSideEffectCases(doc) {
289
+ if (!isRecord(doc))
290
+ return "fixture is not a mapping";
291
+ if (!("cases" in doc) || !Array.isArray(doc.cases)) {
292
+ return "fixture `cases` must be a list";
293
+ }
294
+ const cases = [];
295
+ const seen = new Set();
296
+ for (let i = 0; i < doc.cases.length; i++) {
297
+ const parsed = parseSideEffectCase(doc.cases[i], i);
298
+ if (typeof parsed === "string")
299
+ return parsed;
300
+ if (seen.has(parsed.name))
301
+ return `duplicate case name "${parsed.name}"`;
302
+ seen.add(parsed.name);
303
+ cases.push(parsed);
304
+ }
305
+ return cases;
306
+ }
307
+ function parseSideEffectCase(raw, index) {
308
+ const where = `case #${index + 1}`;
309
+ if (!isRecord(raw))
310
+ return `${where} is not a mapping`;
311
+ const name = raw.name;
312
+ if (typeof name !== "string" || name.trim() === "") {
313
+ return `${where} is missing a non-empty \`name\``;
314
+ }
315
+ const label = `case "${name}"`;
316
+ const event = raw.event;
317
+ if (typeof event !== "string" || event.trim() === "") {
318
+ return `${label} is missing a non-empty \`event\``;
319
+ }
320
+ const tool = raw.tool;
321
+ if (typeof tool !== "string" || tool.trim() === "") {
322
+ return `${label} is missing a non-empty \`tool\``;
323
+ }
324
+ if (!("input" in raw))
325
+ return `${label} is missing a tool \`input\` payload`;
326
+ if (!("effects" in raw)) {
327
+ return `${label} is missing an \`effects\` list (a side-effecting case asserts effects, not exit codes)`;
328
+ }
329
+ const effects = parseEffectExpectations(raw.effects);
330
+ if (typeof effects === "string")
331
+ return `${label}: ${effects}`;
332
+ const requires = parseRequires(raw.requires);
333
+ if (typeof requires === "string")
334
+ return `${label}: ${requires}`;
335
+ const expectedFailure = parseExpectedFailure(raw.expectedFailure);
336
+ if (typeof expectedFailure === "string")
337
+ return `${label}: ${expectedFailure}`;
338
+ const parsedCase = {
339
+ name,
340
+ event,
341
+ tool,
342
+ input: raw.input,
343
+ effects,
344
+ requires,
345
+ };
346
+ if (expectedFailure)
347
+ parsedCase.expectedFailure = expectedFailure;
348
+ return parsedCase;
349
+ }
350
+ function parseRequires(raw) {
351
+ if (raw === undefined || raw === null)
352
+ return [];
353
+ if (!Array.isArray(raw))
354
+ return "`requires` must be a list of precondition names";
355
+ const out = [];
356
+ for (const item of raw) {
357
+ if (typeof item !== "string" || item.trim() === "") {
358
+ return "`requires` entries must be non-empty strings";
359
+ }
360
+ out.push(item);
361
+ }
362
+ return out;
363
+ }
364
+ function parseExpectedFailure(raw) {
365
+ if (raw === undefined || raw === null)
366
+ return undefined;
367
+ if (!isRecord(raw))
368
+ return "`expectedFailure` must be a mapping with a `reason`";
369
+ const reason = raw.reason;
370
+ if (typeof reason !== "string" || reason.trim() === "") {
371
+ return "`expectedFailure` requires a non-empty `reason`";
372
+ }
373
+ return { reason };
374
+ }
375
+ /**
376
+ * Execute every registered side-effecting hook's fixture cases in per-case
377
+ * sandboxes and return the effect verdicts. Read-only against the live tree; all
378
+ * writes and commits happen in throwaway trees that are always torn down.
379
+ * Returns an empty list when there is nothing side-effecting to run.
380
+ */
381
+ export async function runSideEffectTests(context, options = {}) {
382
+ const hqRoot = context.hqRoot;
383
+ const timeoutMs = options.perCaseTimeoutMs ?? DEFAULT_PER_CASE_TIMEOUT_MS;
384
+ const runner = options.runner ?? defaultHookRunner;
385
+ const git = options.gitProbe ?? defaultGitProbe;
386
+ const seeder = options.seeder ??
387
+ ((root) => seedSandbox(root, {
388
+ seedRelpaths: options.seedRelpaths,
389
+ preconditions: options.preconditions,
390
+ }));
391
+ const registered = new Set(registeredHookIds(hqRoot));
392
+ const discovery = discoverSideEffectFixtures(hqRoot);
393
+ const results = [];
394
+ // A malformed side-effecting fixture is surfaced (WARN) but never executed.
395
+ for (const bad of discovery.invalid) {
396
+ results.push({
397
+ status: "WARN",
398
+ checkId: "hooks.deep.effects.invalid",
399
+ target: bad.relpath,
400
+ message: `Side-effecting fixture ${bad.relpath} is malformed and was skipped: ${bad.message}.`,
401
+ remediation: `Correct ${bad.relpath} (each case needs an \`effects\` list; see core/hook-tests/README.md).`,
402
+ });
403
+ }
404
+ const env = { hqRoot, timeoutMs, runner, git, seeder, keepSandbox: options.keepSandbox === true };
405
+ for (const fixture of discovery.fixtures) {
406
+ // A fixture whose hook is registered nowhere is not executed here; the
407
+ // coverage tier (US-007) owns orphan reporting.
408
+ if (!registered.has(fixture.hookId))
409
+ continue;
410
+ for (const testCase of fixture.cases) {
411
+ results.push(runCase(fixture, testCase, env));
412
+ }
413
+ }
414
+ return results;
415
+ }
416
+ /**
417
+ * Run one side-effecting case in its own sandbox and classify the observed
418
+ * effects. Guarantees the sandbox is torn down whatever happens.
419
+ */
420
+ function runCase(fixture, testCase, env) {
421
+ const target = `${fixture.hookId}:${testCase.name}`;
422
+ // A `commit-created` effect cannot be observed without a git repo, so it
423
+ // implicitly requires one — a sandbox with no git yields UNKNOWN, not FAIL.
424
+ const required = new Set(testCase.requires);
425
+ if (testCase.effects.some((e) => e.kind === "commit-created")) {
426
+ required.add("git-repo");
427
+ }
428
+ let seed;
429
+ try {
430
+ seed = env.seeder(env.hqRoot, fixture.hookId);
431
+ }
432
+ catch (error) {
433
+ // Rule 4: one hook's setup failure is reported, but the run continues.
434
+ return {
435
+ status: "UNKNOWN",
436
+ checkId: "hooks.deep.effects.sandbox-setup-failed",
437
+ target,
438
+ message: `Sandbox setup for "${fixture.hookId}" failed, so case "${testCase.name}" could not be run: ${oneLine(error.message)}. Remaining hooks are unaffected.`,
439
+ remediation: `Investigate the sandbox seeding failure for "${fixture.hookId}".`,
440
+ };
441
+ }
442
+ try {
443
+ // Rule 3: a precondition the sandbox did not seed is UNKNOWN, not FAIL.
444
+ const missing = [...required].filter((r) => !seed.seeded.has(r));
445
+ if (missing.length > 0) {
446
+ return {
447
+ status: "UNKNOWN",
448
+ checkId: "hooks.deep.effects.precondition-missing",
449
+ target,
450
+ message: `Cannot verify "${fixture.hookId}" case "${testCase.name}": the sandbox did not seed the required precondition${missing.length === 1 ? "" : "s"} ${formatList(missing)}. Reported UNKNOWN rather than FAIL so a thin sandbox does not manufacture a false failure.`,
451
+ remediation: `Seed ${formatList(missing)} in the sandbox, or drop it from the fixture case's \`requires\`.`,
452
+ };
453
+ }
454
+ const scriptPath = path.join(seed.root, ".claude", "hooks", `${fixture.hookId}.sh`);
455
+ if (!fs.existsSync(scriptPath)) {
456
+ return {
457
+ status: "UNKNOWN",
458
+ checkId: "hooks.deep.effects.script-missing",
459
+ target,
460
+ message: `Cannot verify "${fixture.hookId}": its script .claude/hooks/${fixture.hookId}.sh is absent from the sandbox, so no effect can be observed.`,
461
+ };
462
+ }
463
+ const baseline = captureEffectBaseline(seed.root, testCase.effects, env.git);
464
+ const run = env.runner({
465
+ hookScriptPath: scriptPath,
466
+ payload: buildPayload(testCase, seed.root, seed.sessionId),
467
+ cwd: seed.root,
468
+ sessionId: seed.sessionId,
469
+ timeoutMs: env.timeoutMs,
470
+ });
471
+ if (run.timedOut) {
472
+ return {
473
+ status: "FAIL",
474
+ checkId: "hooks.deep.effects.timeout",
475
+ target,
476
+ message: `Case "${testCase.name}" for "${fixture.hookId}" exceeded the ${env.timeoutMs}ms per-case time bound; a hook that does not return within the bound is treated as a failure, not left to hang the command.`,
477
+ remediation: `Investigate why "${fixture.hookId}" does not return within ${env.timeoutMs}ms.`,
478
+ };
479
+ }
480
+ const evaluations = evaluateEffects(seed.root, testCase.effects, baseline, env.git);
481
+ return classifyEffects(fixture, testCase, evaluations, run);
482
+ }
483
+ finally {
484
+ if (!env.keepSandbox)
485
+ destroySandbox(seed.root);
486
+ }
487
+ }
488
+ /**
489
+ * Turn the per-effect evaluations into a single case result, applying the
490
+ * `expectedFailure` rule (shared with US-008 via {@link classifyCaseStatus}).
491
+ */
492
+ function classifyEffects(fixture, testCase, evaluations, run) {
493
+ const target = `${fixture.hookId}:${testCase.name}`;
494
+ const allMet = evaluations.every((e) => e.met);
495
+ const status = classifyCaseStatus(allMet, testCase.expectedFailure);
496
+ const effectList = formatList(testCase.effects.map(describeEffect));
497
+ if (status === "PASS") {
498
+ return {
499
+ status,
500
+ checkId: "hooks.deep.effects.case",
501
+ target,
502
+ message: `Case "${testCase.name}" for "${fixture.hookId}" produced the expected observable effect${testCase.effects.length === 1 ? "" : "s"} in the sandbox: ${effectList}.`,
503
+ };
504
+ }
505
+ if (status === "WARN") {
506
+ // Pinned as a defect, yet every effect now happens — the marker is stale.
507
+ return {
508
+ status,
509
+ checkId: "hooks.deep.effects.stale-expected-failure",
510
+ target,
511
+ message: `Case "${testCase.name}" for "${fixture.hookId}" is marked expectedFailure (${testCase.expectedFailure?.reason ?? ""}) but now produces every expected effect (${effectList}); remove the stale marker.`,
512
+ remediation: `Delete the expectedFailure marker from case "${testCase.name}" in core/hook-tests/${fixture.hookId}.yaml.`,
513
+ };
514
+ }
515
+ // FAIL or KNOWN-DEFECT: name each effect that did not happen, plus the hook's
516
+ // exit code and stderr for diagnosis.
517
+ const unmet = evaluations
518
+ .filter((e) => !e.met)
519
+ .map((e) => `${describeEffect(e.expectation)} — ${e.detail}`)
520
+ .join("; ");
521
+ const stderr = run.stderr.trim();
522
+ const base = `Case "${testCase.name}" for "${fixture.hookId}" did not produce the expected effect${evaluations.filter((e) => !e.met).length === 1 ? "" : "s"}: ${unmet} (hook exit ${formatExit(run.exitCode)}${stderr ? `, stderr: ${oneLine(stderr)}` : ", no stderr"}).`;
523
+ if (status === "KNOWN-DEFECT") {
524
+ return {
525
+ status,
526
+ checkId: "hooks.deep.effects.case",
527
+ target,
528
+ message: `${base} Tracked as a known defect: ${testCase.expectedFailure?.reason ?? ""}.`,
529
+ };
530
+ }
531
+ return {
532
+ status: "FAIL",
533
+ checkId: "hooks.deep.effects.case",
534
+ target,
535
+ message: base,
536
+ remediation: `Verify "${fixture.hookId}" performs ${effectList} for case "${testCase.name}", or correct the fixture.`,
537
+ };
538
+ }
539
+ /**
540
+ * The Claude/Codex payload the hook reads on stdin: the fixture's event, tool,
541
+ * and opaque input, plus the sandbox cwd and the seeded session id.
542
+ */
543
+ function buildPayload(testCase, cwd, sessionId) {
544
+ return JSON.stringify({
545
+ hook_event_name: testCase.event,
546
+ tool_name: testCase.tool,
547
+ tool_input: testCase.input,
548
+ cwd,
549
+ session_id: sessionId,
550
+ });
551
+ }
552
+ // --- message helpers ----------------------------------------------------------
553
+ /** Render a list as "a", "a and b", or "a, b and c". */
554
+ function formatList(items) {
555
+ if (items.length <= 1)
556
+ return items.join("");
557
+ if (items.length === 2)
558
+ return `${items[0]} and ${items[1]}`;
559
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
560
+ }
561
+ function formatExit(exitCode) {
562
+ return exitCode === null ? "none" : String(exitCode);
563
+ }
564
+ /** Collapse a string to a single trimmed, length-capped line for a message. */
565
+ function oneLine(text) {
566
+ const collapsed = text.replace(/\s+/g, " ").trim();
567
+ return collapsed.length > 200 ? `${collapsed.slice(0, 197)}…` : collapsed;
568
+ }
569
+ function isRecord(value) {
570
+ return typeof value === "object" && value !== null && !Array.isArray(value);
571
+ }
572
+ //# sourceMappingURL=sandbox.js.map
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `hq doctor --fix` — apply the allowlisted safe repairs (US-011).
3
+ *
4
+ * Read-by-default is the doctor's whole safety posture, so the write path is
5
+ * wrapped in guardrails that are NOT polish — each is a PRD acceptance criterion:
6
+ *
7
+ * 1. Dirty-tree refusal. If the working tree has uncommitted changes under
8
+ * `.claude/`, `.codex/`, or `.grok/`, `--fix` refuses and exits non-zero
9
+ * unless `--force`, so a repair can never be tangled up with unrelated
10
+ * in-flight edits to the security layer.
11
+ * 2. Allowlist only. It repairs exactly three classes — restore an execute
12
+ * bit, add a hook id to the gate profiles it is missing from, re-register a
13
+ * script present on disk — and NEVER rewrites a hook body or deletes a file.
14
+ * Classification is owned by {@link deriveRemediation}; a content-drift
15
+ * finding is manual-only and simply never appears in the fixable set.
16
+ * 3. Preview + confirmation. Every change is shown diff-style and requires
17
+ * confirmation, with `--yes` for non-interactive use.
18
+ * 4. Backup first. Before any write, the affected files are copied under
19
+ * `workspace/doctor/backups/<timestamp>/` and the restore command is
20
+ * printed on completion.
21
+ * 5. Re-verify. After applying, the affected checks are re-run and their
22
+ * post-fix status reported, so the user sees the real result rather than an
23
+ * assumed success.
24
+ *
25
+ * The applier resolves nothing about the host: the caller passes the HQ root
26
+ * (the command resolves it once), keeping this module decoupled from the CLI and
27
+ * trivially testable against a fake tree.
28
+ */
29
+ import type { DoctorStatus } from "../types.js";
30
+ import { type GateProfile } from "../hook-gate-profiles.js";
31
+ import { type FixClass } from "./remediation.js";
32
+ /** The tree subtrees whose uncommitted changes block a `--fix` run. */
33
+ export declare const HOOK_CONFIG_DIRS: readonly [".claude", ".codex", ".grok"];
34
+ /** Options for {@link applyFixes}. Everything is injectable for hermetic tests. */
35
+ export interface ApplyFixesOptions {
36
+ /** The resolved HQ root the repairs run against. Required. */
37
+ hqRoot: string;
38
+ /** Skip the interactive confirmation (the `--yes` flag). Default: false. */
39
+ yes?: boolean;
40
+ /** Override the dirty-tree refusal (the `--force` flag). Default: false. */
41
+ force?: boolean;
42
+ /** stdout sink. Default: `process.stdout.write`. */
43
+ stdout?: (chunk: string) => void;
44
+ /** stderr sink. Default: `process.stderr.write`. */
45
+ stderr?: (chunk: string) => void;
46
+ /**
47
+ * The interactive confirmation. Called only when `yes` is false. Default reads
48
+ * a y/N answer from the TTY; a non-interactive default declines. Injected in
49
+ * tests to accept or reject without a terminal.
50
+ */
51
+ confirm?: () => boolean | Promise<boolean>;
52
+ /** Clock for the backup directory name. Default: `new Date()`. */
53
+ now?: () => Date;
54
+ /**
55
+ * The dirty-tree probe: the changed paths under {@link HOOK_CONFIG_DIRS}, or
56
+ * [] when clean or not a git repo. Default shells out to `git status`.
57
+ */
58
+ dirtyCheck?: (hqRoot: string) => string[];
59
+ }
60
+ /** One applied (or attempted) repair, with its post-fix re-check status. */
61
+ export interface AppliedFix {
62
+ /** The check id of the finding that was repaired. */
63
+ checkId: string;
64
+ /** The safe class applied. */
65
+ fixClass: FixClass;
66
+ /** The file path or hook id acted on. */
67
+ target: string;
68
+ /** One-line human summary of the change. */
69
+ summary: string;
70
+ /** The status of the re-run check for this target, or null if not found. */
71
+ postStatus: DoctorStatus | null;
72
+ }
73
+ /** The outcome of an {@link applyFixes} run. */
74
+ export interface ApplyFixesResult {
75
+ /** 0 on success or a clean no-op; 1 when the run was refused. */
76
+ exitCode: number;
77
+ /** Whether any file was written. */
78
+ wrote: boolean;
79
+ /** Why the run wrote nothing, when it did not: refusal reason or null. */
80
+ refused: "dirty-tree" | "declined" | null;
81
+ /** How many findings were auto-fixable. */
82
+ fixableCount: number;
83
+ /** The backup directory, or null when nothing was written. */
84
+ backupDir: string | null;
85
+ /** The restore command, or null when nothing was written. */
86
+ restoreCommand: string | null;
87
+ /** The applied repairs with their post-fix status. */
88
+ applied: AppliedFix[];
89
+ }
90
+ /**
91
+ * Apply every auto-fixable finding, honouring the dirty-tree refusal, the
92
+ * preview/confirmation gate, the pre-write backup, and the post-fix re-check.
93
+ * Returns a structured result rather than throwing so both the CLI and tests can
94
+ * assert on it.
95
+ */
96
+ export declare function applyFixes(options: ApplyFixesOptions): Promise<ApplyFixesResult>;
97
+ /**
98
+ * The uncommitted changes under {@link HOOK_CONFIG_DIRS}, one porcelain line
99
+ * each. Returns [] when the tree is clean OR when `hqRoot` is not a git repo
100
+ * (git exits non-zero): with no VCS there is no in-flight change to protect, and
101
+ * the backup still covers the write.
102
+ */
103
+ export declare function uncommittedHookConfigChanges(hqRoot: string): string[];
104
+ /**
105
+ * Insert a new `case` arm allowlisting `hookId` (leading to `return 0`) at the
106
+ * top of each named profile function's `case "$1" in` block. Additive: it never
107
+ * rewrites an existing arm, so an id already present in another profile is
108
+ * untouched and the parser recognises the new arm exactly as it would a
109
+ * hand-written one. A profile whose function or case block cannot be located is
110
+ * left unchanged rather than corrupted.
111
+ */
112
+ export declare function addHookIdToGateProfiles(source: string, hookId: string, profiles: readonly GateProfile[]): string;
113
+ /**
114
+ * Append a `type: command` hook registration to `settings.hooks[event]`,
115
+ * creating the `hooks` map and the event array as needed. Tolerant of a
116
+ * non-object `hooks` value — it is replaced rather than trusted.
117
+ */
118
+ export declare function registerHookInSettings(settings: unknown, event: string, command: string): Record<string, unknown>;
119
+ //# sourceMappingURL=apply.d.ts.map