@indigoai-us/hq-cli 5.99.0 → 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,28 @@
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
+
25
+ ## [5.99.1] — 2026-08-12
26
+
5
27
  ## [5.99.0] — 2026-08-11
6
28
 
7
29
  ### Added
@@ -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
 
@@ -552,7 +552,7 @@ export function siblingArgs(backend, prompt) {
552
552
  // journal helper, the queue-draining `mv` — hits an approval gate with
553
553
  // no human attached; headless `claude -p` then denies it and the run
554
554
  // stalls having written no report. `bypassPermissions` is the same
555
- // unattended posture codex gets from `-s workspace-write` and grok from
555
+ // unattended posture codex gets from `-s danger-full-access` and grok from
556
556
  // `--always-approve --sandbox workspace`. Hooks still fire natively —
557
557
  // this only removes the interactive approval gate, not the hook chain.
558
558
  "--permission-mode",
@@ -570,7 +570,7 @@ export function siblingArgs(backend, prompt) {
570
570
  // scan, the journal helper — then hits an approval gate with no human
571
571
  // attached, and grok cancels the whole run: `stopReason: cancelled,
572
572
  // cancellationCategory: PermissionCancelled`. Unattended approval plus
573
- // a sandbox is the same posture codex gets from `-s workspace-write`.
573
+ // a sandbox is the same posture codex gets from `-s danger-full-access`.
574
574
  "--always-approve",
575
575
  "--sandbox",
576
576
  "workspace",
@@ -581,8 +581,16 @@ export function siblingArgs(backend, prompt) {
581
581
  return [
582
582
  "exec",
583
583
  "--skip-git-repo-check",
584
+ // danger-full-access, NOT workspace-write. `-s workspace-write` force-
585
+ // enables Codex's restrictive sandbox, which denies the temp/cache/socket
586
+ // writes the maintenance sibling needs on macOS (git/Xcode cache, zsh/asdf
587
+ // heredocs) and cannot even initialize bubblewrap on some Linux hosts
588
+ // (`bwrap: loopback: Failed RTM_NEWADDR`) — HQ harness finding 2.3, which
589
+ // wedged the sibling before it wrote any report. HQ's safety boundary is
590
+ // its hooks, which still fire here via --dangerously-bypass-hook-trust;
591
+ // the sandbox is not what guards the HQ root.
584
592
  "-s",
585
- "workspace-write",
593
+ "danger-full-access",
586
594
  "--dangerously-bypass-hook-trust",
587
595
  "-m",
588
596
  CODEX_SIBLING_MODEL,
@@ -44,6 +44,8 @@ import { createWorktree } from "../lib/core-utils/worktree.js";
44
44
  import { runCodexSkillBridgeCommand } from "../lib/core-utils/codex-skill-bridge-entry.js";
45
45
  import { archiveOldThreads } from "../lib/core-utils/archive-old-threads.js";
46
46
  import { qmdReindexAfterSync } from "../lib/core-utils/qmd-reindex-after-sync.js";
47
+ import { softTimeoutCommand } from "../lib/core-utils/soft-timeout.js";
48
+ import { timeoutGuardCommand } from "../lib/core-utils/timeout-guard.js";
47
49
  /**
48
50
  * These commands are now native implementations. Their retained shell assets
49
51
  * are deliberately claimed in SCAFFOLD_ONLY_ASSETS: loose HQ trees still ship
@@ -421,6 +423,39 @@ export function registerCoreCommands(program) {
421
423
  runCommand(entry, args, cmd);
422
424
  });
423
425
  }
426
+ // `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
427
+ // Async and bespoke (the NATIVE_UTILITY_COMMANDS table is synchronous), since
428
+ // it spawns a child and warns over time. Warn-don't-kill primitive for
429
+ // finding 2.1 — the CLI-hosted twin of core/scripts/lib/soft-timeout.sh.
430
+ core
431
+ .command("soft-timeout")
432
+ .description("Run a command under a soft (warn, don't kill) timeout")
433
+ // Passthrough: the wrapped command owns everything after `--`.
434
+ .allowUnknownOption()
435
+ .allowExcessArguments()
436
+ .helpOption(false)
437
+ .argument("[args...]", "<interval> [--hard-cap <spec>] [--label <name>] -- <cmd...>")
438
+ .action(async (args = [], _opts, cmd) => {
439
+ const operands = cmd.args.length > 0 ? cmd.args : args;
440
+ const code = await softTimeoutCommand(operands);
441
+ if (code !== 0)
442
+ process.exit(code);
443
+ });
444
+ // `hq core timeout-guard` — PreToolUse decision for the foreground-timeout
445
+ // guard (finding 2.1). Reads the hook JSON on stdin and exits 2 to block, 0
446
+ // to allow. The shipped .claude hook is a thin shim over this.
447
+ core
448
+ .command("timeout-guard")
449
+ .description("PreToolUse guard: block over-ceiling foreground timeouts (reads hook JSON on stdin)")
450
+ .helpOption(false)
451
+ .action(async () => {
452
+ const chunks = [];
453
+ for await (const chunk of process.stdin)
454
+ chunks.push(chunk);
455
+ const code = timeoutGuardCommand(Buffer.concat(chunks).toString("utf8"));
456
+ if (code !== 0)
457
+ process.exit(code);
458
+ });
424
459
  return core;
425
460
  }
426
461
  //# sourceMappingURL=core.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
3
+ *
4
+ * Run a command under a SOFT timeout: WARN, don't kill. This is the CLI-hosted
5
+ * form of the shell primitive `core/scripts/lib/soft-timeout.sh`, so HQ scripts
6
+ * and hooks can call `hq core soft-timeout …` directly instead of shelling out
7
+ * to a bundled script.
8
+ *
9
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events):
10
+ * a fixed deadline SIGTERMs an operation still making progress and loses the
11
+ * in-flight work. The fix is warn-and-continue — on each interval the command
12
+ * keeps running and a SOFT-TIMEOUT notice is written to stderr so whoever is
13
+ * watching decides (wait / background / kill), instead of the runner deciding
14
+ * by killing progressing work.
15
+ *
16
+ * Contract (identical to the shell primitive):
17
+ * - `<interval>` — warn every interval. Bare number = seconds; 90s / 2m / 1h.
18
+ * - `--hard-cap <spec>` — OPTIONAL safety ceiling for unattended callers.
19
+ * Pure soft has no cap and never terminates. At the cap the command is
20
+ * SIGTERM'd, then SIGKILL'd after a short grace, and the exit code is 124.
21
+ * - `--label <name>` — name shown in the notice (default: the command basename).
22
+ * - Exit status: the command's own, verbatim — except 124 on a hard-cap.
23
+ * A soft warning alone never changes the exit status.
24
+ */
25
+ import { spawn } from "node:child_process";
26
+ /** Parse a duration spec (Ns / Nm / Nh / bare N=seconds) to integer seconds. */
27
+ export declare function parseDurationSecs(spec: string): number | null;
28
+ export interface ParsedSoftTimeoutArgs {
29
+ intervalSecs: number;
30
+ hardCapSecs: number | null;
31
+ label: string | null;
32
+ command: string[];
33
+ }
34
+ /** Parse the `hq core soft-timeout` argv. Throws a plain Error on misuse. */
35
+ export declare function parseSoftTimeoutArgs(argv: string[]): ParsedSoftTimeoutArgs;
36
+ export interface RunSoftTimeoutDeps {
37
+ spawnFn?: typeof spawn;
38
+ stderr?: NodeJS.WritableStream;
39
+ now?: () => Date;
40
+ /** Signal a whole process group by (leader) pid. Defaults to process.kill(-pid). */
41
+ killGroup?: (pid: number, sig: NodeJS.Signals) => void;
42
+ }
43
+ /**
44
+ * Execute the command under the soft timeout. Resolves to the exit code the
45
+ * process should exit with. Never rejects on a timeout — a warning is only a
46
+ * stderr line. Rejects only on spawn failure.
47
+ */
48
+ export declare function runSoftTimeout(parsed: ParsedSoftTimeoutArgs, deps?: RunSoftTimeoutDeps): Promise<number>;
49
+ /**
50
+ * Entry point for the `hq core soft-timeout` command. Parses argv, runs, and
51
+ * returns the exit code (never throws for a timeout; throws only on misuse or
52
+ * spawn failure, which the caller maps to a non-zero exit).
53
+ */
54
+ export declare function softTimeoutCommand(argv: string[]): Promise<number>;
55
+ //# sourceMappingURL=soft-timeout.d.ts.map
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `hq core soft-timeout <interval> [--hard-cap <spec>] [--label <name>] -- <cmd…>`
3
+ *
4
+ * Run a command under a SOFT timeout: WARN, don't kill. This is the CLI-hosted
5
+ * form of the shell primitive `core/scripts/lib/soft-timeout.sh`, so HQ scripts
6
+ * and hooks can call `hq core soft-timeout …` directly instead of shelling out
7
+ * to a bundled script.
8
+ *
9
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events):
10
+ * a fixed deadline SIGTERMs an operation still making progress and loses the
11
+ * in-flight work. The fix is warn-and-continue — on each interval the command
12
+ * keeps running and a SOFT-TIMEOUT notice is written to stderr so whoever is
13
+ * watching decides (wait / background / kill), instead of the runner deciding
14
+ * by killing progressing work.
15
+ *
16
+ * Contract (identical to the shell primitive):
17
+ * - `<interval>` — warn every interval. Bare number = seconds; 90s / 2m / 1h.
18
+ * - `--hard-cap <spec>` — OPTIONAL safety ceiling for unattended callers.
19
+ * Pure soft has no cap and never terminates. At the cap the command is
20
+ * SIGTERM'd, then SIGKILL'd after a short grace, and the exit code is 124.
21
+ * - `--label <name>` — name shown in the notice (default: the command basename).
22
+ * - Exit status: the command's own, verbatim — except 124 on a hard-cap.
23
+ * A soft warning alone never changes the exit status.
24
+ */
25
+ import { spawn } from "node:child_process";
26
+ import { basename } from "node:path";
27
+ import { constants as osConstants } from "node:os";
28
+ /** Parse a duration spec (Ns / Nm / Nh / bare N=seconds) to integer seconds. */
29
+ export function parseDurationSecs(spec) {
30
+ const m = /^([0-9]+)([smh]?)$/.exec(spec);
31
+ if (!m)
32
+ return null;
33
+ const n = Number(m[1]);
34
+ switch (m[2]) {
35
+ case "":
36
+ case "s":
37
+ return n;
38
+ case "m":
39
+ return n * 60;
40
+ case "h":
41
+ return n * 3600;
42
+ default:
43
+ return null;
44
+ }
45
+ }
46
+ /** Parse the `hq core soft-timeout` argv. Throws a plain Error on misuse. */
47
+ export function parseSoftTimeoutArgs(argv) {
48
+ const args = [...argv];
49
+ if (args.length === 0 || args[0].startsWith("--")) {
50
+ throw new Error("soft-timeout: first argument must be the warn interval");
51
+ }
52
+ const intervalSecs = parseDurationSecs(args.shift());
53
+ if (intervalSecs === null || intervalSecs <= 0) {
54
+ throw new Error("soft-timeout: interval must be a positive duration (e.g. 120, 2m)");
55
+ }
56
+ let hardCapSecs = null;
57
+ let label = null;
58
+ // Consume our own options; the FIRST non-option token (or an explicit `--`)
59
+ // begins the wrapped command. `--` is optional because Commander strips a
60
+ // single leading `--` before these args reach us — so `hq core soft-timeout
61
+ // 30 -- sleep 60` arrives here as `30 sleep 60`, and treating the first
62
+ // non-option as the command start makes both forms parse identically.
63
+ while (args.length > 0) {
64
+ const tok = args[0];
65
+ if (tok === "--") {
66
+ args.shift();
67
+ break;
68
+ }
69
+ if (tok === "--hard-cap") {
70
+ args.shift();
71
+ const spec = args.shift();
72
+ const secs = spec ? parseDurationSecs(spec) : null;
73
+ if (secs === null || secs <= 0)
74
+ throw new Error(`soft-timeout: invalid --hard-cap '${spec ?? ""}'`);
75
+ hardCapSecs = secs;
76
+ }
77
+ else if (tok === "--label") {
78
+ args.shift();
79
+ label = args.shift() ?? null;
80
+ if (label === null)
81
+ throw new Error("soft-timeout: --label needs a value");
82
+ }
83
+ else {
84
+ break; // first non-option token: the wrapped command starts here
85
+ }
86
+ }
87
+ if (args.length === 0) {
88
+ throw new Error("soft-timeout: no command given (expected: soft-timeout <interval> [...] -- cmd)");
89
+ }
90
+ return { intervalSecs, hardCapSecs, label: label ?? null, command: args };
91
+ }
92
+ function hhmmss(now) {
93
+ return now.toISOString().slice(11, 19);
94
+ }
95
+ /**
96
+ * Execute the command under the soft timeout. Resolves to the exit code the
97
+ * process should exit with. Never rejects on a timeout — a warning is only a
98
+ * stderr line. Rejects only on spawn failure.
99
+ */
100
+ export function runSoftTimeout(parsed, deps = {}) {
101
+ const spawnFn = deps.spawnFn ?? spawn;
102
+ const err = deps.stderr ?? process.stderr;
103
+ const now = deps.now ?? (() => new Date());
104
+ const killGroup = deps.killGroup ?? ((pid, sig) => process.kill(-pid, sig));
105
+ const { intervalSecs, hardCapSecs, command } = parsed;
106
+ const label = parsed.label ?? basename(command[0]);
107
+ return new Promise((resolve, reject) => {
108
+ // detached: the child leads its own process group so a hard cap can signal
109
+ // the WHOLE group (child + descendants), not just the child — otherwise an
110
+ // orphaned grandchild (e.g. run-project's builder) keeps running after the
111
+ // wrapper returns. stdio inherited so the command's stdin/stdout/stderr —
112
+ // including piped or interactive input — pass through unchanged.
113
+ const child = spawnFn(command[0], command.slice(1), { stdio: "inherit", detached: true });
114
+ let capped = false;
115
+ let ticks = 0;
116
+ // Signal the child's process group when we can (detached leader), else the
117
+ // child alone. Guarded: the group may already be gone.
118
+ const signalTree = (sig) => {
119
+ try {
120
+ if (typeof child.pid === "number")
121
+ killGroup(child.pid, sig);
122
+ else
123
+ child.kill(sig);
124
+ }
125
+ catch {
126
+ try {
127
+ child.kill(sig);
128
+ }
129
+ catch {
130
+ /* already gone */
131
+ }
132
+ }
133
+ };
134
+ const warner = setInterval(() => {
135
+ ticks += 1;
136
+ const elapsed = ticks * intervalSecs;
137
+ err.write(`[${hhmmss(now())}] SOFT-TIMEOUT: ${label} still running after ${elapsed}s ` +
138
+ `(${ticks}× ${intervalSecs}s window) — exceeded its window; NOT killed. ` +
139
+ `Decide: wait / background / kill (kill ${child.pid}).\n`);
140
+ }, intervalSecs * 1000);
141
+ // The hard cap runs on its OWN timer, independent of the warning interval,
142
+ // so a cap shorter than (or not a multiple of) the interval still fires on
143
+ // time rather than at the next interval boundary.
144
+ let capTimer = null;
145
+ let killTimer = null;
146
+ if (hardCapSecs !== null) {
147
+ capTimer = setTimeout(() => {
148
+ capped = true;
149
+ err.write(`[${hhmmss(now())}] SOFT-TIMEOUT: ${label} hit hard cap ${hardCapSecs}s — sending SIGTERM.\n`);
150
+ signalTree("SIGTERM");
151
+ killTimer = setTimeout(() => signalTree("SIGKILL"), 5000);
152
+ }, hardCapSecs * 1000);
153
+ }
154
+ const cleanup = () => {
155
+ clearInterval(warner);
156
+ if (capTimer)
157
+ clearTimeout(capTimer);
158
+ if (killTimer)
159
+ clearTimeout(killTimer);
160
+ };
161
+ child.on("error", (e) => {
162
+ cleanup();
163
+ reject(e);
164
+ });
165
+ child.on("exit", (code, signal) => {
166
+ cleanup();
167
+ if (capped) {
168
+ resolve(124);
169
+ }
170
+ else if (code !== null) {
171
+ resolve(code);
172
+ }
173
+ else {
174
+ // Killed by a signal (external SIGINT/SIGHUP/SIGTERM/…): mirror the
175
+ // shell convention of 128 + signal number for whichever signal it was,
176
+ // so callers keep the real termination status (e.g. 130 for SIGINT).
177
+ const n = signal ? osConstants.signals[signal] : undefined;
178
+ resolve(typeof n === "number" ? 128 + n : 1);
179
+ }
180
+ });
181
+ });
182
+ }
183
+ /**
184
+ * Entry point for the `hq core soft-timeout` command. Parses argv, runs, and
185
+ * returns the exit code (never throws for a timeout; throws only on misuse or
186
+ * spawn failure, which the caller maps to a non-zero exit).
187
+ */
188
+ export async function softTimeoutCommand(argv) {
189
+ let parsed;
190
+ try {
191
+ parsed = parseSoftTimeoutArgs(argv);
192
+ }
193
+ catch (e) {
194
+ process.stderr.write(`${e.message}\n`);
195
+ return 2;
196
+ }
197
+ try {
198
+ return await runSoftTimeout(parsed);
199
+ }
200
+ catch (e) {
201
+ process.stderr.write(`soft-timeout: failed to run command: ${e.message}\n`);
202
+ return 127;
203
+ }
204
+ }
205
+ //# sourceMappingURL=soft-timeout.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `hq core timeout-guard` — PreToolUse decision for the foreground-timeout guard.
3
+ *
4
+ * The hook logic that used to live in
5
+ * `.claude/hooks/block-foreground-timeout-over-harness-ceiling.sh` now lives
6
+ * here so it can be maintained (and gated) in one place; the shipped hook is a
7
+ * thin shim that pipes the PreToolUse JSON to this command and blocks iff it
8
+ * exits 2.
9
+ *
10
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events): a
11
+ * FOREGROUND shell tool call is SIGTERM'd at the harness's outer deadline
12
+ * (~2 min default, 10 min max) regardless of any longer timeout it declares, so
13
+ * the in-flight work is lost (exit 143). We can't change the harness, so the
14
+ * guard blocks a long foreground declaration and steers it to a background run
15
+ * (no outer deadline, auto-notifies). It fires for Claude, Codex, and Grok —
16
+ * their shell tool calls all reach the hook normalized to `tool_input.command`.
17
+ *
18
+ * Rollout gate: for now the guard only acts for `@getindigo.ai` HQ users; every
19
+ * other identity (and machine identities / logged-out) is allowed through.
20
+ *
21
+ * Exit codes: 0 = allow, 2 = block.
22
+ */
23
+ /**
24
+ * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
25
+ * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
26
+ * counts) or a `perl -e 'alarm(N)…'` deadline. Segment on shell separators and
27
+ * only treat a segment's LEADING command word as an invocation, so the word
28
+ * `timeout` inside a quoted argument (e.g. `printf 'uses timeout 601s'`) does
29
+ * not trip it.
30
+ */
31
+ export declare function parseInnerDeadlineSecs(command: string): number;
32
+ export interface TimeoutGuardInput {
33
+ command: string;
34
+ toolTimeoutMs?: number;
35
+ runInBackground?: boolean;
36
+ }
37
+ export interface GuardVerdict {
38
+ block: boolean;
39
+ reason?: string;
40
+ }
41
+ /**
42
+ * Pure decision: does this foreground declaration exceed the deadline it can
43
+ * actually secure? Compares the inner deadline against the EFFECTIVE ceiling —
44
+ * the declared tool timeout (capped at the harness max) or the 2-minute default
45
+ * when none is declared — rather than always against the 10-minute max.
46
+ */
47
+ export declare function evaluateTimeoutGuard(input: TimeoutGuardInput): GuardVerdict;
48
+ export interface TimeoutGuardDeps {
49
+ /** Resolve the current HQ user email for the rollout gate. Default: cached id token. */
50
+ getEmail?: () => string | undefined;
51
+ env?: NodeJS.ProcessEnv;
52
+ stderr?: NodeJS.WritableStream;
53
+ }
54
+ /** Whether the guard is active for this identity (rollout gate). */
55
+ export declare function isGatedUser(email: string | undefined): boolean;
56
+ /**
57
+ * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
58
+ * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
59
+ * missing identity fails OPEN (allow), because a hook must not break tool calls.
60
+ */
61
+ export declare function timeoutGuardCommand(stdin: string, deps?: TimeoutGuardDeps): number;
62
+ //# sourceMappingURL=timeout-guard.d.ts.map
@@ -0,0 +1,207 @@
1
+ /**
2
+ * `hq core timeout-guard` — PreToolUse decision for the foreground-timeout guard.
3
+ *
4
+ * The hook logic that used to live in
5
+ * `.claude/hooks/block-foreground-timeout-over-harness-ceiling.sh` now lives
6
+ * here so it can be maintained (and gated) in one place; the shipped hook is a
7
+ * thin shim that pipes the PreToolUse JSON to this command and blocks iff it
8
+ * exits 2.
9
+ *
10
+ * Finding 2.1 (team harness analysis 2026-08-10 — 1,393 forced-kill events): a
11
+ * FOREGROUND shell tool call is SIGTERM'd at the harness's outer deadline
12
+ * (~2 min default, 10 min max) regardless of any longer timeout it declares, so
13
+ * the in-flight work is lost (exit 143). We can't change the harness, so the
14
+ * guard blocks a long foreground declaration and steers it to a background run
15
+ * (no outer deadline, auto-notifies). It fires for Claude, Codex, and Grok —
16
+ * their shell tool calls all reach the hook normalized to `tool_input.command`.
17
+ *
18
+ * Rollout gate: for now the guard only acts for `@getindigo.ai` HQ users; every
19
+ * other identity (and machine identities / logged-out) is allowed through.
20
+ *
21
+ * Exit codes: 0 = allow, 2 = block.
22
+ */
23
+ import { loadCachedTokens } from "@indigoai-us/hq-cloud";
24
+ import { peekIdToken } from "../../utils/id-token.js";
25
+ // Observed Claude Code Bash-tool bounds. The tool `timeout` (ms) raises the
26
+ // deadline up to the max; with none declared the default applies.
27
+ const HARNESS_DEFAULT_MS = 120_000; // 2 min
28
+ const HARNESS_MAX_MS = 600_000; // 10 min
29
+ const GATE_DOMAIN = "@getindigo.ai";
30
+ function basename(token) {
31
+ const i = token.lastIndexOf("/");
32
+ return i >= 0 ? token.slice(i + 1) : token;
33
+ }
34
+ /** Duration spec (Ns / Nm / Nh / Nd / bare N=seconds) → seconds, or 0. */
35
+ function durationToSecs(tok) {
36
+ const m = /^([0-9]+)([smhd]?)$/.exec(tok);
37
+ if (!m)
38
+ return 0;
39
+ const n = Number(m[1]);
40
+ switch (m[2]) {
41
+ case "":
42
+ case "s":
43
+ return n;
44
+ case "m":
45
+ return n * 60;
46
+ case "h":
47
+ return n * 3600;
48
+ case "d":
49
+ return n * 86400;
50
+ default:
51
+ return 0;
52
+ }
53
+ }
54
+ /**
55
+ * Worst-case inner deadline (seconds) declared in a command: a `timeout` /
56
+ * `gtimeout` prefix (matched by executable BASENAME, so `/usr/bin/timeout`
57
+ * counts) or a `perl -e 'alarm(N)…'` deadline. Segment on shell separators and
58
+ * only treat a segment's LEADING command word as an invocation, so the word
59
+ * `timeout` inside a quoted argument (e.g. `printf 'uses timeout 601s'`) does
60
+ * not trip it.
61
+ */
62
+ export function parseInnerDeadlineSecs(command) {
63
+ let worst = 0;
64
+ for (const rawSeg of command.split(/[;|&()]/)) {
65
+ const seg = rawSeg.trim();
66
+ if (!seg)
67
+ continue;
68
+ const words = seg.split(/\s+/);
69
+ let i = 0;
70
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))
71
+ i++; // skip env assigns
72
+ if (i >= words.length)
73
+ continue;
74
+ const cmd = basename(words[i]);
75
+ if (cmd !== "timeout" && cmd !== "gtimeout")
76
+ continue;
77
+ i++;
78
+ let dur = "";
79
+ while (i < words.length) {
80
+ const t = words[i];
81
+ if (t === "--foreground" || t === "--preserve-status" || t === "-v" || t === "--verbose") {
82
+ i++;
83
+ continue;
84
+ }
85
+ if (t === "-s" || t === "--signal" || t === "-k" || t === "--kill-after") {
86
+ i += 2;
87
+ continue;
88
+ }
89
+ if (t.startsWith("-")) {
90
+ i++;
91
+ continue;
92
+ }
93
+ dur = t;
94
+ break;
95
+ }
96
+ if (dur)
97
+ worst = Math.max(worst, durationToSecs(dur));
98
+ }
99
+ // perl alarm() — spans separators, so match on the raw command.
100
+ const mMin = /alarm\(\s*([0-9]+)\s*\*\s*60/.exec(command);
101
+ if (mMin)
102
+ worst = Math.max(worst, Number(mMin[1]) * 60);
103
+ else {
104
+ const mSec = /alarm\(?\s*([0-9]+)/.exec(command);
105
+ if (mSec)
106
+ worst = Math.max(worst, Number(mSec[1]));
107
+ }
108
+ return worst;
109
+ }
110
+ /**
111
+ * Pure decision: does this foreground declaration exceed the deadline it can
112
+ * actually secure? Compares the inner deadline against the EFFECTIVE ceiling —
113
+ * the declared tool timeout (capped at the harness max) or the 2-minute default
114
+ * when none is declared — rather than always against the 10-minute max.
115
+ */
116
+ export function evaluateTimeoutGuard(input) {
117
+ const { command, toolTimeoutMs } = input;
118
+ if (input.runInBackground)
119
+ return { block: false };
120
+ if (toolTimeoutMs !== undefined && toolTimeoutMs > HARNESS_MAX_MS) {
121
+ return {
122
+ block: true,
123
+ reason: `the Bash tool 'timeout' parameter is ${toolTimeoutMs}ms (> the ${HARNESS_MAX_MS}ms / 10-minute harness ceiling)`,
124
+ };
125
+ }
126
+ const effectiveMs = toolTimeoutMs !== undefined ? Math.min(toolTimeoutMs, HARNESS_MAX_MS) : HARNESS_DEFAULT_MS;
127
+ const innerSecs = parseInnerDeadlineSecs(command);
128
+ if (innerSecs > 0 && innerSecs * 1000 > effectiveMs) {
129
+ const ceilingDesc = toolTimeoutMs !== undefined
130
+ ? `the declared ${Math.round(effectiveMs / 1000)}s tool deadline`
131
+ : `the ~${HARNESS_DEFAULT_MS / 1000}s default foreground deadline`;
132
+ return {
133
+ block: true,
134
+ reason: `an inner ${innerSecs}s deadline (timeout/gtimeout/alarm) exceeds ${ceilingDesc}`,
135
+ };
136
+ }
137
+ return { block: false };
138
+ }
139
+ const BLOCK_MESSAGE = (reason) => `BLOCKED: this FOREGROUND command declares a timeout past the harness ceiling —
140
+ ${reason}.
141
+
142
+ A foreground shell tool call is SIGTERM'd at the harness's outer deadline
143
+ (~2 min default, 10 min max) regardless of any longer timeout you declared. It
144
+ will die with exit 143 ("timed out after 10m 0s") and lose whatever it was doing
145
+ — the largest harness-friction cluster in the 2026-08-10 team analysis
146
+ (finding 2.1, 1,393 events).
147
+
148
+ Do this instead: launch it as a background task (run_in_background: true).
149
+ Background tasks survive turn boundaries, have no outer deadline, and auto-notify
150
+ on completion. (\`hq core soft-timeout\` warns without killing for work you
151
+ supervise yourself.)
152
+
153
+ Policy: hq-foreground-timeout-killed-by-harness-deadline.
154
+ Bypass for one sanctioned foreground run: prefix HQ_ALLOW_LONG_FOREGROUND=1.`;
155
+ function defaultEmail() {
156
+ try {
157
+ const cached = loadCachedTokens();
158
+ if (!cached)
159
+ return undefined;
160
+ const email = peekIdToken(cached.idToken).email;
161
+ return typeof email === "string" ? email : undefined;
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ }
167
+ /** Whether the guard is active for this identity (rollout gate). */
168
+ export function isGatedUser(email) {
169
+ return typeof email === "string" && email.toLowerCase().endsWith(GATE_DOMAIN);
170
+ }
171
+ /**
172
+ * Command entry: read the PreToolUse JSON, apply the gate + short-circuits, and
173
+ * return the exit code (0 allow / 2 block). Never throws — a parse failure or a
174
+ * missing identity fails OPEN (allow), because a hook must not break tool calls.
175
+ */
176
+ export function timeoutGuardCommand(stdin, deps = {}) {
177
+ const env = deps.env ?? process.env;
178
+ const err = deps.stderr ?? process.stderr;
179
+ let payload;
180
+ try {
181
+ payload = JSON.parse(stdin);
182
+ }
183
+ catch {
184
+ return 0;
185
+ }
186
+ const ti = payload?.tool_input ?? {};
187
+ const command = typeof ti.command === "string" ? ti.command : "";
188
+ if (!command)
189
+ return 0;
190
+ // The inline escape hatch and the env escape hatch both bypass.
191
+ if (env.HQ_ALLOW_LONG_FOREGROUND === "1")
192
+ return 0;
193
+ if (/(^|\s)HQ_ALLOW_LONG_FOREGROUND=1(\s|$)/.test(command))
194
+ return 0;
195
+ // Rollout gate: only act for @getindigo.ai identities.
196
+ const getEmail = deps.getEmail ?? defaultEmail;
197
+ if (!isGatedUser(getEmail()))
198
+ return 0;
199
+ const runInBackground = ti.run_in_background === true;
200
+ const toolTimeoutMs = typeof ti.timeout === "number" ? ti.timeout : undefined;
201
+ const verdict = evaluateTimeoutGuard({ command, toolTimeoutMs, runInBackground });
202
+ if (!verdict.block)
203
+ return 0;
204
+ err.write(`${BLOCK_MESSAGE(verdict.reason ?? "declared timeout exceeds the harness ceiling")}\n`);
205
+ return 2;
206
+ }
207
+ //# sourceMappingURL=timeout-guard.js.map
@@ -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.0",
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": {