From d61ab28082b674a5f3b51f4f99deb2c2502b621a Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 20:39:32 -0300 Subject: [PATCH 1/9] fix(kiro): resolve a rule reference kiro does not write by where that rule is Spec 17: a reference to a rule kiro does not write keeps .claude/rules/ when claude-code writes it, points into AGENTS.md when only that holds it, or reads " (rule not in this workspace)"; unknown names keep the rewrite and are reported. One resolver for agents-md and kiro. --- src/emitters/kiro.ts | 92 ++++++++++++++--- src/emitters/shared.ts | 201 +++++++++++++++++++++++++------------ test/emitters/kiro.test.ts | 188 +++++++++++++++++++++++++++++++++- 3 files changed, 403 insertions(+), 78 deletions(-) diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index 46a1d4e..e40bb76 100644 --- a/src/emitters/kiro.ts +++ b/src/emitters/kiro.ts @@ -1,7 +1,7 @@ import { toCrlf } from "../core/text.js"; import { serializeFrontmatter } from "../core/frontmatter.js"; import { listFiles } from "../core/forge.js"; -import { appliesTo, mcpServers, outName, ruleFile, RULE_NAME_CHARS } from "./shared.js"; +import { appliesTo, buildRuleLookup, mcpServers, outName, resolveRuleRefs, ruleFile, RULE_NAME_CHARS, type RefReport } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; import type { ResolvedIngredient } from "../core/resolve.js"; @@ -12,7 +12,9 @@ export const KIRO_TEXT_EXT = /\.(md|txt|json|ya?ml)$/i; * Kiro target. Reproduces, then extends, the behaviour of the hand-written * `.claude/scripts/sync-steering.ps1` this target was extracted from: * - steering = frontmatter(inclusion) + GENERATED banner + rule body - * - `.claude/rules/` references are rewritten to `.kiro/steering/` + * - `.claude/rules/` references are resolved: kiro-written rules → `.kiro/steering/`, + * claude-code-written rules → unchanged, agents-md-only rules → `AGENTS.md`, + * dead rules → ` (rule not in this workspace)`, unknown → `.kiro/steering/` with a warning (spec 17) * - UTF-8 without BOM, CRLF (the exact shape Kiro already consumes) * and additionally generates what the script never did: agents JSON, commands and skills. */ @@ -24,13 +26,23 @@ export const kiro: Emitter = { const banner = String(ctx.resolution.params["kiro.banner"] ?? ""); const ruleNames = new Set(ctx.resolution.ingredients.filter((i) => i.meta.type === "rule").map((i) => outName(i.meta))); const scopedRules = ctx.resolution.ingredients.filter((i) => i.meta.type === "rule" && i.meta.inclusion === "fileMatch").map((i) => outName(i.meta)); + const targets = ctx.resolution.targets; + const lookup = buildRuleLookup(ctx.resolution.ingredients); + + // Collect reports for the warning (spec 17 §4.5) + const reports: RefReport[] = []; + const resolve = (text: string, citing: string): string => { + const { text: resolved, report } = resolveRuleRefs(text, citing, lookup, targets, "kiro"); + reports.push(report); + return resolved; + }; for (const ing of ctx.resolution.ingredients) { if (!appliesTo(ing.meta.targets, t)) continue; const m = ing.meta; switch (m.type) { case "rule": { - const body = rewrite(await ctx.text(ing, m.file)); + const body = resolve(await ctx.text(ing, m.file), ing.ref); const fm = m.inclusion === "fileMatch" ? `---\ninclusion: fileMatch\nfileMatchPattern: ${JSON.stringify(Array.isArray(m.fileMatchPattern) ? m.fileMatchPattern.join(",") : m.fileMatchPattern ?? "**")}\n---\n\n` @@ -40,34 +52,40 @@ export const kiro: Emitter = { break; } case "steering": + // Steering bodies are emitted as written (spec 17 §2) out.push(crlf(`.kiro/steering/${outName(m)}.md`, await ctx.text(ing, m.file), ing.ref)); break; case "agent": { - const body = rewrite(await ctx.text(ing, m.file)); - const description = rewrite(m.description ?? ""); + // Resolve description first, then body (spec 17 §4.5 order: description before prompt) + const description = resolve(m.description ?? "", ing.ref); + const body = resolve(await ctx.text(ing, m.file), ing.ref); const tools = mapTools(m.tools, ctx); - const resources = m.resources ?? agentResources(outName(m), description + "\n" + body, ruleNames, scopedRules); + // agentResources reads the blanket-rewritten text, not the resolved text (spec 17 §4.6) + const resources = m.resources ?? agentResources(outName(m), rewrite(m.description ?? "") + "\n" + rewrite(await ctx.text(ing, m.file)), ruleNames, scopedRules); const json = JSON.stringify({ name: outName(m), description, prompt: body.replace(/^\n+/, "").replace(/\n+$/, ""), tools, allowedTools: tools, resources }, null, 2) + "\n"; out.push(crlf(`.kiro/agents/${outName(m)}.json`, json, ing.ref)); break; } case "command": { - const body = rewrite(await ctx.text(ing, m.file)); + // Resolve description and raw frontmatter first, then body (spec 17 §4.5 order: frontmatter before body) + const resolvedDescription = m.description !== undefined ? resolve(m.description, ing.ref) : undefined; + const resolvedFm = m.frontmatterRaw ? resolve(m.frontmatterRaw, ing.ref) : null; + const body = resolve(await ctx.text(ing, m.file), ing.ref); const doc = serializeFrontmatter( - { description: m.description, "argument-hint": m.argumentHint, "allowed-tools": m.allowedTools }, + { description: resolvedDescription, "argument-hint": m.argumentHint, "allowed-tools": m.allowedTools }, body, - { raw: m.frontmatterRaw ? rewrite(m.frontmatterRaw) : null }, + { raw: resolvedFm }, ); out.push(crlf(`.kiro/steering/commands/${outName(m)}.md`, `---\ninclusion: manual\n---\n\n` + doc, ing.ref)); break; } case "skill": { if (m.layout === "file") { - out.push(crlf(`.kiro/skills/${outName(m)}/SKILL.md`, rewrite(await ctx.text(ing, "SKILL.md")), ing.ref)); + out.push(crlf(`.kiro/skills/${outName(m)}/SKILL.md`, resolve(await ctx.text(ing, "SKILL.md"), ing.ref), ing.ref)); } else { for (const f of await listFiles(ing.dir)) { if (f === "ingredient.yaml") continue; - out.push(await copy(ctx, ing, f, `.kiro/skills/${outName(m)}/${f}`)); + out.push(await copy(ctx, ing, f, `.kiro/skills/${outName(m)}/${f}`, resolve)); } } break; @@ -82,6 +100,9 @@ export const kiro: Emitter = { } } + // Emit one warning for all dead and unknown references (spec 17 §4.5) + emitKiroWarning(ctx, reports); + const servers = mcpServers(ctx, t, ".kiro/settings/mcp.json"); if (Object.keys(servers).length) { out.push(crlf(".kiro/settings/mcp.json", JSON.stringify({ mcpServers: servers }, null, 2) + "\n", "mcp/*")); @@ -90,6 +111,51 @@ export const kiro: Emitter = { }, }; +/** Emit the kiro warning for dead and unknown references (spec 17 §4.5). */ +function emitKiroWarning(ctx: EmitContext, reports: RefReport[]): void { + // Collect entries: D (has kind with "reaches no target") and unknown (empty kind) + const dead: Array<{ ref: string; citing: string; kind: string }> = []; + const unknown: Array<{ ref: string; citing: string }> = []; + const seenDead = new Set(); + const seenUnknown = new Set(); + + for (const report of reports) { + for (const e of report.reworded) { + const key = `${e.ref}|${e.citing}`; + if (e.kind !== "") { + // D: has a kind string (e.g. "rule/x reaches no target here") + if (!seenDead.has(key)) { + seenDead.add(key); + dead.push(e); + } + } else { + // unknown: empty kind string + if (!seenUnknown.has(key)) { + seenUnknown.add(key); + unknown.push({ ref: e.ref, citing: e.citing }); + } + } + } + } + + if (dead.length === 0 && unknown.length === 0) return; + + const parts: string[] = []; + if (dead.length > 0) { + const entries = dead.map((e) => `${e.ref} (in ${e.citing}; ${e.kind})`).join(", "); + parts.push(`${dead.length} reference(s) to rule files this workspace does not have — reworded: ${entries}`); + } + if (unknown.length > 0) { + const entries = unknown.map((e) => `${e.ref} (in ${e.citing})`).join(", "); + if (dead.length > 0) { + parts.push(`${unknown.length} reference(s) to names that are no rule or steering here — rewritten to .kiro/steering/ as before: ${entries}`); + } else { + parts.push(`${unknown.length} reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: ${entries}`); + } + } + ctx.warn(`kiro: ${parts.join("; ")}`); +} + export function rewrite(text: string): string { return text.replace(/\.claude\/rules\//g, ".kiro/steering/"); } @@ -98,8 +164,8 @@ function crlf(path: string, text: string, ingredient: string): PlannedFile { return { path, content: Buffer.from(toCrlf(text), "utf8"), target: "kiro", ingredient }; } -async function copy(ctx: EmitContext, ing: ResolvedIngredient, file: string, relPath: string): Promise { - if (KIRO_TEXT_EXT.test(file)) return crlf(relPath, rewrite(await ctx.text(ing, file)), ing.ref); +async function copy(ctx: EmitContext, ing: ResolvedIngredient, file: string, relPath: string, resolve: (text: string, citing: string) => string): Promise { + if (KIRO_TEXT_EXT.test(file)) return crlf(relPath, resolve(await ctx.text(ing, file), ing.ref), ing.ref); return { path: relPath, content: await ctx.bytes(ing, file), target: "kiro", ingredient: ing.ref }; } diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index ece009f..546e3a4 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -44,7 +44,8 @@ export function buildRuleLookup(ingredients: ResolvedIngredient[]): RuleLookup { } /** - * Resolve `.claude/rules/.md` references in a body emitted into AGENTS.md (spec 15 §4.1–§4.3). + * Resolve `.claude/rules/.md` references in a body (spec 15 §4.1–§4.3, spec 17 §4.2–§4.3). + * Mode "agents-md" (default) is for AGENTS.md; mode "kiro" is for kiro files. * Returns the transformed text and a report of what was reworded or found dead. */ export function resolveRuleRefs( @@ -52,37 +53,51 @@ export function resolveRuleRefs( citing: string, lookup: RuleLookup, targets: readonly string[], + mode: "agents-md" | "kiro" = "agents-md", ): { text: string; report: RefReport } { const report: RefReport = { reworded: [], others: [] }; const hasCc = targets.includes("claude-code"); const hasKiro = targets.includes("kiro"); + const hasMd = targets.includes("agents-md"); const { rulesByName, steeringsByName } = lookup; - // Determine rule state: A, B, C, D, or unknown (spec 15 §4.2) - const ruleState = (name: string): "A" | "B" | "C" | "D" | "unknown" => { + // Determine rule state (spec 15 §4.2 for agents-md, spec 17 §4.2 for kiro) + // agents-md: A (claude-code writes), B (kiro writes), C (agents-md), D (rule exists), unknown + // kiro: K1 (kiro writes), K2 (claude-code writes), K3 (agents-md has it), D, unknown + type State = "A" | "B" | "C" | "D" | "K1" | "K2" | "K3" | "unknown"; + const ruleState = (name: string): State => { const rule = rulesByName.get(name); + if (mode === "kiro") { + // K1: kiro writes it — a rule or steering that admits kiro (spec 17 §4.2) + if (rule && hasKiro && appliesTo(rule.meta.targets, "kiro")) return "K1"; + const steering = steeringsByName.get(name); + if (steering && hasKiro && appliesTo(steering.meta.targets, "kiro")) return "K1"; + // K2: a rule and claude-code writes it + if (rule && hasCc && appliesTo(rule.meta.targets, "claude-code")) return "K2"; + // K3: a rule aimed at agents-md, and agents-md is a workspace target + if (rule && hasMd && appliesTo(rule.meta.targets, "agents-md")) return "K3"; + // D: any other rule + if (rule) return "D"; + // unknown: anything else + return "unknown"; + } + // agents-md mode (spec 15 §4.2) if (rule) { - // A or B (rule): use ruleWriter to decide const writer = ruleWriter(targets, rule.meta.targets); if (writer === "claude-code") return "A"; if (writer === "kiro") return "B"; } - // B (steering): kiro writes a steering with this name — checked before C/D (spec 15 §4.2) + // B (steering): kiro writes a steering with this name const steering = steeringsByName.get(name); if (steering && hasKiro && appliesTo(steering.meta.targets, "kiro")) return "B"; - // C or D only when a rule exists if (rule) { - // C: rule aimed at agents-md (text is in AGENTS.md) if (appliesTo(rule.meta.targets, "agents-md")) return "C"; - // D: rule exists but not written by any target here return "D"; } - // No rule with this name return "unknown"; }; // Right boundary: not followed by letter, digit, `_`, `-`, or `.` followed by one of those (spec 15 §4.1) - // NOTE: The right boundary class is NOT the same as RULE_NAME_CHARS (which includes `.` for names). const rightBoundary = (after: string): boolean => { if (!after) return true; const c = after[0]; @@ -91,8 +106,9 @@ export function resolveRuleRefs( return true; }; - // Collect all matches from the original body with their offsets, then apply in reverse order - // so that earlier offsets remain valid. This ensures warning order follows the original body (spec 15 §4.5). + // Blanket rewrite for kiro mode: .claude/rules/ → .kiro/steering/ + const kiroRewrite = (s: string): string => s.replace(/\.claude\/rules\//g, ".kiro/steering/"); + interface Match { offset: number; length: number; @@ -114,24 +130,55 @@ export function resolveRuleRefs( const rule = rulesByName.get(name); let replacement: string; let reworded: { ref: string; kind: string } | undefined; - switch (state) { - case "A": - continue; // unchanged, skip - case "B": - replacement = `[${text}](${ruleFile("kiro", { name })}${frag ?? ""})`; - break; - case "C": - replacement = `[${text}](AGENTS.md)`; // fragment dropped - break; - case "D": - reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; - replacement = `${text} (${name}, rule not in this workspace)`; - break; - case "unknown": - if (hasCc) continue; - reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; - replacement = `${text} (${name}, rule not in this workspace)`; - break; + + if (mode === "kiro") { + // Kiro mode (spec 17 §4.3) + switch (state) { + case "K1": + replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${frag ?? ""})`; + break; + case "K2": + // unchanged — exempt from directory rewrite; keep original text exactly + replacement = match; + break; + case "K3": + replacement = `[${kiroRewrite(text)}](AGENTS.md)`; // fragment dropped + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${kiroRewrite(text)} (${name}, rule not in this workspace)`; + break; + case "unknown": + // Unknown name gets blanket rewrite and is reported (no kind string) + reworded = { ref: `.claude/rules/${name}.md`, kind: "" }; + replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${frag ?? ""})`; + break; + default: + continue; + } + } else { + // agents-md mode (spec 15 §4.3) + switch (state) { + case "A": + continue; + case "B": + replacement = `[${text}](${ruleFile("kiro", { name })}${frag ?? ""})`; + break; + case "C": + replacement = `[${text}](AGENTS.md)`; + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${text} (${name}, rule not in this workspace)`; + break; + case "unknown": + if (hasCc) continue; + reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; + replacement = `${text} (${name}, rule not in this workspace)`; + break; + default: + continue; + } } matches.push({ offset, length: match.length, replacement, reworded }); } @@ -144,43 +191,70 @@ export function resolveRuleRefs( let tokenMatch; while ((tokenMatch = tokenPattern.exec(body)) !== null) { const [match, before, token, name] = tokenMatch as RegExpExecArray & [string, string, string, string]; - // The offset of the actual token, not the left boundary; used for overlap checks and ordering const offset = tokenMatch.index + before.length; const fullOffset = tokenMatch.index + match.length; const after = body.slice(fullOffset); if (!rightBoundary(after)) continue; - // Skip if this offset overlaps with a link match (link pattern already captured it) if (matches.some((m) => offset >= m.offset && offset < m.offset + m.length)) continue; const state = ruleState(name); const rule = rulesByName.get(name); let replacement: string; let reworded: { ref: string; kind: string } | undefined; - switch (state) { - case "A": - continue; // unchanged, skip - case "B": - replacement = ruleFile("kiro", { name }); - break; - case "C": - replacement = `AGENTS.md (rule: ${name})`; - break; - case "D": - reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; - replacement = `${name} (rule not in this workspace)`; - break; - case "unknown": - if (hasCc) continue; - reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; - replacement = `${name} (rule not in this workspace)`; - break; + + if (mode === "kiro") { + // Kiro mode (spec 17 §4.3) + switch (state) { + case "K1": + replacement = ruleFile("kiro", { name }); + break; + case "K2": + // unchanged — exempt from directory rewrite; keep original text exactly + replacement = token; + break; + case "K3": + replacement = `AGENTS.md (rule: ${name})`; + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${name} (rule not in this workspace)`; + break; + case "unknown": + // Unknown name gets blanket rewrite and is reported (no kind string) + reworded = { ref: `.claude/rules/${name}.md`, kind: "" }; + replacement = ruleFile("kiro", { name }); + break; + default: + continue; + } + } else { + // agents-md mode (spec 15 §4.3) + switch (state) { + case "A": + continue; + case "B": + replacement = ruleFile("kiro", { name }); + break; + case "C": + replacement = `AGENTS.md (rule: ${name})`; + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${name} (rule not in this workspace)`; + break; + case "unknown": + if (hasCc) continue; + reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; + replacement = `${name} (rule not in this workspace)`; + break; + default: + continue; + } } matches.push({ offset, length: token.length, replacement, reworded }); } - // Sort by offset for correct warning order, then apply replacements in reverse order matches.sort((a, b) => a.offset - b.offset); - // Collect reworded entries with their original offsets for sorting (spec 15 §4.5) const rewordedWithOffset: Array<{ offset: number; ref: string; citing: string; kind: string }> = []; for (const m of matches) { if (m.reworded) { @@ -188,14 +262,18 @@ export function resolveRuleRefs( } } - // Apply replacements from end to start so earlier offsets remain valid - let result = body; - for (let i = matches.length - 1; i >= 0; i--) { - const m = matches[i]; - result = result.slice(0, m.offset) + m.replacement + result.slice(m.offset + m.length); + // Build the result segment by segment: gaps get the blanket rewrite in kiro mode + let result = ""; + let lastEnd = 0; + for (const m of matches) { + const gap = body.slice(lastEnd, m.offset); + result += mode === "kiro" ? kiroRewrite(gap) : gap; + result += m.replacement; + lastEnd = m.offset + m.length; } + const tail = body.slice(lastEnd); + result += mode === "kiro" ? kiroRewrite(tail) : tail; - // Sort by offset and deduplicate by (ref, citing), keeping first occurrence (spec 15 §4.5) rewordedWithOffset.sort((a, b) => a.offset - b.offset); const seen = new Set(); for (const e of rewordedWithOffset) { @@ -206,21 +284,18 @@ export function resolveRuleRefs( } } - // Collect other .claude/ paths (agents, commands, skills, scripts, hooks) only when no claude-code (spec 15 §4.5) - if (!hasCc) { + // Collect other .claude/ paths only in agents-md mode (spec 15 §4.5) — not in kiro mode (spec 17 §2) + if (mode === "agents-md" && !hasCc) { const otherDirs = ["agents", "commands", "skills", "scripts", "hooks"]; - // The path class adds `/` for subdirectories; in `[.../-]` the `/` goes before `-` to avoid a range error. const otherPattern = new RegExp( `(^|[^/${RULE_NAME_CHARS}])\\.claude\\/(${otherDirs.join("|")})\\/([A-Za-z0-9._/-]+)`, "g", ); let otherMatch; while ((otherMatch = otherPattern.exec(result)) !== null) { - // Trim trailing `.` or `/` from the path, and skip if empty after trimming let path = otherMatch[3].replace(/[./]+$/, ""); if (!path) continue; const fullPath = `.claude/${otherMatch[2]}/${path}`; - // Deduplicate by (path, citing) if (!report.others.some((o) => o.path === fullPath && o.citing === citing)) { report.others.push({ path: fullPath, citing }); } diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index b4e8a37..6510114 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -8,12 +8,12 @@ afterEach(async () => { while (cleanups.length) await cleanups.pop()!(); }); -async function planFor(ingredients: IngredientSpec[], profileExtra: Record = {}): Promise { +async function planFor(ingredients: IngredientSpec[], profileExtra: Record = {}, targets: string[] = ["kiro"]): Promise { const s = await scenario( { ingredients, recipes: [recipe("base", ingredients.map((i) => `${i.meta.type}/${i.meta.name}`))], - profiles: [profile("acme", ["base"], ["kiro"], profileExtra)], + profiles: [profile("acme", ["base"], targets, profileExtra)], }, { config: { profile: "acme" } }, ); @@ -141,3 +141,187 @@ describe("kiro emitter — sections (spec 11 §10.2, AC 2)", () => { expect(JSON.parse(file(p, ".kiro/agents/a.json")!.content.toString("utf8")).prompt).toBe("# T\nvalue\nsee .kiro/steering/r.md"); }); }); + +describe("kiro emitter — rule references (spec 17)", () => { + const lf = (p: Plan, rel: string) => file(p, rel)?.content.toString("utf8").replace(/\r\n/g, "\n"); + const kw = (p: Plan) => p.warnings.filter((w) => w.startsWith("kiro:")); + const HUB = [ + "# hub", "", + "- backticks: see `.claude/rules/style.md` and `.claude/rules/api.md`.", + "- link: [the md-only rule](.claude/rules/md-only.md).", + "- bare: .claude/rules/cc-only.md and .claude/rules/kiro-only.md", + "- unknown name: `.claude/rules/handwritten.md`", + "- pattern: `.claude/rules/.md` and `.claude/rules/*.md`", + "- not a rule: `.claude/agents/reviewer.md`, `.claude/commands/open-pr.md`", "", + ].join("\n"); + const LINE = "cites `.claude/rules/style.md` (kiro writes), `.claude/rules/cc-only.md` (kiro does not), `.claude/rules/handwritten.md` (no rule), `.claude/rules/product.md` (steering), `.claude/rules/*.md` (pattern), `.claude/agents/reviewer.md`."; + const LINE_K = "cites `.kiro/steering/style.md` (kiro writes), `cc-only (rule not in this workspace)` (kiro does not), `.kiro/steering/handwritten.md` (no rule), `.kiro/steering/product.md` (steering), `.kiro/steering/*.md` (pattern), `.claude/agents/reviewer.md`."; + const LINE_KC = LINE_K.replace("`cc-only (rule not in this workspace)`", "`.claude/rules/cc-only.md`"); + const forge17 = (): IngredientSpec[] => [ + rule("hub", HUB), + rule("style", "# style\n"), + rule("api", "# api\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" }), + rule("kiro-only", "# kiro-only\n", { inclusion: "fileMatch", fileMatchPattern: "projects/k/**", targets: ["kiro", "agents-md"] }), + rule("md-only", "# md-only\n", { inclusion: "fileMatch", fileMatchPattern: "projects/m/**", targets: ["agents-md"] }), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + { meta: { type: "agent", name: "reviewer", description: "Reviews per .claude/rules/cc-only.md" }, files: { "agent.md": `Agent ${LINE}\n` } }, + { meta: { type: "command", name: "open-pr", description: "Opens per .claude/rules/cc-only.md" }, files: { "command.md": `Command ${LINE}\n` } }, + { meta: { type: "skill", name: "howto" }, files: { "SKILL.md": `Skill ${LINE}\n` } }, + { meta: { type: "steering", name: "product" }, files: { "steering.md": `Steering ${LINE}\n` } }, + ]; + const hubFile = (list: string[]) => + ["---", "inclusion: always", "---", "", "", "", "# hub", "", ...list, ""].join("\n"); + const TODAY = { + backticks: "- backticks: see `.kiro/steering/style.md` and `.kiro/steering/api.md`.", + unknown: "- unknown name: `.kiro/steering/handwritten.md`", + pattern: "- pattern: `.kiro/steering/.md` and `.kiro/steering/*.md`", + notRule: "- not a rule: `.claude/agents/reviewer.md`, `.claude/commands/open-pr.md`", + }; + const DEAD_LINK = "- link: the md-only rule (md-only, rule not in this workspace)."; + const HANDWRITTEN = ["rule/hub", "agent/reviewer", "command/open-pr", "skill/howto"].map((c) => `.claude/rules/handwritten.md (in ${c})`).join(", "); + const W1 = + "kiro: 5 reference(s) to rule files this workspace does not have — reworded: .claude/rules/md-only.md (in rule/hub; rule/md-only reaches no target here), .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in agent/reviewer; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in command/open-pr; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in skill/howto; rule/cc-only reaches no target here); 4 reference(s) to names that are no rule or steering here — rewritten to .kiro/steering/ as before: " + + HANDWRITTEN; + + it("kiro only: §4.4's first block and §4.5's warning (spec 17 test 1)", async () => { + const p = await planFor(forge17()); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile([TODAY.backticks, DEAD_LINK, "- bare: cc-only (rule not in this workspace) and .kiro/steering/kiro-only.md", TODAY.unknown, TODAY.pattern, TODAY.notRule])); + expect(kw(p)).toEqual([W1]); + }); + + it("claude-code + kiro: a rule claude-code writes keeps its .claude/rules/ path (spec 17 test 2)", async () => { + const p = await planFor(forge17(), {}, ["claude-code", "kiro"]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile([TODAY.backticks, DEAD_LINK, "- bare: .claude/rules/cc-only.md and .kiro/steering/kiro-only.md", TODAY.unknown, TODAY.pattern, TODAY.notRule])); + expect(kw(p)).toEqual([ + "kiro: 1 reference(s) to rule files this workspace does not have — reworded: .claude/rules/md-only.md (in rule/hub; rule/md-only reaches no target here); 4 reference(s) to names that are no rule or steering here — rewritten to .kiro/steering/ as before: " + HANDWRITTEN, + ]); + }); + + it("kiro + agents-md: a rule only AGENTS.md holds points there (spec 17 test 3)", async () => { + const p = await planFor(forge17(), {}, ["kiro", "agents-md"]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile([TODAY.backticks, "- link: [the md-only rule](AGENTS.md).", "- bare: cc-only (rule not in this workspace) and .kiro/steering/kiro-only.md", TODAY.unknown, TODAY.pattern, TODAY.notRule])); + }); + + it("agent prompt, command body and skill file are resolved the same way (spec 17 test 4)", async () => { + const k = await planFor(forge17()); + expect(JSON.parse(lf(k, ".kiro/agents/reviewer.json")!).prompt).toBe(`Agent ${LINE_K}`); + expect(lf(k, ".kiro/steering/commands/open-pr.md")).toBe(`---\ninclusion: manual\n---\n\n---\ndescription: Opens per cc-only (rule not in this workspace)\n---\nCommand ${LINE_K}\n`); + expect(lf(k, ".kiro/skills/howto/SKILL.md")).toBe(`Skill ${LINE_K}\n`); + const kc = await planFor(forge17(), {}, ["claude-code", "kiro"]); + expect(JSON.parse(lf(kc, ".kiro/agents/reviewer.json")!).prompt).toBe(`Agent ${LINE_KC}`); + expect(lf(kc, ".kiro/steering/commands/open-pr.md")).toBe(`---\ninclusion: manual\n---\n\n---\ndescription: Opens per .claude/rules/cc-only.md\n---\nCommand ${LINE_KC}\n`); + expect(lf(kc, ".kiro/skills/howto/SKILL.md")).toBe(`Skill ${LINE_KC}\n`); + }); + + it("descriptions are resolved; a hand-written command description gets the kiro rewrite too (spec 17 test 5)", async () => { + const k = await planFor(forge17()); + expect(lf(k, ".kiro/agents/reviewer.json")).toContain('\n "description": "Reviews per cc-only (rule not in this workspace)",\n'); + expect(lf(k, ".kiro/steering/commands/open-pr.md")).toContain("\ndescription: Opens per cc-only (rule not in this workspace)\n"); + const kc = await planFor(forge17(), {}, ["claude-code", "kiro"]); + expect(lf(kc, ".kiro/agents/reviewer.json")).toContain('\n "description": "Reviews per .claude/rules/cc-only.md",\n'); + expect(lf(kc, ".kiro/steering/commands/open-pr.md")).toContain("\ndescription: Opens per .claude/rules/cc-only.md\n"); + const c2 = await planFor([ + rule("style", "# style\n"), + { meta: { type: "command", name: "c2", description: "Uses .claude/rules/style.md, .claude/rules/nope.md and .claude/rules/*.md" }, files: { "command.md": "C2\n" } }, + ]); + expect(lf(c2, ".kiro/steering/commands/c2.md")).toContain("\ndescription: Uses .kiro/steering/style.md, .kiro/steering/nope.md and .kiro/steering/*.md\n"); + expect(kw(c2)).toEqual(["kiro: 1 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/nope.md (in command/c2)"]); + }); + + it("a Forge of the shape import produces keeps every kiro byte (spec 17 test 6)", async () => { + const p = await planFor( + [ + rule("hub", "# hub\n\nsee `.claude/rules/style.md`, [api](.claude/rules/api.md), .claude/rules/product.md and `.claude/rules/*.md`\n"), + rule("style", "# style\n"), + rule("api", "# api\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" }), + { meta: { type: "steering", name: "product" }, files: { "steering.md": "# product\n" } }, + ], + {}, + ["claude-code", "kiro"], + ); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["see `.kiro/steering/style.md`, [api](.kiro/steering/api.md), .kiro/steering/product.md and `.kiro/steering/*.md`"])); + expect(kw(p)).toEqual([]); + }); + + it("patterns and non-references keep the directory rewrite (spec 17 test 7)", async () => { + const p = await planFor([rule("hub", "# hub\n\n.claude/rules/*.md .claude/rules/.md projects/web/.claude/rules/style.md .claude/rules/style.md.bak\n"), rule("style", "# style\n")]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile([".kiro/steering/*.md .kiro/steering/.md projects/web/.kiro/steering/style.md .kiro/steering/style.md.bak"])); + expect(kw(p)).toEqual([]); + }); + + it("an agent's resources do not move, while its prompt is resolved (spec 17 test 8)", async () => { + const p = await planFor([ + rule("backend-api", "# backend-api\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**", targets: ["claude-code"] }), + { meta: { type: "agent", name: "backend-reviewer" }, files: { "agent.md": "Follow .claude/rules/backend-api.md.\n" } }, + ]); + const json = JSON.parse(lf(p, ".kiro/agents/backend-reviewer.json")!); + expect(json.resources).toEqual(["file://.kiro/steering/backend-api.md"]); + expect(json.prompt).toBe("Follow backend-api (rule not in this workspace)."); + }); + + it("an unknown name alone gives the second form (spec 17 test 9)", async () => { + const p = await planFor([rule("hub", "# hub\n\nSee .claude/rules/handwritten.md.\n")]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["See .kiro/steering/handwritten.md."])); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/handwritten.md (in rule/hub)"]); + }); + + it("a steering body is emitted as written and reports nothing (spec 17 test 10)", async () => { + const p = await planFor([{ meta: { type: "steering", name: "product" }, files: { "steering.md": `Steering ${LINE}\n` } }, rule("style", "# style\n")]); + expect(lf(p, ".kiro/steering/product.md")).toBe(`Steering ${LINE}\n`); + expect(kw(p)).toEqual([]); + }); + + it("a raw frontmatter block is resolved (spec 17 test 11)", async () => { + const p = await planFor([ + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + { meta: { type: "command", name: "raw", frontmatterRaw: "description: Uses .claude/rules/cc-only.md" }, files: { "command.md": "Raw\n" } }, + ]); + expect(lf(p, ".kiro/steering/commands/raw.md")).toContain("description: Uses cc-only (rule not in this workspace)"); + }); + + it("K3 as a token (spec 17 test 12)", async () => { + const p = await planFor([rule("hub", "# hub\n\nSee `.claude/rules/md-only.md`.\n"), rule("md-only", "# md-only\n", { inclusion: "manual", targets: ["agents-md"] })], {}, ["kiro", "agents-md"]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["See `AGENTS.md (rule: md-only)`."])); + }); + + it("K2 as a link is left as the Forge wrote it (spec 17 test 13)", async () => { + const p = await planFor([rule("hub", "# hub\n\n[the rule](.claude/rules/cc-only.md)\n"), rule("cc-only", "# cc-only\n", { targets: ["claude-code"] })], {}, ["claude-code", "kiro"]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["[the rule](.claude/rules/cc-only.md)"])); + expect(kw(p)).toEqual([]); + }); + + it("a dead reference alone gives the first form (spec 17 test 14)", async () => { + const p = await planFor([rule("hub", "# hub\n\nSee .claude/rules/cc-only.md.\n"), rule("cc-only", "# cc-only\n", { targets: ["claude-code"] })]); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to rule files this workspace does not have — reworded: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here)"]); + }); + + it("fragments: kept in K1, dropped in K3 (spec 17 test 15)", async () => { + const p = await planFor( + [rule("hub", "# hub\n\n[t](.claude/rules/style.md#part) [u](.claude/rules/md-only.md#part)\n"), rule("style", "# style\n"), rule("md-only", "# md-only\n", { inclusion: "manual", targets: ["agents-md"] })], + {}, + ["kiro", "agents-md"], + ); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["[t](.kiro/steering/style.md#part) [u](AGENTS.md)"])); + }); + + it("entries follow the order of each ingredient's texts (spec 17 test 16)", async () => { + const p = await planFor([ + rule("md-only", "# md-only\n", { inclusion: "manual", targets: ["agents-md"] }), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + { meta: { type: "agent", name: "a1", description: "Per .claude/rules/md-only.md" }, files: { "agent.md": "Per .claude/rules/cc-only.md\n" } }, + { meta: { type: "command", name: "c1", description: "Per .claude/rules/cc-only.md" }, files: { "command.md": "Per .claude/rules/md-only.md\n" } }, + ]); + const e = (x: string, c: string) => `.claude/rules/${x}.md (in ${c}; rule/${x} reaches no target here)`; + expect(kw(p)).toEqual([`kiro: 4 reference(s) to rule files this workspace does not have — reworded: ${[e("md-only", "agent/a1"), e("cc-only", "agent/a1"), e("cc-only", "command/c1"), e("md-only", "command/c1")].join(", ")}`]); + }); + + it("a skill's other text files are resolved; one entry per skill (spec 17 test 17)", async () => { + const p = await planFor([ + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + { meta: { type: "skill", name: "s1" }, files: { "SKILL.md": "See .claude/rules/cc-only.md.\n", "notes.md": "See .claude/rules/cc-only.md.\n" } }, + ]); + expect(lf(p, ".kiro/skills/s1/SKILL.md")).toBe("See cc-only (rule not in this workspace).\n"); + expect(lf(p, ".kiro/skills/s1/notes.md")).toBe("See cc-only (rule not in this workspace).\n"); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to rule files this workspace does not have — reworded: .claude/rules/cc-only.md (in skill/s1; rule/cc-only reaches no target here)"]); + }); +}); \ No newline at end of file From ea68c077ab75244747a1232cc5f36f3fb409899e Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 20:40:52 -0300 Subject: [PATCH 2/9] docs(readme): say how the kiro target resolves rule references, and what 0.8.5 changes Kiro target paragraph now covers the same resolution agents-md does. Upgrading section has the new to 0.8.5 notes. --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cdc9431..4bf0c53 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Sections layer the same way, weakest → strongest: the body's default → the p **claude-code** — emits `.claude/rules|agents|commands|skills|scripts|hooks` and `.mcp.json` verbatim from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its `targets` default to `[kiro]`); one aimed at `claude-code` explicitly (`targets: "*"` or a list naming it) is skipped with a warning. **kiro** — reproduces, then extends, the hand-written `sync-steering.ps1` script it replaces: -steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/`, UTF-8 without BOM, CRLF. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning. +steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/.md` when `claude-code` writes it, `AGENTS.md (rule: )` when only `AGENTS.md` holds it, otherwise ` (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported (the same applies to agent prompts and descriptions, command bodies and descriptions, and skill files; a `steering` ingredient is emitted as written), UTF-8 without BOM, CRLF. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning. **agents-md** — one `AGENTS.md` with the always-on rules concatenated and the scoped rules after them — each listed at the file a target of the workspace writes for it (`.claude/rules/.md` when `claude-code` writes it, else `.kiro/steering/.md` when `kiro` does), or, when no target writes it, embedded in full under a `> Scoped rule — ` line, for tools that read the open standard (Codex, Cursor, Warp, Copilot, Kimi…). A `.claude/rules/.md` reference inside a body follows the rule it names: it is kept when `claude-code` writes that rule, becomes `.kiro/steering/.md` when only `kiro` writes that file (from the rule or from a `steering` ingredient of that name), becomes `AGENTS.md (rule: )` when the rule's text is in this file, and otherwise becomes ` (rule not in this workspace)` with a warning (an unknown name is left as written when `claude-code` is a target); without `claude-code`, the warning also names references to other `.claude/` files, which are left as written. Every other ingredient type aimed at `agents-md` — including through the default `targets: "*"` — has no `AGENTS.md` equivalent: it is skipped with a warning — one line per type, naming every skipped ingredient. The file keeps the line endings and BOM of the one it replaces; a new one is LF without a BOM. @@ -241,6 +241,16 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ## Upgrading +### to 0.8.5 + +- **The kiro target stops turning a reference to a rule it does not write into a `.kiro/steering/` path that does not exist.** Only files under `.kiro/` change; no other generated file and no lock field. + - **No byte change:** a workspace whose kiro texts cite only rules kiro writes, and whose hand-written commands cite no `.claude/rules/` path in their `description`. Every workspace `craftar import` produced is in this case. A cited unknown name now adds a warning. + - **A cited rule kiro does not write:** the reference goes back to `.claude/rules/.md` when `claude-code` writes the rule, becomes `AGENTS.md (rule: )` when only `AGENTS.md` holds it, or else ` (rule not in this workspace)`. + - **A command written by hand in the Forge (no raw frontmatter):** its `description` is now resolved like its body, and that includes the directory rewrite. +- **At most one `kiro:` warning per plan.** It names every reference reworded to "rule not in this workspace", and every unknown name, which is still rewritten to `.kiro/steering/`. It never changes an exit code. +- An affected workspace shows those `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs. +- An agent's `resources` do not change. + ### to 0.8.4 - **A `.claude/rules/.md` reference inside a rule body that goes into `AGENTS.md` is resolved by the rule it names.** Only `AGENTS.md` changes; no other generated file and no lock field. From f7c05981a5e4c35fe034f6bf0c601fc2c4cad716 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 20:41:58 -0300 Subject: [PATCH 3/9] chore: update version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.8.4 → 0.8.5 in package.json, package-lock.json and src/cli.ts. --- 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 f7b49db..a3cb02d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.4", + "version": "0.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 98a4bfc..3487231 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.4", + "version": "0.8.5", "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 a73edce..e4f5fe4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,7 +28,7 @@ import { HUNK_CLASSES, UnifyPlanSchema, type HunkClass, type HunkSuggestion, typ process.stdout.on("error", (e: NodeJS.ErrnoException) => { if (e.code === "EPIPE") process.exit(0); }); const program = new Command(); -program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.4"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.5"); /* ---------------------------------------------------------------- import */ program From 010b31e834781f74384c8b45a3bc98df8d56eafb Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 20:52:37 -0300 Subject: [PATCH 4/9] fix(kiro): resolve a command's description only when it is emitted serializeFrontmatter ignores the description when frontmatterRaw is set, so resolving it adds spurious entries to the kiro warning. --- src/emitters/kiro.ts | 4 +++- test/emitters/kiro.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index e40bb76..7c646e9 100644 --- a/src/emitters/kiro.ts +++ b/src/emitters/kiro.ts @@ -68,7 +68,9 @@ export const kiro: Emitter = { } case "command": { // Resolve description and raw frontmatter first, then body (spec 17 §4.5 order: frontmatter before body) - const resolvedDescription = m.description !== undefined ? resolve(m.description, ing.ref) : undefined; + // When frontmatterRaw is set, the description field is unused (serializeFrontmatter ignores it), + // so we skip resolving it to avoid spurious reports. + const resolvedDescription = !m.frontmatterRaw && m.description !== undefined ? resolve(m.description, ing.ref) : undefined; const resolvedFm = m.frontmatterRaw ? resolve(m.frontmatterRaw, ing.ref) : null; const body = resolve(await ctx.text(ing, m.file), ing.ref); const doc = serializeFrontmatter( diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index 6510114..e07ea9d 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -324,4 +324,14 @@ describe("kiro emitter — rule references (spec 17)", () => { expect(lf(p, ".kiro/skills/s1/notes.md")).toBe("See cc-only (rule not in this workspace).\n"); expect(kw(p)).toEqual(["kiro: 1 reference(s) to rule files this workspace does not have — reworded: .claude/rules/cc-only.md (in skill/s1; rule/cc-only reaches no target here)"]); }); + + it("a raw frontmatter block wins: the unused description is neither emitted nor reported (spec 17 §4.5, review fix)", async () => { + const p = await planFor([ + { meta: { type: "command", name: "raw2", description: "per .claude/rules/q-dead.md", frontmatterRaw: "argument-hint: per .claude/rules/z-dead.md\ndescription: per .claude/rules/a-dead.md" }, files: { "command.md": "Raw\n" } }, + ]); + expect(lf(p, ".kiro/steering/commands/raw2.md")).toBe("---\ninclusion: manual\n---\n\n---\nargument-hint: per .kiro/steering/z-dead.md\ndescription: per .kiro/steering/a-dead.md\n---\nRaw\n"); + expect(kw(p)).toEqual([ + "kiro: 2 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/z-dead.md (in command/raw2), .claude/rules/a-dead.md (in command/raw2)", + ]); + }); }); \ No newline at end of file From 960b207ef7d4d6ef94e977338464ba2690afedec Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 20:54:09 -0300 Subject: [PATCH 5/9] fix(kiro): rewrite a link fragment as 0.8.4 did In kiro mode, K1 and unknown link fragments passed through unchanged. Now they go through the blanket rewrite, matching 0.8.4's behavior. --- src/emitters/shared.ts | 6 ++++-- test/emitters/kiro.test.ts | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 546e3a4..da559b8 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -135,7 +135,8 @@ export function resolveRuleRefs( // Kiro mode (spec 17 §4.3) switch (state) { case "K1": - replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${frag ?? ""})`; + // Fragment gets the blanket rewrite too (spec 17 §4.6) + replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; case "K2": // unchanged — exempt from directory rewrite; keep original text exactly @@ -150,8 +151,9 @@ export function resolveRuleRefs( break; case "unknown": // Unknown name gets blanket rewrite and is reported (no kind string) + // Fragment gets the blanket rewrite too (spec 17 §4.6) reworded = { ref: `.claude/rules/${name}.md`, kind: "" }; - replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${frag ?? ""})`; + replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; default: continue; diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index e07ea9d..27f7a7b 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -334,4 +334,9 @@ describe("kiro emitter — rule references (spec 17)", () => { "kiro: 2 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/z-dead.md (in command/raw2), .claude/rules/a-dead.md (in command/raw2)", ]); }); + + it("a link fragment keeps the directory rewrite, as on 0.8.4 (spec 17 §4.6, review fix)", async () => { + const p = await planFor([rule("hub", "# hub\n\n[t](.claude/rules/style.md#see-.claude/rules/z) [u](.claude/rules/nope.md#see-.claude/rules/z)\n"), rule("style", "# style\n")]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["[t](.kiro/steering/style.md#see-.kiro/steering/z) [u](.kiro/steering/nope.md#see-.kiro/steering/z)"])); + }); }); \ No newline at end of file From 3c778e360436775c22915974564e583080030641 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 20:58:02 -0300 Subject: [PATCH 6/9] chore(emitters): restore review-earned comments and read an agent body once - Restore the comment about right-boundary class not being RULE_NAME_CHARS - Restore the comment about / going before - in otherPattern - Restore offset and order comments for match collection and sorting - Add UNKNOWN_NAME_KIND constant to make unknown discriminant explicit - Read agent body once and derive both resolved and rewritten versions from it --- src/emitters/kiro.ts | 12 +++++++----- src/emitters/shared.ts | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index 7c646e9..cecde7f 100644 --- a/src/emitters/kiro.ts +++ b/src/emitters/kiro.ts @@ -1,7 +1,7 @@ import { toCrlf } from "../core/text.js"; import { serializeFrontmatter } from "../core/frontmatter.js"; import { listFiles } from "../core/forge.js"; -import { appliesTo, buildRuleLookup, mcpServers, outName, resolveRuleRefs, ruleFile, RULE_NAME_CHARS, type RefReport } from "./shared.js"; +import { appliesTo, buildRuleLookup, mcpServers, outName, resolveRuleRefs, ruleFile, RULE_NAME_CHARS, UNKNOWN_NAME_KIND, type RefReport } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; import type { ResolvedIngredient } from "../core/resolve.js"; @@ -58,10 +58,12 @@ export const kiro: Emitter = { case "agent": { // Resolve description first, then body (spec 17 §4.5 order: description before prompt) const description = resolve(m.description ?? "", ing.ref); - const body = resolve(await ctx.text(ing, m.file), ing.ref); + // Read the body once and derive both versions from it + const rawBody = await ctx.text(ing, m.file); + const body = resolve(rawBody, ing.ref); const tools = mapTools(m.tools, ctx); // agentResources reads the blanket-rewritten text, not the resolved text (spec 17 §4.6) - const resources = m.resources ?? agentResources(outName(m), rewrite(m.description ?? "") + "\n" + rewrite(await ctx.text(ing, m.file)), ruleNames, scopedRules); + const resources = m.resources ?? agentResources(outName(m), rewrite(m.description ?? "") + "\n" + rewrite(rawBody), ruleNames, scopedRules); const json = JSON.stringify({ name: outName(m), description, prompt: body.replace(/^\n+/, "").replace(/\n+$/, ""), tools, allowedTools: tools, resources }, null, 2) + "\n"; out.push(crlf(`.kiro/agents/${outName(m)}.json`, json, ing.ref)); break; @@ -124,14 +126,14 @@ function emitKiroWarning(ctx: EmitContext, reports: RefReport[]): void { for (const report of reports) { for (const e of report.reworded) { const key = `${e.ref}|${e.citing}`; - if (e.kind !== "") { + if (e.kind !== UNKNOWN_NAME_KIND) { // D: has a kind string (e.g. "rule/x reaches no target here") if (!seenDead.has(key)) { seenDead.add(key); dead.push(e); } } else { - // unknown: empty kind string + // unknown: empty kind string (UNKNOWN_NAME_KIND) if (!seenUnknown.has(key)) { seenUnknown.add(key); unknown.push({ ref: e.ref, citing: e.citing }); diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index da559b8..02a2a21 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -25,6 +25,9 @@ export interface RefReport { others: Array<{ path: string; citing: string }>; } +/** The kind string for an unknown name (no rule or steering with that name). */ +export const UNKNOWN_NAME_KIND = ""; + /** Lookup maps for rule reference resolution (spec 15 §4.2), built once per AGENTS.md. */ export interface RuleLookup { rulesByName: Map; @@ -98,6 +101,7 @@ export function resolveRuleRefs( }; // Right boundary: not followed by letter, digit, `_`, `-`, or `.` followed by one of those (spec 15 §4.1) + // NOTE: The right boundary class is NOT the same as RULE_NAME_CHARS (which includes `.` for names). const rightBoundary = (after: string): boolean => { if (!after) return true; const c = after[0]; @@ -109,6 +113,9 @@ export function resolveRuleRefs( // Blanket rewrite for kiro mode: .claude/rules/ → .kiro/steering/ const kiroRewrite = (s: string): string => s.replace(/\.claude\/rules\//g, ".kiro/steering/"); + // Collect all matches from the original body with their offsets. Warning order follows the + // original body (spec 15 §4.5); replacements are applied at the end from end to start so that + // earlier offsets remain valid. interface Match { offset: number; length: number; @@ -152,7 +159,7 @@ export function resolveRuleRefs( case "unknown": // Unknown name gets blanket rewrite and is reported (no kind string) // Fragment gets the blanket rewrite too (spec 17 §4.6) - reworded = { ref: `.claude/rules/${name}.md`, kind: "" }; + reworded = { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }; replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; default: @@ -193,10 +200,12 @@ export function resolveRuleRefs( let tokenMatch; while ((tokenMatch = tokenPattern.exec(body)) !== null) { const [match, before, token, name] = tokenMatch as RegExpExecArray & [string, string, string, string]; + // The offset of the actual token, not the left boundary; used for overlap checks and ordering const offset = tokenMatch.index + before.length; const fullOffset = tokenMatch.index + match.length; const after = body.slice(fullOffset); if (!rightBoundary(after)) continue; + // Skip if this offset overlaps with a link match (link pattern already captured it) if (matches.some((m) => offset >= m.offset && offset < m.offset + m.length)) continue; const state = ruleState(name); const rule = rulesByName.get(name); @@ -222,7 +231,7 @@ export function resolveRuleRefs( break; case "unknown": // Unknown name gets blanket rewrite and is reported (no kind string) - reworded = { ref: `.claude/rules/${name}.md`, kind: "" }; + reworded = { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }; replacement = ruleFile("kiro", { name }); break; default: @@ -255,8 +264,10 @@ export function resolveRuleRefs( matches.push({ offset, length: token.length, replacement, reworded }); } + // Sort by offset for correct warning order matches.sort((a, b) => a.offset - b.offset); + // Collect reworded entries with their original offsets for sorting (spec 15 §4.5) const rewordedWithOffset: Array<{ offset: number; ref: string; citing: string; kind: string }> = []; for (const m of matches) { if (m.reworded) { @@ -276,6 +287,7 @@ export function resolveRuleRefs( const tail = body.slice(lastEnd); result += mode === "kiro" ? kiroRewrite(tail) : tail; + // Sort by offset and deduplicate by (ref, citing), keeping first occurrence (spec 15 §4.5) rewordedWithOffset.sort((a, b) => a.offset - b.offset); const seen = new Set(); for (const e of rewordedWithOffset) { @@ -289,6 +301,7 @@ export function resolveRuleRefs( // Collect other .claude/ paths only in agents-md mode (spec 15 §4.5) — not in kiro mode (spec 17 §2) if (mode === "agents-md" && !hasCc) { const otherDirs = ["agents", "commands", "skills", "scripts", "hooks"]; + // The path class adds `/` for subdirectories; in `[.../-]` the `/` goes before `-` to avoid a range error. const otherPattern = new RegExp( `(^|[^/${RULE_NAME_CHARS}])\\.claude\\/(${otherDirs.join("|")})\\/([A-Za-z0-9._/-]+)`, "g", From f93befd067f60f534b953ae4c72c4b695a97c656 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 20:59:52 -0300 Subject: [PATCH 7/9] docs(readme): bound the kiro reference warning and the no-change case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify 'at most one kiro: warning about rule references per plan' - Clarify 'every rule a kiro text cites is one kiro writes' - Move parenthetical about agent/command/skill resolution to its own sentence - Update to 0.8.4: '0.8.5 addresses it in part — see to 0.8.5' --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4bf0c53..9df00bb 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Sections layer the same way, weakest → strongest: the body's default → the p **claude-code** — emits `.claude/rules|agents|commands|skills|scripts|hooks` and `.mcp.json` verbatim from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its `targets` default to `[kiro]`); one aimed at `claude-code` explicitly (`targets: "*"` or a list naming it) is skipped with a warning. **kiro** — reproduces, then extends, the hand-written `sync-steering.ps1` script it replaces: -steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/.md` when `claude-code` writes it, `AGENTS.md (rule: )` when only `AGENTS.md` holds it, otherwise ` (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported (the same applies to agent prompts and descriptions, command bodies and descriptions, and skill files; a `steering` ingredient is emitted as written), UTF-8 without BOM, CRLF. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning. +steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/.md` when `claude-code` writes it, `AGENTS.md (rule: )` when only `AGENTS.md` holds it, otherwise ` (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported, UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a `steering` ingredient is emitted as written. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning. **agents-md** — one `AGENTS.md` with the always-on rules concatenated and the scoped rules after them — each listed at the file a target of the workspace writes for it (`.claude/rules/.md` when `claude-code` writes it, else `.kiro/steering/.md` when `kiro` does), or, when no target writes it, embedded in full under a `> Scoped rule — ` line, for tools that read the open standard (Codex, Cursor, Warp, Copilot, Kimi…). A `.claude/rules/.md` reference inside a body follows the rule it names: it is kept when `claude-code` writes that rule, becomes `.kiro/steering/.md` when only `kiro` writes that file (from the rule or from a `steering` ingredient of that name), becomes `AGENTS.md (rule: )` when the rule's text is in this file, and otherwise becomes ` (rule not in this workspace)` with a warning (an unknown name is left as written when `claude-code` is a target); without `claude-code`, the warning also names references to other `.claude/` files, which are left as written. Every other ingredient type aimed at `agents-md` — including through the default `targets: "*"` — has no `AGENTS.md` equivalent: it is skipped with a warning — one line per type, naming every skipped ingredient. The file keeps the line endings and BOM of the one it replaces; a new one is LF without a BOM. @@ -244,10 +244,10 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ### to 0.8.5 - **The kiro target stops turning a reference to a rule it does not write into a `.kiro/steering/` path that does not exist.** Only files under `.kiro/` change; no other generated file and no lock field. - - **No byte change:** a workspace whose kiro texts cite only rules kiro writes, and whose hand-written commands cite no `.claude/rules/` path in their `description`. Every workspace `craftar import` produced is in this case. A cited unknown name now adds a warning. + - **No byte change:** a workspace in which every rule a kiro text cites is one kiro writes, and whose hand-written commands cite no `.claude/rules/` path in their `description`. Every workspace `craftar import` produced is in this case. A cited unknown name now adds a warning. - **A cited rule kiro does not write:** the reference goes back to `.claude/rules/.md` when `claude-code` writes the rule, becomes `AGENTS.md (rule: )` when only `AGENTS.md` holds it, or else ` (rule not in this workspace)`. - **A command written by hand in the Forge (no raw frontmatter):** its `description` is now resolved like its body, and that includes the directory rewrite. -- **At most one `kiro:` warning per plan.** It names every reference reworded to "rule not in this workspace", and every unknown name, which is still rewritten to `.kiro/steering/`. It never changes an exit code. +- **At most one `kiro:` warning about rule references per plan.** It names every reference reworded to "rule not in this workspace", and every unknown name, which is still rewritten to `.kiro/steering/`. It never changes an exit code. - An affected workspace shows those `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs. - An agent's `resources` do not change. @@ -259,7 +259,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC - **With `claude-code`:** only a reference to a rule `claude-code` does not write, or to a `steering` ingredient `kiro` writes, moves. An unknown name is left as written. - **At most one warning per `AGENTS.md`** names every reference turned into ` (rule not in this workspace)` (a reference made to point at `.kiro/steering/` or into `AGENTS.md` is not reported). Without `claude-code`, it also names references to other `.claude/` files (agents, commands, skills, scripts, hooks), which are left as written. It repeats on every run until the body, the cited rule's `targets` or the workspace's targets change, and it never changes an exit code. - An affected workspace shows `AGENTS.md` as `update`, and `craftar sync --check` exits 1 until it syncs. -- The kiro emitter still rewrites every `.claude/rules/` to `.kiro/steering/`; a later release (0.8.5) addresses it. +- The kiro emitter still rewrites every `.claude/rules/` to `.kiro/steering/`; 0.8.5 addresses it in part — see *to 0.8.5*. ### to 0.8.3 From fcc5edf84c3f508dd525ddd8e69bd03e21002bdb Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 06:16:07 -0300 Subject: [PATCH 8/9] =?UTF-8?q?chore(emitters):=20fix=20stale=20resolver?= =?UTF-8?q?=20comments=20and=20cite=20=C2=A74.3=20for=20fragments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.8.5 builds the result forward, not reverse; cite spec 17 §4.3 (the contract table), not §4.6 (the no-move guarantee). Restore three 0.8.4 comments the merge dropped. --- src/emitters/shared.ts | 12 +++++++----- test/emitters/kiro.test.ts | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 02a2a21..d0997b6 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -90,7 +90,7 @@ export function resolveRuleRefs( if (writer === "claude-code") return "A"; if (writer === "kiro") return "B"; } - // B (steering): kiro writes a steering with this name + // B (steering): kiro writes a steering with this name — checked before C/D (spec 15 §4.2) const steering = steeringsByName.get(name); if (steering && hasKiro && appliesTo(steering.meta.targets, "kiro")) return "B"; if (rule) { @@ -114,8 +114,8 @@ export function resolveRuleRefs( const kiroRewrite = (s: string): string => s.replace(/\.claude\/rules\//g, ".kiro/steering/"); // Collect all matches from the original body with their offsets. Warning order follows the - // original body (spec 15 §4.5); replacements are applied at the end from end to start so that - // earlier offsets remain valid. + // original body (spec 15 §4.5); matches are applied in order, and in kiro mode the gaps between + // them get the directory rewrite. interface Match { offset: number; length: number; @@ -142,7 +142,7 @@ export function resolveRuleRefs( // Kiro mode (spec 17 §4.3) switch (state) { case "K1": - // Fragment gets the blanket rewrite too (spec 17 §4.6) + // Fragment gets the blanket rewrite too (spec 17 §4.3) replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; case "K2": @@ -158,7 +158,7 @@ export function resolveRuleRefs( break; case "unknown": // Unknown name gets blanket rewrite and is reported (no kind string) - // Fragment gets the blanket rewrite too (spec 17 §4.6) + // Fragment gets the blanket rewrite too (spec 17 §4.3) reworded = { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }; replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; @@ -308,9 +308,11 @@ export function resolveRuleRefs( ); let otherMatch; while ((otherMatch = otherPattern.exec(result)) !== null) { + // Trim trailing `.` or `/` from the path, and skip if empty after trimming let path = otherMatch[3].replace(/[./]+$/, ""); if (!path) continue; const fullPath = `.claude/${otherMatch[2]}/${path}`; + // Deduplicate by (path, citing) if (!report.others.some((o) => o.path === fullPath && o.citing === citing)) { report.others.push({ path: fullPath, citing }); } diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index 27f7a7b..cf4b483 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -335,7 +335,7 @@ describe("kiro emitter — rule references (spec 17)", () => { ]); }); - it("a link fragment keeps the directory rewrite, as on 0.8.4 (spec 17 §4.6, review fix)", async () => { + it("a link fragment keeps the directory rewrite, as on 0.8.4 (spec 17 §4.3, review fix)", async () => { const p = await planFor([rule("hub", "# hub\n\n[t](.claude/rules/style.md#see-.claude/rules/z) [u](.claude/rules/nope.md#see-.claude/rules/z)\n"), rule("style", "# style\n")]); expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["[t](.kiro/steering/style.md#see-.kiro/steering/z) [u](.kiro/steering/nope.md#see-.kiro/steering/z)"])); }); From fd98af6358a886dde8bb8c6401496afce300c02c Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 06:17:27 -0300 Subject: [PATCH 9/9] docs(readme): end the kiro reference exception before the encoding A period makes the exception its own sentence; "Files are" clarifies that the UTF-8/CRLF applies to all kiro output, not just unknown names. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9df00bb..373fb5a 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Sections layer the same way, weakest → strongest: the body's default → the p **claude-code** — emits `.claude/rules|agents|commands|skills|scripts|hooks` and `.mcp.json` verbatim from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its `targets` default to `[kiro]`); one aimed at `claude-code` explicitly (`targets: "*"` or a list naming it) is skipped with a warning. **kiro** — reproduces, then extends, the hand-written `sync-steering.ps1` script it replaces: -steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/.md` when `claude-code` writes it, `AGENTS.md (rule: )` when only `AGENTS.md` holds it, otherwise ` (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported, UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a `steering` ingredient is emitted as written. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning. +steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/.md` when `claude-code` writes it, `AGENTS.md (rule: )` when only `AGENTS.md` holds it, otherwise ` (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported. Files are UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a `steering` ingredient is emitted as written. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning. **agents-md** — one `AGENTS.md` with the always-on rules concatenated and the scoped rules after them — each listed at the file a target of the workspace writes for it (`.claude/rules/.md` when `claude-code` writes it, else `.kiro/steering/.md` when `kiro` does), or, when no target writes it, embedded in full under a `> Scoped rule — ` line, for tools that read the open standard (Codex, Cursor, Warp, Copilot, Kimi…). A `.claude/rules/.md` reference inside a body follows the rule it names: it is kept when `claude-code` writes that rule, becomes `.kiro/steering/.md` when only `kiro` writes that file (from the rule or from a `steering` ingredient of that name), becomes `AGENTS.md (rule: )` when the rule's text is in this file, and otherwise becomes ` (rule not in this workspace)` with a warning (an unknown name is left as written when `claude-code` is a target); without `claude-code`, the warning also names references to other `.claude/` files, which are left as written. Every other ingredient type aimed at `agents-md` — including through the default `targets: "*"` — has no `AGENTS.md` equivalent: it is skipped with a warning — one line per type, naming every skipped ingredient. The file keeps the line endings and BOM of the one it replaces; a new one is LF without a BOM.