@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.
- package/.husky/pre-push +15 -18
- package/README.md +41 -1
- package/dist/cache/review-cache.d.ts +115 -0
- package/dist/cache/review-cache.js +200 -0
- package/dist/cli/cache.d.ts +52 -0
- package/dist/cli/cache.js +112 -0
- package/dist/cli/doctor.d.ts +19 -4
- package/dist/cli/doctor.js +172 -5
- package/dist/cli/index.js +50 -1
- package/dist/cli/init.js +109 -7
- package/dist/cli/install/gitignore.d.ts +114 -0
- package/dist/cli/install/gitignore.js +356 -0
- package/dist/cli/install/pre-push.d.ts +335 -0
- package/dist/cli/install/pre-push.js +2818 -0
- package/dist/cli/serve.d.ts +64 -0
- package/dist/cli/serve.js +270 -2
- package/dist/cli/status.d.ts +90 -0
- package/dist/cli/status.js +399 -0
- package/dist/cli/upgrade.js +20 -0
- package/dist/cli/utils.d.ts +4 -0
- package/dist/cli/utils.js +4 -0
- package/dist/gateway/circuit-breaker.d.ts +17 -0
- package/dist/gateway/circuit-breaker.js +32 -3
- package/dist/gateway/downstream-pool.d.ts +2 -1
- package/dist/gateway/downstream-pool.js +2 -2
- package/dist/gateway/downstream.d.ts +39 -3
- package/dist/gateway/downstream.js +73 -14
- package/dist/gateway/log.d.ts +122 -0
- package/dist/gateway/log.js +334 -0
- package/dist/gateway/middleware/audit.d.ts +10 -1
- package/dist/gateway/middleware/audit.js +26 -1
- package/dist/gateway/middleware/blocked-paths.d.ts +0 -9
- package/dist/gateway/middleware/blocked-paths.js +439 -67
- package/dist/gateway/middleware/injection.d.ts +218 -13
- package/dist/gateway/middleware/injection.js +433 -51
- package/dist/gateway/middleware/kill-switch.d.ts +10 -1
- package/dist/gateway/middleware/kill-switch.js +20 -1
- package/dist/gateway/observability/metrics.d.ts +125 -0
- package/dist/gateway/observability/metrics.js +321 -0
- package/dist/gateway/server.d.ts +19 -0
- package/dist/gateway/server.js +99 -15
- package/dist/policy/loader.d.ts +23 -0
- package/dist/policy/loader.js +30 -0
- package/dist/policy/profiles.d.ts +13 -0
- package/dist/policy/profiles.js +12 -0
- package/dist/policy/types.d.ts +48 -0
- package/dist/registry/fingerprint.d.ts +73 -0
- package/dist/registry/fingerprint.js +81 -0
- package/dist/registry/fingerprints-store.d.ts +62 -0
- package/dist/registry/fingerprints-store.js +111 -0
- package/dist/registry/interpolate.d.ts +58 -0
- package/dist/registry/interpolate.js +121 -0
- package/dist/registry/loader.d.ts +2 -2
- package/dist/registry/loader.js +22 -1
- package/dist/registry/tofu-gate.d.ts +41 -0
- package/dist/registry/tofu-gate.js +189 -0
- package/dist/registry/tofu.d.ts +111 -0
- package/dist/registry/tofu.js +173 -0
- package/dist/registry/types.d.ts +9 -1
- package/hooks/push-review-gate.sh +185 -1
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +5 -0
- package/profiles/bst-internal.yaml +7 -0
- package/scripts/tarball-smoke.sh +197 -0
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
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
|
-
|
|
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
|
|
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:
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
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>;
|