@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
@@ -367,6 +367,59 @@ export interface AttributionCoAuthorPolicy {
367
367
  email?: string;
368
368
  skip_merge?: boolean;
369
369
  }
370
+ /**
371
+ * Delegation-advisory nudge policy (0.31.0+).
372
+ *
373
+ * 0.29.0 shipped the delegation-telemetry *observability* layer (the
374
+ * `Agent|Skill` PreToolUse capture hook + `rea audit specialists`
375
+ * reader). 0.31.0 closes the loop with the *nudge*: the
376
+ * `delegation-advisory.sh` PostToolUse hook (matcher
377
+ * `Bash|Edit|Write|MultiEdit|NotebookEdit`) counts the current
378
+ * session's write-class tool calls and, when that count crosses
379
+ * `threshold` WITHOUT a `rea.delegation_signal` record landing in the
380
+ * session, prints a one-time stderr advisory: "this session has done a
381
+ * lot of work without delegating to a specialist".
382
+ *
383
+ * The advisory is purely informational — the hook always exits 0
384
+ * (except under HALT, which exits 2 to keep the kill-switch contract
385
+ * uniform). It NEVER blocks a tool call.
386
+ *
387
+ * Profile defaults: `enabled: true` for the `bst-internal*` profiles
388
+ * (BST's own delegation discipline is load-bearing); `enabled: false`
389
+ * for every external profile (`open-source*`, `minimal`,
390
+ * `client-engagement`, `lit-wc`) — OSS consumers opt in per-repo via
391
+ * `.rea/policy.yaml`, since "you should delegate more" is an opinion
392
+ * not every team shares.
393
+ */
394
+ export interface DelegationAdvisoryPolicy {
395
+ /**
396
+ * Master switch. When `false` (or the whole block is omitted) the
397
+ * `delegation-advisory.sh` hook is a silent no-op. Default `false` at
398
+ * the schema layer; `bst-internal*` profiles pin `true`.
399
+ */
400
+ enabled?: boolean;
401
+ /**
402
+ * Write-class tool-call count at which the advisory fires. The
403
+ * `delegation-advisory.sh` hook maintains a per-session counter file
404
+ * and emits the nudge the first time the counter reaches this value
405
+ * with zero delegation signals recorded for the session. Default
406
+ * `25` — a session that has run 25 Bash/Edit/Write/MultiEdit/
407
+ * NotebookEdit calls without once dispatching a specialist is doing
408
+ * meaningful work solo. Must be a positive integer.
409
+ */
410
+ threshold?: number;
411
+ /**
412
+ * Subagent / skill names that do NOT count as "real delegation" for
413
+ * the purpose of suppressing the advisory. A session that only ever
414
+ * delegated to `general-purpose` / `Explore` / `Plan` (the built-in
415
+ * Claude Code helpers) has not actually routed work to a curated
416
+ * specialist, so those signals don't reset the nudge. Default:
417
+ * `["general-purpose", "Explore", "Plan", "output-style-setup",
418
+ * "statusline-setup"]`. A delegation signal whose `subagent_type` is
419
+ * in this list is ignored when deciding whether to fire.
420
+ */
421
+ exempt_subagents?: string[];
422
+ }
370
423
  /**
371
424
  * G9 — injection tier escalation knobs. The classifier bucketed matches into
372
425
  * `clean` / `suspicious` / `likely_injection`; this block governs what happens
@@ -472,4 +525,12 @@ export interface Policy {
472
525
  * trailer are no-ops. See `AttributionPolicy` for the full contract.
473
526
  */
474
527
  attribution?: AttributionPolicy;
528
+ /**
529
+ * Delegation-advisory nudge (0.31.0+). When `enabled: true`, the
530
+ * `delegation-advisory.sh` PostToolUse hook emits a one-time stderr
531
+ * advisory when a session crosses `threshold` write-class tool calls
532
+ * without dispatching a curated specialist. Advisory only — never
533
+ * blocks. See `DelegationAdvisoryPolicy` for the full contract.
534
+ */
535
+ delegation_advisory?: DelegationAdvisoryPolicy;
475
536
  }
@@ -242,7 +242,7 @@ _rea_load_protected_patterns() {
242
242
  }
243
243
 
244
244
  # Test whether a project-relative path is in the documented husky
245
- # extension surface (`.husky/commit-msg.d/*`, `.husky/pre-push.d/*`).
245
+ # extension surface (`.husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d/*`).
246
246
  # Returns 0 on match, 1 on no match. Case-insensitive.
247
247
  #
248
248
  # 0.16.4 helix-018 Option B: settings-protection.sh §5b has carved
@@ -253,17 +253,24 @@ _rea_load_protected_patterns() {
253
253
  # redirect was refused by the bash-gate even though the equivalent
254
254
  # Write-tool call would succeed. This helper bakes the carve-out
255
255
  # into the shared lib so every caller inherits it uniformly.
256
+ #
257
+ # 0.32.0 codex round 2 P1: `.husky/prepare-commit-msg.d/*` joins the
258
+ # carve-out to match settings-protection.sh §5b — the Write-tier
259
+ # allow-list shipped earlier in 0.32.0 was incomplete without the
260
+ # Bash-tier parity. Without this update, the migration path in
261
+ # MIGRATING.md (`cat > .husky/prepare-commit-msg.d/...`) is refused
262
+ # by `protected-paths-bash-gate.sh` even though Write/Edit succeeds.
256
263
  rea_path_is_extension_surface() {
257
264
  local p_lc
258
265
  p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
259
266
  case "$p_lc" in
260
- .husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*)
267
+ .husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*|.husky/prepare-commit-msg.d/*)
261
268
  # Refuse the bare directory itself — only fragments INSIDE
262
269
  # the surface count. `.husky/pre-push.d/` (trailing slash, no
263
270
  # fragment) and `.husky/pre-push.d` (the dir node) both fall
264
271
  # through to the protection check via the parent prefix.
265
272
  case "$p_lc" in
266
- .husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/) return 1 ;;
273
+ .husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/|.husky/prepare-commit-msg.d/) return 1 ;;
267
274
  esac
268
275
  return 0
269
276
  ;;
@@ -1,162 +1,170 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: attribution-advisory.sh
3
- # Fires BEFORE every Bash tool call.
3
+ # 0.32.0+ Node-binary shim for `rea hook attribution-advisory`.
4
4
  #
5
- # OPT-IN: Only enforces when .rea/policy.yaml contains:
6
- # block_ai_attribution: true
5
+ # Pre-0.32.0 the gate's full body lived here as bash (162 LOC,
6
+ # including the AI-attribution pattern catalog and segment-relevance
7
+ # gating). The migration to the parser-backed Node binary moves all
8
+ # of that into `src/hooks/attribution-advisory/index.ts`. This shim
9
+ # is the Claude Code dispatcher's view of the hook — it forwards
10
+ # stdin to the CLI and exits with whatever the CLI returns.
7
11
  #
8
- # When disabled (default), this hook does nothing.
9
- # When enabled, BLOCKS (exit 2) gh pr create/edit and git commit commands
10
- # that contain structural AI attribution markers.
12
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
13
+ # disabled-policy / non-relevant / clean-command, exit 2 on HALT /
14
+ # attribution detected / malformed payload (fail-closed).
11
15
  #
12
- # Exit codes:
13
- # 0 = allow (disabled, no attribution found, or not a relevant command)
14
- # 2 = block (attribution detected, or HALT is active)
16
+ # # CLI-resolution trust boundary
17
+ #
18
+ # Codex round 1 P1 (2026-05-15): realpath sandbox check + version
19
+ # probe. Mirrors delegation-advisory.sh §3. Defends against
20
+ # symlink-out + tarball-replacement attacks on the resolved CLI AND
21
+ # stale-node_modules version skew that would otherwise turn every
22
+ # Bash dispatch into a hard failure.
15
23
 
16
24
  set -uo pipefail
17
25
 
18
- # ── 1. Read ALL stdin immediately before doing anything else ──────────────────
19
- INPUT=$(cat)
20
-
21
- # ── 2. Dependency check ───────────────────────────────────────────────────────
22
- if ! command -v jq >/dev/null 2>&1; then
23
- printf 'REA ERROR: jq is required but not installed.\n' >&2
24
- printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
25
- exit 2
26
- fi
27
-
28
- # ── 3. HALT check ─────────────────────────────────────────────────────────────
29
- # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
26
+ # 1. HALT check.
30
27
  # shellcheck source=_lib/halt-check.sh
31
28
  source "$(dirname "$0")/_lib/halt-check.sh"
32
29
  check_halt
33
30
  REA_ROOT=$(rea_root)
34
31
 
35
- # ── 4. Check if attribution blocking is enabled ──────────────────────────────
36
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
37
- if [ ! -f "$POLICY_FILE" ]; then
38
- exit 0
39
- fi
40
- if ! grep -qE '^block_ai_attribution:[[:space:]]*true' "$POLICY_FILE" 2>/dev/null; then
41
- exit 0
42
- fi
43
-
44
- # ── 5. Parse tool_input.command from the hook payload ─────────────────────────
45
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
46
-
47
- if [[ -z "$CMD" ]]; then
48
- exit 0
49
- fi
32
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
50
33
 
51
- # 0.15.0: source the shared shell-segment splitter. Pre-fix, the
52
- # attribution patterns greped the FULL command `git commit -m "Note:
53
- # Co-Authored-By with AI was removed in 0.14"` matched and the commit
54
- # was blocked even though the message was COMMENTING on attribution
55
- # rather than including it. Per-segment anchoring scopes detection to
56
- # segments whose first token is `git commit` / `gh pr create|edit`.
57
- # shellcheck source=_lib/cmd-segments.sh
58
- source "$(dirname "$0")/_lib/cmd-segments.sh"
59
-
60
- # ── 6. Check if this is a relevant command ────────────────────────────────────
61
- # 0.18.0 helix-020 / discord-ops Round 10 #2 fix (G4.A): use
62
- # `any_segment_starts_with`, not `any_segment_matches`. The pre-fix
63
- # matcher used the unanchored form, so a segment like
64
- # gh pr edit --body "tracked: gh pr create earlier in the run"
65
- # triggered IS_RELEVANT=1 because the substring `gh pr create` was
66
- # anywhere in the segment. The downstream attribution check then
67
- # scanned the body for the markdown-link / Co-Authored-By patterns,
68
- # and ANY mention of those terms in the body's prose got blocked
69
- # even though the actual command was a `gh pr edit` whose intent had
70
- # nothing to do with structural attribution. The same anchoring fix
71
- # `dangerous-bash-interceptor.sh` got in 0.16.3 F5 finally lands here.
72
- IS_RELEVANT=0
73
-
74
- if any_segment_starts_with "$CMD" 'gh[[:space:]]+pr[[:space:]]+(create|edit)'; then
75
- IS_RELEVANT=1
76
- fi
77
-
78
- if any_segment_starts_with "$CMD" 'git[[:space:]]+commit'; then
79
- IS_RELEVANT=1
34
+ # 2. Relevance pre-gate (0.32.0 round-5 P1, round-6 fix). PreToolUse
35
+ # Bash matchers fire on EVERY shell command, but this hook only
36
+ # enforces against `git commit` / `gh pr create|edit`. Capture
37
+ # stdin + check relevance FIRST so unrelated commands (ls,
38
+ # pnpm test, …) exit 0 even when the CLI is missing/stale/
39
+ # sandboxed-out.
40
+ #
41
+ # Match the pattern ANYWHERE in the command string (after the
42
+ # opening quote, then `[^"]*` for any leading shell prefix —
43
+ # `sudo`, `time`, env assignments like `FOO=x git commit …`).
44
+ # Round-6 P1: prior round-5 pattern anchored at the start of the
45
+ # JSON value and missed all prefixed forms.
46
+ INPUT=$(cat)
47
+ # Substring scan (NOT JSON-aware). Round-7 P2: any JSON-aware regex
48
+ # anchored on `"command":"...` gets tripped by escaped quotes in
49
+ # quoted env prefixes (`FOO="two words" git commit …` → the payload
50
+ # carries `\"two words\"` and `[^"]*` stops at the escaped quote).
51
+ # Plain substring match has no such edge: it over-triggers only on
52
+ # the rare case where the pattern appears inside a quoted argument
53
+ # (`echo "gh pr create"`), and the Node body handles that correctly.
54
+ # This hook only fires on `tool_name=Bash`, so we don't risk matching
55
+ # unrelated payload shapes.
56
+ RELEVANT=0
57
+ if printf '%s' "$INPUT" | grep -qE '(git[[:space:]]+commit|gh[[:space:]]+pr[[:space:]]+(create|edit))'; then
58
+ RELEVANT=1
80
59
  fi
81
-
82
- if [[ $IS_RELEVANT -eq 0 ]]; then
60
+ if [ "$RELEVANT" -eq 0 ]; then
61
+ # Irrelevant Bash call — nothing the pre-0.32.0 body would have
62
+ # processed. Always exit 0 regardless of CLI state.
83
63
  exit 0
84
64
  fi
85
65
 
86
- # ── 7. Check for structural AI attribution markers ───────────────────────────
87
-
88
- FOUND=0
89
-
90
- # Co-Authored-By with noreply@ email
91
- # 0.18.0 helix-020 / discord-ops Round 10 #3 fix (G4.B): exclude
92
- # GitHub's legitimate `<user>@users.noreply.github.com` collaborator
93
- # footers from the noreply match. Pre-fix the regex `Co-Authored-By:.*noreply@`
94
- # matched both AI-tool noreply addresses (anthropic.com, openai.com,
95
- # github-copilot, etc.) AND GitHub's per-user noreply form, blocking
96
- # legitimate human collaborator credits. The new regex requires
97
- # `noreply@` to be followed by something that ISN'T `users.noreply.github.com`
98
- # — covered via a negative-lookahead simulation: match `noreply@` then
99
- # either end-of-line, whitespace, `>`, or a domain that does NOT begin
100
- # with `users.noreply.github.com`. Posix ERE has no lookarounds, so we
101
- # enumerate the allowed-prefix shapes explicitly. The "AI names" branch
102
- # below catches Co-Authored-By with named tools regardless of the email
103
- # domain, so dropping `users.noreply.github.com` from the noreply
104
- # pattern only relaxes the check for human collaborators — never for AI.
105
- if any_segment_matches "$CMD" 'Co-Authored-By:.*noreply@(anthropic\.com|openai\.com|github-copilot|github\.com|claude\.ai|chatgpt\.com|googlemail\.com|google\.com|cursor\.com|codeium\.com|tabnine\.com|amazon\.com|amazonaws\.com|amazon-q\.amazonaws\.com|cody\.dev|sourcegraph\.com|mistral\.ai|xai-org|x\.ai|inflection\.ai|perplexity\.ai|replit\.com|jetbrains\.com|bito\.ai|pieces\.app|phind\.com|you\.com)'; then
106
- FOUND=1
66
+ # 2b. Policy short-circuit (round-6 P2). The pre-0.32.0 bash body
67
+ # no-op'd when `block_ai_attribution` was absent or false. Without
68
+ # this check, an unbuilt/stale install would refuse `git commit`
69
+ # even on repos that DELIBERATELY disable the attribution gate.
70
+ # Read the policy via a simple grep — the canonical loader
71
+ # handles inline forms but we only need block form here, and a
72
+ # conservative "true-and-only-true counts" rule matches the
73
+ # intent (false / absent / inline-only all no enforcement).
74
+ POLICY_FILE="$REA_ROOT/.rea/policy.yaml"
75
+ if [ ! -f "$POLICY_FILE" ] || ! grep -qE '^block_ai_attribution:[[:space:]]*true([[:space:]]|$)' "$POLICY_FILE"; then
76
+ # Attribution blocking disabled — pre-0.32.0 bash body would have
77
+ # exited 0 here. Don't refuse on stale-install grounds.
78
+ exit 0
107
79
  fi
108
80
 
109
- # Co-Authored-By with known AI names
110
- if any_segment_matches "$CMD" 'Co-Authored-By:.*\b(Claude|Sonnet|Opus|Haiku|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|Amazon Q|CodeWhisperer|Devin|Windsurf|Cline|Aider|Anthropic|OpenAI|GitHub Copilot)\b'; then
111
- FOUND=1
81
+ # 3. Resolve the rea CLI.
82
+ REA_ARGV=()
83
+ RESOLVED_CLI_PATH=""
84
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
85
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
86
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
87
+ elif [ -f "$proj/dist/cli/index.js" ]; then
88
+ REA_ARGV=(node "$proj/dist/cli/index.js")
89
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
112
90
  fi
113
91
 
114
- # "Generated/Built/Powered with/by [AI Tool]" lines
115
- if any_segment_matches "$CMD" '(Generated|Created|Built|Powered|Authored|Written|Produced)[[:space:]]+(with|by)[[:space:]]+(Claude|Copilot|GPT|ChatGPT|Gemini|Cursor|Codeium|Tabnine|CodeWhisperer|Devin|Windsurf|Cline|Aider|AI|an? AI)\b'; then
116
- FOUND=1
92
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
93
+ # 0.32.0 round-4 P2: when `block_ai_attribution: true`, this hook is
94
+ # blocking-tier — the pre-0.32.0 bash body enforced the policy
95
+ # without a compiled CLI. Falling through to exit 0 would silently
96
+ # let AI-attribution patterns through every git commit / gh pr
97
+ # create-or-edit until the operator rebuilds. Fail closed and tell
98
+ # the operator how to restore protection.
99
+ printf 'rea: attribution-advisory cannot run — the rea CLI is not built.\n' >&2
100
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
101
+ printf 'This shim fails closed because the pre-0.32.0 bash body enforced attribution policy without a CLI.\n' >&2
102
+ exit 2
117
103
  fi
118
104
 
119
- # Markdown-linked attribution
120
- # 0.16.2 helix-017 P3 #4: anchor on `[Text](` (markdown link shape) so
121
- # legitimate bracketed mentions like `gh pr edit --body "support [Claude
122
- # Code] hook output"` don't false-positive. The actual attribution we
123
- # care about is structural — `Generated with [Claude Code](https://...)`.
124
- if any_segment_matches "$CMD" '\[Claude Code\]\(|\[GitHub Copilot\]\(|\[ChatGPT\]\(|\[Gemini\]\(|\[Cursor\]\('; then
125
- FOUND=1
105
+ # 3. Realpath sandbox check.
106
+ if ! command -v node >/dev/null 2>&1; then
107
+ printf 'rea: attribution-advisory cannot run `node` is not on PATH.\n' >&2
108
+ printf 'Install Node 22+ (engines.node) to restore enforcement.\n' >&2
109
+ exit 2
126
110
  fi
127
111
 
128
- # Emoji attribution
129
- if any_segment_matches "$CMD" '🤖.*[Gg]enerated'; then
130
- FOUND=1
112
+ sandbox_check=$(node -e '
113
+ const fs = require("fs");
114
+ const path = require("path");
115
+ const cli = process.argv[1];
116
+ const projDir = process.argv[2];
117
+ let real, realProj;
118
+ try { real = fs.realpathSync(cli); } catch (e) {
119
+ process.stdout.write("bad:realpath"); process.exit(1);
120
+ }
121
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
122
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
123
+ }
124
+ const sep = path.sep;
125
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
126
+ if (!(real === realProj || real.startsWith(projWithSep))) {
127
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
128
+ }
129
+ let cur = path.dirname(path.dirname(path.dirname(real)));
130
+ let found = false;
131
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
132
+ const pj = path.join(cur, "package.json");
133
+ if (fs.existsSync(pj)) {
134
+ try {
135
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
136
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
137
+ } catch (e) { /* keep walking */ }
138
+ }
139
+ cur = path.dirname(cur);
140
+ }
141
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
142
+ process.stdout.write("ok");
143
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
144
+
145
+ if [ "$sandbox_check" != "ok" ]; then
146
+ # 0.32.0 round-4 P2: fail closed (blocking-tier when policy enables —
147
+ # see top-of-file rationale). Sandbox failure means the CLI cannot
148
+ # be authenticated; refuse rather than silently bypass.
149
+ printf 'rea: attribution-advisory FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
150
+ exit 2
131
151
  fi
132
152
 
133
- if [[ $FOUND -eq 1 ]]; then
134
- {
135
- printf '\n'
136
- printf '═══════════════════════════════════════════════════════════════════\n'
137
- printf ' BLOCKED: AI attribution detected in command\n'
138
- printf '═══════════════════════════════════════════════════════════════════\n'
139
- printf '\n'
140
- printf ' Your command contains structural AI attribution markers.\n'
141
- printf '\n'
142
- printf ' What gets BLOCKED (structural attribution):\n'
143
- printf ' - Co-Authored-By with AI names or noreply@ emails\n'
144
- printf ' - "Generated with/by [AI Tool]" footer lines\n'
145
- printf ' - Markdown-linked tool names: [Claude Code](...)\n'
146
- printf ' - Emoji attribution: 🤖 Generated...\n'
147
- printf '\n'
148
- printf ' What is ALLOWED (legitimate references):\n'
149
- printf ' - "Fix Claude API integration"\n'
150
- printf ' - "Update OpenAI SDK version"\n'
151
- printf ' - "Add Copilot config"\n'
152
- printf '\n'
153
- printf ' Remove the attribution markers and rewrite the command.\n'
154
- printf ' To disable: set block_ai_attribution: false in .rea/policy.yaml\n'
155
- printf '═══════════════════════════════════════════════════════════════════\n'
156
- printf '\n'
157
- } >&2
153
+ # 4. Version-probe: confirm the resolved CLI implements
154
+ # `hook attribution-advisory`. Codex round 1 P1.
155
+ probe_out=$("${REA_ARGV[@]}" hook attribution-advisory --help 2>&1)
156
+ probe_status=$?
157
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'attribution-advisory'; then
158
+ # 0.32.0 round-4 P2: stale/older CLI without the new subcommand is
159
+ # NOT advisory-tier fall-through — the bash body it replaces
160
+ # enforced when policy enabled. Fail closed and tell the operator
161
+ # exactly how to fix.
162
+ printf 'rea: this shim requires the `rea hook attribution-advisory` subcommand (introduced in 0.32.0).\n' >&2
163
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
164
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
158
165
  exit 2
159
166
  fi
160
167
 
161
- # No attribution found allow
162
- exit 0
168
+ # 5. Forward stdin (already captured up-front for the relevance gate).
169
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook attribution-advisory
170
+ exit $?
@@ -0,0 +1,162 @@
1
+ #!/bin/bash
2
+ # PostToolUse hook: delegation-advisory.sh
3
+ # 0.31.0+ — delegation-telemetry completion (the *nudge*).
4
+ #
5
+ # Fires AFTER every write-class tool call. The settings.json matcher is
6
+ # `Bash|Edit|Write|MultiEdit|NotebookEdit`. Reads the Claude Code hook
7
+ # payload from stdin, pipes it to `rea hook delegation-advisory`, and
8
+ # exits 0.
9
+ #
10
+ # 0.29.0 shipped the delegation-telemetry *observability* layer
11
+ # (`delegation-capture.sh` + `rea audit specialists`). 0.31.0 closes the
12
+ # loop with the *nudge*: `rea hook delegation-advisory` maintains a
13
+ # per-session write-class counter and, the FIRST time that counter
14
+ # crosses `policy.delegation_advisory.threshold` while the session has
15
+ # recorded zero real delegation signals, prints a one-time stderr
16
+ # advisory ("this session has done a lot of work without delegating to
17
+ # a specialist").
18
+ #
19
+ # # Advisory, never gating
20
+ #
21
+ # This hook ALWAYS exits 0 (under normal operation). The advisory is a
22
+ # nudge — it never blocks a tool call. The ONLY non-zero exit is 2
23
+ # under HALT, to keep the kill-switch contract uniform with the rest of
24
+ # the hook tree.
25
+ #
26
+ # # Synchronous, NOT detached
27
+ #
28
+ # Unlike `delegation-capture.sh` (which backgrounds `rea hook
29
+ # delegation-signal` with `& disown` because the audit write must not
30
+ # block tool dispatch), this hook runs the CLI SYNCHRONOUSLY. The
31
+ # advisory text must reach the operator's stderr before the hook
32
+ # returns — backgrounding it would race the hook's own exit and the
33
+ # message could be lost or interleaved with the next tool call's
34
+ # output. The CLI is cheap on the hot path: below the threshold it
35
+ # only bumps an integer counter file and exits, no audit scan, no
36
+ # roster discovery.
37
+ #
38
+ # # CLI-resolution trust boundary
39
+ #
40
+ # Same 2-tier sandboxed resolution `delegation-capture.sh`,
41
+ # `protected-paths-bash-gate.sh`, and `blocked-paths-bash-gate.sh` use:
42
+ # 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side
43
+ # published artifact)
44
+ # 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
45
+ # dogfood install)
46
+ # PATH lookup is INTENTIONALLY OMITTED — agent-controlled $PATH would
47
+ # let a forged `rea` binary intercept this hook on every write-class
48
+ # tool call. A realpath sandbox check ensures the resolved CLI lives
49
+ # INSIDE realpath(CLAUDE_PROJECT_DIR) with an ancestor package.json
50
+ # declaring `@bookedsolid/rea`.
51
+ #
52
+ # Exit codes:
53
+ # 0 — always (under normal operation). Disabled-by-policy,
54
+ # below-threshold, already-fired, just-fired — all exit 0.
55
+ # 2 — HALT active.
56
+
57
+ set -uo pipefail
58
+
59
+ # 1. HALT check. Even though this hook is advisory, refusing to run
60
+ # while frozen matches the rest of the hook tree and keeps the
61
+ # kill-switch contract uniform.
62
+ # shellcheck source=_lib/halt-check.sh
63
+ source "$(dirname "$0")/_lib/halt-check.sh"
64
+ check_halt
65
+ REA_ROOT=$(rea_root)
66
+
67
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
68
+
69
+ # 2. Resolve the rea CLI through the fixed 2-tier sandboxed order.
70
+ # PATH lookup is omitted on purpose (see header). Other install
71
+ # shapes silently drop the advisory — matching the bash-gate
72
+ # posture; the nudge is a convenience, not a security claim.
73
+ REA_ARGV=()
74
+ RESOLVED_CLI_PATH=""
75
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
76
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
77
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
78
+ elif [ -f "$proj/dist/cli/index.js" ]; then
79
+ # rea repo dogfood: the project IS @bookedsolid/rea.
80
+ REA_ARGV=(node "$proj/dist/cli/index.js")
81
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
82
+ fi
83
+
84
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
85
+ # No rea CLI in scope — drop the advisory silently. This is the
86
+ # expected state during bootstrap (consumer ran `rea init` but
87
+ # hasn't installed the npm package yet) or in non-rea repos. A
88
+ # noisy stderr warning here would fire on every write-class tool
89
+ # call and drown legitimate output.
90
+ exit 0
91
+ fi
92
+
93
+ # 3. Realpath sandbox check — mirrors delegation-capture.sh §3 and
94
+ # protected-paths-bash-gate.sh §6. The resolved CLI MUST live inside
95
+ # realpath(CLAUDE_PROJECT_DIR) AND have an ancestor package.json
96
+ # declaring `@bookedsolid/rea` as its `name`. Catches symlink-out
97
+ # attacks where an attacker writes
98
+ # node_modules/@bookedsolid/rea → /tmp/forged-tree.
99
+ if ! command -v node >/dev/null 2>&1; then
100
+ # Node not on PATH — we can't verify the CLI shape. Fail safe by
101
+ # dropping the advisory (it is not a security claim; the rest of
102
+ # the Bash gate suite refuses on this path).
103
+ exit 0
104
+ fi
105
+
106
+ sandbox_check=$(node -e '
107
+ const fs = require("fs");
108
+ const path = require("path");
109
+ const cli = process.argv[1];
110
+ const projDir = process.argv[2];
111
+ let real, realProj;
112
+ try { real = fs.realpathSync(cli); } catch (e) {
113
+ process.stdout.write("bad:realpath");
114
+ process.exit(1);
115
+ }
116
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
117
+ process.stdout.write("bad:realpath-proj");
118
+ process.exit(1);
119
+ }
120
+ const sep = path.sep;
121
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
122
+ if (!(real === realProj || real.startsWith(projWithSep))) {
123
+ process.stdout.write("bad:cli-escapes-project");
124
+ process.exit(1);
125
+ }
126
+ // Walk up looking for package.json with the protected name.
127
+ let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
128
+ let found = false;
129
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
130
+ const pj = path.join(cur, "package.json");
131
+ if (fs.existsSync(pj)) {
132
+ try {
133
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
134
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
135
+ } catch (e) { /* keep walking */ }
136
+ }
137
+ cur = path.dirname(cur);
138
+ }
139
+ if (!found) {
140
+ process.stdout.write("bad:no-rea-pkg-json");
141
+ process.exit(1);
142
+ }
143
+ process.stdout.write("ok");
144
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
145
+
146
+ if [ "$sandbox_check" != "ok" ]; then
147
+ # CLI failed the sandbox check — silent drop. The forensic
148
+ # breadcrumb in stderr is intentional but trimmed so this doesn't
149
+ # become spammy on every tool call.
150
+ printf 'rea: delegation-advisory skipped (sandbox check: %s)\n' "$sandbox_check" >&2
151
+ exit 0
152
+ fi
153
+
154
+ # 4. Read stdin and pipe to the CLI SYNCHRONOUSLY. The advisory must
155
+ # print before this hook returns — see the "Synchronous" note in
156
+ # the header. We pass CLAUDE_PROJECT_DIR through explicitly so the
157
+ # CLI resolves the same REA_ROOT this shim did. The CLI's own exit
158
+ # code is the hook's exit code: 0 normally, 2 under HALT (the CLI
159
+ # re-checks HALT itself for defense-in-depth).
160
+ INPUT=$(cat)
161
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook delegation-advisory
162
+ exit $?