@bookedsolid/rea 0.30.1 → 0.32.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 (51) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/audit-specialists.d.ts +106 -24
  4. package/dist/cli/audit-specialists.js +239 -64
  5. package/dist/cli/delegation-advisory.d.ts +161 -0
  6. package/dist/cli/delegation-advisory.js +433 -0
  7. package/dist/cli/doctor.d.ts +110 -39
  8. package/dist/cli/doctor.js +302 -90
  9. package/dist/cli/hook.d.ts +6 -0
  10. package/dist/cli/hook.js +45 -22
  11. package/dist/cli/index.js +1 -1
  12. package/dist/cli/install/settings-merge.js +25 -0
  13. package/dist/cli/roster.d.ts +119 -0
  14. package/dist/cli/roster.js +141 -0
  15. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  16. package/dist/hooks/_lib/halt-check.js +106 -0
  17. package/dist/hooks/_lib/payload.d.ts +86 -0
  18. package/dist/hooks/_lib/payload.js +166 -0
  19. package/dist/hooks/_lib/segments.d.ts +100 -0
  20. package/dist/hooks/_lib/segments.js +444 -0
  21. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  22. package/dist/hooks/attribution-advisory/index.js +233 -0
  23. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  24. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  25. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  26. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  27. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  28. package/dist/policy/loader.d.ts +23 -0
  29. package/dist/policy/loader.js +46 -0
  30. package/dist/policy/profiles.d.ts +23 -0
  31. package/dist/policy/profiles.js +16 -0
  32. package/dist/policy/types.d.ts +61 -0
  33. package/hooks/_lib/protected-paths.sh +10 -3
  34. package/hooks/attribution-advisory.sh +139 -131
  35. package/hooks/delegation-advisory.sh +162 -0
  36. package/hooks/pr-issue-link-gate.sh +114 -45
  37. package/hooks/security-disclosure-gate.sh +148 -316
  38. package/hooks/settings-protection.sh +13 -9
  39. package/package.json +1 -1
  40. package/profiles/bst-internal-no-codex.yaml +12 -0
  41. package/profiles/bst-internal.yaml +13 -0
  42. package/profiles/client-engagement.yaml +11 -0
  43. package/profiles/lit-wc.yaml +10 -0
  44. package/profiles/minimal.yaml +11 -0
  45. package/profiles/open-source-no-codex.yaml +11 -0
  46. package/profiles/open-source.yaml +11 -0
  47. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  48. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  49. package/templates/prepare-commit-msg.husky.sh +80 -6
  50. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  51. package/templates/settings-protection.dogfood.patch +58 -0
package/dist/cli/index.js CHANGED
@@ -148,7 +148,7 @@ async function main() {
148
148
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
149
149
  .option('--metrics', 'also print a 7-day summary of Codex telemetry (G11.5)')
150
150
  .option('--drift', 'report drift vs. the install manifest (read-only; does not mutate)')
151
- .option('--smoke', 'also run the 0.29.0 delegation-signal round-trip (writes a probe `rea.delegation_signal` audit record and verifies chain integrity)')
151
+ .option('--smoke', 'also run the delegation-signal round-trip: drives the real `.claude/hooks/delegation-capture.sh` shell hook end-to-end (writes a probe `rea.delegation_signal` audit record and verifies chain integrity)')
152
152
  .option('--strict', '0.30.0 Class M — promote settings.json schema warnings (zod parse failures, path traversal, missing rea hooks) to hard fail. Use in CI gates.')
153
153
  .action(async (opts) => {
154
154
  await runDoctor({
@@ -380,5 +380,30 @@ export function defaultDesiredHooks() {
380
380
  },
381
381
  ],
382
382
  },
383
+ {
384
+ // 0.31.0 delegation-telemetry completion — the *nudge*. The
385
+ // matcher is `Bash|Edit|Write|MultiEdit|NotebookEdit`: every
386
+ // write-class tool call (note this group INCLUDES Bash, unlike
387
+ // the architecture-review group above). The hook maintains a
388
+ // per-session counter and emits a one-time stderr advisory when
389
+ // a session crosses `policy.delegation_advisory.threshold`
390
+ // without dispatching a curated specialist. Advisory only — it
391
+ // never blocks a tool call, and `policy.delegation_advisory`
392
+ // defaults to disabled (only `bst-internal*` profiles pin
393
+ // `enabled: true`), so a vanilla install sees the hook as a
394
+ // silent no-op. Kept as its own matcher group rather than
395
+ // chained onto the architecture-review group because the
396
+ // matcher string differs (Bash is in this one, not that one).
397
+ event: 'PostToolUse',
398
+ matcher: 'Bash|Edit|Write|MultiEdit|NotebookEdit',
399
+ hooks: [
400
+ {
401
+ type: 'command',
402
+ command: `${base}/delegation-advisory.sh`,
403
+ timeout: 10000,
404
+ statusMessage: 'Checking delegation cadence...',
405
+ },
406
+ ],
407
+ },
383
408
  ];
384
409
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Live `.claude/agents/` roster discovery (0.31.0+).
3
+ *
4
+ * 0.29.0 shipped the delegation-telemetry observability layer. 0.31.0
5
+ * closes the loop with the *nudge* — and the nudge needs to know what
6
+ * counts as a "real" specialist delegation versus a built-in helper.
7
+ *
8
+ * # Why discovery, not a hardcoded list
9
+ *
10
+ * `src/cli/doctor.ts` already carries an `EXPECTED_AGENTS` constant,
11
+ * but it is a deliberately-frozen 10-entry subset — the minimum roster
12
+ * `rea init` guarantees, pinned so a regression that drops a curated
13
+ * agent from the install manifest trips a doctor failure. It is NOT
14
+ * the live roster: this repo ships 23 agents, consumers add their own,
15
+ * and the curated set grows release over release. Keying the
16
+ * delegation nudge off `EXPECTED_AGENTS` would mean a session that
17
+ * delegated exclusively to `principal-engineer` and `data-architect`
18
+ * (both real, curated, neither in the frozen subset) still got nudged
19
+ * — exactly the false positive that erodes trust in an advisory.
20
+ *
21
+ * So the roster is discovered at read time: every `*.md` file under
22
+ * `<baseDir>/.claude/agents/` is a curated specialist. The basename
23
+ * (sans `.md`) is the `subagent_type` Claude Code's `Agent` tool
24
+ * reports, so the mapping is direct.
25
+ *
26
+ * # The exempt set
27
+ *
28
+ * Claude Code ships built-in helpers — `general-purpose`, `Explore`,
29
+ * `Plan`, `output-style-setup`, `statusline-setup` — that are dispatched
30
+ * through the same `Agent` tool but are NOT curated specialists. A
31
+ * session that only ever delegated to those has not actually routed
32
+ * work to the engineering team. The exempt set is policy-configurable
33
+ * (`policy.delegation_advisory.exempt_subagents`) with a 5-entry
34
+ * built-in default; this module takes the resolved list as an argument
35
+ * rather than reading policy itself, so it stays a pure filesystem
36
+ * helper with no policy-loader dependency.
37
+ *
38
+ * # Skills
39
+ *
40
+ * The `Skill` delegation tool is intentionally NOT roster-gated. A
41
+ * skill invocation (`deep-dive`, `due-diligence`, …) is always a real
42
+ * delegation signal — there is no "built-in skill" equivalent of
43
+ * `general-purpose`. The advisory's "did this session delegate"
44
+ * predicate counts every `Skill` signal and every non-exempt `Agent`
45
+ * signal whose `subagent_type` is in the discovered roster.
46
+ */
47
+ /**
48
+ * Default exempt subagent names — Claude Code's built-in helper agents
49
+ * that are dispatched through the `Agent` tool but are not curated
50
+ * specialists. Mirrors the schema-layer default in
51
+ * `DelegationAdvisoryPolicySchema` (`src/policy/loader.ts`). Exported
52
+ * so callers that have no policy in scope (the bash-hook fallback
53
+ * path) can still apply the same filter.
54
+ */
55
+ export declare const DEFAULT_EXEMPT_SUBAGENTS: readonly string[];
56
+ export interface RosterDiscoveryResult {
57
+ /**
58
+ * Sorted list of discovered curated-specialist names — the basename
59
+ * (sans `.md`) of every file under `.claude/agents/`. Empty when the
60
+ * directory is absent or unreadable.
61
+ */
62
+ roster: string[];
63
+ /**
64
+ * Absolute path actually scanned. Returned for diagnostics — doctor
65
+ * surfaces it, the `rea hook` subcommand echoes it in `--json` mode.
66
+ */
67
+ agentsDir: string;
68
+ /**
69
+ * `true` when `.claude/agents/` exists and was read. `false` when it
70
+ * is absent or a read error occurred — callers treat that as "no
71
+ * roster, every Agent delegation is non-exempt-but-also-unverifiable"
72
+ * and fall back to the exempt-list-only filter.
73
+ */
74
+ discovered: boolean;
75
+ }
76
+ /**
77
+ * Discover the curated-specialist roster by listing `*.md` files under
78
+ * `<baseDir>/.claude/agents/`. Pure filesystem read — never throws; an
79
+ * absent or unreadable directory yields `discovered: false` with an
80
+ * empty roster.
81
+ *
82
+ * The `.md` extension match is case-insensitive (`.MD` on a
83
+ * case-preserving-but-insensitive filesystem still counts) and the
84
+ * basename is taken verbatim — `rea-orchestrator.md` →
85
+ * `rea-orchestrator`. Subdirectories and non-`.md` files (READMEs,
86
+ * `.DS_Store`, editor swap files) are skipped.
87
+ */
88
+ export declare function discoverRoster(baseDir: string): RosterDiscoveryResult;
89
+ /**
90
+ * The delegation-nudge predicate, factored out so the CLI subcommand,
91
+ * the `audit specialists` reader, and the doctor smoke check all apply
92
+ * identical logic.
93
+ *
94
+ * Returns `true` when the given `(delegation_tool, subagent_type)` pair
95
+ * counts as a REAL delegation — i.e. the kind that should suppress the
96
+ * advisory nudge:
97
+ *
98
+ * - `Skill` → always counts. There is no "built-in skill" exemption.
99
+ * - `Agent` → counts when the `subagent_type` is NOT in `exempt` AND
100
+ * (the roster was discovered AND contains the name, OR the roster
101
+ * was NOT discovered — in which case we cannot verify and fall
102
+ * back to "non-exempt name counts"). The non-discovered fallback
103
+ * is deliberately permissive: a consumer who deleted
104
+ * `.claude/agents/` has bigger problems than a missed nudge, and a
105
+ * false negative (no nudge when one was due) is far less corrosive
106
+ * than a false positive.
107
+ *
108
+ * `exempt` comparison is case-sensitive — the built-in helper names
109
+ * (`Explore`, `Plan`) are capitalized and the curated specialists are
110
+ * kebab-case, so there is no realistic collision, and a case-folded
111
+ * compare would risk a curated `Plan-something` agent being wrongly
112
+ * exempted.
113
+ */
114
+ export declare function countsAsRealDelegation(args: {
115
+ delegationTool: 'Agent' | 'Skill';
116
+ subagentType: string;
117
+ roster: RosterDiscoveryResult;
118
+ exempt: readonly string[];
119
+ }): boolean;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Live `.claude/agents/` roster discovery (0.31.0+).
3
+ *
4
+ * 0.29.0 shipped the delegation-telemetry observability layer. 0.31.0
5
+ * closes the loop with the *nudge* — and the nudge needs to know what
6
+ * counts as a "real" specialist delegation versus a built-in helper.
7
+ *
8
+ * # Why discovery, not a hardcoded list
9
+ *
10
+ * `src/cli/doctor.ts` already carries an `EXPECTED_AGENTS` constant,
11
+ * but it is a deliberately-frozen 10-entry subset — the minimum roster
12
+ * `rea init` guarantees, pinned so a regression that drops a curated
13
+ * agent from the install manifest trips a doctor failure. It is NOT
14
+ * the live roster: this repo ships 23 agents, consumers add their own,
15
+ * and the curated set grows release over release. Keying the
16
+ * delegation nudge off `EXPECTED_AGENTS` would mean a session that
17
+ * delegated exclusively to `principal-engineer` and `data-architect`
18
+ * (both real, curated, neither in the frozen subset) still got nudged
19
+ * — exactly the false positive that erodes trust in an advisory.
20
+ *
21
+ * So the roster is discovered at read time: every `*.md` file under
22
+ * `<baseDir>/.claude/agents/` is a curated specialist. The basename
23
+ * (sans `.md`) is the `subagent_type` Claude Code's `Agent` tool
24
+ * reports, so the mapping is direct.
25
+ *
26
+ * # The exempt set
27
+ *
28
+ * Claude Code ships built-in helpers — `general-purpose`, `Explore`,
29
+ * `Plan`, `output-style-setup`, `statusline-setup` — that are dispatched
30
+ * through the same `Agent` tool but are NOT curated specialists. A
31
+ * session that only ever delegated to those has not actually routed
32
+ * work to the engineering team. The exempt set is policy-configurable
33
+ * (`policy.delegation_advisory.exempt_subagents`) with a 5-entry
34
+ * built-in default; this module takes the resolved list as an argument
35
+ * rather than reading policy itself, so it stays a pure filesystem
36
+ * helper with no policy-loader dependency.
37
+ *
38
+ * # Skills
39
+ *
40
+ * The `Skill` delegation tool is intentionally NOT roster-gated. A
41
+ * skill invocation (`deep-dive`, `due-diligence`, …) is always a real
42
+ * delegation signal — there is no "built-in skill" equivalent of
43
+ * `general-purpose`. The advisory's "did this session delegate"
44
+ * predicate counts every `Skill` signal and every non-exempt `Agent`
45
+ * signal whose `subagent_type` is in the discovered roster.
46
+ */
47
+ import fs from 'node:fs';
48
+ import path from 'node:path';
49
+ /**
50
+ * Default exempt subagent names — Claude Code's built-in helper agents
51
+ * that are dispatched through the `Agent` tool but are not curated
52
+ * specialists. Mirrors the schema-layer default in
53
+ * `DelegationAdvisoryPolicySchema` (`src/policy/loader.ts`). Exported
54
+ * so callers that have no policy in scope (the bash-hook fallback
55
+ * path) can still apply the same filter.
56
+ */
57
+ export const DEFAULT_EXEMPT_SUBAGENTS = [
58
+ 'general-purpose',
59
+ 'Explore',
60
+ 'Plan',
61
+ 'output-style-setup',
62
+ 'statusline-setup',
63
+ ];
64
+ /**
65
+ * Discover the curated-specialist roster by listing `*.md` files under
66
+ * `<baseDir>/.claude/agents/`. Pure filesystem read — never throws; an
67
+ * absent or unreadable directory yields `discovered: false` with an
68
+ * empty roster.
69
+ *
70
+ * The `.md` extension match is case-insensitive (`.MD` on a
71
+ * case-preserving-but-insensitive filesystem still counts) and the
72
+ * basename is taken verbatim — `rea-orchestrator.md` →
73
+ * `rea-orchestrator`. Subdirectories and non-`.md` files (READMEs,
74
+ * `.DS_Store`, editor swap files) are skipped.
75
+ */
76
+ export function discoverRoster(baseDir) {
77
+ const agentsDir = path.join(baseDir, '.claude', 'agents');
78
+ let entries;
79
+ try {
80
+ entries = fs.readdirSync(agentsDir, { withFileTypes: true });
81
+ }
82
+ catch {
83
+ // ENOENT (no .claude/agents/), ENOTDIR, EACCES — all collapse to
84
+ // "no roster discovered". The caller falls back to the exempt-list
85
+ // filter alone.
86
+ return { roster: [], agentsDir, discovered: false };
87
+ }
88
+ const roster = [];
89
+ for (const entry of entries) {
90
+ // Only regular files. A directory named `foo.md` is not an agent.
91
+ if (!entry.isFile())
92
+ continue;
93
+ const name = entry.name;
94
+ if (!name.toLowerCase().endsWith('.md'))
95
+ continue;
96
+ const base = name.slice(0, name.length - 3);
97
+ if (base.length === 0)
98
+ continue;
99
+ roster.push(base);
100
+ }
101
+ roster.sort();
102
+ return { roster, agentsDir, discovered: true };
103
+ }
104
+ /**
105
+ * The delegation-nudge predicate, factored out so the CLI subcommand,
106
+ * the `audit specialists` reader, and the doctor smoke check all apply
107
+ * identical logic.
108
+ *
109
+ * Returns `true` when the given `(delegation_tool, subagent_type)` pair
110
+ * counts as a REAL delegation — i.e. the kind that should suppress the
111
+ * advisory nudge:
112
+ *
113
+ * - `Skill` → always counts. There is no "built-in skill" exemption.
114
+ * - `Agent` → counts when the `subagent_type` is NOT in `exempt` AND
115
+ * (the roster was discovered AND contains the name, OR the roster
116
+ * was NOT discovered — in which case we cannot verify and fall
117
+ * back to "non-exempt name counts"). The non-discovered fallback
118
+ * is deliberately permissive: a consumer who deleted
119
+ * `.claude/agents/` has bigger problems than a missed nudge, and a
120
+ * false negative (no nudge when one was due) is far less corrosive
121
+ * than a false positive.
122
+ *
123
+ * `exempt` comparison is case-sensitive — the built-in helper names
124
+ * (`Explore`, `Plan`) are capitalized and the curated specialists are
125
+ * kebab-case, so there is no realistic collision, and a case-folded
126
+ * compare would risk a curated `Plan-something` agent being wrongly
127
+ * exempted.
128
+ */
129
+ export function countsAsRealDelegation(args) {
130
+ if (args.delegationTool === 'Skill')
131
+ return true;
132
+ // Agent path.
133
+ if (args.exempt.includes(args.subagentType))
134
+ return false;
135
+ if (!args.roster.discovered) {
136
+ // Roster unverifiable — a non-exempt Agent name is the best signal
137
+ // we have. Count it.
138
+ return true;
139
+ }
140
+ return args.roster.roster.includes(args.subagentType);
141
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Shared HALT kill-switch reader for the Node-binary hook tier.
3
+ *
4
+ * 0.32.0 — extracted from `src/cli/hook.ts`. Pre-extraction the same
5
+ * `.rea/HALT` reader inlined twice (`runHookScanBash` lines 204-222 and
6
+ * `runHookCodexReview` lines 518-531) and `src/hooks/push-gate/halt.ts`
7
+ * carried a third copy with slightly different error semantics (the
8
+ * push-gate variant returns `{ halted: true, reason: 'unknown (HALT
9
+ * file unreadable)' }` on filesystem errors instead of falling through
10
+ * to allow). The Node-binary hook ports landing in 0.32.0 need the
11
+ * same primitive, so consolidate here before more copies accumulate.
12
+ *
13
+ * Contract:
14
+ *
15
+ * - Returns `{ halted: false }` when `<reaRoot>/.rea/HALT` is absent.
16
+ * - Returns `{ halted: true, reason }` when the file exists. `reason`
17
+ * is the first non-empty line trimmed and capped at 1024 bytes;
18
+ * missing/blank content collapses to `"Reason unknown"`.
19
+ * - Filesystem errors during the read collapse to a halted sentinel
20
+ * `"unknown (HALT file unreadable)"` — fail-CLOSED. The historical
21
+ * `runHookScanBash` inline copy fell through to allow on read
22
+ * failure; that is the wrong posture for a kill switch (an
23
+ * attacker who can prevent the read should not get a free allow).
24
+ * The push-gate's halt.ts already takes this stance; we converge.
25
+ * - NEVER throws.
26
+ *
27
+ * Used by:
28
+ * - `runHookScanBash`, `runHookCodexReview` (existing — migrated to
29
+ * this primitive in 0.32.0)
30
+ * - `runHookPrIssueLinkGate`, `runHookSecurityDisclosureGate`,
31
+ * `runHookAttributionAdvisory` (Phase 1 pilots, 0.32.0)
32
+ *
33
+ * Distinct from `src/hooks/push-gate/halt.ts`:
34
+ * - The push-gate's `readHalt` is part of the dependency-injected
35
+ * test seam (`PushGateDeps.readHalt`) and cannot be replaced
36
+ * wholesale without breaking the gate's existing contract.
37
+ * - Future-work item: thread `checkHalt` THROUGH the push-gate's
38
+ * `readHalt` default so a single primitive backs every consumer.
39
+ * Out of scope for 0.32.0 — the push-gate ships green and rotating
40
+ * it now would expand the diff without carrying its own bug fix.
41
+ */
42
+ /**
43
+ * Result of a HALT probe.
44
+ *
45
+ * Discriminated union so callers cannot accidentally read `reason` from
46
+ * the not-halted case. The `halted: true` arm always carries a non-
47
+ * empty `reason` — the reader manufactures a placeholder rather than
48
+ * leaving the field undefined (the operator-facing stderr message
49
+ * `REA HALT: <reason>` would render `undefined` otherwise).
50
+ */
51
+ export type HaltState = {
52
+ halted: true;
53
+ reason: string;
54
+ } | {
55
+ halted: false;
56
+ };
57
+ /**
58
+ * Probe `<reaRoot>/.rea/HALT`. Pure function — does not write, log, or
59
+ * mutate process state. Caller is responsible for the operator-facing
60
+ * stderr emission and the exit code.
61
+ *
62
+ * @param reaRoot Absolute path to the project root that owns `.rea/`.
63
+ * Hooks resolve this from `$CLAUDE_PROJECT_DIR` or
64
+ * `process.cwd()` — callers should pre-resolve before
65
+ * invoking this primitive.
66
+ * @returns `{ halted: false }` when the kill switch is clear, or
67
+ * `{ halted: true, reason }` with a non-empty reason string.
68
+ */
69
+ export declare function checkHalt(reaRoot: string): HaltState;
70
+ /**
71
+ * Render the canonical operator-facing HALT banner. Pulled into a
72
+ * helper so the 5 hook callers (`runHookScanBash`,
73
+ * `runHookCodexReview`, and the 3 Phase 1 pilots) emit the same
74
+ * stderr text byte-for-byte. Matches the historical inline string
75
+ * exactly so existing consumer-side log parsers (if any) continue to
76
+ * work.
77
+ */
78
+ export declare function formatHaltBanner(reason: string): string;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Shared HALT kill-switch reader for the Node-binary hook tier.
3
+ *
4
+ * 0.32.0 — extracted from `src/cli/hook.ts`. Pre-extraction the same
5
+ * `.rea/HALT` reader inlined twice (`runHookScanBash` lines 204-222 and
6
+ * `runHookCodexReview` lines 518-531) and `src/hooks/push-gate/halt.ts`
7
+ * carried a third copy with slightly different error semantics (the
8
+ * push-gate variant returns `{ halted: true, reason: 'unknown (HALT
9
+ * file unreadable)' }` on filesystem errors instead of falling through
10
+ * to allow). The Node-binary hook ports landing in 0.32.0 need the
11
+ * same primitive, so consolidate here before more copies accumulate.
12
+ *
13
+ * Contract:
14
+ *
15
+ * - Returns `{ halted: false }` when `<reaRoot>/.rea/HALT` is absent.
16
+ * - Returns `{ halted: true, reason }` when the file exists. `reason`
17
+ * is the first non-empty line trimmed and capped at 1024 bytes;
18
+ * missing/blank content collapses to `"Reason unknown"`.
19
+ * - Filesystem errors during the read collapse to a halted sentinel
20
+ * `"unknown (HALT file unreadable)"` — fail-CLOSED. The historical
21
+ * `runHookScanBash` inline copy fell through to allow on read
22
+ * failure; that is the wrong posture for a kill switch (an
23
+ * attacker who can prevent the read should not get a free allow).
24
+ * The push-gate's halt.ts already takes this stance; we converge.
25
+ * - NEVER throws.
26
+ *
27
+ * Used by:
28
+ * - `runHookScanBash`, `runHookCodexReview` (existing — migrated to
29
+ * this primitive in 0.32.0)
30
+ * - `runHookPrIssueLinkGate`, `runHookSecurityDisclosureGate`,
31
+ * `runHookAttributionAdvisory` (Phase 1 pilots, 0.32.0)
32
+ *
33
+ * Distinct from `src/hooks/push-gate/halt.ts`:
34
+ * - The push-gate's `readHalt` is part of the dependency-injected
35
+ * test seam (`PushGateDeps.readHalt`) and cannot be replaced
36
+ * wholesale without breaking the gate's existing contract.
37
+ * - Future-work item: thread `checkHalt` THROUGH the push-gate's
38
+ * `readHalt` default so a single primitive backs every consumer.
39
+ * Out of scope for 0.32.0 — the push-gate ships green and rotating
40
+ * it now would expand the diff without carrying its own bug fix.
41
+ */
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ /**
45
+ * Maximum bytes of the HALT file we consider when assembling the
46
+ * `reason` line. Defends against a runaway-write scenario where
47
+ * `.rea/HALT` is megabytes large — we always emit the reason on
48
+ * stderr, and a multi-MB stderr blob can overwhelm a TTY before the
49
+ * user sees the actual exit. 1 KiB is more than enough for a human-
50
+ * authored kill-switch reason.
51
+ */
52
+ const HALT_REASON_MAX_BYTES = 1024;
53
+ /**
54
+ * Probe `<reaRoot>/.rea/HALT`. Pure function — does not write, log, or
55
+ * mutate process state. Caller is responsible for the operator-facing
56
+ * stderr emission and the exit code.
57
+ *
58
+ * @param reaRoot Absolute path to the project root that owns `.rea/`.
59
+ * Hooks resolve this from `$CLAUDE_PROJECT_DIR` or
60
+ * `process.cwd()` — callers should pre-resolve before
61
+ * invoking this primitive.
62
+ * @returns `{ halted: false }` when the kill switch is clear, or
63
+ * `{ halted: true, reason }` with a non-empty reason string.
64
+ */
65
+ export function checkHalt(reaRoot) {
66
+ const haltPath = path.join(reaRoot, '.rea', 'HALT');
67
+ if (!fs.existsSync(haltPath)) {
68
+ return { halted: false };
69
+ }
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(haltPath, 'utf8');
73
+ }
74
+ catch {
75
+ // Fail-closed: the file exists (existsSync passed) but we cannot
76
+ // read it. The operator intended to halt; a permissions glitch or
77
+ // race that prevents the read should NOT translate into a free
78
+ // allow. Surface a generic reason so the operator knows the file
79
+ // was present even when its content was unreadable.
80
+ return { halted: true, reason: 'unknown (HALT file unreadable)' };
81
+ }
82
+ // Cap at HALT_REASON_MAX_BYTES BEFORE splitting to bound the work.
83
+ // The pre-0.32.0 inline copies sliced the entire file content first
84
+ // and then trimmed; that is identical behavior for any reasonable
85
+ // file size but differs unboundedly for pathological inputs.
86
+ const slice = raw.length > HALT_REASON_MAX_BYTES ? raw.slice(0, HALT_REASON_MAX_BYTES) : raw;
87
+ const firstNonEmpty = slice
88
+ .split(/\r?\n/)
89
+ .map((l) => l.trim())
90
+ .find((l) => l.length > 0);
91
+ return {
92
+ halted: true,
93
+ reason: firstNonEmpty !== undefined && firstNonEmpty.length > 0 ? firstNonEmpty : 'Reason unknown',
94
+ };
95
+ }
96
+ /**
97
+ * Render the canonical operator-facing HALT banner. Pulled into a
98
+ * helper so the 5 hook callers (`runHookScanBash`,
99
+ * `runHookCodexReview`, and the 3 Phase 1 pilots) emit the same
100
+ * stderr text byte-for-byte. Matches the historical inline string
101
+ * exactly so existing consumer-side log parsers (if any) continue to
102
+ * work.
103
+ */
104
+ export function formatHaltBanner(reason) {
105
+ return `REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`;
106
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Shared stdin payload primitive for the Node-binary hook tier.
3
+ *
4
+ * 0.32.0 — extracts the `INPUT=$(cat) ; jq -r '.tool_input.command'`
5
+ * pattern that every bash hook in `hooks/` repeats. The Node-binary
6
+ * scan-bash already does this work in `runHookScanBash` (lines 225-258
7
+ * of `src/cli/hook.ts`); the Phase 1 pilots landing in 0.32.0 need
8
+ * the same primitive without copy-pasting the parsing + type-guard +
9
+ * fail-closed-on-malformed-JSON dance into each new hook.
10
+ *
11
+ * The shape mirrors the bash hooks' contract verbatim:
12
+ *
13
+ * - `tool_input.command` is the only field we read; bash hooks only
14
+ * ever ran `jq -r '.tool_input.command // ""'` against this payload.
15
+ * - `tool_name` is also surfaced because two bash hooks
16
+ * (`pr-issue-link-gate.sh` and `security-disclosure-gate.sh`)
17
+ * short-circuit when the tool isn't `Bash`.
18
+ *
19
+ * Failure modes:
20
+ *
21
+ * - Empty stdin → `{ command: '', toolName: '' }`. The bash hooks
22
+ * allow on empty command (`[[ -z "$CMD" ]] && exit 0`); the Node
23
+ * port preserves this by returning empty strings rather than
24
+ * throwing.
25
+ * - Malformed JSON → throws `MalformedPayloadError`. The caller
26
+ * decides whether to fail-closed (block) or fail-open (allow);
27
+ * `runHookScanBash` chose fail-closed (block) and the Phase 1
28
+ * pilots match that posture for consistency.
29
+ * - `tool_input.command` is non-string → throws `TypePayloadError`.
30
+ * A crafted payload like `{"tool_input":{"command":["rm","-rf"]}}`
31
+ * would silently coerce to `''` if we used `String(c)`; that
32
+ * would translate into a free allow. Refuse instead.
33
+ */
34
+ import { Buffer } from 'node:buffer';
35
+ /**
36
+ * Result of parsing a Claude Code hook PreToolUse stdin payload.
37
+ */
38
+ export interface HookPayload {
39
+ /** `tool_name` from the payload, or `''` when absent. */
40
+ toolName: string;
41
+ /** `tool_input.command` from the payload, or `''` when absent. */
42
+ command: string;
43
+ }
44
+ /**
45
+ * Thrown when stdin contains content that is not valid JSON.
46
+ *
47
+ * Distinct error class so callers can `instanceof` discriminate without
48
+ * leaning on string matching of the message.
49
+ */
50
+ export declare class MalformedPayloadError extends Error {
51
+ constructor(message: string);
52
+ }
53
+ /**
54
+ * Thrown when the JSON parses but `tool_input.command` is present and
55
+ * has the wrong type (anything other than `string` / `undefined`).
56
+ */
57
+ export declare class TypePayloadError extends Error {
58
+ constructor(message: string);
59
+ }
60
+ /**
61
+ * Parse a Claude Code PreToolUse stdin payload. Pure function — no I/O.
62
+ *
63
+ * @param raw Bytes / string read from the hook's stdin (the `INPUT=$(cat)`
64
+ * equivalent).
65
+ * @returns A normalized `HookPayload` with both fields always defined.
66
+ * @throws MalformedPayloadError if the input is not parseable JSON.
67
+ * @throws TypePayloadError if `tool_input.command` is present with a
68
+ * non-string type.
69
+ */
70
+ export declare function parseHookPayload(raw: string | Buffer): HookPayload;
71
+ /**
72
+ * Read all of stdin into a string with a soft byte cap and a hard
73
+ * timeout. Mirrors the `readStdinWithTimeout` helper in
74
+ * `src/cli/hook.ts` (which scans a fixed timeout but no byte cap).
75
+ *
76
+ * The cap (default 1 MiB) defends against a misbehaving caller piping
77
+ * an unbounded payload — we'd otherwise sit in the read loop forever
78
+ * even if the caller eventually closed stdin.
79
+ *
80
+ * @param timeoutMs How long to wait for stdin to close before resolving
81
+ * with whatever we have. Default 5_000 ms.
82
+ * @param maxBytes Soft cap on total bytes accepted. Default 1 MiB.
83
+ * Once reached, additional chunks are dropped silently
84
+ * (the caller still gets a parseable string back).
85
+ */
86
+ export declare function readStdinWithTimeout(timeoutMs?: number, maxBytes?: number): Promise<string>;