@bookedsolid/rea 0.31.0 → 0.33.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 (43) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/hook.js +60 -22
  4. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  5. package/dist/hooks/_lib/halt-check.js +106 -0
  6. package/dist/hooks/_lib/payload.d.ts +124 -0
  7. package/dist/hooks/_lib/payload.js +245 -0
  8. package/dist/hooks/_lib/segments.d.ts +125 -0
  9. package/dist/hooks/_lib/segments.js +766 -0
  10. package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
  11. package/dist/hooks/architecture-review-gate/index.js +250 -0
  12. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  13. package/dist/hooks/attribution-advisory/index.js +233 -0
  14. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  15. package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
  16. package/dist/hooks/changeset-security-gate/index.js +330 -0
  17. package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
  18. package/dist/hooks/dependency-audit-gate/index.js +294 -0
  19. package/dist/hooks/env-file-protection/index.d.ts +55 -0
  20. package/dist/hooks/env-file-protection/index.js +159 -0
  21. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  22. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  23. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  24. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  25. package/hooks/_lib/protected-paths.sh +10 -3
  26. package/hooks/architecture-review-gate.sh +92 -77
  27. package/hooks/attribution-advisory.sh +139 -131
  28. package/hooks/changeset-security-gate.sh +114 -149
  29. package/hooks/dependency-audit-gate.sh +115 -156
  30. package/hooks/env-file-protection.sh +130 -97
  31. package/hooks/pr-issue-link-gate.sh +114 -45
  32. package/hooks/security-disclosure-gate.sh +148 -316
  33. package/hooks/settings-protection.sh +13 -9
  34. package/package.json +1 -1
  35. package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
  36. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  37. package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
  38. package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
  39. package/templates/env-file-protection.dogfood-staged.sh +157 -0
  40. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  41. package/templates/prepare-commit-msg.husky.sh +80 -6
  42. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  43. package/templates/settings-protection.dogfood.patch +58 -0
@@ -1,124 +1,157 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: env-file-protection.sh
3
- # Fires BEFORE every Bash tool call.
4
- # Blocks commands that read .env* / .envrc files via shell text utilities.
3
+ # 0.33.0+ Node-binary shim for `rea hook env-file-protection`.
5
4
  #
6
- # Rationale: .env files contain credentials. Reading them via Bash exposes
7
- # the values in command output, logs, and agent transcripts. Load credentials
8
- # in code only (process.env, os.environ, etc.) never via shell reads.
5
+ # Pre-0.33.0 the gate's full body lived here as bash (124 LOC, the
6
+ # segment splitter + `source`/`cp` anchor patterns + utility-vs-.env
7
+ # co-occurrence check). The migration to the parser-backed Node binary
8
+ # moves all of that into `src/hooks/env-file-protection/index.ts`. This
9
+ # shim is the Claude Code dispatcher's view of the hook — it forwards
10
+ # stdin to the CLI and exits with whatever the CLI returns.
9
11
  #
10
- # Trigger: command matches ALL of:
11
- # 1. Uses a text-reading utility (list below)
12
- # 2. References a .env* or .envrc filename
12
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
13
+ # pass-through / no-match, exit 2 on HALT / .env access detected /
14
+ # malformed payload (fail-closed).
13
15
  #
14
- # Exit codes:
15
- # 0 = allow
16
- # 2 = block (env file read detected)
16
+ # # CLI-resolution trust boundary
17
+ #
18
+ # Mirrors the 0.32.0 final shim shape (round-8 of the codex iteration
19
+ # on the three Phase 1 pilots). The resolved CLI MUST live INSIDE
20
+ # realpath(CLAUDE_PROJECT_DIR) AND have an ancestor `package.json`
21
+ # whose `name` is `@bookedsolid/rea`. Defends against symlink-out and
22
+ # tarball-replacement attacks on the resolved CLI.
23
+ #
24
+ # # Fail-closed posture
25
+ #
26
+ # env-file-protection is a BLOCKING-tier gate — the pre-0.33.0 bash
27
+ # body refused on .env access without a compiled CLI. The early-exit
28
+ # branches (CLI missing, node missing, sandbox failed, version skew)
29
+ # fail closed AFTER the relevance pre-gate passes. Irrelevant Bash
30
+ # calls exit 0 regardless of CLI state.
17
31
 
18
32
  set -uo pipefail
19
33
 
20
- # Source shared shell-segment splitter (0.15.0). Replaces full-command
21
- # grep that false-positives on commit messages mentioning `.env` (e.g.
22
- # `git commit -m "stop reading .env via cat"`).
23
- # shellcheck source=_lib/cmd-segments.sh
24
- source "$(dirname "$0")/_lib/cmd-segments.sh"
25
-
26
- INPUT=$(cat)
27
-
28
- # ── Dependency check ──────────────────────────────────────────────────────────
29
- if ! command -v jq >/dev/null 2>&1; then
30
- printf 'REA ERROR: jq is required but not installed.\n' >&2
31
- printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
32
- exit 2
33
- fi
34
-
35
- # ── HALT check ────────────────────────────────────────────────────────────────
36
- # 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
34
+ # 1. HALT check.
37
35
  # shellcheck source=_lib/halt-check.sh
38
36
  source "$(dirname "$0")/_lib/halt-check.sh"
39
37
  check_halt
40
38
  REA_ROOT=$(rea_root)
41
39
 
42
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
40
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
43
41
 
44
- if [[ -z "$CMD" ]]; then
42
+ # 2. Relevance pre-gate. Capture stdin + check for `.env` substring
43
+ # BEFORE any CLI/sandbox/probe work so unrelated Bash calls
44
+ # (`ls`, `pnpm test`, `git status`, …) exit 0 even when the CLI
45
+ # is missing/stale/sandboxed-out.
46
+ #
47
+ # 2026-05-15 codex round-2 P2 fix: the substring scan MUST run
48
+ # against `tool_input.command` ONLY, not the raw JSON payload —
49
+ # otherwise a benign `git commit -m "stop reading .env"` (where
50
+ # `.env` appears inside the commit message ARG, NOT as a file
51
+ # target) would hit the fail-closed branch on a fresh checkout
52
+ # where the CLI is unbuilt. Pre-fix the raw scan saw the substring
53
+ # inside the payload's "command" string-quoted body and refused.
54
+ #
55
+ # Strategy: extract `tool_input.command` via `jq` (already required
56
+ # by 5 other hooks; trust assumption is consistent). When `jq` is
57
+ # not installed, fall back to scanning the raw payload — the cost
58
+ # is the same over-trigger the bash original had, NOT a new
59
+ # regression. When `jq` IS installed (the common case), the
60
+ # pre-gate is field-scoped.
61
+ INPUT=$(cat)
62
+ RELEVANT=0
63
+ PROBE=""
64
+ if command -v jq >/dev/null 2>&1; then
65
+ PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || true)
66
+ if printf '%s' "$PROBE" | grep -qE '\.env'; then
67
+ RELEVANT=1
68
+ fi
69
+ else
70
+ # jq-less fallback — match the pre-0.33.0 over-trigger posture.
71
+ if printf '%s' "$INPUT" | grep -qE '\.env'; then
72
+ RELEVANT=1
73
+ fi
74
+ fi
75
+ if [ "$RELEVANT" -eq 0 ]; then
45
76
  exit 0
46
77
  fi
47
78
 
48
- truncate_cmd() {
49
- local STR="$1"
50
- local MAX=100
51
- if [[ ${#STR} -gt $MAX ]]; then
52
- printf '%s' "${STR:0:$MAX}..."
53
- else
54
- printf '%s' "$STR"
55
- fi
56
- }
79
+ # 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
80
+ REA_ARGV=()
81
+ RESOLVED_CLI_PATH=""
82
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
83
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
84
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
85
+ elif [ -f "$proj/dist/cli/index.js" ]; then
86
+ REA_ARGV=(node "$proj/dist/cli/index.js")
87
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
88
+ fi
57
89
 
58
- # Text-reading utilities (shell and common alternatives)
59
- # Defense-in-depth: this list catches the most common shell-based exfiltration
60
- # vectors. It is NOT exhaustive. Known gaps include:
61
- # - Docker volume mounts (docker run -v .env:/...) — separate concern
62
- # - Editor commands (vim, nano, code) not typically used by agents
63
- # - Redirects/process substitution (< .env) without a listed utility
64
- # - Network tools (curl file://, nc) low-risk in agent context
65
- # The goal is to block casual and accidental reads, not defeat a determined
66
- # adversary with shell access.
67
- PATTERN_UTILITY='(cat|head|tail|less|more|grep|sed|awk|bat|strings|printf|xargs|tee|jq|python3?[[:space:]]+-c|ruby[[:space:]]+-e)[[:space:]]'
68
- # Also catch: source/., cp (reads then writes elsewhere).
69
- #
70
- # 0.16.3 discord-ops Round 9 #4 fix: anchored on segment-start. Pre-fix
71
- # `any_segment_matches` matched anywhere in the segment, so
72
- # `git commit -m "fix: don't source .env files"` fired even though no
73
- # real source-of-.env was happening — the trigger words appeared inside
74
- # the quoted commit-message body. The patterns are command prefixes
75
- # (`source PATH`, `. PATH`, `cp X PATH`), so segment-start anchoring is
76
- # the correct shape.
77
- PATTERN_SOURCE='(source|\.)[[:space:]]+[^;|&]*\.env'
78
- PATTERN_CP_ENV='cp[[:space:]]+[^;|&]*\.env'
79
- # .env* files or .envrc (direnv)
80
- PATTERN_ENV_FILE='(\.env[a-zA-Z0-9._-]*|\.envrc)([[:space:]]|"|'"'"'|$)'
90
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
91
+ # Blocking-tier: fail closed. The pre-0.33.0 bash body enforced
92
+ # .env protection without a CLI. Refuse and tell the operator how
93
+ # to restore protection.
94
+ printf 'rea: env-file-protection cannot run the rea CLI is not built.\n' >&2
95
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
96
+ printf 'This shim fails closed because the pre-0.33.0 bash body enforced .env protection without a CLI.\n' >&2
97
+ exit 2
98
+ fi
81
99
 
82
- # 0.16.2 helix-017 P2 #2: utility AND env-filename must co-occur within
83
- # the SAME shell segment. Pre-fix this set two independent booleans
84
- # (any segment with utility OR any segment with .env) and AND'd them,
85
- # which false-positived across multi-segment constructions like
86
- # `echo "log: cat is broken" ; touch foo.env` (utility in segment 1,
87
- # .env name in segment 2). Detection is fundamentally a same-segment
88
- # co-occurrence property.
89
- MATCHES_BOTH_SAME_SEGMENT=0
90
- if any_segment_matches_both "$CMD" "$PATTERN_UTILITY" "$PATTERN_ENV_FILE"; then
91
- MATCHES_BOTH_SAME_SEGMENT=1
100
+ # 4. Realpath sandbox check.
101
+ if ! command -v node >/dev/null 2>&1; then
102
+ printf 'rea: env-file-protection cannot run `node` is not on PATH.\n' >&2
103
+ printf 'Install Node 22+ (engines.node) to restore .env protection.\n' >&2
104
+ exit 2
92
105
  fi
93
106
 
94
- # Direct source/cp of .env files — always block (segment-start anchored
95
- # per discord-ops Round 9 #4).
96
- if any_segment_starts_with "$CMD" "$PATTERN_SOURCE" || \
97
- any_segment_starts_with "$CMD" "$PATTERN_CP_ENV"; then
98
- TRUNCATED_CMD=$(truncate_cmd "$CMD")
99
- {
100
- printf 'ENV FILE PROTECTION: Direct sourcing or copying of .env files is blocked.\n'
101
- printf '\n'
102
- printf ' Command: %s\n' "$TRUNCATED_CMD"
103
- printf '\n'
104
- printf ' Rule: Load credentials in code only — never via shell source or cp.\n'
105
- printf ' Use: process.env.VAR_NAME, os.environ["VAR_NAME"], etc.\n'
106
- } >&2
107
+ sandbox_check=$(node -e '
108
+ const fs = require("fs");
109
+ const path = require("path");
110
+ const cli = process.argv[1];
111
+ const projDir = process.argv[2];
112
+ let real, realProj;
113
+ try { real = fs.realpathSync(cli); } catch (e) {
114
+ process.stdout.write("bad:realpath"); process.exit(1);
115
+ }
116
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
117
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
118
+ }
119
+ const sep = path.sep;
120
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
121
+ if (!(real === realProj || real.startsWith(projWithSep))) {
122
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
123
+ }
124
+ let cur = path.dirname(path.dirname(path.dirname(real)));
125
+ let found = false;
126
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
127
+ const pj = path.join(cur, "package.json");
128
+ if (fs.existsSync(pj)) {
129
+ try {
130
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
131
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
132
+ } catch (e) { /* keep walking */ }
133
+ }
134
+ cur = path.dirname(cur);
135
+ }
136
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
137
+ process.stdout.write("ok");
138
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
139
+
140
+ if [ "$sandbox_check" != "ok" ]; then
141
+ printf 'rea: env-file-protection FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
107
142
  exit 2
108
143
  fi
109
144
 
110
- if [[ $MATCHES_BOTH_SAME_SEGMENT -eq 1 ]]; then
111
- TRUNCATED_CMD=$(truncate_cmd "$CMD")
112
- {
113
- printf 'ENV FILE PROTECTION: Reading .env files via Bash is blocked.\n'
114
- printf '\n'
115
- printf ' Command: %s\n' "$TRUNCATED_CMD"
116
- printf '\n'
117
- printf ' Rule: Load credentials in code only, never via shell.\n'
118
- printf ' Use: process.env.VAR_NAME, os.environ["VAR_NAME"], etc.\n'
119
- printf ' .env files must not be read via shell utilities in agent sessions.\n'
120
- } >&2
145
+ # 5. Version-probe.
146
+ probe_out=$("${REA_ARGV[@]}" hook env-file-protection --help 2>&1)
147
+ probe_status=$?
148
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'env-file-protection'; then
149
+ printf 'rea: this shim requires the `rea hook env-file-protection` subcommand (introduced in 0.33.0).\n' >&2
150
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
151
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
121
152
  exit 2
122
153
  fi
123
154
 
124
- exit 0
155
+ # 6. Forward stdin (already captured up-front for the relevance gate).
156
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook env-file-protection
157
+ exit $?
@@ -1,65 +1,134 @@
1
- #!/usr/bin/env bash
2
- # pr-issue-link-gate.sh — PreToolUse: Bash
1
+ #!/bin/bash
2
+ # PreToolUse hook: pr-issue-link-gate.sh
3
+ # 0.32.0+ — Node-binary shim for `rea hook pr-issue-link-gate`.
3
4
  #
4
- # Ensures every `gh pr create` command references at least one GitHub issue
5
- # via closes/fixes/resolves #N syntax in the PR body. When the magic keyword
6
- # is present, GitHub automatically closes the linked issue when the PR merges
7
- # to the default branch and creates a cross-reference in the issue timeline.
5
+ # Pre-0.32.0 the gate's full body lived here as bash; the migration to
6
+ # the parser-backed Node binary moves the matching + advisory logic
7
+ # into `src/hooks/pr-issue-link-gate/index.ts`. This shim is the
8
+ # Claude Code dispatcher's view of the hook it forwards stdin to the
9
+ # CLI and exits with whatever the CLI returns.
8
10
  #
9
- # This gate is ADVISORY (exit 0) — it warns but does not block. Some PRs
10
- # legitimately have no linked issue (chores, hotfixes, release PRs). The
11
- # advisory gives the agent an opportunity to add the link before proceeding.
11
+ # Behavioral contract is preserved byte-for-byte: ALWAYS exit 0 except
12
+ # under HALT (exit 2) or a malformed payload (exit 2, fail-closed).
12
13
  #
13
- # Only active for Bash tool calls containing `gh pr create`.
14
- # JSONL-only projects (no GitHub) are unaffected — gh is unavailable there.
14
+ # # CLI-resolution trust boundary
15
15
  #
16
- # Triggered by: PreToolUse Bash tool
17
-
18
- set -euo pipefail
16
+ # Codex round 1 P1 (2026-05-15): mirrors the realpath sandbox check
17
+ # from `delegation-advisory.sh` §3 and `protected-paths-bash-gate.sh`
18
+ # §6. The resolved CLI MUST live INSIDE realpath(CLAUDE_PROJECT_DIR)
19
+ # AND have an ancestor `package.json` whose `name` is
20
+ # `@bookedsolid/rea`. Pre-fix the shim executed
21
+ # `node_modules/@bookedsolid/rea/dist/cli/index.js` directly without
22
+ # realpathing the target, which would let an attacker who controlled
23
+ # `node_modules/@bookedsolid/rea` (symlink-out, postinstall script,
24
+ # tarball-replacement) ship forged review code that intercepts every
25
+ # Bash dispatch.
26
+ #
27
+ # Sandboxed resolution order (PATH is INTENTIONALLY OMITTED):
28
+ # 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side)
29
+ # 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (dogfood)
30
+ #
31
+ # When NO rea CLI is reachable through the sandboxed order, this hook
32
+ # falls through to allow (exit 0) — the advisory is a nudge, not a
33
+ # security claim. The bash-tier path gates fail-closed because they
34
+ # protect write surfaces; this gate only emits prose.
35
+ #
36
+ # # Version skew
37
+ #
38
+ # Codex round 1 P1 (2026-05-15): a fresh `rea init` against a stale
39
+ # `node_modules/@bookedsolid/rea` would deliver this 0.32.0 shim while
40
+ # the installed CLI lacks the `hook pr-issue-link-gate` subcommand —
41
+ # every Bash dispatch would then fail with `unknown command` (exit 1).
42
+ # Probe the subcommand's `--help` output before propagating the exit
43
+ # code; on probe failure, advise the operator to `pnpm install` and
44
+ # fall through silently so the workspace stays usable.
19
45
 
20
- # shellcheck source=_lib/common.sh
21
- source "$(dirname "$0")/_lib/common.sh"
46
+ set -uo pipefail
22
47
 
48
+ # 1. HALT check. Even though the CLI re-checks for defense-in-depth,
49
+ # short-circuit here so we never spawn `node` while frozen.
50
+ # shellcheck source=_lib/halt-check.sh
51
+ source "$(dirname "$0")/_lib/halt-check.sh"
23
52
  check_halt
53
+ REA_ROOT=$(rea_root)
24
54
 
25
- INPUT="$(cat)"
26
- TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
55
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
27
56
 
28
- if [[ "$TOOL_NAME" != "Bash" ]]; then
29
- exit 0
57
+ # 2. Resolve the rea CLI through the fixed 2-tier sandboxed order.
58
+ REA_ARGV=()
59
+ RESOLVED_CLI_PATH=""
60
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
61
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
62
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
63
+ elif [ -f "$proj/dist/cli/index.js" ]; then
64
+ REA_ARGV=(node "$proj/dist/cli/index.js")
65
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
30
66
  fi
31
67
 
32
- COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
68
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
69
+ exit 0
70
+ fi
33
71
 
34
- # Only intercept gh pr create
35
- if ! echo "$COMMAND" | grep -qE 'gh\s+pr\s+create'; then
72
+ # 3. Realpath sandbox check — mirrors delegation-advisory.sh §3.
73
+ if ! command -v node >/dev/null 2>&1; then
36
74
  exit 0
37
75
  fi
38
76
 
39
- require_jq
77
+ sandbox_check=$(node -e '
78
+ const fs = require("fs");
79
+ const path = require("path");
80
+ const cli = process.argv[1];
81
+ const projDir = process.argv[2];
82
+ let real, realProj;
83
+ try { real = fs.realpathSync(cli); } catch (e) {
84
+ process.stdout.write("bad:realpath"); process.exit(1);
85
+ }
86
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
87
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
88
+ }
89
+ const sep = path.sep;
90
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
91
+ if (!(real === realProj || real.startsWith(projWithSep))) {
92
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
93
+ }
94
+ let cur = path.dirname(path.dirname(path.dirname(real)));
95
+ let found = false;
96
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
97
+ const pj = path.join(cur, "package.json");
98
+ if (fs.existsSync(pj)) {
99
+ try {
100
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
101
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
102
+ } catch (e) { /* keep walking */ }
103
+ }
104
+ cur = path.dirname(cur);
105
+ }
106
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
107
+ process.stdout.write("ok");
108
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
40
109
 
41
- # Check for closing keywords followed by an issue number
42
- # Accepted: closes #N, fixes #N, resolves #N (case-insensitive, any spacing)
43
- if echo "$COMMAND" | grep -qiE '(closes|fixes|resolves)\s+#[0-9]+'; then
110
+ if [ "$sandbox_check" != "ok" ]; then
111
+ printf 'rea: pr-issue-link-gate skipped (sandbox check: %s)\n' "$sandbox_check" >&2
44
112
  exit 0
45
113
  fi
46
114
 
47
- # Advisory warn but do not block.
48
- # Chore PRs, release PRs, and hotfixes may legitimately have no linked issue.
49
- printf 'PR ISSUE LINK ADVISORY: This PR does not reference a GitHub issue.\n' >&2
50
- printf '\n' >&2
51
- printf 'When a PR body includes a closing reference, GitHub automatically:\n' >&2
52
- printf ' - Closes the issue when the PR merges to the default branch\n' >&2
53
- printf ' - Creates a cross-reference in the issue timeline\n' >&2
54
- printf ' - Links the PR in the CHANGELOG context\n' >&2
55
- printf '\n' >&2
56
- printf 'Add to the --body:\n' >&2
57
- printf ' closes #N closes one issue\n' >&2
58
- printf ' fixes #N same effect\n' >&2
59
- printf ' resolves #N same effect\n' >&2
60
- printf ' closes #N, closes #M closes multiple issues\n' >&2
61
- printf '\n' >&2
62
- printf 'If this is a chore, release, or hotfix PR with no upstream issue, you may proceed.\n' >&2
115
+ # 4. Version-probe: confirm the resolved CLI implements the
116
+ # `hook pr-issue-link-gate` subcommand. A stale node_modules from
117
+ # a fresh `rea init` against an older installed version would
118
+ # otherwise turn every Bash dispatch into a hard failure.
119
+ probe_out=$("${REA_ARGV[@]}" hook pr-issue-link-gate --help 2>&1)
120
+ probe_status=$?
121
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'pr-issue-link-gate'; then
122
+ printf 'rea: this shim requires the `rea hook pr-issue-link-gate` subcommand (introduced in 0.32.0).\n' >&2
123
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
124
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI to the version this shim expects.\n' >&2
125
+ exit 0
126
+ fi
63
127
 
64
- # Exit 0 advisory only, does not block the PR creation
65
- exit 0
128
+ # 5. Forward stdin to the CLI synchronously. The advisory text must
129
+ # reach the operator's stderr before this hook returns; the CLI's
130
+ # own exit code is the hook's exit code (0 normally, 2 under HALT
131
+ # or malformed payload).
132
+ INPUT=$(cat)
133
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook pr-issue-link-gate
134
+ exit $?