@bookedsolid/rea 0.1.0 → 0.2.1

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 (90) hide show
  1. package/.husky/commit-msg +130 -0
  2. package/.husky/pre-push +128 -0
  3. package/README.md +5 -5
  4. package/agents/codex-adversarial.md +23 -8
  5. package/commands/codex-review.md +2 -2
  6. package/dist/audit/append.d.ts +62 -0
  7. package/dist/audit/append.js +189 -0
  8. package/dist/audit/codex-event.d.ts +28 -0
  9. package/dist/audit/codex-event.js +15 -0
  10. package/dist/cli/doctor.d.ts +60 -1
  11. package/dist/cli/doctor.js +459 -20
  12. package/dist/cli/index.js +35 -5
  13. package/dist/cli/init.d.ts +13 -0
  14. package/dist/cli/init.js +278 -67
  15. package/dist/cli/install/canonical.d.ts +43 -0
  16. package/dist/cli/install/canonical.js +101 -0
  17. package/dist/cli/install/claude-md.d.ts +48 -0
  18. package/dist/cli/install/claude-md.js +93 -0
  19. package/dist/cli/install/commit-msg.d.ts +30 -0
  20. package/dist/cli/install/commit-msg.js +102 -0
  21. package/dist/cli/install/copy.d.ts +169 -0
  22. package/dist/cli/install/copy.js +455 -0
  23. package/dist/cli/install/fs-safe.d.ts +91 -0
  24. package/dist/cli/install/fs-safe.js +347 -0
  25. package/dist/cli/install/manifest-io.d.ts +12 -0
  26. package/dist/cli/install/manifest-io.js +44 -0
  27. package/dist/cli/install/manifest-schema.d.ts +83 -0
  28. package/dist/cli/install/manifest-schema.js +80 -0
  29. package/dist/cli/install/reagent.d.ts +59 -0
  30. package/dist/cli/install/reagent.js +160 -0
  31. package/dist/cli/install/settings-merge.d.ts +91 -0
  32. package/dist/cli/install/settings-merge.js +239 -0
  33. package/dist/cli/install/sha.d.ts +9 -0
  34. package/dist/cli/install/sha.js +21 -0
  35. package/dist/cli/serve.d.ts +11 -0
  36. package/dist/cli/serve.js +72 -6
  37. package/dist/cli/upgrade.d.ts +67 -0
  38. package/dist/cli/upgrade.js +509 -0
  39. package/dist/gateway/downstream-pool.d.ts +39 -0
  40. package/dist/gateway/downstream-pool.js +93 -0
  41. package/dist/gateway/downstream.d.ts +80 -0
  42. package/dist/gateway/downstream.js +196 -0
  43. package/dist/gateway/middleware/audit-types.d.ts +10 -0
  44. package/dist/gateway/middleware/audit.js +14 -0
  45. package/dist/gateway/middleware/injection.d.ts +59 -2
  46. package/dist/gateway/middleware/injection.js +91 -14
  47. package/dist/gateway/middleware/kill-switch.d.ts +20 -5
  48. package/dist/gateway/middleware/kill-switch.js +57 -35
  49. package/dist/gateway/middleware/redact.d.ts +83 -6
  50. package/dist/gateway/middleware/redact.js +133 -46
  51. package/dist/gateway/observability/codex-probe.d.ts +110 -0
  52. package/dist/gateway/observability/codex-probe.js +234 -0
  53. package/dist/gateway/observability/codex-telemetry.d.ts +93 -0
  54. package/dist/gateway/observability/codex-telemetry.js +221 -0
  55. package/dist/gateway/redact-safe/match-timeout.d.ts +83 -0
  56. package/dist/gateway/redact-safe/match-timeout.js +179 -0
  57. package/dist/gateway/reviewers/claude-self.d.ts +99 -0
  58. package/dist/gateway/reviewers/claude-self.js +316 -0
  59. package/dist/gateway/reviewers/codex.d.ts +64 -0
  60. package/dist/gateway/reviewers/codex.js +80 -0
  61. package/dist/gateway/reviewers/select.d.ts +64 -0
  62. package/dist/gateway/reviewers/select.js +102 -0
  63. package/dist/gateway/reviewers/types.d.ts +85 -0
  64. package/dist/gateway/reviewers/types.js +14 -0
  65. package/dist/gateway/server.d.ts +51 -0
  66. package/dist/gateway/server.js +258 -0
  67. package/dist/gateway/session.d.ts +9 -0
  68. package/dist/gateway/session.js +17 -0
  69. package/dist/policy/loader.d.ts +59 -0
  70. package/dist/policy/loader.js +65 -0
  71. package/dist/policy/profiles.d.ts +80 -0
  72. package/dist/policy/profiles.js +94 -0
  73. package/dist/policy/types.d.ts +38 -0
  74. package/dist/registry/loader.d.ts +98 -0
  75. package/dist/registry/loader.js +153 -0
  76. package/dist/registry/types.d.ts +44 -0
  77. package/dist/registry/types.js +6 -0
  78. package/dist/scripts/read-policy-field.d.ts +36 -0
  79. package/dist/scripts/read-policy-field.js +96 -0
  80. package/hooks/push-review-gate.sh +627 -17
  81. package/package.json +13 -2
  82. package/profiles/bst-internal-no-codex.yaml +40 -0
  83. package/profiles/bst-internal.yaml +23 -0
  84. package/profiles/client-engagement.yaml +23 -0
  85. package/profiles/lit-wc.yaml +17 -0
  86. package/profiles/minimal.yaml +11 -0
  87. package/profiles/open-source-no-codex.yaml +33 -0
  88. package/profiles/open-source.yaml +18 -0
  89. package/scripts/lint-safe-regex.mjs +78 -0
  90. package/scripts/postinstall.mjs +131 -0
@@ -1 +1,60 @@
1
- export declare function runDoctor(): void;
1
+ import { type CodexProbeState } from '../gateway/observability/codex-probe.js';
2
+ export interface CheckResult {
3
+ label: string;
4
+ /**
5
+ * `info` is purely informational — not a pass, fail, or warning. Used to
6
+ * print a one-line note about why a check was skipped (e.g. "codex:
7
+ * disabled via policy.review.codex_required").
8
+ */
9
+ status: 'pass' | 'fail' | 'warn' | 'info';
10
+ detail?: string;
11
+ }
12
+ /**
13
+ * Translate a `CodexProbeState` into two doctor CheckResults: one for
14
+ * responsiveness (pass/warn) and one informational line about the last
15
+ * probe time. Extracted so tests can feed a stub state without running
16
+ * the real probe.
17
+ */
18
+ export declare function checksFromProbeState(state: CodexProbeState): CheckResult[];
19
+ /**
20
+ * Assemble the full checklist for a given baseDir. Exported so tests can
21
+ * exercise the conditional branching without capturing stdout from
22
+ * `runDoctor`.
23
+ *
24
+ * `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.
28
+ */
29
+ export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState): CheckResult[];
30
+ export interface RunDoctorOptions {
31
+ /** When true, print a 7-day telemetry summary after the checks (G11.5). */
32
+ metrics?: boolean;
33
+ /**
34
+ * G12 — when true, append a read-only drift report comparing on-disk SHAs
35
+ * to the install manifest + canonical sources. Never mutates; never fails
36
+ * the exit code on its own (drift is information, not a hard error — use
37
+ * `rea upgrade` to reconcile).
38
+ */
39
+ drift?: boolean;
40
+ }
41
+ export interface DriftRow {
42
+ path: string;
43
+ status: 'unmodified' | 'drifted-from-canonical' | 'drifted-from-manifest' | 'missing' | 'untracked' | 'removed-upstream';
44
+ detail?: string;
45
+ }
46
+ export interface DriftReport {
47
+ hasManifest: boolean;
48
+ bootstrap: boolean;
49
+ manifestVersion: string | null;
50
+ reaVersion: string;
51
+ rows: DriftRow[];
52
+ }
53
+ /**
54
+ * Compute drift between the install manifest and the current state of the
55
+ * consumer's tree. Strictly read-only — no file writes. Synthetic entries
56
+ * (settings subset, managed CLAUDE.md fragment) are checked by hashing the
57
+ * computed values against the manifest SHA.
58
+ */
59
+ export declare function collectDriftReport(baseDir: string): Promise<DriftReport>;
60
+ export declare function runDoctor(opts?: RunDoctorOptions): Promise<void>;
@@ -1,7 +1,15 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { loadPolicy } from '../policy/loader.js';
4
- import { POLICY_FILE, REA_DIR, REGISTRY_FILE, log, reaPath } from './utils.js';
4
+ import { loadRegistry } from '../registry/loader.js';
5
+ import { CodexProbe, } from '../gateway/observability/codex-probe.js';
6
+ import { summarizeTelemetry } from '../gateway/observability/codex-telemetry.js';
7
+ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
8
+ import { buildFragment } from './install/claude-md.js';
9
+ import { canonicalSettingsSubsetHash, defaultDesiredHooks, } from './install/settings-merge.js';
10
+ import { manifestExists, readManifest } from './install/manifest-io.js';
11
+ import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
12
+ import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
5
13
  function checkFileExists(label, filePath, fatal) {
6
14
  const exists = fs.existsSync(filePath);
7
15
  if (exists)
@@ -32,46 +40,453 @@ function checkPolicyParses(baseDir, policyPath) {
32
40
  };
33
41
  }
34
42
  }
35
- function checkCodexPlugin(baseDir) {
36
- const commandsDir = path.join(baseDir, '.claude', 'commands');
37
- if (!fs.existsSync(commandsDir)) {
43
+ function checkRegistryParses(baseDir, registryPath) {
44
+ if (!fs.existsSync(registryPath)) {
38
45
  return {
39
- label: 'Codex plugin command(s)',
46
+ label: 'registry.yaml parses',
40
47
  status: 'warn',
41
- detail: 'no .claude/commands/ directory — Codex adversarial review not wired',
48
+ detail: `missing: ${registryPath}`,
42
49
  };
43
50
  }
44
- const entries = fs.readdirSync(commandsDir);
45
- const codexEntry = entries.find((name) => name.toLowerCase().startsWith('codex'));
46
- if (codexEntry !== undefined) {
47
- return { label: 'Codex plugin command(s)', status: 'pass', detail: codexEntry };
51
+ try {
52
+ const registry = loadRegistry(baseDir);
53
+ return {
54
+ label: 'registry.yaml parses',
55
+ status: 'pass',
56
+ detail: `${registry.servers.length} server(s) declared`,
57
+ };
58
+ }
59
+ catch (e) {
60
+ return {
61
+ label: 'registry.yaml parses',
62
+ status: 'fail',
63
+ detail: e instanceof Error ? e.message : String(e),
64
+ };
65
+ }
66
+ }
67
+ const EXPECTED_AGENTS = [
68
+ 'accessibility-engineer.md',
69
+ 'backend-engineer.md',
70
+ 'code-reviewer.md',
71
+ 'codex-adversarial.md',
72
+ 'frontend-specialist.md',
73
+ 'qa-engineer.md',
74
+ 'rea-orchestrator.md',
75
+ 'security-engineer.md',
76
+ 'technical-writer.md',
77
+ 'typescript-specialist.md',
78
+ ];
79
+ const EXPECTED_HOOKS = [
80
+ 'architecture-review-gate.sh',
81
+ 'attribution-advisory.sh',
82
+ 'blocked-paths-enforcer.sh',
83
+ 'changeset-security-gate.sh',
84
+ 'commit-review-gate.sh',
85
+ 'dangerous-bash-interceptor.sh',
86
+ 'dependency-audit-gate.sh',
87
+ 'env-file-protection.sh',
88
+ 'pr-issue-link-gate.sh',
89
+ 'push-review-gate.sh',
90
+ 'secret-scanner.sh',
91
+ 'security-disclosure-gate.sh',
92
+ 'settings-protection.sh',
93
+ ];
94
+ function checkAgentsPresent(baseDir) {
95
+ const agentsDir = path.join(baseDir, '.claude', 'agents');
96
+ if (!fs.existsSync(agentsDir)) {
97
+ return { label: 'curated agents installed', status: 'fail', detail: `missing: ${agentsDir}` };
98
+ }
99
+ const missing = EXPECTED_AGENTS.filter((name) => !fs.existsSync(path.join(agentsDir, name)));
100
+ if (missing.length === 0) {
101
+ return {
102
+ label: 'curated agents installed',
103
+ status: 'pass',
104
+ detail: `${EXPECTED_AGENTS.length} agents present`,
105
+ };
106
+ }
107
+ return {
108
+ label: 'curated agents installed',
109
+ status: 'fail',
110
+ detail: `missing: ${missing.join(', ')}`,
111
+ };
112
+ }
113
+ function checkHooksInstalled(baseDir) {
114
+ const hooksDir = path.join(baseDir, '.claude', 'hooks');
115
+ if (!fs.existsSync(hooksDir)) {
116
+ return { label: 'hooks installed + executable', status: 'fail', detail: `missing: ${hooksDir}` };
117
+ }
118
+ const issues = [];
119
+ for (const name of EXPECTED_HOOKS) {
120
+ const p = path.join(hooksDir, name);
121
+ if (!fs.existsSync(p)) {
122
+ issues.push(`missing ${name}`);
123
+ continue;
124
+ }
125
+ const stat = fs.statSync(p);
126
+ if ((stat.mode & 0o111) === 0)
127
+ issues.push(`${name} not executable (mode=${(stat.mode & 0o777).toString(8)})`);
128
+ }
129
+ if (issues.length === 0) {
130
+ return {
131
+ label: 'hooks installed + executable',
132
+ status: 'pass',
133
+ detail: `${EXPECTED_HOOKS.length} hooks present`,
134
+ };
135
+ }
136
+ return { label: 'hooks installed + executable', status: 'fail', detail: issues.join('; ') };
137
+ }
138
+ function checkSettingsJson(baseDir) {
139
+ const settingsPath = path.join(baseDir, '.claude', 'settings.json');
140
+ if (!fs.existsSync(settingsPath)) {
141
+ return {
142
+ label: 'settings.json matchers cover Bash + Write|Edit',
143
+ status: 'fail',
144
+ detail: `missing: ${settingsPath}`,
145
+ };
146
+ }
147
+ try {
148
+ const raw = fs.readFileSync(settingsPath, 'utf8');
149
+ const parsed = JSON.parse(raw);
150
+ const pre = parsed.hooks?.PreToolUse ?? [];
151
+ const matchers = new Set(pre.map((g) => g.matcher).filter((m) => typeof m === 'string'));
152
+ const needs = [];
153
+ if (!matchers.has('Bash'))
154
+ needs.push('Bash');
155
+ if (!matchers.has('Write|Edit'))
156
+ needs.push('Write|Edit');
157
+ if (needs.length === 0) {
158
+ return {
159
+ label: 'settings.json matchers cover Bash + Write|Edit',
160
+ status: 'pass',
161
+ };
162
+ }
163
+ return {
164
+ label: 'settings.json matchers cover Bash + Write|Edit',
165
+ status: 'fail',
166
+ detail: `missing PreToolUse matchers: ${needs.join(', ')}`,
167
+ };
168
+ }
169
+ catch (e) {
170
+ return {
171
+ label: 'settings.json matchers cover Bash + Write|Edit',
172
+ status: 'fail',
173
+ detail: e instanceof Error ? e.message : String(e),
174
+ };
175
+ }
176
+ }
177
+ function checkCommitMsgHook(baseDir) {
178
+ const hookPath = path.join(baseDir, '.git', 'hooks', 'commit-msg');
179
+ if (!fs.existsSync(hookPath)) {
180
+ return {
181
+ label: 'commit-msg hook installed',
182
+ status: 'warn',
183
+ detail: `missing: ${hookPath} (block_ai_attribution will not be enforced at commit time)`,
184
+ };
185
+ }
186
+ try {
187
+ const stat = fs.statSync(hookPath);
188
+ if (stat.size === 0) {
189
+ return { label: 'commit-msg hook installed', status: 'fail', detail: 'file is empty' };
190
+ }
191
+ return { label: 'commit-msg hook installed', status: 'pass' };
192
+ }
193
+ catch (e) {
194
+ return {
195
+ label: 'commit-msg hook installed',
196
+ status: 'fail',
197
+ detail: e instanceof Error ? e.message : String(e),
198
+ };
48
199
  }
200
+ }
201
+ function checkCodexAgent(baseDir) {
202
+ const agentPath = path.join(baseDir, '.claude', 'agents', 'codex-adversarial.md');
203
+ if (fs.existsSync(agentPath))
204
+ return { label: 'codex-adversarial agent installed', status: 'pass' };
205
+ return {
206
+ label: 'codex-adversarial agent installed',
207
+ status: 'warn',
208
+ detail: `missing: ${agentPath}`,
209
+ };
210
+ }
211
+ function checkCodexCommand(baseDir) {
212
+ const cmdPath = path.join(baseDir, '.claude', 'commands', 'codex-review.md');
213
+ if (fs.existsSync(cmdPath))
214
+ return { label: '/codex-review command installed', status: 'pass' };
49
215
  return {
50
- label: 'Codex plugin command(s)',
216
+ label: '/codex-review command installed',
51
217
  status: 'warn',
52
- detail: 'no .claude/commands/codex* found — /codex-review will not be available',
218
+ detail: `missing: ${cmdPath}`,
53
219
  };
54
220
  }
221
+ /**
222
+ * Translate a `CodexProbeState` into two doctor CheckResults: one for
223
+ * responsiveness (pass/warn) and one informational line about the last
224
+ * probe time. Extracted so tests can feed a stub state without running
225
+ * the real probe.
226
+ */
227
+ export function checksFromProbeState(state) {
228
+ const responsive = state.cli_responsive
229
+ ? state.version !== undefined
230
+ ? {
231
+ label: 'codex.cli_responsive',
232
+ status: 'pass',
233
+ detail: `version: ${state.version}`,
234
+ }
235
+ : { label: 'codex.cli_responsive', status: 'pass' }
236
+ : {
237
+ label: 'codex.cli_responsive',
238
+ status: 'warn',
239
+ detail: state.last_error ?? 'Codex CLI did not respond',
240
+ };
241
+ const lastProbe = {
242
+ label: 'codex.last_probe_at',
243
+ status: 'info',
244
+ detail: state.last_probe_at,
245
+ };
246
+ return [responsive, lastProbe];
247
+ }
55
248
  function formatSymbol(status) {
56
249
  if (status === 'pass')
57
250
  return '[ok] ';
58
251
  if (status === 'warn')
59
252
  return '[warn]';
253
+ if (status === 'info')
254
+ return '[info]';
60
255
  return '[fail]';
61
256
  }
62
- export function runDoctor() {
63
- const baseDir = process.cwd();
257
+ /**
258
+ * Return whether Codex adversarial review is required. Read from the parsed
259
+ * policy; default is `true` when the field is absent. Isolated so tests can
260
+ * stub a policy without having to touch disk.
261
+ */
262
+ function codexRequiredFromPolicy(baseDir) {
263
+ try {
264
+ const policy = loadPolicy(baseDir);
265
+ return policy.review?.codex_required !== false;
266
+ }
267
+ catch {
268
+ // If the policy itself is unreadable, checkPolicyParses will already
269
+ // report a fail. Default to "Codex required" so we still run those
270
+ // checks and surface the full picture.
271
+ return true;
272
+ }
273
+ }
274
+ /**
275
+ * Assemble the full checklist for a given baseDir. Exported so tests can
276
+ * exercise the conditional branching without capturing stdout from
277
+ * `runDoctor`.
278
+ *
279
+ * `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.
283
+ */
284
+ export function collectChecks(baseDir, codexProbeState) {
64
285
  const policyPath = reaPath(baseDir, POLICY_FILE);
65
286
  const registryPath = reaPath(baseDir, REGISTRY_FILE);
66
- const reaDir = path.join(baseDir, REA_DIR);
67
- const commitMsgHook = path.join(baseDir, '.git', 'hooks', 'commit-msg');
287
+ const reaDirPath = path.join(baseDir, REA_DIR);
68
288
  const checks = [
69
- checkFileExists('.rea/ directory exists', reaDir, true),
289
+ checkFileExists('.rea/ directory exists', reaDirPath, true),
70
290
  checkPolicyParses(baseDir, policyPath),
71
- checkFileExists('.rea/registry.yaml exists', registryPath, false),
72
- checkFileExists('.git/hooks/commit-msg installed', commitMsgHook, false),
73
- checkCodexPlugin(baseDir),
291
+ checkRegistryParses(baseDir, registryPath),
292
+ checkAgentsPresent(baseDir),
293
+ checkHooksInstalled(baseDir),
294
+ checkSettingsJson(baseDir),
295
+ checkCommitMsgHook(baseDir),
74
296
  ];
297
+ if (codexRequiredFromPolicy(baseDir)) {
298
+ checks.push(checkCodexAgent(baseDir), checkCodexCommand(baseDir));
299
+ if (codexProbeState !== undefined) {
300
+ checks.push(...checksFromProbeState(codexProbeState));
301
+ }
302
+ }
303
+ else {
304
+ // Single informational line replaces the two Codex-specific checks.
305
+ // The `codex-adversarial.md` agent is still expected to be present by
306
+ // checkAgentsPresent — that's deliberate; the agent is cheap to ship
307
+ // and flipping the flag should not require a re-install.
308
+ checks.push({
309
+ label: 'codex',
310
+ status: 'info',
311
+ detail: 'disabled via policy.review.codex_required — skipping Codex-related checks',
312
+ });
313
+ }
314
+ return checks;
315
+ }
316
+ /**
317
+ * Compute drift between the install manifest and the current state of the
318
+ * consumer's tree. Strictly read-only — no file writes. Synthetic entries
319
+ * (settings subset, managed CLAUDE.md fragment) are checked by hashing the
320
+ * computed values against the manifest SHA.
321
+ */
322
+ export async function collectDriftReport(baseDir) {
323
+ const reaVersion = getPkgVersion();
324
+ const rows = [];
325
+ if (!manifestExists(baseDir)) {
326
+ return {
327
+ hasManifest: false,
328
+ bootstrap: false,
329
+ manifestVersion: null,
330
+ reaVersion,
331
+ rows,
332
+ };
333
+ }
334
+ const manifest = await readManifest(baseDir);
335
+ if (manifest === null) {
336
+ return {
337
+ hasManifest: false,
338
+ bootstrap: false,
339
+ manifestVersion: null,
340
+ reaVersion,
341
+ rows,
342
+ };
343
+ }
344
+ const manifestByPath = new Map(manifest.files.map((e) => [e.path, e]));
345
+ const canonical = await enumerateCanonicalFiles();
346
+ const canonicalByPath = new Map(canonical.map((c) => [c.destRelPath, c]));
347
+ // Canonical files: compare on-disk against canonical + manifest.
348
+ for (const c of canonical) {
349
+ const abs = path.join(baseDir, c.destRelPath);
350
+ if (!fs.existsSync(abs)) {
351
+ rows.push({
352
+ path: c.destRelPath,
353
+ status: 'missing',
354
+ detail: 'file not installed',
355
+ });
356
+ continue;
357
+ }
358
+ const localSha = await sha256OfFile(abs);
359
+ const canonicalSha = await sha256OfFile(c.sourceAbsPath);
360
+ const entry = manifestByPath.get(c.destRelPath);
361
+ if (localSha === canonicalSha) {
362
+ rows.push({ path: c.destRelPath, status: 'unmodified' });
363
+ continue;
364
+ }
365
+ if (entry !== undefined && localSha === entry.sha256) {
366
+ rows.push({
367
+ path: c.destRelPath,
368
+ status: 'drifted-from-canonical',
369
+ detail: `local matches manifest but differs from rea v${reaVersion} canonical — run \`rea upgrade\``,
370
+ });
371
+ continue;
372
+ }
373
+ rows.push({
374
+ path: c.destRelPath,
375
+ status: 'drifted-from-manifest',
376
+ detail: 'file modified locally since install',
377
+ });
378
+ }
379
+ // Manifest entries no longer in canonical (removed upstream), excluding
380
+ // synthetic entries handled below.
381
+ for (const entry of manifest.files) {
382
+ if (entry.path === CLAUDE_MD_MANIFEST_PATH ||
383
+ entry.path === SETTINGS_MANIFEST_PATH)
384
+ continue;
385
+ if (!canonicalByPath.has(entry.path)) {
386
+ rows.push({
387
+ path: entry.path,
388
+ status: 'removed-upstream',
389
+ detail: 'no longer shipped — run `rea upgrade` to remove',
390
+ });
391
+ }
392
+ }
393
+ // Synthetic: rea-owned settings subset.
394
+ const settingsEntry = manifestByPath.get(SETTINGS_MANIFEST_PATH);
395
+ const settingsSha = canonicalSettingsSubsetHash(defaultDesiredHooks());
396
+ if (settingsEntry === undefined) {
397
+ rows.push({ path: SETTINGS_MANIFEST_PATH, status: 'untracked' });
398
+ }
399
+ else if (settingsEntry.sha256 !== settingsSha) {
400
+ rows.push({
401
+ path: SETTINGS_MANIFEST_PATH,
402
+ status: 'drifted-from-canonical',
403
+ detail: 'desired-hooks set has changed since install',
404
+ });
405
+ }
406
+ else {
407
+ rows.push({ path: SETTINGS_MANIFEST_PATH, status: 'unmodified' });
408
+ }
409
+ // Synthetic: managed CLAUDE.md fragment. Render the fragment from the
410
+ // current policy and compare. If policy is unreadable, skip gracefully.
411
+ const mdEntry = manifestByPath.get(CLAUDE_MD_MANIFEST_PATH);
412
+ try {
413
+ const policy = loadPolicy(baseDir);
414
+ const fragment = buildFragment({
415
+ policyPath: '.rea/policy.yaml',
416
+ profile: policy.profile,
417
+ autonomyLevel: policy.autonomy_level,
418
+ maxAutonomyLevel: policy.max_autonomy_level,
419
+ blockedPathsCount: policy.blocked_paths.length,
420
+ blockAiAttribution: policy.block_ai_attribution,
421
+ });
422
+ const currentSha = sha256OfBuffer(fragment);
423
+ if (mdEntry === undefined) {
424
+ rows.push({ path: CLAUDE_MD_MANIFEST_PATH, status: 'untracked' });
425
+ }
426
+ else if (mdEntry.sha256 !== currentSha) {
427
+ rows.push({
428
+ path: CLAUDE_MD_MANIFEST_PATH,
429
+ status: 'drifted-from-canonical',
430
+ detail: 'policy values or fragment template changed since install',
431
+ });
432
+ }
433
+ else {
434
+ rows.push({ path: CLAUDE_MD_MANIFEST_PATH, status: 'unmodified' });
435
+ }
436
+ }
437
+ catch {
438
+ // Policy unreadable — drift of the fragment is meaningless to compute.
439
+ // The main doctor checks will already surface the policy failure.
440
+ }
441
+ return {
442
+ hasManifest: true,
443
+ bootstrap: manifest.bootstrap === true,
444
+ manifestVersion: manifest.version,
445
+ reaVersion,
446
+ rows,
447
+ };
448
+ }
449
+ function printDriftReport(report) {
450
+ console.log('');
451
+ log('Drift report');
452
+ if (!report.hasManifest) {
453
+ console.log(' no .rea/install-manifest.json — run `rea upgrade` once to bootstrap a manifest.');
454
+ console.log('');
455
+ return;
456
+ }
457
+ console.log(` manifest v${report.manifestVersion ?? '?'} — running rea v${report.reaVersion}${report.bootstrap ? ' (bootstrap)' : ''}`);
458
+ console.log('');
459
+ let clean = 0;
460
+ for (const row of report.rows) {
461
+ if (row.status === 'unmodified') {
462
+ clean += 1;
463
+ continue;
464
+ }
465
+ const detail = row.detail !== undefined ? ` — ${row.detail}` : '';
466
+ console.log(` [${row.status}] ${row.path}${detail}`);
467
+ }
468
+ console.log('');
469
+ console.log(` ${clean} clean, ${report.rows.length - clean} with drift/issues.`);
470
+ console.log('');
471
+ }
472
+ export async function runDoctor(opts = {}) {
473
+ const baseDir = process.cwd();
474
+ // G11.3 — one-shot probe when Codex is required by policy. Doctor may be
475
+ // invoked without a running gateway, so we don't share state with
476
+ // `rea serve`; we just run a fresh probe here. Failure is observational —
477
+ // a warn row, never a hard failure of `rea doctor`.
478
+ let probeState;
479
+ if (codexRequiredFromPolicy(baseDir)) {
480
+ try {
481
+ probeState = await new CodexProbe().probe();
482
+ }
483
+ catch {
484
+ // `probe()` is documented as never-throws, but belt-and-suspenders:
485
+ // missing probe data should never crash doctor.
486
+ probeState = undefined;
487
+ }
488
+ }
489
+ const checks = collectChecks(baseDir, probeState);
75
490
  console.log('');
76
491
  log(`Doctor — ${baseDir}`);
77
492
  console.log('');
@@ -82,6 +497,17 @@ export function runDoctor() {
82
497
  if (c.status === 'fail')
83
498
  hardFail = true;
84
499
  }
500
+ // G11.5 — optional telemetry summary. Prints AFTER the main checks and
501
+ // NEVER contributes to the exit code. Purely observational.
502
+ if (opts.metrics === true) {
503
+ await printTelemetrySummary(baseDir);
504
+ }
505
+ // G12 — optional drift report. Also purely observational; `rea upgrade`
506
+ // is the action path.
507
+ if (opts.drift === true) {
508
+ const report = await collectDriftReport(baseDir);
509
+ printDriftReport(report);
510
+ }
85
511
  console.log('');
86
512
  if (hardFail) {
87
513
  log('Doctor: one or more hard checks failed.');
@@ -91,3 +517,16 @@ export function runDoctor() {
91
517
  log('Doctor: OK (warnings do not fail the check).');
92
518
  console.log('');
93
519
  }
520
+ /**
521
+ * Render the telemetry summary after the doctor checks. Compact by
522
+ * design — the exact shape may evolve as G5 formalizes metrics export.
523
+ */
524
+ async function printTelemetrySummary(baseDir) {
525
+ const summary = await summarizeTelemetry(baseDir);
526
+ console.log('');
527
+ log(`Telemetry — last ${summary.window_days} days`);
528
+ console.log(` invocations/day: ${summary.invocations_per_day.join(', ')}`);
529
+ console.log(` total estimated tokens: ${summary.total_estimated_tokens}`);
530
+ console.log(` rate-limited responses: ${summary.rate_limited_count}`);
531
+ console.log(` avg latency: ${Math.round(summary.avg_latency_ms)} ms`);
532
+ }
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@ import { runDoctor } from './doctor.js';
5
5
  import { runFreeze, runUnfreeze } from './freeze.js';
6
6
  import { runInit } from './init.js';
7
7
  import { runServe } from './serve.js';
8
+ import { runUpgrade } from './upgrade.js';
8
9
  import { err, getPkgVersion } from './utils.js';
9
10
  async function main() {
10
11
  const program = new Command();
@@ -14,15 +15,39 @@ async function main() {
14
15
  .version(getPkgVersion(), '-v, --version', 'print rea version');
15
16
  program
16
17
  .command('init')
17
- .description('Interactive wizard — write .rea/policy.yaml and .rea/registry.yaml')
18
- .option('-y, --yes', 'non-interactive mode — accept defaults for all prompts')
18
+ .description('Interactive wizard — write .rea/policy.yaml, install .claude/, commit-msg hook, and CLAUDE.md fragment')
19
+ .option('-y, --yes', 'non-interactive mode — accept defaults, skip existing files')
19
20
  .option('--from-reagent', 'migrate from a .reagent/ directory if present')
20
- .option('--profile <name>', 'profile: minimal | client-engagement | bst-internal | lit-wc | open-source')
21
+ .option('--profile <name>', 'profile: minimal | client-engagement | bst-internal | bst-internal-no-codex | lit-wc | open-source | open-source-no-codex')
22
+ .option('--force', 'overwrite existing .claude/ artifacts and .rea/policy.yaml')
23
+ .option('--accept-dropped-fields', 'allow reagent translation when drop-list fields are present (security-adjacent)')
24
+ // Commander's boolean-with-negation pair: `--codex` sets codex=true,
25
+ // `--no-codex` sets codex=false. Leaving both unset produces
26
+ // `opts.codex === undefined`, and runInit derives the value from the
27
+ // profile name.
28
+ .option('--codex', 'require Codex adversarial review (writes review.codex_required: true)')
29
+ .option('--no-codex', 'disable Codex adversarial review (writes review.codex_required: false)')
21
30
  .action(async (opts) => {
22
31
  await runInit({
23
32
  yes: opts.yes,
24
33
  fromReagent: opts.fromReagent,
25
34
  profile: opts.profile,
35
+ force: opts.force,
36
+ acceptDroppedFields: opts.acceptDroppedFields,
37
+ codex: opts.codex,
38
+ });
39
+ });
40
+ program
41
+ .command('upgrade')
42
+ .description('Sync .claude/, .husky/, and managed fragments with this rea version. Prompts on drift; auto-updates unmodified files.')
43
+ .option('--dry-run', 'show what would change; write nothing')
44
+ .option('-y, --yes', 'non-interactive — keep drifted files, skip removed-upstream')
45
+ .option('--force', 'non-interactive — overwrite drift, delete removed-upstream')
46
+ .action(async (opts) => {
47
+ await runUpgrade({
48
+ dryRun: opts.dryRun,
49
+ yes: opts.yes,
50
+ force: opts.force,
26
51
  });
27
52
  });
28
53
  program
@@ -54,8 +79,13 @@ async function main() {
54
79
  program
55
80
  .command('doctor')
56
81
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
57
- .action(() => {
58
- runDoctor();
82
+ .option('--metrics', 'also print a 7-day summary of Codex telemetry (G11.5)')
83
+ .option('--drift', 'report drift vs. the install manifest (read-only; does not mutate)')
84
+ .action(async (opts) => {
85
+ await runDoctor({
86
+ ...(opts.metrics === true ? { metrics: true } : {}),
87
+ ...(opts.drift === true ? { drift: true } : {}),
88
+ });
59
89
  });
60
90
  await program.parseAsync(process.argv);
61
91
  }
@@ -2,5 +2,18 @@ export interface InitOptions {
2
2
  yes?: boolean | undefined;
3
3
  fromReagent?: boolean | undefined;
4
4
  profile?: string | undefined;
5
+ force?: boolean | undefined;
6
+ acceptDroppedFields?: boolean | undefined;
7
+ /**
8
+ * G11.4: explicit override for Codex adversarial review. `true` forces
9
+ * `review.codex_required: true` in the written policy; `false` forces
10
+ * `false`. Undefined → derive default from the chosen profile name
11
+ * (profiles whose name ends with `-no-codex` default to no-codex).
12
+ *
13
+ * Non-interactive semantics: in `--yes` mode the flag is honored
14
+ * directly. Interactive mode confirms the flag value as the prompt's
15
+ * initial value (but still prompts for a final answer).
16
+ */
17
+ codex?: boolean | undefined;
5
18
  }
6
19
  export declare function runInit(options: InitOptions): Promise<void>;