@bookedsolid/rea 0.3.0 → 0.5.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 (64) hide show
  1. package/.husky/pre-push +15 -18
  2. package/README.md +41 -1
  3. package/dist/cache/review-cache.d.ts +115 -0
  4. package/dist/cache/review-cache.js +200 -0
  5. package/dist/cli/cache.d.ts +52 -0
  6. package/dist/cli/cache.js +112 -0
  7. package/dist/cli/doctor.d.ts +19 -4
  8. package/dist/cli/doctor.js +172 -5
  9. package/dist/cli/index.js +50 -1
  10. package/dist/cli/init.js +109 -7
  11. package/dist/cli/install/gitignore.d.ts +114 -0
  12. package/dist/cli/install/gitignore.js +356 -0
  13. package/dist/cli/install/pre-push.d.ts +335 -0
  14. package/dist/cli/install/pre-push.js +2818 -0
  15. package/dist/cli/serve.d.ts +64 -0
  16. package/dist/cli/serve.js +270 -2
  17. package/dist/cli/status.d.ts +90 -0
  18. package/dist/cli/status.js +399 -0
  19. package/dist/cli/upgrade.js +20 -0
  20. package/dist/cli/utils.d.ts +4 -0
  21. package/dist/cli/utils.js +4 -0
  22. package/dist/gateway/circuit-breaker.d.ts +17 -0
  23. package/dist/gateway/circuit-breaker.js +32 -3
  24. package/dist/gateway/downstream-pool.d.ts +2 -1
  25. package/dist/gateway/downstream-pool.js +2 -2
  26. package/dist/gateway/downstream.d.ts +39 -3
  27. package/dist/gateway/downstream.js +73 -14
  28. package/dist/gateway/log.d.ts +122 -0
  29. package/dist/gateway/log.js +334 -0
  30. package/dist/gateway/middleware/audit.d.ts +10 -1
  31. package/dist/gateway/middleware/audit.js +26 -1
  32. package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
  33. package/dist/gateway/middleware/blocked-paths.js +439 -67
  34. package/dist/gateway/middleware/injection.d.ts +218 -13
  35. package/dist/gateway/middleware/injection.js +433 -51
  36. package/dist/gateway/middleware/kill-switch.d.ts +10 -1
  37. package/dist/gateway/middleware/kill-switch.js +20 -1
  38. package/dist/gateway/observability/metrics.d.ts +125 -0
  39. package/dist/gateway/observability/metrics.js +321 -0
  40. package/dist/gateway/server.d.ts +19 -0
  41. package/dist/gateway/server.js +99 -15
  42. package/dist/policy/loader.d.ts +23 -0
  43. package/dist/policy/loader.js +30 -0
  44. package/dist/policy/profiles.d.ts +13 -0
  45. package/dist/policy/profiles.js +12 -0
  46. package/dist/policy/types.d.ts +48 -0
  47. package/dist/registry/fingerprint.d.ts +73 -0
  48. package/dist/registry/fingerprint.js +81 -0
  49. package/dist/registry/fingerprints-store.d.ts +62 -0
  50. package/dist/registry/fingerprints-store.js +111 -0
  51. package/dist/registry/interpolate.d.ts +58 -0
  52. package/dist/registry/interpolate.js +121 -0
  53. package/dist/registry/loader.d.ts +2 -2
  54. package/dist/registry/loader.js +22 -1
  55. package/dist/registry/tofu-gate.d.ts +41 -0
  56. package/dist/registry/tofu-gate.js +189 -0
  57. package/dist/registry/tofu.d.ts +111 -0
  58. package/dist/registry/tofu.js +173 -0
  59. package/dist/registry/types.d.ts +9 -1
  60. package/hooks/push-review-gate.sh +185 -1
  61. package/package.json +1 -1
  62. package/profiles/bst-internal-no-codex.yaml +5 -0
  63. package/profiles/bst-internal.yaml +7 -0
  64. 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,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import { runAuditRotate, runAuditVerify } from './audit.js';
4
+ import { parseCacheResult, runCacheCheck, runCacheClear, runCacheList, runCacheSet, } from './cache.js';
4
5
  import { runCheck } from './check.js';
5
6
  import { runDoctor } from './doctor.js';
6
7
  import { runFreeze, runUnfreeze } from './freeze.js';
7
8
  import { runInit } from './init.js';
8
9
  import { runServe } from './serve.js';
10
+ import { runStatus } from './status.js';
9
11
  import { runUpgrade } from './upgrade.js';
10
12
  import { err, getPkgVersion } from './utils.js';
11
13
  async function main() {
@@ -53,7 +55,7 @@ async function main() {
53
55
  });
54
56
  program
55
57
  .command('serve')
56
- .description('Start the MCP gateway (stub prints status, verifies policy loads).')
58
+ .description('Start the MCP gateway — stdio server that proxies downstream MCPs declared in .rea/registry.yaml through the middleware chain.')
57
59
  .action(async () => {
58
60
  await runServe();
59
61
  });
@@ -77,6 +79,13 @@ async function main() {
77
79
  .action(() => {
78
80
  runCheck();
79
81
  });
82
+ program
83
+ .command('status')
84
+ .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.')
85
+ .option('--json', 'emit JSON instead of the pretty table (composes with jq)')
86
+ .action((opts) => {
87
+ runStatus({ json: opts.json });
88
+ });
80
89
  const audit = program
81
90
  .command('audit')
82
91
  .description('Audit log operations — rotate and verify .rea/audit.jsonl (G1).');
@@ -93,6 +102,46 @@ async function main() {
93
102
  .action(async (opts) => {
94
103
  await runAuditVerify({ ...(opts.since !== undefined ? { since: opts.since } : {}) });
95
104
  });
105
+ const cache = program
106
+ .command('cache')
107
+ .description('Review-cache operations — check/set/clear/list .rea/review-cache.jsonl (BUG-009). Used by hooks/push-review-gate.sh to skip re-review on a previously-approved diff.');
108
+ cache
109
+ .command('check <sha>')
110
+ .description('Look up a cache entry. Emits JSON to stdout ONLY — hook contract. On hit: {hit,true,result,branch,base,recorded_at[,reason]}. On miss: {hit:false}. Never exits non-zero for normal miss.')
111
+ .requiredOption('--branch <branch>', 'feature branch being pushed')
112
+ .requiredOption('--base <base>', 'base branch the feature targets')
113
+ .action(async (sha, opts) => {
114
+ await runCacheCheck({ sha, branch: opts.branch, base: opts.base });
115
+ });
116
+ cache
117
+ .command('set <sha> <result>')
118
+ .description('Record a review outcome. <result> must be "pass" or "fail". Idempotent line-per-invocation; last write wins on (sha, branch, base).')
119
+ .requiredOption('--branch <branch>', 'feature branch being pushed')
120
+ .requiredOption('--base <base>', 'base branch the feature targets')
121
+ .option('--reason <text>', 'free-text context for this entry (recommended on fail)')
122
+ .action(async (sha, rawResult, opts) => {
123
+ const result = parseCacheResult(rawResult);
124
+ await runCacheSet({
125
+ sha,
126
+ result,
127
+ branch: opts.branch,
128
+ base: opts.base,
129
+ ...(opts.reason !== undefined ? { reason: opts.reason } : {}),
130
+ });
131
+ });
132
+ cache
133
+ .command('clear <sha>')
134
+ .description('Remove every cache entry matching <sha>. Dev convenience — prints the removed count.')
135
+ .action(async (sha) => {
136
+ await runCacheClear({ sha });
137
+ });
138
+ cache
139
+ .command('list')
140
+ .description('Print cache entries in file order. Filter with --branch.')
141
+ .option('--branch <branch>', 'only list entries for this branch')
142
+ .action(async (opts) => {
143
+ await runCacheList({ ...(opts.branch !== undefined ? { branch: opts.branch } : {}) });
144
+ });
96
145
  program
97
146
  .command('doctor')
98
147
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
package/dist/cli/init.js CHANGED
@@ -5,8 +5,11 @@ import * as p from '@clack/prompts';
5
5
  import { AutonomyLevel } from '../policy/types.js';
6
6
  import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js';
7
7
  import { copyArtifacts } from './install/copy.js';
8
+ import { ensureReaGitignore } from './install/gitignore.js';
8
9
  import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
9
10
  import { installCommitMsgHook } from './install/commit-msg.js';
11
+ import { installPrePushFallback } from './install/pre-push.js';
12
+ import { CodexProbe } from '../gateway/observability/codex-probe.js';
10
13
  import { buildFragment, writeClaudeMdFragment } from './install/claude-md.js';
11
14
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
12
15
  import { writeManifestAtomic } from './install/manifest-io.js';
@@ -188,6 +191,48 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase) {
188
191
  reagentNotices: [],
189
192
  };
190
193
  }
194
+ /**
195
+ * G6 — Codex install-assist probe.
196
+ *
197
+ * Runs a single {@link CodexProbe} attempt and prints a guidance block when
198
+ * the CLI is NOT responsive. Behavior:
199
+ *
200
+ * - `cli_responsive === true` → print a single-line "Codex CLI detected"
201
+ * acknowledgement (informational, not verbose).
202
+ * - `cli_responsive === false` → print a 4-line install guidance block
203
+ * naming the Claude Code helper that installs Codex.
204
+ *
205
+ * Failure of the probe itself is never fatal — a hung CLI must not stall
206
+ * `rea init`. The probe class already caps each subcommand at 2s/5s. Any
207
+ * throw bubbling out here is caught and treated as "not responsive".
208
+ *
209
+ * We deliberately reference the user-visible helper path (`/codex:setup`)
210
+ * rather than shelling out to install Codex ourselves. `rea init` does not
211
+ * auto-install third-party tooling; the operator signs off.
212
+ */
213
+ async function printCodexInstallAssist() {
214
+ let responsive = false;
215
+ let versionLine;
216
+ try {
217
+ const state = await new CodexProbe().probe();
218
+ responsive = state.cli_responsive;
219
+ versionLine = state.version;
220
+ }
221
+ catch {
222
+ // probe() is documented as never-throws, but belt-and-suspenders.
223
+ responsive = false;
224
+ }
225
+ console.log('');
226
+ if (responsive) {
227
+ const suffix = versionLine !== undefined ? ` (${versionLine})` : '';
228
+ console.log(`Codex CLI detected${suffix}.`);
229
+ return;
230
+ }
231
+ console.log('Codex CLI not detected on PATH.');
232
+ console.log(' Adversarial review via `/codex-review` requires the Codex plugin.');
233
+ console.log(' Install via the Claude Code Codex plugin helper: `/codex:setup`,');
234
+ console.log(' or set `review.codex_required: false` in .rea/policy.yaml to opt out.');
235
+ }
191
236
  function writePolicyYaml(targetDir, config, layered) {
192
237
  const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
193
238
  const installedBy = process.env.USER ?? os.userInfo().username ?? 'unknown';
@@ -213,6 +258,14 @@ function writePolicyYaml(targetDir, config, layered) {
213
258
  if (layered.injection_detection !== undefined) {
214
259
  lines.push(`injection_detection: ${layered.injection_detection}`);
215
260
  }
261
+ // G9: preserve `injection.suspicious_blocks_writes` when the layered profile
262
+ // pinned it (bst-internal/bst-internal-no-codex pin `true`). External profiles
263
+ // leave this unset so the policy loader's schema default (`false`) applies,
264
+ // which keeps 0.2.x consumers from being silently tightened on upgrade.
265
+ if (layered.injection?.suspicious_blocks_writes !== undefined) {
266
+ lines.push(`injection:`);
267
+ lines.push(` suspicious_blocks_writes: ${layered.injection.suspicious_blocks_writes ? 'true' : 'false'}`);
268
+ }
216
269
  if (layered.context_protection !== undefined) {
217
270
  lines.push(`context_protection:`);
218
271
  const cp = layered.context_protection;
@@ -243,6 +296,21 @@ function writeRegistryYaml(targetDir) {
243
296
  const content = [
244
297
  `# .rea/registry.yaml — downstream MCP servers proxied through rea serve.`,
245
298
  `# Every entry below is subject to the same middleware chain as native tool calls.`,
299
+ `#`,
300
+ `# env: values support \${VAR} interpolation against rea-serve's own process.env.`,
301
+ `# If a referenced var is unset at startup, the affected server fails to start`,
302
+ `# (the rest of the gateway still comes up). Only the curly-brace form is`,
303
+ `# supported — no $VAR, no defaults, no command substitution.`,
304
+ `#`,
305
+ `# Example (uncomment and export the vars in your shell before running \`rea serve\`):`,
306
+ `#`,
307
+ `# - name: discord-ops`,
308
+ `# command: npx`,
309
+ `# args: ['-y', 'discord-ops@latest']`,
310
+ `# env:`,
311
+ `# BOOKED_DISCORD_BOT_TOKEN: '\${BOOKED_DISCORD_BOT_TOKEN}'`,
312
+ `# CLARITY_DISCORD_BOT_TOKEN: '\${CLARITY_DISCORD_BOT_TOKEN}'`,
313
+ `# enabled: false # flip to true after exporting the tokens`,
246
314
  `version: "1"`,
247
315
  `servers: []`,
248
316
  ``,
@@ -387,6 +455,7 @@ export async function runInit(options) {
387
455
  const mergeResult = mergeSettings(settings, desired);
388
456
  await writeSettingsAtomic(settingsPath, mergeResult.merged);
389
457
  const commitMsgResult = await installCommitMsgHook(targetDir);
458
+ const prePushResult = await installPrePushFallback(targetDir);
390
459
  const fragmentInput = {
391
460
  policyPath: `.${path.sep}rea${path.sep}policy.yaml`.replace(/\\/g, '/'),
392
461
  profile: config.profile,
@@ -396,6 +465,10 @@ export async function runInit(options) {
396
465
  blockAiAttribution: config.blockAiAttribution,
397
466
  };
398
467
  const mdResult = await writeClaudeMdFragment(targetDir, fragmentInput);
468
+ // BUG-010 — scaffold `.gitignore` entries for every runtime artifact
469
+ // `rea serve` / `rea cache` / `/freeze` can write under `.rea/`. Idempotent
470
+ // append (and `rea upgrade` backfills older installs that never got this).
471
+ const gitignoreResult = await ensureReaGitignore(targetDir);
399
472
  // G12 — record the install manifest. SHAs are of the files actually on disk
400
473
  // after the copy pass, so drift detection compares against real state (not
401
474
  // canonical, which may differ if the consumer's copy was aborted mid-run).
@@ -410,7 +483,26 @@ export async function runInit(options) {
410
483
  console.log(` + ${path.relative(targetDir, commitMsgResult.gitHook)}`);
411
484
  if (commitMsgResult.huskyHook)
412
485
  console.log(` + ${path.relative(targetDir, commitMsgResult.huskyHook)}`);
486
+ if (prePushResult.written !== undefined) {
487
+ const verb = prePushResult.decision.action === 'refresh' ? '~' : '+';
488
+ console.log(` ${verb} ${path.relative(targetDir, prePushResult.written)} (pre-push fallback)`);
489
+ }
490
+ else if (prePushResult.decision.action === 'skip' &&
491
+ prePushResult.decision.reason === 'active-pre-push-present') {
492
+ console.log(` = ${path.relative(targetDir, prePushResult.decision.hookPath)} (active pre-push already present — skipped fallback)`);
493
+ }
413
494
  console.log(` ${mdResult.replaced ? '~' : '+'} ${path.relative(targetDir, mdResult.path)} (fragment ${mdResult.replaced ? 'replaced' : 'written'})`);
495
+ if (gitignoreResult.action === 'created') {
496
+ console.log(` + ${path.relative(targetDir, gitignoreResult.path)} (managed block written)`);
497
+ }
498
+ else if (gitignoreResult.action === 'updated') {
499
+ console.log(` ~ ${path.relative(targetDir, gitignoreResult.path)} (managed block ${gitignoreResult.addedEntries.length} entr${gitignoreResult.addedEntries.length === 1 ? 'y' : 'ies'} added)`);
500
+ }
501
+ else {
502
+ console.log(` · ${path.relative(targetDir, gitignoreResult.path)} (managed block up to date)`);
503
+ }
504
+ for (const w of gitignoreResult.warnings)
505
+ warn(w);
414
506
  console.log(` + ${path.relative(targetDir, manifestPath)}`);
415
507
  if (mergeResult.warnings.length > 0) {
416
508
  console.log('');
@@ -419,15 +511,25 @@ export async function runInit(options) {
419
511
  }
420
512
  for (const w of commitMsgResult.warnings)
421
513
  warn(w);
514
+ for (const w of prePushResult.warnings)
515
+ warn(w);
422
516
  for (const n of config.reagentNotices)
423
517
  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) {
518
+ // G6 + G11.4: Codex install-assist.
519
+ //
520
+ // Split by codex_required:
521
+ // - codex_required=true → probe the CLI; if it is not responsive, print
522
+ // a clear "install Codex" guidance block so the
523
+ // operator knows why /codex-review will fail.
524
+ // - codex_required=false → skip the probe entirely and print the
525
+ // existing "Codex review disabled" notice.
526
+ // Probing here is pointless (wasted 2s) and
527
+ // actively confusing — no-codex mode is a
528
+ // supported first-class configuration.
529
+ if (config.codexRequired) {
530
+ await printCodexInstallAssist();
531
+ }
532
+ else {
431
533
  console.log('');
432
534
  console.log('Codex review disabled. ClaudeSelfReviewer will be used.');
433
535
  console.log(' Set review.codex_required: true in .rea/policy.yaml to re-enable.');
@@ -0,0 +1,114 @@
1
+ /**
2
+ * BUG-010 — `.gitignore` scaffolding for rea-managed runtime artifacts.
3
+ *
4
+ * Background. `rea serve` (G7 catalog fingerprint) writes
5
+ * `.rea/fingerprints.json` at startup. `rea init` in 0.4.0 and earlier never
6
+ * scaffolded ANY `.gitignore` entries for the consumer repo, so an operator
7
+ * who ran `rea init` then started the gateway would see a "new file" in
8
+ * `git status` that nobody told them about. Helix reported this as BUG-010.
9
+ *
10
+ * The fix is broader than fingerprints.json — every runtime artifact rea
11
+ * writes (under `.rea/` AND its sibling `proper-lockfile` directory at
12
+ * `.rea.lock`) must be in the consumer's `.gitignore`:
13
+ *
14
+ * - `.rea/audit.jsonl` — G1 hash-chained audit log (append-only)
15
+ * - `.rea/audit-*.jsonl` — G1 rotated audit archives
16
+ * - `.rea/HALT` — /freeze marker (ephemeral)
17
+ * - `.rea/metrics.jsonl` — G5 metrics stream
18
+ * - `.rea/serve.pid` — G5 `rea serve` pidfile
19
+ * - `.rea/serve.state.json` — G5 `rea serve` state snapshot
20
+ * - `.rea/fingerprints.json` — G7 downstream catalog fingerprints (BUG-010)
21
+ * - `.rea/review-cache.jsonl` — BUG-009 review cache (rea cache set/check)
22
+ * - `.rea/*.tmp` — serve temp-file-then-rename pattern
23
+ * - `.rea/*.tmp.*` — review-cache pid-salted temp pattern
24
+ * - `.rea/install-manifest.json.bak` / `.tmp` — fs-safe atomic-replace sidecars
25
+ * - `.gitignore.rea-tmp-*` — this module's own temp files on crash
26
+ * (root-level — writeAtomic stages next
27
+ * to .gitignore, not under .rea/)
28
+ * - `.rea.lock` — proper-lockfile sibling dir (NOT under .rea/)
29
+ * (Codex F1 on the BUG-010 review caught all three of these last groups.)
30
+ *
31
+ * Idempotency contract.
32
+ *
33
+ * - `rea init` on a fresh repo with no `.gitignore` → create one with the
34
+ * managed block only.
35
+ * - `rea init` on a repo with a `.gitignore` that has NO rea block → append
36
+ * a managed block separated by a blank line.
37
+ * - `rea upgrade` on an older install whose `.gitignore` lacks the block →
38
+ * same as init; backfill the block so `fingerprints.json` stops showing
39
+ * up as an untracked file.
40
+ * - `rea upgrade` where the managed block exists but is missing some new
41
+ * entries (e.g. `fingerprints.json`, `review-cache.jsonl` added in 0.5.0)
42
+ * → insert the missing lines inside the existing block, preserving any
43
+ * operator-authored lines within the block.
44
+ * - All entries already present, in any order → no-op.
45
+ *
46
+ * Operator DELETIONS of canonical entries are NOT preserved — re-running
47
+ * ensureReaGitignore will re-insert any canonical entry missing from the
48
+ * block body. To opt out of ignoring a specific artifact, operators must
49
+ * configure rea itself, not edit the managed block. This is intentional —
50
+ * the managed block is rea's territory.
51
+ *
52
+ * Security/containment.
53
+ *
54
+ * - Refuse to follow a `.gitignore` symlink (`lstat` gate before any read).
55
+ * The subsequent read uses `O_NOFOLLOW | O_RDONLY` so a TOCTOU swap after
56
+ * the lstat cannot trick us into reading through a symlink to secrets
57
+ * (e.g. `~/.ssh/id_rsa`) and splicing them into the written `.gitignore`.
58
+ * - Temp file name uses `crypto.randomBytes(16)` — not PID + Date.now, which
59
+ * are predictable and leak process info. (Codex F2.)
60
+ * - Cleanup best-effort on write failure so a stale temp file from a
61
+ * prior crash does not accrete.
62
+ *
63
+ * CRLF compatibility (Codex F3).
64
+ *
65
+ * Windows consumers with `core.autocrlf=true` get CRLF line endings on
66
+ * `.gitignore`. Without explicit handling, `"# === rea managed ==="` !==
67
+ * `"# === rea managed ===\r"` and every upgrade would append a duplicate
68
+ * block. We detect the input EOL on read, split on `\r?\n`, trim trailing
69
+ * whitespace from each line before marker-anchored matching, and re-emit
70
+ * with the detected EOL on write.
71
+ *
72
+ * Duplicate blocks (Codex F4).
73
+ *
74
+ * If the file already contains two managed blocks (from a prior bug,
75
+ * manual copy-paste, or two different rea versions), refuse to modify and
76
+ * surface a warning. Merging is more ambitious than this module needs to
77
+ * be — the operator resolves manually, then a subsequent run proceeds.
78
+ */
79
+ export declare const GITIGNORE_BLOCK_START = "# === rea managed \u2014 do not edit between markers ===";
80
+ export declare const GITIGNORE_BLOCK_END = "# === end rea managed ===";
81
+ /**
82
+ * Ordered list of entries every rea install must gitignore. Order is stable
83
+ * so the scaffolded block is deterministic across runs, which in turn makes
84
+ * drift detection tractable: a diff in the managed block means a consumer
85
+ * (or another installer) edited it, not that rea reshuffled.
86
+ *
87
+ * The grouping below is by origin, not alphabetical:
88
+ * 1. audit + HALT + metrics (G1, G4, G5)
89
+ * 2. serve state (G5)
90
+ * 3. fingerprints (G7 / BUG-010)
91
+ * 4. review cache (BUG-009)
92
+ * 5. temp/sidecar patterns (Codex F1)
93
+ * 6. sibling lockfile (Codex F1 — OUTSIDE .rea/)
94
+ */
95
+ export declare const REA_GITIGNORE_ENTRIES: readonly string[];
96
+ export interface EnsureGitignoreResult {
97
+ /** Absolute path to the `.gitignore` file that was (maybe) written. */
98
+ path: string;
99
+ /** `created` = no file before. `updated` = block added or amended. `unchanged` = no-op. */
100
+ action: 'created' | 'updated' | 'unchanged';
101
+ /** Entries the caller added this run (subset of `REA_GITIGNORE_ENTRIES`). */
102
+ addedEntries: string[];
103
+ /** Non-fatal operator-facing messages (e.g. symlink refused, duplicate blocks). */
104
+ warnings: string[];
105
+ }
106
+ /**
107
+ * Main entry point. Idempotent: calling twice in a row produces `unchanged`
108
+ * on the second call.
109
+ *
110
+ * The `entries` parameter defaults to `REA_GITIGNORE_ENTRIES` — both `rea
111
+ * init` and `rea upgrade` pass the default. Tests override to verify
112
+ * reconciliation.
113
+ */
114
+ export declare function ensureReaGitignore(targetDir: string, entries?: readonly string[]): Promise<EnsureGitignoreResult>;