@bookedsolid/rea 0.29.0 → 0.30.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.
- package/.husky/prepare-commit-msg +295 -0
- package/MIGRATING.md +75 -0
- package/dist/cli/doctor.d.ts +49 -1
- package/dist/cli/doctor.js +266 -6
- package/dist/cli/index.js +2 -0
- package/dist/cli/init.js +120 -0
- package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
- package/dist/cli/install/prepare-commit-msg.js +208 -0
- package/dist/cli/upgrade.js +34 -0
- package/dist/config/settings-schema.d.ts +2087 -0
- package/dist/config/settings-schema.js +294 -0
- package/dist/policy/loader.d.ts +58 -0
- package/dist/policy/loader.js +68 -0
- package/dist/policy/profiles.d.ts +48 -0
- package/dist/policy/profiles.js +25 -0
- package/dist/policy/types.d.ts +51 -0
- package/dist/registry/loader.d.ts +6 -6
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +15 -0
- package/profiles/bst-internal.yaml +16 -0
- package/profiles/client-engagement.yaml +14 -0
- package/profiles/lit-wc.yaml +14 -0
- package/profiles/minimal.yaml +16 -0
- package/profiles/open-source-no-codex.yaml +13 -0
- package/profiles/open-source.yaml +13 -0
- package/templates/prepare-commit-msg.husky.sh +295 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# rea:prepare-commit-msg v1
|
|
3
|
+
# rea:augment-body-v1
|
|
4
|
+
#
|
|
5
|
+
# Husky prepare-commit-msg hook installed by `rea init` / `rea upgrade`.
|
|
6
|
+
# Do NOT edit by hand — the file is refreshed on every rea upgrade.
|
|
7
|
+
#
|
|
8
|
+
# Governance contract: when policy.attribution.co_author.enabled is
|
|
9
|
+
# `true`, append a `Co-Authored-By: <name> <email>` trailer to the
|
|
10
|
+
# commit message file. Idempotent on email match (case-insensitive,
|
|
11
|
+
# line-anchored). Skips merge commits when policy.attribution.co_author
|
|
12
|
+
# .skip_merge is true.
|
|
13
|
+
#
|
|
14
|
+
# Triggers under all five commit sources git delivers:
|
|
15
|
+
# - $2 unset / empty (`git commit` with no body provided)
|
|
16
|
+
# - $2 = 'message' (`git commit -m "..."`)
|
|
17
|
+
# - $2 = 'template' (commit.template configured)
|
|
18
|
+
# - $2 = 'merge' (merge commit; honored by skip_merge: true)
|
|
19
|
+
# - $2 = 'squash' (squash merge / rebase)
|
|
20
|
+
# - $2 = 'commit' (`git commit --amend`)
|
|
21
|
+
#
|
|
22
|
+
# Skip conditions:
|
|
23
|
+
# - REA_SKIP_ATTRIBUTION=1 in env (per-invocation override)
|
|
24
|
+
# - .rea/HALT present (kill switch active)
|
|
25
|
+
# - $1 (message file path) missing or not a file
|
|
26
|
+
# - policy.attribution.co_author.enabled !== true
|
|
27
|
+
#
|
|
28
|
+
# Coexistence: this hook does NOT block on anything. The companion
|
|
29
|
+
# `commit-msg` hook (which runs AFTER prepare-commit-msg in git's
|
|
30
|
+
# lifecycle) still enforces `block_ai_attribution`. A human trailer
|
|
31
|
+
# `Co-Authored-By: Real Name <real@email.tld>` is NOT AI attribution
|
|
32
|
+
# (no AI noreply domain, no AI name keyword) and is not blocked.
|
|
33
|
+
|
|
34
|
+
set -u
|
|
35
|
+
|
|
36
|
+
COMMIT_MSG_FILE="${1:-}"
|
|
37
|
+
COMMIT_SOURCE="${2:-}"
|
|
38
|
+
|
|
39
|
+
# Skip conditions: any missing precondition exits 0 silently. The hook
|
|
40
|
+
# is purely additive; refusing here would break commits with no upside.
|
|
41
|
+
|
|
42
|
+
# Missing message file → nothing to augment.
|
|
43
|
+
if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ]; then
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# Per-invocation override.
|
|
48
|
+
if [ -n "${REA_SKIP_ATTRIBUTION:-}" ]; then
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
53
|
+
|
|
54
|
+
# HALT kill switch — refuse to mutate anything while frozen.
|
|
55
|
+
if [ -f "${REA_ROOT}/.rea/HALT" ]; then
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
60
|
+
if [ ! -f "$POLICY_FILE" ]; then
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# Delegate policy reads to the canonical rea CLI when available so we
|
|
65
|
+
# get the zod-validated document regardless of whether the operator
|
|
66
|
+
# wrote block-form (`attribution:\n co_author:\n enabled: true`)
|
|
67
|
+
# or inline-form (`attribution: { co_author: { enabled: true } }`)
|
|
68
|
+
# YAML. Codex round 1 P2: the prior Python inline parser only handled
|
|
69
|
+
# block form. When the CLI is unreachable (fresh consumer install
|
|
70
|
+
# pre-`pnpm i`, foreign dev environment, …) we fall back to the
|
|
71
|
+
# embedded Python state machine — it correctly handles block-form
|
|
72
|
+
# YAML, which is what `rea init` writes.
|
|
73
|
+
#
|
|
74
|
+
# Locator priority mirrors `.husky/pre-push`: project node_modules →
|
|
75
|
+
# dogfood dist → PATH.
|
|
76
|
+
rea_invoke() {
|
|
77
|
+
if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
|
|
78
|
+
"${REA_ROOT}/node_modules/.bin/rea" "$@"
|
|
79
|
+
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
|
|
80
|
+
node "${REA_ROOT}/dist/cli/index.js" "$@"
|
|
81
|
+
elif command -v rea >/dev/null 2>&1; then
|
|
82
|
+
rea "$@"
|
|
83
|
+
else
|
|
84
|
+
return 127
|
|
85
|
+
fi
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ENABLED=$(rea_invoke hook policy-get attribution.co_author.enabled 2>/dev/null)
|
|
89
|
+
REA_RC=$?
|
|
90
|
+
|
|
91
|
+
if [ "$REA_RC" = "0" ]; then
|
|
92
|
+
CO_NAME=$(rea_invoke hook policy-get attribution.co_author.name 2>/dev/null || printf '')
|
|
93
|
+
CO_EMAIL=$(rea_invoke hook policy-get attribution.co_author.email 2>/dev/null || printf '')
|
|
94
|
+
SKIP_MERGE=$(rea_invoke hook policy-get attribution.co_author.skip_merge 2>/dev/null || printf 'false')
|
|
95
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
96
|
+
# rea CLI unreachable — fall back to Python block-form parser.
|
|
97
|
+
CO_AUTHOR_PARSE=$(python3 - "$POLICY_FILE" <<'PY' 2>/dev/null
|
|
98
|
+
import re
|
|
99
|
+
import sys
|
|
100
|
+
|
|
101
|
+
path = sys.argv[1]
|
|
102
|
+
try:
|
|
103
|
+
with open(path, 'r', encoding='utf-8') as fh:
|
|
104
|
+
lines = fh.readlines()
|
|
105
|
+
except OSError:
|
|
106
|
+
print('false'); print(''); print(''); print('false'); sys.exit(0)
|
|
107
|
+
|
|
108
|
+
in_attr = False
|
|
109
|
+
in_co = False
|
|
110
|
+
enabled = 'false'
|
|
111
|
+
name = ''
|
|
112
|
+
email = ''
|
|
113
|
+
skip_merge = 'false'
|
|
114
|
+
|
|
115
|
+
def strip_value(raw):
|
|
116
|
+
raw = raw.rstrip('\n').rstrip()
|
|
117
|
+
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'):
|
|
118
|
+
return raw[1:-1]
|
|
119
|
+
if '#' in raw:
|
|
120
|
+
raw = raw.split('#', 1)[0].rstrip()
|
|
121
|
+
return raw
|
|
122
|
+
|
|
123
|
+
for line in lines:
|
|
124
|
+
stripped_line = line.rstrip('\n')
|
|
125
|
+
if re.match(r'^\s*#', stripped_line):
|
|
126
|
+
continue
|
|
127
|
+
if re.match(r'^attribution:\s*(#.*)?$', stripped_line):
|
|
128
|
+
in_attr = True; in_co = False; continue
|
|
129
|
+
if in_attr and re.match(r'^\S', stripped_line):
|
|
130
|
+
in_attr = False; in_co = False
|
|
131
|
+
if in_attr and re.match(r'^\s+co_author:\s*(#.*)?$', stripped_line):
|
|
132
|
+
in_co = True; continue
|
|
133
|
+
if in_co:
|
|
134
|
+
m = re.match(r'^(\s*)\S', stripped_line)
|
|
135
|
+
if m and len(m.group(1)) <= 2:
|
|
136
|
+
in_co = False; continue
|
|
137
|
+
if re.search(r'enabled:\s*true(\s|$)', stripped_line):
|
|
138
|
+
enabled = 'true'
|
|
139
|
+
elif re.search(r'enabled:\s*false(\s|$)', stripped_line):
|
|
140
|
+
enabled = 'false'
|
|
141
|
+
if re.search(r'skip_merge:\s*true(\s|$)', stripped_line):
|
|
142
|
+
skip_merge = 'true'
|
|
143
|
+
elif re.search(r'skip_merge:\s*false(\s|$)', stripped_line):
|
|
144
|
+
skip_merge = 'false'
|
|
145
|
+
m = re.search(r'name:\s*(.*)$', stripped_line)
|
|
146
|
+
if m:
|
|
147
|
+
name = strip_value(m.group(1))
|
|
148
|
+
m = re.search(r'email:\s*(.*)$', stripped_line)
|
|
149
|
+
if m:
|
|
150
|
+
email = strip_value(m.group(1))
|
|
151
|
+
|
|
152
|
+
print(enabled); print(name); print(email); print(skip_merge)
|
|
153
|
+
PY
|
|
154
|
+
)
|
|
155
|
+
if [ -z "$CO_AUTHOR_PARSE" ]; then
|
|
156
|
+
exit 0
|
|
157
|
+
fi
|
|
158
|
+
ENABLED=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '1p')
|
|
159
|
+
CO_NAME=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '2p')
|
|
160
|
+
CO_EMAIL=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '3p')
|
|
161
|
+
SKIP_MERGE=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '4p')
|
|
162
|
+
else
|
|
163
|
+
# Neither rea CLI nor python3 reachable — silent no-op.
|
|
164
|
+
exit 0
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
if [ "$ENABLED" != "true" ]; then
|
|
168
|
+
exit 0
|
|
169
|
+
fi
|
|
170
|
+
|
|
171
|
+
# Defense-in-depth: if we got here with enabled=true but no identity,
|
|
172
|
+
# the policy loader's cross-field refinement was bypassed (or someone
|
|
173
|
+
# edited the YAML around the load path). Bail without augmenting and
|
|
174
|
+
# emit a stderr advisory so the operator sees the misconfig at commit
|
|
175
|
+
# time. We deliberately do NOT exit non-zero — refusing the commit
|
|
176
|
+
# would be more disruptive than the silent no-op (the loader + doctor
|
|
177
|
+
# already surface the misconfig at policy load and at `rea doctor`).
|
|
178
|
+
#
|
|
179
|
+
# When `rea audit record <topic>` lands in a future release this
|
|
180
|
+
# branch should emit a `rea.attribution_augmented_invalid_config`
|
|
181
|
+
# record instead of stderr. Tracked as a 0.31.0+ item.
|
|
182
|
+
if [ -z "$CO_NAME" ] || [ -z "$CO_EMAIL" ]; then
|
|
183
|
+
printf 'rea: attribution.co_author.enabled=true but %s%s%s is empty — augmenter no-op.\n' \
|
|
184
|
+
"$([ -z "$CO_NAME" ] && printf name)" \
|
|
185
|
+
"$([ -z "$CO_NAME" ] && [ -z "$CO_EMAIL" ] && printf '+')" \
|
|
186
|
+
"$([ -z "$CO_EMAIL" ] && printf email)" >&2
|
|
187
|
+
printf 'rea: edit .rea/policy.yaml — set name + email, OR set enabled: false.\n' >&2
|
|
188
|
+
exit 0
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
# skip_merge: true → skip when commit source is 'merge'.
|
|
192
|
+
if [ "$SKIP_MERGE" = "true" ] && [ "$COMMIT_SOURCE" = "merge" ]; then
|
|
193
|
+
exit 0
|
|
194
|
+
fi
|
|
195
|
+
|
|
196
|
+
# Idempotency: scan the current message file for a Co-Authored-By line
|
|
197
|
+
# that names the same email (case-insensitive). Line-anchored — body
|
|
198
|
+
# prose mentioning the email in passing does NOT count.
|
|
199
|
+
LOWER_EMAIL=$(printf '%s' "$CO_EMAIL" | tr '[:upper:]' '[:lower:]')
|
|
200
|
+
# grep -E with case-insensitive flag; portable across BSD + GNU grep.
|
|
201
|
+
# The pattern: ^co-authored-by: <anything> <EMAIL>[ws]*$
|
|
202
|
+
# Email is regex-escaped via the conservative approach: assume the
|
|
203
|
+
# email passed policy validation (only safe chars per loader regex
|
|
204
|
+
# /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/), so the only metachars present
|
|
205
|
+
# are `.` and possibly `+` / `-`. We escape `.` and rely on the
|
|
206
|
+
# permissive char set.
|
|
207
|
+
ESCAPED_EMAIL=$(printf '%s' "$LOWER_EMAIL" | sed 's/[.[\*^$(){}+?|]/\\&/g')
|
|
208
|
+
if grep -iE "^co-authored-by:[[:space:]]*[^<]*<${ESCAPED_EMAIL}>[[:space:]]*$" \
|
|
209
|
+
"$COMMIT_MSG_FILE" >/dev/null 2>&1; then
|
|
210
|
+
exit 0
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
# Build the trailer line. Idempotency above already lower-cased the
|
|
214
|
+
# email for comparison; we ship the trailer with the policy-supplied
|
|
215
|
+
# casing so the user's preferred display name + email render verbatim.
|
|
216
|
+
TRAILER="Co-Authored-By: ${CO_NAME} <${CO_EMAIL}>"
|
|
217
|
+
|
|
218
|
+
# Find the insert point: at the bottom of the message, after stripping
|
|
219
|
+
# trailing blank/comment lines (git's scissors line `# -- >8 --` and
|
|
220
|
+
# everything below is appended verbatim to preserve git's own view).
|
|
221
|
+
TMP_BODY=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || exit 0
|
|
222
|
+
TMP_TAIL=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || { rm -f "$TMP_BODY"; exit 0; }
|
|
223
|
+
trap 'rm -f "$TMP_BODY" "$TMP_TAIL"' EXIT INT TERM
|
|
224
|
+
|
|
225
|
+
# Split the file: body (above the scissors marker) vs. tail (scissors
|
|
226
|
+
# and everything below). Codex round 2 P1: previously used python3
|
|
227
|
+
# unconditionally — on environments where rea CLI is reachable but
|
|
228
|
+
# python3 is missing, the split silently failed and the user's commit
|
|
229
|
+
# body got dropped. awk is universally available on POSIX systems and
|
|
230
|
+
# does the same work.
|
|
231
|
+
SCISSORS='# ------------------------ >8 ------------------------'
|
|
232
|
+
awk -v scissors="$SCISSORS" -v body_dst="$TMP_BODY" -v tail_dst="$TMP_TAIL" '
|
|
233
|
+
BEGIN { found = 0 }
|
|
234
|
+
{
|
|
235
|
+
if (!found && $0 == scissors) found = 1
|
|
236
|
+
if (found) print > tail_dst
|
|
237
|
+
else print > body_dst
|
|
238
|
+
}
|
|
239
|
+
' "$COMMIT_MSG_FILE"
|
|
240
|
+
|
|
241
|
+
# Determine whether the body's last non-blank/non-comment line is a
|
|
242
|
+
# real git trailer (`Key: value` where Key matches `[A-Za-z][-A-Za-z0-9]*`)
|
|
243
|
+
# AND part of a multi-line trailer block (not the subject of a single-line
|
|
244
|
+
# conventional commit). Codex round 3 P1: the round-2 fix correctly
|
|
245
|
+
# rejected commit-prose `: ` patterns but still matched the conventional
|
|
246
|
+
# commit subject form `feat: add x` because that line is ALSO
|
|
247
|
+
# `[A-Za-z][-A-Za-z0-9]*: <value>`. The right distinguisher: a real
|
|
248
|
+
# trailer block has at least one preceding non-blank body line; a bare
|
|
249
|
+
# `feat: x` commit is just a subject and always needs a separator.
|
|
250
|
+
LAST_BODY_LINE=$(awk '
|
|
251
|
+
/^[[:space:]]*#/ { next }
|
|
252
|
+
/^[[:space:]]*$/ { next }
|
|
253
|
+
{ lastline = $0 }
|
|
254
|
+
END { if (lastline != "") print lastline }
|
|
255
|
+
' "$TMP_BODY")
|
|
256
|
+
BODY_LINE_COUNT=$(awk '
|
|
257
|
+
/^[[:space:]]*#/ { next }
|
|
258
|
+
/^[[:space:]]*$/ { next }
|
|
259
|
+
{ count++ }
|
|
260
|
+
END { print count + 0 }
|
|
261
|
+
' "$TMP_BODY")
|
|
262
|
+
|
|
263
|
+
SEPARATOR_NEEDED=1
|
|
264
|
+
if [ -z "$LAST_BODY_LINE" ]; then
|
|
265
|
+
SEPARATOR_NEEDED=0
|
|
266
|
+
elif [ "$BODY_LINE_COUNT" -gt 1 ] && printf '%s' "$LAST_BODY_LINE" | grep -qE '^[A-Za-z][-A-Za-z0-9]*: '; then
|
|
267
|
+
SEPARATOR_NEEDED=0
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
# Trim trailing blank lines from the body so the trailer lands cleanly
|
|
271
|
+
# (without leaving a triple-newline before it).
|
|
272
|
+
TMP_BODY_TRIMMED=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || exit 0
|
|
273
|
+
awk '
|
|
274
|
+
{ lines[NR] = $0; total = NR }
|
|
275
|
+
END {
|
|
276
|
+
end = total
|
|
277
|
+
while (end > 0 && lines[end] ~ /^[[:space:]]*$/) { end-- }
|
|
278
|
+
for (i = 1; i <= end; i++) print lines[i]
|
|
279
|
+
}
|
|
280
|
+
' "$TMP_BODY" > "$TMP_BODY_TRIMMED"
|
|
281
|
+
|
|
282
|
+
# Compose the new file: trimmed body + (optional blank) + trailer + tail.
|
|
283
|
+
{
|
|
284
|
+
cat "$TMP_BODY_TRIMMED"
|
|
285
|
+
if [ "$SEPARATOR_NEEDED" -eq 1 ]; then
|
|
286
|
+
printf '\n'
|
|
287
|
+
fi
|
|
288
|
+
printf '%s\n' "$TRAILER"
|
|
289
|
+
if [ -s "$TMP_TAIL" ]; then
|
|
290
|
+
cat "$TMP_TAIL"
|
|
291
|
+
fi
|
|
292
|
+
} > "${COMMIT_MSG_FILE}.rea-tmp" && mv "${COMMIT_MSG_FILE}.rea-tmp" "$COMMIT_MSG_FILE"
|
|
293
|
+
|
|
294
|
+
rm -f "$TMP_BODY_TRIMMED"
|
|
295
|
+
exit 0
|
package/MIGRATING.md
CHANGED
|
@@ -59,6 +59,10 @@ are on the vanilla-git path — install husky first.
|
|
|
59
59
|
- `.husky/pre-push` — package-managed; **do not edit**. Refreshed on every
|
|
60
60
|
`rea upgrade`.
|
|
61
61
|
- `.husky/commit-msg` — package-managed; **do not edit**. Same.
|
|
62
|
+
- `.husky/prepare-commit-msg` — package-managed (added in 0.30.0).
|
|
63
|
+
Drives the optional `attribution.co_author` augmenter; **do not edit**.
|
|
64
|
+
No-op when `policy.attribution.co_author.enabled !== true`, so it is
|
|
65
|
+
safe to ship under every profile (default disabled).
|
|
62
66
|
- `.git/hooks/pre-push` (fallback when `core.hooksPath` is unset).
|
|
63
67
|
- `.claude/hooks/*.sh` — protection + audit + advisory hooks.
|
|
64
68
|
- `.claude/agents/*.md`, `.claude/commands/*.md`.
|
|
@@ -125,6 +129,77 @@ Re-run `rea upgrade`. The package-managed `.husky/commit-msg` body now
|
|
|
125
129
|
runs first (HALT check, AI-attribution block when policy enables it),
|
|
126
130
|
then runs your fragment.
|
|
127
131
|
|
|
132
|
+
## Conflict pattern: existing prepare-commit-msg (rea 0.30.0+)
|
|
133
|
+
|
|
134
|
+
You probably have a hook that templates the message, adds a Jira
|
|
135
|
+
ticket prefix, or inserts a branch name:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
#!/bin/sh
|
|
139
|
+
# .husky/prepare-commit-msg — user-authored
|
|
140
|
+
COMMIT_MSG_FILE=$1
|
|
141
|
+
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
142
|
+
echo "[$BRANCH] $(cat "$COMMIT_MSG_FILE")" > "$COMMIT_MSG_FILE"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**rea 0.30.0+ refuses to overwrite a foreign `.husky/prepare-commit-msg`.**
|
|
146
|
+
On `rea init` you'll see a `[fail]` from `rea doctor`:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
[fail] prepare-commit-msg hook (attribution augmenter)
|
|
150
|
+
(attribution.co_author.enabled: true but the prepare-commit-msg
|
|
151
|
+
hook is foreign (no rea marker) — remove the existing hook
|
|
152
|
+
and re-run `rea init`, or set enabled: false.)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Migration
|
|
156
|
+
|
|
157
|
+
Two paths, depending on whether you intend to use the rea augmenter.
|
|
158
|
+
|
|
159
|
+
**Path A — you want the augmenter (Co-Authored-By trailer)**
|
|
160
|
+
|
|
161
|
+
Move your branch-prefix logic into rea's chained body. As of 0.30.0
|
|
162
|
+
rea's prepare-commit-msg body does NOT support `.husky/prepare-commit-msg.d/*`
|
|
163
|
+
fragments yet (it's on the 0.31.0 roadmap). For now, port the logic
|
|
164
|
+
into a wrapper invoked by `commit-msg.d` instead:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
mkdir -p .husky/commit-msg.d
|
|
168
|
+
cat > .husky/commit-msg.d/00-branch-prefix <<'EOF'
|
|
169
|
+
#!/bin/sh
|
|
170
|
+
# Branch-prefix logic moved from prepare-commit-msg to commit-msg.d
|
|
171
|
+
# (runs AFTER rea's augmenter, before the commit is finalized).
|
|
172
|
+
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
|
|
173
|
+
case $(head -1 "$1") in
|
|
174
|
+
"[$BRANCH]"*) ;; # already prefixed
|
|
175
|
+
*) printf '[%s] %s' "$BRANCH" "$(cat "$1")" > "$1" ;;
|
|
176
|
+
esac
|
|
177
|
+
EOF
|
|
178
|
+
chmod +x .husky/commit-msg.d/00-branch-prefix
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Then remove the old `.husky/prepare-commit-msg`:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
rm .husky/prepare-commit-msg .git/hooks/prepare-commit-msg
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Re-run `rea init`. rea's prepare-commit-msg now installs cleanly.
|
|
188
|
+
|
|
189
|
+
**Path B — you do NOT want the augmenter**
|
|
190
|
+
|
|
191
|
+
Leave your existing hook in place. Set the augmenter off explicitly:
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
# .rea/policy.yaml
|
|
195
|
+
attribution:
|
|
196
|
+
co_author:
|
|
197
|
+
enabled: false
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`rea doctor` reports `[warn]` (not fail) for the foreign hook —
|
|
201
|
+
your commits keep going through your existing logic.
|
|
202
|
+
|
|
128
203
|
## Conflict pattern: lint-staged on pre-push
|
|
129
204
|
|
|
130
205
|
You probably have:
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -19,6 +19,44 @@ export interface CheckResult {
|
|
|
19
19
|
* Exported so tests can drive this without spinning up the full `runDoctor`.
|
|
20
20
|
*/
|
|
21
21
|
export declare function checkFingerprintStore(baseDir: string): Promise<CheckResult>;
|
|
22
|
+
/**
|
|
23
|
+
* 0.30.0 (Class M settings.json schema) — `EXPECTED_HOOKS` is exported
|
|
24
|
+
* so the schema validator at `src/config/settings-schema.ts` can
|
|
25
|
+
* cross-check rea-shipped hook filenames against entries it sees in
|
|
26
|
+
* a consumer's `.claude/settings.json`. The validator's `--strict`
|
|
27
|
+
* mode FAILS when a known rea-managed hook is missing from the
|
|
28
|
+
* consumer's registration; default mode logs a warn.
|
|
29
|
+
*/
|
|
30
|
+
export declare const EXPECTED_AGENTS: string[];
|
|
31
|
+
export declare const EXPECTED_HOOKS: string[];
|
|
32
|
+
/**
|
|
33
|
+
* 0.30.0 Class M — validate `.claude/settings.json` against the zod
|
|
34
|
+
* schema in `src/config/settings-schema.ts`.
|
|
35
|
+
*
|
|
36
|
+
* Status posture:
|
|
37
|
+
*
|
|
38
|
+
* - `strict: false` (default `rea doctor`) — emit a warn when:
|
|
39
|
+
* - zod parse fails (unknown top-level key, missing matcher,
|
|
40
|
+
* malformed hook entry, etc.),
|
|
41
|
+
* - any `command` contains a `..` traversal after stripping
|
|
42
|
+
* `$CLAUDE_PROJECT_DIR`,
|
|
43
|
+
* - any rea-shipped hook from `EXPECTED_HOOKS` is missing from
|
|
44
|
+
* the consumer's registrations.
|
|
45
|
+
* The harness keeps working — the schema only refuses to call
|
|
46
|
+
* malformed hook entries; we surface the issue without breaking
|
|
47
|
+
* the install.
|
|
48
|
+
*
|
|
49
|
+
* - `strict: true` (`rea doctor --strict`) — fail (hard) on the
|
|
50
|
+
* same conditions. Used by CI gates that want a hard floor on
|
|
51
|
+
* consumer settings.
|
|
52
|
+
*
|
|
53
|
+
* Returns `pass` when everything cleared. Returns one `CheckResult`
|
|
54
|
+
* per concern; called once and emits one result. Combined with the
|
|
55
|
+
* existing `checkSettingsJson` (which checks for the historical Bash
|
|
56
|
+
* + Write|Edit|MultiEdit|NotebookEdit matchers), gives consumers a
|
|
57
|
+
* complete picture.
|
|
58
|
+
*/
|
|
59
|
+
export declare function checkSettingsSchema(baseDir: string, strict: boolean): CheckResult;
|
|
22
60
|
/**
|
|
23
61
|
* Detect whether `baseDir` is a git repository. Returns true for the three
|
|
24
62
|
* shapes git itself accepts:
|
|
@@ -46,6 +84,7 @@ export declare function checkFingerprintStore(baseDir: string): Promise<CheckRes
|
|
|
46
84
|
* NOT a trust boundary. Do not key security decisions on the return value.
|
|
47
85
|
*/
|
|
48
86
|
export declare function isGitRepo(baseDir: string): boolean;
|
|
87
|
+
export declare function checkPrepareCommitMsgHook(baseDir: string): CheckResult;
|
|
49
88
|
/**
|
|
50
89
|
* Hard-fail when `policy.review.codex_required: true` but the `codex`
|
|
51
90
|
* binary is not on PATH. Pre-0.12.0 this prereq surfaced only at first
|
|
@@ -137,7 +176,9 @@ export declare function checkDelegationRoundTrip(baseDir: string): Promise<Check
|
|
|
137
176
|
*
|
|
138
177
|
* `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
|
|
139
178
|
*/
|
|
140
|
-
export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState, prePushState?: PrePushDoctorState
|
|
179
|
+
export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState, prePushState?: PrePushDoctorState, options?: {
|
|
180
|
+
strict?: boolean;
|
|
181
|
+
}): CheckResult[];
|
|
141
182
|
export interface RunDoctorOptions {
|
|
142
183
|
/** When true, print a 7-day telemetry summary after the checks (G11.5). */
|
|
143
184
|
metrics?: boolean;
|
|
@@ -156,6 +197,13 @@ export interface RunDoctorOptions {
|
|
|
156
197
|
* audit log with probe records.
|
|
157
198
|
*/
|
|
158
199
|
smoke?: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* 0.30.0 — when true, every advisory check (settings.json schema
|
|
202
|
+
* cross-check, prepare-commit-msg foreign-hook warn, etc.) is
|
|
203
|
+
* promoted to hard fail. Used by CI gates that want a strict floor
|
|
204
|
+
* on consumer installs. Default `false`.
|
|
205
|
+
*/
|
|
206
|
+
strict?: boolean;
|
|
159
207
|
}
|
|
160
208
|
export interface DriftRow {
|
|
161
209
|
path: string;
|