@friedbotstudio/create-baseline 0.4.0 → 0.6.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/src/cli/merge.js CHANGED
@@ -4,6 +4,7 @@ import { hashFile, saveManifest } from './manifest.js';
4
4
  import { deepMergeMcpServers } from './mcp.js';
5
5
  import { NEVER_TOUCH, SPECIAL_MERGE } from './install.js';
6
6
  import { pathExists } from './util.js';
7
+ import { dispatchByTier, NoBaseError } from './upgrade-tiers.js';
7
8
 
8
9
  export const ACTION_KINDS = Object.freeze({
9
10
  ADD: 'ADD',
@@ -15,6 +16,9 @@ export const ACTION_KINDS = Object.freeze({
15
16
  NEVER_TOUCH_PRESERVE: 'NEVER_TOUCH_PRESERVE',
16
17
  NEVER_TOUCH_ADD: 'NEVER_TOUCH_ADD',
17
18
  SPECIAL_MERGE: 'SPECIAL_MERGE',
19
+ MECHANICAL_MERGE_CLEAN: 'MECHANICAL_MERGE_CLEAN',
20
+ MECHANICAL_MERGE_CONFLICTED: 'MECHANICAL_MERGE_CONFLICTED',
21
+ SEMANTIC_MERGE_STAGED: 'SEMANTIC_MERGE_STAGED',
18
22
  });
19
23
 
20
24
  async function copyFile(src, dst) {
@@ -22,13 +26,40 @@ async function copyFile(src, dst) {
22
26
  await cp(src, dst, { force: true });
23
27
  }
24
28
 
29
+ function readShaFromEntry(entry) {
30
+ if (typeof entry === 'string') return entry;
31
+ if (entry && typeof entry === 'object' && typeof entry.sha256 === 'string') return entry.sha256;
32
+ return null;
33
+ }
34
+
35
+ function readTierFromEntry(entry) {
36
+ if (entry && typeof entry === 'object' && typeof entry.tier === 'string') return entry.tier;
37
+ // Bare-sha entries (legacy shipped manifest_version: 2 OR installed-manifest
38
+ // round-trips without tier overlay) fall back to BINARY_PROMPT — the safe
39
+ // default that preserves today's two-way prompt behavior. New shipped
40
+ // manifests (v3+) carry `{sha256, tier}` per file and exercise the full
41
+ // three-tier flow.
42
+ return 'BINARY_PROMPT';
43
+ }
44
+
25
45
  export async function threeWayMerge(templateDir, target, oldManifest, newManifest, opts = {}) {
26
- const { dryRun = false, onSkipCustomized = null } = opts;
46
+ const { dryRun = false, onSkipCustomized = null, pack = null } = opts;
27
47
  const actions = [];
28
48
  const oldFiles = oldManifest?.files ?? {};
29
49
  const newFiles = newManifest?.files ?? {};
50
+ const baseline_version = oldManifest?.baseline_version;
30
51
  const allPaths = new Set([...Object.keys(oldFiles), ...Object.keys(newFiles)]);
31
52
 
53
+ const tierCtx = {
54
+ target,
55
+ templateDir,
56
+ oldManifest,
57
+ newManifest,
58
+ baseline_version,
59
+ pack,
60
+ stageRunTs: null,
61
+ };
62
+
32
63
  for (const rel of allPaths) {
33
64
  const tplPath = join(templateDir, rel);
34
65
  const tgtPath = join(target, rel);
@@ -51,8 +82,10 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
51
82
  continue;
52
83
  }
53
84
 
54
- const newHash = newFiles[rel];
55
- const oldHash = oldFiles[rel];
85
+ const newEntry = newFiles[rel];
86
+ const oldEntry = oldFiles[rel];
87
+ const newHash = readShaFromEntry(newEntry);
88
+ const oldHash = readShaFromEntry(oldEntry);
56
89
  const targetExists = await pathExists(tgtPath);
57
90
  const tgtHash = targetExists ? await hashFile(tgtPath) : null;
58
91
 
@@ -74,13 +107,10 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
74
107
  }
75
108
 
76
109
  if (newHash && tgtHash && tgtHash !== oldHash) {
77
- const choice = onSkipCustomized ? await onSkipCustomized(rel) : 'keep-mine';
78
- if (choice === 'take-theirs') {
79
- if (!dryRun) await copyFile(tplPath, tgtPath);
80
- actions.push({ kind: ACTION_KINDS.OVERWRITE, path: rel, reason: 'customized file; user chose take-theirs' });
81
- } else {
82
- actions.push({ kind: ACTION_KINDS.SKIP_CUSTOMIZED, path: rel, reason: 'target customized since last install' });
83
- }
110
+ const action = await dispatchCustomized({
111
+ rel, newEntry, tierCtx, dryRun, onSkipCustomized, tplPath, tgtPath,
112
+ });
113
+ actions.push(action);
84
114
  continue;
85
115
  }
86
116
 
@@ -106,7 +136,44 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
106
136
  await saveManifest(join(target, '.claude/.baseline-manifest.json'), newManifest);
107
137
  }
108
138
 
109
- const skipKinds = [ACTION_KINDS.SKIP_CUSTOMIZED, ACTION_KINDS.PRUNE_SKIPPED_CUSTOMIZED];
110
- const exitCode = actions.some((a) => skipKinds.includes(a.kind)) ? 3 : 0;
111
- return { actions, exitCode };
139
+ return { actions, exitCode: computeExitCode(actions) };
140
+ }
141
+
142
+ async function dispatchCustomized({ rel, newEntry, tierCtx, dryRun, onSkipCustomized, tplPath, tgtPath }) {
143
+ const tier = readTierFromEntry(newEntry);
144
+ if (tier === 'MECHANICAL' || tier === 'SEMANTIC') {
145
+ if (dryRun) {
146
+ return { kind: tier === 'MECHANICAL' ? ACTION_KINDS.MECHANICAL_MERGE_CLEAN : ACTION_KINDS.SEMANTIC_MERGE_STAGED, path: rel, reason: 'dry-run: tier dispatch deferred' };
147
+ }
148
+ try {
149
+ return await dispatchByTier(rel, tier, tierCtx);
150
+ } catch (err) {
151
+ if (err instanceof NoBaseError) {
152
+ return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, err });
153
+ }
154
+ throw err;
155
+ }
156
+ }
157
+ return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath });
158
+ }
159
+
160
+ async function fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, err = null }) {
161
+ const choice = onSkipCustomized ? await onSkipCustomized(rel) : 'keep-mine';
162
+ if (choice === 'take-theirs') {
163
+ if (!dryRun) await copyFile(tplPath, tgtPath);
164
+ return { kind: ACTION_KINDS.OVERWRITE, path: rel, reason: err ? `BASE recovery failed (${err.kind}); user chose take-theirs` : 'customized file; user chose take-theirs' };
165
+ }
166
+ return { kind: ACTION_KINDS.SKIP_CUSTOMIZED, path: rel, reason: err ? `BASE recovery failed (${err.kind}); preserved` : 'target customized since last install' };
167
+ }
168
+
169
+ function computeExitCode(actions) {
170
+ let code = 0;
171
+ for (const a of actions) {
172
+ if (a.kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED) code = Math.max(code, 5);
173
+ else if (a.kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED) code = Math.max(code, 4);
174
+ else if (a.kind === ACTION_KINDS.SKIP_CUSTOMIZED || a.kind === ACTION_KINDS.PRUNE_SKIPPED_CUSTOMIZED) {
175
+ code = Math.max(code, 3);
176
+ }
177
+ }
178
+ return code;
112
179
  }
@@ -6,6 +6,7 @@ import * as clackModule from '@clack/prompts';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { freshInstall, forceInstall } from '../install.js';
8
8
  import { fetchPlantumlIfMissing, FETCH_OUTCOMES } from '../plantuml.js';
9
+ import { renderBrandStrip } from './splash.js';
9
10
 
10
11
  const SUCCESS = 0;
11
12
  const ERR_INSTALL_FAILED = 1;
@@ -20,7 +21,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
20
21
  }
21
22
 
22
23
  const version = await readPackageVersion();
23
- prompts.intro(`create-baseline v${version}`);
24
+ process.stdout.write(renderBrandStrip({ version, subtitle: 'install' }));
25
+ prompts.intro('create-baseline');
24
26
 
25
27
  const spinner = prompts.spinner();
26
28
  spinner.start('Copying baseline files');
@@ -1,24 +1,30 @@
1
- // Domain — branded renderers for the meta commands (--help, --version).
2
- // In a TTY, a brand banner frames the canonical body; in non-TTY the body is
3
- // emitted unchanged so that piped consumers (`$(cli --version)`, `cli --help |
4
- // grep ...`) keep working byte-clean.
1
+ // Domain — branded renderers for the meta commands (--help, --version) and
2
+ // for usage-class errors. In a TTY, the splash marquee (wordmark + brand
3
+ // strip from splash.js) frames the canonical body; in non-TTY the body is
4
+ // emitted unchanged so that piped consumers (`$(cli --version)`,
5
+ // `cli --help | grep ...`) keep working byte-clean.
5
6
 
6
- import { accent, muted, rule } from './tokens.js';
7
+ import { accent, muted, rule, error as errorPaint } from './tokens.js';
8
+ import {
9
+ renderSplash,
10
+ renderBrandStrip,
11
+ renderVersionMarquee,
12
+ wordmarkFits,
13
+ } from './splash.js';
7
14
 
8
- export function renderHelp(helpText, version) {
9
- if (!process.stdout.isTTY) {
10
- process.stdout.write(helpText.endsWith('\n') ? helpText : helpText + '\n');
15
+ const DISCOVER_URL = 'https://baseline.friedbotstudio.com/';
16
+
17
+ export function renderHelp(helpText, _version) {
18
+ const body = helpText.endsWith('\n') ? helpText : helpText + '\n';
19
+ if (!process.stdout.isTTY || !wordmarkFits()) {
20
+ process.stdout.write(body);
11
21
  return;
12
22
  }
13
- const banner = [
14
- '',
15
- ` ${accent('Baseline CLI')} ${muted(`v${version}`)}`,
16
- ` ${muted('@friedbotstudio/create-baseline')}`,
17
- ` ${rule('─'.repeat(48))}`,
18
- '',
19
- ].join('\n');
20
- process.stdout.write(banner + '\n');
21
- process.stdout.write(helpText.endsWith('\n') ? helpText : helpText + '\n');
23
+ process.stdout.write(renderSplash({
24
+ tryLine: 'npx @friedbotstudio/create-baseline ./my-project',
25
+ discoverUrl: DISCOVER_URL,
26
+ }));
27
+ process.stdout.write(body);
22
28
  }
23
29
 
24
30
  export function renderVersion(version) {
@@ -26,5 +32,32 @@ export function renderVersion(version) {
26
32
  process.stdout.write(`${version}\n`);
27
33
  return;
28
34
  }
29
- process.stdout.write(`${accent('baseline')} ${muted('v')}${version}\n`);
35
+ if (wordmarkFits()) {
36
+ process.stdout.write(renderVersionMarquee(version));
37
+ return;
38
+ }
39
+ process.stdout.write(renderBrandStrip({ version }));
40
+ }
41
+
42
+ // Usage errors always print to stderr (so a `cli ... 2>/dev/null` pipeline
43
+ // can still consume stdout cleanly). In a TTY we wrap the message and the
44
+ // HELP_TEXT body in the same brand banner used by --help, with the error
45
+ // label painted in --mac-red. In non-TTY we emit a plain `Error: <msg>`
46
+ // line followed by the canonical help body — same body, no ANSI.
47
+ export function renderUsageError(msg, helpText, version) {
48
+ const body = helpText.endsWith('\n') ? helpText : helpText + '\n';
49
+ if (!process.stderr.isTTY) {
50
+ process.stderr.write(`Error: ${msg}\n`);
51
+ process.stderr.write(body);
52
+ return;
53
+ }
54
+ const banner = [
55
+ '',
56
+ ` ${errorPaint('Error')} ${msg}`,
57
+ ` ${muted(`@friedbotstudio/create-baseline v${version}`)}`,
58
+ ` ${rule('─'.repeat(48))}`,
59
+ '',
60
+ ].join('\n');
61
+ process.stderr.write(banner + '\n');
62
+ process.stderr.write(body);
30
63
  }
@@ -0,0 +1,111 @@
1
+ // Domain — branded splash surfaces for the CLI. Renders a chunky pixel-art
2
+ // "BASELINE" wordmark in three bands of FBS orange (bevel: shadow / mid /
3
+ // highlight / mid / shadow) so the marquee surfaces (--help, --version,
4
+ // no-arg landing) share a single visual identity. Slimmer brand strip is
5
+ // reused by install / upgrade intros and inside the usage-error renderer.
6
+ //
7
+ // All renderers degrade cleanly when stdout is not a TTY or NO_COLOR is set
8
+ // (paintRGB short-circuits to plain text). When the terminal is narrower
9
+ // than the wordmark, callers should fall through to the plain banner via
10
+ // `wordmarkFits(width)` instead of letting the glyphs wrap.
11
+
12
+ import { paintRGB, PALETTE, accent, muted } from './tokens.js';
13
+
14
+ // ANSI-Shadow style block-letter wordmark for "BASELINE". 5 rows × ~60 cols.
15
+ // Kept as raw strings (not paint-wrapped) so renderWordmark can apply a
16
+ // per-row shade. Trailing spaces matter — they're part of the glyph shape.
17
+ const WORDMARK = [
18
+ '██████ █████ ███████ ███████ ██ ██ ███ ██ ███████',
19
+ '██ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ',
20
+ '██████ ███████ ███████ █████ ██ ██ ██ ██ ██ █████ ',
21
+ '██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ',
22
+ '██████ ██ ██ ███████ ███████ ███████ ██ ██ ████ ███████',
23
+ ];
24
+
25
+ // Outline trace — mirrors the bottom row of the wordmark using the upper
26
+ // one-eighth block (▔) so it visually kisses the base of every letter,
27
+ // producing the subtle "letters are sitting on a baseline" shadow from
28
+ // the skills.sh reference. Painted in accentShadow so it reads as a
29
+ // trace, not a fifth band of the letter body.
30
+ const WORDMARK_OUTLINE = WORDMARK[4].replace(/█/g, '▔');
31
+
32
+ // Bevel banding: dim → mid → bright → mid → dim. Produces the chiseled
33
+ // pixel-art look of the skills.sh reference (substituting FBS oranges for
34
+ // the reference's grayscale palette).
35
+ const SHADES = [
36
+ PALETTE.accentShadow,
37
+ PALETTE.accent,
38
+ PALETTE.accentLight,
39
+ PALETTE.accent,
40
+ PALETTE.accentShadow,
41
+ ];
42
+
43
+ const WORDMARK_WIDTH = Math.max(...WORDMARK.map((row) => row.length));
44
+
45
+ export const SPLASH_COMMANDS = Object.freeze([
46
+ ['$ npx @friedbotstudio/create-baseline <target>', 'Install the baseline'],
47
+ ['$ npx @friedbotstudio/create-baseline upgrade', 'Three-way merge upgrade'],
48
+ ['$ npx @friedbotstudio/create-baseline doctor', 'Drift report'],
49
+ ]);
50
+
51
+ // `process.stdout.columns` is 0 (not undefined) under `script(1)` and some
52
+ // CI ptys; treat any falsy value as "unknown, assume wide enough" so the
53
+ // marquee renders rather than silently degrading to the plain banner.
54
+ export function wordmarkFits(columns) {
55
+ const cols = columns ?? process.stdout.columns;
56
+ if (!cols) return true;
57
+ return cols >= WORDMARK_WIDTH;
58
+ }
59
+
60
+ export function renderWordmark() {
61
+ const bands = WORDMARK.map((row, i) => paintRGB(SHADES[i], row));
62
+ bands.push(paintRGB(PALETTE.accentShadow, WORDMARK_OUTLINE));
63
+ return bands.join('\n');
64
+ }
65
+
66
+ // Full marquee splash. Used by --help in TTY and the no-arg landing. The
67
+ // version is intentionally NOT rendered here — `--version` already surfaces
68
+ // it via renderVersionMarquee, and embedding it in the splash would force
69
+ // docs-site screenshots to re-render every release.
70
+ export function renderSplash({ tagline, tryLine, discoverUrl } = {}) {
71
+ const lines = [
72
+ '',
73
+ `${muted('▲')} ${muted('~/')} ${muted('npx @friedbotstudio/create-baseline@latest')}`,
74
+ '',
75
+ renderWordmark(),
76
+ '',
77
+ muted(tagline ?? 'The Claude Code baseline — hooks, skills, MCP, governance.'),
78
+ '',
79
+ ];
80
+ for (const [cmd, desc] of SPLASH_COMMANDS) {
81
+ const left = ` ${cmd}`;
82
+ lines.push(`${left.padEnd(54)}${muted(desc)}`);
83
+ }
84
+ if (tryLine) {
85
+ lines.push('');
86
+ lines.push(`${muted('try:')} ${tryLine}`);
87
+ }
88
+ if (discoverUrl) {
89
+ lines.push('');
90
+ lines.push(`${muted('Discover more at')} ${discoverUrl}`);
91
+ }
92
+ lines.push('');
93
+ lines.push(`${muted('▲')} ${muted('~/')}`);
94
+ lines.push('');
95
+ return lines.join('\n');
96
+ }
97
+
98
+ // Slim two-line brand strip. Used by install / upgrade intros, --version,
99
+ // and the top of the usage-error renderer. Cheap and width-safe (~32 cols).
100
+ export function renderBrandStrip({ version, subtitle } = {}) {
101
+ const left = `${accent('▲ BASELINE')}`;
102
+ const right = version ? ` ${muted(`v${version}`)}` : '';
103
+ const sub = subtitle ? `\n ${muted(subtitle)}` : '';
104
+ return ['', `${left}${right}${sub}`, ''].join('\n');
105
+ }
106
+
107
+ // --version flourish: the wordmark + a version line. Wider than the strip;
108
+ // callers should fall back to renderBrandStrip when the terminal is narrow.
109
+ export function renderVersionMarquee(version) {
110
+ return ['', renderWordmark(), '', ` ${muted(`v${version}`)}`, ''].join('\n');
111
+ }
@@ -6,6 +6,7 @@
6
6
  const NO_COLOR = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '';
7
7
 
8
8
  // oklch -> approximate sRGB hex used in the rendered docs site:
9
+ // --accent-shadow oklch(35% 0.15 41.5) ~ #7a2907 (dark band of the wordmark bevel)
9
10
  // --accent oklch(55.8% 0.187 41.5) ~ #c2410c (orange-700)
10
11
  // --accent-light oklch(70.3% 0.187 41.5) ~ #ea6a25 (orange-500)
11
12
  // --muted oklch(45% 0.026 257) ~ #6b7280
@@ -14,6 +15,7 @@ const NO_COLOR = process.env.NO_COLOR !== undefined && process.env.NO_COLOR !==
14
15
  // --mac-red oklch(70% 0.21 24) ~ #ef4444
15
16
  // --rule oklch(89% 0.013 257) ~ #d1d5db
16
17
  const RGB = {
18
+ accentShadow: [122, 41, 7],
17
19
  accent: [194, 65, 12],
18
20
  accentLight: [234, 106, 37],
19
21
  muted: [107, 114, 128],
@@ -23,16 +25,21 @@ const RGB = {
23
25
  rule: [209, 213, 219],
24
26
  };
25
27
 
26
- function paint(rgb, text) {
28
+ // Exposed for the splash wordmark, which paints individual rows in their own
29
+ // shade. All other UI tokens funnel through the named helpers below.
30
+ export function paintRGB(rgb, text) {
27
31
  if (NO_COLOR || !process.stdout.isTTY) return text;
28
32
  const [r, g, b] = rgb;
29
33
  return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
30
34
  }
31
35
 
32
- export const accent = (text) => paint(RGB.accent, text);
33
- export const accentLight = (text) => paint(RGB.accentLight, text);
34
- export const muted = (text) => paint(RGB.muted, text);
35
- export const success = (text) => paint(RGB.success, text);
36
- export const warn = (text) => paint(RGB.warn, text);
37
- export const error = (text) => paint(RGB.error, text);
38
- export const rule = (text) => paint(RGB.rule, text);
36
+ export const PALETTE = Object.freeze(RGB);
37
+
38
+ export const accentShadow = (text) => paintRGB(RGB.accentShadow, text);
39
+ export const accent = (text) => paintRGB(RGB.accent, text);
40
+ export const accentLight = (text) => paintRGB(RGB.accentLight, text);
41
+ export const muted = (text) => paintRGB(RGB.muted, text);
42
+ export const success = (text) => paintRGB(RGB.success, text);
43
+ export const warn = (text) => paintRGB(RGB.warn, text);
44
+ export const error = (text) => paintRGB(RGB.error, text);
45
+ export const rule = (text) => paintRGB(RGB.rule, text);
@@ -1,28 +1,40 @@
1
- // Domain — branded upgrade flow with interactive per-file conflict resolution.
1
+ // Domain — branded upgrade flow with three-tier merge orchestration.
2
2
  // Plan/apply split:
3
- // 1. dry-run threeWayMerge enumerate SKIP_CUSTOMIZED conflicts
4
- // 2. prompt the user once per conflict
5
- // 3. on cancel/abort: bail before any write
6
- // 4. on resolve: real threeWayMerge with onSkipCustomized backed by the Map
3
+ // 1. detect pending semantic-merge stage (idempotency short-circuit, AC-007)
4
+ // 2. dry-run threeWayMerge enumerate SKIP_CUSTOMIZED conflicts (tier-1 only)
5
+ // 3. prompt the user once per tier-1 conflict (with Show-diff loop, cap-at-2)
6
+ // 4. on cancel/abort: bail before any write
7
+ // 5. on resolve: real threeWayMerge with onSkipCustomized backed by the Map.
8
+ // Tier-2 MECHANICAL and tier-3 SEMANTIC files are NOT prompted — they're
9
+ // dispatched by the merge engine via upgrade-tiers.dispatchByTier.
7
10
 
8
11
  import * as clackModule from '@clack/prompts';
9
12
  import { existsSync } from 'node:fs';
10
- import { readdir } from 'node:fs/promises';
13
+ import { readdir, readFile } from 'node:fs/promises';
11
14
  import { join, relative, sep } from 'node:path';
12
15
  import { threeWayMerge, ACTION_KINDS } from '../merge.js';
13
16
  import { loadManifest, buildManifestFromDir } from '../manifest.js';
17
+ import { COPY_EXCLUDE } from '../install.js';
18
+ import { findPendingStage } from '../upgrade-tiers.js';
19
+ import { renderUnifiedDiff } from '../diff-render.js';
20
+ import { renderBrandStrip } from './splash.js';
14
21
 
15
22
  const SUCCESS = 0;
16
23
  const ERR_ABORT = 1;
17
24
  const ERR_NO_MANIFEST = 2;
18
25
  const ERR_DIVERGENCE = 3;
26
+ const ERR_MECHANICAL_CONFLICTED = 4;
27
+ const ERR_SEMANTIC_STAGED = 5;
19
28
 
20
29
  const CHOICE_OPTIONS = [
21
- { value: 'keep-mine', label: 'Keep mine', hint: 'preserve target file as-is' },
22
- { value: 'take-theirs', label: 'Take theirs', hint: 'overwrite with new baseline' },
30
+ { value: 'keep-mine', label: 'Keep your version', hint: 'preserve target file as-is' },
31
+ { value: 'take-theirs', label: 'Use new baseline', hint: 'overwrite with new template' },
32
+ { value: 'show-diff', label: 'Show diff', hint: 'render local vs incoming and re-prompt' },
23
33
  { value: 'abort', label: 'Abort', hint: 'exit without changes' },
24
34
  ];
25
35
 
36
+ const SHOW_DIFF_CONSECUTIVE_CAP = 2;
37
+
26
38
  export async function run({ target, opts = {}, prompts = clackModule } = {}) {
27
39
  if (!target || typeof target !== 'string') {
28
40
  throw new Error('tui.upgrade.run requires a non-empty string target');
@@ -37,28 +49,31 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
37
49
  return ERR_NO_MANIFEST;
38
50
  }
39
51
 
52
+ const version = await readPackageVersion();
53
+ process.stdout.write(renderBrandStrip({ version, subtitle: 'upgrade' }));
40
54
  prompts.intro('create-baseline upgrade');
41
55
 
56
+ const pending = await findPendingStage(target);
57
+ if (pending) return reportPendingStage(prompts, pending);
58
+
42
59
  const { oldManifest, newManifest } = await loadManifests(opts.templateDir, manifestPath);
60
+ if (isLegacyManifest(oldManifest)) {
61
+ prompts.log.warn('legacy manifest_version: 1 detected; BASE-content recovery unavailable. Tier-2 / tier-3 files will fall back to the binary prompt.');
62
+ }
63
+
43
64
  const dryReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { dryRun: true });
44
65
  const conflicts = dryReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED);
45
66
 
46
67
  const choices = new Map();
47
- for (const conflict of conflicts) {
48
- const choice = await prompts.select({
49
- message: `${conflict.path} has been customized — choose:`,
50
- options: CHOICE_OPTIONS,
51
- });
52
- if (prompts.isCancel(choice) || choice === 'abort') {
53
- prompts.cancel('Upgrade aborted; tree unchanged.');
54
- return ERR_ABORT;
55
- }
56
- choices.set(conflict.path, choice);
68
+ const aborted = await collectUserChoices(prompts, conflicts, opts.templateDir, target, choices);
69
+ if (aborted) {
70
+ prompts.cancel('Upgrade aborted; tree unchanged.');
71
+ return ERR_ABORT;
57
72
  }
58
73
 
59
74
  if (opts.dryRun) {
60
75
  for (const action of dryReport.actions) {
61
- prompts.log.info(`${action.kind.padEnd(24)} ${action.path}`);
76
+ prompts.log.info(`${action.kind.padEnd(28)} ${action.path}`);
62
77
  }
63
78
  prompts.outro('Dry run complete; no changes written.');
64
79
  return SUCCESS;
@@ -67,10 +82,79 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
67
82
  const onSkipCustomized = (rel) => choices.get(rel) ?? 'keep-mine';
68
83
  const finalReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { onSkipCustomized });
69
84
 
85
+ for (const action of finalReport.actions) {
86
+ if (isReportableAction(action.kind)) {
87
+ prompts.log.info(`${action.kind.padEnd(28)} ${action.path}`);
88
+ }
89
+ if (action.kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED) {
90
+ prompts.log.warn(`Merged with conflicts — resolve in ${action.path}`);
91
+ }
92
+ }
93
+
94
+ const stagedCount = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED).length;
95
+ if (stagedCount > 0) {
96
+ prompts.log.info(`${stagedCount} file(s) need semantic merge. Open Claude Code and run /upgrade-project to reconcile.`);
97
+ }
98
+
70
99
  const applied = finalReport.actions.filter((a) => isApplied(a.kind)).length;
71
100
  const skipped = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED).length;
72
101
  prompts.outro(`Applied ${applied}; ${skipped} skipped.`);
73
- return finalReport.exitCode === 3 ? ERR_DIVERGENCE : SUCCESS;
102
+ return mapExitCode(finalReport.exitCode);
103
+ }
104
+
105
+ function reportPendingStage(prompts, pending) {
106
+ const fileLines = pending.files.map((f) => ` - ${f}`).join('\n');
107
+ prompts.log.warn(`Pending semantic-merge stage at ${pending.stage_ts}.\n${pending.files.length} file(s) awaiting reconciliation:\n${fileLines}\nOpen Claude Code and run /upgrade-project to reconcile.`);
108
+ prompts.outro('No new work; existing stage pending.');
109
+ return ERR_SEMANTIC_STAGED;
110
+ }
111
+
112
+ function isLegacyManifest(m) {
113
+ if (!m) return false;
114
+ if (m.manifest_version === 1) return true;
115
+ return typeof m.baseline_version !== 'string';
116
+ }
117
+
118
+ async function collectUserChoices(prompts, conflicts, templateDir, target, choices) {
119
+ for (const conflict of conflicts) {
120
+ const choice = await pickForFile(prompts, conflict.path, templateDir, target);
121
+ if (choice === 'abort') return true;
122
+ if (choice !== null) choices.set(conflict.path, choice);
123
+ }
124
+ return false;
125
+ }
126
+
127
+ async function pickForFile(prompts, rel, templateDir, target) {
128
+ let consecutiveShowDiff = 0;
129
+ while (true) {
130
+ const choice = await prompts.select({
131
+ message: `${rel} has been customized — choose:`,
132
+ options: CHOICE_OPTIONS,
133
+ });
134
+ if (prompts.isCancel(choice)) return 'abort';
135
+ if (choice !== 'show-diff') return choice;
136
+ await renderConflictDiff(prompts, rel, templateDir, target);
137
+ consecutiveShowDiff++;
138
+ if (consecutiveShowDiff >= SHOW_DIFF_CONSECUTIVE_CAP) {
139
+ prompts.log.info(`Show-diff picked ${SHOW_DIFF_CONSECUTIVE_CAP} times for ${rel}; falling through (keeping your version). Re-run if you want to choose differently.`);
140
+ return null;
141
+ }
142
+ }
143
+ }
144
+
145
+ async function renderConflictDiff(prompts, rel, templateDir, target) {
146
+ const localBytes = await readFile(join(target, rel), 'utf8');
147
+ const incomingBytes = await readFile(join(templateDir, rel), 'utf8');
148
+ const diff = renderUnifiedDiff(localBytes, incomingBytes, { colorize: process.stdout.isTTY === true });
149
+ prompts.log.info(`Diff for ${rel} (local → incoming):\n${diff}`);
150
+ }
151
+
152
+ function isReportableAction(kind) {
153
+ return (
154
+ kind === ACTION_KINDS.MECHANICAL_MERGE_CLEAN ||
155
+ kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED ||
156
+ kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED
157
+ );
74
158
  }
75
159
 
76
160
  function isApplied(kind) {
@@ -79,22 +163,54 @@ function isApplied(kind) {
79
163
  kind === ACTION_KINDS.OVERWRITE ||
80
164
  kind === ACTION_KINDS.PRUNE ||
81
165
  kind === ACTION_KINDS.SPECIAL_MERGE ||
82
- kind === ACTION_KINDS.NEVER_TOUCH_ADD
166
+ kind === ACTION_KINDS.NEVER_TOUCH_ADD ||
167
+ kind === ACTION_KINDS.MECHANICAL_MERGE_CLEAN
83
168
  );
84
169
  }
85
170
 
171
+ function mapExitCode(mergeExit) {
172
+ if (mergeExit === 5) return ERR_SEMANTIC_STAGED;
173
+ if (mergeExit === 4) return ERR_MECHANICAL_CONFLICTED;
174
+ if (mergeExit === 3) return ERR_DIVERGENCE;
175
+ return SUCCESS;
176
+ }
177
+
86
178
  async function loadManifests(templateDir, manifestPath) {
87
179
  const oldManifest = await loadManifest(manifestPath);
88
180
  const tplFiles = await listShippedFiles(templateDir);
89
181
  const newManifest = await buildManifestFromDir(templateDir, tplFiles);
182
+ await overlayShippedTiers(templateDir, newManifest);
90
183
  return { oldManifest, newManifest };
91
184
  }
92
185
 
186
+ async function overlayShippedTiers(templateDir, newManifest) {
187
+ const shippedPath = join(templateDir, '.claude/manifest.json');
188
+ if (!existsSync(shippedPath)) return;
189
+ const shipped = JSON.parse(await readFile(shippedPath, 'utf8'));
190
+ if (!shipped?.files) return;
191
+ for (const rel of Object.keys(newManifest.files)) {
192
+ const shippedEntry = shipped.files[rel];
193
+ if (shippedEntry && typeof shippedEntry === 'object' && typeof shippedEntry.tier === 'string') {
194
+ newManifest.files[rel] = { sha256: newManifest.files[rel], tier: shippedEntry.tier };
195
+ }
196
+ }
197
+ }
198
+
199
+ async function readPackageVersion() {
200
+ try {
201
+ const url = new URL('../../../package.json', import.meta.url);
202
+ const pkg = JSON.parse(await readFile(url, 'utf8'));
203
+ return pkg.version || '0.0.0';
204
+ } catch {
205
+ return '0.0.0';
206
+ }
207
+ }
208
+
93
209
  async function listShippedFiles(root, base = root, acc = []) {
94
210
  for (const entry of await readdir(root, { withFileTypes: true })) {
95
211
  const full = join(root, entry.name);
96
212
  if (entry.isDirectory()) await listShippedFiles(full, base, acc);
97
213
  else if (entry.isFile()) acc.push(relative(base, full).split(sep).join('/'));
98
214
  }
99
- return acc;
215
+ return acc.filter((p) => !COPY_EXCLUDE.includes(p));
100
216
  }