@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.
Files changed (38) hide show
  1. package/.husky/prepare-commit-msg +295 -0
  2. package/MIGRATING.md +75 -0
  3. package/dist/audit/append.d.ts +1 -0
  4. package/dist/audit/append.js +1 -0
  5. package/dist/audit/delegation-event.d.ts +215 -0
  6. package/dist/audit/delegation-event.js +113 -0
  7. package/dist/cli/audit-specialists.d.ts +113 -0
  8. package/dist/cli/audit-specialists.js +220 -0
  9. package/dist/cli/doctor.d.ts +114 -1
  10. package/dist/cli/doctor.js +523 -5
  11. package/dist/cli/hook.d.ts +40 -8
  12. package/dist/cli/hook.js +305 -8
  13. package/dist/cli/index.js +9 -0
  14. package/dist/cli/init.js +120 -0
  15. package/dist/cli/install/manifest-schema.d.ts +6 -6
  16. package/dist/cli/install/prepare-commit-msg.d.ts +83 -0
  17. package/dist/cli/install/prepare-commit-msg.js +208 -0
  18. package/dist/cli/install/settings-merge.js +20 -0
  19. package/dist/cli/upgrade.js +34 -0
  20. package/dist/config/settings-schema.d.ts +2087 -0
  21. package/dist/config/settings-schema.js +294 -0
  22. package/dist/config/tier-map.js +22 -1
  23. package/dist/policy/loader.d.ts +58 -0
  24. package/dist/policy/loader.js +68 -0
  25. package/dist/policy/profiles.d.ts +48 -0
  26. package/dist/policy/profiles.js +25 -0
  27. package/dist/policy/types.d.ts +51 -0
  28. package/dist/registry/loader.d.ts +12 -12
  29. package/hooks/delegation-capture.sh +158 -0
  30. package/package.json +1 -1
  31. package/profiles/bst-internal-no-codex.yaml +15 -0
  32. package/profiles/bst-internal.yaml +16 -0
  33. package/profiles/client-engagement.yaml +14 -0
  34. package/profiles/lit-wc.yaml +14 -0
  35. package/profiles/minimal.yaml +16 -0
  36. package/profiles/open-source-no-codex.yaml +13 -0
  37. package/profiles/open-source.yaml +13 -0
  38. package/templates/prepare-commit-msg.husky.sh +295 -0
@@ -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>;
@@ -0,0 +1,208 @@
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
+ import { execFile } from 'node:child_process';
24
+ import fs from 'node:fs';
25
+ import fsPromises from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { promisify } from 'node:util';
28
+ import { PKG_ROOT, warn } from '../utils.js';
29
+ import { isHusky9Stub, resolveHusky9StubTarget } from './pre-push.js';
30
+ const execFileAsync = promisify(execFile);
31
+ /**
32
+ * Marker baked into every rea-installed prepare-commit-msg hook.
33
+ * Anchored on line 2 (immediately after the shebang) for classification.
34
+ * Bump the version suffix whenever the body semantics change so
35
+ * upgrades migrate cleanly.
36
+ *
37
+ * v1 — 0.30.0: first version of the augmenter hook.
38
+ */
39
+ export const PREPARE_COMMIT_MSG_MARKER = '# rea:prepare-commit-msg v1';
40
+ /**
41
+ * Body marker anchored on line 3. A foreign hook that carries the
42
+ * header marker as a comment but has an empty body (stubbed by a
43
+ * consumer) will NOT be classified as rea-managed because the body
44
+ * marker won't be on line 3. Both markers together close the
45
+ * classification question.
46
+ */
47
+ export const PREPARE_COMMIT_MSG_BODY_MARKER = '# rea:augment-body-v1';
48
+ /**
49
+ * Inspect `hookPath` and decide whether it is rea-authored or foreign.
50
+ * Strict: BOTH markers must appear on lines 2 + 3 in order. Substring
51
+ * matches deliberately rejected so a comment quoting the marker doesn't
52
+ * fool the classifier.
53
+ */
54
+ export async function classifyPrepareCommitMsgHook(hookPath) {
55
+ let stat;
56
+ try {
57
+ stat = await fsPromises.lstat(hookPath);
58
+ }
59
+ catch {
60
+ return { kind: 'absent' };
61
+ }
62
+ if (stat.isDirectory())
63
+ return { kind: 'foreign', reason: 'is-directory' };
64
+ if (stat.isSymbolicLink())
65
+ return { kind: 'foreign', reason: 'is-symlink' };
66
+ if (!stat.isFile())
67
+ return { kind: 'foreign', reason: 'not-regular-file' };
68
+ let content;
69
+ try {
70
+ content = await fsPromises.readFile(hookPath, 'utf8');
71
+ }
72
+ catch (e) {
73
+ return {
74
+ kind: 'foreign',
75
+ reason: `read-error: ${e instanceof Error ? e.message : String(e)}`,
76
+ };
77
+ }
78
+ // Codex round 2 P2: Husky 9 layout (`core.hooksPath=.husky/_`) auto-
79
+ // generates a stub like `#!/usr/bin/env sh\n. "${0%/*}/h"` at the
80
+ // active hooks path. Git dispatches through that stub to `.husky/
81
+ // prepare-commit-msg` (the canonical body, which IS rea-managed).
82
+ // Treat the stub as a managed pointer — follow the indirection and
83
+ // re-classify against the canonical target. Same pattern as
84
+ // pre-push.ts's husky 9 handling.
85
+ if (isHusky9Stub(content)) {
86
+ const target = resolveHusky9StubTarget(hookPath);
87
+ if (target !== null && target !== hookPath) {
88
+ return classifyPrepareCommitMsgHook(target);
89
+ }
90
+ }
91
+ if (!content.startsWith('#!/bin/sh\n')) {
92
+ return { kind: 'foreign', reason: 'no-marker' };
93
+ }
94
+ const lines = content.split('\n');
95
+ if (lines[1] !== PREPARE_COMMIT_MSG_MARKER || lines[2] !== PREPARE_COMMIT_MSG_BODY_MARKER) {
96
+ return { kind: 'foreign', reason: 'no-marker' };
97
+ }
98
+ return { kind: 'rea-managed', version: 'v1' };
99
+ }
100
+ /**
101
+ * Read `core.hooksPath` via `git config --get`. Returns `null` when the
102
+ * key is unset. Same execFile (not exec) discipline as the other
103
+ * installers so the target directory cannot interpolate through a
104
+ * shell.
105
+ */
106
+ async function readHooksPathFromGit(targetDir) {
107
+ try {
108
+ const { stdout } = await execFileAsync('git', ['-C', targetDir, 'config', '--get', 'core.hooksPath'], { encoding: 'utf8' });
109
+ const trimmed = stdout.trim();
110
+ return trimmed.length > 0 ? trimmed : null;
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ function sourceHookPath() {
117
+ return path.join(PKG_ROOT, '.husky', 'prepare-commit-msg');
118
+ }
119
+ async function writeExecutable(src, dst) {
120
+ await fsPromises.mkdir(path.dirname(dst), { recursive: true });
121
+ await fsPromises.copyFile(src, dst);
122
+ await fsPromises.chmod(dst, 0o755);
123
+ }
124
+ /**
125
+ * Install the prepare-commit-msg hook into the consumer project at
126
+ * `targetDir`. Refuses to stomp foreign hooks; refreshes rea-managed
127
+ * hooks in place. Best-effort: a missing `.husky/` directory simply
128
+ * skips the husky copy (git-hooks copy is sufficient for vanilla git).
129
+ *
130
+ * Foreign-hook conflict (the 0.13.2 pre-push prior art): we never
131
+ * overwrite a non-rea body. The caller surfaces the conflict to the
132
+ * operator; `rea doctor` flags the gap so the operator can decide
133
+ * whether to relocate their existing hook into a fragment, replace it
134
+ * with rea's body, or set `attribution.co_author.enabled: false`.
135
+ */
136
+ export async function installPrepareCommitMsgHook(targetDir) {
137
+ const result = { warnings: [] };
138
+ const src = sourceHookPath();
139
+ if (!fs.existsSync(src)) {
140
+ result.warnings.push(`packaged prepare-commit-msg hook missing at ${src}`);
141
+ return result;
142
+ }
143
+ const gitDir = path.join(targetDir, '.git');
144
+ if (!fs.existsSync(gitDir)) {
145
+ result.warnings.push('.git/ not found — skipping prepare-commit-msg install (not a git repo?)');
146
+ return result;
147
+ }
148
+ // Codex round 4 P2: `.git` may be a FILE (linked worktrees, submodules)
149
+ // rather than a directory. `path.join(targetDir, '.git', 'hooks')` then
150
+ // points into a non-existent location and the writeExecutable mkdir
151
+ // throws ENOTDIR. Use `git rev-parse --git-path hooks` to resolve
152
+ // the actual hooks dir regardless of worktree/submodule indirection.
153
+ let hooksDir;
154
+ const configuredHooksPath = await readHooksPathFromGit(targetDir);
155
+ if (configuredHooksPath !== null) {
156
+ hooksDir = path.isAbsolute(configuredHooksPath)
157
+ ? configuredHooksPath
158
+ : path.join(targetDir, configuredHooksPath);
159
+ result.warnings.push(`git core.hooksPath is set — installing prepare-commit-msg to ${hooksDir}`);
160
+ }
161
+ else {
162
+ try {
163
+ const { stdout } = await execFileAsync('git', ['-C', targetDir, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8' });
164
+ const resolved = stdout.trim();
165
+ hooksDir = path.isAbsolute(resolved) ? resolved : path.join(targetDir, resolved);
166
+ }
167
+ catch {
168
+ hooksDir = path.join(gitDir, 'hooks');
169
+ }
170
+ }
171
+ const gitHookPath = path.join(hooksDir, 'prepare-commit-msg');
172
+ const gitClassification = await classifyPrepareCommitMsgHook(gitHookPath);
173
+ if (gitClassification.kind === 'foreign') {
174
+ result.warnings.push(`foreign prepare-commit-msg at ${gitHookPath} (${gitClassification.reason}) — ` +
175
+ `leaving alone. Either remove it and re-run rea init, or migrate to a ` +
176
+ `fragment under .husky/prepare-commit-msg.d/ (not yet supported in 0.30.0).`);
177
+ result.skippedForeign = true;
178
+ }
179
+ else {
180
+ await writeExecutable(src, gitHookPath);
181
+ result.gitHook = gitHookPath;
182
+ if (gitClassification.kind === 'rea-managed') {
183
+ result.refreshed = true;
184
+ }
185
+ }
186
+ const huskyDir = path.join(targetDir, '.husky');
187
+ if (fs.existsSync(huskyDir)) {
188
+ const huskyHookPath = path.join(huskyDir, 'prepare-commit-msg');
189
+ const huskyClassification = await classifyPrepareCommitMsgHook(huskyHookPath);
190
+ if (huskyClassification.kind === 'foreign') {
191
+ result.warnings.push(`foreign .husky/prepare-commit-msg at ${huskyHookPath} ` +
192
+ `(${huskyClassification.reason}) — leaving alone.`);
193
+ result.skippedForeign = true;
194
+ }
195
+ else {
196
+ await writeExecutable(src, huskyHookPath);
197
+ result.huskyHook = huskyHookPath;
198
+ if (huskyClassification.kind === 'rea-managed' && result.refreshed !== true) {
199
+ result.refreshed = true;
200
+ }
201
+ }
202
+ }
203
+ else {
204
+ warn('no .husky/ directory — skipped husky prepare-commit-msg copy ' +
205
+ '(git-hooks copy is sufficient)');
206
+ }
207
+ return result;
208
+ }
@@ -318,6 +318,26 @@ export function defaultDesiredHooks() {
318
318
  },
319
319
  ],
320
320
  },
321
+ {
322
+ // 0.29.0 delegation-telemetry MVP. The matcher is `Agent|Skill` —
323
+ // the two delegation tools in current Claude Code. NOT `Task|Skill`:
324
+ // `TaskCreate`/`TaskList`/`TaskUpdate` are unrelated todo-list tools
325
+ // and MUST NOT match. The hook is observational; its only effect is
326
+ // to append a `rea.delegation_signal` audit record. Worst-case
327
+ // latency budget is 50ms even under audit-chain contention (the
328
+ // signal is backgrounded and the CLI uses a 2s lock-acquisition
329
+ // fallback that exits 0 on timeout).
330
+ event: 'PreToolUse',
331
+ matcher: 'Agent|Skill',
332
+ hooks: [
333
+ {
334
+ type: 'command',
335
+ command: `${base}/delegation-capture.sh`,
336
+ timeout: 5000,
337
+ statusMessage: 'Recording delegation signal...',
338
+ },
339
+ ],
340
+ },
321
341
  {
322
342
  event: 'PreToolUse',
323
343
  matcher: 'Write|Edit|MultiEdit|NotebookEdit',
@@ -45,7 +45,9 @@ import { CLAUDE_MD_MANIFEST_PATH, SETTINGS_MANIFEST_PATH, enumerateCanonicalFile
45
45
  import { buildFragment, extractFragment } from './install/claude-md.js';
46
46
  import { atomicReplaceFile, safeDeleteFile, safeInstallFile, safeReadFile, } from './install/fs-safe.js';
47
47
  import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, pruneHookCommands, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
48
+ import { validateSettings } from '../config/settings-schema.js';
48
49
  import { ensureReaGitignore } from './install/gitignore.js';
50
+ import { installPrepareCommitMsgHook } from './install/prepare-commit-msg.js';
49
51
  import { manifestExists, readManifest, writeManifestAtomic } from './install/manifest-io.js';
50
52
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
51
53
  import { err, getPkgVersion, log, warn } from './utils.js';
@@ -408,6 +410,17 @@ async function upgradeSettings(baseDir, opts) {
408
410
  // pointless work. Pruning first means the merge sees a clean baseline.
409
411
  const pruned = pruneHookCommands(settings, STALE_HOOK_COMMAND_TOKENS);
410
412
  const mergeResult = mergeSettings(pruned.merged, desired);
413
+ // 0.30.0 Class M — validate the merged result with the non-strict
414
+ // schema before writing. If the merged output would fail zod parse,
415
+ // refuse the write and leave the consumer settings untouched. This
416
+ // matches the 0.21.1 idempotency contract: rea never produces a
417
+ // broken settings.json — when in doubt, do nothing.
418
+ const validation = validateSettings(mergeResult.merged);
419
+ if (!validation.parsed) {
420
+ throw new Error(`rea upgrade: refusing to write .claude/settings.json because the merged result ` +
421
+ `fails schema validation. This is a safety guardrail — your existing file ` +
422
+ `is unchanged. zod errors: ${validation.errors.join('; ')}`);
423
+ }
411
424
  if (opts.dryRun !== true) {
412
425
  await writeSettingsAtomic(settingsPath, mergeResult.merged);
413
426
  }
@@ -605,6 +618,27 @@ export async function runUpgrade(options = {}) {
605
618
  source: 'claude-md',
606
619
  });
607
620
  }
621
+ // 0.30.0 — install the prepare-commit-msg augmenter on upgrade too
622
+ // (codex round 1 P1: consumers upgrading from 0.29.x to 0.30.0 would
623
+ // not get the new husky hook unless they re-ran `rea init`). The
624
+ // hook is a no-op when policy.attribution.co_author.enabled !== true,
625
+ // so installing unconditionally is safe; consumers opt in by editing
626
+ // .rea/policy.yaml. The installer's marker-classification path
627
+ // refuses to overwrite foreign hooks — same shape as 0.13.2
628
+ // pre-push foreign-hook handling.
629
+ if (!dryRun) {
630
+ const pcmResult = await installPrepareCommitMsgHook(resolvedRoot);
631
+ if (pcmResult.skippedForeign) {
632
+ warn(` · .husky/prepare-commit-msg (kept; foreign hook detected — see MIGRATING.md)`);
633
+ }
634
+ else if (pcmResult.huskyHook ?? pcmResult.gitHook) {
635
+ const target = pcmResult.huskyHook ?? pcmResult.gitHook;
636
+ const marker = pcmResult.refreshed ? '~' : '+';
637
+ console.log(` ${marker} ${target} (attribution augmenter)`);
638
+ }
639
+ for (const w of pcmResult.warnings)
640
+ warn(w);
641
+ }
608
642
  // BUG-010 — ensure `.gitignore` carries every runtime artifact entry. This
609
643
  // backfills older installs that predate the scaffolding in `rea init`. A
610
644
  // consumer who upgraded from 0.3.x/0.4.0 was previously seeing