@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.
@@ -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
+ }
@@ -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;
@@ -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('');
@@ -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` — stateless pre-push Codex review (called by husky).
196
- * - `scan-bash` — parser-backed bash-tier scanner (called by Claude
197
- * Code shim hooks).
198
- * - `policy-get` — single-source-of-truth policy reader for bash hooks.
199
- * - `codex-review` — thin Bash-direct codex invocation (0.27.0+) for
200
- * marathon-mode review cycles. The canonical
201
- * invocation that all agents and slash commands
202
- * route through.
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.