@bookedsolid/rea 0.31.0 → 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.
@@ -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 $?
@@ -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 $?