@indigoai-us/hq-cli 5.98.3 → 5.99.1

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 (73) hide show
  1. package/CHANGELOG.md +43 -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-checkpoint.js +11 -3
  8. package/dist/commands/core.js +60 -5
  9. package/dist/commands/doctor.d.ts +97 -0
  10. package/dist/commands/doctor.js +228 -0
  11. package/dist/commands/scaffold-fast.d.ts +41 -0
  12. package/dist/commands/scaffold-fast.js +57 -0
  13. package/dist/fast-core.d.ts +16 -0
  14. package/dist/fast-core.js +47 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +10 -1
  17. package/dist/lib/core-utils/soft-timeout.d.ts +55 -0
  18. package/dist/lib/core-utils/soft-timeout.js +205 -0
  19. package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
  20. package/dist/lib/core-utils/timeout-guard.js +207 -0
  21. package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
  22. package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
  23. package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
  24. package/dist/lib/doctor/allowed-divergence.js +134 -0
  25. package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
  26. package/dist/lib/doctor/checks/claude-wiring.js +524 -0
  27. package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
  28. package/dist/lib/doctor/checks/codex-wiring.js +376 -0
  29. package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
  30. package/dist/lib/doctor/checks/grok-wiring.js +186 -0
  31. package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
  32. package/dist/lib/doctor/checks/runtime-probe.js +335 -0
  33. package/dist/lib/doctor/compat.d.ts +85 -0
  34. package/dist/lib/doctor/compat.js +102 -0
  35. package/dist/lib/doctor/deep/classify.d.ts +61 -0
  36. package/dist/lib/doctor/deep/classify.js +75 -0
  37. package/dist/lib/doctor/deep/effects.d.ts +107 -0
  38. package/dist/lib/doctor/deep/effects.js +229 -0
  39. package/dist/lib/doctor/deep/executor.d.ts +112 -0
  40. package/dist/lib/doctor/deep/executor.js +369 -0
  41. package/dist/lib/doctor/deep/parity.d.ts +129 -0
  42. package/dist/lib/doctor/deep/parity.js +355 -0
  43. package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
  44. package/dist/lib/doctor/deep/sandbox.js +572 -0
  45. package/dist/lib/doctor/fix/apply.d.ts +119 -0
  46. package/dist/lib/doctor/fix/apply.js +352 -0
  47. package/dist/lib/doctor/fix/backup.d.ts +40 -0
  48. package/dist/lib/doctor/fix/backup.js +64 -0
  49. package/dist/lib/doctor/fix/remediation.d.ts +71 -0
  50. package/dist/lib/doctor/fix/remediation.js +103 -0
  51. package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
  52. package/dist/lib/doctor/fixtures/discover.js +287 -0
  53. package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
  54. package/dist/lib/doctor/fixtures/schema.js +248 -0
  55. package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
  56. package/dist/lib/doctor/hook-gate-profiles.js +107 -0
  57. package/dist/lib/doctor/json-output.d.ts +90 -0
  58. package/dist/lib/doctor/json-output.js +76 -0
  59. package/dist/lib/doctor/payload-shapes.d.ts +170 -0
  60. package/dist/lib/doctor/payload-shapes.js +275 -0
  61. package/dist/lib/doctor/platform.d.ts +244 -0
  62. package/dist/lib/doctor/platform.js +490 -0
  63. package/dist/lib/doctor/registry.d.ts +49 -0
  64. package/dist/lib/doctor/registry.js +176 -0
  65. package/dist/lib/doctor/report.d.ts +87 -0
  66. package/dist/lib/doctor/report.js +164 -0
  67. package/dist/lib/doctor/types.d.ts +87 -0
  68. package/dist/lib/doctor/types.js +29 -0
  69. package/dist/main.js +6 -0
  70. package/dist/utils/version-check.js +2 -2
  71. package/dist/utils/version-gate.d.ts +1 -1
  72. package/dist/utils/version-gate.js +1 -1
  73. package/package.json +1 -1
@@ -0,0 +1,357 @@
1
+ /**
2
+ * buildFakeHqTree — a reusable test harness that materialises a fake HQ tree in
3
+ * the OS temp directory from a declarative spec.
4
+ *
5
+ * Every `hq doctor` check is fundamentally a function of the on-disk shape of an
6
+ * HQ tree: which hooks exist, whether they are executable, whether they are
7
+ * registered in settings, which `hook-gate.sh` profiles list them, and whether
8
+ * the Codex mirror still matches its Claude original. This helper lets a test
9
+ * declare that shape — including its deliberately-broken variants — and get back
10
+ * a throwaway tree plus a manifest describing exactly what was written, without
11
+ * ever touching the real HQ tree.
12
+ *
13
+ * Design notes:
14
+ * - Everything is written under a `mkdtempSync` root inside `os.tmpdir()`. The
15
+ * real HQ tree is never read or written.
16
+ * - The root path is canonicalised with `realpathSync` so callers can assert
17
+ * containment under the temp dir on platforms (macOS) where the temp dir is
18
+ * a symlink (`/var` -> `/private/var`).
19
+ * - Cleanup is automatic: the first build registers a single `process` exit
20
+ * handler that removes every tracked root, so nothing survives a test run,
21
+ * including on test failure. `tree.cleanup()` is also exposed for eager
22
+ * removal, and is idempotent.
23
+ *
24
+ * The reference for this pattern is core/scripts/test-codex-hook-adapter.sh,
25
+ * which stands up a throwaway HQ tree with `mktemp -d` and stubbed hook scripts.
26
+ */
27
+ import { spawnSync } from "node:child_process";
28
+ import * as fs from "node:fs";
29
+ import * as os from "node:os";
30
+ import * as path from "node:path";
31
+ /** Canonical ordering of the gate profiles. */
32
+ export const GATE_PROFILES = [
33
+ "minimal",
34
+ "standard",
35
+ "strict",
36
+ ];
37
+ const DEFAULT_HOOK_BODY = "#!/bin/bash\ncat >/dev/null\nexit 0\n";
38
+ const EXECUTABLE_MODE = 0o755;
39
+ const NON_EXECUTABLE_MODE = 0o644;
40
+ const DEFAULT_PREFIX = "hq-doctor-fake-";
41
+ // --- Automatic cleanup registry ------------------------------------------------
42
+ const trackedRoots = new Set();
43
+ let exitHandlerRegistered = false;
44
+ /** Remove every fake HQ tree still on disk. Safe to call repeatedly. */
45
+ export function cleanupAllFakeHqTrees() {
46
+ for (const root of trackedRoots) {
47
+ removeTree(root);
48
+ }
49
+ trackedRoots.clear();
50
+ }
51
+ /** The set of tree roots this process is still tracking for cleanup. */
52
+ export function trackedFakeHqTreeRoots() {
53
+ return [...trackedRoots];
54
+ }
55
+ function removeTree(root) {
56
+ try {
57
+ fs.rmSync(root, { recursive: true, force: true });
58
+ }
59
+ catch {
60
+ // Best-effort: never let cleanup throw and mask a test's real failure.
61
+ }
62
+ }
63
+ /**
64
+ * Register a single process-exit handler that sweeps every tracked root. Bound
65
+ * once (guarded by a module flag) so repeated builds do not pile up listeners.
66
+ * `exit` fires on normal completion and after a failed test run alike, which is
67
+ * what guarantees no temp dir survives.
68
+ */
69
+ function ensureExitHandler() {
70
+ if (exitHandlerRegistered)
71
+ return;
72
+ exitHandlerRegistered = true;
73
+ process.on("exit", cleanupAllFakeHqTrees);
74
+ }
75
+ // --- Builder -------------------------------------------------------------------
76
+ /**
77
+ * Materialise a fake HQ tree from `spec` and return its root, manifest, and a
78
+ * cleanup handle. The tree lives under `os.tmpdir()` and is swept automatically
79
+ * when the process exits.
80
+ */
81
+ export function buildFakeHqTree(spec = {}) {
82
+ ensureExitHandler();
83
+ const prefix = spec.prefix ?? DEFAULT_PREFIX;
84
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
85
+ trackedRoots.add(root);
86
+ const claudeHooksDir = path.join(root, ".claude", "hooks");
87
+ const codexHooksDir = path.join(root, ".codex", "hooks");
88
+ fs.mkdirSync(claudeHooksDir, { recursive: true });
89
+ fs.mkdirSync(codexHooksDir, { recursive: true });
90
+ // A `core/` directory is what marks this as an HQ tree for later root
91
+ // discovery; cheap to add and keeps the fixture realistic.
92
+ fs.mkdirSync(path.join(root, "core", "scripts"), { recursive: true });
93
+ const hookSpecs = spec.hooks ?? [];
94
+ const hookEntries = hookSpecs.map((hook) => writeHook(claudeHooksDir, codexHooksDir, hook));
95
+ // hook-gate.sh (mirrored to both platforms) carries the three profile lists,
96
+ // aggregated across every hook's `profiles`.
97
+ const gateSource = renderHookGate(hookEntries);
98
+ const claudeHookGatePath = path.join(claudeHooksDir, "hook-gate.sh");
99
+ const codexHookGatePath = path.join(codexHooksDir, "hook-gate.sh");
100
+ writeScript(claudeHookGatePath, gateSource, EXECUTABLE_MODE);
101
+ writeScript(codexHookGatePath, gateSource, EXECUTABLE_MODE);
102
+ const claudeSettingsPath = path.join(root, ".claude", "settings.json");
103
+ fs.writeFileSync(claudeSettingsPath, JSON.stringify(renderClaudeSettings(hookEntries, spec.claudeSettings), null, 2) + "\n");
104
+ const codexHooksJsonPath = path.join(root, ".codex", "hooks.json");
105
+ fs.writeFileSync(codexHooksJsonPath, JSON.stringify(renderCodexHooksJson(root, hookEntries), null, 2) + "\n");
106
+ const grok = writeGrok(root, spec.grok);
107
+ let gitInitialised = false;
108
+ if (spec.git) {
109
+ gitInitialised = initGitRepo(root);
110
+ }
111
+ const manifest = {
112
+ root,
113
+ claudeSettingsPath,
114
+ claudeHooksDir,
115
+ claudeHookGatePath,
116
+ codexHooksJsonPath,
117
+ codexHooksDir,
118
+ codexHookGatePath,
119
+ grokDir: grok.dir,
120
+ grokAdapterPath: grok.adapterPath,
121
+ grokRegistrationPath: grok.registrationPath,
122
+ grokBridgeInstalled: grok.bridgeInstalled,
123
+ gitInitialised,
124
+ hooks: hookEntries,
125
+ };
126
+ let cleaned = false;
127
+ const cleanup = () => {
128
+ if (cleaned)
129
+ return;
130
+ cleaned = true;
131
+ removeTree(root);
132
+ trackedRoots.delete(root);
133
+ };
134
+ return {
135
+ root,
136
+ manifest,
137
+ path: (...segments) => path.join(root, ...segments),
138
+ cleanup,
139
+ };
140
+ }
141
+ // --- Hook materialisation ------------------------------------------------------
142
+ function writeHook(claudeHooksDir, codexHooksDir, hook) {
143
+ const claudeBody = hook.body ?? DEFAULT_HOOK_BODY;
144
+ const present = hook.present !== false;
145
+ const registered = hook.registered !== false;
146
+ const events = hook.events ?? ["PreToolUse"];
147
+ const profiles = hook.profiles ?? [...GATE_PROFILES];
148
+ const scriptPath = path.join(claudeHooksDir, `${hook.id}.sh`);
149
+ let mode = null;
150
+ let executable = false;
151
+ if (present) {
152
+ const targetMode = resolveMode(hook.mode, hook.executable);
153
+ writeScript(scriptPath, claudeBody, targetMode);
154
+ mode = statMode(scriptPath);
155
+ executable = isExecutable(mode);
156
+ }
157
+ const codex = writeCodexMirror(codexHooksDir, hook, claudeBody);
158
+ return {
159
+ id: hook.id,
160
+ scriptPath,
161
+ present,
162
+ mode,
163
+ executable,
164
+ registered,
165
+ events,
166
+ matcher: hook.matcher ?? null,
167
+ profiles,
168
+ codex,
169
+ };
170
+ }
171
+ function writeCodexMirror(codexHooksDir, hook, claudeBody) {
172
+ // `codex: false` => a Claude hook with no Codex counterpart.
173
+ if (hook.codex === false)
174
+ return null;
175
+ const codexSpec = hook.codex ?? {};
176
+ const present = codexSpec.present !== false;
177
+ const registered = codexSpec.registered !== false;
178
+ const body = codexSpec.body ?? claudeBody;
179
+ const drifted = body !== claudeBody;
180
+ const scriptPath = path.join(codexHooksDir, `${hook.id}.sh`);
181
+ let mode = null;
182
+ let executable = false;
183
+ if (present) {
184
+ const targetMode = resolveMode(codexSpec.mode, codexSpec.executable);
185
+ writeScript(scriptPath, body, targetMode);
186
+ mode = statMode(scriptPath);
187
+ executable = isExecutable(mode);
188
+ }
189
+ return { scriptPath, present, mode, executable, registered, drifted };
190
+ }
191
+ function renderClaudeSettings(hooks, extra) {
192
+ const events = {};
193
+ for (const hook of hooks) {
194
+ if (!hook.registered)
195
+ continue;
196
+ const command = `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/hook-gate.sh" ${hook.id} ` +
197
+ `"$CLAUDE_PROJECT_DIR/.claude/hooks/${hook.id}.sh"`;
198
+ for (const event of hook.events) {
199
+ (events[event] ??= []).push(makeSettingsEntry(command, hook.matcher ?? undefined));
200
+ }
201
+ }
202
+ return { ...extra, hooks: events };
203
+ }
204
+ function renderCodexHooksJson(root, hooks) {
205
+ const events = {};
206
+ for (const hook of hooks) {
207
+ if (!hook.codex || !hook.codex.registered)
208
+ continue;
209
+ const gate = `${root}/.codex/hooks/hook-gate.sh`;
210
+ const script = `${root}/.codex/hooks/${hook.id}.sh`;
211
+ const command = `'${gate}' ${hook.id} '${script}'`;
212
+ for (const event of hook.events) {
213
+ (events[event] ??= []).push(makeSettingsEntry(command, hook.matcher ?? undefined));
214
+ }
215
+ }
216
+ return { hooks: events };
217
+ }
218
+ function makeSettingsEntry(command, matcher) {
219
+ const entry = {
220
+ hooks: [{ type: "command", command, timeout: 5 }],
221
+ };
222
+ if (matcher)
223
+ entry.matcher = matcher;
224
+ return entry;
225
+ }
226
+ /**
227
+ * Render a self-contained `hook-gate.sh` whose three profile functions list the
228
+ * hook ids assigned to each profile, modelled on the real gate in
229
+ * `.claude/hooks/hook-gate.sh`. Self-contained so the fixture never has to
230
+ * source the real HQ tree.
231
+ */
232
+ function renderHookGate(hooks) {
233
+ const byProfile = {
234
+ minimal: [],
235
+ standard: [],
236
+ strict: [],
237
+ };
238
+ for (const hook of hooks) {
239
+ for (const profile of hook.profiles) {
240
+ byProfile[profile].push(hook.id);
241
+ }
242
+ }
243
+ const fns = GATE_PROFILES.map((profile) => renderProfileFunction(profile, byProfile[profile])).join("\n\n");
244
+ return `#!/bin/bash
245
+ # Fake hook-gate.sh generated by buildFakeHqTree (test fixture — not the real gate).
246
+ set -euo pipefail
247
+
248
+ if [ $# -lt 2 ]; then
249
+ echo "USAGE: hook-gate.sh <hook-id> <actual-hook-script> [args...]" >&2
250
+ exit 1
251
+ fi
252
+
253
+ HOOK_ID="$1"
254
+ HOOK_SCRIPT="$2"
255
+ shift 2
256
+
257
+ PROFILE="\${HQ_HOOK_PROFILE:-standard}"
258
+
259
+ ${fns}
260
+
261
+ should_run=0
262
+ case "$PROFILE" in
263
+ minimal)
264
+ if is_in_minimal_profile "$HOOK_ID"; then should_run=1; fi
265
+ ;;
266
+ standard)
267
+ if is_in_standard_profile "$HOOK_ID"; then should_run=1; fi
268
+ ;;
269
+ strict)
270
+ if is_in_strict_profile "$HOOK_ID"; then should_run=1; fi
271
+ ;;
272
+ *)
273
+ echo "ERROR: Unknown profile '$PROFILE'. Use minimal|standard|strict" >&2
274
+ exit 1
275
+ ;;
276
+ esac
277
+
278
+ if [ "$should_run" -eq 0 ]; then
279
+ exit 0
280
+ fi
281
+
282
+ exec "$HOOK_SCRIPT" "$@"
283
+ `;
284
+ }
285
+ function renderProfileFunction(profile, ids) {
286
+ const caseBlock = ids.length > 0 ? ` ${ids.join("|")})\n return 0\n ;;\n` : "";
287
+ return `is_in_${profile}_profile() {
288
+ case "$1" in
289
+ ${caseBlock} *)
290
+ return 1
291
+ ;;
292
+ esac
293
+ }`;
294
+ }
295
+ function writeGrok(root, spec) {
296
+ const absent = {
297
+ dir: null,
298
+ adapterPath: null,
299
+ registrationPath: null,
300
+ bridgeInstalled: false,
301
+ };
302
+ if (spec === false)
303
+ return absent;
304
+ const grokSpec = spec ?? {};
305
+ if (grokSpec.present === false)
306
+ return absent;
307
+ const dir = path.join(root, ".grok", "hooks");
308
+ fs.mkdirSync(dir, { recursive: true });
309
+ const adapterPath = path.join(dir, "hq-grok-hook-adapter.sh");
310
+ writeScript(adapterPath, "#!/bin/bash\ncat >/dev/null\nexit 0\n", grokSpec.adapterExecutable === false ? NON_EXECUTABLE_MODE : EXECUTABLE_MODE);
311
+ const registrationPath = path.join(dir, "hq-grok.json");
312
+ const registration = {
313
+ hooks: {
314
+ PreToolUse: [
315
+ {
316
+ hooks: [
317
+ { type: "command", command: "./hq-grok-hook-adapter.sh", timeout: 30 },
318
+ ],
319
+ },
320
+ ],
321
+ },
322
+ };
323
+ fs.writeFileSync(registrationPath, JSON.stringify(registration, null, 2) + "\n");
324
+ return {
325
+ dir,
326
+ adapterPath,
327
+ registrationPath,
328
+ bridgeInstalled: grokSpec.bridgeInstalled !== false,
329
+ };
330
+ }
331
+ // --- git -----------------------------------------------------------------------
332
+ function initGitRepo(root) {
333
+ // Seed a git repo the way the reference harness does, without shelling out
334
+ // for identity config: write it straight into .git/config.
335
+ const res = spawnSync("git", ["init", "-q", "-b", "main"], { cwd: root });
336
+ if (res.status !== 0)
337
+ return false;
338
+ fs.appendFileSync(path.join(root, ".git", "config"), "[user]\n\temail = fake-hq-tree@example.com\n\tname = fake-hq-tree\n[commit]\n\tgpgsign = false\n");
339
+ return true;
340
+ }
341
+ // --- Low-level helpers ---------------------------------------------------------
342
+ function resolveMode(explicit, executable) {
343
+ if (explicit !== undefined)
344
+ return explicit & 0o777;
345
+ return executable === false ? NON_EXECUTABLE_MODE : EXECUTABLE_MODE;
346
+ }
347
+ function writeScript(target, body, mode) {
348
+ fs.writeFileSync(target, body);
349
+ fs.chmodSync(target, mode);
350
+ }
351
+ function statMode(target) {
352
+ return fs.statSync(target).mode & 0o777;
353
+ }
354
+ function isExecutable(mode) {
355
+ return mode !== null && (mode & 0o111) !== 0;
356
+ }
357
+ //# sourceMappingURL=fake-hq-tree.js.map
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The allowed-divergence list — the doctor's suppression list for known,
3
+ * intentional differences between a Codex mirror and its Claude original.
4
+ *
5
+ * Claude and Grok both execute the canonical `.claude/hooks/` scripts; only
6
+ * Codex runs duplicated copies, so the Codex mirror is the entire drift surface
7
+ * (US-005). Most drift is a bug the doctor must FAIL on — but a handful of
8
+ * mirrors legitimately differ (a Codex payload shape, an adapter shim). Those
9
+ * are declared here so the doctor can suppress the FAIL, *provided every entry
10
+ * carries a human reason*.
11
+ *
12
+ * The list is discovered at runtime from the HQ tree at
13
+ * `core/hook-tests/allowed-divergence.yaml`, not baked into the CLI, so a hook
14
+ * and its allowed-divergence note ship in the same hq-core commit. The file is
15
+ * optional: an absent list simply allows no divergences. Two guard rails keep
16
+ * the list honest and are enforced by the Codex wiring check that consumes this
17
+ * module:
18
+ * - an entry with no `reason` is rejected (reported WARN), and
19
+ * - an entry whose file no longer differs is reported WARN as *stale*, so the
20
+ * list cannot silently rot into suppressing drift that has since changed.
21
+ */
22
+ /** Location of the allowed-divergence list, relative to the HQ tree root. */
23
+ export declare const ALLOWED_DIVERGENCE_RELPATH: string;
24
+ /** One valid allowed-divergence entry. */
25
+ export interface AllowedDivergenceEntry {
26
+ /** Hook filename this entry suppresses, normalised to a `<id>.sh` basename. */
27
+ file: string;
28
+ /** Required, non-empty human reason the divergence is intentional. */
29
+ reason: string;
30
+ }
31
+ /** A rejected entry — bad shape, missing file, or missing reason. */
32
+ export interface AllowedDivergenceProblem {
33
+ /** The offending file basename, when one could be identified. */
34
+ file: string | null;
35
+ /** Why the entry was rejected. */
36
+ message: string;
37
+ }
38
+ /** The parsed allowed-divergence list plus every entry it rejected. */
39
+ export interface AllowedDivergenceLoad {
40
+ /** Absolute path the list was read from (whether or not it exists). */
41
+ path: string;
42
+ /** Whether the YAML file exists on disk. */
43
+ present: boolean;
44
+ /** Valid entries; each carries a non-empty `reason`. */
45
+ entries: AllowedDivergenceEntry[];
46
+ /** Malformed entries — missing reason, missing file, or a bad shape. */
47
+ problems: AllowedDivergenceProblem[];
48
+ }
49
+ /** Absolute path to the allowed-divergence list for `hqRoot`. */
50
+ export declare function allowedDivergencePath(hqRoot: string): string;
51
+ /**
52
+ * Normalise a hook reference to a `<id>.sh` basename so entries written as
53
+ * `detect-secrets`, `detect-secrets.sh`, or `.codex/hooks/detect-secrets.sh`
54
+ * all match the same on-disk file.
55
+ */
56
+ export declare function normaliseHookFile(file: string): string;
57
+ /**
58
+ * Read and parse the allowed-divergence list for `hqRoot`. An absent file is
59
+ * not an error — it yields `present: false` with no entries and no problems,
60
+ * because a tree that has declared no divergences is a valid, healthy state.
61
+ */
62
+ export declare function loadAllowedDivergence(hqRoot: string): AllowedDivergenceLoad;
63
+ /**
64
+ * Parse allowed-divergence YAML into valid entries and rejected problems.
65
+ * Accepts either a top-level list of `{file, reason}` mappings or an object
66
+ * carrying that list under `divergences` / `allowed` / `entries`.
67
+ */
68
+ export declare function parseAllowedDivergence(source: string): {
69
+ entries: AllowedDivergenceEntry[];
70
+ problems: AllowedDivergenceProblem[];
71
+ };
72
+ //# sourceMappingURL=allowed-divergence.d.ts.map
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The allowed-divergence list — the doctor's suppression list for known,
3
+ * intentional differences between a Codex mirror and its Claude original.
4
+ *
5
+ * Claude and Grok both execute the canonical `.claude/hooks/` scripts; only
6
+ * Codex runs duplicated copies, so the Codex mirror is the entire drift surface
7
+ * (US-005). Most drift is a bug the doctor must FAIL on — but a handful of
8
+ * mirrors legitimately differ (a Codex payload shape, an adapter shim). Those
9
+ * are declared here so the doctor can suppress the FAIL, *provided every entry
10
+ * carries a human reason*.
11
+ *
12
+ * The list is discovered at runtime from the HQ tree at
13
+ * `core/hook-tests/allowed-divergence.yaml`, not baked into the CLI, so a hook
14
+ * and its allowed-divergence note ship in the same hq-core commit. The file is
15
+ * optional: an absent list simply allows no divergences. Two guard rails keep
16
+ * the list honest and are enforced by the Codex wiring check that consumes this
17
+ * module:
18
+ * - an entry with no `reason` is rejected (reported WARN), and
19
+ * - an entry whose file no longer differs is reported WARN as *stale*, so the
20
+ * list cannot silently rot into suppressing drift that has since changed.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import * as yaml from "js-yaml";
25
+ /** Location of the allowed-divergence list, relative to the HQ tree root. */
26
+ export const ALLOWED_DIVERGENCE_RELPATH = path.join("core", "hook-tests", "allowed-divergence.yaml");
27
+ /** Absolute path to the allowed-divergence list for `hqRoot`. */
28
+ export function allowedDivergencePath(hqRoot) {
29
+ return path.join(hqRoot, ALLOWED_DIVERGENCE_RELPATH);
30
+ }
31
+ /**
32
+ * Normalise a hook reference to a `<id>.sh` basename so entries written as
33
+ * `detect-secrets`, `detect-secrets.sh`, or `.codex/hooks/detect-secrets.sh`
34
+ * all match the same on-disk file.
35
+ */
36
+ export function normaliseHookFile(file) {
37
+ const base = path.basename(String(file).trim());
38
+ return base.endsWith(".sh") ? base : `${base}.sh`;
39
+ }
40
+ /**
41
+ * Read and parse the allowed-divergence list for `hqRoot`. An absent file is
42
+ * not an error — it yields `present: false` with no entries and no problems,
43
+ * because a tree that has declared no divergences is a valid, healthy state.
44
+ */
45
+ export function loadAllowedDivergence(hqRoot) {
46
+ const filePath = allowedDivergencePath(hqRoot);
47
+ let source;
48
+ try {
49
+ source = fs.readFileSync(filePath, "utf8");
50
+ }
51
+ catch {
52
+ return { path: filePath, present: false, entries: [], problems: [] };
53
+ }
54
+ const parsed = parseAllowedDivergence(source);
55
+ return { path: filePath, present: true, ...parsed };
56
+ }
57
+ /**
58
+ * Parse allowed-divergence YAML into valid entries and rejected problems.
59
+ * Accepts either a top-level list of `{file, reason}` mappings or an object
60
+ * carrying that list under `divergences` / `allowed` / `entries`.
61
+ */
62
+ export function parseAllowedDivergence(source) {
63
+ const entries = [];
64
+ const problems = [];
65
+ let doc;
66
+ try {
67
+ doc = yaml.load(source);
68
+ }
69
+ catch (error) {
70
+ problems.push({
71
+ file: null,
72
+ message: `allowed-divergence.yaml is not valid YAML: ${error.message}`,
73
+ });
74
+ return { entries, problems };
75
+ }
76
+ const rawList = extractList(doc);
77
+ if (rawList === null) {
78
+ // An empty document (null / undefined) legitimately allows no divergences.
79
+ // Any other non-list shape is a mistake worth surfacing.
80
+ if (doc !== null && doc !== undefined) {
81
+ problems.push({
82
+ file: null,
83
+ message: "allowed-divergence.yaml must be a list of {file, reason} entries, or an object with a `divergences` list.",
84
+ });
85
+ }
86
+ return { entries, problems };
87
+ }
88
+ for (const item of rawList) {
89
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
90
+ problems.push({
91
+ file: null,
92
+ message: "allowed-divergence entry must be a mapping with `file` and `reason`.",
93
+ });
94
+ continue;
95
+ }
96
+ const record = item;
97
+ const rawFile = record.file;
98
+ const file = typeof rawFile === "string" && rawFile.trim()
99
+ ? normaliseHookFile(rawFile)
100
+ : null;
101
+ if (!file) {
102
+ problems.push({
103
+ file: null,
104
+ message: "allowed-divergence entry is missing a `file`.",
105
+ });
106
+ continue;
107
+ }
108
+ const rawReason = record.reason;
109
+ if (typeof rawReason !== "string" || !rawReason.trim()) {
110
+ problems.push({
111
+ file,
112
+ message: `allowed-divergence entry for ${file} is missing a required \`reason\`.`,
113
+ });
114
+ continue;
115
+ }
116
+ entries.push({ file, reason: rawReason.trim() });
117
+ }
118
+ return { entries, problems };
119
+ }
120
+ /** Pull the entry list out of whichever supported top-level shape was used. */
121
+ function extractList(doc) {
122
+ if (Array.isArray(doc))
123
+ return doc;
124
+ if (doc && typeof doc === "object") {
125
+ const record = doc;
126
+ for (const key of ["divergences", "allowed", "allowedDivergences", "entries"]) {
127
+ const value = record[key];
128
+ if (Array.isArray(value))
129
+ return value;
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+ //# sourceMappingURL=allowed-divergence.js.map
@@ -0,0 +1,55 @@
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 type { CheckContext, CheckResult } from "../types.js";
25
+ /** Common id prefix for every result this check family emits. */
26
+ export declare const CLAUDE_WIRING_PREFIX = "hooks.claude";
27
+ /**
28
+ * Run every Claude wiring check against the resolved HQ tree and return the
29
+ * per-item results. Never throws on a malformed or partial tree — a missing or
30
+ * unreadable input contributes a result (or nothing), not an exception.
31
+ */
32
+ export declare function checkClaudeWiring(context: CheckContext): CheckResult[];
33
+ /** The result of quote-aware analysis of one hook command string. */
34
+ export interface CommandScan {
35
+ /**
36
+ * True when $CLAUDE_PROJECT_DIR expands OUTSIDE double quotes anywhere in the
37
+ * command — the one form `/bin/sh` word-splits.
38
+ */
39
+ unquotedProjectDir: boolean;
40
+ /**
41
+ * Relative paths (to the HQ root) the command actually EXECUTES: the
42
+ * interpreter's script argument, plus the delegated script when that
43
+ * entrypoint is hook-gate.sh. Guarded optional paths and data arguments are
44
+ * deliberately excluded — a missing optional file is not a broken install.
45
+ */
46
+ requiredRelpaths: string[];
47
+ /**
48
+ * The gated hook id, when the command routes through hook-gate.sh (its first
49
+ * bare argument), else null.
50
+ */
51
+ gatedHookId: string | null;
52
+ }
53
+ /** Quote-aware analysis of a single hook command. See {@link CommandScan}. */
54
+ export declare function scanHookCommand(command: string): CommandScan;
55
+ //# sourceMappingURL=claude-wiring.d.ts.map