@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.
- package/.husky/prepare-commit-msg +20 -1
- package/dist/cli/audit-specialists.d.ts +106 -24
- package/dist/cli/audit-specialists.js +239 -64
- package/dist/cli/delegation-advisory.d.ts +161 -0
- package/dist/cli/delegation-advisory.js +433 -0
- package/dist/cli/doctor.d.ts +110 -39
- package/dist/cli/doctor.js +333 -98
- package/dist/cli/hook.d.ts +6 -0
- package/dist/cli/hook.js +13 -0
- package/dist/cli/index.js +1 -1
- package/dist/cli/install/settings-merge.js +25 -0
- package/dist/cli/roster.d.ts +119 -0
- package/dist/cli/roster.js +141 -0
- package/dist/config/settings-schema.d.ts +13 -1
- package/dist/config/settings-schema.js +13 -2
- package/dist/policy/loader.d.ts +24 -1
- package/dist/policy/loader.js +61 -1
- package/dist/policy/profiles.d.ts +23 -0
- package/dist/policy/profiles.js +16 -0
- package/dist/policy/types.d.ts +61 -0
- package/hooks/delegation-advisory.sh +162 -0
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +12 -0
- package/profiles/bst-internal.yaml +13 -0
- package/profiles/client-engagement.yaml +11 -0
- package/profiles/lit-wc.yaml +10 -0
- package/profiles/minimal.yaml +11 -0
- package/profiles/open-source-no-codex.yaml +11 -0
- package/profiles/open-source.yaml +11 -0
- package/templates/prepare-commit-msg.husky.sh +20 -1
package/dist/cli/doctor.js
CHANGED
|
@@ -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 `
|
|
464
|
-
* synchronous
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
* `.
|
|
468
|
-
*
|
|
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
|
|
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
|
|
1014
|
+
* Status posture:
|
|
976
1015
|
*
|
|
977
|
-
*
|
|
978
|
-
* `defaultDesiredHooks()`
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
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
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
1005
|
-
detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init
|
|
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:
|
|
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:
|
|
1062
|
+
status: REFUSE,
|
|
1025
1063
|
detail: 'no PreToolUse group with matcher "Agent|Skill" found in .claude/settings.json — ' +
|
|
1026
|
-
'run `rea upgrade` to install
|
|
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:
|
|
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
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
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
|
-
*
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
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
|
-
//
|
|
1072
|
-
//
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
|
1079
|
-
status: '
|
|
1080
|
-
detail:
|
|
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
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
//
|
|
1108
|
-
|
|
1109
|
-
const res = spawnSync(
|
|
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
|
|
1332
|
+
label,
|
|
1119
1333
|
status: 'fail',
|
|
1120
|
-
detail: `
|
|
1334
|
+
detail: `shell hook spawn failed: ${res.error.message}`,
|
|
1121
1335
|
};
|
|
1122
1336
|
}
|
|
1123
1337
|
if (res.status !== 0) {
|
|
1124
1338
|
return {
|
|
1125
|
-
label
|
|
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: `
|
|
1341
|
+
detail: `shell hook exited ${res.status ?? 'null'}; stderr: ${(res.stderr ?? '').slice(0, 240)}`,
|
|
1140
1342
|
};
|
|
1141
1343
|
}
|
|
1142
|
-
|
|
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
|
-
|
|
1364
|
+
const deadline = Date.now() + 10_000;
|
|
1365
|
+
while (Date.now() < deadline) {
|
|
1366
|
+
let raw = null;
|
|
1145
1367
|
try {
|
|
1146
|
-
|
|
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
|
-
//
|
|
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
|
|
1384
|
+
label,
|
|
1158
1385
|
status: 'fail',
|
|
1159
|
-
detail: `
|
|
1160
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1428
|
+
label,
|
|
1200
1429
|
status: 'pass',
|
|
1201
|
-
detail: `probe via real
|
|
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
|
package/dist/cli/hook.d.ts
CHANGED
|
@@ -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
|
|
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({
|