@bookedsolid/rea 0.30.1 → 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
@@ -995,37 +1011,36 @@ function codexRequiredFromPolicy(baseDir) {
995
1011
  * `.claude/settings.json` under PreToolUse with matcher `Agent|Skill`
996
1012
  * AND that the hook file exists at the expected dogfood path.
997
1013
  *
998
- * Status posture for 0.29.0:
1014
+ * Status posture:
999
1015
  *
1000
- * The 0.29.0 release introduces a new desired-hook entry in
1001
- * `defaultDesiredHooks()` that `rea init` and `rea upgrade` will merge
1002
- * into consumer `.claude/settings.json` files. Existing consumer
1003
- * installs (and this repo's own dogfood, which is locked from
1004
- * agent-driven edits by `settings-protection.sh`) won't have the
1005
- * 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".
1006
1022
  *
1007
- * To keep the upgrade-lag period from breaking `rea doctor`, the
1008
- * check is `warn` (not `fail`) for 0.29.0. The detail message names
1009
- * the exact command to fix and points at the canonical
1010
- * `delegation-capture.sh` install. After 0.29.0+1 consumer-install
1011
- * cycles have propagated, this should be promoted to `fail` so a
1012
- * skipped upgrade is loud rather than silent. Codex round 2 P2
1013
- * (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.
1014
1030
  *
1015
1031
  * Hook-file presence is verified separately by `checkHooksInstalled`
1016
- * via `EXPECTED_HOOKS` — that path stays at the hard-`fail` posture
1017
- * because file presence is part of the install manifest and doesn't
1018
- * suffer the same template-propagation lag.
1032
+ * via `EXPECTED_HOOKS` — that path was always hard-`fail`.
1019
1033
  */
1020
1034
  export function checkDelegationHookRegistered(baseDir) {
1021
1035
  const label = 'delegation-capture hook registered';
1022
- const ADVISORY = 'warn';
1036
+ // 0.31.0 promoted from `warn` to `fail` (see the docstring).
1037
+ const REFUSE = 'fail';
1023
1038
  const settingsPath = path.join(baseDir, '.claude', 'settings.json');
1024
1039
  if (!fs.existsSync(settingsPath)) {
1025
1040
  return {
1026
1041
  label,
1027
- status: ADVISORY,
1028
- 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\``,
1029
1044
  };
1030
1045
  }
1031
1046
  let parsed;
@@ -1035,7 +1050,7 @@ export function checkDelegationHookRegistered(baseDir) {
1035
1050
  catch (e) {
1036
1051
  return {
1037
1052
  label,
1038
- status: ADVISORY,
1053
+ status: REFUSE,
1039
1054
  detail: e instanceof Error ? e.message : String(e),
1040
1055
  };
1041
1056
  }
@@ -1044,9 +1059,9 @@ export function checkDelegationHookRegistered(baseDir) {
1044
1059
  if (group === undefined) {
1045
1060
  return {
1046
1061
  label,
1047
- status: ADVISORY,
1062
+ status: REFUSE,
1048
1063
  detail: 'no PreToolUse group with matcher "Agent|Skill" found in .claude/settings.json — ' +
1049
- 'run `rea upgrade` to install (advisory in 0.29.0; promoted to fail in 0.30.0). ' +
1064
+ 'run `rea upgrade` to install. ' +
1050
1065
  'NOTE: matcher MUST be exactly `Agent|Skill` ' +
1051
1066
  '(NOT `Task|Skill` — `TaskCreate`/`TaskList` are unrelated todo-list tools).',
1052
1067
  };
@@ -1055,52 +1070,228 @@ export function checkDelegationHookRegistered(baseDir) {
1055
1070
  if (!cmds.some((c) => c.includes('delegation-capture.sh'))) {
1056
1071
  return {
1057
1072
  label,
1058
- status: ADVISORY,
1073
+ status: REFUSE,
1059
1074
  detail: 'Agent|Skill matcher exists but no delegation-capture.sh command found in its hooks list',
1060
1075
  };
1061
1076
  }
1062
1077
  return { label, status: 'pass' };
1063
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
+ }
1064
1202
  /**
1065
1203
  * 0.29.0 — synthetic round-trip of the delegation-signal audit path.
1066
- * Drives a synthetic Claude Code PreToolUse hook payload through the
1067
- * REAL `rea hook delegation-signal` CLI by spawning a child process
1068
- * (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.
1069
1206
  *
1070
- * - The CLI exited 0.
1071
- * - A new `rea.delegation_signal` record landed on disk.
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:
1210
+ *
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.
1072
1216
  * - The record's metadata contains the probe tag (so we don't
1073
1217
  * mistakenly attribute an existing record to our run).
1218
+ * - The recorded `invocation_description_sha256` matches the
1219
+ * expected hash of the probe description.
1074
1220
  * - Chain integrity holds (recomputed hash == stored hash).
1075
1221
  *
1076
- * Codex round 1 P2 (2026-05-12): the previous implementation called
1077
- * `appendAuditRecord()` directly — short-circuiting stdin parsing,
1078
- * SHA-256 hashing, redact-secrets timing, and the `process.exit`
1079
- * ordering that round 1's P1 exposed. That made the smoke check
1080
- * report success even when the real production path was broken.
1222
+ * # Why drive the shell hook, not the CLI directly
1223
+ *
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.
1234
+ *
1235
+ * # Prerequisites and graceful degradation
1081
1236
  *
1082
- * This rewrite exercises the same surface the `Agent|Skill`
1083
- * PreToolUse hook does in production, so future regressions in
1084
- * stdin parsing, hashing, redaction, or process-lifecycle behavior
1085
- * fail the smoke check loudly.
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:
1086
1240
  *
1087
- * Gated behind `--smoke` so a casual `rea doctor` doesn't write
1088
- * probe records on every invocation. Operators run
1089
- * `rea doctor --smoke` after install / upgrade to confirm the
1090
- * pipeline is wired end-to-end.
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.
1091
1253
  */
1092
1254
  export async function checkDelegationRoundTrip(baseDir) {
1255
+ const label = 'delegation-signal round-trip';
1093
1256
  const probeTag = `doctor-smoke-${process.pid}-${Date.now()}`;
1094
- // Resolve the rea CLI binary the same way the shell hook does.
1095
- // First-class: this very process is running rea, so `process.argv[1]`
1096
- // is the right entrypoint. Fall back to the dist path in
1097
- // node_modules.
1098
- const cliEntry = process.argv[1];
1099
- 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)) {
1100
1264
  return {
1101
- label: 'delegation-signal round-trip',
1102
- status: 'fail',
1103
- 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.',
1104
1295
  };
1105
1296
  }
1106
1297
  // Codex round 4 P3 (2026-05-12): exercise a NON-EMPTY description
@@ -1123,13 +1314,13 @@ export async function checkDelegationRoundTrip(baseDir) {
1123
1314
  },
1124
1315
  });
1125
1316
  const auditPath = path.join(baseDir, '.rea', 'audit.jsonl');
1126
- // Synchronously spawn the CLI. The blocking wait is appropriate for
1127
- // a doctor check the operator just typed `rea doctor --smoke` and
1128
- // is waiting for output anyway. `--detach` is NOT passed: we want
1129
- // the CLI to await its own append (the post-P1 fix) and exit
1130
- // cleanly.
1131
- const { spawnSync } = await import('node:child_process');
1132
- 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], {
1133
1324
  cwd: baseDir,
1134
1325
  input: payload,
1135
1326
  encoding: 'utf8',
@@ -1138,49 +1329,64 @@ export async function checkDelegationRoundTrip(baseDir) {
1138
1329
  });
1139
1330
  if (res.error !== undefined) {
1140
1331
  return {
1141
- label: 'delegation-signal round-trip',
1332
+ label,
1142
1333
  status: 'fail',
1143
- detail: `CLI spawn failed: ${res.error.message}`,
1334
+ detail: `shell hook spawn failed: ${res.error.message}`,
1144
1335
  };
1145
1336
  }
1146
1337
  if (res.status !== 0) {
1147
1338
  return {
1148
- label: 'delegation-signal round-trip',
1149
- status: 'fail',
1150
- detail: `CLI exited ${res.status ?? 'null'}; stderr: ${(res.stderr ?? '').slice(0, 240)}`,
1151
- };
1152
- }
1153
- // Read the audit log and find the record carrying our probe tag.
1154
- let raw;
1155
- try {
1156
- raw = await fsPromises.readFile(auditPath, 'utf8');
1157
- }
1158
- catch (e) {
1159
- return {
1160
- label: 'delegation-signal round-trip',
1339
+ label,
1161
1340
  status: 'fail',
1162
- 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)}`,
1163
1342
  };
1164
1343
  }
1165
- 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
+ };
1166
1363
  let matched = null;
1167
- for (const line of lines) {
1364
+ const deadline = Date.now() + 10_000;
1365
+ while (Date.now() < deadline) {
1366
+ let raw = null;
1168
1367
  try {
1169
- const p = JSON.parse(line);
1170
- if (p.tool_name === DELEGATION_SIGNAL_TOOL_NAME && p.metadata?.subagent_type === probeTag) {
1171
- matched = { line, parsed: p };
1172
- }
1368
+ raw = await fsPromises.readFile(auditPath, 'utf8');
1173
1369
  }
1174
1370
  catch {
1175
- // 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;
1176
1374
  }
1375
+ if (raw !== null) {
1376
+ matched = scanForProbe(raw);
1377
+ if (matched !== null)
1378
+ break;
1379
+ }
1380
+ await new Promise((r) => setTimeout(r, 150));
1177
1381
  }
1178
1382
  if (matched === null) {
1179
1383
  return {
1180
- label: 'delegation-signal round-trip',
1384
+ label,
1181
1385
  status: 'fail',
1182
- detail: `CLI exited 0 but no ` +
1183
- `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)})`,
1184
1390
  };
1185
1391
  }
1186
1392
  // Codex round 4 P3 (2026-05-12): assert the recorded
@@ -1190,7 +1396,7 @@ export async function checkDelegationRoundTrip(baseDir) {
1190
1396
  const recordedDescHash = matched.parsed.metadata?.invocation_description_sha256;
1191
1397
  if (recordedDescHash !== expectedDescriptionHash) {
1192
1398
  return {
1193
- label: 'delegation-signal round-trip',
1399
+ label,
1194
1400
  status: 'fail',
1195
1401
  detail: `recorded invocation_description_sha256 mismatch: ` +
1196
1402
  `expected ${expectedDescriptionHash.slice(0, 16)}…, ` +
@@ -1203,7 +1409,7 @@ export async function checkDelegationRoundTrip(baseDir) {
1203
1409
  const storedHash = recordParsed.hash;
1204
1410
  if (typeof storedHash !== 'string' || storedHash.length !== 64) {
1205
1411
  return {
1206
- label: 'delegation-signal round-trip',
1412
+ label,
1207
1413
  status: 'fail',
1208
1414
  detail: 'probe record has no valid `hash` field',
1209
1415
  };
@@ -1213,15 +1419,15 @@ export async function checkDelegationRoundTrip(baseDir) {
1213
1419
  const recomputed = computeHash(rest);
1214
1420
  if (recomputed !== storedHash) {
1215
1421
  return {
1216
- label: 'delegation-signal round-trip',
1422
+ label,
1217
1423
  status: 'fail',
1218
1424
  detail: `chain integrity broken: stored=${storedHash} recomputed=${recomputed}`,
1219
1425
  };
1220
1426
  }
1221
1427
  return {
1222
- label: 'delegation-signal round-trip',
1428
+ label,
1223
1429
  status: 'pass',
1224
- 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)})`,
1225
1431
  };
1226
1432
  }
1227
1433
  /**
@@ -1259,8 +1465,14 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
1259
1465
  // checkSettingsJson because that check only validates the
1260
1466
  // existence of the Bash + Write|Edit|MultiEdit|NotebookEdit
1261
1467
  // matcher groups. The Agent|Skill matcher is new and needs its
1262
- // own pass/fail signal.
1468
+ // own pass/fail signal. 0.31.0 — promoted warn → fail.
1263
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),
1264
1476
  ];
1265
1477
  // Non-git escape hatch: when `.git/` is absent, both git-hook checks are
1266
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({
@@ -380,5 +380,30 @@ export function defaultDesiredHooks() {
380
380
  },
381
381
  ],
382
382
  },
383
+ {
384
+ // 0.31.0 delegation-telemetry completion — the *nudge*. The
385
+ // matcher is `Bash|Edit|Write|MultiEdit|NotebookEdit`: every
386
+ // write-class tool call (note this group INCLUDES Bash, unlike
387
+ // the architecture-review group above). The hook maintains a
388
+ // per-session counter and emits a one-time stderr advisory when
389
+ // a session crosses `policy.delegation_advisory.threshold`
390
+ // without dispatching a curated specialist. Advisory only — it
391
+ // never blocks a tool call, and `policy.delegation_advisory`
392
+ // defaults to disabled (only `bst-internal*` profiles pin
393
+ // `enabled: true`), so a vanilla install sees the hook as a
394
+ // silent no-op. Kept as its own matcher group rather than
395
+ // chained onto the architecture-review group because the
396
+ // matcher string differs (Bash is in this one, not that one).
397
+ event: 'PostToolUse',
398
+ matcher: 'Bash|Edit|Write|MultiEdit|NotebookEdit',
399
+ hooks: [
400
+ {
401
+ type: 'command',
402
+ command: `${base}/delegation-advisory.sh`,
403
+ timeout: 10000,
404
+ statusMessage: 'Checking delegation cadence...',
405
+ },
406
+ ],
407
+ },
383
408
  ];
384
409
  }