@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.
@@ -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,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
- { value: 'minimal', label: 'minimal', hint: 'bare policy, no extras (default)' },
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: existingPolicy?.autonomyLevel !== undefined
138
- ? `Starting autonomy_level (current: ${existingPolicy.autonomyLevel})`
139
- : 'Starting autonomy_level',
176
+ message: autonomyMessage,
140
177
  initialValue: autonomyDefault,
141
178
  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)' },
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: 'max_autonomy_level (ceilingcannot be exceeded at runtime)',
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: 'Enforce block_ai_attribution (reject AI-authored commit trailers)?',
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?" — 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);
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: 'Use Codex adversarial review? (requires an OpenAI account can be added later)',
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
- blockedPaths: layeredBase.blocked_paths ?? ['.env', '.env.*'],
199
- notificationChannel: layeredBase.notification_channel ?? '',
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
- 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);
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
- 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) {
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.42.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)",