@bookedsolid/rea 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Class M — `.claude/settings.json` zod schema (0.30.0+).
3
+ *
4
+ * Consumer-side validation for the Claude Code harness `settings.json`
5
+ * file. The harness itself accepts a JSON object with a documented
6
+ * shape; rea adds a strict subset of that shape so a typo in a hook
7
+ * registration ("statusMessasge", "PreToolUze") doesn't silently
8
+ * disable a load-bearing rea hook on a consumer's install.
9
+ *
10
+ * Two modes:
11
+ * - Default (`mode: 'warn'`) — `rea doctor` logs a warn for any
12
+ * unknown top-level key, unknown hook event, or unrecognized
13
+ * matcher pattern. Existing consumer files that haven't seen the
14
+ * schema before keep working without action.
15
+ * - Strict (`mode: 'strict'`) — `rea doctor --strict` fails on any
16
+ * zod failure, on path-traversal in a `command` value, and when
17
+ * a rea-shipped hook (`EXPECTED_HOOKS`) is missing from the
18
+ * PreToolUse / PostToolUse registrations. Used by CI gates that
19
+ * want a hard floor.
20
+ *
21
+ * Path-traversal is checked OUTSIDE zod (see `validateNoTraversal`)
22
+ * — zod's job is shape; ours is to make sure a `command` literally
23
+ * cannot reference `..` after stripping `$CLAUDE_PROJECT_DIR`. The
24
+ * harness expands the variable at exec time, so the on-disk value
25
+ * is the right anchor for the check.
26
+ */
27
+ import { z } from 'zod';
28
+ import { EXPECTED_HOOKS } from '../cli/doctor.js';
29
+ import { defaultDesiredHooks } from '../cli/install/settings-merge.js';
30
+ /**
31
+ * Hook event names the harness honors. Strict union so a typo
32
+ * ("PreToolUze") fails closed. The union mirrors Anthropic's
33
+ * published list as of 2026-05; new events get added here as the
34
+ * harness ships them.
35
+ */
36
+ export const HOOK_EVENT_NAMES = [
37
+ 'PreToolUse',
38
+ 'PostToolUse',
39
+ 'UserPromptSubmit',
40
+ 'Stop',
41
+ 'SubagentStop',
42
+ 'PreCompact',
43
+ 'Notification',
44
+ 'SessionStart',
45
+ ];
46
+ /**
47
+ * Hook-command entry. `statusMessage` is REQUIRED to ALLOW (not to
48
+ * require) because every canonical entry rea ships carries one — the
49
+ * harness uses it for the spinner status line, and omitting it would
50
+ * be technically valid but degrade UX. `timeout` is optional; when
51
+ * present must be a positive int up to the harness ceiling of
52
+ * 600_000 ms (10 minutes).
53
+ */
54
+ export const HookCommandSchema = z
55
+ .object({
56
+ type: z.literal('command'),
57
+ command: z.string().min(1, 'hook command must be a non-empty string'),
58
+ timeout: z.number().int().positive().max(600_000).optional(),
59
+ statusMessage: z.string().optional(),
60
+ })
61
+ .strict();
62
+ /**
63
+ * Hook entry group: a matcher pattern (string like `Bash` or
64
+ * `Write|Edit|MultiEdit|NotebookEdit`) plus its list of hook commands.
65
+ * The matcher is opaque to the schema — the harness parses it as a
66
+ * pipe-separated tool-name list. Empty matcher is rejected.
67
+ */
68
+ export const HookEntrySchema = z
69
+ .object({
70
+ matcher: z.string().min(1, 'hook matcher must be a non-empty string'),
71
+ hooks: z.array(HookCommandSchema).min(1, 'each matcher must register at least one hook'),
72
+ })
73
+ .strict();
74
+ /**
75
+ * Top-level `.claude/settings.json` shape.
76
+ *
77
+ * `permissions` is pass-through (`z.record`) because it carries
78
+ * harness-defined structure that rea is not the source of truth for.
79
+ * `env` is a string-to-string map. `model` is a free string (the
80
+ * harness validates the model name at runtime).
81
+ *
82
+ * Each hook-event field is strict — a typo in the event name fails
83
+ * the parse instead of silently registering on a phantom event.
84
+ */
85
+ export const SettingsSchema = z
86
+ .object({
87
+ env: z.record(z.string()).optional(),
88
+ permissions: z.unknown().optional(),
89
+ model: z.string().optional(),
90
+ hooks: z
91
+ .object({
92
+ PreToolUse: z.array(HookEntrySchema).optional(),
93
+ PostToolUse: z.array(HookEntrySchema).optional(),
94
+ UserPromptSubmit: z.array(HookEntrySchema).optional(),
95
+ Stop: z.array(HookEntrySchema).optional(),
96
+ SubagentStop: z.array(HookEntrySchema).optional(),
97
+ PreCompact: z.array(HookEntrySchema).optional(),
98
+ Notification: z.array(HookEntrySchema).optional(),
99
+ SessionStart: z.array(HookEntrySchema).optional(),
100
+ })
101
+ .strict()
102
+ .optional(),
103
+ })
104
+ // Codex round 4 P1: top-level is .passthrough(), NOT .strict().
105
+ // Claude Code keeps adding harness-side top-level keys (model,
106
+ // permissions, env, future entries) and rea is NOT the source of
107
+ // truth for them. A strict top-level would refuse to validate the
108
+ // moment Claude Code ships a new key, breaking `rea upgrade` for
109
+ // every consumer mid-version. Hook events are still strict (a
110
+ // matcher typo in a known event must fail loudly).
111
+ .passthrough();
112
+ /**
113
+ * Strict variant for `rea doctor --strict`. Same shape as `SettingsSchema`
114
+ * but rejects unknown top-level keys. Used only by the CI-gate path,
115
+ * never by `rea upgrade`. Exported so the doctor can opt into stricter
116
+ * validation when consumers explicitly request it.
117
+ */
118
+ export const SettingsSchemaStrict = SettingsSchema.extend({}).strict();
119
+ const TRAVERSAL_RE = /\.\.[/\\]/;
120
+ export function validateNoTraversal(settings) {
121
+ const findings = [];
122
+ const hooks = settings.hooks;
123
+ if (hooks === undefined)
124
+ return findings;
125
+ for (const event of HOOK_EVENT_NAMES) {
126
+ const entries = hooks[event];
127
+ if (!Array.isArray(entries))
128
+ continue;
129
+ for (const group of entries) {
130
+ for (let i = 0; i < group.hooks.length; i += 1) {
131
+ const hook = group.hooks[i];
132
+ const stripped = hook.command
133
+ .replace(/"\$CLAUDE_PROJECT_DIR"/g, '')
134
+ .replace(/\$CLAUDE_PROJECT_DIR/g, '');
135
+ if (TRAVERSAL_RE.test(stripped)) {
136
+ findings.push({
137
+ event,
138
+ matcher: group.matcher,
139
+ index: i,
140
+ command: hook.command,
141
+ reason: 'contains `..` path segment outside $CLAUDE_PROJECT_DIR',
142
+ });
143
+ }
144
+ }
145
+ }
146
+ }
147
+ return findings;
148
+ }
149
+ /**
150
+ * Validate a parsed JSON object against the settings schema.
151
+ *
152
+ * Strategy: try `SettingsSchema.parse(input)`. On success run the
153
+ * traversal + missing-hooks checks. On failure fall back to a
154
+ * best-effort scan of any `hooks: { PreToolUse: [...] }` shape we
155
+ * recognize, so the operator still sees traversal + missing-hooks
156
+ * findings.
157
+ */
158
+ export function validateSettings(input) {
159
+ const result = {
160
+ parsed: false,
161
+ settings: null,
162
+ errors: [],
163
+ traversalFindings: [],
164
+ missingReaHooks: [],
165
+ warnings: [],
166
+ };
167
+ const parsed = SettingsSchema.safeParse(input);
168
+ if (parsed.success) {
169
+ result.parsed = true;
170
+ result.settings = parsed.data;
171
+ result.traversalFindings = validateNoTraversal(parsed.data);
172
+ result.missingReaHooks = findMissingReaHooks(parsed.data);
173
+ }
174
+ else {
175
+ result.errors = parsed.error.issues.map((i) => `${i.path.length > 0 ? `${i.path.join('.')}: ` : ''}${i.message}`);
176
+ // Best-effort fallback scan on the raw input so the operator sees
177
+ // every finding in one shot.
178
+ const recovered = recoverSettingsShape(input);
179
+ if (recovered !== null) {
180
+ result.traversalFindings = validateNoTraversal(recovered);
181
+ result.missingReaHooks = findMissingReaHooks(recovered);
182
+ }
183
+ }
184
+ return result;
185
+ }
186
+ /**
187
+ * Cross-check: every name in `EXPECTED_HOOKS` should appear as the
188
+ * basename of at least one `command` across PreToolUse + PostToolUse.
189
+ * Returns the missing list (empty when complete).
190
+ *
191
+ * Defensive: `EXPECTED_HOOKS` is sourced from `doctor.ts` so the
192
+ * single edit-point is preserved (new hook adds → both doctor's
193
+ * file-existence check AND this schema check pick it up).
194
+ */
195
+ export function findMissingReaHooks(settings) {
196
+ const registeredCommands = new Set();
197
+ const hooks = settings.hooks;
198
+ if (hooks !== undefined) {
199
+ for (const event of ['PreToolUse', 'PostToolUse']) {
200
+ const entries = hooks[event];
201
+ if (!Array.isArray(entries))
202
+ continue;
203
+ for (const group of entries) {
204
+ for (const hook of group.hooks) {
205
+ registeredCommands.add(hook.command);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ const missing = [];
211
+ for (const name of EXPECTED_HOOKS) {
212
+ const found = Array.from(registeredCommands).some((cmd) => cmd.endsWith(`/${name}`));
213
+ if (!found)
214
+ missing.push(name);
215
+ }
216
+ return missing;
217
+ }
218
+ /**
219
+ * Compute the desired-hooks registration that `rea init` would emit
220
+ * for a fresh install. Exported so `rea doctor --strict` can show the
221
+ * operator the exact set the schema is enforcing without forcing them
222
+ * to dig through source.
223
+ */
224
+ export function expectedHookNames() {
225
+ // Single source of truth: the canonical desired-hooks list.
226
+ const out = new Set();
227
+ for (const group of defaultDesiredHooks()) {
228
+ for (const hook of group.hooks) {
229
+ const tail = hook.command.split('/').pop() ?? '';
230
+ if (tail.length > 0)
231
+ out.add(tail);
232
+ }
233
+ }
234
+ return Array.from(out).sort();
235
+ }
236
+ /**
237
+ * Best-effort shape recovery when zod parse fails. Walks `input` and
238
+ * extracts as much of the `Settings` shape as possible, dropping any
239
+ * field that doesn't structurally match. Used ONLY to feed the
240
+ * traversal + missing-hooks checks — never returned to the caller as
241
+ * a "parsed" result.
242
+ */
243
+ function recoverSettingsShape(input) {
244
+ if (input === null || typeof input !== 'object')
245
+ return null;
246
+ const o = input;
247
+ const hooks = o['hooks'];
248
+ if (hooks === null || typeof hooks !== 'object')
249
+ return null;
250
+ const h = hooks;
251
+ const recovered = { hooks: {} };
252
+ const recoveredHooks = {};
253
+ for (const event of HOOK_EVENT_NAMES) {
254
+ const entries = h[event];
255
+ if (!Array.isArray(entries))
256
+ continue;
257
+ const goodEntries = [];
258
+ for (const entry of entries) {
259
+ if (entry === null || typeof entry !== 'object')
260
+ continue;
261
+ const e = entry;
262
+ if (typeof e.matcher !== 'string')
263
+ continue;
264
+ if (!Array.isArray(e.hooks))
265
+ continue;
266
+ const goodHooks = [];
267
+ for (const hk of e.hooks) {
268
+ if (hk === null || typeof hk !== 'object')
269
+ continue;
270
+ const c = hk;
271
+ if (c.type !== 'command')
272
+ continue;
273
+ if (typeof c.command !== 'string' || c.command.length === 0)
274
+ continue;
275
+ const out = { type: 'command', command: c.command };
276
+ if (typeof c.timeout === 'number' && Number.isInteger(c.timeout) && c.timeout > 0) {
277
+ out.timeout = c.timeout;
278
+ }
279
+ if (typeof c.statusMessage === 'string') {
280
+ out.statusMessage = c.statusMessage;
281
+ }
282
+ goodHooks.push(out);
283
+ }
284
+ if (goodHooks.length > 0) {
285
+ goodEntries.push({ matcher: e.matcher, hooks: goodHooks });
286
+ }
287
+ }
288
+ if (goodEntries.length > 0) {
289
+ recoveredHooks[event] = goodEntries;
290
+ }
291
+ }
292
+ recovered.hooks = recoveredHooks;
293
+ return recovered;
294
+ }
@@ -246,6 +246,48 @@ declare const PolicySchema: z.ZodObject<{
246
246
  warn_at_commits?: number | undefined;
247
247
  refuse_at_commits?: number | undefined;
248
248
  }>>;
249
+ attribution: z.ZodOptional<z.ZodObject<{
250
+ co_author: z.ZodOptional<z.ZodEffects<z.ZodObject<{
251
+ enabled: z.ZodOptional<z.ZodBoolean>;
252
+ name: z.ZodOptional<z.ZodString>;
253
+ email: z.ZodUnion<[z.ZodOptional<z.ZodString>, z.ZodLiteral<"">]>;
254
+ skip_merge: z.ZodOptional<z.ZodBoolean>;
255
+ }, "strict", z.ZodTypeAny, {
256
+ name?: string | undefined;
257
+ enabled?: boolean | undefined;
258
+ email?: string | undefined;
259
+ skip_merge?: boolean | undefined;
260
+ }, {
261
+ name?: string | undefined;
262
+ enabled?: boolean | undefined;
263
+ email?: string | undefined;
264
+ skip_merge?: boolean | undefined;
265
+ }>, {
266
+ name?: string | undefined;
267
+ enabled?: boolean | undefined;
268
+ email?: string | undefined;
269
+ skip_merge?: boolean | undefined;
270
+ }, {
271
+ name?: string | undefined;
272
+ enabled?: boolean | undefined;
273
+ email?: string | undefined;
274
+ skip_merge?: boolean | undefined;
275
+ }>>;
276
+ }, "strict", z.ZodTypeAny, {
277
+ co_author?: {
278
+ name?: string | undefined;
279
+ enabled?: boolean | undefined;
280
+ email?: string | undefined;
281
+ skip_merge?: boolean | undefined;
282
+ } | undefined;
283
+ }, {
284
+ co_author?: {
285
+ name?: string | undefined;
286
+ enabled?: boolean | undefined;
287
+ email?: string | undefined;
288
+ skip_merge?: boolean | undefined;
289
+ } | undefined;
290
+ }>>;
249
291
  }, "strict", z.ZodTypeAny, {
250
292
  version: string;
251
293
  profile: string;
@@ -311,6 +353,14 @@ declare const PolicySchema: z.ZodObject<{
311
353
  warn_at_commits?: number | undefined;
312
354
  refuse_at_commits?: number | undefined;
313
355
  } | undefined;
356
+ attribution?: {
357
+ co_author?: {
358
+ name?: string | undefined;
359
+ enabled?: boolean | undefined;
360
+ email?: string | undefined;
361
+ skip_merge?: boolean | undefined;
362
+ } | undefined;
363
+ } | undefined;
314
364
  }, {
315
365
  version: string;
316
366
  profile: string;
@@ -376,6 +426,14 @@ declare const PolicySchema: z.ZodObject<{
376
426
  warn_at_commits?: number | undefined;
377
427
  refuse_at_commits?: number | undefined;
378
428
  } | undefined;
429
+ attribution?: {
430
+ co_author?: {
431
+ name?: string | undefined;
432
+ enabled?: boolean | undefined;
433
+ email?: string | undefined;
434
+ skip_merge?: boolean | undefined;
435
+ } | undefined;
436
+ } | undefined;
379
437
  }>;
380
438
  /**
381
439
  * Async policy loader with TTL cache and mtime-based invalidation.
@@ -212,6 +212,69 @@ const GatewayPolicySchema = z
212
212
  health: GatewayHealthPolicySchema.optional(),
213
213
  })
214
214
  .strict();
215
+ /**
216
+ * 0.30.0 — attribution augmenter policy. The husky `prepare-commit-msg`
217
+ * hook appends a `Co-Authored-By: <name> <email>` trailer to every commit
218
+ * when `co_author.enabled: true`. Idempotent (skip if the email already
219
+ * appears on a `Co-Authored-By:` line, case-insensitive); skips merge
220
+ * commits when `skip_merge: true`.
221
+ *
222
+ * Cross-field refinement: when `enabled: true`, BOTH `name` AND `email`
223
+ * MUST be non-empty. Fail-closed at policy load — pre-fix a partial
224
+ * config (`enabled: true` with empty `email`) would silently fail at
225
+ * hook fire time, producing zero-name trailers and confusing audit
226
+ * records. Loud failure at load surfaces the misconfiguration immediately.
227
+ *
228
+ * Email validation is permissive (`<local>@<host>.<tld>` shape) — codex
229
+ * + claude + github noreply emails all pass; the only reject case is a
230
+ * malformed string (spaces, angle brackets, missing `@` or domain dot).
231
+ * Stricter validation is the consumer's job — RFC 5322 is too permissive
232
+ * for an opt-in audit footprint anyway.
233
+ */
234
+ const AttributionCoAuthorSchema = z
235
+ .object({
236
+ enabled: z.boolean().optional(),
237
+ name: z.string().optional(),
238
+ email: z
239
+ .string()
240
+ .regex(/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/, {
241
+ message: 'attribution.co_author.email must match <local>@<host>.<tld> ' +
242
+ '(no spaces, no angle brackets, must contain @ and a dot in the host)',
243
+ })
244
+ .optional()
245
+ .or(z.literal('')),
246
+ skip_merge: z.boolean().optional(),
247
+ })
248
+ .strict()
249
+ .superRefine((val, ctx) => {
250
+ if (val.enabled !== true)
251
+ return;
252
+ const name = (val.name ?? '').trim();
253
+ const email = (val.email ?? '').trim();
254
+ if (name.length === 0) {
255
+ ctx.addIssue({
256
+ code: z.ZodIssueCode.custom,
257
+ path: ['name'],
258
+ message: 'attribution.co_author.enabled: true requires a non-empty `name`. ' +
259
+ 'Either set `name: "Your Name"` and `email: "you@example.com"`, or ' +
260
+ 'set `enabled: false` to disable the augmenter.',
261
+ });
262
+ }
263
+ if (email.length === 0) {
264
+ ctx.addIssue({
265
+ code: z.ZodIssueCode.custom,
266
+ path: ['email'],
267
+ message: 'attribution.co_author.enabled: true requires a non-empty `email`. ' +
268
+ 'Either set `name: "Your Name"` and `email: "you@example.com"`, or ' +
269
+ 'set `enabled: false` to disable the augmenter.',
270
+ });
271
+ }
272
+ });
273
+ const AttributionPolicySchema = z
274
+ .object({
275
+ co_author: AttributionCoAuthorSchema.optional(),
276
+ })
277
+ .strict();
215
278
  const PolicySchema = z
216
279
  .object({
217
280
  version: z.string(),
@@ -259,6 +322,11 @@ const PolicySchema = z
259
322
  // 0.26.0 commit-hygiene thresholds — top-level so it's discoverable
260
323
  // separately from `review.local_review`. `rea preflight` consumes it.
261
324
  commit_hygiene: CommitHygienePolicySchema.optional(),
325
+ // 0.30.0 attribution augmenter — drives the husky
326
+ // `prepare-commit-msg` hook. The cross-field refinement on
327
+ // `AttributionCoAuthorSchema` fails closed when `enabled: true` but
328
+ // `name`/`email` are empty so we never ship a half-configured trailer.
329
+ attribution: AttributionPolicySchema.optional(),
262
330
  })
263
331
  .strict();
264
332
  const DEFAULT_CACHE_TTL_MS = 30_000;
@@ -76,6 +76,38 @@ export declare const ProfileSchema: z.ZodObject<{
76
76
  }, {
77
77
  patterns?: string[] | undefined;
78
78
  }>>;
79
+ attribution: z.ZodOptional<z.ZodObject<{
80
+ co_author: z.ZodOptional<z.ZodObject<{
81
+ enabled: z.ZodOptional<z.ZodBoolean>;
82
+ name: z.ZodOptional<z.ZodString>;
83
+ email: z.ZodOptional<z.ZodString>;
84
+ skip_merge: z.ZodOptional<z.ZodBoolean>;
85
+ }, "strict", z.ZodTypeAny, {
86
+ name?: string | undefined;
87
+ enabled?: boolean | undefined;
88
+ email?: string | undefined;
89
+ skip_merge?: boolean | undefined;
90
+ }, {
91
+ name?: string | undefined;
92
+ enabled?: boolean | undefined;
93
+ email?: string | undefined;
94
+ skip_merge?: boolean | undefined;
95
+ }>>;
96
+ }, "strict", z.ZodTypeAny, {
97
+ co_author?: {
98
+ name?: string | undefined;
99
+ enabled?: boolean | undefined;
100
+ email?: string | undefined;
101
+ skip_merge?: boolean | undefined;
102
+ } | undefined;
103
+ }, {
104
+ co_author?: {
105
+ name?: string | undefined;
106
+ enabled?: boolean | undefined;
107
+ email?: string | undefined;
108
+ skip_merge?: boolean | undefined;
109
+ } | undefined;
110
+ }>>;
79
111
  }, "strict", z.ZodTypeAny, {
80
112
  autonomy_level?: AutonomyLevel | undefined;
81
113
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -102,6 +134,14 @@ export declare const ProfileSchema: z.ZodObject<{
102
134
  architecture_review?: {
103
135
  patterns?: string[] | undefined;
104
136
  } | undefined;
137
+ attribution?: {
138
+ co_author?: {
139
+ name?: string | undefined;
140
+ enabled?: boolean | undefined;
141
+ email?: string | undefined;
142
+ skip_merge?: boolean | undefined;
143
+ } | undefined;
144
+ } | undefined;
105
145
  }, {
106
146
  autonomy_level?: AutonomyLevel | undefined;
107
147
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -128,6 +168,14 @@ export declare const ProfileSchema: z.ZodObject<{
128
168
  architecture_review?: {
129
169
  patterns?: string[] | undefined;
130
170
  } | undefined;
171
+ attribution?: {
172
+ co_author?: {
173
+ name?: string | undefined;
174
+ enabled?: boolean | undefined;
175
+ email?: string | undefined;
176
+ skip_merge?: boolean | undefined;
177
+ } | undefined;
178
+ } | undefined;
131
179
  }>;
132
180
  export type Profile = z.infer<typeof ProfileSchema>;
133
181
  /** Hard defaults applied before any profile or wizard answer. */
@@ -75,6 +75,31 @@ export const ProfileSchema = z
75
75
  patterns: z.array(z.string()).optional(),
76
76
  })
77
77
  .optional(),
78
+ // 0.30.0+ attribution augmenter — every shipped profile pins
79
+ // `enabled: false`. Opt-in is repo-local (.rea/policy.yaml edit)
80
+ // because the identity to roll commits onto is per-developer; a
81
+ // profile that pinned `enabled: true` would route every other
82
+ // contributor's commits onto the profile author's heatmap.
83
+ //
84
+ // The profile-layer schema mirrors the policy-loader's
85
+ // `AttributionPolicySchema` but does NOT apply the cross-field
86
+ // refinement — a profile that ships `enabled: false` doesn't need
87
+ // a `name`/`email` to validate. Cross-field validation only runs
88
+ // at the policy-loader layer where the materialized file is parsed.
89
+ attribution: z
90
+ .object({
91
+ co_author: z
92
+ .object({
93
+ enabled: z.boolean().optional(),
94
+ name: z.string().optional(),
95
+ email: z.string().optional(),
96
+ skip_merge: z.boolean().optional(),
97
+ })
98
+ .strict()
99
+ .optional(),
100
+ })
101
+ .strict()
102
+ .optional(),
78
103
  })
79
104
  .strict();
80
105
  /** Hard defaults applied before any profile or wizard answer. */
@@ -324,6 +324,49 @@ export interface AuditRotationPolicy {
324
324
  export interface AuditPolicy {
325
325
  rotation?: AuditRotationPolicy;
326
326
  }
327
+ /**
328
+ * Attribution augmenter policy (0.30.0+).
329
+ *
330
+ * Drives the husky `prepare-commit-msg` hook that appends a single
331
+ * `Co-Authored-By: <name> <email>` trailer to every commit message. The
332
+ * intended use case: a contributor whose enterprise git identity (e.g.
333
+ * `alice@enterprise.example`) differs from their personal GitHub identity
334
+ * (e.g. `alice@personal.example`) — GitHub's contribution graph keys off
335
+ * the commit author/co-author email, so adding the personal address as a
336
+ * trailer rolls the work onto their personal heatmap.
337
+ *
338
+ * The augmenter does NOT modify the primary author line. It appends a
339
+ * trailer only — and is idempotent: re-running on a message that already
340
+ * contains the trailer (by email match, case-insensitive) is a no-op.
341
+ *
342
+ * Note: this is orthogonal to the `attribution-advisory.sh` Bash hook
343
+ * and the `block_ai_attribution` enforcement in the commit-msg hook.
344
+ * Those reject AI-tool noreply emails + AI assistant names. A
345
+ * human-authored `Co-Authored-By: Real Name <real@email.tld>` trailer
346
+ * is not AI attribution and is not blocked.
347
+ *
348
+ * Profile defaults: every shipped profile leaves `co_author.enabled:
349
+ * false`. The opt-in lives in repo-local edits to `.rea/policy.yaml`,
350
+ * never in the profile, because the identity to roll commits onto is
351
+ * inherently per-developer.
352
+ */
353
+ export interface AttributionPolicy {
354
+ co_author?: AttributionCoAuthorPolicy;
355
+ }
356
+ /**
357
+ * Co-author trailer config. When `enabled: true`, BOTH `name` AND `email`
358
+ * must be non-empty — the policy loader fails closed with a clear error
359
+ * message when one is empty. `skip_merge: true` skips augmentation on
360
+ * merge commits (commit source `merge`) only; all other sources
361
+ * (`message`, `template`, `squash`, `commit`) are always augmented when
362
+ * enabled.
363
+ */
364
+ export interface AttributionCoAuthorPolicy {
365
+ enabled?: boolean;
366
+ name?: string;
367
+ email?: string;
368
+ skip_merge?: boolean;
369
+ }
327
370
  /**
328
371
  * G9 — injection tier escalation knobs. The classifier bucketed matches into
329
372
  * `clean` / `suspicious` / `likely_injection`; this block governs what happens
@@ -421,4 +464,12 @@ export interface Policy {
421
464
  * knob, not a review knob. The push-gate doesn't consume it.
422
465
  */
423
466
  commit_hygiene?: CommitHygienePolicy;
467
+ /**
468
+ * Attribution augmenter (0.30.0+). When `co_author.enabled: true`, the
469
+ * husky `prepare-commit-msg` hook appends a `Co-Authored-By:` trailer
470
+ * to every commit (or every non-merge commit when `skip_merge: true`).
471
+ * Idempotent — repeated runs over a message that already carries the
472
+ * trailer are no-ops. See `AttributionPolicy` for the full contract.
473
+ */
474
+ attribution?: AttributionPolicy;
424
475
  }
@@ -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;
21
22
  env: Record<string, string>;
22
23
  command: string;
23
24
  args: 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
+ enabled?: boolean | undefined;
30
31
  env?: Record<string, string> | undefined;
31
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;
48
49
  env: Record<string, string>;
49
50
  command: string;
50
51
  args: 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
+ enabled?: boolean | undefined;
57
58
  env?: Record<string, string> | undefined;
58
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;
68
69
  env: Record<string, string>;
69
70
  command: string;
70
71
  args: 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
+ enabled?: boolean | undefined;
81
82
  env?: Record<string, string> | undefined;
82
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
  }>;