@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
@@ -1,20 +1,33 @@
1
1
  /**
2
- * `rea audit specialists` — 0.29.0 reader CLI for delegation-telemetry.
2
+ * `rea audit specialists` — reader CLI for delegation-telemetry
3
+ * (0.29.0; `--since` / `--session` added 0.31.0).
3
4
  *
4
- * Walks `.rea/audit.jsonl`, filters records by
5
- * `tool_name === 'rea.delegation_signal'`, groups by
6
- * `metadata.subagent_type`, and prints a table (default) or JSON
7
- * document (`--json`).
5
+ * Walks `.rea/audit.jsonl` (plus any rotated files when `--since` is
6
+ * given), filters records by `tool_name === 'rea.delegation_signal'`,
7
+ * groups by `metadata.subagent_type`, and prints a table (default) or
8
+ * JSON document (`--json`).
8
9
  *
9
- * # Current-session-only in v1
10
+ * # Session filtering
10
11
  *
11
- * v1 has NO `--since` flag and NO `--session=ID` flag. The principal-
12
- * engineer scope-cut deferred both to 0.29.1. The filter is:
12
+ * Three ways to scope, in precedence order:
13
13
  *
14
- * - If `CLAUDE_SESSION_ID` is set, include only records whose
15
- * `metadata.session_id_observed` matches.
16
- * - Otherwise, include all records in the chain and print a note so
17
- * the operator knows what they're seeing.
14
+ * 1. `--session <id>` — explicit. Filters to records whose
15
+ * `metadata.session_id_observed` matches `<id>`. The literal
16
+ * `--session all` disables filtering entirely (show every
17
+ * session). Wins over the env var.
18
+ * 2. `$CLAUDE_SESSION_ID` — when set and `--session` is omitted,
19
+ * filters to the current Claude Code session.
20
+ * 3. neither — no filter; every record in the walked files is shown,
21
+ * and a note tells the operator what they're seeing.
22
+ *
23
+ * # `--since <rotated-file>`
24
+ *
25
+ * By default the reader walks only the current `.rea/audit.jsonl`.
26
+ * `--since audit-YYYYMMDD-HHMMSS.jsonl` extends the walk backward: the
27
+ * named rotated file and every rotated file after it (timestamp-
28
+ * ascending) are scanned, then the current `audit.jsonl` as the tail.
29
+ * Mirrors `rea audit verify --since`. The filename must match the
30
+ * canonical rotated-audit shape or the CLI exits 1.
18
31
  *
19
32
  * # Output shape
20
33
  *
@@ -23,14 +36,27 @@
23
36
  * code-reviewer 5 2026-05-12T21:28:00Z
24
37
  * deep-dive 2 2026-05-12T21:14:00Z
25
38
  *
26
- * JSON mode prints `{ session_filter, records, groups }` where
27
- * `records` is the raw filtered subset (for piping into jq) and
39
+ * JSON mode prints `{ session_filter, records, groups, files_scanned }`
40
+ * where `records` is the raw filtered subset (for piping into jq) and
28
41
  * `groups` is the per-subagent rollup.
29
42
  */
30
43
  import fs from 'node:fs/promises';
31
44
  import path from 'node:path';
32
45
  import { DELEGATION_SIGNAL_TOOL_NAME, DELEGATION_SIGNAL_SCHEMA_VERSION, } from '../audit/delegation-event.js';
33
46
  import { AUDIT_FILE, REA_DIR, log } from './utils.js';
47
+ /**
48
+ * Thrown by `computeAuditSpecialists` when `--since` names something
49
+ * that is not a valid rotated-audit basename, or names a rotated file
50
+ * that does not exist. The commander wrapper catches it and exits 1.
51
+ */
52
+ export class AuditSpecialistsSinceError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = 'AuditSpecialistsSinceError';
56
+ }
57
+ }
58
+ /** Canonical rotated-audit filename shape — mirrors `audit.ts`. */
59
+ const ROTATED_AUDIT_RE = /^audit-\d{8}-\d{6}(-\d+)?\.jsonl$/;
34
60
  /**
35
61
  * Type guard for a record that has the delegation-signal shape. Skips
36
62
  * envelope shape validation (zod runs at write time); checks only the
@@ -51,52 +77,167 @@ function isDelegationRecord(r) {
51
77
  return true;
52
78
  }
53
79
  /**
54
- * Read the audit file and return delegation records (filtered + parsed
55
- * into a reader-friendly shape). Malformed lines are skipped silently
56
- * `rea audit verify` is the right tool for chain integrity.
80
+ * Sort key for a rotated-audit basename. Returns `[stamp, suffix]`:
81
+ *
82
+ * - `stamp` the `YYYYMMDD-HHMMSS` block. Zero-padded fixed-width, so
83
+ * a plain lexical compare on it IS chronological order.
84
+ * - `suffix` — the intra-second collision counter (`-N`), parsed as an
85
+ * integer. The base file (`audit-...jsonl`, no `-N`) is the FIRST
86
+ * rotation in its second, so its suffix is `0` and it sorts ahead of
87
+ * `audit-...-1.jsonl`.
88
+ *
89
+ * Round-2 P3: the previous implementation sorted the whole basename
90
+ * lexically, which misorders the `-N` suffix once it reaches two digits
91
+ * (`...-10.jsonl` sorts BEFORE `...-2.jsonl`). A repo that rotates more
92
+ * than 9 times in one second would then have `resolveAuditFileWalk`
93
+ * slice from the wrong index and silently drop later segments — and,
94
+ * post-0.31.0, the delegation-advisory predicate (which reuses this
95
+ * resolution) would miss delegations and fire false-positive nudges.
96
+ */
97
+ function rotatedAuditSortKey(name) {
98
+ // `ROTATED_AUDIT_RE` already guaranteed the shape; capture the parts.
99
+ const m = /^audit-(\d{8}-\d{6})(?:-(\d+))?\.jsonl$/.exec(name);
100
+ if (m === null) {
101
+ // Defensive — callers only pass names that matched ROTATED_AUDIT_RE.
102
+ return [name, 0];
103
+ }
104
+ const stamp = m[1];
105
+ const suffix = m[2] !== undefined ? Number.parseInt(m[2], 10) : 0;
106
+ return [stamp, Number.isInteger(suffix) ? suffix : 0];
107
+ }
108
+ /**
109
+ * List rotated audit files in `.rea/`, timestamp-ascending. Filenames
110
+ * follow `audit-YYYYMMDD-HHMMSS(-N).jsonl`. Sorted via
111
+ * `rotatedAuditSortKey` — the `YYYYMMDD-HHMMSS` block lexically (it is
112
+ * fixed-width zero-padded, so lexical == chronological) then the `-N`
113
+ * intra-second suffix NUMERICALLY (a plain lexical sort of the whole
114
+ * basename misorders two-digit suffixes — see `rotatedAuditSortKey`).
115
+ * Mirrors the private helper in `audit.ts` — kept local so this reader
116
+ * doesn't import the verify command's internals.
117
+ *
118
+ * Exported (0.31.0 round-2 P3) so `delegation-advisory.ts` can resolve
119
+ * the rotated-file set without duplicating the `ROTATED_AUDIT_RE` glob:
120
+ * the advisory's "did this session delegate" predicate must scan rotated
121
+ * segments too, or a delegation recorded before an audit rotation is
122
+ * invisible to the nudge and the session gets a false-positive advisory.
57
123
  */
58
- export async function loadDelegationRecords(baseDir, sessionFilter) {
59
- const auditFile = path.join(baseDir, REA_DIR, AUDIT_FILE);
60
- let raw;
124
+ export async function listRotatedAuditFiles(reaDir) {
125
+ let entries;
61
126
  try {
62
- raw = await fs.readFile(auditFile, 'utf8');
127
+ entries = await fs.readdir(reaDir);
63
128
  }
64
- catch (e) {
65
- if (e.code === 'ENOENT') {
66
- return { records: [], filesScanned: [] };
129
+ catch {
130
+ return [];
131
+ }
132
+ const rotated = entries.filter((n) => ROTATED_AUDIT_RE.test(n));
133
+ rotated.sort((a, b) => {
134
+ const [stampA, suffixA] = rotatedAuditSortKey(a);
135
+ const [stampB, suffixB] = rotatedAuditSortKey(b);
136
+ if (stampA !== stampB)
137
+ return stampA < stampB ? -1 : 1;
138
+ return suffixA - suffixB;
139
+ });
140
+ return rotated;
141
+ }
142
+ /**
143
+ * Resolve the ordered list of absolute audit-file paths to walk.
144
+ *
145
+ * - `since === undefined` → just the current `.rea/audit.jsonl` (when
146
+ * it exists). Pre-0.31.0 behavior.
147
+ * - `since` set → validate it names a real rotated file, then walk
148
+ * that file + every later rotated file (timestamp-ascending), with
149
+ * the current `audit.jsonl` as the tail.
150
+ *
151
+ * The current `audit.jsonl` is included at the END of the walk
152
+ * whenever it exists — it is always the newest segment of the chain.
153
+ * A `--since` that names a non-rotated string, or a rotated file that
154
+ * isn't present on disk, throws `AuditSpecialistsSinceError`.
155
+ */
156
+ export async function resolveAuditFileWalk(baseDir, since) {
157
+ const reaDir = path.join(baseDir, REA_DIR);
158
+ const currentAudit = path.join(reaDir, AUDIT_FILE);
159
+ const files = [];
160
+ if (since !== undefined && since.length > 0) {
161
+ const sinceName = path.basename(since);
162
+ if (!ROTATED_AUDIT_RE.test(sinceName)) {
163
+ throw new AuditSpecialistsSinceError(`--since must name a rotated audit file (audit-YYYYMMDD-HHMMSS.jsonl); got ${JSON.stringify(since)}`);
67
164
  }
68
- throw e;
165
+ const allRotated = await listRotatedAuditFiles(reaDir);
166
+ const startIdx = allRotated.indexOf(sinceName);
167
+ if (startIdx === -1) {
168
+ throw new AuditSpecialistsSinceError(`Rotated file not found: ${path.join(REA_DIR, sinceName)}`);
169
+ }
170
+ for (const name of allRotated.slice(startIdx)) {
171
+ files.push(path.join(reaDir, name));
172
+ }
173
+ }
174
+ // The current audit.jsonl is always the tail of the walk (when present).
175
+ try {
176
+ const stat = await fs.stat(currentAudit);
177
+ if (stat.isFile())
178
+ files.push(currentAudit);
179
+ }
180
+ catch (e) {
181
+ if (e.code !== 'ENOENT')
182
+ throw e;
69
183
  }
184
+ return files;
185
+ }
186
+ /**
187
+ * Read the audit file(s) and return delegation records (filtered +
188
+ * parsed into a reader-friendly shape). Malformed lines are skipped
189
+ * silently — `rea audit verify` is the right tool for chain integrity.
190
+ *
191
+ * `since` defaults to `undefined` (current `.rea/audit.jsonl` only) so
192
+ * existing callers — including `computeDelegationAdvisory` in
193
+ * `delegation-advisory.ts` — keep their pre-0.31.0 single-file
194
+ * behavior without passing the argument.
195
+ */
196
+ export async function loadDelegationRecords(baseDir, sessionFilter, since) {
197
+ const filesToWalk = await resolveAuditFileWalk(baseDir, since);
70
198
  const records = [];
71
- for (const line of raw.split('\n')) {
72
- if (line.length === 0)
73
- continue;
74
- let parsed;
199
+ const filesScanned = [];
200
+ for (const auditFile of filesToWalk) {
201
+ let raw;
75
202
  try {
76
- parsed = JSON.parse(line);
203
+ raw = await fs.readFile(auditFile, 'utf8');
77
204
  }
78
- catch {
79
- continue;
205
+ catch (e) {
206
+ if (e.code === 'ENOENT')
207
+ continue;
208
+ throw e;
80
209
  }
81
- if (!isDelegationRecord(parsed))
82
- continue;
83
- const m = parsed.metadata;
84
- if (sessionFilter !== null && m.session_id_observed !== sessionFilter)
85
- continue;
86
- const rec = {
87
- timestamp: parsed.timestamp,
88
- session_id_observed: m.session_id_observed,
89
- delegation_tool: m.delegation_tool,
90
- subagent_type: m.subagent_type,
91
- parent_subagent_type: m.parent_subagent_type,
92
- invocation_description_sha256: m.invocation_description_sha256,
93
- ...(m.hook_event_timestamp !== undefined
94
- ? { hook_event_timestamp: m.hook_event_timestamp }
95
- : {}),
96
- };
97
- records.push(rec);
98
- }
99
- return { records, filesScanned: [auditFile] };
210
+ filesScanned.push(auditFile);
211
+ for (const line of raw.split('\n')) {
212
+ if (line.length === 0)
213
+ continue;
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(line);
217
+ }
218
+ catch {
219
+ continue;
220
+ }
221
+ if (!isDelegationRecord(parsed))
222
+ continue;
223
+ const m = parsed.metadata;
224
+ if (sessionFilter !== null && m.session_id_observed !== sessionFilter)
225
+ continue;
226
+ const rec = {
227
+ timestamp: parsed.timestamp,
228
+ session_id_observed: m.session_id_observed,
229
+ delegation_tool: m.delegation_tool,
230
+ subagent_type: m.subagent_type,
231
+ parent_subagent_type: m.parent_subagent_type,
232
+ invocation_description_sha256: m.invocation_description_sha256,
233
+ ...(m.hook_event_timestamp !== undefined
234
+ ? { hook_event_timestamp: m.hook_event_timestamp }
235
+ : {}),
236
+ };
237
+ records.push(rec);
238
+ }
239
+ }
240
+ return { records, filesScanned };
100
241
  }
101
242
  /**
102
243
  * Group records by `subagent_type`. Sorts by descending count, then
@@ -136,7 +277,24 @@ export async function computeAuditSpecialists(options = {}) {
136
277
  const baseDir = options.baseDir ?? process.cwd();
137
278
  let sessionFilter;
138
279
  let source;
139
- if (options.sessionFilter === undefined) {
280
+ // Precedence: explicit `sessionFilter` test seam > `--session` flag >
281
+ // `$CLAUDE_SESSION_ID` env > no filter.
282
+ if (options.sessionFilter !== undefined) {
283
+ sessionFilter = options.sessionFilter;
284
+ source = options.sessionFilter === null ? 'none' : 'option';
285
+ }
286
+ else if (options.sessionOption !== undefined && options.sessionOption.length > 0) {
287
+ // `--session all` (case-insensitive) means "show every session".
288
+ if (options.sessionOption.toLowerCase() === 'all') {
289
+ sessionFilter = null;
290
+ source = 'none';
291
+ }
292
+ else {
293
+ sessionFilter = options.sessionOption;
294
+ source = 'option';
295
+ }
296
+ }
297
+ else {
140
298
  const envId = process.env['CLAUDE_SESSION_ID'];
141
299
  if (typeof envId === 'string' && envId.length > 0) {
142
300
  sessionFilter = envId;
@@ -147,11 +305,7 @@ export async function computeAuditSpecialists(options = {}) {
147
305
  source = 'none';
148
306
  }
149
307
  }
150
- else {
151
- sessionFilter = options.sessionFilter;
152
- source = options.sessionFilter === null ? 'none' : 'option';
153
- }
154
- const { records, filesScanned } = await loadDelegationRecords(baseDir, sessionFilter);
308
+ const { records, filesScanned } = await loadDelegationRecords(baseDir, sessionFilter, options.since);
155
309
  const groups = groupBySubagent(records);
156
310
  return {
157
311
  session_filter: sessionFilter,
@@ -186,11 +340,22 @@ function renderTable(result) {
186
340
  return lines.join('\n') + '\n';
187
341
  }
188
342
  /**
189
- * Commander entrypoint. Reads, renders, exits 0. The CLI is read-only
190
- * no audit-chain writes, no exit-code-as-verdict semantics.
343
+ * Commander entrypoint. Reads, renders, exits 0 on success / 1 when
344
+ * `--since` is malformed or names a missing rotated file. The CLI is
345
+ * read-only — no audit-chain writes.
191
346
  */
192
347
  export async function runAuditSpecialists(options) {
193
- const result = await computeAuditSpecialists(options);
348
+ let result;
349
+ try {
350
+ result = await computeAuditSpecialists(options);
351
+ }
352
+ catch (e) {
353
+ if (e instanceof AuditSpecialistsSinceError) {
354
+ process.stderr.write(`rea audit specialists: ${e.message}\n`);
355
+ process.exit(1);
356
+ }
357
+ throw e;
358
+ }
194
359
  if (options.json === true) {
195
360
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
196
361
  return;
@@ -199,7 +364,11 @@ export async function runAuditSpecialists(options) {
199
364
  log(`Delegation signals (session=${result.session_filter}, source=${result.session_filter_source}):`);
200
365
  }
201
366
  else {
202
- log('Delegation signals (no session filter — set $CLAUDE_SESSION_ID to scope; v1 omits --since / --session by design):');
367
+ log('Delegation signals (no session filter — pass --session <id> to scope, ' +
368
+ '--session all to show every session, or set $CLAUDE_SESSION_ID):');
369
+ }
370
+ if (result.files_scanned.length > 1) {
371
+ log(` scanned ${String(result.files_scanned.length)} audit files (--since walk).`);
203
372
  }
204
373
  process.stdout.write(renderTable(result));
205
374
  }
@@ -212,9 +381,15 @@ export async function runAuditSpecialists(options) {
212
381
  export function registerAuditSpecialistsSubcommand(auditCommand) {
213
382
  auditCommand
214
383
  .command('specialists')
215
- .description('Summarize `rea.delegation_signal` audit records — counts per subagent / skill, last-seen timestamps, agent-vs-skill breakdown. Reads only the current `.rea/audit.jsonl`. Honors $CLAUDE_SESSION_ID for current-session filtering. v1 omits --since / --session by design (deferred to 0.29.1).')
216
- .option('--json', 'emit JSON (records + groups) instead of the human-readable table. Composes with jq.')
384
+ .description('Summarize `rea.delegation_signal` audit records — counts per subagent / skill, last-seen timestamps, agent-vs-skill breakdown. Walks `.rea/audit.jsonl` (plus rotated files with --since). Honors --session / $CLAUDE_SESSION_ID for session scoping.')
385
+ .option('--json', 'emit JSON (records + groups + files_scanned) instead of the human-readable table. Composes with jq.')
386
+ .option('--session <id>', 'filter to a specific session_id_observed. Wins over $CLAUDE_SESSION_ID. The literal `all` disables session filtering (shows every session). When omitted, falls back to $CLAUDE_SESSION_ID then no filter.')
387
+ .option('--since <rotated-file>', 'extend the walk backward through rotated audit files. Names a rotated file (audit-YYYYMMDD-HHMMSS.jsonl); that file and every later rotated file are scanned, then the current audit.jsonl. Mirrors `rea audit verify --since`.')
217
388
  .action(async (opts) => {
218
- await runAuditSpecialists({ ...(opts.json === true ? { json: true } : {}) });
389
+ await runAuditSpecialists({
390
+ ...(opts.json === true ? { json: true } : {}),
391
+ ...(opts.session !== undefined ? { sessionOption: opts.session } : {}),
392
+ ...(opts.since !== undefined ? { since: opts.since } : {}),
393
+ });
219
394
  });
220
395
  }
@@ -0,0 +1,161 @@
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
+ /**
66
+ * Resolved `policy.delegation_advisory` knobs the CLI actually acts on.
67
+ * Defaults mirror `DelegationAdvisoryPolicySchema` in
68
+ * `src/policy/loader.ts` — kept in sync by hand; the policy-schema test
69
+ * pins both so drift fails loud.
70
+ */
71
+ interface ResolvedAdvisoryPolicy {
72
+ enabled: boolean;
73
+ threshold: number;
74
+ exemptSubagents: readonly string[];
75
+ }
76
+ export interface HookDelegationAdvisoryOptions {
77
+ /**
78
+ * Override REA_ROOT. Tests set this; the production caller relies on
79
+ * `$CLAUDE_PROJECT_DIR` or `process.cwd()`.
80
+ */
81
+ reaRoot?: string;
82
+ /**
83
+ * Test seam — inject the resolved policy instead of reading
84
+ * `.rea/policy.yaml`. Production omits; the CLI reads policy itself.
85
+ */
86
+ policyOverride?: ResolvedAdvisoryPolicy;
87
+ /**
88
+ * Test seam — inject stdin instead of reading the real stream.
89
+ * Production omits.
90
+ */
91
+ stdinOverride?: string;
92
+ }
93
+ /**
94
+ * Derive a filesystem-safe, **collision-free** per-session state-key
95
+ * basename from an untrusted session id.
96
+ *
97
+ * Shape: `<readable-prefix>-<hash>` where
98
+ *
99
+ * - `<readable-prefix>` is the raw id with every byte outside
100
+ * `[A-Za-z0-9._-]` replaced by `_`, length-capped at
101
+ * `STATE_KEY_PREFIX_CAP`. Path-traversal basenames (`.`, `..`) and
102
+ * an empty/all-stripped result collapse to `unknown`. This half is
103
+ * purely for human glanceability of the `.rea/.delegation-advisory/`
104
+ * directory — it is intentionally lossy.
105
+ * - `<hash>` is the first 16 hex chars of `sha256(raw)`. This is the
106
+ * correctness half: the sanitized prefix alone is lossy (`a/b` and
107
+ * `a:b` both sanitize to `a_b`), so without the hash two distinct
108
+ * sessions would share `count`/`fired` files — one could inherit the
109
+ * other's counter or suppress the other's advisory. The hash is
110
+ * computed over the RAW id, so distinct raw ids always get distinct
111
+ * keys.
112
+ *
113
+ * A missing / empty / non-string id returns the fixed key `unknown` (no
114
+ * hash suffix) — every untagged session deliberately shares one counter,
115
+ * matching the `'unknown'` audit-form id `runHookDelegationSignal`
116
+ * records for the same sessions.
117
+ *
118
+ * The result is always a safe single path segment: no `/`, no `..`, no
119
+ * leading dot beyond the literal `unknown`, bounded length.
120
+ */
121
+ export declare function sessionStateKey(raw: unknown): string;
122
+ /**
123
+ * The advisory text. Factored out so the test can assert on it without
124
+ * duplicating the prose. Printed to stderr (the hook's only output
125
+ * channel) exactly once per session.
126
+ */
127
+ export declare function advisoryMessage(count: number, threshold: number): string;
128
+ /**
129
+ * Core logic, exported for direct unit testing without spawning a
130
+ * process. Returns the action taken so tests can assert without
131
+ * capturing stderr.
132
+ */
133
+ export interface DelegationAdvisoryResult {
134
+ /** `'disabled'` when policy is off; `'halt'` under HALT; otherwise `'ran'`. */
135
+ outcome: 'disabled' | 'halt' | 'ran' | 'no-payload';
136
+ /** Post-increment counter value (only meaningful when `outcome === 'ran'`). */
137
+ count?: number;
138
+ /** `true` when the advisory was printed this invocation. */
139
+ fired?: boolean;
140
+ /**
141
+ * The `sessionStateKey()`-derived filesystem basename used for the
142
+ * `.count` / `.fired` state files (`<readable-prefix>-<hash>`, or the
143
+ * literal `unknown` for an untagged session). Collision-free across
144
+ * distinct raw session ids. Field name kept as `sessionId` for
145
+ * backward-compatible result-shape; it is a state key, not the raw id.
146
+ */
147
+ sessionId?: string;
148
+ }
149
+ export declare function computeDelegationAdvisory(options: HookDelegationAdvisoryOptions): Promise<DelegationAdvisoryResult>;
150
+ /**
151
+ * Commander entrypoint. Reads the hook payload, runs the advisory
152
+ * logic, exits.
153
+ *
154
+ * Exit-code contract:
155
+ * 0 — always, EXCEPT HALT. Disabled, no-payload, below-threshold,
156
+ * already-fired, just-fired — all exit 0. The advisory is a
157
+ * nudge, never a gate.
158
+ * 2 — HALT active (kill-switch contract uniform with the hook tree).
159
+ */
160
+ export declare function runHookDelegationAdvisory(options?: HookDelegationAdvisoryOptions): Promise<void>;
161
+ export {};