@bookedsolid/rea 0.28.2 → 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/audit/append.d.ts +1 -0
- package/dist/audit/append.js +1 -0
- package/dist/audit/delegation-event.d.ts +215 -0
- package/dist/audit/delegation-event.js +113 -0
- package/dist/cli/audit-specialists.d.ts +113 -0
- package/dist/cli/audit-specialists.js +220 -0
- package/dist/cli/doctor.d.ts +114 -1
- package/dist/cli/doctor.js +523 -5
- package/dist/cli/hook.d.ts +40 -8
- package/dist/cli/hook.js +305 -8
- package/dist/cli/index.js +9 -0
- package/dist/cli/init.js +120 -0
- package/dist/cli/install/manifest-schema.d.ts +6 -6
- package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
- package/dist/cli/install/prepare-commit-msg.js +208 -0
- package/dist/cli/install/settings-merge.js +20 -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/config/tier-map.js +22 -1
- 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 +12 -12
- package/hooks/delegation-capture.sh +158 -0
- 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/audit/append.d.ts
CHANGED
|
@@ -83,3 +83,4 @@ export type { AuditRecord, EmissionSource } from '../gateway/middleware/audit-ty
|
|
|
83
83
|
export { Tier, InvocationStatus } from '../policy/types.js';
|
|
84
84
|
export { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, type CodexVerdict, type CodexReviewMetadata, } from './codex-event.js';
|
|
85
85
|
export { LOCAL_REVIEW_TOOL_NAME, LOCAL_REVIEW_SKIPPED_OVERRIDE_TOOL_NAME, LOCAL_REVIEW_SKIPPED_UNAVAILABLE_TOOL_NAME, LOCAL_REVIEW_PREFLIGHT_SKIPPED_TOOL_NAME, LOCAL_REVIEW_SERVER_NAME, type LocalReviewVerdict, type LocalReviewMetadata, type LocalReviewSkippedOverrideMetadata, type LocalReviewSkippedUnavailableMetadata, } from './local-review-event.js';
|
|
86
|
+
export { DELEGATION_SIGNAL_TOOL_NAME, DELEGATION_SIGNAL_SERVER_NAME, DELEGATION_SIGNAL_SCHEMA_VERSION, DelegationSignalMetadataSchema, type DelegationTool, type DelegationSignalMetadata, type DelegationSignalMetadataParsed, } from './delegation-event.js';
|
package/dist/audit/append.js
CHANGED
|
@@ -204,3 +204,4 @@ export async function appendAuditRecord(baseDir, input) {
|
|
|
204
204
|
export { Tier, InvocationStatus } from '../policy/types.js';
|
|
205
205
|
export { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from './codex-event.js';
|
|
206
206
|
export { LOCAL_REVIEW_TOOL_NAME, LOCAL_REVIEW_SKIPPED_OVERRIDE_TOOL_NAME, LOCAL_REVIEW_SKIPPED_UNAVAILABLE_TOOL_NAME, LOCAL_REVIEW_PREFLIGHT_SKIPPED_TOOL_NAME, LOCAL_REVIEW_SERVER_NAME, } from './local-review-event.js';
|
|
207
|
+
export { DELEGATION_SIGNAL_TOOL_NAME, DELEGATION_SIGNAL_SERVER_NAME, DELEGATION_SIGNAL_SCHEMA_VERSION, DelegationSignalMetadataSchema, } from './delegation-event.js';
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for the `rea.delegation_signal` audit event shape
|
|
3
|
+
* (0.29.0+).
|
|
4
|
+
*
|
|
5
|
+
* 0.29.0 — delegation-telemetry MVP. Claude Code's PreToolUse hook tree
|
|
6
|
+
* gains a new matcher (`Agent|Skill`) that pipes a redacted, hashed
|
|
7
|
+
* record of every subagent dispatch and skill invocation into
|
|
8
|
+
* `.rea/audit.jsonl`. The signal is observational, not gating — it
|
|
9
|
+
* answers "which specialists is this session actually delegating to,
|
|
10
|
+
* and how often" without altering the autonomy tree.
|
|
11
|
+
*
|
|
12
|
+
* # The two delegation tools
|
|
13
|
+
*
|
|
14
|
+
* Current Claude Code exposes exactly two delegation surfaces:
|
|
15
|
+
*
|
|
16
|
+
* - `Agent` — dispatches a curated subagent (rea-orchestrator,
|
|
17
|
+
* code-reviewer, …). The agent name is at
|
|
18
|
+
* `tool_input.subagent_type`.
|
|
19
|
+
* - `Skill` — invokes a named skill (deep-dive, /loop, …). The skill
|
|
20
|
+
* name is at `tool_input.skill`.
|
|
21
|
+
*
|
|
22
|
+
* mcp-protocol-specialist verified BOTH payload paths against current
|
|
23
|
+
* Claude Code. A Skill that internally forks an Agent fires PreToolUse
|
|
24
|
+
* TWICE (Skill then Agent) for the same logical action; v1 records
|
|
25
|
+
* both — deduplication lives in the reader, not the writer.
|
|
26
|
+
*
|
|
27
|
+
* # Not `Task`
|
|
28
|
+
*
|
|
29
|
+
* In current Claude Code the tools are `Agent` and `Skill`. The names
|
|
30
|
+
* `TaskCreate`/`TaskList`/`TaskUpdate` belong to the unrelated todo-list
|
|
31
|
+
* tool surface and MUST NOT match. The settings.json matcher is
|
|
32
|
+
* `Agent|Skill` everywhere — anchored on a `^…$` boundary by the hook
|
|
33
|
+
* runtime, so `Agent` doesn't accidentally collide with hypothetical
|
|
34
|
+
* future tools named `Agentic…`.
|
|
35
|
+
*
|
|
36
|
+
* # Privacy invariant
|
|
37
|
+
*
|
|
38
|
+
* The raw `description` / `prompt` payload NEVER touches `.rea/audit.jsonl`
|
|
39
|
+
* — only its SHA-256 hash. The hash is collision-resistant identification
|
|
40
|
+
* (two identical prompts produce identical hashes, enabling
|
|
41
|
+
* delegation-pattern discovery) without persisting prompt content.
|
|
42
|
+
*
|
|
43
|
+
* The agent / skill name field DOES land in the audit log, but is run
|
|
44
|
+
* through `redactSecrets` first. A subagent_type that contains a
|
|
45
|
+
* planted credential string (synthetic AWS key, GitHub token, …) is
|
|
46
|
+
* replaced with `[REDACTED]` and the matching pattern names are
|
|
47
|
+
* appended to the record's `redacted_fields` envelope.
|
|
48
|
+
*
|
|
49
|
+
* # Provider seam (kept tiny)
|
|
50
|
+
*
|
|
51
|
+
* Unlike `rea.local_review`, this event does NOT have a `provider`
|
|
52
|
+
* field. The producer is always Claude Code's hook runtime and the
|
|
53
|
+
* `emission_source: 'rea-cli'` envelope is sufficient. If a future
|
|
54
|
+
* runtime (e.g. another agent host) wants to emit signals through the
|
|
55
|
+
* same channel, it writes the same shape with the same tool_name and
|
|
56
|
+
* relies on `session_id_observed` / `delegation_tool` for
|
|
57
|
+
* disambiguation.
|
|
58
|
+
*
|
|
59
|
+
* # Schema version
|
|
60
|
+
*
|
|
61
|
+
* The literal `schema_version: 1` is part of the metadata payload. Zod
|
|
62
|
+
* strict-mode rejects unknown fields, so a future v2 producer writing
|
|
63
|
+
* v2-only fields against a v1 consumer fails-loud rather than silently
|
|
64
|
+
* dropping data. Readers filter by `tool_name === 'rea.delegation_signal'`
|
|
65
|
+
* AND `metadata.schema_version === 1`.
|
|
66
|
+
*/
|
|
67
|
+
import { z } from 'zod';
|
|
68
|
+
/**
|
|
69
|
+
* Canonical `tool_name` on the audit record envelope. Readers filter on
|
|
70
|
+
* this exact literal — anything else is a different event class.
|
|
71
|
+
*/
|
|
72
|
+
export declare const DELEGATION_SIGNAL_TOOL_NAME: "rea.delegation_signal";
|
|
73
|
+
/**
|
|
74
|
+
* `server_name` envelope value. The signal originates from Claude Code's
|
|
75
|
+
* hook runtime, captured by `rea hook delegation-signal` and appended
|
|
76
|
+
* via the public audit-record API. Naming it `claude-code-hooks` makes
|
|
77
|
+
* the producer surface unambiguous in forensic queries (vs.
|
|
78
|
+
* `'rea'` which is used for first-party rea CLI events like
|
|
79
|
+
* `rea.local_review`).
|
|
80
|
+
*/
|
|
81
|
+
export declare const DELEGATION_SIGNAL_SERVER_NAME: "claude-code-hooks";
|
|
82
|
+
/**
|
|
83
|
+
* Schema version literal. Bumped only when the metadata shape gains a
|
|
84
|
+
* non-backwards-compatible change. Adding optional fields does NOT bump
|
|
85
|
+
* the version — zod's strict mode rejects them, so any new field MUST
|
|
86
|
+
* either ship with a major-version bump OR have its zod parser updated
|
|
87
|
+
* in lockstep.
|
|
88
|
+
*/
|
|
89
|
+
export declare const DELEGATION_SIGNAL_SCHEMA_VERSION: 1;
|
|
90
|
+
/**
|
|
91
|
+
* The two valid delegation-tool values. `Agent` and `Skill` are the
|
|
92
|
+
* exact tool names emitted by Claude Code's PreToolUse hook payload —
|
|
93
|
+
* anything else is a misclassification at the hook layer.
|
|
94
|
+
*/
|
|
95
|
+
export type DelegationTool = 'Agent' | 'Skill';
|
|
96
|
+
/**
|
|
97
|
+
* Canonical metadata payload for `rea.delegation_signal`. Embedded
|
|
98
|
+
* under `metadata` on the audit record. The audit-record envelope
|
|
99
|
+
* itself supplies `tool_name`, `server_name`, `session_id`, `timestamp`,
|
|
100
|
+
* `prev_hash`, `hash`, `redacted_fields`, etc. — keep those out of
|
|
101
|
+
* metadata.
|
|
102
|
+
*/
|
|
103
|
+
export interface DelegationSignalMetadata {
|
|
104
|
+
/**
|
|
105
|
+
* Always `1` for the 0.29.0 shape. Carried as a literal so future
|
|
106
|
+
* v2-aware readers can distinguish records they understand from
|
|
107
|
+
* records they don't.
|
|
108
|
+
*/
|
|
109
|
+
schema_version: typeof DELEGATION_SIGNAL_SCHEMA_VERSION;
|
|
110
|
+
/**
|
|
111
|
+
* Which Claude Code surface fired the hook — `'Agent'` for the
|
|
112
|
+
* subagent dispatch tool, `'Skill'` for the skill invocation tool.
|
|
113
|
+
* The reader CLI groups records on `subagent_type` regardless of
|
|
114
|
+
* `delegation_tool` (a `deep-dive` skill and a `deep-dive` agent
|
|
115
|
+
* roll into the same bucket), but the field is retained for forensic
|
|
116
|
+
* queries that want to distinguish the two.
|
|
117
|
+
*/
|
|
118
|
+
delegation_tool: DelegationTool;
|
|
119
|
+
/**
|
|
120
|
+
* For `Agent`: the value of `tool_input.subagent_type` at the hook
|
|
121
|
+
* (e.g. `'rea-orchestrator'`).
|
|
122
|
+
*
|
|
123
|
+
* For `Skill`: the value of `tool_input.skill` (e.g. `'deep-dive'`).
|
|
124
|
+
*
|
|
125
|
+
* Always passed through `redactSecrets` before landing here. If a
|
|
126
|
+
* planted secret pattern fires, this field is `'[REDACTED]'` and
|
|
127
|
+
* the matching pattern name appears in the record's
|
|
128
|
+
* `redacted_fields` envelope.
|
|
129
|
+
*
|
|
130
|
+
* Reader CLI groups records on this field.
|
|
131
|
+
*/
|
|
132
|
+
subagent_type: string;
|
|
133
|
+
/**
|
|
134
|
+
* The session id Claude Code attached to the hook payload — the same
|
|
135
|
+
* value the harness uses for its own correlation. Captured verbatim
|
|
136
|
+
* so a future per-session breakdown (deferred to 0.29.1) can group
|
|
137
|
+
* records without scanning the entire chain.
|
|
138
|
+
*
|
|
139
|
+
* Distinct from the audit envelope's `session_id`, which uses the
|
|
140
|
+
* caller's session ("external" for the CLI subcommand). The
|
|
141
|
+
* envelope's `session_id` says WHO wrote the record; this field says
|
|
142
|
+
* WHO Claude Code thinks is delegating.
|
|
143
|
+
*/
|
|
144
|
+
session_id_observed: string;
|
|
145
|
+
/**
|
|
146
|
+
* When the dispatching agent is itself a subagent, this is the
|
|
147
|
+
* parent's subagent_type at hook-fire time. Drawn from
|
|
148
|
+
* `CLAUDE_PARENT_SUBAGENT` / `tool_input.parent_subagent_type` when
|
|
149
|
+
* present; `null` for top-level dispatches.
|
|
150
|
+
*
|
|
151
|
+
* Like `subagent_type`, redacted before landing here.
|
|
152
|
+
*/
|
|
153
|
+
parent_subagent_type: string | null;
|
|
154
|
+
/**
|
|
155
|
+
* SHA-256 hex digest of `tool_input.description` (Agent) or
|
|
156
|
+
* `tool_input.prompt` (Skill). When neither is present an empty
|
|
157
|
+
* string is hashed — the resulting digest is the well-known
|
|
158
|
+
* `e3b0c4...` constant, which readers can recognize as "no prompt".
|
|
159
|
+
*
|
|
160
|
+
* # Why hash, not redact
|
|
161
|
+
*
|
|
162
|
+
* The prompt is the actionable content of the delegation — it
|
|
163
|
+
* routinely names files, customers, internal URLs, half-finished
|
|
164
|
+
* thoughts. Redacting it via pattern-matching is best-effort; hashing
|
|
165
|
+
* it is total. The collision-resistance of SHA-256 still lets two
|
|
166
|
+
* identical prompts produce identical hashes, which is enough for
|
|
167
|
+
* the delegation-pattern queries this telemetry exists to support.
|
|
168
|
+
*/
|
|
169
|
+
invocation_description_sha256: string;
|
|
170
|
+
/**
|
|
171
|
+
* ISO-8601 timestamp Claude Code attached to the hook event, when
|
|
172
|
+
* present. Distinct from the audit-record envelope's `timestamp`
|
|
173
|
+
* (which is the moment the CLI subcommand wrote the line). Both
|
|
174
|
+
* fields are useful: the envelope timestamp orders the chain,
|
|
175
|
+
* `hook_event_timestamp` orders the underlying events.
|
|
176
|
+
*/
|
|
177
|
+
hook_event_timestamp?: string;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Strict-mode zod schema for the metadata payload. Unknown fields are
|
|
181
|
+
* rejected — a future v2 producer must bump `DELEGATION_SIGNAL_SCHEMA_VERSION`
|
|
182
|
+
* AND update this schema in the same commit, otherwise v1 readers fail
|
|
183
|
+
* loud rather than silently dropping new fields.
|
|
184
|
+
*
|
|
185
|
+
* The schema is exported so the CLI subcommand validates its OWN
|
|
186
|
+
* emitted metadata before passing it to `appendAuditRecord` — defense
|
|
187
|
+
* in depth against a future refactor that wires the field set
|
|
188
|
+
* incorrectly. (Same posture as `loadPolicy` self-validation.)
|
|
189
|
+
*/
|
|
190
|
+
export declare const DelegationSignalMetadataSchema: z.ZodObject<{
|
|
191
|
+
schema_version: z.ZodLiteral<1>;
|
|
192
|
+
delegation_tool: z.ZodUnion<[z.ZodLiteral<"Agent">, z.ZodLiteral<"Skill">]>;
|
|
193
|
+
subagent_type: z.ZodString;
|
|
194
|
+
session_id_observed: z.ZodString;
|
|
195
|
+
parent_subagent_type: z.ZodUnion<[z.ZodString, z.ZodNull]>;
|
|
196
|
+
invocation_description_sha256: z.ZodString;
|
|
197
|
+
hook_event_timestamp: z.ZodOptional<z.ZodString>;
|
|
198
|
+
}, "strict", z.ZodTypeAny, {
|
|
199
|
+
schema_version: 1;
|
|
200
|
+
delegation_tool: "Agent" | "Skill";
|
|
201
|
+
subagent_type: string;
|
|
202
|
+
session_id_observed: string;
|
|
203
|
+
parent_subagent_type: string | null;
|
|
204
|
+
invocation_description_sha256: string;
|
|
205
|
+
hook_event_timestamp?: string | undefined;
|
|
206
|
+
}, {
|
|
207
|
+
schema_version: 1;
|
|
208
|
+
delegation_tool: "Agent" | "Skill";
|
|
209
|
+
subagent_type: string;
|
|
210
|
+
session_id_observed: string;
|
|
211
|
+
parent_subagent_type: string | null;
|
|
212
|
+
invocation_description_sha256: string;
|
|
213
|
+
hook_event_timestamp?: string | undefined;
|
|
214
|
+
}>;
|
|
215
|
+
export type DelegationSignalMetadataParsed = z.infer<typeof DelegationSignalMetadataSchema>;
|