@friedbotstudio/create-baseline 0.7.0 → 0.8.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/README.md +1 -1
- package/bin/cli.js +3 -1
- package/obj/template/.claude/hooks/memory_session_start.sh +24 -0
- package/obj/template/.claude/manifest.json +4 -4
- package/obj/template/.claude/skills/upgrade-project/SKILL.md +18 -7
- package/package.json +1 -1
- package/src/cli/merge.js +15 -5
- package/src/cli/tui/upgrade.js +16 -33
- package/src/cli/upgrade-tiers.js +20 -4
- package/src/cli/diff-render.js +0 -54
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ npx @friedbotstudio/create-baseline ./your-project
|
|
|
95
95
|
npx @friedbotstudio/create-baseline ./your-project --force
|
|
96
96
|
|
|
97
97
|
# Upgrade an existing install against a newer baseline version.
|
|
98
|
-
# In a TTY, each tier-1 customised file
|
|
98
|
+
# In a TTY, each tier-1 customised file prompts: keep-mine / take-theirs / merge / abort
|
|
99
99
|
# prompt; tier-2 files auto-merge via `git merge-file --diff3`; tier-3 files
|
|
100
100
|
# stage for the /upgrade-project Claude Code skill to reconcile. In CI / piped
|
|
101
101
|
# stdout, every per-file action is reported with a user-facing label:
|
package/bin/cli.js
CHANGED
|
@@ -33,7 +33,9 @@ Upgrade:
|
|
|
33
33
|
Replaces the prior --merge flag. Reads <target>/.claude/.baseline-manifest.json
|
|
34
34
|
and runs a three-tier merge against the shipped template:
|
|
35
35
|
- tier 1 (binary prompt): customized files prompt "Keep your version / Use
|
|
36
|
-
new baseline /
|
|
36
|
+
new baseline / Merge / Abort" in TTY mode. "Merge" stages incoming bytes
|
|
37
|
+
under .claude/state/upgrade/<ts>/ for /upgrade-project to reconcile in
|
|
38
|
+
Claude Code (exit 5); "Keep your version" exits 3 on any skipped.
|
|
37
39
|
- tier 2 (mechanical): files routed through git merge-file --diff3 with
|
|
38
40
|
BASE recovered from .claude/.baseline-prior/ cache or npm fallback;
|
|
39
41
|
clean merges land silently, conflicts surface with markers (exit 4).
|
|
@@ -189,6 +189,30 @@ if pending_count > 0 and not active_workflow:
|
|
|
189
189
|
'run `/memory-flush` to clear before starting new work.'
|
|
190
190
|
)
|
|
191
191
|
|
|
192
|
+
# Pending upgrade stages (tier1-merge-option AC-004 + AC-008). Scans
|
|
193
|
+
# .claude/state/upgrade/*/manifest.json for entries with status: PENDING.
|
|
194
|
+
# Fires regardless of active_workflow (design pick 2C): stages are stable
|
|
195
|
+
# infrastructure debt, distinct from memory-candidate debt above.
|
|
196
|
+
upgrade_pending = 0
|
|
197
|
+
upgrade_root = root / '.claude/state/upgrade'
|
|
198
|
+
if upgrade_root.is_dir():
|
|
199
|
+
for stage_manifest in upgrade_root.glob('*/manifest.json'):
|
|
200
|
+
try:
|
|
201
|
+
with open(stage_manifest) as f:
|
|
202
|
+
stage = json.load(f)
|
|
203
|
+
except Exception:
|
|
204
|
+
continue
|
|
205
|
+
for entry in stage.get('files', []):
|
|
206
|
+
if entry.get('status') == 'PENDING':
|
|
207
|
+
upgrade_pending += 1
|
|
208
|
+
|
|
209
|
+
if upgrade_pending > 0:
|
|
210
|
+
noun = 'file' if upgrade_pending == 1 else 'files'
|
|
211
|
+
lines.append(
|
|
212
|
+
f'**{upgrade_pending} {noun} staged for /upgrade-project to reconcile** — '
|
|
213
|
+
'run `/upgrade-project` when ready.'
|
|
214
|
+
)
|
|
215
|
+
|
|
192
216
|
lines.append('')
|
|
193
217
|
lines.append(
|
|
194
218
|
'Files are read on demand by the relevant skill (scout reads landmarks, research reads libraries, etc.). '
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
|
-
"generated_at": "2026-05-
|
|
3
|
+
"generated_at": "2026-05-22T13:30:10.925Z",
|
|
4
4
|
"files": {
|
|
5
5
|
".claude/agents/swarm-worker.md": {
|
|
6
6
|
"sha256": "1735a220f268c9765cb22e0567b728803f2edd7776cbde51dd017a9f062ae41f",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"tier": "MECHANICAL"
|
|
84
84
|
},
|
|
85
85
|
".claude/hooks/memory_session_start.sh": {
|
|
86
|
-
"sha256": "
|
|
86
|
+
"sha256": "ec03026919c3cfeb0bdb8e46d8ace19b23d93b5b21f1ebdb4f579e9c52293ac9",
|
|
87
87
|
"tier": "MECHANICAL"
|
|
88
88
|
},
|
|
89
89
|
".claude/hooks/memory_stop.sh": {
|
|
@@ -823,7 +823,7 @@
|
|
|
823
823
|
"tier": "MECHANICAL"
|
|
824
824
|
},
|
|
825
825
|
".claude/skills/upgrade-project/SKILL.md": {
|
|
826
|
-
"sha256": "
|
|
826
|
+
"sha256": "a8d31370b626bb2c5fb733cf3b1e210f66ade1de84007ad0b3a46e31d115687a",
|
|
827
827
|
"tier": "MECHANICAL"
|
|
828
828
|
},
|
|
829
829
|
".claude/skills/verify/SKILL.md": {
|
|
@@ -889,5 +889,5 @@
|
|
|
889
889
|
"verify": "baseline"
|
|
890
890
|
}
|
|
891
891
|
},
|
|
892
|
-
"build_id": "gha-
|
|
892
|
+
"build_id": "gha-26290648827"
|
|
893
893
|
}
|
|
@@ -31,7 +31,7 @@ For each stage directory under `.claude/state/upgrade/`:
|
|
|
31
31
|
"files": [
|
|
32
32
|
{
|
|
33
33
|
"rel": "docs/init/seed.md",
|
|
34
|
-
"base_sha256": "<hex>",
|
|
34
|
+
"base_sha256": "<hex>" | null,
|
|
35
35
|
"incoming_sha256": "<hex>",
|
|
36
36
|
"local_sha256": "<hex>",
|
|
37
37
|
"status": "PENDING"
|
|
@@ -39,24 +39,35 @@ For each stage directory under `.claude/state/upgrade/`:
|
|
|
39
39
|
]
|
|
40
40
|
}
|
|
41
41
|
```
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
- `<rel>.baseline-incoming` — the **INCOMING** content
|
|
42
|
+
`base_sha256` is the **per-entry classification discriminator**: a 64-hex string means the CLI staged a recoverable BASE (three-way reconciliation); the JSON value `null` means BASE was unrecoverable when the user picked Merge on the tier-1 prompt (two-way reconciliation). See [tier1-merge-option spec](../../../docs/specs/tier1-merge-option.md) §Design pick 1A.
|
|
43
|
+
- For each entry, the staged artifacts are:
|
|
44
|
+
- `<rel>.baseline-incoming` — the **INCOMING** content. Always present.
|
|
45
|
+
- `<rel>.baseline-base` — the **BASE** content. Present iff `base_sha256` is a string; **absent** for BASE-less entries.
|
|
45
46
|
- The LOCAL file remains at its real path inside the target tree (untouched by the CLI).
|
|
46
47
|
|
|
47
48
|
## Procedure
|
|
48
49
|
|
|
49
50
|
1. **Discover the stage.** Read `.claude/state/upgrade/` and pick the most-recent stage directory whose manifest has at least one file with `status: PENDING` or `status: NEEDS_USER_INPUT`. If no such stage exists, tell the user "No pending stage to reconcile" and exit.
|
|
50
|
-
2. **Per
|
|
51
|
+
2. **Per-entry classification** (binding). For each entry in the stage manifest, in declared order:
|
|
52
|
+
- If `entry.base_sha256` is a 64-hex string → **three-way reconciliation** (existing path; BASE was recoverable).
|
|
53
|
+
- If `entry.base_sha256` is `null` → **two-way reconciliation** (new path; BASE was unrecoverable when the user picked Merge on tier-1; the zero-drift renumbering rule does not apply because there is no BASE anchor to shift against).
|
|
54
|
+
- Any other value → apply the `NEEDS_USER_INPUT` fallback with reason `malformed-base-sha256`.
|
|
55
|
+
3. **Three-way reconciliation** (BASE recoverable):
|
|
51
56
|
- Read BASE, INCOMING, and LOCAL.
|
|
52
57
|
- Reason about the three-way delta. Identify what changed between BASE → INCOMING (the upstream edit), what changed between BASE → LOCAL (the user edit), and where they conflict.
|
|
53
58
|
- If both edits are textually non-overlapping, the CLI would have routed the file to tier 2 (mechanical merge). The fact that the file is in tier 3 means structural reconciliation is needed — most commonly: both sides inserted content at the same structural anchor (a new section, a new numbered article, a new TOC entry).
|
|
54
59
|
- Apply the **zero-drift renumbering rule** below.
|
|
55
60
|
- Write the reconciled bytes to the LOCAL path.
|
|
56
61
|
- Update the stage manifest entry's `status` to `RECONCILED`.
|
|
57
|
-
|
|
62
|
+
4. **Two-way reconciliation** (BASE-less; tier-1 Merge):
|
|
63
|
+
- Read INCOMING and LOCAL. Do NOT attempt to read `<rel>.baseline-base` — it is absent by construction.
|
|
64
|
+
- Reason about the two-way diff: which lines/sections in INCOMING are new bytes that should land in LOCAL, and which lines/sections in LOCAL are user-authored content that should be preserved.
|
|
65
|
+
- The **zero-drift renumbering rule does NOT apply** to two-way reconciliation — there is no BASE anchor to shift against, so "shift, never fold" cannot be evaluated. When LOCAL and INCOMING both add structural entries at the same anchor and you cannot determine which is user content vs baseline content without the BASE, apply the `NEEDS_USER_INPUT` fallback.
|
|
66
|
+
- Write the reconciled bytes to the LOCAL path.
|
|
67
|
+
- Update the stage manifest entry's `status` to `RECONCILED`.
|
|
68
|
+
5. **Finalize the stage.** When every entry's status is `RECONCILED`, delete the stage directory (`rm -rf .claude/state/upgrade/<ts>/`). Report per-file status to the user.
|
|
58
69
|
|
|
59
|
-
## The zero-drift renumbering rule (binding)
|
|
70
|
+
## The zero-drift renumbering rule (binding for three-way only)
|
|
60
71
|
|
|
61
72
|
When BASE → INCOMING adds a new structural entry (a new Article, a new section, a new numbered item) at position N, and BASE → LOCAL added the user's own entry at the same position N, you SHALL renumber the user's entry to the **next available** slot (N+1) — you SHALL **never fold** the user's entry into an existing baseline section.
|
|
62
73
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friedbotstudio/create-baseline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Node CLI scaffolder that materializes the Claude Code baseline (hooks, skills, commands, MCP servers, governance docs) into a target project, with branded interactive install / upgrade / doctor flows. Run via `npx @friedbotstudio/create-baseline <target>`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/merge.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { cp, mkdir, unlink } from 'node:fs/promises';
|
|
1
|
+
import { cp, mkdir, readFile, unlink } from 'node:fs/promises';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
3
|
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
|
+
import { dispatchByTier, NoBaseError, canRecoverBase, writeStageBaseless } from './upgrade-tiers.js';
|
|
8
8
|
|
|
9
9
|
export const ACTION_KINDS = Object.freeze({
|
|
10
10
|
ADD: 'ADD',
|
|
@@ -176,23 +176,33 @@ async function dispatchCustomized({ rel, newEntry, tierCtx, dryRun, onSkipCustom
|
|
|
176
176
|
return await dispatchByTier(rel, tier, tierCtx);
|
|
177
177
|
} catch (err) {
|
|
178
178
|
if (err instanceof NoBaseError) {
|
|
179
|
-
return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, err });
|
|
179
|
+
return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, tierCtx, err });
|
|
180
180
|
}
|
|
181
181
|
throw err;
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
-
return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath });
|
|
184
|
+
return fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, tierCtx });
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
async function fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, err = null }) {
|
|
187
|
+
async function fallbackToBinaryPrompt({ rel, onSkipCustomized, dryRun, tplPath, tgtPath, tierCtx, err = null }) {
|
|
188
188
|
const choice = onSkipCustomized ? await onSkipCustomized(rel) : 'keep-mine';
|
|
189
189
|
if (choice === 'take-theirs') {
|
|
190
190
|
if (!dryRun) await copyFile(tplPath, tgtPath);
|
|
191
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
192
|
}
|
|
193
|
+
if (choice === 'merge') {
|
|
194
|
+
if (!dryRun) await stageBaselessMerge({ rel, tplPath, tgtPath, tierCtx });
|
|
195
|
+
return { kind: ACTION_KINDS.SEMANTIC_MERGE_STAGED, path: rel, reason: err ? `BASE recovery failed (${err.kind}); staged for two-way merge` : 'tier-1 customized; staged for two-way merge' };
|
|
196
|
+
}
|
|
193
197
|
return { kind: ACTION_KINDS.SKIP_CUSTOMIZED, path: rel, reason: err ? `BASE recovery failed (${err.kind}); preserved` : 'target customized since last install' };
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
async function stageBaselessMerge({ rel, tplPath, tgtPath, tierCtx }) {
|
|
201
|
+
const incomingBuf = await readFile(tplPath);
|
|
202
|
+
const localBuf = await readFile(tgtPath);
|
|
203
|
+
await writeStageBaseless(tierCtx, rel, incomingBuf, localBuf);
|
|
204
|
+
}
|
|
205
|
+
|
|
196
206
|
function computeExitCode(actions) {
|
|
197
207
|
let code = 0;
|
|
198
208
|
for (const a of actions) {
|
package/src/cli/tui/upgrade.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Plan/apply split:
|
|
3
3
|
// 1. detect pending semantic-merge stage (idempotency short-circuit, AC-007)
|
|
4
4
|
// 2. dry-run threeWayMerge → enumerate SKIP_CUSTOMIZED conflicts (tier-1 only)
|
|
5
|
-
// 3. prompt the user once per tier-1 conflict
|
|
5
|
+
// 3. prompt the user once per tier-1 conflict: Keep your version / Use new
|
|
6
|
+
// baseline / Merge / Abort. The Merge pick stages incoming bytes for
|
|
7
|
+
// /upgrade-project to reconcile (tier1-merge-option spec).
|
|
6
8
|
// 4. on cancel/abort: bail before any write
|
|
7
9
|
// 5. on resolve: real threeWayMerge with onSkipCustomized backed by the Map.
|
|
8
10
|
// Tier-2 MECHANICAL and tier-3 SEMANTIC files are NOT prompted — they're
|
|
@@ -16,7 +18,6 @@ import { threeWayMerge, ACTION_KINDS, ACTION_LABELS, ACTION_LABEL_WIDTH } from '
|
|
|
16
18
|
import { loadManifest, buildManifestFromDir } from '../manifest.js';
|
|
17
19
|
import { COPY_EXCLUDE } from '../install.js';
|
|
18
20
|
import { findPendingStage, formatStageTimestamp } from '../upgrade-tiers.js';
|
|
19
|
-
import { renderUnifiedDiff } from '../diff-render.js';
|
|
20
21
|
import { renderBrandStrip } from './splash.js';
|
|
21
22
|
|
|
22
23
|
const SUCCESS = 0;
|
|
@@ -29,12 +30,10 @@ const ERR_SEMANTIC_STAGED = 5;
|
|
|
29
30
|
const CHOICE_OPTIONS = [
|
|
30
31
|
{ value: 'keep-mine', label: 'Keep your version', hint: 'preserve target file as-is' },
|
|
31
32
|
{ value: 'take-theirs', label: 'Use new baseline', hint: 'overwrite with new template' },
|
|
32
|
-
{ value: '
|
|
33
|
+
{ value: 'merge', label: 'Merge', hint: 'stage incoming bytes for /upgrade-project to reconcile' },
|
|
33
34
|
{ value: 'abort', label: 'Abort', hint: 'exit without changes' },
|
|
34
35
|
];
|
|
35
36
|
|
|
36
|
-
const SHOW_DIFF_CONSECUTIVE_CAP = 2;
|
|
37
|
-
|
|
38
37
|
export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
39
38
|
if (!target || typeof target !== 'string') {
|
|
40
39
|
throw new Error('tui.upgrade.run requires a non-empty string target');
|
|
@@ -65,7 +64,7 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
|
65
64
|
const conflicts = dryReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED);
|
|
66
65
|
|
|
67
66
|
const choices = new Map();
|
|
68
|
-
const aborted = await collectUserChoices(prompts, conflicts,
|
|
67
|
+
const aborted = await collectUserChoices(prompts, conflicts, choices);
|
|
69
68
|
if (aborted) {
|
|
70
69
|
prompts.cancel('Upgrade aborted; tree unchanged.');
|
|
71
70
|
return ERR_ABORT;
|
|
@@ -95,7 +94,7 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
|
95
94
|
|
|
96
95
|
const stagedCount = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED).length;
|
|
97
96
|
if (stagedCount > 0) {
|
|
98
|
-
prompts.log.info(`${stagedCount} file(s)
|
|
97
|
+
prompts.log.info(`${stagedCount} file(s) staged. Open Claude Code and run /upgrade-project to reconcile.`);
|
|
99
98
|
}
|
|
100
99
|
|
|
101
100
|
const applied = finalReport.actions.filter((a) => isApplied(a.kind)).length;
|
|
@@ -121,38 +120,22 @@ function isLegacyManifest(m) {
|
|
|
121
120
|
return typeof m.baseline_version !== 'string';
|
|
122
121
|
}
|
|
123
122
|
|
|
124
|
-
async function collectUserChoices(prompts, conflicts,
|
|
123
|
+
async function collectUserChoices(prompts, conflicts, choices) {
|
|
125
124
|
for (const conflict of conflicts) {
|
|
126
|
-
const choice = await pickForFile(prompts, conflict.path
|
|
125
|
+
const choice = await pickForFile(prompts, conflict.path);
|
|
127
126
|
if (choice === 'abort') return true;
|
|
128
|
-
|
|
127
|
+
choices.set(conflict.path, choice);
|
|
129
128
|
}
|
|
130
129
|
return false;
|
|
131
130
|
}
|
|
132
131
|
|
|
133
|
-
async function pickForFile(prompts, rel
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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}`);
|
|
132
|
+
async function pickForFile(prompts, rel) {
|
|
133
|
+
const choice = await prompts.select({
|
|
134
|
+
message: `${rel} has been customized — choose:`,
|
|
135
|
+
options: CHOICE_OPTIONS,
|
|
136
|
+
});
|
|
137
|
+
if (prompts.isCancel(choice)) return 'abort';
|
|
138
|
+
return choice;
|
|
156
139
|
}
|
|
157
140
|
|
|
158
141
|
function isReportableAction(kind) {
|
package/src/cli/upgrade-tiers.js
CHANGED
|
@@ -86,14 +86,30 @@ export async function dispatchByTier(rel, tier, ctx) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export async function writeStage(ctx, rel, baseBuf, incomingBuf, localBuf) {
|
|
89
|
-
|
|
90
|
-
const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
|
|
91
|
-
await mkdir(stageDir, { recursive: true });
|
|
89
|
+
const stageDir = await ensureStageDir(ctx);
|
|
92
90
|
await writeStageArtifact(stageDir, `${rel}.baseline-base`, baseBuf);
|
|
93
91
|
await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
|
|
94
92
|
await appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, localBuf);
|
|
95
93
|
}
|
|
96
94
|
|
|
95
|
+
// BASE-less stage writer used by the tier-1 Merge pick (see
|
|
96
|
+
// docs/specs/tier1-merge-option.md §Behavior #2 + design pick 1A). Unlike
|
|
97
|
+
// writeStage, no BASE artifact is written and the manifest entry carries
|
|
98
|
+
// base_sha256: null — the discriminator /upgrade-project reads to route to
|
|
99
|
+
// two-way reconciliation.
|
|
100
|
+
export async function writeStageBaseless(ctx, rel, incomingBuf, localBuf) {
|
|
101
|
+
const stageDir = await ensureStageDir(ctx);
|
|
102
|
+
await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
|
|
103
|
+
await appendToStageManifest(stageDir, ctx, rel, null, incomingBuf, localBuf);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function ensureStageDir(ctx) {
|
|
107
|
+
if (!ctx.stageRunTs) ctx.stageRunTs = stageTimestamp();
|
|
108
|
+
const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
|
|
109
|
+
await mkdir(stageDir, { recursive: true });
|
|
110
|
+
return stageDir;
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
// --- foundation helpers ---
|
|
98
114
|
|
|
99
115
|
function sha256(buf) {
|
|
@@ -232,7 +248,7 @@ async function appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, l
|
|
|
232
248
|
: newStageManifest(ctx);
|
|
233
249
|
manifest.files.push({
|
|
234
250
|
rel,
|
|
235
|
-
base_sha256: sha256(baseBuf),
|
|
251
|
+
base_sha256: baseBuf === null ? null : sha256(baseBuf),
|
|
236
252
|
incoming_sha256: sha256(incomingBuf),
|
|
237
253
|
local_sha256: sha256(localBuf),
|
|
238
254
|
status: 'PENDING',
|
package/src/cli/diff-render.js
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
// Foundation — line-level unified-diff renderer used by the upgrade TUI's
|
|
2
|
-
// "Show diff" prompt. Pure function; no IO, no side effects.
|
|
3
|
-
|
|
4
|
-
const ANSI_RED = '\x1b[31m';
|
|
5
|
-
const ANSI_GREEN = '\x1b[32m';
|
|
6
|
-
const ANSI_RESET = '\x1b[0m';
|
|
7
|
-
|
|
8
|
-
export function renderUnifiedDiff(localText, incomingText, opts = {}) {
|
|
9
|
-
const colorize = opts.colorize === true;
|
|
10
|
-
const ops = diffLines(splitLines(localText), splitLines(incomingText));
|
|
11
|
-
return ops.map((op) => renderOp(op, colorize)).join('\n');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function splitLines(text) {
|
|
15
|
-
return String(text).split('\n');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function renderOp(op, colorize) {
|
|
19
|
-
if (op.kind === 'context') return ' ' + op.line;
|
|
20
|
-
const marker = op.kind === 'remove' ? '-' : '+';
|
|
21
|
-
if (!colorize) return marker + op.line;
|
|
22
|
-
const color = op.kind === 'remove' ? ANSI_RED : ANSI_GREEN;
|
|
23
|
-
return color + marker + op.line + ANSI_RESET;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function diffLines(a, b) {
|
|
27
|
-
const m = a.length;
|
|
28
|
-
const n = b.length;
|
|
29
|
-
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
30
|
-
for (let i = 1; i <= m; i++) {
|
|
31
|
-
for (let j = 1; j <= n; j++) {
|
|
32
|
-
if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
|
|
33
|
-
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
const ops = [];
|
|
37
|
-
let i = m;
|
|
38
|
-
let j = n;
|
|
39
|
-
while (i > 0 && j > 0) {
|
|
40
|
-
if (a[i - 1] === b[j - 1]) {
|
|
41
|
-
ops.push({ kind: 'context', line: a[i - 1] });
|
|
42
|
-
i--; j--;
|
|
43
|
-
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
|
|
44
|
-
ops.push({ kind: 'remove', line: a[i - 1] });
|
|
45
|
-
i--;
|
|
46
|
-
} else {
|
|
47
|
-
ops.push({ kind: 'add', line: b[j - 1] });
|
|
48
|
-
j--;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
while (i > 0) { ops.push({ kind: 'remove', line: a[i - 1] }); i--; }
|
|
52
|
-
while (j > 0) { ops.push({ kind: 'add', line: b[j - 1] }); j--; }
|
|
53
|
-
return ops.reverse();
|
|
54
|
-
}
|