@bookedsolid/rea 0.3.0 → 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 (56) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/dist/cli/doctor.d.ts +19 -4
  4. package/dist/cli/doctor.js +172 -5
  5. package/dist/cli/index.js +9 -1
  6. package/dist/cli/init.js +93 -7
  7. package/dist/cli/install/pre-push.d.ts +335 -0
  8. package/dist/cli/install/pre-push.js +2818 -0
  9. package/dist/cli/serve.d.ts +64 -0
  10. package/dist/cli/serve.js +270 -2
  11. package/dist/cli/status.d.ts +90 -0
  12. package/dist/cli/status.js +399 -0
  13. package/dist/cli/utils.d.ts +4 -0
  14. package/dist/cli/utils.js +4 -0
  15. package/dist/gateway/circuit-breaker.d.ts +17 -0
  16. package/dist/gateway/circuit-breaker.js +32 -3
  17. package/dist/gateway/downstream-pool.d.ts +2 -1
  18. package/dist/gateway/downstream-pool.js +2 -2
  19. package/dist/gateway/downstream.d.ts +39 -3
  20. package/dist/gateway/downstream.js +73 -14
  21. package/dist/gateway/log.d.ts +122 -0
  22. package/dist/gateway/log.js +334 -0
  23. package/dist/gateway/middleware/audit.d.ts +10 -1
  24. package/dist/gateway/middleware/audit.js +26 -1
  25. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  26. package/dist/gateway/middleware/blocked-paths.js +439 -67
  27. package/dist/gateway/middleware/injection.d.ts +218 -13
  28. package/dist/gateway/middleware/injection.js +433 -51
  29. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  30. package/dist/gateway/middleware/kill-switch.js +20 -1
  31. package/dist/gateway/observability/metrics.d.ts +125 -0
  32. package/dist/gateway/observability/metrics.js +321 -0
  33. package/dist/gateway/server.d.ts +19 -0
  34. package/dist/gateway/server.js +99 -15
  35. package/dist/policy/loader.d.ts +13 -0
  36. package/dist/policy/loader.js +28 -0
  37. package/dist/policy/profiles.d.ts +13 -0
  38. package/dist/policy/profiles.js +12 -0
  39. package/dist/policy/types.d.ts +28 -0
  40. package/dist/registry/fingerprint.d.ts +73 -0
  41. package/dist/registry/fingerprint.js +81 -0
  42. package/dist/registry/fingerprints-store.d.ts +62 -0
  43. package/dist/registry/fingerprints-store.js +111 -0
  44. package/dist/registry/interpolate.d.ts +58 -0
  45. package/dist/registry/interpolate.js +121 -0
  46. package/dist/registry/loader.d.ts +2 -2
  47. package/dist/registry/loader.js +22 -1
  48. package/dist/registry/tofu-gate.d.ts +41 -0
  49. package/dist/registry/tofu-gate.js +189 -0
  50. package/dist/registry/tofu.d.ts +111 -0
  51. package/dist/registry/tofu.js +173 -0
  52. package/dist/registry/types.d.ts +9 -1
  53. package/package.json +1 -1
  54. package/profiles/bst-internal-no-codex.yaml +5 -0
  55. package/profiles/bst-internal.yaml +7 -0
  56. package/scripts/tarball-smoke.sh +197 -0
package/.husky/pre-push CHANGED
@@ -1,4 +1,6 @@
1
1
  #!/bin/sh
2
+ # rea:husky-pre-push-gate v1
3
+ # rea:gate-body-v1
2
4
  # .husky/pre-push — rea governance gate for terminal-initiated pushes.
3
5
  #
4
6
  # Mirrors the logic of `.claude/hooks/push-review-gate.sh` but consumes the
@@ -20,8 +22,10 @@
20
22
  # which ran the loop in a subshell — `exit 1` inside the loop aborted the
21
23
  # subshell only, and the script then ran `exit 0` and allowed the push. We
22
24
  # now feed the loop with a here-doc so it runs in the main shell, and we
23
- # track `block_push` in the enclosing scope. Final `exit 1` is reached only
24
- # if no refspec is blocked; a single blocking refspec propagates correctly.
25
+ # abort immediately (`exit 1`) on the first blocking refspec. The accumulator
26
+ # pattern (`block_push=1; continue`) was dropped so the text-level detector
27
+ # in `src/cli/install/pre-push.ts` can verify the miss-path is truly blocking
28
+ # without modeling loop-carried flags and post-loop exit blocks.
25
29
 
26
30
  set -eu
27
31
 
@@ -63,13 +67,11 @@ if [ -f "$READ_FIELD_JS" ]; then
63
67
  fi
64
68
  fi
65
69
 
66
- block_push=0
67
-
68
- # Here-doc feeds the loop without creating a subshell, so `block_push=1`
69
- # assignments below persist in the enclosing scope and the final `exit`
70
- # reflects them. A pipeline would run the loop in a subshell and `exit 1`
71
- # inside it would only abort that subshell — NOT the push — which was a
72
- # real governance defect in the pre-review version of this file.
70
+ # Here-doc feeds the loop without creating a subshell, so an `exit 1`
71
+ # inside the loop terminates the hook and blocks the push. A pipeline
72
+ # would run the loop in a subshell and `exit 1` inside it would only
73
+ # abort that subshell NOT the push which was a real governance
74
+ # defect in the pre-review version of this file.
73
75
  while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
74
76
  [ -z "${local_sha:-}" ] && continue
75
77
  # Branch deletion: local_sha is 40 zeros. Skip protected-path check.
@@ -103,26 +105,21 @@ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
103
105
  if [ ! -f "$AUDIT_LOG" ]; then
104
106
  printf 'PUSH BLOCKED: protected paths changed but no audit log found at %s\n' "$AUDIT_LOG" >&2
105
107
  printf ' Run /codex-review on HEAD %s before pushing.\n' "$local_sha" >&2
106
- block_push=1
107
- continue
108
+ exit 1
108
109
  fi
109
110
  # Require both (a) a `codex.review` tool_name and (b) the exact head_sha
110
111
  # on the same JSONL line. The `codex.review` pattern ends with a closing
111
- # quote, so `codex.review.skipped` never satisfies the gate.
112
+ # quote, so `codex.review.skipped` never satisfies the gate. The first
113
+ # refspec that fails this check aborts the hook — no accumulator needed.
112
114
  if ! grep -E '"tool_name":"codex\.review"' "$AUDIT_LOG" 2>/dev/null | \
113
115
  grep -qF "\"head_sha\":\"$local_sha\""; then
114
116
  printf 'PUSH BLOCKED: protected paths changed — /codex-review required for HEAD %s\n' "$local_sha" >&2
115
117
  printf ' Run /codex-review, or set REA_SKIP_CODEX_REVIEW=<reason> to bypass.\n' >&2
116
- block_push=1
117
- continue
118
+ exit 1
118
119
  fi
119
120
  fi
120
121
  done <<HOOK_INPUT_EOF
121
122
  $INPUT
122
123
  HOOK_INPUT_EOF
123
124
 
124
- if [ "$block_push" -ne 0 ]; then
125
- exit 1
126
- fi
127
-
128
125
  exit 0
package/README.md CHANGED
@@ -66,7 +66,10 @@ to build a separate package that composes with REA.
66
66
  `policy.yaml` is the maximum surface area — one outbound POST, opt-in.
67
67
  - **Not a daemon supervisor.** `rea serve` is started by Claude Code via
68
68
  `.mcp.json`. Claude Code owns the lifecycle. There is no `rea start`,
69
- no `rea stop`, no pid file, no systemd unit.
69
+ no `rea stop`, no systemd unit. A short-lived `.rea/serve.pid`
70
+ breadcrumb is written at startup so `rea status` can detect a live
71
+ gateway — it is removed on graceful shutdown and never used for
72
+ locking or lifecycle management.
70
73
  - **Not a hosted service.** There is no REA Cloud, no SaaS tier, no
71
74
  multi-token workstreams, no workload isolation platform.
72
75
  - **Not a 70-agent roster.** 10 curated agents ship in the package. Four
@@ -132,6 +135,43 @@ install, `.mcp.json` gateway wiring, Codex plugin availability, and the
132
135
  integrity of the audit hash chain. It returns a pass/fail summary with
133
136
  specific remediation hints.
134
137
 
138
+ ### 4. Watch the running gateway
139
+
140
+ ```bash
141
+ rea status # human-readable summary
142
+ rea status --json # JSON — pipe to jq
143
+ ```
144
+
145
+ `rea status` is the live-process view. It reads the pidfile written by
146
+ `rea serve`, verifies the pid is alive, and surfaces the session id,
147
+ policy summary (profile, autonomy, HALT state), and audit stats (lines,
148
+ last timestamp, whether the tail record's hash looks well-formed). Use
149
+ `rea check` when you want the pure on-disk view without probing for a
150
+ live process.
151
+
152
+ ### 5. Optional Prometheus `/metrics` endpoint
153
+
154
+ `rea serve` can expose a loopback-only Prometheus endpoint when the
155
+ `REA_METRICS_PORT` environment variable is set:
156
+
157
+ ```bash
158
+ REA_METRICS_PORT=9464 rea serve
159
+ # in another shell
160
+ curl http://127.0.0.1:9464/metrics
161
+ ```
162
+
163
+ Metrics exposed: per-downstream call and error counters, in-flight
164
+ gauge, audit-lines-appended counter, circuit-breaker state gauge, and a
165
+ seconds-since-last-HALT-check gauge. The listener binds to `127.0.0.1`
166
+ only, serves only `GET /metrics` (everything else is a fixed-body 404),
167
+ and never binds by default — "no silent listeners" is a design rule.
168
+ There is no TLS; scrape through SSH/a reverse proxy if you need
169
+ cross-host access.
170
+
171
+ Set `REA_LOG_LEVEL=debug` for verbose gateway logs; the default is
172
+ `info`. Records are JSON lines on a non-TTY stderr and pretty-printed
173
+ on an interactive terminal.
174
+
135
175
  ## Architecture
136
176
 
137
177
  ### Middleware chain
@@ -1,4 +1,5 @@
1
1
  import { type CodexProbeState } from '../gateway/observability/codex-probe.js';
2
+ import { type PrePushDoctorState } from './install/pre-push.js';
2
3
  export interface CheckResult {
3
4
  label: string;
4
5
  /**
@@ -9,6 +10,15 @@ export interface CheckResult {
9
10
  status: 'pass' | 'fail' | 'warn' | 'info';
10
11
  detail?: string;
11
12
  }
13
+ /**
14
+ * G7: report the TOFU fingerprint-store state. Pass = every enabled server
15
+ * in the registry has a matching stored fingerprint. Warn = at least one
16
+ * server would be first-seen or drifted at next `rea serve`. Info = no
17
+ * enabled servers (nothing to fingerprint). Fail only for unreadable store.
18
+ *
19
+ * Exported so tests can drive this without spinning up the full `runDoctor`.
20
+ */
21
+ export declare function checkFingerprintStore(baseDir: string): Promise<CheckResult>;
12
22
  /**
13
23
  * Translate a `CodexProbeState` into two doctor CheckResults: one for
14
24
  * responsiveness (pass/warn) and one informational line about the last
@@ -22,11 +32,16 @@ export declare function checksFromProbeState(state: CodexProbeState): CheckResul
22
32
  * `runDoctor`.
23
33
  *
24
34
  * `codexProbeState` is consulted ONLY when Codex is required by policy.
25
- * Callers that already have a fresh probe state (e.g. `runDoctor`) should
26
- * pass it; callers that don't (e.g. unit tests of the existing doctor
27
- * surface) can omit it and the probe-derived fields are skipped.
35
+ * `prePushState` is the pre-computed G6 pre-push inspection; when omitted
36
+ * the pre-push check is skipped entirely (older call sites that don't yet
37
+ * thread the state through keep working without behavioural change).
38
+ * Callers that already have fresh state (e.g. `runDoctor`) should pass
39
+ * both; callers that don't (e.g. unit tests of the existing doctor
40
+ * surface) can omit them and those checks are skipped.
41
+ *
42
+ * `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
28
43
  */
29
- export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState): CheckResult[];
44
+ export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState, prePushState?: PrePushDoctorState): CheckResult[];
30
45
  export interface RunDoctorOptions {
31
46
  /** When true, print a 7-day telemetry summary after the checks (G11.5). */
32
47
  metrics?: boolean;
@@ -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
@@ -6,6 +6,7 @@ import { runDoctor } from './doctor.js';
6
6
  import { runFreeze, runUnfreeze } from './freeze.js';
7
7
  import { runInit } from './init.js';
8
8
  import { runServe } from './serve.js';
9
+ import { runStatus } from './status.js';
9
10
  import { runUpgrade } from './upgrade.js';
10
11
  import { err, getPkgVersion } from './utils.js';
11
12
  async function main() {
@@ -53,7 +54,7 @@ async function main() {
53
54
  });
54
55
  program
55
56
  .command('serve')
56
- .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.')
57
58
  .action(async () => {
58
59
  await runServe();
59
60
  });
@@ -77,6 +78,13 @@ async function main() {
77
78
  .action(() => {
78
79
  runCheck();
79
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
+ });
80
88
  const audit = program
81
89
  .command('audit')
82
90
  .description('Audit log operations — rotate and verify .rea/audit.jsonl (G1).');
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.');