craftar 0.21.0 → 0.22.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.
@@ -0,0 +1,1218 @@
1
+ /**
2
+ * `craftar drift promote` (spec 30 §4.4–§4.7): carry a hand-edited file back to the Forge as a variant.
3
+ * Writes nothing in a workspace, its lock, the registry or the Forge cache.
4
+ */
5
+ import { promises as fs } from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { isDeepStrictEqual } from "node:util";
9
+ import YAML from "yaml";
10
+ import { classifyForge, cacheKey as computeCacheKey } from "../core/remote.js";
11
+ import { loadForge, gitIsRepo, gitHasCommit, gitIgnored, gitUnheld, exists, listFiles, typeFolder, } from "../core/forge.js";
12
+ import { readWorkspaceConfig, plan, status, readLock, unsetDeclared } from "../core/sync.js";
13
+ import { driftList, unmanaged } from "../core/drift.js";
14
+ import { hashNormalized, toLf } from "../core/text.js";
15
+ import { fingerprintOf } from "../core/fingerprint.js";
16
+ import { samePath } from "../core/registry.js";
17
+ import { workspaceAgainst, forgeWorkspaces, remoteUrls, urlToKey, planAll, impactOf, nextSync, } from "../core/impact.js";
18
+ import { findProfileFile, resolvedBy } from "../core/param-writes.js";
19
+ import { outName } from "../emitters/shared.js";
20
+ import { readEmitted, secretIn, markerIn, validateImported, recipesOf, recipeFile, ForgeStage, } from "./claude-code.js";
21
+ import { decide, workspaceParams, workspaceSections } from "./decide.js";
22
+ import { citedKeys, expandedTexts, readBase, sectionNames } from "../core/template-import.js";
23
+ import { sectionKey } from "../core/resolve.js";
24
+ import { parseYamlText } from "../core/yaml-read.js";
25
+ import { editYamlText } from "../core/yaml-edit.js";
26
+ /** Error thrown when gitUnheld finds paths that are not held by git. */
27
+ export class UnheldError extends Error {
28
+ forgeRoot;
29
+ unheld;
30
+ constructor(forgeRoot, unheld) {
31
+ super("gitUnheld");
32
+ this.forgeRoot = forgeRoot;
33
+ this.unheld = unheld;
34
+ }
35
+ }
36
+ /** D1–D17 messages. */
37
+ const D1 = "the Forge of this workspace is remote, read from a cache craftar never writes — pass `--forge <your clone>`";
38
+ const D2_PATH = (given, real) => `--forge ${given} is not this workspace's Forge (${real})`;
39
+ const D2_URL = (given) => `--forge ${given} is not a clone of this workspace's Forge — none of its git remotes matches`;
40
+ const D3 = (dir) => `${dir} is not a git repository with at least one commit — promote needs git to undo its edit`;
41
+ const D4_OTHER = (p, state) => `${p} is not drifted (${state}) — nothing to promote`;
42
+ const D4_ORPHAN = (p) => `${p} is no longer produced by the Forge — nothing to promote into; \`craftar drift discard ${p}\` removes it`;
43
+ const D5_KIRO = (p, hint) => `${p} is a kiro file — promote reads only what the claude-code target wrote${hint}`;
44
+ const D5_AGENTSMD = (p) => `${p} holds several rules in one file — promote reads only what the claude-code target wrote`;
45
+ const D6 = (p) => `${p} holds every mcp ingredient in one file — edit the mcp ingredient in the Forge`;
46
+ const D7 = (ing, p, state, profile) => `the Forge changed ${ing} since the last sync (${p}: ${state}) — a variant taken from the disk would undo that change for profile ${profile}; sync the rest, redo the edit on top, and promote again`;
47
+ const D8 = (p, ing) => `${p} of ${ing} is missing on disk — restore it with \`craftar sync\` first`;
48
+ const D9 = (ing, reason) => `${ing} looks like it holds a secret (${reason}) — nothing was written`;
49
+ const D10 = (ing, q) => `${ing} is also used by profile ${q} — its files would change there`;
50
+ const D11 = (p, line) => `${p}:${line} holds a section marker — a variant body cannot hold one; remove it and promote again`;
51
+ const D12_SECTION = (ing, name) => `${ing} takes section "${name}" from this workspace's overrides (craftar.yaml) — a variant would silently stop applying it; edit craftar.yaml, or remove the override and sync first`;
52
+ const D12_PARAM = (ing, key) => `${ing} takes param "${key}" from this workspace's overrides (craftar.yaml) — a variant would silently stop applying it; edit craftar.yaml, or remove the override and sync first`;
53
+ const D13_EXISTS = (rel) => `${rel} already exists in the Forge — promote does not overwrite it`;
54
+ const D13_IGNORED = (rel) => `${rel} is ignored by git — git could not show or undo it`;
55
+ const D15 = (what) => `promote could not be proved for this workspace: ${what} — the Forge was left untouched`;
56
+ const D16_OTHER_PROFILE = (wsPath, profile, changedPath) => `promote would change ${wsPath} (profile ${profile}): ${changedPath} — the Forge was left untouched`;
57
+ const D16_SAME_PROFILE_OUTSIDE = (wsPath, ingredient, changedPath) => `promote would change ${wsPath} beyond ${ingredient}: ${changedPath} — the Forge was left untouched`;
58
+ const D16_SAME_PROFILE_OVERRIDE = (wsPath, ingredient) => `${wsPath} fills ${ingredient} from its own overrides — a variant would silently stop applying them there; the Forge was left untouched`;
59
+ const D16_BREAK = (wsPath, profile, errMsg) => `promote would break ${wsPath} (profile ${profile}): ${errMsg.split("\n")[0]} — the Forge was left untouched`;
60
+ const D17 = (recipe, chain) => `recipe ${recipe} reaches this workspace through ${chain} — promote does not fork a recipe chain; re-import the workspace or edit the recipes by hand`;
61
+ const INTERNAL_REUSE = (p) => `internal: ${p} is drift but the Forge already renders it — please report this`;
62
+ const SYMLINK_REFUSAL = (rel) => `${rel} is a symbolic link in the Forge — promote does not write through a link; replace it with the file and commit`;
63
+ const SYMLINK_DIR_REFUSAL = (rel) => `${rel} is a symbolic link in the Forge — promote does not read a linked recipes, ingredients or profiles directory; replace it with the directory and commit`;
64
+ const SYMLINK_OUT_OR_DANGLING = (rel) => `${rel} is a symbolic link out of the Forge, or to nothing — promote cannot copy the Forge to prove its edit; fix or remove it`;
65
+ const NON_REGULAR_FILE_REFUSAL = (rel) => `${rel} is not a regular file — promote cannot copy the Forge to prove its edit; remove it`;
66
+ /** The root directories loadForge reads; a link there causes the scratch to differ from the real Forge. */
67
+ const ROOT_DIRS = new Set(["ingredients", "recipes", "profiles"]);
68
+ /**
69
+ * Copy the Forge to a scratch directory without `.git`, for the proof.
70
+ * - A regular file: copied.
71
+ * - A directory: recursed.
72
+ * - A symbolic link to a FILE whose real path is inside the Forge: copied as a regular file (dereferenced).
73
+ * - A symbolic link to a DIRECTORY (inside or outside): skipped — except a root directory link
74
+ * (ingredients, recipes, profiles), which is refused because loadForge follows it and the scratch would not.
75
+ * - A symbolic link that is dangling, or whose target is a file OUTSIDE the Forge: refused.
76
+ * - A non-regular file (FIFO, socket, device): refused.
77
+ * Exported so tests can verify the copy behaviour directly.
78
+ */
79
+ export async function copyForgeForProof(forgeDir, scratchDir) {
80
+ const forgeReal = await fs.realpath(forgeDir);
81
+ async function walk(rel) {
82
+ const src = path.join(forgeDir, rel);
83
+ const dest = path.join(scratchDir, rel);
84
+ const entries = await fs.readdir(src, { withFileTypes: true });
85
+ for (const ent of entries) {
86
+ const entRel = rel ? `${rel}/${ent.name}` : ent.name;
87
+ const entSrc = path.join(src, ent.name);
88
+ const entDest = path.join(dest, ent.name);
89
+ // Skip .git at the top level
90
+ if (rel === "" && ent.name === ".git")
91
+ continue;
92
+ if (ent.isSymbolicLink()) {
93
+ // First, check if we can resolve the symlink at all
94
+ let targetReal;
95
+ let targetStat;
96
+ try {
97
+ targetReal = await fs.realpath(entSrc);
98
+ targetStat = await fs.stat(targetReal);
99
+ }
100
+ catch (e) {
101
+ // Dangling link or unresolvable — refuse
102
+ throw new Error(SYMLINK_OUT_OR_DANGLING(entRel));
103
+ }
104
+ // If it's a directory (inside or outside Forge)
105
+ if (targetStat.isDirectory()) {
106
+ // A root directory link (ingredients, recipes, profiles) is refused: loadForge follows it
107
+ // but the scratch would skip it, so the scratch would not load the same as the real Forge.
108
+ if (rel === "" && ROOT_DIRS.has(ent.name)) {
109
+ throw new Error(SYMLINK_DIR_REFUSAL(ent.name));
110
+ }
111
+ // Other directory links are skipped entirely — loadForge doesn't traverse them
112
+ continue;
113
+ }
114
+ // Target is neither a directory nor a regular file (FIFO, socket, etc.) — refuse
115
+ // Check this BEFORE the "inside Forge" check so the message is clear
116
+ if (!targetStat.isFile()) {
117
+ throw new Error(NON_REGULAR_FILE_REFUSAL(entRel));
118
+ }
119
+ // It's a file link — check if target is inside the Forge
120
+ const relToForge = path.relative(forgeReal, targetReal);
121
+ // A name starting with ".." but not a ".." segment is valid (e.g. "..notes")
122
+ const isOutside = relToForge === ".." || relToForge.startsWith(".." + path.sep) || path.isAbsolute(relToForge);
123
+ if (isOutside) {
124
+ // File link to outside the Forge — refuse
125
+ throw new Error(SYMLINK_OUT_OR_DANGLING(entRel));
126
+ }
127
+ // File link to inside the Forge — copy the target's bytes as a regular file (dereferenced)
128
+ await fs.copyFile(targetReal, entDest);
129
+ }
130
+ else if (ent.isDirectory()) {
131
+ await fs.mkdir(entDest, { recursive: true });
132
+ await walk(entRel);
133
+ }
134
+ else if (ent.isFile()) {
135
+ // Regular file
136
+ await fs.copyFile(entSrc, entDest);
137
+ }
138
+ else {
139
+ // Non-regular file (FIFO, socket, device, etc.) — refuse
140
+ throw new Error(NON_REGULAR_FILE_REFUSAL(entRel));
141
+ }
142
+ }
143
+ }
144
+ // Create the scratch root
145
+ await fs.mkdir(scratchDir, { recursive: true });
146
+ await walk("");
147
+ }
148
+ /**
149
+ * Check every staged path for symlinks in its path (the path itself or any ancestor between it and forgeDir).
150
+ * Throws with SYMLINK_REFUSAL if any is found.
151
+ */
152
+ async function checkStagedSymlinks(forgeDir, stage) {
153
+ const entries = await stage.entries();
154
+ const checked = new Set();
155
+ for (const entry of entries) {
156
+ // Check the path and every ancestor up to but not including forgeDir
157
+ let current = entry.abs;
158
+ while (path.relative(forgeDir, current) !== "") {
159
+ if (checked.has(current))
160
+ break;
161
+ checked.add(current);
162
+ // Check if current exists and is a symlink
163
+ try {
164
+ const stat = await fs.lstat(current);
165
+ if (stat.isSymbolicLink()) {
166
+ const rel = path.relative(forgeDir, current).split(path.sep).join("/");
167
+ // Use the directory-specific message for root directories (ingredients, recipes, profiles)
168
+ if (ROOT_DIRS.has(rel)) {
169
+ throw new Error(SYMLINK_DIR_REFUSAL(rel));
170
+ }
171
+ throw new Error(SYMLINK_REFUSAL(rel));
172
+ }
173
+ }
174
+ catch (e) {
175
+ // ENOENT means the path doesn't exist yet (it will be created) — that's fine
176
+ if (e.code !== "ENOENT") {
177
+ throw e;
178
+ }
179
+ }
180
+ current = path.dirname(current);
181
+ }
182
+ }
183
+ }
184
+ /**
185
+ * The proof: build a scratch copy, plan against it, and verify the promote would work.
186
+ * Shared by params/sections and variant branches.
187
+ *
188
+ * Returns the proof results; throws D15 errors when the proof fails.
189
+ * The scratch directory is NOT cleaned up — the caller must do that after using scratchForge.
190
+ */
191
+ async function provePromote(opts) {
192
+ const { forgeDir, scratchDir, stage, root, p0, lock, E, EPaths, ingredientRef, meta, N } = opts;
193
+ // Copy Forge without .git, with file symlinks dereferenced and non-regular files refused
194
+ await copyForgeForProof(forgeDir, scratchDir);
195
+ await stage.flushTo(scratchDir);
196
+ const scratchForge = await loadForge(scratchDir);
197
+ const wsScratch = await workspaceAgainst(root, scratchForge);
198
+ const p1 = await plan(wsScratch);
199
+ // Proof checks
200
+ // 1. Same path set
201
+ const p0Paths = new Set(p0.files.map((f) => f.path));
202
+ const p1Paths = new Set(p1.files.map((f) => f.path));
203
+ if (p0Paths.size !== p1Paths.size || [...p0Paths].some((p) => !p1Paths.has(p))) {
204
+ throw new Error(D15(`the planned files would change (${p0Paths.size} → ${p1Paths.size})`));
205
+ }
206
+ // 2. For each path in E, p1 content matches disk
207
+ // Note: the "would not be planned" case is unreachable: step 1 ensures every p0 path is in p1,
208
+ // and E is a subset of p0.files, so every E path is guaranteed to be in p1.
209
+ for (const f of E) {
210
+ const p1File = p1.files.find((x) => x.path === f.path);
211
+ const diskContent = await fs.readFile(path.join(root, f.path));
212
+ if (hashNormalized(p1File.content) !== hashNormalized(diskContent)) {
213
+ throw new Error(D15(`${f.path} would not match the file on disk`));
214
+ }
215
+ }
216
+ // 3. Dependent paths: files of this ingredient on non-claude-code targets
217
+ const dependentPaths = new Set();
218
+ for (const pf of p0.files) {
219
+ if (pf.ingredient === ingredientRef && pf.target !== "claude-code") {
220
+ dependentPaths.add(pf.path);
221
+ }
222
+ }
223
+ // Also add AGENTS.md if this is a rule that's embedded in it (has a marker line)
224
+ let agentsMdIsDependent = false;
225
+ if (meta.type === "rule") {
226
+ const agentsMd = p0.files.find((f) => f.ingredient === "rule/*" || f.path === "AGENTS.md");
227
+ if (agentsMd) {
228
+ const p0AgentsContent = agentsMd.content.toString("utf8");
229
+ const markerLine = `<!-- rule: ${N} -->`;
230
+ if (p0AgentsContent.includes(markerLine)) {
231
+ // This rule is embedded in AGENTS.md — it's a dependent path with bound
232
+ dependentPaths.add(agentsMd.path);
233
+ agentsMdIsDependent = true;
234
+ }
235
+ // If no marker, AGENTS.md just points at the file; it should be byte-equal
236
+ }
237
+ }
238
+ // 4. Every path not in E and not dependent must be byte-equal
239
+ let otherFilesUnchanged = 0;
240
+ for (const p0f of p0.files) {
241
+ if (EPaths.has(p0f.path) || dependentPaths.has(p0f.path))
242
+ continue;
243
+ const p1f = p1.files.find((x) => x.path === p0f.path);
244
+ if (!p1f)
245
+ throw new Error(D15(`${p0f.path} would change`));
246
+ if (!p0f.content.equals(p1f.content)) {
247
+ throw new Error(D15(`${p0f.path} would change`));
248
+ }
249
+ otherFilesUnchanged++;
250
+ }
251
+ // 5. Check AGENTS.md bound (§4.6 item 4)
252
+ if (agentsMdIsDependent) {
253
+ const p0AgentsMd = p0.files.find((f) => f.path === "AGENTS.md");
254
+ const p1AgentsMd = p1.files.find((f) => f.path === "AGENTS.md");
255
+ if (p0AgentsMd && p1AgentsMd) {
256
+ const p0Text = p0AgentsMd.content.toString("utf8");
257
+ const p1Text = p1AgentsMd.content.toString("utf8");
258
+ if (!agentsBound(p0Text, p1Text, N)) {
259
+ throw new Error(D15(`AGENTS.md would change outside rule ${N}`));
260
+ }
261
+ }
262
+ }
263
+ // Compute next sync statuses
264
+ const stScratch = await status(wsScratch, p1, lock);
265
+ return { scratchForge, wsScratch, p1, dependentPaths, otherFilesUnchanged, stScratch };
266
+ }
267
+ /**
268
+ * Check other workspaces for impact (spec 30 §4.6 items 5-6).
269
+ * Throws on D16 refusals; returns the impact rows to report otherwise.
270
+ */
271
+ async function checkOtherWorkspaces(opts) {
272
+ const { home, forgeDir, env, thisWsRealPath, forge, scratchForge, profile, outcome, ingredientRef, variantRef, ingredientName, baseCitedKeys, baseSectionNames, ingKey, } = opts;
273
+ const fwResult = await forgeWorkspaces(home, forgeDir, env);
274
+ const warnings = [];
275
+ // Filter out this workspace
276
+ const otherEntries = fwResult.workspaces.filter((fw) => !samePath(fw.entry.path, thisWsRealPath));
277
+ if (otherEntries.length === 0 || fwResult.state === "off") {
278
+ return {
279
+ state: fwResult.state,
280
+ rows: [],
281
+ warnings: [],
282
+ };
283
+ }
284
+ // Plan all before and after
285
+ const before = await planAll(otherEntries, forge);
286
+ const after = await planAll(otherEntries, scratchForge);
287
+ const rows = [];
288
+ for (let i = 0; i < otherEntries.length; i++) {
289
+ const fw = otherEntries[i];
290
+ const bPlan = before[i];
291
+ const aPlan = after[i];
292
+ // Build the row using nextSync for state/counts
293
+ const ns = await nextSync(aPlan);
294
+ const row = {
295
+ path: fw.entry.path,
296
+ profile: fw.entry.profile,
297
+ match: fw.match,
298
+ via: fw.via,
299
+ ref: fw.ref,
300
+ state: ns.state,
301
+ counts: ns.counts,
302
+ error: ns.error,
303
+ };
304
+ rows.push(row);
305
+ // Skip missing/error for D16 checks - report in warnings
306
+ if (bPlan.kind === "missing" || aPlan.kind === "missing") {
307
+ warnings.push(`${fw.entry.path}: missing`);
308
+ continue;
309
+ }
310
+ if (bPlan.kind === "error" || aPlan.kind === "error") {
311
+ // A sibling that planned BEFORE but fails to plan AFTER is a refusal — the promote would break it
312
+ if (bPlan.kind === "planned" && aPlan.kind === "error") {
313
+ throw new Error(D16_BREAK(fw.entry.path, fw.entry.profile, aPlan.message));
314
+ }
315
+ const errMsg = aPlan.kind === "error" ? aPlan.message : bPlan.kind === "error" ? bPlan.message : "";
316
+ warnings.push(`${fw.entry.path}: error: ${errMsg}`);
317
+ continue;
318
+ }
319
+ // Compare the plans
320
+ const impact = impactOf(bPlan, aPlan);
321
+ if (impact.state === "missing" || impact.state === "error")
322
+ continue;
323
+ const otherProfile = fw.entry.profile;
324
+ // For same-profile siblings with variant outcome, check that X.ref doesn't resolve beside the variant
325
+ // BEFORE checking changed files — the resolution itself is problematic even if files are byte-equal
326
+ // (§4.6 item 5: spec 30r1 commit 2 issue 2)
327
+ if (otherProfile === profile && outcome === "variant" && variantRef) {
328
+ const siblingAfterRefs = new Set(aPlan.plan.resolution.ingredients.map((i) => i.ref));
329
+ if (siblingAfterRefs.has(ingredientRef)) {
330
+ // The sibling still resolves X beside the variant — this is D16 same-profile-outside
331
+ // Use the file path that would be affected (the file X writes to)
332
+ const xPath = bPlan.plan.files.find((pf) => pf.ingredient === ingredientRef)?.path ?? ingredientRef;
333
+ throw new Error(D16_SAME_PROFILE_OUTSIDE(fw.entry.path, ingredientRef, xPath));
334
+ }
335
+ }
336
+ // D16 checks that require changed files
337
+ const changedFiles = impact.files;
338
+ if (impact.state === "no-effect" || changedFiles.length === 0)
339
+ continue;
340
+ const firstChanged = changedFiles[0];
341
+ // D16 (other profile) — any change to another profile is refused
342
+ if (otherProfile !== profile) {
343
+ throw new Error(D16_OTHER_PROFILE(fw.entry.path, otherProfile, firstChanged));
344
+ }
345
+ // Same profile - check if the change is within allowed bounds
346
+ // For variant outcomes, check if the workspace fills the ingredient from overrides
347
+ if (outcome === "variant" || outcome === "variant-updated") {
348
+ // Check if this workspace's merged config sets sections or params that X cites
349
+ const merged = aPlan.workspace.merged;
350
+ const wsSections = merged.config.overrides?.sections?.[ingKey];
351
+ const wsParams = merged.config.overrides?.params ?? {};
352
+ // Check sections
353
+ if (wsSections) {
354
+ for (const secName of baseSectionNames) {
355
+ if (Object.hasOwn(wsSections, secName)) {
356
+ throw new Error(D16_SAME_PROFILE_OVERRIDE(fw.entry.path, ingredientRef));
357
+ }
358
+ }
359
+ }
360
+ // Check params
361
+ for (const key of baseCitedKeys) {
362
+ if (Object.hasOwn(wsParams, key)) {
363
+ throw new Error(D16_SAME_PROFILE_OVERRIDE(fw.entry.path, ingredientRef));
364
+ }
365
+ }
366
+ }
367
+ // D16 same-profile-outside: for a same-profile sibling, a changed path is allowed only when:
368
+ // - it's one of the sibling's OWN planned files of X (any target), OR
369
+ // - it's AGENTS.md within agentsBound for rule N (§4.6 item 5)
370
+ // Get the sibling's before-plan files for X.ref
371
+ const siblingXPaths = new Set();
372
+ for (const pf of bPlan.plan.files) {
373
+ if (pf.ingredient === ingredientRef) {
374
+ siblingXPaths.add(pf.path);
375
+ }
376
+ }
377
+ for (const changed of changedFiles) {
378
+ // Allow if it's a planned file of X in the sibling's before-plan
379
+ if (siblingXPaths.has(changed))
380
+ continue;
381
+ // Allow AGENTS.md if it's bound within the rule's marker
382
+ if (changed === "AGENTS.md") {
383
+ const bAgentsMd = bPlan.plan.files.find((f) => f.path === "AGENTS.md");
384
+ const aAgentsMd = aPlan.plan.files.find((f) => f.path === "AGENTS.md");
385
+ if (bAgentsMd && aAgentsMd) {
386
+ const bText = bAgentsMd.content.toString("utf8");
387
+ const aText = aAgentsMd.content.toString("utf8");
388
+ if (agentsBound(bText, aText, ingredientName))
389
+ continue;
390
+ }
391
+ }
392
+ // Not allowed — D16 same-profile-outside
393
+ throw new Error(D16_SAME_PROFILE_OUTSIDE(fw.entry.path, ingredientRef, changed));
394
+ }
395
+ }
396
+ return { state: fwResult.state, rows, warnings };
397
+ }
398
+ /**
399
+ * Plan a promote operation. Throws an Error with the D1–D17 message on every refusal.
400
+ * Writes NOTHING in the Forge until `applyPromote` is called.
401
+ */
402
+ export async function planPromote(input) {
403
+ const { workspaceRoot, path: inputPath, home, env } = input;
404
+ const root = path.resolve(workspaceRoot);
405
+ // Step 1: The Forge directory (D1, D2, D3)
406
+ const merged = await readWorkspaceConfig(root);
407
+ const forgeValue = merged.config.forge;
408
+ const forgeKind = classifyForge(forgeValue);
409
+ let forgeDir;
410
+ if (input.forgeDir !== null) {
411
+ // --forge given: verify it's this workspace's Forge
412
+ forgeDir = path.resolve(input.forgeDir);
413
+ if (forgeKind === "path") {
414
+ // D2: path Forge — must be the same real path
415
+ const realWorkspaceForge = await fs.realpath(path.resolve(root, forgeValue));
416
+ const realGiven = await fs.realpath(forgeDir);
417
+ if (realWorkspaceForge !== realGiven)
418
+ throw new Error(D2_PATH(input.forgeDir, realWorkspaceForge));
419
+ }
420
+ else {
421
+ // D2: URL Forge — the clone must have a remote that matches
422
+ const warnings = [];
423
+ const cloneRemotes = await remoteUrls(forgeDir, warnings, forgeDir);
424
+ const wsKey = computeCacheKey(forgeValue);
425
+ let found = false;
426
+ for (const [, urls] of cloneRemotes) {
427
+ for (const url of urls) {
428
+ if (urlToKey(url, forgeDir) === wsKey) {
429
+ found = true;
430
+ break;
431
+ }
432
+ }
433
+ if (found)
434
+ break;
435
+ }
436
+ if (!found) {
437
+ // D2: append any credential warnings so the user knows why the remote was not matched
438
+ const msg = warnings.length > 0 ? D2_URL(input.forgeDir) + "\n" + warnings.join("\n") : D2_URL(input.forgeDir);
439
+ throw new Error(msg);
440
+ }
441
+ }
442
+ }
443
+ else {
444
+ // No --forge: the workspace must have a path Forge (D1)
445
+ if (forgeKind === "url")
446
+ throw new Error(D1);
447
+ forgeDir = path.resolve(root, forgeValue);
448
+ }
449
+ // D3: must be a git repo with at least one commit
450
+ const realForgeDir = await fs.realpath(forgeDir);
451
+ const isRepo = await gitIsRepo(forgeDir);
452
+ const hasCommit = isRepo && (await gitHasCommit(forgeDir));
453
+ if (!isRepo || !hasCommit)
454
+ throw new Error(D3(realForgeDir));
455
+ // Load the Forge and workspace
456
+ const forge = await loadForge(forgeDir);
457
+ const ws = await workspaceAgainst(root, forge);
458
+ const p0 = await plan(ws);
459
+ const lock = await readLock(root);
460
+ const st = await status(ws, p0, lock);
461
+ // Step 2: Classify the path (D4, D5, D6)
462
+ const normalized = inputPath.replace(/\\/g, "/").replace(/^\.\//, "");
463
+ const fileStatus = st.find((s) => s.path === normalized);
464
+ if (!fileStatus)
465
+ throw new Error(unmanaged(normalized));
466
+ if (fileStatus.state === "orphan-drift")
467
+ throw new Error(D4_ORPHAN(normalized));
468
+ if (fileStatus.state !== "drift")
469
+ throw new Error(D4_OTHER(normalized, fileStatus.state));
470
+ // Static checks (promotability) using driftList's row
471
+ const rows = driftList([fileStatus]);
472
+ if (rows.length === 0) {
473
+ throw new Error(D4_OTHER(normalized, fileStatus.state));
474
+ }
475
+ const row = rows[0];
476
+ if (!row.promotable) {
477
+ // Build the right D5/D6 message from the static reason
478
+ const reason = row.reason;
479
+ if (reason === "kiro file") {
480
+ // D5 kiro: check if the plan has a claude-code path for this ingredient
481
+ const ingredientRef = fileStatus.ingredient;
482
+ const claudePath = ingredientRef ? p0.files.find((f) => f.ingredient === ingredientRef && f.target === "claude-code")?.path : null;
483
+ const hint = claudePath ? `; edit and promote \`${claudePath}\` instead` : "";
484
+ throw new Error(D5_KIRO(normalized, hint));
485
+ }
486
+ else if (reason === "AGENTS.md") {
487
+ throw new Error(D5_AGENTSMD(normalized));
488
+ }
489
+ else if (reason === ".mcp.json") {
490
+ throw new Error(D6(normalized));
491
+ }
492
+ else {
493
+ // Fallback for any other reason (shouldn't happen for drift state)
494
+ throw new Error(D4_OTHER(normalized, fileStatus.state));
495
+ }
496
+ }
497
+ const profile = ws.config.profile;
498
+ const ingredientRef = fileStatus.ingredient;
499
+ // Step 3: The emitted set E — every file the plan emits for this ingredient on claude-code
500
+ const E = p0.files.filter((f) => f.ingredient === ingredientRef && f.target === "claude-code");
501
+ const EPaths = new Set(E.map((f) => f.path));
502
+ // Check each path of E for D7 and D8, using driftList's forge field for D7
503
+ for (const f of E) {
504
+ const s = st.find((x) => x.path === f.path);
505
+ if (!s)
506
+ continue; // shouldn't happen
507
+ // D8: missing on disk (the file is `new`)
508
+ if (s.state === "new") {
509
+ throw new Error(D8(f.path, ingredientRef));
510
+ }
511
+ // D7: Forge changed since last sync (for drifted files, use driftList's forge field)
512
+ if (s.state === "drift" || s.state === "update" || s.state === "collision" || s.state === "adopt") {
513
+ if (s.state === "drift") {
514
+ // Get forge side from driftList
515
+ const driftRow = driftList([s])[0];
516
+ if (driftRow && driftRow.forge === "changed") {
517
+ throw new Error(D7(ingredientRef, f.path, "drift, forge changed", profile));
518
+ }
519
+ }
520
+ else {
521
+ // update, collision, adopt
522
+ throw new Error(D7(ingredientRef, f.path, s.state, profile));
523
+ }
524
+ }
525
+ }
526
+ // Step 4: Read the source from disk
527
+ const X = p0.resolution.ingredients.find((i) => i.ref === ingredientRef);
528
+ const claudeDir = path.join(root, ".claude");
529
+ const meta = X.meta;
530
+ const outputName = outName(meta);
531
+ // Determine the relative path for readEmitted based on type
532
+ let rel;
533
+ let readType;
534
+ if (meta.type === "skill") {
535
+ readType = "skill";
536
+ if (meta.layout === "dir") {
537
+ rel = outputName; // directory name
538
+ }
539
+ else {
540
+ rel = `${outputName}.md`; // file-layout skill
541
+ }
542
+ }
543
+ else if (meta.type === "rule") {
544
+ readType = "rule";
545
+ rel = `${outputName}.md`;
546
+ }
547
+ else if (meta.type === "agent") {
548
+ readType = "agent";
549
+ rel = `${outputName}.md`;
550
+ }
551
+ else if (meta.type === "command") {
552
+ readType = "command";
553
+ rel = `${outputName}.md`;
554
+ }
555
+ else if (meta.type === "script") {
556
+ readType = "script";
557
+ // For multi-file scripts, we need to read all files
558
+ rel = meta.files?.[0] ?? `${outputName}.sh`;
559
+ }
560
+ else if (meta.type === "hook") {
561
+ readType = "hook";
562
+ // For multi-file hooks, we need to read all files
563
+ rel = meta.files?.[0] ?? `${outputName}.sh`;
564
+ }
565
+ else {
566
+ throw new Error(`cannot promote ${ingredientRef} — unsupported type`);
567
+ }
568
+ // For scripts/hooks with multiple files, read each file and merge
569
+ const scriptHookFiles = (meta.type === "script" || meta.type === "hook") ? (meta.files ?? [rel]) : null;
570
+ let emitted;
571
+ let mergedFiles = {};
572
+ let mergedScan = {};
573
+ if (scriptHookFiles && scriptHookFiles.length > 1) {
574
+ // Read each file and merge
575
+ for (const fileRel of scriptHookFiles) {
576
+ const fileEmitted = await readEmitted(claudeDir, readType, fileRel, (r) => ({ workspace: path.basename(root), path: r }));
577
+ if (!fileEmitted)
578
+ throw new Error(`cannot read ${readType} file ${fileRel} from ${claudeDir}`);
579
+ Object.assign(mergedFiles, fileEmitted.files);
580
+ Object.assign(mergedScan, fileEmitted.scan);
581
+ }
582
+ // Use the first file's metadata as base but with files from the original ingredient
583
+ const firstEmitted = await readEmitted(claudeDir, readType, scriptHookFiles[0], (r) => ({ workspace: path.basename(root), path: r }));
584
+ emitted = firstEmitted ? { meta: { ...firstEmitted.meta, files: scriptHookFiles }, files: mergedFiles, scan: mergedScan } : null;
585
+ }
586
+ else {
587
+ // Single file case: call readEmitted normally
588
+ emitted = await readEmitted(claudeDir, readType, rel, (r) => ({ workspace: path.basename(root), path: r }));
589
+ }
590
+ if (!emitted)
591
+ throw new Error(`cannot read ${readType} ${outputName} from ${claudeDir}`);
592
+ // Source metadata: X's own, but re-read certain fields; drop params
593
+ const sourceMeta = {
594
+ ...X.meta,
595
+ ...emittedMetaFields(emitted.meta, readType),
596
+ };
597
+ // Remove params from source (sources carry none)
598
+ if ("params" in sourceMeta)
599
+ delete sourceMeta.params;
600
+ // D9: Secret check
601
+ const secret = secretIn(sourceMeta, emitted.files, emitted.scan);
602
+ if (secret)
603
+ throw new Error(D9(ingredientRef, secret));
604
+ // Collect warnings about hand-added files in a skill directory
605
+ const warnings = [];
606
+ if (meta.type === "skill" && meta.layout === "dir") {
607
+ const skillDir = path.join(claudeDir, "skills", outputName);
608
+ const diskFiles = await listFiles(skillDir).catch(() => []);
609
+ const plannedFiles = new Set(E.map((f) => f.path.replace(/^\.claude\/skills\/[^/]+\//, "")));
610
+ for (const df of diskFiles) {
611
+ if (!plannedFiles.has(df)) {
612
+ warnings.push(`.claude/skills/${outputName}/${df} is not a file craftar generated — not promoted`);
613
+ }
614
+ }
615
+ }
616
+ // Step 5: Decide the outcome
617
+ const stage = new ForgeStage(forgeDir);
618
+ const readWs = (abs) => fs.readFile(abs, "utf8");
619
+ const ctx = {
620
+ P: { ...(forge.profiles.get(profile)?.params ?? {}) },
621
+ W: await workspaceParams(root, readWs, "drift promote"),
622
+ PS: structuredClone(forge.profiles.get(profile)?.sections ?? {}),
623
+ WS: await workspaceSections(root, readWs, "drift promote"),
624
+ pinned: new Map(),
625
+ forge,
626
+ markedBase: false,
627
+ };
628
+ const fingerprint = fingerprintOf(validateImported(sourceMeta), emitted.files);
629
+ const decision = await decide(ctx, X.dir, stage.reader(), ingredientRef, sourceMeta, emitted.files, fingerprint, [], new Set());
630
+ // Handle the decision
631
+ let outcome;
632
+ const N = outputName;
633
+ // Determine if X is this profile's variant (name is `<N>--<p>` AND as is `<N>`)
634
+ const isOwnVariant = X.meta.name === `${N}--${profile}` && X.meta.as === N;
635
+ // The variant name is always <outputName>--<profile>
636
+ const variantName = `${N}--${profile}`;
637
+ const variantRef = `${meta.type}/${variantName}`;
638
+ // Params/sections that changed for the profile, if outcome is params/sections
639
+ let paramsChanged = [];
640
+ let sectionsChanged = [];
641
+ // For flattened reporting: track what the base cited that the variant no longer has
642
+ let flattenedParams = [];
643
+ let flattenedSections = [];
644
+ // Read the base to detect cited keys and sections
645
+ const base = await readBase(X.dir, stage.reader(), forge.root);
646
+ const ingKey = sectionKey(X.meta);
647
+ const PSk = ctx.PS[ingKey] ?? {};
648
+ const WSk = ctx.WS[ingKey] ?? {};
649
+ const S = { ...PSk, ...WSk };
650
+ const baseCitedKeys = citedKeys(expandedTexts(base, S));
651
+ const baseSectionNames = sectionNames(base);
652
+ if (decision.kind === "reuse") {
653
+ // No delta means the Forge already renders this — internal error
654
+ if (!decision.delta?.length && !decision.sectioned?.length && !decision.sectionDelta?.length) {
655
+ throw new Error(INTERNAL_REUSE(normalized));
656
+ }
657
+ // Has delta: params outcome
658
+ if (decision.delta?.length) {
659
+ outcome = "params";
660
+ paramsChanged = decision.delta.map((d) => ({
661
+ key: d.key,
662
+ old: d.old,
663
+ value: d.value,
664
+ }));
665
+ }
666
+ else if (decision.sectionDelta?.length) {
667
+ // Has sectionDelta: sections outcome
668
+ outcome = "sections";
669
+ sectionsChanged = decision.sectionDelta.map((d) => ({
670
+ key: d.key,
671
+ name: d.name,
672
+ lines: d.value.split("\n").filter((l) => l !== "").length || (d.value === "" ? 0 : 1),
673
+ value: d.value,
674
+ }));
675
+ }
676
+ else {
677
+ // sectioned but no delta means the profile already had the value — internal error
678
+ throw new Error(INTERNAL_REUSE(normalized));
679
+ }
680
+ }
681
+ else if (decision.kind === "variant" || decision.kind === "literal") {
682
+ // Check if this is updating an existing variant
683
+ if (isOwnVariant) {
684
+ outcome = "variant-updated";
685
+ }
686
+ else {
687
+ outcome = "variant";
688
+ }
689
+ // For variant outcomes, track what was flattened
690
+ flattenedParams = [...baseCitedKeys].sort();
691
+ flattenedSections = baseSectionNames;
692
+ }
693
+ else {
694
+ // literal — shouldn't happen with no others, treat as variant
695
+ outcome = isOwnVariant ? "variant-updated" : "variant";
696
+ flattenedParams = [...baseCitedKeys].sort();
697
+ flattenedSections = baseSectionNames;
698
+ }
699
+ // D12: check workspace overrides before variant outcomes (not for params/sections)
700
+ if (outcome === "variant" || outcome === "variant-updated") {
701
+ // Check if the workspace sets a section of this ingredient
702
+ const wsIngSections = ctx.WS[ingKey];
703
+ if (wsIngSections) {
704
+ const firstName = Object.keys(wsIngSections).sort()[0];
705
+ if (firstName) {
706
+ throw new Error(D12_SECTION(ingredientRef, firstName));
707
+ }
708
+ }
709
+ // Check if the workspace sets a param the body cites
710
+ const wsCitedParam = [...baseCitedKeys].sort().find((k) => Object.hasOwn(ctx.W, k));
711
+ if (wsCitedParam) {
712
+ throw new Error(D12_PARAM(ingredientRef, wsCitedParam));
713
+ }
714
+ }
715
+ // D10: another profile resolves that variant (only for variant-updated)
716
+ if (outcome === "variant-updated") {
717
+ for (const [q] of forge.profiles) {
718
+ if (q !== profile && resolvedBy(forge, q).has(variantRef)) {
719
+ throw new Error(D10(variantRef, q));
720
+ }
721
+ }
722
+ }
723
+ // D11: marker check (only for variant outcomes)
724
+ if (outcome === "variant" || outcome === "variant-updated") {
725
+ const marker = markerIn(sourceMeta, emitted.files, normalized);
726
+ if (marker)
727
+ throw new Error(D11(marker.where, marker.line));
728
+ }
729
+ // For params/sections outcomes, stage the profile edit
730
+ if (outcome === "params" || outcome === "sections") {
731
+ const profileAbs = await findProfileFile(forgeDir, profile);
732
+ if (!profileAbs)
733
+ throw new Error(`profile ${profile} not found`);
734
+ // D14 check: profile file must be held by git
735
+ const unheld = await gitUnheld(forgeDir, [profileAbs]);
736
+ if (unheld.length > 0) {
737
+ throw new UnheldError(realForgeDir, unheld);
738
+ }
739
+ const raw = await fs.readFile(profileAbs, "utf8");
740
+ const label = path.relative(forgeDir, profileAbs).split(path.sep).join("/");
741
+ const editKeys = outcome === "params" ? ["params"] : ["sections"];
742
+ const content = editYamlText(raw, { command: "drift promote", label, keys: editKeys }, (doc) => {
743
+ if (outcome === "params") {
744
+ for (const p of paramsChanged) {
745
+ doc.setIn(["params", p.key], p.value);
746
+ }
747
+ }
748
+ else {
749
+ for (const s of sectionsChanged) {
750
+ doc.setIn(["sections", s.key, s.name], s.value);
751
+ }
752
+ }
753
+ });
754
+ stage.write(profileAbs, content);
755
+ const editedKeys = new Map();
756
+ editedKeys.set(label, editKeys);
757
+ // Check for symlinks in staged paths before any write
758
+ await checkStagedSymlinks(forgeDir, stage);
759
+ // Build proof in scratch copy
760
+ const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), "craftar-promote-"));
761
+ try {
762
+ const proof = await provePromote({
763
+ forgeDir, scratchDir, stage, root, p0, lock, E, EPaths, ingredientRef, meta, N,
764
+ });
765
+ const { scratchForge, p1, dependentPaths, otherFilesUnchanged, stScratch } = proof;
766
+ // Impact — check other workspaces (step 30h)
767
+ const thisWsRealPath = await fs.realpath(root);
768
+ // For params/sections, the override check is not needed (the params/sections are going into the profile)
769
+ // So we pass empty baseCitedKeys and baseSectionNames to skip the override check
770
+ const impactResult = await checkOtherWorkspaces({
771
+ home,
772
+ forgeDir,
773
+ env,
774
+ thisWsRealPath,
775
+ forge,
776
+ scratchForge,
777
+ profile,
778
+ outcome,
779
+ ingredientRef,
780
+ variantRef: null, // params/sections don't create a variant
781
+ ingredientName: N,
782
+ baseCitedKeys: new Set(), // params/sections don't trigger override check
783
+ baseSectionNames: [], // params/sections don't trigger override check
784
+ ingKey,
785
+ });
786
+ return {
787
+ outcome,
788
+ promoted: null,
789
+ profile,
790
+ inputPath: normalized,
791
+ entries: (await stage.entries()).map((e) => ({ rel: e.rel, created: e.created })),
792
+ editedKeys,
793
+ params: paramsChanged,
794
+ sections: sectionsChanged,
795
+ flattened: { params: [], sections: [] },
796
+ dependents: [...dependentPaths],
797
+ otherFilesUnchanged,
798
+ nextSync: stScratch,
799
+ nextUnset: unsetDeclared(p1),
800
+ impact: { registry: impactResult.state, workspaces: impactResult.rows },
801
+ warnings: [...warnings, ...impactResult.warnings],
802
+ stage,
803
+ forgeRoot: realForgeDir,
804
+ mustHold: [profileAbs],
805
+ creates: [],
806
+ workspaceRealPath: thisWsRealPath,
807
+ ingredientRef,
808
+ isUrlForge: forgeKind === "url",
809
+ };
810
+ }
811
+ finally {
812
+ await fs.rm(scratchDir, { recursive: true, force: true });
813
+ }
814
+ }
815
+ // Build the variant ingredient — strip params for flattened variants
816
+ const variantMeta = {
817
+ ...validateImported(sourceMeta),
818
+ name: variantName,
819
+ as: N,
820
+ origin: {
821
+ workspace: path.basename(root),
822
+ path: meta.type === "skill" && meta.layout === "dir" ? `.claude/skills/${N}/` : normalized,
823
+ },
824
+ };
825
+ // Remove params from variant (they are flattened into the body)
826
+ if ("params" in variantMeta)
827
+ delete variantMeta.params;
828
+ // Stage the variant files using typeFolder
829
+ const folder = typeFolder(meta.type);
830
+ const variantDir = path.join(forgeDir, "ingredients", folder, variantName);
831
+ const variantDirRel = `ingredients/${folder}/${variantName}`;
832
+ // For variant-updated, we don't check D13 — we're updating the existing directory
833
+ if (outcome === "variant") {
834
+ // D13 early check: variant directory must not already exist
835
+ if (await exists(variantDir)) {
836
+ throw new Error(D13_EXISTS(variantDirRel));
837
+ }
838
+ }
839
+ const ingredientYaml = YAML.stringify(variantMeta, { lineWidth: 0 });
840
+ const ingredientYamlPath = path.join(variantDir, "ingredient.yaml");
841
+ // For variant-updated, only stage ingredient.yaml if it changed
842
+ if (outcome === "variant") {
843
+ stage.write(ingredientYamlPath, ingredientYaml);
844
+ }
845
+ else {
846
+ // variant-updated: compare with existing file, only write if different
847
+ let shouldWrite = true;
848
+ try {
849
+ const existingYaml = await fs.readFile(ingredientYamlPath, "utf8");
850
+ // Compare parsed YAML to ignore formatting differences
851
+ const label = path.relative(forgeDir, ingredientYamlPath).split(path.sep).join("/");
852
+ const existingParsed = parseYamlText(label, existingYaml);
853
+ const newParsed = parseYamlText(label, ingredientYaml);
854
+ // Deep compare the objects
855
+ if (isDeepStrictEqual(existingParsed, newParsed)) {
856
+ shouldWrite = false;
857
+ }
858
+ }
859
+ catch {
860
+ // File doesn't exist or can't be read — write it
861
+ }
862
+ if (shouldWrite) {
863
+ stage.write(ingredientYamlPath, ingredientYaml);
864
+ }
865
+ }
866
+ // For skills with dir layout, only write files that were planned (not hand-added ones)
867
+ const plannedRelPaths = meta.type === "skill" && meta.layout === "dir"
868
+ ? new Set(E.map((f) => f.path.replace(/^\.claude\/skills\/[^/]+\//, "")))
869
+ : null;
870
+ for (const [fileRel, content] of Object.entries(emitted.files)) {
871
+ if (plannedRelPaths && !plannedRelPaths.has(fileRel))
872
+ continue; // skip hand-added files
873
+ stage.write(path.join(variantDir, fileRel), content);
874
+ }
875
+ // Step 6: Recipe placement (§4.5)
876
+ // For variant-updated, no recipe changes needed
877
+ const viaRecipes = X.via;
878
+ const editedKeys = new Map();
879
+ const profileRecipes = forge.profiles.get(profile)?.recipes ?? [];
880
+ // The ref we're replacing in recipes: X.ref (not T/N, because X might be another profile's variant)
881
+ const refToReplace = X.ref;
882
+ if (outcome === "variant") {
883
+ // Check which recipes need to be edited or forked
884
+ for (const R of viaRecipes) {
885
+ // Check if this profile owns the recipe (resolves it in its own layer)
886
+ const profileOwns = recipesOf(forge, profile).has(R);
887
+ // Check if any other profile resolves it
888
+ let otherOwns = false;
889
+ for (const [q] of forge.profiles) {
890
+ if (q !== profile && recipesOf(forge, q).has(R)) {
891
+ otherOwns = true;
892
+ break;
893
+ }
894
+ }
895
+ // Find the extends chain to R
896
+ const extendsChain = findExtendsChain(forge, p0.resolution.recipes, R);
897
+ // Check if R is in the profile's recipes list
898
+ const inProfileRecipes = profileRecipes.includes(R);
899
+ // Check if any other resolved recipe extends R
900
+ const extendedByOther = isExtendedByOtherResolved(forge, p0.resolution.recipes, R);
901
+ if (profileOwns && !otherOwns) {
902
+ // Case 1: profile owns it alone — edit recipe in place
903
+ const recipeOpts = { stage, dir: path.join(forgeDir, "recipes") };
904
+ const recFile = await recipeFile(recipeOpts, R, "drift promote");
905
+ // D14 check: recipe file must be held by git
906
+ const unheld = await gitUnheld(forgeDir, [recFile]);
907
+ if (unheld.length > 0) {
908
+ throw new UnheldError(realForgeDir, unheld);
909
+ }
910
+ // Edit recipe in place
911
+ const raw = await fs.readFile(recFile, "utf8");
912
+ const label = `recipes/${R}.yaml`;
913
+ const content = editYamlText(raw, { command: "drift promote", label, keys: ["ingredients"] }, (doc) => {
914
+ const seq = doc.get("ingredients", true);
915
+ if (!YAML.isSeq(seq))
916
+ return;
917
+ for (const item of seq.items) {
918
+ if (YAML.isScalar(item) && item.value === refToReplace) {
919
+ item.value = variantRef;
920
+ }
921
+ }
922
+ });
923
+ stage.write(recFile, content);
924
+ editedKeys.set(`recipes/${R}.yaml`, ["ingredients"]);
925
+ }
926
+ else if (inProfileRecipes && !extendedByOther) {
927
+ // Case 2: R is in profile's recipes but also used by others, and no other resolved recipe extends R
928
+ // Fork the recipe as R--<p> and edit the profile's recipes list
929
+ const forkedRecipeName = `${R}--${profile}`;
930
+ const forkedRecipeFile = path.join(forgeDir, "recipes", `${forkedRecipeName}.yaml`);
931
+ const forkedRecipeRel = `recipes/${forkedRecipeName}.yaml`;
932
+ // D13: check the forked recipe doesn't already exist
933
+ if (await exists(forkedRecipeFile)) {
934
+ throw new Error(D13_EXISTS(forkedRecipeRel));
935
+ }
936
+ // Check if ignored
937
+ const ignored = await gitIgnored(forgeDir, forkedRecipeRel);
938
+ if (ignored) {
939
+ throw new Error(D13_IGNORED(forkedRecipeRel));
940
+ }
941
+ // Read the original recipe and create the forked one
942
+ const recipeOpts = { stage, dir: path.join(forgeDir, "recipes") };
943
+ const origRecFile = await recipeFile(recipeOpts, R, "drift promote");
944
+ const origRaw = await fs.readFile(origRecFile, "utf8");
945
+ // Edit the forked recipe: change name and swap the ingredient
946
+ const forkedContent = editYamlText(origRaw, { command: "drift promote", label: forkedRecipeRel, keys: ["name", "ingredients"] }, (doc) => {
947
+ doc.set("name", forkedRecipeName);
948
+ const seq = doc.get("ingredients", true);
949
+ if (!YAML.isSeq(seq))
950
+ return;
951
+ for (const item of seq.items) {
952
+ if (YAML.isScalar(item) && item.value === refToReplace) {
953
+ item.value = variantRef;
954
+ }
955
+ }
956
+ });
957
+ stage.write(forkedRecipeFile, forkedContent);
958
+ // Edit the profile's recipes list to replace R with R--<p>
959
+ const profileFile = await findProfileFile(forgeDir, profile);
960
+ if (!profileFile)
961
+ throw new Error(`profile ${profile} not found`);
962
+ // D14 check: profile file must be held by git
963
+ const unheld = await gitUnheld(forgeDir, [profileFile]);
964
+ if (unheld.length > 0) {
965
+ throw new UnheldError(realForgeDir, unheld);
966
+ }
967
+ const profileRaw = await stage.readText(profileFile);
968
+ const profileLabel = path.relative(forgeDir, profileFile).split(path.sep).join("/");
969
+ const newProfileContent = editYamlText(profileRaw, { command: "drift promote", label: profileLabel, keys: ["recipes"] }, (doc) => {
970
+ const seq = doc.get("recipes", true);
971
+ if (!YAML.isSeq(seq))
972
+ return;
973
+ for (const item of seq.items) {
974
+ if (YAML.isScalar(item) && item.value === R) {
975
+ item.value = forkedRecipeName;
976
+ }
977
+ }
978
+ });
979
+ stage.write(profileFile, newProfileContent);
980
+ editedKeys.set(profileLabel, ["recipes"]);
981
+ }
982
+ else {
983
+ // Case 3: D17 — recipe reaches through extends or recipes.add, or case 2 applies but another resolved recipe extends R
984
+ let chain;
985
+ const wsAdds = ws.config.recipes?.add ?? [];
986
+ if (wsAdds.includes(R)) {
987
+ chain = "recipes.add (craftar.yaml)";
988
+ }
989
+ else if (extendsChain) {
990
+ chain = extendsChain;
991
+ }
992
+ else {
993
+ chain = R;
994
+ }
995
+ throw new Error(D17(R, chain));
996
+ }
997
+ }
998
+ }
999
+ // For variant-updated, no recipe changes are needed
1000
+ // Step 7: Gate (D13, D14, symlinks)
1001
+ const entries = await stage.entries();
1002
+ const creates = entries.filter((e) => e.created).map((e) => e.rel);
1003
+ const mustHold = entries.filter((e) => !e.created).map((e) => e.abs);
1004
+ // Check for symlinks in staged paths before any write or further gate checks
1005
+ await checkStagedSymlinks(forgeDir, stage);
1006
+ // D13: check created paths don't exist and aren't ignored
1007
+ for (const e of entries.filter((e) => e.created)) {
1008
+ // For the variant directory, check if it exists
1009
+ if (e.rel.includes("/ingredient.yaml")) {
1010
+ const varDirRel = e.rel.replace("/ingredient.yaml", "");
1011
+ const varDirAbs = path.join(forgeDir, varDirRel);
1012
+ if (await exists(varDirAbs)) {
1013
+ throw new Error(D13_EXISTS(varDirRel));
1014
+ }
1015
+ }
1016
+ // Check if ignored
1017
+ const ignored = await gitIgnored(forgeDir, e.rel);
1018
+ if (ignored) {
1019
+ throw new Error(D13_IGNORED(e.rel));
1020
+ }
1021
+ }
1022
+ // D14: gitUnheld check
1023
+ if (mustHold.length > 0) {
1024
+ const unheld = await gitUnheld(forgeDir, mustHold);
1025
+ if (unheld.length > 0) {
1026
+ throw new UnheldError(realForgeDir, unheld);
1027
+ }
1028
+ }
1029
+ // Step 8: The proof
1030
+ const scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), "craftar-promote-"));
1031
+ try {
1032
+ const proof = await provePromote({
1033
+ forgeDir, scratchDir, stage, root, p0, lock, E, EPaths, ingredientRef, meta, N,
1034
+ });
1035
+ const { scratchForge, p1, dependentPaths, otherFilesUnchanged, stScratch } = proof;
1036
+ // Variant-specific check: X.ref should not be in p1's resolution (only for new variants)
1037
+ // For variant-updated, ingredientRef === variantRef, so it SHOULD still be there
1038
+ if (outcome === "variant") {
1039
+ const p1Refs = new Set(p1.resolution.ingredients.map((i) => i.ref));
1040
+ if (p1Refs.has(ingredientRef)) {
1041
+ throw new Error(D15(`${ingredientRef} would still resolve beside ${variantRef}`));
1042
+ }
1043
+ }
1044
+ // Impact: check for other workspaces (step 30h)
1045
+ const thisWsRealPath = await fs.realpath(root);
1046
+ const impactResult = await checkOtherWorkspaces({
1047
+ home,
1048
+ forgeDir,
1049
+ env,
1050
+ thisWsRealPath,
1051
+ forge,
1052
+ scratchForge,
1053
+ profile,
1054
+ outcome,
1055
+ ingredientRef,
1056
+ variantRef,
1057
+ ingredientName: N,
1058
+ baseCitedKeys,
1059
+ baseSectionNames,
1060
+ ingKey,
1061
+ });
1062
+ // Add warning for profile section values that are now unused by the variant
1063
+ if (flattenedSections.length > 0) {
1064
+ const profileSections = forge.profiles.get(profile)?.sections ?? {};
1065
+ const profileIngSections = profileSections[ingKey] ?? {};
1066
+ for (const name of flattenedSections) {
1067
+ if (Object.hasOwn(profileIngSections, name)) {
1068
+ warnings.push(`profile ${profile} still sets section ${ingKey} ${name}, which ${variantRef} no longer holds — every plan will warn until it is removed`);
1069
+ }
1070
+ }
1071
+ }
1072
+ return {
1073
+ outcome,
1074
+ promoted: variantRef,
1075
+ profile,
1076
+ inputPath: normalized,
1077
+ entries: entries.map((e) => ({ rel: e.rel, created: e.created })),
1078
+ editedKeys,
1079
+ params: [],
1080
+ sections: [],
1081
+ flattened: { params: flattenedParams, sections: flattenedSections },
1082
+ dependents: [...dependentPaths],
1083
+ otherFilesUnchanged,
1084
+ nextSync: stScratch,
1085
+ nextUnset: unsetDeclared(p1),
1086
+ impact: { registry: impactResult.state, workspaces: impactResult.rows },
1087
+ warnings: [...warnings, ...impactResult.warnings],
1088
+ stage,
1089
+ forgeRoot: realForgeDir,
1090
+ mustHold,
1091
+ creates,
1092
+ workspaceRealPath: thisWsRealPath,
1093
+ ingredientRef,
1094
+ isUrlForge: forgeKind === "url",
1095
+ };
1096
+ }
1097
+ finally {
1098
+ await fs.rm(scratchDir, { recursive: true, force: true });
1099
+ }
1100
+ }
1101
+ /**
1102
+ * Apply a promote plan: write the staged files to the Forge.
1103
+ * Order: ingredient files, then recipes, then profile.yaml last.
1104
+ */
1105
+ export async function applyPromote(plan, journal) {
1106
+ // Sort entries: ingredient files first, then recipes
1107
+ const orderOf = (rel) => {
1108
+ if (rel.startsWith("ingredients/"))
1109
+ return 0;
1110
+ if (rel.startsWith("recipes/"))
1111
+ return 1;
1112
+ if (rel.startsWith("profiles/"))
1113
+ return 2;
1114
+ return 3;
1115
+ };
1116
+ const order = (a, b) => orderOf(a) - orderOf(b) || a.localeCompare(b);
1117
+ await plan.stage.flushTo(plan.forgeRoot, { order, journal });
1118
+ }
1119
+ // Helper functions
1120
+ function emittedMetaFields(emitted, type) {
1121
+ // Only re-read certain fields from the emitted source
1122
+ if (type === "agent") {
1123
+ return {
1124
+ description: emitted.description,
1125
+ tools: emitted.tools,
1126
+ model: emitted.model,
1127
+ frontmatterRaw: emitted.frontmatterRaw,
1128
+ };
1129
+ }
1130
+ if (type === "command") {
1131
+ return {
1132
+ description: emitted.description,
1133
+ argumentHint: emitted.argumentHint,
1134
+ allowedTools: emitted.allowedTools,
1135
+ frontmatterRaw: emitted.frontmatterRaw,
1136
+ };
1137
+ }
1138
+ return {};
1139
+ }
1140
+ /**
1141
+ * Find the extends chain from a resolved recipe to the target recipe.
1142
+ * Returns the chain like "target → child" if found, null otherwise.
1143
+ */
1144
+ function findExtendsChain(forge, resolvedRecipes, targetRecipe) {
1145
+ // Look for a resolved recipe that extends the target
1146
+ for (const name of resolvedRecipes) {
1147
+ const r = forge.recipes.get(name);
1148
+ if (r?.extends?.includes(targetRecipe)) {
1149
+ return `${name} → ${targetRecipe}`;
1150
+ }
1151
+ }
1152
+ return null;
1153
+ }
1154
+ /**
1155
+ * Check if any other resolved recipe extends the given recipe.
1156
+ */
1157
+ function isExtendedByOtherResolved(forge, resolvedRecipes, R) {
1158
+ for (const name of resolvedRecipes) {
1159
+ if (name === R)
1160
+ continue;
1161
+ const r = forge.recipes.get(name);
1162
+ if (r?.extends?.includes(R))
1163
+ return true;
1164
+ }
1165
+ return false;
1166
+ }
1167
+ /**
1168
+ * Check if AGENTS.md changes are within the bound for rule N (spec 30 §4.6 item 4).
1169
+ * Returns true if the change is acceptable, false if it would be D15.
1170
+ */
1171
+ export function agentsBound(p0Text, p1Text, N) {
1172
+ // Normalize both to LF before comparing (spec 30r1 §2.1)
1173
+ const p0 = toLf(p0Text);
1174
+ const p1 = toLf(p1Text);
1175
+ const markerRe = /^<!-- rule: (.+) -->$/;
1176
+ // If p0 has no marker for N, p1 must be byte-equal
1177
+ const markerLine = `<!-- rule: ${N} -->`;
1178
+ if (!p0.includes(markerLine)) {
1179
+ return p0 === p1;
1180
+ }
1181
+ // Find the boundaries in p0
1182
+ const p0Lines = p0.split("\n");
1183
+ const p1Lines = p1.split("\n");
1184
+ // Find the marker line for N in p0
1185
+ let markerIdx = -1;
1186
+ for (let i = 0; i < p0Lines.length; i++) {
1187
+ if (p0Lines[i] === markerLine) {
1188
+ markerIdx = i;
1189
+ break;
1190
+ }
1191
+ }
1192
+ if (markerIdx === -1)
1193
+ return p0 === p1;
1194
+ // Find the next boundary in p0: next <!-- rule: ... --> or ## Scoped rules, or end
1195
+ let boundaryIdx = p0Lines.length;
1196
+ for (let i = markerIdx + 1; i < p0Lines.length; i++) {
1197
+ if (markerRe.test(p0Lines[i]) || p0Lines[i] === "## Scoped rules") {
1198
+ boundaryIdx = i;
1199
+ break;
1200
+ }
1201
+ }
1202
+ // p1 must equal p0 up to and including the marker line
1203
+ const prefixEnd = markerIdx + 1;
1204
+ for (let i = 0; i < prefixEnd; i++) {
1205
+ if (p0Lines[i] !== p1Lines[i])
1206
+ return false;
1207
+ }
1208
+ // p1 must equal p0 from the boundary to the end
1209
+ const p0Suffix = p0Lines.slice(boundaryIdx).join("\n");
1210
+ // The suffix in p1 starts at the same distance from the end as in p0
1211
+ const suffixLen = p0Lines.length - boundaryIdx;
1212
+ if (p1Lines.length < suffixLen)
1213
+ return false;
1214
+ const p1SuffixStart = p1Lines.length - suffixLen;
1215
+ const p1SuffixLines = p1Lines.slice(p1SuffixStart);
1216
+ return p0Suffix === p1SuffixLines.join("\n");
1217
+ }
1218
+ //# sourceMappingURL=drift-promote.js.map