@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.
Files changed (38) hide show
  1. package/.husky/prepare-commit-msg +295 -0
  2. package/MIGRATING.md +75 -0
  3. package/dist/audit/append.d.ts +1 -0
  4. package/dist/audit/append.js +1 -0
  5. package/dist/audit/delegation-event.d.ts +215 -0
  6. package/dist/audit/delegation-event.js +113 -0
  7. package/dist/cli/audit-specialists.d.ts +113 -0
  8. package/dist/cli/audit-specialists.js +220 -0
  9. package/dist/cli/doctor.d.ts +114 -1
  10. package/dist/cli/doctor.js +523 -5
  11. package/dist/cli/hook.d.ts +40 -8
  12. package/dist/cli/hook.js +305 -8
  13. package/dist/cli/index.js +9 -0
  14. package/dist/cli/init.js +120 -0
  15. package/dist/cli/install/manifest-schema.d.ts +6 -6
  16. package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
  17. package/dist/cli/install/prepare-commit-msg.js +208 -0
  18. package/dist/cli/install/settings-merge.js +20 -0
  19. package/dist/cli/upgrade.js +34 -0
  20. package/dist/config/settings-schema.d.ts +2087 -0
  21. package/dist/config/settings-schema.js +294 -0
  22. package/dist/config/tier-map.js +22 -1
  23. package/dist/policy/loader.d.ts +58 -0
  24. package/dist/policy/loader.js +68 -0
  25. package/dist/policy/profiles.d.ts +48 -0
  26. package/dist/policy/profiles.js +25 -0
  27. package/dist/policy/types.d.ts +51 -0
  28. package/dist/registry/loader.d.ts +12 -12
  29. package/hooks/delegation-capture.sh +158 -0
  30. package/package.json +1 -1
  31. package/profiles/bst-internal-no-codex.yaml +15 -0
  32. package/profiles/bst-internal.yaml +16 -0
  33. package/profiles/client-engagement.yaml +14 -0
  34. package/profiles/lit-wc.yaml +14 -0
  35. package/profiles/minimal.yaml +16 -0
  36. package/profiles/open-source-no-codex.yaml +13 -0
  37. package/profiles/open-source.yaml +13 -0
  38. 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:
@@ -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';
@@ -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>;