@bookedsolid/rea 0.28.2 → 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/dist/cli/hook.js CHANGED
@@ -38,6 +38,8 @@ import { runBlockedScan, runProtectedScan } from '../hooks/bash-scanner/index.js
38
38
  import { loadPolicy } from '../policy/loader.js';
39
39
  import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
40
40
  import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
41
+ import { DELEGATION_SIGNAL_TOOL_NAME, DELEGATION_SIGNAL_SERVER_NAME, DELEGATION_SIGNAL_SCHEMA_VERSION, DelegationSignalMetadataSchema, } from '../audit/delegation-event.js';
42
+ import { compileDefaultSecretPatterns, redactSecrets, } from '../gateway/middleware/redact.js';
41
43
  import { CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, CodexTimeoutError, IRON_GATE_DEFAULT_MODEL, IRON_GATE_DEFAULT_REASONING, createRealGitExecutor, runCodexReview, } from '../hooks/push-gate/codex-runner.js';
42
44
  import { resolveBaseRef } from '../hooks/push-gate/base.js';
43
45
  import { resolvePushGatePolicy } from '../hooks/push-gate/policy.js';
@@ -577,18 +579,296 @@ export async function runHookCodexReview(options) {
577
579
  }
578
580
  process.exit(exitCode);
579
581
  }
582
+ /**
583
+ * Cached default secret patterns for the redact path. Compiling the
584
+ * regex set is non-trivial (it spawns a worker per pattern), so cache
585
+ * across invocations. CLI process lifecycle is short enough that this
586
+ * is effectively per-process.
587
+ */
588
+ let _delegationSecretPatterns = null;
589
+ function getDelegationSecretPatterns() {
590
+ if (_delegationSecretPatterns === null) {
591
+ // Per-pattern timeout budget. The redact-safe wrapper runs each
592
+ // regex in a worker thread, and the worker spawn itself takes
593
+ // ~1ms on hot paths but 50–200ms cold. The spec asked for 50ms
594
+ // but that value caused spurious timeouts in cold-process tests
595
+ // (the very first PreToolUse hook invocation in a fresh shell)
596
+ // and converted every input into the timeout sentinel, leaking
597
+ // false-positive redactions. 250ms is generous enough to absorb
598
+ // a cold worker spawn while still bounded enough that the
599
+ // delegation-capture hook stays well under its 5s settings.json
600
+ // timeout. The hook itself backgrounds the CLI call so this
601
+ // budget never gates Claude Code's tool dispatch.
602
+ _delegationSecretPatterns = compileDefaultSecretPatterns({ timeoutMs: 250 });
603
+ }
604
+ return _delegationSecretPatterns;
605
+ }
606
+ /**
607
+ * Apply `redactSecrets` to a single string field. Returns the
608
+ * (possibly redacted) string plus the list of pattern names that
609
+ * fired. On timeout, returns `'[REDACTED: pattern timeout]'` (the
610
+ * sentinel from redact.ts) and the timeout pattern name so the audit
611
+ * envelope still records the redaction happened.
612
+ *
613
+ * Best-effort: any exception in the redact path returns the input
614
+ * unchanged + an empty pattern list. The audit record is observational
615
+ * — failing the whole signal because the redact timer threw would lose
616
+ * the signal entirely.
617
+ */
618
+ /**
619
+ * Sentinel emitted when redaction is unable to make a definitive
620
+ * decision (regex timeout, worker error). The redactor's invariant is
621
+ * "never let a potentially secret-bearing string pass through
622
+ * unredacted on failure" — that invariant MUST hold for the
623
+ * delegation-signal path too. Falling back to the raw input on
624
+ * timeout would silently leak a planted credential into
625
+ * .rea/audit.jsonl. Codex round 2 P1 (2026-05-12).
626
+ */
627
+ const REDACT_INDETERMINATE_SENTINEL = '[REDACTED: indeterminate]';
628
+ function redactField(value) {
629
+ try {
630
+ const { output, redacted, timedOut } = redactSecrets(value, getDelegationSecretPatterns());
631
+ // Timeout: the redactor's `[REDACTED: pattern timeout]` output
632
+ // already says "I couldn't decide". Treat that as a full-field
633
+ // redaction here too — under no circumstance let the raw input
634
+ // through when the scanner failed to complete. This is the
635
+ // fail-closed posture redact.ts itself takes; we mirror it.
636
+ // The redactor's own telemetry separately records the timeout
637
+ // (REDACT_TIMEOUT_METADATA_KEY), so observability isn't lost.
638
+ if (timedOut) {
639
+ return {
640
+ value: REDACT_INDETERMINATE_SENTINEL,
641
+ patterns: ['redact_timeout'],
642
+ };
643
+ }
644
+ if (redacted.length === 0)
645
+ return { value, patterns: [] };
646
+ // The redact contract replaces matched substrings with `[REDACTED]`.
647
+ // For a short identifier field like `subagent_type`, treat any hit
648
+ // as a full-field redaction so a partial match doesn't leak the
649
+ // surrounding context.
650
+ return { value: output.includes('[REDACTED') ? '[REDACTED]' : output, patterns: redacted };
651
+ }
652
+ catch {
653
+ // Synchronous redactor exception (extremely rare — the wrapper
654
+ // catches its own errors). Fail closed: indeterminate sentinel.
655
+ return {
656
+ value: REDACT_INDETERMINATE_SENTINEL,
657
+ patterns: ['redact_error'],
658
+ };
659
+ }
660
+ }
661
+ /**
662
+ * The actual audit-write — wrapped so it can run inline (default) or
663
+ * as a detached background tail call (`--detach`). Returns the
664
+ * promise; the caller decides whether to await it.
665
+ */
666
+ async function writeDelegationSignal(baseDir, metadata, redactedFields, sessionId) {
667
+ // Defense-in-depth: validate the metadata shape against the strict
668
+ // zod schema before handing it off to `appendAuditRecord`. A future
669
+ // refactor that introduces a field-name typo here would otherwise
670
+ // silently land a malformed line in the chain.
671
+ const parsed = DelegationSignalMetadataSchema.safeParse(metadata);
672
+ if (!parsed.success) {
673
+ process.stderr.write(`[rea] delegation-signal: metadata failed strict-mode validation: ${parsed.error.message}\n`);
674
+ return;
675
+ }
676
+ await appendAuditRecord(baseDir, {
677
+ tool_name: DELEGATION_SIGNAL_TOOL_NAME,
678
+ server_name: DELEGATION_SIGNAL_SERVER_NAME,
679
+ tier: Tier.Read,
680
+ status: InvocationStatus.Allowed,
681
+ session_id: sessionId,
682
+ ...(redactedFields.length > 0 ? { redacted_fields: redactedFields } : {}),
683
+ metadata: parsed.data,
684
+ });
685
+ }
686
+ /**
687
+ * Read the hook stdin payload, redact + hash, and either await the
688
+ * audit append OR fire-and-forget it (when `--detach` is set).
689
+ *
690
+ * Exit-code contract: ALWAYS exit 0. The delegation signal is
691
+ * observational, not gating — failure to write the record must NOT
692
+ * block Claude Code's tool dispatch. Errors are surfaced on stderr.
693
+ */
694
+ export async function runHookDelegationSignal(options) {
695
+ const baseDir = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
696
+ const lockTimeoutMs = options.lockTimeoutMs ?? 2000;
697
+ // Read stdin. TTY → empty (no harness payload available, nothing to
698
+ // emit — exit 0 silently). Timeout 1s; the hook shim feeds us a
699
+ // small JSON blob that fully prints in milliseconds.
700
+ const stdinRaw = process.stdin.isTTY ? '' : await readStdinWithTimeout(1_000);
701
+ if (stdinRaw.length === 0) {
702
+ process.exit(0);
703
+ }
704
+ let payload;
705
+ try {
706
+ payload = JSON.parse(stdinRaw);
707
+ }
708
+ catch (e) {
709
+ // Malformed payload — observational signal only, exit 0 silently
710
+ // with stderr breadcrumb. Failing here would propagate to the hook
711
+ // shim and risk blocking the underlying Agent/Skill dispatch.
712
+ process.stderr.write(`[rea] delegation-signal: malformed stdin JSON (${e instanceof Error ? e.message : String(e)}), signal dropped\n`);
713
+ process.exit(0);
714
+ }
715
+ // Resolve which delegation tool fired. Anything else is a misfire at
716
+ // the matcher layer (Claude Code routed a non-delegation tool to us)
717
+ // — exit 0 silently.
718
+ const rawToolName = typeof payload.tool_name === 'string' ? payload.tool_name : '';
719
+ const delegationTool = rawToolName === 'Agent' ? 'Agent' : rawToolName === 'Skill' ? 'Skill' : null;
720
+ if (delegationTool === null) {
721
+ process.exit(0);
722
+ }
723
+ // Extract the agent / skill name. Agent → tool_input.subagent_type;
724
+ // Skill → tool_input.skill. Missing → emit a placeholder ('unknown')
725
+ // so the chain still records the delegation event.
726
+ const ti = payload.tool_input ?? {};
727
+ let rawSubagentType = '';
728
+ if (delegationTool === 'Agent' && typeof ti.subagent_type === 'string') {
729
+ rawSubagentType = ti.subagent_type;
730
+ }
731
+ else if (delegationTool === 'Skill' && typeof ti.skill === 'string') {
732
+ rawSubagentType = ti.skill;
733
+ }
734
+ if (rawSubagentType.length === 0)
735
+ rawSubagentType = 'unknown';
736
+ // Parent subagent — Claude Code surfaces this either as
737
+ // `tool_input.parent_subagent_type` (when the dispatcher attaches
738
+ // it to the payload) or as the `CLAUDE_PARENT_SUBAGENT` env var
739
+ // (the alternate source the integrated spec calls out). Payload
740
+ // wins when both are present — payload is closer to the originating
741
+ // event and the env var can be stale across subprocess fan-out.
742
+ // Codex round 4 P2 (2026-05-12): pre-fix the env-var source was
743
+ // ignored and every nested delegation was recorded as null,
744
+ // defeating the parent/child telemetry the field was added for.
745
+ let rawParent = null;
746
+ if (typeof ti.parent_subagent_type === 'string' && ti.parent_subagent_type.length > 0) {
747
+ rawParent = ti.parent_subagent_type;
748
+ }
749
+ else {
750
+ const envParent = process.env['CLAUDE_PARENT_SUBAGENT'];
751
+ if (typeof envParent === 'string' && envParent.length > 0) {
752
+ rawParent = envParent;
753
+ }
754
+ }
755
+ // Description / prompt → SHA-256, never persisted in clear.
756
+ // Agent dispatches the prompt under `description`; Skill under
757
+ // `prompt`. When neither is present we hash the empty string so the
758
+ // field is always present.
759
+ const rawDescription = delegationTool === 'Agent' && typeof ti.description === 'string'
760
+ ? ti.description
761
+ : delegationTool === 'Skill' && typeof ti.prompt === 'string'
762
+ ? ti.prompt
763
+ : '';
764
+ const descriptionHash = crypto.createHash('sha256').update(rawDescription).digest('hex');
765
+ // Run subagent_type + parent_subagent_type through the redact path.
766
+ // A planted credential string in either field is replaced with
767
+ // [REDACTED] before landing in the audit log.
768
+ const redactedFields = [];
769
+ const sub = redactField(rawSubagentType);
770
+ if (sub.patterns.length > 0) {
771
+ redactedFields.push('metadata.subagent_type');
772
+ }
773
+ let parentValue = rawParent;
774
+ if (rawParent !== null) {
775
+ const parentRed = redactField(rawParent);
776
+ parentValue = parentRed.value;
777
+ if (parentRed.patterns.length > 0) {
778
+ redactedFields.push('metadata.parent_subagent_type');
779
+ }
780
+ }
781
+ const sessionIdObserved = typeof payload.session_id === 'string' && payload.session_id.length > 0
782
+ ? payload.session_id
783
+ : 'unknown';
784
+ const hookEventTimestamp = typeof payload.hook_event_timestamp === 'string' && payload.hook_event_timestamp.length > 0
785
+ ? payload.hook_event_timestamp
786
+ : undefined;
787
+ const metadata = {
788
+ schema_version: DELEGATION_SIGNAL_SCHEMA_VERSION,
789
+ delegation_tool: delegationTool,
790
+ subagent_type: sub.value,
791
+ session_id_observed: sessionIdObserved,
792
+ parent_subagent_type: parentValue,
793
+ invocation_description_sha256: descriptionHash,
794
+ ...(hookEventTimestamp !== undefined ? { hook_event_timestamp: hookEventTimestamp } : {}),
795
+ };
796
+ // Audit envelope `session_id` carries the observed session so a
797
+ // future reader can correlate without traversing metadata. (The
798
+ // envelope value is duplicated in metadata.session_id_observed for
799
+ // record-self-containment.)
800
+ const writePromise = writeDelegationSignal(baseDir, metadata, redactedFields, sessionIdObserved);
801
+ // The audit append must complete BEFORE this CLI process exits.
802
+ // Earlier iterations treated `--detach` as "fire-and-forget at the
803
+ // CLI level", but Node terminates a process when the event loop has
804
+ // no more sync work — and `appendAuditRecord()` is async filesystem
805
+ // work that does NOT keep the process alive across `process.exit`.
806
+ // The fire-and-forget concept lives one level UP, in the SHELL hook:
807
+ // the .sh stub backgrounds this entire CLI invocation with `&` +
808
+ // `disown` so Claude Code's tool dispatch is not blocked. From inside
809
+ // the CLI we always wait for the append.
810
+ //
811
+ // Codex round 1 P1 (2026-05-12): the previous implementation called
812
+ // `process.exit(0)` immediately after kicking off the promise under
813
+ // `--detach`. Tests stubbed `process.exit` so the promise still ran
814
+ // to completion in-test, masking the bug. In production every
815
+ // Agent/Skill dispatch silently dropped its delegation record.
816
+ //
817
+ // `--detach` is RETAINED as a flag for backwards compat with the
818
+ // shell hook stub's `--detach &` argv (and to document that the
819
+ // shell hook is the backgrounding layer, not the CLI). Its only
820
+ // remaining effect is the doc comment and an audit-append-failure
821
+ // mode that NEVER emits to stderr (no parent shell is listening
822
+ // when the CLI ran detached).
823
+ const detached = options.detach === true;
824
+ let timer = null;
825
+ try {
826
+ await Promise.race([
827
+ writePromise,
828
+ new Promise((resolve) => {
829
+ timer = setTimeout(() => resolve('timeout'), lockTimeoutMs);
830
+ timer.unref?.();
831
+ }).then((tag) => {
832
+ if (tag === 'timeout') {
833
+ if (!detached) {
834
+ process.stderr.write(`[rea] delegation-signal: lock timeout, signal dropped\n`);
835
+ }
836
+ // Surface the eventual error so it doesn't escape as an
837
+ // unhandled rejection after we've exited the await.
838
+ writePromise.catch(() => {
839
+ /* already reported via stderr */
840
+ });
841
+ return;
842
+ }
843
+ }),
844
+ ]);
845
+ }
846
+ catch (e) {
847
+ // Append failure (e.g. ENOSPC) — surface to stderr unless we ran
848
+ // detached (no parent shell is listening).
849
+ if (!detached) {
850
+ process.stderr.write(`[rea] delegation-signal: audit append failed: ${e instanceof Error ? e.message : String(e)}\n`);
851
+ }
852
+ }
853
+ finally {
854
+ if (timer !== null)
855
+ clearTimeout(timer);
856
+ }
857
+ process.exit(0);
858
+ }
580
859
  /**
581
860
  * Attach the `rea hook` subcommand tree to a commander Program.
582
861
  *
583
862
  * Subcommands:
584
- * - `push-gate` — stateless pre-push Codex review (called by husky).
585
- * - `scan-bash` — parser-backed bash-tier scanner (called by Claude
586
- * Code shim hooks).
587
- * - `policy-get` — single-source-of-truth policy reader for bash hooks.
588
- * - `codex-review` — thin Bash-direct codex invocation (0.27.0+) for
589
- * marathon-mode review cycles. The canonical
590
- * invocation that all agents and slash commands
591
- * route through.
863
+ * - `push-gate` — stateless pre-push Codex review (called by husky).
864
+ * - `scan-bash` — parser-backed bash-tier scanner (called by Claude
865
+ * Code shim hooks).
866
+ * - `policy-get` — single-source-of-truth policy reader for bash hooks.
867
+ * - `codex-review` — thin Bash-direct codex invocation (0.27.0+) for
868
+ * marathon-mode review cycles.
869
+ * - `delegation-signal` — 0.29.0 delegation-telemetry MVP. Reads a Claude
870
+ * Code PreToolUse hook payload for `Agent` / `Skill`
871
+ * and emits a `rea.delegation_signal` audit record.
592
872
  *
593
873
  * New hooks should land here rather than as top-level commands so the
594
874
  * CLI surface stays navigable.
@@ -653,6 +933,23 @@ export function registerHookCommand(program) {
653
933
  ...(opts.json === true ? { json: true } : {}),
654
934
  });
655
935
  });
936
+ hook
937
+ .command('delegation-signal')
938
+ .description('Read a Claude Code PreToolUse hook payload for `Agent` or `Skill` from stdin and emit a `rea.delegation_signal` audit record (0.29.0+). Observational telemetry only — exit ALWAYS 0; failure to write the record never blocks tool dispatch. The hook shim at `.claude/hooks/delegation-capture.sh` invokes this with `--detach` so the Agent/Skill call proceeds without waiting on the audit lock.')
939
+ .option('--detach', 'fire the audit append in the background and return immediately. Set by the shell hook stub so worst-case latency stays low under lock contention.')
940
+ .option('--lock-timeout-ms <n>', 'milliseconds to wait for the audit-chain lock before dropping the signal. Default 2000.', (raw) => {
941
+ const n = Number(raw);
942
+ if (!Number.isInteger(n) || n <= 0) {
943
+ throw new Error(`--lock-timeout-ms must be a positive integer, got ${JSON.stringify(raw)}`);
944
+ }
945
+ return n;
946
+ })
947
+ .action(async (opts) => {
948
+ await runHookDelegationSignal({
949
+ ...(opts.detach === true ? { detach: true } : {}),
950
+ ...(opts.lockTimeoutMs !== undefined ? { lockTimeoutMs: opts.lockTimeoutMs } : {}),
951
+ });
952
+ });
656
953
  hook
657
954
  .command('policy-get')
658
955
  .description('Read a value from `.rea/policy.yaml` via the canonical YAML parser. Used by bash-tier hooks (`hooks/_lib/policy-read.sh::policy_nested_scalar`) so inline AND block YAML forms agree at a single source of truth. Default scalar mode: prints raw value or empty. With `--json`: emits JSON (scalar or object/array; missing path → `null`). Unparseable YAML → empty / null, exit 1.')
package/dist/cli/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import { runAuditRotate, runAuditVerify } from './audit.js';
4
+ import { registerAuditSpecialistsSubcommand } from './audit-specialists.js';
4
5
  import { runCheck } from './check.js';
5
6
  import { registerHookCommand } from './hook.js';
6
7
  import { runDoctor } from './doctor.js';
@@ -106,6 +107,10 @@ async function main() {
106
107
  .action(async (opts) => {
107
108
  await runAuditVerify({ ...(opts.since !== undefined ? { since: opts.since } : {}) });
108
109
  });
110
+ // 0.29.0 — `rea audit specialists` reader for delegation-telemetry
111
+ // records. Read-only; honors $CLAUDE_SESSION_ID for current-session
112
+ // filtering. v1 omits --since / --session (deferred to 0.29.1).
113
+ registerAuditSpecialistsSubcommand(audit);
109
114
  // Register `rea hook push-gate` — the stateless pre-push Codex gate
110
115
  // called by `.husky/pre-push` and `.git/hooks/pre-push`.
111
116
  registerHookCommand(program);
@@ -143,10 +148,12 @@ async function main() {
143
148
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
144
149
  .option('--metrics', 'also print a 7-day summary of Codex telemetry (G11.5)')
145
150
  .option('--drift', 'report drift vs. the install manifest (read-only; does not mutate)')
151
+ .option('--smoke', 'also run the 0.29.0 delegation-signal round-trip (writes a probe `rea.delegation_signal` audit record and verifies chain integrity)')
146
152
  .action(async (opts) => {
147
153
  await runDoctor({
148
154
  ...(opts.metrics === true ? { metrics: true } : {}),
149
155
  ...(opts.drift === true ? { drift: true } : {}),
156
+ ...(opts.smoke === true ? { smoke: true } : {}),
150
157
  });
151
158
  });
152
159
  await program.parseAsync(process.argv);
@@ -22,12 +22,12 @@ export declare const ManifestEntrySchema: z.ZodObject<{
22
22
  }, "strict", z.ZodTypeAny, {
23
23
  path: string;
24
24
  sha256: string;
25
- source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
25
+ source: "agent" | "command" | "hook" | "husky" | "claude-md" | "settings";
26
26
  mode?: number | undefined;
27
27
  }, {
28
28
  path: string;
29
29
  sha256: string;
30
- source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
30
+ source: "agent" | "command" | "hook" | "husky" | "claude-md" | "settings";
31
31
  mode?: number | undefined;
32
32
  }>;
33
33
  export type ManifestEntry = z.infer<typeof ManifestEntrySchema>;
@@ -45,12 +45,12 @@ export declare const InstallManifestSchema: z.ZodObject<{
45
45
  }, "strict", z.ZodTypeAny, {
46
46
  path: string;
47
47
  sha256: string;
48
- source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
48
+ source: "agent" | "command" | "hook" | "husky" | "claude-md" | "settings";
49
49
  mode?: number | undefined;
50
50
  }, {
51
51
  path: string;
52
52
  sha256: string;
53
- source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
53
+ source: "agent" | "command" | "hook" | "husky" | "claude-md" | "settings";
54
54
  mode?: number | undefined;
55
55
  }>, "many">;
56
56
  }, "strict", z.ZodTypeAny, {
@@ -60,7 +60,7 @@ export declare const InstallManifestSchema: z.ZodObject<{
60
60
  files: {
61
61
  path: string;
62
62
  sha256: string;
63
- source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
63
+ source: "agent" | "command" | "hook" | "husky" | "claude-md" | "settings";
64
64
  mode?: number | undefined;
65
65
  }[];
66
66
  upgraded_at?: string | undefined;
@@ -72,7 +72,7 @@ export declare const InstallManifestSchema: z.ZodObject<{
72
72
  files: {
73
73
  path: string;
74
74
  sha256: string;
75
- source: "command" | "hook" | "agent" | "husky" | "claude-md" | "settings";
75
+ source: "agent" | "command" | "hook" | "husky" | "claude-md" | "settings";
76
76
  mode?: number | undefined;
77
77
  }[];
78
78
  upgraded_at?: string | undefined;
@@ -318,6 +318,26 @@ export function defaultDesiredHooks() {
318
318
  },
319
319
  ],
320
320
  },
321
+ {
322
+ // 0.29.0 delegation-telemetry MVP. The matcher is `Agent|Skill` —
323
+ // the two delegation tools in current Claude Code. NOT `Task|Skill`:
324
+ // `TaskCreate`/`TaskList`/`TaskUpdate` are unrelated todo-list tools
325
+ // and MUST NOT match. The hook is observational; its only effect is
326
+ // to append a `rea.delegation_signal` audit record. Worst-case
327
+ // latency budget is 50ms even under audit-chain contention (the
328
+ // signal is backgrounded and the CLI uses a 2s lock-acquisition
329
+ // fallback that exits 0 on timeout).
330
+ event: 'PreToolUse',
331
+ matcher: 'Agent|Skill',
332
+ hooks: [
333
+ {
334
+ type: 'command',
335
+ command: `${base}/delegation-capture.sh`,
336
+ timeout: 5000,
337
+ statusMessage: 'Recording delegation signal...',
338
+ },
339
+ ],
340
+ },
321
341
  {
322
342
  event: 'PreToolUse',
323
343
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
@@ -264,9 +264,18 @@ export function reaCommandTier(command) {
264
264
  const subcommandTier = (() => {
265
265
  switch (sub) {
266
266
  case 'check':
267
- case 'doctor':
268
267
  case 'status':
269
268
  return Tier.Read;
269
+ case 'doctor': {
270
+ // 0.29.0 — `rea doctor --smoke` appends a probe
271
+ // `rea.delegation_signal` record to `.rea/audit.jsonl` to
272
+ // verify the end-to-end pipeline. That makes it a writer
273
+ // (hash-chain mutation) and therefore Write-tier — bare
274
+ // `rea doctor` stays Read. Codex round 3 P2 (2026-05-12).
275
+ if (tokens.slice(idx + 1).includes('--smoke'))
276
+ return Tier.Write;
277
+ return Tier.Read;
278
+ }
270
279
  case 'hook': {
271
280
  // `rea hook push-gate` is execution-only — it runs codex exec review
272
281
  // and writes `.rea/last-review.json` + an audit record, but the
@@ -275,11 +284,23 @@ export function reaCommandTier(command) {
275
284
  // `audit record codex-review`).
276
285
  if (sub2 === 'push-gate')
277
286
  return Tier.Read;
287
+ // 0.29.0 — `rea hook delegation-signal` reads stdin, appends a
288
+ // single audit record, and exits 0. Read-tier: observational
289
+ // telemetry with no policy effect and no behavior change to the
290
+ // underlying Agent/Skill dispatch.
291
+ if (sub2 === 'delegation-signal')
292
+ return Tier.Read;
278
293
  return Tier.Write;
279
294
  }
280
295
  case 'audit': {
281
296
  if (sub2 === 'verify')
282
297
  return Tier.Read;
298
+ // 0.29.0 — `rea audit specialists` is a read-only reader for
299
+ // the delegation-telemetry MVP. Classify Read so L0 sessions
300
+ // can summarize delegation patterns without tripping the
301
+ // Write-tier default. Codex round 3 P2 (2026-05-12).
302
+ if (sub2 === 'specialists')
303
+ return Tier.Read;
283
304
  if (sub2 === 'rotate')
284
305
  return Tier.Write;
285
306
  return Tier.Write;
@@ -18,17 +18,17 @@ declare const RegistryServerSchema: z.ZodObject<{
18
18
  enabled: z.ZodDefault<z.ZodBoolean>;
19
19
  }, "strict", z.ZodTypeAny, {
20
20
  name: string;
21
+ env: Record<string, string>;
21
22
  command: string;
22
23
  args: string[];
23
- env: Record<string, string>;
24
24
  enabled: boolean;
25
25
  env_passthrough?: string[] | undefined;
26
26
  tier_overrides?: Record<string, Tier> | undefined;
27
27
  }, {
28
28
  name: string;
29
29
  command: string;
30
- args?: string[] | undefined;
31
30
  env?: Record<string, string> | undefined;
31
+ args?: string[] | undefined;
32
32
  env_passthrough?: string[] | undefined;
33
33
  tier_overrides?: Record<string, Tier> | undefined;
34
34
  enabled?: boolean | undefined;
@@ -45,17 +45,17 @@ declare const RegistrySchema: z.ZodObject<{
45
45
  enabled: z.ZodDefault<z.ZodBoolean>;
46
46
  }, "strict", z.ZodTypeAny, {
47
47
  name: string;
48
+ env: Record<string, string>;
48
49
  command: string;
49
50
  args: string[];
50
- env: Record<string, string>;
51
51
  enabled: boolean;
52
52
  env_passthrough?: string[] | undefined;
53
53
  tier_overrides?: Record<string, Tier> | undefined;
54
54
  }, {
55
55
  name: string;
56
56
  command: string;
57
- args?: string[] | undefined;
58
57
  env?: Record<string, string> | undefined;
58
+ args?: string[] | undefined;
59
59
  env_passthrough?: string[] | undefined;
60
60
  tier_overrides?: Record<string, Tier> | undefined;
61
61
  enabled?: boolean | undefined;
@@ -65,9 +65,9 @@ declare const RegistrySchema: z.ZodObject<{
65
65
  version: "1";
66
66
  servers: {
67
67
  name: string;
68
+ env: Record<string, string>;
68
69
  command: string;
69
70
  args: string[];
70
- env: Record<string, string>;
71
71
  enabled: boolean;
72
72
  env_passthrough?: string[] | undefined;
73
73
  tier_overrides?: Record<string, Tier> | undefined;
@@ -78,8 +78,8 @@ declare const RegistrySchema: z.ZodObject<{
78
78
  servers?: {
79
79
  name: string;
80
80
  command: string;
81
- args?: string[] | undefined;
82
81
  env?: Record<string, string> | undefined;
82
+ args?: string[] | undefined;
83
83
  env_passthrough?: string[] | undefined;
84
84
  tier_overrides?: Record<string, Tier> | undefined;
85
85
  enabled?: boolean | undefined;