@bookedsolid/rea 0.13.1 → 0.13.3

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/pre-push CHANGED
@@ -37,40 +37,47 @@ fi
37
37
  # splitting; `/Users/jane/My Projects/repo` produced four argv tokens
38
38
  # instead of two). The `set --` form below preserves spaces verbatim.
39
39
  #
40
- # The pre-push stdin carries one line per refspec; `exec` inherits stdin
41
- # unchanged. $@ on entry carries git's <remote-name> <remote-url>; we
42
- # preserve those by appending "$@" inside each `set --` arm.
40
+ # 0.13.2 fix: the dispatch + invocation runs inside a SUBSHELL so the
41
+ # `set --` rewrite does NOT bleed into the parent's $@. Before 0.13.2,
42
+ # the parent's $@ ended up as the rea-CLI argv (`node /path/dist/cli/index.js
43
+ # hook push-gate <remote-name> <remote-url>`) and the extension-fragment
44
+ # loop below passed that mangled argv to fragments instead of git's
45
+ # original `<remote-name> <remote-url>`. The subshell scopes the rewrite
46
+ # so fragments see git's argv unchanged.
47
+ #
48
+ # The pre-push stdin carries one line per refspec; the subshell inherits
49
+ # stdin unchanged. $@ on entry carries git's <remote-name> <remote-url>;
50
+ # the subshell sees those as its initial $@, appends them inside each
51
+ # `set --` arm, and the parent's $@ is preserved.
43
52
 
44
- if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
45
- set -- "${REA_ROOT}/node_modules/.bin/rea" hook push-gate "$@"
46
- elif [ -f "${REA_ROOT}/dist/cli/index.js" ] && [ -f "${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "${REA_ROOT}/package.json" 2>/dev/null; then
47
- # rea's own repo (dogfood) the package is not installed under
48
- # node_modules here because we ARE the package. The built CLI
49
- # entry point lives at dist/cli/index.js; node runs it directly.
50
- # Gate this branch on `package.json` declaring `@bookedsolid/rea` so a
51
- # consumer repo that happens to ship its own `dist/cli/index.js` does
52
- # not get this hook executing the consumer's unrelated build.
53
- set -- node "${REA_ROOT}/dist/cli/index.js" hook push-gate "$@"
54
- elif command -v rea >/dev/null 2>&1; then
55
- set -- rea hook push-gate "$@"
56
- elif command -v npx >/dev/null 2>&1; then
57
- # Last resort: npx will resolve the package from npm or the cache.
58
- # Pass `--no-install` so a rare cache-cold machine surfaces a clear
59
- # error instead of silently downloading at push time.
60
- set -- npx --no-install @bookedsolid/rea hook push-gate "$@"
53
+ if (
54
+ if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
55
+ set -- "${REA_ROOT}/node_modules/.bin/rea" hook push-gate "$@"
56
+ elif [ -f "${REA_ROOT}/dist/cli/index.js" ] && [ -f "${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "${REA_ROOT}/package.json" 2>/dev/null; then
57
+ # rea's own repo (dogfood) the package is not installed under
58
+ # node_modules here because we ARE the package. The built CLI
59
+ # entry point lives at dist/cli/index.js; node runs it directly.
60
+ # Gate this branch on `package.json` declaring `@bookedsolid/rea` so a
61
+ # consumer repo that happens to ship its own `dist/cli/index.js` does
62
+ # not get this hook executing the consumer's unrelated build.
63
+ set -- node "${REA_ROOT}/dist/cli/index.js" hook push-gate "$@"
64
+ elif command -v rea >/dev/null 2>&1; then
65
+ set -- rea hook push-gate "$@"
66
+ elif command -v npx >/dev/null 2>&1; then
67
+ # Last resort: npx will resolve the package from npm or the cache.
68
+ # Pass `--no-install` so a rare cache-cold machine surfaces a clear
69
+ # error instead of silently downloading at push time.
70
+ set -- npx --no-install @bookedsolid/rea hook push-gate "$@"
71
+ else
72
+ printf 'rea: cannot locate the rea CLI. Install locally (`pnpm add -D @bookedsolid/rea`) or globally (`npm i -g @bookedsolid/rea`).\n' >&2
73
+ exit 2
74
+ fi
75
+ exec "$@"
76
+ ); then
77
+ rea_status=0
61
78
  else
62
- printf 'rea: cannot locate the rea CLI. Install locally (`pnpm add -D @bookedsolid/rea`) or globally (`npm i -g @bookedsolid/rea`).\n' >&2
63
- exit 2
79
+ rea_status=$?
64
80
  fi
65
-
66
- # Run the rea push-gate FIRST. We capture its exit and explicitly propagate
67
- # instead of `exec`-ing — extension fragments must only run after rea's own
68
- # governance work succeeds. The fragments are user code; surfacing them
69
- # AFTER rea's body (HALT check, Codex review, audit write) preserves the
70
- # governance contract while letting consumers chain their own checks (e.g.
71
- # commitlint, branch-policy linters) without losing rea coverage.
72
- "$@"
73
- rea_status=$?
74
81
  if [ "$rea_status" -ne 0 ]; then
75
82
  exit "$rea_status"
76
83
  fi
package/MIGRATING.md ADDED
@@ -0,0 +1,306 @@
1
+ # Migrating to `@bookedsolid/rea` from a project with prior tooling
2
+
3
+ `rea` was originally written for greenfield projects. Real consumers
4
+ arrive with prior infrastructure already in place — commitlint,
5
+ lint-staged, gitleaks, act-CI, branch-policy linters, project-specific
6
+ gates wired into `.husky/`. This guide names the conflict patterns by
7
+ name and shows the supported migration path for each.
8
+
9
+ If you hit something this doc doesn't cover, file an issue at
10
+ https://github.com/bookedsolidtech/rea/issues with the offending hook
11
+ body and the prior tool name.
12
+
13
+ ## Prerequisite — husky must be installed and `core.hooksPath` configured
14
+
15
+ The `.husky/{commit-msg,pre-push}.d/` extension surface is sourced from
16
+ the rea-managed bodies under `.husky/<hookname>`. Those bodies only fire
17
+ when git is configured to use husky. Confirm one of the following:
18
+
19
+ - Husky 9 (recommended): `pnpm dlx husky init` (or `npx husky init`)
20
+ during onboarding. Husky 9 sets `core.hooksPath=.husky/_` automatically;
21
+ rea's bodies live at `.husky/<hookname>` and husky's auto-generated
22
+ stubs at `.husky/_/<hookname>` source them at hook-fire time. `rea
23
+ doctor` (0.13.1+) follows the husky 9 stub indirection correctly.
24
+ - Husky 4-8 (legacy): `core.hooksPath=.husky` set, husky's
25
+ `_/husky.sh` runner installed. Functional but unsupported by husky
26
+ upstream — migrate to husky 9.
27
+ - Vanilla git (no husky): rea installs the fallback at
28
+ `.git/hooks/pre-push`. **The fragment recipes below DO NOT run** in
29
+ this configuration — `.git/hooks/pre-push` is not the same body as
30
+ `.husky/pre-push`. Either install husky (recommended) or chain your
31
+ per-tool commands directly into the fallback (you'll lose the
32
+ upgrade-safe property — `rea upgrade` will refresh the fallback and
33
+ drop your chain).
34
+
35
+ `pnpm rea doctor` reports the active hook path. If it shows
36
+ `.git/hooks/pre-push` (rea-managed at .../.git/hooks/pre-push), you
37
+ are on the vanilla-git path — install husky first.
38
+
39
+ ## TL;DR
40
+
41
+ 1. Confirm husky is installed (see prereq above).
42
+ 2. Run `rea init` (fresh install) or `rea upgrade` (existing).
43
+ 3. **Do not lose your existing chain.** rea now refuses to silently
44
+ overwrite an executable `.husky/pre-push` or `.husky/commit-msg`
45
+ that is not rea-managed; you'll see a `[fail]` from `rea doctor`
46
+ pointing here.
47
+ 4. Move each chained command from your existing hook body to a
48
+ per-tool fragment under `.husky/pre-push.d/<NN>-<name>` or
49
+ `.husky/commit-msg.d/<NN>-<name>` (executable, lex-ordered).
50
+ 5. Re-run `rea init`. The fresh hook body delegates to
51
+ `rea hook push-gate` and then runs your fragments AFTER the
52
+ governance gate.
53
+ 6. `rea doctor` should now report all checks green.
54
+
55
+ ## What rea ships and what it doesn't
56
+
57
+ `rea init` / `rea upgrade` install:
58
+
59
+ - `.husky/pre-push` — package-managed; **do not edit**. Refreshed on every
60
+ `rea upgrade`.
61
+ - `.husky/commit-msg` — package-managed; **do not edit**. Same.
62
+ - `.git/hooks/pre-push` (fallback when `core.hooksPath` is unset).
63
+ - `.claude/hooks/*.sh` — protection + audit + advisory hooks.
64
+ - `.claude/agents/*.md`, `.claude/commands/*.md`.
65
+ - `.rea/policy.yaml`, `.rea/registry.yaml`.
66
+
67
+ `rea` does **not** install:
68
+
69
+ - `.husky/pre-commit` — completely yours. Out of scope for the rea
70
+ push-gate. If you have one, keep it.
71
+ - `.husky/post-commit`, `post-merge`, `post-checkout`, etc. — yours.
72
+ - Any tool's binary (`commitlint`, `gitleaks`, `husky`, etc.) — yours.
73
+
74
+ The only files rea touches are explicitly enumerated above. Everything
75
+ else is the consumer's surface.
76
+
77
+ ## Extension surface (added in 0.13.0)
78
+
79
+ `.husky/pre-push.d/*` and `.husky/commit-msg.d/*` are the
80
+ **upgrade-safe** place to layer your own gates. Files in those
81
+ directories must be executable; rea sources them in lex order AFTER
82
+ its own governance work succeeds. A non-zero exit from any fragment
83
+ fails the hook (matches husky's normal chaining).
84
+
85
+ - Fragment receives positional args from git (`<remote-name> <remote-url>`
86
+ for pre-push, `<commit-msg-file>` for commit-msg).
87
+ - Missing directory is a no-op (no fragments = no chained checks).
88
+ - Non-executable files are silently skipped (drop a `README` if you
89
+ want context next to the fragments — it won't run).
90
+ - Fragments run with the current shell's `set -eu`; an unset variable
91
+ or a non-zero exit anywhere in the fragment short-circuits.
92
+
93
+ `rea doctor` reports detected fragments at `[info]` level so you can
94
+ confirm the chain.
95
+
96
+ ## Conflict pattern: commitlint
97
+
98
+ You probably have something like this in `.husky/commit-msg`:
99
+
100
+ ```sh
101
+ #!/usr/bin/env sh
102
+ . "$(dirname -- "$0")/_/husky.sh" # husky 4-8
103
+ npx --no-install commitlint --edit "$1"
104
+ ```
105
+
106
+ Or, with husky 9, your own command interleaved with `husky 9`'s body.
107
+
108
+ **rea 0.11.0+ overwrites `.husky/commit-msg` on `rea upgrade --force`.**
109
+ Your commitlint invocation will be lost.
110
+
111
+ ### Migration
112
+
113
+ Move commitlint to a fragment:
114
+
115
+ ```bash
116
+ mkdir -p .husky/commit-msg.d
117
+ cat > .husky/commit-msg.d/01-commitlint <<'EOF'
118
+ #!/bin/sh
119
+ exec npx --no-install commitlint --edit "$1"
120
+ EOF
121
+ chmod +x .husky/commit-msg.d/01-commitlint
122
+ ```
123
+
124
+ Re-run `rea upgrade`. The package-managed `.husky/commit-msg` body now
125
+ runs first (HALT check, AI-attribution block when policy enables it),
126
+ then runs your fragment.
127
+
128
+ ## Conflict pattern: lint-staged on pre-push
129
+
130
+ You probably have:
131
+
132
+ ```sh
133
+ #!/usr/bin/env sh
134
+ . "$(dirname -- "$0")/_/husky.sh"
135
+ npx --no-install lint-staged
136
+ ```
137
+
138
+ ### Migration
139
+
140
+ ```bash
141
+ mkdir -p .husky/pre-push.d
142
+ cat > .husky/pre-push.d/02-lint-staged <<'EOF'
143
+ #!/bin/sh
144
+ exec npx --no-install lint-staged
145
+ EOF
146
+ chmod +x .husky/pre-push.d/02-lint-staged
147
+ ```
148
+
149
+ ## Conflict pattern: gitleaks (pre-commit)
150
+
151
+ `rea` does NOT install a pre-commit hook. Your existing
152
+ `.husky/pre-commit` keeps working unchanged. Just confirm:
153
+
154
+ - Shebang is `#!/usr/bin/env bash` (not `#!/bin/sh`) if the body uses
155
+ `set -o pipefail`. On Linux where `/bin/sh = dash`, `pipefail`
156
+ aborts immediately.
157
+ - gitleaks invocation includes `--redact` so detected secrets don't
158
+ hit terminal scrollback.
159
+ - gitleaks binary is vendored or installed via postinstall (e.g.
160
+ `gitleaks-secret-scanner` npm wrapper) so fresh clones work without
161
+ manual install.
162
+
163
+ If you want gitleaks to run on push instead of commit, add a fragment:
164
+
165
+ ```bash
166
+ cat > .husky/pre-push.d/03-gitleaks <<'EOF'
167
+ #!/bin/sh
168
+ exec gitleaks detect --redact --no-banner
169
+ EOF
170
+ chmod +x .husky/pre-push.d/03-gitleaks
171
+ ```
172
+
173
+ ## Conflict pattern: act-CI matrix
174
+
175
+ If you have a project-specific CI gate like `./scripts/act-ci.sh`
176
+ chained into `.husky/pre-push` (e.g. BST), it gets clobbered by
177
+ `rea upgrade --force`.
178
+
179
+ ### Migration
180
+
181
+ ```bash
182
+ cat > .husky/pre-push.d/00-act-ci <<'EOF'
183
+ #!/bin/sh
184
+ exec ./scripts/act-ci.sh
185
+ EOF
186
+ chmod +x .husky/pre-push.d/00-act-ci
187
+ ```
188
+
189
+ The `00-` prefix puts act-CI first in lex order so it runs before any
190
+ later fragments. Adjust ordering as needed.
191
+
192
+ ## Conflict pattern: branch-policy linter
193
+
194
+ A common pattern that reads `$1` (remote name) and `$2` (remote URL)
195
+ to allow/deny pushes to specific remotes:
196
+
197
+ ```sh
198
+ #!/bin/sh
199
+ remote="$1"
200
+ url="$2"
201
+ if [ "$remote" = "origin" ] && echo "$url" | grep -q "production"; then
202
+ echo "Direct push to production blocked. PR via main." >&2
203
+ exit 1
204
+ fi
205
+ ```
206
+
207
+ This requires the standard pre-push argv. **rea 0.13.2+ preserves
208
+ git's argv unchanged** for fragments — earlier versions (0.13.0 /
209
+ 0.13.1) had a known bug where `set --` mutation in the rea dispatch
210
+ clobbered `$@`. Upgrade to `^0.13.2` if branch-policy linters are part
211
+ of your chain.
212
+
213
+ ### Migration
214
+
215
+ Drop the body into a fragment as-is:
216
+
217
+ ```bash
218
+ cat > .husky/pre-push.d/05-branch-policy <<'EOF'
219
+ #!/bin/sh
220
+ remote="$1"
221
+ url="$2"
222
+ if [ "$remote" = "origin" ] && echo "$url" | grep -q "production"; then
223
+ echo "Direct push to production blocked. PR via main." >&2
224
+ exit 1
225
+ fi
226
+ EOF
227
+ chmod +x .husky/pre-push.d/05-branch-policy
228
+ ```
229
+
230
+ ## Conflict pattern: pre-existing rea-CLI invocation
231
+
232
+ Some consumers had `exec rea hook push-gate "$@"` chained inline in a
233
+ foreign hook body. `rea doctor` recognizes this pattern and reports
234
+ the hook as `external (delegates to rea hook push-gate)` — `pass`,
235
+ not `fail`. No migration required, but you cannot benefit from the
236
+ extension-fragment chain unless you let rea own the hook body.
237
+
238
+ If you want both — rea ownership AND your other commands — migrate the
239
+ other commands to fragments.
240
+
241
+ ## Conflict pattern: husky 9 layout (`core.hooksPath=.husky/_`)
242
+
243
+ This is the default husky 9 install. rea 0.13.1+ supports it
244
+ correctly: doctor follows the husky 9 stub indirection from
245
+ `.husky/_/<hookname>` through `.husky/_/h` to the canonical
246
+ `.husky/<hookname>`. No migration required.
247
+
248
+ If you're on rea 0.13.0 and seeing `[fail] pre-push hook` despite a
249
+ correctly-installed `.husky/pre-push`, upgrade to `^0.13.1`.
250
+
251
+ ## What `rea doctor` will tell you
252
+
253
+ After migration, run `pnpm rea doctor`. The relevant lines:
254
+
255
+ - `[ok] pre-push hook installed` — rea-managed body active, fragments
256
+ (if any) detected
257
+ - `[fail] pre-push hook installed` with **"Detected prior tooling: X,
258
+ Y, Z"** — your existing hook still chains tooling that should be in
259
+ fragments. Move each named tool to a `.d/` fragment, then re-run
260
+ `rea init`.
261
+ - `[info] extension-hook fragments detected: N pre-push.d, M
262
+ commit-msg.d` — your fragment chain is active
263
+
264
+ ## Policy knobs worth setting
265
+
266
+ For consumers with a long-running migration branch (>30 commits since
267
+ last push), the push-gate auto-narrows the codex review window unless
268
+ you opt out. Pin explicit values to avoid surprises:
269
+
270
+ ```yaml
271
+ # .rea/policy.yaml
272
+ review:
273
+ codex_required: true
274
+ timeout_ms: 1800000 # 30 min — explicit pin
275
+ auto_narrow_threshold: 30 # 0 to disable auto-narrow
276
+ last_n_commits: 10 # explicit scope window
277
+ ```
278
+
279
+ ## Bypass when you genuinely need to
280
+
281
+ ```bash
282
+ # Audited skip: codex flips on a known-ambivalent file
283
+ REA_SKIP_CODEX_REVIEW="cemPath-ambivalence" git push
284
+
285
+ # Whole-gate skip: codex CLI itself is broken
286
+ REA_SKIP_PUSH_GATE="codex-cli-crash-pinging-team" git push
287
+
288
+ # Concerns-only override (P2 findings) without skipping the gate
289
+ REA_ALLOW_CONCERNS=1 git push
290
+ ```
291
+
292
+ Every bypass is audit-logged with the reason in `.rea/audit.jsonl`.
293
+ Reasons should be specific — "skip" is not a reason; the file or
294
+ verdict that triggered it is.
295
+
296
+ ## When to file an issue vs handle in-tree
297
+
298
+ - **rea hook ate my chain on `rea upgrade`** → file an issue, that's
299
+ rea's fault. Workaround: migrate to `.d/` fragments.
300
+ - **rea doctor false-positives on my legitimate setup** → file an
301
+ issue.
302
+ - **codex flips verdicts on the same code** → upstream of rea (codex
303
+ CLI itself). Use `REA_SKIP_CODEX_REVIEW` with a specific reason and
304
+ document the ambivalence.
305
+ - **My pre-commit hook breaks on push** → not rea (rea ships no
306
+ pre-commit). Fix in your repo.
@@ -374,14 +374,30 @@ function checkPrePushHook(state) {
374
374
  if (state.activeForeign) {
375
375
  // Executable file exists at the active path but neither carries a rea
376
376
  // marker nor invokes `rea hook push-gate` — the push-gate is silently
377
- // bypassed. Always a hard fail.
377
+ // bypassed. Always a hard fail. When the foreign hook references a
378
+ // recognizable prior tool (commitlint, lint-staged, gitleaks, act-CI,
379
+ // …), surface the .d/ migration path explicitly so consumers know
380
+ // exactly how to keep their existing chain without losing rea coverage
381
+ // or having `rea upgrade` clobber them again.
382
+ const hints = state.activePath !== null
383
+ ? detectPriorToolHints(state.activePath)
384
+ : [];
385
+ let detail = `active pre-push at ${state.activePath} is present and executable but does NOT ` +
386
+ 'invoke `rea hook push-gate` — the 0.11.0 push-gate is silently bypassed. ' +
387
+ 'Either add `exec rea hook push-gate "$@"` to the existing hook, or ' +
388
+ 'remove it and re-run `rea init` to install the fallback.';
389
+ if (hints.length > 0) {
390
+ detail +=
391
+ `\n Detected prior tooling in the foreign hook: ${hints.join(', ')}. ` +
392
+ 'Recommended migration (rea 0.13.0+): move each chained command to ' +
393
+ '`.husky/pre-push.d/<NN>-<name>` as a separate executable file; rea then ' +
394
+ 'runs them in lex order AFTER the push-gate, surviving `rea upgrade` ' +
395
+ 'unchanged. See `MIGRATING.md` for a worked example.';
396
+ }
378
397
  return {
379
398
  label: 'pre-push hook installed',
380
399
  status: 'fail',
381
- detail: `active pre-push at ${state.activePath} is present and executable but does NOT ` +
382
- 'invoke `rea hook push-gate` — the 0.11.0 push-gate is silently bypassed. ' +
383
- 'Either add `exec rea hook push-gate "$@"` to the existing hook, or ' +
384
- 'remove it and re-run `rea init` to install the fallback.',
400
+ detail,
385
401
  };
386
402
  }
387
403
  const present = state.candidates
@@ -403,6 +419,47 @@ function checkPrePushHook(state) {
403
419
  'Run `rea init` to install the fallback.',
404
420
  };
405
421
  }
422
+ /**
423
+ * Best-effort scan of a foreign hook body for references to recognizable
424
+ * consumer tooling. Each match returns a short label so doctor can render
425
+ * a precise migration recommendation without dumping the raw hook body.
426
+ *
427
+ * Patterns are intentionally narrow: a substring match in a non-comment line
428
+ * referencing a tool's CLI binary or a well-known wrapper. False positives
429
+ * here are cosmetic (extra hint) — the hard-fail decision still drives the
430
+ * doctor verdict.
431
+ *
432
+ * Read errors are swallowed (return []). Doctor's foreign-hook message is
433
+ * still useful without hints.
434
+ */
435
+ function detectPriorToolHints(hookPath) {
436
+ let body;
437
+ try {
438
+ body = fs.readFileSync(hookPath, 'utf8');
439
+ }
440
+ catch {
441
+ return [];
442
+ }
443
+ const lines = body.split(/\r?\n/);
444
+ const found = new Set();
445
+ for (const raw of lines) {
446
+ if (/^\s*#/.test(raw))
447
+ continue; // skip comments
448
+ if (/\bcommitlint\b/.test(raw))
449
+ found.add('commitlint');
450
+ if (/\blint-staged\b/.test(raw))
451
+ found.add('lint-staged');
452
+ if (/\bgitleaks\b/.test(raw))
453
+ found.add('gitleaks');
454
+ if (/\bact[-_]ci\b/i.test(raw) || /\bact-CI\b/.test(raw))
455
+ found.add('act-CI');
456
+ if (/\bhusky\.sh\b/.test(raw))
457
+ found.add('legacy husky 4-8 wrapper');
458
+ if (/\bnpx\s+--no-install\s+commitlint/.test(raw))
459
+ found.add('commitlint');
460
+ }
461
+ return Array.from(found).sort();
462
+ }
406
463
  /**
407
464
  * Detect and list extension-hook fragments under `.husky/commit-msg.d/` and
408
465
  * `.husky/pre-push.d/`. Informational only — fragments are an opt-in feature
@@ -148,40 +148,47 @@ fi
148
148
  # splitting; \`/Users/jane/My Projects/repo\` produced four argv tokens
149
149
  # instead of two). The \`set --\` form below preserves spaces verbatim.
150
150
  #
151
- # The pre-push stdin carries one line per refspec; \`exec\` inherits stdin
152
- # unchanged. \$@ on entry carries git's <remote-name> <remote-url>; we
153
- # preserve those by appending "\$@" inside each \`set --\` arm.
151
+ # 0.13.2 fix: the dispatch + invocation runs inside a SUBSHELL so the
152
+ # \`set --\` rewrite does NOT bleed into the parent's \$@. Before 0.13.2,
153
+ # the parent's \$@ ended up as the rea-CLI argv (\`node /path/dist/cli/index.js
154
+ # hook push-gate <remote-name> <remote-url>\`) and the extension-fragment
155
+ # loop below passed that mangled argv to fragments instead of git's
156
+ # original \`<remote-name> <remote-url>\`. The subshell scopes the rewrite
157
+ # so fragments see git's argv unchanged.
158
+ #
159
+ # The pre-push stdin carries one line per refspec; the subshell inherits
160
+ # stdin unchanged. \$@ on entry carries git's <remote-name> <remote-url>;
161
+ # the subshell sees those as its initial \$@, appends them inside each
162
+ # \`set --\` arm, and the parent's \$@ is preserved.
154
163
 
155
- if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
156
- set -- "\${REA_ROOT}/node_modules/.bin/rea" hook push-gate "\$@"
157
- elif [ -f "\${REA_ROOT}/dist/cli/index.js" ] && [ -f "\${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "\${REA_ROOT}/package.json" 2>/dev/null; then
158
- # rea's own repo (dogfood) the package is not installed under
159
- # node_modules here because we ARE the package. The built CLI
160
- # entry point lives at dist/cli/index.js; node runs it directly.
161
- # Gate this branch on \`package.json\` declaring \`@bookedsolid/rea\` so a
162
- # consumer repo that happens to ship its own \`dist/cli/index.js\` does
163
- # not get this hook executing the consumer's unrelated build.
164
- set -- node "\${REA_ROOT}/dist/cli/index.js" hook push-gate "\$@"
165
- elif command -v rea >/dev/null 2>&1; then
166
- set -- rea hook push-gate "\$@"
167
- elif command -v npx >/dev/null 2>&1; then
168
- # Last resort: npx will resolve the package from npm or the cache.
169
- # Pass \`--no-install\` so a rare cache-cold machine surfaces a clear
170
- # error instead of silently downloading at push time.
171
- set -- npx --no-install @bookedsolid/rea hook push-gate "\$@"
164
+ if (
165
+ if [ -x "\${REA_ROOT}/node_modules/.bin/rea" ]; then
166
+ set -- "\${REA_ROOT}/node_modules/.bin/rea" hook push-gate "\$@"
167
+ elif [ -f "\${REA_ROOT}/dist/cli/index.js" ] && [ -f "\${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "\${REA_ROOT}/package.json" 2>/dev/null; then
168
+ # rea's own repo (dogfood) the package is not installed under
169
+ # node_modules here because we ARE the package. The built CLI
170
+ # entry point lives at dist/cli/index.js; node runs it directly.
171
+ # Gate this branch on \`package.json\` declaring \`@bookedsolid/rea\` so a
172
+ # consumer repo that happens to ship its own \`dist/cli/index.js\` does
173
+ # not get this hook executing the consumer's unrelated build.
174
+ set -- node "\${REA_ROOT}/dist/cli/index.js" hook push-gate "\$@"
175
+ elif command -v rea >/dev/null 2>&1; then
176
+ set -- rea hook push-gate "\$@"
177
+ elif command -v npx >/dev/null 2>&1; then
178
+ # Last resort: npx will resolve the package from npm or the cache.
179
+ # Pass \`--no-install\` so a rare cache-cold machine surfaces a clear
180
+ # error instead of silently downloading at push time.
181
+ set -- npx --no-install @bookedsolid/rea hook push-gate "\$@"
182
+ else
183
+ printf 'rea: cannot locate the rea CLI. Install locally (\`pnpm add -D @bookedsolid/rea\`) or globally (\`npm i -g @bookedsolid/rea\`).\\n' >&2
184
+ exit 2
185
+ fi
186
+ exec "\$@"
187
+ ); then
188
+ rea_status=0
172
189
  else
173
- printf 'rea: cannot locate the rea CLI. Install locally (\`pnpm add -D @bookedsolid/rea\`) or globally (\`npm i -g @bookedsolid/rea\`).\\n' >&2
174
- exit 2
190
+ rea_status=\$?
175
191
  fi
176
-
177
- # Run the rea push-gate FIRST. We capture its exit and explicitly propagate
178
- # instead of \`exec\`-ing — extension fragments must only run after rea's own
179
- # governance work succeeds. The fragments are user code; surfacing them
180
- # AFTER rea's body (HALT check, Codex review, audit write) preserves the
181
- # governance contract while letting consumers chain their own checks (e.g.
182
- # commitlint, branch-policy linters) without losing rea coverage.
183
- "\$@"
184
- rea_status=\$?
185
192
  if [ "\$rea_status" -ne 0 ]; then
186
193
  exit "\$rea_status"
187
194
  fi
@@ -130,6 +130,59 @@ if [[ "$raw_has_traversal" -eq 1 ]] || [[ "$norm_has_traversal" -eq 1 ]]; then
130
130
  exit 2
131
131
  fi
132
132
 
133
+ # Compute lower-cased path early so the §5b allow-list (and §6/§6b matchers
134
+ # below) all reference a single normalized variable.
135
+ LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
136
+
137
+ # ── 5b. Extension-surface allow-list ──────────────────────────────────────────
138
+ # `.husky/commit-msg.d/*` and `.husky/pre-push.d/*` are the documented
139
+ # consumer extension surface (Fix H / 0.13.0). Consumers — and the agents
140
+ # that govern those consumers — are expected to write here freely so they
141
+ # can layer commitlint, lint-staged, branch-policy, act-CI, etc. without
142
+ # losing rea coverage on `rea upgrade`.
143
+ #
144
+ # The §6 PROTECTED_PATTERNS list below has `.husky/` as a prefix block,
145
+ # which (correctly) keeps `.husky/pre-push`, `.husky/commit-msg`, and
146
+ # the `.husky/_/*` runtime stubs out of agent reach. But the same prefix
147
+ # also caught `.husky/pre-push.d/00-act-ci` and `.husky/commit-msg.d/*`
148
+ # until 0.13.2 — the very directories advertised as the extension
149
+ # surface. This early allow-list closes that contract gap.
150
+ #
151
+ # Anchored on the literal `.d/` segment (not `.d`) so `.husky/pre-push.d.bak/`
152
+ # or `.husky/pre-push.dump` still hit the prefix block. Nested fragments
153
+ # (e.g. `pre-push.d/sub/file`) are allowed so the surface composes naturally.
154
+ #
155
+ # SECURITY: runs AFTER §5a (path-traversal reject), so a clever
156
+ # `.husky/pre-push.d/../pre-push` cannot bypass §6's protection of the
157
+ # package-managed body — §5a kills it before this matcher runs.
158
+ #
159
+ # SECURITY (defense-in-depth): symlinks INSIDE the .d/ surface are
160
+ # refused. A fragment is a short shell script authored in place;
161
+ # consumers do not need symlinks here. Without this check, a sequence
162
+ # like `ln -s ../pre-push .husky/pre-push.d/00-evil; write 00-evil`
163
+ # would be allowed by §5b's path-string match and the downstream
164
+ # Write/Edit tool would follow the symlink, overwriting the
165
+ # package-managed `.husky/pre-push` body that §6 is meant to protect.
166
+ # Costs near-zero (no legitimate use case for symlinked fragments);
167
+ # closes the path-string→symlink bypass completely.
168
+ case "$LOWER_NORM" in
169
+ .husky/commit-msg.d/*|.husky/pre-push.d/*)
170
+ if [ -L "$FILE_PATH" ]; then
171
+ {
172
+ printf 'SETTINGS PROTECTION: symlink in extension surface refused\n'
173
+ printf '\n'
174
+ printf ' File: %s\n' "$SAFE_FILE_PATH"
175
+ printf ' Rule: .husky/commit-msg.d/* and .husky/pre-push.d/* must be\n'
176
+ printf ' regular files (a symlink could resolve to a protected\n'
177
+ printf ' package-managed body and bypass §6 protection).\n'
178
+ } >&2
179
+ exit 2
180
+ fi
181
+ # Documented extension surface — agents can write here freely.
182
+ exit 0
183
+ ;;
184
+ esac
185
+
133
186
  # ── 6. Protected path patterns ────────────────────────────────────────────────
134
187
  # §6 runs BEFORE the patch-session allowlist so hook-patch sessions cannot
135
188
  # reach .rea/policy.yaml, .rea/HALT, or .claude/settings.json via any glob
@@ -149,7 +202,7 @@ PATCH_SESSION_PATTERNS=(
149
202
  '.claude/hooks/'
150
203
  )
151
204
 
152
- LOWER_NORM=$(printf '%s' "$NORMALIZED" | tr '[:upper:]' '[:lower:]')
205
+ # LOWER_NORM was computed in §5b above and is reused here.
153
206
 
154
207
  # Match $NORMALIZED against PROTECTED_PATTERNS (exact or prefix for patterns
155
208
  # ending in '/'). Sets $PROTECTED_MATCH to the matched pattern; exit 0 on hit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -49,6 +49,7 @@
49
49
  ".husky/",
50
50
  "LICENSE",
51
51
  "README.md",
52
+ "MIGRATING.md",
52
53
  "SECURITY.md",
53
54
  "THREAT_MODEL.md"
54
55
  ],