@bookedsolid/rea 0.30.0 → 0.31.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.
@@ -169,6 +169,22 @@ export const EXPECTED_HOOKS = [
169
169
  'blocked-paths-enforcer.sh',
170
170
  'changeset-security-gate.sh',
171
171
  'dangerous-bash-interceptor.sh',
172
+ // 0.31.0 — `delegation-advisory.sh` is INTENTIONALLY NOT in this list.
173
+ // It is the brand-new PostToolUse delegation-nudge hook this release
174
+ // ships; adding it to EXPECTED_HOOKS would make `checkHooksInstalled`
175
+ // hard-`fail` on every pre-0.31.0 consumer install (and on this repo's
176
+ // own dogfood) the instant they upgrade the rea binary but before they
177
+ // run `rea upgrade` to lay down the new hook file — a regression that
178
+ // turns a green doctor red purely from upgrade lag. This mirrors the
179
+ // staged rollout `delegation-capture.sh` itself used: the file-presence
180
+ // entry joins EXPECTED_HOOKS in the SAME future minor that promotes
181
+ // `checkDelegationAdvisoryHookRegistered` from `warn` to `fail`, once
182
+ // consumers have had upgrade-lag time. Until then, a missing
183
+ // `delegation-advisory.sh` surfaces only through that `warn`-tier
184
+ // registration check — proportionate, since the hook is advisory at
185
+ // runtime and never blocks a tool call. See `git blame` on the 0.29.0
186
+ // round-2 P2 comment on `checkDelegationHookRegistered` for the prior
187
+ // application of this exact discipline.
172
188
  // 0.29.0 — delegation-telemetry MVP. The PreToolUse hook on
173
189
  // matcher `Agent|Skill` emits a `rea.delegation_signal` audit record
174
190
  // on every subagent / skill dispatch. Observational only — fails
@@ -301,7 +317,7 @@ export function checkSettingsSchema(baseDir, strict) {
301
317
  detail: `malformed JSON: ${e instanceof Error ? e.message : String(e)}`,
302
318
  };
303
319
  }
304
- const result = validateSettings(parsed);
320
+ const result = validateSettings(parsed, { strict });
305
321
  const issues = [];
306
322
  if (!result.parsed) {
307
323
  issues.push(...result.errors.map((e) => `schema: ${e}`));
@@ -460,12 +476,22 @@ export function isGitRepo(baseDir) {
460
476
  */
461
477
  /**
462
478
  * 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.
479
+ * check. Mirrors `installPrepareCommitMsgHook`'s resolution order
480
+ * (synchronous doctor is sync end-to-end):
481
+ *
482
+ * 1. `core.hooksPath` explicit operator override (husky 9 installs
483
+ * land at `.husky/_/`). Honored verbatim.
484
+ * 2. `git rev-parse --git-path hooks` — resolves the canonical hooks
485
+ * dir even when `.git` is a FILE (linked worktrees, submodules).
486
+ * 0.30.1 round-5 P2: the prior implementation hardcoded
487
+ * `.git/hooks`, which is wrong for worktrees/submodules where
488
+ * `.git` is a gitdir pointer file, not a directory.
489
+ * 3. `.git/hooks` — last-resort fallback when git itself is missing.
490
+ *
491
+ * The Husky 9 STUB indirection (active file at the resolved path is a
492
+ * `. "${0%/*}/h"` stub that dispatches to `.husky/prepare-commit-msg`)
493
+ * is followed separately inside `checkPrepareCommitMsgHook` via
494
+ * `isHusky9Stub` / `resolveHusky9StubTarget`.
469
495
  */
470
496
  function resolveHooksDirSync(baseDir) {
471
497
  try {
@@ -479,7 +505,20 @@ function resolveHooksDirSync(baseDir) {
479
505
  }
480
506
  }
481
507
  catch {
482
- // git missing or `core.hooksPath` unset — fall through to default.
508
+ // git missing or `core.hooksPath` unset — fall through.
509
+ }
510
+ try {
511
+ const out = execFileSync('git', ['-C', baseDir, 'rev-parse', '--git-path', 'hooks'], {
512
+ encoding: 'utf8',
513
+ stdio: ['ignore', 'pipe', 'ignore'],
514
+ });
515
+ const trimmed = out.trim();
516
+ if (trimmed.length > 0) {
517
+ return path.isAbsolute(trimmed) ? trimmed : path.join(baseDir, trimmed);
518
+ }
519
+ }
520
+ catch {
521
+ // git missing — fall through to the literal default.
483
522
  }
484
523
  return path.join(baseDir, '.git', 'hooks');
485
524
  }
@@ -972,37 +1011,36 @@ function codexRequiredFromPolicy(baseDir) {
972
1011
  * `.claude/settings.json` under PreToolUse with matcher `Agent|Skill`
973
1012
  * AND that the hook file exists at the expected dogfood path.
974
1013
  *
975
- * Status posture for 0.29.0:
1014
+ * Status posture:
976
1015
  *
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`.
1016
+ * 0.29.0 shipped this check as `warn` (advisory) — the
1017
+ * `defaultDesiredHooks()` entry was new, and existing consumer
1018
+ * installs (plus this repo's own dogfood, locked from agent-driven
1019
+ * edits by `settings-protection.sh`) wouldn't have the matcher
1020
+ * registered until the operator ran `rea upgrade`. The comments
1021
+ * promised promotion to `fail` "in 0.30.0".
983
1022
  *
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).
1023
+ * **0.31.0 makes good on that promise.** The 0.29.0 0.30.x consumer
1024
+ * cycles have propagated; the `Agent|Skill` matcher has been in
1025
+ * `defaultDesiredHooks()` for multiple minors. A consumer install
1026
+ * that still lacks the registration is a real governance gap (the
1027
+ * delegation telemetry and now the 0.31.0 nudge silently does
1028
+ * nothing), so the check is `fail`. The detail message still names
1029
+ * the exact `rea upgrade` fix.
991
1030
  *
992
1031
  * 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.
1032
+ * via `EXPECTED_HOOKS` — that path was always hard-`fail`.
996
1033
  */
997
1034
  export function checkDelegationHookRegistered(baseDir) {
998
1035
  const label = 'delegation-capture hook registered';
999
- const ADVISORY = 'warn';
1036
+ // 0.31.0 promoted from `warn` to `fail` (see the docstring).
1037
+ const REFUSE = 'fail';
1000
1038
  const settingsPath = path.join(baseDir, '.claude', 'settings.json');
1001
1039
  if (!fs.existsSync(settingsPath)) {
1002
1040
  return {
1003
1041
  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)`,
1042
+ status: REFUSE,
1043
+ detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\``,
1006
1044
  };
1007
1045
  }
1008
1046
  let parsed;
@@ -1012,7 +1050,7 @@ export function checkDelegationHookRegistered(baseDir) {
1012
1050
  catch (e) {
1013
1051
  return {
1014
1052
  label,
1015
- status: ADVISORY,
1053
+ status: REFUSE,
1016
1054
  detail: e instanceof Error ? e.message : String(e),
1017
1055
  };
1018
1056
  }
@@ -1021,9 +1059,9 @@ export function checkDelegationHookRegistered(baseDir) {
1021
1059
  if (group === undefined) {
1022
1060
  return {
1023
1061
  label,
1024
- status: ADVISORY,
1062
+ status: REFUSE,
1025
1063
  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). ' +
1064
+ 'run `rea upgrade` to install. ' +
1027
1065
  'NOTE: matcher MUST be exactly `Agent|Skill` ' +
1028
1066
  '(NOT `Task|Skill` — `TaskCreate`/`TaskList` are unrelated todo-list tools).',
1029
1067
  };
@@ -1032,52 +1070,228 @@ export function checkDelegationHookRegistered(baseDir) {
1032
1070
  if (!cmds.some((c) => c.includes('delegation-capture.sh'))) {
1033
1071
  return {
1034
1072
  label,
1035
- status: ADVISORY,
1073
+ status: REFUSE,
1036
1074
  detail: 'Agent|Skill matcher exists but no delegation-capture.sh command found in its hooks list',
1037
1075
  };
1038
1076
  }
1039
1077
  return { label, status: 'pass' };
1040
1078
  }
1079
+ /**
1080
+ * 0.31.0 — verify the delegation-advisory hook is registered in
1081
+ * `.claude/settings.json` under PostToolUse with matcher
1082
+ * `Bash|Edit|Write|MultiEdit|NotebookEdit`, that a
1083
+ * `delegation-advisory.sh` command is present in that group, AND that
1084
+ * the `.claude/hooks/delegation-advisory.sh` file actually exists.
1085
+ *
1086
+ * Status posture: `warn` (advisory) for 0.31.0. This is a brand-new
1087
+ * `defaultDesiredHooks()` entry — the exact same upgrade-lag situation
1088
+ * `checkDelegationHookRegistered` faced in 0.29.0. Existing consumer
1089
+ * installs (and this repo's own dogfood, locked from agent-driven
1090
+ * edits by `settings-protection.sh`) won't have the PostToolUse group
1091
+ * until the operator runs `rea upgrade`. Holding at `warn` for one
1092
+ * release cycle keeps `rea doctor` green during propagation; a future
1093
+ * minor promotes it to `fail` once consumer installs have caught up —
1094
+ * the same ratchet `checkDelegationHookRegistered` just completed.
1095
+ *
1096
+ * The hook is ALSO advisory at runtime (it never blocks a tool call,
1097
+ * and `policy.delegation_advisory` defaults to disabled), so a missing
1098
+ * registration is a lower-stakes gap than a missing security gate —
1099
+ * `warn` is proportionate even setting the upgrade-lag aside.
1100
+ *
1101
+ * # Why this check verifies file presence AND executability (round-2/3 P2)
1102
+ *
1103
+ * `delegation-advisory.sh` is deliberately NOT in `EXPECTED_HOOKS` for
1104
+ * 0.31.0 (staged rollout — see the `EXPECTED_HOOKS` comment). That
1105
+ * leaves THIS function as the only 0.31.0 doctor signal covering the
1106
+ * new hook, so it must check the file too:
1107
+ *
1108
+ * - File MISSING — a settings.json that references
1109
+ * `delegation-advisory.sh` while the actual script is absent (a
1110
+ * partial `rea upgrade`, manual drift) would otherwise report
1111
+ * `pass`, and every matching PostToolUse dispatch would shell out
1112
+ * to a nonexistent path.
1113
+ * - File present but NOT EXECUTABLE — a script copied without its
1114
+ * mode bits (a manual `cp`, an archive extracted without `+x`
1115
+ * preservation) cannot be launched by Claude Code from
1116
+ * `settings.json` at all. `checkHooksInstalled` performs this exact
1117
+ * `0o111` check for every `EXPECTED_HOOKS` entry; because
1118
+ * `delegation-advisory.sh` is held out of that list, the parity
1119
+ * check has to live here.
1120
+ *
1121
+ * Both failures are held at the same `warn` tier as the registration
1122
+ * failures: consistent posture for 0.31.0, and they promote to `fail`
1123
+ * alongside them — at which point `delegation-advisory.sh` also joins
1124
+ * `EXPECTED_HOOKS` and gets the hard-`fail` `checkHooksInstalled`
1125
+ * coverage (presence + executability) the other hooks have.
1126
+ */
1127
+ export function checkDelegationAdvisoryHookRegistered(baseDir) {
1128
+ const label = 'delegation-advisory hook registered';
1129
+ const ADVISORY = 'warn';
1130
+ const MATCHER = 'Bash|Edit|Write|MultiEdit|NotebookEdit';
1131
+ const settingsPath = path.join(baseDir, '.claude', 'settings.json');
1132
+ if (!fs.existsSync(settingsPath)) {
1133
+ return {
1134
+ label,
1135
+ status: ADVISORY,
1136
+ detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\` (advisory in 0.31.0)`,
1137
+ };
1138
+ }
1139
+ let parsed;
1140
+ try {
1141
+ parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1142
+ }
1143
+ catch (e) {
1144
+ return {
1145
+ label,
1146
+ status: ADVISORY,
1147
+ detail: e instanceof Error ? e.message : String(e),
1148
+ };
1149
+ }
1150
+ const groups = parsed.hooks?.PostToolUse ?? [];
1151
+ const group = groups.find((g) => g.matcher === MATCHER);
1152
+ if (group === undefined) {
1153
+ return {
1154
+ label,
1155
+ status: ADVISORY,
1156
+ detail: `no PostToolUse group with matcher "${MATCHER}" found in .claude/settings.json — ` +
1157
+ 'run `rea upgrade` to install (advisory in 0.31.0; promoted to fail in a later minor). ' +
1158
+ 'NOTE: the matcher INCLUDES Bash — the delegation nudge counts every write-class ' +
1159
+ 'tool call, not just file edits.',
1160
+ };
1161
+ }
1162
+ const cmds = (group.hooks ?? []).map((h) => (typeof h.command === 'string' ? h.command : ''));
1163
+ if (!cmds.some((c) => c.includes('delegation-advisory.sh'))) {
1164
+ return {
1165
+ label,
1166
+ status: ADVISORY,
1167
+ detail: `${MATCHER} matcher exists but no delegation-advisory.sh command found in its hooks list`,
1168
+ };
1169
+ }
1170
+ // Round-2/3 P2: the registration string is present — now confirm the
1171
+ // hook file it points at actually exists AND is executable. Because
1172
+ // `delegation-advisory.sh` is intentionally out of `EXPECTED_HOOKS`
1173
+ // for 0.31.0, `checkHooksInstalled` does NOT cover it; this is the
1174
+ // only signal, so it owns both the presence and the `0o111` checks
1175
+ // that `checkHooksInstalled` does for every other shipped hook.
1176
+ const hookFile = path.join(baseDir, '.claude', 'hooks', 'delegation-advisory.sh');
1177
+ let hookStat;
1178
+ try {
1179
+ hookStat = fs.statSync(hookFile);
1180
+ }
1181
+ catch {
1182
+ return {
1183
+ label,
1184
+ status: ADVISORY,
1185
+ detail: `${MATCHER} matcher references delegation-advisory.sh but the hook file is missing: ` +
1186
+ `${hookFile} — run \`rea upgrade\` to lay it down (advisory in 0.31.0). ` +
1187
+ 'Without the file every matching PostToolUse dispatch shells out to a nonexistent path.',
1188
+ };
1189
+ }
1190
+ if ((hookStat.mode & 0o111) === 0) {
1191
+ return {
1192
+ label,
1193
+ status: ADVISORY,
1194
+ detail: `${MATCHER} matcher references delegation-advisory.sh but the hook file is not executable ` +
1195
+ `(mode=${(hookStat.mode & 0o777).toString(8)}): ${hookFile} — ` +
1196
+ 'run `rea upgrade` or `chmod +x` it (advisory in 0.31.0). ' +
1197
+ 'A non-executable hook cannot be launched by Claude Code from settings.json.',
1198
+ };
1199
+ }
1200
+ return { label, status: 'pass' };
1201
+ }
1041
1202
  /**
1042
1203
  * 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:
1204
+ * 0.31.0 drives the REAL `.claude/hooks/delegation-capture.sh` shell
1205
+ * hook, not just the `rea hook delegation-signal` CLI underneath it.
1206
+ *
1207
+ * Feeds a synthetic Claude Code PreToolUse hook payload to the shell
1208
+ * hook (the exact entry point Claude Code's `Agent|Skill` matcher
1209
+ * invokes in production) and asserts:
1046
1210
  *
1047
- * - The CLI exited 0.
1048
- * - A new `rea.delegation_signal` record landed on disk.
1211
+ * - The shell hook exited 0.
1212
+ * - A new `rea.delegation_signal` record landed on disk — the smoke
1213
+ * check POLLS for it, because `delegation-capture.sh` backgrounds
1214
+ * + disowns the CLI (`& disown`) so the shell hook returns before
1215
+ * the audit append completes.
1049
1216
  * - The record's metadata contains the probe tag (so we don't
1050
1217
  * mistakenly attribute an existing record to our run).
1218
+ * - The recorded `invocation_description_sha256` matches the
1219
+ * expected hash of the probe description.
1051
1220
  * - Chain integrity holds (recomputed hash == stored hash).
1052
1221
  *
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.
1222
+ * # Why drive the shell hook, not the CLI directly
1058
1223
  *
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.
1224
+ * 0.29.0's version spawned `rea hook delegation-signal` directly. That
1225
+ * exercised the CLI's stdin parsing / hashing / redaction / process-
1226
+ * lifecycle but NOT the shell shim's own logic: the 2-tier sandboxed
1227
+ * CLI resolution, the realpath sandbox check, the `& disown`
1228
+ * backgrounding. A regression in the shim (a botched resolution order,
1229
+ * a sandbox check that rejects the legitimate dogfood CLI, a
1230
+ * backgrounding bug that drops the signal) would pass 0.29.0's smoke
1231
+ * check while breaking production. 0.31.0 closes that gap: the smoke
1232
+ * check now invokes `bash .claude/hooks/delegation-capture.sh` and
1233
+ * the CLI is reached only through the shim.
1063
1234
  *
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.
1235
+ * # Prerequisites and graceful degradation
1236
+ *
1237
+ * The check needs THREE things and degrades to `warn` (not `fail`)
1238
+ * when any is absent — a missing prerequisite is an environment gap,
1239
+ * not a wiring regression:
1240
+ *
1241
+ * - `bash` on PATH.
1242
+ * - `.claude/hooks/delegation-capture.sh` present (the consumer
1243
+ * install path; absent before `rea init` / `rea upgrade`).
1244
+ * - A sandboxed rea CLI the shim can resolve — either
1245
+ * `<baseDir>/node_modules/@bookedsolid/rea/dist/cli/index.js` OR
1246
+ * `<baseDir>/dist/cli/index.js` (the rea-repo dogfood). Without
1247
+ * one the shim silently drops the signal by design, so the smoke
1248
+ * check would time out waiting for a record that will never land.
1249
+ *
1250
+ * Gated behind `--smoke` so a casual `rea doctor` doesn't write probe
1251
+ * records on every invocation. Operators run `rea doctor --smoke`
1252
+ * after install / upgrade to confirm the pipeline is wired end-to-end.
1068
1253
  */
1069
1254
  export async function checkDelegationRoundTrip(baseDir) {
1255
+ const label = 'delegation-signal round-trip';
1070
1256
  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) {
1257
+ // Prerequisite 1: the shell hook file. The consumer install path is
1258
+ // `.claude/hooks/delegation-capture.sh`; that is the exact file
1259
+ // Claude Code's matcher invokes. We do NOT fall back to the source
1260
+ // `hooks/` copy — the point of the smoke check is to validate the
1261
+ // INSTALLED artifact.
1262
+ const hookPath = path.join(baseDir, '.claude', 'hooks', 'delegation-capture.sh');
1263
+ if (!fs.existsSync(hookPath)) {
1077
1264
  return {
1078
- label: 'delegation-signal round-trip',
1079
- status: 'fail',
1080
- detail: 'could not resolve the rea CLI entrypoint (process.argv[1] empty)',
1265
+ label,
1266
+ status: 'warn',
1267
+ detail: `shell hook not installed at ${hookPath} — run \`rea init\` / \`rea upgrade\`. ` +
1268
+ 'Smoke check needs the installed hook to drive the full chain.',
1269
+ };
1270
+ }
1271
+ // Prerequisite 2: bash on PATH.
1272
+ const { spawnSync } = await import('node:child_process');
1273
+ const bashProbe = spawnSync('bash', ['--version'], { encoding: 'utf8' });
1274
+ if (bashProbe.status !== 0) {
1275
+ return {
1276
+ label,
1277
+ status: 'warn',
1278
+ detail: 'bash not available — cannot drive the delegation-capture.sh shell hook',
1279
+ };
1280
+ }
1281
+ // Prerequisite 3: a sandboxed rea CLI the shim can resolve. The
1282
+ // shim's 2-tier order is node_modules/@bookedsolid/rea/dist/... then
1283
+ // <proj>/dist/cli/index.js. If NEITHER exists the shim drops the
1284
+ // signal silently (by design) and the poll below would just time
1285
+ // out — surface the real reason instead.
1286
+ const consumerCli = path.join(baseDir, 'node_modules', '@bookedsolid', 'rea', 'dist', 'cli', 'index.js');
1287
+ const dogfoodCli = path.join(baseDir, 'dist', 'cli', 'index.js');
1288
+ if (!fs.existsSync(consumerCli) && !fs.existsSync(dogfoodCli)) {
1289
+ return {
1290
+ label,
1291
+ status: 'warn',
1292
+ detail: 'no sandboxed rea CLI in scope (need node_modules/@bookedsolid/rea/dist/cli/index.js ' +
1293
+ 'or <baseDir>/dist/cli/index.js) — the shell hook drops the signal silently here. ' +
1294
+ 'Run `pnpm build` (dogfood) or `pnpm i` (consumer) first.',
1081
1295
  };
1082
1296
  }
1083
1297
  // Codex round 4 P3 (2026-05-12): exercise a NON-EMPTY description
@@ -1100,13 +1314,13 @@ export async function checkDelegationRoundTrip(baseDir) {
1100
1314
  },
1101
1315
  });
1102
1316
  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'], {
1317
+ // Drive the REAL shell hook. `delegation-capture.sh` reads the
1318
+ // payload on stdin, resolves + sandbox-checks the rea CLI, then
1319
+ // backgrounds `rea hook delegation-signal --detach` with `& disown`.
1320
+ // The hook itself returns near-instantly; the audit append lands
1321
+ // asynchronously. CLAUDE_PROJECT_DIR is set so the shim resolves the
1322
+ // same baseDir doctor is checking.
1323
+ const res = spawnSync('bash', [hookPath], {
1110
1324
  cwd: baseDir,
1111
1325
  input: payload,
1112
1326
  encoding: 'utf8',
@@ -1115,49 +1329,64 @@ export async function checkDelegationRoundTrip(baseDir) {
1115
1329
  });
1116
1330
  if (res.error !== undefined) {
1117
1331
  return {
1118
- label: 'delegation-signal round-trip',
1332
+ label,
1119
1333
  status: 'fail',
1120
- detail: `CLI spawn failed: ${res.error.message}`,
1334
+ detail: `shell hook spawn failed: ${res.error.message}`,
1121
1335
  };
1122
1336
  }
1123
1337
  if (res.status !== 0) {
1124
1338
  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',
1339
+ label,
1138
1340
  status: 'fail',
1139
- detail: `audit log read failed: ${e instanceof Error ? e.message : String(e)}`,
1341
+ detail: `shell hook exited ${res.status ?? 'null'}; stderr: ${(res.stderr ?? '').slice(0, 240)}`,
1140
1342
  };
1141
1343
  }
1142
- const lines = raw.split('\n').filter((l) => l.length > 0);
1344
+ /** Scan one audit-file's content for the probe record; null when absent. */
1345
+ const scanForProbe = (raw) => {
1346
+ let found = null;
1347
+ for (const line of raw.split('\n')) {
1348
+ if (line.length === 0)
1349
+ continue;
1350
+ try {
1351
+ const p = JSON.parse(line);
1352
+ if (p.tool_name === DELEGATION_SIGNAL_TOOL_NAME &&
1353
+ p.metadata?.subagent_type === probeTag) {
1354
+ found = { line, parsed: p };
1355
+ }
1356
+ }
1357
+ catch {
1358
+ // skip malformed
1359
+ }
1360
+ }
1361
+ return found;
1362
+ };
1143
1363
  let matched = null;
1144
- for (const line of lines) {
1364
+ const deadline = Date.now() + 10_000;
1365
+ while (Date.now() < deadline) {
1366
+ let raw = null;
1145
1367
  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
- }
1368
+ raw = await fsPromises.readFile(auditPath, 'utf8');
1150
1369
  }
1151
1370
  catch {
1152
- // skip malformed
1371
+ // Audit file may not exist yet on a brand-new install — keep
1372
+ // polling; the first append creates it.
1373
+ raw = null;
1374
+ }
1375
+ if (raw !== null) {
1376
+ matched = scanForProbe(raw);
1377
+ if (matched !== null)
1378
+ break;
1153
1379
  }
1380
+ await new Promise((r) => setTimeout(r, 150));
1154
1381
  }
1155
1382
  if (matched === null) {
1156
1383
  return {
1157
- label: 'delegation-signal round-trip',
1384
+ label,
1158
1385
  status: 'fail',
1159
- detail: `CLI exited 0 but no ` +
1160
- `rea.delegation_signal record with probe-tag ${probeTag} found in audit.jsonl`,
1386
+ detail: `shell hook exited 0 but no rea.delegation_signal record with probe-tag ` +
1387
+ `${probeTag} appeared in audit.jsonl within 10s — the shim resolved/sandboxed ` +
1388
+ `the CLI but the backgrounded append never landed (check shell-hook stderr: ` +
1389
+ `${(res.stderr ?? '').slice(0, 160)})`,
1161
1390
  };
1162
1391
  }
1163
1392
  // Codex round 4 P3 (2026-05-12): assert the recorded
@@ -1167,7 +1396,7 @@ export async function checkDelegationRoundTrip(baseDir) {
1167
1396
  const recordedDescHash = matched.parsed.metadata?.invocation_description_sha256;
1168
1397
  if (recordedDescHash !== expectedDescriptionHash) {
1169
1398
  return {
1170
- label: 'delegation-signal round-trip',
1399
+ label,
1171
1400
  status: 'fail',
1172
1401
  detail: `recorded invocation_description_sha256 mismatch: ` +
1173
1402
  `expected ${expectedDescriptionHash.slice(0, 16)}…, ` +
@@ -1180,7 +1409,7 @@ export async function checkDelegationRoundTrip(baseDir) {
1180
1409
  const storedHash = recordParsed.hash;
1181
1410
  if (typeof storedHash !== 'string' || storedHash.length !== 64) {
1182
1411
  return {
1183
- label: 'delegation-signal round-trip',
1412
+ label,
1184
1413
  status: 'fail',
1185
1414
  detail: 'probe record has no valid `hash` field',
1186
1415
  };
@@ -1190,15 +1419,15 @@ export async function checkDelegationRoundTrip(baseDir) {
1190
1419
  const recomputed = computeHash(rest);
1191
1420
  if (recomputed !== storedHash) {
1192
1421
  return {
1193
- label: 'delegation-signal round-trip',
1422
+ label,
1194
1423
  status: 'fail',
1195
1424
  detail: `chain integrity broken: stored=${storedHash} recomputed=${recomputed}`,
1196
1425
  };
1197
1426
  }
1198
1427
  return {
1199
- label: 'delegation-signal round-trip',
1428
+ label,
1200
1429
  status: 'pass',
1201
- detail: `probe via real CLI (hash=${storedHash.slice(0, 16)}, tag=${probeTag.slice(-8)})`,
1430
+ detail: `probe via real .claude/hooks/delegation-capture.sh shell hook (hash=${storedHash.slice(0, 16)}, tag=${probeTag.slice(-8)})`,
1202
1431
  };
1203
1432
  }
1204
1433
  /**
@@ -1236,8 +1465,14 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
1236
1465
  // checkSettingsJson because that check only validates the
1237
1466
  // existence of the Bash + Write|Edit|MultiEdit|NotebookEdit
1238
1467
  // matcher groups. The Agent|Skill matcher is new and needs its
1239
- // own pass/fail signal.
1468
+ // own pass/fail signal. 0.31.0 — promoted warn → fail.
1240
1469
  checkDelegationHookRegistered(baseDir),
1470
+ // 0.31.0 — delegation-telemetry completion. The PostToolUse
1471
+ // `Bash|Edit|Write|MultiEdit|NotebookEdit` matcher group drives
1472
+ // the delegation-advisory nudge hook. Advisory (warn) for 0.31.0
1473
+ // — same upgrade-lag ratchet checkDelegationHookRegistered went
1474
+ // through in 0.29.0.
1475
+ checkDelegationAdvisoryHookRegistered(baseDir),
1241
1476
  ];
1242
1477
  // Non-git escape hatch: when `.git/` is absent, both git-hook checks are
1243
1478
  // meaningless (commit-msg + pre-push can't be invoked without git). Emit
@@ -232,6 +232,12 @@ export declare function runHookDelegationSignal(options: HookDelegationSignalOpt
232
232
  * - `delegation-signal` — 0.29.0 delegation-telemetry MVP. Reads a Claude
233
233
  * Code PreToolUse hook payload for `Agent` / `Skill`
234
234
  * and emits a `rea.delegation_signal` audit record.
235
+ * - `delegation-advisory` — 0.31.0 delegation nudge. Reads a Claude Code
236
+ * PostToolUse hook payload for the write-class
237
+ * tools, maintains a per-session counter, and emits
238
+ * a one-time stderr advisory when the session
239
+ * crosses `policy.delegation_advisory.threshold`
240
+ * without dispatching a curated specialist.
235
241
  *
236
242
  * New hooks should land here rather than as top-level commands so the
237
243
  * CLI surface stays navigable.
package/dist/cli/hook.js CHANGED
@@ -44,6 +44,7 @@ import { CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, Codex
44
44
  import { resolveBaseRef } from '../hooks/push-gate/base.js';
45
45
  import { resolvePushGatePolicy } from '../hooks/push-gate/policy.js';
46
46
  import { summarizeReview } from '../hooks/push-gate/findings.js';
47
+ import { runHookDelegationAdvisory } from './delegation-advisory.js';
47
48
  import { err } from './utils.js';
48
49
  /**
49
50
  * Public runner, exposed so integration tests and the commander binding can
@@ -869,6 +870,12 @@ export async function runHookDelegationSignal(options) {
869
870
  * - `delegation-signal` — 0.29.0 delegation-telemetry MVP. Reads a Claude
870
871
  * Code PreToolUse hook payload for `Agent` / `Skill`
871
872
  * and emits a `rea.delegation_signal` audit record.
873
+ * - `delegation-advisory` — 0.31.0 delegation nudge. Reads a Claude Code
874
+ * PostToolUse hook payload for the write-class
875
+ * tools, maintains a per-session counter, and emits
876
+ * a one-time stderr advisory when the session
877
+ * crosses `policy.delegation_advisory.threshold`
878
+ * without dispatching a curated specialist.
872
879
  *
873
880
  * New hooks should land here rather than as top-level commands so the
874
881
  * CLI surface stays navigable.
@@ -950,6 +957,12 @@ export function registerHookCommand(program) {
950
957
  ...(opts.lockTimeoutMs !== undefined ? { lockTimeoutMs: opts.lockTimeoutMs } : {}),
951
958
  });
952
959
  });
960
+ hook
961
+ .command('delegation-advisory')
962
+ .description('Read a Claude Code PostToolUse hook payload for a write-class tool (Bash/Edit/Write/MultiEdit/NotebookEdit) from stdin, bump a per-session write-class counter, and emit a one-time stderr advisory when the session crosses `policy.delegation_advisory.threshold` without dispatching a curated specialist (0.31.0+). Advisory only — exit ALWAYS 0 except HALT (exit 2). Disabled unless `policy.delegation_advisory.enabled: true`. The hook shim at `.claude/hooks/delegation-advisory.sh` invokes this.')
963
+ .action(async () => {
964
+ await runHookDelegationAdvisory();
965
+ });
953
966
  hook
954
967
  .command('policy-get')
955
968
  .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
@@ -148,7 +148,7 @@ async function main() {
148
148
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
149
149
  .option('--metrics', 'also print a 7-day summary of Codex telemetry (G11.5)')
150
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)')
151
+ .option('--smoke', 'also run the delegation-signal round-trip: drives the real `.claude/hooks/delegation-capture.sh` shell hook end-to-end (writes a probe `rea.delegation_signal` audit record and verifies chain integrity)')
152
152
  .option('--strict', '0.30.0 Class M — promote settings.json schema warnings (zod parse failures, path traversal, missing rea hooks) to hard fail. Use in CI gates.')
153
153
  .action(async (opts) => {
154
154
  await runDoctor({