craftar 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,13 @@ import { exists, listFiles, typeFolder, FORGE_MANIFEST } from "../core/forge.js"
6
6
  import { decodeForScan, findSecrets, hasUtf16Bom, secretValueKind } from "../core/secrets.js";
7
7
  import { stripBom, toLf } from "../core/text.js";
8
8
  import { fingerprintDir, fingerprintOf } from "../core/fingerprint.js";
9
- import { IngredientSchema } from "../schema/index.js";
9
+ import { IngredientSchema, ProfileSchema, RecipeSchema, WorkspaceConfigSchema } from "../schema/index.js";
10
+ import { isDeepStrictEqual } from "node:util";
11
+ import { resolve } from "../core/resolve.js";
12
+ import { editYamlText } from "../core/yaml-edit.js";
13
+ import { resolvedBy } from "../core/param-writes.js";
14
+ import { decide, forgeBefore, pin, sourceKeys, workspaceParams } from "./decide.js";
15
+ import { renderMap } from "../core/template-import.js";
10
16
  /** Rules that every workspace shares by intent — they seed the `base` recipe. */
11
17
  const BASE_RULES = new Set([
12
18
  "README",
@@ -38,28 +44,42 @@ export async function importClaudeCode(opts) {
38
44
  throw new Error(`${e instanceof Error ? e.message : String(e)}\nThe Forge was left untouched.`, { cause: e });
39
45
  }
40
46
  await stage.flush();
41
- const { report, targets } = planned;
42
- if (opts.writeWorkspaceConfig) {
43
- const ws = path.resolve(opts.workspaceRoot);
44
- const rel = path.relative(ws, stage.root).replace(/\\/g, "/") || ".";
45
- await fs.writeFile(path.join(ws, "craftar.yaml"), YAML.stringify({ forge: rel, profile: opts.profileName, targets }));
46
- report.created.push("craftar.yaml (workspace)");
47
+ const { report, config } = planned;
48
+ if (config && config.action !== "unchanged") {
49
+ try {
50
+ await fs.writeFile(config.abs, config.content);
51
+ }
52
+ catch (e) {
53
+ throw new Error(`The Forge was written in full and the import succeeded; writing craftar.yaml failed (${e instanceof Error ? e.message : String(e)}) — ` +
54
+ `fix the cause and re-run with --write-config, or edit craftar.yaml by hand.`, { cause: e });
55
+ }
47
56
  }
57
+ if (config?.action === "created")
58
+ report.created.push("craftar.yaml (workspace)");
48
59
  return report;
49
60
  }
50
61
  async function planImport(opts, stage) {
51
62
  const ws = path.resolve(opts.workspaceRoot);
52
63
  const forge = stage.root;
53
- const report = { created: [], reused: [], variants: [], rejected: [], recipes: [], profile: opts.profileName, warnings: [] };
64
+ const report = {
65
+ created: [], reused: [], variants: [], rejected: [], recipes: [], profile: opts.profileName, warnings: [], rendered: [], inferred: [], params: [], recipeSplits: [],
66
+ profileWrite: { path: `profiles/${opts.profileName}/profile.yaml`, action: "unchanged", fields: [] },
67
+ configWrite: null,
68
+ };
54
69
  const claudeDir = path.join(ws, ".claude");
55
70
  if (!(await exists(claudeDir)))
56
71
  throw new Error(`${claudeDir} not found — is this a Claude Code workspace?`);
57
72
  const manifest = path.join(forge, FORGE_MANIFEST);
73
+ const manifestOnDisk = manifest;
58
74
  if (!(await stage.exists(manifest))) {
59
75
  stage.write(manifest, YAML.stringify({ name: path.basename(forge), schema: 1, description: "Craftar Forge — shared harness ingredients, recipes and client profiles." }));
60
76
  report.created.push(FORGE_MANIFEST);
61
77
  }
62
78
  const origin = (rel) => ({ workspace: path.basename(ws), path: rel.replace(/\\/g, "/") });
79
+ // Two passes (spec 10 §6.8): every source is read first, in today's order, then decided in that
80
+ // order. `after` does the bookkeeping today's code did right after each decision.
81
+ const queue = [];
82
+ const read = (meta, files, scan, after, skip) => queue.push({ meta, files, scan, after, skip });
63
83
  const refs = { base: [], stacks: new Map(), steering: [] };
64
84
  const ruleNames = [];
65
85
  const scopedRules = new Map(); // rule name → fileMatchPattern
@@ -67,7 +87,7 @@ async function planImport(opts, stage) {
67
87
  const steeringDir = path.join(ws, ".kiro", "steering");
68
88
  const steeringMeta = new Map();
69
89
  if (await exists(steeringDir)) {
70
- for (const f of await fs.readdir(steeringDir)) {
90
+ for (const f of (await fs.readdir(steeringDir)).sort()) {
71
91
  if (!f.endsWith(".md"))
72
92
  continue;
73
93
  const src = await readSource(path.join(steeringDir, f));
@@ -100,14 +120,13 @@ async function planImport(opts, stage) {
100
120
  tags: [],
101
121
  origin: origin(`.claude/rules/${f}`),
102
122
  };
103
- const ref = await writeIngredient(stage, meta, { "rule.md": text }, opts.profileName, report, scanOf("rule.md", src));
104
- if (!ref)
105
- continue;
106
- ruleNames.push(ref.split("/")[1]);
107
- if (meta.inclusion === "fileMatch")
108
- scopedRules.set(ref, sm?.fileMatchPattern ?? "");
109
- else
110
- refs.base.push(ref);
123
+ read(meta, { "rule.md": text }, scanOf("rule.md", src), (ref) => {
124
+ ruleNames.push(ref.split("/")[1]);
125
+ if (meta.inclusion === "fileMatch")
126
+ scopedRules.set(ref, sm?.fileMatchPattern ?? "");
127
+ else
128
+ refs.base.push(ref);
129
+ });
111
130
  }
112
131
  /* ---- agents ---- */
113
132
  const agentBodies = new Map();
@@ -130,11 +149,10 @@ async function planImport(opts, stage) {
130
149
  tags: [],
131
150
  origin: origin(`.claude/agents/${f}`),
132
151
  };
133
- const ref = await writeIngredient(stage, meta, { "agent.md": body }, opts.profileName, report, scanOf("agent.md", src));
134
- if (!ref)
135
- continue;
136
- agentBodies.set(ref, (data.description ?? "") + "\n" + body);
137
- refs.base.push(ref);
152
+ read(meta, { "agent.md": body }, scanOf("agent.md", src), (ref) => {
153
+ agentBodies.set(ref, (data.description ?? "") + "\n" + body);
154
+ refs.base.push(ref);
155
+ });
138
156
  }
139
157
  /* ---- commands ---- */
140
158
  for (const f of await safeList(path.join(claudeDir, "commands"))) {
@@ -155,12 +173,13 @@ async function planImport(opts, stage) {
155
173
  tags: [],
156
174
  origin: origin(`.claude/commands/${f}`),
157
175
  };
158
- addRef(refs.base, await writeIngredient(stage, meta, { "command.md": body }, opts.profileName, report, scanOf("command.md", src)));
176
+ read(meta, { "command.md": body }, scanOf("command.md", src), (ref) => addRef(refs.base, ref));
159
177
  }
160
178
  /* ---- skills ---- */
161
179
  const skillsDir = path.join(claudeDir, "skills");
162
180
  if (await exists(skillsDir)) {
163
- for (const e of await fs.readdir(skillsDir, { withFileTypes: true })) {
181
+ // Sorted, so the decision order — and which of two sources is "the later one" — is reproducible (spec 10 §14 Q6).
182
+ for (const e of (await fs.readdir(skillsDir, { withFileTypes: true })).sort((x, y) => x.name.localeCompare(y.name))) {
164
183
  if (e.name === ".gitkeep")
165
184
  continue;
166
185
  if (e.isDirectory()) {
@@ -181,13 +200,13 @@ async function planImport(opts, stage) {
181
200
  continue;
182
201
  }
183
202
  const meta = { type: "skill", name: e.name, layout: "dir", targets: "*", tags: [], origin: origin(`.claude/skills/${e.name}/`) };
184
- addRef(refs.base, await writeIngredient(stage, meta, files, opts.profileName, report, scan));
203
+ read(meta, files, scan, (ref) => addRef(refs.base, ref));
185
204
  }
186
205
  else if (e.name.endsWith(".md")) {
187
206
  const src = await readSource(path.join(skillsDir, e.name));
188
207
  const text = toLf(stripBom(src.text));
189
208
  const meta = { type: "skill", name: e.name.replace(/\.md$/, ""), layout: "file", targets: "*", tags: [], origin: origin(`.claude/skills/${e.name}`) };
190
- addRef(refs.base, await writeIngredient(stage, meta, { "SKILL.md": text }, opts.profileName, report, scanOf("SKILL.md", src)));
209
+ read(meta, { "SKILL.md": text }, scanOf("SKILL.md", src), (ref) => addRef(refs.base, ref));
191
210
  }
192
211
  }
193
212
  }
@@ -209,7 +228,7 @@ async function planImport(opts, stage) {
209
228
  tags: [],
210
229
  origin: origin(`.claude/${kind}/${f}`),
211
230
  };
212
- addRef(refs.base, await writeIngredient(stage, meta, { [f]: content }, opts.profileName, report, src ? scanOf(f, src) : {}));
231
+ read(meta, { [f]: content }, src ? scanOf(f, src) : {}, (ref) => addRef(refs.base, ref));
213
232
  }
214
233
  }
215
234
  /* ---- MCP servers ---- */
@@ -232,22 +251,45 @@ async function planImport(opts, stage) {
232
251
  throw new Error(`.mcp.json server "${name}" is not an object — fix the file and re-run import`);
233
252
  }
234
253
  const meta = { type: "mcp", name, server, targets: "*", tags: [], origin: origin(".mcp.json") };
235
- addRef(refs.base, await writeIngredient(stage, meta, {}, opts.profileName, report));
254
+ read(meta, {}, {}, (ref) => addRef(refs.base, ref));
236
255
  }
237
256
  }
238
257
  /* ---- hand-written Kiro steering (no Claude counterpart) ---- */
258
+ // Whether a steering file is hand-written depends on the rules decided before it, so the check runs at decision time.
239
259
  for (const [name, sm] of steeringMeta) {
240
- if (ruleNames.includes(name) || sm.generated)
241
- continue;
242
- if (name === "commands")
243
- continue;
244
260
  const meta = { type: "steering", name, file: "steering.md", targets: ["kiro"], tags: ["client"], origin: origin(`.kiro/steering/${name}.md`) };
245
- addRef(refs.steering, await writeIngredient(stage, meta, { "steering.md": sm.text }, opts.profileName, report, scanOf("steering.md", sm)));
261
+ read(meta, { "steering.md": sm.text }, scanOf("steering.md", sm), (ref) => addRef(refs.steering, ref), () => ruleNames.includes(name) || sm.generated || name === "commands");
262
+ }
263
+ /* ---- what the importing profile renders (spec 10 §6.1, §6.8 step 2) ---- */
264
+ const loaded = await forgeBefore(forge, manifestOnDisk);
265
+ const ctx = {
266
+ P: Object.assign(Object.create(null), await existingProfileParams(forge, opts.profileName, loaded)),
267
+ W: await workspaceParams(ws, (abs) => fs.readFile(abs, "utf8")),
268
+ pinned: new Map(),
269
+ forge: loaded,
270
+ };
271
+ const runBases = new Set(queue.map((q) => `${q.meta.type}/${q.meta.name}`));
272
+ const literal = [];
273
+ /* ---- decide, in read order ---- */
274
+ for (const src of queue) {
275
+ if (src.skip?.())
276
+ continue;
277
+ const others = queue.filter((q) => q !== src).map((q) => ({ ref: `${q.meta.type}/${q.meta.name}`, meta: q.meta, files: q.files }));
278
+ const ref = await writeIngredient(stage, src.meta, src.files, opts.profileName, report, src.scan, { ctx, others, runBases, literal });
279
+ if (ref)
280
+ src.after(ref);
281
+ }
282
+ // I3: a created or variant ingredient is the workspace text itself; a key the profile now sets would change it at sync.
283
+ for (const l of literal) {
284
+ const map = renderMap(l.meta, ctx.P, ctx.W);
285
+ const key = [...sourceKeys(l.meta, l.files)].find((k) => Object.hasOwn(map, k));
286
+ if (key)
287
+ throw new Error(`import: ${l.source} holds {{${key}}} literally, but profile ${opts.profileName} sets ${key} — sync would render it`);
246
288
  }
247
289
  /* ---- recipes ---- */
248
290
  const recipesDir = path.join(forge, "recipes");
249
291
  const isVariant = (ref) => report.variants.some((v) => v.name === ref);
250
- const suffixIf = (name, ingredients) => (ingredients.some(isVariant) ? `${name}--${opts.profileName}` : name);
292
+ const recipeOpts = { stage, dir: recipesDir, profile: opts.profileName, forge: ctx.forge, report, currentRules: currentRuleOrder(ctx.forge, opts.profileName) };
251
293
  const stackRecipes = [];
252
294
  for (const [ruleRef, pattern] of scopedRules) {
253
295
  const ruleName = ruleRef.split("/")[1].replace(/--.*$/, "");
@@ -255,17 +297,14 @@ async function planImport(opts, stage) {
255
297
  for (const a of agents)
256
298
  refs.base = refs.base.filter((r) => r !== a);
257
299
  const ingredients = [ruleRef, ...agents];
258
- const recipeName = suffixIf(`stack-${ruleName}`, ingredients);
259
- await writeRecipe(stage, recipesDir, { name: recipeName, description: `Conventions + reviewer for repos matching ${pattern}`, extends: [], ingredients, params: {} }, report);
260
- stackRecipes.push(recipeName);
300
+ stackRecipes.push(await placeRecipe(recipeOpts, `stack-${ruleName}`, ingredients, `Conventions + reviewer for repos matching ${pattern}`, ingredients.some(isVariant)));
261
301
  }
262
302
  const baseIngredients = unique(refs.base);
263
- const baseName = suffixIf("base", baseIngredients);
264
- await writeRecipe(stage, recipesDir, { name: baseName, description: "Always-on conventions, commands, agents, scripts and MCP servers.", extends: [], ingredients: baseIngredients, params: {} }, report);
303
+ const baseName = await placeRecipe(recipeOpts, "base", baseIngredients, "Always-on conventions, commands, agents, scripts and MCP servers.", baseIngredients.some(isVariant));
265
304
  const profileRecipes = [baseName, ...stackRecipes];
266
305
  if (refs.steering.length) {
267
306
  const n = `${opts.profileName}-steering`;
268
- await writeRecipe(stage, recipesDir, { name: n, description: `Hand-written Kiro steering specific to ${opts.profileName}.`, extends: [], ingredients: refs.steering, params: {} }, report);
307
+ await writeOwnedRecipe(recipeOpts, n, refs.steering, `Hand-written Kiro steering specific to ${opts.profileName}.`);
269
308
  profileRecipes.push(n);
270
309
  }
271
310
  /* ---- profile ---- */
@@ -284,12 +323,14 @@ async function planImport(opts, stage) {
284
323
  frontend: {},
285
324
  executor: (await exists(path.join(claudeDir, "rules", "kiro-execution.md"))) ? { kind: "kiro", terminal: "orca" } : {},
286
325
  integrations: {},
287
- params: {},
326
+ // A new profile holds the values this run inferred, in acceptance order (spec 10 §6.6).
327
+ params: Object.fromEntries(report.params.map((x) => [x.key, ctx.P[x.key]])),
288
328
  repos: [],
289
329
  };
290
- stage.write(path.join(forge, "profiles", opts.profileName, "profile.yaml"), YAML.stringify(profile));
291
- report.created.push(`profiles/${opts.profileName}/profile.yaml`);
292
- return { report, targets };
330
+ await writeProfile(stage, forge, opts.profileName, profile, ctx, report);
331
+ const config = opts.writeWorkspaceConfig ? await planConfig(ws, forge, opts.profileName, targets) : null;
332
+ report.configWrite = config?.action ?? null;
333
+ return { report, targets, config };
293
334
  }
294
335
  /**
295
336
  * The Forge as the import run sees it: the files on disk overlaid with the files this run has staged.
@@ -473,7 +514,7 @@ async function sniffScm(claudeDir) {
473
514
  * identical content → reuse; different content → write a `<name>--<profile>` variant and report it,
474
515
  * so the human decides whether to parameterize or keep a client-specific copy.
475
516
  */
476
- async function writeIngredient(stage, meta, files, profile, report, scan = {}) {
517
+ async function writeIngredient(stage, meta, files, profile, report, scan = {}, run) {
477
518
  const secret = secretIn(meta, files, scan);
478
519
  if (secret) {
479
520
  report.rejected.push({ name: `${meta.type}/${meta.name}`, reason: secret });
@@ -485,22 +526,55 @@ async function writeIngredient(stage, meta, files, profile, report, scan = {}) {
485
526
  let dir = path.join(forge, "ingredients", folder, name);
486
527
  // Hash what the Forge will load back, the way fingerprintDir hashes the other side.
487
528
  const fingerprint = fingerprintOf(validateImported(meta), files);
529
+ const ref = `${meta.type}/${name}`;
530
+ const source = meta.origin?.path ?? ref;
531
+ const sourceMeta = validateImported(meta);
488
532
  if (await stage.exists(path.join(dir, "ingredient.yaml"))) {
489
- const existing = await fingerprintDir(dir, stage.reader());
490
- if (existing === fingerprint) {
491
- report.reused.push(`${meta.type}/${name}`);
492
- return `${meta.type}/${name}`;
533
+ let why;
534
+ const d = run ? await decide(run.ctx, dir, stage.reader(), ref, sourceMeta, files, fingerprint, run.others, run.runBases) : { kind: "literal", warn: "" };
535
+ if (d.kind === "literal") {
536
+ if (d.warn)
537
+ report.warnings.push(d.warn);
538
+ if ((await fingerprintDir(dir, stage.reader())) === fingerprint) {
539
+ report.reused.push(ref);
540
+ return ref;
541
+ }
542
+ }
543
+ else if (d.kind === "reuse") {
544
+ report.reused.push(ref);
545
+ if (d.rendered)
546
+ report.rendered.push({ name: ref, keys: d.rendered });
547
+ if (d.inferred)
548
+ report.inferred.push({ name: ref, values: d.inferred });
549
+ for (const x of d.delta ?? [])
550
+ report.params.push({ ...x, from: ref });
551
+ return ref;
552
+ }
553
+ else {
554
+ why = d.why;
493
555
  }
494
556
  const as = meta.name;
495
557
  name = `${meta.name}--${profile}`;
496
558
  dir = path.join(forge, "ingredients", folder, name);
497
- report.variants.push({ name: `${meta.type}/${name}`, reason: `differs from ${meta.type}/${as} already in the Forge` });
559
+ report.variants.push({ name: `${meta.type}/${name}`, reason: `differs from ${meta.type}/${as} already in the Forge${why ? ` (${why})` : ""}` });
498
560
  meta = { ...meta, name, as };
499
561
  validateImported(meta); // the variant name must be slug-like too (a `--profile` with a space is not)
562
+ // I8: rewriting an existing variant another profile resolves would change its files there.
563
+ if (run?.ctx.forge && (await stage.exists(path.join(dir, "ingredient.yaml"))) && (await fingerprintDir(dir, stage.reader())) !== fingerprintOf(validateImported(meta), files)) {
564
+ for (const q of run.ctx.forge.profiles.keys()) {
565
+ if (q !== profile && resolvedBy(run.ctx.forge, q).has(`${meta.type}/${name}`)) {
566
+ throw new Error(`import: ${meta.type}/${name} is also used by profile ${q} — its files would change there`);
567
+ }
568
+ }
569
+ }
500
570
  }
501
571
  else {
502
572
  report.created.push(`${meta.type}/${name}`);
503
573
  }
574
+ if (run) {
575
+ pin(run.ctx, sourceKeys(sourceMeta, files), renderMap(sourceMeta, run.ctx.P, run.ctx.W));
576
+ run.literal.push({ ref: `${meta.type}/${name}`, source, meta: sourceMeta, files });
577
+ }
504
578
  const yamlMeta = { ...meta };
505
579
  if (yamlMeta.targets === "*")
506
580
  yamlMeta.targets = "*";
@@ -521,14 +595,226 @@ function validateImported(meta) {
521
595
  const source = meta.origin?.path ?? `${meta.type}/${meta.name}`;
522
596
  throw new Error(`${source} (${meta.type}/${meta.name}) does not fit the ingredient schema: ${r.error.message}`);
523
597
  }
524
- async function writeRecipe(stage, dir, recipe, report) {
525
- const file = path.join(dir, `${recipe.name}.yaml`);
526
- const clean = JSON.parse(JSON.stringify(recipe)); // drop undefined
527
- if (await stage.exists(file)) {
528
- const prev = YAML.parse(await stage.readText(file));
529
- clean.ingredients = unique([...(prev.ingredients ?? []), ...clean.ingredients]);
530
- }
531
- stage.write(file, YAML.stringify(clean));
532
- report.recipes.push(recipe.name);
598
+ function currentRuleOrder(forge, profile) {
599
+ if (!forge?.profiles.has(profile))
600
+ return null;
601
+ try {
602
+ return resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).ingredients.map((i) => i.ref).filter((r) => r.startsWith("rule/"));
603
+ }
604
+ catch {
605
+ return null;
606
+ }
607
+ }
608
+ const recipeText = (name, description, ingredients) => YAML.stringify(JSON.parse(JSON.stringify({ name, description, extends: [], ingredients, params: {} }))); // today's bytes
609
+ /**
610
+ * Where a shared recipe's computed list goes (spec 10 §6.7, Ruling 7): a shared recipe is never
611
+ * edited and never widened for one client. It is used only when its ingredients equal the list as
612
+ * a set — and, for a profile that already exists, in the same relative rule order, so AGENTS.md
613
+ * does not reorder (Q10). Otherwise the profile gets its own `<name>--<profile>`.
614
+ */
615
+ async function placeRecipe(o, name, list, description, holdsVariant) {
616
+ const owned = `${name}--${o.profile}`;
617
+ if (holdsVariant)
618
+ return writeOwnedRecipe(o, owned, list, description);
619
+ const file = path.join(o.dir, `${name}.yaml`);
620
+ if (!(await o.stage.exists(file))) {
621
+ o.stage.write(file, recipeText(name, description, list));
622
+ o.report.recipes.push(name);
623
+ return name;
624
+ }
625
+ const existing = RecipeSchema.parse(YAML.parse(stripBom(await o.stage.readText(file))) ?? {}).ingredients;
626
+ const rules = (xs) => xs.filter((x) => x.startsWith("rule/"));
627
+ const lacks = existing.find((x) => !list.includes(x));
628
+ const extra = list.find((x) => !existing.includes(x));
629
+ // Q10: an existing profile moves to R only if R orders its rules as the profile resolves them today, so AGENTS.md keeps its order.
630
+ const reorders = o.currentRules !== null && JSON.stringify(rules(existing)) !== JSON.stringify(o.currentRules.filter((r) => existing.includes(r)));
631
+ if (!lacks && !extra && !reorders) {
632
+ o.report.recipes.push(name);
633
+ return name;
634
+ }
635
+ const reason = lacks
636
+ ? `${name} lists ${lacks}, which this workspace lacks`
637
+ : extra
638
+ ? `this workspace has ${extra}, which ${name} lacks`
639
+ : `${name} orders its rules differently`;
640
+ o.report.recipeSplits.push({ shared: name, owned, reason });
641
+ return writeOwnedRecipe(o, owned, list, description);
642
+ }
643
+ /**
644
+ * A recipe the importing profile owns holds exactly this workspace's list: a new one is written
645
+ * as today; an existing one is edited in place — entries kept in their order, dropped ones
646
+ * removed, new ones appended — refused if another profile resolves it (I7) or it cannot be
647
+ * edited in place (I2).
648
+ */
649
+ async function writeOwnedRecipe(o, name, list, description) {
650
+ const file = path.join(o.dir, `${name}.yaml`);
651
+ o.report.recipes.push(name);
652
+ if (!(await o.stage.exists(file))) {
653
+ o.stage.write(file, recipeText(name, description, list));
654
+ return name;
655
+ }
656
+ const raw = await o.stage.readText(file);
657
+ const before = RecipeSchema.parse(YAML.parse(stripBom(raw)) ?? {});
658
+ const next = [...before.ingredients.filter((x) => list.includes(x)), ...list.filter((x) => !before.ingredients.includes(x))];
659
+ if (JSON.stringify(next) === JSON.stringify(before.ingredients))
660
+ return name;
661
+ if (o.forge) {
662
+ for (const q of o.forge.profiles.keys()) {
663
+ if (q !== o.profile && recipesOf(o.forge, q).has(name))
664
+ throw new Error(`import: recipe ${name} is also used by profile ${q} — its ingredients would change there`);
665
+ }
666
+ }
667
+ const label = `recipes/${name}.yaml`;
668
+ const content = editYamlText(raw, { command: "import", label, keys: ["ingredients"] }, (doc) => {
669
+ const seq = doc.get("ingredients", true);
670
+ if (!YAML.isSeq(seq)) {
671
+ doc.set("ingredients", next);
672
+ return;
673
+ }
674
+ seq.items = seq.items.filter((it) => list.includes(String(YAML.isScalar(it) ? it.value : it)));
675
+ for (const x of list)
676
+ if (!before.ingredients.includes(x))
677
+ seq.items.push(doc.createNode(x));
678
+ });
679
+ const after = RecipeSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
680
+ if (!after.success || !isDeepStrictEqual(after.data, { ...before, ingredients: next })) {
681
+ throw new Error(`import: cannot edit ${label} in place (the edit does not read back as exactly the new ingredients) — reformat it by hand, commit, and re-run`);
682
+ }
683
+ o.stage.write(file, content);
684
+ return name;
685
+ }
686
+ /** The recipes a profile resolves inside the Forge; fails closed — a profile that does not resolve counts as using everything. */
687
+ function recipesOf(forge, profile) {
688
+ try {
689
+ return new Set(resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).recipes);
690
+ }
691
+ catch {
692
+ return { has: () => true };
693
+ }
694
+ }
695
+ /**
696
+ * The importing profile's params before the run (`P`, spec 10 §6.1), and I4: import writes
697
+ * `profiles/<p>/profile.yaml`, so a profile named `<p>` elsewhere, or that file naming another
698
+ * profile, would leave two files for one name.
699
+ */
700
+ async function existingProfileParams(forge, profile, loaded) {
701
+ if (!loaded)
702
+ return {};
703
+ const dir = path.join(forge, "profiles");
704
+ for (const d of (await exists(dir)) ? await fs.readdir(dir, { withFileTypes: true }) : []) {
705
+ if (!d.isDirectory())
706
+ continue;
707
+ const abs = path.join(dir, d.name, "profile.yaml");
708
+ if (!(await exists(abs)))
709
+ continue;
710
+ const name = ProfileSchema.parse(YAML.parse(stripBom(await fs.readFile(abs, "utf8"))) ?? {}).name;
711
+ if ((name === profile) !== (d.name === profile)) {
712
+ throw new Error(`import: profile ${profile} is profiles/${d.name}/profile.yaml — import writes profiles/${profile}/profile.yaml`);
713
+ }
714
+ }
715
+ return { ...(loaded.profiles.get(profile)?.params ?? {}) };
716
+ }
717
+ /** Recipe entries import owns in a profile's `recipes`: the ones it computes and replaces (spec 10 §6.6, §14 Q4). */
718
+ const importOwned = (name, profile) => name === "base" || name === `base--${profile}` || name === `${profile}-steering` || (/^stack-/.test(name) && (!name.includes("--") || name.endsWith(`--${profile}`)));
719
+ /**
720
+ * Write `profiles/<p>/profile.yaml` (spec 10 §6.6, Ruling 2): a new profile as today; an existing
721
+ * one edited in place — `params` gains this run's values, `recipes` gets the computed entries in
722
+ * place of the ones import owns, `targets` gains missing ones — every other field, comment and
723
+ * line kept. I1 when it cannot be edited in place.
724
+ */
725
+ async function writeProfile(stage, forge, name, computed, ctx, report) {
726
+ const file = path.join(forge, "profiles", name, "profile.yaml");
727
+ const label = `profiles/${name}/profile.yaml`;
728
+ if (!(await stage.exists(file))) {
729
+ stage.write(file, YAML.stringify(computed));
730
+ report.created.push(label);
731
+ report.profileWrite = { path: label, action: "created", fields: [] };
732
+ return;
733
+ }
734
+ const raw = await stage.readText(file);
735
+ const before = ProfileSchema.parse(YAML.parse(stripBom(raw)) ?? {});
736
+ const values = Object.fromEntries(report.params.map((x) => [x.key, String(ctx.P[x.key])]));
737
+ const firstOwned = before.recipes.findIndex((r) => importOwned(r, name));
738
+ const kept = before.recipes.filter((r) => !importOwned(r, name));
739
+ const at = firstOwned === -1 ? kept.length : before.recipes.slice(0, firstOwned).filter((r) => !importOwned(r, name)).length;
740
+ const recipes = [...kept.slice(0, at), ...computed.recipes, ...kept.slice(at)];
741
+ const targets = [...before.targets, ...computed.targets.filter((t) => !before.targets.includes(t))];
742
+ const fields = [
743
+ ...(Object.keys(values).length ? ["params"] : []),
744
+ ...(JSON.stringify(recipes) !== JSON.stringify(before.recipes) ? ["recipes"] : []),
745
+ ...(targets.length !== before.targets.length ? ["targets"] : []),
746
+ ];
747
+ if (!fields.length) {
748
+ report.profileWrite = { path: label, action: "unchanged", fields: [] };
749
+ return;
750
+ }
751
+ const content = editYamlText(raw, { command: "import", label, keys: ["params", "recipes", "targets"] }, (doc) => {
752
+ for (const [k, v] of Object.entries(values))
753
+ doc.setIn(["params", k], v);
754
+ if (fields.includes("recipes")) {
755
+ const seq = doc.get("recipes", true);
756
+ if (YAML.isSeq(seq)) {
757
+ const nodes = seq.items.filter((it) => !importOwned(String(YAML.isScalar(it) ? it.value : it), name));
758
+ seq.items = [...nodes.slice(0, at), ...computed.recipes.map((r) => doc.createNode(r)), ...nodes.slice(at)];
759
+ }
760
+ else
761
+ doc.set("recipes", recipes);
762
+ }
763
+ if (fields.includes("targets")) {
764
+ const seq = doc.get("targets", true);
765
+ if (YAML.isSeq(seq))
766
+ for (const t of targets.slice(before.targets.length))
767
+ seq.items.push(doc.createNode(t));
768
+ else
769
+ doc.set("targets", targets);
770
+ }
771
+ });
772
+ const after = ProfileSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
773
+ const expected = { ...before, recipes, targets, params: { ...before.params, ...values } };
774
+ if (!after.success || !isDeepStrictEqual(after.data, expected)) {
775
+ throw new Error(`import: cannot edit ${label} in place (the edit does not read back as exactly the intended change) — reformat it by hand, commit, and re-run`);
776
+ }
777
+ stage.write(file, content);
778
+ report.profileWrite = { path: label, action: "edited", fields };
779
+ for (const x of report.params) {
780
+ const was = before.params[x.key] === undefined ? "" : ` (was ${JSON.stringify(String(before.params[x.key]))})`;
781
+ report.warnings.push(`profile ${name} now sets ${x.key} to ${JSON.stringify(values[x.key])}${was} — every workspace on ${name} renders it at its next sync; import cannot reach them`);
782
+ }
783
+ }
784
+ /**
785
+ * `--write-config` (spec 10 §6.9, Ruling 8): a new or empty craftar.yaml is written as today; an
786
+ * existing one is edited in place — `forge`, `profile` and `targets` set, everything else, comments
787
+ * included, kept. Checked here, before the Forge flush; I9 when it cannot be edited in place.
788
+ */
789
+ async function planConfig(ws, forge, profile, targets) {
790
+ const abs = path.join(ws, "craftar.yaml");
791
+ const rel = path.relative(ws, forge).replace(/\\/g, "/") || ".";
792
+ const fresh = YAML.stringify({ forge: rel, profile, targets });
793
+ const raw = (await exists(abs)) ? await fs.readFile(abs, "utf8") : null;
794
+ const before = raw === null ? null : YAML.parse(stripBom(raw));
795
+ if (raw === null || before === null || before === undefined)
796
+ return { abs, content: fresh, action: "created" };
797
+ const i9 = (why) => new Error(`import: cannot edit craftar.yaml in place (${why}) — reformat it by hand and re-run`);
798
+ if (typeof before !== "object" || Array.isArray(before))
799
+ throw i9("it is not a YAML mapping");
800
+ let content;
801
+ try {
802
+ content = editYamlText(raw, { command: "import", label: "craftar.yaml", keys: [] }, (doc) => {
803
+ doc.set("forge", rel);
804
+ doc.set("profile", profile);
805
+ if (JSON.stringify(before.targets) !== JSON.stringify(targets))
806
+ doc.set("targets", targets);
807
+ });
808
+ }
809
+ catch (e) {
810
+ throw i9(e.message.replace(/^.*in place \((.*)\) — .*$/s, "$1"));
811
+ }
812
+ const after = YAML.parse(stripBom(content));
813
+ if (!isDeepStrictEqual(after, { ...before, forge: rel, profile, targets }))
814
+ throw i9("the edit does not read back as exactly forge, profile and targets set");
815
+ const loaded = WorkspaceConfigSchema.safeParse(after);
816
+ if (!loaded.success)
817
+ throw i9(`it no longer loads: ${loaded.error.message}`);
818
+ return { abs, content, action: content === raw ? "unchanged" : "edited" };
533
819
  }
534
820
  //# sourceMappingURL=claude-code.js.map