@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.
- package/CHANGELOG.md +43 -0
- package/assets/scaffold/core/scripts/checkpoint-stop-gate.sh +347 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +557 -0
- package/assets/scaffold/core/scripts/hq-session.sh +251 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +96 -0
- package/assets/scaffold/core/scripts/lib/session-scope-capability.sh +52 -0
- package/dist/commands/core-checkpoint.js +11 -3
- package/dist/commands/core.js +60 -5
- package/dist/commands/doctor.d.ts +97 -0
- package/dist/commands/doctor.js +228 -0
- package/dist/commands/scaffold-fast.d.ts +41 -0
- package/dist/commands/scaffold-fast.js +57 -0
- package/dist/fast-core.d.ts +16 -0
- package/dist/fast-core.js +47 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -1
- package/dist/lib/core-utils/soft-timeout.d.ts +55 -0
- package/dist/lib/core-utils/soft-timeout.js +205 -0
- package/dist/lib/core-utils/timeout-guard.d.ts +62 -0
- package/dist/lib/core-utils/timeout-guard.js +207 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +194 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +357 -0
- package/dist/lib/doctor/allowed-divergence.d.ts +72 -0
- package/dist/lib/doctor/allowed-divergence.js +134 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +55 -0
- package/dist/lib/doctor/checks/claude-wiring.js +524 -0
- package/dist/lib/doctor/checks/codex-wiring.d.ts +45 -0
- package/dist/lib/doctor/checks/codex-wiring.js +376 -0
- package/dist/lib/doctor/checks/grok-wiring.d.ts +35 -0
- package/dist/lib/doctor/checks/grok-wiring.js +186 -0
- package/dist/lib/doctor/checks/runtime-probe.d.ts +101 -0
- package/dist/lib/doctor/checks/runtime-probe.js +335 -0
- package/dist/lib/doctor/compat.d.ts +85 -0
- package/dist/lib/doctor/compat.js +102 -0
- package/dist/lib/doctor/deep/classify.d.ts +61 -0
- package/dist/lib/doctor/deep/classify.js +75 -0
- package/dist/lib/doctor/deep/effects.d.ts +107 -0
- package/dist/lib/doctor/deep/effects.js +229 -0
- package/dist/lib/doctor/deep/executor.d.ts +112 -0
- package/dist/lib/doctor/deep/executor.js +369 -0
- package/dist/lib/doctor/deep/parity.d.ts +129 -0
- package/dist/lib/doctor/deep/parity.js +355 -0
- package/dist/lib/doctor/deep/sandbox.d.ts +190 -0
- package/dist/lib/doctor/deep/sandbox.js +572 -0
- package/dist/lib/doctor/fix/apply.d.ts +119 -0
- package/dist/lib/doctor/fix/apply.js +352 -0
- package/dist/lib/doctor/fix/backup.d.ts +40 -0
- package/dist/lib/doctor/fix/backup.js +64 -0
- package/dist/lib/doctor/fix/remediation.d.ts +71 -0
- package/dist/lib/doctor/fix/remediation.js +103 -0
- package/dist/lib/doctor/fixtures/discover.d.ts +96 -0
- package/dist/lib/doctor/fixtures/discover.js +287 -0
- package/dist/lib/doctor/fixtures/schema.d.ts +171 -0
- package/dist/lib/doctor/fixtures/schema.js +248 -0
- package/dist/lib/doctor/hook-gate-profiles.d.ts +55 -0
- package/dist/lib/doctor/hook-gate-profiles.js +107 -0
- package/dist/lib/doctor/json-output.d.ts +90 -0
- package/dist/lib/doctor/json-output.js +76 -0
- package/dist/lib/doctor/payload-shapes.d.ts +170 -0
- package/dist/lib/doctor/payload-shapes.js +275 -0
- package/dist/lib/doctor/platform.d.ts +244 -0
- package/dist/lib/doctor/platform.js +490 -0
- package/dist/lib/doctor/registry.d.ts +49 -0
- package/dist/lib/doctor/registry.js +176 -0
- package/dist/lib/doctor/report.d.ts +87 -0
- package/dist/lib/doctor/report.js +164 -0
- package/dist/lib/doctor/types.d.ts +87 -0
- package/dist/lib/doctor/types.js +29 -0
- package/dist/main.js +6 -0
- package/dist/utils/version-check.js +2 -2
- package/dist/utils/version-gate.d.ts +1 -1
- package/dist/utils/version-gate.js +1 -1
- 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
|