@bookedsolid/rea 0.13.1 → 0.13.2

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
@@ -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.2",
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)",