@indigoai-us/hq-cli 5.99.1 → 5.99.2
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 +20 -0
- package/assets/scaffold/core/scripts/hq-session.sh +39 -8
- package/dist/lib/doctor/__testing__/fake-hq-tree.d.ts +33 -0
- package/dist/lib/doctor/__testing__/fake-hq-tree.js +60 -9
- package/dist/lib/doctor/checks/codex-wiring.d.ts +51 -5
- package/dist/lib/doctor/checks/codex-wiring.js +242 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.99.2] — 2026-08-13
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq doctor` now grades the Codex **adapter architecture** instead of only the
|
|
10
|
+
legacy per-hook mirror model (#369). On a current tree — where
|
|
11
|
+
`.codex/config.toml` routes every lifecycle event through
|
|
12
|
+
`hq-codex-hook-adapter.sh` dispatching the canonical `.claude/hooks` scripts —
|
|
13
|
+
the doctor previously reported dozens of phantom FAILs (missing `hooks.json`,
|
|
14
|
+
missing per-hook counterparts). It now verifies the adapter, its dispatch
|
|
15
|
+
library, and per-event registration, flags leftover pre-adapter artifacts for
|
|
16
|
+
deletion, and skips mirror-parity checks that no longer apply.
|
|
17
|
+
- `hq core hq-session` company-bind hard-policy emission is deduped and budgeted
|
|
18
|
+
(#370). Sync-conflict copies (`foo 2.md`), `_digest*`, `README`, and
|
|
19
|
+
`example-policy.md` no longer contribute lines, and the emission is capped by
|
|
20
|
+
`HQ_COMPANY_BIND_POLICY_CAP` (32) / `HQ_COMPANY_BIND_POLICY_BYTES` (40960)
|
|
21
|
+
with a non-silent overflow pointer — a large tenant's bind drops from ~298
|
|
22
|
+
lines to 32 + pointer. Withheld policies re-surface via the reactive policy
|
|
23
|
+
trigger hook when they apply.
|
|
24
|
+
|
|
5
25
|
## [5.99.1] — 2026-08-12
|
|
6
26
|
|
|
7
27
|
## [5.99.0] — 2026-08-11
|
|
@@ -183,18 +183,31 @@ spawn_work_mesh_register() {
|
|
|
183
183
|
|
|
184
184
|
# Print a company's hard-enforcement policies, read directly from the policy
|
|
185
185
|
# files (the pre-built digest was retired — the when/on trigger hook is now the
|
|
186
|
-
# sole policy-surfacing path). Emits one `- [hard] **slug**: rule` line each
|
|
186
|
+
# sole policy-surfacing path). Emits one `- [hard] **slug**: rule` line each,
|
|
187
|
+
# deduped (digest/README/example/sync-conflict copies are skipped) and budgeted
|
|
188
|
+
# (HQ_COMPANY_BIND_POLICY_CAP lines / HQ_COMPANY_BIND_POLICY_BYTES bytes, with a
|
|
189
|
+
# non-silent pointer for the overflow). An unbounded dump — observed at ~298
|
|
190
|
+
# lines for a large tenant — buries the rules it exists to surface; the
|
|
191
|
+
# reactive trigger hook re-injects any withheld policy when its trigger fires.
|
|
187
192
|
emit_company_hard_policies() {
|
|
188
193
|
local co="$1"
|
|
189
194
|
local dir="$REPO_ROOT/companies/$co/policies"
|
|
190
195
|
[ -d "$dir" ] || return 0
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
|
|
196
|
+
# Collect real policy files only. Skip the generated digest, docs, examples,
|
|
197
|
+
# and sync-conflict duplicates: policy slugs never contain spaces, so a space
|
|
198
|
+
# in the basename ("foo 2.md") marks a stray copy whose lines would duplicate
|
|
199
|
+
# the original's. (Also guards the glob: with no matching files "$dir"/*.md
|
|
200
|
+
# expands literally and awk would exit nonzero under `set -e`.)
|
|
201
|
+
local files=() f b
|
|
194
202
|
for f in "$dir"/*.md; do
|
|
195
|
-
[ -e "$f" ]
|
|
203
|
+
[ -e "$f" ] || continue
|
|
204
|
+
b="${f##*/}"
|
|
205
|
+
case "$b" in
|
|
206
|
+
_digest*.md|README.md|readme.md|example-policy.md|*.conflict-*|*" "*) continue ;;
|
|
207
|
+
esac
|
|
208
|
+
files+=("$f")
|
|
196
209
|
done
|
|
197
|
-
[ "$
|
|
210
|
+
[ "${#files[@]}" -gt 0 ] || return 0
|
|
198
211
|
local lines
|
|
199
212
|
lines="$(awk '
|
|
200
213
|
function bn(p, n,a,b){ n=split(p,a,"/"); b=a[n]; sub(/\.md$/,"",b); return b }
|
|
@@ -207,13 +220,31 @@ emit_company_hard_policies() {
|
|
|
207
220
|
d>=2 && rsec && /^## / { rsec=0 }
|
|
208
221
|
d>=2 && rsec && !rcap && NF { line=$0; gsub(/\*\*/,"",line); if(length(line)>160)line=substr(line,1,157)"..."; rule=line; rcap=1 }
|
|
209
222
|
END { if(seen) flush() }
|
|
210
|
-
' "$
|
|
223
|
+
' "${files[@]}" 2>/dev/null)"
|
|
211
224
|
[ -z "$lines" ] && return 0
|
|
225
|
+
# Budget the emission: count cap and cumulative byte cap, prefix-stable
|
|
226
|
+
# (glob order), never silent — overflow is summarized with a pointer so the
|
|
227
|
+
# withheld policies stay one command away.
|
|
228
|
+
local cap="${HQ_COMPANY_BIND_POLICY_CAP:-32}"
|
|
229
|
+
local max_bytes="${HQ_COMPANY_BIND_POLICY_BYTES:-40960}"
|
|
212
230
|
printf '\n<company-policy-digest co="%s">\n' "$co"
|
|
213
231
|
printf '# %s hard-enforcement policies (auto-surfaced on company bind)\n' "$co"
|
|
214
232
|
printf '> Company context just bound mid-session. These HARD rules now apply.\n'
|
|
215
233
|
printf '> Full text: `companies/%s/policies/{slug}.md` (or `qmd get -c %s {slug}`).\n\n' "$co" "$co"
|
|
216
|
-
printf '%s\n' "$lines"
|
|
234
|
+
printf '%s\n' "$lines" | awk -v cap="$cap" -v maxb="$max_bytes" -v co="$co" '
|
|
235
|
+
NF {
|
|
236
|
+
n++
|
|
237
|
+
if (!stop) {
|
|
238
|
+
bytes += length($0) + 1
|
|
239
|
+
if (n <= cap && bytes <= maxb) { print; kept = n } else stop = 1
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
END {
|
|
243
|
+
dropped = n - kept
|
|
244
|
+
if (dropped > 0)
|
|
245
|
+
printf "\n> %d more hard %s not shown (bind budget: %d policies / %d bytes). Full set: `companies/%s/policies/` or `qmd get -c %s {slug}`; matching rules re-surface via the policy trigger hook when they apply.\n", \
|
|
246
|
+
dropped, (dropped == 1 ? "policy" : "policies"), cap, maxb, co, co
|
|
247
|
+
}'
|
|
217
248
|
printf '</company-policy-digest>\n'
|
|
218
249
|
}
|
|
219
250
|
|
|
@@ -91,6 +91,28 @@ export interface FakeHookSpec {
|
|
|
91
91
|
*/
|
|
92
92
|
codex?: FakeCodexMirrorSpec | false;
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Declares the adapter-architecture Codex wiring: `.codex/config.toml` routing
|
|
96
|
+
* lifecycle events to `hq-codex-hook-adapter.sh`, which dispatches the
|
|
97
|
+
* `.claude/settings.json` hooks live. When this spec is present the tree models
|
|
98
|
+
* a current-release root: no `.codex/hooks.json` and no per-hook mirrors are
|
|
99
|
+
* written by default (declare a hook's `codex` spec explicitly, or set
|
|
100
|
+
* `legacyHooksJson`, to model pre-adapter leftovers).
|
|
101
|
+
*/
|
|
102
|
+
export interface FakeCodexAdapterSpec {
|
|
103
|
+
/** Events wired to the adapter in config.toml. Default: the shipped eight. */
|
|
104
|
+
events?: string[];
|
|
105
|
+
/** Whether the adapter script is written. Default: true. */
|
|
106
|
+
adapterPresent?: boolean;
|
|
107
|
+
/** Whether the adapter script carries the executable bit. Default: true. */
|
|
108
|
+
adapterExecutable?: boolean;
|
|
109
|
+
/** Whether `core/scripts/lib/hook-adapter-core.sh` is written. Default: true. */
|
|
110
|
+
dispatchLibPresent?: boolean;
|
|
111
|
+
/** Whether a leftover `.codex/hooks.json` is still written. Default: false. */
|
|
112
|
+
legacyHooksJson?: boolean;
|
|
113
|
+
}
|
|
114
|
+
/** The lifecycle events the shipped `.codex/config.toml` wires. */
|
|
115
|
+
export declare const ADAPTER_DEFAULT_EVENTS: readonly string[];
|
|
94
116
|
/** Declares the `.grok/hooks/` state. */
|
|
95
117
|
export interface FakeGrokSpec {
|
|
96
118
|
/** Whether the `.grok/hooks/` scaffold is created at all. Default: true. */
|
|
@@ -110,6 +132,13 @@ export interface FakeHqTreeSpec {
|
|
|
110
132
|
hooks?: FakeHookSpec[];
|
|
111
133
|
/** Extra top-level keys merged into `.claude/settings.json`. */
|
|
112
134
|
claudeSettings?: Record<string, unknown>;
|
|
135
|
+
/**
|
|
136
|
+
* Adapter-architecture Codex wiring. When set (object or `true` for a fully
|
|
137
|
+
* healthy default), the tree models a current-release root: config.toml +
|
|
138
|
+
* adapter + dispatch library, and no hooks.json or per-hook mirrors unless
|
|
139
|
+
* explicitly declared. When omitted, the tree keeps the legacy mirror shape.
|
|
140
|
+
*/
|
|
141
|
+
codexAdapter?: FakeCodexAdapterSpec | true;
|
|
113
142
|
/** Grok scaffold config, or `false` to omit `.grok/`. Default: healthy. */
|
|
114
143
|
grok?: FakeGrokSpec | false;
|
|
115
144
|
/** Initialise a git repo at the tree root. Default: false. */
|
|
@@ -163,6 +192,10 @@ export interface FakeHqTreeManifest {
|
|
|
163
192
|
codexHooksJsonPath: string;
|
|
164
193
|
codexHooksDir: string;
|
|
165
194
|
codexHookGatePath: string;
|
|
195
|
+
/** Path of `.codex/config.toml`, or null when no adapter spec was given. */
|
|
196
|
+
codexConfigTomlPath: string | null;
|
|
197
|
+
/** Path of the adapter script, or null when no adapter spec was given. */
|
|
198
|
+
codexAdapterPath: string | null;
|
|
166
199
|
grokDir: string | null;
|
|
167
200
|
grokAdapterPath: string | null;
|
|
168
201
|
grokRegistrationPath: string | null;
|
|
@@ -38,6 +38,17 @@ const DEFAULT_HOOK_BODY = "#!/bin/bash\ncat >/dev/null\nexit 0\n";
|
|
|
38
38
|
const EXECUTABLE_MODE = 0o755;
|
|
39
39
|
const NON_EXECUTABLE_MODE = 0o644;
|
|
40
40
|
const DEFAULT_PREFIX = "hq-doctor-fake-";
|
|
41
|
+
/** The lifecycle events the shipped `.codex/config.toml` wires. */
|
|
42
|
+
export const ADAPTER_DEFAULT_EVENTS = [
|
|
43
|
+
"SessionStart",
|
|
44
|
+
"UserPromptSubmit",
|
|
45
|
+
"PreToolUse",
|
|
46
|
+
"PostToolUse",
|
|
47
|
+
"Stop",
|
|
48
|
+
"SubagentStop",
|
|
49
|
+
"PreCompact",
|
|
50
|
+
"SessionEnd",
|
|
51
|
+
];
|
|
41
52
|
// --- Automatic cleanup registry ------------------------------------------------
|
|
42
53
|
const trackedRoots = new Set();
|
|
43
54
|
let exitHandlerRegistered = false;
|
|
@@ -90,19 +101,29 @@ export function buildFakeHqTree(spec = {}) {
|
|
|
90
101
|
// A `core/` directory is what marks this as an HQ tree for later root
|
|
91
102
|
// discovery; cheap to add and keeps the fixture realistic.
|
|
92
103
|
fs.mkdirSync(path.join(root, "core", "scripts"), { recursive: true });
|
|
104
|
+
const adapterSpec = spec.codexAdapter === true ? {} : (spec.codexAdapter ?? null);
|
|
93
105
|
const hookSpecs = spec.hooks ?? [];
|
|
94
|
-
const hookEntries = hookSpecs.map((hook) =>
|
|
95
|
-
//
|
|
96
|
-
//
|
|
106
|
+
const hookEntries = hookSpecs.map((hook) =>
|
|
107
|
+
// Adapter-architecture trees have no per-hook mirrors unless a hook
|
|
108
|
+
// explicitly declares one (to model a pre-adapter leftover).
|
|
109
|
+
writeHook(claudeHooksDir, codexHooksDir, hook, adapterSpec === null));
|
|
110
|
+
// hook-gate.sh carries the three profile lists, aggregated across every
|
|
111
|
+
// hook's `profiles`. Legacy trees mirror it to `.codex/hooks/` too; adapter
|
|
112
|
+
// trees run the Claude gate directly, so no mirror is written there.
|
|
97
113
|
const gateSource = renderHookGate(hookEntries);
|
|
98
114
|
const claudeHookGatePath = path.join(claudeHooksDir, "hook-gate.sh");
|
|
99
115
|
const codexHookGatePath = path.join(codexHooksDir, "hook-gate.sh");
|
|
100
116
|
writeScript(claudeHookGatePath, gateSource, EXECUTABLE_MODE);
|
|
101
|
-
|
|
117
|
+
if (adapterSpec === null) {
|
|
118
|
+
writeScript(codexHookGatePath, gateSource, EXECUTABLE_MODE);
|
|
119
|
+
}
|
|
102
120
|
const claudeSettingsPath = path.join(root, ".claude", "settings.json");
|
|
103
121
|
fs.writeFileSync(claudeSettingsPath, JSON.stringify(renderClaudeSettings(hookEntries, spec.claudeSettings), null, 2) + "\n");
|
|
104
122
|
const codexHooksJsonPath = path.join(root, ".codex", "hooks.json");
|
|
105
|
-
|
|
123
|
+
if (adapterSpec === null || adapterSpec.legacyHooksJson) {
|
|
124
|
+
fs.writeFileSync(codexHooksJsonPath, JSON.stringify(renderCodexHooksJson(root, hookEntries), null, 2) + "\n");
|
|
125
|
+
}
|
|
126
|
+
const adapterPaths = writeCodexAdapter(root, codexHooksDir, adapterSpec);
|
|
106
127
|
const grok = writeGrok(root, spec.grok);
|
|
107
128
|
let gitInitialised = false;
|
|
108
129
|
if (spec.git) {
|
|
@@ -116,6 +137,8 @@ export function buildFakeHqTree(spec = {}) {
|
|
|
116
137
|
codexHooksJsonPath,
|
|
117
138
|
codexHooksDir,
|
|
118
139
|
codexHookGatePath,
|
|
140
|
+
codexConfigTomlPath: adapterPaths.configTomlPath,
|
|
141
|
+
codexAdapterPath: adapterPaths.adapterPath,
|
|
119
142
|
grokDir: grok.dir,
|
|
120
143
|
grokAdapterPath: grok.adapterPath,
|
|
121
144
|
grokRegistrationPath: grok.registrationPath,
|
|
@@ -139,7 +162,7 @@ export function buildFakeHqTree(spec = {}) {
|
|
|
139
162
|
};
|
|
140
163
|
}
|
|
141
164
|
// --- Hook materialisation ------------------------------------------------------
|
|
142
|
-
function writeHook(claudeHooksDir, codexHooksDir, hook) {
|
|
165
|
+
function writeHook(claudeHooksDir, codexHooksDir, hook, defaultMirror) {
|
|
143
166
|
const claudeBody = hook.body ?? DEFAULT_HOOK_BODY;
|
|
144
167
|
const present = hook.present !== false;
|
|
145
168
|
const registered = hook.registered !== false;
|
|
@@ -154,7 +177,7 @@ function writeHook(claudeHooksDir, codexHooksDir, hook) {
|
|
|
154
177
|
mode = statMode(scriptPath);
|
|
155
178
|
executable = isExecutable(mode);
|
|
156
179
|
}
|
|
157
|
-
const codex = writeCodexMirror(codexHooksDir, hook, claudeBody);
|
|
180
|
+
const codex = writeCodexMirror(codexHooksDir, hook, claudeBody, defaultMirror);
|
|
158
181
|
return {
|
|
159
182
|
id: hook.id,
|
|
160
183
|
scriptPath,
|
|
@@ -168,10 +191,13 @@ function writeHook(claudeHooksDir, codexHooksDir, hook) {
|
|
|
168
191
|
codex,
|
|
169
192
|
};
|
|
170
193
|
}
|
|
171
|
-
function writeCodexMirror(codexHooksDir, hook, claudeBody) {
|
|
172
|
-
// `codex: false` => a Claude hook with no Codex counterpart.
|
|
194
|
+
function writeCodexMirror(codexHooksDir, hook, claudeBody, defaultMirror) {
|
|
195
|
+
// `codex: false` => a Claude hook with no Codex counterpart. An undeclared
|
|
196
|
+
// mirror defaults to healthy on legacy trees and to absent on adapter trees.
|
|
173
197
|
if (hook.codex === false)
|
|
174
198
|
return null;
|
|
199
|
+
if (hook.codex === undefined && !defaultMirror)
|
|
200
|
+
return null;
|
|
175
201
|
const codexSpec = hook.codex ?? {};
|
|
176
202
|
const present = codexSpec.present !== false;
|
|
177
203
|
const registered = codexSpec.registered !== false;
|
|
@@ -292,6 +318,31 @@ ${caseBlock} *)
|
|
|
292
318
|
esac
|
|
293
319
|
}`;
|
|
294
320
|
}
|
|
321
|
+
/**
|
|
322
|
+
* Materialise the adapter-architecture wiring: `.codex/config.toml` routing the
|
|
323
|
+
* spec'd events to `hq-codex-hook-adapter.sh`, the adapter script itself, and
|
|
324
|
+
* the `core/scripts/lib/hook-adapter-core.sh` dispatch library it sources —
|
|
325
|
+
* mirroring the shape the hq-core release ships.
|
|
326
|
+
*/
|
|
327
|
+
function writeCodexAdapter(root, codexHooksDir, spec) {
|
|
328
|
+
if (spec === null)
|
|
329
|
+
return { configTomlPath: null, adapterPath: null };
|
|
330
|
+
const events = spec.events ?? [...ADAPTER_DEFAULT_EVENTS];
|
|
331
|
+
const adapterPath = path.join(codexHooksDir, "hq-codex-hook-adapter.sh");
|
|
332
|
+
const command = `exec /bin/bash "$PWD/.codex/hooks/hq-codex-hook-adapter.sh"`;
|
|
333
|
+
const blocks = events.map((event) => `[[hooks.${event}]]\n\n[[hooks.${event}.hooks]]\ntype = "command"\ncommand = '${command}'\ntimeout = 30\n`);
|
|
334
|
+
const configTomlPath = path.join(root, ".codex", "config.toml");
|
|
335
|
+
fs.writeFileSync(configTomlPath, `[features]\nhooks = true\n\n${blocks.join("\n")}`);
|
|
336
|
+
if (spec.adapterPresent !== false) {
|
|
337
|
+
writeScript(adapterPath, "#!/bin/bash\ncat >/dev/null\nexit 0\n", spec.adapterExecutable === false ? NON_EXECUTABLE_MODE : EXECUTABLE_MODE);
|
|
338
|
+
}
|
|
339
|
+
if (spec.dispatchLibPresent !== false) {
|
|
340
|
+
const libPath = path.join(root, "core", "scripts", "lib");
|
|
341
|
+
fs.mkdirSync(libPath, { recursive: true });
|
|
342
|
+
fs.writeFileSync(path.join(libPath, "hook-adapter-core.sh"), "# fake hook-adapter-core.sh (test fixture)\n");
|
|
343
|
+
}
|
|
344
|
+
return { configTomlPath, adapterPath };
|
|
345
|
+
}
|
|
295
346
|
function writeGrok(root, spec) {
|
|
296
347
|
const absent = {
|
|
297
348
|
dir: null,
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codex hook wiring
|
|
2
|
+
* Codex hook wiring checks (US-005), covering both Codex architectures.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Codex
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* **Adapter architecture (current releases).** `.codex/config.toml` routes
|
|
5
|
+
* every Codex lifecycle event to one script,
|
|
6
|
+
* `.codex/hooks/hq-codex-hook-adapter.sh`, which reads `.claude/settings.json`
|
|
7
|
+
* live and dispatches the canonical `.claude/hooks/` scripts — one policy
|
|
8
|
+
* implementation shared by Claude and Codex, no per-hook mirrors. When that
|
|
9
|
+
* routing is present the doctor grades:
|
|
10
|
+
*
|
|
11
|
+
* 1. The adapter script itself (present + executable) and the dispatch
|
|
12
|
+
* library it sources (`core/scripts/lib/hook-adapter-core.sh`).
|
|
13
|
+
* 2. Event coverage — each required lifecycle event in `.codex/config.toml`
|
|
14
|
+
* routes through the adapter.
|
|
15
|
+
* 3. Legacy artifacts — a leftover `.codex/hooks.json` or `.codex/hooks/`
|
|
16
|
+
* mirror that shadows a `.claude/hooks/` original is dead code from the
|
|
17
|
+
* pre-adapter architecture. Stale copies rot (they keep bugs the Claude
|
|
18
|
+
* original has since fixed), so each is a FAIL with a delete remediation.
|
|
19
|
+
*
|
|
20
|
+
* Mirror-model checks (parity, missing counterparts) are not emitted in this
|
|
21
|
+
* mode: Codex executes the Claude originals, so there is nothing to mirror.
|
|
22
|
+
*
|
|
23
|
+
* **Legacy mirror architecture (pre-adapter roots).** Codex runs duplicated
|
|
24
|
+
* copies under `.codex/hooks/`, registered in `.codex/hooks.json`, so the
|
|
25
|
+
* mirrors are the entire drift surface the doctor has to police:
|
|
8
26
|
*
|
|
9
27
|
* 1. Enumerates the registrations in `.codex/hooks.json` and verifies each
|
|
10
28
|
* referenced script exists and is executable — the Codex analogue of the
|
|
@@ -23,10 +41,32 @@
|
|
|
23
41
|
*/
|
|
24
42
|
import { type AllowedDivergenceLoad } from "../allowed-divergence.js";
|
|
25
43
|
import type { CheckContext, CheckResult } from "../types.js";
|
|
44
|
+
/** Basename of the single-adapter script the current architecture routes to. */
|
|
45
|
+
export declare const CODEX_ADAPTER_BASENAME = "hq-codex-hook-adapter.sh";
|
|
46
|
+
/** Library the adapter sources to dispatch `.claude/settings.json` live. */
|
|
47
|
+
export declare const CODEX_DISPATCH_LIB_RELPATH: string;
|
|
48
|
+
/**
|
|
49
|
+
* Lifecycle events the shipped `.codex/config.toml` routes through the adapter.
|
|
50
|
+
* A missing event means the Claude hooks for that event never run under Codex.
|
|
51
|
+
*/
|
|
52
|
+
export declare const REQUIRED_ADAPTER_EVENTS: readonly string[];
|
|
26
53
|
/** Options for {@link checkCodexWiring}; all injectable for hermetic tests. */
|
|
27
54
|
export interface CodexWiringOptions {
|
|
28
55
|
/** Pre-loaded allowed-divergence list. Default: loaded from the tree. */
|
|
29
56
|
allowed?: AllowedDivergenceLoad;
|
|
57
|
+
/** Pre-computed adapter detection. Default: detected from the tree. */
|
|
58
|
+
adapter?: CodexAdapterDetection;
|
|
59
|
+
}
|
|
60
|
+
/** What {@link detectCodexAdapter} learned about `.codex/config.toml`. */
|
|
61
|
+
export interface CodexAdapterDetection {
|
|
62
|
+
/** True when at least one config.toml hook command routes to the adapter. */
|
|
63
|
+
detected: boolean;
|
|
64
|
+
/** Whether `.codex/config.toml` exists and was readable. */
|
|
65
|
+
configPresent: boolean;
|
|
66
|
+
/** TOML parse failure, when the file exists but could not be parsed. */
|
|
67
|
+
configParseError: string | null;
|
|
68
|
+
/** Events whose config.toml command references the adapter script. */
|
|
69
|
+
wiredEvents: string[];
|
|
30
70
|
}
|
|
31
71
|
/**
|
|
32
72
|
* Run the Codex tier of the hooks family against the resolved HQ tree. Never
|
|
@@ -34,6 +74,12 @@ export interface CodexWiringOptions {
|
|
|
34
74
|
* is reported as a result, not an exception.
|
|
35
75
|
*/
|
|
36
76
|
export declare function checkCodexWiring(context: CheckContext, options?: CodexWiringOptions): CheckResult[];
|
|
77
|
+
/**
|
|
78
|
+
* Inspect `.codex/config.toml` for adapter-architecture routing: any hook
|
|
79
|
+
* command that references `hq-codex-hook-adapter.sh`. A tree with no such
|
|
80
|
+
* routing (or no parseable config at all) is graded by the legacy mirror model.
|
|
81
|
+
*/
|
|
82
|
+
export declare function detectCodexAdapter(hqRoot: string): CodexAdapterDetection;
|
|
37
83
|
/**
|
|
38
84
|
* Extract the hook ids listed in `is_in_minimal_profile()` from a `hook-gate.sh`
|
|
39
85
|
* source. Scoped to that one function's `{ … }` body so the standard and strict
|
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Codex hook wiring
|
|
2
|
+
* Codex hook wiring checks (US-005), covering both Codex architectures.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Codex
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* **Adapter architecture (current releases).** `.codex/config.toml` routes
|
|
5
|
+
* every Codex lifecycle event to one script,
|
|
6
|
+
* `.codex/hooks/hq-codex-hook-adapter.sh`, which reads `.claude/settings.json`
|
|
7
|
+
* live and dispatches the canonical `.claude/hooks/` scripts — one policy
|
|
8
|
+
* implementation shared by Claude and Codex, no per-hook mirrors. When that
|
|
9
|
+
* routing is present the doctor grades:
|
|
10
|
+
*
|
|
11
|
+
* 1. The adapter script itself (present + executable) and the dispatch
|
|
12
|
+
* library it sources (`core/scripts/lib/hook-adapter-core.sh`).
|
|
13
|
+
* 2. Event coverage — each required lifecycle event in `.codex/config.toml`
|
|
14
|
+
* routes through the adapter.
|
|
15
|
+
* 3. Legacy artifacts — a leftover `.codex/hooks.json` or `.codex/hooks/`
|
|
16
|
+
* mirror that shadows a `.claude/hooks/` original is dead code from the
|
|
17
|
+
* pre-adapter architecture. Stale copies rot (they keep bugs the Claude
|
|
18
|
+
* original has since fixed), so each is a FAIL with a delete remediation.
|
|
19
|
+
*
|
|
20
|
+
* Mirror-model checks (parity, missing counterparts) are not emitted in this
|
|
21
|
+
* mode: Codex executes the Claude originals, so there is nothing to mirror.
|
|
22
|
+
*
|
|
23
|
+
* **Legacy mirror architecture (pre-adapter roots).** Codex runs duplicated
|
|
24
|
+
* copies under `.codex/hooks/`, registered in `.codex/hooks.json`, so the
|
|
25
|
+
* mirrors are the entire drift surface the doctor has to police:
|
|
8
26
|
*
|
|
9
27
|
* 1. Enumerates the registrations in `.codex/hooks.json` and verifies each
|
|
10
28
|
* referenced script exists and is executable — the Codex analogue of the
|
|
@@ -23,7 +41,26 @@
|
|
|
23
41
|
*/
|
|
24
42
|
import * as fs from "node:fs";
|
|
25
43
|
import * as path from "node:path";
|
|
44
|
+
import { parse as parseToml } from "smol-toml";
|
|
26
45
|
import { ALLOWED_DIVERGENCE_RELPATH, loadAllowedDivergence, } from "../allowed-divergence.js";
|
|
46
|
+
/** Basename of the single-adapter script the current architecture routes to. */
|
|
47
|
+
export const CODEX_ADAPTER_BASENAME = "hq-codex-hook-adapter.sh";
|
|
48
|
+
/** Library the adapter sources to dispatch `.claude/settings.json` live. */
|
|
49
|
+
export const CODEX_DISPATCH_LIB_RELPATH = path.join("core", "scripts", "lib", "hook-adapter-core.sh");
|
|
50
|
+
/**
|
|
51
|
+
* Lifecycle events the shipped `.codex/config.toml` routes through the adapter.
|
|
52
|
+
* A missing event means the Claude hooks for that event never run under Codex.
|
|
53
|
+
*/
|
|
54
|
+
export const REQUIRED_ADAPTER_EVENTS = [
|
|
55
|
+
"SessionStart",
|
|
56
|
+
"UserPromptSubmit",
|
|
57
|
+
"PreToolUse",
|
|
58
|
+
"PostToolUse",
|
|
59
|
+
"Stop",
|
|
60
|
+
"SubagentStop",
|
|
61
|
+
"PreCompact",
|
|
62
|
+
"SessionEnd",
|
|
63
|
+
];
|
|
27
64
|
/**
|
|
28
65
|
* Run the Codex tier of the hooks family against the resolved HQ tree. Never
|
|
29
66
|
* throws for a merely-broken tree — a missing or malformed `.codex/hooks.json`
|
|
@@ -32,6 +69,7 @@ import { ALLOWED_DIVERGENCE_RELPATH, loadAllowedDivergence, } from "../allowed-d
|
|
|
32
69
|
export function checkCodexWiring(context, options = {}) {
|
|
33
70
|
const hqRoot = context.hqRoot;
|
|
34
71
|
const allowed = options.allowed ?? loadAllowedDivergence(hqRoot);
|
|
72
|
+
const adapter = options.adapter ?? detectCodexAdapter(hqRoot);
|
|
35
73
|
const results = [];
|
|
36
74
|
results.push({
|
|
37
75
|
status: "NA",
|
|
@@ -39,8 +77,21 @@ export function checkCodexWiring(context, options = {}) {
|
|
|
39
77
|
target: "codex",
|
|
40
78
|
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
79
|
});
|
|
42
|
-
|
|
43
|
-
|
|
80
|
+
if (adapter.detected) {
|
|
81
|
+
results.push(...checkAdapterWiring(hqRoot, adapter, allowed));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (adapter.configPresent && adapter.configParseError) {
|
|
85
|
+
results.push({
|
|
86
|
+
status: "WARN",
|
|
87
|
+
checkId: "hooks.codex.adapter-config",
|
|
88
|
+
target: path.join(".codex", "config.toml"),
|
|
89
|
+
message: `.codex/config.toml exists but could not be parsed as TOML (${adapter.configParseError}); grading Codex wiring by the legacy mirror model instead.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
results.push(...checkRegistrations(hqRoot));
|
|
93
|
+
results.push(...checkParity(hqRoot, allowed));
|
|
94
|
+
}
|
|
44
95
|
for (const problem of allowed.problems) {
|
|
45
96
|
results.push({
|
|
46
97
|
status: "WARN",
|
|
@@ -51,6 +102,190 @@ export function checkCodexWiring(context, options = {}) {
|
|
|
51
102
|
}
|
|
52
103
|
return results;
|
|
53
104
|
}
|
|
105
|
+
// --- Adapter architecture (current releases) -----------------------------------
|
|
106
|
+
/**
|
|
107
|
+
* Inspect `.codex/config.toml` for adapter-architecture routing: any hook
|
|
108
|
+
* command that references `hq-codex-hook-adapter.sh`. A tree with no such
|
|
109
|
+
* routing (or no parseable config at all) is graded by the legacy mirror model.
|
|
110
|
+
*/
|
|
111
|
+
export function detectCodexAdapter(hqRoot) {
|
|
112
|
+
const configPath = path.join(hqRoot, ".codex", "config.toml");
|
|
113
|
+
let source;
|
|
114
|
+
try {
|
|
115
|
+
source = fs.readFileSync(configPath, "utf8");
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return {
|
|
119
|
+
detected: false,
|
|
120
|
+
configPresent: false,
|
|
121
|
+
configParseError: null,
|
|
122
|
+
wiredEvents: [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
let doc;
|
|
126
|
+
try {
|
|
127
|
+
doc = parseToml(source);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
return {
|
|
131
|
+
detected: false,
|
|
132
|
+
configPresent: true,
|
|
133
|
+
configParseError: error.message,
|
|
134
|
+
wiredEvents: [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const wiredEvents = [];
|
|
138
|
+
const hooks = doc?.hooks;
|
|
139
|
+
if (hooks && typeof hooks === "object" && !Array.isArray(hooks)) {
|
|
140
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
141
|
+
if (!Array.isArray(entries))
|
|
142
|
+
continue;
|
|
143
|
+
const routed = entries.some((entry) => {
|
|
144
|
+
const inner = entry?.hooks;
|
|
145
|
+
if (!Array.isArray(inner))
|
|
146
|
+
return false;
|
|
147
|
+
return inner.some((hook) => {
|
|
148
|
+
const command = hook?.command;
|
|
149
|
+
return (typeof command === "string" &&
|
|
150
|
+
command.includes(CODEX_ADAPTER_BASENAME));
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
if (routed)
|
|
154
|
+
wiredEvents.push(event);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
detected: wiredEvents.length > 0,
|
|
159
|
+
configPresent: true,
|
|
160
|
+
configParseError: null,
|
|
161
|
+
wiredEvents,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Grade a tree whose `.codex/config.toml` routes events through the adapter:
|
|
166
|
+
* the adapter script and its dispatch library, event coverage, leftover legacy
|
|
167
|
+
* artifacts, and now-obsolete allowed-divergence entries.
|
|
168
|
+
*/
|
|
169
|
+
function checkAdapterWiring(hqRoot, adapter, allowed) {
|
|
170
|
+
const results = [];
|
|
171
|
+
const adapterRelPath = path.join(".codex", "hooks", CODEX_ADAPTER_BASENAME);
|
|
172
|
+
const adapterPath = path.join(hqRoot, adapterRelPath);
|
|
173
|
+
const adapterState = fileState(adapterPath);
|
|
174
|
+
if (!adapterState.present) {
|
|
175
|
+
results.push({
|
|
176
|
+
status: "FAIL",
|
|
177
|
+
checkId: "hooks.codex.adapter",
|
|
178
|
+
target: adapterRelPath,
|
|
179
|
+
message: `.codex/config.toml routes Codex events to ${CODEX_ADAPTER_BASENAME}, but the adapter script is missing — no HQ hooks run under Codex.`,
|
|
180
|
+
remediation: `Restore ${adapterRelPath} from the hq-core release.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
else if (!adapterState.executable) {
|
|
184
|
+
results.push({
|
|
185
|
+
status: "FAIL",
|
|
186
|
+
checkId: "hooks.codex.adapter",
|
|
187
|
+
target: adapterRelPath,
|
|
188
|
+
message: "Codex hook adapter is present but not executable.",
|
|
189
|
+
remediation: `chmod +x ${adapterPath}`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
results.push({
|
|
194
|
+
status: "PASS",
|
|
195
|
+
checkId: "hooks.codex.adapter",
|
|
196
|
+
target: adapterRelPath,
|
|
197
|
+
message: "Codex hook adapter is present and executable; it dispatches the .claude/settings.json hooks live, so Claude and Codex share one canonical hook implementation.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const dispatchLibPath = path.join(hqRoot, CODEX_DISPATCH_LIB_RELPATH);
|
|
201
|
+
if (fileState(dispatchLibPath).present) {
|
|
202
|
+
results.push({
|
|
203
|
+
status: "PASS",
|
|
204
|
+
checkId: "hooks.codex.adapter-dispatch",
|
|
205
|
+
target: CODEX_DISPATCH_LIB_RELPATH,
|
|
206
|
+
message: "Adapter dispatch library is present.",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
results.push({
|
|
211
|
+
status: "FAIL",
|
|
212
|
+
checkId: "hooks.codex.adapter-dispatch",
|
|
213
|
+
target: CODEX_DISPATCH_LIB_RELPATH,
|
|
214
|
+
message: `${CODEX_DISPATCH_LIB_RELPATH} is missing. The adapter sources it (with \`|| true\`) to dispatch .claude/settings.json hooks, so without it the adapter silently dispatches nothing.`,
|
|
215
|
+
remediation: `Restore ${CODEX_DISPATCH_LIB_RELPATH} from the hq-core release.`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// Event coverage: each required lifecycle event must route through the
|
|
219
|
+
// adapter; extra wired events (future Codex additions) simply PASS.
|
|
220
|
+
const wired = new Set(adapter.wiredEvents);
|
|
221
|
+
for (const event of REQUIRED_ADAPTER_EVENTS) {
|
|
222
|
+
if (wired.has(event)) {
|
|
223
|
+
results.push({
|
|
224
|
+
status: "PASS",
|
|
225
|
+
checkId: "hooks.codex.registration",
|
|
226
|
+
target: event,
|
|
227
|
+
message: `Codex ${event} routes through the hq adapter (.codex/config.toml).`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
results.push({
|
|
232
|
+
status: "FAIL",
|
|
233
|
+
checkId: "hooks.codex.registration",
|
|
234
|
+
target: event,
|
|
235
|
+
message: `Codex ${event} is not wired to the adapter in .codex/config.toml — the Claude hooks for this event never run under Codex.`,
|
|
236
|
+
remediation: `Restore the [[hooks.${event}]] block routing to ${CODEX_ADAPTER_BASENAME} from the release .codex/config.toml.`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const event of adapter.wiredEvents) {
|
|
241
|
+
if (REQUIRED_ADAPTER_EVENTS.includes(event))
|
|
242
|
+
continue;
|
|
243
|
+
results.push({
|
|
244
|
+
status: "PASS",
|
|
245
|
+
checkId: "hooks.codex.registration",
|
|
246
|
+
target: event,
|
|
247
|
+
message: `Codex ${event} routes through the hq adapter (.codex/config.toml).`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// Legacy artifacts from the pre-adapter mirror architecture. Dead code, but
|
|
251
|
+
// dangerous dead code: a stale mirror keeps bugs its Claude original has
|
|
252
|
+
// since fixed, and hooks.json invites tooling to treat mirrors as live.
|
|
253
|
+
const hooksJsonRelPath = path.join(".codex", "hooks.json");
|
|
254
|
+
if (fileState(path.join(hqRoot, hooksJsonRelPath)).present) {
|
|
255
|
+
results.push({
|
|
256
|
+
status: "FAIL",
|
|
257
|
+
checkId: "hooks.codex.legacy-artifact",
|
|
258
|
+
target: hooksJsonRelPath,
|
|
259
|
+
message: ".codex/hooks.json is a leftover from the pre-adapter mirror architecture — Codex registration now lives in .codex/config.toml and the adapter dispatches .claude/settings.json live.",
|
|
260
|
+
remediation: `Delete ${hooksJsonRelPath}.`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const claudeSet = new Set(listShellScripts(path.join(hqRoot, ".claude", "hooks")));
|
|
264
|
+
for (const file of listShellScripts(path.join(hqRoot, ".codex", "hooks"))) {
|
|
265
|
+
if (file === CODEX_ADAPTER_BASENAME)
|
|
266
|
+
continue;
|
|
267
|
+
if (!claudeSet.has(file))
|
|
268
|
+
continue; // Codex-native script — not a mirror.
|
|
269
|
+
results.push({
|
|
270
|
+
status: "FAIL",
|
|
271
|
+
checkId: "hooks.codex.legacy-artifact",
|
|
272
|
+
target: file,
|
|
273
|
+
message: `.codex/hooks/${file} is a leftover mirror from the pre-adapter architecture. Codex executes the .claude/hooks original via the adapter; the dead copy only rots.`,
|
|
274
|
+
remediation: `Delete .codex/hooks/${file}.`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// The allowed-divergence list is a mirror-model concept; under the adapter
|
|
278
|
+
// architecture there are no mirrors left to diverge.
|
|
279
|
+
for (const entry of allowed.entries) {
|
|
280
|
+
results.push({
|
|
281
|
+
status: "WARN",
|
|
282
|
+
checkId: "hooks.codex.allowed-divergence-stale",
|
|
283
|
+
target: entry.file,
|
|
284
|
+
message: `allowed-divergence entry for ${entry.file} is obsolete under the adapter architecture — Codex runs the Claude originals, so no mirror can diverge. Remove it so the list cannot rot.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
54
289
|
// --- 1. Registration checks ----------------------------------------------------
|
|
55
290
|
function checkRegistrations(hqRoot) {
|
|
56
291
|
const results = [];
|