@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,524 @@
1
+ /**
2
+ * Claude Code hook WIRING checks (US-004).
3
+ *
4
+ * "Wiring" is everything short of firing the hook: is every registration backed
5
+ * by a script that exists and is executable, is every script on disk actually
6
+ * registered, is every gated hook id a member of all three `hook-gate.sh`
7
+ * profiles, and does any command word-split its own script path. A hook can be
8
+ * present, correct, and completely dead — this check catches the ways that
9
+ * happens without a single error surfacing to the user.
10
+ *
11
+ * It is strictly read-only: it stats and reads files and never writes, so a
12
+ * doctor run is byte-identical to no run at all (asserted by test).
13
+ *
14
+ * The unquoted-`$CLAUDE_PROJECT_DIR` detection is a faithful TypeScript port of
15
+ * the quote-aware shell tokenizer in `core/scripts/lib/hook-command-scan.sh`
16
+ * (used by `core/scripts/check-hq-hooks.sh`), not a reinvention: Claude Code
17
+ * runs each hook command through `/bin/sh`, so an expansion outside double
18
+ * quotes is word-split, and on an install root containing whitespace the shell
19
+ * execs a truncated path and every such hook dies as an invisible non-blocking
20
+ * error. Sharing the tokenizer's semantics is the point — a quoted form such as
21
+ * `"$CLAUDE_PROJECT_DIR/x"` or `"${CLAUDE_PROJECT_DIR}/x"` must never be called
22
+ * broken, and an unquoted one must never be missed.
23
+ */
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import { GATE_PROFILES, gateMembership, parseHookGateProfiles, } from "../hook-gate-profiles.js";
27
+ /** Common id prefix for every result this check family emits. */
28
+ export const CLAUDE_WIRING_PREFIX = "hooks.claude";
29
+ /**
30
+ * Run every Claude wiring check against the resolved HQ tree and return the
31
+ * per-item results. Never throws on a malformed or partial tree — a missing or
32
+ * unreadable input contributes a result (or nothing), not an exception.
33
+ */
34
+ export function checkClaudeWiring(context) {
35
+ const root = context.hqRoot;
36
+ const claudeDir = path.join(root, ".claude");
37
+ const hooksDir = path.join(claudeDir, "hooks");
38
+ const gatePath = path.join(hooksDir, "hook-gate.sh");
39
+ const results = [];
40
+ // --- Load registrations from both settings files -----------------------------
41
+ // Claude Code merges .claude/settings.local.json over .claude/settings.json, so
42
+ // a hook that lives only in the overlay is wired exactly like a shipped one.
43
+ const registrations = [];
44
+ const base = readJsonFile(path.join(claudeDir, "settings.json"));
45
+ if (base.present && !base.invalid) {
46
+ registrations.push(...enumerateRegistrations(base.value, ".claude/settings.json"));
47
+ }
48
+ const local = readJsonFile(path.join(claudeDir, "settings.local.json"));
49
+ if (local.present && local.invalid) {
50
+ results.push({
51
+ status: "FAIL",
52
+ checkId: `${CLAUDE_WIRING_PREFIX}.settings-local-valid-json`,
53
+ target: path.join(claudeDir, "settings.local.json"),
54
+ message: ".claude/settings.local.json is present but is not valid JSON.",
55
+ });
56
+ }
57
+ else if (local.present) {
58
+ registrations.push(...enumerateRegistrations(local.value, ".claude/settings.local.json"));
59
+ }
60
+ const scans = registrations.map((reg) => ({
61
+ reg,
62
+ scan: scanHookCommand(reg.command),
63
+ }));
64
+ // --- AC1: total registrations per lifecycle event ----------------------------
65
+ const perEvent = new Map();
66
+ for (const reg of registrations) {
67
+ perEvent.set(reg.event, (perEvent.get(reg.event) ?? 0) + 1);
68
+ }
69
+ for (const [event, count] of [...perEvent.entries()].sort(byKey)) {
70
+ results.push({
71
+ status: "PASS",
72
+ checkId: `${CLAUDE_WIRING_PREFIX}.event.${event}`,
73
+ target: event,
74
+ message: `${count} Claude hook registration${count === 1 ? "" : "s"} on ${event}.`,
75
+ });
76
+ }
77
+ // --- AC2: every referenced script exists and is executable -------------------
78
+ const scriptRelpaths = new Set();
79
+ for (const { scan } of scans) {
80
+ for (const rp of scan.requiredRelpaths)
81
+ scriptRelpaths.add(rp);
82
+ }
83
+ for (const rp of [...scriptRelpaths].sort()) {
84
+ const abs = path.join(root, rp);
85
+ const stat = statSafe(abs);
86
+ if (!stat) {
87
+ results.push({
88
+ status: "FAIL",
89
+ checkId: `${CLAUDE_WIRING_PREFIX}.script-missing`,
90
+ target: abs,
91
+ message: `Registered hook script is missing on disk: ${rp}.`,
92
+ remediation: `Restore ${rp} or remove its registration from .claude/settings.json.`,
93
+ });
94
+ continue;
95
+ }
96
+ if (!isExecutable(stat.mode)) {
97
+ results.push({
98
+ status: "FAIL",
99
+ checkId: `${CLAUDE_WIRING_PREFIX}.script-not-executable`,
100
+ target: abs,
101
+ message: `Registered hook script is present but not executable: ${rp}.`,
102
+ remediation: `chmod +x "${abs}"`,
103
+ });
104
+ continue;
105
+ }
106
+ results.push({
107
+ status: "PASS",
108
+ checkId: `${CLAUDE_WIRING_PREFIX}.script`,
109
+ target: abs,
110
+ message: `Registered hook script exists and is executable: ${rp}.`,
111
+ });
112
+ }
113
+ // --- AC3: scripts present on disk but registered nowhere ---------------------
114
+ const referencedBasenames = new Set(["hook-gate.sh"]);
115
+ for (const { scan } of scans) {
116
+ for (const rp of scan.requiredRelpaths)
117
+ referencedBasenames.add(baseName(rp));
118
+ }
119
+ for (const file of listShellScripts(hooksDir)) {
120
+ if (referencedBasenames.has(file))
121
+ continue;
122
+ results.push({
123
+ status: "WARN",
124
+ checkId: `${CLAUDE_WIRING_PREFIX}.orphan`,
125
+ target: path.join(hooksDir, file),
126
+ message: `Hook script ${file} is present in .claude/hooks/ but is not registered in any settings file.`,
127
+ remediation: `Register ${file} in .claude/settings.json or delete it.`,
128
+ });
129
+ }
130
+ // --- AC4: every gated hook id is in ALL THREE profiles -----------------------
131
+ const gatedIds = new Set();
132
+ for (const { scan } of scans) {
133
+ if (scan.gatedHookId)
134
+ gatedIds.add(scan.gatedHookId);
135
+ }
136
+ if (gatedIds.size > 0) {
137
+ const gateSource = readTextFile(gatePath);
138
+ if (gateSource === null) {
139
+ results.push({
140
+ status: "FAIL",
141
+ checkId: `${CLAUDE_WIRING_PREFIX}.hook-gate-missing`,
142
+ target: gatePath,
143
+ message: "Hooks route through hook-gate.sh but .claude/hooks/hook-gate.sh is missing; profile membership cannot be verified.",
144
+ });
145
+ }
146
+ else {
147
+ const profiles = parseHookGateProfiles(gateSource);
148
+ for (const id of [...gatedIds].sort()) {
149
+ const { present, missing } = gateMembership(id, profiles);
150
+ if (missing.length === 0) {
151
+ results.push({
152
+ status: "PASS",
153
+ checkId: `${CLAUDE_WIRING_PREFIX}.gate-profiles`,
154
+ target: id,
155
+ message: `Gated hook ${id} is allowlisted in all three hook-gate.sh profiles.`,
156
+ });
157
+ continue;
158
+ }
159
+ const missingList = formatProfiles(missing);
160
+ const presentDesc = present.length > 0 ? formatProfiles(present) : "no profile";
161
+ results.push({
162
+ status: "FAIL",
163
+ checkId: `${CLAUDE_WIRING_PREFIX}.gate-profiles`,
164
+ target: id,
165
+ message: `Gated hook ${id} is missing from the ${missingList} hook-gate.sh ` +
166
+ `profile${missing.length === 1 ? "" : "s"} (present in ${presentDesc}); ` +
167
+ `it silently no-ops under ${missing.length === 1 ? "that profile" : "those profiles"}.`,
168
+ remediation: `Add ${id} to the ${missingList} allowlist${missing.length === 1 ? "" : "s"} in .claude/hooks/hook-gate.sh.`,
169
+ });
170
+ }
171
+ }
172
+ }
173
+ // --- AC5: unquoted $CLAUDE_PROJECT_DIR word-splits on a spaced root ----------
174
+ const unquoted = scans.filter(({ scan }) => scan.unquotedProjectDir);
175
+ if (unquoted.length > 0) {
176
+ const events = [...new Set(unquoted.map(({ reg }) => reg.event))]
177
+ .sort()
178
+ .join(", ");
179
+ const remediation = 'Quote every $CLAUDE_PROJECT_DIR expansion (use "$CLAUDE_PROJECT_DIR/…") in .claude/settings.json.';
180
+ if (/\s/.test(root)) {
181
+ results.push({
182
+ status: "FAIL",
183
+ checkId: `${CLAUDE_WIRING_PREFIX}.unquoted-project-dir`,
184
+ target: root,
185
+ message: `${unquoted.length} hook command(s) reference $CLAUDE_PROJECT_DIR without quotes and this HQ root ` +
186
+ `contains whitespace, so /bin/sh word-splits the path and every one of those hooks is failing ` +
187
+ `right now (events: ${events}): ${root}`,
188
+ remediation,
189
+ });
190
+ }
191
+ else {
192
+ results.push({
193
+ status: "WARN",
194
+ checkId: `${CLAUDE_WIRING_PREFIX}.unquoted-project-dir`,
195
+ target: root,
196
+ message: `${unquoted.length} hook command(s) reference $CLAUDE_PROJECT_DIR without quotes; they break on ` +
197
+ `any install path containing whitespace (events: ${events}).`,
198
+ remediation,
199
+ });
200
+ }
201
+ }
202
+ // --- AC6: event-directory hooks under core/hooks/<Event>/ --------------------
203
+ const coreHooksDir = path.join(root, "core", "hooks");
204
+ for (const event of listSubdirs(coreHooksDir)) {
205
+ const eventDir = path.join(coreHooksDir, event);
206
+ for (const file of listShellScripts(eventDir)) {
207
+ const abs = path.join(eventDir, file);
208
+ const stat = statSafe(abs);
209
+ if (!stat)
210
+ continue;
211
+ if (!isExecutable(stat.mode)) {
212
+ results.push({
213
+ status: "FAIL",
214
+ checkId: `hooks.core-event.not-executable`,
215
+ target: abs,
216
+ message: `Event-directory hook core/hooks/${event}/${file} is not executable; the runtime skips non-executable event hooks silently.`,
217
+ remediation: `chmod +x "${abs}"`,
218
+ });
219
+ }
220
+ else {
221
+ results.push({
222
+ status: "PASS",
223
+ checkId: `hooks.core-event`,
224
+ target: abs,
225
+ message: `Event-directory hook core/hooks/${event}/${file} is executable.`,
226
+ });
227
+ }
228
+ }
229
+ }
230
+ return results;
231
+ }
232
+ /**
233
+ * Enumerate every `type: "command"` hook registration across every lifecycle
234
+ * event in a parsed settings object. Tolerant of malformed shapes — any
235
+ * non-conforming branch contributes nothing rather than throwing.
236
+ */
237
+ function enumerateRegistrations(settings, source) {
238
+ const out = [];
239
+ if (!settings || typeof settings !== "object")
240
+ return out;
241
+ const hooks = settings.hooks;
242
+ if (!hooks || typeof hooks !== "object")
243
+ return out;
244
+ for (const [event, entries] of Object.entries(hooks)) {
245
+ if (!Array.isArray(entries))
246
+ continue;
247
+ for (const entry of entries) {
248
+ const inner = entry?.hooks;
249
+ if (!Array.isArray(inner))
250
+ continue;
251
+ for (const item of inner) {
252
+ const hook = item;
253
+ if (hook?.type === "command" &&
254
+ typeof hook.command === "string" &&
255
+ hook.command.length > 0) {
256
+ out.push({ event, command: hook.command, source });
257
+ }
258
+ }
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+ // --- command scanner (port of core/scripts/lib/hook-command-scan.sh) -----------
264
+ /** A sentinel standing in for a resolved $CLAUDE_PROJECT_DIR expansion. */
265
+ const SENTINEL = "\u0001";
266
+ /** The interpreters whose first path argument is the script actually run. */
267
+ const INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
268
+ /** Quote-aware analysis of a single hook command. See {@link CommandScan}. */
269
+ export function scanHookCommand(command) {
270
+ const { first, unsafe } = tokenizeCommand(command);
271
+ const { required, gatedHookId } = requiredFromFirstCommand(first);
272
+ return {
273
+ unquotedProjectDir: unsafe,
274
+ requiredRelpaths: required,
275
+ gatedHookId,
276
+ };
277
+ }
278
+ /**
279
+ * Split a command the way `/bin/sh` would: track single- and double-quote state,
280
+ * honour backslash escapes, replace each $CLAUDE_PROJECT_DIR expansion with a
281
+ * sentinel, and flag the command when such an expansion sits outside double
282
+ * quotes. Mirrors the `tokenize` awk function in hook-command-scan.sh.
283
+ */
284
+ function tokenizeCommand(command) {
285
+ const first = [];
286
+ let firstDone = false;
287
+ let unsafe = false;
288
+ let tok = "";
289
+ let inTok = false;
290
+ let inSingle = false;
291
+ let inDouble = false;
292
+ const endToken = () => {
293
+ if (inTok && !firstDone)
294
+ first.push(tok);
295
+ tok = "";
296
+ inTok = false;
297
+ };
298
+ const n = command.length;
299
+ let i = 0;
300
+ while (i < n) {
301
+ const c = command[i];
302
+ if (inSingle) {
303
+ if (c === "'")
304
+ inSingle = false;
305
+ else
306
+ tok += c;
307
+ inTok = true;
308
+ i += 1;
309
+ continue;
310
+ }
311
+ if (c === "\\") {
312
+ const d = i + 1 < n ? command[i + 1] : "";
313
+ if (d === "") {
314
+ tok += c;
315
+ inTok = true;
316
+ i += 1;
317
+ continue;
318
+ }
319
+ // In double quotes a backslash only escapes " \ $ ` — otherwise it is literal.
320
+ if (inDouble && d !== '"' && d !== "\\" && d !== "$" && d !== "`") {
321
+ tok += c;
322
+ inTok = true;
323
+ i += 1;
324
+ continue;
325
+ }
326
+ tok += d;
327
+ inTok = true;
328
+ i += 2;
329
+ continue;
330
+ }
331
+ if (c === '"') {
332
+ inDouble = !inDouble;
333
+ inTok = true;
334
+ i += 1;
335
+ continue;
336
+ }
337
+ if (!inDouble && c === "'") {
338
+ inSingle = true;
339
+ inTok = true;
340
+ i += 1;
341
+ continue;
342
+ }
343
+ const vl = varExpansionLen(command, i);
344
+ if (vl > 0) {
345
+ if (!inDouble)
346
+ unsafe = true;
347
+ tok += SENTINEL;
348
+ inTok = true;
349
+ i += vl;
350
+ continue;
351
+ }
352
+ if (!inDouble) {
353
+ if (c === " " || c === "\t") {
354
+ endToken();
355
+ i += 1;
356
+ continue;
357
+ }
358
+ if (c === "\n" ||
359
+ c === ";" ||
360
+ c === "&" ||
361
+ c === "|" ||
362
+ c === "(" ||
363
+ c === ")") {
364
+ endToken();
365
+ firstDone = true;
366
+ i += 1;
367
+ continue;
368
+ }
369
+ }
370
+ tok += c;
371
+ inTok = true;
372
+ i += 1;
373
+ }
374
+ endToken();
375
+ return { first, unsafe };
376
+ }
377
+ /** Length of a $CLAUDE_PROJECT_DIR expansion starting at index `i`, or 0. */
378
+ function varExpansionLen(s, i) {
379
+ if (s.startsWith("$CLAUDE_PROJECT_DIR", i)) {
380
+ const next = s.charAt(i + 19);
381
+ if (next === "" || !/[A-Za-z0-9_]/.test(next))
382
+ return 19;
383
+ return 0;
384
+ }
385
+ if (s.startsWith("${CLAUDE_PROJECT_DIR}", i))
386
+ return 21;
387
+ return 0;
388
+ }
389
+ /**
390
+ * From the first simple command's tokens, return the scripts it actually
391
+ * executes (relative to the root) and the gated hook id when it routes through
392
+ * hook-gate.sh. Mirrors the `emit_required` awk function.
393
+ */
394
+ function requiredFromFirstCommand(first) {
395
+ const required = [];
396
+ let gatedHookId = null;
397
+ let k = 0;
398
+ // Skip leading VAR=value environment assignments.
399
+ while (k < first.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(first[k]))
400
+ k += 1;
401
+ if (k >= first.length)
402
+ return { required, gatedHookId };
403
+ if (INTERPRETERS.has(baseName(first[k]))) {
404
+ k += 1;
405
+ while (k < first.length && first[k].startsWith("-")) {
406
+ // -c means the script is inline, not a file we can check.
407
+ if (/^-[A-Za-z]*c/.test(first[k]))
408
+ return { required, gatedHookId };
409
+ k += 1;
410
+ }
411
+ }
412
+ if (k >= first.length)
413
+ return { required, gatedHookId };
414
+ const rp = relpathOf(first[k]);
415
+ if (rp === "")
416
+ return { required, gatedHookId };
417
+ required.push(rp);
418
+ // hook-gate.sh runs the script named in its second argument; its first bare
419
+ // argument is the gated hook id.
420
+ if (baseName(rp) === "hook-gate.sh") {
421
+ for (let j = k + 1; j < first.length; j++) {
422
+ if (gatedHookId === null && !first[j].includes(SENTINEL) && first[j].length > 0) {
423
+ gatedHookId = first[j];
424
+ }
425
+ const rp2 = relpathOf(first[j]);
426
+ if (rp2 !== "") {
427
+ required.push(rp2);
428
+ break;
429
+ }
430
+ }
431
+ }
432
+ return { required, gatedHookId };
433
+ }
434
+ /**
435
+ * The path a token builds from its first $CLAUDE_PROJECT_DIR expansion: the text
436
+ * between the sentinel and either the next sentinel or the token end. Quoting is
437
+ * already collapsed, so a space inside the path survives. Only an absolute
438
+ * expansion (`$CLAUDE_PROJECT_DIR/…`) yields a relpath.
439
+ */
440
+ function relpathOf(token) {
441
+ const p = token.indexOf(SENTINEL);
442
+ if (p < 0)
443
+ return "";
444
+ let q = token.slice(p + 1);
445
+ const r = q.indexOf(SENTINEL);
446
+ if (r >= 0)
447
+ q = q.slice(0, r);
448
+ if (q.charAt(0) !== "/")
449
+ return "";
450
+ return q.slice(1);
451
+ }
452
+ function readJsonFile(file) {
453
+ let raw;
454
+ try {
455
+ raw = fs.readFileSync(file, "utf8");
456
+ }
457
+ catch {
458
+ return { present: false };
459
+ }
460
+ try {
461
+ return { present: true, value: JSON.parse(raw) };
462
+ }
463
+ catch {
464
+ return { present: true, invalid: true };
465
+ }
466
+ }
467
+ function readTextFile(file) {
468
+ try {
469
+ return fs.readFileSync(file, "utf8");
470
+ }
471
+ catch {
472
+ return null;
473
+ }
474
+ }
475
+ function statSafe(file) {
476
+ try {
477
+ return fs.statSync(file);
478
+ }
479
+ catch {
480
+ return null;
481
+ }
482
+ }
483
+ function isExecutable(mode) {
484
+ return (mode & 0o111) !== 0;
485
+ }
486
+ /** Top-level `*.sh` filenames in `dir`, sorted; empty when `dir` is absent. */
487
+ function listShellScripts(dir) {
488
+ return listDir(dir)
489
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".sh"))
490
+ .map((entry) => entry.name)
491
+ .sort();
492
+ }
493
+ /** Immediate subdirectory names of `dir`, sorted; empty when `dir` is absent. */
494
+ function listSubdirs(dir) {
495
+ return listDir(dir)
496
+ .filter((entry) => entry.isDirectory())
497
+ .map((entry) => entry.name)
498
+ .sort();
499
+ }
500
+ function listDir(dir) {
501
+ try {
502
+ return fs.readdirSync(dir, { withFileTypes: true });
503
+ }
504
+ catch {
505
+ return [];
506
+ }
507
+ }
508
+ function baseName(p) {
509
+ const idx = p.lastIndexOf("/");
510
+ return idx >= 0 ? p.slice(idx + 1) : p;
511
+ }
512
+ /** Render a profile list as "minimal", "minimal and standard", or "a, b and c". */
513
+ function formatProfiles(profiles) {
514
+ const ordered = GATE_PROFILES.filter((p) => profiles.includes(p));
515
+ if (ordered.length <= 1)
516
+ return ordered.join("");
517
+ if (ordered.length === 2)
518
+ return `${ordered[0]} and ${ordered[1]}`;
519
+ return `${ordered.slice(0, -1).join(", ")} and ${ordered[ordered.length - 1]}`;
520
+ }
521
+ function byKey(a, b) {
522
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
523
+ }
524
+ //# sourceMappingURL=claude-wiring.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Codex hook wiring and Claude-parity checks (US-005).
3
+ *
4
+ * Claude and Grok both execute the canonical `.claude/hooks/` scripts; only
5
+ * Codex runs duplicated copies under `.codex/hooks/`, so Codex is the entire
6
+ * drift surface the doctor has to police. This check does three things, all
7
+ * read-only and all against the resolved HQ tree (not the live host):
8
+ *
9
+ * 1. Enumerates the registrations in `.codex/hooks.json` and verifies each
10
+ * referenced script exists and is executable — the Codex analogue of the
11
+ * Claude wiring tier.
12
+ * 2. Compares every `.codex/hooks/<file>` against its same-named
13
+ * `.claude/hooks/<file>` original and FAILs on any content divergence that
14
+ * is not on the allowed-divergence list.
15
+ * 3. WARNs on any `.claude/hooks/<file>` that has no Codex counterpart, and
16
+ * escalates that to FAIL when the hook id is in the `hook-gate.sh` minimal
17
+ * profile, because minimal-profile membership marks it safety-critical.
18
+ *
19
+ * These are wiring checks, not live verification: Codex hook *execution* is only
20
+ * exercised when the doctor runs under the Codex host. That distinction is
21
+ * stated plainly in the report via an `NA` scope note, so a green Codex tier is
22
+ * never mistaken for "Codex actually enforced this here".
23
+ */
24
+ import { type AllowedDivergenceLoad } from "../allowed-divergence.js";
25
+ import type { CheckContext, CheckResult } from "../types.js";
26
+ /** Options for {@link checkCodexWiring}; all injectable for hermetic tests. */
27
+ export interface CodexWiringOptions {
28
+ /** Pre-loaded allowed-divergence list. Default: loaded from the tree. */
29
+ allowed?: AllowedDivergenceLoad;
30
+ }
31
+ /**
32
+ * Run the Codex tier of the hooks family against the resolved HQ tree. Never
33
+ * throws for a merely-broken tree — a missing or malformed `.codex/hooks.json`
34
+ * is reported as a result, not an exception.
35
+ */
36
+ export declare function checkCodexWiring(context: CheckContext, options?: CodexWiringOptions): CheckResult[];
37
+ /**
38
+ * Extract the hook ids listed in `is_in_minimal_profile()` from a `hook-gate.sh`
39
+ * source. Scoped to that one function's `{ … }` body so the standard and strict
40
+ * profiles' ids are never mixed in. A focused, self-contained parser: the Codex
41
+ * check only needs the minimal profile, so it does not depend on the broader
42
+ * profile parser built by the Claude wiring tier.
43
+ */
44
+ export declare function parseMinimalProfileIds(gateSource: string): Set<string>;
45
+ //# sourceMappingURL=codex-wiring.d.ts.map