@bookedsolid/rea 0.42.0 → 0.43.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.
@@ -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 python3/jq. flow-form scalars work; flow-form
1664
- // arrays silently no-op via Tier 3 fallthrough. (Tier 2 path
1665
- // is unreachable here because Tier 2 requires python3.)
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
- 'BUT neither jq nor python3 is on PATH so `policy_reader_get_list` cannot iterate ' +
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
- 'silently falls through to Tier 3 awk and misses inline arrays. Install jq ' +
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 {
@@ -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,105 @@ 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.43.0 UX polish: build the human-readable install summary shown
64
+ * BEFORE any files are written. Lists, in order: the policy file
65
+ * being written, the chosen profile + autonomy, hook + agent counts
66
+ * planned, the git/husky hooks planned (paths reflect what the
67
+ * installer will ACTUALLY do given the target tree's shape), and
68
+ * whether re-run preservation is active.
69
+ *
70
+ * Rendered via clack's `note` primitive so it sits in a bordered block
71
+ * adjacent to the final `confirm` gate. The string is also returned
72
+ * verbatim so the test suite can assert content without mocking clack.
73
+ *
74
+ * `targetState` is computed by {@link detectTargetState} — kept as a
75
+ * separate argument so tests can drive both shapes (husky-present and
76
+ * husky-absent) without touching the filesystem.
77
+ */
78
+ export declare function buildInstallSummary(targetDir: string, config: ResolvedConfig, reRunMode: boolean, targetState: TargetState): string;
79
+ /**
80
+ * 0.43.0 codex round-1 P3: shape of the target tree the installer
81
+ * will see. `buildInstallSummary` and the post-install verifier both
82
+ * need to know whether `.git/` and `.husky/` are present so the
83
+ * summary doesn't lie about which hook files will be written.
84
+ */
85
+ export interface TargetState {
86
+ gitRepoPresent: boolean;
87
+ huskyDirPresent: boolean;
88
+ }
89
+ /**
90
+ * 0.43.0 codex round-1 P3: detect which hook surfaces the installer
91
+ * will actually touch. Returns a snapshot so the install-summary
92
+ * confirm screen can show the right paths.
93
+ *
94
+ * Intentionally simple — the installers themselves (commit-msg,
95
+ * prepare-commit-msg, pre-push) each re-check at write time, so this
96
+ * detection is purely presentational. If something races between the
97
+ * snapshot and the writes (a `pnpm install` adding `.husky/` in the
98
+ * window between confirm and spinner), the installer's own checks win
99
+ * and the summary was only slightly stale.
100
+ */
101
+ export declare function detectTargetState(targetDir: string): TargetState;
102
+ /**
103
+ * 0.43.0 UX polish: post-install sanity check. Runs synchronously
104
+ * after the file-write phase to catch installs that completed
105
+ * "successfully" but are missing a critical artifact (write
106
+ * permissions issue, partial copy, etc.).
107
+ *
108
+ * Strictly read-only — no probes that touch python3 / jq / codex.
109
+ * Pattern modelled on the synthetic round-trip checks established by
110
+ * `checkDelegationRoundTrip` in 0.29.0/0.31.0: cheap, in-process,
111
+ * sufficient to catch the "looks-installed-but-isn't" failure shape
112
+ * that bites first-time consumers hardest. For deep diagnostics
113
+ * point the operator at `rea doctor`.
114
+ *
115
+ * Returns the list of issues found (empty = healthy). The caller
116
+ * surfaces them via clack's `log.warn` and points the operator at
117
+ * `rea doctor` for follow-up.
118
+ */
119
+ export declare function postInstallVerify(targetDir: string): string[];
19
120
  export declare function runInit(options: InitOptions): Promise<void>;
121
+ export {};
package/dist/cli/init.js CHANGED
@@ -90,6 +90,24 @@ function resolveLayered(profileName, reagentTranslated) {
90
90
  async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy = undefined) {
91
91
  const projectName = detectProjectName(targetDir);
92
92
  p.intro(`rea init — ${projectName}`);
93
+ // 0.43.0 UX polish: surface re-run vs fresh-install mode at the top
94
+ // of the wizard so the operator sees up-front that an existing policy
95
+ // is being preserved (not overwritten with defaults). Pre-fix the only
96
+ // signal was an oblique prompt label change ("current: L2") several
97
+ // questions in — easy to miss when running the wizard interactively.
98
+ if (existingPolicy !== undefined) {
99
+ p.note([
100
+ `Existing install detected at ${path.join(REA_DIR, POLICY_FILE)}.`,
101
+ 'Re-run mode: your current settings will be preserved as defaults below.',
102
+ 'Pass --force to reset everything to profile defaults.',
103
+ ].join('\n'), 'Re-running init');
104
+ }
105
+ else {
106
+ p.note([
107
+ `Setting up rea governance for ${projectName}.`,
108
+ 'You can change every answer later by editing .rea/policy.yaml.',
109
+ ].join('\n'), 'Fresh install');
110
+ }
93
111
  let fromReagent = options.fromReagent === true;
94
112
  if (!fromReagent && reagentPolicyPath !== null) {
95
113
  const migrate = await p.confirm({
@@ -112,18 +130,34 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
112
130
  }
113
131
  else {
114
132
  const picked = await p.select({
115
- message: 'Pick a profile',
133
+ message: 'Pick a profile preset',
116
134
  initialValue: 'minimal',
117
135
  options: [
118
- { value: 'minimal', label: 'minimal', hint: 'bare policy, no extras (default)' },
136
+ {
137
+ value: 'minimal',
138
+ label: 'minimal',
139
+ hint: 'bare policy, no extras — safe starting point',
140
+ },
119
141
  {
120
142
  value: 'client-engagement',
121
143
  label: 'client-engagement',
122
- hint: 'zero-trust client project',
144
+ hint: 'zero-trust client project — strict by default',
145
+ },
146
+ {
147
+ value: 'bst-internal',
148
+ label: 'bst-internal',
149
+ hint: 'BookedSolid internal projects (Codex review on)',
150
+ },
151
+ {
152
+ value: 'lit-wc',
153
+ label: 'lit-wc',
154
+ hint: 'Lit / web-component libraries',
155
+ },
156
+ {
157
+ value: 'open-source',
158
+ label: 'open-source',
159
+ hint: 'public OSS repos (Codex review on)',
123
160
  },
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
161
  ],
128
162
  });
129
163
  if (p.isCancel(picked))
@@ -133,16 +167,34 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
133
167
  // 0.21.1: prefer the existing on-disk value over the profile default
134
168
  // so re-running `rea init` doesn't reset an operator's manual edit.
135
169
  const autonomyDefault = existingPolicy?.autonomyLevel ?? layeredBase.autonomy_level ?? AutonomyLevel.L1;
170
+ const autonomyMessage = existingPolicy?.autonomyLevel !== undefined
171
+ ? `Starting autonomy level (current: ${existingPolicy.autonomyLevel}). Controls how much the ` +
172
+ `agent can do without asking you first.`
173
+ : 'Starting autonomy level — how much can the agent do without asking you first?';
136
174
  const autonomyPick = await p.select({
137
- message: existingPolicy?.autonomyLevel !== undefined
138
- ? `Starting autonomy_level (current: ${existingPolicy.autonomyLevel})`
139
- : 'Starting autonomy_level',
175
+ message: autonomyMessage,
140
176
  initialValue: autonomyDefault,
141
177
  options: [
142
- { value: AutonomyLevel.L0, label: 'L0', hint: 'read-only; every write needs approval' },
143
- { value: AutonomyLevel.L1, label: 'L1', hint: 'default — writes allowed, destructive gated' },
144
- { value: AutonomyLevel.L2, label: 'L2', hint: 'wider latitude; destructive ops allowed' },
145
- { value: AutonomyLevel.L3, label: 'L3', hint: 'full autonomy (rare supervised only)' },
178
+ {
179
+ value: AutonomyLevel.L0,
180
+ label: 'L0 — read-only',
181
+ hint: 'every write needs your approval; safest, slowest',
182
+ },
183
+ {
184
+ value: AutonomyLevel.L1,
185
+ label: 'L1 — supervised writes',
186
+ hint: 'default — writes allowed, destructive ops gated',
187
+ },
188
+ {
189
+ value: AutonomyLevel.L2,
190
+ label: 'L2 — wide latitude',
191
+ hint: 'destructive ops allowed; suitable for experienced operators',
192
+ },
193
+ {
194
+ value: AutonomyLevel.L3,
195
+ label: 'L3 — full autonomy',
196
+ hint: 'rare — supervised long-running agents only',
197
+ },
146
198
  ],
147
199
  });
148
200
  if (p.isCancel(autonomyPick))
@@ -164,7 +216,8 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
164
216
  return { value: lvl, label: lvl };
165
217
  });
166
218
  const maxPick = await p.select({
167
- message: 'max_autonomy_level (ceilingcannot be exceeded at runtime)',
219
+ message: 'Ceiling autonomy level the agent can never exceed this even if asked. ' +
220
+ 'Promoting past the ceiling requires editing policy.yaml by hand.',
168
221
  initialValue: defaultMax,
169
222
  options: maxOptions,
170
223
  });
@@ -172,31 +225,54 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
172
225
  cancel('Init cancelled.');
173
226
  const maxAutonomyLevel = maxPick;
174
227
  const attribPick = await p.confirm({
175
- message: 'Enforce block_ai_attribution (reject AI-authored commit trailers)?',
228
+ message: 'Block AI-attribution in commits? (rejects "Co-Authored-By: Claude" and similar ' +
229
+ 'trailers — keeps your git history human-attributed)',
176
230
  initialValue: layeredBase.block_ai_attribution ?? true,
177
231
  });
178
232
  if (p.isCancel(attribPick))
179
233
  cancel('Init cancelled.');
180
234
  const blockAiAttribution = attribPick === true;
181
- // G11.4: "Use Codex adversarial review?" — the default follows the
182
- // chosen profile (any `*-no-codex` profile defaults to No). An explicit
183
- // flag on the command line overrides that default for the initial value.
184
- const codexInitial = options.codex !== undefined ? options.codex : profileDefaultCodexRequired(profileName);
235
+ // G11.4: "Use Codex adversarial review?" — initial-value precedence:
236
+ // 1. explicit --codex / --no-codex flag wins
237
+ // 2. otherwise existing on-disk value (preserves operator edit on re-run)
238
+ // 3. otherwise profile default (`*-no-codex` profiles default to No)
239
+ //
240
+ // 0.43.0 codex round-1 P2: prior to this commit step 2 was skipped on
241
+ // the interactive path — the initial value collapsed to the profile
242
+ // default even on a re-run where the operator had already toggled
243
+ // codex off. The summary screen advertised codex_required as
244
+ // preserved while the prompt default silently reverted it. The
245
+ // `--yes` path already had the correct precedence (see the
246
+ // non-interactive branch in `runInit`); this brings the wizard in
247
+ // line.
248
+ const codexInitial = options.codex !== undefined
249
+ ? options.codex
250
+ : (existingPolicy?.codexRequired ?? profileDefaultCodexRequired(profileName));
185
251
  const codexPick = await p.confirm({
186
- message: 'Use Codex adversarial review? (requires an OpenAI account can be added later)',
252
+ message: 'Enable Codex adversarial review? (runs a GPT-5.4 second-opinion review on every push; ' +
253
+ 'requires the Codex CLI + an OpenAI account — can be installed later via /codex:setup)',
187
254
  initialValue: codexInitial,
188
255
  });
189
256
  if (p.isCancel(codexPick))
190
257
  cancel('Init cancelled.');
191
258
  const codexRequired = codexPick === true;
192
- p.outro('Config collected — installing files.');
193
259
  return {
194
260
  profile: profileName,
195
261
  autonomyLevel,
196
262
  maxAutonomyLevel,
197
263
  blockAiAttribution,
198
- blockedPaths: layeredBase.blocked_paths ?? ['.env', '.env.*'],
199
- notificationChannel: layeredBase.notification_channel ?? '',
264
+ // 0.43.0 codex round-1 P2: preserve the wizard-untouched fields
265
+ // (`blocked_paths` + `notification_channel`) the same way the
266
+ // `--yes` path already does. Pre-fix the wizard return rebuilt
267
+ // these from `layeredBase` on every interactive re-run, silently
268
+ // dropping operator edits even though the new install-summary
269
+ // confirm gate advertised them as preserved. The wizard does NOT
270
+ // prompt for either field (they are policy-file edits, not
271
+ // first-question UX), so falling back to the layered profile
272
+ // default is the correct seed shape on a fresh install; a re-run
273
+ // simply forwards whatever the operator committed to disk.
274
+ blockedPaths: existingPolicy?.blockedPaths ?? layeredBase.blocked_paths ?? ['.env', '.env.*'],
275
+ notificationChannel: existingPolicy?.notificationChannel ?? layeredBase.notification_channel ?? '',
200
276
  codexRequired,
201
277
  // Round-27 F6: the wizard does NOT prompt for the 0.26.0 knobs (they
202
278
  // are advanced config — most teams accept defaults). But when the
@@ -737,6 +813,174 @@ function readExistingManifestInstalledAt(manifestPath) {
737
813
  }
738
814
  return undefined;
739
815
  }
816
+ /**
817
+ * 0.43.0 UX polish: build the human-readable install summary shown
818
+ * BEFORE any files are written. Lists, in order: the policy file
819
+ * being written, the chosen profile + autonomy, hook + agent counts
820
+ * planned, the git/husky hooks planned (paths reflect what the
821
+ * installer will ACTUALLY do given the target tree's shape), and
822
+ * whether re-run preservation is active.
823
+ *
824
+ * Rendered via clack's `note` primitive so it sits in a bordered block
825
+ * adjacent to the final `confirm` gate. The string is also returned
826
+ * verbatim so the test suite can assert content without mocking clack.
827
+ *
828
+ * `targetState` is computed by {@link detectTargetState} — kept as a
829
+ * separate argument so tests can drive both shapes (husky-present and
830
+ * husky-absent) without touching the filesystem.
831
+ */
832
+ export function buildInstallSummary(targetDir, config, reRunMode, targetState) {
833
+ const lines = [];
834
+ const mode = reRunMode ? 'Re-run (preserving your existing edits)' : 'Fresh install';
835
+ lines.push(`Mode: ${mode}`);
836
+ lines.push(`Target: ${targetDir}`);
837
+ lines.push('');
838
+ lines.push('Will write:');
839
+ lines.push(` .rea/policy.yaml — profile=${config.profile}`);
840
+ lines.push(` autonomy=${config.autonomyLevel} (max=${config.maxAutonomyLevel})`);
841
+ lines.push(` attribution-block=${config.blockAiAttribution ? 'on' : 'off'}`);
842
+ lines.push(` codex-review=${config.codexRequired ? 'on' : 'off'}`);
843
+ lines.push(` .rea/registry.yaml — empty MCP-server registry`);
844
+ lines.push(` .rea/install-manifest.json — hash record for drift detection`);
845
+ lines.push(` .claude/agents/ — curated specialist agents`);
846
+ lines.push(` .claude/hooks/ — hook scripts (executable)`);
847
+ lines.push(` .claude/commands/ — slash commands`);
848
+ lines.push(` .claude/settings.json — hook registration entries`);
849
+ // 0.43.0 codex round-1 P3: the installer writes to `.git/hooks/*`
850
+ // ALWAYS when a git repo is present, AND to `.husky/*` ONLY when
851
+ // `.husky/` already exists. Pre-fix the summary hard-coded `.husky/*`
852
+ // and silently omitted the `.git/hooks/*` writes from the "no
853
+ // .husky/" install shape — the most common one. The operator then
854
+ // confirmed without being told their `.git/hooks/` would be
855
+ // modified. We list both surfaces conditionally based on the
856
+ // detected target state so the screen is faithful to what actually
857
+ // happens.
858
+ if (targetState.gitRepoPresent) {
859
+ lines.push(` .git/hooks/commit-msg — commit-message attribution gate`);
860
+ lines.push(` .git/hooks/prepare-commit-msg — attribution augmenter (no-op until enabled)`);
861
+ lines.push(` .git/hooks/pre-push — local-review gate (fallback if no active hook present)`);
862
+ }
863
+ else {
864
+ lines.push(` (no .git/ directory detected — git hook copies will be skipped)`);
865
+ }
866
+ if (targetState.huskyDirPresent) {
867
+ lines.push(` .husky/commit-msg — commit-message attribution gate (husky mirror)`);
868
+ lines.push(` .husky/prepare-commit-msg — attribution augmenter (husky mirror)`);
869
+ lines.push(` .husky/pre-push — local-review gate (husky mirror)`);
870
+ }
871
+ else {
872
+ lines.push(` (no .husky/ directory detected — husky mirrors will be skipped)`);
873
+ }
874
+ lines.push(` CLAUDE.md fragment — managed governance block`);
875
+ lines.push(` .gitignore — managed entries for .rea runtime artifacts`);
876
+ if (reRunMode) {
877
+ lines.push('');
878
+ // 0.43.0 codex round-1 P2: list only the fields the wizard
879
+ // ACTUALLY preserves. `blocked_paths`, `notification_channel`,
880
+ // and `review.codex_required` are now preserved by the wizard
881
+ // path (matching the `--yes` path's documented contract); the
882
+ // wizard does NOT prompt for the 0.26.0 local_review or
883
+ // commit_hygiene knobs, but those values forward verbatim from
884
+ // the existing policy when set.
885
+ lines.push('Re-run preserves your manually-edited:');
886
+ lines.push(' • autonomy_level / max_autonomy_level / block_ai_attribution');
887
+ lines.push(' • blocked_paths / notification_channel');
888
+ lines.push(' • review.codex_required + local_review.* + commit_hygiene.*');
889
+ lines.push(' • attribution.co_author.* + installed_at timestamp');
890
+ }
891
+ return lines.join('\n');
892
+ }
893
+ /**
894
+ * 0.43.0 codex round-1 P3: detect which hook surfaces the installer
895
+ * will actually touch. Returns a snapshot so the install-summary
896
+ * confirm screen can show the right paths.
897
+ *
898
+ * Intentionally simple — the installers themselves (commit-msg,
899
+ * prepare-commit-msg, pre-push) each re-check at write time, so this
900
+ * detection is purely presentational. If something races between the
901
+ * snapshot and the writes (a `pnpm install` adding `.husky/` in the
902
+ * window between confirm and spinner), the installer's own checks win
903
+ * and the summary was only slightly stale.
904
+ */
905
+ export function detectTargetState(targetDir) {
906
+ return {
907
+ gitRepoPresent: fs.existsSync(path.join(targetDir, '.git')),
908
+ huskyDirPresent: fs.existsSync(path.join(targetDir, '.husky')),
909
+ };
910
+ }
911
+ /**
912
+ * 0.43.0 UX polish: post-install sanity check. Runs synchronously
913
+ * after the file-write phase to catch installs that completed
914
+ * "successfully" but are missing a critical artifact (write
915
+ * permissions issue, partial copy, etc.).
916
+ *
917
+ * Strictly read-only — no probes that touch python3 / jq / codex.
918
+ * Pattern modelled on the synthetic round-trip checks established by
919
+ * `checkDelegationRoundTrip` in 0.29.0/0.31.0: cheap, in-process,
920
+ * sufficient to catch the "looks-installed-but-isn't" failure shape
921
+ * that bites first-time consumers hardest. For deep diagnostics
922
+ * point the operator at `rea doctor`.
923
+ *
924
+ * Returns the list of issues found (empty = healthy). The caller
925
+ * surfaces them via clack's `log.warn` and points the operator at
926
+ * `rea doctor` for follow-up.
927
+ */
928
+ export function postInstallVerify(targetDir) {
929
+ const issues = [];
930
+ // 1. policy file exists + parses as YAML object.
931
+ const policyPath = path.join(targetDir, REA_DIR, POLICY_FILE);
932
+ if (!fs.existsSync(policyPath)) {
933
+ issues.push(`.rea/policy.yaml missing after install (expected at ${policyPath})`);
934
+ }
935
+ else {
936
+ try {
937
+ const raw = fs.readFileSync(policyPath, 'utf8');
938
+ const parsed = parseYaml(raw);
939
+ if (parsed === null || typeof parsed !== 'object') {
940
+ issues.push('.rea/policy.yaml parsed to a non-object — run `rea doctor` for details');
941
+ }
942
+ }
943
+ catch (e) {
944
+ issues.push(`.rea/policy.yaml failed to parse: ${e instanceof Error ? e.message : String(e)} — ` +
945
+ 'run `rea doctor` for details');
946
+ }
947
+ }
948
+ // 2. .claude/hooks directory present with executable scripts.
949
+ const hooksDir = path.join(targetDir, '.claude', 'hooks');
950
+ if (!fs.existsSync(hooksDir)) {
951
+ issues.push(`.claude/hooks/ directory missing after install (expected at ${hooksDir})`);
952
+ }
953
+ else {
954
+ let executableCount = 0;
955
+ try {
956
+ for (const entry of fs.readdirSync(hooksDir)) {
957
+ if (!entry.endsWith('.sh'))
958
+ continue;
959
+ const stat = fs.statSync(path.join(hooksDir, entry));
960
+ if ((stat.mode & 0o111) !== 0)
961
+ executableCount += 1;
962
+ }
963
+ }
964
+ catch (e) {
965
+ issues.push(`failed to enumerate .claude/hooks/: ${e instanceof Error ? e.message : String(e)}`);
966
+ }
967
+ if (executableCount === 0) {
968
+ issues.push('.claude/hooks/ contains zero executable .sh files — run `rea doctor`');
969
+ }
970
+ }
971
+ // 3. settings.json present.
972
+ const settingsPath = path.join(targetDir, '.claude', 'settings.json');
973
+ if (!fs.existsSync(settingsPath)) {
974
+ issues.push(`.claude/settings.json missing after install (expected at ${settingsPath})`);
975
+ }
976
+ // 4. install manifest present.
977
+ const manifestPath = path.join(targetDir, REA_DIR, 'install-manifest.json');
978
+ if (!fs.existsSync(manifestPath)) {
979
+ issues.push(`.rea/install-manifest.json missing after install (expected at ${manifestPath}) — ` +
980
+ 'drift detection will not work until `rea init` is re-run');
981
+ }
982
+ return issues;
983
+ }
740
984
  export async function runInit(options) {
741
985
  const targetDir = process.cwd();
742
986
  const reagentPolicyPath = detectReagentPolicy(targetDir);
@@ -860,46 +1104,105 @@ export async function runInit(options) {
860
1104
  config = await runWizard(options, targetDir, reagentPolicyPath, layeredBase, existingPolicy);
861
1105
  config.reagentNotices = reagentNotices;
862
1106
  }
1107
+ // 0.43.0 UX polish: install summary + final confirm gate. The
1108
+ // operator sees exactly what's about to happen BEFORE any
1109
+ // filesystem writes. Skipped on `--yes` / `--force` (non-interactive
1110
+ // paths assume consent). The confirm step is the last chance to
1111
+ // bail without leaving partial state on disk.
1112
+ const reRunMode = existingPolicy !== undefined;
1113
+ const interactive = options.yes !== true;
1114
+ if (interactive) {
1115
+ const targetState = detectTargetState(targetDir);
1116
+ const summary = buildInstallSummary(targetDir, config, reRunMode, targetState);
1117
+ p.note(summary, reRunMode ? 'Ready to refresh' : 'Ready to install');
1118
+ const proceed = await p.confirm({
1119
+ message: reRunMode ? 'Proceed with the refresh?' : 'Proceed with the install?',
1120
+ initialValue: true,
1121
+ });
1122
+ if (p.isCancel(proceed) || proceed !== true) {
1123
+ cancel('Init cancelled — no files written.');
1124
+ }
1125
+ }
863
1126
  if (!fs.existsSync(reaDir))
864
1127
  fs.mkdirSync(reaDir, { recursive: true });
865
- const written = [];
866
- written.push(writePolicyYaml(targetDir, config, layeredBase));
867
- written.push(writeRegistryYaml(targetDir));
868
- // Artifact copies + settings merge + commit-msg + CLAUDE.md fragment.
869
- const copyOptions = {
870
- force: options.force === true,
871
- yes: options.yes === true || options.force === true,
872
- };
873
- const copyResult = await copyArtifacts(targetDir, copyOptions);
874
- const { settings, settingsPath } = readSettings(targetDir);
875
- const desired = defaultDesiredHooks();
876
- const mergeResult = mergeSettings(settings, desired);
877
- await writeSettingsAtomic(settingsPath, mergeResult.merged);
878
- const commitMsgResult = await installCommitMsgHook(targetDir);
879
- // 0.30.0 attribution augmenter — install the prepare-commit-msg
880
- // hook unconditionally. The hook is a no-op when
881
- // policy.attribution.co_author.enabled !== true, so it is safe to
882
- // ship under every profile; consumers opt in by editing their
883
- // .rea/policy.yaml.
884
- const prepareCommitMsgResult = await installPrepareCommitMsgHook(targetDir);
885
- const prePushResult = await installPrePushFallback({ targetDir });
886
- const fragmentInput = {
887
- policyPath: `.${path.sep}rea${path.sep}policy.yaml`.replace(/\\/g, '/'),
888
- profile: config.profile,
889
- autonomyLevel: config.autonomyLevel,
890
- maxAutonomyLevel: config.maxAutonomyLevel,
891
- blockedPathsCount: config.blockedPaths.length,
892
- blockAiAttribution: config.blockAiAttribution,
893
- };
894
- const mdResult = await writeClaudeMdFragment(targetDir, fragmentInput);
895
- // BUG-010 scaffold `.gitignore` entries for every runtime artifact
896
- // `rea serve` / `rea cache` / `/freeze` can write under `.rea/`. Idempotent
897
- // append (and `rea upgrade` backfills older installs that never got this).
898
- const gitignoreResult = await ensureReaGitignore(targetDir);
899
- // G12 — record the install manifest. SHAs are of the files actually on disk
900
- // after the copy pass, so drift detection compares against real state (not
901
- // canonical, which may differ if the consumer's copy was aborted mid-run).
902
- const manifestPath = await writeInstallManifest(targetDir, config.profile, fragmentInput);
1128
+ // 0.43.0 UX polish: wrap the file-write phase in a clack spinner so
1129
+ // operators on slow disks see progress instead of staring at a
1130
+ // motionless prompt. Skipped under `--yes` (non-interactive paths
1131
+ // log line-by-line). All operations remain identical the spinner
1132
+ // is purely presentational.
1133
+ const spinner = interactive ? p.spinner() : null;
1134
+ if (spinner !== null)
1135
+ spinner.start('Writing rea install');
1136
+ let written;
1137
+ let copyResult;
1138
+ let mergeResult;
1139
+ let commitMsgResult;
1140
+ let prepareCommitMsgResult;
1141
+ let prePushResult;
1142
+ let mdResult;
1143
+ let gitignoreResult;
1144
+ let manifestPath;
1145
+ let fragmentInput;
1146
+ try {
1147
+ written = [];
1148
+ written.push(writePolicyYaml(targetDir, config, layeredBase));
1149
+ written.push(writeRegistryYaml(targetDir));
1150
+ // Artifact copies + settings merge + commit-msg + CLAUDE.md fragment.
1151
+ const copyOptions = {
1152
+ force: options.force === true,
1153
+ yes: options.yes === true || options.force === true,
1154
+ };
1155
+ copyResult = await copyArtifacts(targetDir, copyOptions);
1156
+ const { settings, settingsPath } = readSettings(targetDir);
1157
+ const desired = defaultDesiredHooks();
1158
+ mergeResult = mergeSettings(settings, desired);
1159
+ await writeSettingsAtomic(settingsPath, mergeResult.merged);
1160
+ commitMsgResult = await installCommitMsgHook(targetDir);
1161
+ // 0.30.0 attribution augmenter — install the prepare-commit-msg
1162
+ // hook unconditionally. The hook is a no-op when
1163
+ // policy.attribution.co_author.enabled !== true, so it is safe to
1164
+ // ship under every profile; consumers opt in by editing their
1165
+ // .rea/policy.yaml.
1166
+ prepareCommitMsgResult = await installPrepareCommitMsgHook(targetDir);
1167
+ prePushResult = await installPrePushFallback({ targetDir });
1168
+ fragmentInput = {
1169
+ policyPath: `.${path.sep}rea${path.sep}policy.yaml`.replace(/\\/g, '/'),
1170
+ profile: config.profile,
1171
+ autonomyLevel: config.autonomyLevel,
1172
+ maxAutonomyLevel: config.maxAutonomyLevel,
1173
+ blockedPathsCount: config.blockedPaths.length,
1174
+ blockAiAttribution: config.blockAiAttribution,
1175
+ };
1176
+ mdResult = await writeClaudeMdFragment(targetDir, fragmentInput);
1177
+ // BUG-010 — scaffold `.gitignore` entries for every runtime artifact
1178
+ // `rea serve` / `rea cache` / `/freeze` can write under `.rea/`. Idempotent
1179
+ // append (and `rea upgrade` backfills older installs that never got this).
1180
+ gitignoreResult = await ensureReaGitignore(targetDir);
1181
+ // G12 — record the install manifest. SHAs are of the files actually on disk
1182
+ // after the copy pass, so drift detection compares against real state (not
1183
+ // canonical, which may differ if the consumer's copy was aborted mid-run).
1184
+ manifestPath = await writeInstallManifest(targetDir, config.profile, fragmentInput);
1185
+ }
1186
+ catch (e) {
1187
+ // 0.43.0 UX polish: surface install failures via the spinner's
1188
+ // error state when interactive, then re-throw with a clack-rendered
1189
+ // "what failed → suggested fix" envelope so the operator isn't
1190
+ // left staring at a raw stack trace.
1191
+ if (spinner !== null)
1192
+ spinner.stop('Install failed');
1193
+ const message = e instanceof Error ? e.message : String(e);
1194
+ // Pattern: <what failed>: <why> → <suggested fix>. Many of the
1195
+ // underlying installers throw with the why already in `message`;
1196
+ // we always append the actionable next step so the operator
1197
+ // knows where to look.
1198
+ p.log.error(`Install aborted: ${message}\n` +
1199
+ ` Suggested fix: re-run with --force to reset, or run \`rea doctor\` to ` +
1200
+ `diagnose the partial state, or escalate via \`rea freeze\` if a hook is ` +
1201
+ `actively blocking the operator's work.`);
1202
+ throw e;
1203
+ }
1204
+ if (spinner !== null)
1205
+ spinner.stop('Install written');
903
1206
  console.log('');
904
1207
  log('init complete');
905
1208
  for (const file of written)
@@ -971,17 +1274,59 @@ export async function runInit(options) {
971
1274
  console.log('Codex review disabled. ClaudeSelfReviewer will be used.');
972
1275
  console.log(' Set review.codex_required: true in .rea/policy.yaml to re-enable.');
973
1276
  }
974
- console.log('');
975
- console.log('Next steps:');
976
- console.log(' 1. Review .rea/policy.yaml and commit it.');
977
- console.log(' 2. Run `rea doctor` to validate the install.');
978
- console.log(' 3. Run `rea check` to see current status.');
979
- if (config.fromReagent) {
1277
+ // 0.43.0 UX polish: inline post-install verification. NOT a full
1278
+ // `rea doctor` (that takes seconds and spawns subprocesses) — just
1279
+ // a synchronous in-process sanity check that the install is sane.
1280
+ // If anything looks off we surface a loud warning and direct the
1281
+ // operator at `rea doctor` for the deep dive. Modelled on the
1282
+ // 0.29.0/0.31.0 synthetic round-trip pattern.
1283
+ const verifyIssues = postInstallVerify(targetDir);
1284
+ if (verifyIssues.length > 0) {
1285
+ console.log('');
1286
+ warn('post-install verification flagged the following:');
1287
+ for (const issue of verifyIssues)
1288
+ warn(` • ${issue}`);
1289
+ warn('Run `rea doctor` for a full diagnostic.');
1290
+ }
1291
+ else if (interactive) {
1292
+ // Quiet success — confirm we checked, but don't shout about it.
1293
+ p.log.success('Post-install check: install looks healthy.');
1294
+ }
1295
+ if (interactive) {
1296
+ // 0.43.0 UX polish: clack outro with structured next-steps.
1297
+ // Replaces the bare `console.log('Next steps:')` block with a
1298
+ // bordered note so the call-to-action is unmissable on a busy
1299
+ // terminal scrollback. The non-interactive path keeps the plain
1300
+ // console.log block (CI logs don't render clack borders).
1301
+ const nextSteps = [];
1302
+ nextSteps.push('1. Review .rea/policy.yaml and commit it.');
1303
+ nextSteps.push('2. Run `rea doctor` to validate the install end-to-end.');
1304
+ nextSteps.push('3. Run `rea check` to see current status (autonomy, HALT, recent audit).');
1305
+ if (config.fromReagent) {
1306
+ nextSteps.push('');
1307
+ nextSteps.push('Reagent migration:');
1308
+ nextSteps.push(` Source: ${config.reagentPolicyPath ?? '(none)'}`);
1309
+ nextSteps.push(' Copied fields were applied per the translator rules.');
1310
+ nextSteps.push(' Once satisfied, you can remove the .reagent/ directory.');
1311
+ }
1312
+ nextSteps.push('');
1313
+ nextSteps.push('Docs: https://github.com/bookedsolidtech/rea#readme');
1314
+ p.note(nextSteps.join('\n'), 'Next steps');
1315
+ p.outro(reRunMode ? 'rea refresh complete.' : 'rea install complete.');
1316
+ }
1317
+ else {
1318
+ console.log('');
1319
+ console.log('Next steps:');
1320
+ console.log(' 1. Review .rea/policy.yaml and commit it.');
1321
+ console.log(' 2. Run `rea doctor` to validate the install.');
1322
+ console.log(' 3. Run `rea check` to see current status.');
1323
+ if (config.fromReagent) {
1324
+ console.log('');
1325
+ console.log('Reagent migration:');
1326
+ console.log(` Source: ${config.reagentPolicyPath ?? '(none)'}`);
1327
+ console.log(' Copied fields were applied per the translator rules.');
1328
+ console.log(' Once satisfied, you can remove the .reagent/ directory.');
1329
+ }
980
1330
  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
1331
  }
986
- console.log('');
987
1332
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.42.0",
3
+ "version": "0.43.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)",