@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.
- package/.husky/prepare-commit-msg +80 -6
- package/MIGRATING.md +24 -15
- package/dist/cli/audit-specialists.d.ts +106 -24
- package/dist/cli/audit-specialists.js +239 -64
- package/dist/cli/delegation-advisory.d.ts +161 -0
- package/dist/cli/delegation-advisory.js +433 -0
- package/dist/cli/doctor.d.ts +110 -39
- package/dist/cli/doctor.js +302 -90
- package/dist/cli/hook.d.ts +6 -0
- package/dist/cli/hook.js +45 -22
- package/dist/cli/index.js +1 -1
- package/dist/cli/install/settings-merge.js +25 -0
- package/dist/cli/roster.d.ts +119 -0
- package/dist/cli/roster.js +141 -0
- package/dist/hooks/_lib/halt-check.d.ts +78 -0
- package/dist/hooks/_lib/halt-check.js +106 -0
- package/dist/hooks/_lib/payload.d.ts +86 -0
- package/dist/hooks/_lib/payload.js +166 -0
- package/dist/hooks/_lib/segments.d.ts +100 -0
- package/dist/hooks/_lib/segments.js +444 -0
- package/dist/hooks/attribution-advisory/index.d.ts +72 -0
- package/dist/hooks/attribution-advisory/index.js +233 -0
- package/dist/hooks/bash-scanner/protected-scan.js +14 -2
- package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
- package/dist/hooks/pr-issue-link-gate/index.js +127 -0
- package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
- package/dist/hooks/security-disclosure-gate/index.js +502 -0
- package/dist/policy/loader.d.ts +23 -0
- package/dist/policy/loader.js +46 -0
- package/dist/policy/profiles.d.ts +23 -0
- package/dist/policy/profiles.js +16 -0
- package/dist/policy/types.d.ts +61 -0
- package/hooks/_lib/protected-paths.sh +10 -3
- package/hooks/attribution-advisory.sh +139 -131
- package/hooks/delegation-advisory.sh +162 -0
- package/hooks/pr-issue-link-gate.sh +114 -45
- package/hooks/security-disclosure-gate.sh +148 -316
- package/hooks/settings-protection.sh +13 -9
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +12 -0
- package/profiles/bst-internal.yaml +13 -0
- package/profiles/client-engagement.yaml +11 -0
- package/profiles/lit-wc.yaml +10 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +11 -0
- package/profiles/open-source.yaml +11 -0
- package/templates/attribution-advisory.dogfood-staged.sh +170 -0
- package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
- package/templates/prepare-commit-msg.husky.sh +80 -6
- package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
- package/templates/settings-protection.dogfood.patch +58 -0
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `rea audit specialists` —
|
|
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
|
|
5
|
-
* `tool_name === 'rea.delegation_signal'`,
|
|
6
|
-
* `metadata.subagent_type`, and prints a table (default) or
|
|
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
|
-
* #
|
|
10
|
+
* # Session filtering
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
* engineer scope-cut deferred both to 0.29.1. The filter is:
|
|
12
|
+
* Three ways to scope, in precedence order:
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 }`
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
59
|
-
|
|
60
|
-
let raw;
|
|
124
|
+
export async function listRotatedAuditFiles(reaDir) {
|
|
125
|
+
let entries;
|
|
61
126
|
try {
|
|
62
|
-
|
|
127
|
+
entries = await fs.readdir(reaDir);
|
|
63
128
|
}
|
|
64
|
-
catch
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
let parsed;
|
|
199
|
+
const filesScanned = [];
|
|
200
|
+
for (const auditFile of filesToWalk) {
|
|
201
|
+
let raw;
|
|
75
202
|
try {
|
|
76
|
-
|
|
203
|
+
raw = await fs.readFile(auditFile, 'utf8');
|
|
77
204
|
}
|
|
78
|
-
catch {
|
|
79
|
-
|
|
205
|
+
catch (e) {
|
|
206
|
+
if (e.code === 'ENOENT')
|
|
207
|
+
continue;
|
|
208
|
+
throw e;
|
|
80
209
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
190
|
-
*
|
|
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
|
-
|
|
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 —
|
|
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.
|
|
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({
|
|
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 {};
|