@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
@@ -0,0 +1,433 @@
1
+ /**
2
+ * `rea hook delegation-advisory` — the 0.31.0 delegation *nudge*.
3
+ *
4
+ * 0.29.0 shipped the delegation-telemetry observability layer (the
5
+ * `Agent|Skill` PreToolUse capture hook + `rea audit specialists`
6
+ * reader). It could *see* delegation patterns but said nothing about
7
+ * them. 0.31.0 closes the loop: the `delegation-advisory.sh` PostToolUse
8
+ * hook (matcher `Bash|Edit|Write|MultiEdit|NotebookEdit`) pipes each
9
+ * write-class tool call through this CLI, which maintains a per-session
10
+ * counter and — the FIRST time the counter crosses
11
+ * `policy.delegation_advisory.threshold` while the session has recorded
12
+ * ZERO real delegation signals — prints a one-time stderr advisory.
13
+ *
14
+ * # Advisory, never gating
15
+ *
16
+ * The CLI ALWAYS exits 0 except under HALT (exit 2, to keep the
17
+ * kill-switch contract uniform with the rest of the hook tree). It
18
+ * NEVER blocks a tool call. The whole point is a nudge — "this session
19
+ * has done a lot of work without delegating to a specialist" — not an
20
+ * enforcement gate. A consumer who disagrees sets
21
+ * `policy.delegation_advisory.enabled: false` (the schema default; only
22
+ * `bst-internal*` profiles pin `true`) and the hook is a silent no-op.
23
+ *
24
+ * # State: the per-session counter directory
25
+ *
26
+ * State lives under `.rea/.delegation-advisory/`:
27
+ *
28
+ * - `<state-key>.count` — a single integer, the running write-class
29
+ * tool-call count for the session.
30
+ * - `<state-key>.fired` — a sentinel file; present once the advisory
31
+ * has fired for the session, so it never fires twice.
32
+ *
33
+ * The session id comes from the untrusted hook payload, so it is run
34
+ * through `sessionStateKey` before it touches a filesystem path. That
35
+ * key is `<readable-prefix>-<hash>`: a sanitized, length-capped prefix
36
+ * (`[A-Za-z0-9._-]` only — keeps the directory glanceable) plus a short
37
+ * SHA-256 hex digest of the RAW id. The hash suffix is the correctness
38
+ * half — a bare sanitized prefix is lossy (`a/b` and `a:b` both
39
+ * sanitize to `a_b`), so two distinct sessions would otherwise share
40
+ * `count`/`fired` files and one could inherit the other's counter or
41
+ * suppress the other's advisory. A missing / empty / non-string session
42
+ * id collapses to the literal `unknown` key, so sessions Claude Code
43
+ * didn't tag still get a (deliberately shared) counter rather than
44
+ * crashing the hook — and that matches the `'unknown'` audit-form id
45
+ * `runHookDelegationSignal` records for the same untagged sessions.
46
+ *
47
+ * The directory is best-effort: any filesystem error (ENOSPC, EACCES,
48
+ * a read-only `.rea/`) is swallowed and the CLI exits 0. Losing the
49
+ * nudge is acceptable; breaking tool dispatch is not.
50
+ *
51
+ * # The "did this session delegate" predicate
52
+ *
53
+ * A session has delegated when `.rea/audit.jsonl` contains at least one
54
+ * `rea.delegation_signal` record whose `session_id_observed` matches
55
+ * the current session AND that record counts as a REAL delegation per
56
+ * `countsAsRealDelegation` (see `src/cli/roster.ts`): every `Skill`
57
+ * signal counts; an `Agent` signal counts when its `subagent_type` is
58
+ * a discovered curated specialist and not in the exempt set.
59
+ *
60
+ * Scanning the whole audit chain on every write-class tool call would
61
+ * be wasteful, so the scan is gated: it only runs when the counter has
62
+ * actually reached the threshold (the rare case). Below the threshold
63
+ * the CLI just bumps the counter and exits.
64
+ */
65
+ import crypto from 'node:crypto';
66
+ import fs from 'node:fs';
67
+ import path from 'node:path';
68
+ import { parse as parseYaml } from 'yaml';
69
+ import { loadDelegationRecords, listRotatedAuditFiles, } from './audit-specialists.js';
70
+ import { discoverRoster, countsAsRealDelegation, DEFAULT_EXEMPT_SUBAGENTS, } from './roster.js';
71
+ import { REA_DIR } from './utils.js';
72
+ const DEFAULT_THRESHOLD = 25;
73
+ /**
74
+ * Maximum length of the human-readable prefix in a state key. The full
75
+ * key is `<prefix>-<16-hex-hash>`, so a 64-char cap keeps basenames well
76
+ * under any filesystem limit while staying glanceable.
77
+ */
78
+ const STATE_KEY_PREFIX_CAP = 64;
79
+ /**
80
+ * Derive a filesystem-safe, **collision-free** per-session state-key
81
+ * basename from an untrusted session id.
82
+ *
83
+ * Shape: `<readable-prefix>-<hash>` where
84
+ *
85
+ * - `<readable-prefix>` is the raw id with every byte outside
86
+ * `[A-Za-z0-9._-]` replaced by `_`, length-capped at
87
+ * `STATE_KEY_PREFIX_CAP`. Path-traversal basenames (`.`, `..`) and
88
+ * an empty/all-stripped result collapse to `unknown`. This half is
89
+ * purely for human glanceability of the `.rea/.delegation-advisory/`
90
+ * directory — it is intentionally lossy.
91
+ * - `<hash>` is the first 16 hex chars of `sha256(raw)`. This is the
92
+ * correctness half: the sanitized prefix alone is lossy (`a/b` and
93
+ * `a:b` both sanitize to `a_b`), so without the hash two distinct
94
+ * sessions would share `count`/`fired` files — one could inherit the
95
+ * other's counter or suppress the other's advisory. The hash is
96
+ * computed over the RAW id, so distinct raw ids always get distinct
97
+ * keys.
98
+ *
99
+ * A missing / empty / non-string id returns the fixed key `unknown` (no
100
+ * hash suffix) — every untagged session deliberately shares one counter,
101
+ * matching the `'unknown'` audit-form id `runHookDelegationSignal`
102
+ * records for the same sessions.
103
+ *
104
+ * The result is always a safe single path segment: no `/`, no `..`, no
105
+ * leading dot beyond the literal `unknown`, bounded length.
106
+ */
107
+ export function sessionStateKey(raw) {
108
+ if (typeof raw !== 'string' || raw.length === 0)
109
+ return 'unknown';
110
+ const hash = crypto.createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 16);
111
+ let prefix = raw.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, STATE_KEY_PREFIX_CAP);
112
+ // A prefix of `.` / `..` (or an all-stripped empty prefix) would make
113
+ // the basename start with a traversal-looking token; normalize it.
114
+ // The hash suffix still guarantees uniqueness, so this only affects
115
+ // the readable half.
116
+ if (prefix.length === 0 || prefix === '.' || prefix === '..')
117
+ prefix = 'session';
118
+ return `${prefix}-${hash}`;
119
+ }
120
+ /**
121
+ * Read `.rea/policy.yaml` and resolve the `delegation_advisory` block.
122
+ * Uses the canonical YAML parser (same as `rea hook policy-get`) so
123
+ * inline and block forms agree. A missing file / missing block / parse
124
+ * error all resolve to `enabled: false` — the safe default is "the
125
+ * nudge is off", matching the schema-layer default and the OSS-profile
126
+ * posture.
127
+ *
128
+ * The policy loader's strict zod schema is NOT used here: this CLI runs
129
+ * on EVERY write-class tool call and must never fail-loud on an
130
+ * unrelated policy typo (that's `rea doctor`'s job). A best-effort
131
+ * shallow read is the right posture for an advisory hook.
132
+ */
133
+ function resolveAdvisoryPolicy(reaRoot) {
134
+ const off = {
135
+ enabled: false,
136
+ threshold: DEFAULT_THRESHOLD,
137
+ exemptSubagents: DEFAULT_EXEMPT_SUBAGENTS,
138
+ };
139
+ const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
140
+ let raw;
141
+ try {
142
+ raw = fs.readFileSync(policyPath, 'utf8');
143
+ }
144
+ catch {
145
+ return off;
146
+ }
147
+ let parsed;
148
+ try {
149
+ parsed = parseYaml(raw);
150
+ }
151
+ catch {
152
+ return off;
153
+ }
154
+ if (parsed === null || typeof parsed !== 'object')
155
+ return off;
156
+ const block = parsed['delegation_advisory'];
157
+ if (block === null || block === undefined || typeof block !== 'object') {
158
+ return off;
159
+ }
160
+ const b = block;
161
+ const enabled = b['enabled'] === true;
162
+ if (!enabled)
163
+ return off;
164
+ let threshold = DEFAULT_THRESHOLD;
165
+ if (typeof b['threshold'] === 'number' && Number.isInteger(b['threshold']) && b['threshold'] > 0) {
166
+ threshold = b['threshold'];
167
+ }
168
+ let exemptSubagents = DEFAULT_EXEMPT_SUBAGENTS;
169
+ if (Array.isArray(b['exempt_subagents'])) {
170
+ const list = b['exempt_subagents'].filter((x) => typeof x === 'string');
171
+ // An explicit empty list IS meaningful (the operator wants every
172
+ // Agent delegation to count) — only fall back to the default when
173
+ // the key is absent, which is the `=== DEFAULT` identity above.
174
+ exemptSubagents = list;
175
+ }
176
+ return { enabled, threshold, exemptSubagents };
177
+ }
178
+ /**
179
+ * Read the per-session counter. Missing file / unparseable contents →
180
+ * 0. Never throws.
181
+ */
182
+ function readCounter(counterPath) {
183
+ let raw;
184
+ try {
185
+ raw = fs.readFileSync(counterPath, 'utf8');
186
+ }
187
+ catch {
188
+ return 0;
189
+ }
190
+ const n = Number.parseInt(raw.trim(), 10);
191
+ return Number.isInteger(n) && n >= 0 ? n : 0;
192
+ }
193
+ /**
194
+ * Write the per-session counter. Best-effort — a write failure is
195
+ * swallowed (the next invocation just re-reads the stale value, which
196
+ * at worst delays the nudge by one tool call).
197
+ */
198
+ function writeCounter(counterPath, value) {
199
+ try {
200
+ fs.writeFileSync(counterPath, `${String(value)}\n`, 'utf8');
201
+ }
202
+ catch {
203
+ /* best-effort */
204
+ }
205
+ }
206
+ /**
207
+ * The advisory text. Factored out so the test can assert on it without
208
+ * duplicating the prose. Printed to stderr (the hook's only output
209
+ * channel) exactly once per session.
210
+ */
211
+ export function advisoryMessage(count, threshold) {
212
+ return (`\nrea: DELEGATION ADVISORY\n` +
213
+ ` This session has run ${String(count)} write-class tool calls ` +
214
+ `(Bash/Edit/Write/MultiEdit/NotebookEdit) — at or past the configured ` +
215
+ `threshold of ${String(threshold)} — without dispatching a curated ` +
216
+ `specialist.\n` +
217
+ ` rea's engineering model routes non-trivial work through the ` +
218
+ `rea-orchestrator agent (or a domain specialist from .claude/agents/).\n` +
219
+ ` Consider whether the remaining work would benefit from a specialist: ` +
220
+ `plan/build/review loops, adversarial review, domain expertise.\n` +
221
+ ` This is advisory only — it never blocks a tool call, and it fires ` +
222
+ `at most once per session. Set policy.delegation_advisory.enabled: false ` +
223
+ `to silence it.\n`);
224
+ }
225
+ /**
226
+ * Scan the audit chain for a REAL delegation signal in the current
227
+ * session. Returns `true` as soon as one is found (short-circuits).
228
+ *
229
+ * "Real" per `countsAsRealDelegation`: every `Skill` signal counts; an
230
+ * `Agent` signal counts when its `subagent_type` is a discovered
231
+ * curated specialist (live `.claude/agents/` roster) and not in the
232
+ * exempt set.
233
+ *
234
+ * Reuses `loadDelegationRecords` from `audit-specialists.ts` so the
235
+ * audit-record parsing / session filtering logic has a single home.
236
+ *
237
+ * # Rotated segments (0.31.0 round-2 P3)
238
+ *
239
+ * The scan walks rotated audit segments, not just the current
240
+ * `.rea/audit.jsonl`. A long session can outlive an audit rotation: its
241
+ * early `rea.delegation_signal` records land in a rotated file, and only
242
+ * later write-class calls land in the current `audit.jsonl`. Scanning
243
+ * the current file alone would miss that delegation and fire a
244
+ * false-positive nudge at a session that DID delegate. We resolve the
245
+ * full rotated set via `listRotatedAuditFiles` (the same resolution
246
+ * `rea audit specialists --since` uses) and hand the EARLIEST rotated
247
+ * filename to `loadDelegationRecords` as its `since` anchor —
248
+ * `resolveAuditFileWalk` then walks that file, every later rotated file,
249
+ * and the current `audit.jsonl` as the tail. No rotated files → the
250
+ * `since` anchor is `undefined` and behavior is the pre-0.31.0
251
+ * single-file walk.
252
+ */
253
+ async function sessionHasRealDelegation(reaRoot, sessionId, exemptSubagents) {
254
+ let records;
255
+ try {
256
+ // Resolve the rotated-file set the same way `rea audit specialists`
257
+ // does. The earliest rotated filename is the `since` anchor:
258
+ // `resolveAuditFileWalk` walks from it forward through every later
259
+ // rotated segment, then the current `audit.jsonl`.
260
+ const rotated = await listRotatedAuditFiles(path.join(reaRoot, REA_DIR));
261
+ const sinceAnchor = rotated.length > 0 ? rotated[0] : undefined;
262
+ const loaded = await loadDelegationRecords(reaRoot, sessionId, sinceAnchor);
263
+ records = loaded.records;
264
+ }
265
+ catch {
266
+ // Audit log unreadable — we cannot prove the session delegated, so
267
+ // we DON'T fire (fail toward silence, not toward a false-positive
268
+ // nudge). Returning `true` here suppresses the advisory.
269
+ return true;
270
+ }
271
+ if (records.length === 0)
272
+ return false;
273
+ const roster = discoverRoster(reaRoot);
274
+ for (const rec of records) {
275
+ if (countsAsRealDelegation({
276
+ delegationTool: rec.delegation_tool,
277
+ subagentType: rec.subagent_type,
278
+ roster,
279
+ exempt: exemptSubagents,
280
+ })) {
281
+ return true;
282
+ }
283
+ }
284
+ return false;
285
+ }
286
+ /**
287
+ * Read stdin synchronously to EOF. The hook shim feeds a small JSON
288
+ * blob; a bounded read is fine. Returns '' on any error or when stdin
289
+ * is a TTY (no harness payload).
290
+ */
291
+ function readStdinSync() {
292
+ if (process.stdin.isTTY)
293
+ return '';
294
+ try {
295
+ return fs.readFileSync(0, 'utf8');
296
+ }
297
+ catch {
298
+ return '';
299
+ }
300
+ }
301
+ export async function computeDelegationAdvisory(options) {
302
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
303
+ // HALT check — uniform with the rest of the hook tree. The advisory
304
+ // hook is observational, but refusing to run while frozen keeps the
305
+ // kill-switch contract simple: every hook exits 2 under HALT.
306
+ const haltPath = path.join(reaRoot, '.rea', 'HALT');
307
+ if (fs.existsSync(haltPath)) {
308
+ return { outcome: 'halt' };
309
+ }
310
+ const policy = options.policyOverride ?? resolveAdvisoryPolicy(reaRoot);
311
+ if (!policy.enabled) {
312
+ return { outcome: 'disabled' };
313
+ }
314
+ const stdinRaw = options.stdinOverride ?? readStdinSync();
315
+ if (stdinRaw.length === 0) {
316
+ // No payload — nothing to count. Exit clean.
317
+ return { outcome: 'no-payload' };
318
+ }
319
+ let payload;
320
+ try {
321
+ payload = JSON.parse(stdinRaw);
322
+ }
323
+ catch {
324
+ // Malformed payload — observational hook, swallow and exit clean.
325
+ return { outcome: 'no-payload' };
326
+ }
327
+ // Two forms of the session id, deliberately kept distinct:
328
+ //
329
+ // - `auditSessionId` — the value to match against the audit log's
330
+ // `session_id_observed` field. This MUST be byte-identical to what
331
+ // `delegation-capture.sh` → `runHookDelegationSignal` recorded:
332
+ // the untrusted `session_id` verbatim when it is a non-empty
333
+ // string, and the literal `'unknown'` when it is missing / empty /
334
+ // non-string. Mirroring that exact fallback is load-bearing —
335
+ // using a bare `''` here would never match the `'unknown'` records
336
+ // the capture hook writes for untagged sessions, so EVERY untagged
337
+ // session that had actually delegated would still get a
338
+ // false-positive nudge once its counter crossed the threshold.
339
+ // (See `runHookDelegationSignal` in `hook.ts` for the canonical
340
+ // fallback this kept in sync with — the policy-schema-style "kept
341
+ // in sync by hand" contract.)
342
+ // - `stateKey` — the `sessionStateKey()`-derived filesystem basename
343
+ // (`<readable-prefix>-<sha256-hash>`). Used ONLY to build paths
344
+ // under `.rea/.delegation-advisory/`. NEVER used for audit
345
+ // matching, and — unlike a bare sanitized id — collision-free, so
346
+ // two sessions whose ids only differ in characters sanitization
347
+ // would flatten (`a/b` vs `a:b`) still get distinct state files.
348
+ const auditSessionId = typeof payload.session_id === 'string' && payload.session_id.length > 0
349
+ ? payload.session_id
350
+ : 'unknown';
351
+ const stateKey = sessionStateKey(payload.session_id);
352
+ // State directory. Best-effort mkdir — a failure here means we can't
353
+ // keep a counter, so we exit clean (the nudge is lost, tool dispatch
354
+ // is not).
355
+ const stateDir = path.join(reaRoot, '.rea', '.delegation-advisory');
356
+ try {
357
+ fs.mkdirSync(stateDir, { recursive: true });
358
+ }
359
+ catch {
360
+ return { outcome: 'ran', count: 0, fired: false, sessionId: stateKey };
361
+ }
362
+ const counterPath = path.join(stateDir, `${stateKey}.count`);
363
+ const firedPath = path.join(stateDir, `${stateKey}.fired`);
364
+ // Increment the counter for this write-class tool call.
365
+ const next = readCounter(counterPath) + 1;
366
+ writeCounter(counterPath, next);
367
+ // Below threshold → nothing more to do. This is the hot path: no
368
+ // audit scan, no roster discovery.
369
+ if (next < policy.threshold) {
370
+ return { outcome: 'ran', count: next, fired: false, sessionId: stateKey };
371
+ }
372
+ // Already fired this session → never fire twice.
373
+ if (fs.existsSync(firedPath)) {
374
+ return { outcome: 'ran', count: next, fired: false, sessionId: stateKey };
375
+ }
376
+ // At/past threshold and not yet fired — run the (rare) audit scan to
377
+ // decide whether the session has actually delegated. Pass the
378
+ // audit-form session id: audit records store the untrusted value
379
+ // verbatim (or `'unknown'` for untagged sessions), so the `stateKey`
380
+ // filesystem form would never match (see the comment at the
381
+ // `auditSessionId` / `stateKey` split above).
382
+ const delegated = await sessionHasRealDelegation(reaRoot, auditSessionId, policy.exemptSubagents);
383
+ if (delegated) {
384
+ // Session DID delegate to a real specialist — no nudge warranted.
385
+ // We deliberately do NOT write the `.fired` sentinel here: if the
386
+ // session later stops delegating and keeps piling on write-class
387
+ // calls, a future invocation should still be able to nudge. (The
388
+ // counter keeps climbing; the predicate is re-evaluated each time
389
+ // past the threshold.)
390
+ return { outcome: 'ran', count: next, fired: false, sessionId: stateKey };
391
+ }
392
+ // Fire the advisory. Write the sentinel FIRST so a crash between the
393
+ // print and the sentinel-write doesn't cause a double-fire on the
394
+ // next call — at-most-once is the contract, and a missed nudge is
395
+ // better than a repeated one.
396
+ try {
397
+ fs.writeFileSync(firedPath, `${new Date().toISOString()}\n`, 'utf8');
398
+ }
399
+ catch {
400
+ // Can't write the sentinel — fire anyway, but accept the small
401
+ // risk of a second fire. Still better than never nudging.
402
+ }
403
+ process.stderr.write(advisoryMessage(next, policy.threshold));
404
+ return { outcome: 'ran', count: next, fired: true, sessionId: stateKey };
405
+ }
406
+ /**
407
+ * Commander entrypoint. Reads the hook payload, runs the advisory
408
+ * logic, exits.
409
+ *
410
+ * Exit-code contract:
411
+ * 0 — always, EXCEPT HALT. Disabled, no-payload, below-threshold,
412
+ * already-fired, just-fired — all exit 0. The advisory is a
413
+ * nudge, never a gate.
414
+ * 2 — HALT active (kill-switch contract uniform with the hook tree).
415
+ */
416
+ export async function runHookDelegationAdvisory(options = {}) {
417
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
418
+ const result = await computeDelegationAdvisory(options);
419
+ if (result.outcome === 'halt') {
420
+ // Surface the HALT reason — same shape the other hooks print.
421
+ let reason = 'Reason unknown';
422
+ try {
423
+ const content = fs.readFileSync(path.join(reaRoot, '.rea', 'HALT'), 'utf8');
424
+ reason = content.slice(0, 1024).trim() || reason;
425
+ }
426
+ catch {
427
+ /* leave default */
428
+ }
429
+ process.stderr.write(`REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`);
430
+ process.exit(2);
431
+ }
432
+ process.exit(0);
433
+ }
@@ -109,56 +109,127 @@ export declare function checksFromProbeState(state: CodexProbeState): CheckResul
109
109
  * `.claude/settings.json` under PreToolUse with matcher `Agent|Skill`
110
110
  * AND that the hook file exists at the expected dogfood path.
111
111
  *
112
- * Status posture for 0.29.0:
113
- *
114
- * The 0.29.0 release introduces a new desired-hook entry in
115
- * `defaultDesiredHooks()` that `rea init` and `rea upgrade` will merge
116
- * into consumer `.claude/settings.json` files. Existing consumer
117
- * installs (and this repo's own dogfood, which is locked from
118
- * agent-driven edits by `settings-protection.sh`) won't have the
119
- * matcher registered until the operator runs `rea upgrade`.
120
- *
121
- * To keep the upgrade-lag period from breaking `rea doctor`, the
122
- * check is `warn` (not `fail`) for 0.29.0. The detail message names
123
- * the exact command to fix and points at the canonical
124
- * `delegation-capture.sh` install. After 0.29.0+1 consumer-install
125
- * cycles have propagated, this should be promoted to `fail` so a
126
- * skipped upgrade is loud rather than silent. Codex round 2 P2
127
- * (2026-05-12).
112
+ * Status posture:
113
+ *
114
+ * 0.29.0 shipped this check as `warn` (advisory) — the
115
+ * `defaultDesiredHooks()` entry was new, and existing consumer
116
+ * installs (plus this repo's own dogfood, locked from agent-driven
117
+ * edits by `settings-protection.sh`) wouldn't have the matcher
118
+ * registered until the operator ran `rea upgrade`. The comments
119
+ * promised promotion to `fail` "in 0.30.0".
120
+ *
121
+ * **0.31.0 makes good on that promise.** The 0.29.0 0.30.x consumer
122
+ * cycles have propagated; the `Agent|Skill` matcher has been in
123
+ * `defaultDesiredHooks()` for multiple minors. A consumer install
124
+ * that still lacks the registration is a real governance gap (the
125
+ * delegation telemetry and now the 0.31.0 nudge silently does
126
+ * nothing), so the check is `fail`. The detail message still names
127
+ * the exact `rea upgrade` fix.
128
128
  *
129
129
  * Hook-file presence is verified separately by `checkHooksInstalled`
130
- * via `EXPECTED_HOOKS` — that path stays at the hard-`fail` posture
131
- * because file presence is part of the install manifest and doesn't
132
- * suffer the same template-propagation lag.
130
+ * via `EXPECTED_HOOKS` — that path was always hard-`fail`.
133
131
  */
134
132
  export declare function checkDelegationHookRegistered(baseDir: string): CheckResult;
133
+ /**
134
+ * 0.31.0 — verify the delegation-advisory hook is registered in
135
+ * `.claude/settings.json` under PostToolUse with matcher
136
+ * `Bash|Edit|Write|MultiEdit|NotebookEdit`, that a
137
+ * `delegation-advisory.sh` command is present in that group, AND that
138
+ * the `.claude/hooks/delegation-advisory.sh` file actually exists.
139
+ *
140
+ * Status posture: `warn` (advisory) for 0.31.0. This is a brand-new
141
+ * `defaultDesiredHooks()` entry — the exact same upgrade-lag situation
142
+ * `checkDelegationHookRegistered` faced in 0.29.0. Existing consumer
143
+ * installs (and this repo's own dogfood, locked from agent-driven
144
+ * edits by `settings-protection.sh`) won't have the PostToolUse group
145
+ * until the operator runs `rea upgrade`. Holding at `warn` for one
146
+ * release cycle keeps `rea doctor` green during propagation; a future
147
+ * minor promotes it to `fail` once consumer installs have caught up —
148
+ * the same ratchet `checkDelegationHookRegistered` just completed.
149
+ *
150
+ * The hook is ALSO advisory at runtime (it never blocks a tool call,
151
+ * and `policy.delegation_advisory` defaults to disabled), so a missing
152
+ * registration is a lower-stakes gap than a missing security gate —
153
+ * `warn` is proportionate even setting the upgrade-lag aside.
154
+ *
155
+ * # Why this check verifies file presence AND executability (round-2/3 P2)
156
+ *
157
+ * `delegation-advisory.sh` is deliberately NOT in `EXPECTED_HOOKS` for
158
+ * 0.31.0 (staged rollout — see the `EXPECTED_HOOKS` comment). That
159
+ * leaves THIS function as the only 0.31.0 doctor signal covering the
160
+ * new hook, so it must check the file too:
161
+ *
162
+ * - File MISSING — a settings.json that references
163
+ * `delegation-advisory.sh` while the actual script is absent (a
164
+ * partial `rea upgrade`, manual drift) would otherwise report
165
+ * `pass`, and every matching PostToolUse dispatch would shell out
166
+ * to a nonexistent path.
167
+ * - File present but NOT EXECUTABLE — a script copied without its
168
+ * mode bits (a manual `cp`, an archive extracted without `+x`
169
+ * preservation) cannot be launched by Claude Code from
170
+ * `settings.json` at all. `checkHooksInstalled` performs this exact
171
+ * `0o111` check for every `EXPECTED_HOOKS` entry; because
172
+ * `delegation-advisory.sh` is held out of that list, the parity
173
+ * check has to live here.
174
+ *
175
+ * Both failures are held at the same `warn` tier as the registration
176
+ * failures: consistent posture for 0.31.0, and they promote to `fail`
177
+ * alongside them — at which point `delegation-advisory.sh` also joins
178
+ * `EXPECTED_HOOKS` and gets the hard-`fail` `checkHooksInstalled`
179
+ * coverage (presence + executability) the other hooks have.
180
+ */
181
+ export declare function checkDelegationAdvisoryHookRegistered(baseDir: string): CheckResult;
135
182
  /**
136
183
  * 0.29.0 — synthetic round-trip of the delegation-signal audit path.
137
- * Drives a synthetic Claude Code PreToolUse hook payload through the
138
- * REAL `rea hook delegation-signal` CLI by spawning a child process
139
- * (same path the shell hook hits) and asserts:
184
+ * 0.31.0 drives the REAL `.claude/hooks/delegation-capture.sh` shell
185
+ * hook, not just the `rea hook delegation-signal` CLI underneath it.
140
186
  *
141
- * - The CLI exited 0.
142
- * - A new `rea.delegation_signal` record landed on disk.
187
+ * Feeds a synthetic Claude Code PreToolUse hook payload to the shell
188
+ * hook (the exact entry point Claude Code's `Agent|Skill` matcher
189
+ * invokes in production) and asserts:
190
+ *
191
+ * - The shell hook exited 0.
192
+ * - A new `rea.delegation_signal` record landed on disk — the smoke
193
+ * check POLLS for it, because `delegation-capture.sh` backgrounds
194
+ * + disowns the CLI (`& disown`) so the shell hook returns before
195
+ * the audit append completes.
143
196
  * - The record's metadata contains the probe tag (so we don't
144
197
  * mistakenly attribute an existing record to our run).
198
+ * - The recorded `invocation_description_sha256` matches the
199
+ * expected hash of the probe description.
145
200
  * - Chain integrity holds (recomputed hash == stored hash).
146
201
  *
147
- * Codex round 1 P2 (2026-05-12): the previous implementation called
148
- * `appendAuditRecord()` directly — short-circuiting stdin parsing,
149
- * SHA-256 hashing, redact-secrets timing, and the `process.exit`
150
- * ordering that round 1's P1 exposed. That made the smoke check
151
- * report success even when the real production path was broken.
152
- *
153
- * This rewrite exercises the same surface the `Agent|Skill`
154
- * PreToolUse hook does in production, so future regressions in
155
- * stdin parsing, hashing, redaction, or process-lifecycle behavior
156
- * fail the smoke check loudly.
157
- *
158
- * Gated behind `--smoke` so a casual `rea doctor` doesn't write
159
- * probe records on every invocation. Operators run
160
- * `rea doctor --smoke` after install / upgrade to confirm the
161
- * pipeline is wired end-to-end.
202
+ * # Why drive the shell hook, not the CLI directly
203
+ *
204
+ * 0.29.0's version spawned `rea hook delegation-signal` directly. That
205
+ * exercised the CLI's stdin parsing / hashing / redaction / process-
206
+ * lifecycle but NOT the shell shim's own logic: the 2-tier sandboxed
207
+ * CLI resolution, the realpath sandbox check, the `& disown`
208
+ * backgrounding. A regression in the shim (a botched resolution order,
209
+ * a sandbox check that rejects the legitimate dogfood CLI, a
210
+ * backgrounding bug that drops the signal) would pass 0.29.0's smoke
211
+ * check while breaking production. 0.31.0 closes that gap: the smoke
212
+ * check now invokes `bash .claude/hooks/delegation-capture.sh` and
213
+ * the CLI is reached only through the shim.
214
+ *
215
+ * # Prerequisites and graceful degradation
216
+ *
217
+ * The check needs THREE things and degrades to `warn` (not `fail`)
218
+ * when any is absent — a missing prerequisite is an environment gap,
219
+ * not a wiring regression:
220
+ *
221
+ * - `bash` on PATH.
222
+ * - `.claude/hooks/delegation-capture.sh` present (the consumer
223
+ * install path; absent before `rea init` / `rea upgrade`).
224
+ * - A sandboxed rea CLI the shim can resolve — either
225
+ * `<baseDir>/node_modules/@bookedsolid/rea/dist/cli/index.js` OR
226
+ * `<baseDir>/dist/cli/index.js` (the rea-repo dogfood). Without
227
+ * one the shim silently drops the signal by design, so the smoke
228
+ * check would time out waiting for a record that will never land.
229
+ *
230
+ * Gated behind `--smoke` so a casual `rea doctor` doesn't write probe
231
+ * records on every invocation. Operators run `rea doctor --smoke`
232
+ * after install / upgrade to confirm the pipeline is wired end-to-end.
162
233
  */
163
234
  export declare function checkDelegationRoundTrip(baseDir: string): Promise<CheckResult>;
164
235
  /**