@friedbotstudio/create-baseline 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +46 -15
  3. package/obj/template/.claude/commands/init-project-doctor.md +74 -0
  4. package/obj/template/.claude/hooks/lib/resume_writer.py +14 -1
  5. package/obj/template/.claude/hooks/track_guard.sh +11 -1
  6. package/obj/template/.claude/manifest.json +848 -230
  7. package/obj/template/.claude/schemas/workflow-track.v1.json +64 -0
  8. package/obj/template/.claude/skills/audit-baseline/audit.sh +6 -3
  9. package/obj/template/.claude/skills/chore/SKILL.md +2 -2
  10. package/obj/template/.claude/skills/harness/SKILL.md +15 -6
  11. package/obj/template/.claude/skills/intake/SKILL.md +1 -1
  12. package/obj/template/.claude/skills/swarm-plan/SKILL.md +2 -0
  13. package/obj/template/.claude/skills/tdd/SKILL.md +2 -2
  14. package/obj/template/.claude/skills/triage/SKILL.md +29 -6
  15. package/obj/template/.claude/skills/triage/seed-tasklist.mjs +107 -0
  16. package/obj/template/.claude/skills/upgrade-project/SKILL.md +121 -0
  17. package/obj/template/.claude/workflows.jsonl +6 -0
  18. package/obj/template/CLAUDE.md +14 -19
  19. package/obj/template/docs/init/seed.md +152 -7
  20. package/package.json +1 -1
  21. package/src/.claude/workflows.template.jsonl +6 -0
  22. package/src/CLAUDE.template.md +14 -19
  23. package/src/cli/diff-render.js +54 -0
  24. package/src/cli/install.js +38 -3
  25. package/src/cli/manifest.js +7 -3
  26. package/src/cli/merge.js +107 -13
  27. package/src/cli/track-tasklist-materializer.js +223 -0
  28. package/src/cli/tui/upgrade.js +130 -27
  29. package/src/cli/upgrade-tiers.js +256 -0
  30. package/src/cli/workflow-migrator.js +40 -0
  31. package/src/cli/workflows-validator-invariants.js +417 -0
  32. package/src/cli/workflows-validator-predicates.js +19 -0
  33. package/src/cli/workflows-validator.js +156 -0
  34. package/src/seed.template.md +152 -7
  35. package/obj/template/.claude/skills/google-analytics/SKILL.md +0 -129
  36. package/obj/template/.claude/skills/google-analytics/references/audiences.md +0 -389
  37. package/obj/template/.claude/skills/google-analytics/references/bigquery.md +0 -470
  38. package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +0 -355
  39. package/obj/template/.claude/skills/google-analytics/references/custom-events.md +0 -383
  40. package/obj/template/.claude/skills/google-analytics/references/data-management.md +0 -416
  41. package/obj/template/.claude/skills/google-analytics/references/debugview.md +0 -364
  42. package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +0 -398
  43. package/obj/template/.claude/skills/google-analytics/references/gtag.md +0 -502
  44. package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +0 -483
  45. package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +0 -519
  46. package/obj/template/.claude/skills/google-analytics/references/privacy.md +0 -441
  47. package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +0 -464
  48. package/obj/template/.claude/skills/google-analytics/references/reporting.md +0 -397
  49. package/obj/template/.claude/skills/google-analytics/references/setup.md +0 -344
  50. package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +0 -417
  51. package/obj/template/.claude/skills/optimize-seo/SKILL.md +0 -313
  52. package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +0 -197
  53. package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +0 -37
  54. package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +0 -446
  55. package/obj/template/.claude/skills/pagespeed-insights/reference.md +0 -50
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, canRecoverBase } from './upgrade-tiers.js';
7
8
 
8
9
  export const ACTION_KINDS = Object.freeze({
9
10
  ADD: 'ADD',
@@ -15,20 +16,70 @@ 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
 
24
+ // User-facing labels for each ACTION_KIND. Surfaced in the per-file upgrade
25
+ // report (TTY via `tui/upgrade.js`, non-TTY via `bin/cli.js dispatchUpgrade`).
26
+ // Kept centralized so both paths render identically.
27
+ export const ACTION_LABELS = Object.freeze({
28
+ ADD: 'add',
29
+ OVERWRITE: 'update',
30
+ NOOP: 'unchanged',
31
+ SKIP_CUSTOMIZED: 'kept yours',
32
+ PRUNE: 'removed (upstream)',
33
+ PRUNE_SKIPPED_CUSTOMIZED: 'kept yours (upstream removed)',
34
+ NEVER_TOUCH_PRESERVE: 'kept yours (never-touch)',
35
+ NEVER_TOUCH_ADD: 'add (never-touch)',
36
+ SPECIAL_MERGE: 'merged (.mcp.json deep-merge)',
37
+ MECHANICAL_MERGE_CLEAN: 'merged cleanly',
38
+ MECHANICAL_MERGE_CONFLICTED: 'merged with conflicts — resolve manually',
39
+ SEMANTIC_MERGE_STAGED: 'staged for /upgrade-project',
40
+ });
41
+
42
+ export const ACTION_LABEL_WIDTH = Math.max(...Object.values(ACTION_LABELS).map((s) => s.length));
43
+
20
44
  async function copyFile(src, dst) {
21
45
  await mkdir(dirname(dst), { recursive: true });
22
46
  await cp(src, dst, { force: true });
23
47
  }
24
48
 
49
+ function readShaFromEntry(entry) {
50
+ if (typeof entry === 'string') return entry;
51
+ if (entry && typeof entry === 'object' && typeof entry.sha256 === 'string') return entry.sha256;
52
+ return null;
53
+ }
54
+
55
+ function readTierFromEntry(entry) {
56
+ if (entry && typeof entry === 'object' && typeof entry.tier === 'string') return entry.tier;
57
+ // Bare-sha entries (legacy shipped manifest_version: 2 OR installed-manifest
58
+ // round-trips without tier overlay) fall back to BINARY_PROMPT — the safe
59
+ // default that preserves today's two-way prompt behavior. New shipped
60
+ // manifests (v3+) carry `{sha256, tier}` per file and exercise the full
61
+ // three-tier flow.
62
+ return 'BINARY_PROMPT';
63
+ }
64
+
25
65
  export async function threeWayMerge(templateDir, target, oldManifest, newManifest, opts = {}) {
26
- const { dryRun = false, onSkipCustomized = null } = opts;
66
+ const { dryRun = false, onSkipCustomized = null, pack = null } = opts;
27
67
  const actions = [];
28
68
  const oldFiles = oldManifest?.files ?? {};
29
69
  const newFiles = newManifest?.files ?? {};
70
+ const baseline_version = oldManifest?.baseline_version;
30
71
  const allPaths = new Set([...Object.keys(oldFiles), ...Object.keys(newFiles)]);
31
72
 
73
+ const tierCtx = {
74
+ target,
75
+ templateDir,
76
+ oldManifest,
77
+ newManifest,
78
+ baseline_version,
79
+ pack,
80
+ stageRunTs: null,
81
+ };
82
+
32
83
  for (const rel of allPaths) {
33
84
  const tplPath = join(templateDir, rel);
34
85
  const tgtPath = join(target, rel);
@@ -51,8 +102,10 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
51
102
  continue;
52
103
  }
53
104
 
54
- const newHash = newFiles[rel];
55
- const oldHash = oldFiles[rel];
105
+ const newEntry = newFiles[rel];
106
+ const oldEntry = oldFiles[rel];
107
+ const newHash = readShaFromEntry(newEntry);
108
+ const oldHash = readShaFromEntry(oldEntry);
56
109
  const targetExists = await pathExists(tgtPath);
57
110
  const tgtHash = targetExists ? await hashFile(tgtPath) : null;
58
111
 
@@ -74,13 +127,10 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
74
127
  }
75
128
 
76
129
  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
- }
130
+ const action = await dispatchCustomized({
131
+ rel, newEntry, tierCtx, dryRun, onSkipCustomized, tplPath, tgtPath,
132
+ });
133
+ actions.push(action);
84
134
  continue;
85
135
  }
86
136
 
@@ -106,7 +156,51 @@ export async function threeWayMerge(templateDir, target, oldManifest, newManifes
106
156
  await saveManifest(join(target, '.claude/.baseline-manifest.json'), newManifest);
107
157
  }
108
158
 
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 };
159
+ return { actions, exitCode: computeExitCode(actions) };
160
+ }
161
+
162
+ async function dispatchCustomized({ rel, newEntry, tierCtx, dryRun, onSkipCustomized, tplPath, tgtPath }) {
163
+ const tier = readTierFromEntry(newEntry);
164
+ if (tier === 'MECHANICAL' || tier === 'SEMANTIC') {
165
+ if (dryRun) {
166
+ // When BASE recovery would fail (legacy manifest with no cache hit, no
167
+ // npm fallback), the real run will fall through to the binary prompt.
168
+ // Surface this file as SKIP_CUSTOMIZED at dry-run time so the TUI
169
+ // collects a user choice up front instead of silently keep-mine'ing it.
170
+ if (!canRecoverBase(rel, tierCtx.baseline_version, tierCtx.target)) {
171
+ return { kind: ACTION_KINDS.SKIP_CUSTOMIZED, path: rel, reason: 'BASE unrecoverable; will prompt user' };
172
+ }
173
+ return { kind: tier === 'MECHANICAL' ? ACTION_KINDS.MECHANICAL_MERGE_CLEAN : ACTION_KINDS.SEMANTIC_MERGE_STAGED, path: rel, reason: 'dry-run: tier dispatch deferred' };
174
+ }
175
+ try {
176
+ return await dispatchByTier(rel, tier, tierCtx);
177
+ } catch (err) {
178
+ if (err instanceof NoBaseError) {
179
+ return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, err });
180
+ }
181
+ throw err;
182
+ }
183
+ }
184
+ return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath });
185
+ }
186
+
187
+ async function fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, err = null }) {
188
+ const choice = onSkipCustomized ? await onSkipCustomized(rel) : 'keep-mine';
189
+ if (choice === 'take-theirs') {
190
+ if (!dryRun) await copyFile(tplPath, tgtPath);
191
+ return { kind: ACTION_KINDS.OVERWRITE, path: rel, reason: err ? `BASE recovery failed (${err.kind}); user chose take-theirs` : 'customized file; user chose take-theirs' };
192
+ }
193
+ return { kind: ACTION_KINDS.SKIP_CUSTOMIZED, path: rel, reason: err ? `BASE recovery failed (${err.kind}); preserved` : 'target customized since last install' };
194
+ }
195
+
196
+ function computeExitCode(actions) {
197
+ let code = 0;
198
+ for (const a of actions) {
199
+ if (a.kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED) code = Math.max(code, 5);
200
+ else if (a.kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED) code = Math.max(code, 4);
201
+ else if (a.kind === ACTION_KINDS.SKIP_CUSTOMIZED || a.kind === ACTION_KINDS.PRUNE_SKIPPED_CUSTOMIZED) {
202
+ code = Math.max(code, 3);
203
+ }
204
+ }
205
+ return code;
112
206
  }
@@ -0,0 +1,223 @@
1
+ // Foundation — translate a workflows.jsonl Track record into a canonical
2
+ // TaskList shape. Used by triage at workflow-seed time AND by the
3
+ // byte-equivalent-migration tests (compared to golden fixtures).
4
+ //
5
+ // The returned shape is ordinal-positioned (1-indexed `ord`); `blockedBy`
6
+ // references predecessor ordinals (NOT session task_ids). The runtime
7
+ // caller (triage skill body, harness re-seed) translates ordinals to
8
+ // TaskCreate-assigned task_ids at materialization time.
9
+ //
10
+ // Selector nodes resolve to their default alternate (the first alternate
11
+ // with empty preconditions). Sub-track refs read the originating track's
12
+ // `_allTracks` Map (attached by the validator) to find the target track.
13
+
14
+ export function materializeTaskList(track, { slug, ctx } = {}) {
15
+ if (!slug) {
16
+ throw new Error('materializeTaskList requires a slug option (used for <slug> substitution in subjects/activeForms).');
17
+ }
18
+ const emitter = createEmitter(slug, track._allTracks ?? new Map(), ctx);
19
+ emitNodes(track.nodes, emitter);
20
+ return finalize(emitter);
21
+ }
22
+
23
+ function createEmitter(slug, allTracks, ctx) {
24
+ return {
25
+ slug,
26
+ allTracks,
27
+ ctx: ctx ?? null,
28
+ tasks: [],
29
+ idToOrd: new Map(),
30
+ };
31
+ }
32
+
33
+ function emitNodes(nodes, emitter) {
34
+ for (const node of nodes) {
35
+ emitNode(node, emitter);
36
+ }
37
+ }
38
+
39
+ function emitNode(node, emitter) {
40
+ if (node.type === 'selector') {
41
+ const chosen = evaluateAlternates(node, emitter.ctx);
42
+ if (!chosen) {
43
+ throw new Error(
44
+ `Selector node '${node.id}' has no alternate whose preconditions pass against the provided context. ` +
45
+ `Either provide a ctx that satisfies one alternate's preconditions, or declare an alternate with empty preconditions (unconditional default).`
46
+ );
47
+ }
48
+ emitAlternate(chosen, node, emitter);
49
+ return;
50
+ }
51
+ if (node.sub_track) {
52
+ expandSubTrack(node.sub_track, node, emitter);
53
+ return;
54
+ }
55
+ recordTask(node, emitter, []);
56
+ }
57
+
58
+ function emitAlternate(alternate, parentNode, emitter) {
59
+ if (alternate.sub_track) {
60
+ expandSubTrack(alternate.sub_track, parentNode, emitter);
61
+ return;
62
+ }
63
+ if (alternate.skill) {
64
+ const synthetic = {
65
+ id: parentNode.id,
66
+ type: 'task',
67
+ skill: alternate.skill,
68
+ depends_on: parentNode.depends_on || [],
69
+ blocks: parentNode.blocks || [],
70
+ can_parallel: false,
71
+ needs_user: false,
72
+ };
73
+ recordTask(synthetic, emitter, []);
74
+ return;
75
+ }
76
+ throw new Error(`Alternate on selector '${parentNode.id}' has neither skill nor sub_track.`);
77
+ }
78
+
79
+ function expandSubTrack(subTrackId, parentNode, emitter) {
80
+ const sub = emitter.allTracks.get(subTrackId);
81
+ if (!sub) {
82
+ throw new Error(`sub_track '${subTrackId}' referenced by node '${parentNode.id}' not found in _allTracks.`);
83
+ }
84
+ const parentDepends = parentNode.depends_on || [];
85
+ const beforeOrd = emitter.tasks.length;
86
+ for (const subNode of sub.nodes) {
87
+ const isEntry = !subNode.depends_on || subNode.depends_on.length === 0;
88
+ const effectiveDepends = isEntry ? parentDepends : subNode.depends_on;
89
+ recordTask(subNode, emitter, effectiveDepends);
90
+ if (emitter.tasks.length === beforeOrd + 1) {
91
+ emitter.idToOrd.set(parentNode.id, emitter.tasks[beforeOrd].ord);
92
+ }
93
+ }
94
+ }
95
+
96
+ function recordTask(node, emitter, effectiveDepends) {
97
+ const ord = emitter.tasks.length + 1;
98
+ emitter.idToOrd.set(node.id, ord);
99
+ emitter.tasks.push({
100
+ ord,
101
+ subject: deriveSubject(node, emitter.slug),
102
+ activeForm: deriveActiveForm(node),
103
+ metadata: deriveMetadata(node),
104
+ needs_user: !!node.needs_user,
105
+ can_parallel: !!node.can_parallel,
106
+ _dependsOnIds: effectiveDepends.length > 0 ? effectiveDepends : (node.depends_on || []),
107
+ });
108
+ }
109
+
110
+ function finalize(emitter) {
111
+ const out = [];
112
+ for (const task of emitter.tasks) {
113
+ const blockedBy = task._dependsOnIds
114
+ .map((id) => emitter.idToOrd.get(id))
115
+ .filter((ord) => typeof ord === 'number');
116
+ out.push({
117
+ ord: task.ord,
118
+ subject: task.subject,
119
+ activeForm: task.activeForm,
120
+ metadata: task.metadata,
121
+ needs_user: task.needs_user,
122
+ can_parallel: task.can_parallel,
123
+ blockedBy,
124
+ });
125
+ }
126
+ return out;
127
+ }
128
+
129
+ // evaluateAlternates walks the selector node's alternates in declaration order
130
+ // and returns the first one whose preconditions all pass against ctx. When ctx
131
+ // is null/undefined, only alternates with empty preconditions are eligible
132
+ // (preserves the materialize-time-only default-fallback behavior used by tests
133
+ // that don't pass a ctx — e.g., the byte-equivalent fixture comparison). When
134
+ // ctx is provided, predicates evaluate against its fields:
135
+ // { isGit: bool, componentCount: int, userOverride: string|null,
136
+ // completed: string[], knownSkills: Set<string> }
137
+ // Any predicate whose required field is absent in ctx evaluates false.
138
+ function evaluateAlternates(selectorNode, ctx) {
139
+ const alts = selectorNode.alternates || [];
140
+ for (const alt of alts) {
141
+ const preconditions = Array.isArray(alt.preconditions) ? alt.preconditions : [];
142
+ if (preconditions.every((p) => evaluatePredicate(p, ctx))) {
143
+ return alt;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ function evaluatePredicate(pred, ctx) {
150
+ if (!ctx) return false;
151
+ switch (pred.name) {
152
+ case 'requires_git':
153
+ return ctx.isGit === true;
154
+ case 'requires_user_override':
155
+ return typeof ctx.userOverride === 'string' && ctx.userOverride === pred.argument;
156
+ case 'requires_min_components': {
157
+ const n = parseInt(pred.argument, 10);
158
+ return Number.isFinite(n) && typeof ctx.componentCount === 'number' && ctx.componentCount >= n;
159
+ }
160
+ case 'requires_phase_completed':
161
+ return Array.isArray(ctx.completed) && ctx.completed.includes(pred.argument);
162
+ case 'requires_skill_present':
163
+ return ctx.knownSkills instanceof Set && ctx.knownSkills.has(pred.argument);
164
+ default:
165
+ return false;
166
+ }
167
+ }
168
+
169
+ const ACTIVE_FORM_OVERRIDES = Object.freeze({
170
+ tdd: 'Running TDD',
171
+ intake: 'Running intake',
172
+ scout: 'Running scout',
173
+ research: 'Running research',
174
+ spec: 'Running spec',
175
+ simplify: 'Running simplify',
176
+ security: 'Running security',
177
+ integrate: 'Running integrate',
178
+ document: 'Running document',
179
+ archive: 'Running archive',
180
+ 'memory-flush': 'Running memory-flush',
181
+ changelog: 'Running changelog',
182
+ commit: 'Running commit',
183
+ chore: 'Running chore',
184
+ 'swarm-plan': 'Running swarm-plan',
185
+ 'swarm-dispatch': 'Running swarm-dispatch',
186
+ });
187
+
188
+ const CONSENT_GATE_SUBJECTS = Object.freeze({
189
+ 'approve-spec': 'Wait for /approve-spec <path>',
190
+ 'grant-commit': 'Wait for /grant-commit',
191
+ 'approve-swarm': 'Wait for /approve-swarm <slug>',
192
+ });
193
+
194
+ const CONSENT_GATE_ACTIVE_FORMS = Object.freeze({
195
+ 'approve-spec': 'Awaiting spec approval',
196
+ 'grant-commit': 'Awaiting commit consent',
197
+ 'approve-swarm': 'Awaiting swarm approval',
198
+ });
199
+
200
+ function deriveSubject(node, slug) {
201
+ if (node.needs_user) {
202
+ return CONSENT_GATE_SUBJECTS[node.id] ?? `Wait for /${node.id}`;
203
+ }
204
+ const skill = node.skill || node.id;
205
+ return `Run /${skill} for ${slug}`;
206
+ }
207
+
208
+ function deriveActiveForm(node) {
209
+ if (node.activeForm) return node.activeForm;
210
+ if (node.needs_user) {
211
+ return CONSENT_GATE_ACTIVE_FORMS[node.id] ?? `Awaiting /${node.id}`;
212
+ }
213
+ const skill = node.skill || node.id;
214
+ return ACTIVE_FORM_OVERRIDES[skill] ?? `Running ${skill}`;
215
+ }
216
+
217
+ function deriveMetadata(node) {
218
+ const phase = node.metadata?.phase ?? node.skill ?? node.id;
219
+ if (node.needs_user) {
220
+ return { phase, needs_user: true };
221
+ }
222
+ return { phase };
223
+ }
@@ -1,30 +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
13
  import { readdir, readFile } from 'node:fs/promises';
11
14
  import { join, relative, sep } from 'node:path';
12
- import { threeWayMerge, ACTION_KINDS } from '../merge.js';
15
+ import { threeWayMerge, ACTION_KINDS, ACTION_LABELS, ACTION_LABEL_WIDTH } from '../merge.js';
13
16
  import { loadManifest, buildManifestFromDir } from '../manifest.js';
14
17
  import { COPY_EXCLUDE } from '../install.js';
18
+ import { findPendingStage, formatStageTimestamp } from '../upgrade-tiers.js';
19
+ import { renderUnifiedDiff } from '../diff-render.js';
15
20
  import { renderBrandStrip } from './splash.js';
16
21
 
17
22
  const SUCCESS = 0;
18
23
  const ERR_ABORT = 1;
19
24
  const ERR_NO_MANIFEST = 2;
20
25
  const ERR_DIVERGENCE = 3;
26
+ const ERR_MECHANICAL_CONFLICTED = 4;
27
+ const ERR_SEMANTIC_STAGED = 5;
21
28
 
22
29
  const CHOICE_OPTIONS = [
23
- { value: 'keep-mine', label: 'Keep mine', hint: 'preserve target file as-is' },
24
- { 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' },
25
33
  { value: 'abort', label: 'Abort', hint: 'exit without changes' },
26
34
  ];
27
35
 
36
+ const SHOW_DIFF_CONSECUTIVE_CAP = 2;
37
+
28
38
  export async function run({ target, opts = {}, prompts = clackModule } = {}) {
29
39
  if (!target || typeof target !== 'string') {
30
40
  throw new Error('tui.upgrade.run requires a non-empty string target');
@@ -43,26 +53,28 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
43
53
  process.stdout.write(renderBrandStrip({ version, subtitle: 'upgrade' }));
44
54
  prompts.intro('create-baseline upgrade');
45
55
 
56
+ const pending = await findPendingStage(target);
57
+ if (pending) return reportPendingStage(prompts, pending);
58
+
46
59
  const { oldManifest, newManifest } = await loadManifests(opts.templateDir, manifestPath);
60
+ if (isLegacyManifest(oldManifest)) {
61
+ prompts.log.warn("Your previous install predates version-tracked manifests, so this upgrade can't perform automatic three-way merges on customized files. You'll be prompted to keep your version or take the new baseline for each customized file. To enable three-way merges next time, re-install with the latest baseline.");
62
+ }
63
+
47
64
  const dryReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { dryRun: true });
48
65
  const conflicts = dryReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED);
49
66
 
50
67
  const choices = new Map();
51
- for (const conflict of conflicts) {
52
- const choice = await prompts.select({
53
- message: `${conflict.path} has been customized — choose:`,
54
- options: CHOICE_OPTIONS,
55
- });
56
- if (prompts.isCancel(choice) || choice === 'abort') {
57
- prompts.cancel('Upgrade aborted; tree unchanged.');
58
- return ERR_ABORT;
59
- }
60
- 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;
61
72
  }
62
73
 
63
74
  if (opts.dryRun) {
64
75
  for (const action of dryReport.actions) {
65
- prompts.log.info(`${action.kind.padEnd(24)} ${action.path}`);
76
+ const label = ACTION_LABELS[action.kind] ?? action.kind;
77
+ prompts.log.info(`${label.padEnd(ACTION_LABEL_WIDTH)} ${action.path}`);
66
78
  }
67
79
  prompts.outro('Dry run complete; no changes written.');
68
80
  return SUCCESS;
@@ -71,10 +83,84 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
71
83
  const onSkipCustomized = (rel) => choices.get(rel) ?? 'keep-mine';
72
84
  const finalReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { onSkipCustomized });
73
85
 
86
+ for (const action of finalReport.actions) {
87
+ if (isReportableAction(action.kind)) {
88
+ const label = ACTION_LABELS[action.kind] ?? action.kind;
89
+ prompts.log.info(`${label.padEnd(ACTION_LABEL_WIDTH)} ${action.path}`);
90
+ }
91
+ if (action.kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED) {
92
+ prompts.log.warn(`Merged with conflicts — resolve in ${action.path}`);
93
+ }
94
+ }
95
+
96
+ const stagedCount = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED).length;
97
+ if (stagedCount > 0) {
98
+ prompts.log.info(`${stagedCount} file(s) need semantic merge. Open Claude Code and run /upgrade-project to reconcile.`);
99
+ }
100
+
74
101
  const applied = finalReport.actions.filter((a) => isApplied(a.kind)).length;
75
102
  const skipped = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED).length;
76
- prompts.outro(`Applied ${applied}; ${skipped} skipped.`);
77
- return finalReport.exitCode === 3 ? ERR_DIVERGENCE : SUCCESS;
103
+ prompts.outro(
104
+ skipped === 0
105
+ ? `Applied ${applied} update(s).`
106
+ : `Applied ${applied} update(s); kept your version on ${skipped} customized file(s). Re-run \`create-baseline upgrade\` if you want to revisit those choices.`,
107
+ );
108
+ return mapExitCode(finalReport.exitCode);
109
+ }
110
+
111
+ function reportPendingStage(prompts, pending) {
112
+ const fileLines = pending.files.map((f) => ` - ${f}`).join('\n');
113
+ prompts.log.warn(`A previous upgrade staged ${pending.files.length} file(s) for Claude Code review (staged ${formatStageTimestamp(pending.stage_ts)}):\n${fileLines}\nOpen Claude Code and run /upgrade-project to reconcile.`);
114
+ prompts.outro('No new work; previous staged files still need reconciliation.');
115
+ return ERR_SEMANTIC_STAGED;
116
+ }
117
+
118
+ function isLegacyManifest(m) {
119
+ if (!m) return false;
120
+ if (m.manifest_version === 1) return true;
121
+ return typeof m.baseline_version !== 'string';
122
+ }
123
+
124
+ async function collectUserChoices(prompts, conflicts, templateDir, target, choices) {
125
+ for (const conflict of conflicts) {
126
+ const choice = await pickForFile(prompts, conflict.path, templateDir, target);
127
+ if (choice === 'abort') return true;
128
+ if (choice !== null) choices.set(conflict.path, choice);
129
+ }
130
+ return false;
131
+ }
132
+
133
+ async function pickForFile(prompts, rel, templateDir, target) {
134
+ let consecutiveShowDiff = 0;
135
+ while (true) {
136
+ const choice = await prompts.select({
137
+ message: `${rel} has been customized — choose:`,
138
+ options: CHOICE_OPTIONS,
139
+ });
140
+ if (prompts.isCancel(choice)) return 'abort';
141
+ if (choice !== 'show-diff') return choice;
142
+ await renderConflictDiff(prompts, rel, templateDir, target);
143
+ consecutiveShowDiff++;
144
+ if (consecutiveShowDiff >= SHOW_DIFF_CONSECUTIVE_CAP) {
145
+ 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.`);
146
+ return null;
147
+ }
148
+ }
149
+ }
150
+
151
+ async function renderConflictDiff(prompts, rel, templateDir, target) {
152
+ const localBytes = await readFile(join(target, rel), 'utf8');
153
+ const incomingBytes = await readFile(join(templateDir, rel), 'utf8');
154
+ const diff = renderUnifiedDiff(localBytes, incomingBytes, { colorize: process.stdout.isTTY === true });
155
+ prompts.log.info(`Diff for ${rel} (local → incoming):\n${diff}`);
156
+ }
157
+
158
+ function isReportableAction(kind) {
159
+ return (
160
+ kind === ACTION_KINDS.MECHANICAL_MERGE_CLEAN ||
161
+ kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED ||
162
+ kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED
163
+ );
78
164
  }
79
165
 
80
166
  function isApplied(kind) {
@@ -83,17 +169,39 @@ function isApplied(kind) {
83
169
  kind === ACTION_KINDS.OVERWRITE ||
84
170
  kind === ACTION_KINDS.PRUNE ||
85
171
  kind === ACTION_KINDS.SPECIAL_MERGE ||
86
- kind === ACTION_KINDS.NEVER_TOUCH_ADD
172
+ kind === ACTION_KINDS.NEVER_TOUCH_ADD ||
173
+ kind === ACTION_KINDS.MECHANICAL_MERGE_CLEAN
87
174
  );
88
175
  }
89
176
 
177
+ function mapExitCode(mergeExit) {
178
+ if (mergeExit === 5) return ERR_SEMANTIC_STAGED;
179
+ if (mergeExit === 4) return ERR_MECHANICAL_CONFLICTED;
180
+ if (mergeExit === 3) return ERR_DIVERGENCE;
181
+ return SUCCESS;
182
+ }
183
+
90
184
  async function loadManifests(templateDir, manifestPath) {
91
185
  const oldManifest = await loadManifest(manifestPath);
92
186
  const tplFiles = await listShippedFiles(templateDir);
93
187
  const newManifest = await buildManifestFromDir(templateDir, tplFiles);
188
+ await overlayShippedTiers(templateDir, newManifest);
94
189
  return { oldManifest, newManifest };
95
190
  }
96
191
 
192
+ async function overlayShippedTiers(templateDir, newManifest) {
193
+ const shippedPath = join(templateDir, '.claude/manifest.json');
194
+ if (!existsSync(shippedPath)) return;
195
+ const shipped = JSON.parse(await readFile(shippedPath, 'utf8'));
196
+ if (!shipped?.files) return;
197
+ for (const rel of Object.keys(newManifest.files)) {
198
+ const shippedEntry = shipped.files[rel];
199
+ if (shippedEntry && typeof shippedEntry === 'object' && typeof shippedEntry.tier === 'string') {
200
+ newManifest.files[rel] = { sha256: newManifest.files[rel], tier: shippedEntry.tier };
201
+ }
202
+ }
203
+ }
204
+
97
205
  async function readPackageVersion() {
98
206
  try {
99
207
  const url = new URL('../../../package.json', import.meta.url);
@@ -110,10 +218,5 @@ async function listShippedFiles(root, base = root, acc = []) {
110
218
  if (entry.isDirectory()) await listShippedFiles(full, base, acc);
111
219
  else if (entry.isFile()) acc.push(relative(base, full).split(sep).join('/'));
112
220
  }
113
- // COPY_EXCLUDE (single source of truth in install.js) now lists no paths —
114
- // the shipped manifest moved into `.claude/manifest.json` so the recursive
115
- // walk picks it up at the same path the consumer expects. The filter stays
116
- // for forward-compat; if a future path needs to be kept out of the merge,
117
- // add it to install.js → COPY_EXCLUDE in one place.
118
221
  return acc.filter((p) => !COPY_EXCLUDE.includes(p));
119
222
  }