@bookedsolid/rea 0.28.2 → 0.30.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.
Files changed (38) hide show
  1. package/.husky/prepare-commit-msg +295 -0
  2. package/MIGRATING.md +75 -0
  3. package/dist/audit/append.d.ts +1 -0
  4. package/dist/audit/append.js +1 -0
  5. package/dist/audit/delegation-event.d.ts +215 -0
  6. package/dist/audit/delegation-event.js +113 -0
  7. package/dist/cli/audit-specialists.d.ts +113 -0
  8. package/dist/cli/audit-specialists.js +220 -0
  9. package/dist/cli/doctor.d.ts +114 -1
  10. package/dist/cli/doctor.js +523 -5
  11. package/dist/cli/hook.d.ts +40 -8
  12. package/dist/cli/hook.js +305 -8
  13. package/dist/cli/index.js +9 -0
  14. package/dist/cli/init.js +120 -0
  15. package/dist/cli/install/manifest-schema.d.ts +6 -6
  16. package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
  17. package/dist/cli/install/prepare-commit-msg.js +208 -0
  18. package/dist/cli/install/settings-merge.js +20 -0
  19. package/dist/cli/upgrade.js +34 -0
  20. package/dist/config/settings-schema.d.ts +2087 -0
  21. package/dist/config/settings-schema.js +294 -0
  22. package/dist/config/tier-map.js +22 -1
  23. package/dist/policy/loader.d.ts +58 -0
  24. package/dist/policy/loader.js +68 -0
  25. package/dist/policy/profiles.d.ts +48 -0
  26. package/dist/policy/profiles.js +25 -0
  27. package/dist/policy/types.d.ts +51 -0
  28. package/dist/registry/loader.d.ts +12 -12
  29. package/hooks/delegation-capture.sh +158 -0
  30. package/package.json +1 -1
  31. package/profiles/bst-internal-no-codex.yaml +15 -0
  32. package/profiles/bst-internal.yaml +16 -0
  33. package/profiles/client-engagement.yaml +14 -0
  34. package/profiles/lit-wc.yaml +14 -0
  35. package/profiles/minimal.yaml +16 -0
  36. package/profiles/open-source-no-codex.yaml +13 -0
  37. package/profiles/open-source.yaml +13 -0
  38. package/templates/prepare-commit-msg.husky.sh +295 -0
@@ -1,17 +1,24 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import crypto from 'node:crypto';
1
3
  import fs from 'node:fs';
4
+ import fsPromises from 'node:fs/promises';
2
5
  import path from 'node:path';
3
6
  import { loadPolicy } from '../policy/loader.js';
4
7
  import { loadRegistry } from '../registry/loader.js';
5
8
  import { loadFingerprintStore } from '../registry/fingerprints-store.js';
6
9
  import { fingerprintServer } from '../registry/fingerprint.js';
7
10
  import { CodexProbe } from '../gateway/observability/codex-probe.js';
8
- import { inspectPrePushState } from './install/pre-push.js';
11
+ import { inspectPrePushState, isHusky9Stub, resolveHusky9StubTarget, } from './install/pre-push.js';
9
12
  import { summarizeTelemetry } from '../gateway/observability/codex-telemetry.js';
10
13
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
11
14
  import { buildFragment } from './install/claude-md.js';
12
15
  import { canonicalSettingsSubsetHash, defaultDesiredHooks } from './install/settings-merge.js';
13
16
  import { manifestExists, readManifest } from './install/manifest-io.js';
14
17
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
18
+ import { DELEGATION_SIGNAL_TOOL_NAME } from '../audit/delegation-event.js';
19
+ import { computeHash } from '../audit/fs.js';
20
+ import { PREPARE_COMMIT_MSG_BODY_MARKER, PREPARE_COMMIT_MSG_MARKER, } from './install/prepare-commit-msg.js';
21
+ import { validateSettings } from '../config/settings-schema.js';
15
22
  import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
16
23
  function checkFileExists(label, filePath, fatal) {
17
24
  const exists = fs.existsSync(filePath);
@@ -130,7 +137,15 @@ function checkRegistryParses(baseDir, registryPath) {
130
137
  };
131
138
  }
132
139
  }
133
- const EXPECTED_AGENTS = [
140
+ /**
141
+ * 0.30.0 (Class M settings.json schema) — `EXPECTED_HOOKS` is exported
142
+ * so the schema validator at `src/config/settings-schema.ts` can
143
+ * cross-check rea-shipped hook filenames against entries it sees in
144
+ * a consumer's `.claude/settings.json`. The validator's `--strict`
145
+ * mode FAILS when a known rea-managed hook is missing from the
146
+ * consumer's registration; default mode logs a warn.
147
+ */
148
+ export const EXPECTED_AGENTS = [
134
149
  'accessibility-engineer.md',
135
150
  'backend-engineer.md',
136
151
  'code-reviewer.md',
@@ -142,7 +157,7 @@ const EXPECTED_AGENTS = [
142
157
  'technical-writer.md',
143
158
  'typescript-specialist.md',
144
159
  ];
145
- const EXPECTED_HOOKS = [
160
+ export const EXPECTED_HOOKS = [
146
161
  'architecture-review-gate.sh',
147
162
  'attribution-advisory.sh',
148
163
  // 0.22.0 — Bash-tier parity with `blocked-paths-enforcer.sh`.
@@ -154,6 +169,13 @@ const EXPECTED_HOOKS = [
154
169
  'blocked-paths-enforcer.sh',
155
170
  'changeset-security-gate.sh',
156
171
  'dangerous-bash-interceptor.sh',
172
+ // 0.29.0 — delegation-telemetry MVP. The PreToolUse hook on
173
+ // matcher `Agent|Skill` emits a `rea.delegation_signal` audit record
174
+ // on every subagent / skill dispatch. Observational only — fails
175
+ // open so missing rea binary doesn't crash dispatch. Doctor surfaces
176
+ // a missing hook file so consumers don't silently lose the signal
177
+ // after upgrade.
178
+ 'delegation-capture.sh',
157
179
  'dependency-audit-gate.sh',
158
180
  'env-file-protection.sh',
159
181
  // 0.26.0 local-first enforcement (CTO directive 2026-05-05).
@@ -220,6 +242,85 @@ function checkHooksInstalled(baseDir) {
220
242
  }
221
243
  return { label: 'hooks installed + executable', status: 'fail', detail: issues.join('; ') };
222
244
  }
245
+ /**
246
+ * 0.30.0 Class M — validate `.claude/settings.json` against the zod
247
+ * schema in `src/config/settings-schema.ts`.
248
+ *
249
+ * Status posture:
250
+ *
251
+ * - `strict: false` (default `rea doctor`) — emit a warn when:
252
+ * - zod parse fails (unknown top-level key, missing matcher,
253
+ * malformed hook entry, etc.),
254
+ * - any `command` contains a `..` traversal after stripping
255
+ * `$CLAUDE_PROJECT_DIR`,
256
+ * - any rea-shipped hook from `EXPECTED_HOOKS` is missing from
257
+ * the consumer's registrations.
258
+ * The harness keeps working — the schema only refuses to call
259
+ * malformed hook entries; we surface the issue without breaking
260
+ * the install.
261
+ *
262
+ * - `strict: true` (`rea doctor --strict`) — fail (hard) on the
263
+ * same conditions. Used by CI gates that want a hard floor on
264
+ * consumer settings.
265
+ *
266
+ * Returns `pass` when everything cleared. Returns one `CheckResult`
267
+ * per concern; called once and emits one result. Combined with the
268
+ * existing `checkSettingsJson` (which checks for the historical Bash
269
+ * + Write|Edit|MultiEdit|NotebookEdit matchers), gives consumers a
270
+ * complete picture.
271
+ */
272
+ export function checkSettingsSchema(baseDir, strict) {
273
+ const label = strict ? 'settings.json schema (strict)' : 'settings.json schema (advisory)';
274
+ const settingsPath = path.join(baseDir, '.claude', 'settings.json');
275
+ if (!fs.existsSync(settingsPath)) {
276
+ return {
277
+ label,
278
+ status: strict ? 'fail' : 'warn',
279
+ detail: `missing: ${settingsPath}`,
280
+ };
281
+ }
282
+ let raw;
283
+ try {
284
+ raw = fs.readFileSync(settingsPath, 'utf8');
285
+ }
286
+ catch (e) {
287
+ return {
288
+ label,
289
+ status: strict ? 'fail' : 'warn',
290
+ detail: e instanceof Error ? e.message : String(e),
291
+ };
292
+ }
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(raw);
296
+ }
297
+ catch (e) {
298
+ return {
299
+ label,
300
+ status: 'fail',
301
+ detail: `malformed JSON: ${e instanceof Error ? e.message : String(e)}`,
302
+ };
303
+ }
304
+ const result = validateSettings(parsed);
305
+ const issues = [];
306
+ if (!result.parsed) {
307
+ issues.push(...result.errors.map((e) => `schema: ${e}`));
308
+ }
309
+ for (const t of result.traversalFindings) {
310
+ issues.push(`traversal: ${t.event}[${t.matcher}].hooks[${t.index}].command — ${t.reason}`);
311
+ }
312
+ for (const missing of result.missingReaHooks) {
313
+ issues.push(`missing rea hook: ${missing} not registered in PreToolUse/PostToolUse`);
314
+ }
315
+ if (issues.length === 0) {
316
+ return { label, status: 'pass' };
317
+ }
318
+ return {
319
+ label,
320
+ status: strict ? 'fail' : 'warn',
321
+ detail: issues.join('; '),
322
+ };
323
+ }
223
324
  function checkSettingsJson(baseDir) {
224
325
  const settingsPath = path.join(baseDir, '.claude', 'settings.json');
225
326
  if (!fs.existsSync(settingsPath)) {
@@ -333,6 +434,165 @@ export function isGitRepo(baseDir) {
333
434
  const resolved = path.isAbsolute(targetPath) ? targetPath : path.join(baseDir, targetPath);
334
435
  return fs.existsSync(resolved);
335
436
  }
437
+ /**
438
+ * 0.30.0 attribution augmenter — verify the husky `prepare-commit-msg`
439
+ * hook state matches what `policy.attribution.co_author.enabled` asks
440
+ * for. Four buckets:
441
+ *
442
+ * 1. `enabled: true` + hook present + rea-managed marker → pass.
443
+ * 2. `enabled: true` + hook missing OR marker mismatched → fail.
444
+ * Defense in depth: the loader's cross-field refinement should
445
+ * already have rejected `enabled: true` without identity, but
446
+ * we surface a missing hook file separately.
447
+ * 3. `enabled: true` + name OR email empty → fail. The loader should
448
+ * have already caught this; surfacing here ensures `rea doctor`
449
+ * reports a clean state for the entire augmenter surface.
450
+ * 4. `enabled: false` (or absent) + hook present (rea-managed) → pass
451
+ * (no-op — hook ships under every install).
452
+ * 5. `enabled: false` (or absent) + foreign file → warn. The operator
453
+ * has a `prepare-commit-msg` outside rea's marker; their commits
454
+ * get whatever it does, which is fine.
455
+ * 6. `enabled: false` + hook absent → pass (vanilla state).
456
+ *
457
+ * Returns `info` when the rea-shipped `.git/hooks/prepare-commit-msg`
458
+ * lives under a hooksPath we couldn't resolve (treat as same as case
459
+ * 6 from doctor's perspective).
460
+ */
461
+ /**
462
+ * Resolve the active git hooks directory for the doctor's prepare-commit-msg
463
+ * check. Mirrors `installCommitMsgHook`'s `readHooksPathFromGit` but
464
+ * synchronous (doctor is sync end-to-end). Honors `core.hooksPath` when set
465
+ * (husky 9 installs land at `.husky/_/`); falls back to `.git/hooks/`
466
+ * otherwise. Codex round 1 P2: prior implementation always looked at
467
+ * `.git/hooks/prepare-commit-msg`, false-reporting missing on any consumer
468
+ * running husky.
469
+ */
470
+ function resolveHooksDirSync(baseDir) {
471
+ try {
472
+ const out = execFileSync('git', ['-C', baseDir, 'config', '--get', 'core.hooksPath'], {
473
+ encoding: 'utf8',
474
+ stdio: ['ignore', 'pipe', 'ignore'],
475
+ });
476
+ const trimmed = out.trim();
477
+ if (trimmed.length > 0) {
478
+ return path.isAbsolute(trimmed) ? trimmed : path.join(baseDir, trimmed);
479
+ }
480
+ }
481
+ catch {
482
+ // git missing or `core.hooksPath` unset — fall through to default.
483
+ }
484
+ return path.join(baseDir, '.git', 'hooks');
485
+ }
486
+ export function checkPrepareCommitMsgHook(baseDir) {
487
+ const label = 'prepare-commit-msg hook (attribution augmenter)';
488
+ const hooksDir = resolveHooksDirSync(baseDir);
489
+ const hookPath = path.join(hooksDir, 'prepare-commit-msg');
490
+ let policyAttr;
491
+ try {
492
+ const policy = loadPolicy(baseDir);
493
+ policyAttr = policy.attribution?.co_author;
494
+ }
495
+ catch {
496
+ // policy-parse failure is surfaced elsewhere; default to "absent"
497
+ policyAttr = undefined;
498
+ }
499
+ const enabled = policyAttr?.enabled === true;
500
+ const hookExists = fs.existsSync(hookPath);
501
+ let hookIsReaManaged = false;
502
+ let hookMarkerMismatch = false;
503
+ if (hookExists) {
504
+ try {
505
+ let content = fs.readFileSync(hookPath, 'utf8');
506
+ // Codex round 3 P2: Husky 9 (`core.hooksPath=.husky/_`) auto-
507
+ // generates a stub like `. "${0%/*}/h"` at the active hooks path.
508
+ // Git dispatches through that stub to `.husky/prepare-commit-msg`
509
+ // (the canonical body, which IS rea-managed). Follow the
510
+ // indirection so doctor classifies the canonical body, not the
511
+ // stub. Same pattern as installer + pre-push doctor checks.
512
+ if (isHusky9Stub(content)) {
513
+ const target = resolveHusky9StubTarget(hookPath);
514
+ if (target !== null && target !== hookPath && fs.existsSync(target)) {
515
+ try {
516
+ content = fs.readFileSync(target, 'utf8');
517
+ }
518
+ catch {
519
+ // canonical body unreadable — fall through with stub content,
520
+ // which will classify as foreign and surface a clear error.
521
+ }
522
+ }
523
+ }
524
+ const lines = content.split('\n');
525
+ hookIsReaManaged =
526
+ content.startsWith('#!/bin/sh\n') &&
527
+ lines[1] === PREPARE_COMMIT_MSG_MARKER &&
528
+ lines[2] === PREPARE_COMMIT_MSG_BODY_MARKER;
529
+ if (!hookIsReaManaged &&
530
+ content.includes('rea:prepare-commit-msg') &&
531
+ lines[1] !== PREPARE_COMMIT_MSG_MARKER) {
532
+ hookMarkerMismatch = true;
533
+ }
534
+ }
535
+ catch {
536
+ hookIsReaManaged = false;
537
+ }
538
+ }
539
+ if (enabled) {
540
+ if (!hookExists) {
541
+ return {
542
+ label,
543
+ status: 'fail',
544
+ detail: 'attribution.co_author.enabled: true but .git/hooks/prepare-commit-msg is missing — ' +
545
+ 'run `rea init` to install the hook, or set enabled: false.',
546
+ };
547
+ }
548
+ if (!hookIsReaManaged) {
549
+ const reason = hookMarkerMismatch
550
+ ? 'marker mismatch (older rea or hand-edited)'
551
+ : 'no rea marker';
552
+ return {
553
+ label,
554
+ status: 'fail',
555
+ detail: `attribution.co_author.enabled: true but the prepare-commit-msg hook is foreign (${reason}) — ` +
556
+ 'remove the existing hook and re-run `rea init`, or set enabled: false.',
557
+ };
558
+ }
559
+ const name = (policyAttr?.name ?? '').trim();
560
+ const email = (policyAttr?.email ?? '').trim();
561
+ if (name.length === 0 || email.length === 0) {
562
+ const which = name.length === 0 ? 'name' : 'email';
563
+ return {
564
+ label,
565
+ status: 'fail',
566
+ detail: `attribution.co_author.enabled: true but ${which} is empty — ` +
567
+ 'the policy loader should have rejected this; if you are seeing this, edit ' +
568
+ '.rea/policy.yaml and either set both name+email or set enabled: false.',
569
+ };
570
+ }
571
+ return {
572
+ label,
573
+ status: 'pass',
574
+ detail: `enabled — trailer: ${name} <${email}>`,
575
+ };
576
+ }
577
+ // enabled: false (or absent).
578
+ if (!hookExists) {
579
+ return { label, status: 'pass', detail: 'disabled (no hook installed — vanilla state)' };
580
+ }
581
+ if (hookIsReaManaged) {
582
+ return {
583
+ label,
584
+ status: 'pass',
585
+ detail: 'disabled (rea-managed hook present, runs as no-op)',
586
+ };
587
+ }
588
+ return {
589
+ label,
590
+ status: 'warn',
591
+ detail: 'foreign prepare-commit-msg hook present — rea would refuse to overwrite. ' +
592
+ 'When you enable attribution.co_author.enabled, the existing hook must be ' +
593
+ 'removed or migrated to a fragment first.',
594
+ };
595
+ }
336
596
  function checkCommitMsgHook(baseDir) {
337
597
  const hookPath = path.join(baseDir, '.git', 'hooks', 'commit-msg');
338
598
  if (!fs.existsSync(hookPath)) {
@@ -707,6 +967,240 @@ function codexRequiredFromPolicy(baseDir) {
707
967
  return true;
708
968
  }
709
969
  }
970
+ /**
971
+ * 0.29.0 — verify the delegation-capture hook is registered in
972
+ * `.claude/settings.json` under PreToolUse with matcher `Agent|Skill`
973
+ * AND that the hook file exists at the expected dogfood path.
974
+ *
975
+ * Status posture for 0.29.0:
976
+ *
977
+ * The 0.29.0 release introduces a new desired-hook entry in
978
+ * `defaultDesiredHooks()` that `rea init` and `rea upgrade` will merge
979
+ * into consumer `.claude/settings.json` files. Existing consumer
980
+ * installs (and this repo's own dogfood, which is locked from
981
+ * agent-driven edits by `settings-protection.sh`) won't have the
982
+ * matcher registered until the operator runs `rea upgrade`.
983
+ *
984
+ * To keep the upgrade-lag period from breaking `rea doctor`, the
985
+ * check is `warn` (not `fail`) for 0.29.0. The detail message names
986
+ * the exact command to fix and points at the canonical
987
+ * `delegation-capture.sh` install. After 0.29.0+1 consumer-install
988
+ * cycles have propagated, this should be promoted to `fail` so a
989
+ * skipped upgrade is loud rather than silent. Codex round 2 P2
990
+ * (2026-05-12).
991
+ *
992
+ * Hook-file presence is verified separately by `checkHooksInstalled`
993
+ * via `EXPECTED_HOOKS` — that path stays at the hard-`fail` posture
994
+ * because file presence is part of the install manifest and doesn't
995
+ * suffer the same template-propagation lag.
996
+ */
997
+ export function checkDelegationHookRegistered(baseDir) {
998
+ const label = 'delegation-capture hook registered';
999
+ const ADVISORY = 'warn';
1000
+ const settingsPath = path.join(baseDir, '.claude', 'settings.json');
1001
+ if (!fs.existsSync(settingsPath)) {
1002
+ return {
1003
+ label,
1004
+ status: ADVISORY,
1005
+ detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\` (advisory in 0.29.0; promoted to fail in 0.30.0)`,
1006
+ };
1007
+ }
1008
+ let parsed;
1009
+ try {
1010
+ parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1011
+ }
1012
+ catch (e) {
1013
+ return {
1014
+ label,
1015
+ status: ADVISORY,
1016
+ detail: e instanceof Error ? e.message : String(e),
1017
+ };
1018
+ }
1019
+ const groups = parsed.hooks?.PreToolUse ?? [];
1020
+ const group = groups.find((g) => g.matcher === 'Agent|Skill');
1021
+ if (group === undefined) {
1022
+ return {
1023
+ label,
1024
+ status: ADVISORY,
1025
+ detail: 'no PreToolUse group with matcher "Agent|Skill" found in .claude/settings.json — ' +
1026
+ 'run `rea upgrade` to install (advisory in 0.29.0; promoted to fail in 0.30.0). ' +
1027
+ 'NOTE: matcher MUST be exactly `Agent|Skill` ' +
1028
+ '(NOT `Task|Skill` — `TaskCreate`/`TaskList` are unrelated todo-list tools).',
1029
+ };
1030
+ }
1031
+ const cmds = (group.hooks ?? []).map((h) => (typeof h.command === 'string' ? h.command : ''));
1032
+ if (!cmds.some((c) => c.includes('delegation-capture.sh'))) {
1033
+ return {
1034
+ label,
1035
+ status: ADVISORY,
1036
+ detail: 'Agent|Skill matcher exists but no delegation-capture.sh command found in its hooks list',
1037
+ };
1038
+ }
1039
+ return { label, status: 'pass' };
1040
+ }
1041
+ /**
1042
+ * 0.29.0 — synthetic round-trip of the delegation-signal audit path.
1043
+ * Drives a synthetic Claude Code PreToolUse hook payload through the
1044
+ * REAL `rea hook delegation-signal` CLI by spawning a child process
1045
+ * (same path the shell hook hits) and asserts:
1046
+ *
1047
+ * - The CLI exited 0.
1048
+ * - A new `rea.delegation_signal` record landed on disk.
1049
+ * - The record's metadata contains the probe tag (so we don't
1050
+ * mistakenly attribute an existing record to our run).
1051
+ * - Chain integrity holds (recomputed hash == stored hash).
1052
+ *
1053
+ * Codex round 1 P2 (2026-05-12): the previous implementation called
1054
+ * `appendAuditRecord()` directly — short-circuiting stdin parsing,
1055
+ * SHA-256 hashing, redact-secrets timing, and the `process.exit`
1056
+ * ordering that round 1's P1 exposed. That made the smoke check
1057
+ * report success even when the real production path was broken.
1058
+ *
1059
+ * This rewrite exercises the same surface the `Agent|Skill`
1060
+ * PreToolUse hook does in production, so future regressions in
1061
+ * stdin parsing, hashing, redaction, or process-lifecycle behavior
1062
+ * fail the smoke check loudly.
1063
+ *
1064
+ * Gated behind `--smoke` so a casual `rea doctor` doesn't write
1065
+ * probe records on every invocation. Operators run
1066
+ * `rea doctor --smoke` after install / upgrade to confirm the
1067
+ * pipeline is wired end-to-end.
1068
+ */
1069
+ export async function checkDelegationRoundTrip(baseDir) {
1070
+ const probeTag = `doctor-smoke-${process.pid}-${Date.now()}`;
1071
+ // Resolve the rea CLI binary the same way the shell hook does.
1072
+ // First-class: this very process is running rea, so `process.argv[1]`
1073
+ // is the right entrypoint. Fall back to the dist path in
1074
+ // node_modules.
1075
+ const cliEntry = process.argv[1];
1076
+ if (cliEntry === undefined || cliEntry.length === 0) {
1077
+ return {
1078
+ label: 'delegation-signal round-trip',
1079
+ status: 'fail',
1080
+ detail: 'could not resolve the rea CLI entrypoint (process.argv[1] empty)',
1081
+ };
1082
+ }
1083
+ // Codex round 4 P3 (2026-05-12): exercise a NON-EMPTY description
1084
+ // so the smoke check actually validates SHA-256 hashing of prompt
1085
+ // content. Pre-fix the description was '' and the hash was always
1086
+ // the well-known empty-string SHA-256 — a regression that ignored
1087
+ // tool_input.description and substituted an empty hash would have
1088
+ // passed the smoke check.
1089
+ const probeDescription = `doctor-smoke probe (${probeTag})`;
1090
+ const expectedDescriptionHash = crypto
1091
+ .createHash('sha256')
1092
+ .update(probeDescription)
1093
+ .digest('hex');
1094
+ const payload = JSON.stringify({
1095
+ tool_name: 'Agent',
1096
+ session_id: 'doctor-smoke',
1097
+ tool_input: {
1098
+ subagent_type: probeTag,
1099
+ description: probeDescription,
1100
+ },
1101
+ });
1102
+ const auditPath = path.join(baseDir, '.rea', 'audit.jsonl');
1103
+ // Synchronously spawn the CLI. The blocking wait is appropriate for
1104
+ // a doctor check — the operator just typed `rea doctor --smoke` and
1105
+ // is waiting for output anyway. `--detach` is NOT passed: we want
1106
+ // the CLI to await its own append (the post-P1 fix) and exit
1107
+ // cleanly.
1108
+ const { spawnSync } = await import('node:child_process');
1109
+ const res = spawnSync(process.execPath, [cliEntry, 'hook', 'delegation-signal'], {
1110
+ cwd: baseDir,
1111
+ input: payload,
1112
+ encoding: 'utf8',
1113
+ timeout: 15_000,
1114
+ env: { ...process.env, CLAUDE_PROJECT_DIR: baseDir },
1115
+ });
1116
+ if (res.error !== undefined) {
1117
+ return {
1118
+ label: 'delegation-signal round-trip',
1119
+ status: 'fail',
1120
+ detail: `CLI spawn failed: ${res.error.message}`,
1121
+ };
1122
+ }
1123
+ if (res.status !== 0) {
1124
+ return {
1125
+ label: 'delegation-signal round-trip',
1126
+ status: 'fail',
1127
+ detail: `CLI exited ${res.status ?? 'null'}; stderr: ${(res.stderr ?? '').slice(0, 240)}`,
1128
+ };
1129
+ }
1130
+ // Read the audit log and find the record carrying our probe tag.
1131
+ let raw;
1132
+ try {
1133
+ raw = await fsPromises.readFile(auditPath, 'utf8');
1134
+ }
1135
+ catch (e) {
1136
+ return {
1137
+ label: 'delegation-signal round-trip',
1138
+ status: 'fail',
1139
+ detail: `audit log read failed: ${e instanceof Error ? e.message : String(e)}`,
1140
+ };
1141
+ }
1142
+ const lines = raw.split('\n').filter((l) => l.length > 0);
1143
+ let matched = null;
1144
+ for (const line of lines) {
1145
+ try {
1146
+ const p = JSON.parse(line);
1147
+ if (p.tool_name === DELEGATION_SIGNAL_TOOL_NAME && p.metadata?.subagent_type === probeTag) {
1148
+ matched = { line, parsed: p };
1149
+ }
1150
+ }
1151
+ catch {
1152
+ // skip malformed
1153
+ }
1154
+ }
1155
+ if (matched === null) {
1156
+ return {
1157
+ label: 'delegation-signal round-trip',
1158
+ status: 'fail',
1159
+ detail: `CLI exited 0 but no ` +
1160
+ `rea.delegation_signal record with probe-tag ${probeTag} found in audit.jsonl`,
1161
+ };
1162
+ }
1163
+ // Codex round 4 P3 (2026-05-12): assert the recorded
1164
+ // invocation_description_sha256 matches the expected hash of the
1165
+ // probe description we sent. Catches a regression where the parser
1166
+ // ignores tool_input.description and substitutes the empty hash.
1167
+ const recordedDescHash = matched.parsed.metadata?.invocation_description_sha256;
1168
+ if (recordedDescHash !== expectedDescriptionHash) {
1169
+ return {
1170
+ label: 'delegation-signal round-trip',
1171
+ status: 'fail',
1172
+ detail: `recorded invocation_description_sha256 mismatch: ` +
1173
+ `expected ${expectedDescriptionHash.slice(0, 16)}…, ` +
1174
+ `got ${(recordedDescHash ?? 'undefined').slice(0, 16)}…`,
1175
+ };
1176
+ }
1177
+ // Verify chain integrity for the probe record. Recompute its hash
1178
+ // over the record-minus-hash payload and compare.
1179
+ const recordParsed = JSON.parse(matched.line);
1180
+ const storedHash = recordParsed.hash;
1181
+ if (typeof storedHash !== 'string' || storedHash.length !== 64) {
1182
+ return {
1183
+ label: 'delegation-signal round-trip',
1184
+ status: 'fail',
1185
+ detail: 'probe record has no valid `hash` field',
1186
+ };
1187
+ }
1188
+ const { hash: _h, ...rest } = recordParsed;
1189
+ void _h;
1190
+ const recomputed = computeHash(rest);
1191
+ if (recomputed !== storedHash) {
1192
+ return {
1193
+ label: 'delegation-signal round-trip',
1194
+ status: 'fail',
1195
+ detail: `chain integrity broken: stored=${storedHash} recomputed=${recomputed}`,
1196
+ };
1197
+ }
1198
+ return {
1199
+ label: 'delegation-signal round-trip',
1200
+ status: 'pass',
1201
+ detail: `probe via real CLI (hash=${storedHash.slice(0, 16)}, tag=${probeTag.slice(-8)})`,
1202
+ };
1203
+ }
710
1204
  /**
711
1205
  * Assemble the full checklist for a given baseDir. Exported so tests can
712
1206
  * exercise the conditional branching without capturing stdout from
@@ -722,7 +1216,7 @@ function codexRequiredFromPolicy(baseDir) {
722
1216
  *
723
1217
  * `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
724
1218
  */
725
- export function collectChecks(baseDir, codexProbeState, prePushState) {
1219
+ export function collectChecks(baseDir, codexProbeState, prePushState, options = {}) {
726
1220
  const policyPath = reaPath(baseDir, POLICY_FILE);
727
1221
  const registryPath = reaPath(baseDir, REGISTRY_FILE);
728
1222
  const reaDirPath = path.join(baseDir, REA_DIR);
@@ -733,6 +1227,17 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
733
1227
  checkAgentsPresent(baseDir),
734
1228
  checkHooksInstalled(baseDir),
735
1229
  checkSettingsJson(baseDir),
1230
+ // 0.30.0 Class M — strict zod schema check of the full
1231
+ // .claude/settings.json shape. Complements checkSettingsJson
1232
+ // (matcher coverage) and checkDelegationHookRegistered (Agent|Skill
1233
+ // wiring). Hard fail under `--strict`, warn by default.
1234
+ checkSettingsSchema(baseDir, options.strict === true),
1235
+ // 0.29.0 — delegation-telemetry MVP wiring check. Separate from
1236
+ // checkSettingsJson because that check only validates the
1237
+ // existence of the Bash + Write|Edit|MultiEdit|NotebookEdit
1238
+ // matcher groups. The Agent|Skill matcher is new and needs its
1239
+ // own pass/fail signal.
1240
+ checkDelegationHookRegistered(baseDir),
736
1241
  ];
737
1242
  // Non-git escape hatch: when `.git/` is absent, both git-hook checks are
738
1243
  // meaningless (commit-msg + pre-push can't be invoked without git). Emit
@@ -740,6 +1245,10 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
740
1245
  // other non-source-code directories that consume rea governance.
741
1246
  if (isGitRepo(baseDir)) {
742
1247
  checks.push(checkCommitMsgHook(baseDir));
1248
+ // 0.30.0 attribution augmenter — only check when policy.attribution
1249
+ // is declared. Vanilla installs without the block see no check
1250
+ // (cleaner output for consumers who don't opt in).
1251
+ checks.push(checkPrepareCommitMsgHook(baseDir));
743
1252
  if (prePushState !== undefined) {
744
1253
  checks.push(checkPrePushHook(prePushState));
745
1254
  }
@@ -952,11 +1461,20 @@ export async function runDoctor(opts = {}) {
952
1461
  catch {
953
1462
  prePushState = undefined;
954
1463
  }
955
- const checks = collectChecks(baseDir, probeState, prePushState);
1464
+ const checks = collectChecks(baseDir, probeState, prePushState, {
1465
+ strict: opts.strict === true,
1466
+ });
956
1467
  // G7: async fingerprint-store check. Kept out of `collectChecks` so the
957
1468
  // existing sync contract stays intact for downstream consumers; appended
958
1469
  // here so runDoctor surfaces it inline.
959
1470
  checks.push(await checkFingerprintStore(baseDir));
1471
+ // 0.29.0 — optional synthetic round-trip of the delegation-signal
1472
+ // audit path. Only runs under `--smoke` because it writes a probe
1473
+ // record to the audit chain; default `rea doctor` invocations leave
1474
+ // the chain untouched.
1475
+ if (opts.smoke === true) {
1476
+ checks.push(await checkDelegationRoundTrip(baseDir));
1477
+ }
960
1478
  console.log('');
961
1479
  log(`Doctor — ${baseDir}`);
962
1480
  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.