@bookedsolid/rea 0.2.1 → 0.4.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.
Files changed (65) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/THREAT_MODEL.md +100 -29
  4. package/dist/audit/append.d.ts +21 -8
  5. package/dist/audit/append.js +48 -83
  6. package/dist/audit/fs.d.ts +68 -0
  7. package/dist/audit/fs.js +171 -0
  8. package/dist/cli/audit.d.ts +40 -0
  9. package/dist/cli/audit.js +205 -0
  10. package/dist/cli/doctor.d.ts +19 -4
  11. package/dist/cli/doctor.js +172 -5
  12. package/dist/cli/index.js +26 -1
  13. package/dist/cli/init.js +93 -7
  14. package/dist/cli/install/pre-push.d.ts +335 -0
  15. package/dist/cli/install/pre-push.js +2818 -0
  16. package/dist/cli/serve.d.ts +64 -0
  17. package/dist/cli/serve.js +270 -2
  18. package/dist/cli/status.d.ts +90 -0
  19. package/dist/cli/status.js +399 -0
  20. package/dist/cli/utils.d.ts +4 -0
  21. package/dist/cli/utils.js +4 -0
  22. package/dist/gateway/audit/rotator.d.ts +116 -0
  23. package/dist/gateway/audit/rotator.js +289 -0
  24. package/dist/gateway/circuit-breaker.d.ts +17 -0
  25. package/dist/gateway/circuit-breaker.js +32 -3
  26. package/dist/gateway/downstream-pool.d.ts +2 -1
  27. package/dist/gateway/downstream-pool.js +2 -2
  28. package/dist/gateway/downstream.d.ts +39 -3
  29. package/dist/gateway/downstream.js +73 -14
  30. package/dist/gateway/log.d.ts +122 -0
  31. package/dist/gateway/log.js +334 -0
  32. package/dist/gateway/middleware/audit.d.ts +24 -1
  33. package/dist/gateway/middleware/audit.js +103 -58
  34. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  35. package/dist/gateway/middleware/blocked-paths.js +439 -67
  36. package/dist/gateway/middleware/injection.d.ts +218 -13
  37. package/dist/gateway/middleware/injection.js +433 -51
  38. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  39. package/dist/gateway/middleware/kill-switch.js +20 -1
  40. package/dist/gateway/observability/metrics.d.ts +125 -0
  41. package/dist/gateway/observability/metrics.js +321 -0
  42. package/dist/gateway/server.d.ts +19 -0
  43. package/dist/gateway/server.js +99 -15
  44. package/dist/policy/loader.d.ts +47 -0
  45. package/dist/policy/loader.js +47 -0
  46. package/dist/policy/profiles.d.ts +13 -0
  47. package/dist/policy/profiles.js +12 -0
  48. package/dist/policy/types.d.ts +52 -0
  49. package/dist/registry/fingerprint.d.ts +73 -0
  50. package/dist/registry/fingerprint.js +81 -0
  51. package/dist/registry/fingerprints-store.d.ts +62 -0
  52. package/dist/registry/fingerprints-store.js +111 -0
  53. package/dist/registry/interpolate.d.ts +58 -0
  54. package/dist/registry/interpolate.js +121 -0
  55. package/dist/registry/loader.d.ts +2 -2
  56. package/dist/registry/loader.js +22 -1
  57. package/dist/registry/tofu-gate.d.ts +41 -0
  58. package/dist/registry/tofu-gate.js +189 -0
  59. package/dist/registry/tofu.d.ts +111 -0
  60. package/dist/registry/tofu.js +173 -0
  61. package/dist/registry/types.d.ts +9 -1
  62. package/package.json +3 -1
  63. package/profiles/bst-internal-no-codex.yaml +5 -0
  64. package/profiles/bst-internal.yaml +7 -0
  65. package/scripts/tarball-smoke.sh +197 -0
@@ -2,7 +2,10 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { loadPolicy } from '../policy/loader.js';
4
4
  import { loadRegistry } from '../registry/loader.js';
5
+ import { loadFingerprintStore } from '../registry/fingerprints-store.js';
6
+ import { fingerprintServer } from '../registry/fingerprint.js';
5
7
  import { CodexProbe, } from '../gateway/observability/codex-probe.js';
8
+ import { inspectPrePushState, } from './install/pre-push.js';
6
9
  import { summarizeTelemetry } from '../gateway/observability/codex-telemetry.js';
7
10
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
8
11
  import { buildFragment } from './install/claude-md.js';
@@ -40,6 +43,69 @@ function checkPolicyParses(baseDir, policyPath) {
40
43
  };
41
44
  }
42
45
  }
46
+ /**
47
+ * G7: report the TOFU fingerprint-store state. Pass = every enabled server
48
+ * in the registry has a matching stored fingerprint. Warn = at least one
49
+ * server would be first-seen or drifted at next `rea serve`. Info = no
50
+ * enabled servers (nothing to fingerprint). Fail only for unreadable store.
51
+ *
52
+ * Exported so tests can drive this without spinning up the full `runDoctor`.
53
+ */
54
+ export async function checkFingerprintStore(baseDir) {
55
+ const label = 'fingerprint store';
56
+ let registry;
57
+ try {
58
+ registry = loadRegistry(baseDir);
59
+ }
60
+ catch {
61
+ return {
62
+ label,
63
+ status: 'info',
64
+ detail: 'registry missing — no fingerprints to compare',
65
+ };
66
+ }
67
+ const enabled = registry.servers.filter((s) => s.enabled);
68
+ if (enabled.length === 0) {
69
+ return { label, status: 'info', detail: 'no enabled servers to fingerprint' };
70
+ }
71
+ let store;
72
+ try {
73
+ store = await loadFingerprintStore(baseDir);
74
+ }
75
+ catch (e) {
76
+ return {
77
+ label,
78
+ status: 'fail',
79
+ detail: e instanceof Error ? e.message : String(e),
80
+ };
81
+ }
82
+ let firstSeen = 0;
83
+ let drifted = 0;
84
+ for (const s of enabled) {
85
+ const stored = store.servers[s.name];
86
+ if (stored === undefined)
87
+ firstSeen += 1;
88
+ else if (stored !== fingerprintServer(s))
89
+ drifted += 1;
90
+ }
91
+ if (firstSeen === 0 && drifted === 0) {
92
+ return {
93
+ label,
94
+ status: 'pass',
95
+ detail: `${enabled.length} server(s) trusted`,
96
+ };
97
+ }
98
+ const parts = [];
99
+ if (firstSeen > 0)
100
+ parts.push(`${firstSeen} first-seen`);
101
+ if (drifted > 0)
102
+ parts.push(`${drifted} drifted`);
103
+ return {
104
+ label,
105
+ status: 'warn',
106
+ detail: `${parts.join(', ')} — next \`rea serve\` will block drift (set REA_ACCEPT_DRIFT=<name> to accept)`,
107
+ };
108
+ }
43
109
  function checkRegistryParses(baseDir, registryPath) {
44
110
  if (!fs.existsSync(registryPath)) {
45
111
  return {
@@ -198,6 +264,86 @@ function checkCommitMsgHook(baseDir) {
198
264
  };
199
265
  }
200
266
  }
267
+ /**
268
+ * G6 — Verify at least one pre-push hook is installed and executable AND
269
+ * actually wires the protected-path review gate.
270
+ *
271
+ * Three install shapes are acceptable:
272
+ * 1. `.git/hooks/pre-push` — vanilla git (no hooksPath). Must carry the
273
+ * rea fallback marker or delegate to `push-review-gate.sh`.
274
+ * 2. `${core.hooksPath}/pre-push` — husky 9 or custom hooksPath. Same
275
+ * governance rule.
276
+ * 3. `.husky/pre-push` is present on disk but only counts if husky has
277
+ * configured `core.hooksPath=.husky`. A `.husky/pre-push` with an
278
+ * unconfigured hooksPath is dead weight; we do NOT treat it as
279
+ * sufficient.
280
+ *
281
+ * Two possible outcomes:
282
+ * - `pass`: active hook exists, is executable, and governance-carrying
283
+ * (rea-managed marker or direct gate delegation).
284
+ * - `fail`: no active hook, active file is non-executable, OR the active
285
+ * hook does not reference `.claude/hooks/push-review-gate.sh`. The last
286
+ * case is the "silent bypass" state — a lint-only husky hook or a
287
+ * pre-existing repo hook that bypasses the Codex audit gate entirely.
288
+ * Always a hard fail; `rea init` can install the fallback if the user
289
+ * removes or updates the existing hook.
290
+ *
291
+ * "Executable" is defined by any user/group/other exec bit, matching
292
+ * `checkHooksInstalled`.
293
+ */
294
+ function checkPrePushHook(state) {
295
+ if (state.ok) {
296
+ const active = state.candidates.find((c) => c.path === state.activePath);
297
+ const kind = active?.reaManaged === true
298
+ ? 'rea-managed'
299
+ : active?.delegatesToGate === true
300
+ ? 'external (delegates to push-review-gate.sh)'
301
+ : 'external';
302
+ const detail = active !== undefined ? `${kind} at ${active.path}` : undefined;
303
+ return detail !== undefined
304
+ ? { label: 'pre-push hook installed', status: 'pass', detail }
305
+ : { label: 'pre-push hook installed', status: 'pass' };
306
+ }
307
+ if (state.activeForeign) {
308
+ // Executable file exists at the active path but does not carry
309
+ // governance — the parser could not confirm the review gate is
310
+ // invoked unconditionally. Always a hard fail.
311
+ //
312
+ // R13 F3: previously, a substring match of the gate path in the hook
313
+ // downgraded this to WARN. That was unsafe — any comment, echo, or
314
+ // dead string mentioning the path would mask a silent-bypass hook.
315
+ // The classifier now fails closed: either the structural parser
316
+ // (`referencesReviewGate` in `pre-push.ts`) recognizes a real
317
+ // invocation, or doctor reports fail.
318
+ return {
319
+ label: 'pre-push hook installed',
320
+ status: 'fail',
321
+ detail: `active pre-push at ${state.activePath} is present and executable but does NOT ` +
322
+ `reference \`.claude/hooks/push-review-gate.sh\` — the protected-path ` +
323
+ `Codex audit gate is silently bypassed. Either add ` +
324
+ '`exec .claude/hooks/push-review-gate.sh "$@"` to the existing hook, or ' +
325
+ 'remove it and re-run `rea init` to install the fallback.',
326
+ };
327
+ }
328
+ const present = state.candidates
329
+ .filter((c) => c.exists)
330
+ .map((c) => `${c.path}${c.executable ? '' : ' (not executable)'}`);
331
+ if (present.length > 0) {
332
+ return {
333
+ label: 'pre-push hook installed',
334
+ status: 'fail',
335
+ detail: `no active pre-push hook. Files on disk: ${present.join(', ')}. ` +
336
+ 'Run `rea init` to install the fallback, or configure `core.hooksPath=.husky` ' +
337
+ 'if you are using husky.',
338
+ };
339
+ }
340
+ return {
341
+ label: 'pre-push hook installed',
342
+ status: 'fail',
343
+ detail: 'no pre-push hook found in `.git/hooks/`, configured `core.hooksPath`, or `.husky/`. ' +
344
+ 'Run `rea init` to install the fallback.',
345
+ };
346
+ }
201
347
  function checkCodexAgent(baseDir) {
202
348
  const agentPath = path.join(baseDir, '.claude', 'agents', 'codex-adversarial.md');
203
349
  if (fs.existsSync(agentPath))
@@ -277,11 +423,16 @@ function codexRequiredFromPolicy(baseDir) {
277
423
  * `runDoctor`.
278
424
  *
279
425
  * `codexProbeState` is consulted ONLY when Codex is required by policy.
280
- * Callers that already have a fresh probe state (e.g. `runDoctor`) should
281
- * pass it; callers that don't (e.g. unit tests of the existing doctor
282
- * surface) can omit it and the probe-derived fields are skipped.
426
+ * `prePushState` is the pre-computed G6 pre-push inspection; when omitted
427
+ * the pre-push check is skipped entirely (older call sites that don't yet
428
+ * thread the state through keep working without behavioural change).
429
+ * Callers that already have fresh state (e.g. `runDoctor`) should pass
430
+ * both; callers that don't (e.g. unit tests of the existing doctor
431
+ * surface) can omit them and those checks are skipped.
432
+ *
433
+ * `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
283
434
  */
284
- export function collectChecks(baseDir, codexProbeState) {
435
+ export function collectChecks(baseDir, codexProbeState, prePushState) {
285
436
  const policyPath = reaPath(baseDir, POLICY_FILE);
286
437
  const registryPath = reaPath(baseDir, REGISTRY_FILE);
287
438
  const reaDirPath = path.join(baseDir, REA_DIR);
@@ -294,6 +445,9 @@ export function collectChecks(baseDir, codexProbeState) {
294
445
  checkSettingsJson(baseDir),
295
446
  checkCommitMsgHook(baseDir),
296
447
  ];
448
+ if (prePushState !== undefined) {
449
+ checks.push(checkPrePushHook(prePushState));
450
+ }
297
451
  if (codexRequiredFromPolicy(baseDir)) {
298
452
  checks.push(checkCodexAgent(baseDir), checkCodexCommand(baseDir));
299
453
  if (codexProbeState !== undefined) {
@@ -486,7 +640,20 @@ export async function runDoctor(opts = {}) {
486
640
  probeState = undefined;
487
641
  }
488
642
  }
489
- const checks = collectChecks(baseDir, probeState);
643
+ // G6 inspect pre-push state. Never throws; unreadable files downgrade
644
+ // individual candidates but never break the whole check.
645
+ let prePushState;
646
+ try {
647
+ prePushState = await inspectPrePushState(baseDir);
648
+ }
649
+ catch {
650
+ prePushState = undefined;
651
+ }
652
+ const checks = collectChecks(baseDir, probeState, prePushState);
653
+ // G7: async fingerprint-store check. Kept out of `collectChecks` so the
654
+ // existing sync contract stays intact for downstream consumers; appended
655
+ // here so runDoctor surfaces it inline.
656
+ checks.push(await checkFingerprintStore(baseDir));
490
657
  console.log('');
491
658
  log(`Doctor — ${baseDir}`);
492
659
  console.log('');
package/dist/cli/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { runAuditRotate, runAuditVerify } from './audit.js';
3
4
  import { runCheck } from './check.js';
4
5
  import { runDoctor } from './doctor.js';
5
6
  import { runFreeze, runUnfreeze } from './freeze.js';
6
7
  import { runInit } from './init.js';
7
8
  import { runServe } from './serve.js';
9
+ import { runStatus } from './status.js';
8
10
  import { runUpgrade } from './upgrade.js';
9
11
  import { err, getPkgVersion } from './utils.js';
10
12
  async function main() {
@@ -52,7 +54,7 @@ async function main() {
52
54
  });
53
55
  program
54
56
  .command('serve')
55
- .description('Start the MCP gateway (stub prints status, verifies policy loads).')
57
+ .description('Start the MCP gateway — stdio server that proxies downstream MCPs declared in .rea/registry.yaml through the middleware chain.')
56
58
  .action(async () => {
57
59
  await runServe();
58
60
  });
@@ -76,6 +78,29 @@ async function main() {
76
78
  .action(() => {
77
79
  runCheck();
78
80
  });
81
+ program
82
+ .command('status')
83
+ .description('Running-process view — is `rea serve` live for this project? Session id, policy summary, audit stats. Use `rea check` for the on-disk view.')
84
+ .option('--json', 'emit JSON instead of the pretty table (composes with jq)')
85
+ .action((opts) => {
86
+ runStatus({ json: opts.json });
87
+ });
88
+ const audit = program
89
+ .command('audit')
90
+ .description('Audit log operations — rotate and verify .rea/audit.jsonl (G1).');
91
+ audit
92
+ .command('rotate')
93
+ .description('Force-rotate .rea/audit.jsonl now. Preserves hash-chain via a marker record.')
94
+ .action(async () => {
95
+ await runAuditRotate({});
96
+ });
97
+ audit
98
+ .command('verify')
99
+ .description('Re-hash the audit chain; exit 0 on clean, 1 on the first tampered record.')
100
+ .option('--since <file>', 'verify starting at a rotated file (e.g. audit-YYYYMMDD-HHMMSS.jsonl), walking forward through the chain')
101
+ .action(async (opts) => {
102
+ await runAuditVerify({ ...(opts.since !== undefined ? { since: opts.since } : {}) });
103
+ });
79
104
  program
80
105
  .command('doctor')
81
106
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
package/dist/cli/init.js CHANGED
@@ -7,6 +7,8 @@ import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js
7
7
  import { copyArtifacts } from './install/copy.js';
8
8
  import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
9
9
  import { installCommitMsgHook } from './install/commit-msg.js';
10
+ import { installPrePushFallback } from './install/pre-push.js';
11
+ import { CodexProbe } from '../gateway/observability/codex-probe.js';
10
12
  import { buildFragment, writeClaudeMdFragment } from './install/claude-md.js';
11
13
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
12
14
  import { writeManifestAtomic } from './install/manifest-io.js';
@@ -188,6 +190,48 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
188
190
  reagentNotices: [],
189
191
  };
190
192
  }
193
+ /**
194
+ * G6 — Codex install-assist probe.
195
+ *
196
+ * Runs a single {@link CodexProbe} attempt and prints a guidance block when
197
+ * the CLI is NOT responsive. Behavior:
198
+ *
199
+ * - `cli_responsive === true` → print a single-line "Codex CLI detected"
200
+ * acknowledgement (informational, not verbose).
201
+ * - `cli_responsive === false` → print a 4-line install guidance block
202
+ * naming the Claude Code helper that installs Codex.
203
+ *
204
+ * Failure of the probe itself is never fatal — a hung CLI must not stall
205
+ * `rea init`. The probe class already caps each subcommand at 2s/5s. Any
206
+ * throw bubbling out here is caught and treated as "not responsive".
207
+ *
208
+ * We deliberately reference the user-visible helper path (`/codex:setup`)
209
+ * rather than shelling out to install Codex ourselves. `rea init` does not
210
+ * auto-install third-party tooling; the operator signs off.
211
+ */
212
+ async function printCodexInstallAssist() {
213
+ let responsive = false;
214
+ let versionLine;
215
+ try {
216
+ const state = await new CodexProbe().probe();
217
+ responsive = state.cli_responsive;
218
+ versionLine = state.version;
219
+ }
220
+ catch {
221
+ // probe() is documented as never-throws, but belt-and-suspenders.
222
+ responsive = false;
223
+ }
224
+ console.log('');
225
+ if (responsive) {
226
+ const suffix = versionLine !== undefined ? ` (${versionLine})` : '';
227
+ console.log(`Codex CLI detected${suffix}.`);
228
+ return;
229
+ }
230
+ console.log('Codex CLI not detected on PATH.');
231
+ console.log(' Adversarial review via `/codex-review` requires the Codex plugin.');
232
+ console.log(' Install via the Claude Code Codex plugin helper: `/codex:setup`,');
233
+ console.log(' or set `review.codex_required: false` in .rea/policy.yaml to opt out.');
234
+ }
191
235
  function writePolicyYaml(targetDir, config, layered) {
192
236
  const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
193
237
  const installedBy = process.env.USER ?? os.userInfo().username ?? 'unknown';
@@ -213,6 +257,14 @@ function writePolicyYaml(targetDir, config, layered) {
213
257
  if (layered.injection_detection !== undefined) {
214
258
  lines.push(`injection_detection: ${layered.injection_detection}`);
215
259
  }
260
+ // G9: preserve `injection.suspicious_blocks_writes` when the layered profile
261
+ // pinned it (bst-internal/bst-internal-no-codex pin `true`). External profiles
262
+ // leave this unset so the policy loader's schema default (`false`) applies,
263
+ // which keeps 0.2.x consumers from being silently tightened on upgrade.
264
+ if (layered.injection?.suspicious_blocks_writes !== undefined) {
265
+ lines.push(`injection:`);
266
+ lines.push(` suspicious_blocks_writes: ${layered.injection.suspicious_blocks_writes ? 'true' : 'false'}`);
267
+ }
216
268
  if (layered.context_protection !== undefined) {
217
269
  lines.push(`context_protection:`);
218
270
  const cp = layered.context_protection;
@@ -243,6 +295,21 @@ function writeRegistryYaml(targetDir) {
243
295
  const content = [
244
296
  `# .rea/registry.yaml — downstream MCP servers proxied through rea serve.`,
245
297
  `# Every entry below is subject to the same middleware chain as native tool calls.`,
298
+ `#`,
299
+ `# env: values support \${VAR} interpolation against rea-serve's own process.env.`,
300
+ `# If a referenced var is unset at startup, the affected server fails to start`,
301
+ `# (the rest of the gateway still comes up). Only the curly-brace form is`,
302
+ `# supported — no $VAR, no defaults, no command substitution.`,
303
+ `#`,
304
+ `# Example (uncomment and export the vars in your shell before running \`rea serve\`):`,
305
+ `#`,
306
+ `# - name: discord-ops`,
307
+ `# command: npx`,
308
+ `# args: ['-y', 'discord-ops@latest']`,
309
+ `# env:`,
310
+ `# BOOKED_DISCORD_BOT_TOKEN: '\${BOOKED_DISCORD_BOT_TOKEN}'`,
311
+ `# CLARITY_DISCORD_BOT_TOKEN: '\${CLARITY_DISCORD_BOT_TOKEN}'`,
312
+ `# enabled: false # flip to true after exporting the tokens`,
246
313
  `version: "1"`,
247
314
  `servers: []`,
248
315
  ``,
@@ -387,6 +454,7 @@ export async function runInit(options) {
387
454
  const mergeResult = mergeSettings(settings, desired);
388
455
  await writeSettingsAtomic(settingsPath, mergeResult.merged);
389
456
  const commitMsgResult = await installCommitMsgHook(targetDir);
457
+ const prePushResult = await installPrePushFallback(targetDir);
390
458
  const fragmentInput = {
391
459
  policyPath: `.${path.sep}rea${path.sep}policy.yaml`.replace(/\\/g, '/'),
392
460
  profile: config.profile,
@@ -410,6 +478,14 @@ export async function runInit(options) {
410
478
  console.log(` + ${path.relative(targetDir, commitMsgResult.gitHook)}`);
411
479
  if (commitMsgResult.huskyHook)
412
480
  console.log(` + ${path.relative(targetDir, commitMsgResult.huskyHook)}`);
481
+ if (prePushResult.written !== undefined) {
482
+ const verb = prePushResult.decision.action === 'refresh' ? '~' : '+';
483
+ console.log(` ${verb} ${path.relative(targetDir, prePushResult.written)} (pre-push fallback)`);
484
+ }
485
+ else if (prePushResult.decision.action === 'skip' &&
486
+ prePushResult.decision.reason === 'active-pre-push-present') {
487
+ console.log(` = ${path.relative(targetDir, prePushResult.decision.hookPath)} (active pre-push already present — skipped fallback)`);
488
+ }
413
489
  console.log(` ${mdResult.replaced ? '~' : '+'} ${path.relative(targetDir, mdResult.path)} (fragment ${mdResult.replaced ? 'replaced' : 'written'})`);
414
490
  console.log(` + ${path.relative(targetDir, manifestPath)}`);
415
491
  if (mergeResult.warnings.length > 0) {
@@ -419,15 +495,25 @@ export async function runInit(options) {
419
495
  }
420
496
  for (const w of commitMsgResult.warnings)
421
497
  warn(w);
498
+ for (const w of prePushResult.warnings)
499
+ warn(w);
422
500
  for (const n of config.reagentNotices)
423
501
  warn(n);
424
- // G11.4: when Codex review is disabled, print a durable notice. Mentions
425
- // the exact edit path so the operator can flip back later without having
426
- // to re-run init. (Coupling note: a future G6-style "Codex install
427
- // assist" prompt belongs here too, and should short-circuit when
428
- // codex_required is false do not invoke install-assist in no-codex
429
- // mode.)
430
- if (!config.codexRequired) {
502
+ // G6 + G11.4: Codex install-assist.
503
+ //
504
+ // Split by codex_required:
505
+ // - codex_required=true → probe the CLI; if it is not responsive, print
506
+ // a clear "install Codex" guidance block so the
507
+ // operator knows why /codex-review will fail.
508
+ // - codex_required=false → skip the probe entirely and print the
509
+ // existing "Codex review disabled" notice.
510
+ // Probing here is pointless (wasted 2s) and
511
+ // actively confusing — no-codex mode is a
512
+ // supported first-class configuration.
513
+ if (config.codexRequired) {
514
+ await printCodexInstallAssist();
515
+ }
516
+ else {
431
517
  console.log('');
432
518
  console.log('Codex review disabled. ClaudeSelfReviewer will be used.');
433
519
  console.log(' Set review.codex_required: true in .rea/policy.yaml to re-enable.');