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.
- package/README.md +13 -1
- package/dist/cli.js +345 -41
- package/dist/cli.js.map +1 -1
- package/dist/core/drift.js +29 -0
- package/dist/core/drift.js.map +1 -0
- package/dist/core/forge.js +34 -3
- package/dist/core/forge.js.map +1 -1
- package/dist/core/frontmatter.js +2 -2
- package/dist/core/frontmatter.js.map +1 -1
- package/dist/core/impact.js +14 -11
- package/dist/core/impact.js.map +1 -1
- package/dist/core/manifest-edit.js +4 -5
- package/dist/core/manifest-edit.js.map +1 -1
- package/dist/core/param-writes.js +6 -7
- package/dist/core/param-writes.js.map +1 -1
- package/dist/core/sync.js +11 -3
- package/dist/core/sync.js.map +1 -1
- package/dist/core/unify.js +6 -5
- package/dist/core/unify.js.map +1 -1
- package/dist/core/workspace-yaml.js +2 -10
- package/dist/core/workspace-yaml.js.map +1 -1
- package/dist/core/yaml-read.js +32 -0
- package/dist/core/yaml-read.js.map +1 -0
- package/dist/importers/claude-code.js +191 -94
- package/dist/importers/claude-code.js.map +1 -1
- package/dist/importers/decide.js +4 -4
- package/dist/importers/decide.js.map +1 -1
- package/dist/importers/drift-promote.js +1218 -0
- package/dist/importers/drift-promote.js.map +1 -0
- package/package.json +1 -1
|
@@ -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
|