@bookedsolid/rea 0.26.1 → 0.28.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 (45) hide show
  1. package/README.md +16 -3
  2. package/agents/adversarial-test-specialist.md +113 -0
  3. package/agents/ast-parser-specialist.md +92 -0
  4. package/agents/codex-adversarial.md +50 -97
  5. package/agents/figma-dx-specialist.md +112 -0
  6. package/agents/mcp-protocol-specialist.md +94 -0
  7. package/agents/observability-specialist.md +103 -0
  8. package/agents/rea-orchestrator.md +25 -5
  9. package/agents/shell-scripting-specialist.md +101 -0
  10. package/commands/codex-review.md +62 -59
  11. package/data/claims/helix-022.json +51 -0
  12. package/data/claims/helix-023.json +44 -0
  13. package/data/claims/helix-024.json +72 -0
  14. package/data/claims/helix-028.json +23 -0
  15. package/data/claims/helix-031.json +27 -0
  16. package/dist/cli/hook.d.ts +78 -4
  17. package/dist/cli/hook.js +291 -4
  18. package/dist/cli/index.js +6 -0
  19. package/dist/cli/preflight.d.ts +12 -0
  20. package/dist/cli/preflight.js +65 -4
  21. package/dist/cli/status.d.ts +6 -0
  22. package/dist/cli/status.js +7 -0
  23. package/dist/cli/verify-claim.d.ts +149 -0
  24. package/dist/cli/verify-claim.js +386 -0
  25. package/dist/gateway/downstream-pool.d.ts +17 -0
  26. package/dist/gateway/downstream-pool.js +1 -0
  27. package/dist/gateway/downstream.d.ts +25 -0
  28. package/dist/gateway/downstream.js +40 -0
  29. package/dist/gateway/live-state.d.ts +12 -0
  30. package/dist/gateway/live-state.js +1 -0
  31. package/dist/hooks/bash-scanner/walker.js +196 -0
  32. package/dist/hooks/push-gate/codex-runner.d.ts +9 -0
  33. package/dist/hooks/push-gate/codex-runner.js +14 -1
  34. package/dist/hooks/push-gate/findings.d.ts +27 -0
  35. package/dist/hooks/push-gate/findings.js +87 -0
  36. package/dist/hooks/push-gate/index.js +58 -4
  37. package/dist/hooks/push-gate/policy.d.ts +15 -0
  38. package/dist/hooks/push-gate/policy.js +82 -0
  39. package/dist/policy/loader.d.ts +20 -0
  40. package/dist/policy/loader.js +12 -0
  41. package/dist/policy/types.d.ts +31 -0
  42. package/hooks/_lib/cmd-segments.sh +10 -0
  43. package/hooks/blocked-paths-bash-gate.sh +12 -0
  44. package/hooks/protected-paths-bash-gate.sh +21 -0
  45. package/package.json +2 -1
@@ -124,10 +124,20 @@ export async function computePreflight(baseDir, options, env = process.env) {
124
124
  if (!reviewCheckSkipped) {
125
125
  const lookup = findRecentLocalReview(baseDir, headSha, maxAgeSeconds, new Date(), contentToken);
126
126
  if (!lookup.found) {
127
+ // 0.28.0 round-29 P3: when the most recent path-matching audit
128
+ // entry was blocking/error, the operator HAS reviewed — they
129
+ // just need to address the findings. The original message ("no
130
+ // recent local-review audit entry covers HEAD") makes them
131
+ // think they forgot to review. Distinguish the two cases.
132
+ const reason = lookup.last_blocking_verdict === 'blocking'
133
+ ? 'your last local review was blocking — address findings or override'
134
+ : lookup.last_blocking_verdict === 'error'
135
+ ? 'your last local review errored — re-run `rea review` and address findings'
136
+ : 'no recent local-review audit entry covers HEAD';
127
137
  return {
128
138
  outcome: {
129
139
  status: 'refuse',
130
- reason: 'no recent local-review audit entry covers HEAD',
140
+ reason,
131
141
  exitCode: 2,
132
142
  details: {
133
143
  head_sha: headSha,
@@ -135,6 +145,12 @@ export async function computePreflight(baseDir, options, env = process.env) {
135
145
  max_age_seconds: maxAgeSeconds,
136
146
  bypass_env_var: bypassEnvVar,
137
147
  policy_off_switch: 'policy.review.local_review.mode: off',
148
+ ...(lookup.last_blocking_verdict !== undefined
149
+ ? {
150
+ last_blocking_verdict: lookup.last_blocking_verdict,
151
+ last_blocking_timestamp: lookup.last_blocking_timestamp,
152
+ }
153
+ : {}),
138
154
  },
139
155
  },
140
156
  policy,
@@ -292,6 +308,21 @@ function resolveCommitCountBase(baseDir) {
292
308
  if (resolveRef(baseDir, ref).length > 0)
293
309
  return ref;
294
310
  }
311
+ // 0.28.0 round-29 P3: develop-branch repos sometimes omit
312
+ // `origin/HEAD` entirely (the symbolic ref is unset until a fresh
313
+ // `git remote set-head origin -a`), and `origin/main` /
314
+ // `origin/master` may not exist when the trunk is `origin/develop`.
315
+ // Pre-fix the resolver silently fell through to `null`, disabling
316
+ // the auto-narrow check without telling the operator. Emit a single
317
+ // advisory line on stderr so the failure mode is visible — but do
318
+ // not fail; this path is best-effort and a missing trunk is a
319
+ // recoverable misconfiguration.
320
+ if (resolveRef(baseDir, 'origin/develop').length > 0) {
321
+ process.stderr.write(`rea: preflight commit-count base falling through to origin/develop ` +
322
+ `(origin/HEAD/main/master not resolvable). ` +
323
+ `Consider: \`git remote set-head origin -a\` to seed origin/HEAD.\n`);
324
+ return 'origin/develop';
325
+ }
295
326
  // `@{upstream}` LAST. We additionally probe what it resolves to —
296
327
  // if it's a remote feature-branch ref under `refs/remotes/origin/`
297
328
  // (not a primary trunk ref we already tried), the candidate is
@@ -365,6 +396,13 @@ export function findRecentLocalReview(baseDir, headSha, maxAgeSeconds, now = new
365
396
  }
366
397
  const lines = raw.split(/\r?\n/);
367
398
  const cutoffMs = now.getTime() - maxAgeSeconds * 1000;
399
+ // 0.28.0 round-29 P3: track the most recent blocking/error entry that
400
+ // path-matched HEAD even though it didn't qualify as coverage. The
401
+ // not-found return path consumes this so the operator-facing message
402
+ // distinguishes "you haven't reviewed" from "your review found
403
+ // problems".
404
+ let lastBlockingVerdict;
405
+ let lastBlockingTimestamp;
368
406
  // Walk in reverse — most recent first.
369
407
  for (let i = lines.length - 1; i >= 0; i--) {
370
408
  const line = lines[i];
@@ -421,8 +459,19 @@ export function findRecentLocalReview(baseDir, headSha, maxAgeSeconds, now = new
421
459
  if (matchKind === null)
422
460
  continue;
423
461
  const verdict = typeof metadata.verdict === 'string' ? metadata.verdict : '';
424
- if (verdict === 'error' || verdict === 'blocking')
462
+ if (verdict === 'error' || verdict === 'blocking') {
463
+ // 0.28.0 round-29 P3 — capture the FIRST (i.e., most-recent in
464
+ // reverse walk) blocking/error entry that path-matched HEAD so
465
+ // the not-found path can render a better message. Don't
466
+ // overwrite a later catch — we want the most-recent one.
467
+ if (lastBlockingVerdict === undefined) {
468
+ lastBlockingVerdict = verdict;
469
+ const ts = typeof record.timestamp === 'string' ? record.timestamp : '';
470
+ if (ts.length > 0)
471
+ lastBlockingTimestamp = ts;
472
+ }
425
473
  continue;
474
+ }
426
475
  const timestamp = typeof record.timestamp === 'string' ? record.timestamp : '';
427
476
  if (timestamp.length > 0) {
428
477
  const ts = Date.parse(timestamp);
@@ -430,7 +479,13 @@ export function findRecentLocalReview(baseDir, headSha, maxAgeSeconds, now = new
430
479
  // Older than max_age_seconds — keep walking; a more recent valid
431
480
  // record may exist further back? No: we walk newest-to-oldest so
432
481
  // anything older from here on is also stale. Stop early.
433
- return { found: false };
482
+ return {
483
+ found: false,
484
+ ...(lastBlockingVerdict !== undefined ? { last_blocking_verdict: lastBlockingVerdict } : {}),
485
+ ...(lastBlockingTimestamp !== undefined
486
+ ? { last_blocking_timestamp: lastBlockingTimestamp }
487
+ : {}),
488
+ };
434
489
  }
435
490
  }
436
491
  return {
@@ -441,7 +496,13 @@ export function findRecentLocalReview(baseDir, headSha, maxAgeSeconds, now = new
441
496
  match_kind: matchKind,
442
497
  };
443
498
  }
444
- return { found: false };
499
+ return {
500
+ found: false,
501
+ ...(lastBlockingVerdict !== undefined ? { last_blocking_verdict: lastBlockingVerdict } : {}),
502
+ ...(lastBlockingTimestamp !== undefined
503
+ ? { last_blocking_timestamp: lastBlockingTimestamp }
504
+ : {}),
505
+ };
445
506
  }
446
507
  async function safeAudit(baseDir, toolName, status, metadata, policy) {
447
508
  try {
@@ -75,6 +75,12 @@ export interface LiveDownstreamSnapshot {
75
75
  circuit_state: 'closed' | 'open' | 'half-open';
76
76
  retry_at: string | null;
77
77
  last_error: string | null;
78
+ /**
79
+ * 0.28.0 helix-025 F1 — `'never' | 'ok' | 'errored'` tri-state.
80
+ * `null` for snapshots written by pre-0.28.0 gateways that did not
81
+ * include the field (back-compat).
82
+ */
83
+ connection_state: 'never' | 'ok' | 'errored' | null;
78
84
  tools_count: number | null;
79
85
  open_transitions: number;
80
86
  session_blocker_emitted: boolean;
@@ -129,6 +129,12 @@ function parseDownstreamEntry(raw) {
129
129
  const circuit = r.circuit_state === 'open' || r.circuit_state === 'half-open' || r.circuit_state === 'closed'
130
130
  ? r.circuit_state
131
131
  : 'closed';
132
+ // 0.28.0 helix-025 F1: tri-state. `null` when the snapshot was written
133
+ // by a pre-0.28.0 gateway (back-compat) — the pretty-printer renders
134
+ // that as "—" rather than fabricating a value.
135
+ const connectionState = r.connection_state === 'never' || r.connection_state === 'ok' || r.connection_state === 'errored'
136
+ ? r.connection_state
137
+ : null;
132
138
  return {
133
139
  name: r.name,
134
140
  connected: typeof r.connected === 'boolean' ? r.connected : false,
@@ -136,6 +142,7 @@ function parseDownstreamEntry(raw) {
136
142
  circuit_state: circuit,
137
143
  retry_at: typeof r.retry_at === 'string' ? r.retry_at : null,
138
144
  last_error: typeof r.last_error === 'string' ? r.last_error : null,
145
+ connection_state: connectionState,
139
146
  tools_count: typeof r.tools_count === 'number' && Number.isInteger(r.tools_count) ? r.tools_count : null,
140
147
  open_transitions: typeof r.open_transitions === 'number' && Number.isInteger(r.open_transitions)
141
148
  ? r.open_transitions
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `rea verify-claim <claim-id>` — replay a recorded security-claim PoC
3
+ * battery against the currently-installed (or in-tree dogfood) rea CLI.
4
+ *
5
+ * The centerpiece of 0.28.0 (4th structural pivot — claims as
6
+ * machine-verifiable artifacts rather than prose-only release notes).
7
+ *
8
+ * Each claim lives at `data/claims/<id>.json` and lists 1..N PoCs.
9
+ * Every PoC has a `type` that names the executor:
10
+ *
11
+ * - `scan-bash` (primary): pipes `input` into
12
+ * `dist/cli/index.js hook scan-bash --mode <protected|blocked>` and
13
+ * compares the resulting verdict to `expected_verdict`.
14
+ * - `shellcheck` (helix-031 case): runs shellcheck on `target` and
15
+ * asserts the run is clean (no SC<code> warnings).
16
+ *
17
+ * Resolution order for the rea CLI under test:
18
+ *
19
+ * - `--installed` → resolves to `<cwd>/node_modules/@bookedsolid/rea/dist/cli/index.js`.
20
+ * This is the canonical "verify against MY pinned rea" mode for
21
+ * consumers — tells them whether the version they actually have
22
+ * installed still rejects the PoCs the claim targets.
23
+ * - default → uses the same `dist/cli/index.js` that ships with the
24
+ * CLI itself (i.e. the rea repo's own dogfood). Resolved relative
25
+ * to the running script.
26
+ *
27
+ * Exit codes:
28
+ *
29
+ * - 0 — every PoC matched the recorded `expected_verdict`.
30
+ * - 1 — at least one PoC mismatched (regression — investigate).
31
+ * - 2 — claim id is unknown / no JSON file at `data/claims/<id>.json`.
32
+ */
33
+ import { type SpawnSyncReturns } from 'node:child_process';
34
+ import type { Command } from 'commander';
35
+ export interface ScanBashPoC {
36
+ id: string;
37
+ type: 'scan-bash';
38
+ input: string;
39
+ mode: 'protected' | 'blocked';
40
+ expected_verdict: 'allow' | 'block';
41
+ }
42
+ export interface ShellcheckPoC {
43
+ id: string;
44
+ type: 'shellcheck';
45
+ target: string;
46
+ expected_verdict: 'clean';
47
+ }
48
+ export type ClaimPoC = ScanBashPoC | ShellcheckPoC;
49
+ export interface Claim {
50
+ id: string;
51
+ title: string;
52
+ introduced_in: string;
53
+ closed_in: string;
54
+ summary?: string;
55
+ pocs: ClaimPoC[];
56
+ }
57
+ export interface VerifyClaimOptions {
58
+ /** Resolve the CLI to `<cwd>/node_modules/@bookedsolid/rea/dist/cli/index.js`. */
59
+ installed?: boolean;
60
+ /** Emit a single JSON document on stdout. */
61
+ json?: boolean;
62
+ /**
63
+ * Override the claim-file root. Production resolves this internally
64
+ * (ships at `data/claims/` next to the package). Tests pass an
65
+ * absolute path so they can stage fixtures.
66
+ */
67
+ claimsDir?: string;
68
+ /**
69
+ * Override the rea CLI under test. Wins over `installed`. Used by
70
+ * tests to point at a stub binary. Production callers leave this
71
+ * unset.
72
+ */
73
+ cliOverride?: string;
74
+ /**
75
+ * Override the working directory the `--installed` resolver uses.
76
+ * Defaults to `process.cwd()`; tests pass a tmp dir.
77
+ */
78
+ cwd?: string;
79
+ }
80
+ export interface PoCResult {
81
+ poc_id: string;
82
+ type: ClaimPoC['type'];
83
+ expected: string;
84
+ actual: string;
85
+ match: boolean;
86
+ /** Empty on match; populated on mismatch with a one-line diagnostic. */
87
+ detail: string;
88
+ }
89
+ export interface VerifyClaimResult {
90
+ claim_id: string;
91
+ cli: string;
92
+ total: number;
93
+ matched: number;
94
+ mismatched: number;
95
+ results: PoCResult[];
96
+ exit_code: 0 | 1 | 2;
97
+ }
98
+ /**
99
+ * Resolve the directory holding the bundled claim JSON files. Walks up
100
+ * from the running script (or from this file at dev time) looking for
101
+ * a `data/claims/` sibling. Returns null when the directory cannot be
102
+ * located — the caller falls back to whatever `claimsDir` override was
103
+ * passed.
104
+ */
105
+ export declare function resolveDefaultClaimsDir(): string | null;
106
+ /**
107
+ * Load and validate a claim file. Throws on malformed JSON or shape
108
+ * mismatch — `runVerifyClaim` translates the throw into exit-code 2 +
109
+ * a stderr message.
110
+ */
111
+ export declare function loadClaim(claimsDir: string, claimId: string): Claim;
112
+ /**
113
+ * Resolve the rea CLI to invoke for `scan-bash` PoCs.
114
+ *
115
+ * Precedence: cliOverride > --installed > sibling dogfood dist/cli/index.js.
116
+ *
117
+ * Returns a pair `[command, args]` so the caller can do
118
+ * `spawnSync(cmd, [...args, 'hook', 'scan-bash', ...])`. The shape
119
+ * keeps node-vs-direct-binary differences localized to this resolver.
120
+ */
121
+ export declare function resolveCli(opts: VerifyClaimOptions): {
122
+ cmd: string;
123
+ args: string[];
124
+ path: string;
125
+ };
126
+ interface SpawnImpl {
127
+ (cmd: string, args: string[], options: {
128
+ input?: string;
129
+ encoding: 'utf8';
130
+ timeout: number;
131
+ }): SpawnSyncReturns<string>;
132
+ }
133
+ /**
134
+ * Run a single PoC against the resolved CLI. Pure function — no global
135
+ * state, all dependencies threaded through `cliCmd` / `cliArgs` / `spawn`.
136
+ * Tests substitute `spawn` with a fake.
137
+ */
138
+ export declare function runPoC(poc: ClaimPoC, cliCmd: string, cliArgs: string[], spawn?: SpawnImpl, cwd?: string): PoCResult;
139
+ /**
140
+ * Run all PoCs in a claim. Pure — exposed so tests can drive without
141
+ * spawning processes if they substitute `spawn`.
142
+ */
143
+ export declare function runVerifyClaimSync(claim: Claim, cliCmd: string, cliArgs: string[], cliPath: string, spawn?: SpawnImpl, cwd?: string): VerifyClaimResult;
144
+ export declare function runVerifyClaim(claimId: string, opts: VerifyClaimOptions): Promise<void>;
145
+ /**
146
+ * Attach `rea verify-claim <claim-id>` to the commander program.
147
+ */
148
+ export declare function registerVerifyClaimCommand(program: Command): void;
149
+ export {};