From e4693e6525dfa5ca8f2b38bdf1e35e5db700667d Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 13:37:16 -0300 Subject: [PATCH 01/13] fix(import): group scripts and hooks that share a base name readEmitted returns one part per file; the scripts/hooks loop now groups them by the ingredient name (lowercased base name) and merges with a shared mergeEmitted helper. drift-promote also uses this helper. --- src/importers/claude-code.ts | 81 ++++++++- src/importers/drift-promote.ts | 17 +- test/import-script-groups.test.ts | 281 ++++++++++++++++++++++++++++++ test/importer.test.ts | 13 +- 4 files changed, 377 insertions(+), 15 deletions(-) create mode 100644 test/import-script-groups.test.ts diff --git a/src/importers/claude-code.ts b/src/importers/claude-code.ts index 7406587f..aa6d522d 100644 --- a/src/importers/claude-code.ts +++ b/src/importers/claude-code.ts @@ -263,11 +263,49 @@ async function planImport(opts: ImportOptions, stage: ForgeStage): Promise<{ rep /* ---- scripts & hooks ---- */ for (const kind of ["scripts", "hooks"] as const) { + const type = kind === "scripts" ? "script" : "hook"; + // Read each file and group by the base name (lowercased) that readEmitted gives + const emittedList: EmittedSource[] = []; for (const f of await safeList(path.join(claudeDir, kind))) { if (f === ".gitkeep" || f.startsWith("__pycache__") || f.endsWith(".pyc")) continue; - const emitted = await readEmitted(claudeDir, kind === "scripts" ? "script" : "hook", f, origin); + const emitted = await readEmitted(claudeDir, type, f, origin); if (!emitted) continue; - read(emitted.meta, emitted.files, emitted.scan, (ref) => addRef(refs.base, ref)); + emittedList.push(emitted); + } + // Group by ingredient name (lowercased base name) + const groups = new Map(); + for (const e of emittedList) { + const name = e.meta.name; // already lowercased by readEmitted + const list = groups.get(name) ?? []; + list.push(e); + groups.set(name, list); + } + // Queue one ingredient per group + for (const [name, parts] of groups) { + // Case collision check: do any two files in the group have names equal ignoring case? + const allFiles = parts.flatMap((p) => (p.meta as { files?: string[] }).files ?? []); + const seenLower = new Map(); // lowercase → original + let collision: { a: string; b: string } | null = null; + for (const f of allFiles) { + const lower = f.toLowerCase(); + const prev = seenLower.get(lower); + if (prev !== undefined && prev !== f) { + // Collision: two different file names map to same lowercase + const [a, b] = [prev, f].sort(); + collision = { a, b }; + break; + } + seenLower.set(lower, f); + } + if (collision) { + report.rejected.push({ + name: `${type}/${name}`, + reason: `files ${collision.a} and ${collision.b} differ only in case — they would collide on a case-insensitive checkout`, + }); + continue; + } + const merged = mergeEmitted(parts); + read(merged.meta, merged.files, merged.scan, (ref) => addRef(refs.base, ref)); } } @@ -637,6 +675,32 @@ export interface EmittedSource { warnings?: string[]; } +/** + * Merge multiple EmittedSource parts that share a base name into one ingredient. + * The meta of `parts[0]` is kept with `files` listing every file from all parts in order; + * `files` and `scan` are the unions of all parts; `warnings` are concatenated (key omitted when empty). + */ +export function mergeEmitted(parts: EmittedSource[]): EmittedSource { + if (parts.length === 0) throw new Error("mergeEmitted: parts must not be empty"); + if (parts.length === 1) return parts[0]; + const first = parts[0]; + const mergedMeta = { ...first.meta } as Ingredient & { files?: string[] }; + // Build the files list from each part's meta.files (not from their files map keys, to preserve original order) + const filesList: string[] = []; + for (const p of parts) { + const partFiles = (p.meta as { files?: string[] }).files ?? []; + for (const f of partFiles) filesList.push(f); + } + mergedMeta.files = filesList; + const mergedFiles: Record = {}; + for (const p of parts) Object.assign(mergedFiles, p.files); + const mergedScan: Record = {}; + for (const p of parts) Object.assign(mergedScan, p.scan); + const mergedWarnings: string[] = []; + for (const p of parts) if (p.warnings) mergedWarnings.push(...p.warnings); + return { meta: mergedMeta, files: mergedFiles, scan: mergedScan, ...(mergedWarnings.length > 0 ? { warnings: mergedWarnings } : {}) }; +} + /** * A Claude rule's `paths:`, read from its raw frontmatter. `present` says the key is there; `value` is set only * when it is usable — a non-empty string, or a non-empty list of non-empty strings. YAML is tried first; when it @@ -1101,6 +1165,19 @@ async function writeIngredient( } stage.write(path.join(dir, "ingredient.yaml"), YAML.stringify(yamlMeta, { lineWidth: 0 })); for (const [rel, content] of Object.entries(files)) stage.write(path.join(dir, rel), content); + + // For script and hook, warn about leftover files in the directory + if (meta.type === "script" || meta.type === "hook") { + const diskFiles = await stage.reader().list(dir); + const plannedFiles = new Set(["ingredient.yaml", ...Object.keys(files)]); + for (const diskRel of diskFiles) { + if (!plannedFiles.has(diskRel)) { + const relDir = path.relative(forge, dir).split(path.sep).join("/"); + report.warnings.push(`${relDir}/${diskRel} is not in the files of ${meta.type}/${name} — sync does not write it; remove it by hand`); + } + } + } + return `${meta.type}/${name}`; } diff --git a/src/importers/drift-promote.ts b/src/importers/drift-promote.ts index 8d2c9cfc..7625cb44 100644 --- a/src/importers/drift-promote.ts +++ b/src/importers/drift-promote.ts @@ -54,6 +54,7 @@ import { resolvedRecipes, recipeFile, ForgeStage, + mergeEmitted, type EmittedSource, } from "./claude-code.js"; import { decide, workspaceParams, workspaceSections, type RunContext } from "./decide.js"; @@ -689,23 +690,21 @@ export async function planPromote(input: PromoteInput): Promise { throw new Error(`cannot promote ${ingredientRef} — unsupported type`); } - // For scripts/hooks with multiple files, read each file and merge + // For scripts/hooks with multiple files, read each file and merge using mergeEmitted const scriptHookFiles = (meta.type === "script" || meta.type === "hook") ? (meta.files ?? [rel]) : null; let emitted: EmittedSource | null; - let mergedFiles: Record = {}; - let mergedScan: Record = {}; if (scriptHookFiles && scriptHookFiles.length > 1) { - // Read each file and merge + // Read each file with readEmitted, in the ingredient's files order + const parts: EmittedSource[] = []; for (const fileRel of scriptHookFiles) { const fileEmitted = await readEmitted(claudeDir, readType, fileRel, (r) => ({ workspace: path.basename(root), path: r })); if (!fileEmitted) throw new Error(`cannot read ${readType} file ${fileRel} from ${claudeDir}`); - Object.assign(mergedFiles, fileEmitted.files); - Object.assign(mergedScan, fileEmitted.scan); + parts.push(fileEmitted); } - // Use the first file's metadata as base but with files from the original ingredient - const firstEmitted = await readEmitted(claudeDir, readType, scriptHookFiles[0], (r) => ({ workspace: path.basename(root), path: r })); - emitted = firstEmitted ? { meta: { ...firstEmitted.meta, files: scriptHookFiles } as Ingredient, files: mergedFiles, scan: mergedScan } : null; + const merged = mergeEmitted(parts); + // Use the original ingredient's files list (meta.files) to preserve order + emitted = { ...merged, meta: { ...merged.meta, files: scriptHookFiles } as Ingredient }; } else { // Single file case: call readEmitted normally emitted = await readEmitted(claudeDir, readType, rel, (r) => ({ workspace: path.basename(root), path: r })); diff --git a/test/import-script-groups.test.ts b/test/import-script-groups.test.ts new file mode 100644 index 00000000..d34201c8 --- /dev/null +++ b/test/import-script-groups.test.ts @@ -0,0 +1,281 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import { importClaudeCode } from "../src/importers/claude-code.js"; +import { exists } from "../src/core/forge.js"; +import { loadWorkspace, plan } from "../src/core/sync.js"; +import { tmpDir, writeFiles } from "./helpers/forge.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); +async function setup() { + const root = await tmpDir("craftar-import-groups-"); + cleanups.push(() => fs.rm(root, { recursive: true, force: true })); + return { root, forge: path.join(root, "forge"), ws: (n: string) => path.join(root, n) }; +} +const importInto = (forge: string, workspaceRoot: string, profileName: string) => + importClaudeCode({ workspaceRoot, forgeRoot: forge, profileName }); +const yaml = async (f: string) => YAML.parse(await fs.readFile(f, "utf8")); + +// Detect case-insensitive file system at module load +let caseInsensitiveFs = false; +(async () => { + const probeTmpDir = await tmpDir("craftar-fs-probe-"); + try { + await fs.writeFile(path.join(probeTmpDir, "probe.txt"), "probe"); + caseInsensitiveFs = await exists(path.join(probeTmpDir, "PROBE.TXT")); + } finally { + await fs.rm(probeTmpDir, { recursive: true, force: true }); + } +})(); + +describe("import groups scripts/hooks by base name", () => { + it("1. two scripts with one base name become one ingredient listing both", async () => { + const t = await setup(); + await writeFiles(t.ws("a"), { + ".claude/scripts/run-x.ps1": "Write-Host hi\n", + ".claude/scripts/run-x.sh": "echo hi\n", + }); + const r = await importInto(t.forge, t.ws("a"), "acme"); + + // No variants — both files in one ingredient + expect(r.variants).toEqual([]); + + // script/run-x created, no script/run-x--acme + expect(r.created).toContain("script/run-x"); + expect(r.created.filter((c) => c.startsWith("script/run-x--"))).toEqual([]); + + // ingredient.yaml has both files + const meta = await yaml(path.join(t.forge, "ingredients/scripts/run-x/ingredient.yaml")); + expect(meta.files).toEqual(["run-x.ps1", "run-x.sh"]); + + // Both files exist + expect(await exists(path.join(t.forge, "ingredients/scripts/run-x/run-x.ps1"))).toBe(true); + expect(await exists(path.join(t.forge, "ingredients/scripts/run-x/run-x.sh"))).toBe(true); + + // No variant folder + expect(await exists(path.join(t.forge, "ingredients/scripts/run-x--acme"))).toBe(false); + }); + + it("2. names that differ only in case group (Windows-shaped)", async () => { + const t = await setup(); + await writeFiles(t.ws("a"), { + ".claude/scripts/X.PS1": "Write-Host x\n", + ".claude/scripts/x.sh": "echo x\n", + }); + const r = await importInto(t.forge, t.ws("a"), "acme"); + + // Both have base name "x" (lowercase), so they group + expect(r.variants).toEqual([]); + expect(r.created).toContain("script/x"); + expect(r.created.filter((c) => c.startsWith("script/x--"))).toEqual([]); + + const meta = await yaml(path.join(t.forge, "ingredients/scripts/x/ingredient.yaml")); + // Files in sorted order: X.PS1 < x.sh + expect(meta.files).toEqual(["X.PS1", "x.sh"]); + }); + + it("3. hooks group the same way", async () => { + const t = await setup(); + await writeFiles(t.ws("a"), { + ".claude/hooks/guard.ps1": "Write-Host guard\n", + ".claude/hooks/guard.sh": "echo guard\n", + }); + const r = await importInto(t.forge, t.ws("a"), "acme"); + + expect(r.variants).toEqual([]); + expect(r.created).toContain("hook/guard"); + expect(r.created.filter((c) => c.startsWith("hook/guard--"))).toEqual([]); + + const meta = await yaml(path.join(t.forge, "ingredients/hooks/guard/ingredient.yaml")); + expect(meta.files).toEqual(["guard.ps1", "guard.sh"]); + }); + + it.skipIf(caseInsensitiveFs)( + "4. two files equal ignoring case are rejected (skipped on a case-insensitive file system)", + async () => { + const t = await setup(); + // On a case-sensitive FS, both files can coexist + await writeFiles(t.ws("a"), { + ".claude/scripts/run.SH": "echo a\n", + ".claude/scripts/run.sh": "echo b\n", + }); + const r = await importInto(t.forge, t.ws("a"), "acme"); + + // Should be rejected due to case collision + expect(r.rejected).toEqual([ + { + name: "script/run", + reason: "files run.SH and run.sh differ only in case — they would collide on a case-insensitive checkout", + }, + ]); + + // No ingredient written + expect(await exists(path.join(t.forge, "ingredients/scripts/run"))).toBe(false); + }, + ); + + it("5. sync after the import writes both files, with no duplicate-write warning", async () => { + const t = await setup(); + await writeFiles(t.ws("a"), { + ".claude/scripts/run-x.ps1": "Write-Host hi\n", + ".claude/scripts/run-x.sh": "echo hi\n", + }); + await importInto(t.forge, t.ws("a"), "acme"); + + // Write craftar.yaml + const forgeAbs = path.resolve(t.forge); + await writeFiles(t.ws("a"), { + "craftar.yaml": `forge: ${forgeAbs}\nprofile: acme\n`, + }); + + const ws = await loadWorkspace(t.ws("a")); + const p = await plan(ws); + + // Both files planned + const paths = p.files.map((f) => f.path); + expect(paths).toContain(".claude/scripts/run-x.ps1"); + expect(paths).toContain(".claude/scripts/run-x.sh"); + + // Contents match + const ps1File = p.files.find((f) => f.path === ".claude/scripts/run-x.ps1")!; + const shFile = p.files.find((f) => f.path === ".claude/scripts/run-x.sh")!; + expect(ps1File.content.toString()).toBe("Write-Host hi\n"); + expect(shFile.content.toString()).toBe("echo hi\n"); + + // No duplicate-write warning + expect(p.warnings.some((w) => w.startsWith("two ingredients write"))).toBe(false); + }); + + it("6. a second profile reuses the grouped ingredient", async () => { + const t = await setup(); + await writeFiles(t.ws("a"), { + ".claude/scripts/run-x.ps1": "Write-Host hi\n", + ".claude/scripts/run-x.sh": "echo hi\n", + }); + await importInto(t.forge, t.ws("a"), "acme"); + + // Import an identical workspace as beta + await writeFiles(t.ws("b"), { + ".claude/scripts/run-x.ps1": "Write-Host hi\n", + ".claude/scripts/run-x.sh": "echo hi\n", + }); + const r2 = await importInto(t.forge, t.ws("b"), "beta"); + + expect(r2.reused).toContain("script/run-x"); + expect(r2.variants).toEqual([]); + }); + + it("7. a Forge imported before keeps working and a re-import rewrites the old variant", async () => { + const t = await setup(); + + // Build the OLD shape by hand: base with one file, variant with the other + await writeFiles(t.forge, { + "craftar.forge.yaml": "name: forge\nschema: 1\n", + "ingredients/scripts/run-x/ingredient.yaml": YAML.stringify({ + type: "script", + name: "run-x", + files: ["run-x.ps1"], + targets: ["claude-code"], + tags: [], + }), + "ingredients/scripts/run-x/run-x.ps1": "Write-Host hi\n", + "ingredients/scripts/run-x--acme/ingredient.yaml": YAML.stringify({ + type: "script", + name: "run-x--acme", + as: "run-x", + files: ["run-x.sh"], + targets: ["claude-code"], + tags: [], + }), + "ingredients/scripts/run-x--acme/run-x.sh": "echo hi\n", + "recipes/base--acme.yaml": YAML.stringify({ + name: "base--acme", + ingredients: ["script/run-x--acme"], + }), + "profiles/acme/profile.yaml": YAML.stringify({ + name: "acme", + recipes: ["base--acme"], + targets: ["claude-code"], + }), + }); + + // Re-import ws `a` (case 1's files) as acme + await writeFiles(t.ws("a"), { + ".claude/scripts/run-x.ps1": "Write-Host hi\n", + ".claude/scripts/run-x.sh": "echo hi\n", + }); + const r = await importInto(t.forge, t.ws("a"), "acme"); + + // Record for report + console.log("Case 7 — r.warnings:", r.warnings); + console.log("Case 7 — r.variants:", r.variants); + + // The variant directory should now have BOTH files + const variantMeta = await yaml(path.join(t.forge, "ingredients/scripts/run-x--acme/ingredient.yaml")); + expect(variantMeta.files).toEqual(["run-x.ps1", "run-x.sh"]); + + // Plan the workspace + const forgeAbs = path.resolve(t.forge); + await writeFiles(t.ws("a"), { + "craftar.yaml": `forge: ${forgeAbs}\nprofile: acme\n`, + }); + const ws = await loadWorkspace(t.ws("a")); + const p = await plan(ws); + + // Record for report + console.log("Case 7 — plan warnings:", p.warnings); + + // Both planned files equal sources + const ps1File = p.files.find((f) => f.path === ".claude/scripts/run-x.ps1")!; + const shFile = p.files.find((f) => f.path === ".claude/scripts/run-x.sh")!; + expect(ps1File.content.toString()).toBe("Write-Host hi\n"); + expect(shFile.content.toString()).toBe("echo hi\n"); + }); + + it("8. a leftover file in a rewritten script folder is warned", async () => { + const t = await setup(); + + // Forge with base script/run-x [run-x.ps1] with DIFFERENT content so variant is made + // and an existing variant folder with a stray file + await writeFiles(t.forge, { + "craftar.forge.yaml": "name: forge\nschema: 1\n", + "ingredients/scripts/run-x/ingredient.yaml": YAML.stringify({ + type: "script", + name: "run-x", + files: ["run-x.ps1"], + targets: ["claude-code"], + tags: [], + }), + "ingredients/scripts/run-x/run-x.ps1": "Write-Host OLD\n", + "ingredients/scripts/run-x--acme/ingredient.yaml": YAML.stringify({ + type: "script", + name: "run-x--acme", + as: "run-x", + files: ["run-x.ps1"], + targets: ["claude-code"], + tags: [], + }), + "ingredients/scripts/run-x--acme/run-x.ps1": "Write-Host OLD\n", + "ingredients/scripts/run-x--acme/stale.sh": "echo stale\n", + "recipes/base.yaml": YAML.stringify({ + name: "base", + ingredients: ["script/run-x"], + }), + }); + + // Import ws with only .claude/scripts/run-x.ps1 with NEW content + await writeFiles(t.ws("a"), { + ".claude/scripts/run-x.ps1": "Write-Host hi\n", + }); + const r = await importInto(t.forge, t.ws("a"), "acme"); + + // Should warn about the leftover file + expect(r.warnings).toContain( + "ingredients/scripts/run-x--acme/stale.sh is not in the files of script/run-x--acme — sync does not write it; remove it by hand", + ); + }); +}); diff --git a/test/importer.test.ts b/test/importer.test.ts index 7b6f2758..67893e81 100644 --- a/test/importer.test.ts +++ b/test/importer.test.ts @@ -328,8 +328,8 @@ describe("import --from claude-code — all checks before the first write", () = }); it("compares a later ingredient against one staged earlier in the same run", async () => { - // Two hook files that map to one ingredient name: the second sees the first as already in the - // Forge, exactly as it did when the importer wrote as it went. + // Two hook files with the same base name: they are now grouped into one ingredient listing both. + // (Before the fix, the second saw the first as already in the Forge and became a variant.) const t = await setup(); await writeFiles(t.ws("api"), { ".claude/rules/workflow.md": "# Workflow\n", @@ -338,9 +338,14 @@ describe("import --from claude-code — all checks before the first write", () = }); const r = await importInto(t.forge, t.ws("api"), "api"); expect(r.created).toContain("hook/guard"); - expect(r.variants).toEqual([{ name: "hook/guard--api", reason: "differs from hook/guard already in the Forge" }]); + // No variant — both files in one ingredient + expect(r.variants.filter((v) => v.name.startsWith("hook/guard"))).toEqual([]); + // Both files exist in the same ingredient directory expect(await fs.readFile(path.join(t.forge, "ingredients/hooks/guard/guard.ps1"), "utf8")).toBe("Write-Output ps1\n"); - expect(await fs.readFile(path.join(t.forge, "ingredients/hooks/guard--api/guard.sh"), "utf8")).toBe("echo sh\n"); + expect(await fs.readFile(path.join(t.forge, "ingredients/hooks/guard/guard.sh"), "utf8")).toBe("echo sh\n"); + // ingredient.yaml lists both files + const meta = YAML.parse(await fs.readFile(path.join(t.forge, "ingredients/hooks/guard/ingredient.yaml"), "utf8")); + expect(meta.files).toEqual(["guard.ps1", "guard.sh"]); }); }); From 05147491fd01f75868ce5b394c9c61b641afe33d Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 13:37:21 -0300 Subject: [PATCH 02/13] docs(readme): upgrade note for grouped scripts One script/hook ingredient per base name; old Forges keep working, a re-import updates the variant with both files. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b1f62d5c..3f28cea3 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,10 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; ## Upgrading +### to 0.34.2 + +- **`import` now creates one script/hook ingredient per base name**, listing every file that shares that name (e.g., `run-x.ps1` and `run-x.sh` become one `script/run-x` with `files: ["run-x.ps1", "run-x.sh"]`). A Forge imported before keeps its shape (a base with one file and a `--` variant with the other); a re-import rewrites that variant with both files. No emitted byte changes; a Forge with the old shape may be fixed with `forge promote` or by hand. + ### to 0.34.1 - **`forge unify`, `forge promote`, `drift promote` and `import` refuse a tree of craftar's Forge cache** passed as the Forge directory (`--forge `, or — for the first three — a workspace's `forge:` path), as `init --new-profile` has since 0.34.0. A cache tree is a clean, tracked git repository with a commit, so the git gates of the first three passed (`import` has none) and the edit landed in the tree that every workspace reading that Forge at that commit plans from. `import` also refuses a `--forge` it would create inside the cache. `drift promote` gets it before the `--forge` match, whose messages print the paths they compare. `forge unify --save-plan` still reads a tree. From eb4457ae074695863ca64212a4bc9794db8b9003 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 13:57:59 -0300 Subject: [PATCH 03/13] fix(sync): warn about an unresolved placeholder only when its key is declared --- src/core/sync.ts | 10 +- test/cli.test.ts | 14 +-- test/doctor.test.ts | 7 +- test/integrations-resolve.test.ts | 9 +- test/param-warning-declared.test.ts | 143 ++++++++++++++++++++++++++++ test/plan.test.ts | 12 ++- test/repos-cli.test.ts | 5 +- test/resolve.test.ts | 5 +- test/unset-params.test.ts | 4 +- 9 files changed, 184 insertions(+), 25 deletions(-) create mode 100644 test/param-warning-declared.test.ts diff --git a/src/core/sync.ts b/src/core/sync.ts index 190e62c0..1bc778f5 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -19,6 +19,7 @@ import { AGENTS_MD_BUDGET_KEY, agentsMd } from "../emitters/agents-md.js"; import type { EmitBase, Emitter, PlannedFile } from "../emitters/types.js"; import { written } from "./capabilities.js"; import { planServices } from "./services.js"; +import { INTEGRATIONS_PREFIX, readsIntegrations } from "./integrations.js"; export const WORKSPACE_FILE = "craftar.yaml"; export const LOCAL_FILE = "craftar.local.yaml"; @@ -626,8 +627,13 @@ export async function plan(ws: Workspace): Promise { const declared = declaredWithoutValue({ resolution }); for (const [key, refs] of [...missingParams].sort(([a], [b]) => a.localeCompare(b))) { const warning = `param "${key}" has no value in any layer — left verbatim (${[...refs].sort().join(", ")})`; - warnings.push(warning); - missing.push({ key, refs: [...refs].sort(), warning, declaredBy: [...(declared.get(key) ?? [])].sort() }); + const declaredBy = [...(declared.get(key) ?? [])].sort(); + // Warn only when the key is declared, or when it is under `integrations.` in a Forge at schema 3+. + // An undeclared key (Angular's `{{ 'X' | localize }}`, a stray `{{record.foo}}`) is left silently. + if (declaredBy.length > 0 || (readsIntegrations(ws.forge.manifest) && key.startsWith(INTEGRATIONS_PREFIX))) { + warnings.push(warning); + } + missing.push({ key, refs: [...refs].sort(), warning, declaredBy }); } const params: Plan["params"] = new Map(); for (const [ref, { ing, keys }] of cited) { diff --git a/test/cli.test.ts b/test/cli.test.ts index 7e5f0bac..81e31e5b 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -56,7 +56,7 @@ describe("cli", () => { expect(keys.filter((k) => k === ".git" || k.startsWith(".git/"))).toEqual([]); }); - it("status prints the unresolved-param warning and exits 0; sync --check still passes", async () => { + it("status with an undeclared {{key}}: no param warning, exits 0; sync --check still passes", async () => { const s = await scenario( { ingredients: [rule("a", "Org: {{missing}}\n")], recipes: [recipe("base", ["rule/a"])], profiles: [profile("acme", ["base"])] }, { config: { profile: "acme" } }, @@ -65,7 +65,8 @@ describe("cli", () => { const st = runCli(["status", "--workspace", s.wsRoot]); expect(st.code).toBe(0); - expect(st.stdout).toContain('param "missing" has no value in any layer'); + // An undeclared key is left verbatim silently — no warning + expect(st.stdout).not.toContain('param "missing"'); expect(runCli(["sync", "--workspace", s.wsRoot]).code).toBe(0); const check = runCli(["sync", "--check", "--workspace", s.wsRoot]); @@ -4075,7 +4076,7 @@ describe("cli — add recipe / remove recipe (spec 22)", () => { await unmoved(); }); - it("test 13: nothing to sync when no file moves; a plan warning goes to stderr", async () => { + it("test 13: nothing to sync when no file moves; an undeclared param is left silently", async () => { const s = await recipeScenario(); const unmovedS = await guard(s); await setYaml(s, BASE_YAML + "recipes:\n remove: [base]\n"); @@ -4095,7 +4096,8 @@ describe("cli — add recipe / remove recipe (spec 22)", () => { "recipes: base → stack-api → front-c\n" + "next sync: 1 new, 1 orphan — run `craftar sync`\n", ); - expect(w.stderr).toBe('warn param "owner" has no value in any layer — left verbatim (rule/front-c)\n'); + // An undeclared key is left verbatim silently — no warning + expect(w.stderr).toBe(""); await unmovedT(); }); @@ -4324,17 +4326,17 @@ describe("cli — craftar init (spec 23 §9.2)", () => { ]); }); - it("--no-sync: craftar.yaml only, the next sync line and the plan's warnings, nothing registered", async () => { + it("--no-sync: craftar.yaml only, the next sync line, nothing registered; an undeclared param is left silently", async () => { const s = await setup(); const ws = path.join(s.root, "ws"); const r = s.init(ws, ["--no-sync", "--add-recipe", "ph"]); expect(r.code, r.stderr).toBe(0); + // An undeclared key is left verbatim silently — no warning expect(r.stdout).toBe( [ `craftar init — wrote craftar.yaml in ${ws}`, " forge ../forge · profile acme · recipes base → ph · targets claude-code (from the profile)", "next sync: 2 new — run `craftar sync`", - " warn param \"nope\" has no value in any layer — left verbatim (rule/p)", "", ].join("\n"), ); diff --git a/test/doctor.test.ts b/test/doctor.test.ts index 11564481..2bf7dbfe 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -228,7 +228,7 @@ describe("workspace checks", () => { expect(one((await run({ home: f.home, workspace: c })).checks, "lock")).toMatchObject({ level: "error", fix: "upgrade craftar", message: expect.stringMatching(/declares schema 9/) }); }); - it("params: a declared, cited key with no value is one error, naming its citers, and plan does not repeat it", async () => { + it("params: a declared, cited key with no value is one error, naming its citers; undeclared keys are silent", async () => { const f = await fixture({ ingredients: [rule("a", "# {{who}}\n", { params: { who: { description: "w" } } }), rule("b", "# {{who}} too\n"), rule("c", "# {{other}}\n")], recipes: [recipe("base", ["rule/a", "rule/b", "rule/c"])], @@ -239,8 +239,9 @@ describe("workspace checks", () => { expect(one(r.checks, "params")).toMatchObject({ level: "error", message: '"who" declared by rule/a has no value — cited by rule/a, rule/b', fix: "set who in the profile's params or overrides.params" }); const planLines = of(r.checks, "plan").map((c) => c.message); expect(planLines.some((x) => x.includes('param "who"'))).toBe(false); - expect(planLines.some((x) => x.includes('param "other"'))).toBe(true); - expect(of(r.checks, "plan").map((c) => c.level)).toEqual(["warn"]); + // An undeclared key is left verbatim silently — no warning in plan either + expect(planLines.some((x) => x.includes('param "other"'))).toBe(false); + expect(of(r.checks, "plan").map((c) => c.level)).toEqual(["ok"]); }); it("params: cited is an error, cited nowhere stays a warning — one line each, and the summary counts them", async () => { diff --git a/test/integrations-resolve.test.ts b/test/integrations-resolve.test.ts index fb63fb4f..0e08b101 100644 --- a/test/integrations-resolve.test.ts +++ b/test/integrations-resolve.test.ts @@ -63,10 +63,8 @@ describe("spec 33 §9.2 — resolve() reads the effective profile", () => { p2.files.map((f) => [f.path, f.content.toString("base64")]) ); - // the two warnings arrays are toEqual, and each is exactly this - expect(p1.warnings).toEqual([ - 'param "integrations.pm.org" has no value in any layer — left verbatim (rule/where)', - ]); + // the two warnings arrays are toEqual, and each is empty (at schema 2, integrations.* keys are undeclared) + expect(p1.warnings).toEqual([]); expect(p2.warnings).toEqual(p1.warnings); // text check @@ -80,7 +78,8 @@ describe("spec 33 §9.2 — resolve() reads the effective profile", () => { expect(p1s1.files.map((f) => [f.path, f.content.toString("base64")])).toEqual( p1.files.map((f) => [f.path, f.content.toString("base64")]) ); - expect(p1s1.warnings).toEqual(p1.warnings); + // At schema 1, integrations.* keys are also undeclared — no warning + expect(p1s1.warnings).toEqual([]); expect(text(p1s1, ".claude/rules/where.md")).toBe("Org {{integrations.pm.org}}\n"); }); diff --git a/test/param-warning-declared.test.ts b/test/param-warning-declared.test.ts new file mode 100644 index 00000000..95356ec9 --- /dev/null +++ b/test/param-warning-declared.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { loadWorkspace, plan, unsetDeclared } from "../src/core/sync.js"; +import { profile, recipe, rule, scenario, type ForgeSpec } from "./helpers/forge.js"; +import { forgeAt } from "./helpers/integrations.js"; +import { runCli } from "./helpers/cli.js"; + +// Spec: warn about an unresolved {{key}} only when it is declared +// (or, at Forge schema 3+, under the reserved prefix `integrations.`). + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +describe("param warning — declared vs undeclared", () => { + it("1. an undeclared {{key}} is left verbatim with no warning", async () => { + // rule r with body "Use {{record.foo}}.\n", nothing declares record.foo + const spec: ForgeSpec = { + ingredients: [rule("r", "Use {{record.foo}}.\n")], + recipes: [recipe("base", ["rule/r"])], + profiles: [profile("acme", ["base"], ["claude-code"])], + }; + const s = await scenario(spec, { config: { profile: "acme" } }); + cleanups.push(s.cleanup); + + const ws = await loadWorkspace(s.wsRoot); + const p = await plan(ws); + + // No param warning at all + expect(p.warnings.filter((w) => w.startsWith("param "))).toEqual([]); + + // missingParams still records the key + expect(p.missingParams).toEqual([ + { + key: "record.foo", + refs: ["rule/r"], + warning: 'param "record.foo" has no value in any layer — left verbatim (rule/r)', + declaredBy: [], + }, + ]); + + // The planned file content still has the placeholder verbatim + const content = p.files.find((f) => f.path === ".claude/rules/r.md")!.content.toString("utf8"); + expect(content).toBe("Use {{record.foo}}.\n"); + }); + + it("2. a declared key with no value still warns and refuses", async () => { + // rule r declares `params: { org: {} }` (no default) and its body cites {{org}} + const spec: ForgeSpec = { + ingredients: [rule("r", "Org: {{org}}\n", { params: { org: { description: "the organisation" } } })], + recipes: [recipe("base", ["rule/r"])], + profiles: [profile("acme", ["base"], ["claude-code"])], + }; + const s = await scenario(spec, { config: { profile: "acme" } }); + cleanups.push(s.cleanup); + + const ws = await loadWorkspace(s.wsRoot); + const p = await plan(ws); + + // The warning IS present for a declared key + expect(p.warnings).toContain('param "org" has no value in any layer — left verbatim (rule/r)'); + + // unsetDeclared returns the declared-but-unset key + expect(unsetDeclared(p)).toEqual([ + { key: "org", declaredBy: ["rule/r"], citedBy: ["rule/r"] }, + ]); + }); + + it("3. at schema 3 an unset integrations key still warns", async () => { + // Forge schema: 3, rule `where` body "Org {{integrations.pm.org}}\n", + // profile with integrations: { pm: { kind: } } and no org + const spec: ForgeSpec = { + ingredients: [ + rule("workflow", "# Workflow\n"), + rule("where", "Org {{integrations.pm.org}}\n"), + ], + recipes: [ + recipe("base", ["rule/workflow", "rule/where"]), + recipe("pm-azure-devops", [], { slot: "pm" }), + ], + profiles: [ + profile("acme", ["base"], ["claude-code"], { + integrations: { pm: { kind: "azure-devops" } }, // no org key + }), + ], + }; + const s = await forgeAt(3, spec); + cleanups.push(s.cleanup); + + const ws = await loadWorkspace(s.wsRoot); + const p = await plan(ws); + + // The warning IS present for an integrations.* key at schema 3 + expect(p.warnings).toContain('param "integrations.pm.org" has no value in any layer — left verbatim (rule/where)'); + }); + + it("4. at schema 1 an integrations-looking key is undeclared, so no warning", async () => { + // Same body at schema 1, no integrations block + const spec: ForgeSpec = { + ingredients: [ + rule("workflow", "# Workflow\n"), + rule("where", "Org {{integrations.pm.org}}\n"), + ], + recipes: [recipe("base", ["rule/workflow", "rule/where"])], + profiles: [profile("acme", ["base"], ["claude-code"])], + }; + const s = await forgeAt(1, spec); + cleanups.push(s.cleanup); + + const ws = await loadWorkspace(s.wsRoot); + const p = await plan(ws); + + // No param warning — it's undeclared and not under schema 3 + expect(p.warnings.filter((w) => w.startsWith("param "))).toEqual([]); + + // But missingParams still records it + expect(p.missingParams.some((m) => m.key === "integrations.pm.org")).toBe(true); + }); + + it("5. CLI: doctor --strict passes on a workspace whose only unresolved {{…}} is undeclared", async () => { + // Sync the workspace of case 1, then doctor --strict → exit 0 + const spec: ForgeSpec = { + ingredients: [rule("r", "Use {{record.foo}}.\n")], + recipes: [recipe("base", ["rule/r"])], + profiles: [profile("acme", ["base"], ["claude-code"])], + }; + const s = await scenario(spec, { config: { profile: "acme" } }); + cleanups.push(s.cleanup); + + // Sync first using runCli (so workspace is registered) + const syncR = runCli(["sync", "--workspace", s.wsRoot]); + expect(syncR.code).toBe(0); + + // Run doctor --strict + const r = runCli(["doctor", "--strict", "--workspace", s.wsRoot]); + + // Should exit 0 (no warn or error for undeclared params) + expect(r.code).toBe(0); + + // stdout should not contain record.foo — it's undeclared, no warning + expect(r.stdout).not.toContain("record.foo"); + }); +}); diff --git a/test/plan.test.ts b/test/plan.test.ts index 7085408f..1b88791c 100644 --- a/test/plan.test.ts +++ b/test/plan.test.ts @@ -23,9 +23,12 @@ async function planFor(ingredients: IngredientSpec[], refs: string[], profileExt const BODY = "Org: {{scm.org}}\nTitle: {{ 'X' | localize }}\n"; describe("plan warnings", () => { - it("reports each unresolved param once, with the ingredients citing it", async () => { + it("an undeclared param is left verbatim with no warning, but is still in missingParams", async () => { const p = await planFor([rule("a", BODY), rule("b", "Also {{scm.org}}\n")], ["rule/a", "rule/b"]); - expect(p.warnings.filter((w) => w.startsWith("param "))).toEqual(['param "scm.org" has no value in any layer — left verbatim (rule/a, rule/b)']); + // An undeclared key is left verbatim silently — no warning + expect(p.warnings.filter((w) => w.startsWith("param "))).toEqual([]); + // But it's recorded in missingParams + expect(p.missingParams.map((m) => m.key)).toContain("scm.org"); const a = p.files.find((f) => f.path === ".claude/rules/a.md")!.content.toString("utf8"); expect(a).toContain("{{scm.org}}"); expect(a).toContain("{{ 'X' | localize }}"); @@ -295,10 +298,11 @@ describe("sections — order: sections first, then params (spec 11 §6.4)", () = expect(out(p, ".claude/rules/r.md")).toBe("{{ 'A' | localize }}\n{{ 'B' | localize }}\n{{ 'C' | localize }}\n"); }); - it("the unresolved-param warning counts the expanded text: a replaced default's key is not reported, a value's is", async () => { + it("a replaced default's key is not reported, a value's undeclared key is left silently", async () => { const body = ["# R", OPEN("a"), "{{gone}}", CLOSE, ""].join("\n"); const p = await sectionPlan({ ingredients: [rule("r", body)], profileExtra: { sections: { "rule/r": { a: "{{kept}}" } } }, targets: ["claude-code"] }); - expect(p.warnings.filter((w) => w.startsWith("param "))).toEqual(['param "kept" has no value in any layer — left verbatim (rule/r)']); + // An undeclared key is left verbatim silently — no warning + expect(p.warnings.filter((w) => w.startsWith("param "))).toEqual([]); }); it("records each section's layer for explain", async () => { diff --git a/test/repos-cli.test.ts b/test/repos-cli.test.ts index a71711ab..fbd4ef26 100644 --- a/test/repos-cli.test.ts +++ b/test/repos-cli.test.ts @@ -115,14 +115,15 @@ describe("craftar repos — the listing", () => { expect(r.code).toBe(0); expect(r.stdout).toBe("profile acme lists no repositories\n"); }); - it("a profile with no entry whose plan warns: the warn line follows the one line (review round 3)", async () => { + it("a profile with no entry and an undeclared param: no warn line (undeclared keys are left silently)", async () => { const s = await setup({ repos: [] }); await fs.writeFile(path.join(s.forge, "ingredients", "rules", "a", "rule.md"), "# A {{nobody.sets.this}}\n"); const r = s.run(["repos"]); expect(r.code, r.stderr).toBe(0); const lines = r.stdout.split("\n"); expect(lines[0]).toBe("profile acme lists no repositories"); - expect(lines[1]).toMatch(/^ warn .*nobody\.sets\.this/); + // An undeclared key is left verbatim silently — no warning line + expect(lines.length).toBeLessThanOrEqual(2); // "profile acme lists no repositories" + empty line }); it("C4: the block does not read — exit 1", async () => { const s = await setup({ repos: [{ name: "a" }] }); diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 89ce537d..01375bcf 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -96,11 +96,12 @@ describe("the ingredient default layer (spec 09 §5.4)", () => { return { a: text(".claude/rules/a.md"), b: text(".claude/rules/b.md"), warnings: p.warnings }; } - it("fills the declaring ingredient only; another ingredient's placeholder stays literal and warned", async () => { + it("fills the declaring ingredient only; another ingredient's placeholder stays literal (no warning)", async () => { const r = await rendered(); expect(r.a).toBe("api: ingredient\n"); expect(r.b).toBe("api: {{deploy.api}}\n"); - expect(r.warnings.join("\n")).toContain('param "deploy.api" has no value in any layer — left verbatim (rule/b)'); + // An undeclared key is left verbatim silently — no warning + expect(r.warnings.join("\n")).not.toContain("deploy.api"); }); it("is the weakest layer: recipe, profile, workspace and local each override it", async () => { diff --git a/test/unset-params.test.ts b/test/unset-params.test.ts index e54ca1ba..e727c945 100644 --- a/test/unset-params.test.ts +++ b/test/unset-params.test.ts @@ -33,13 +33,15 @@ async function planned(f: ForgeSpec, w: WorkspaceSpec = { config: { profile: "ac const ORG_WARNING = 'param "org" has no value in any layer — left verbatim (rule/a, rule/b)'; describe("unset declared parameters — plan", () => { - it("missingParams names who declares each key: the declaring ingredient, or nobody", async () => { + it("missingParams names who declares each key: the declaring ingredient, or nobody; only declared keys warn", async () => { const { p } = await planned(forge()); expect(p.missingParams).toEqual([ { key: "free", refs: ["rule/b"], warning: 'param "free" has no value in any layer — left verbatim (rule/b)', declaredBy: [] }, { key: "org", refs: ["rule/a", "rule/b"], warning: ORG_WARNING, declaredBy: ["rule/a"] }, ]); + // Only the declared key's warning is in p.warnings expect(p.warnings).toContain(ORG_WARNING); + expect(p.warnings).not.toContain('param "free" has no value in any layer — left verbatim (rule/b)'); }); it("unsetDeclared keeps only the declared ones, and the plan still holds the files with {{org}} verbatim", async () => { From 3b0fd9713212c36413f665f7ea2d36c8c5d530d8 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 13:58:10 -0300 Subject: [PATCH 04/13] docs(readme): placeholder warnings and the doctor --strict exit --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3f28cea3..5fec4e63 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | `craftar repos clone […] [--workspace .]` | Clones the `missing` entries — every one, or the names given (compared without case) — as `init --clone` clones them; typing the command is the consent, and nothing is asked. A `present` entry is skipped with a line; a named one is refused. Refused before any clone, exit 1: no `git` on `PATH`; a remote Forge that cannot be fetched (it never clones from a cached copy, and has no `--offline`); a profile with no entry; a block that does not read; any `blocked` entry; a name that is not an entry, or is given twice. It clones whatever the workspace's sync state, and exits 1 when a clone fails, with the command to run it by hand. It writes the clones only: no `craftar.yaml`, lock or registry entry. | | `craftar import --from claude-code --forge --profile [--workspace .] [--write-config] [--report ] [--prefer-kiro ]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). A rule's scope comes, in this order, from: a hand-written `.kiro/steering/.md` whose frontmatter sets `inclusion`; a usable `paths:` in the rule's own frontmatter — a non-empty one-line string or a list of them, quoted or not (`- **/*.ts` is read as Claude Code reads it) — which gives `inclusion: fileMatch` and `fileMatchPattern`; a steering mirror craftar generated (its default banner); any other steering file, as before; and `always`. When `paths:` is usable the rule's frontmatter is kept verbatim apart from its body (`frontmatterRaw`), whichever source set the scope, so `.claude/rules/.md` is written back byte for byte and neither `.kiro/steering/.md` nor `AGENTS.md` carries it — except a file whose closing `---` is not followed by a newline, which keeps its frontmatter in its body and is written back as is; its steering mirror — and `AGENTS.md`, where it embeds the rule — then carry the block. What the workspace sees next, for a rule a craftar before this one read as always-on: its `.kiro/steering/.md`, `AGENTS.md` and the `.kiro/agents/*.json` of agents bound to the rule now differ from the plan — where `craftar.lock` records them unedited, `sync` updates them; with no lock entry for them (a first import), they read `collision` and are left alone: delete them and run `craftar sync`. Four warnings name the rule and its files, never the pattern: the hand-written steering file and `paths:` disagree (the steering file's scope is kept); the generated mirror disagrees with `paths:` (scope taken from `paths:`); the steering file sets no `inclusion` (scope taken from `paths:`); `paths:` is not a string or a list of strings (ignored). With `--prefer-kiro`, a steering body that starts with a frontmatter block (as an earlier craftar wrote a `paths:` rule) is taken with it, as before, and the rule keeps no separate frontmatter. Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or above is left as it is). A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a Forge at schema 3 or above, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe (integrations.): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a `--forge` inside craftar's Forge cache (a tree there, or a directory it would create there), a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report ` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule` (not one `--prefer-kiro` took the rule from); and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report `; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro ` takes the named rules from `.kiro/steering/.md` instead of `.claude/rules/.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: )`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro — source .kiro/steering/.md (N lines; .claude/rules/.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/.md` again and lands that older text as a `--` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as `sync` renders it (its declared params substituted into its `server`): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value `sync` will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no `params` (it declares nothing, so `sync` writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. | | `craftar status` | With a remote Forge, the header's second line names it — `forge @ (default branch)> `, plus `· lock ` when the Forge moved since the last sync — and `--json` carries `forge: {kind, source, ref, defaultBranch, commit, lockCommit, fetched}` for every Forge. Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling: `{forge, statuses, warnings, unsetParams}`. Exits 0 whenever it prints its listing, with two exceptions: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1; and when `AGENTS.md` is over an explicit budget (see *Targets › agents-md*) it prints the listing, then that block on stderr, and exits 1. In both cases `--json` still prints the whole object — with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none) and the budget line in `warnings` — and prints no block on stderr. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). | -| `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2, or 3 when the workspace has a service — see *Local services*; it records the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync, or when `AGENTS.md` is over an explicit budget (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default); `craftar drift` lists them, discards one, or promotes one to the Forge. A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. **Refuses when a declared parameter has no value**: a key an ingredient declares under `params` with no `default`, that a file the plan writes cites as `{{key}}`, and that no layer sets (recipe default, profile `params`, `overrides.params` in `craftar.yaml` or `craftar.local.yaml`). The plan's warnings print, then one block on stderr — `error: N declared parameter(s) have no value — nothing written`, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and `sync` exits 1 with nothing written: no file, no lock, no registry entry; `--dry-run` exits 1 the same way, and `--check` prints its listing, then the block, and exits 1 even when every file is `unchanged`. A `{{key}}` no ingredient declares (an Angular `{{ 'X' \| localize }}`, a stray placeholder) is still left verbatim with a warning, and an empty string is a value. (That is a body; a `{{key}}` in an MCP server of a schema 4 Forge follows *Server params*.) | +| `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2, or 3 when the workspace has a service — see *Local services*; it records the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync, or when `AGENTS.md` is over an explicit budget (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default); `craftar drift` lists them, discards one, or promotes one to the Forge. A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. **Refuses when a declared parameter has no value**: a key an ingredient declares under `params` with no `default`, that a file the plan writes cites as `{{key}}`, and that no layer sets (recipe default, profile `params`, `overrides.params` in `craftar.yaml` or `craftar.local.yaml`). The plan's warnings print, then one block on stderr — `error: N declared parameter(s) have no value — nothing written`, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and `sync` exits 1 with nothing written: no file, no lock, no registry entry; `--dry-run` exits 1 the same way, and `--check` prints its listing, then the block, and exits 1 even when every file is `unchanged`. A `{{key}}` no ingredient declares (an Angular `{{ 'X' \| localize }}`, a stray placeholder) is left verbatim silently, and an empty string is a value. (That is a body; a `{{key}}` in an MCP server of a schema 4 Forge follows *Server params*.) | | `craftar diff [path] [--exit-code]` | Line diff between disk and what the Forge would generate, for every file `sync --check` refuses: `update`, `drift`, `collision`, `new`, an `orphan` (shown as the removal of the whole file — the next `sync` deletes it) and an `orphan-drift` (a header and one line: `sync` keeps it). For `.claude/settings.craftar.example.json`, when it has drifted or collides, `diff` prints the headers and `content not shown: an example file may hold a value typed by hand`. `[path]` limits it to the one file whose workspace-relative path, as `status` prints it, equals it. Exits 0 by default, differences or not. `--exit-code` exits 1 when there are differences, or when `AGENTS.md` is over an explicit budget — exactly when `sync --check` would — and makes a `[path]` that names no file craftar manages an error (exit 1, nothing on stdout); without the flag such a path prints `no differences`. With a remote Forge, `--exit-code` also exits 1 when the fetch fails, as `sync --check` does; plain `diff` reads the cached copy with a warning. When a declared parameter a file cites has no value (see `sync`), the refusal is printed on stderr after the diff: plain `diff` still exits 0, `--exit-code` with no `[path]` exits 1 even with `no differences` — as `sync --check` does — and with a `[path]` its exit code answers for that file only. The `AGENTS.md` budget block (see *Targets › agents-md*) is printed the same way, on stderr in every form, and sets the exit only for `--exit-code` with no `[path]`. | | `craftar drift [show] [path] [--workspace ] [--offline] [--json]` | The hand-edited files (`drift`, `orphan-drift`), whether the Forge also changed each, whether it can be promoted; with a path, its diff. In a Forge at schema 3 or above, a file the profile's `integrations` block alone provides reads `promote: no (provided by integrations.)` — a service file reads `promote: no (service file)` there too, whoever provides it. In a workspace whose profile's block holds a `kind` and that does not resolve on its own (its `recipes.remove` takes out a missing integration recipe, say), `show` warns that what the block provides could not be computed: a file the block provides then reads `promote: yes`, and `drift promote` refuses it. `show` does not plan a promote, so a file `drift promote` refuses by a check only its plan makes reads `promote: yes` too — a recipe chain (D17), or a recipe a profile's block selects that another profile resolves, or that a profile which does not resolve might (D19). Read-only. `--json` is a contract: `{workspace, profile, files: [{path, state, target, ingredient, forge, promotable, reason}], warnings, unsetParams}`. Exits 0 whenever it prints its listing, with one exception: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1 — `--json` still prints the whole object, with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none). | | `craftar drift discard [--workspace ] [--dry-run] [--offline]` | A `sync` that regenerates only the named hand-edited files and removes a named hand-edited orphan; everything else `sync` would do happens too. A path that is not `drift` or `orphan-drift` refuses the whole run before any write. `--dry-run`. | @@ -315,7 +315,7 @@ overrides: ingredients: {disable: []} ``` -Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for `forge unify` and `import` — plus, in a schema 4 Forge, the keys an MCP server cites **and its ingredient declares** (see *Server params*); a placeholder in a body with no value in any layer is left untouched and reported as a warning by `status` and `sync` (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently). When the key is one an ingredient **declares** under `params` with no `default`, that is also an error: `sync` refuses and writes nothing (see `sync` in *Commands*). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. +Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for `forge unify` and `import` — plus, in a schema 4 Forge, the keys an MCP server cites **and its ingredient declares** (see *Server params*); a placeholder whose key an ingredient declares but has no value in any layer is left untouched, reported as a warning by `status` and `sync`, and refuses the sync when cited (see `sync` in *Commands*); a placeholder whose key is under `integrations.` in a Forge at schema 3 or above is warned the same way (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently, and any other `{{key}}` no ingredient declares is left verbatim **silently**). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. Sections layer the same way, weakest → strongest: the body's default → the profile's `sections` → `craftar.yaml`'s `overrides.sections` → `craftar.local.yaml`'s, merged per section, so a workspace that sets one section of an ingredient keeps the profile's others. There is no recipe layer for sections. @@ -524,6 +524,7 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; ### to 0.34.2 - **`import` now creates one script/hook ingredient per base name**, listing every file that shares that name (e.g., `run-x.ps1` and `run-x.sh` become one `script/run-x` with `files: ["run-x.ps1", "run-x.sh"]`). A Forge imported before keeps its shape (a base with one file and a `--` variant with the other); a re-import rewrites that variant with both files. No emitted byte changes; a Forge with the old shape may be fixed with `forge promote` or by hand. +- **An unresolved `{{key}}` is now warned only when the key is declared** (or, at Forge schema 3 or above, under `integrations.`). Any other `{{…}}` is left verbatim silently. This affects `sync`, `status` (their `warnings` arrays in `--json` may be shorter; the keys stay the same), `diff`, `workspaces`, `forge impact`, `craftar mcp` and `craftar ui`. `doctor`'s `plan` check may go from `warn` to `ok` for an undeclared key, so **`doctor --strict` may exit 0 where it exited 1**. A typo in an undeclared placeholder key is no longer reported — only declared keys that have no value are. ### to 0.34.1 From 6d3c0025e64381d623fc1556b8041b6a3c4b8e82 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 14:02:36 -0300 Subject: [PATCH 05/13] test(import): keep the same-run staging test on a pair of skills --- test/importer.test.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/test/importer.test.ts b/test/importer.test.ts index 67893e81..abb9a34e 100644 --- a/test/importer.test.ts +++ b/test/importer.test.ts @@ -327,7 +327,7 @@ describe("import --from claude-code — all checks before the first write", () = expect(await snapshot(t.forge)).toEqual(before); }); - it("compares a later ingredient against one staged earlier in the same run", async () => { + it("two hook files with one base name are one ingredient", async () => { // Two hook files with the same base name: they are now grouped into one ingredient listing both. // (Before the fix, the second saw the first as already in the Forge and became a variant.) const t = await setup(); @@ -347,6 +347,27 @@ describe("import --from claude-code — all checks before the first write", () = const meta = YAML.parse(await fs.readFile(path.join(t.forge, "ingredients/hooks/guard/ingredient.yaml"), "utf8")); expect(meta.files).toEqual(["guard.ps1", "guard.sh"]); }); + + it("compares a later ingredient against one staged earlier in the same run", async () => { + // .claude/skills/x (dir) and .claude/skills/x.md (file) both map to skill/x. + // The loop sorts entries by name, so x (dir) comes before x.md (file). + // The first one gets the base, the second becomes a variant because the base is already staged. + const t = await setup(); + await writeFiles(t.ws("api"), { + ".claude/rules/workflow.md": "# Workflow\n", + ".claude/skills/x/SKILL.md": "---\nname: x\ndescription: one\n---\n\nbody one\n", + ".claude/skills/x.md": "---\nname: x\ndescription: two\n---\n\nbody two\n", + }); + const r = await importInto(t.forge, t.ws("api"), "api"); + expect(r.created).toContain("skill/x"); + expect(r.variants).toEqual([{ name: "skill/x--api", reason: "differs from skill/x already in the Forge" }]); + // Both ingredient directories exist + expect(await exists(path.join(t.forge, "ingredients/skills/x/"))).toBe(true); + expect(await exists(path.join(t.forge, "ingredients/skills/x--api/"))).toBe(true); + // The base comes from the dir layout (x/ → processed first), the variant from the file layout (x.md) + expect(await fs.readFile(path.join(t.forge, "ingredients/skills/x/SKILL.md"), "utf8")).toBe("---\nname: x\ndescription: one\n---\n\nbody one\n"); + expect(await fs.readFile(path.join(t.forge, "ingredients/skills/x--api/SKILL.md"), "utf8")).toBe("---\nname: x\ndescription: two\n---\n\nbody two\n"); + }); }); /** Every file under `root`, POSIX-relative, with its bytes. */ From dcae5e90f81e88e069bfb00ab03b5d69c232bed8 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 14:38:52 -0300 Subject: [PATCH 06/13] test(import): probe case sensitivity before collecting the case test Also add proper assertions in test 7 (S3), test 9 for grouped script rejection location (S1), and unit tests for mergeEmitted (N5). --- test/import-script-groups.test.ts | 128 +++++++++++++++++++++++++----- 1 file changed, 109 insertions(+), 19 deletions(-) diff --git a/test/import-script-groups.test.ts b/test/import-script-groups.test.ts index d34201c8..91159e67 100644 --- a/test/import-script-groups.test.ts +++ b/test/import-script-groups.test.ts @@ -1,12 +1,25 @@ import { afterEach, describe, expect, it } from "vitest"; -import { promises as fs } from "node:fs"; +import { promises as fs, mkdtempSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; import YAML from "yaml"; -import { importClaudeCode } from "../src/importers/claude-code.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { importClaudeCode, mergeEmitted, type EmittedSource } from "../src/importers/claude-code.js"; import { exists } from "../src/core/forge.js"; import { loadWorkspace, plan } from "../src/core/sync.js"; import { tmpDir, writeFiles } from "./helpers/forge.js"; +// Synchronous probe at module load: case-insensitive on NTFS/APFS, case-sensitive on most Linux. +const caseInsensitiveFs = (() => { + const dir = mkdtempSync(path.join(os.tmpdir(), "craftar-case-")); + try { + writeFileSync(path.join(dir, "probe.txt"), "probe"); + return existsSync(path.join(dir, "PROBE.TXT")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +})(); + const cleanups: Array<() => Promise> = []; afterEach(async () => { while (cleanups.length) await cleanups.pop()!(); @@ -20,18 +33,6 @@ const importInto = (forge: string, workspaceRoot: string, profileName: string) = importClaudeCode({ workspaceRoot, forgeRoot: forge, profileName }); const yaml = async (f: string) => YAML.parse(await fs.readFile(f, "utf8")); -// Detect case-insensitive file system at module load -let caseInsensitiveFs = false; -(async () => { - const probeTmpDir = await tmpDir("craftar-fs-probe-"); - try { - await fs.writeFile(path.join(probeTmpDir, "probe.txt"), "probe"); - caseInsensitiveFs = await exists(path.join(probeTmpDir, "PROBE.TXT")); - } finally { - await fs.rm(probeTmpDir, { recursive: true, force: true }); - } -})(); - describe("import groups scripts/hooks by base name", () => { it("1. two scripts with one base name become one ingredient listing both", async () => { const t = await setup(); @@ -210,9 +211,13 @@ describe("import groups scripts/hooks by base name", () => { }); const r = await importInto(t.forge, t.ws("a"), "acme"); - // Record for report - console.log("Case 7 — r.warnings:", r.warnings); - console.log("Case 7 — r.variants:", r.variants); + // The re-import produces a variant (old shape differs from new) + expect(r.variants).toEqual([{ name: "script/run-x--acme", reason: "differs from script/run-x already in the Forge" }]); + + // The recipe no longer lists script/run-x but lists the variant + const recipeMeta = await yaml(path.join(t.forge, "recipes/base--acme.yaml")); + expect(recipeMeta.ingredients).not.toContain("script/run-x"); + expect(recipeMeta.ingredients).toContain("script/run-x--acme"); // The variant directory should now have BOTH files const variantMeta = await yaml(path.join(t.forge, "ingredients/scripts/run-x--acme/ingredient.yaml")); @@ -226,8 +231,8 @@ describe("import groups scripts/hooks by base name", () => { const ws = await loadWorkspace(t.ws("a")); const p = await plan(ws); - // Record for report - console.log("Case 7 — plan warnings:", p.warnings); + // No duplicate-write warning + expect(p.warnings.filter((w) => w.startsWith("two ingredients write"))).toEqual([]); // Both planned files equal sources const ps1File = p.files.find((f) => f.path === ".claude/scripts/run-x.ps1")!; @@ -278,4 +283,89 @@ describe("import groups scripts/hooks by base name", () => { "ingredients/scripts/run-x--acme/stale.sh is not in the files of script/run-x--acme — sync does not write it; remove it by hand", ); }); + + it("9. a secret in the second file of a group names that file", async () => { + const t = await setup(); + // Assemble token at runtime to avoid tripping the repo's own secret scanner + const token = "ghp_" + "x".repeat(36); + await writeFiles(t.ws("a"), { + ".claude/scripts/run-x.ps1": "Write-Host hi\n", + ".claude/scripts/run-x.sh": "echo hi\ntoken=" + token + "\n", + }); + const r = await importInto(t.forge, t.ws("a"), "acme"); + + // The rejection must name the .sh file (where the secret is), not the .ps1 file + expect(r.rejected).toEqual([ + { name: "script/run-x", reason: "secret-like value (github-token) in .claude/scripts/run-x.sh line 2" }, + ]); + + // No ingredient written + expect(await exists(path.join(t.forge, "ingredients/scripts/run-x"))).toBe(false); + }); +}); + +describe("mergeEmitted", () => { + it("one part is returned as is", () => { + const part: EmittedSource = { + meta: { type: "script", name: "x", files: ["x.sh"], targets: ["claude-code"], tags: [] }, + files: { "x.sh": "echo x\n" }, + scan: {}, + }; + const merged = mergeEmitted([part]); + expect(merged).toBe(part); + }); + + it("three parts: files concatenated in order, union of files/scan, warnings concatenated", () => { + const a: EmittedSource = { + meta: { type: "script", name: "x", files: ["a.sh"], targets: ["claude-code"], tags: [], origin: { workspace: "w", path: ".claude/scripts/a.sh" } }, + files: { "a.sh": "echo a\n" }, + scan: { "a.sh": "scanned a" }, + warnings: ["warn a"], + }; + const b: EmittedSource = { + meta: { type: "script", name: "x", files: ["b.ps1"], targets: ["claude-code"], tags: [], origin: { workspace: "w", path: ".claude/scripts/b.ps1" } }, + files: { "b.ps1": "Write b\n" }, + scan: {}, + }; + const c: EmittedSource = { + meta: { type: "script", name: "x", files: ["c.sh"], targets: ["claude-code"], tags: [], origin: { workspace: "w", path: ".claude/scripts/c.sh" } }, + files: { "c.sh": "echo c\n" }, + scan: { "c.sh": "scanned c" }, + warnings: ["warn c"], + }; + + const merged = mergeEmitted([a, b, c]); + + // Meta from first part, files list concatenated + expect((merged.meta as { files?: string[] }).files).toEqual(["a.sh", "b.ps1", "c.sh"]); + + // Origin is the first part's origin (unchanged) + expect(merged.meta.origin).toEqual({ workspace: "w", path: ".claude/scripts/a.sh" }); + + // Files is the union of all + expect(merged.files).toEqual({ "a.sh": "echo a\n", "b.ps1": "Write b\n", "c.sh": "echo c\n" }); + + // Scan is the union of all + expect(merged.scan).toEqual({ "a.sh": "scanned a", "c.sh": "scanned c" }); + + // Warnings concatenated + expect(merged.warnings).toEqual(["warn a", "warn c"]); + }); + + it("warnings key absent when no part has warnings", () => { + const a: EmittedSource = { + meta: { type: "script", name: "x", files: ["a.sh"], targets: ["claude-code"], tags: [] }, + files: { "a.sh": "echo a\n" }, + scan: {}, + }; + const b: EmittedSource = { + meta: { type: "script", name: "x", files: ["b.sh"], targets: ["claude-code"], tags: [] }, + files: { "b.sh": "echo b\n" }, + scan: {}, + }; + + const merged = mergeEmitted([a, b]); + + expect("warnings" in merged).toBe(false); + }); }); From 6bf65443692392531cf776fec2a4ca8404900a0d Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 14:38:58 -0300 Subject: [PATCH 07/13] fix(import): name the file of a grouped script in a rejection Add fileWhere helper for the three-way path logic (skill dir layout, grouped script/hook, other). Use path.posix.dirname instead of split/slice/join. Copy merged.warnings into report.warnings for the script/hook loop (N1). --- src/importers/claude-code.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/importers/claude-code.ts b/src/importers/claude-code.ts index aa6d522d..a8e544e3 100644 --- a/src/importers/claude-code.ts +++ b/src/importers/claude-code.ts @@ -305,6 +305,10 @@ async function planImport(opts: ImportOptions, stage: ForgeStage): Promise<{ rep continue; } const merged = mergeEmitted(parts); + // Copy any warnings from mergeEmitted into the report + if (merged.warnings) { + for (const w of merged.warnings) report.warnings.push(w); + } read(merged.meta, merged.files, merged.scan, (ref) => addRef(refs.base, ref)); } } @@ -993,6 +997,20 @@ function bodyFromSteering(meta: Ingredient): boolean { return meta.type === "rule" && (meta.origin?.path ?? "").startsWith(".kiro/steering/"); } +/** + * The file path to report in a rejection message (secret, marker). + * - For skill dir layout: base ends with `/`, so returns `base + rel`. + * - For grouped scripts/hooks (files.length > 1): returns `dirname(base) + "/" + rel`. + * - Otherwise: returns `base` unchanged. + */ +function fileWhere(meta: Ingredient, base: string, rel: string): string { + if (meta.type === "skill" && meta.layout === "dir") return `${base}${rel}`; + if ((meta.type === "script" || meta.type === "hook") && meta.files && meta.files.length > 1) { + return `${path.posix.dirname(base)}/${rel}`; + } + return base; +} + /** First secret-like value in an ingredient about to be imported, described by location only. */ export function secretIn(meta: Ingredient, files: Record, scan: Record = {}, lineOffset = 0): string | null { const origin = meta.origin?.path ?? `${meta.type}/${meta.name}`; @@ -1005,7 +1023,7 @@ export function secretIn(meta: Ingredient, files: Record Date: Fri, 23 Oct 2026 14:39:03 -0300 Subject: [PATCH 08/13] fix(sync): warn about a placeholder any ingredient declares Warn when the key is declared by ANY resolved ingredient (with or without a default), not just when declared without a default. A default reaches only its own ingredient's files. --- src/core/sync.ts | 13 ++++++++++--- test/param-warning-declared.test.ts | 29 +++++++++++++++++++++++++++++ test/resolve.test.ts | 5 ++--- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/core/sync.ts b/src/core/sync.ts index 1bc778f5..314fd823 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -625,12 +625,19 @@ export async function plan(ws: Workspace): Promise { assertInsideWorkspace(files); const missing: Plan["missingParams"] = []; const declared = declaredWithoutValue({ resolution }); + // Keys declared by ANY resolved ingredient (with or without default) — for the warning condition + const declaredByAny = new Set(); + for (const ing of resolution.ingredients) { + for (const key of Object.keys(ing.meta.params ?? {})) { + declaredByAny.add(key); + } + } for (const [key, refs] of [...missingParams].sort(([a], [b]) => a.localeCompare(b))) { const warning = `param "${key}" has no value in any layer — left verbatim (${[...refs].sort().join(", ")})`; const declaredBy = [...(declared.get(key) ?? [])].sort(); - // Warn only when the key is declared, or when it is under `integrations.` in a Forge at schema 3+. - // An undeclared key (Angular's `{{ 'X' | localize }}`, a stray `{{record.foo}}`) is left silently. - if (declaredBy.length > 0 || (readsIntegrations(ws.forge.manifest) && key.startsWith(INTEGRATIONS_PREFIX))) { + // Warn when the key is declared by ANY resolved ingredient (with or without default), or when it + // is under `integrations.` in a Forge at schema 3+. An undeclared key is left silently. + if (declaredByAny.has(key) || (readsIntegrations(ws.forge.manifest) && key.startsWith(INTEGRATIONS_PREFIX))) { warnings.push(warning); } missing.push({ key, refs: [...refs].sort(), warning, declaredBy }); diff --git a/test/param-warning-declared.test.ts b/test/param-warning-declared.test.ts index 95356ec9..e2e37c3b 100644 --- a/test/param-warning-declared.test.ts +++ b/test/param-warning-declared.test.ts @@ -140,4 +140,33 @@ describe("param warning — declared vs undeclared", () => { // stdout should not contain record.foo — it's undeclared, no warning expect(r.stdout).not.toContain("record.foo"); }); + + it("6. a key another ingredient declares with a default still warns where it stays literal", async () => { + // Rule `a` declares `deploy.api` with default "x" and cites it + // Rule `b` cites `{{deploy.api}}` and declares nothing + // The warning should fire for b because the key is declared (by a, with a default) + const spec: ForgeSpec = { + ingredients: [ + rule("a", "api: {{deploy.api}}\n", { params: { "deploy.api": { default: "x" } } }), + rule("b", "api: {{deploy.api}}\n"), + ], + recipes: [recipe("base", ["rule/a", "rule/b"])], + profiles: [profile("acme", ["base"], ["claude-code"])], + }; + const s = await scenario(spec, { config: { profile: "acme" } }); + cleanups.push(s.cleanup); + + const ws = await loadWorkspace(s.wsRoot); + const p = await plan(ws); + + // The warning IS present because deploy.api is declared by rule/a + expect(p.warnings).toContain('param "deploy.api" has no value in any layer — left verbatim (rule/b)'); + + // unsetDeclared does NOT include this key, because the declaring ingredient has a default + expect(unsetDeclared(p)).toEqual([]); + + // Rule b's content still has the literal placeholder + const content = p.files.find((f) => f.path === ".claude/rules/b.md")!.content.toString("utf8"); + expect(content).toBe("api: {{deploy.api}}\n"); + }); }); diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 01375bcf..89ce537d 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -96,12 +96,11 @@ describe("the ingredient default layer (spec 09 §5.4)", () => { return { a: text(".claude/rules/a.md"), b: text(".claude/rules/b.md"), warnings: p.warnings }; } - it("fills the declaring ingredient only; another ingredient's placeholder stays literal (no warning)", async () => { + it("fills the declaring ingredient only; another ingredient's placeholder stays literal and warned", async () => { const r = await rendered(); expect(r.a).toBe("api: ingredient\n"); expect(r.b).toBe("api: {{deploy.api}}\n"); - // An undeclared key is left verbatim silently — no warning - expect(r.warnings.join("\n")).not.toContain("deploy.api"); + expect(r.warnings.join("\n")).toContain('param "deploy.api" has no value in any layer — left verbatim (rule/b)'); }); it("is the weakest layer: recipe, profile, workspace and local each override it", async () => { From 5b69be8dcfe8380529d547556c87bfd664a91d5d Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 14:39:08 -0300 Subject: [PATCH 09/13] test(repos): pin the stdout of a profile with no entry --- test/repos-cli.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/repos-cli.test.ts b/test/repos-cli.test.ts index fbd4ef26..939d681c 100644 --- a/test/repos-cli.test.ts +++ b/test/repos-cli.test.ts @@ -123,7 +123,7 @@ describe("craftar repos — the listing", () => { const lines = r.stdout.split("\n"); expect(lines[0]).toBe("profile acme lists no repositories"); // An undeclared key is left verbatim silently — no warning line - expect(lines.length).toBeLessThanOrEqual(2); // "profile acme lists no repositories" + empty line + expect(lines.length).toBe(2); // "profile acme lists no repositories" + empty line from trailing newline }); it("C4: the block does not read — exit 1", async () => { const s = await setup({ repos: [{ name: "a" }] }); From 73dab5f3a884fa2130debd2cfb5cf78346b9479e Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 14:39:12 -0300 Subject: [PATCH 10/13] docs(readme): grouped scripts in the import row and layout; declared placeholders --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5fec4e63..a0c8db77 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | `craftar init [--forge ] [--profile ] [--new-profile [--like ]] [--ref ] [--targets ] [--add-recipe ]... [--remove-recipe ]... [--replace] [--no-sync] [--clone \| --no-clone] [--offline] [--workspace .]` | Starts a workspace: writes a `craftar.yaml` holding `forge` (a directory as the path from the workspace, POSIX; a URL as given), `ref` (a remote Forge only), `profile`, `recipes` (only the lists the recipe flags leave non-empty) and `targets` (only with `--targets`; otherwise the workspace follows the profile's), then runs the first `sync` — which also registers the workspace. **In a terminal** (stdin and stdout both a TTY), `--forge` and `--profile` are optional: what is missing is asked for — the Forge (offering the one last synced on this machine, from the registry), a remote Forge's ref, the profile (from the Forge's list), then the recipes and the targets the profile gives, to keep or adjust; a flag given is never asked for. One confirmation before anything is written, showing what the first sync would do. Ctrl-C, the end of input, or `no` at the confirmation: nothing written, exit 1 (`init cancelled — nothing written`). The run ends with an `again craftar init …` line: the same run as flags, for CI. Off a terminal (a pipe, CI), a missing flag is refused, exit 1, naming the flag; where the terminal does not report a TTY, the flags are required. With both flags given nothing is asked, in a terminal or not. The file is written only once the configuration loads (a remote Forge is fetched as `sync` fetches it; `--offline` reads the cache), resolves and plans, and the sync applies that same plan. `--remove-recipe` and `--add-recipe` go through `remove recipe` / `add recipe`'s core (removes first, then adds; `--replace` swaps a slot holder); a call whose every name changes nothing prints its reasons as a `note`. `--workspace` is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a `collision` otherwise, never overwritten, with one warning pointing at `craftar import`. `--no-sync` writes `craftar.yaml` only and prints what the first `sync` would do. When a declared parameter a file cites has no value (see `sync`), `craftar.yaml` is still written — the workspace's `overrides.params` is a place for the value — but the first `sync` is not run: the refusal is printed (`error: N declared parameter(s) have no value — craftar.yaml written, sync not run`, with `then run craftar sync` in its fix line), exit 1; in a terminal the confirmation says `first sync: refused — …` and asks only `Write craftar.yaml`; `--no-sync` prints `next sync: refused — …` and exits 0. Refused, with nothing written and no directory created: an existing `craftar.yaml`; a `--workspace` that is a file, or under one; credentials in `--forge` (the value is never printed); `--ref` with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of `add recipe` / `remove recipe`; a `craftar.local.yaml` that sets `forge` or `profile`, or a key a given flag would write (`ref`, `recipes`, `targets`) — its value would replace the flag's; a Forge, profile or plan that does not load. **`--clone`** clones every entry of the profile's `repos` (see *Forge layout › Repositories*) into the workspace after the first `sync` — one `git clone --no-recurse-submodules [--branch=] -- ` per entry, one after another, with the workspace root as working directory, `git` found in the absolute entries of `PATH` and `GIT_ALLOW_PROTOCOL=https:ssh`; git's own output and credential prompt go to the terminal, with no timeout. With `--no-sync`, `--clone` still clones. Without `--clone` nothing is cloned: with both `--forge` and `--profile` given nothing is asked and nothing is said about repositories; in a terminal, when a flag is missing, a profile with entries is listed — name, URL, destination, ref — and `Clone them into the workspace [no]:` is asked (Enter is no), except under `--offline` or `--no-clone`. A clone that fails is reported with the command to run it by hand and `craftar repos clone `, the other entries are still tried, and `init` exits 1 with the workspace written, synced and registered. A first sync that would be refused clones nothing (`repositories: not cloned — the first sync is refused`). Refused before anything is written: `--clone` with `--no-clone`, or with `--offline`; `--clone` with no `git` on `PATH`, on a profile with no entry, with an entry that does not read, or with a destination that exists, holds a file `sync` writes, lies inside the Forge, leaves the workspace through a link or sits under a file. A clone is recorded nowhere — not in `craftar.lock`, not in the registry; `sync` never updates or removes it, and craftar does not edit a `.gitignore` (in a workspace that is itself a repository the clones show as untracked directories). **`--new-profile `** creates the profile first: one new file, `profiles//profile.yaml`, in the Forge's working tree — its `name`, `recipes` and `targets` only — then starts the workspace on it as for a profile that was there. The Forge must be a directory that is a git repository with a commit (git is how the write is undone); nothing is committed, no branch is made and nothing is pushed — commit the file in the Forge, on a branch, before anyone else syncs. With `--new-profile`, `--add-recipe`, `--remove-recipe`, `--replace` and `--targets` shape **the profile**, and `craftar.yaml` gets no `recipes` and no `targets`. `--like ` starts the new profile's `recipes` and `targets` from that profile's written lists — nothing else of it is copied: no `params`, `sections`, `integrations`, `repos`, `templates` or `description`; in a Forge at schema 3 or above a recipe only its `integrations` block selects does not come along, and a `note` line says so. The name is lowercase letters and digits in groups joined by one `-` (no `--`, not a Windows device name). Refused before anything is written: `--new-profile` with `--profile`, `--like` without `--new-profile`, a name of another shape, a Forge URL (clone it and pass the directory), a tree of craftar's Forge cache, a directory that is not a git repository with a commit, a name a profile already carries or a `profiles/` that exists, a path git ignores or that goes through a symbolic link, a recipe named for another profile (`--

`, `

-steering` — add the shared recipe it stands for), and a profile that does not resolve or plan. The Forge is planned in memory, the file written, then the Forge read back from disk must plan the same bytes before `craftar.yaml` is written; a failure after the profile was written exits 1 and prints the `git -C clean -fd -- profiles/` line that removes it. In a terminal the profile list offers `0) a new profile` (for a Forge directory that can take one), and a Forge directory with no profile goes straight to the new profile's questions; the `again` line then names `--profile `, the run a second workspace of that client uses. | | `craftar repos [--workspace .] [--offline] [--json]` | Lists the profile's repositories (its `repos`, see *Forge layout › Repositories*) with their state in the workspace — `present` (the destination exists; craftar does not look inside), `missing`, or `blocked` with the reason a clone would be refused — and each one's destination, URL and ref. It loads the Forge as `status` does (a remote Forge is fetched unless `--offline`; a failed fetch is a warning over the cached copy), starts no other program, contacts no repository's host and writes nothing. A profile with no entry prints `profile

lists no repositories`. Exits 0 whenever the block reads, missing entries included; 1 for a block that does not read, or a workspace that does not load or plan, as `status`. `--json`, a tooling contract: `{workspace, profile, repos: [{name, url, path, ref, state, reason}], warnings}` (`ref` and `reason` `null` when absent). | | `craftar repos clone […] [--workspace .]` | Clones the `missing` entries — every one, or the names given (compared without case) — as `init --clone` clones them; typing the command is the consent, and nothing is asked. A `present` entry is skipped with a line; a named one is refused. Refused before any clone, exit 1: no `git` on `PATH`; a remote Forge that cannot be fetched (it never clones from a cached copy, and has no `--offline`); a profile with no entry; a block that does not read; any `blocked` entry; a name that is not an entry, or is given twice. It clones whatever the workspace's sync state, and exits 1 when a clone fails, with the command to run it by hand. It writes the clones only: no `craftar.yaml`, lock or registry entry. | -| `craftar import --from claude-code --forge

--profile [--workspace .] [--write-config] [--report ] [--prefer-kiro ]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). A rule's scope comes, in this order, from: a hand-written `.kiro/steering/.md` whose frontmatter sets `inclusion`; a usable `paths:` in the rule's own frontmatter — a non-empty one-line string or a list of them, quoted or not (`- **/*.ts` is read as Claude Code reads it) — which gives `inclusion: fileMatch` and `fileMatchPattern`; a steering mirror craftar generated (its default banner); any other steering file, as before; and `always`. When `paths:` is usable the rule's frontmatter is kept verbatim apart from its body (`frontmatterRaw`), whichever source set the scope, so `.claude/rules/.md` is written back byte for byte and neither `.kiro/steering/.md` nor `AGENTS.md` carries it — except a file whose closing `---` is not followed by a newline, which keeps its frontmatter in its body and is written back as is; its steering mirror — and `AGENTS.md`, where it embeds the rule — then carry the block. What the workspace sees next, for a rule a craftar before this one read as always-on: its `.kiro/steering/.md`, `AGENTS.md` and the `.kiro/agents/*.json` of agents bound to the rule now differ from the plan — where `craftar.lock` records them unedited, `sync` updates them; with no lock entry for them (a first import), they read `collision` and are left alone: delete them and run `craftar sync`. Four warnings name the rule and its files, never the pattern: the hand-written steering file and `paths:` disagree (the steering file's scope is kept); the generated mirror disagrees with `paths:` (scope taken from `paths:`); the steering file sets no `inclusion` (scope taken from `paths:`); `paths:` is not a string or a list of strings (ignored). With `--prefer-kiro`, a steering body that starts with a frontmatter block (as an earlier craftar wrote a `paths:` rule) is taken with it, as before, and the rule keeps no separate frontmatter. Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or above is left as it is). A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a Forge at schema 3 or above, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe (integrations.): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a `--forge` inside craftar's Forge cache (a tree there, or a directory it would create there), a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report ` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule` (not one `--prefer-kiro` took the rule from); and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report `; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro ` takes the named rules from `.kiro/steering/.md` instead of `.claude/rules/.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: )`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro — source .kiro/steering/.md (N lines; .claude/rules/.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/.md` again and lands that older text as a `--` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as `sync` renders it (its declared params substituted into its `server`): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value `sync` will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no `params` (it declares nothing, so `sync` writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. | +| `craftar import --from claude-code --forge --profile [--workspace .] [--write-config] [--report ] [--prefer-kiro ]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). Scripts and hooks sharing the same base name (e.g., `run-x.ps1` and `run-x.sh`) become one ingredient listing all those files; two that differ only in case refuse the import. A rule's scope comes, in this order, from: a hand-written `.kiro/steering/.md` whose frontmatter sets `inclusion`; a usable `paths:` in the rule's own frontmatter — a non-empty one-line string or a list of them, quoted or not (`- **/*.ts` is read as Claude Code reads it) — which gives `inclusion: fileMatch` and `fileMatchPattern`; a steering mirror craftar generated (its default banner); any other steering file, as before; and `always`. When `paths:` is usable the rule's frontmatter is kept verbatim apart from its body (`frontmatterRaw`), whichever source set the scope, so `.claude/rules/.md` is written back byte for byte and neither `.kiro/steering/.md` nor `AGENTS.md` carries it — except a file whose closing `---` is not followed by a newline, which keeps its frontmatter in its body and is written back as is; its steering mirror — and `AGENTS.md`, where it embeds the rule — then carry the block. What the workspace sees next, for a rule a craftar before this one read as always-on: its `.kiro/steering/.md`, `AGENTS.md` and the `.kiro/agents/*.json` of agents bound to the rule now differ from the plan — where `craftar.lock` records them unedited, `sync` updates them; with no lock entry for them (a first import), they read `collision` and are left alone: delete them and run `craftar sync`. Four warnings name the rule and its files, never the pattern: the hand-written steering file and `paths:` disagree (the steering file's scope is kept); the generated mirror disagrees with `paths:` (scope taken from `paths:`); the steering file sets no `inclusion` (scope taken from `paths:`); `paths:` is not a string or a list of strings (ignored). With `--prefer-kiro`, a steering body that starts with a frontmatter block (as an earlier craftar wrote a `paths:` rule) is taken with it, as before, and the rule keeps no separate frontmatter. Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or above is left as it is). A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a Forge at schema 3 or above, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe (integrations.): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a `--forge` inside craftar's Forge cache (a tree there, or a directory it would create there), a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report ` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule` (not one `--prefer-kiro` took the rule from); and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report `; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro ` takes the named rules from `.kiro/steering/.md` instead of `.claude/rules/.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: )`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro — source .kiro/steering/.md (N lines; .claude/rules/.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/.md` again and lands that older text as a `--` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as `sync` renders it (its declared params substituted into its `server`): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value `sync` will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no `params` (it declares nothing, so `sync` writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. | | `craftar status` | With a remote Forge, the header's second line names it — `forge @ (default branch)> `, plus `· lock ` when the Forge moved since the last sync — and `--json` carries `forge: {kind, source, ref, defaultBranch, commit, lockCommit, fetched}` for every Forge. Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling: `{forge, statuses, warnings, unsetParams}`. Exits 0 whenever it prints its listing, with two exceptions: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1; and when `AGENTS.md` is over an explicit budget (see *Targets › agents-md*) it prints the listing, then that block on stderr, and exits 1. In both cases `--json` still prints the whole object — with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none) and the budget line in `warnings` — and prints no block on stderr. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). | | `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2, or 3 when the workspace has a service — see *Local services*; it records the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync, or when `AGENTS.md` is over an explicit budget (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default); `craftar drift` lists them, discards one, or promotes one to the Forge. A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. **Refuses when a declared parameter has no value**: a key an ingredient declares under `params` with no `default`, that a file the plan writes cites as `{{key}}`, and that no layer sets (recipe default, profile `params`, `overrides.params` in `craftar.yaml` or `craftar.local.yaml`). The plan's warnings print, then one block on stderr — `error: N declared parameter(s) have no value — nothing written`, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and `sync` exits 1 with nothing written: no file, no lock, no registry entry; `--dry-run` exits 1 the same way, and `--check` prints its listing, then the block, and exits 1 even when every file is `unchanged`. A `{{key}}` no ingredient declares (an Angular `{{ 'X' \| localize }}`, a stray placeholder) is left verbatim silently, and an empty string is a value. (That is a body; a `{{key}}` in an MCP server of a schema 4 Forge follows *Server params*.) | | `craftar diff [path] [--exit-code]` | Line diff between disk and what the Forge would generate, for every file `sync --check` refuses: `update`, `drift`, `collision`, `new`, an `orphan` (shown as the removal of the whole file — the next `sync` deletes it) and an `orphan-drift` (a header and one line: `sync` keeps it). For `.claude/settings.craftar.example.json`, when it has drifted or collides, `diff` prints the headers and `content not shown: an example file may hold a value typed by hand`. `[path]` limits it to the one file whose workspace-relative path, as `status` prints it, equals it. Exits 0 by default, differences or not. `--exit-code` exits 1 when there are differences, or when `AGENTS.md` is over an explicit budget — exactly when `sync --check` would — and makes a `[path]` that names no file craftar manages an error (exit 1, nothing on stdout); without the flag such a path prints `no differences`. With a remote Forge, `--exit-code` also exits 1 when the fetch fails, as `sync --check` does; plain `diff` reads the cached copy with a warning. When a declared parameter a file cites has no value (see `sync`), the refusal is printed on stderr after the diff: plain `diff` still exits 0, `--exit-code` with no `[path]` exits 1 even with `no differences` — as `sync --check` does — and with a `[path]` its exit code answers for that file only. The `AGENTS.md` budget block (see *Targets › agents-md*) is printed the same way, on stderr in every form, and sets the exit only for `--exit-code` with no `[path]`. | @@ -123,8 +123,8 @@ forge/ │ ├── agents//{ingredient.yaml, agent.md} │ ├── commands//{ingredient.yaml, command.md} │ ├── skills//{ingredient.yaml, SKILL.md, …} -│ ├── scripts//{ingredient.yaml, } -│ ├── hooks//{ingredient.yaml, } +│ ├── scripts//{ingredient.yaml, …} +│ ├── hooks//{ingredient.yaml, …} │ ├── mcp//ingredient.yaml # server definition (its declared params rendered at schema 4) │ ├── steerings//{ingredient.yaml, steering.md} # Kiro-only, no Claude counterpart │ └── services//{ingredient.yaml, compose.yaml, …} # a Compose fragment and its files — no AI coder's; see Local services @@ -315,7 +315,7 @@ overrides: ingredients: {disable: []} ``` -Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for `forge unify` and `import` — plus, in a schema 4 Forge, the keys an MCP server cites **and its ingredient declares** (see *Server params*); a placeholder whose key an ingredient declares but has no value in any layer is left untouched, reported as a warning by `status` and `sync`, and refuses the sync when cited (see `sync` in *Commands*); a placeholder whose key is under `integrations.` in a Forge at schema 3 or above is warned the same way (Angular's `{{ 'X' | localize }}` does not look like a placeholder and passes silently, and any other `{{key}}` no ingredient declares is left verbatim **silently**). Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. +Layer precedence, weakest → strongest: the ingredient's declared defaults (scoped to that ingredient) → recipe defaults → profile → `craftar.yaml` → `craftar.local.yaml` (personal, git-ignored). Bodies may use `{{param}}` placeholders; a placeholder in a file no target emits is never rendered, and only body files count as a citation for `forge unify` and `import` — plus, in a schema 4 Forge, the keys an MCP server cites **and its ingredient declares** (see *Server params*); a placeholder whose key some ingredient declares under `params` (with or without a default — a default reaches only its own ingredient's files) but has no value in any layer is left untouched, reported as a warning by `status` and `sync`, and refuses the sync when cited (see `sync` in *Commands*); a placeholder whose key is under `integrations.` in a Forge at schema 3 or above is warned the same way; any other `{{key}}` no ingredient declares is left verbatim **silently**. Between layers, objects merge key by key while arrays and scalars from the stronger layer replace the weaker one — `targets: [kiro]` in `craftar.local.yaml` means exactly `[kiro]`. Omit a key in `craftar.local.yaml` to inherit it — an empty list there means empty. Sections layer the same way, weakest → strongest: the body's default → the profile's `sections` → `craftar.yaml`'s `overrides.sections` → `craftar.local.yaml`'s, merged per section, so a workspace that sets one section of an ingredient keeps the profile's others. There is no recipe layer for sections. @@ -523,8 +523,8 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; ### to 0.34.2 -- **`import` now creates one script/hook ingredient per base name**, listing every file that shares that name (e.g., `run-x.ps1` and `run-x.sh` become one `script/run-x` with `files: ["run-x.ps1", "run-x.sh"]`). A Forge imported before keeps its shape (a base with one file and a `--` variant with the other); a re-import rewrites that variant with both files. No emitted byte changes; a Forge with the old shape may be fixed with `forge promote` or by hand. -- **An unresolved `{{key}}` is now warned only when the key is declared** (or, at Forge schema 3 or above, under `integrations.`). Any other `{{…}}` is left verbatim silently. This affects `sync`, `status` (their `warnings` arrays in `--json` may be shorter; the keys stay the same), `diff`, `workspaces`, `forge impact`, `craftar mcp` and `craftar ui`. `doctor`'s `plan` check may go from `warn` to `ok` for an undeclared key, so **`doctor --strict` may exit 0 where it exited 1**. A typo in an undeclared placeholder key is no longer reported — only declared keys that have no value are. +- **`import` now creates one script/hook ingredient per base name**, listing every file that shares that name (e.g., `run-x.ps1` and `run-x.sh` become one `script/run-x` with `files: ["run-x.ps1", "run-x.sh"]`). A Forge imported before keeps its shape (a base with one file and a `--` variant with the other); a re-import rewrites that variant with both files. Two files that differ only in case (on a case-insensitive checkout they would collide) refuse the import and name them. A file left in a variant directory after a re-import that grouped it is warned. No emitted byte changes; a Forge with the old shape may be fixed with `forge promote` or by hand. +- **An unresolved `{{key}}` is now warned only when some ingredient declares the key under `params`** (with or without a default — a default reaches only its own ingredient's files), or, at Forge schema 3 or above, when it is under `integrations.`. Any other `{{…}}` is left verbatim silently. This affects `sync`, `status` (their `warnings` arrays in `--json` may be shorter; the keys stay the same), `diff`, `workspaces`, `forge impact`, `craftar mcp` and `craftar ui`. `doctor`'s `plan` check may go from `warn` to `ok` for an undeclared key, so **`doctor --strict` may exit 0 where it exited 1**. A typo in an undeclared placeholder key is no longer reported — only declared keys that have no value are. ### to 0.34.1 From aa16488051322ac495af5e41e6adcbb02a0246a2 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 14:50:33 -0300 Subject: [PATCH 11/13] test: drop a duplicate import; pin the repos stdout exactly --- test/import-script-groups.test.ts | 1 - test/repos-cli.test.ts | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/test/import-script-groups.test.ts b/test/import-script-groups.test.ts index 91159e67..5e312442 100644 --- a/test/import-script-groups.test.ts +++ b/test/import-script-groups.test.ts @@ -3,7 +3,6 @@ import { promises as fs, mkdtempSync, writeFileSync, existsSync, rmSync } from " import os from "node:os"; import path from "node:path"; import YAML from "yaml"; -import { afterEach, describe, expect, it } from "vitest"; import { importClaudeCode, mergeEmitted, type EmittedSource } from "../src/importers/claude-code.js"; import { exists } from "../src/core/forge.js"; import { loadWorkspace, plan } from "../src/core/sync.js"; diff --git a/test/repos-cli.test.ts b/test/repos-cli.test.ts index 939d681c..6be4fa3d 100644 --- a/test/repos-cli.test.ts +++ b/test/repos-cli.test.ts @@ -120,10 +120,7 @@ describe("craftar repos — the listing", () => { await fs.writeFile(path.join(s.forge, "ingredients", "rules", "a", "rule.md"), "# A {{nobody.sets.this}}\n"); const r = s.run(["repos"]); expect(r.code, r.stderr).toBe(0); - const lines = r.stdout.split("\n"); - expect(lines[0]).toBe("profile acme lists no repositories"); - // An undeclared key is left verbatim silently — no warning line - expect(lines.length).toBe(2); // "profile acme lists no repositories" + empty line from trailing newline + expect(r.stdout).toBe("profile acme lists no repositories\n"); }); it("C4: the block does not read — exit 1", async () => { const s = await setup({ repos: [{ name: "a" }] }); From e5e87fc6efbf21aef67e23c24e2bd8225071011c Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 14:50:36 -0300 Subject: [PATCH 12/13] docs(readme): a case collision rejects one ingredient, not the import --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a0c8db77..a4084dd8 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ From then on, change a rule in `forge/ingredients/rules//rule.md`, run `cr | `craftar init [--forge ] [--profile ] [--new-profile [--like ]] [--ref ] [--targets ] [--add-recipe ]... [--remove-recipe ]... [--replace] [--no-sync] [--clone \| --no-clone] [--offline] [--workspace .]` | Starts a workspace: writes a `craftar.yaml` holding `forge` (a directory as the path from the workspace, POSIX; a URL as given), `ref` (a remote Forge only), `profile`, `recipes` (only the lists the recipe flags leave non-empty) and `targets` (only with `--targets`; otherwise the workspace follows the profile's), then runs the first `sync` — which also registers the workspace. **In a terminal** (stdin and stdout both a TTY), `--forge` and `--profile` are optional: what is missing is asked for — the Forge (offering the one last synced on this machine, from the registry), a remote Forge's ref, the profile (from the Forge's list), then the recipes and the targets the profile gives, to keep or adjust; a flag given is never asked for. One confirmation before anything is written, showing what the first sync would do. Ctrl-C, the end of input, or `no` at the confirmation: nothing written, exit 1 (`init cancelled — nothing written`). The run ends with an `again craftar init …` line: the same run as flags, for CI. Off a terminal (a pipe, CI), a missing flag is refused, exit 1, naming the flag; where the terminal does not report a TTY, the flags are required. With both flags given nothing is asked, in a terminal or not. The file is written only once the configuration loads (a remote Forge is fetched as `sync` fetches it; `--offline` reads the cache), resolves and plans, and the sync applies that same plan. `--remove-recipe` and `--add-recipe` go through `remove recipe` / `add recipe`'s core (removes first, then adds; `--replace` swaps a slot holder); a call whose every name changes nothing prints its reasons as a `note`. `--workspace` is created when missing. Files already on disk and not in a lock are adopted when equal to the plan and skipped as a `collision` otherwise, never overwritten, with one warning pointing at `craftar import`. `--no-sync` writes `craftar.yaml` only and prints what the first `sync` would do. When a declared parameter a file cites has no value (see `sync`), `craftar.yaml` is still written — the workspace's `overrides.params` is a place for the value — but the first `sync` is not run: the refusal is printed (`error: N declared parameter(s) have no value — craftar.yaml written, sync not run`, with `then run craftar sync` in its fix line), exit 1; in a terminal the confirmation says `first sync: refused — …` and asks only `Write craftar.yaml`; `--no-sync` prints `next sync: refused — …` and exits 0. Refused, with nothing written and no directory created: an existing `craftar.yaml`; a `--workspace` that is a file, or under one; credentials in `--forge` (the value is never printed); `--ref` with a directory Forge; an unknown target; a recipe both added and removed, or any refusal of `add recipe` / `remove recipe`; a `craftar.local.yaml` that sets `forge` or `profile`, or a key a given flag would write (`ref`, `recipes`, `targets`) — its value would replace the flag's; a Forge, profile or plan that does not load. **`--clone`** clones every entry of the profile's `repos` (see *Forge layout › Repositories*) into the workspace after the first `sync` — one `git clone --no-recurse-submodules [--branch=] -- ` per entry, one after another, with the workspace root as working directory, `git` found in the absolute entries of `PATH` and `GIT_ALLOW_PROTOCOL=https:ssh`; git's own output and credential prompt go to the terminal, with no timeout. With `--no-sync`, `--clone` still clones. Without `--clone` nothing is cloned: with both `--forge` and `--profile` given nothing is asked and nothing is said about repositories; in a terminal, when a flag is missing, a profile with entries is listed — name, URL, destination, ref — and `Clone them into the workspace [no]:` is asked (Enter is no), except under `--offline` or `--no-clone`. A clone that fails is reported with the command to run it by hand and `craftar repos clone `, the other entries are still tried, and `init` exits 1 with the workspace written, synced and registered. A first sync that would be refused clones nothing (`repositories: not cloned — the first sync is refused`). Refused before anything is written: `--clone` with `--no-clone`, or with `--offline`; `--clone` with no `git` on `PATH`, on a profile with no entry, with an entry that does not read, or with a destination that exists, holds a file `sync` writes, lies inside the Forge, leaves the workspace through a link or sits under a file. A clone is recorded nowhere — not in `craftar.lock`, not in the registry; `sync` never updates or removes it, and craftar does not edit a `.gitignore` (in a workspace that is itself a repository the clones show as untracked directories). **`--new-profile `** creates the profile first: one new file, `profiles//profile.yaml`, in the Forge's working tree — its `name`, `recipes` and `targets` only — then starts the workspace on it as for a profile that was there. The Forge must be a directory that is a git repository with a commit (git is how the write is undone); nothing is committed, no branch is made and nothing is pushed — commit the file in the Forge, on a branch, before anyone else syncs. With `--new-profile`, `--add-recipe`, `--remove-recipe`, `--replace` and `--targets` shape **the profile**, and `craftar.yaml` gets no `recipes` and no `targets`. `--like ` starts the new profile's `recipes` and `targets` from that profile's written lists — nothing else of it is copied: no `params`, `sections`, `integrations`, `repos`, `templates` or `description`; in a Forge at schema 3 or above a recipe only its `integrations` block selects does not come along, and a `note` line says so. The name is lowercase letters and digits in groups joined by one `-` (no `--`, not a Windows device name). Refused before anything is written: `--new-profile` with `--profile`, `--like` without `--new-profile`, a name of another shape, a Forge URL (clone it and pass the directory), a tree of craftar's Forge cache, a directory that is not a git repository with a commit, a name a profile already carries or a `profiles/` that exists, a path git ignores or that goes through a symbolic link, a recipe named for another profile (`--

`, `

-steering` — add the shared recipe it stands for), and a profile that does not resolve or plan. The Forge is planned in memory, the file written, then the Forge read back from disk must plan the same bytes before `craftar.yaml` is written; a failure after the profile was written exits 1 and prints the `git -C clean -fd -- profiles/` line that removes it. In a terminal the profile list offers `0) a new profile` (for a Forge directory that can take one), and a Forge directory with no profile goes straight to the new profile's questions; the `again` line then names `--profile `, the run a second workspace of that client uses. | | `craftar repos [--workspace .] [--offline] [--json]` | Lists the profile's repositories (its `repos`, see *Forge layout › Repositories*) with their state in the workspace — `present` (the destination exists; craftar does not look inside), `missing`, or `blocked` with the reason a clone would be refused — and each one's destination, URL and ref. It loads the Forge as `status` does (a remote Forge is fetched unless `--offline`; a failed fetch is a warning over the cached copy), starts no other program, contacts no repository's host and writes nothing. A profile with no entry prints `profile

lists no repositories`. Exits 0 whenever the block reads, missing entries included; 1 for a block that does not read, or a workspace that does not load or plan, as `status`. `--json`, a tooling contract: `{workspace, profile, repos: [{name, url, path, ref, state, reason}], warnings}` (`ref` and `reason` `null` when absent). | | `craftar repos clone […] [--workspace .]` | Clones the `missing` entries — every one, or the names given (compared without case) — as `init --clone` clones them; typing the command is the consent, and nothing is asked. A `present` entry is skipped with a line; a named one is refused. Refused before any clone, exit 1: no `git` on `PATH`; a remote Forge that cannot be fetched (it never clones from a cached copy, and has no `--offline`); a profile with no entry; a block that does not read; any `blocked` entry; a name that is not an entry, or is given twice. It clones whatever the workspace's sync state, and exits 1 when a clone fails, with the command to run it by hand. It writes the clones only: no `craftar.yaml`, lock or registry entry. | -| `craftar import --from claude-code --forge

--profile [--workspace .] [--write-config] [--report ] [--prefer-kiro ]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). Scripts and hooks sharing the same base name (e.g., `run-x.ps1` and `run-x.sh`) become one ingredient listing all those files; two that differ only in case refuse the import. A rule's scope comes, in this order, from: a hand-written `.kiro/steering/.md` whose frontmatter sets `inclusion`; a usable `paths:` in the rule's own frontmatter — a non-empty one-line string or a list of them, quoted or not (`- **/*.ts` is read as Claude Code reads it) — which gives `inclusion: fileMatch` and `fileMatchPattern`; a steering mirror craftar generated (its default banner); any other steering file, as before; and `always`. When `paths:` is usable the rule's frontmatter is kept verbatim apart from its body (`frontmatterRaw`), whichever source set the scope, so `.claude/rules/.md` is written back byte for byte and neither `.kiro/steering/.md` nor `AGENTS.md` carries it — except a file whose closing `---` is not followed by a newline, which keeps its frontmatter in its body and is written back as is; its steering mirror — and `AGENTS.md`, where it embeds the rule — then carry the block. What the workspace sees next, for a rule a craftar before this one read as always-on: its `.kiro/steering/.md`, `AGENTS.md` and the `.kiro/agents/*.json` of agents bound to the rule now differ from the plan — where `craftar.lock` records them unedited, `sync` updates them; with no lock entry for them (a first import), they read `collision` and are left alone: delete them and run `craftar sync`. Four warnings name the rule and its files, never the pattern: the hand-written steering file and `paths:` disagree (the steering file's scope is kept); the generated mirror disagrees with `paths:` (scope taken from `paths:`); the steering file sets no `inclusion` (scope taken from `paths:`); `paths:` is not a string or a list of strings (ignored). With `--prefer-kiro`, a steering body that starts with a frontmatter block (as an earlier craftar wrote a `paths:` rule) is taken with it, as before, and the rule keeps no separate frontmatter. Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or above is left as it is). A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a Forge at schema 3 or above, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe (integrations.): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a `--forge` inside craftar's Forge cache (a tree there, or a directory it would create there), a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report ` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule` (not one `--prefer-kiro` took the rule from); and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report `; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro ` takes the named rules from `.kiro/steering/.md` instead of `.claude/rules/.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: )`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro — source .kiro/steering/.md (N lines; .claude/rules/.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/.md` again and lands that older text as a `--` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as `sync` renders it (its declared params substituted into its `server`): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value `sync` will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no `params` (it declares nothing, so `sync` writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. | +| `craftar import --from claude-code --forge --profile [--workspace .] [--write-config] [--report ] [--prefer-kiro ]` | Reads `.claude/{rules,agents,commands,skills,scripts,hooks}`, `.mcp.json` and, when present, `.kiro/steering` (for inclusion modes and hand-written steering — and, with `--prefer-kiro`, the text of the rules it names). Scripts and hooks sharing the same base name (e.g., `run-x.ps1` and `run-x.sh`) become one ingredient listing all those files; two that differ only in case are rejected (the rest of the import proceeds). A rule's scope comes, in this order, from: a hand-written `.kiro/steering/.md` whose frontmatter sets `inclusion`; a usable `paths:` in the rule's own frontmatter — a non-empty one-line string or a list of them, quoted or not (`- **/*.ts` is read as Claude Code reads it) — which gives `inclusion: fileMatch` and `fileMatchPattern`; a steering mirror craftar generated (its default banner); any other steering file, as before; and `always`. When `paths:` is usable the rule's frontmatter is kept verbatim apart from its body (`frontmatterRaw`), whichever source set the scope, so `.claude/rules/.md` is written back byte for byte and neither `.kiro/steering/.md` nor `AGENTS.md` carries it — except a file whose closing `---` is not followed by a newline, which keeps its frontmatter in its body and is written back as is; its steering mirror — and `AGENTS.md`, where it embeds the rule — then carry the block. What the workspace sees next, for a rule a craftar before this one read as always-on: its `.kiro/steering/.md`, `AGENTS.md` and the `.kiro/agents/*.json` of agents bound to the rule now differ from the plan — where `craftar.lock` records them unedited, `sync` updates them; with no lock entry for them (a first import), they read `collision` and are left alone: delete them and run `craftar sync`. Four warnings name the rule and its files, never the pattern: the hand-written steering file and `paths:` disagree (the steering file's scope is kept); the generated mirror disagrees with `paths:` (scope taken from `paths:`); the steering file sets no `inclusion` (scope taken from `paths:`); `paths:` is not a string or a list of strings (ignored). With `--prefer-kiro`, a steering body that starts with a frontmatter block (as an earlier craftar wrote a `paths:` rule) is taken with it, as before, and the rule keeps no separate frontmatter. Creates ingredients, recipes (`base`, one `stack-*` per scoped rule, `-steering`) and a profile, or updates them. An ingredient already in the Forge is reused when the base, **rendered** for this profile (its declared defaults, the profile's `params`, the workspace's `overrides.params`), equals the workspace file; failing that, when a unique, proved assignment of the base's declared `{{keys}}` reproduces it, the values are **inferred** into the profile's `params` (one value per key per run; never a change another ingredient would feel). A base with section markers is rendered with the profile's `sections` and the workspace's `overrides.sections` first; when the workspace file differs from it only inside sections, the unique content that reproduces the file byte for byte is **inferred** into the profile's `sections` (tried after params, never combined with them; the report counts lines and never prints the content). An import that writes a section value, or compares a workspace file against a base holding markers, sets `schema: 2` in `craftar.forge.yaml` (a manifest already at 2 or above is left as it is). A reused base takes the place its `--` variant held: the profile moves to the shared recipe when that orders the base there, and an owned recipe gets the base in the variant's slot, so the rule order (and `AGENTS.md`) stays as it was. Anything else becomes a `--` variant that emits under the original name, with the reason, so the workspace still round-trips while you decide what to unify. A shared recipe is never widened for one client: a profile whose ingredients differ from `base` or a `stack-*` gets its own `--`. An existing profile is merged in place (`params`, `sections`, the recipes import owns, missing `targets`; every other field and comment kept, and an empty `{}` / `[]` it does not change stays on its line), as is a recipe the profile owns; `--write-config` merges an existing `craftar.yaml` (`forge`, `profile`, `targets`). The report adds `rendered` (with `sections ` when a section value filled the render), `inferred`, `sectioned`, `param : → `, `section : → `, `forge craftar.forge.yaml edited (schema: 2)` and `profile … created\|edited\|unchanged` lines. In a Forge at schema 3 or above, what the profile's `integrations` block alone provides is **not imported** — not compared, not written — and is listed (`left to recipe (integrations.): …`, and under `## Left to an integration recipe` in the `--report` file); `craftar status` then shows whether the workspace's copies differ (`drift`, `collision`); move an agent attached to a scoped rule into the integration recipe with its rule, or `base` splits on import. It refuses, with the Forge untouched, a `--forge` inside craftar's Forge cache (a tree there, or a directory it would create there), a Forge that does not load, a profile or recipe or `craftar.yaml` that does not round-trip through the YAML writer, a misplaced profile, a recipe import writes whose file and `name` disagree, a workspace override that does not parse, a literal `{{key}}` the profile would render, changing a recipe or variant another profile uses, a workspace file holding a section marker at column 0 (sync would not reproduce it: indent it), a Forge base with malformed section markers, a `craftar.forge.yaml` it needs to bump to `schema: 2` that does not round-trip (set it by hand), and — in a Forge at schema 3 or above — a profile whose `integrations` block is malformed or, when the block holds a `kind`, that does not resolve. Ingredients holding a secret-like value (tokens, private keys, high-entropy MCP `env`/`args` values) are rejected and listed by location — the value is never printed. `authEnv` is never inferred; a variant import rewrites keeps the one it declares, a new variant takes its base's, and a declaration a profile loses is reported. A UTF-16 file with a byte-order mark is decoded for the scan; UTF-16 without one is not detected. An ingredient that would not load back into the Forge (a name that is not slug-like, a non-string MCP `env` value) refuses the import, naming its source; so does an ingredient already in the Forge whose `ingredient.yaml` has an unknown key or a YAML syntax error, naming that file. Every read and check runs before the first write, so an import that fails leaves the Forge untouched and says so. `--report ` also writes a Markdown report, after the Forge is written: what was created, reused, made a variant or rejected, the import's warnings, and the Kiro side — *Kiro collisions*, every `.kiro/` file that differs from what `sync` would generate for the imported profile (`status`' `collision` state against no lock, so a difference of line endings, BOM or JSON formatting is not one), each with its ingredient and the line counts of the two sides, a rule's steering mirror that outgrew its rule marked `steering bigger than the rule` (not one `--prefer-kiro` took the rule from); and *Unsourced Kiro files*, the files under `.kiro/steering/`, `.kiro/agents/`, `.kiro/skills/` and `.kiro/settings/mcp.json` that the plan for the imported profile does not write — listed, never imported. The report holds **paths and line counts, never content** (a `.kiro/` mirror is not scanned for secrets), and names an inferred parameter's key, not its value; a warning, a rejection reason or the `not computed` line is written with every parameter and section value this run accepted, replaced or inferred as `"…"`, and a variant is named against its base, without its reason — the summary on the terminal keeps them. It describes the profile as imported: of the workspace's own `craftar.yaml` and `craftar.local.yaml` only `overrides.params` and `overrides.sections` are read (as import reads them to decide a reuse) — not its `forge`, `profile`, `targets` or recipes — and `craftar.lock` is not read. The path may be inside the workspace; it is refused, before anything is imported, when it resolves inside the Forge (through symlinks and `..`), when a file already exists there, or when a component of it cannot be resolved. If the Kiro side cannot be computed — the plan fails, or `sync` would refuse it for a declared parameter with no value — its sections read `not computed: …`, the same is an import warning, and the import still exits 0. The report is written after the summary, which then ends with `report `; if that write fails the import has still succeeded and the Forge is written, and the command says so and exits 1. Without `--report` none of this is computed. `--prefer-kiro ` takes the named rules from `.kiro/steering/.md` instead of `.claude/rules/.md` — for a steering mirror that was edited by hand and is the text to keep; a rule left to an integration recipe is warned (`nothing to prefer; its steering file was not used`) and skipped, before that rule's own refusals (no steering file, a form with no certain source, a line that would not come back). The rule's text is built from the steering file — its frontmatter and its opening banner (the default one, or the line rendered by the `kiro.banner` sync would use: the workspace's `overrides.params`, else — for a profile already in the Forge — the profile's own or a default of a recipe it resolves) dropped, `.kiro/steering/` turned back into `.claude/rules/` except in a reference to a hand-written steering file and under `commands/` — and is accepted only when it is **proved to render back** to that steering file's body for Kiro over the workspace's own rules (every rule aimed at every target, as import writes them); it is then scanned for secrets and decided like any source (reused, a variant, or created; a rejection names the steering file's line, and a rejected rule gets no `prefer-kiro` line), its `inclusion` and `fileMatchPattern` still read from the steering frontmatter. Refused, with the Forge untouched: an empty or repeated name; a name with no `.claude/rules/.md` (the flag takes rules; a steering file with no rule is already imported as steering); a rule with no `.kiro/steering/.md`; a steering file holding a form Kiro writes for a rule it does not have (`AGENTS.md (rule: )`, a link to `AGENTS.md`, `rule not in this workspace`), which has no certain source; and a steering file that would not come back from the rule built from it (it names `.claude/rules/` where `sync` writes `.kiro/steering/`) — the last two name the line of the steering file and quote none of it. The ingredient's `origin.path` is the steering file; the summary gains `prefer-kiro — source .kiro/steering/.md (N lines; .claude/rules/.md has M)` per rule — N and M count the two files whole, the steering file's frontmatter and banner included — and `--report` a *Preferred from Kiro* section with the same lines. What the workspace sees next: the Forge rule now differs from `.claude/rules/.md` on disk, so with no lock entry for that file the next `sync` shows it as `collision` and leaves it alone — delete it (and the steering file, if it also collides) and run `craftar sync` to regenerate both; where `craftar.lock` already records the file unedited, `sync` updates it. The summary says so in a `note` for each rule whose text changed (after the warnings, before the `report` line). Until the workspace converges, an import without the flag reads `.claude/rules/.md` again and lands that older text as a `--` variant: keep the flag, or converge first. Rules only: commands, agents and skills are not taken from Kiro. In a schema 4 Forge an MCP base is compared as `sync` renders it (its declared params substituted into its `server`): a workspace whose server equals that rendering reuses the base — unless the base cites a declared key that neither the profile nor the workspace sets while some recipe of the Forge defaults it (import cannot know which value `sync` will render): that one becomes a variant whatever it holds; one whose server differs becomes a variant holding the server as the workspace has it, braces included, with no `params` (it declares nothing, so `sync` writes any braces in it back, with the undeclared-key warning whenever a layer gives that key a value). No value is ever inferred into a profile from a server. | | `craftar status` | With a remote Forge, the header's second line names it — `forge @ (default branch)> `, plus `· lock ` when the Forge moved since the last sync — and `--json` carries `forge: {kind, source, ref, defaultBranch, commit, lockCommit, fetched}` for every Forge. Classifies every file the Forge would produce: `new`, `update`, `unchanged`, `adopt`, `drift`, `collision`, `orphan`, `orphan-drift`. A `.json` file not yet in `craftar.lock` that parses to the planned value with its keys in the same order (it differs only in whitespace, escapes, number spelling or duplicate keys) is `adopt`, and the first `sync` rewrites it with the planned bytes — by design. For `claude-code`'s `.mcp.json` that is two-space JSON keeping the file's BOM and line endings; Kiro's JSON files become CRLF without a BOM, like every Kiro text file. A different key order is a `collision` — except for integer-like keys, which `JSON.parse` reorders. A locked JSON file reformatted by hand is `drift`, not `adopt`. `--json` for tooling: `{forge, statuses, warnings, unsetParams}`. Exits 0 whenever it prints its listing, with two exceptions: when a declared parameter a file cites has no value (see `sync`) it prints the listing, then the refusal on stderr, and exits 1; and when `AGENTS.md` is over an explicit budget (see *Targets › agents-md*) it prints the listing, then that block on stderr, and exits 1. In both cases `--json` still prints the whole object — with `unsetParams: [{key, declaredBy, citedBy}]` (`[]` when there is none) and the budget line in `warnings` — and prints no block on stderr. Exits 1 on malformed section markers and on a section marker in an ingredient the workspace resolves while `craftar.forge.yaml` declares `schema: 1` (or none), naming the Forge file and line, and when a rendered file would still hold a marker line, naming the ingredient, file and rendered line — as do `sync`, `diff`, `explain` and `ls`. Warns about a section value that names no ingredient, or no marker of an ingredient the workspace resolves, and about marker lines in an emitted file not every target renders as text (copied as they are). | | `craftar sync` | Renders sections (no rendered body holds a marker; an emitted file not every target renders as text keeps its markers, with a warning), then writes the plan and `craftar.lock` (schema 2, or 3 when the workspace has a service — see *Local services*; it records the Forge's source, requested `ref` and commit, the resolved recipes and targets — rewritten only when that content changes). With a remote Forge it fetches first and exits 1 when the fetch fails, `--dry-run` and `--check` included. `--dry-run` shows without writing. `--check` exits 1 when anything is out of sync, or when `AGENTS.md` is over an explicit budget (CI). `--overwrite-drift` regenerates hand-edited files (explicit, never default); `craftar drift` lists them, discards one, or promotes one to the Forge. A writing `sync` (not `--dry-run`, not `--check`) also records the workspace in `$CRAFTAR_HOME/registry.json` (see *Workspace registry*); a registry it cannot write is a warning, never a failed `sync`. **Refuses when a declared parameter has no value**: a key an ingredient declares under `params` with no `default`, that a file the plan writes cites as `{{key}}`, and that no layer sets (recipe default, profile `params`, `overrides.params` in `craftar.yaml` or `craftar.local.yaml`). The plan's warnings print, then one block on stderr — `error: N declared parameter(s) have no value — nothing written`, one line per key with the ingredients that declare it and the ones that cite it, and the fix — and `sync` exits 1 with nothing written: no file, no lock, no registry entry; `--dry-run` exits 1 the same way, and `--check` prints its listing, then the block, and exits 1 even when every file is `unchanged`. A `{{key}}` no ingredient declares (an Angular `{{ 'X' \| localize }}`, a stray placeholder) is left verbatim silently, and an empty string is a value. (That is a body; a `{{key}}` in an MCP server of a schema 4 Forge follows *Server params*.) | | `craftar diff [path] [--exit-code]` | Line diff between disk and what the Forge would generate, for every file `sync --check` refuses: `update`, `drift`, `collision`, `new`, an `orphan` (shown as the removal of the whole file — the next `sync` deletes it) and an `orphan-drift` (a header and one line: `sync` keeps it). For `.claude/settings.craftar.example.json`, when it has drifted or collides, `diff` prints the headers and `content not shown: an example file may hold a value typed by hand`. `[path]` limits it to the one file whose workspace-relative path, as `status` prints it, equals it. Exits 0 by default, differences or not. `--exit-code` exits 1 when there are differences, or when `AGENTS.md` is over an explicit budget — exactly when `sync --check` would — and makes a `[path]` that names no file craftar manages an error (exit 1, nothing on stdout); without the flag such a path prints `no differences`. With a remote Forge, `--exit-code` also exits 1 when the fetch fails, as `sync --check` does; plain `diff` reads the cached copy with a warning. When a declared parameter a file cites has no value (see `sync`), the refusal is printed on stderr after the diff: plain `diff` still exits 0, `--exit-code` with no `[path]` exits 1 even with `no differences` — as `sync --check` does — and with a `[path]` its exit code answers for that file only. The `AGENTS.md` budget block (see *Targets › agents-md*) is printed the same way, on stderr in every form, and sets the exit only for `--exit-code` with no `[path]`. | @@ -523,7 +523,7 @@ Later: rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; ### to 0.34.2 -- **`import` now creates one script/hook ingredient per base name**, listing every file that shares that name (e.g., `run-x.ps1` and `run-x.sh` become one `script/run-x` with `files: ["run-x.ps1", "run-x.sh"]`). A Forge imported before keeps its shape (a base with one file and a `--` variant with the other); a re-import rewrites that variant with both files. Two files that differ only in case (on a case-insensitive checkout they would collide) refuse the import and name them. A file left in a variant directory after a re-import that grouped it is warned. No emitted byte changes; a Forge with the old shape may be fixed with `forge promote` or by hand. +- **`import` now creates one script/hook ingredient per base name**, listing every file that shares that name (e.g., `run-x.ps1` and `run-x.sh` become one `script/run-x` with `files: ["run-x.ps1", "run-x.sh"]`). A Forge imported before keeps its shape (a base with one file and a `--` variant with the other); a re-import rewrites that variant with both files. Two files that differ only in case (on a case-insensitive checkout they would collide) are rejected and named; the rest of the import proceeds. A file left in a variant directory after a re-import that grouped it is warned. No emitted byte changes; a Forge with the old shape may be fixed with `forge promote` or by hand. - **An unresolved `{{key}}` is now warned only when some ingredient declares the key under `params`** (with or without a default — a default reaches only its own ingredient's files), or, at Forge schema 3 or above, when it is under `integrations.`. Any other `{{…}}` is left verbatim silently. This affects `sync`, `status` (their `warnings` arrays in `--json` may be shorter; the keys stay the same), `diff`, `workspaces`, `forge impact`, `craftar mcp` and `craftar ui`. `doctor`'s `plan` check may go from `warn` to `ok` for an undeclared key, so **`doctor --strict` may exit 0 where it exited 1**. A typo in an undeclared placeholder key is no longer reported — only declared keys that have no value are. ### to 0.34.1 From fc7ca301486b36baed6366e0fbcd9a9bd1cfc83a Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Fri, 23 Oct 2026 15:14:26 -0300 Subject: [PATCH 13/13] chore: update version --- package-lock.json | 4 ++-- package.json | 2 +- src/cli.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index b759b7cf..a1f33681 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.34.1", + "version": "0.34.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.34.1", + "version": "0.34.2", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 777c9f8b..88f91138 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.34.1", + "version": "0.34.2", "description": "Craft, sync and convert AI-coding workspace harnesses across clients and tools.", "license": "MIT", "type": "module", diff --git a/src/cli.ts b/src/cli.ts index 3bab32a2..6233a6d6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -74,7 +74,7 @@ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE const DIFF_EXIT_CODE_FETCH_MODE: FetchMode = "sync"; const program = new Command(); -program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.1"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.34.2"); /* ---------------------------------------------------------------- import */ /** The words of one output-path gate (`gateOutsideForge`), declared before the commands that run at load: what is written, by which command, and its two refusals' endings. */