@bookedsolid/rea 0.30.1 → 0.32.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 (51) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/audit-specialists.d.ts +106 -24
  4. package/dist/cli/audit-specialists.js +239 -64
  5. package/dist/cli/delegation-advisory.d.ts +161 -0
  6. package/dist/cli/delegation-advisory.js +433 -0
  7. package/dist/cli/doctor.d.ts +110 -39
  8. package/dist/cli/doctor.js +302 -90
  9. package/dist/cli/hook.d.ts +6 -0
  10. package/dist/cli/hook.js +45 -22
  11. package/dist/cli/index.js +1 -1
  12. package/dist/cli/install/settings-merge.js +25 -0
  13. package/dist/cli/roster.d.ts +119 -0
  14. package/dist/cli/roster.js +141 -0
  15. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  16. package/dist/hooks/_lib/halt-check.js +106 -0
  17. package/dist/hooks/_lib/payload.d.ts +86 -0
  18. package/dist/hooks/_lib/payload.js +166 -0
  19. package/dist/hooks/_lib/segments.d.ts +100 -0
  20. package/dist/hooks/_lib/segments.js +444 -0
  21. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  22. package/dist/hooks/attribution-advisory/index.js +233 -0
  23. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  24. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  25. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  26. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  27. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  28. package/dist/policy/loader.d.ts +23 -0
  29. package/dist/policy/loader.js +46 -0
  30. package/dist/policy/profiles.d.ts +23 -0
  31. package/dist/policy/profiles.js +16 -0
  32. package/dist/policy/types.d.ts +61 -0
  33. package/hooks/_lib/protected-paths.sh +10 -3
  34. package/hooks/attribution-advisory.sh +139 -131
  35. package/hooks/delegation-advisory.sh +162 -0
  36. package/hooks/pr-issue-link-gate.sh +114 -45
  37. package/hooks/security-disclosure-gate.sh +148 -316
  38. package/hooks/settings-protection.sh +13 -9
  39. package/package.json +1 -1
  40. package/profiles/bst-internal-no-codex.yaml +12 -0
  41. package/profiles/bst-internal.yaml +13 -0
  42. package/profiles/client-engagement.yaml +11 -0
  43. package/profiles/lit-wc.yaml +10 -0
  44. package/profiles/minimal.yaml +11 -0
  45. package/profiles/open-source-no-codex.yaml +11 -0
  46. package/profiles/open-source.yaml +11 -0
  47. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  48. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  49. package/templates/prepare-commit-msg.husky.sh +80 -6
  50. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  51. package/templates/settings-protection.dogfood.patch +58 -0
@@ -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
@@ -35,6 +35,10 @@ import crypto from 'node:crypto';
35
35
  import { parse as parseYaml } from 'yaml';
36
36
  import { parsePrePushStdin, runPushGate } from '../hooks/push-gate/index.js';
37
37
  import { runBlockedScan, runProtectedScan } from '../hooks/bash-scanner/index.js';
38
+ import { checkHalt, formatHaltBanner } from '../hooks/_lib/halt-check.js';
39
+ import { runHookPrIssueLinkGate } from '../hooks/pr-issue-link-gate/index.js';
40
+ import { runHookSecurityDisclosureGate } from '../hooks/security-disclosure-gate/index.js';
41
+ import { runHookAttributionAdvisory } from '../hooks/attribution-advisory/index.js';
38
42
  import { loadPolicy } from '../policy/loader.js';
39
43
  import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
40
44
  import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
@@ -44,6 +48,7 @@ import { CodexNotInstalledError, CodexProtocolError, CodexSubprocessError, Codex
44
48
  import { resolveBaseRef } from '../hooks/push-gate/base.js';
45
49
  import { resolvePushGatePolicy } from '../hooks/push-gate/policy.js';
46
50
  import { summarizeReview } from '../hooks/push-gate/findings.js';
51
+ import { runHookDelegationAdvisory } from './delegation-advisory.js';
47
52
  import { err } from './utils.js';
48
53
  /**
49
54
  * Public runner, exposed so integration tests and the commander binding can
@@ -128,17 +133,12 @@ export async function runHookScanBash(options) {
128
133
  // HALT check — uniform with the bash hooks. We exit 2 (block) so
129
134
  // the shim refuses the command in the same way settings-protection
130
135
  // and the bash gates do.
131
- const haltPath = path.join(reaRoot, '.rea', 'HALT');
132
- if (fs.existsSync(haltPath)) {
133
- let reason = 'Reason unknown';
134
- try {
135
- const content = fs.readFileSync(haltPath, 'utf8');
136
- reason = content.slice(0, 1024).trim() || reason;
137
- }
138
- catch {
139
- /* leave default */
140
- }
141
- process.stderr.write(`REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`);
136
+ // 0.32.0: shared via `src/hooks/_lib/halt-check.ts` so the Phase 1
137
+ // pilots and the codex-review hook below all emit the same banner
138
+ // byte-for-byte and apply the same fail-closed read posture.
139
+ const halt = checkHalt(reaRoot);
140
+ if (halt.halted) {
141
+ process.stderr.write(formatHaltBanner(halt.reason));
142
142
  const haltVerdict = {
143
143
  verdict: 'block',
144
144
  reason: 'rea HALT active',
@@ -331,17 +331,10 @@ export async function runHookPolicyGet(options) {
331
331
  export async function runHookCodexReview(options) {
332
332
  const baseDir = options.reaRoot ?? process.cwd();
333
333
  // HALT check — uniform with the rest of the hook tree.
334
- const haltPath = path.join(baseDir, '.rea', 'HALT');
335
- if (fs.existsSync(haltPath)) {
336
- let reason = 'Reason unknown';
337
- try {
338
- const content = fs.readFileSync(haltPath, 'utf8');
339
- reason = content.slice(0, 1024).trim() || reason;
340
- }
341
- catch {
342
- /* leave default */
343
- }
344
- process.stderr.write(`REA HALT: ${reason}\nAll agent operations suspended. Run: rea unfreeze\n`);
334
+ // 0.32.0: shared via `src/hooks/_lib/halt-check.ts`.
335
+ const halt = checkHalt(baseDir);
336
+ if (halt.halted) {
337
+ process.stderr.write(formatHaltBanner(halt.reason));
345
338
  process.exit(2);
346
339
  }
347
340
  // Resolve git context + base ref using the same primitives the push-
@@ -869,6 +862,12 @@ export async function runHookDelegationSignal(options) {
869
862
  * - `delegation-signal` — 0.29.0 delegation-telemetry MVP. Reads a Claude
870
863
  * Code PreToolUse hook payload for `Agent` / `Skill`
871
864
  * and emits a `rea.delegation_signal` audit record.
865
+ * - `delegation-advisory` — 0.31.0 delegation nudge. Reads a Claude Code
866
+ * PostToolUse hook payload for the write-class
867
+ * tools, maintains a per-session counter, and emits
868
+ * a one-time stderr advisory when the session
869
+ * crosses `policy.delegation_advisory.threshold`
870
+ * without dispatching a curated specialist.
872
871
  *
873
872
  * New hooks should land here rather than as top-level commands so the
874
873
  * CLI surface stays navigable.
@@ -950,6 +949,30 @@ export function registerHookCommand(program) {
950
949
  ...(opts.lockTimeoutMs !== undefined ? { lockTimeoutMs: opts.lockTimeoutMs } : {}),
951
950
  });
952
951
  });
952
+ hook
953
+ .command('delegation-advisory')
954
+ .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.')
955
+ .action(async () => {
956
+ await runHookDelegationAdvisory();
957
+ });
958
+ hook
959
+ .command('pr-issue-link-gate')
960
+ .description('Node-binary port of `hooks/pr-issue-link-gate.sh` (0.32.0). Reads a Claude Code PreToolUse Bash payload from stdin; when the command is `gh pr create` without a `closes/fixes/resolves #N` reference, prints an advisory banner to stderr. ALWAYS exits 0 except HALT (exit 2) or malformed payload (exit 2, fail-closed). The bash shim at `hooks/pr-issue-link-gate.sh` invokes this.')
961
+ .action(async () => {
962
+ await runHookPrIssueLinkGate();
963
+ });
964
+ hook
965
+ .command('security-disclosure-gate')
966
+ .description('Node-binary port of `hooks/security-disclosure-gate.sh` (0.32.0). Reads a Claude Code PreToolUse Bash payload from stdin; when the command is `gh issue create` AND title/body/body-file contents match a SECURITY_PATTERNS keyword, emits a deny JSON on stdout and exits 2. Routing depends on REA_DISCLOSURE_MODE: advisory (default, redirect to GHSA), issues (private repo, redirect to labeled issue), disabled (pass through).')
967
+ .action(async () => {
968
+ await runHookSecurityDisclosureGate();
969
+ });
970
+ hook
971
+ .command('attribution-advisory')
972
+ .description('Node-binary port of `hooks/attribution-advisory.sh` (0.32.0). Opt-in via policy.yaml `block_ai_attribution: true`. Reads a Claude Code PreToolUse Bash payload from stdin; when the command is `git commit` or `gh pr create|edit` AND contains structural AI attribution markers (Co-Authored-By with vendor noreply, AI tool names, "Generated with [X]", markdown-linked tools, 🤖 Generated), exits 2 with banner. Otherwise exits 0.')
973
+ .action(async () => {
974
+ await runHookAttributionAdvisory();
975
+ });
953
976
  hook
954
977
  .command('policy-get')
955
978
  .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.')