@bookedsolid/rea 0.42.0 → 0.44.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/dist/cli/doctor.js +29 -6
- package/dist/cli/init.d.ts +172 -0
- package/dist/cli/init.js +617 -72
- package/package.json +1 -1
package/dist/cli/doctor.js
CHANGED
|
@@ -1660,17 +1660,40 @@ export function checkPolicyReaderTierSummary(baseDir, probes) {
|
|
|
1660
1660
|
reachable.push('Tier 3 (awk)');
|
|
1661
1661
|
if (tier1 || tier2) {
|
|
1662
1662
|
if (!listWalker) {
|
|
1663
|
-
// Tier 1 + no
|
|
1664
|
-
// arrays silently no-op via Tier 3 fallthrough.
|
|
1665
|
-
// is unreachable here because Tier 2 requires
|
|
1663
|
+
// Tier 1 + no working list walker. flow-form scalars work;
|
|
1664
|
+
// flow-form arrays silently no-op via Tier 3 fallthrough.
|
|
1665
|
+
// (Tier 2 path is unreachable here because Tier 2 requires
|
|
1666
|
+
// python3 itself reachable.)
|
|
1667
|
+
//
|
|
1668
|
+
// 0.43.0 round-7 P3 (2026-05-17): mirror the round-6 P3 fix
|
|
1669
|
+
// from `checkPolicyReaderTier3`. Pre-fix this branch always
|
|
1670
|
+
// said "neither jq nor python3 is on PATH" — but the
|
|
1671
|
+
// `listWalker` predicate is `jq OR python3ListWalkerReachable`,
|
|
1672
|
+
// so it also fires when python3 IS on PATH but the EXECUTION
|
|
1673
|
+
// probe fails (broken pyenv/asdf shim, dangling symlink,
|
|
1674
|
+
// sandboxed interpreter that fails to start). That
|
|
1675
|
+
// misdiagnosis sent operators chasing the wrong remediation
|
|
1676
|
+
// ("install python3" when python3 was already installed but
|
|
1677
|
+
// broken). Distinguish the two shapes so the operator sees
|
|
1678
|
+
// the actual problem, and surface the resolved path so they
|
|
1679
|
+
// can `ls -l` it on the filesystem.
|
|
1680
|
+
const pythonOnPath = py;
|
|
1681
|
+
const pythonState = pythonOnPath === null
|
|
1682
|
+
? 'neither jq nor python3 is on PATH'
|
|
1683
|
+
: `jq is not on PATH AND python3 at ${pythonOnPath} cannot execute \`import json\` ` +
|
|
1684
|
+
'(broken pyenv/asdf shim, sandboxed interpreter, or permission-denied binary — ' +
|
|
1685
|
+
'fix the interpreter or remove the shim)';
|
|
1686
|
+
const remediation = pythonOnPath === null
|
|
1687
|
+
? 'Install jq (`brew install jq` / `apt-get install jq`) or python3 to close the gap.'
|
|
1688
|
+
: `Install jq (\`brew install jq\` / \`apt-get install jq\`) or repair the python3 ` +
|
|
1689
|
+
`interpreter at ${pythonOnPath} to close the gap.`;
|
|
1666
1690
|
return {
|
|
1667
1691
|
label,
|
|
1668
1692
|
status: 'warn',
|
|
1669
1693
|
detail: `${reachable.join(', ')} reachable — flow-form scalars parse via Tier 1 CLI, ` +
|
|
1670
|
-
|
|
1694
|
+
`BUT ${pythonState} so \`policy_reader_get_list\` cannot iterate ` +
|
|
1671
1695
|
'the resulting JSON arrays. Flow-form list policy (e.g. `blocked_paths: [.env, ...]`) ' +
|
|
1672
|
-
|
|
1673
|
-
'(`brew install jq` / `apt-get install jq`) or python3 to close the gap.',
|
|
1696
|
+
`silently falls through to Tier 3 awk and misses inline arrays. ${remediation}`,
|
|
1674
1697
|
};
|
|
1675
1698
|
}
|
|
1676
1699
|
return {
|
package/dist/cli/init.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AutonomyLevel } from '../policy/types.js';
|
|
1
2
|
export interface InitOptions {
|
|
2
3
|
yes?: boolean | undefined;
|
|
3
4
|
fromReagent?: boolean | undefined;
|
|
@@ -16,4 +17,175 @@ export interface InitOptions {
|
|
|
16
17
|
*/
|
|
17
18
|
codex?: boolean | undefined;
|
|
18
19
|
}
|
|
20
|
+
type ProfileName = 'client-engagement' | 'bst-internal' | 'bst-internal-no-codex' | 'lit-wc' | 'open-source' | 'open-source-no-codex' | 'minimal';
|
|
21
|
+
export interface ResolvedConfig {
|
|
22
|
+
profile: ProfileName;
|
|
23
|
+
autonomyLevel: AutonomyLevel;
|
|
24
|
+
maxAutonomyLevel: AutonomyLevel;
|
|
25
|
+
blockAiAttribution: boolean;
|
|
26
|
+
blockedPaths: string[];
|
|
27
|
+
notificationChannel: string;
|
|
28
|
+
/**
|
|
29
|
+
* G11.4: written to `.rea/policy.yaml` as `review.codex_required`. We
|
|
30
|
+
* always emit the field explicitly — no implicit defaults — so an
|
|
31
|
+
* operator reading the file sees the choice that was made at init time.
|
|
32
|
+
*/
|
|
33
|
+
codexRequired: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Round-27 F6: preserved 0.26.0 local-review + commit-hygiene knobs.
|
|
36
|
+
* Each is `undefined` when the operator never set it, in which case
|
|
37
|
+
* the policy writer omits the corresponding line from the YAML output
|
|
38
|
+
* (consumers fall through to the documented 0.26.0 defaults).
|
|
39
|
+
*/
|
|
40
|
+
localReviewMode?: 'enforced' | 'off';
|
|
41
|
+
localReviewRefuseAt?: 'push' | 'commit' | 'both';
|
|
42
|
+
localReviewBypassEnvVar?: string;
|
|
43
|
+
localReviewMaxAgeSeconds?: number;
|
|
44
|
+
commitHygieneWarnAtCommits?: number;
|
|
45
|
+
commitHygieneRefuseAtCommits?: number;
|
|
46
|
+
/**
|
|
47
|
+
* 0.30.0 attribution augmenter. Preserved across re-init from a prior
|
|
48
|
+
* on-disk policy and seeded from the chosen profile on first install.
|
|
49
|
+
* Every shipped profile pins `enabled: false`, so the default for new
|
|
50
|
+
* installs is "block ready, opt in by editing the policy".
|
|
51
|
+
*/
|
|
52
|
+
attributionCoAuthor?: {
|
|
53
|
+
enabled?: boolean;
|
|
54
|
+
name?: string;
|
|
55
|
+
email?: string;
|
|
56
|
+
skipMerge?: boolean;
|
|
57
|
+
};
|
|
58
|
+
fromReagent: boolean;
|
|
59
|
+
reagentPolicyPath: string | null;
|
|
60
|
+
reagentNotices: string[];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 0.44.0 charter item 1: derive the canonical hook filename set the
|
|
64
|
+
* installer will lay down. Union of:
|
|
65
|
+
*
|
|
66
|
+
* - `EXPECTED_HOOKS` (the doctor's required-on-disk list — source of
|
|
67
|
+
* truth for "what `.claude/hooks/` must contain after install").
|
|
68
|
+
* - The `command` paths of every entry in `defaultDesiredHooks()`
|
|
69
|
+
* (the source of truth for "what `.claude/settings.json` registers
|
|
70
|
+
* with Claude Code"). Each command path ends in
|
|
71
|
+
* `.claude/hooks/<name>.sh`; we extract `<name>.sh` so the result
|
|
72
|
+
* joins cleanly with `EXPECTED_HOOKS`.
|
|
73
|
+
*
|
|
74
|
+
* Pre-0.44.0 `buildInstallSummary` hard-coded a hook count / list. If
|
|
75
|
+
* a new hook was added to `EXPECTED_HOOKS` (e.g. `delegation-advisory`
|
|
76
|
+
* was promoted in 0.36.0) or registered in `defaultDesiredHooks()`
|
|
77
|
+
* without anyone touching the summary, the operator's confirm screen
|
|
78
|
+
* silently lied about what was about to be installed. This helper
|
|
79
|
+
* means the summary now tracks the real installer surface — adding
|
|
80
|
+
* a hook to either canonical source automatically updates the screen.
|
|
81
|
+
*
|
|
82
|
+
* Sorted + deduped so the screen is stable across orderings.
|
|
83
|
+
*/
|
|
84
|
+
export declare function canonicalInstalledHooks(): string[];
|
|
85
|
+
/**
|
|
86
|
+
* 0.43.0 UX polish: build the human-readable install summary shown
|
|
87
|
+
* BEFORE any files are written. Lists, in order: the policy file
|
|
88
|
+
* being written, the chosen profile + autonomy, hook + agent counts
|
|
89
|
+
* planned, the git/husky hooks planned (paths reflect what the
|
|
90
|
+
* installer will ACTUALLY do given the target tree's shape), and
|
|
91
|
+
* whether re-run preservation is active.
|
|
92
|
+
*
|
|
93
|
+
* 0.44.0 charter item 1: hook count + listing is derived from the
|
|
94
|
+
* canonical hook resolvers via {@link canonicalInstalledHooks}, NOT
|
|
95
|
+
* hard-coded. Adding a hook to `EXPECTED_HOOKS` or
|
|
96
|
+
* `defaultDesiredHooks()` automatically reflects in this screen.
|
|
97
|
+
*
|
|
98
|
+
* Rendered via clack's `note` primitive so it sits in a bordered block
|
|
99
|
+
* adjacent to the final `confirm` gate. The string is also returned
|
|
100
|
+
* verbatim so the test suite can assert content without mocking clack.
|
|
101
|
+
*
|
|
102
|
+
* `targetState` is computed by {@link detectTargetState} — kept as a
|
|
103
|
+
* separate argument so tests can drive both shapes (husky-present and
|
|
104
|
+
* husky-absent) without touching the filesystem.
|
|
105
|
+
*/
|
|
106
|
+
export declare function buildInstallSummary(targetDir: string, config: ResolvedConfig, reRunMode: boolean, targetState: TargetState): string;
|
|
107
|
+
/**
|
|
108
|
+
* 0.43.0 codex round-1 P3: shape of the target tree the installer
|
|
109
|
+
* will see. `buildInstallSummary` and the post-install verifier both
|
|
110
|
+
* need to know whether `.git/` and `.husky/` are present so the
|
|
111
|
+
* summary doesn't lie about which hook files will be written.
|
|
112
|
+
*/
|
|
113
|
+
export interface TargetState {
|
|
114
|
+
gitRepoPresent: boolean;
|
|
115
|
+
huskyDirPresent: boolean;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 0.43.0 codex round-1 P3: detect which hook surfaces the installer
|
|
119
|
+
* will actually touch. Returns a snapshot so the install-summary
|
|
120
|
+
* confirm screen can show the right paths.
|
|
121
|
+
*
|
|
122
|
+
* Intentionally simple — the installers themselves (commit-msg,
|
|
123
|
+
* prepare-commit-msg, pre-push) each re-check at write time, so this
|
|
124
|
+
* detection is purely presentational. If something races between the
|
|
125
|
+
* snapshot and the writes (a `pnpm install` adding `.husky/` in the
|
|
126
|
+
* window between confirm and spinner), the installer's own checks win
|
|
127
|
+
* and the summary was only slightly stale.
|
|
128
|
+
*/
|
|
129
|
+
export declare function detectTargetState(targetDir: string): TargetState;
|
|
130
|
+
/**
|
|
131
|
+
* 0.44.0 charter item 2: detect filesystems where Unix mode bits are
|
|
132
|
+
* unreliable (Windows-class FSes, WSL/native crossings, some network
|
|
133
|
+
* mounts). On these, `stat.mode` for a freshly-installed `.sh` either
|
|
134
|
+
* reads back without the `0o111` exec bit set, or is zeroed entirely.
|
|
135
|
+
*
|
|
136
|
+
* Pre-fix `postInstallVerify` hard-failed the install when zero `.sh`
|
|
137
|
+
* files had the exec bit — every Windows install thus produced a
|
|
138
|
+
* false-positive "0 executable .sh files" warning even on a perfectly
|
|
139
|
+
* healthy install. We now treat exec-bit checks as advisory on these
|
|
140
|
+
* filesystems and still verify the more meaningful invariant: the
|
|
141
|
+
* files exist and have non-empty bytes.
|
|
142
|
+
*
|
|
143
|
+
* Detection strategy — two layers, either sufficient:
|
|
144
|
+
*
|
|
145
|
+
* 1. Platform — `process.platform === 'win32'` always skips the
|
|
146
|
+
* exec-bit check (native Windows has no POSIX mode bit; node's
|
|
147
|
+
* `stat.mode` is a translation that may or may not preserve the
|
|
148
|
+
* 0o111 bit depending on the source).
|
|
149
|
+
* 2. Sample — even on Linux/macOS, when crossing into a Windows-
|
|
150
|
+
* backed filesystem (WSL bind-mount onto `/mnt/c/`, an SMB
|
|
151
|
+
* share, etc.), `stat.mode` returns a value whose `0o777`
|
|
152
|
+
* portion is zero. We detect this by sampling the FIRST `.sh`
|
|
153
|
+
* file in the hooks directory and checking whether ANY of the
|
|
154
|
+
* `0o777` bits are set; if none are, treat as mode-less.
|
|
155
|
+
*
|
|
156
|
+
* Returns true when the exec-bit check should be SKIPPED.
|
|
157
|
+
*
|
|
158
|
+
* Exported for testability — callers can stub the filesystem and
|
|
159
|
+
* exercise both shapes (mode-aware vs mode-less) without spinning
|
|
160
|
+
* up an actual Windows VM.
|
|
161
|
+
*/
|
|
162
|
+
export declare function isModeLessFilesystem(hooksDir: string): boolean;
|
|
163
|
+
/**
|
|
164
|
+
* 0.43.0 UX polish: post-install sanity check. Runs synchronously
|
|
165
|
+
* after the file-write phase to catch installs that completed
|
|
166
|
+
* "successfully" but are missing a critical artifact (write
|
|
167
|
+
* permissions issue, partial copy, etc.).
|
|
168
|
+
*
|
|
169
|
+
* 0.44.0 charter item 2: exec-bit check is skipped on mode-less
|
|
170
|
+
* filesystems (Windows / WSL-crossing / SMB mounts). When skipped, we
|
|
171
|
+
* still verify the files exist + are non-empty — that's the invariant
|
|
172
|
+
* a partial-copy or zero-byte write would actually violate. The skip
|
|
173
|
+
* is annotated in the returned advisory so the operator knows why a
|
|
174
|
+
* check they expected to run didn't.
|
|
175
|
+
*
|
|
176
|
+
* Strictly read-only — no probes that touch python3 / jq / codex.
|
|
177
|
+
* Pattern modelled on the synthetic round-trip checks established by
|
|
178
|
+
* `checkDelegationRoundTrip` in 0.29.0/0.31.0: cheap, in-process,
|
|
179
|
+
* sufficient to catch the "looks-installed-but-isn't" failure shape
|
|
180
|
+
* that bites first-time consumers hardest. For deep diagnostics
|
|
181
|
+
* point the operator at `rea doctor`.
|
|
182
|
+
*
|
|
183
|
+
* Returns the list of issues found (empty = healthy). Advisory
|
|
184
|
+
* (skipped-check) lines are prefixed with `advisory:` so the caller
|
|
185
|
+
* can distinguish them from real issues if desired. The caller
|
|
186
|
+
* surfaces them via clack's `log.warn` and points the operator at
|
|
187
|
+
* `rea doctor` for follow-up.
|
|
188
|
+
*/
|
|
189
|
+
export declare function postInstallVerify(targetDir: string): string[];
|
|
19
190
|
export declare function runInit(options: InitOptions): Promise<void>;
|
|
191
|
+
export {};
|
package/dist/cli/init.js
CHANGED
|
@@ -8,6 +8,7 @@ import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js
|
|
|
8
8
|
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
|
+
import { EXPECTED_HOOKS } from './doctor.js';
|
|
11
12
|
import { installCommitMsgHook } from './install/commit-msg.js';
|
|
12
13
|
import { installPrepareCommitMsgHook } from './install/prepare-commit-msg.js';
|
|
13
14
|
import { installPrePushFallback } from './install/pre-push.js';
|
|
@@ -90,6 +91,24 @@ function resolveLayered(profileName, reagentTranslated) {
|
|
|
90
91
|
async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy = undefined) {
|
|
91
92
|
const projectName = detectProjectName(targetDir);
|
|
92
93
|
p.intro(`rea init — ${projectName}`);
|
|
94
|
+
// 0.43.0 UX polish: surface re-run vs fresh-install mode at the top
|
|
95
|
+
// of the wizard so the operator sees up-front that an existing policy
|
|
96
|
+
// is being preserved (not overwritten with defaults). Pre-fix the only
|
|
97
|
+
// signal was an oblique prompt label change ("current: L2") several
|
|
98
|
+
// questions in — easy to miss when running the wizard interactively.
|
|
99
|
+
if (existingPolicy !== undefined) {
|
|
100
|
+
p.note([
|
|
101
|
+
`Existing install detected at ${path.join(REA_DIR, POLICY_FILE)}.`,
|
|
102
|
+
'Re-run mode: your current settings will be preserved as defaults below.',
|
|
103
|
+
'Pass --force to reset everything to profile defaults.',
|
|
104
|
+
].join('\n'), 'Re-running init');
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
p.note([
|
|
108
|
+
`Setting up rea governance for ${projectName}.`,
|
|
109
|
+
'You can change every answer later by editing .rea/policy.yaml.',
|
|
110
|
+
].join('\n'), 'Fresh install');
|
|
111
|
+
}
|
|
93
112
|
let fromReagent = options.fromReagent === true;
|
|
94
113
|
if (!fromReagent && reagentPolicyPath !== null) {
|
|
95
114
|
const migrate = await p.confirm({
|
|
@@ -112,18 +131,34 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
112
131
|
}
|
|
113
132
|
else {
|
|
114
133
|
const picked = await p.select({
|
|
115
|
-
message: 'Pick a profile',
|
|
134
|
+
message: 'Pick a profile preset',
|
|
116
135
|
initialValue: 'minimal',
|
|
117
136
|
options: [
|
|
118
|
-
{
|
|
137
|
+
{
|
|
138
|
+
value: 'minimal',
|
|
139
|
+
label: 'minimal',
|
|
140
|
+
hint: 'bare policy, no extras — safe starting point',
|
|
141
|
+
},
|
|
119
142
|
{
|
|
120
143
|
value: 'client-engagement',
|
|
121
144
|
label: 'client-engagement',
|
|
122
|
-
hint: 'zero-trust client project',
|
|
145
|
+
hint: 'zero-trust client project — strict by default',
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
value: 'bst-internal',
|
|
149
|
+
label: 'bst-internal',
|
|
150
|
+
hint: 'BookedSolid internal projects (Codex review on)',
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
value: 'lit-wc',
|
|
154
|
+
label: 'lit-wc',
|
|
155
|
+
hint: 'Lit / web-component libraries',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
value: 'open-source',
|
|
159
|
+
label: 'open-source',
|
|
160
|
+
hint: 'public OSS repos (Codex review on)',
|
|
123
161
|
},
|
|
124
|
-
{ value: 'bst-internal', label: 'bst-internal', hint: 'internal BST projects' },
|
|
125
|
-
{ value: 'lit-wc', label: 'lit-wc', hint: 'Lit / web component libraries' },
|
|
126
|
-
{ value: 'open-source', label: 'open-source', hint: 'public OSS repos' },
|
|
127
162
|
],
|
|
128
163
|
});
|
|
129
164
|
if (p.isCancel(picked))
|
|
@@ -133,16 +168,34 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
133
168
|
// 0.21.1: prefer the existing on-disk value over the profile default
|
|
134
169
|
// so re-running `rea init` doesn't reset an operator's manual edit.
|
|
135
170
|
const autonomyDefault = existingPolicy?.autonomyLevel ?? layeredBase.autonomy_level ?? AutonomyLevel.L1;
|
|
171
|
+
const autonomyMessage = existingPolicy?.autonomyLevel !== undefined
|
|
172
|
+
? `Starting autonomy level (current: ${existingPolicy.autonomyLevel}). Controls how much the ` +
|
|
173
|
+
`agent can do without asking you first.`
|
|
174
|
+
: 'Starting autonomy level — how much can the agent do without asking you first?';
|
|
136
175
|
const autonomyPick = await p.select({
|
|
137
|
-
message:
|
|
138
|
-
? `Starting autonomy_level (current: ${existingPolicy.autonomyLevel})`
|
|
139
|
-
: 'Starting autonomy_level',
|
|
176
|
+
message: autonomyMessage,
|
|
140
177
|
initialValue: autonomyDefault,
|
|
141
178
|
options: [
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
179
|
+
{
|
|
180
|
+
value: AutonomyLevel.L0,
|
|
181
|
+
label: 'L0 — read-only',
|
|
182
|
+
hint: 'every write needs your approval; safest, slowest',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
value: AutonomyLevel.L1,
|
|
186
|
+
label: 'L1 — supervised writes',
|
|
187
|
+
hint: 'default — writes allowed, destructive ops gated',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
value: AutonomyLevel.L2,
|
|
191
|
+
label: 'L2 — wide latitude',
|
|
192
|
+
hint: 'destructive ops allowed; suitable for experienced operators',
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
value: AutonomyLevel.L3,
|
|
196
|
+
label: 'L3 — full autonomy',
|
|
197
|
+
hint: 'rare — supervised long-running agents only',
|
|
198
|
+
},
|
|
146
199
|
],
|
|
147
200
|
});
|
|
148
201
|
if (p.isCancel(autonomyPick))
|
|
@@ -164,7 +217,8 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
164
217
|
return { value: lvl, label: lvl };
|
|
165
218
|
});
|
|
166
219
|
const maxPick = await p.select({
|
|
167
|
-
message: '
|
|
220
|
+
message: 'Ceiling autonomy level — the agent can never exceed this even if asked. ' +
|
|
221
|
+
'Promoting past the ceiling requires editing policy.yaml by hand.',
|
|
168
222
|
initialValue: defaultMax,
|
|
169
223
|
options: maxOptions,
|
|
170
224
|
});
|
|
@@ -172,31 +226,54 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
|
|
|
172
226
|
cancel('Init cancelled.');
|
|
173
227
|
const maxAutonomyLevel = maxPick;
|
|
174
228
|
const attribPick = await p.confirm({
|
|
175
|
-
message: '
|
|
229
|
+
message: 'Block AI-attribution in commits? (rejects "Co-Authored-By: Claude" and similar ' +
|
|
230
|
+
'trailers — keeps your git history human-attributed)',
|
|
176
231
|
initialValue: layeredBase.block_ai_attribution ?? true,
|
|
177
232
|
});
|
|
178
233
|
if (p.isCancel(attribPick))
|
|
179
234
|
cancel('Init cancelled.');
|
|
180
235
|
const blockAiAttribution = attribPick === true;
|
|
181
|
-
// G11.4: "Use Codex adversarial review?" —
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
236
|
+
// G11.4: "Use Codex adversarial review?" — initial-value precedence:
|
|
237
|
+
// 1. explicit --codex / --no-codex flag wins
|
|
238
|
+
// 2. otherwise existing on-disk value (preserves operator edit on re-run)
|
|
239
|
+
// 3. otherwise profile default (`*-no-codex` profiles default to No)
|
|
240
|
+
//
|
|
241
|
+
// 0.43.0 codex round-1 P2: prior to this commit step 2 was skipped on
|
|
242
|
+
// the interactive path — the initial value collapsed to the profile
|
|
243
|
+
// default even on a re-run where the operator had already toggled
|
|
244
|
+
// codex off. The summary screen advertised codex_required as
|
|
245
|
+
// preserved while the prompt default silently reverted it. The
|
|
246
|
+
// `--yes` path already had the correct precedence (see the
|
|
247
|
+
// non-interactive branch in `runInit`); this brings the wizard in
|
|
248
|
+
// line.
|
|
249
|
+
const codexInitial = options.codex !== undefined
|
|
250
|
+
? options.codex
|
|
251
|
+
: (existingPolicy?.codexRequired ?? profileDefaultCodexRequired(profileName));
|
|
185
252
|
const codexPick = await p.confirm({
|
|
186
|
-
message: '
|
|
253
|
+
message: 'Enable Codex adversarial review? (runs a GPT-5.4 second-opinion review on every push; ' +
|
|
254
|
+
'requires the Codex CLI + an OpenAI account — can be installed later via /codex:setup)',
|
|
187
255
|
initialValue: codexInitial,
|
|
188
256
|
});
|
|
189
257
|
if (p.isCancel(codexPick))
|
|
190
258
|
cancel('Init cancelled.');
|
|
191
259
|
const codexRequired = codexPick === true;
|
|
192
|
-
p.outro('Config collected — installing files.');
|
|
193
260
|
return {
|
|
194
261
|
profile: profileName,
|
|
195
262
|
autonomyLevel,
|
|
196
263
|
maxAutonomyLevel,
|
|
197
264
|
blockAiAttribution,
|
|
198
|
-
|
|
199
|
-
|
|
265
|
+
// 0.43.0 codex round-1 P2: preserve the wizard-untouched fields
|
|
266
|
+
// (`blocked_paths` + `notification_channel`) the same way the
|
|
267
|
+
// `--yes` path already does. Pre-fix the wizard return rebuilt
|
|
268
|
+
// these from `layeredBase` on every interactive re-run, silently
|
|
269
|
+
// dropping operator edits even though the new install-summary
|
|
270
|
+
// confirm gate advertised them as preserved. The wizard does NOT
|
|
271
|
+
// prompt for either field (they are policy-file edits, not
|
|
272
|
+
// first-question UX), so falling back to the layered profile
|
|
273
|
+
// default is the correct seed shape on a fresh install; a re-run
|
|
274
|
+
// simply forwards whatever the operator committed to disk.
|
|
275
|
+
blockedPaths: existingPolicy?.blockedPaths ?? layeredBase.blocked_paths ?? ['.env', '.env.*'],
|
|
276
|
+
notificationChannel: existingPolicy?.notificationChannel ?? layeredBase.notification_channel ?? '',
|
|
200
277
|
codexRequired,
|
|
201
278
|
// Round-27 F6: the wizard does NOT prompt for the 0.26.0 knobs (they
|
|
202
279
|
// are advanced config — most teams accept defaults). But when the
|
|
@@ -737,6 +814,352 @@ function readExistingManifestInstalledAt(manifestPath) {
|
|
|
737
814
|
}
|
|
738
815
|
return undefined;
|
|
739
816
|
}
|
|
817
|
+
/**
|
|
818
|
+
* 0.44.0 charter item 1: derive the canonical hook filename set the
|
|
819
|
+
* installer will lay down. Union of:
|
|
820
|
+
*
|
|
821
|
+
* - `EXPECTED_HOOKS` (the doctor's required-on-disk list — source of
|
|
822
|
+
* truth for "what `.claude/hooks/` must contain after install").
|
|
823
|
+
* - The `command` paths of every entry in `defaultDesiredHooks()`
|
|
824
|
+
* (the source of truth for "what `.claude/settings.json` registers
|
|
825
|
+
* with Claude Code"). Each command path ends in
|
|
826
|
+
* `.claude/hooks/<name>.sh`; we extract `<name>.sh` so the result
|
|
827
|
+
* joins cleanly with `EXPECTED_HOOKS`.
|
|
828
|
+
*
|
|
829
|
+
* Pre-0.44.0 `buildInstallSummary` hard-coded a hook count / list. If
|
|
830
|
+
* a new hook was added to `EXPECTED_HOOKS` (e.g. `delegation-advisory`
|
|
831
|
+
* was promoted in 0.36.0) or registered in `defaultDesiredHooks()`
|
|
832
|
+
* without anyone touching the summary, the operator's confirm screen
|
|
833
|
+
* silently lied about what was about to be installed. This helper
|
|
834
|
+
* means the summary now tracks the real installer surface — adding
|
|
835
|
+
* a hook to either canonical source automatically updates the screen.
|
|
836
|
+
*
|
|
837
|
+
* Sorted + deduped so the screen is stable across orderings.
|
|
838
|
+
*/
|
|
839
|
+
export function canonicalInstalledHooks() {
|
|
840
|
+
const fromExpected = new Set(EXPECTED_HOOKS);
|
|
841
|
+
for (const group of defaultDesiredHooks()) {
|
|
842
|
+
for (const h of group.hooks) {
|
|
843
|
+
const cmd = h.command;
|
|
844
|
+
// Commands have shape `"$CLAUDE_PROJECT_DIR"/.claude/hooks/<name>.sh`.
|
|
845
|
+
// Take the basename (everything after the last `/`). Robust against
|
|
846
|
+
// future path changes — only the filename matters here.
|
|
847
|
+
const slashIdx = cmd.lastIndexOf('/');
|
|
848
|
+
const basename = slashIdx >= 0 ? cmd.slice(slashIdx + 1) : cmd;
|
|
849
|
+
if (basename.endsWith('.sh'))
|
|
850
|
+
fromExpected.add(basename);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return Array.from(fromExpected).sort();
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* 0.43.0 UX polish: build the human-readable install summary shown
|
|
857
|
+
* BEFORE any files are written. Lists, in order: the policy file
|
|
858
|
+
* being written, the chosen profile + autonomy, hook + agent counts
|
|
859
|
+
* planned, the git/husky hooks planned (paths reflect what the
|
|
860
|
+
* installer will ACTUALLY do given the target tree's shape), and
|
|
861
|
+
* whether re-run preservation is active.
|
|
862
|
+
*
|
|
863
|
+
* 0.44.0 charter item 1: hook count + listing is derived from the
|
|
864
|
+
* canonical hook resolvers via {@link canonicalInstalledHooks}, NOT
|
|
865
|
+
* hard-coded. Adding a hook to `EXPECTED_HOOKS` or
|
|
866
|
+
* `defaultDesiredHooks()` automatically reflects in this screen.
|
|
867
|
+
*
|
|
868
|
+
* Rendered via clack's `note` primitive so it sits in a bordered block
|
|
869
|
+
* adjacent to the final `confirm` gate. The string is also returned
|
|
870
|
+
* verbatim so the test suite can assert content without mocking clack.
|
|
871
|
+
*
|
|
872
|
+
* `targetState` is computed by {@link detectTargetState} — kept as a
|
|
873
|
+
* separate argument so tests can drive both shapes (husky-present and
|
|
874
|
+
* husky-absent) without touching the filesystem.
|
|
875
|
+
*/
|
|
876
|
+
export function buildInstallSummary(targetDir, config, reRunMode, targetState) {
|
|
877
|
+
const lines = [];
|
|
878
|
+
const mode = reRunMode ? 'Re-run (preserving your existing edits)' : 'Fresh install';
|
|
879
|
+
lines.push(`Mode: ${mode}`);
|
|
880
|
+
lines.push(`Target: ${targetDir}`);
|
|
881
|
+
lines.push('');
|
|
882
|
+
lines.push('Will write:');
|
|
883
|
+
lines.push(` .rea/policy.yaml — profile=${config.profile}`);
|
|
884
|
+
lines.push(` autonomy=${config.autonomyLevel} (max=${config.maxAutonomyLevel})`);
|
|
885
|
+
lines.push(` attribution-block=${config.blockAiAttribution ? 'on' : 'off'}`);
|
|
886
|
+
lines.push(` codex-review=${config.codexRequired ? 'on' : 'off'}`);
|
|
887
|
+
lines.push(` .rea/registry.yaml — empty MCP-server registry`);
|
|
888
|
+
lines.push(` .rea/install-manifest.json — hash record for drift detection`);
|
|
889
|
+
lines.push(` .claude/agents/ — curated specialist agents`);
|
|
890
|
+
// 0.44.0 charter item 1: hook count derived from the canonical
|
|
891
|
+
// resolvers (EXPECTED_HOOKS + defaultDesiredHooks). Pre-fix this
|
|
892
|
+
// line read `.claude/hooks/ — hook scripts (executable)`
|
|
893
|
+
// with no count, so adding a new hook silently changed the install
|
|
894
|
+
// surface without surfacing in the operator's confirm screen.
|
|
895
|
+
const hookNames = canonicalInstalledHooks();
|
|
896
|
+
lines.push(` .claude/hooks/ — ${hookNames.length} hook scripts (executable):`);
|
|
897
|
+
for (const name of hookNames) {
|
|
898
|
+
lines.push(` ${name}`);
|
|
899
|
+
}
|
|
900
|
+
lines.push(` .claude/commands/ — slash commands`);
|
|
901
|
+
lines.push(` .claude/settings.json — hook registration entries`);
|
|
902
|
+
// 0.43.0 codex round-1 P3: the installer writes to `.git/hooks/*`
|
|
903
|
+
// ALWAYS when a git repo is present, AND to `.husky/*` ONLY when
|
|
904
|
+
// `.husky/` already exists. Pre-fix the summary hard-coded `.husky/*`
|
|
905
|
+
// and silently omitted the `.git/hooks/*` writes from the "no
|
|
906
|
+
// .husky/" install shape — the most common one. The operator then
|
|
907
|
+
// confirmed without being told their `.git/hooks/` would be
|
|
908
|
+
// modified. We list both surfaces conditionally based on the
|
|
909
|
+
// detected target state so the screen is faithful to what actually
|
|
910
|
+
// happens.
|
|
911
|
+
if (targetState.gitRepoPresent) {
|
|
912
|
+
lines.push(` .git/hooks/commit-msg — commit-message attribution gate`);
|
|
913
|
+
lines.push(` .git/hooks/prepare-commit-msg — attribution augmenter (no-op until enabled)`);
|
|
914
|
+
lines.push(` .git/hooks/pre-push — local-review gate (fallback if no active hook present)`);
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
lines.push(` (no .git/ directory detected — git hook copies will be skipped)`);
|
|
918
|
+
}
|
|
919
|
+
if (targetState.huskyDirPresent) {
|
|
920
|
+
lines.push(` .husky/commit-msg — commit-message attribution gate (husky mirror)`);
|
|
921
|
+
lines.push(` .husky/prepare-commit-msg — attribution augmenter (husky mirror)`);
|
|
922
|
+
lines.push(` .husky/pre-push — local-review gate (husky mirror)`);
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
lines.push(` (no .husky/ directory detected — husky mirrors will be skipped)`);
|
|
926
|
+
}
|
|
927
|
+
lines.push(` CLAUDE.md fragment — managed governance block`);
|
|
928
|
+
lines.push(` .gitignore — managed entries for .rea runtime artifacts`);
|
|
929
|
+
if (reRunMode) {
|
|
930
|
+
lines.push('');
|
|
931
|
+
// 0.43.0 codex round-1 P2: list only the fields the wizard
|
|
932
|
+
// ACTUALLY preserves. `blocked_paths`, `notification_channel`,
|
|
933
|
+
// and `review.codex_required` are now preserved by the wizard
|
|
934
|
+
// path (matching the `--yes` path's documented contract); the
|
|
935
|
+
// wizard does NOT prompt for the 0.26.0 local_review or
|
|
936
|
+
// commit_hygiene knobs, but those values forward verbatim from
|
|
937
|
+
// the existing policy when set.
|
|
938
|
+
lines.push('Re-run preserves your manually-edited:');
|
|
939
|
+
lines.push(' • autonomy_level / max_autonomy_level / block_ai_attribution');
|
|
940
|
+
lines.push(' • blocked_paths / notification_channel');
|
|
941
|
+
lines.push(' • review.codex_required + local_review.* + commit_hygiene.*');
|
|
942
|
+
lines.push(' • attribution.co_author.* + installed_at timestamp');
|
|
943
|
+
}
|
|
944
|
+
return lines.join('\n');
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* 0.43.0 codex round-1 P3: detect which hook surfaces the installer
|
|
948
|
+
* will actually touch. Returns a snapshot so the install-summary
|
|
949
|
+
* confirm screen can show the right paths.
|
|
950
|
+
*
|
|
951
|
+
* Intentionally simple — the installers themselves (commit-msg,
|
|
952
|
+
* prepare-commit-msg, pre-push) each re-check at write time, so this
|
|
953
|
+
* detection is purely presentational. If something races between the
|
|
954
|
+
* snapshot and the writes (a `pnpm install` adding `.husky/` in the
|
|
955
|
+
* window between confirm and spinner), the installer's own checks win
|
|
956
|
+
* and the summary was only slightly stale.
|
|
957
|
+
*/
|
|
958
|
+
export function detectTargetState(targetDir) {
|
|
959
|
+
return {
|
|
960
|
+
gitRepoPresent: fs.existsSync(path.join(targetDir, '.git')),
|
|
961
|
+
huskyDirPresent: fs.existsSync(path.join(targetDir, '.husky')),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* 0.44.0 charter item 2: detect filesystems where Unix mode bits are
|
|
966
|
+
* unreliable (Windows-class FSes, WSL/native crossings, some network
|
|
967
|
+
* mounts). On these, `stat.mode` for a freshly-installed `.sh` either
|
|
968
|
+
* reads back without the `0o111` exec bit set, or is zeroed entirely.
|
|
969
|
+
*
|
|
970
|
+
* Pre-fix `postInstallVerify` hard-failed the install when zero `.sh`
|
|
971
|
+
* files had the exec bit — every Windows install thus produced a
|
|
972
|
+
* false-positive "0 executable .sh files" warning even on a perfectly
|
|
973
|
+
* healthy install. We now treat exec-bit checks as advisory on these
|
|
974
|
+
* filesystems and still verify the more meaningful invariant: the
|
|
975
|
+
* files exist and have non-empty bytes.
|
|
976
|
+
*
|
|
977
|
+
* Detection strategy — two layers, either sufficient:
|
|
978
|
+
*
|
|
979
|
+
* 1. Platform — `process.platform === 'win32'` always skips the
|
|
980
|
+
* exec-bit check (native Windows has no POSIX mode bit; node's
|
|
981
|
+
* `stat.mode` is a translation that may or may not preserve the
|
|
982
|
+
* 0o111 bit depending on the source).
|
|
983
|
+
* 2. Sample — even on Linux/macOS, when crossing into a Windows-
|
|
984
|
+
* backed filesystem (WSL bind-mount onto `/mnt/c/`, an SMB
|
|
985
|
+
* share, etc.), `stat.mode` returns a value whose `0o777`
|
|
986
|
+
* portion is zero. We detect this by sampling the FIRST `.sh`
|
|
987
|
+
* file in the hooks directory and checking whether ANY of the
|
|
988
|
+
* `0o777` bits are set; if none are, treat as mode-less.
|
|
989
|
+
*
|
|
990
|
+
* Returns true when the exec-bit check should be SKIPPED.
|
|
991
|
+
*
|
|
992
|
+
* Exported for testability — callers can stub the filesystem and
|
|
993
|
+
* exercise both shapes (mode-aware vs mode-less) without spinning
|
|
994
|
+
* up an actual Windows VM.
|
|
995
|
+
*/
|
|
996
|
+
export function isModeLessFilesystem(hooksDir) {
|
|
997
|
+
if (process.platform === 'win32')
|
|
998
|
+
return true;
|
|
999
|
+
// Sample any single .sh file to probe whether the FS preserves
|
|
1000
|
+
// exec bits at all. We don't need every file — just one signal.
|
|
1001
|
+
try {
|
|
1002
|
+
const entries = fs.readdirSync(hooksDir);
|
|
1003
|
+
const firstSh = entries.find((e) => e.endsWith('.sh'));
|
|
1004
|
+
if (firstSh === undefined) {
|
|
1005
|
+
// No .sh files at all — let the caller's existence check fire.
|
|
1006
|
+
// Treat as mode-aware (skip = false) so we don't hide the
|
|
1007
|
+
// genuinely-missing-files case behind the WSL advisory.
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
const stat = fs.statSync(path.join(hooksDir, firstSh));
|
|
1011
|
+
// If ALL 0o777 bits are clear, the FS is not preserving Unix
|
|
1012
|
+
// mode bits. Genuine Unix installs always have at least the
|
|
1013
|
+
// owner-read bit (0o400) set, so an entirely-zero perms triple
|
|
1014
|
+
// means we're on a mode-less mount.
|
|
1015
|
+
if ((stat.mode & 0o777) === 0)
|
|
1016
|
+
return true;
|
|
1017
|
+
return false;
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
// Stat failed — let the caller's enumeration handle the error.
|
|
1021
|
+
// Returning false here means "don't skip" so a genuine ENOENT
|
|
1022
|
+
// surfaces through the normal exec-bit branch.
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* 0.43.0 UX polish: post-install sanity check. Runs synchronously
|
|
1028
|
+
* after the file-write phase to catch installs that completed
|
|
1029
|
+
* "successfully" but are missing a critical artifact (write
|
|
1030
|
+
* permissions issue, partial copy, etc.).
|
|
1031
|
+
*
|
|
1032
|
+
* 0.44.0 charter item 2: exec-bit check is skipped on mode-less
|
|
1033
|
+
* filesystems (Windows / WSL-crossing / SMB mounts). When skipped, we
|
|
1034
|
+
* still verify the files exist + are non-empty — that's the invariant
|
|
1035
|
+
* a partial-copy or zero-byte write would actually violate. The skip
|
|
1036
|
+
* is annotated in the returned advisory so the operator knows why a
|
|
1037
|
+
* check they expected to run didn't.
|
|
1038
|
+
*
|
|
1039
|
+
* Strictly read-only — no probes that touch python3 / jq / codex.
|
|
1040
|
+
* Pattern modelled on the synthetic round-trip checks established by
|
|
1041
|
+
* `checkDelegationRoundTrip` in 0.29.0/0.31.0: cheap, in-process,
|
|
1042
|
+
* sufficient to catch the "looks-installed-but-isn't" failure shape
|
|
1043
|
+
* that bites first-time consumers hardest. For deep diagnostics
|
|
1044
|
+
* point the operator at `rea doctor`.
|
|
1045
|
+
*
|
|
1046
|
+
* Returns the list of issues found (empty = healthy). Advisory
|
|
1047
|
+
* (skipped-check) lines are prefixed with `advisory:` so the caller
|
|
1048
|
+
* can distinguish them from real issues if desired. The caller
|
|
1049
|
+
* surfaces them via clack's `log.warn` and points the operator at
|
|
1050
|
+
* `rea doctor` for follow-up.
|
|
1051
|
+
*/
|
|
1052
|
+
export function postInstallVerify(targetDir) {
|
|
1053
|
+
const issues = [];
|
|
1054
|
+
// 1. policy file exists + parses as YAML object.
|
|
1055
|
+
const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
|
|
1056
|
+
if (!fs.existsSync(policyPath)) {
|
|
1057
|
+
issues.push(`.rea/policy.yaml missing after install (expected at ${policyPath})`);
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
try {
|
|
1061
|
+
const raw = fs.readFileSync(policyPath, 'utf8');
|
|
1062
|
+
const parsed = parseYaml(raw);
|
|
1063
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
1064
|
+
issues.push('.rea/policy.yaml parsed to a non-object — run `rea doctor` for details');
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
catch (e) {
|
|
1068
|
+
issues.push(`.rea/policy.yaml failed to parse: ${e instanceof Error ? e.message : String(e)} — ` +
|
|
1069
|
+
'run `rea doctor` for details');
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
// 2. .claude/hooks directory present with non-empty scripts (and,
|
|
1073
|
+
// on mode-aware filesystems, executable).
|
|
1074
|
+
const hooksDir = path.join(targetDir, '.claude', 'hooks');
|
|
1075
|
+
if (!fs.existsSync(hooksDir)) {
|
|
1076
|
+
issues.push(`.claude/hooks/ directory missing after install (expected at ${hooksDir})`);
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
const modeLess = isModeLessFilesystem(hooksDir);
|
|
1080
|
+
let executableCount = 0;
|
|
1081
|
+
let shCount = 0;
|
|
1082
|
+
try {
|
|
1083
|
+
for (const entry of fs.readdirSync(hooksDir)) {
|
|
1084
|
+
if (!entry.endsWith('.sh'))
|
|
1085
|
+
continue;
|
|
1086
|
+
shCount += 1;
|
|
1087
|
+
const stat = fs.statSync(path.join(hooksDir, entry));
|
|
1088
|
+
if ((stat.mode & 0o111) !== 0)
|
|
1089
|
+
executableCount += 1;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
catch (e) {
|
|
1093
|
+
issues.push(`failed to enumerate .claude/hooks/: ${e instanceof Error ? e.message : String(e)}`);
|
|
1094
|
+
}
|
|
1095
|
+
if (modeLess) {
|
|
1096
|
+
// 0.44.0 charter item 2: emit a one-liner advisory so the
|
|
1097
|
+
// operator understands why the exec-bit check didn't run. Still
|
|
1098
|
+
// verify the files exist + have content — that's the partial-
|
|
1099
|
+
// copy failure shape we genuinely want to catch on these FSes.
|
|
1100
|
+
//
|
|
1101
|
+
// 0.44.0 codex round-1 P2 fix: validate the FULL canonical hook
|
|
1102
|
+
// set, not just `shCount > 0 && nonEmptyCount > 0`. Pre-fix a
|
|
1103
|
+
// partial copy that left ONE non-empty .sh and dropped the rest
|
|
1104
|
+
// would still report "install looks healthy" because the
|
|
1105
|
+
// substitute invariant only required at least one survivor.
|
|
1106
|
+
// Now we per-file check every entry in canonicalInstalledHooks()
|
|
1107
|
+
// for existence + non-empty bytes — equivalent rigor to the
|
|
1108
|
+
// mode-aware path's per-file exec-bit check.
|
|
1109
|
+
issues.push('advisory: skipping exec-bit check on this filesystem ' +
|
|
1110
|
+
'(Windows/WSL/SMB-class; mode bits not reliable). ' +
|
|
1111
|
+
'Verifying per-file presence and non-empty content instead.');
|
|
1112
|
+
const expected = canonicalInstalledHooks();
|
|
1113
|
+
const missing = [];
|
|
1114
|
+
const empty = [];
|
|
1115
|
+
for (const name of expected) {
|
|
1116
|
+
const hookPath = path.join(hooksDir, name);
|
|
1117
|
+
if (!fs.existsSync(hookPath)) {
|
|
1118
|
+
missing.push(name);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
try {
|
|
1122
|
+
const stat = fs.statSync(hookPath);
|
|
1123
|
+
if (stat.size === 0)
|
|
1124
|
+
empty.push(name);
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
// Treat unstattable as missing — the partial-copy failure
|
|
1128
|
+
// shape we are trying to detect.
|
|
1129
|
+
missing.push(name);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (missing.length > 0) {
|
|
1133
|
+
issues.push(`.claude/hooks/ is missing ${missing.length} expected hook file(s): ${missing.join(', ')}`);
|
|
1134
|
+
}
|
|
1135
|
+
if (empty.length > 0) {
|
|
1136
|
+
issues.push(`.claude/hooks/ has ${empty.length} empty hook file(s): ${empty.join(', ')}`);
|
|
1137
|
+
}
|
|
1138
|
+
// Fallback for the no-canonical-list-known case (defensive — the
|
|
1139
|
+
// helper always returns >=1 in practice, but if a future
|
|
1140
|
+
// refactor empties the resolvers we still want to catch a
|
|
1141
|
+
// completely-empty hooks dir).
|
|
1142
|
+
if (expected.length === 0 && shCount === 0) {
|
|
1143
|
+
issues.push('.claude/hooks/ contains zero .sh files — run `rea doctor`');
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
else if (executableCount === 0) {
|
|
1147
|
+
issues.push('.claude/hooks/ contains zero executable .sh files — run `rea doctor`');
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
// 3. settings.json present.
|
|
1151
|
+
const settingsPath = path.join(targetDir, '.claude', 'settings.json');
|
|
1152
|
+
if (!fs.existsSync(settingsPath)) {
|
|
1153
|
+
issues.push(`.claude/settings.json missing after install (expected at ${settingsPath})`);
|
|
1154
|
+
}
|
|
1155
|
+
// 4. install manifest present.
|
|
1156
|
+
const manifestPath = path.join(targetDir, REA_DIR, 'install-manifest.json');
|
|
1157
|
+
if (!fs.existsSync(manifestPath)) {
|
|
1158
|
+
issues.push(`.rea/install-manifest.json missing after install (expected at ${manifestPath}) — ` +
|
|
1159
|
+
'drift detection will not work until `rea init` is re-run');
|
|
1160
|
+
}
|
|
1161
|
+
return issues;
|
|
1162
|
+
}
|
|
740
1163
|
export async function runInit(options) {
|
|
741
1164
|
const targetDir = process.cwd();
|
|
742
1165
|
const reagentPolicyPath = detectReagentPolicy(targetDir);
|
|
@@ -860,46 +1283,105 @@ export async function runInit(options) {
|
|
|
860
1283
|
config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy);
|
|
861
1284
|
config.reagentNotices = reagentNotices;
|
|
862
1285
|
}
|
|
1286
|
+
// 0.43.0 UX polish: install summary + final confirm gate. The
|
|
1287
|
+
// operator sees exactly what's about to happen BEFORE any
|
|
1288
|
+
// filesystem writes. Skipped on `--yes` / `--force` (non-interactive
|
|
1289
|
+
// paths assume consent). The confirm step is the last chance to
|
|
1290
|
+
// bail without leaving partial state on disk.
|
|
1291
|
+
const reRunMode = existingPolicy !== undefined;
|
|
1292
|
+
const interactive = options.yes !== true;
|
|
1293
|
+
if (interactive) {
|
|
1294
|
+
const targetState = detectTargetState(targetDir);
|
|
1295
|
+
const summary = buildInstallSummary(targetDir, config, reRunMode, targetState);
|
|
1296
|
+
p.note(summary, reRunMode ? 'Ready to refresh' : 'Ready to install');
|
|
1297
|
+
const proceed = await p.confirm({
|
|
1298
|
+
message: reRunMode ? 'Proceed with the refresh?' : 'Proceed with the install?',
|
|
1299
|
+
initialValue: true,
|
|
1300
|
+
});
|
|
1301
|
+
if (p.isCancel(proceed) || proceed !== true) {
|
|
1302
|
+
cancel('Init cancelled — no files written.');
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
863
1305
|
if (!fs.existsSync(reaDir))
|
|
864
1306
|
fs.mkdirSync(reaDir, { recursive: true });
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
//
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1307
|
+
// 0.43.0 UX polish: wrap the file-write phase in a clack spinner so
|
|
1308
|
+
// operators on slow disks see progress instead of staring at a
|
|
1309
|
+
// motionless prompt. Skipped under `--yes` (non-interactive paths
|
|
1310
|
+
// log line-by-line). All operations remain identical — the spinner
|
|
1311
|
+
// is purely presentational.
|
|
1312
|
+
const spinner = interactive ? p.spinner() : null;
|
|
1313
|
+
if (spinner !== null)
|
|
1314
|
+
spinner.start('Writing rea install');
|
|
1315
|
+
let written;
|
|
1316
|
+
let copyResult;
|
|
1317
|
+
let mergeResult;
|
|
1318
|
+
let commitMsgResult;
|
|
1319
|
+
let prepareCommitMsgResult;
|
|
1320
|
+
let prePushResult;
|
|
1321
|
+
let mdResult;
|
|
1322
|
+
let gitignoreResult;
|
|
1323
|
+
let manifestPath;
|
|
1324
|
+
let fragmentInput;
|
|
1325
|
+
try {
|
|
1326
|
+
written = [];
|
|
1327
|
+
written.push(writePolicyYaml(targetDir, config, layeredBase));
|
|
1328
|
+
written.push(writeRegistryYaml(targetDir));
|
|
1329
|
+
// Artifact copies + settings merge + commit-msg + CLAUDE.md fragment.
|
|
1330
|
+
const copyOptions = {
|
|
1331
|
+
force: options.force === true,
|
|
1332
|
+
yes: options.yes === true || options.force === true,
|
|
1333
|
+
};
|
|
1334
|
+
copyResult = await copyArtifacts(targetDir, copyOptions);
|
|
1335
|
+
const { settings, settingsPath } = readSettings(targetDir);
|
|
1336
|
+
const desired = defaultDesiredHooks();
|
|
1337
|
+
mergeResult = mergeSettings(settings, desired);
|
|
1338
|
+
await writeSettingsAtomic(settingsPath, mergeResult.merged);
|
|
1339
|
+
commitMsgResult = await installCommitMsgHook(targetDir);
|
|
1340
|
+
// 0.30.0 attribution augmenter — install the prepare-commit-msg
|
|
1341
|
+
// hook unconditionally. The hook is a no-op when
|
|
1342
|
+
// policy.attribution.co_author.enabled !== true, so it is safe to
|
|
1343
|
+
// ship under every profile; consumers opt in by editing their
|
|
1344
|
+
// .rea/policy.yaml.
|
|
1345
|
+
prepareCommitMsgResult = await installPrepareCommitMsgHook(targetDir);
|
|
1346
|
+
prePushResult = await installPrePushFallback({ targetDir });
|
|
1347
|
+
fragmentInput = {
|
|
1348
|
+
policyPath: `.${path.sep}rea${path.sep}policy.yaml`.replace(/\\/g, '/'),
|
|
1349
|
+
profile: config.profile,
|
|
1350
|
+
autonomyLevel: config.autonomyLevel,
|
|
1351
|
+
maxAutonomyLevel: config.maxAutonomyLevel,
|
|
1352
|
+
blockedPathsCount: config.blockedPaths.length,
|
|
1353
|
+
blockAiAttribution: config.blockAiAttribution,
|
|
1354
|
+
};
|
|
1355
|
+
mdResult = await writeClaudeMdFragment(targetDir, fragmentInput);
|
|
1356
|
+
// BUG-010 — scaffold `.gitignore` entries for every runtime artifact
|
|
1357
|
+
// `rea serve` / `rea cache` / `/freeze` can write under `.rea/`. Idempotent
|
|
1358
|
+
// append (and `rea upgrade` backfills older installs that never got this).
|
|
1359
|
+
gitignoreResult = await ensureReaGitignore(targetDir);
|
|
1360
|
+
// G12 — record the install manifest. SHAs are of the files actually on disk
|
|
1361
|
+
// after the copy pass, so drift detection compares against real state (not
|
|
1362
|
+
// canonical, which may differ if the consumer's copy was aborted mid-run).
|
|
1363
|
+
manifestPath = await writeInstallManifest(targetDir, config.profile, fragmentInput);
|
|
1364
|
+
}
|
|
1365
|
+
catch (e) {
|
|
1366
|
+
// 0.43.0 UX polish: surface install failures via the spinner's
|
|
1367
|
+
// error state when interactive, then re-throw with a clack-rendered
|
|
1368
|
+
// "what failed → suggested fix" envelope so the operator isn't
|
|
1369
|
+
// left staring at a raw stack trace.
|
|
1370
|
+
if (spinner !== null)
|
|
1371
|
+
spinner.stop('Install failed');
|
|
1372
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1373
|
+
// Pattern: <what failed>: <why> → <suggested fix>. Many of the
|
|
1374
|
+
// underlying installers throw with the why already in `message`;
|
|
1375
|
+
// we always append the actionable next step so the operator
|
|
1376
|
+
// knows where to look.
|
|
1377
|
+
p.log.error(`Install aborted: ${message}\n` +
|
|
1378
|
+
` Suggested fix: re-run with --force to reset, or run \`rea doctor\` to ` +
|
|
1379
|
+
`diagnose the partial state, or escalate via \`rea freeze\` if a hook is ` +
|
|
1380
|
+
`actively blocking the operator's work.`);
|
|
1381
|
+
throw e;
|
|
1382
|
+
}
|
|
1383
|
+
if (spinner !== null)
|
|
1384
|
+
spinner.stop('Install written');
|
|
903
1385
|
console.log('');
|
|
904
1386
|
log('init complete');
|
|
905
1387
|
for (const file of written)
|
|
@@ -971,17 +1453,80 @@ export async function runInit(options) {
|
|
|
971
1453
|
console.log('Codex review disabled. ClaudeSelfReviewer will be used.');
|
|
972
1454
|
console.log(' Set review.codex_required: true in .rea/policy.yaml to re-enable.');
|
|
973
1455
|
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1456
|
+
// 0.43.0 UX polish: inline post-install verification. NOT a full
|
|
1457
|
+
// `rea doctor` (that takes seconds and spawns subprocesses) — just
|
|
1458
|
+
// a synchronous in-process sanity check that the install is sane.
|
|
1459
|
+
// If anything looks off we surface a loud warning and direct the
|
|
1460
|
+
// operator at `rea doctor` for the deep dive. Modelled on the
|
|
1461
|
+
// 0.29.0/0.31.0 synthetic round-trip pattern.
|
|
1462
|
+
const verifyIssues = postInstallVerify(targetDir);
|
|
1463
|
+
// 0.44.0 charter item 2: split advisory (`advisory:`-prefixed) from
|
|
1464
|
+
// real issues. Advisories explain skipped checks (Windows/WSL exec-
|
|
1465
|
+
// bit skip) and don't merit the loud "verification flagged" header
|
|
1466
|
+
// when no real issue is present.
|
|
1467
|
+
const realIssues = verifyIssues.filter((i) => !i.startsWith('advisory:'));
|
|
1468
|
+
const advisories = verifyIssues.filter((i) => i.startsWith('advisory:'));
|
|
1469
|
+
if (realIssues.length > 0) {
|
|
1470
|
+
console.log('');
|
|
1471
|
+
warn('post-install verification flagged the following:');
|
|
1472
|
+
for (const issue of realIssues)
|
|
1473
|
+
warn(` • ${issue}`);
|
|
1474
|
+
for (const adv of advisories)
|
|
1475
|
+
warn(` • ${adv}`);
|
|
1476
|
+
warn('Run `rea doctor` for a full diagnostic.');
|
|
1477
|
+
}
|
|
1478
|
+
else if (advisories.length > 0) {
|
|
1479
|
+
if (interactive) {
|
|
1480
|
+
p.log.success('Post-install check: install looks healthy.');
|
|
1481
|
+
for (const adv of advisories)
|
|
1482
|
+
p.log.info(adv);
|
|
1483
|
+
}
|
|
1484
|
+
else {
|
|
1485
|
+
console.log('');
|
|
1486
|
+
console.log('Post-install check: install looks healthy.');
|
|
1487
|
+
for (const adv of advisories)
|
|
1488
|
+
console.log(` ${adv}`);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
else if (interactive) {
|
|
1492
|
+
// Quiet success — confirm we checked, but don't shout about it.
|
|
1493
|
+
p.log.success('Post-install check: install looks healthy.');
|
|
1494
|
+
}
|
|
1495
|
+
if (interactive) {
|
|
1496
|
+
// 0.43.0 UX polish: clack outro with structured next-steps.
|
|
1497
|
+
// Replaces the bare `console.log('Next steps:')` block with a
|
|
1498
|
+
// bordered note so the call-to-action is unmissable on a busy
|
|
1499
|
+
// terminal scrollback. The non-interactive path keeps the plain
|
|
1500
|
+
// console.log block (CI logs don't render clack borders).
|
|
1501
|
+
const nextSteps = [];
|
|
1502
|
+
nextSteps.push('1. Review .rea/policy.yaml and commit it.');
|
|
1503
|
+
nextSteps.push('2. Run `rea doctor` to validate the install end-to-end.');
|
|
1504
|
+
nextSteps.push('3. Run `rea check` to see current status (autonomy, HALT, recent audit).');
|
|
1505
|
+
if (config.fromReagent) {
|
|
1506
|
+
nextSteps.push('');
|
|
1507
|
+
nextSteps.push('Reagent migration:');
|
|
1508
|
+
nextSteps.push(` Source: ${config.reagentPolicyPath ?? '(none)'}`);
|
|
1509
|
+
nextSteps.push(' Copied fields were applied per the translator rules.');
|
|
1510
|
+
nextSteps.push(' Once satisfied, you can remove the .reagent/ directory.');
|
|
1511
|
+
}
|
|
1512
|
+
nextSteps.push('');
|
|
1513
|
+
nextSteps.push('Docs: https://github.com/bookedsolidtech/rea#readme');
|
|
1514
|
+
p.note(nextSteps.join('\n'), 'Next steps');
|
|
1515
|
+
p.outro(reRunMode ? 'rea refresh complete.' : 'rea install complete.');
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
console.log('');
|
|
1519
|
+
console.log('Next steps:');
|
|
1520
|
+
console.log(' 1. Review .rea/policy.yaml and commit it.');
|
|
1521
|
+
console.log(' 2. Run `rea doctor` to validate the install.');
|
|
1522
|
+
console.log(' 3. Run `rea check` to see current status.');
|
|
1523
|
+
if (config.fromReagent) {
|
|
1524
|
+
console.log('');
|
|
1525
|
+
console.log('Reagent migration:');
|
|
1526
|
+
console.log(` Source: ${config.reagentPolicyPath ?? '(none)'}`);
|
|
1527
|
+
console.log(' Copied fields were applied per the translator rules.');
|
|
1528
|
+
console.log(' Once satisfied, you can remove the .reagent/ directory.');
|
|
1529
|
+
}
|
|
980
1530
|
console.log('');
|
|
981
|
-
console.log('Reagent migration:');
|
|
982
|
-
console.log(` Source: ${config.reagentPolicyPath ?? '(none)'}`);
|
|
983
|
-
console.log(' Copied fields were applied per the translator rules.');
|
|
984
|
-
console.log(' Once satisfied, you can remove the .reagent/ directory.');
|
|
985
1531
|
}
|
|
986
|
-
console.log('');
|
|
987
1532
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|