@bookedsolid/rea 0.28.1 → 0.29.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/agents/rea-orchestrator.md +18 -0
- package/dist/audit/append.d.ts +1 -0
- package/dist/audit/append.js +1 -0
- package/dist/audit/delegation-event.d.ts +215 -0
- package/dist/audit/delegation-event.js +113 -0
- package/dist/cli/audit-specialists.d.ts +113 -0
- package/dist/cli/audit-specialists.js +220 -0
- package/dist/cli/doctor.d.ts +65 -0
- package/dist/cli/doctor.js +258 -0
- package/dist/cli/hook.d.ts +40 -8
- package/dist/cli/hook.js +305 -8
- package/dist/cli/index.js +7 -0
- package/dist/cli/install/manifest-schema.d.ts +6 -6
- package/dist/cli/install/settings-merge.js +20 -0
- package/dist/config/tier-map.js +22 -1
- package/dist/registry/loader.d.ts +6 -6
- package/hooks/blocked-paths-enforcer.sh +39 -0
- package/hooks/delegation-capture.sh +158 -0
- package/hooks/settings-protection.sh +39 -0
- package/package.json +1 -1
- package/scripts/dist-regression-gate.sh +46 -2
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rea audit specialists` — 0.29.0 reader CLI for delegation-telemetry.
|
|
3
|
+
*
|
|
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`).
|
|
8
|
+
*
|
|
9
|
+
* # Current-session-only in v1
|
|
10
|
+
*
|
|
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:
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* # Output shape
|
|
20
|
+
*
|
|
21
|
+
* subagent_type count last_seen (UTC)
|
|
22
|
+
* rea-orchestrator 12 2026-05-12T21:30:00Z
|
|
23
|
+
* code-reviewer 5 2026-05-12T21:28:00Z
|
|
24
|
+
* deep-dive 2 2026-05-12T21:14:00Z
|
|
25
|
+
*
|
|
26
|
+
* JSON mode prints `{ session_filter, records, groups }` where
|
|
27
|
+
* `records` is the raw filtered subset (for piping into jq) and
|
|
28
|
+
* `groups` is the per-subagent rollup.
|
|
29
|
+
*/
|
|
30
|
+
import fs from 'node:fs/promises';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { DELEGATION_SIGNAL_TOOL_NAME, DELEGATION_SIGNAL_SCHEMA_VERSION, } from '../audit/delegation-event.js';
|
|
33
|
+
import { AUDIT_FILE, REA_DIR, log } from './utils.js';
|
|
34
|
+
/**
|
|
35
|
+
* Type guard for a record that has the delegation-signal shape. Skips
|
|
36
|
+
* envelope shape validation (zod runs at write time); checks only the
|
|
37
|
+
* fields the reader actually uses.
|
|
38
|
+
*/
|
|
39
|
+
function isDelegationRecord(r) {
|
|
40
|
+
if (r.tool_name !== DELEGATION_SIGNAL_TOOL_NAME)
|
|
41
|
+
return false;
|
|
42
|
+
const m = r.metadata;
|
|
43
|
+
if (m === undefined)
|
|
44
|
+
return false;
|
|
45
|
+
if (m.schema_version !== DELEGATION_SIGNAL_SCHEMA_VERSION)
|
|
46
|
+
return false;
|
|
47
|
+
if (m.delegation_tool !== 'Agent' && m.delegation_tool !== 'Skill')
|
|
48
|
+
return false;
|
|
49
|
+
if (typeof m.subagent_type !== 'string')
|
|
50
|
+
return false;
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
export async function loadDelegationRecords(baseDir, sessionFilter) {
|
|
59
|
+
const auditFile = path.join(baseDir, REA_DIR, AUDIT_FILE);
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = await fs.readFile(auditFile, 'utf8');
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
if (e.code === 'ENOENT') {
|
|
66
|
+
return { records: [], filesScanned: [] };
|
|
67
|
+
}
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
const records = [];
|
|
71
|
+
for (const line of raw.split('\n')) {
|
|
72
|
+
if (line.length === 0)
|
|
73
|
+
continue;
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(line);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
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] };
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Group records by `subagent_type`. Sorts by descending count, then
|
|
103
|
+
* alphabetical on tie. `last_seen` is the latest envelope timestamp in
|
|
104
|
+
* the group.
|
|
105
|
+
*/
|
|
106
|
+
export function groupBySubagent(records) {
|
|
107
|
+
const byName = new Map();
|
|
108
|
+
for (const r of records) {
|
|
109
|
+
let g = byName.get(r.subagent_type);
|
|
110
|
+
if (g === undefined) {
|
|
111
|
+
g = {
|
|
112
|
+
subagent_type: r.subagent_type,
|
|
113
|
+
count: 0,
|
|
114
|
+
last_seen: r.timestamp,
|
|
115
|
+
by_tool: { Agent: 0, Skill: 0 },
|
|
116
|
+
};
|
|
117
|
+
byName.set(r.subagent_type, g);
|
|
118
|
+
}
|
|
119
|
+
g.count += 1;
|
|
120
|
+
g.by_tool[r.delegation_tool] += 1;
|
|
121
|
+
if (r.timestamp > g.last_seen)
|
|
122
|
+
g.last_seen = r.timestamp;
|
|
123
|
+
}
|
|
124
|
+
return Array.from(byName.values()).sort((a, b) => {
|
|
125
|
+
if (b.count !== a.count)
|
|
126
|
+
return b.count - a.count;
|
|
127
|
+
return a.subagent_type.localeCompare(b.subagent_type);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Computation-only entrypoint. Returns the full result so callers
|
|
132
|
+
* (CLI, tests) can render or assert. `runAuditSpecialists` is the thin
|
|
133
|
+
* commander wrapper that prints + exits.
|
|
134
|
+
*/
|
|
135
|
+
export async function computeAuditSpecialists(options = {}) {
|
|
136
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
137
|
+
let sessionFilter;
|
|
138
|
+
let source;
|
|
139
|
+
if (options.sessionFilter === undefined) {
|
|
140
|
+
const envId = process.env['CLAUDE_SESSION_ID'];
|
|
141
|
+
if (typeof envId === 'string' && envId.length > 0) {
|
|
142
|
+
sessionFilter = envId;
|
|
143
|
+
source = 'env';
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
sessionFilter = null;
|
|
147
|
+
source = 'none';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
sessionFilter = options.sessionFilter;
|
|
152
|
+
source = options.sessionFilter === null ? 'none' : 'option';
|
|
153
|
+
}
|
|
154
|
+
const { records, filesScanned } = await loadDelegationRecords(baseDir, sessionFilter);
|
|
155
|
+
const groups = groupBySubagent(records);
|
|
156
|
+
return {
|
|
157
|
+
session_filter: sessionFilter,
|
|
158
|
+
session_filter_source: source,
|
|
159
|
+
records,
|
|
160
|
+
groups,
|
|
161
|
+
files_scanned: filesScanned,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function renderTable(result) {
|
|
165
|
+
if (result.groups.length === 0) {
|
|
166
|
+
const note = result.session_filter !== null
|
|
167
|
+
? `No delegation signals recorded for session ${result.session_filter}.`
|
|
168
|
+
: 'No delegation signals recorded.';
|
|
169
|
+
return `${note}\n (Records are written by the .claude/hooks/delegation-capture.sh PreToolUse hook on every Agent/Skill dispatch.)\n`;
|
|
170
|
+
}
|
|
171
|
+
const headers = ['subagent_type', 'count', 'agent', 'skill', 'last_seen (UTC)'];
|
|
172
|
+
const rows = result.groups.map((g) => [
|
|
173
|
+
g.subagent_type,
|
|
174
|
+
String(g.count),
|
|
175
|
+
String(g.by_tool.Agent),
|
|
176
|
+
String(g.by_tool.Skill),
|
|
177
|
+
g.last_seen,
|
|
178
|
+
]);
|
|
179
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
180
|
+
const lines = [];
|
|
181
|
+
lines.push(headers.map((h, i) => h.padEnd(widths[i])).join(' '));
|
|
182
|
+
lines.push(widths.map((w) => '-'.repeat(w)).join(' '));
|
|
183
|
+
for (const row of rows) {
|
|
184
|
+
lines.push(row.map((c, i) => c.padEnd(widths[i])).join(' '));
|
|
185
|
+
}
|
|
186
|
+
return lines.join('\n') + '\n';
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Commander entrypoint. Reads, renders, exits 0. The CLI is read-only
|
|
190
|
+
* — no audit-chain writes, no exit-code-as-verdict semantics.
|
|
191
|
+
*/
|
|
192
|
+
export async function runAuditSpecialists(options) {
|
|
193
|
+
const result = await computeAuditSpecialists(options);
|
|
194
|
+
if (options.json === true) {
|
|
195
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (result.session_filter !== null) {
|
|
199
|
+
log(`Delegation signals (session=${result.session_filter}, source=${result.session_filter_source}):`);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
log('Delegation signals (no session filter — set $CLAUDE_SESSION_ID to scope; v1 omits --since / --session by design):');
|
|
203
|
+
}
|
|
204
|
+
process.stdout.write(renderTable(result));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Attach the `specialists` subcommand to the `rea audit` command group.
|
|
208
|
+
* Exported as a registrar so `src/cli/index.ts` can wire it next to the
|
|
209
|
+
* existing `rotate` and `verify` subcommands without leaking commander
|
|
210
|
+
* knowledge into this module.
|
|
211
|
+
*/
|
|
212
|
+
export function registerAuditSpecialistsSubcommand(auditCommand) {
|
|
213
|
+
auditCommand
|
|
214
|
+
.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.')
|
|
217
|
+
.action(async (opts) => {
|
|
218
|
+
await runAuditSpecialists({ ...(opts.json === true ? { json: true } : {}) });
|
|
219
|
+
});
|
|
220
|
+
}
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -65,6 +65,63 @@ export declare function checkCodexBinaryOnPath(): CheckResult;
|
|
|
65
65
|
* the real probe.
|
|
66
66
|
*/
|
|
67
67
|
export declare function checksFromProbeState(state: CodexProbeState): CheckResult[];
|
|
68
|
+
/**
|
|
69
|
+
* 0.29.0 — verify the delegation-capture hook is registered in
|
|
70
|
+
* `.claude/settings.json` under PreToolUse with matcher `Agent|Skill`
|
|
71
|
+
* AND that the hook file exists at the expected dogfood path.
|
|
72
|
+
*
|
|
73
|
+
* Status posture for 0.29.0:
|
|
74
|
+
*
|
|
75
|
+
* The 0.29.0 release introduces a new desired-hook entry in
|
|
76
|
+
* `defaultDesiredHooks()` that `rea init` and `rea upgrade` will merge
|
|
77
|
+
* into consumer `.claude/settings.json` files. Existing consumer
|
|
78
|
+
* installs (and this repo's own dogfood, which is locked from
|
|
79
|
+
* agent-driven edits by `settings-protection.sh`) won't have the
|
|
80
|
+
* matcher registered until the operator runs `rea upgrade`.
|
|
81
|
+
*
|
|
82
|
+
* To keep the upgrade-lag period from breaking `rea doctor`, the
|
|
83
|
+
* check is `warn` (not `fail`) for 0.29.0. The detail message names
|
|
84
|
+
* the exact command to fix and points at the canonical
|
|
85
|
+
* `delegation-capture.sh` install. After 0.29.0+1 consumer-install
|
|
86
|
+
* cycles have propagated, this should be promoted to `fail` so a
|
|
87
|
+
* skipped upgrade is loud rather than silent. Codex round 2 P2
|
|
88
|
+
* (2026-05-12).
|
|
89
|
+
*
|
|
90
|
+
* Hook-file presence is verified separately by `checkHooksInstalled`
|
|
91
|
+
* via `EXPECTED_HOOKS` — that path stays at the hard-`fail` posture
|
|
92
|
+
* because file presence is part of the install manifest and doesn't
|
|
93
|
+
* suffer the same template-propagation lag.
|
|
94
|
+
*/
|
|
95
|
+
export declare function checkDelegationHookRegistered(baseDir: string): CheckResult;
|
|
96
|
+
/**
|
|
97
|
+
* 0.29.0 — synthetic round-trip of the delegation-signal audit path.
|
|
98
|
+
* Drives a synthetic Claude Code PreToolUse hook payload through the
|
|
99
|
+
* REAL `rea hook delegation-signal` CLI by spawning a child process
|
|
100
|
+
* (same path the shell hook hits) and asserts:
|
|
101
|
+
*
|
|
102
|
+
* - The CLI exited 0.
|
|
103
|
+
* - A new `rea.delegation_signal` record landed on disk.
|
|
104
|
+
* - The record's metadata contains the probe tag (so we don't
|
|
105
|
+
* mistakenly attribute an existing record to our run).
|
|
106
|
+
* - Chain integrity holds (recomputed hash == stored hash).
|
|
107
|
+
*
|
|
108
|
+
* Codex round 1 P2 (2026-05-12): the previous implementation called
|
|
109
|
+
* `appendAuditRecord()` directly — short-circuiting stdin parsing,
|
|
110
|
+
* SHA-256 hashing, redact-secrets timing, and the `process.exit`
|
|
111
|
+
* ordering that round 1's P1 exposed. That made the smoke check
|
|
112
|
+
* report success even when the real production path was broken.
|
|
113
|
+
*
|
|
114
|
+
* This rewrite exercises the same surface the `Agent|Skill`
|
|
115
|
+
* PreToolUse hook does in production, so future regressions in
|
|
116
|
+
* stdin parsing, hashing, redaction, or process-lifecycle behavior
|
|
117
|
+
* fail the smoke check loudly.
|
|
118
|
+
*
|
|
119
|
+
* Gated behind `--smoke` so a casual `rea doctor` doesn't write
|
|
120
|
+
* probe records on every invocation. Operators run
|
|
121
|
+
* `rea doctor --smoke` after install / upgrade to confirm the
|
|
122
|
+
* pipeline is wired end-to-end.
|
|
123
|
+
*/
|
|
124
|
+
export declare function checkDelegationRoundTrip(baseDir: string): Promise<CheckResult>;
|
|
68
125
|
/**
|
|
69
126
|
* Assemble the full checklist for a given baseDir. Exported so tests can
|
|
70
127
|
* exercise the conditional branching without capturing stdout from
|
|
@@ -91,6 +148,14 @@ export interface RunDoctorOptions {
|
|
|
91
148
|
* `rea upgrade` to reconcile).
|
|
92
149
|
*/
|
|
93
150
|
drift?: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* 0.29.0 — when true, run the synthetic delegation-signal round-trip
|
|
153
|
+
* check. Writes a probe `rea.delegation_signal` audit record (with
|
|
154
|
+
* the doctor-smoke session id) and verifies chain integrity. Gated
|
|
155
|
+
* behind a flag so casual `rea doctor` invocations don't pollute the
|
|
156
|
+
* audit log with probe records.
|
|
157
|
+
*/
|
|
158
|
+
smoke?: boolean;
|
|
94
159
|
}
|
|
95
160
|
export interface DriftRow {
|
|
96
161
|
path: string;
|
package/dist/cli/doctor.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
3
|
+
import fsPromises from 'node:fs/promises';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { loadPolicy } from '../policy/loader.js';
|
|
4
6
|
import { loadRegistry } from '../registry/loader.js';
|
|
@@ -12,6 +14,8 @@ import { buildFragment } from './install/claude-md.js';
|
|
|
12
14
|
import { canonicalSettingsSubsetHash, defaultDesiredHooks } from './install/settings-merge.js';
|
|
13
15
|
import { manifestExists, readManifest } from './install/manifest-io.js';
|
|
14
16
|
import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
|
|
17
|
+
import { DELEGATION_SIGNAL_TOOL_NAME } from '../audit/delegation-event.js';
|
|
18
|
+
import { computeHash } from '../audit/fs.js';
|
|
15
19
|
import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
|
|
16
20
|
function checkFileExists(label, filePath, fatal) {
|
|
17
21
|
const exists = fs.existsSync(filePath);
|
|
@@ -154,6 +158,13 @@ const EXPECTED_HOOKS = [
|
|
|
154
158
|
'blocked-paths-enforcer.sh',
|
|
155
159
|
'changeset-security-gate.sh',
|
|
156
160
|
'dangerous-bash-interceptor.sh',
|
|
161
|
+
// 0.29.0 — delegation-telemetry MVP. The PreToolUse hook on
|
|
162
|
+
// matcher `Agent|Skill` emits a `rea.delegation_signal` audit record
|
|
163
|
+
// on every subagent / skill dispatch. Observational only — fails
|
|
164
|
+
// open so missing rea binary doesn't crash dispatch. Doctor surfaces
|
|
165
|
+
// a missing hook file so consumers don't silently lose the signal
|
|
166
|
+
// after upgrade.
|
|
167
|
+
'delegation-capture.sh',
|
|
157
168
|
'dependency-audit-gate.sh',
|
|
158
169
|
'env-file-protection.sh',
|
|
159
170
|
// 0.26.0 local-first enforcement (CTO directive 2026-05-05).
|
|
@@ -707,6 +718,240 @@ function codexRequiredFromPolicy(baseDir) {
|
|
|
707
718
|
return true;
|
|
708
719
|
}
|
|
709
720
|
}
|
|
721
|
+
/**
|
|
722
|
+
* 0.29.0 — verify the delegation-capture hook is registered in
|
|
723
|
+
* `.claude/settings.json` under PreToolUse with matcher `Agent|Skill`
|
|
724
|
+
* AND that the hook file exists at the expected dogfood path.
|
|
725
|
+
*
|
|
726
|
+
* Status posture for 0.29.0:
|
|
727
|
+
*
|
|
728
|
+
* The 0.29.0 release introduces a new desired-hook entry in
|
|
729
|
+
* `defaultDesiredHooks()` that `rea init` and `rea upgrade` will merge
|
|
730
|
+
* into consumer `.claude/settings.json` files. Existing consumer
|
|
731
|
+
* installs (and this repo's own dogfood, which is locked from
|
|
732
|
+
* agent-driven edits by `settings-protection.sh`) won't have the
|
|
733
|
+
* matcher registered until the operator runs `rea upgrade`.
|
|
734
|
+
*
|
|
735
|
+
* To keep the upgrade-lag period from breaking `rea doctor`, the
|
|
736
|
+
* check is `warn` (not `fail`) for 0.29.0. The detail message names
|
|
737
|
+
* the exact command to fix and points at the canonical
|
|
738
|
+
* `delegation-capture.sh` install. After 0.29.0+1 consumer-install
|
|
739
|
+
* cycles have propagated, this should be promoted to `fail` so a
|
|
740
|
+
* skipped upgrade is loud rather than silent. Codex round 2 P2
|
|
741
|
+
* (2026-05-12).
|
|
742
|
+
*
|
|
743
|
+
* Hook-file presence is verified separately by `checkHooksInstalled`
|
|
744
|
+
* via `EXPECTED_HOOKS` — that path stays at the hard-`fail` posture
|
|
745
|
+
* because file presence is part of the install manifest and doesn't
|
|
746
|
+
* suffer the same template-propagation lag.
|
|
747
|
+
*/
|
|
748
|
+
export function checkDelegationHookRegistered(baseDir) {
|
|
749
|
+
const label = 'delegation-capture hook registered';
|
|
750
|
+
const ADVISORY = 'warn';
|
|
751
|
+
const settingsPath = path.join(baseDir, '.claude', 'settings.json');
|
|
752
|
+
if (!fs.existsSync(settingsPath)) {
|
|
753
|
+
return {
|
|
754
|
+
label,
|
|
755
|
+
status: ADVISORY,
|
|
756
|
+
detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\` (advisory in 0.29.0; promoted to fail in 0.30.0)`,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
let parsed;
|
|
760
|
+
try {
|
|
761
|
+
parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
762
|
+
}
|
|
763
|
+
catch (e) {
|
|
764
|
+
return {
|
|
765
|
+
label,
|
|
766
|
+
status: ADVISORY,
|
|
767
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
const groups = parsed.hooks?.PreToolUse ?? [];
|
|
771
|
+
const group = groups.find((g) => g.matcher === 'Agent|Skill');
|
|
772
|
+
if (group === undefined) {
|
|
773
|
+
return {
|
|
774
|
+
label,
|
|
775
|
+
status: ADVISORY,
|
|
776
|
+
detail: 'no PreToolUse group with matcher "Agent|Skill" found in .claude/settings.json — ' +
|
|
777
|
+
'run `rea upgrade` to install (advisory in 0.29.0; promoted to fail in 0.30.0). ' +
|
|
778
|
+
'NOTE: matcher MUST be exactly `Agent|Skill` ' +
|
|
779
|
+
'(NOT `Task|Skill` — `TaskCreate`/`TaskList` are unrelated todo-list tools).',
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const cmds = (group.hooks ?? []).map((h) => typeof h.command === 'string' ? h.command : '');
|
|
783
|
+
if (!cmds.some((c) => c.includes('delegation-capture.sh'))) {
|
|
784
|
+
return {
|
|
785
|
+
label,
|
|
786
|
+
status: ADVISORY,
|
|
787
|
+
detail: 'Agent|Skill matcher exists but no delegation-capture.sh command found in its hooks list',
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
return { label, status: 'pass' };
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* 0.29.0 — synthetic round-trip of the delegation-signal audit path.
|
|
794
|
+
* Drives a synthetic Claude Code PreToolUse hook payload through the
|
|
795
|
+
* REAL `rea hook delegation-signal` CLI by spawning a child process
|
|
796
|
+
* (same path the shell hook hits) and asserts:
|
|
797
|
+
*
|
|
798
|
+
* - The CLI exited 0.
|
|
799
|
+
* - A new `rea.delegation_signal` record landed on disk.
|
|
800
|
+
* - The record's metadata contains the probe tag (so we don't
|
|
801
|
+
* mistakenly attribute an existing record to our run).
|
|
802
|
+
* - Chain integrity holds (recomputed hash == stored hash).
|
|
803
|
+
*
|
|
804
|
+
* Codex round 1 P2 (2026-05-12): the previous implementation called
|
|
805
|
+
* `appendAuditRecord()` directly — short-circuiting stdin parsing,
|
|
806
|
+
* SHA-256 hashing, redact-secrets timing, and the `process.exit`
|
|
807
|
+
* ordering that round 1's P1 exposed. That made the smoke check
|
|
808
|
+
* report success even when the real production path was broken.
|
|
809
|
+
*
|
|
810
|
+
* This rewrite exercises the same surface the `Agent|Skill`
|
|
811
|
+
* PreToolUse hook does in production, so future regressions in
|
|
812
|
+
* stdin parsing, hashing, redaction, or process-lifecycle behavior
|
|
813
|
+
* fail the smoke check loudly.
|
|
814
|
+
*
|
|
815
|
+
* Gated behind `--smoke` so a casual `rea doctor` doesn't write
|
|
816
|
+
* probe records on every invocation. Operators run
|
|
817
|
+
* `rea doctor --smoke` after install / upgrade to confirm the
|
|
818
|
+
* pipeline is wired end-to-end.
|
|
819
|
+
*/
|
|
820
|
+
export async function checkDelegationRoundTrip(baseDir) {
|
|
821
|
+
const probeTag = `doctor-smoke-${process.pid}-${Date.now()}`;
|
|
822
|
+
// Resolve the rea CLI binary the same way the shell hook does.
|
|
823
|
+
// First-class: this very process is running rea, so `process.argv[1]`
|
|
824
|
+
// is the right entrypoint. Fall back to the dist path in
|
|
825
|
+
// node_modules.
|
|
826
|
+
const cliEntry = process.argv[1];
|
|
827
|
+
if (cliEntry === undefined || cliEntry.length === 0) {
|
|
828
|
+
return {
|
|
829
|
+
label: 'delegation-signal round-trip',
|
|
830
|
+
status: 'fail',
|
|
831
|
+
detail: 'could not resolve the rea CLI entrypoint (process.argv[1] empty)',
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
// Codex round 4 P3 (2026-05-12): exercise a NON-EMPTY description
|
|
835
|
+
// so the smoke check actually validates SHA-256 hashing of prompt
|
|
836
|
+
// content. Pre-fix the description was '' and the hash was always
|
|
837
|
+
// the well-known empty-string SHA-256 — a regression that ignored
|
|
838
|
+
// tool_input.description and substituted an empty hash would have
|
|
839
|
+
// passed the smoke check.
|
|
840
|
+
const probeDescription = `doctor-smoke probe (${probeTag})`;
|
|
841
|
+
const expectedDescriptionHash = crypto
|
|
842
|
+
.createHash('sha256')
|
|
843
|
+
.update(probeDescription)
|
|
844
|
+
.digest('hex');
|
|
845
|
+
const payload = JSON.stringify({
|
|
846
|
+
tool_name: 'Agent',
|
|
847
|
+
session_id: 'doctor-smoke',
|
|
848
|
+
tool_input: {
|
|
849
|
+
subagent_type: probeTag,
|
|
850
|
+
description: probeDescription,
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
const auditPath = path.join(baseDir, '.rea', 'audit.jsonl');
|
|
854
|
+
// Synchronously spawn the CLI. The blocking wait is appropriate for
|
|
855
|
+
// a doctor check — the operator just typed `rea doctor --smoke` and
|
|
856
|
+
// is waiting for output anyway. `--detach` is NOT passed: we want
|
|
857
|
+
// the CLI to await its own append (the post-P1 fix) and exit
|
|
858
|
+
// cleanly.
|
|
859
|
+
const { spawnSync } = await import('node:child_process');
|
|
860
|
+
const res = spawnSync(process.execPath, [cliEntry, 'hook', 'delegation-signal'], {
|
|
861
|
+
cwd: baseDir,
|
|
862
|
+
input: payload,
|
|
863
|
+
encoding: 'utf8',
|
|
864
|
+
timeout: 15_000,
|
|
865
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: baseDir },
|
|
866
|
+
});
|
|
867
|
+
if (res.error !== undefined) {
|
|
868
|
+
return {
|
|
869
|
+
label: 'delegation-signal round-trip',
|
|
870
|
+
status: 'fail',
|
|
871
|
+
detail: `CLI spawn failed: ${res.error.message}`,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
if (res.status !== 0) {
|
|
875
|
+
return {
|
|
876
|
+
label: 'delegation-signal round-trip',
|
|
877
|
+
status: 'fail',
|
|
878
|
+
detail: `CLI exited ${res.status ?? 'null'}; stderr: ${(res.stderr ?? '').slice(0, 240)}`,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
// Read the audit log and find the record carrying our probe tag.
|
|
882
|
+
let raw;
|
|
883
|
+
try {
|
|
884
|
+
raw = await fsPromises.readFile(auditPath, 'utf8');
|
|
885
|
+
}
|
|
886
|
+
catch (e) {
|
|
887
|
+
return {
|
|
888
|
+
label: 'delegation-signal round-trip',
|
|
889
|
+
status: 'fail',
|
|
890
|
+
detail: `audit log read failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
894
|
+
let matched = null;
|
|
895
|
+
for (const line of lines) {
|
|
896
|
+
try {
|
|
897
|
+
const p = JSON.parse(line);
|
|
898
|
+
if (p.tool_name === DELEGATION_SIGNAL_TOOL_NAME && p.metadata?.subagent_type === probeTag) {
|
|
899
|
+
matched = { line, parsed: p };
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
// skip malformed
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
if (matched === null) {
|
|
907
|
+
return {
|
|
908
|
+
label: 'delegation-signal round-trip',
|
|
909
|
+
status: 'fail',
|
|
910
|
+
detail: `CLI exited 0 but no ` +
|
|
911
|
+
`rea.delegation_signal record with probe-tag ${probeTag} found in audit.jsonl`,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
// Codex round 4 P3 (2026-05-12): assert the recorded
|
|
915
|
+
// invocation_description_sha256 matches the expected hash of the
|
|
916
|
+
// probe description we sent. Catches a regression where the parser
|
|
917
|
+
// ignores tool_input.description and substitutes the empty hash.
|
|
918
|
+
const recordedDescHash = matched.parsed.metadata?.invocation_description_sha256;
|
|
919
|
+
if (recordedDescHash !== expectedDescriptionHash) {
|
|
920
|
+
return {
|
|
921
|
+
label: 'delegation-signal round-trip',
|
|
922
|
+
status: 'fail',
|
|
923
|
+
detail: `recorded invocation_description_sha256 mismatch: ` +
|
|
924
|
+
`expected ${expectedDescriptionHash.slice(0, 16)}…, ` +
|
|
925
|
+
`got ${(recordedDescHash ?? 'undefined').slice(0, 16)}…`,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
// Verify chain integrity for the probe record. Recompute its hash
|
|
929
|
+
// over the record-minus-hash payload and compare.
|
|
930
|
+
const recordParsed = JSON.parse(matched.line);
|
|
931
|
+
const storedHash = recordParsed.hash;
|
|
932
|
+
if (typeof storedHash !== 'string' || storedHash.length !== 64) {
|
|
933
|
+
return {
|
|
934
|
+
label: 'delegation-signal round-trip',
|
|
935
|
+
status: 'fail',
|
|
936
|
+
detail: 'probe record has no valid `hash` field',
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
const { hash: _h, ...rest } = recordParsed;
|
|
940
|
+
void _h;
|
|
941
|
+
const recomputed = computeHash(rest);
|
|
942
|
+
if (recomputed !== storedHash) {
|
|
943
|
+
return {
|
|
944
|
+
label: 'delegation-signal round-trip',
|
|
945
|
+
status: 'fail',
|
|
946
|
+
detail: `chain integrity broken: stored=${storedHash} recomputed=${recomputed}`,
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
return {
|
|
950
|
+
label: 'delegation-signal round-trip',
|
|
951
|
+
status: 'pass',
|
|
952
|
+
detail: `probe via real CLI (hash=${storedHash.slice(0, 16)}, tag=${probeTag.slice(-8)})`,
|
|
953
|
+
};
|
|
954
|
+
}
|
|
710
955
|
/**
|
|
711
956
|
* Assemble the full checklist for a given baseDir. Exported so tests can
|
|
712
957
|
* exercise the conditional branching without capturing stdout from
|
|
@@ -733,6 +978,12 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
|
|
|
733
978
|
checkAgentsPresent(baseDir),
|
|
734
979
|
checkHooksInstalled(baseDir),
|
|
735
980
|
checkSettingsJson(baseDir),
|
|
981
|
+
// 0.29.0 — delegation-telemetry MVP wiring check. Separate from
|
|
982
|
+
// checkSettingsJson because that check only validates the
|
|
983
|
+
// existence of the Bash + Write|Edit|MultiEdit|NotebookEdit
|
|
984
|
+
// matcher groups. The Agent|Skill matcher is new and needs its
|
|
985
|
+
// own pass/fail signal.
|
|
986
|
+
checkDelegationHookRegistered(baseDir),
|
|
736
987
|
];
|
|
737
988
|
// Non-git escape hatch: when `.git/` is absent, both git-hook checks are
|
|
738
989
|
// meaningless (commit-msg + pre-push can't be invoked without git). Emit
|
|
@@ -957,6 +1208,13 @@ export async function runDoctor(opts = {}) {
|
|
|
957
1208
|
// existing sync contract stays intact for downstream consumers; appended
|
|
958
1209
|
// here so runDoctor surfaces it inline.
|
|
959
1210
|
checks.push(await checkFingerprintStore(baseDir));
|
|
1211
|
+
// 0.29.0 — optional synthetic round-trip of the delegation-signal
|
|
1212
|
+
// audit path. Only runs under `--smoke` because it writes a probe
|
|
1213
|
+
// record to the audit chain; default `rea doctor` invocations leave
|
|
1214
|
+
// the chain untouched.
|
|
1215
|
+
if (opts.smoke === true) {
|
|
1216
|
+
checks.push(await checkDelegationRoundTrip(baseDir));
|
|
1217
|
+
}
|
|
960
1218
|
console.log('');
|
|
961
1219
|
log(`Doctor — ${baseDir}`);
|
|
962
1220
|
console.log('');
|
package/dist/cli/hook.d.ts
CHANGED
|
@@ -188,18 +188,50 @@ export interface HookCodexReviewOptions {
|
|
|
188
188
|
rawStdoutDir?: string;
|
|
189
189
|
}
|
|
190
190
|
export declare function runHookCodexReview(options: HookCodexReviewOptions): Promise<void>;
|
|
191
|
+
export interface HookDelegationSignalOptions {
|
|
192
|
+
/**
|
|
193
|
+
* Run the audit append in the background and return immediately. The
|
|
194
|
+
* shell hook stub sets this so the worst-case latency of the
|
|
195
|
+
* `Agent|Skill` PreToolUse hook stays in the tens-of-milliseconds
|
|
196
|
+
* range even when the audit chain is under cross-process contention.
|
|
197
|
+
*/
|
|
198
|
+
detach?: boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Override REA_ROOT. Tests set this; the production caller relies on
|
|
201
|
+
* `process.cwd()` or the `$CLAUDE_PROJECT_DIR` env var.
|
|
202
|
+
*/
|
|
203
|
+
reaRoot?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Lock-acquisition timeout in milliseconds. If `appendAuditRecord`
|
|
206
|
+
* hasn't returned within this budget, the CLI exits 0 with a stderr
|
|
207
|
+
* warning. The append is fire-and-forget at that point — we'd rather
|
|
208
|
+
* drop a single signal than block Claude Code's tool dispatch on
|
|
209
|
+
* audit-log contention. Default: 2000 ms.
|
|
210
|
+
*/
|
|
211
|
+
lockTimeoutMs?: number;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Read the hook stdin payload, redact + hash, and either await the
|
|
215
|
+
* audit append OR fire-and-forget it (when `--detach` is set).
|
|
216
|
+
*
|
|
217
|
+
* Exit-code contract: ALWAYS exit 0. The delegation signal is
|
|
218
|
+
* observational, not gating — failure to write the record must NOT
|
|
219
|
+
* block Claude Code's tool dispatch. Errors are surfaced on stderr.
|
|
220
|
+
*/
|
|
221
|
+
export declare function runHookDelegationSignal(options: HookDelegationSignalOptions): Promise<void>;
|
|
191
222
|
/**
|
|
192
223
|
* Attach the `rea hook` subcommand tree to a commander Program.
|
|
193
224
|
*
|
|
194
225
|
* Subcommands:
|
|
195
|
-
* - `push-gate`
|
|
196
|
-
* - `scan-bash`
|
|
197
|
-
*
|
|
198
|
-
* - `policy-get`
|
|
199
|
-
* - `codex-review`
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
226
|
+
* - `push-gate` — stateless pre-push Codex review (called by husky).
|
|
227
|
+
* - `scan-bash` — parser-backed bash-tier scanner (called by Claude
|
|
228
|
+
* Code shim hooks).
|
|
229
|
+
* - `policy-get` — single-source-of-truth policy reader for bash hooks.
|
|
230
|
+
* - `codex-review` — thin Bash-direct codex invocation (0.27.0+) for
|
|
231
|
+
* marathon-mode review cycles.
|
|
232
|
+
* - `delegation-signal` — 0.29.0 delegation-telemetry MVP. Reads a Claude
|
|
233
|
+
* Code PreToolUse hook payload for `Agent` / `Skill`
|
|
234
|
+
* and emits a `rea.delegation_signal` audit record.
|
|
203
235
|
*
|
|
204
236
|
* New hooks should land here rather than as top-level commands so the
|
|
205
237
|
* CLI surface stays navigable.
|