@bookedsolid/rea 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/prepare-commit-msg +295 -0
- package/MIGRATING.md +75 -0
- package/dist/cli/doctor.d.ts +49 -1
- package/dist/cli/doctor.js +266 -6
- package/dist/cli/index.js +2 -0
- package/dist/cli/init.js +120 -0
- package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
- package/dist/cli/install/prepare-commit-msg.js +208 -0
- package/dist/cli/upgrade.js +34 -0
- package/dist/config/settings-schema.d.ts +2087 -0
- package/dist/config/settings-schema.js +294 -0
- package/dist/policy/loader.d.ts +58 -0
- package/dist/policy/loader.js +68 -0
- package/dist/policy/profiles.d.ts +48 -0
- package/dist/policy/profiles.js +25 -0
- package/dist/policy/types.d.ts +51 -0
- package/dist/registry/loader.d.ts +6 -6
- package/package.json +1 -1
- package/profiles/bst-internal-no-codex.yaml +15 -0
- package/profiles/bst-internal.yaml +16 -0
- package/profiles/client-engagement.yaml +14 -0
- package/profiles/lit-wc.yaml +14 -0
- package/profiles/minimal.yaml +16 -0
- package/profiles/open-source-no-codex.yaml +13 -0
- package/profiles/open-source.yaml +13 -0
- package/templates/prepare-commit-msg.husky.sh +295 -0
|
@@ -0,0 +1,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
|
+
}
|
package/dist/policy/loader.d.ts
CHANGED
|
@@ -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.
|
package/dist/policy/loader.js
CHANGED
|
@@ -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. */
|
package/dist/policy/profiles.js
CHANGED
|
@@ -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. */
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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
|
}>;
|