@bookedsolid/rea 0.28.2 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/prepare-commit-msg +295 -0
- package/MIGRATING.md +75 -0
- package/dist/audit/append.d.ts +1 -0
- package/dist/audit/append.js +1 -0
- package/dist/audit/delegation-event.d.ts +215 -0
- package/dist/audit/delegation-event.js +113 -0
- package/dist/cli/audit-specialists.d.ts +113 -0
- package/dist/cli/audit-specialists.js +220 -0
- package/dist/cli/doctor.d.ts +114 -1
- package/dist/cli/doctor.js +523 -5
- package/dist/cli/hook.d.ts +40 -8
- package/dist/cli/hook.js +305 -8
- package/dist/cli/index.js +9 -0
- package/dist/cli/init.js +120 -0
- package/dist/cli/install/manifest-schema.d.ts +6 -6
- package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
- package/dist/cli/install/prepare-commit-msg.js +208 -0
- package/dist/cli/install/settings-merge.js +20 -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/config/tier-map.js +22 -1
- 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 +12 -12
- package/hooks/delegation-capture.sh +158 -0
- 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,17 +1,24 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
1
3
|
import fs from 'node:fs';
|
|
4
|
+
import fsPromises from 'node:fs/promises';
|
|
2
5
|
import path from 'node:path';
|
|
3
6
|
import { loadPolicy } from '../policy/loader.js';
|
|
4
7
|
import { loadRegistry } from '../registry/loader.js';
|
|
5
8
|
import { loadFingerprintStore } from '../registry/fingerprints-store.js';
|
|
6
9
|
import { fingerprintServer } from '../registry/fingerprint.js';
|
|
7
10
|
import { CodexProbe } from '../gateway/observability/codex-probe.js';
|
|
8
|
-
import { inspectPrePushState } from './install/pre-push.js';
|
|
11
|
+
import { inspectPrePushState, isHusky9Stub, resolveHusky9StubTarget, } from './install/pre-push.js';
|
|
9
12
|
import { summarizeTelemetry } from '../gateway/observability/codex-telemetry.js';
|
|
10
13
|
import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFiles, } from './install/canonical.js';
|
|
11
14
|
import { buildFragment } from './install/claude-md.js';
|
|
12
15
|
import { canonicalSettingsSubsetHash, defaultDesiredHooks } from './install/settings-merge.js';
|
|
13
16
|
import { manifestExists, readManifest } from './install/manifest-io.js';
|
|
14
17
|
import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
|
|
18
|
+
import { DELEGATION_SIGNAL_TOOL_NAME } from '../audit/delegation-event.js';
|
|
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';
|
|
15
22
|
import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
|
|
16
23
|
function checkFileExists(label, filePath, fatal) {
|
|
17
24
|
const exists = fs.existsSync(filePath);
|
|
@@ -130,7 +137,15 @@ function checkRegistryParses(baseDir, registryPath) {
|
|
|
130
137
|
};
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
|
-
|
|
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 = [
|
|
134
149
|
'accessibility-engineer.md',
|
|
135
150
|
'backend-engineer.md',
|
|
136
151
|
'code-reviewer.md',
|
|
@@ -142,7 +157,7 @@ const EXPECTED_AGENTS = [
|
|
|
142
157
|
'technical-writer.md',
|
|
143
158
|
'typescript-specialist.md',
|
|
144
159
|
];
|
|
145
|
-
const EXPECTED_HOOKS = [
|
|
160
|
+
export const EXPECTED_HOOKS = [
|
|
146
161
|
'architecture-review-gate.sh',
|
|
147
162
|
'attribution-advisory.sh',
|
|
148
163
|
// 0.22.0 — Bash-tier parity with `blocked-paths-enforcer.sh`.
|
|
@@ -154,6 +169,13 @@ const EXPECTED_HOOKS = [
|
|
|
154
169
|
'blocked-paths-enforcer.sh',
|
|
155
170
|
'changeset-security-gate.sh',
|
|
156
171
|
'dangerous-bash-interceptor.sh',
|
|
172
|
+
// 0.29.0 — delegation-telemetry MVP. The PreToolUse hook on
|
|
173
|
+
// matcher `Agent|Skill` emits a `rea.delegation_signal` audit record
|
|
174
|
+
// on every subagent / skill dispatch. Observational only — fails
|
|
175
|
+
// open so missing rea binary doesn't crash dispatch. Doctor surfaces
|
|
176
|
+
// a missing hook file so consumers don't silently lose the signal
|
|
177
|
+
// after upgrade.
|
|
178
|
+
'delegation-capture.sh',
|
|
157
179
|
'dependency-audit-gate.sh',
|
|
158
180
|
'env-file-protection.sh',
|
|
159
181
|
// 0.26.0 local-first enforcement (CTO directive 2026-05-05).
|
|
@@ -220,6 +242,85 @@ function checkHooksInstalled(baseDir) {
|
|
|
220
242
|
}
|
|
221
243
|
return { label: 'hooks installed + executable', status: 'fail', detail: issues.join('; ') };
|
|
222
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
|
+
}
|
|
223
324
|
function checkSettingsJson(baseDir) {
|
|
224
325
|
const settingsPath = path.join(baseDir, '.claude', 'settings.json');
|
|
225
326
|
if (!fs.existsSync(settingsPath)) {
|
|
@@ -333,6 +434,165 @@ export function isGitRepo(baseDir) {
|
|
|
333
434
|
const resolved = path.isAbsolute(targetPath) ? targetPath : path.join(baseDir, targetPath);
|
|
334
435
|
return fs.existsSync(resolved);
|
|
335
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
|
+
}
|
|
336
596
|
function checkCommitMsgHook(baseDir) {
|
|
337
597
|
const hookPath = path.join(baseDir, '.git', 'hooks', 'commit-msg');
|
|
338
598
|
if (!fs.existsSync(hookPath)) {
|
|
@@ -707,6 +967,240 @@ function codexRequiredFromPolicy(baseDir) {
|
|
|
707
967
|
return true;
|
|
708
968
|
}
|
|
709
969
|
}
|
|
970
|
+
/**
|
|
971
|
+
* 0.29.0 — verify the delegation-capture hook is registered in
|
|
972
|
+
* `.claude/settings.json` under PreToolUse with matcher `Agent|Skill`
|
|
973
|
+
* AND that the hook file exists at the expected dogfood path.
|
|
974
|
+
*
|
|
975
|
+
* Status posture for 0.29.0:
|
|
976
|
+
*
|
|
977
|
+
* The 0.29.0 release introduces a new desired-hook entry in
|
|
978
|
+
* `defaultDesiredHooks()` that `rea init` and `rea upgrade` will merge
|
|
979
|
+
* into consumer `.claude/settings.json` files. Existing consumer
|
|
980
|
+
* installs (and this repo's own dogfood, which is locked from
|
|
981
|
+
* agent-driven edits by `settings-protection.sh`) won't have the
|
|
982
|
+
* matcher registered until the operator runs `rea upgrade`.
|
|
983
|
+
*
|
|
984
|
+
* To keep the upgrade-lag period from breaking `rea doctor`, the
|
|
985
|
+
* check is `warn` (not `fail`) for 0.29.0. The detail message names
|
|
986
|
+
* the exact command to fix and points at the canonical
|
|
987
|
+
* `delegation-capture.sh` install. After 0.29.0+1 consumer-install
|
|
988
|
+
* cycles have propagated, this should be promoted to `fail` so a
|
|
989
|
+
* skipped upgrade is loud rather than silent. Codex round 2 P2
|
|
990
|
+
* (2026-05-12).
|
|
991
|
+
*
|
|
992
|
+
* Hook-file presence is verified separately by `checkHooksInstalled`
|
|
993
|
+
* via `EXPECTED_HOOKS` — that path stays at the hard-`fail` posture
|
|
994
|
+
* because file presence is part of the install manifest and doesn't
|
|
995
|
+
* suffer the same template-propagation lag.
|
|
996
|
+
*/
|
|
997
|
+
export function checkDelegationHookRegistered(baseDir) {
|
|
998
|
+
const label = 'delegation-capture hook registered';
|
|
999
|
+
const ADVISORY = 'warn';
|
|
1000
|
+
const settingsPath = path.join(baseDir, '.claude', 'settings.json');
|
|
1001
|
+
if (!fs.existsSync(settingsPath)) {
|
|
1002
|
+
return {
|
|
1003
|
+
label,
|
|
1004
|
+
status: ADVISORY,
|
|
1005
|
+
detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\` (advisory in 0.29.0; promoted to fail in 0.30.0)`,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
let parsed;
|
|
1009
|
+
try {
|
|
1010
|
+
parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
1011
|
+
}
|
|
1012
|
+
catch (e) {
|
|
1013
|
+
return {
|
|
1014
|
+
label,
|
|
1015
|
+
status: ADVISORY,
|
|
1016
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
const groups = parsed.hooks?.PreToolUse ?? [];
|
|
1020
|
+
const group = groups.find((g) => g.matcher === 'Agent|Skill');
|
|
1021
|
+
if (group === undefined) {
|
|
1022
|
+
return {
|
|
1023
|
+
label,
|
|
1024
|
+
status: ADVISORY,
|
|
1025
|
+
detail: 'no PreToolUse group with matcher "Agent|Skill" found in .claude/settings.json — ' +
|
|
1026
|
+
'run `rea upgrade` to install (advisory in 0.29.0; promoted to fail in 0.30.0). ' +
|
|
1027
|
+
'NOTE: matcher MUST be exactly `Agent|Skill` ' +
|
|
1028
|
+
'(NOT `Task|Skill` — `TaskCreate`/`TaskList` are unrelated todo-list tools).',
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
const cmds = (group.hooks ?? []).map((h) => (typeof h.command === 'string' ? h.command : ''));
|
|
1032
|
+
if (!cmds.some((c) => c.includes('delegation-capture.sh'))) {
|
|
1033
|
+
return {
|
|
1034
|
+
label,
|
|
1035
|
+
status: ADVISORY,
|
|
1036
|
+
detail: 'Agent|Skill matcher exists but no delegation-capture.sh command found in its hooks list',
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
return { label, status: 'pass' };
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* 0.29.0 — synthetic round-trip of the delegation-signal audit path.
|
|
1043
|
+
* Drives a synthetic Claude Code PreToolUse hook payload through the
|
|
1044
|
+
* REAL `rea hook delegation-signal` CLI by spawning a child process
|
|
1045
|
+
* (same path the shell hook hits) and asserts:
|
|
1046
|
+
*
|
|
1047
|
+
* - The CLI exited 0.
|
|
1048
|
+
* - A new `rea.delegation_signal` record landed on disk.
|
|
1049
|
+
* - The record's metadata contains the probe tag (so we don't
|
|
1050
|
+
* mistakenly attribute an existing record to our run).
|
|
1051
|
+
* - Chain integrity holds (recomputed hash == stored hash).
|
|
1052
|
+
*
|
|
1053
|
+
* Codex round 1 P2 (2026-05-12): the previous implementation called
|
|
1054
|
+
* `appendAuditRecord()` directly — short-circuiting stdin parsing,
|
|
1055
|
+
* SHA-256 hashing, redact-secrets timing, and the `process.exit`
|
|
1056
|
+
* ordering that round 1's P1 exposed. That made the smoke check
|
|
1057
|
+
* report success even when the real production path was broken.
|
|
1058
|
+
*
|
|
1059
|
+
* This rewrite exercises the same surface the `Agent|Skill`
|
|
1060
|
+
* PreToolUse hook does in production, so future regressions in
|
|
1061
|
+
* stdin parsing, hashing, redaction, or process-lifecycle behavior
|
|
1062
|
+
* fail the smoke check loudly.
|
|
1063
|
+
*
|
|
1064
|
+
* Gated behind `--smoke` so a casual `rea doctor` doesn't write
|
|
1065
|
+
* probe records on every invocation. Operators run
|
|
1066
|
+
* `rea doctor --smoke` after install / upgrade to confirm the
|
|
1067
|
+
* pipeline is wired end-to-end.
|
|
1068
|
+
*/
|
|
1069
|
+
export async function checkDelegationRoundTrip(baseDir) {
|
|
1070
|
+
const probeTag = `doctor-smoke-${process.pid}-${Date.now()}`;
|
|
1071
|
+
// Resolve the rea CLI binary the same way the shell hook does.
|
|
1072
|
+
// First-class: this very process is running rea, so `process.argv[1]`
|
|
1073
|
+
// is the right entrypoint. Fall back to the dist path in
|
|
1074
|
+
// node_modules.
|
|
1075
|
+
const cliEntry = process.argv[1];
|
|
1076
|
+
if (cliEntry === undefined || cliEntry.length === 0) {
|
|
1077
|
+
return {
|
|
1078
|
+
label: 'delegation-signal round-trip',
|
|
1079
|
+
status: 'fail',
|
|
1080
|
+
detail: 'could not resolve the rea CLI entrypoint (process.argv[1] empty)',
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
// Codex round 4 P3 (2026-05-12): exercise a NON-EMPTY description
|
|
1084
|
+
// so the smoke check actually validates SHA-256 hashing of prompt
|
|
1085
|
+
// content. Pre-fix the description was '' and the hash was always
|
|
1086
|
+
// the well-known empty-string SHA-256 — a regression that ignored
|
|
1087
|
+
// tool_input.description and substituted an empty hash would have
|
|
1088
|
+
// passed the smoke check.
|
|
1089
|
+
const probeDescription = `doctor-smoke probe (${probeTag})`;
|
|
1090
|
+
const expectedDescriptionHash = crypto
|
|
1091
|
+
.createHash('sha256')
|
|
1092
|
+
.update(probeDescription)
|
|
1093
|
+
.digest('hex');
|
|
1094
|
+
const payload = JSON.stringify({
|
|
1095
|
+
tool_name: 'Agent',
|
|
1096
|
+
session_id: 'doctor-smoke',
|
|
1097
|
+
tool_input: {
|
|
1098
|
+
subagent_type: probeTag,
|
|
1099
|
+
description: probeDescription,
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1102
|
+
const auditPath = path.join(baseDir, '.rea', 'audit.jsonl');
|
|
1103
|
+
// Synchronously spawn the CLI. The blocking wait is appropriate for
|
|
1104
|
+
// a doctor check — the operator just typed `rea doctor --smoke` and
|
|
1105
|
+
// is waiting for output anyway. `--detach` is NOT passed: we want
|
|
1106
|
+
// the CLI to await its own append (the post-P1 fix) and exit
|
|
1107
|
+
// cleanly.
|
|
1108
|
+
const { spawnSync } = await import('node:child_process');
|
|
1109
|
+
const res = spawnSync(process.execPath, [cliEntry, 'hook', 'delegation-signal'], {
|
|
1110
|
+
cwd: baseDir,
|
|
1111
|
+
input: payload,
|
|
1112
|
+
encoding: 'utf8',
|
|
1113
|
+
timeout: 15_000,
|
|
1114
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: baseDir },
|
|
1115
|
+
});
|
|
1116
|
+
if (res.error !== undefined) {
|
|
1117
|
+
return {
|
|
1118
|
+
label: 'delegation-signal round-trip',
|
|
1119
|
+
status: 'fail',
|
|
1120
|
+
detail: `CLI spawn failed: ${res.error.message}`,
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
if (res.status !== 0) {
|
|
1124
|
+
return {
|
|
1125
|
+
label: 'delegation-signal round-trip',
|
|
1126
|
+
status: 'fail',
|
|
1127
|
+
detail: `CLI exited ${res.status ?? 'null'}; stderr: ${(res.stderr ?? '').slice(0, 240)}`,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
// Read the audit log and find the record carrying our probe tag.
|
|
1131
|
+
let raw;
|
|
1132
|
+
try {
|
|
1133
|
+
raw = await fsPromises.readFile(auditPath, 'utf8');
|
|
1134
|
+
}
|
|
1135
|
+
catch (e) {
|
|
1136
|
+
return {
|
|
1137
|
+
label: 'delegation-signal round-trip',
|
|
1138
|
+
status: 'fail',
|
|
1139
|
+
detail: `audit log read failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
1143
|
+
let matched = null;
|
|
1144
|
+
for (const line of lines) {
|
|
1145
|
+
try {
|
|
1146
|
+
const p = JSON.parse(line);
|
|
1147
|
+
if (p.tool_name === DELEGATION_SIGNAL_TOOL_NAME && p.metadata?.subagent_type === probeTag) {
|
|
1148
|
+
matched = { line, parsed: p };
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
catch {
|
|
1152
|
+
// skip malformed
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (matched === null) {
|
|
1156
|
+
return {
|
|
1157
|
+
label: 'delegation-signal round-trip',
|
|
1158
|
+
status: 'fail',
|
|
1159
|
+
detail: `CLI exited 0 but no ` +
|
|
1160
|
+
`rea.delegation_signal record with probe-tag ${probeTag} found in audit.jsonl`,
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
// Codex round 4 P3 (2026-05-12): assert the recorded
|
|
1164
|
+
// invocation_description_sha256 matches the expected hash of the
|
|
1165
|
+
// probe description we sent. Catches a regression where the parser
|
|
1166
|
+
// ignores tool_input.description and substitutes the empty hash.
|
|
1167
|
+
const recordedDescHash = matched.parsed.metadata?.invocation_description_sha256;
|
|
1168
|
+
if (recordedDescHash !== expectedDescriptionHash) {
|
|
1169
|
+
return {
|
|
1170
|
+
label: 'delegation-signal round-trip',
|
|
1171
|
+
status: 'fail',
|
|
1172
|
+
detail: `recorded invocation_description_sha256 mismatch: ` +
|
|
1173
|
+
`expected ${expectedDescriptionHash.slice(0, 16)}…, ` +
|
|
1174
|
+
`got ${(recordedDescHash ?? 'undefined').slice(0, 16)}…`,
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
// Verify chain integrity for the probe record. Recompute its hash
|
|
1178
|
+
// over the record-minus-hash payload and compare.
|
|
1179
|
+
const recordParsed = JSON.parse(matched.line);
|
|
1180
|
+
const storedHash = recordParsed.hash;
|
|
1181
|
+
if (typeof storedHash !== 'string' || storedHash.length !== 64) {
|
|
1182
|
+
return {
|
|
1183
|
+
label: 'delegation-signal round-trip',
|
|
1184
|
+
status: 'fail',
|
|
1185
|
+
detail: 'probe record has no valid `hash` field',
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
const { hash: _h, ...rest } = recordParsed;
|
|
1189
|
+
void _h;
|
|
1190
|
+
const recomputed = computeHash(rest);
|
|
1191
|
+
if (recomputed !== storedHash) {
|
|
1192
|
+
return {
|
|
1193
|
+
label: 'delegation-signal round-trip',
|
|
1194
|
+
status: 'fail',
|
|
1195
|
+
detail: `chain integrity broken: stored=${storedHash} recomputed=${recomputed}`,
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
label: 'delegation-signal round-trip',
|
|
1200
|
+
status: 'pass',
|
|
1201
|
+
detail: `probe via real CLI (hash=${storedHash.slice(0, 16)}, tag=${probeTag.slice(-8)})`,
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
710
1204
|
/**
|
|
711
1205
|
* Assemble the full checklist for a given baseDir. Exported so tests can
|
|
712
1206
|
* exercise the conditional branching without capturing stdout from
|
|
@@ -722,7 +1216,7 @@ function codexRequiredFromPolicy(baseDir) {
|
|
|
722
1216
|
*
|
|
723
1217
|
* `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
|
|
724
1218
|
*/
|
|
725
|
-
export function collectChecks(baseDir, codexProbeState, prePushState) {
|
|
1219
|
+
export function collectChecks(baseDir, codexProbeState, prePushState, options = {}) {
|
|
726
1220
|
const policyPath = reaPath(baseDir, POLICY_FILE);
|
|
727
1221
|
const registryPath = reaPath(baseDir, REGISTRY_FILE);
|
|
728
1222
|
const reaDirPath = path.join(baseDir, REA_DIR);
|
|
@@ -733,6 +1227,17 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
|
|
|
733
1227
|
checkAgentsPresent(baseDir),
|
|
734
1228
|
checkHooksInstalled(baseDir),
|
|
735
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),
|
|
1235
|
+
// 0.29.0 — delegation-telemetry MVP wiring check. Separate from
|
|
1236
|
+
// checkSettingsJson because that check only validates the
|
|
1237
|
+
// existence of the Bash + Write|Edit|MultiEdit|NotebookEdit
|
|
1238
|
+
// matcher groups. The Agent|Skill matcher is new and needs its
|
|
1239
|
+
// own pass/fail signal.
|
|
1240
|
+
checkDelegationHookRegistered(baseDir),
|
|
736
1241
|
];
|
|
737
1242
|
// Non-git escape hatch: when `.git/` is absent, both git-hook checks are
|
|
738
1243
|
// meaningless (commit-msg + pre-push can't be invoked without git). Emit
|
|
@@ -740,6 +1245,10 @@ export function collectChecks(baseDir, codexProbeState, prePushState) {
|
|
|
740
1245
|
// other non-source-code directories that consume rea governance.
|
|
741
1246
|
if (isGitRepo(baseDir)) {
|
|
742
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));
|
|
743
1252
|
if (prePushState !== undefined) {
|
|
744
1253
|
checks.push(checkPrePushHook(prePushState));
|
|
745
1254
|
}
|
|
@@ -952,11 +1461,20 @@ export async function runDoctor(opts = {}) {
|
|
|
952
1461
|
catch {
|
|
953
1462
|
prePushState = undefined;
|
|
954
1463
|
}
|
|
955
|
-
const checks = collectChecks(baseDir, probeState, prePushState
|
|
1464
|
+
const checks = collectChecks(baseDir, probeState, prePushState, {
|
|
1465
|
+
strict: opts.strict === true,
|
|
1466
|
+
});
|
|
956
1467
|
// G7: async fingerprint-store check. Kept out of `collectChecks` so the
|
|
957
1468
|
// existing sync contract stays intact for downstream consumers; appended
|
|
958
1469
|
// here so runDoctor surfaces it inline.
|
|
959
1470
|
checks.push(await checkFingerprintStore(baseDir));
|
|
1471
|
+
// 0.29.0 — optional synthetic round-trip of the delegation-signal
|
|
1472
|
+
// audit path. Only runs under `--smoke` because it writes a probe
|
|
1473
|
+
// record to the audit chain; default `rea doctor` invocations leave
|
|
1474
|
+
// the chain untouched.
|
|
1475
|
+
if (opts.smoke === true) {
|
|
1476
|
+
checks.push(await checkDelegationRoundTrip(baseDir));
|
|
1477
|
+
}
|
|
960
1478
|
console.log('');
|
|
961
1479
|
log(`Doctor — ${baseDir}`);
|
|
962
1480
|
console.log('');
|
package/dist/cli/hook.d.ts
CHANGED
|
@@ -188,18 +188,50 @@ export interface HookCodexReviewOptions {
|
|
|
188
188
|
rawStdoutDir?: string;
|
|
189
189
|
}
|
|
190
190
|
export declare function runHookCodexReview(options: HookCodexReviewOptions): Promise<void>;
|
|
191
|
+
export interface HookDelegationSignalOptions {
|
|
192
|
+
/**
|
|
193
|
+
* Run the audit append in the background and return immediately. The
|
|
194
|
+
* shell hook stub sets this so the worst-case latency of the
|
|
195
|
+
* `Agent|Skill` PreToolUse hook stays in the tens-of-milliseconds
|
|
196
|
+
* range even when the audit chain is under cross-process contention.
|
|
197
|
+
*/
|
|
198
|
+
detach?: boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Override REA_ROOT. Tests set this; the production caller relies on
|
|
201
|
+
* `process.cwd()` or the `$CLAUDE_PROJECT_DIR` env var.
|
|
202
|
+
*/
|
|
203
|
+
reaRoot?: string;
|
|
204
|
+
/**
|
|
205
|
+
* Lock-acquisition timeout in milliseconds. If `appendAuditRecord`
|
|
206
|
+
* hasn't returned within this budget, the CLI exits 0 with a stderr
|
|
207
|
+
* warning. The append is fire-and-forget at that point — we'd rather
|
|
208
|
+
* drop a single signal than block Claude Code's tool dispatch on
|
|
209
|
+
* audit-log contention. Default: 2000 ms.
|
|
210
|
+
*/
|
|
211
|
+
lockTimeoutMs?: number;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Read the hook stdin payload, redact + hash, and either await the
|
|
215
|
+
* audit append OR fire-and-forget it (when `--detach` is set).
|
|
216
|
+
*
|
|
217
|
+
* Exit-code contract: ALWAYS exit 0. The delegation signal is
|
|
218
|
+
* observational, not gating — failure to write the record must NOT
|
|
219
|
+
* block Claude Code's tool dispatch. Errors are surfaced on stderr.
|
|
220
|
+
*/
|
|
221
|
+
export declare function runHookDelegationSignal(options: HookDelegationSignalOptions): Promise<void>;
|
|
191
222
|
/**
|
|
192
223
|
* Attach the `rea hook` subcommand tree to a commander Program.
|
|
193
224
|
*
|
|
194
225
|
* Subcommands:
|
|
195
|
-
* - `push-gate`
|
|
196
|
-
* - `scan-bash`
|
|
197
|
-
*
|
|
198
|
-
* - `policy-get`
|
|
199
|
-
* - `codex-review`
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
226
|
+
* - `push-gate` — stateless pre-push Codex review (called by husky).
|
|
227
|
+
* - `scan-bash` — parser-backed bash-tier scanner (called by Claude
|
|
228
|
+
* Code shim hooks).
|
|
229
|
+
* - `policy-get` — single-source-of-truth policy reader for bash hooks.
|
|
230
|
+
* - `codex-review` — thin Bash-direct codex invocation (0.27.0+) for
|
|
231
|
+
* marathon-mode review cycles.
|
|
232
|
+
* - `delegation-signal` — 0.29.0 delegation-telemetry MVP. Reads a Claude
|
|
233
|
+
* Code PreToolUse hook payload for `Agent` / `Skill`
|
|
234
|
+
* and emits a `rea.delegation_signal` audit record.
|
|
203
235
|
*
|
|
204
236
|
* New hooks should land here rather than as top-level commands so the
|
|
205
237
|
* CLI surface stays navigable.
|