@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
package/dist/cli/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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>;
|