@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 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
- # Guard the glob: with no matching files "$dir"/*.md expands literally, awk
192
- # exits nonzero, and under `set -e` that would abort an otherwise fine bind.
193
- local have=0 f
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" ] && { have=1; break; }
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
- [ "$have" = 1 ] || return 0
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
- ' "$dir"/*.md 2>/dev/null)"
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) => writeHook(claudeHooksDir, codexHooksDir, hook));
95
- // hook-gate.sh (mirrored to both platforms) carries the three profile lists,
96
- // aggregated across every hook's `profiles`.
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
- writeScript(codexHookGatePath, gateSource, EXECUTABLE_MODE);
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
- fs.writeFileSync(codexHooksJsonPath, JSON.stringify(renderCodexHooksJson(root, hookEntries), null, 2) + "\n");
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 and Claude-parity checks (US-005).
2
+ * Codex hook wiring checks (US-005), covering both Codex architectures.
3
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):
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 and Claude-parity checks (US-005).
2
+ * Codex hook wiring checks (US-005), covering both Codex architectures.
3
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):
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
- results.push(...checkRegistrations(hqRoot));
43
- results.push(...checkParity(hqRoot, allowed));
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 = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.99.1",
3
+ "version": "5.99.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {