@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,376 @@
|
|
|
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 * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { ALLOWED_DIVERGENCE_RELPATH, loadAllowedDivergence, } from "../allowed-divergence.js";
|
|
27
|
+
/**
|
|
28
|
+
* Run the Codex tier of the hooks family against the resolved HQ tree. Never
|
|
29
|
+
* throws for a merely-broken tree — a missing or malformed `.codex/hooks.json`
|
|
30
|
+
* is reported as a result, not an exception.
|
|
31
|
+
*/
|
|
32
|
+
export function checkCodexWiring(context, options = {}) {
|
|
33
|
+
const hqRoot = context.hqRoot;
|
|
34
|
+
const allowed = options.allowed ?? loadAllowedDivergence(hqRoot);
|
|
35
|
+
const results = [];
|
|
36
|
+
results.push({
|
|
37
|
+
status: "NA",
|
|
38
|
+
checkId: "hooks.codex.scope",
|
|
39
|
+
target: "codex",
|
|
40
|
+
message: "Codex hook checks are wiring only (registration, executable bit, and Claude parity); live Codex hook execution is verified only when the doctor runs under the Codex host.",
|
|
41
|
+
});
|
|
42
|
+
results.push(...checkRegistrations(hqRoot));
|
|
43
|
+
results.push(...checkParity(hqRoot, allowed));
|
|
44
|
+
for (const problem of allowed.problems) {
|
|
45
|
+
results.push({
|
|
46
|
+
status: "WARN",
|
|
47
|
+
checkId: "hooks.codex.allowed-divergence-invalid",
|
|
48
|
+
target: problem.file ?? ALLOWED_DIVERGENCE_RELPATH,
|
|
49
|
+
message: problem.message,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
// --- 1. Registration checks ----------------------------------------------------
|
|
55
|
+
function checkRegistrations(hqRoot) {
|
|
56
|
+
const results = [];
|
|
57
|
+
const hooksJsonPath = path.join(hqRoot, ".codex", "hooks.json");
|
|
58
|
+
let raw;
|
|
59
|
+
try {
|
|
60
|
+
raw = fs.readFileSync(hooksJsonPath, "utf8");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
results.push({
|
|
64
|
+
status: "FAIL",
|
|
65
|
+
checkId: "hooks.codex.registrations-present",
|
|
66
|
+
target: hooksJsonPath,
|
|
67
|
+
message: ".codex/hooks.json is missing — no Codex hooks are wired.",
|
|
68
|
+
});
|
|
69
|
+
return results;
|
|
70
|
+
}
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(raw);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
results.push({
|
|
77
|
+
status: "FAIL",
|
|
78
|
+
checkId: "hooks.codex.registrations-valid",
|
|
79
|
+
target: hooksJsonPath,
|
|
80
|
+
message: ".codex/hooks.json is present but is not valid JSON.",
|
|
81
|
+
});
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
const registrations = collectRegistrations(parsed);
|
|
85
|
+
if (registrations.length === 0) {
|
|
86
|
+
results.push({
|
|
87
|
+
status: "WARN",
|
|
88
|
+
checkId: "hooks.codex.registrations-present",
|
|
89
|
+
target: hooksJsonPath,
|
|
90
|
+
message: ".codex/hooks.json declares no hook registrations.",
|
|
91
|
+
});
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
const gatesChecked = new Set();
|
|
95
|
+
for (const registration of registrations) {
|
|
96
|
+
results.push(...checkRegistration(hqRoot, registration, gatesChecked));
|
|
97
|
+
}
|
|
98
|
+
return results;
|
|
99
|
+
}
|
|
100
|
+
function checkRegistration(hqRoot, registration, gatesChecked) {
|
|
101
|
+
const results = [];
|
|
102
|
+
const scriptPath = resolvePath(hqRoot, registration.script);
|
|
103
|
+
const id = registration.id ??
|
|
104
|
+
(scriptPath ? path.basename(scriptPath, ".sh") : "unknown");
|
|
105
|
+
if (!scriptPath) {
|
|
106
|
+
results.push({
|
|
107
|
+
status: "FAIL",
|
|
108
|
+
checkId: "hooks.codex.registration",
|
|
109
|
+
target: id,
|
|
110
|
+
message: `Codex registration for ${id} does not reference a hook script path.`,
|
|
111
|
+
});
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
const state = fileState(scriptPath);
|
|
115
|
+
if (!state.present) {
|
|
116
|
+
results.push({
|
|
117
|
+
status: "FAIL",
|
|
118
|
+
checkId: "hooks.codex.registration",
|
|
119
|
+
target: id,
|
|
120
|
+
message: `Registered Codex hook script is missing: ${scriptPath}`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
else if (!state.executable) {
|
|
124
|
+
results.push({
|
|
125
|
+
status: "FAIL",
|
|
126
|
+
checkId: "hooks.codex.registration",
|
|
127
|
+
target: id,
|
|
128
|
+
message: `Registered Codex hook script is present but not executable: ${scriptPath}`,
|
|
129
|
+
remediation: `chmod +x ${scriptPath}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
results.push({
|
|
134
|
+
status: "PASS",
|
|
135
|
+
checkId: "hooks.codex.registration",
|
|
136
|
+
target: id,
|
|
137
|
+
message: `Codex hook ${id} is registered, present, and executable.`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// The gate is referenced by every registration; check each distinct gate once.
|
|
141
|
+
const gatePath = resolvePath(hqRoot, registration.gate);
|
|
142
|
+
if (gatePath && !gatesChecked.has(gatePath)) {
|
|
143
|
+
gatesChecked.add(gatePath);
|
|
144
|
+
const gateState = fileState(gatePath);
|
|
145
|
+
if (!gateState.present) {
|
|
146
|
+
results.push({
|
|
147
|
+
status: "FAIL",
|
|
148
|
+
checkId: "hooks.codex.gate",
|
|
149
|
+
target: gatePath,
|
|
150
|
+
message: `Codex hook-gate is missing: ${gatePath}`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (!gateState.executable) {
|
|
154
|
+
results.push({
|
|
155
|
+
status: "FAIL",
|
|
156
|
+
checkId: "hooks.codex.gate",
|
|
157
|
+
target: gatePath,
|
|
158
|
+
message: `Codex hook-gate is present but not executable: ${gatePath}`,
|
|
159
|
+
remediation: `chmod +x ${gatePath}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
results.push({
|
|
164
|
+
status: "PASS",
|
|
165
|
+
checkId: "hooks.codex.gate",
|
|
166
|
+
target: gatePath,
|
|
167
|
+
message: "Codex hook-gate is present and executable.",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return results;
|
|
172
|
+
}
|
|
173
|
+
/** Flatten `{hooks:{Event:[{hooks:[{command}]}]}}` into parsed registrations. */
|
|
174
|
+
function collectRegistrations(parsed) {
|
|
175
|
+
const registrations = [];
|
|
176
|
+
for (const command of collectCommands(parsed)) {
|
|
177
|
+
registrations.push(parseCodexCommand(command));
|
|
178
|
+
}
|
|
179
|
+
return registrations;
|
|
180
|
+
}
|
|
181
|
+
/** Parse a Codex gate command: `'<gate>' <hook-id> '<script>'`. */
|
|
182
|
+
function parseCodexCommand(command) {
|
|
183
|
+
const canonical = command.match(/'([^']*)'\s+(\S+)\s+'([^']*)'/);
|
|
184
|
+
if (canonical) {
|
|
185
|
+
return { gate: canonical[1], id: canonical[2], script: canonical[3] };
|
|
186
|
+
}
|
|
187
|
+
const quoted = [...command.matchAll(/'([^']*)'/g)].map((m) => m[1]);
|
|
188
|
+
return {
|
|
189
|
+
gate: quoted[0],
|
|
190
|
+
script: quoted.length >= 2 ? quoted[quoted.length - 1] : undefined,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
// --- 2 & 3. Parity, missing counterparts, stale allowed entries ---------------
|
|
194
|
+
function checkParity(hqRoot, allowed) {
|
|
195
|
+
const results = [];
|
|
196
|
+
const claudeDir = path.join(hqRoot, ".claude", "hooks");
|
|
197
|
+
const codexDir = path.join(hqRoot, ".codex", "hooks");
|
|
198
|
+
const claudeFiles = listShellScripts(claudeDir);
|
|
199
|
+
const codexFiles = listShellScripts(codexDir);
|
|
200
|
+
const claudeSet = new Set(claudeFiles);
|
|
201
|
+
const codexSet = new Set(codexFiles);
|
|
202
|
+
const allowedSet = new Set(allowed.entries.map((entry) => entry.file));
|
|
203
|
+
const minimalIds = readMinimalProfileIds(hqRoot);
|
|
204
|
+
const driftingFiles = new Set();
|
|
205
|
+
// Content parity for every Codex mirror with a Claude counterpart.
|
|
206
|
+
for (const file of codexFiles) {
|
|
207
|
+
if (!claudeSet.has(file))
|
|
208
|
+
continue; // Codex-only script — not a mirror.
|
|
209
|
+
if (filesEqual(path.join(codexDir, file), path.join(claudeDir, file))) {
|
|
210
|
+
continue; // Byte-identical — healthy.
|
|
211
|
+
}
|
|
212
|
+
driftingFiles.add(file);
|
|
213
|
+
if (allowedSet.has(file))
|
|
214
|
+
continue; // Intentional divergence — suppressed.
|
|
215
|
+
results.push({
|
|
216
|
+
status: "FAIL",
|
|
217
|
+
checkId: "hooks.codex.parity",
|
|
218
|
+
target: file,
|
|
219
|
+
message: `Codex mirror .codex/hooks/${file} differs from its Claude original and is not on the allowed-divergence list.`,
|
|
220
|
+
remediation: `Reconcile the two copies, or add {file: ${file}, reason: ...} to ${ALLOWED_DIVERGENCE_RELPATH}.`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
// Claude hooks with no Codex counterpart: WARN, or FAIL when safety-critical.
|
|
224
|
+
for (const file of claudeFiles) {
|
|
225
|
+
if (codexSet.has(file))
|
|
226
|
+
continue;
|
|
227
|
+
const id = file.replace(/\.sh$/, "");
|
|
228
|
+
if (minimalIds.has(id)) {
|
|
229
|
+
results.push({
|
|
230
|
+
status: "FAIL",
|
|
231
|
+
checkId: "hooks.codex.missing-counterpart",
|
|
232
|
+
target: file,
|
|
233
|
+
message: `Safety-critical Claude hook ${file} has no Codex counterpart — it is in the hook-gate.sh minimal profile, so Codex is missing an enforced guard.`,
|
|
234
|
+
remediation: `Mirror .claude/hooks/${file} into .codex/hooks/${file}.`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
results.push({
|
|
239
|
+
status: "WARN",
|
|
240
|
+
checkId: "hooks.codex.missing-counterpart",
|
|
241
|
+
target: file,
|
|
242
|
+
message: `Claude hook ${file} has no Codex counterpart; Codex will not run it.`,
|
|
243
|
+
remediation: `Mirror .claude/hooks/${file} into .codex/hooks/${file} if Codex should enforce it.`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// Stale allowed-divergence entries: the list must not rot into suppressing
|
|
248
|
+
// drift that no longer exists.
|
|
249
|
+
for (const entry of allowed.entries) {
|
|
250
|
+
if (driftingFiles.has(entry.file))
|
|
251
|
+
continue;
|
|
252
|
+
results.push({
|
|
253
|
+
status: "WARN",
|
|
254
|
+
checkId: "hooks.codex.allowed-divergence-stale",
|
|
255
|
+
target: entry.file,
|
|
256
|
+
message: `allowed-divergence entry for ${entry.file} is stale — the Codex and Claude copies no longer differ. Remove it so the list cannot rot.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return results;
|
|
260
|
+
}
|
|
261
|
+
// --- minimal-profile parsing ---------------------------------------------------
|
|
262
|
+
function readMinimalProfileIds(hqRoot) {
|
|
263
|
+
const gatePath = path.join(hqRoot, ".claude", "hooks", "hook-gate.sh");
|
|
264
|
+
let source;
|
|
265
|
+
try {
|
|
266
|
+
source = fs.readFileSync(gatePath, "utf8");
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return new Set();
|
|
270
|
+
}
|
|
271
|
+
return parseMinimalProfileIds(source);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Extract the hook ids listed in `is_in_minimal_profile()` from a `hook-gate.sh`
|
|
275
|
+
* source. Scoped to that one function's `{ … }` body so the standard and strict
|
|
276
|
+
* profiles' ids are never mixed in. A focused, self-contained parser: the Codex
|
|
277
|
+
* check only needs the minimal profile, so it does not depend on the broader
|
|
278
|
+
* profile parser built by the Claude wiring tier.
|
|
279
|
+
*/
|
|
280
|
+
export function parseMinimalProfileIds(gateSource) {
|
|
281
|
+
const ids = new Set();
|
|
282
|
+
const fnIndex = gateSource.indexOf("is_in_minimal_profile");
|
|
283
|
+
if (fnIndex < 0)
|
|
284
|
+
return ids;
|
|
285
|
+
const body = extractBraceBlock(gateSource, fnIndex);
|
|
286
|
+
// Each `case` arm that returns 0 is a pipe-delimited list of ids before `)`.
|
|
287
|
+
const armPattern = /([A-Za-z0-9_.|-]+)\)\s*(?:\r?\n)\s*return\s+0\b/g;
|
|
288
|
+
let match;
|
|
289
|
+
while ((match = armPattern.exec(body)) !== null) {
|
|
290
|
+
for (const token of match[1].split("|")) {
|
|
291
|
+
const id = token.trim();
|
|
292
|
+
if (id && id !== "*")
|
|
293
|
+
ids.add(id);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return ids;
|
|
297
|
+
}
|
|
298
|
+
/** Return the contents of the first brace-balanced `{ … }` block after `from`. */
|
|
299
|
+
function extractBraceBlock(source, from) {
|
|
300
|
+
const open = source.indexOf("{", from);
|
|
301
|
+
if (open < 0)
|
|
302
|
+
return "";
|
|
303
|
+
let depth = 0;
|
|
304
|
+
for (let i = open; i < source.length; i++) {
|
|
305
|
+
const char = source[i];
|
|
306
|
+
if (char === "{")
|
|
307
|
+
depth++;
|
|
308
|
+
else if (char === "}") {
|
|
309
|
+
depth--;
|
|
310
|
+
if (depth === 0)
|
|
311
|
+
return source.slice(open + 1, i);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return source.slice(open + 1);
|
|
315
|
+
}
|
|
316
|
+
// --- shared low-level helpers --------------------------------------------------
|
|
317
|
+
/** Collect every `command` string from a `{hooks:{Event:[{hooks:[…]}]}}` doc. */
|
|
318
|
+
function collectCommands(parsed) {
|
|
319
|
+
const commands = [];
|
|
320
|
+
if (!parsed || typeof parsed !== "object")
|
|
321
|
+
return commands;
|
|
322
|
+
const hooks = parsed.hooks;
|
|
323
|
+
if (!hooks || typeof hooks !== "object")
|
|
324
|
+
return commands;
|
|
325
|
+
for (const entries of Object.values(hooks)) {
|
|
326
|
+
if (!Array.isArray(entries))
|
|
327
|
+
continue;
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
const inner = entry?.hooks;
|
|
330
|
+
if (!Array.isArray(inner))
|
|
331
|
+
continue;
|
|
332
|
+
for (const hook of inner) {
|
|
333
|
+
const command = hook?.command;
|
|
334
|
+
if (typeof command === "string")
|
|
335
|
+
commands.push(command);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return commands;
|
|
340
|
+
}
|
|
341
|
+
function resolvePath(hqRoot, target) {
|
|
342
|
+
if (!target)
|
|
343
|
+
return null;
|
|
344
|
+
return path.isAbsolute(target) ? target : path.resolve(hqRoot, target);
|
|
345
|
+
}
|
|
346
|
+
function fileState(target) {
|
|
347
|
+
try {
|
|
348
|
+
const stat = fs.statSync(target);
|
|
349
|
+
return { present: stat.isFile(), executable: (stat.mode & 0o111) !== 0 };
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return { present: false, executable: false };
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function filesEqual(a, b) {
|
|
356
|
+
try {
|
|
357
|
+
return fs.readFileSync(a).equals(fs.readFileSync(b));
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function listShellScripts(dir) {
|
|
364
|
+
let entries;
|
|
365
|
+
try {
|
|
366
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
return entries
|
|
372
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".sh"))
|
|
373
|
+
.map((entry) => entry.name)
|
|
374
|
+
.sort();
|
|
375
|
+
}
|
|
376
|
+
//# sourceMappingURL=codex-wiring.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok hook wiring checks (US-005).
|
|
3
|
+
*
|
|
4
|
+
* Grok executes the canonical `.claude/hooks/` scripts (it does not duplicate
|
|
5
|
+
* them the way Codex does), reaching them through a small adapter and a
|
|
6
|
+
* user-global bridge. So the Grok tier has no content-parity surface; instead it
|
|
7
|
+
* verifies the three things that make Grok enforcement actually reachable:
|
|
8
|
+
*
|
|
9
|
+
* 1. `.grok/hooks/hq-grok.json` exists and registers `hq-grok-hook-adapter.sh`.
|
|
10
|
+
* 2. `hq-grok-hook-adapter.sh` exists and is executable.
|
|
11
|
+
* 3. The user-global bridge under `~/.grok/hooks/` is installed — Grok loads
|
|
12
|
+
* user hooks reliably but project hooks only when the folder is trusted, so
|
|
13
|
+
* the bridge is what actually routes Grok through the HQ adapter. When it is
|
|
14
|
+
* absent the doctor WARNs (not FAILs — a project-local run cannot always see
|
|
15
|
+
* it) and points at `core/scripts/grok-trust.sh` to install it.
|
|
16
|
+
*
|
|
17
|
+
* These are wiring checks, not live verification: Grok hook execution is only
|
|
18
|
+
* exercised under the Grok host. The report states that plainly via an `NA`
|
|
19
|
+
* scope note. The bridge directory is injectable so tests are hermetic and never
|
|
20
|
+
* depend on the developer's real home directory.
|
|
21
|
+
*/
|
|
22
|
+
import type { CheckContext, CheckResult } from "../types.js";
|
|
23
|
+
/** The command a user runs to install the user-global Grok bridge. */
|
|
24
|
+
export declare const GROK_TRUST_REMEDIATION = "bash core/scripts/grok-trust.sh";
|
|
25
|
+
/** Options for {@link checkGrokWiring}; injectable for hermetic tests. */
|
|
26
|
+
export interface GrokWiringOptions {
|
|
27
|
+
/** Directory of the user-global Grok bridge. Default: `~/.grok/hooks`. */
|
|
28
|
+
bridgeDir?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Run the Grok tier of the hooks family against the resolved HQ tree. Never
|
|
32
|
+
* throws for a merely-broken tree — every broken state is reported as a result.
|
|
33
|
+
*/
|
|
34
|
+
export declare function checkGrokWiring(context: CheckContext, options?: GrokWiringOptions): CheckResult[];
|
|
35
|
+
//# sourceMappingURL=grok-wiring.d.ts.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok hook wiring checks (US-005).
|
|
3
|
+
*
|
|
4
|
+
* Grok executes the canonical `.claude/hooks/` scripts (it does not duplicate
|
|
5
|
+
* them the way Codex does), reaching them through a small adapter and a
|
|
6
|
+
* user-global bridge. So the Grok tier has no content-parity surface; instead it
|
|
7
|
+
* verifies the three things that make Grok enforcement actually reachable:
|
|
8
|
+
*
|
|
9
|
+
* 1. `.grok/hooks/hq-grok.json` exists and registers `hq-grok-hook-adapter.sh`.
|
|
10
|
+
* 2. `hq-grok-hook-adapter.sh` exists and is executable.
|
|
11
|
+
* 3. The user-global bridge under `~/.grok/hooks/` is installed — Grok loads
|
|
12
|
+
* user hooks reliably but project hooks only when the folder is trusted, so
|
|
13
|
+
* the bridge is what actually routes Grok through the HQ adapter. When it is
|
|
14
|
+
* absent the doctor WARNs (not FAILs — a project-local run cannot always see
|
|
15
|
+
* it) and points at `core/scripts/grok-trust.sh` to install it.
|
|
16
|
+
*
|
|
17
|
+
* These are wiring checks, not live verification: Grok hook execution is only
|
|
18
|
+
* exercised under the Grok host. The report states that plainly via an `NA`
|
|
19
|
+
* scope note. The bridge directory is injectable so tests are hermetic and never
|
|
20
|
+
* depend on the developer's real home directory.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as os from "node:os";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
/** The command a user runs to install the user-global Grok bridge. */
|
|
26
|
+
export const GROK_TRUST_REMEDIATION = "bash core/scripts/grok-trust.sh";
|
|
27
|
+
/** Files that grok-trust.sh installs into `~/.grok/hooks/` for the bridge. */
|
|
28
|
+
const GROK_BRIDGE_FILES = ["hq-hq-bridge.sh", "hq-hq-bridge.json"];
|
|
29
|
+
/**
|
|
30
|
+
* Run the Grok tier of the hooks family against the resolved HQ tree. Never
|
|
31
|
+
* throws for a merely-broken tree — every broken state is reported as a result.
|
|
32
|
+
*/
|
|
33
|
+
export function checkGrokWiring(context, options = {}) {
|
|
34
|
+
const grokDir = path.join(context.hqRoot, ".grok", "hooks");
|
|
35
|
+
const bridgeDir = options.bridgeDir ?? path.join(os.homedir(), ".grok", "hooks");
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
status: "NA",
|
|
39
|
+
checkId: "hooks.grok.scope",
|
|
40
|
+
target: "grok",
|
|
41
|
+
message: "Grok hook checks are wiring only (registration, adapter executable bit, and user-global bridge presence); live Grok hook execution is verified only when the doctor runs under the Grok host.",
|
|
42
|
+
},
|
|
43
|
+
checkRegistration(path.join(grokDir, "hq-grok.json")),
|
|
44
|
+
checkAdapter(path.join(grokDir, "hq-grok-hook-adapter.sh")),
|
|
45
|
+
checkBridge(bridgeDir),
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
function checkRegistration(registrationPath) {
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
raw = fs.readFileSync(registrationPath, "utf8");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {
|
|
55
|
+
status: "FAIL",
|
|
56
|
+
checkId: "hooks.grok.registration",
|
|
57
|
+
target: registrationPath,
|
|
58
|
+
message: ".grok/hooks/hq-grok.json is missing — Grok will not run the HQ adapter.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {
|
|
67
|
+
status: "FAIL",
|
|
68
|
+
checkId: "hooks.grok.registration",
|
|
69
|
+
target: registrationPath,
|
|
70
|
+
message: ".grok/hooks/hq-grok.json is present but is not valid JSON.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const commands = collectCommands(parsed);
|
|
74
|
+
if (commands.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
status: "FAIL",
|
|
77
|
+
checkId: "hooks.grok.registration",
|
|
78
|
+
target: registrationPath,
|
|
79
|
+
message: ".grok/hooks/hq-grok.json declares no hook registrations.",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (!commands.some((command) => command.includes("hq-grok-hook-adapter.sh"))) {
|
|
83
|
+
return {
|
|
84
|
+
status: "FAIL",
|
|
85
|
+
checkId: "hooks.grok.registration",
|
|
86
|
+
target: registrationPath,
|
|
87
|
+
message: ".grok/hooks/hq-grok.json does not register hq-grok-hook-adapter.sh.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
status: "PASS",
|
|
92
|
+
checkId: "hooks.grok.registration",
|
|
93
|
+
target: registrationPath,
|
|
94
|
+
message: `.grok/hooks/hq-grok.json registers the HQ Grok adapter across ${commands.length} event${commands.length === 1 ? "" : "s"}.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function checkAdapter(adapterPath) {
|
|
98
|
+
const state = fileState(adapterPath);
|
|
99
|
+
if (!state.present) {
|
|
100
|
+
return {
|
|
101
|
+
status: "FAIL",
|
|
102
|
+
checkId: "hooks.grok.adapter",
|
|
103
|
+
target: adapterPath,
|
|
104
|
+
message: "hq-grok-hook-adapter.sh is missing.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (!state.executable) {
|
|
108
|
+
return {
|
|
109
|
+
status: "FAIL",
|
|
110
|
+
checkId: "hooks.grok.adapter",
|
|
111
|
+
target: adapterPath,
|
|
112
|
+
message: "hq-grok-hook-adapter.sh is present but not executable.",
|
|
113
|
+
remediation: `chmod +x ${adapterPath}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
status: "PASS",
|
|
118
|
+
checkId: "hooks.grok.adapter",
|
|
119
|
+
target: adapterPath,
|
|
120
|
+
message: "hq-grok-hook-adapter.sh is present and executable.",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function checkBridge(bridgeDir) {
|
|
124
|
+
if (bridgeInstalled(bridgeDir)) {
|
|
125
|
+
return {
|
|
126
|
+
status: "PASS",
|
|
127
|
+
checkId: "hooks.grok.bridge",
|
|
128
|
+
target: bridgeDir,
|
|
129
|
+
message: `User-global Grok bridge is installed at ${bridgeDir}.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
status: "WARN",
|
|
134
|
+
checkId: "hooks.grok.bridge",
|
|
135
|
+
target: bridgeDir,
|
|
136
|
+
message: `User-global Grok bridge is not installed at ${bridgeDir}; Grok user hooks will not reach the HQ adapter. Install it with \`${GROK_TRUST_REMEDIATION}\`.`,
|
|
137
|
+
remediation: GROK_TRUST_REMEDIATION,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function bridgeInstalled(bridgeDir) {
|
|
141
|
+
for (const name of GROK_BRIDGE_FILES) {
|
|
142
|
+
try {
|
|
143
|
+
if (fs.statSync(path.join(bridgeDir, name)).isFile())
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Keep checking the remaining bridge files.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
// --- shared low-level helpers --------------------------------------------------
|
|
153
|
+
/** Collect every `command` string from a `{hooks:{Event:[{hooks:[…]}]}}` doc. */
|
|
154
|
+
function collectCommands(parsed) {
|
|
155
|
+
const commands = [];
|
|
156
|
+
if (!parsed || typeof parsed !== "object")
|
|
157
|
+
return commands;
|
|
158
|
+
const hooks = parsed.hooks;
|
|
159
|
+
if (!hooks || typeof hooks !== "object")
|
|
160
|
+
return commands;
|
|
161
|
+
for (const entries of Object.values(hooks)) {
|
|
162
|
+
if (!Array.isArray(entries))
|
|
163
|
+
continue;
|
|
164
|
+
for (const entry of entries) {
|
|
165
|
+
const inner = entry?.hooks;
|
|
166
|
+
if (!Array.isArray(inner))
|
|
167
|
+
continue;
|
|
168
|
+
for (const hook of inner) {
|
|
169
|
+
const command = hook?.command;
|
|
170
|
+
if (typeof command === "string")
|
|
171
|
+
commands.push(command);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return commands;
|
|
176
|
+
}
|
|
177
|
+
function fileState(target) {
|
|
178
|
+
try {
|
|
179
|
+
const stat = fs.statSync(target);
|
|
180
|
+
return { present: stat.isFile(), executable: (stat.mode & 0o111) !== 0 };
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { present: false, executable: false };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=grok-wiring.js.map
|