@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
@@ -18,20 +18,20 @@ declare const RegistryServerSchema: z.ZodObject<{
18
18
  enabled: z.ZodDefault<z.ZodBoolean>;
19
19
  }, "strict", z.ZodTypeAny, {
20
20
  name: string;
21
+ enabled: boolean;
22
+ env: Record<string, string>;
21
23
  command: string;
22
24
  args: string[];
23
- env: Record<string, string>;
24
- enabled: boolean;
25
25
  env_passthrough?: string[] | undefined;
26
26
  tier_overrides?: Record<string, Tier> | undefined;
27
27
  }, {
28
28
  name: string;
29
29
  command: string;
30
- args?: string[] | undefined;
30
+ enabled?: boolean | undefined;
31
31
  env?: Record<string, string> | undefined;
32
+ args?: string[] | undefined;
32
33
  env_passthrough?: string[] | undefined;
33
34
  tier_overrides?: Record<string, Tier> | undefined;
34
- enabled?: boolean | undefined;
35
35
  }>;
36
36
  declare const RegistrySchema: z.ZodObject<{
37
37
  version: z.ZodLiteral<"1">;
@@ -45,30 +45,30 @@ declare const RegistrySchema: z.ZodObject<{
45
45
  enabled: z.ZodDefault<z.ZodBoolean>;
46
46
  }, "strict", z.ZodTypeAny, {
47
47
  name: string;
48
+ enabled: boolean;
49
+ env: Record<string, string>;
48
50
  command: string;
49
51
  args: string[];
50
- env: Record<string, string>;
51
- enabled: boolean;
52
52
  env_passthrough?: string[] | undefined;
53
53
  tier_overrides?: Record<string, Tier> | undefined;
54
54
  }, {
55
55
  name: string;
56
56
  command: string;
57
- args?: string[] | undefined;
57
+ enabled?: boolean | undefined;
58
58
  env?: Record<string, string> | undefined;
59
+ args?: string[] | undefined;
59
60
  env_passthrough?: string[] | undefined;
60
61
  tier_overrides?: Record<string, Tier> | undefined;
61
- enabled?: boolean | undefined;
62
62
  }>, "many">>;
63
63
  reviewer: z.ZodOptional<z.ZodEnum<["codex", "claude-self"]>>;
64
64
  }, "strict", z.ZodTypeAny, {
65
65
  version: "1";
66
66
  servers: {
67
67
  name: string;
68
+ enabled: boolean;
69
+ env: Record<string, string>;
68
70
  command: string;
69
71
  args: string[];
70
- env: Record<string, string>;
71
- enabled: boolean;
72
72
  env_passthrough?: string[] | undefined;
73
73
  tier_overrides?: Record<string, Tier> | undefined;
74
74
  }[];
@@ -78,11 +78,11 @@ declare const RegistrySchema: z.ZodObject<{
78
78
  servers?: {
79
79
  name: string;
80
80
  command: string;
81
- args?: string[] | undefined;
81
+ enabled?: boolean | undefined;
82
82
  env?: Record<string, string> | undefined;
83
+ args?: string[] | undefined;
83
84
  env_passthrough?: string[] | undefined;
84
85
  tier_overrides?: Record<string, Tier> | undefined;
85
- enabled?: boolean | undefined;
86
86
  }[] | undefined;
87
87
  reviewer?: "codex" | "claude-self" | undefined;
88
88
  }>;
@@ -0,0 +1,158 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: delegation-capture.sh
3
+ # 0.29.0+ — delegation-telemetry MVP.
4
+ #
5
+ # Fires BEFORE every `Agent` or `Skill` tool call. Reads the Claude
6
+ # Code hook payload from stdin, pipes it to
7
+ # `rea hook delegation-signal --detach`, and exits 0 immediately.
8
+ #
9
+ # The signal is OBSERVATIONAL — never gates tool dispatch. Worst-case
10
+ # latency budget is ~50ms even when the audit chain is under
11
+ # cross-process contention, because the audit append runs in the
12
+ # background (via `&`) and the CLI subcommand itself only validates
13
+ # the payload before forking the writer.
14
+ #
15
+ # Matcher: `Agent|Skill` (NOT `Task|Skill` — `TaskCreate`/`TaskList`
16
+ # are the unrelated todo-list tools and MUST NOT match).
17
+ #
18
+ # # CLI-resolution trust boundary
19
+ #
20
+ # Codex round 3 P1 (2026-05-12): pre-fix this hook resolved the rea
21
+ # binary via `$REA_ROOT/node_modules/.bin/rea` then PATH-walked
22
+ # `command -v rea`. Either path was attacker-influenced in a consumer
23
+ # repo with a forged `node_modules/.bin/rea` symlink or a
24
+ # PATH-prepended fake `rea` binary — giving attacker-controlled code
25
+ # execution on every Agent/Skill dispatch.
26
+ #
27
+ # Fix: this hook now uses the same 2-tier sandboxed resolution that
28
+ # protected-paths-bash-gate.sh + blocked-paths-bash-gate.sh use:
29
+ # 1. node_modules/@bookedsolid/rea/dist/cli/index.js (consumer-side
30
+ # published artifact)
31
+ # 2. dist/cli/index.js under CLAUDE_PROJECT_DIR (the rea repo's own
32
+ # dogfood install)
33
+ #
34
+ # A realpath sandbox check ensures the resolved CLI lives INSIDE
35
+ # realpath(CLAUDE_PROJECT_DIR) — catches symlink-out attacks.
36
+ #
37
+ # Exit codes:
38
+ # 0 — always (under normal operation). Failure to write the audit
39
+ # signal must NEVER block Claude Code's tool dispatch. Stderr
40
+ # breadcrumbs surface diagnostic info to the operator. HALT
41
+ # still exits 2 because the kill-switch contract must hold.
42
+ # 2 — HALT active.
43
+
44
+ set -uo pipefail
45
+
46
+ # 1. HALT check. Even though this hook is observational, refusing to
47
+ # emit signals while frozen matches the rest of the hook tree and
48
+ # keeps the kill-switch contract uniform.
49
+ # shellcheck source=_lib/halt-check.sh
50
+ source "$(dirname "$0")/_lib/halt-check.sh"
51
+ check_halt
52
+ REA_ROOT=$(rea_root)
53
+
54
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
55
+
56
+ # 2. Resolve the rea CLI through the same fixed 2-tier sandboxed order
57
+ # the protected-paths / blocked-paths bash gates use. PATH lookup
58
+ # is INTENTIONALLY OMITTED — agent-controlled $PATH would let a
59
+ # forged `rea` binary on a consumer machine intercept the
60
+ # delegation signal on every Agent/Skill dispatch. The trade-off:
61
+ # consumers MUST have `@bookedsolid/rea` installed under
62
+ # `node_modules` (the common case after `pnpm i`) OR be running
63
+ # against the rea repo's own dogfood (where dist/cli/index.js
64
+ # holds the canonical CLI). Other install shapes silently drop the
65
+ # signal — matching the bash-gate posture.
66
+ REA_ARGV=()
67
+ RESOLVED_CLI_PATH=""
68
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
69
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
70
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
71
+ elif [ -f "$proj/dist/cli/index.js" ]; then
72
+ # rea repo dogfood: the project IS @bookedsolid/rea.
73
+ REA_ARGV=(node "$proj/dist/cli/index.js")
74
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
75
+ fi
76
+
77
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
78
+ # No rea CLI in scope — drop the signal silently. This is the
79
+ # expected state during bootstrap (consumer ran `rea init` but
80
+ # hasn't installed the npm package yet) or in non-rea repos. A
81
+ # noisy stderr warning here would fire on every Agent/Skill
82
+ # dispatch and drown legitimate signals.
83
+ exit 0
84
+ fi
85
+
86
+ # 3. Realpath sandbox check — mirrors protected-paths-bash-gate.sh §6.
87
+ # The resolved CLI MUST live inside realpath(CLAUDE_PROJECT_DIR)
88
+ # AND have an ancestor package.json declaring `@bookedsolid/rea`
89
+ # as its `name`. Catches symlink-out attacks where an attacker
90
+ # writes node_modules/@bookedsolid/rea → /tmp/forged-tree.
91
+ if ! command -v node >/dev/null 2>&1; then
92
+ # Node not on PATH — we can't verify the CLI shape. Fail safe by
93
+ # dropping the signal (observability is not a security claim; the
94
+ # rest of the Bash gate suite refuses on this path).
95
+ exit 0
96
+ fi
97
+
98
+ sandbox_check=$(node -e '
99
+ const fs = require("fs");
100
+ const path = require("path");
101
+ const cli = process.argv[1];
102
+ const projDir = process.argv[2];
103
+ let real, realProj;
104
+ try { real = fs.realpathSync(cli); } catch (e) {
105
+ process.stdout.write("bad:realpath");
106
+ process.exit(1);
107
+ }
108
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
109
+ process.stdout.write("bad:realpath-proj");
110
+ process.exit(1);
111
+ }
112
+ const sep = path.sep;
113
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
114
+ if (!(real === realProj || real.startsWith(projWithSep))) {
115
+ process.stdout.write("bad:cli-escapes-project");
116
+ process.exit(1);
117
+ }
118
+ // Walk up looking for package.json with the protected name.
119
+ let cur = path.dirname(path.dirname(path.dirname(real))); // pkg root
120
+ let found = false;
121
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
122
+ const pj = path.join(cur, "package.json");
123
+ if (fs.existsSync(pj)) {
124
+ try {
125
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
126
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
127
+ } catch (e) { /* keep walking */ }
128
+ }
129
+ cur = path.dirname(cur);
130
+ }
131
+ if (!found) {
132
+ process.stdout.write("bad:no-rea-pkg-json");
133
+ process.exit(1);
134
+ }
135
+ process.stdout.write("ok");
136
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
137
+
138
+ if [ "$sandbox_check" != "ok" ]; then
139
+ # CLI failed the sandbox check — silent drop. The forensic
140
+ # breadcrumb in stderr is intentional but trimmed so this doesn't
141
+ # become spammy on every dispatch.
142
+ printf 'rea: delegation-capture skipped (sandbox check: %s)\n' "$sandbox_check" >&2
143
+ exit 0
144
+ fi
145
+
146
+ # 4. Read stdin and pipe to the CLI. `--detach` tells the CLI to
147
+ # suppress stderr output (no parent shell is listening); we ALSO
148
+ # background the whole pipeline with `&` and `disown` so the
149
+ # shell hook returns instantly even if the CLI's own startup
150
+ # takes a few ms.
151
+ INPUT=$(cat)
152
+ {
153
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook delegation-signal --detach \
154
+ >/dev/null 2>&1 &
155
+ disown 2>/dev/null || true
156
+ } 2>/dev/null
157
+
158
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.28.2",
3
+ "version": "0.30.0",
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)",
@@ -43,3 +43,18 @@ context_protection:
43
43
  - pnpm run test
44
44
  - pnpm run lint
45
45
  max_bash_output_lines: 100
46
+ # 0.30.0 attribution augmenter — opt-in.
47
+ # Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
48
+ # every commit when enabled. Even the bst-internal profile ships
49
+ # `enabled: false` so other BST contributors using the profile don't
50
+ # silently route their commits onto the profile author's GitHub
51
+ # heatmap; opt-in lives in repo-local edits to .rea/policy.yaml.
52
+ # attribution:
53
+ # co_author:
54
+ # enabled: true
55
+ # name: 'Your Name'
56
+ # email: 'you@example.com'
57
+ # skip_merge: false
58
+ attribution:
59
+ co_author:
60
+ enabled: false
@@ -51,3 +51,19 @@ architecture_review:
51
51
  - hooks/_lib/
52
52
  - templates/
53
53
  - profiles/
54
+ # 0.30.0 attribution augmenter — opt-in.
55
+ # Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
56
+ # every commit when enabled. Even the bst-internal profile ships
57
+ # `enabled: false` so other BST contributors using the profile don't
58
+ # silently route their commits onto the profile author's GitHub
59
+ # heatmap; opt-in lives in repo-local edits to .rea/policy.yaml
60
+ # because the identity to roll commits onto is per-developer.
61
+ # attribution:
62
+ # co_author:
63
+ # enabled: true
64
+ # name: 'Your Name'
65
+ # email: 'you@example.com'
66
+ # skip_merge: false
67
+ attribution:
68
+ co_author:
69
+ enabled: false
@@ -21,3 +21,17 @@ context_protection:
21
21
  - pnpm run build
22
22
  - pnpm run test
23
23
  max_bash_output_lines: 100
24
+ # 0.30.0 attribution augmenter — opt-in.
25
+ # Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
26
+ # every commit when enabled. client-engagement profile ships
27
+ # `enabled: false`; opt-in lives in repo-local edits to .rea/policy.yaml
28
+ # because the identity to roll commits onto is per-developer.
29
+ # attribution:
30
+ # co_author:
31
+ # enabled: true
32
+ # name: 'Your Name'
33
+ # email: 'you@example.com'
34
+ # skip_merge: false
35
+ attribution:
36
+ co_author:
37
+ enabled: false
@@ -15,3 +15,17 @@ blocked_paths:
15
15
  - .github/workflows/publish.yml
16
16
  - tokens/
17
17
  notification_channel: ''
18
+ # 0.30.0 attribution augmenter — opt-in.
19
+ # Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
20
+ # every commit when enabled. lit-wc profile ships `enabled: false`;
21
+ # opt-in lives in repo-local edits to .rea/policy.yaml because the
22
+ # identity to roll commits onto is per-developer.
23
+ # attribution:
24
+ # co_author:
25
+ # enabled: true
26
+ # name: 'Your Name'
27
+ # email: 'you@example.com'
28
+ # skip_merge: false
29
+ attribution:
30
+ co_author:
31
+ enabled: false
@@ -9,3 +9,19 @@ blocked_paths:
9
9
  - .env
10
10
  - .env.*
11
11
  notification_channel: ''
12
+ # 0.30.0 attribution augmenter — opt-in.
13
+ # When enabled: true, the husky prepare-commit-msg hook appends a
14
+ # Co-Authored-By trailer to every commit (or every non-merge commit
15
+ # when skip_merge: true). Idempotent on email match (case-insensitive).
16
+ # Profile defaults are off; opt in per-repo via .rea/policy.yaml —
17
+ # the identity to roll commits onto is per-developer.
18
+ #
19
+ # attribution:
20
+ # co_author:
21
+ # enabled: true
22
+ # name: 'Your Name'
23
+ # email: 'you@example.com'
24
+ # skip_merge: false
25
+ attribution:
26
+ co_author:
27
+ enabled: false
@@ -31,3 +31,16 @@ blocked_paths:
31
31
  - .github/workflows/release.yml
32
32
  - .github/workflows/publish.yml
33
33
  notification_channel: ''
34
+ # 0.30.0 attribution augmenter — opt-in.
35
+ # Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
36
+ # every commit when enabled. Profile defaults are off — opt in
37
+ # per-repo via .rea/policy.yaml because the identity is per-developer.
38
+ # attribution:
39
+ # co_author:
40
+ # enabled: true
41
+ # name: 'Your Name'
42
+ # email: 'you@example.com'
43
+ # skip_merge: false
44
+ attribution:
45
+ co_author:
46
+ enabled: false
@@ -16,3 +16,16 @@ blocked_paths:
16
16
  - .github/workflows/release.yml
17
17
  - .github/workflows/publish.yml
18
18
  notification_channel: ''
19
+ # 0.30.0 attribution augmenter — opt-in.
20
+ # Husky prepare-commit-msg hook appends a Co-Authored-By trailer to
21
+ # every commit when enabled. Profile defaults are off — opt in
22
+ # per-repo via .rea/policy.yaml because the identity is per-developer.
23
+ # attribution:
24
+ # co_author:
25
+ # enabled: true
26
+ # name: 'Your Name'
27
+ # email: 'you@example.com'
28
+ # skip_merge: false
29
+ attribution:
30
+ co_author:
31
+ enabled: false
@@ -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