craftar 0.5.0 → 0.6.1

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.
@@ -2,11 +2,17 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { parseFrontmatter } from "../core/frontmatter.js";
5
- import { exists, listFiles, typeFolder, FORGE_MANIFEST } from "../core/forge.js";
5
+ import { exists, listFiles, parseYaml, 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,52 @@ 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
+ // F9 (§6.5): a source rejected for a secret or skipped is never decided. Its base stays in the Forge-wide scan (b),
272
+ // and its text leaves the literal check (c). `ruleNames` fills only as rules are decided, so each set errs on its
273
+ // safe side for a steering file a rule of its name may shadow: out of `runBases`, still in `others`.
274
+ const queuedRules = new Set(queue.filter((q) => q.meta.type === "rule").map((q) => q.meta.name));
275
+ const mayBeSkipped = (q) => q.skip?.() || (q.meta.type === "steering" && queuedRules.has(q.meta.name));
276
+ const clean = queue.filter((q) => !secretIn(q.meta, q.files, q.scan));
277
+ const runBases = new Set(clean.filter((q) => !mayBeSkipped(q)).map((q) => `${q.meta.type}/${q.meta.name}`));
278
+ const mayBeDecided = clean.filter((q) => !q.skip?.());
279
+ const literal = [];
280
+ /* ---- decide, in read order ---- */
281
+ for (const src of queue) {
282
+ if (src.skip?.())
283
+ continue;
284
+ const others = mayBeDecided.filter((q) => q !== src).map((q) => ({ ref: `${q.meta.type}/${q.meta.name}`, meta: q.meta, files: q.files }));
285
+ const ref = await writeIngredient(stage, src.meta, src.files, opts.profileName, report, src.scan, { ctx, others, runBases, literal });
286
+ if (ref)
287
+ src.after(ref);
288
+ }
289
+ // I3: a created or variant ingredient is the workspace text itself; a key the profile now sets would change it at sync.
290
+ for (const l of literal) {
291
+ const map = renderMap(l.meta, ctx.P, ctx.W);
292
+ const key = [...sourceKeys(l.meta, l.files)].find((k) => Object.hasOwn(map, k));
293
+ if (key)
294
+ throw new Error(`import: ${l.source} holds {{${key}}} literally, but profile ${opts.profileName} sets ${key} — sync would render it`);
246
295
  }
247
296
  /* ---- recipes ---- */
248
297
  const recipesDir = path.join(forge, "recipes");
249
298
  const isVariant = (ref) => report.variants.some((v) => v.name === ref);
250
- const suffixIf = (name, ingredients) => (ingredients.some(isVariant) ? `${name}--${opts.profileName}` : name);
299
+ const recipeOpts = { stage, dir: recipesDir, profile: opts.profileName, forge: ctx.forge, report, currentRules: currentRuleOrder(ctx.forge, opts.profileName) };
251
300
  const stackRecipes = [];
252
301
  for (const [ruleRef, pattern] of scopedRules) {
253
302
  const ruleName = ruleRef.split("/")[1].replace(/--.*$/, "");
@@ -255,17 +304,14 @@ async function planImport(opts, stage) {
255
304
  for (const a of agents)
256
305
  refs.base = refs.base.filter((r) => r !== a);
257
306
  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);
307
+ stackRecipes.push(await placeRecipe(recipeOpts, `stack-${ruleName}`, ingredients, `Conventions + reviewer for repos matching ${pattern}`, ingredients.some(isVariant)));
261
308
  }
262
309
  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);
310
+ const baseName = await placeRecipe(recipeOpts, "base", baseIngredients, "Always-on conventions, commands, agents, scripts and MCP servers.", baseIngredients.some(isVariant));
265
311
  const profileRecipes = [baseName, ...stackRecipes];
266
312
  if (refs.steering.length) {
267
313
  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);
314
+ await writeOwnedRecipe(recipeOpts, n, refs.steering, `Hand-written Kiro steering specific to ${opts.profileName}.`);
269
315
  profileRecipes.push(n);
270
316
  }
271
317
  /* ---- profile ---- */
@@ -284,12 +330,14 @@ async function planImport(opts, stage) {
284
330
  frontend: {},
285
331
  executor: (await exists(path.join(claudeDir, "rules", "kiro-execution.md"))) ? { kind: "kiro", terminal: "orca" } : {},
286
332
  integrations: {},
287
- params: {},
333
+ // A new profile holds the values this run inferred, in acceptance order (spec 10 §6.6).
334
+ params: Object.fromEntries(report.params.map((x) => [x.key, ctx.P[x.key]])),
288
335
  repos: [],
289
336
  };
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 };
337
+ await writeProfile(stage, forge, opts.profileName, profile, ctx, report);
338
+ const config = opts.writeWorkspaceConfig ? await planConfig(ws, forge, opts.profileName, targets) : null;
339
+ report.configWrite = config?.action ?? null;
340
+ return { report, targets, config };
293
341
  }
294
342
  /**
295
343
  * The Forge as the import run sees it: the files on disk overlaid with the files this run has staged.
@@ -473,7 +521,7 @@ async function sniffScm(claudeDir) {
473
521
  * identical content → reuse; different content → write a `<name>--<profile>` variant and report it,
474
522
  * so the human decides whether to parameterize or keep a client-specific copy.
475
523
  */
476
- async function writeIngredient(stage, meta, files, profile, report, scan = {}) {
524
+ async function writeIngredient(stage, meta, files, profile, report, scan = {}, run) {
477
525
  const secret = secretIn(meta, files, scan);
478
526
  if (secret) {
479
527
  report.rejected.push({ name: `${meta.type}/${meta.name}`, reason: secret });
@@ -485,22 +533,55 @@ async function writeIngredient(stage, meta, files, profile, report, scan = {}) {
485
533
  let dir = path.join(forge, "ingredients", folder, name);
486
534
  // Hash what the Forge will load back, the way fingerprintDir hashes the other side.
487
535
  const fingerprint = fingerprintOf(validateImported(meta), files);
536
+ const ref = `${meta.type}/${name}`;
537
+ const source = meta.origin?.path ?? ref;
538
+ const sourceMeta = validateImported(meta);
488
539
  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}`;
540
+ let why;
541
+ const d = run ? await decide(run.ctx, dir, stage.reader(), ref, sourceMeta, files, fingerprint, run.others, run.runBases) : { kind: "literal", warn: "" };
542
+ if (d.kind === "literal") {
543
+ if (d.warn)
544
+ report.warnings.push(d.warn);
545
+ if ((await fingerprintDir(dir, stage.reader())) === fingerprint) {
546
+ report.reused.push(ref);
547
+ return ref;
548
+ }
549
+ }
550
+ else if (d.kind === "reuse") {
551
+ report.reused.push(ref);
552
+ if (d.rendered)
553
+ report.rendered.push({ name: ref, keys: d.rendered });
554
+ if (d.inferred)
555
+ report.inferred.push({ name: ref, values: d.inferred });
556
+ for (const x of d.delta ?? [])
557
+ report.params.push({ ...x, from: ref });
558
+ return ref;
559
+ }
560
+ else {
561
+ why = d.why;
493
562
  }
494
563
  const as = meta.name;
495
564
  name = `${meta.name}--${profile}`;
496
565
  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` });
566
+ report.variants.push({ name: `${meta.type}/${name}`, reason: `differs from ${meta.type}/${as} already in the Forge${why ? ` (${why})` : ""}` });
498
567
  meta = { ...meta, name, as };
499
568
  validateImported(meta); // the variant name must be slug-like too (a `--profile` with a space is not)
569
+ // I8: rewriting an existing variant another profile resolves would change its files there.
570
+ if (run?.ctx.forge && (await stage.exists(path.join(dir, "ingredient.yaml"))) && (await fingerprintDir(dir, stage.reader())) !== fingerprintOf(validateImported(meta), files)) {
571
+ for (const q of run.ctx.forge.profiles.keys()) {
572
+ if (q !== profile && resolvedBy(run.ctx.forge, q).has(`${meta.type}/${name}`)) {
573
+ throw new Error(`import: ${meta.type}/${name} is also used by profile ${q} — its files would change there`);
574
+ }
575
+ }
576
+ }
500
577
  }
501
578
  else {
502
579
  report.created.push(`${meta.type}/${name}`);
503
580
  }
581
+ if (run) {
582
+ pin(run.ctx, sourceKeys(sourceMeta, files), renderMap(sourceMeta, run.ctx.P, run.ctx.W));
583
+ run.literal.push({ ref: `${meta.type}/${name}`, source, meta: sourceMeta, files });
584
+ }
504
585
  const yamlMeta = { ...meta };
505
586
  if (yamlMeta.targets === "*")
506
587
  yamlMeta.targets = "*";
@@ -521,14 +602,246 @@ function validateImported(meta) {
521
602
  const source = meta.origin?.path ?? `${meta.type}/${meta.name}`;
522
603
  throw new Error(`${source} (${meta.type}/${meta.name}) does not fit the ingredient schema: ${r.error.message}`);
523
604
  }
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);
605
+ function currentRuleOrder(forge, profile) {
606
+ if (!forge?.profiles.has(profile))
607
+ return null;
608
+ try {
609
+ return resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).ingredients.map((i) => i.ref).filter((r) => r.startsWith("rule/"));
610
+ }
611
+ catch {
612
+ return null;
613
+ }
614
+ }
615
+ const recipeText = (name, description, ingredients) => YAML.stringify(JSON.parse(JSON.stringify({ name, description, extends: [], ingredients, params: {} }))); // today's bytes
616
+ /**
617
+ * Where a shared recipe's computed list goes (spec 10 §6.7, Ruling 7): a shared recipe is never
618
+ * edited and never widened for one client. It is used only when its ingredients equal the list as
619
+ * a set — and, for a profile that already exists, in the same relative rule order, so AGENTS.md
620
+ * does not reorder (Q10). Otherwise the profile gets its own `<name>--<profile>`.
621
+ */
622
+ async function placeRecipe(o, name, list, description, holdsVariant) {
623
+ const owned = `${name}--${o.profile}`;
624
+ if (holdsVariant)
625
+ return writeOwnedRecipe(o, owned, list, description);
626
+ const file = await recipeFile(o, name);
627
+ if (!(await o.stage.exists(file))) {
628
+ o.stage.write(file, recipeText(name, description, list));
629
+ o.report.recipes.push(name);
630
+ return name;
631
+ }
632
+ const existing = parseYaml(file, stripBom(await o.stage.readText(file)), RecipeSchema).ingredients;
633
+ const rules = (xs) => xs.filter((x) => x.startsWith("rule/"));
634
+ const lacks = existing.find((x) => !list.includes(x));
635
+ const extra = list.find((x) => !existing.includes(x));
636
+ // 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.
637
+ const reorders = o.currentRules !== null && JSON.stringify(rules(existing)) !== JSON.stringify(o.currentRules.filter((r) => existing.includes(r)));
638
+ if (!lacks && !extra && !reorders) {
639
+ o.report.recipes.push(name);
640
+ return name;
641
+ }
642
+ const reason = lacks
643
+ ? `${name} lists ${lacks}, which this workspace lacks`
644
+ : extra
645
+ ? `this workspace has ${extra}, which ${name} lacks`
646
+ : `${name} orders its rules differently`;
647
+ o.report.recipeSplits.push({ shared: name, owned, reason });
648
+ return writeOwnedRecipe(o, owned, list, description);
649
+ }
650
+ /**
651
+ * A recipe the importing profile owns holds exactly this workspace's list: a new one is written
652
+ * as today; an existing one is edited in place — entries kept in their order, dropped ones
653
+ * removed, new ones appended — refused if another profile resolves it (I7) or it cannot be
654
+ * edited in place (I2).
655
+ */
656
+ async function writeOwnedRecipe(o, name, list, description) {
657
+ const file = await recipeFile(o, name);
658
+ o.report.recipes.push(name);
659
+ if (!(await o.stage.exists(file))) {
660
+ o.stage.write(file, recipeText(name, description, list));
661
+ return name;
662
+ }
663
+ const raw = await o.stage.readText(file);
664
+ const before = parseYaml(file, stripBom(raw), RecipeSchema);
665
+ const next = [...before.ingredients.filter((x) => list.includes(x)), ...list.filter((x) => !before.ingredients.includes(x))];
666
+ if (JSON.stringify(next) === JSON.stringify(before.ingredients))
667
+ return name;
668
+ if (o.forge) {
669
+ for (const q of o.forge.profiles.keys()) {
670
+ if (q !== o.profile && recipesOf(o.forge, q).has(name))
671
+ throw new Error(`import: recipe ${name} is also used by profile ${q} — its ingredients would change there`);
672
+ }
673
+ }
674
+ const label = `recipes/${name}.yaml`;
675
+ const content = editYamlText(raw, { command: "import", label, keys: ["ingredients"] }, (doc) => {
676
+ const seq = doc.get("ingredients", true);
677
+ if (!YAML.isSeq(seq)) {
678
+ doc.set("ingredients", next);
679
+ return;
680
+ }
681
+ seq.items = seq.items.filter((it) => list.includes(String(YAML.isScalar(it) ? it.value : it)));
682
+ for (const x of list)
683
+ if (!before.ingredients.includes(x))
684
+ seq.items.push(doc.createNode(x));
685
+ });
686
+ const after = RecipeSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
687
+ if (!after.success || !isDeepStrictEqual(after.data, { ...before, ingredients: next })) {
688
+ 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`);
689
+ }
690
+ o.stage.write(file, content);
691
+ return name;
692
+ }
693
+ /**
694
+ * `recipes/<name>.yaml`, the file import reads and writes for recipe `<name>`. loadForge keys
695
+ * recipes by their `name` field, not by file, so a recipe `<name>` in another file, or that file
696
+ * declaring another name, would leave two recipes under one name and loadForge would keep one.
697
+ * Refused before the first write, as I4 refuses the same split for a profile.
698
+ */
699
+ async function recipeFile(o, name) {
700
+ const file = path.join(o.dir, `${name}.yaml`);
701
+ for (const f of await o.stage.reader().list(o.dir)) {
702
+ if (f.includes("/") || !/\.ya?ml$/.test(f))
703
+ continue;
704
+ const abs = path.join(o.dir, f);
705
+ const declared = parseYaml(abs, stripBom(await o.stage.readText(abs)), RecipeSchema).name;
706
+ if (abs === file && declared !== name)
707
+ throw new Error(`import: recipes/${f} is recipe ${declared} — import writes recipe ${name} there`);
708
+ if (abs !== file && declared === name)
709
+ throw new Error(`import: recipe ${name} is recipes/${f} — import writes recipes/${name}.yaml`);
710
+ }
711
+ return file;
712
+ }
713
+ /** The recipes a profile resolves inside the Forge; fails closed — a profile that does not resolve counts as using everything. */
714
+ function recipesOf(forge, profile) {
715
+ try {
716
+ return new Set(resolve(forge, WorkspaceConfigSchema.parse({ forge: ".", profile })).recipes);
717
+ }
718
+ catch {
719
+ return { has: () => true };
720
+ }
721
+ }
722
+ /**
723
+ * The importing profile's params before the run (`P`, spec 10 §6.1), and I4: import writes
724
+ * `profiles/<p>/profile.yaml`, so a profile named `<p>` elsewhere, or that file naming another
725
+ * profile, would leave two files for one name.
726
+ */
727
+ async function existingProfileParams(forge, profile, loaded) {
728
+ if (!loaded)
729
+ return {};
730
+ const dir = path.join(forge, "profiles");
731
+ for (const d of (await exists(dir)) ? await fs.readdir(dir, { withFileTypes: true }) : []) {
732
+ if (!d.isDirectory())
733
+ continue;
734
+ const abs = path.join(dir, d.name, "profile.yaml");
735
+ if (!(await exists(abs)))
736
+ continue;
737
+ const name = parseYaml(abs, stripBom(await fs.readFile(abs, "utf8")), ProfileSchema).name;
738
+ if ((name === profile) !== (d.name === profile)) {
739
+ throw new Error(`import: profile ${profile} is profiles/${d.name}/profile.yaml — import writes profiles/${profile}/profile.yaml`);
740
+ }
741
+ }
742
+ return { ...(loaded.profiles.get(profile)?.params ?? {}) };
743
+ }
744
+ /** Recipe entries import owns in a profile's `recipes`: the ones it computes and replaces (spec 10 §6.6, §14 Q4). */
745
+ const importOwned = (name, profile) => name === "base" || name === `base--${profile}` || name === `${profile}-steering` || (/^stack-/.test(name) && (!name.includes("--") || name.endsWith(`--${profile}`)));
746
+ /**
747
+ * Write `profiles/<p>/profile.yaml` (spec 10 §6.6, Ruling 2): a new profile as today; an existing
748
+ * one edited in place — `params` gains this run's values, `recipes` gets the computed entries in
749
+ * place of the ones import owns, `targets` gains missing ones — every other field, comment and
750
+ * line kept. I1 when it cannot be edited in place.
751
+ */
752
+ async function writeProfile(stage, forge, name, computed, ctx, report) {
753
+ const file = path.join(forge, "profiles", name, "profile.yaml");
754
+ const label = `profiles/${name}/profile.yaml`;
755
+ if (!(await stage.exists(file))) {
756
+ stage.write(file, YAML.stringify(computed));
757
+ report.created.push(label);
758
+ report.profileWrite = { path: label, action: "created", fields: [] };
759
+ return;
760
+ }
761
+ const raw = await stage.readText(file);
762
+ const before = parseYaml(file, stripBom(raw), ProfileSchema);
763
+ const values = Object.fromEntries(report.params.map((x) => [x.key, String(ctx.P[x.key])]));
764
+ const firstOwned = before.recipes.findIndex((r) => importOwned(r, name));
765
+ const kept = before.recipes.filter((r) => !importOwned(r, name));
766
+ const at = firstOwned === -1 ? kept.length : before.recipes.slice(0, firstOwned).filter((r) => !importOwned(r, name)).length;
767
+ const recipes = [...kept.slice(0, at), ...computed.recipes, ...kept.slice(at)];
768
+ const targets = [...before.targets, ...computed.targets.filter((t) => !before.targets.includes(t))];
769
+ const fields = [
770
+ ...(Object.keys(values).length ? ["params"] : []),
771
+ ...(JSON.stringify(recipes) !== JSON.stringify(before.recipes) ? ["recipes"] : []),
772
+ ...(targets.length !== before.targets.length ? ["targets"] : []),
773
+ ];
774
+ if (!fields.length) {
775
+ report.profileWrite = { path: label, action: "unchanged", fields: [] };
776
+ return;
777
+ }
778
+ const content = editYamlText(raw, { command: "import", label, keys: ["params", "recipes", "targets"] }, (doc) => {
779
+ for (const [k, v] of Object.entries(values))
780
+ doc.setIn(["params", k], v);
781
+ if (fields.includes("recipes")) {
782
+ const seq = doc.get("recipes", true);
783
+ if (YAML.isSeq(seq)) {
784
+ const nodes = seq.items.filter((it) => !importOwned(String(YAML.isScalar(it) ? it.value : it), name));
785
+ seq.items = [...nodes.slice(0, at), ...computed.recipes.map((r) => doc.createNode(r)), ...nodes.slice(at)];
786
+ }
787
+ else
788
+ doc.set("recipes", recipes);
789
+ }
790
+ if (fields.includes("targets")) {
791
+ const seq = doc.get("targets", true);
792
+ if (YAML.isSeq(seq))
793
+ for (const t of targets.slice(before.targets.length))
794
+ seq.items.push(doc.createNode(t));
795
+ else
796
+ doc.set("targets", targets);
797
+ }
798
+ });
799
+ const after = ProfileSchema.safeParse(YAML.parse(stripBom(content)) ?? {});
800
+ const expected = { ...before, recipes, targets, params: { ...before.params, ...values } };
801
+ if (!after.success || !isDeepStrictEqual(after.data, expected)) {
802
+ 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`);
803
+ }
804
+ stage.write(file, content);
805
+ report.profileWrite = { path: label, action: "edited", fields };
806
+ for (const x of report.params) {
807
+ const was = before.params[x.key] === undefined ? "" : ` (was ${JSON.stringify(String(before.params[x.key]))})`;
808
+ 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`);
809
+ }
810
+ }
811
+ /**
812
+ * `--write-config` (spec 10 §6.9, Ruling 8): a new or empty craftar.yaml is written as today; an
813
+ * existing one is edited in place — `forge`, `profile` and `targets` set, everything else, comments
814
+ * included, kept. Checked here, before the Forge flush; I9 when it cannot be edited in place.
815
+ */
816
+ async function planConfig(ws, forge, profile, targets) {
817
+ const abs = path.join(ws, "craftar.yaml");
818
+ const rel = path.relative(ws, forge).replace(/\\/g, "/") || ".";
819
+ const fresh = YAML.stringify({ forge: rel, profile, targets });
820
+ const raw = (await exists(abs)) ? await fs.readFile(abs, "utf8") : null;
821
+ const before = raw === null ? null : YAML.parse(stripBom(raw));
822
+ if (raw === null || before === null || before === undefined)
823
+ return { abs, content: fresh, action: "created" };
824
+ const i9 = (why) => new Error(`import: cannot edit craftar.yaml in place (${why}) — reformat it by hand and re-run`);
825
+ if (typeof before !== "object" || Array.isArray(before))
826
+ throw i9("it is not a YAML mapping");
827
+ let content;
828
+ try {
829
+ content = editYamlText(raw, { command: "import", label: "craftar.yaml", keys: [] }, (doc) => {
830
+ doc.set("forge", rel);
831
+ doc.set("profile", profile);
832
+ if (JSON.stringify(before.targets) !== JSON.stringify(targets))
833
+ doc.set("targets", targets);
834
+ });
835
+ }
836
+ catch (e) {
837
+ throw i9(e.message.replace(/^.*in place \((.*)\) — .*$/s, "$1"));
838
+ }
839
+ const after = YAML.parse(stripBom(content));
840
+ if (!isDeepStrictEqual(after, { ...before, forge: rel, profile, targets }))
841
+ throw i9("the edit does not read back as exactly forge, profile and targets set");
842
+ const loaded = WorkspaceConfigSchema.safeParse(after);
843
+ if (!loaded.success)
844
+ throw i9(`it no longer loads: ${loaded.error.message}`);
845
+ return { abs, content, action: content === raw ? "unchanged" : "edited" };
533
846
  }
534
847
  //# sourceMappingURL=claude-code.js.map