From 231dcf63130449f32dc9ab1d7e0ede638782ecb2 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 08:35:21 -0300 Subject: [PATCH 01/10] fix(emitters): resolve rule references inside link text, and collapse a dead self-link Spec 20: a reference in a link's text is resolved like a token in agents-md and kiro; a link whose own state reads "rule not in this workspace" and whose text is exactly its path becomes the token form. A K2 link keeps its non-references. --- src/emitters/shared.ts | 240 +++++++++++++++++++++----------- test/emitters/agents-md.test.ts | 91 ++++++++++++ test/emitters/kiro.test.ts | 77 +++++++++- 3 files changed, 323 insertions(+), 85 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index d0997b6..2432c19 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -47,7 +47,7 @@ export function buildRuleLookup(ingredients: ResolvedIngredient[]): RuleLookup { } /** - * Resolve `.claude/rules/.md` references in a body (spec 15 §4.1–§4.3, spec 17 §4.2–§4.3). + * Resolve `.claude/rules/.md` references in a body (spec 15 §4.1–§4.3, spec 17 §4.2–§4.3, spec 20). * 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. */ @@ -113,14 +113,77 @@ export function resolveRuleRefs( // Blanket rewrite for kiro mode: .claude/rules/ → .kiro/steering/ const kiroRewrite = (s: string): string => s.replace(/\.claude\/rules\//g, ".kiro/steering/"); + // Resolve a single token reference (spec 20 §4.1): used for tokens and link text references. + // Returns the replacement text and, if it rewords to "not in this workspace", the reworded entry. + const resolveToken = (name: string): { text: string; reworded?: { ref: string; kind: string } } => { + const state = ruleState(name); + const rule = rulesByName.get(name); + if (mode === "kiro") { + switch (state) { + case "K1": return { text: ruleFile("kiro", { name }) }; + case "K2": return { text: `.claude/rules/${name}.md` }; // unchanged + case "K3": return { text: `AGENTS.md (rule: ${name})` }; + case "D": return { text: `${name} (rule not in this workspace)`, reworded: { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` } }; + case "unknown": return { text: ruleFile("kiro", { name }), reworded: { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND } }; + } + } else { + switch (state) { + case "A": return { text: `.claude/rules/${name}.md` }; // unchanged + case "B": return { text: ruleFile("kiro", { name }) }; + case "C": return { text: `AGENTS.md (rule: ${name})` }; + case "D": return { text: `${name} (rule not in this workspace)`, reworded: { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` } }; + case "unknown": + if (hasCc) return { text: `.claude/rules/${name}.md` }; // unchanged + return { text: `${name} (rule not in this workspace)`, reworded: { ref: `.claude/rules/${name}.md`, kind: "no such rule" } }; + } + } + return { text: `.claude/rules/${name}.md` }; // fallback + }; + + // Resolve references inside link text (spec 20 §4.1). Returns the resolved text and any reworded + // entries from the text's references. In kiro mode, non-references get the directory rewrite + // except for K2 links (Ruling 4: non-references stay as written). + const resolveTextRefs = (text: string, isK2: boolean): { resolved: string; reworded: Array<{ offset: number; ref: string; kind: string }> } => { + const textPattern = new RegExp( + `(^|[^/${RULE_NAME_CHARS}])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`, + "g", + ); + const textMatches: Array<{ offset: number; length: number; replacement: string; reworded?: { ref: string; kind: string } }> = []; + let textMatch; + while ((textMatch = textPattern.exec(text)) !== null) { + const [match, before, token, name] = textMatch as RegExpExecArray & [string, string, string, string]; + const offset = textMatch.index + before.length; + const fullOffset = textMatch.index + match.length; + const after = text.slice(fullOffset); + if (!rightBoundary(after)) continue; + const { text: replacement, reworded } = resolveToken(name); + textMatches.push({ offset, length: token.length, replacement, reworded }); + } + // Build the result: gaps get the blanket rewrite in kiro mode, unless this is a K2 link (Ruling 4) + let resolved = ""; + let lastEnd = 0; + const rewordedEntries: Array<{ offset: number; ref: string; kind: string }> = []; + for (const m of textMatches) { + const gap = text.slice(lastEnd, m.offset); + resolved += (mode === "kiro" && !isK2) ? kiroRewrite(gap) : gap; + resolved += m.replacement; + lastEnd = m.offset + m.length; + if (m.reworded) rewordedEntries.push({ offset: m.offset, ref: m.reworded.ref, kind: m.reworded.kind }); + } + const tail = text.slice(lastEnd); + resolved += (mode === "kiro" && !isK2) ? kiroRewrite(tail) : tail; + return { resolved, reworded: rewordedEntries }; + }; + // Collect all matches from the original body with their offsets. Warning order follows the - // original body (spec 15 §4.5); matches are applied in order, and in kiro mode the gaps between - // them get the directory rewrite. + // original body (spec 15 §4.5, spec 20 §4.3); matches are applied in order, and in kiro mode the + // gaps between them get the directory rewrite. interface Match { - offset: number; + offset: number; // Position for applying the match + warningOffset: number; // Position for warning order (spec 20 §4.3: link's target offset) length: number; replacement: string; - reworded?: { ref: string; kind: string }; + reworded: Array<{ offset: number; ref: string; kind: string }>; } const matches: Match[] = []; @@ -133,35 +196,56 @@ export function resolveRuleRefs( while ((linkMatch = linkPattern.exec(body)) !== null) { const [match, text, name, frag] = linkMatch as RegExpExecArray & [string, string, string, string | undefined]; const offset = linkMatch.index; + // Warning offset is at the target's start, not at `[` (spec 20 §4.3) + const targetOffset = offset + 1 + text.length + 2; // `[` + text + `](` const state = ruleState(name); const rule = rulesByName.get(name); let replacement: string; - let reworded: { ref: string; kind: string } | undefined; + const reworded: Array<{ offset: number; ref: string; kind: string }> = []; + + // Resolve references in the link's text (spec 20 §4.1) + const isK2 = mode === "kiro" && state === "K2"; + const { resolved: resolvedText, reworded: textReworded } = resolveTextRefs(text, isK2); + // Add text reworded entries, positioned before the link's target (spec 20 §4.3) + for (const tw of textReworded) reworded.push({ offset: offset + 1 + tw.offset, ref: tw.ref, kind: tw.kind }); if (mode === "kiro") { // Kiro mode (spec 17 §4.3) switch (state) { case "K1": // Fragment gets the blanket rewrite too (spec 17 §4.3) - replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; + replacement = `[${resolvedText}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; case "K2": - // unchanged — exempt from directory rewrite; keep original text exactly - replacement = match; + // unchanged target — but text references are resolved (spec 20 §4.1, Ruling 4) + replacement = `[${resolvedText}](${`.claude/rules/${name}.md`}${frag ?? ""})`; break; case "K3": - replacement = `[${kiroRewrite(text)}](AGENTS.md)`; // fragment dropped + replacement = `[${resolvedText}](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)`; + case "D": { + // Check for collapse (spec 20 §4.2): text is exactly the path, optionally with backticks + const exactPath = `.claude/rules/${name}.md`; + const backtickedPath = `\`${exactPath}\``; + if (text === exactPath || text === backtickedPath) { + const hasBackticks = text === backtickedPath; + const tokenText = `${name} (rule not in this workspace)`; + replacement = hasBackticks ? `\`${tokenText}\`` : tokenText; + // Collapsed: only one entry, from the token form + reworded.length = 0; // Clear text entries, they merge into the collapse + } else { + replacement = `${resolvedText} (${name}, rule not in this workspace)`; + } + reworded.push({ offset: targetOffset, ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }); break; - case "unknown": + } + case "unknown": { // Unknown name gets blanket rewrite and is reported (no kind string) // 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 ?? "")})`; + reworded.push({ offset: targetOffset, ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }); + replacement = `[${resolvedText}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; break; + } default: continue; } @@ -169,27 +253,64 @@ export function resolveRuleRefs( // agents-md mode (spec 15 §4.3) switch (state) { case "A": - continue; + // State A: link unchanged, but text is now resolved like a token (spec 20 §4.1) + // The text resolution already happened; we only emit a match if the text changed + if (resolvedText !== text) { + replacement = `[${resolvedText}](${`.claude/rules/${name}.md`}${frag ?? ""})`; + } else { + continue; // No change needed + } + break; case "B": - replacement = `[${text}](${ruleFile("kiro", { name })}${frag ?? ""})`; + replacement = `[${resolvedText}](${ruleFile("kiro", { name })}${frag ?? ""})`; break; case "C": - replacement = `[${text}](AGENTS.md)`; + replacement = `[${resolvedText}](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)`; + case "D": { + // Check for collapse (spec 20 §4.2): text is exactly the path, optionally with backticks + const exactPath = `.claude/rules/${name}.md`; + const backtickedPath = `\`${exactPath}\``; + if (text === exactPath || text === backtickedPath) { + const hasBackticks = text === backtickedPath; + const tokenText = `${name} (rule not in this workspace)`; + replacement = hasBackticks ? `\`${tokenText}\`` : tokenText; + // Collapsed: only one entry, from the token form + reworded.length = 0; // Clear text entries, they merge into the collapse + } else { + replacement = `${resolvedText} (${name}, rule not in this workspace)`; + } + reworded.push({ offset: targetOffset, ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }); break; + } case "unknown": - if (hasCc) continue; - reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; - replacement = `${text} (${name}, rule not in this workspace)`; + if (hasCc) { + // With claude-code, unknown links are not consumed — but text is resolved (spec 20 §4.1) + if (resolvedText !== text) { + replacement = `[${resolvedText}](${`.claude/rules/${name}.md`}${frag ?? ""})`; + } else { + continue; // No change needed + } + } else { + // Without claude-code: collapse or reword (spec 20 §4.2) + const exactPath = `.claude/rules/${name}.md`; + const backtickedPath = `\`${exactPath}\``; + if (text === exactPath || text === backtickedPath) { + const hasBackticks = text === backtickedPath; + const tokenText = `${name} (rule not in this workspace)`; + replacement = hasBackticks ? `\`${tokenText}\`` : tokenText; + reworded.length = 0; // Collapsed: clear text entries + } else { + replacement = `${resolvedText} (${name}, rule not in this workspace)`; + } + reworded.push({ offset: targetOffset, ref: `.claude/rules/${name}.md`, kind: "no such rule" }); + } break; default: continue; } } - matches.push({ offset, length: match.length, replacement, reworded }); + matches.push({ offset, warningOffset: offset, length: match.length, replacement, reworded }); } // Token pattern: .claude/rules/.md (not in a link) with left boundary @@ -207,71 +328,22 @@ export function resolveRuleRefs( 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; - - 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: UNKNOWN_NAME_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 }); + const { text: replacement, reworded } = resolveToken(name); + const rewordedEntries: Array<{ offset: number; ref: string; kind: string }> = []; + if (reworded) rewordedEntries.push({ offset, ref: reworded.ref, kind: reworded.kind }); + // Always add the match even if unchanged (K2, state A, unknown with claude-code) so the token + // is excluded from gap rewriting in kiro mode + matches.push({ offset, warningOffset: offset, length: token.length, replacement, reworded: rewordedEntries }); } // 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) + // Collect reworded entries with their original offsets for sorting (spec 15 §4.5, spec 20 §4.3) const rewordedWithOffset: Array<{ offset: number; ref: string; citing: string; kind: string }> = []; for (const m of matches) { - if (m.reworded) { - rewordedWithOffset.push({ offset: m.offset, ref: m.reworded.ref, citing, kind: m.reworded.kind }); + for (const rw of m.reworded) { + rewordedWithOffset.push({ offset: rw.offset, ref: rw.ref, citing, kind: rw.kind }); } } diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index e838036..e4db0c8 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -552,3 +552,94 @@ describe("agents-md emitter — rule references in bodies (spec 15)", () => { ]); }); }); + +describe("agents-md emitter — references inside link text (spec 20)", () => { + const LINES = [ + "1 [`.claude/rules/cc.md`](.claude/rules/cc.md)", + "2 [.claude/rules/cc.md](.claude/rules/cc.md#part)", + "3 [`.claude/rules/k.md`](.claude/rules/k.md)", + "4 [see .claude/rules/cc.md](.claude/rules/a.md)", + "5 [.claude/rules/md.md](.claude/rules/md.md)", + "6 [see .claude/rules/a.md](.claude/rules/cc.md)", + "7 [see .claude/rules/*.md](.claude/rules/cc.md)", + "8 [see .claude/rules/cc.md](.claude/rules/cc.md)", + "9 [.claude/rules/cc.md#part](.claude/rules/cc.md#part)", + "10 [.claude/rules/zz.md](.claude/rules/zz.md)", + ]; + const forge20 = (): IngredientSpec[] => [ + rule("hub", "# hub\n\n" + LINES.join("\n") + "\n"), + rule("a", "# a\n"), + rule("cc", "# cc\n", { targets: ["claude-code"] }), + rule("md", "# md\n", { inclusion: "manual", targets: ["agents-md"] }), + rule("k", "# k\n", { inclusion: "manual", targets: ["kiro", "agents-md"] }), + ]; + const numbered = (p: Plan) => agentsMd(p)!.split("\n").filter((l) => /^\d+ /.test(l)); + const amw = (p: Plan) => p.warnings.filter((w) => w.startsWith("agents-md:")); + const D = (x: string) => `${x} (rule not in this workspace)`; + + it("agents-md only: link text resolved like a token, dead self-links collapse (spec 20 §4.4)", async () => { + const p = await planFor(forge20()); + expect(numbered(p)).toEqual([ + "1 `" + D("cc") + "`", + "2 " + D("cc"), + "3 [`AGENTS.md (rule: k)`](AGENTS.md)", + "4 [see " + D("cc") + "](AGENTS.md)", + "5 [AGENTS.md (rule: md)](AGENTS.md)", + "6 see AGENTS.md (rule: a) (cc, rule not in this workspace)", + "7 see .claude/rules/*.md (cc, rule not in this workspace)", + "8 see " + D("cc") + " (cc, rule not in this workspace)", + "9 " + D("cc") + "#part (cc, rule not in this workspace)", + "10 " + D("zz"), + ]); + expect(amw(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc.md (in rule/hub; rule/cc reaches no target here), .claude/rules/zz.md (in rule/hub; no such rule)", + ]); + }); + + it("kiro + agents-md: link text follows the rule it names (spec 20 §4.4)", async () => { + const p = await planFor(forge20(), undefined, ["kiro", "agents-md"]); + expect(numbered(p)).toEqual([ + "1 `" + D("cc") + "`", + "2 " + D("cc"), + "3 [`.kiro/steering/k.md`](.kiro/steering/k.md)", + "4 [see " + D("cc") + "](.kiro/steering/a.md)", + "5 [AGENTS.md (rule: md)](AGENTS.md)", + "6 see .kiro/steering/a.md (cc, rule not in this workspace)", + "7 see .claude/rules/*.md (cc, rule not in this workspace)", + "8 see " + D("cc") + " (cc, rule not in this workspace)", + "9 " + D("cc") + "#part (cc, rule not in this workspace)", + "10 " + D("zz"), + ]); + }); + + it("claude-code + agents-md: only the text of a consumed link moves (spec 20 §4.1)", async () => { + const p = await planFor(forge20(), undefined, ["claude-code", "agents-md"]); + const before = LINES.slice(); + before[2] = "3 [`AGENTS.md (rule: k)`](AGENTS.md)"; + before[4] = "5 [AGENTS.md (rule: md)](AGENTS.md)"; + expect(numbered(p)).toEqual(before); + expect(amw(p)).toEqual([]); + }); + + it("a reference in a link's text comes before the link's target in the warning (spec 20 §4.3)", async () => { + const p = await planFor([rule("hub", "# hub\n\n[see .claude/rules/zz.md](.claude/rules/cc.md)\n"), rule("cc", "# cc\n", { targets: ["claude-code"] })]); + expect(amw(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/zz.md (in rule/hub; no such rule), .claude/rules/cc.md (in rule/hub; rule/cc reaches no target here)", + ]); + }); + + it("pin: a reference-style definition and a link with a title stay tokens (spec 15 §12.6)", async () => { + const p = await planFor([rule("hub", '# hub\n\n1 [x]: .claude/rules/cc.md\n2 [t](.claude/rules/cc.md "T")\n'), rule("cc", "# cc\n", { targets: ["claude-code"] })]); + expect(numbered(p)).toEqual(["1 [x]: " + D("cc"), '2 [t](' + D("cc") + ' "T")']); + }); + + it("pin: an imported-shape Forge keeps its AGENTS.md (spec 20 §5)", async () => { + const p = await planFor( + [rule("hub", "# hub\n\n1 [see .claude/rules/b.md](.claude/rules/a.md)\n2 [`.claude/rules/a.md`](.claude/rules/a.md)\n3 [t](.claude/rules/zz.md)\n"), rule("a", "# a\n"), rule("b", "# b\n")], + undefined, + ["claude-code", "kiro", "agents-md"], + ); + expect(numbered(p)).toEqual(["1 [see .claude/rules/b.md](.claude/rules/a.md)", "2 [`.claude/rules/a.md`](.claude/rules/a.md)", "3 [t](.claude/rules/zz.md)"]); + expect(amw(p)).toEqual([]); + }); +}); diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index cf4b483..c0a64e5 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -339,4 +339,79 @@ describe("kiro emitter — rule references (spec 17)", () => { 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 +}); + +describe("kiro emitter — references inside link text (spec 20)", () => { + const LINES = [ + "1 [`.claude/rules/cc.md`](.claude/rules/cc.md)", + "2 [.claude/rules/cc.md](.claude/rules/cc.md#part)", + "3 [`.claude/rules/k.md`](.claude/rules/k.md)", + "4 [see .claude/rules/cc.md](.claude/rules/a.md)", + "5 [.claude/rules/md.md](.claude/rules/md.md)", + "6 [see .claude/rules/a.md](.claude/rules/cc.md)", + "7 [see .claude/rules/*.md](.claude/rules/cc.md)", + "8 [see .claude/rules/cc.md](.claude/rules/cc.md)", + "9 [.claude/rules/cc.md#part](.claude/rules/cc.md#part)", + "10 [.claude/rules/zz.md](.claude/rules/zz.md)", + ]; + const forge20 = (): IngredientSpec[] => [ + rule("hub", "# hub\n\n" + LINES.join("\n") + "\n"), + rule("a", "# a\n"), + rule("cc", "# cc\n", { targets: ["claude-code"] }), + rule("md", "# md\n", { inclusion: "manual", targets: ["agents-md"] }), + rule("k", "# k\n", { inclusion: "manual", targets: ["kiro", "agents-md"] }), + ]; + const numbered = (p: Plan) => file(p, ".kiro/steering/hub.md")!.content.toString("utf8").replace(/\r\n/g, "\n").split("\n").filter((l) => /^\d+ /.test(l)); + const kw = (p: Plan) => p.warnings.filter((w) => w.startsWith("kiro:")); + const D = (x: string) => `${x} (rule not in this workspace)`; + + it("kiro only (spec 20 §4.4)", async () => { + const p = await planFor(forge20()); + expect(numbered(p)).toEqual([ + "1 `" + D("cc") + "`", + "2 " + D("cc"), + "3 [`.kiro/steering/k.md`](.kiro/steering/k.md)", + "4 [see " + D("cc") + "](.kiro/steering/a.md)", + "5 " + D("md"), + "6 see .kiro/steering/a.md (cc, rule not in this workspace)", + "7 see .kiro/steering/*.md (cc, rule not in this workspace)", + "8 see " + D("cc") + " (cc, rule not in this workspace)", + "9 " + D("cc") + "#part (cc, rule not in this workspace)", + "10 [.kiro/steering/zz.md](.kiro/steering/zz.md)", + ]); + expect(kw(p)).toEqual([ + "kiro: 2 reference(s) to rule files this workspace does not have — reworded: .claude/rules/cc.md (in rule/hub; rule/cc reaches no target here), .claude/rules/md.md (in rule/hub; rule/md reaches no target here); 1 reference(s) to names that are no rule or steering here — rewritten to .kiro/steering/ as before: .claude/rules/zz.md (in rule/hub)", + ]); + }); + + it("claude-code + kiro: a K2 link keeps its non-references, its text references resolve (spec 20 §4.1, Ruling 4)", async () => { + const p = await planFor(forge20(), {}, ["claude-code", "kiro"]); + expect(numbered(p)).toEqual([ + "1 [`.claude/rules/cc.md`](.claude/rules/cc.md)", + "2 [.claude/rules/cc.md](.claude/rules/cc.md#part)", + "3 [`.kiro/steering/k.md`](.kiro/steering/k.md)", + "4 [see .claude/rules/cc.md](.kiro/steering/a.md)", + "5 " + D("md"), + "6 [see .kiro/steering/a.md](.claude/rules/cc.md)", + "7 [see .claude/rules/*.md](.claude/rules/cc.md)", + "8 [see .claude/rules/cc.md](.claude/rules/cc.md)", + "9 [.claude/rules/cc.md#part](.claude/rules/cc.md#part)", + "10 [.kiro/steering/zz.md](.kiro/steering/zz.md)", + ]); + }); + + it("claude-code + kiro + agents-md: K3 text (spec 20 §4.4)", async () => { + const p = await planFor(forge20(), {}, ["claude-code", "kiro", "agents-md"]); + expect(numbered(p)[4]).toBe("5 [AGENTS.md (rule: md)](AGENTS.md)"); + }); + + it("pin: an imported-shape Forge keeps its kiro bytes and warning (spec 20 §5)", async () => { + const p = await planFor( + [rule("hub", "# hub\n\n1 [see .claude/rules/b.md](.claude/rules/a.md)\n2 [`.claude/rules/a.md`](.claude/rules/a.md)\n3 [t](.claude/rules/zz.md)\n"), rule("a", "# a\n"), rule("b", "# b\n")], + {}, + ["claude-code", "kiro", "agents-md"], + ); + expect(numbered(p)).toEqual(["1 [see .kiro/steering/b.md](.kiro/steering/a.md)", "2 [`.kiro/steering/a.md`](.kiro/steering/a.md)", "3 [t](.kiro/steering/zz.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/zz.md (in rule/hub)"]); + }); +}); From 47f713fed575409749b131060bd3e372b50317e5 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 08:36:57 -0300 Subject: [PATCH 02/10] docs(readme): say how references inside link text resolve, and what 0.8.6 changes Document that references inside link text are resolved like bare tokens in both agents-md and kiro, and that a dead self-link collapses to the token form. --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 373fb5a..ee82be2 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,9 @@ 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. 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. +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. A reference inside a link's text follows the same rules, and a link whose text is exactly its own dead path becomes the plain ` (rule not in this workspace)`. 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. +**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); a reference inside a link's text is resolved the same way, and a link whose text is exactly its own dead path becomes the plain ` (rule not in this workspace)`; 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. Conversion of hooks/subagents to other tools is out of scope here: the plan is to delegate that to [rulesync](https://github.com/dyoshikawa/rulesync) rather than reimplement it. @@ -241,6 +241,14 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ## Upgrading +### to 0.8.6 + +- **A rule reference inside the text of a Markdown link is now resolved like any other**, in `AGENTS.md` and in the kiro files. Before, the text of a link that `AGENTS.md` or kiro rewrote was copied as the Forge held it, or blanket-rewritten to `.kiro/steering/`. + - A link whose target reads "rule not in this workspace" and whose text is exactly its own path — `[.claude/rules/x.md](.claude/rules/x.md)`, backticks or not — becomes `x (rule not in this workspace)`. Any other text keeps the link's usual wording, with its references resolved. + - In a link kiro keeps for `claude-code`, only the references in the text move; the rest of the text stays as written. +- **No byte change** for a workspace whose links cite only rules its targets write — every workspace `craftar import` produced. A link's text that cites an unknown name now adds a warning entry. +- An affected workspace shows `AGENTS.md` or the `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs. + ### 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. From 55f8f2670a3350902b2f2b3e8c1a3d869c2733c5 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 08:38:11 -0300 Subject: [PATCH 03/10] chore: update version Bump version to 0.8.6 for the link-text-references fix. --- 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 a3cb02d..bbc0745 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.5", + "version": "0.8.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.5", + "version": "0.8.6", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 3487231..37a0f5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.5", + "version": "0.8.6", "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 e4f5fe4..32726e1 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.5"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.6"); /* ---------------------------------------------------------------- import */ program From d864a66e924b9ccdb6bf8a18ddbf4fe2ca639da0 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 08:48:40 -0300 Subject: [PATCH 04/10] test(kiro): pin the link-text warning order and the import-shaped warning entry Both tests validate spec 20's warning order and the imported-shape no-change guarantee, pinning behaviour that must not regress. --- test/emitters/kiro.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index c0a64e5..d1d0943 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -414,4 +414,18 @@ describe("kiro emitter — references inside link text (spec 20)", () => { expect(numbered(p)).toEqual(["1 [see .kiro/steering/b.md](.kiro/steering/a.md)", "2 [`.kiro/steering/a.md`](.kiro/steering/a.md)", "3 [t](.kiro/steering/zz.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/zz.md (in rule/hub)"]); }); + + it("a reference in a link's text is reported before the link's target (spec 20 §4.3)", async () => { + const p = await planFor([rule("hub", "# hub\n\n1 [see .claude/rules/yy.md](.claude/rules/zz.md)\n")]); + expect(numbered(p)).toEqual(["1 [see .kiro/steering/yy.md](.kiro/steering/zz.md)"]); + 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/yy.md (in rule/hub), .claude/rules/zz.md (in rule/hub)", + ]); + }); + + it("an imported-shape Forge keeps its kiro bytes; an unknown name in link text adds one entry (spec 20 §5)", async () => { + const p = await planFor([rule("hub", "# hub\n\n1 [see .claude/rules/zz.md](.claude/rules/a.md)\n"), rule("a", "# a\n")], {}, ["claude-code", "kiro", "agents-md"]); + expect(numbered(p)).toEqual(["1 [see .kiro/steering/zz.md](.kiro/steering/a.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/zz.md (in rule/hub)"]); + }); }); From e050223023863986f6fe9cf82300c526851790fe Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 08:51:27 -0300 Subject: [PATCH 05/10] refactor(emitters): one token pattern and one collapse helper in resolveRuleRefs Deduplicate the token regex pattern and the collapse logic to reduce code duplication and make future maintenance easier. Remove the unused warningOffset field from the Match interface. --- src/emitters/shared.ts | 68 ++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 2432c19..4333d10 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -9,6 +9,9 @@ export function appliesTo(targets: "*" | string[], target: string): boolean { /** The characters of a rule name in a `.claude/rules/.md` reference (spec 15 §4.1). */ export const RULE_NAME_CHARS = "A-Za-z0-9._-"; +/** Token pattern for rule references: `.claude/rules/.md` with left boundary (spec 15 §4.1). */ +const TOKEN_PATTERN_SOURCE = `(^|[^/${RULE_NAME_CHARS}])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`; + /** * The target that writes a rule's own file in this workspace, `claude-code` first, else `kiro`, else none * (spec 14 §4.1): a workspace target the rule's `targets` admit. @@ -140,14 +143,22 @@ export function resolveRuleRefs( return { text: `.claude/rules/${name}.md` }; // fallback }; + // Collapse: a link whose text is exactly its path becomes the token form (spec 20 §4.2). + // Returns the token form if collapsible, null otherwise. + const collapse = (text: string, name: string): string | null => { + const exactPath = `.claude/rules/${name}.md`; + const backtickedPath = `\`${exactPath}\``; + if (text !== exactPath && text !== backtickedPath) return null; + const hasBackticks = text === backtickedPath; + const tokenText = `${name} (rule not in this workspace)`; + return hasBackticks ? `\`${tokenText}\`` : tokenText; + }; + // Resolve references inside link text (spec 20 §4.1). Returns the resolved text and any reworded // entries from the text's references. In kiro mode, non-references get the directory rewrite // except for K2 links (Ruling 4: non-references stay as written). const resolveTextRefs = (text: string, isK2: boolean): { resolved: string; reworded: Array<{ offset: number; ref: string; kind: string }> } => { - const textPattern = new RegExp( - `(^|[^/${RULE_NAME_CHARS}])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`, - "g", - ); + const textPattern = new RegExp(TOKEN_PATTERN_SOURCE, "g"); const textMatches: Array<{ offset: number; length: number; replacement: string; reworded?: { ref: string; kind: string } }> = []; let textMatch; while ((textMatch = textPattern.exec(text)) !== null) { @@ -179,8 +190,7 @@ export function resolveRuleRefs( // original body (spec 15 §4.5, spec 20 §4.3); matches are applied in order, and in kiro mode the // gaps between them get the directory rewrite. interface Match { - offset: number; // Position for applying the match - warningOffset: number; // Position for warning order (spec 20 §4.3: link's target offset) + offset: number; length: number; replacement: string; reworded: Array<{ offset: number; ref: string; kind: string }>; @@ -224,15 +234,11 @@ export function resolveRuleRefs( replacement = `[${resolvedText}](AGENTS.md)`; // fragment dropped break; case "D": { - // Check for collapse (spec 20 §4.2): text is exactly the path, optionally with backticks - const exactPath = `.claude/rules/${name}.md`; - const backtickedPath = `\`${exactPath}\``; - if (text === exactPath || text === backtickedPath) { - const hasBackticks = text === backtickedPath; - const tokenText = `${name} (rule not in this workspace)`; - replacement = hasBackticks ? `\`${tokenText}\`` : tokenText; - // Collapsed: only one entry, from the token form - reworded.length = 0; // Clear text entries, they merge into the collapse + // Check for collapse (spec 20 §4.2) + const collapsed = collapse(text, name); + if (collapsed) { + replacement = collapsed; + reworded.length = 0; // Collapsed: only one entry, from the token form } else { replacement = `${resolvedText} (${name}, rule not in this workspace)`; } @@ -268,15 +274,11 @@ export function resolveRuleRefs( replacement = `[${resolvedText}](AGENTS.md)`; break; case "D": { - // Check for collapse (spec 20 §4.2): text is exactly the path, optionally with backticks - const exactPath = `.claude/rules/${name}.md`; - const backtickedPath = `\`${exactPath}\``; - if (text === exactPath || text === backtickedPath) { - const hasBackticks = text === backtickedPath; - const tokenText = `${name} (rule not in this workspace)`; - replacement = hasBackticks ? `\`${tokenText}\`` : tokenText; - // Collapsed: only one entry, from the token form - reworded.length = 0; // Clear text entries, they merge into the collapse + // Check for collapse (spec 20 §4.2) + const collapsed = collapse(text, name); + if (collapsed) { + replacement = collapsed; + reworded.length = 0; // Collapsed: only one entry, from the token form } else { replacement = `${resolvedText} (${name}, rule not in this workspace)`; } @@ -293,12 +295,9 @@ export function resolveRuleRefs( } } else { // Without claude-code: collapse or reword (spec 20 §4.2) - const exactPath = `.claude/rules/${name}.md`; - const backtickedPath = `\`${exactPath}\``; - if (text === exactPath || text === backtickedPath) { - const hasBackticks = text === backtickedPath; - const tokenText = `${name} (rule not in this workspace)`; - replacement = hasBackticks ? `\`${tokenText}\`` : tokenText; + const collapsed = collapse(text, name); + if (collapsed) { + replacement = collapsed; reworded.length = 0; // Collapsed: clear text entries } else { replacement = `${resolvedText} (${name}, rule not in this workspace)`; @@ -310,14 +309,11 @@ export function resolveRuleRefs( continue; } } - matches.push({ offset, warningOffset: offset, length: match.length, replacement, reworded }); + matches.push({ offset, length: match.length, replacement, reworded }); } // Token pattern: .claude/rules/.md (not in a link) with left boundary - const tokenPattern = new RegExp( - `(^|[^/${RULE_NAME_CHARS}])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`, - "g", - ); + const tokenPattern = new RegExp(TOKEN_PATTERN_SOURCE, "g"); let tokenMatch; while ((tokenMatch = tokenPattern.exec(body)) !== null) { const [match, before, token, name] = tokenMatch as RegExpExecArray & [string, string, string, string]; @@ -333,7 +329,7 @@ export function resolveRuleRefs( if (reworded) rewordedEntries.push({ offset, ref: reworded.ref, kind: reworded.kind }); // Always add the match even if unchanged (K2, state A, unknown with claude-code) so the token // is excluded from gap rewriting in kiro mode - matches.push({ offset, warningOffset: offset, length: token.length, replacement, reworded: rewordedEntries }); + matches.push({ offset, length: token.length, replacement, reworded: rewordedEntries }); } // Sort by offset for correct warning order From 3cfe2ce3a1312dc21d1ecc013d8a635795e71653 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 08:53:07 -0300 Subject: [PATCH 06/10] docs(readme): bound the 0.8.6 no-change case and the collapse Clarify the exact conditions for collapsed links and specify which workspaces see no byte change in 0.8.6. --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ee82be2..cf10737 100644 --- a/README.md +++ b/README.md @@ -187,9 +187,9 @@ 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. A reference inside a link's text follows the same rules, and a link whose text is exactly its own dead path becomes the plain ` (rule not in this workspace)`. 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. +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. A reference inside a link's text follows the same rules (in a link kept for `claude-code`, only those references move), and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes ` (rule not in this workspace)` (backticks kept); an unknown name keeps its link. 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); a reference inside a link's text is resolved the same way, and a link whose text is exactly its own dead path becomes the plain ` (rule not in this workspace)`; 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. +**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); a reference inside a link's text is resolved the same way, and a link whose target reads "rule not in this workspace" and whose text is exactly its own path becomes ` (rule not in this workspace)` (backticks kept); 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. Conversion of hooks/subagents to other tools is out of scope here: the plan is to delegate that to [rulesync](https://github.com/dyoshikawa/rulesync) rather than reimplement it. @@ -244,9 +244,9 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ### to 0.8.6 - **A rule reference inside the text of a Markdown link is now resolved like any other**, in `AGENTS.md` and in the kiro files. Before, the text of a link that `AGENTS.md` or kiro rewrote was copied as the Forge held it, or blanket-rewritten to `.kiro/steering/`. - - A link whose target reads "rule not in this workspace" and whose text is exactly its own path — `[.claude/rules/x.md](.claude/rules/x.md)`, backticks or not — becomes `x (rule not in this workspace)`. Any other text keeps the link's usual wording, with its references resolved. + - A link whose target reads "rule not in this workspace" and whose text is exactly its own path — `[.claude/rules/x.md](.claude/rules/x.md)`, backticks or not — becomes `x (rule not in this workspace)`, backticks kept. Any other text keeps the link's usual wording, with its references resolved. - In a link kiro keeps for `claude-code`, only the references in the text move; the rest of the text stays as written. -- **No byte change** for a workspace whose links cite only rules its targets write — every workspace `craftar import` produced. A link's text that cites an unknown name now adds a warning entry. +- **No byte change** for the `.kiro/` files of a workspace whose kiro texts cite only rules kiro writes, nor for an `AGENTS.md` written with `claude-code` whose links cite only rules `claude-code` writes — every workspace `craftar import` produced. An `AGENTS.md` written without `claude-code` updates wherever a link's text cites a rule. In a kiro file, a link's text that cites an unknown name now adds an entry to the `kiro:` warning; its bytes do not change. - An affected workspace shows `AGENTS.md` or the `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs. ### to 0.8.5 From cc00a3be73af5ada6a4b5f75fc1bbce42621a25a Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 09:00:28 -0300 Subject: [PATCH 07/10] test(kiro): an unknown name in a K2 link's text moves to .kiro/steering/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 20 §4.1 says a reference inside the text of a K2 link (kept for claude-code) is resolved. An unknown name inside that text rewrites to .kiro/steering/ and is reported, covering the only Kiro-specific byte change 0.8.6 can make in an imported-shape workspace. --- test/emitters/kiro.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index d1d0943..d772f8b 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -428,4 +428,10 @@ describe("kiro emitter — references inside link text (spec 20)", () => { expect(numbered(p)).toEqual(["1 [see .kiro/steering/zz.md](.kiro/steering/a.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/zz.md (in rule/hub)"]); }); + + it("an unknown name in the text of a link kept for claude-code is rewritten and reported (spec 20 §4.1)", async () => { + const p = await planFor([rule("hub", "# hub\n\n1 [see .claude/rules/zz.md](.claude/rules/cc.md)\n"), rule("cc", "# cc\n", { targets: ["claude-code"] })], {}, ["claude-code", "kiro"]); + expect(numbered(p)).toEqual(["1 [see .kiro/steering/zz.md](.claude/rules/cc.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/zz.md (in rule/hub)"]); + }); }); From 86b5013209a024019582bdc36b45dc3027e67981 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 09:00:54 -0300 Subject: [PATCH 08/10] docs(readme): say where a link's text moves in 0.8.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unknown name in a K2 link's text now moves to .kiro/steering/ and is reported—the only Kiro-specific byte change 0.8.6 makes in an imported- shape workspace. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cf10737..8df8963 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC - **A rule reference inside the text of a Markdown link is now resolved like any other**, in `AGENTS.md` and in the kiro files. Before, the text of a link that `AGENTS.md` or kiro rewrote was copied as the Forge held it, or blanket-rewritten to `.kiro/steering/`. - A link whose target reads "rule not in this workspace" and whose text is exactly its own path — `[.claude/rules/x.md](.claude/rules/x.md)`, backticks or not — becomes `x (rule not in this workspace)`, backticks kept. Any other text keeps the link's usual wording, with its references resolved. - In a link kiro keeps for `claude-code`, only the references in the text move; the rest of the text stays as written. -- **No byte change** for the `.kiro/` files of a workspace whose kiro texts cite only rules kiro writes, nor for an `AGENTS.md` written with `claude-code` whose links cite only rules `claude-code` writes — every workspace `craftar import` produced. An `AGENTS.md` written without `claude-code` updates wherever a link's text cites a rule. In a kiro file, a link's text that cites an unknown name now adds an entry to the `kiro:` warning; its bytes do not change. +- **No byte change** for the `.kiro/` files of a workspace whose kiro texts cite only rules kiro writes or names that are no rule — every workspace `craftar import` produced keeps its `.kiro/` bytes — nor for an `AGENTS.md` written with `claude-code` whose links cite only rules `claude-code` writes. An `AGENTS.md` written without `claude-code` updates wherever the text of a link to a rule cites a rule. In a kiro file, a link's text that cites an unknown name now adds an entry to the `kiro:` warning; its bytes do not change, except in a link kept for `claude-code`, where that name moves to `.kiro/steering/`. - An affected workspace shows `AGENTS.md` or the `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs. ### to 0.8.5 From e392eadd15d868c003fb8b73c00449ad01df2e27 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 09:05:44 -0300 Subject: [PATCH 09/10] docs(readme): state the 0.8.6 AGENTS.md change by reference, known rule or not Clarify that the No byte change condition for AGENTS.md with claude-code applies when links cite rules claude-code writes OR names that are no rule, and that without claude-code, updates occur wherever a link text holds a .claude/rules/.md reference, known rule or not. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8df8963..869dd82 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC - **A rule reference inside the text of a Markdown link is now resolved like any other**, in `AGENTS.md` and in the kiro files. Before, the text of a link that `AGENTS.md` or kiro rewrote was copied as the Forge held it, or blanket-rewritten to `.kiro/steering/`. - A link whose target reads "rule not in this workspace" and whose text is exactly its own path — `[.claude/rules/x.md](.claude/rules/x.md)`, backticks or not — becomes `x (rule not in this workspace)`, backticks kept. Any other text keeps the link's usual wording, with its references resolved. - In a link kiro keeps for `claude-code`, only the references in the text move; the rest of the text stays as written. -- **No byte change** for the `.kiro/` files of a workspace whose kiro texts cite only rules kiro writes or names that are no rule — every workspace `craftar import` produced keeps its `.kiro/` bytes — nor for an `AGENTS.md` written with `claude-code` whose links cite only rules `claude-code` writes. An `AGENTS.md` written without `claude-code` updates wherever the text of a link to a rule cites a rule. In a kiro file, a link's text that cites an unknown name now adds an entry to the `kiro:` warning; its bytes do not change, except in a link kept for `claude-code`, where that name moves to `.kiro/steering/`. +- **No byte change** for the `.kiro/` files of a workspace whose kiro texts cite only rules kiro writes or names that are no rule, nor for an `AGENTS.md` written with `claude-code` whose links cite only rules `claude-code` writes or names that are no rule — every workspace `craftar import` produced keeps both. An `AGENTS.md` written without `claude-code` updates wherever a link's text holds a `.claude/rules/.md` reference, known rule or not. In a kiro file, a link's text that cites an unknown name now adds an entry to the `kiro:` warning; its bytes do not change, except in a link kept for `claude-code`, where that name moves to `.kiro/steering/`. - An affected workspace shows `AGENTS.md` or the `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs. ### to 0.8.5 From 77e395a72576398eab057e99434acbb8e3336a84 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Mon, 5 Oct 2026 09:08:34 -0300 Subject: [PATCH 10/10] docs(readme): a name kiro writes as steering is not "no rule" for AGENTS.md The "No byte change" clause now correctly states that AGENTS.md preserves bytes unless a link's text names a steering file kiro writes (state B), not just when it names "no rule" (state C or unknown). --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 869dd82..195603c 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC - **A rule reference inside the text of a Markdown link is now resolved like any other**, in `AGENTS.md` and in the kiro files. Before, the text of a link that `AGENTS.md` or kiro rewrote was copied as the Forge held it, or blanket-rewritten to `.kiro/steering/`. - A link whose target reads "rule not in this workspace" and whose text is exactly its own path — `[.claude/rules/x.md](.claude/rules/x.md)`, backticks or not — becomes `x (rule not in this workspace)`, backticks kept. Any other text keeps the link's usual wording, with its references resolved. - In a link kiro keeps for `claude-code`, only the references in the text move; the rest of the text stays as written. -- **No byte change** for the `.kiro/` files of a workspace whose kiro texts cite only rules kiro writes or names that are no rule, nor for an `AGENTS.md` written with `claude-code` whose links cite only rules `claude-code` writes or names that are no rule — every workspace `craftar import` produced keeps both. An `AGENTS.md` written without `claude-code` updates wherever a link's text holds a `.claude/rules/.md` reference, known rule or not. In a kiro file, a link's text that cites an unknown name now adds an entry to the `kiro:` warning; its bytes do not change, except in a link kept for `claude-code`, where that name moves to `.kiro/steering/`. +- **No byte change** for the `.kiro/` files of a workspace whose kiro texts cite only rules kiro writes or names that are no rule, nor for an `AGENTS.md` written with `claude-code` whose links cite only rules `claude-code` writes or names that are neither a rule nor a `steering` file kiro writes — a workspace `craftar import` produced keeps its `.kiro/` bytes, and its `AGENTS.md` bytes unless a link's text names one of its hand-written steering files. An `AGENTS.md` written without `claude-code` updates wherever a link's text holds a `.claude/rules/.md` reference, known rule or not. In a kiro file, a link's text that cites an unknown name now adds an entry to the `kiro:` warning; its bytes do not change, except in a link kept for `claude-code`, where that name moves to `.kiro/steering/`. - An affected workspace shows `AGENTS.md` or the `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs. ### to 0.8.5