@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
|
@@ -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
|
+
}
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -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
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* `defaultDesiredHooks()`
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
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
|
-
*
|
|
138
|
-
*
|
|
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
|
-
*
|
|
142
|
-
*
|
|
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
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
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
|
/**
|