@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.
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import crypto from 'node:crypto';
2
3
  import fs from 'node:fs';
3
4
  import fsPromises from 'node:fs/promises';
@@ -7,7 +8,7 @@ import { loadRegistry } from '../registry/loader.js';
7
8
  import { loadFingerprintStore } from '../registry/fingerprints-store.js';
8
9
  import { fingerprintServer } from '../registry/fingerprint.js';
9
10
  import { CodexProbe } from '../gateway/observability/codex-probe.js';
10
- import { inspectPrePushState } from './install/pre-push.js';
11
+ import { inspectPrePushState, isHusky9Stub, resolveHusky9StubTarget, } from './install/pre-push.js';
11
12
  import { summarizeTelemetry } from '../gateway/observability/codex-telemetry.js';
12
13
  import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
13
14
  import { buildFragment } from './install/claude-md.js';
@@ -16,6 +17,8 @@ import { manifestExists, readManifest } from './install/manifest-io.js';
16
17
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
17
18
  import { DELEGATION_SIGNAL_TOOL_NAME } from '../audit/delegation-event.js';
18
19
  import { computeHash } from '../audit/fs.js';
20
+ import { PREPARE_COMMIT_MSG_BODY_MARKER, PREPARE_COMMIT_MSG_MARKER, } from './install/prepare-commit-msg.js';
21
+ import { validateSettings } from '../config/settings-schema.js';
19
22
  import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
20
23
  function checkFileExists(label, filePath, fatal) {
21
24
  const exists = fs.existsSync(filePath);
@@ -134,7 +137,15 @@ function checkRegistryParses(baseDir, registryPath) {
134
137
  };
135
138
  }
136
139
  }
137
- const EXPECTED_AGENTS = [
140
+ /**
141
+ * 0.30.0 (Class M settings.json schema) — `EXPECTED_HOOKS` is exported
142
+ * so the schema validator at `src/config/settings-schema.ts` can
143
+ * cross-check rea-shipped hook filenames against entries it sees in
144
+ * a consumer's `.claude/settings.json`. The validator's `--strict`
145
+ * mode FAILS when a known rea-managed hook is missing from the
146
+ * consumer's registration; default mode logs a warn.
147
+ */
148
+ export const EXPECTED_AGENTS = [
138
149
  'accessibility-engineer.md',
139
150
  'backend-engineer.md',
140
151
  'code-reviewer.md',
@@ -146,7 +157,7 @@ const EXPECTED_AGENTS = [
146
157
  'technical-writer.md',
147
158
  'typescript-specialist.md',
148
159
  ];
149
- const EXPECTED_HOOKS = [
160
+ export const EXPECTED_HOOKS = [
150
161
  'architecture-review-gate.sh',
151
162
  'attribution-advisory.sh',
152
163
  // 0.22.0 — Bash-tier parity with `blocked-paths-enforcer.sh`.
@@ -231,6 +242,85 @@ function checkHooksInstalled(baseDir) {
231
242
  }
232
243
  return { label: 'hooks installed + executable', status: 'fail', detail: issues.join('; ') };
233
244
  }
245
+ /**
246
+ * 0.30.0 Class M — validate `.claude/settings.json` against the zod
247
+ * schema in `src/config/settings-schema.ts`.
248
+ *
249
+ * Status posture:
250
+ *
251
+ * - `strict: false` (default `rea doctor`) — emit a warn when:
252
+ * - zod parse fails (unknown top-level key, missing matcher,
253
+ * malformed hook entry, etc.),
254
+ * - any `command` contains a `..` traversal after stripping
255
+ * `$CLAUDE_PROJECT_DIR`,
256
+ * - any rea-shipped hook from `EXPECTED_HOOKS` is missing from
257
+ * the consumer's registrations.
258
+ * The harness keeps working — the schema only refuses to call
259
+ * malformed hook entries; we surface the issue without breaking
260
+ * the install.
261
+ *
262
+ * - `strict: true` (`rea doctor --strict`) — fail (hard) on the
263
+ * same conditions. Used by CI gates that want a hard floor on
264
+ * consumer settings.
265
+ *
266
+ * Returns `pass` when everything cleared. Returns one `CheckResult`
267
+ * per concern; called once and emits one result. Combined with the
268
+ * existing `checkSettingsJson` (which checks for the historical Bash
269
+ * + Write|Edit|MultiEdit|NotebookEdit matchers), gives consumers a
270
+ * complete picture.
271
+ */
272
+ export function checkSettingsSchema(baseDir, strict) {
273
+ const label = strict ? 'settings.json schema (strict)' : 'settings.json schema (advisory)';
274
+ const settingsPath = path.join(baseDir, '.claude', 'settings.json');
275
+ if (!fs.existsSync(settingsPath)) {
276
+ return {
277
+ label,
278
+ status: strict ? 'fail' : 'warn',
279
+ detail: `missing: ${settingsPath}`,
280
+ };
281
+ }
282
+ let raw;
283
+ try {
284
+ raw = fs.readFileSync(settingsPath, 'utf8');
285
+ }
286
+ catch (e) {
287
+ return {
288
+ label,
289
+ status: strict ? 'fail' : 'warn',
290
+ detail: e instanceof Error ? e.message : String(e),
291
+ };
292
+ }
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(raw);
296
+ }
297
+ catch (e) {
298
+ return {
299
+ label,
300
+ status: 'fail',
301
+ detail: `malformed JSON: ${e instanceof Error ? e.message : String(e)}`,
302
+ };
303
+ }
304
+ const result = validateSettings(parsed);
305
+ const issues = [];
306
+ if (!result.parsed) {
307
+ issues.push(...result.errors.map((e) => `schema: ${e}`));
308
+ }
309
+ for (const t of result.traversalFindings) {
310
+ issues.push(`traversal: ${t.event}[${t.matcher}].hooks[${t.index}].command — ${t.reason}`);
311
+ }
312
+ for (const missing of result.missingReaHooks) {
313
+ issues.push(`missing rea hook: ${missing} not registered in PreToolUse/PostToolUse`);
314
+ }
315
+ if (issues.length === 0) {
316
+ return { label, status: 'pass' };
317
+ }
318
+ return {
319
+ label,
320
+ status: strict ? 'fail' : 'warn',
321
+ detail: issues.join('; '),
322
+ };
323
+ }
234
324
  function checkSettingsJson(baseDir) {
235
325
  const settingsPath = path.join(baseDir, '.claude', 'settings.json');
236
326
  if (!fs.existsSync(settingsPath)) {
@@ -344,6 +434,165 @@ export function isGitRepo(baseDir) {
344
434
  const resolved = path.isAbsolute(targetPath) ? targetPath : path.join(baseDir, targetPath);
345
435
  return fs.existsSync(resolved);
346
436
  }
437
+ /**
438
+ * 0.30.0 attribution augmenter — verify the husky `prepare-commit-msg`
439
+ * hook state matches what `policy.attribution.co_author.enabled` asks
440
+ * for. Four buckets:
441
+ *
442
+ * 1. `enabled: true` + hook present + rea-managed marker → pass.
443
+ * 2. `enabled: true` + hook missing OR marker mismatched → fail.
444
+ * Defense in depth: the loader's cross-field refinement should
445
+ * already have rejected `enabled: true` without identity, but
446
+ * we surface a missing hook file separately.
447
+ * 3. `enabled: true` + name OR email empty → fail. The loader should
448
+ * have already caught this; surfacing here ensures `rea doctor`
449
+ * reports a clean state for the entire augmenter surface.
450
+ * 4. `enabled: false` (or absent) + hook present (rea-managed) → pass
451
+ * (no-op — hook ships under every install).
452
+ * 5. `enabled: false` (or absent) + foreign file → warn. The operator
453
+ * has a `prepare-commit-msg` outside rea's marker; their commits
454
+ * get whatever it does, which is fine.
455
+ * 6. `enabled: false` + hook absent → pass (vanilla state).
456
+ *
457
+ * Returns `info` when the rea-shipped `.git/hooks/prepare-commit-msg`
458
+ * lives under a hooksPath we couldn't resolve (treat as same as case
459
+ * 6 from doctor's perspective).
460
+ */
461
+ /**
462
+ * Resolve the active git hooks directory for the doctor's prepare-commit-msg
463
+ * check. Mirrors `installCommitMsgHook`'s `readHooksPathFromGit` but
464
+ * synchronous (doctor is sync end-to-end). Honors `core.hooksPath` when set
465
+ * (husky 9 installs land at `.husky/_/`); falls back to `.git/hooks/`
466
+ * otherwise. Codex round 1 P2: prior implementation always looked at
467
+ * `.git/hooks/prepare-commit-msg`, false-reporting missing on any consumer
468
+ * running husky.
469
+ */
470
+ function resolveHooksDirSync(baseDir) {
471
+ try {
472
+ const out = execFileSync('git', ['-C', baseDir, 'config', '--get', 'core.hooksPath'], {
473
+ encoding: 'utf8',
474
+ stdio: ['ignore', 'pipe', 'ignore'],
475
+ });
476
+ const trimmed = out.trim();
477
+ if (trimmed.length > 0) {
478
+ return path.isAbsolute(trimmed) ? trimmed : path.join(baseDir, trimmed);
479
+ }
480
+ }
481
+ catch {
482
+ // git missing or `core.hooksPath` unset — fall through to default.
483
+ }
484
+ return path.join(baseDir, '.git', 'hooks');
485
+ }
486
+ export function checkPrepareCommitMsgHook(baseDir) {
487
+ const label = 'prepare-commit-msg hook (attribution augmenter)';
488
+ const hooksDir = resolveHooksDirSync(baseDir);
489
+ const hookPath = path.join(hooksDir, 'prepare-commit-msg');
490
+ let policyAttr;
491
+ try {
492
+ const policy = loadPolicy(baseDir);
493
+ policyAttr = policy.attribution?.co_author;
494
+ }
495
+ catch {
496
+ // policy-parse failure is surfaced elsewhere; default to "absent"
497
+ policyAttr = undefined;
498
+ }
499
+ const enabled = policyAttr?.enabled === true;
500
+ const hookExists = fs.existsSync(hookPath);
501
+ let hookIsReaManaged = false;
502
+ let hookMarkerMismatch = false;
503
+ if (hookExists) {
504
+ try {
505
+ let content = fs.readFileSync(hookPath, 'utf8');
506
+ // Codex round 3 P2: Husky 9 (`core.hooksPath=.husky/_`) auto-
507
+ // generates a stub like `. "${0%/*}/h"` at the active hooks path.
508
+ // Git dispatches through that stub to `.husky/prepare-commit-msg`
509
+ // (the canonical body, which IS rea-managed). Follow the
510
+ // indirection so doctor classifies the canonical body, not the
511
+ // stub. Same pattern as installer + pre-push doctor checks.
512
+ if (isHusky9Stub(content)) {
513
+ const target = resolveHusky9StubTarget(hookPath);
514
+ if (target !== null && target !== hookPath && fs.existsSync(target)) {
515
+ try {
516
+ content = fs.readFileSync(target, 'utf8');
517
+ }
518
+ catch {
519
+ // canonical body unreadable — fall through with stub content,
520
+ // which will classify as foreign and surface a clear error.
521
+ }
522
+ }
523
+ }
524
+ const lines = content.split('\n');
525
+ hookIsReaManaged =
526
+ content.startsWith('#!/bin/sh\n') &&
527
+ lines[1] === PREPARE_COMMIT_MSG_MARKER &&
528
+ lines[2] === PREPARE_COMMIT_MSG_BODY_MARKER;
529
+ if (!hookIsReaManaged &&
530
+ content.includes('rea:prepare-commit-msg') &&
531
+ lines[1] !== PREPARE_COMMIT_MSG_MARKER) {
532
+ hookMarkerMismatch = true;
533
+ }
534
+ }
535
+ catch {
536
+ hookIsReaManaged = false;
537
+ }
538
+ }
539
+ if (enabled) {
540
+ if (!hookExists) {
541
+ return {
542
+ label,
543
+ status: 'fail',
544
+ detail: 'attribution.co_author.enabled: true but .git/hooks/prepare-commit-msg is missing — ' +
545
+ 'run `rea init` to install the hook, or set enabled: false.',
546
+ };
547
+ }
548
+ if (!hookIsReaManaged) {
549
+ const reason = hookMarkerMismatch
550
+ ? 'marker mismatch (older rea or hand-edited)'
551
+ : 'no rea marker';
552
+ return {
553
+ label,
554
+ status: 'fail',
555
+ detail: `attribution.co_author.enabled: true but the prepare-commit-msg hook is foreign (${reason}) — ` +
556
+ 'remove the existing hook and re-run `rea init`, or set enabled: false.',
557
+ };
558
+ }
559
+ const name = (policyAttr?.name ?? '').trim();
560
+ const email = (policyAttr?.email ?? '').trim();
561
+ if (name.length === 0 || email.length === 0) {
562
+ const which = name.length === 0 ? 'name' : 'email';
563
+ return {
564
+ label,
565
+ status: 'fail',
566
+ detail: `attribution.co_author.enabled: true but ${which} is empty — ` +
567
+ 'the policy loader should have rejected this; if you are seeing this, edit ' +
568
+ '.rea/policy.yaml and either set both name+email or set enabled: false.',
569
+ };
570
+ }
571
+ return {
572
+ label,
573
+ status: 'pass',
574
+ detail: `enabled — trailer: ${name} <${email}>`,
575
+ };
576
+ }
577
+ // enabled: false (or absent).
578
+ if (!hookExists) {
579
+ return { label, status: 'pass', detail: 'disabled (no hook installed — vanilla state)' };
580
+ }
581
+ if (hookIsReaManaged) {
582
+ return {
583
+ label,
584
+ status: 'pass',
585
+ detail: 'disabled (rea-managed hook present, runs as no-op)',
586
+ };
587
+ }
588
+ return {
589
+ label,
590
+ status: 'warn',
591
+ detail: 'foreign prepare-commit-msg hook present — rea would refuse to overwrite. ' +
592
+ 'When you enable attribution.co_author.enabled, the existing hook must be ' +
593
+ 'removed or migrated to a fragment first.',
594
+ };
595
+ }
347
596
  function checkCommitMsgHook(baseDir) {
348
597
  const hookPath = path.join(baseDir, '.git', 'hooks', 'commit-msg');
349
598
  if (!fs.existsSync(hookPath)) {
@@ -779,7 +1028,7 @@ export function checkDelegationHookRegistered(baseDir) {
779
1028
  '(NOT `Task|Skill` — `TaskCreate`/`TaskList` are unrelated todo-list tools).',
780
1029
  };
781
1030
  }
782
- const cmds = (group.hooks ?? []).map((h) => typeof h.command === 'string' ? h.command : '');
1031
+ const cmds = (group.hooks ?? []).map((h) => (typeof h.command === 'string' ? h.command : ''));
783
1032
  if (!cmds.some((c) => c.includes('delegation-capture.sh'))) {
784
1033
  return {
785
1034
  label,
@@ -967,7 +1216,7 @@ export async function checkDelegationRoundTrip(baseDir) {
967
1216
  *
968
1217
  * `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
969
1218
  */
970
- export function collectChecks(baseDir, codexProbeState, prePushState) {
1219
+ export function collectChecks(baseDir, codexProbeState, prePushState, options = {}) {
971
1220
  const policyPath = reaPath(baseDir, POLICY_FILE);
972
1221
  const registryPath = reaPath(baseDir, REGISTRY_FILE);
973
1222
  const reaDirPath = path.join(baseDir, REA_DIR);
@@ -978,6 +1227,11 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
978
1227
  checkAgentsPresent(baseDir),
979
1228
  checkHooksInstalled(baseDir),
980
1229
  checkSettingsJson(baseDir),
1230
+ // 0.30.0 Class M — strict zod schema check of the full
1231
+ // .claude/settings.json shape. Complements checkSettingsJson
1232
+ // (matcher coverage) and checkDelegationHookRegistered (Agent|Skill
1233
+ // wiring). Hard fail under `--strict`, warn by default.
1234
+ checkSettingsSchema(baseDir, options.strict === true),
981
1235
  // 0.29.0 — delegation-telemetry MVP wiring check. Separate from
982
1236
  // checkSettingsJson because that check only validates the
983
1237
  // existence of the Bash + Write|Edit|MultiEdit|NotebookEdit
@@ -991,6 +1245,10 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
991
1245
  // other non-source-code directories that consume rea governance.
992
1246
  if (isGitRepo(baseDir)) {
993
1247
  checks.push(checkCommitMsgHook(baseDir));
1248
+ // 0.30.0 attribution augmenter — only check when policy.attribution
1249
+ // is declared. Vanilla installs without the block see no check
1250
+ // (cleaner output for consumers who don't opt in).
1251
+ checks.push(checkPrepareCommitMsgHook(baseDir));
994
1252
  if (prePushState !== undefined) {
995
1253
  checks.push(checkPrePushHook(prePushState));
996
1254
  }
@@ -1203,7 +1461,9 @@ export async function runDoctor(opts = {}) {
1203
1461
  catch {
1204
1462
  prePushState = undefined;
1205
1463
  }
1206
- const checks = collectChecks(baseDir, probeState, prePushState);
1464
+ const checks = collectChecks(baseDir, probeState, prePushState, {
1465
+ strict: opts.strict === true,
1466
+ });
1207
1467
  // G7: async fingerprint-store check. Kept out of `collectChecks` so the
1208
1468
  // existing sync contract stays intact for downstream consumers; appended
1209
1469
  // here so runDoctor surfaces it inline.
package/dist/cli/index.js CHANGED
@@ -149,11 +149,13 @@ async function main() {
149
149
  .option('--metrics', 'also print a 7-day summary of Codex telemetry (G11.5)')
150
150
  .option('--drift', 'report drift vs. the install manifest (read-only; does not mutate)')
151
151
  .option('--smoke', 'also run the 0.29.0 delegation-signal round-trip (writes a probe `rea.delegation_signal` audit record and verifies chain integrity)')
152
+ .option('--strict', '0.30.0 Class M — promote settings.json schema warnings (zod parse failures, path traversal, missing rea hooks) to hard fail. Use in CI gates.')
152
153
  .action(async (opts) => {
153
154
  await runDoctor({
154
155
  ...(opts.metrics === true ? { metrics: true } : {}),
155
156
  ...(opts.drift === true ? { drift: true } : {}),
156
157
  ...(opts.smoke === true ? { smoke: true } : {}),
158
+ ...(opts.strict === true ? { strict: true } : {}),
157
159
  });
158
160
  });
159
161
  await program.parseAsync(process.argv);
package/dist/cli/init.js CHANGED
@@ -9,6 +9,7 @@ import { copyArtifacts } from './install/copy.js';
9
9
  import { ensureReaGitignore } from './install/gitignore.js';
10
10
  import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
11
11
  import { installCommitMsgHook } from './install/commit-msg.js';
12
+ import { installPrepareCommitMsgHook } from './install/prepare-commit-msg.js';
12
13
  import { installPrePushFallback } from './install/pre-push.js';
13
14
  import { CodexProbe } from '../gateway/observability/codex-probe.js';
14
15
  import { buildFragment, writeClaudeMdFragment } from './install/claude-md.js';
@@ -220,11 +221,56 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
220
221
  ...(existingPolicy?.commitHygieneRefuseAtCommits !== undefined
221
222
  ? { commitHygieneRefuseAtCommits: existingPolicy.commitHygieneRefuseAtCommits }
222
223
  : {}),
224
+ // 0.30.0 attribution augmenter — preserved across re-init OR
225
+ // seeded from the layered profile (every shipped profile pins
226
+ // `enabled: false`). Conditional spread so undefined → key omitted
227
+ // (the field is exact-optional).
228
+ ...attributionConfigSpread(layeredBase, existingPolicy),
223
229
  fromReagent,
224
230
  reagentPolicyPath,
225
231
  reagentNotices: [],
226
232
  };
227
233
  }
234
+ /**
235
+ * Compute the attribution-augmenter config spread to inject into a
236
+ * partial `ResolvedConfig` literal. Returns `{}` when neither the
237
+ * existing on-disk policy nor the layered profile declared the
238
+ * augmenter — the policy writer then omits the block entirely so
239
+ * consumers who haven't seen 0.30.0 don't get a mystery YAML block.
240
+ *
241
+ * Returns `{ attributionCoAuthor: ... }` otherwise. Using a spread
242
+ * helper instead of a value-returning function lets `exactOptionalProperty
243
+ * Types` distinguish "omitted" from "explicitly undefined" — required
244
+ * by the strict tsconfig.
245
+ */
246
+ function attributionConfigSpread(layered, existing) {
247
+ const preserved = existing?.attributionCoAuthor;
248
+ if (preserved !== undefined) {
249
+ return {
250
+ attributionCoAuthor: {
251
+ ...(preserved.enabled !== undefined ? { enabled: preserved.enabled } : {}),
252
+ ...(preserved.name !== undefined ? { name: preserved.name } : {}),
253
+ ...(preserved.email !== undefined ? { email: preserved.email } : {}),
254
+ ...(preserved.skipMerge !== undefined ? { skipMerge: preserved.skipMerge } : {}),
255
+ },
256
+ };
257
+ }
258
+ const fromProfile = layered.attribution?.co_author;
259
+ if (fromProfile === undefined)
260
+ return {};
261
+ return {
262
+ attributionCoAuthor: {
263
+ ...(fromProfile.enabled !== undefined ? { enabled: fromProfile.enabled } : {}),
264
+ ...(fromProfile.name !== undefined && fromProfile.name.length > 0
265
+ ? { name: fromProfile.name }
266
+ : {}),
267
+ ...(fromProfile.email !== undefined && fromProfile.email.length > 0
268
+ ? { email: fromProfile.email }
269
+ : {}),
270
+ ...(fromProfile.skip_merge !== undefined ? { skipMerge: fromProfile.skip_merge } : {}),
271
+ },
272
+ };
273
+ }
228
274
  /**
229
275
  * G6 — Codex install-assist probe.
230
276
  *
@@ -408,6 +454,38 @@ function readExistingPolicyForPreservation(targetDir) {
408
454
  out.commitHygieneRefuseAtCommits = refuseAt;
409
455
  }
410
456
  }
457
+ // 0.30.0 attribution augmenter. Preserve every field the operator
458
+ // may have configured so re-running `rea init` doesn't silently
459
+ // revert an opt-in. Block AND inline forms agree at the parser
460
+ // layer.
461
+ const attribution = policy['attribution'];
462
+ if (attribution !== null && typeof attribution === 'object') {
463
+ const attr = attribution;
464
+ const coAuthor = attr['co_author'];
465
+ if (coAuthor !== null && typeof coAuthor === 'object') {
466
+ const ca = coAuthor;
467
+ const preserved = {};
468
+ let any = false;
469
+ if (typeof ca['enabled'] === 'boolean') {
470
+ preserved.enabled = ca['enabled'];
471
+ any = true;
472
+ }
473
+ if (typeof ca['name'] === 'string') {
474
+ preserved.name = ca['name'];
475
+ any = true;
476
+ }
477
+ if (typeof ca['email'] === 'string') {
478
+ preserved.email = ca['email'];
479
+ any = true;
480
+ }
481
+ if (typeof ca['skip_merge'] === 'boolean') {
482
+ preserved.skipMerge = ca['skip_merge'];
483
+ any = true;
484
+ }
485
+ if (any)
486
+ out.attributionCoAuthor = preserved;
487
+ }
488
+ }
411
489
  return out;
412
490
  }
413
491
  function readExistingInstalledAt(policyPath) {
@@ -484,6 +562,28 @@ function writePolicyYaml(targetDir, config, layered) {
484
562
  lines.push(` - ${JSON.stringify(p)}`);
485
563
  }
486
564
  }
565
+ // 0.30.0 attribution augmenter — emit the block whenever the layered
566
+ // profile (or a preserved on-disk policy) declared it. We always emit
567
+ // a fully-explicit `enabled` so an operator reading the file can
568
+ // confirm the current state at a glance without falling back to
569
+ // schema defaults. Identity (name/email) is omitted when empty —
570
+ // operators opt in by hand-editing those two fields, which keeps
571
+ // the policy file diff-clean on profile re-init.
572
+ const attr = config.attributionCoAuthor;
573
+ if (attr !== undefined) {
574
+ lines.push(`attribution:`);
575
+ lines.push(` co_author:`);
576
+ lines.push(` enabled: ${attr.enabled === true ? 'true' : 'false'}`);
577
+ if (attr.name !== undefined && attr.name.length > 0) {
578
+ lines.push(` name: ${JSON.stringify(attr.name)}`);
579
+ }
580
+ if (attr.email !== undefined && attr.email.length > 0) {
581
+ lines.push(` email: ${JSON.stringify(attr.email)}`);
582
+ }
583
+ if (attr.skipMerge !== undefined) {
584
+ lines.push(` skip_merge: ${attr.skipMerge ? 'true' : 'false'}`);
585
+ }
586
+ }
487
587
  // 0.18.1+ helixir #9: emit audit.rotation when the layered profile
488
588
  // declared it. Empty `rotation: {}` opts in to documented defaults
489
589
  // (50 MiB / 30 days); explicit values override.
@@ -741,6 +841,10 @@ export async function runInit(options) {
741
841
  ...(existingPolicy?.commitHygieneRefuseAtCommits !== undefined
742
842
  ? { commitHygieneRefuseAtCommits: existingPolicy.commitHygieneRefuseAtCommits }
743
843
  : {}),
844
+ // 0.30.0 attribution augmenter — preserved across re-init OR
845
+ // seeded from the layered profile. Same precedence as the
846
+ // wizard path above. Conditional spread for exact-optional.
847
+ ...attributionConfigSpread(layeredBase, existingPolicy),
744
848
  fromReagent,
745
849
  reagentPolicyPath,
746
850
  reagentNotices,
@@ -772,6 +876,12 @@ export async function runInit(options) {
772
876
  const mergeResult = mergeSettings(settings, desired);
773
877
  await writeSettingsAtomic(settingsPath, mergeResult.merged);
774
878
  const commitMsgResult = await installCommitMsgHook(targetDir);
879
+ // 0.30.0 attribution augmenter — install the prepare-commit-msg
880
+ // hook unconditionally. The hook is a no-op when
881
+ // policy.attribution.co_author.enabled !== true, so it is safe to
882
+ // ship under every profile; consumers opt in by editing their
883
+ // .rea/policy.yaml.
884
+ const prepareCommitMsgResult = await installPrepareCommitMsgHook(targetDir);
775
885
  const prePushResult = await installPrePushFallback({ targetDir });
776
886
  const fragmentInput = {
777
887
  policyPath: `.${path.sep}rea${path.sep}policy.yaml`.replace(/\\/g, '/'),
@@ -800,6 +910,14 @@ export async function runInit(options) {
800
910
  console.log(` + ${path.relative(targetDir, commitMsgResult.gitHook)}`);
801
911
  if (commitMsgResult.huskyHook)
802
912
  console.log(` + ${path.relative(targetDir, commitMsgResult.huskyHook)}`);
913
+ if (prepareCommitMsgResult.gitHook) {
914
+ const verb = prepareCommitMsgResult.refreshed === true ? '~' : '+';
915
+ console.log(` ${verb} ${path.relative(targetDir, prepareCommitMsgResult.gitHook)} (attribution augmenter)`);
916
+ }
917
+ if (prepareCommitMsgResult.huskyHook) {
918
+ const verb = prepareCommitMsgResult.refreshed === true ? '~' : '+';
919
+ console.log(` ${verb} ${path.relative(targetDir, prepareCommitMsgResult.huskyHook)} (attribution augmenter)`);
920
+ }
803
921
  if (prePushResult.written !== undefined) {
804
922
  const verb = prePushResult.decision.action === 'refresh' ? '~' : '+';
805
923
  console.log(` ${verb} ${path.relative(targetDir, prePushResult.written)} (pre-push fallback)`);
@@ -828,6 +946,8 @@ export async function runInit(options) {
828
946
  }
829
947
  for (const w of commitMsgResult.warnings)
830
948
  warn(w);
949
+ for (const w of prepareCommitMsgResult.warnings)
950
+ warn(w);
831
951
  for (const w of prePushResult.warnings)
832
952
  warn(w);
833
953
  for (const n of config.reagentNotices)
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Install the husky `prepare-commit-msg` hook that drives the 0.30.0
3
+ * attribution augmenter.
4
+ *
5
+ * The hook itself is a stable POSIX-sh body sourced from the package's
6
+ * own `.husky/prepare-commit-msg`. `rea init` and `rea upgrade` copy it
7
+ * into `.husky/` and (when `core.hooksPath` is not configured at
8
+ * `.husky`) `.git/hooks/` as the belt-and-suspenders pair — mirroring
9
+ * the `installCommitMsgHook` strategy in `commit-msg.ts`.
10
+ *
11
+ * Foreign-hook conflict pattern: the 0.13.2 prepush prior art applies.
12
+ * If a foreign `prepare-commit-msg` exists (no rea marker, not the husky
13
+ * 9 indirection stub), we REFUSE to overwrite, surface the conflict via
14
+ * `rea doctor`, and recommend the `.husky/prepare-commit-msg.d/<NN>-name`
15
+ * extension-fragment migration path (TODO: wire fragment chaining if
16
+ * consumers demand it; not in 0.30.0 scope).
17
+ *
18
+ * Idempotency: the canonical body carries the `# rea:prepare-commit-msg v1`
19
+ * marker on line 2 and `# rea:augment-body-v1` on line 3. Re-running rea
20
+ * init / upgrade refreshes the file in-place whenever the marker matches;
21
+ * foreign hooks are left alone.
22
+ */
23
+ /**
24
+ * Marker baked into every rea-installed prepare-commit-msg hook.
25
+ * Anchored on line 2 (immediately after the shebang) for classification.
26
+ * Bump the version suffix whenever the body semantics change so
27
+ * upgrades migrate cleanly.
28
+ *
29
+ * v1 — 0.30.0: first version of the augmenter hook.
30
+ */
31
+ export declare const PREPARE_COMMIT_MSG_MARKER = "# rea:prepare-commit-msg v1";
32
+ /**
33
+ * Body marker anchored on line 3. A foreign hook that carries the
34
+ * header marker as a comment but has an empty body (stubbed by a
35
+ * consumer) will NOT be classified as rea-managed because the body
36
+ * marker won't be on line 3. Both markers together close the
37
+ * classification question.
38
+ */
39
+ export declare const PREPARE_COMMIT_MSG_BODY_MARKER = "# rea:augment-body-v1";
40
+ export type PrepareCommitMsgClassification = {
41
+ kind: 'absent';
42
+ } | {
43
+ kind: 'rea-managed';
44
+ version: string;
45
+ } | {
46
+ kind: 'foreign';
47
+ reason: string;
48
+ };
49
+ /**
50
+ * Inspect `hookPath` and decide whether it is rea-authored or foreign.
51
+ * Strict: BOTH markers must appear on lines 2 + 3 in order. Substring
52
+ * matches deliberately rejected so a comment quoting the marker doesn't
53
+ * fool the classifier.
54
+ */
55
+ export declare function classifyPrepareCommitMsgHook(hookPath: string): Promise<PrepareCommitMsgClassification>;
56
+ export interface PrepareCommitMsgInstallResult {
57
+ gitHook?: string;
58
+ huskyHook?: string;
59
+ warnings: string[];
60
+ /**
61
+ * When the install is a refresh of an existing rea-managed body, this
62
+ * is true. Useful for upgrade messaging.
63
+ */
64
+ refreshed?: boolean;
65
+ /**
66
+ * When the install was skipped because a foreign hook is present.
67
+ * Surfaced separately so `rea doctor` can render the migration path.
68
+ */
69
+ skippedForeign?: boolean;
70
+ }
71
+ /**
72
+ * Install the prepare-commit-msg hook into the consumer project at
73
+ * `targetDir`. Refuses to stomp foreign hooks; refreshes rea-managed
74
+ * hooks in place. Best-effort: a missing `.husky/` directory simply
75
+ * skips the husky copy (git-hooks copy is sufficient for vanilla git).
76
+ *
77
+ * Foreign-hook conflict (the 0.13.2 pre-push prior art): we never
78
+ * overwrite a non-rea body. The caller surfaces the conflict to the
79
+ * operator; `rea doctor` flags the gap so the operator can decide
80
+ * whether to relocate their existing hook into a fragment, replace it
81
+ * with rea's body, or set `attribution.co_author.enabled: false`.
82
+ */
83
+ export declare function installPrepareCommitMsgHook(targetDir: string): Promise<PrepareCommitMsgInstallResult>;