@bookedsolid/rea 0.38.1 → 0.40.0

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.
@@ -89,7 +89,47 @@ export interface HookDelegationAdvisoryOptions {
89
89
  * Production omits.
90
90
  */
91
91
  stdinOverride?: string;
92
+ /**
93
+ * Test seam — override the sleep used by
94
+ * `sessionHasRealDelegation`'s poll-and-backoff loop. Production
95
+ * omits and uses real `setTimeout`-backed sleeps; tests pass a
96
+ * controllable fake so the race-coverage tests don't wall-clock on
97
+ * the real 500ms budget.
98
+ *
99
+ * 0.40.0 charter item 1.
100
+ */
101
+ sleepOverride?: (ms: number) => Promise<void>;
92
102
  }
103
+ /**
104
+ * Backoff schedule (in milliseconds) for `sessionHasRealDelegation`'s
105
+ * poll-and-backoff loop.
106
+ *
107
+ * 0.40.0 charter item 1 — closes the `& disown` race between
108
+ * `delegation-capture.sh` (which fire-and-forgets `rea hook
109
+ * delegation-signal --detach &` for sub-50ms PreToolUse latency) and the
110
+ * `delegation-advisory.sh` PostToolUse path (which reads the audit log
111
+ * to decide whether the session has delegated). A `git commit` landing
112
+ * within the narrow window between an Agent dispatch and the audit
113
+ * append-on-disk would read the stale chain, see no delegation, fire
114
+ * the nudge spuriously, AND write the `.fired` sentinel — silencing
115
+ * every future advisory in the session even though delegation DID
116
+ * happen.
117
+ *
118
+ * The schedule is delays BETWEEN re-reads (NOT cumulative): 50ms,
119
+ * 150ms, 300ms. Total worst-case 500ms — acceptable hot-path budget
120
+ * for a PostToolUse hook running on `Bash|Edit|Write|MultiEdit|
121
+ * NotebookEdit`. The first read is immediate (no upfront delay), so a
122
+ * session that DID delegate before threshold-crossing pays zero extra
123
+ * latency. Only the rare "we crossed threshold while a recent
124
+ * delegation signal hasn't yet hit disk" case pays the full budget,
125
+ * and only ONCE per session (the `.fired` sentinel suppresses future
126
+ * scans).
127
+ *
128
+ * Exported for the race-coverage test in
129
+ * `delegation-advisory.test.ts` so it can assert on the schedule
130
+ * without duplicating the constant.
131
+ */
132
+ export declare const DELEGATION_POLL_BACKOFF_MS: readonly number[];
93
133
  /**
94
134
  * Derive a filesystem-safe, **collision-free** per-session state-key
95
135
  * basename from an untrusted session id.
@@ -70,6 +70,36 @@ import { loadDelegationRecords, listRotatedAuditFiles, } from './audit-specialis
70
70
  import { discoverRoster, countsAsRealDelegation, DEFAULT_EXEMPT_SUBAGENTS, } from './roster.js';
71
71
  import { REA_DIR } from './utils.js';
72
72
  const DEFAULT_THRESHOLD = 25;
73
+ /**
74
+ * Backoff schedule (in milliseconds) for `sessionHasRealDelegation`'s
75
+ * poll-and-backoff loop.
76
+ *
77
+ * 0.40.0 charter item 1 — closes the `& disown` race between
78
+ * `delegation-capture.sh` (which fire-and-forgets `rea hook
79
+ * delegation-signal --detach &` for sub-50ms PreToolUse latency) and the
80
+ * `delegation-advisory.sh` PostToolUse path (which reads the audit log
81
+ * to decide whether the session has delegated). A `git commit` landing
82
+ * within the narrow window between an Agent dispatch and the audit
83
+ * append-on-disk would read the stale chain, see no delegation, fire
84
+ * the nudge spuriously, AND write the `.fired` sentinel — silencing
85
+ * every future advisory in the session even though delegation DID
86
+ * happen.
87
+ *
88
+ * The schedule is delays BETWEEN re-reads (NOT cumulative): 50ms,
89
+ * 150ms, 300ms. Total worst-case 500ms — acceptable hot-path budget
90
+ * for a PostToolUse hook running on `Bash|Edit|Write|MultiEdit|
91
+ * NotebookEdit`. The first read is immediate (no upfront delay), so a
92
+ * session that DID delegate before threshold-crossing pays zero extra
93
+ * latency. Only the rare "we crossed threshold while a recent
94
+ * delegation signal hasn't yet hit disk" case pays the full budget,
95
+ * and only ONCE per session (the `.fired` sentinel suppresses future
96
+ * scans).
97
+ *
98
+ * Exported for the race-coverage test in
99
+ * `delegation-advisory.test.ts` so it can assert on the schedule
100
+ * without duplicating the constant.
101
+ */
102
+ export const DELEGATION_POLL_BACKOFF_MS = [50, 150, 300];
73
103
  /**
74
104
  * Maximum length of the human-readable prefix in a state key. The full
75
105
  * key is `<prefix>-<16-hex-hash>`, so a 64-char cap keeps basenames well
@@ -250,7 +280,22 @@ export function advisoryMessage(count, threshold) {
250
280
  * `since` anchor is `undefined` and behavior is the pre-0.31.0
251
281
  * single-file walk.
252
282
  */
253
- async function sessionHasRealDelegation(reaRoot, sessionId, exemptSubagents) {
283
+ /**
284
+ * Real-clock sleep used by the production poll-and-backoff loop.
285
+ * Factored out so tests can swap it for a fake controllable scheduler
286
+ * via `HookDelegationAdvisoryOptions.sleepOverride`.
287
+ */
288
+ function realSleep(ms) {
289
+ return new Promise((resolve) => setTimeout(resolve, ms));
290
+ }
291
+ /**
292
+ * Single audit-chain scan: returns `'delegated'` when a real
293
+ * delegation signal is found, `'not-delegated'` when scanning succeeds
294
+ * but no real signal is in the chain, and `'unreadable'` when audit
295
+ * loading throws (the chain is missing / unreadable). Split out from
296
+ * the polling loop so each retry runs identical scan logic.
297
+ */
298
+ async function scanForRealDelegationOnce(reaRoot, sessionId, exemptSubagents) {
254
299
  let records;
255
300
  try {
256
301
  // Resolve the rotated-file set the same way `rea audit specialists`
@@ -263,13 +308,10 @@ async function sessionHasRealDelegation(reaRoot, sessionId, exemptSubagents) {
263
308
  records = loaded.records;
264
309
  }
265
310
  catch {
266
- // Audit log unreadable — we cannot prove the session delegated, so
267
- // we DON'T fire (fail toward silence, not toward a false-positive
268
- // nudge). Returning `true` here suppresses the advisory.
269
- return true;
311
+ return 'unreadable';
270
312
  }
271
313
  if (records.length === 0)
272
- return false;
314
+ return 'not-delegated';
273
315
  const roster = discoverRoster(reaRoot);
274
316
  for (const rec of records) {
275
317
  if (countsAsRealDelegation({
@@ -278,9 +320,45 @@ async function sessionHasRealDelegation(reaRoot, sessionId, exemptSubagents) {
278
320
  roster,
279
321
  exempt: exemptSubagents,
280
322
  })) {
281
- return true;
323
+ return 'delegated';
282
324
  }
283
325
  }
326
+ return 'not-delegated';
327
+ }
328
+ async function sessionHasRealDelegation(reaRoot, sessionId, exemptSubagents, sleep = realSleep) {
329
+ // 0.40.0 charter item 1 — poll-and-backoff before declaring
330
+ // "no delegation in this session".
331
+ //
332
+ // The producer (`delegation-capture.sh`) calls `rea hook
333
+ // delegation-signal --detach &` to fire-and-forget the audit append.
334
+ // For sub-50ms PreToolUse latency this is the right call, but it
335
+ // opens a narrow race: a write-class call (Bash/Edit/Write/…)
336
+ // landing in the same tick as an Agent/Skill dispatch can run this
337
+ // predicate BEFORE the audit append commits to disk. Pre-fix, the
338
+ // function then returned `false`, the caller fired the advisory,
339
+ // wrote the `.fired` sentinel, and silenced every future nudge in
340
+ // the session — even though delegation DID happen.
341
+ //
342
+ // Each retry runs a full audit scan. The first scan is immediate
343
+ // (no upfront delay); subsequent scans wait per
344
+ // `DELEGATION_POLL_BACKOFF_MS`. Worst-case total: 50+150+300 = 500ms
345
+ // for the four-scan path. We exit early as soon as a delegation is
346
+ // observed OR the chain becomes unreadable (preserving the pre-fix
347
+ // "audit log unreadable → suppress the advisory" posture so a
348
+ // missing chain never produces a false-positive nudge).
349
+ let outcome = await scanForRealDelegationOnce(reaRoot, sessionId, exemptSubagents);
350
+ if (outcome === 'delegated')
351
+ return true;
352
+ if (outcome === 'unreadable')
353
+ return true;
354
+ for (const waitMs of DELEGATION_POLL_BACKOFF_MS) {
355
+ await sleep(waitMs);
356
+ outcome = await scanForRealDelegationOnce(reaRoot, sessionId, exemptSubagents);
357
+ if (outcome === 'delegated')
358
+ return true;
359
+ if (outcome === 'unreadable')
360
+ return true;
361
+ }
284
362
  return false;
285
363
  }
286
364
  /**
@@ -379,7 +457,7 @@ export async function computeDelegationAdvisory(options) {
379
457
  // verbatim (or `'unknown'` for untagged sessions), so the `stateKey`
380
458
  // filesystem form would never match (see the comment at the
381
459
  // `auditSessionId` / `stateKey` split above).
382
- const delegated = await sessionHasRealDelegation(reaRoot, auditSessionId, policy.exemptSubagents);
460
+ const delegated = await sessionHasRealDelegation(reaRoot, auditSessionId, policy.exemptSubagents, options.sleepOverride);
383
461
  if (delegated) {
384
462
  // Session DID delegate to a real specialist — no nudge warranted.
385
463
  // We deliberately do NOT write the `.fired` sentinel here: if the
@@ -97,6 +97,159 @@ export declare function checkPrepareCommitMsgHook(baseDir: string): CheckResult;
97
97
  * (the doctor then short-circuits past every Codex check).
98
98
  */
99
99
  export declare function checkCodexBinaryOnPath(): CheckResult;
100
+ /**
101
+ * Probe interface accepted by the policy-reader tier checks. Each
102
+ * field is optional; when omitted the check uses the real-environment
103
+ * default (PATH walk, spawnSync). Tests inject stubs to get
104
+ * deterministic, fast verdicts without touching the real filesystem or
105
+ * spawning subprocesses.
106
+ *
107
+ * - `cliDistExists` — does the rea CLI binary exist on disk at one of
108
+ * the two shim-resolved paths? Cheap (single `existsSync`). Used to
109
+ * give a clear "missing vs. broken" error message when Tier 1 is
110
+ * unreachable.
111
+ * - `cliInvokable` — does the resolved CLI actually respond to
112
+ * `rea hook policy-get version --json`? The expensive probe (one
113
+ * subprocess spawn). Mirrors EXACTLY what `_pr_load_full_json`
114
+ * does in `hooks/_lib/policy-reader.sh` so a stale or broken dist
115
+ * reports `warn` here — same outcome the real shim ladder would
116
+ * produce. Codex round-1 P2 (2026-05-16).
117
+ * - `python3OnPath` / `python3PyYamlReachable` — Tier 2 reachability.
118
+ * `python3PyYamlReachable` returns `true` when both python3 AND the
119
+ * `yaml` stdlib (PyYAML) can be imported (the Tier 2 loader needs
120
+ * both).
121
+ * - `awkOnPath` / `jqOnPath` — Tier 3 + JSON-accelerator reachability.
122
+ */
123
+ export interface PolicyReaderProbes {
124
+ cliDistExists?: (baseDir: string) => boolean;
125
+ cliInvokable?: (baseDir: string) => boolean;
126
+ python3OnPath?: () => string | null;
127
+ /**
128
+ * 0.40.0 charter item 3 — accepts the consumer's `baseDir` so the
129
+ * probe can thread it as `cwd` to the spawned `python3 -c` process.
130
+ * Pre-fix, the spawn happened in doctor's own cwd, which meant the
131
+ * sys.path scrub (which removes "", ".", CWD, realpath(CWD) to
132
+ * mirror policy-reader.sh's defense against a malicious repo-local
133
+ * `./yaml.py`) operated on the wrong directory when `rea doctor`
134
+ * was invoked from outside the consumer tree (e.g. `cd /tmp && rea
135
+ * doctor --base-dir /Users/.../consumer-repo`).
136
+ *
137
+ * Probes that don't care about cwd (test stubs, fakes) can simply
138
+ * ignore the argument; the default production probe uses it.
139
+ */
140
+ python3PyYamlReachable?: (baseDir: string) => boolean;
141
+ awkOnPath?: () => string | null;
142
+ jqOnPath?: () => string | null;
143
+ }
144
+ /**
145
+ * Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
146
+ * present at one of the two shim-resolved paths (consumer install OR
147
+ * dogfood `dist/`) AND actually responds to `rea hook policy-get
148
+ * version --json`. The shim ladder uses that exact invocation as its
149
+ * Tier 1 probe (see `_pr_load_full_json` in `hooks/_lib/policy-reader.sh`);
150
+ * mirroring it here means a stale or broken dist (file present but
151
+ * import-throws / postinstall failed) reports `warn` — matching the
152
+ * real fall-through to Tier 2/3 the shim would do at runtime.
153
+ *
154
+ * Three states:
155
+ * - dist present + CLI responds → `pass` (canonical loader fully wired).
156
+ * - dist present + CLI broken → `warn` (stale build, missing native
157
+ * module, broken postinstall — needs `pnpm build` / `rea upgrade`).
158
+ * - dist absent → `warn` (not installed; Tier 2/3 still cover).
159
+ *
160
+ * Codex round-1 P2 (2026-05-16) replaced the file-existence-only
161
+ * probe with this CLI-invocation probe — pre-fix, a consumer with
162
+ * `dist/cli/index.js` present but throwing on load would see `pass`
163
+ * here while every real shim would silently fall through.
164
+ */
165
+ export declare function checkPolicyReaderTier1(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
166
+ /**
167
+ * Tier 2 — python3 + stdlib `yaml` (PyYAML). Handles BOTH block-form
168
+ * and flow-form YAML; the practical floor when Tier 1 is unreachable.
169
+ *
170
+ * Three states:
171
+ * - python3 present + PyYAML importable → `pass`.
172
+ * - python3 present, PyYAML missing → `warn` (the loader will fall
173
+ * through to Tier 3, which only handles block-form).
174
+ * - python3 absent → `warn` (same Tier 3 fall-through).
175
+ *
176
+ * Never `fail` — Tier 3 is still a valid floor for block-form policy.
177
+ * The warning highlights the silent no-op risk for flow-form lookups
178
+ * when CLI is also unreachable.
179
+ */
180
+ export declare function checkPolicyReaderTier2(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
181
+ /**
182
+ * Tier 3 — awk block-form parser. Last-resort no-dep fallback.
183
+ * Practically always present (POSIX requirement).
184
+ *
185
+ * 0.40.0 charter item 2 — conditional verdict, refined by codex
186
+ * round 1 P2:
187
+ * - awk present → `pass`
188
+ * - awk absent AND Tier 2 reachable → `warn`
189
+ * (Tier 2 implies python3, which is a list-walker)
190
+ * - awk absent AND Tier 1 reachable AND a list walker
191
+ * (jq OR python3) is on PATH → `warn`
192
+ * - awk absent AND Tier 1 reachable BUT no list walker → `fail`
193
+ * (codex round 1 P2 — list-valued policy reads silently
194
+ * fail-closed even though scalar reads work, so the
195
+ * downgrade-to-warn is misleading; doctor would exit 0 on a
196
+ * broken install)
197
+ * - awk absent AND no other tier reachable → `fail`
198
+ *
199
+ * Pre-fix the absent-awk branch always returned `fail` — but when
200
+ * Tier 1 (rea CLI) AND/OR Tier 2 (python3+PyYAML) are reachable AND
201
+ * the list walker exists, the operator's effective floor is fine even
202
+ * without awk; Tier 3 is the LAST fallback, not a hard requirement.
203
+ * The summary check (`checkPolicyReaderTierSummary`) already
204
+ * aggregates correctly; this per-tier verdict now reflects the same
205
+ * severity logic so an operator who reads ONLY the Tier 3 row isn't
206
+ * misled into thinking the install is broken on a perfectly-
207
+ * functional box that has python3 + jq + the rea CLI all wired but
208
+ * happens to lack awk.
209
+ *
210
+ * Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
211
+ * present + CLI invokable) and Tier 2's reachability. Probes are
212
+ * threaded through identically.
213
+ */
214
+ export declare function checkPolicyReaderTier3(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
215
+ /**
216
+ * jq — optional accelerator used by Tier 1/2's JSON subtree parsing.
217
+ * Per the 0.37.0 round-1 P2 fix the helper falls back to a python3
218
+ * walker when jq is absent (still correct, just an extra spawn per
219
+ * leaf). `warn` when missing so operators know they're paying the
220
+ * latency cost.
221
+ *
222
+ * `info` when present — no action needed, just confirming the
223
+ * accelerator is wired.
224
+ */
225
+ export declare function checkPolicyReaderJq(probes?: PolicyReaderProbes): CheckResult;
226
+ /**
227
+ * Summary roll-up: which tiers are reachable, what's the effective
228
+ * floor when the CLI is unreachable, and is flow-form policy at risk
229
+ * of silent no-op.
230
+ *
231
+ * Four verdicts:
232
+ * - `pass` — Tier 1 OR Tier 2 reachable AND a JSON list walker
233
+ * (jq or python3) is available. Flow-form scalars AND flow-form
234
+ * arrays both parse correctly via whichever tier is hit first.
235
+ * - `warn` (flow-form-lists-degraded) — Tier 1 reachable but neither
236
+ * jq nor python3 on PATH. Flow-form SCALARS parse correctly via
237
+ * the CLI's JSON output, but `policy_reader_get_list` cannot
238
+ * iterate the resulting JSON array — it falls through to Tier 3
239
+ * awk, which silently misses flow-form arrays like
240
+ * `blocked_paths: [.env, ...]`. Codex round-1 P2 (2026-05-16).
241
+ * - `warn` (Tier-3-only) — Only Tier 3 (awk) reachable. Block-form
242
+ * policy works; flow-form scalars AND arrays both silently no-op
243
+ * on every shim fallback.
244
+ * - `fail` — No tiers reachable. Shims fail closed on every policy
245
+ * lookup. (Practically requires losing awk too — see Tier 3.)
246
+ *
247
+ * Tier 2 implies python3 is on PATH (it's the interpreter that runs
248
+ * the loader), so when Tier 2 is reachable the list-iteration python3
249
+ * fallback is also reachable — only the Tier-1-without-list-walker
250
+ * shape can produce the degraded warning.
251
+ */
252
+ export declare function checkPolicyReaderTierSummary(baseDir: string, probes?: PolicyReaderProbes): CheckResult;
100
253
  /**
101
254
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
102
255
  * responsiveness (pass/warn) and one informational line about the last
@@ -1,4 +1,4 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
2
  import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import fsPromises from 'node:fs/promises';
@@ -950,6 +950,649 @@ export function checkCodexBinaryOnPath() {
950
950
  'To disable the push-gate instead, set policy.review.codex_required: false in .rea/policy.yaml.',
951
951
  };
952
952
  }
953
+ /**
954
+ * 0.39.0 — `rea doctor` visibility into the 4-tier shim policy reader.
955
+ *
956
+ * `hooks/_lib/policy-reader.sh` (introduced 0.37.0) is the unified
957
+ * shim-side policy reader. Each shim sources it and reads policy
958
+ * values via a graceful-degradation ladder:
959
+ *
960
+ * Tier 1: `rea hook policy-get --json` — canonical TS loader.
961
+ * Tier 2: `python3` + stdlib `yaml` (PyYAML).
962
+ * Tier 3: `awk` block-form parser (last resort, block-form ONLY).
963
+ * Tier 4: fail-closed sentinel.
964
+ *
965
+ * The Tier 1/2 path handles BOTH block-form and flow-form YAML
966
+ * (`local_review: { mode: off }`). Tier 3 only handles block-form, so
967
+ * a consumer with flow-form policy AND no reachable CLI AND no python3
968
+ * silently no-ops on every shim fallback path — exactly the split-brain
969
+ * 0.37.0 set out to fix. The risk persists if the consumer's box lacks
970
+ * the upper tiers; operators currently have no way to see which tier
971
+ * their shims would actually use.
972
+ *
973
+ * These doctor checks surface the tier inventory so the gap is visible
974
+ * before it produces a silent regression. Each check is independent and
975
+ * uses optional probe-function injection so unit tests can simulate any
976
+ * combination of tier availability without manipulating PATH.
977
+ *
978
+ * Pure environment probes — no policy read, no shim spawn. Doctor calls
979
+ * each one in turn and the summary check aggregates the verdicts.
980
+ */
981
+ /**
982
+ * Cheap PATH walker — returns the absolute path of `bin` when found
983
+ * with an executable bit set, or `null` otherwise. Mirrors
984
+ * `resolveCodexBinary`'s POSIX path but generalized for any binary.
985
+ *
986
+ * Windows path: walks PATHEXT and the bare name like `resolveCodexBinary`
987
+ * does for `codex`. Most consumer machines that run the shim ladder are
988
+ * POSIX (the shim is bash); Windows support is best-effort.
989
+ */
990
+ function resolveBinaryOnPath(bin) {
991
+ const isWindows = process.platform === 'win32';
992
+ const pathEnv = process.env.PATH ?? process.env.Path ?? '';
993
+ if (pathEnv.length === 0)
994
+ return null;
995
+ const sep = isWindows ? ';' : ':';
996
+ const entries = pathEnv.split(sep).filter((p) => p.length > 0);
997
+ if (isWindows) {
998
+ const pathExt = (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';');
999
+ for (const dir of entries) {
1000
+ for (const ext of pathExt) {
1001
+ const candidate = path.join(dir, `${bin}${ext}`);
1002
+ try {
1003
+ const st = fs.statSync(candidate);
1004
+ if (st.isFile())
1005
+ return candidate;
1006
+ }
1007
+ catch {
1008
+ // not present — keep walking
1009
+ }
1010
+ }
1011
+ const bare = path.join(dir, bin);
1012
+ try {
1013
+ const st = fs.statSync(bare);
1014
+ if (st.isFile())
1015
+ return bare;
1016
+ }
1017
+ catch {
1018
+ // not present — keep walking
1019
+ }
1020
+ }
1021
+ return null;
1022
+ }
1023
+ for (const dir of entries) {
1024
+ const candidate = path.join(dir, bin);
1025
+ try {
1026
+ const st = fs.statSync(candidate);
1027
+ if (st.isFile() && (st.mode & 0o111) !== 0)
1028
+ return candidate;
1029
+ }
1030
+ catch {
1031
+ // not present — keep walking
1032
+ }
1033
+ }
1034
+ return null;
1035
+ }
1036
+ /** Resolve the shim's preferred CLI dist path, or null when no layout matches. */
1037
+ function resolveCliDistPath(baseDir) {
1038
+ // The shim's Tier 1 path requires the rea CLI binary to be
1039
+ // resolvable from the consumer's tree. Two layouts cover every
1040
+ // real-world install:
1041
+ // 1. <baseDir>/node_modules/@bookedsolid/rea/dist/cli/index.js
1042
+ // (consumer install — `pnpm i @bookedsolid/rea`)
1043
+ // 2. <baseDir>/dist/cli/index.js
1044
+ // (rea-repo dogfood after `pnpm build`)
1045
+ // Either presence is enough for the shim's sandboxed CLI resolution
1046
+ // (see hooks/_lib/shim-runtime.sh).
1047
+ const consumerCli = path.join(baseDir, 'node_modules', '@bookedsolid', 'rea', 'dist', 'cli', 'index.js');
1048
+ if (fs.existsSync(consumerCli))
1049
+ return consumerCli;
1050
+ const dogfoodCli = path.join(baseDir, 'dist', 'cli', 'index.js');
1051
+ if (fs.existsSync(dogfoodCli))
1052
+ return dogfoodCli;
1053
+ return null;
1054
+ }
1055
+ function defaultCliDistExists(baseDir) {
1056
+ return resolveCliDistPath(baseDir) !== null;
1057
+ }
1058
+ /**
1059
+ * Sandbox check — mirrors `shim_sandbox_check` in
1060
+ * `hooks/_lib/shim-runtime.sh` (introduced 0.38.0).
1061
+ *
1062
+ * Codex round-2 P1 (2026-05-16): the pre-fix `defaultCliInvokable`
1063
+ * spawned the resolved CLI WITHOUT this validation. An attacker who
1064
+ * could plant a `dist/cli/index.js` outside `realpath(baseDir)` (via
1065
+ * a symlink) — OR plant one inside the tree but WITHOUT an ancestor
1066
+ * `package.json` whose `name === "@bookedsolid/rea"` — would have
1067
+ * their forged code executed every time doctor probed Tier 1
1068
+ * reachability. The real shim chain refuses these layouts; the
1069
+ * doctor probe MUST refuse them identically so it cannot be tricked
1070
+ * into reporting `pass` on a layout the production shims would
1071
+ * never trust.
1072
+ *
1073
+ * Returns `true` when:
1074
+ * 1. `realpath(cli)` resolves AND lives INSIDE `realpath(baseDir)`
1075
+ * (no symlink-out of the project)
1076
+ * 2. an ancestor `package.json` (walking up from
1077
+ * `dirname(dirname(dirname(real)))` — i.e. the package root for
1078
+ * a `dist/cli/index.js` shape) has `name === "@bookedsolid/rea"`
1079
+ * (max 20 hops)
1080
+ *
1081
+ * Returns `false` on any failure (realpath miss, escapes-project,
1082
+ * missing/wrong package.json). Doctor's Tier 1 check then treats a
1083
+ * sandbox-failed CLI identically to a CLI-missing layout — both
1084
+ * report `warn` ("Tier 1 unreachable") rather than `pass`.
1085
+ *
1086
+ * This mirrors the bash logic EXACTLY:
1087
+ * - `fs.realpathSync` on both paths (no symlink slippage)
1088
+ * - path-prefix containment via `realProj + sep` (so a sibling
1089
+ * directory whose name STARTS with realProj cannot match)
1090
+ * - ancestor walk capped at 20 hops with a filesystem-root break
1091
+ * (`cur === path.dirname(cur)`)
1092
+ * - JSON parse failures in any candidate `package.json` are
1093
+ * swallowed and the walk continues (mirrors the bash `try/catch`)
1094
+ *
1095
+ * Kept in sync with the bash helper: any future change to the
1096
+ * sandbox-check shape (e.g. CLI-shape enforcement) MUST be applied
1097
+ * in both places.
1098
+ */
1099
+ function sandboxCheckCli(cli, baseDir) {
1100
+ let real;
1101
+ let realProj;
1102
+ try {
1103
+ real = fs.realpathSync(cli);
1104
+ }
1105
+ catch {
1106
+ return false;
1107
+ }
1108
+ try {
1109
+ realProj = fs.realpathSync(baseDir);
1110
+ }
1111
+ catch {
1112
+ return false;
1113
+ }
1114
+ const sep = path.sep;
1115
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
1116
+ if (!(real === realProj || real.startsWith(projWithSep))) {
1117
+ return false;
1118
+ }
1119
+ // Walk ancestor directories from the package root (3 levels up
1120
+ // from a `<root>/dist/cli/index.js` shape) looking for a
1121
+ // package.json whose `name === "@bookedsolid/rea"`. Max 20 hops
1122
+ // with a filesystem-root break so we never loop forever on
1123
+ // exotic mount layouts.
1124
+ let cur = path.dirname(path.dirname(path.dirname(real)));
1125
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
1126
+ const pj = path.join(cur, 'package.json');
1127
+ if (fs.existsSync(pj)) {
1128
+ try {
1129
+ const data = JSON.parse(fs.readFileSync(pj, 'utf8'));
1130
+ if (data && data.name === '@bookedsolid/rea') {
1131
+ return true;
1132
+ }
1133
+ }
1134
+ catch {
1135
+ // keep walking — malformed package.json on the path is not fatal
1136
+ }
1137
+ }
1138
+ cur = path.dirname(cur);
1139
+ }
1140
+ return false;
1141
+ }
1142
+ /**
1143
+ * Codex round-1 P2 (2026-05-16): the file-presence probe alone allows
1144
+ * a stale or broken dist (e.g. an upgrade-lagged consumer who never
1145
+ * re-ran `pnpm build`) to falsely report `pass` while the real shim
1146
+ * ladder in `hooks/_lib/policy-reader.sh` would skip Tier 1 because
1147
+ * `rea hook policy-get version --json` exits non-zero. We mirror that
1148
+ * exact probe verbatim — same key (`version`), same `--json` flag,
1149
+ * same accept-criterion (exit 0 + non-empty stdout).
1150
+ *
1151
+ * Codex round-2 P1 (2026-05-16): BEFORE invoking the resolved CLI,
1152
+ * apply the same realpath + ancestor-package.json sandbox check the
1153
+ * shims apply in `hooks/_lib/shim-runtime.sh::shim_sandbox_check`.
1154
+ * Pre-fix, an attacker who could plant a `dist/cli/index.js` via a
1155
+ * symlink-out (or without a `@bookedsolid/rea` package.json ancestor)
1156
+ * would have their forged code executed every probe call — yet the
1157
+ * real shim ladder would refuse the same layout. This probe MUST
1158
+ * refuse identically so it cannot mis-report `pass` on an
1159
+ * unsandboxed CLI.
1160
+ *
1161
+ * Returns `true` when the CLI responds correctly; `false` when the
1162
+ * dist is missing OR present-but-broken OR present-but-unsandboxed.
1163
+ * Doctor's Tier 1 check then surfaces the difference: missing →
1164
+ * install guidance; broken/unsandboxed → rebuild guidance. (The
1165
+ * unsandboxed branch deliberately collapses into the "broken" bucket
1166
+ * because either way Tier 1 is unreachable for the shim chain.)
1167
+ *
1168
+ * 8s timeout: the CLI's `hook policy-get` path is local-only (zod
1169
+ * load + YAML parse + JSON walk); on any reasonable machine it
1170
+ * resolves in under a second. The timeout is a defense against a CLI
1171
+ * that hangs on import (a broken postinstall, a missing native module)
1172
+ * rather than a normal-operation budget.
1173
+ */
1174
+ function defaultCliInvokable(baseDir) {
1175
+ const cli = resolveCliDistPath(baseDir);
1176
+ if (cli === null)
1177
+ return false;
1178
+ // Codex round-2 P1: sandbox check BEFORE spawn. Pre-fix the probe
1179
+ // spawned arbitrary code that happened to live at the expected
1180
+ // shim-resolved path; if a symlink-out OR a missing rea
1181
+ // package.json ancestor existed, we executed an attacker payload.
1182
+ if (!sandboxCheckCli(cli, baseDir))
1183
+ return false;
1184
+ try {
1185
+ const res = spawnSync('node', [cli, 'hook', 'policy-get', 'version', '--json'], {
1186
+ cwd: baseDir,
1187
+ timeout: 8_000,
1188
+ // Tier 1 reads policy.yaml at REA_ROOT — propagate so the probe
1189
+ // honors the same scope the real shim chain would (a missing
1190
+ // `CLAUDE_PROJECT_DIR` falls back to cwd, which doctor has
1191
+ // already set).
1192
+ env: { ...process.env, CLAUDE_PROJECT_DIR: baseDir },
1193
+ encoding: 'utf8',
1194
+ stdio: ['ignore', 'pipe', 'ignore'],
1195
+ });
1196
+ if (res.status !== 0)
1197
+ return false;
1198
+ const out = (res.stdout ?? '').trim();
1199
+ return out.length > 0;
1200
+ }
1201
+ catch {
1202
+ return false;
1203
+ }
1204
+ }
1205
+ function defaultPython3PyYamlReachable(baseDir) {
1206
+ // The Tier 2 loader runs `python3 -c "import yaml"`. We mirror that
1207
+ // probe verbatim so a `yaml`-installable-but-broken interpreter is
1208
+ // not falsely reported as "reachable". Apply the SAME env scrub
1209
+ // (PYTHONPATH / PYTHONHOME / PYTHONSTARTUP unset, PYTHONSAFEPATH=1)
1210
+ // that policy-reader.sh applies, so a repo-local `yaml.py` cannot
1211
+ // shadow the stdlib copy here either — otherwise this probe would
1212
+ // report `true` against a malicious repo where the actual loader
1213
+ // would (correctly) refuse to import.
1214
+ //
1215
+ // Codex round-3 P1 (2026-05-16): `PYTHONSAFEPATH=1` is the env-var
1216
+ // form of `python3 -P` and is only honored on Python 3.11+. On
1217
+ // Python 3.4-3.10 (still installed by default on macOS Big Sur /
1218
+ // Monterey / Ventura, RHEL 8, Ubuntu 20.04, …) it is SILENTLY
1219
+ // IGNORED — meaning the interpreter will still prepend `""`/`"."`/
1220
+ // CWD to `sys.path[0]` and import a repo-local `./yaml.py` instead
1221
+ // of the stdlib copy. The production loader in
1222
+ // hooks/_lib/policy-reader.sh closes this gap with a defensive
1223
+ // sys.path scrub at the top of every `python3 -c` body (see the
1224
+ // "Codex round 2 P1" comment block in policy-reader.sh:256-267).
1225
+ // We MUST mirror that scrub here — without it, a malicious repo
1226
+ // could plant `./yaml.py`, get this probe to report `true`, while
1227
+ // the real Tier 2 loader (which DOES scrub) refuses to import and
1228
+ // falls through to Tier 3. The doctor verdict would then point
1229
+ // operators at the wrong tier when diagnosing a stuck shim.
1230
+ try {
1231
+ const probeEnv = { ...process.env, PYTHONSAFEPATH: '1' };
1232
+ delete probeEnv['PYTHONPATH'];
1233
+ delete probeEnv['PYTHONHOME'];
1234
+ delete probeEnv['PYTHONSTARTUP'];
1235
+ // Same scrub shape as policy-reader.sh's Tier 2 body — strip
1236
+ // empty/CWD entries from sys.path BEFORE the `import yaml` so
1237
+ // the probe and the production loader produce the same answer
1238
+ // on Python 3.4-3.10.
1239
+ const probeBody = [
1240
+ 'import sys',
1241
+ 'import os',
1242
+ '_cwd = os.getcwd()',
1243
+ '_cwd_real = os.path.realpath(_cwd)',
1244
+ 'sys.path[:] = [p for p in sys.path if p not in ("", ".", _cwd, _cwd_real)]',
1245
+ 'import yaml',
1246
+ ].join('\n');
1247
+ // 0.40.0 charter item 3 — thread `baseDir` as cwd so the sys.path
1248
+ // scrub above strips THIS consumer's repo root (the directory the
1249
+ // production shim chain runs from), not doctor's own cwd. Pre-fix,
1250
+ // `rea doctor --base-dir <consumer>` invoked from `/tmp/foo` would
1251
+ // scrub against `/tmp/foo`, leaving any `<consumer>/yaml.py`
1252
+ // shadowing potential undetected — exactly the multi-repo workflow
1253
+ // every other doctor probe (cliInvokable, …) already handles by
1254
+ // setting cwd to baseDir.
1255
+ const res = spawnSync('python3', ['-c', probeBody], {
1256
+ cwd: baseDir,
1257
+ env: probeEnv,
1258
+ timeout: 5_000,
1259
+ stdio: ['ignore', 'ignore', 'ignore'],
1260
+ });
1261
+ return res.status === 0;
1262
+ }
1263
+ catch {
1264
+ return false;
1265
+ }
1266
+ }
1267
+ const DEFAULT_PROBES = {
1268
+ cliDistExists: defaultCliDistExists,
1269
+ cliInvokable: defaultCliInvokable,
1270
+ python3OnPath: () => resolveBinaryOnPath('python3'),
1271
+ python3PyYamlReachable: defaultPython3PyYamlReachable,
1272
+ awkOnPath: () => resolveBinaryOnPath('awk'),
1273
+ jqOnPath: () => resolveBinaryOnPath('jq'),
1274
+ };
1275
+ function resolveProbes(probes) {
1276
+ if (probes === undefined)
1277
+ return DEFAULT_PROBES;
1278
+ return { ...DEFAULT_PROBES, ...probes };
1279
+ }
1280
+ /**
1281
+ * Tier 1 — `rea hook policy-get`. Reachable when the rea CLI is
1282
+ * present at one of the two shim-resolved paths (consumer install OR
1283
+ * dogfood `dist/`) AND actually responds to `rea hook policy-get
1284
+ * version --json`. The shim ladder uses that exact invocation as its
1285
+ * Tier 1 probe (see `_pr_load_full_json` in `hooks/_lib/policy-reader.sh`);
1286
+ * mirroring it here means a stale or broken dist (file present but
1287
+ * import-throws / postinstall failed) reports `warn` — matching the
1288
+ * real fall-through to Tier 2/3 the shim would do at runtime.
1289
+ *
1290
+ * Three states:
1291
+ * - dist present + CLI responds → `pass` (canonical loader fully wired).
1292
+ * - dist present + CLI broken → `warn` (stale build, missing native
1293
+ * module, broken postinstall — needs `pnpm build` / `rea upgrade`).
1294
+ * - dist absent → `warn` (not installed; Tier 2/3 still cover).
1295
+ *
1296
+ * Codex round-1 P2 (2026-05-16) replaced the file-existence-only
1297
+ * probe with this CLI-invocation probe — pre-fix, a consumer with
1298
+ * `dist/cli/index.js` present but throwing on load would see `pass`
1299
+ * here while every real shim would silently fall through.
1300
+ */
1301
+ export function checkPolicyReaderTier1(baseDir, probes) {
1302
+ const label = 'policy-reader Tier 1 (rea CLI)';
1303
+ const p = resolveProbes(probes);
1304
+ const distPresent = p.cliDistExists(baseDir);
1305
+ if (!distPresent) {
1306
+ return {
1307
+ label,
1308
+ status: 'warn',
1309
+ detail: 'rea CLI dist not found at node_modules/@bookedsolid/rea/dist/cli/index.js or <baseDir>/dist/cli/index.js — ' +
1310
+ 'shims fall through to Tier 2/3 (works, but loses validated schema + full subtree shapes). ' +
1311
+ 'Consumer: run `pnpm i @bookedsolid/rea`. Dogfood: run `pnpm build`.',
1312
+ };
1313
+ }
1314
+ if (!p.cliInvokable(baseDir)) {
1315
+ return {
1316
+ label,
1317
+ status: 'warn',
1318
+ detail: 'rea CLI dist exists but `rea hook policy-get version --json` failed — the dist is ' +
1319
+ 'stale or broken (incomplete build, missing native module, broken postinstall). The ' +
1320
+ 'shim ladder will skip Tier 1 and fall through to Tier 2/3 just as this probe did. ' +
1321
+ 'Run `pnpm build` (dogfood) or `rea upgrade` (consumer) to rebuild.',
1322
+ };
1323
+ }
1324
+ return {
1325
+ label,
1326
+ status: 'pass',
1327
+ detail: 'rea CLI dist responds to `hook policy-get version --json` — canonical loader fully wired',
1328
+ };
1329
+ }
1330
+ /**
1331
+ * Tier 2 — python3 + stdlib `yaml` (PyYAML). Handles BOTH block-form
1332
+ * and flow-form YAML; the practical floor when Tier 1 is unreachable.
1333
+ *
1334
+ * Three states:
1335
+ * - python3 present + PyYAML importable → `pass`.
1336
+ * - python3 present, PyYAML missing → `warn` (the loader will fall
1337
+ * through to Tier 3, which only handles block-form).
1338
+ * - python3 absent → `warn` (same Tier 3 fall-through).
1339
+ *
1340
+ * Never `fail` — Tier 3 is still a valid floor for block-form policy.
1341
+ * The warning highlights the silent no-op risk for flow-form lookups
1342
+ * when CLI is also unreachable.
1343
+ */
1344
+ export function checkPolicyReaderTier2(baseDir, probes) {
1345
+ const label = 'policy-reader Tier 2 (python3 + PyYAML)';
1346
+ const p = resolveProbes(probes);
1347
+ const py = p.python3OnPath();
1348
+ if (py === null) {
1349
+ return {
1350
+ label,
1351
+ status: 'warn',
1352
+ detail: 'python3 not on PATH — Tier 2 unavailable. Shims fall through to Tier 3 (awk, ' +
1353
+ 'block-form only). Flow-form policy (e.g. `local_review: { mode: off }`) silently ' +
1354
+ 'no-ops when the rea CLI is also unreachable. Install python3 to close this gap.',
1355
+ };
1356
+ }
1357
+ if (!p.python3PyYamlReachable(baseDir)) {
1358
+ return {
1359
+ label,
1360
+ status: 'warn',
1361
+ detail: `python3 found at ${py} but \`import yaml\` failed — PyYAML missing. ` +
1362
+ 'Shims fall through to Tier 3 (awk, block-form only). Flow-form policy silently ' +
1363
+ 'no-ops when the rea CLI is also unreachable. Install: `pip3 install pyyaml`.',
1364
+ };
1365
+ }
1366
+ return {
1367
+ label,
1368
+ status: 'pass',
1369
+ detail: `python3 + PyYAML reachable at ${py} — flow-form policy parses correctly`,
1370
+ };
1371
+ }
1372
+ /**
1373
+ * Tier 3 — awk block-form parser. Last-resort no-dep fallback.
1374
+ * Practically always present (POSIX requirement).
1375
+ *
1376
+ * 0.40.0 charter item 2 — conditional verdict, refined by codex
1377
+ * round 1 P2:
1378
+ * - awk present → `pass`
1379
+ * - awk absent AND Tier 2 reachable → `warn`
1380
+ * (Tier 2 implies python3, which is a list-walker)
1381
+ * - awk absent AND Tier 1 reachable AND a list walker
1382
+ * (jq OR python3) is on PATH → `warn`
1383
+ * - awk absent AND Tier 1 reachable BUT no list walker → `fail`
1384
+ * (codex round 1 P2 — list-valued policy reads silently
1385
+ * fail-closed even though scalar reads work, so the
1386
+ * downgrade-to-warn is misleading; doctor would exit 0 on a
1387
+ * broken install)
1388
+ * - awk absent AND no other tier reachable → `fail`
1389
+ *
1390
+ * Pre-fix the absent-awk branch always returned `fail` — but when
1391
+ * Tier 1 (rea CLI) AND/OR Tier 2 (python3+PyYAML) are reachable AND
1392
+ * the list walker exists, the operator's effective floor is fine even
1393
+ * without awk; Tier 3 is the LAST fallback, not a hard requirement.
1394
+ * The summary check (`checkPolicyReaderTierSummary`) already
1395
+ * aggregates correctly; this per-tier verdict now reflects the same
1396
+ * severity logic so an operator who reads ONLY the Tier 3 row isn't
1397
+ * misled into thinking the install is broken on a perfectly-
1398
+ * functional box that has python3 + jq + the rea CLI all wired but
1399
+ * happens to lack awk.
1400
+ *
1401
+ * Takes `baseDir` so it can evaluate Tier 1's two-stage check (dist
1402
+ * present + CLI invokable) and Tier 2's reachability. Probes are
1403
+ * threaded through identically.
1404
+ */
1405
+ export function checkPolicyReaderTier3(baseDir, probes) {
1406
+ const label = 'policy-reader Tier 3 (awk)';
1407
+ const p = resolveProbes(probes);
1408
+ const awk = p.awkOnPath();
1409
+ if (awk !== null) {
1410
+ return {
1411
+ label,
1412
+ status: 'pass',
1413
+ detail: `awk at ${awk} — block-form fallback available`,
1414
+ };
1415
+ }
1416
+ // 0.40.0 — awk is absent. Decide whether this is `warn` (other tiers
1417
+ // cover) or `fail` (catastrophic — no working policy lookup tier).
1418
+ // Mirror Tier 1's two-stage check (dist + invokable) and Tier 2's
1419
+ // python3 + PyYAML pair so the verdict here matches what the shim
1420
+ // ladder would actually do at runtime.
1421
+ const tier1 = p.cliDistExists(baseDir) && p.cliInvokable(baseDir);
1422
+ const tier2 = p.python3OnPath() !== null && p.python3PyYamlReachable(baseDir);
1423
+ // Codex round 1 P2 (2026-05-16): the downgrade-to-warn branch needs
1424
+ // a list walker too. `policy_reader_get_list` (the helper that reads
1425
+ // list-valued keys like `blocked_paths`) iterates the parsed JSON
1426
+ // array via jq OR python3, falling back to Tier 3 awk for inline
1427
+ // arrays. With awk gone AND no jq AND no python3, list-valued
1428
+ // policy reads silently fail-closed even when Tier 1 is reachable —
1429
+ // `blocked-paths-bash-gate.sh` etc. would see an EMPTY blocked-paths
1430
+ // set and stop enforcing entries the operator declared. Pre-fix
1431
+ // this concrete shape (cliInvokable + no python3 + no jq + no awk)
1432
+ // returned `warn` and the doctor exited 0 on a broken install.
1433
+ // Post-fix the downgrade requires a list walker; otherwise we stay
1434
+ // on `fail`. Tier 2 implies python3 on PATH (the interpreter that
1435
+ // ran PyYAML), so Tier 2 always brings list-walker support — no
1436
+ // additional check needed for the Tier-2 branch.
1437
+ const py = p.python3OnPath();
1438
+ const listWalker = p.jqOnPath() !== null || py !== null;
1439
+ if (tier2 || (tier1 && listWalker)) {
1440
+ const reachable = [];
1441
+ if (tier1)
1442
+ reachable.push('Tier 1 (rea CLI)');
1443
+ if (tier2)
1444
+ reachable.push('Tier 2 (python3+PyYAML)');
1445
+ return {
1446
+ label,
1447
+ status: 'warn',
1448
+ detail: `awk not on PATH — Tier 3 (block-form fallback) unreachable. ${reachable.join(' and ')} ` +
1449
+ 'still cover the shim ladder, so policy lookups continue to work; this is a ' +
1450
+ 'soft degradation, not a hard failure. Install awk (`mawk`, `gawk`, or `nawk`) ' +
1451
+ 'to restore the last-resort fallback.',
1452
+ };
1453
+ }
1454
+ // Codex round 1 P2: separate "no list walker" diagnosis from the
1455
+ // catastrophic "no tier at all" case. Tier 1 reachable but no jq
1456
+ // AND no python3 AND no awk means list-valued policy reads
1457
+ // fail-closed silently — distinct from the truly-empty
1458
+ // no-CLI-no-python-no-awk shape, and worth a precise remediation.
1459
+ if (tier1) {
1460
+ return {
1461
+ label,
1462
+ status: 'fail',
1463
+ detail: 'awk not on PATH AND neither jq nor python3 is on PATH — Tier 1 (rea CLI) parses ' +
1464
+ 'flow-form scalars, but `policy_reader_get_list` cannot iterate list-valued keys ' +
1465
+ '(e.g. `blocked_paths: [.env, ...]`) without jq, python3, OR awk to walk the ' +
1466
+ 'resulting JSON arrays. Affected hooks (`blocked-paths-bash-gate.sh`, ' +
1467
+ '`blocked-paths-enforcer.sh`, …) see an EMPTY list and silently stop enforcing. ' +
1468
+ 'Install awk OR jq OR python3 to restore list-iteration.',
1469
+ };
1470
+ }
1471
+ return {
1472
+ label,
1473
+ status: 'fail',
1474
+ detail: 'awk not on PATH — no fallback tier reachable. If the rea CLI and python3+PyYAML are ' +
1475
+ 'ALSO unreachable, every shim policy lookup fails closed. This is unusual; awk is a ' +
1476
+ 'POSIX requirement. Install awk (`mawk`, `gawk`, or `nawk`).',
1477
+ };
1478
+ }
1479
+ /**
1480
+ * jq — optional accelerator used by Tier 1/2's JSON subtree parsing.
1481
+ * Per the 0.37.0 round-1 P2 fix the helper falls back to a python3
1482
+ * walker when jq is absent (still correct, just an extra spawn per
1483
+ * leaf). `warn` when missing so operators know they're paying the
1484
+ * latency cost.
1485
+ *
1486
+ * `info` when present — no action needed, just confirming the
1487
+ * accelerator is wired.
1488
+ */
1489
+ export function checkPolicyReaderJq(probes) {
1490
+ const label = 'policy-reader jq (JSON accelerator)';
1491
+ const p = resolveProbes(probes);
1492
+ const jq = p.jqOnPath();
1493
+ if (jq !== null) {
1494
+ return {
1495
+ label,
1496
+ status: 'pass',
1497
+ detail: `jq at ${jq} — used by Tier 1/2 JSON subtree walking`,
1498
+ };
1499
+ }
1500
+ return {
1501
+ label,
1502
+ status: 'warn',
1503
+ detail: 'jq not on PATH — Tier 1/2 fall back to a python3 JSON walker per leaf (correct, ' +
1504
+ 'just slower). Install jq to reduce per-leaf spawn overhead.',
1505
+ };
1506
+ }
1507
+ /**
1508
+ * Summary roll-up: which tiers are reachable, what's the effective
1509
+ * floor when the CLI is unreachable, and is flow-form policy at risk
1510
+ * of silent no-op.
1511
+ *
1512
+ * Four verdicts:
1513
+ * - `pass` — Tier 1 OR Tier 2 reachable AND a JSON list walker
1514
+ * (jq or python3) is available. Flow-form scalars AND flow-form
1515
+ * arrays both parse correctly via whichever tier is hit first.
1516
+ * - `warn` (flow-form-lists-degraded) — Tier 1 reachable but neither
1517
+ * jq nor python3 on PATH. Flow-form SCALARS parse correctly via
1518
+ * the CLI's JSON output, but `policy_reader_get_list` cannot
1519
+ * iterate the resulting JSON array — it falls through to Tier 3
1520
+ * awk, which silently misses flow-form arrays like
1521
+ * `blocked_paths: [.env, ...]`. Codex round-1 P2 (2026-05-16).
1522
+ * - `warn` (Tier-3-only) — Only Tier 3 (awk) reachable. Block-form
1523
+ * policy works; flow-form scalars AND arrays both silently no-op
1524
+ * on every shim fallback.
1525
+ * - `fail` — No tiers reachable. Shims fail closed on every policy
1526
+ * lookup. (Practically requires losing awk too — see Tier 3.)
1527
+ *
1528
+ * Tier 2 implies python3 is on PATH (it's the interpreter that runs
1529
+ * the loader), so when Tier 2 is reachable the list-iteration python3
1530
+ * fallback is also reachable — only the Tier-1-without-list-walker
1531
+ * shape can produce the degraded warning.
1532
+ */
1533
+ export function checkPolicyReaderTierSummary(baseDir, probes) {
1534
+ const label = 'policy-reader effective floor';
1535
+ const p = resolveProbes(probes);
1536
+ // Mirror Tier 1's two-stage check — dist present + CLI invokable.
1537
+ // A stale/broken dist that fails the invokable probe is treated as
1538
+ // "Tier 1 not reachable" so the summary matches what the shim
1539
+ // ladder would actually do at runtime.
1540
+ const tier1 = p.cliDistExists(baseDir) && p.cliInvokable(baseDir);
1541
+ const py = p.python3OnPath();
1542
+ const tier2 = py !== null && p.python3PyYamlReachable(baseDir);
1543
+ const tier3 = p.awkOnPath() !== null;
1544
+ const jq = p.jqOnPath();
1545
+ // List iteration after Tier 1/2 needs jq OR python3 to walk the
1546
+ // JSON. Tier 2 implies python3 on PATH (the interpreter that ran
1547
+ // the loader); so the only "lists broken" shape is Tier 1 reachable
1548
+ // but neither jq nor python3 on PATH.
1549
+ const listWalker = jq !== null || py !== null;
1550
+ const reachable = [];
1551
+ if (tier1)
1552
+ reachable.push('Tier 1 (CLI)');
1553
+ if (tier2)
1554
+ reachable.push('Tier 2 (python3+PyYAML)');
1555
+ if (tier3)
1556
+ reachable.push('Tier 3 (awk)');
1557
+ if (tier1 || tier2) {
1558
+ if (!listWalker) {
1559
+ // Tier 1 + no python3/jq. flow-form scalars work; flow-form
1560
+ // arrays silently no-op via Tier 3 fallthrough. (Tier 2 path
1561
+ // is unreachable here because Tier 2 requires python3.)
1562
+ return {
1563
+ label,
1564
+ status: 'warn',
1565
+ detail: `${reachable.join(', ')} reachable — flow-form scalars parse via Tier 1 CLI, ` +
1566
+ 'BUT neither jq nor python3 is on PATH so `policy_reader_get_list` cannot iterate ' +
1567
+ 'the resulting JSON arrays. Flow-form list policy (e.g. `blocked_paths: [.env, ...]`) ' +
1568
+ 'silently falls through to Tier 3 awk and misses inline arrays. Install jq ' +
1569
+ '(`brew install jq` / `apt-get install jq`) or python3 to close the gap.',
1570
+ };
1571
+ }
1572
+ return {
1573
+ label,
1574
+ status: 'pass',
1575
+ detail: `${reachable.join(', ')} reachable — flow-form policy parses correctly`,
1576
+ };
1577
+ }
1578
+ if (tier3) {
1579
+ return {
1580
+ label,
1581
+ status: 'warn',
1582
+ detail: 'only Tier 3 (awk, block-form ONLY) reachable — flow-form policy ' +
1583
+ '(e.g. `local_review: { mode: off }`, `blocked_paths: [.env, ...]`) silently ' +
1584
+ 'no-ops on every shim fallback path. Restore Tier 1 (rea CLI dist) or Tier 2 ' +
1585
+ '(python3 + PyYAML) to close the gap.',
1586
+ };
1587
+ }
1588
+ return {
1589
+ label,
1590
+ status: 'fail',
1591
+ detail: 'no policy-reader tier reachable — every shim policy lookup fails closed. ' +
1592
+ 'Install at least one of: rea CLI dist (Tier 1), python3 + PyYAML (Tier 2), ' +
1593
+ 'awk (Tier 3).',
1594
+ };
1595
+ }
953
1596
  /**
954
1597
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
955
1598
  * responsiveness (pass/warn) and one informational line about the last
@@ -1457,9 +2100,15 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
1457
2100
  const policyPath = reaPath(baseDir, POLICY_FILE);
1458
2101
  const registryPath = reaPath(baseDir, REGISTRY_FILE);
1459
2102
  const reaDirPath = path.join(baseDir, REA_DIR);
2103
+ // Run checkPolicyParses up-front so we can both push its result and
2104
+ // use the verdict to gate the 0.39.0 policy-reader tier checks below.
2105
+ // A malformed policy file should NOT trigger the tier-reachability
2106
+ // probes — those reports would misattribute a parse failure to a
2107
+ // runtime/install problem (codex round-3 P2, 2026-05-16).
2108
+ const policyParsesResult = checkPolicyParses(baseDir, policyPath);
1460
2109
  const checks = [
1461
2110
  checkFileExists('.rea/ directory exists', reaDirPath, true),
1462
- checkPolicyParses(baseDir, policyPath),
2111
+ policyParsesResult,
1463
2112
  checkRegistryParses(baseDir, registryPath),
1464
2113
  checkAgentsPresent(baseDir),
1465
2114
  checkHooksInstalled(baseDir),
@@ -1482,6 +2131,30 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
1482
2131
  // went through in 0.29.0 → 0.30.0, after 4 release cycles of
1483
2132
  // propagation).
1484
2133
  checkDelegationAdvisoryHookRegistered(baseDir),
2134
+ // 0.39.0 — policy-reader tier visibility. Surfaces which tiers of
2135
+ // the 4-tier `hooks/_lib/policy-reader.sh` ladder are reachable in
2136
+ // this environment so operators can SEE whether flow-form policy
2137
+ // would silently no-op when the CLI is unreachable.
2138
+ //
2139
+ // Codex round-3 P2 (2026-05-16): gated on `policyParsesResult`
2140
+ // being a `pass` — NOT just `existsSync(policyPath)`. A
2141
+ // malformed policy file (present but unparseable) should report
2142
+ // exactly ONE failure — the parse-error from `checkPolicyParses`
2143
+ // above — and not also light up the tier probes with misleading
2144
+ // "Tier 1 dist exists but failed" or summary "ladder degraded"
2145
+ // diagnostics that misattribute a config bug to an
2146
+ // install/runtime problem. The parse-failure row already tells
2147
+ // the operator the right thing to fix; adding more downstream
2148
+ // noise would obscure it.
2149
+ ...(policyParsesResult.status === 'pass'
2150
+ ? [
2151
+ checkPolicyReaderTier1(baseDir),
2152
+ checkPolicyReaderTier2(baseDir),
2153
+ checkPolicyReaderTier3(baseDir),
2154
+ checkPolicyReaderJq(),
2155
+ checkPolicyReaderTierSummary(baseDir),
2156
+ ]
2157
+ : []),
1485
2158
  ];
1486
2159
  // Non-git escape hatch: when `.git/` is absent, both git-hook checks are
1487
2160
  // meaningless (commit-msg + pre-push can't be invoked without git). Emit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.38.1",
3
+ "version": "0.40.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",