From 6d972e28c822c188e15080dff81f9c9ee0fb3a39 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 17:51:49 -0300 Subject: [PATCH 01/15] refactor(emitters): share the rule-name class and the writer of a rule The name-class regex of kiro.ts's referencedRules is now RULE_NAME_CHARS in shared.ts, so there is one spelling for both emitters. The inline writers computation in agents-md.ts uses ruleWriter, the same logic shared.ts already exposes for spec-14 and spec-15 lookups. --- src/emitters/agents-md.ts | 10 ++++------ src/emitters/kiro.ts | 4 ++-- src/emitters/shared.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/emitters/agents-md.ts b/src/emitters/agents-md.ts index 90071fe..8302a01 100644 --- a/src/emitters/agents-md.ts +++ b/src/emitters/agents-md.ts @@ -1,4 +1,4 @@ -import { appliesTo, outName, ruleFile, textFile } from "./shared.js"; +import { appliesTo, outName, ruleFile, ruleWriter, textFile } from "./shared.js"; import type { Emitter } from "./types.js"; /** @@ -41,16 +41,14 @@ export const agentsMd: Emitter = { parts.push(``, body, ""); } else { // Spec 14 §4.1: decide which target writes this rule file - const writers = (["claude-code", "kiro"] as const).filter( - (t) => ctx.resolution.targets.includes(t) && appliesTo(ing.meta.targets, t), - ); + const writer = ruleWriter(ctx.resolution.targets, ing.meta.targets); const scopeText = ing.meta.inclusion === "fileMatch" ? `applies to \`${[ing.meta.fileMatchPattern].flat().join("`, `")}\`` : ing.meta.inclusion; - if (writers.includes("claude-code")) { + if (writer === "claude-code") { pathLines.push(`- \`${ruleFile("claude-code", ing.meta)}\` — ${scopeText}`); - } else if (writers.includes("kiro")) { + } else if (writer === "kiro") { pathLines.push(`- \`${ruleFile("kiro", ing.meta)}\` — ${scopeText}`); } else { // State C: no target writes the rule file; embed it diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index 1d7fd98..46a1d4e 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 } from "./shared.js"; +import { appliesTo, mcpServers, outName, ruleFile, RULE_NAME_CHARS } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; import type { ResolvedIngredient } from "../core/resolve.js"; @@ -152,7 +152,7 @@ export function agentResources(agentName: string, text: string, known: Set.md` or `.kiro/steering/.md`, in order of first appearance. */ export function referencedRules(text: string, known: Set): string[] { const out: string[] = []; - for (const m of text.matchAll(/\.(?:claude\/rules|kiro\/steering)\/([A-Za-z0-9._-]+)\.md/g)) { + for (const m of text.matchAll(new RegExp(`\\.(?:claude\\/rules|kiro\\/steering)\\/([${RULE_NAME_CHARS}]+)\\.md`, "g"))) { const name = m[1]; if (known.has(name) && !out.includes(name)) out.push(name); } diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 5a97045..45f31aa 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -5,6 +5,19 @@ export function appliesTo(targets: "*" | string[], target: string): boolean { return targets === "*" || targets.includes(target); } +/** 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._-"; + +/** + * 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. + */ +export function ruleWriter(targets: readonly string[], ruleTargets: "*" | string[]): "claude-code" | "kiro" | null { + if (targets.includes("claude-code") && appliesTo(ruleTargets, "claude-code")) return "claude-code"; + if (targets.includes("kiro") && appliesTo(ruleTargets, "kiro")) return "kiro"; + return null; +} + const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]); /** Text file that keeps the EOL and the BOM of the file it replaces (LF, no BOM when new). */ From f3280447ffca63e1c49d1971b6d2a37f1c32d968 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 18:56:37 -0300 Subject: [PATCH 02/15] fix(agents-md): resolve .claude/rules references in bodies by the rule they name Spec 15: a reference becomes the file a target writes, the rule's place in AGENTS.md, or " (rule not in this workspace)", with one warning per AGENTS.md. Three spec-14 tests now expect their resolved bodies (approved). --- src/emitters/agents-md.ts | 65 ++++++++- src/emitters/shared.ts | 155 ++++++++++++++++++++ test/emitters/agents-md.test.ts | 243 +++++++++++++++++++++++++++++++- 3 files changed, 458 insertions(+), 5 deletions(-) diff --git a/src/emitters/agents-md.ts b/src/emitters/agents-md.ts index 8302a01..ebea709 100644 --- a/src/emitters/agents-md.ts +++ b/src/emitters/agents-md.ts @@ -1,4 +1,4 @@ -import { appliesTo, outName, ruleFile, ruleWriter, textFile } from "./shared.js"; +import { appliesTo, outName, resolveRuleRefs, ruleFile, ruleWriter, textFile, type RefReport } from "./shared.js"; import type { Emitter } from "./types.js"; /** @@ -34,10 +34,17 @@ export const agentsMd: Emitter = { ]; const pathLines: string[] = []; const embedded: string[] = []; + // Collect reports in AGENTS.md order: always-on first, then embedded (spec 15 §4.5) + const alwaysReports: RefReport[] = []; + const embeddedReports: RefReport[] = []; for (const ing of rules) { if (ing.meta.type !== "rule") continue; - const body = (await ctx.text(ing, ing.meta.file)).replace(/\n+$/, ""); + const rawBody = await ctx.text(ing, ing.meta.file); if (ing.meta.inclusion === "always") { + // Resolve references, then trim trailing newlines (spec 15: after ctx.text, before the trim) + const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, ctx.resolution.ingredients, ctx.resolution.targets); + alwaysReports.push(report); + const body = resolved.replace(/\n+$/, ""); parts.push(``, body, ""); } else { // Spec 14 §4.1: decide which target writes this rule file @@ -52,6 +59,10 @@ export const agentsMd: Emitter = { pathLines.push(`- \`${ruleFile("kiro", ing.meta)}\` — ${scopeText}`); } else { // State C: no target writes the rule file; embed it + // Resolve references in embedded bodies too (spec 15 §4, after ctx.text, before trim) + const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, ctx.resolution.ingredients, ctx.resolution.targets); + embeddedReports.push(report); + const body = resolved.replace(/\n+$/, ""); embedded.push(``, `> Scoped rule — ${scopeText}`, "", body, ""); } } @@ -61,6 +72,56 @@ export const agentsMd: Emitter = { if (pathLines.length) parts.push(""); parts.push(...embedded); } + + // Emit the warning per spec 15 §4.5: at most one ctx.warn for AGENTS.md + // Reports in AGENTS.md order: always-on first, then embedded + emitRefWarning(ctx.warn, [...alwaysReports, ...embeddedReports]); + return [await textFile(ctx, "AGENTS.md", parts.join("\n"), "agents-md", "rule/*")]; }, }; + +/** + * Emit the spec 15 §4.5 warning: one line with reworded rule references and/or dead .claude/ paths. + * Deduplicates entries by (reference, citing) and (path, citing). + */ +function emitRefWarning(warn: (msg: string) => void, reports: RefReport[]): void { + // Collect and deduplicate reworded entries by (ref, citing) + const rewordedSet = new Map(); + for (const r of reports) { + for (const e of r.reworded) { + const key = `${e.ref}|${e.citing}`; + if (!rewordedSet.has(key)) rewordedSet.set(key, e); + } + } + // Collect and deduplicate other paths by (path, citing) + const othersSet = new Map(); + for (const r of reports) { + for (const e of r.others) { + const key = `${e.path}|${e.citing}`; + if (!othersSet.has(key)) othersSet.set(key, e); + } + } + + const reworded = [...rewordedSet.values()]; + const others = [...othersSet.values()]; + + if (!reworded.length && !others.length) return; + + const formatReworded = (entries: typeof reworded) => + entries.map((e) => `${e.ref} (in ${e.citing}; ${e.kind})`).join(", "); + const formatOthers = (entries: typeof others) => + entries.map((e) => `${e.path} (in ${e.citing})`).join(", "); + + let msg = ""; + if (reworded.length) { + msg = `agents-md: ${reworded.length} reference(s) to rule files this workspace does not have — reworded in AGENTS.md: ${formatReworded(reworded)}`; + if (others.length) { + msg += `; ${others.length} reference(s) to other .claude/ files it does not have — left as written: ${formatOthers(others)}`; + } + } else if (others.length) { + msg = `agents-md: ${others.length} reference(s) to .claude/ files this workspace does not have — left as written: ${formatOthers(others)}`; + } + + if (msg) warn(msg); +} diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 45f31aa..c826814 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -1,5 +1,6 @@ import { detectEol, hasBom, withEol, type Eol } from "../core/text.js"; import type { EmitContext, PlannedFile } from "./types.js"; +import type { ResolvedIngredient } from "../core/resolve.js"; export function appliesTo(targets: "*" | string[], target: string): boolean { return targets === "*" || targets.includes(target); @@ -18,6 +19,160 @@ export function ruleWriter(targets: readonly string[], ruleTargets: "*" | string return null; } +/** Report of what resolveRuleRefs reworded or found dead (spec 15 §4.5). */ +export interface RefReport { + reworded: Array<{ ref: string; citing: string; kind: string }>; + others: Array<{ path: string; citing: string }>; +} + +/** + * Resolve `.claude/rules/.md` references in a body emitted into AGENTS.md (spec 15 §4.1–§4.3). + * Returns the transformed text and a report of what was reworded or found dead. + */ +export function resolveRuleRefs( + body: string, + citing: string, + ingredients: ResolvedIngredient[], + targets: readonly string[], +): { text: string; report: RefReport } { + const report: RefReport = { reworded: [], others: [] }; + const hasCc = targets.includes("claude-code"); + const hasKiro = targets.includes("kiro"); + + // Build a lookup map by output name + const byOutName = new Map(); + for (const ing of ingredients) { + byOutName.set(outName(ing.meta), ing); + } + + // Determine rule state: A, B, C, D, or unknown (spec 15 §4.2) + const ruleState = (name: string): "A" | "B" | "C" | "D" | "unknown" => { + const ing = byOutName.get(name); + if (!ing) return "unknown"; + const type = ing.meta.type; + // A: claude-code writes it + if (type === "rule" && hasCc && appliesTo(ing.meta.targets, "claude-code")) return "A"; + // B: kiro writes it (rule or steering) + if ((type === "rule" || type === "steering") && hasKiro && appliesTo(ing.meta.targets, "kiro")) return "B"; + // C: aimed at agents-md (text is in AGENTS.md) + if (type === "rule" && appliesTo(ing.meta.targets, "agents-md")) return "C"; + // D: rule exists but not written by any target here + if (type === "rule") return "D"; + // Not a rule or steering: unknown + return "unknown"; + }; + + // Helper to get the ingredient ref for reporting + const ingRef = (name: string): string | undefined => byOutName.get(name)?.ref; + + // Right boundary: not followed by letter, digit, `_`, `-`, or `.` followed by one of those (spec 15 §4.1) + const rightBoundary = (after: string): boolean => { + if (!after) return true; + const c = after[0]; + if (/[A-Za-z0-9_-]/.test(c)) return false; + if (c === "." && after.length > 1 && /[A-Za-z0-9_-]/.test(after[1])) return false; + return true; + }; + + // Left boundary: not preceded by `/` or a name character (spec 15 §4.1) + const leftBoundary = (before: string): boolean => { + if (!before) return true; + const c = before[before.length - 1]; + return c !== "/" && !/[A-Za-z0-9._-]/.test(c); + }; + + // Process links first: [text](.claude/rules/.md) or [text](.claude/rules/.md#frag) + // Pattern: `[](.claude/rules/.md)` or `[](.claude/rules/.md#)` + const linkPattern = new RegExp( + `\\[([^\\]]+)\\]\\(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md(#[^)]*)?\\)`, + "g", + ); + + let result = body.replace(linkPattern, (match, text: string, name: string, frag: string | undefined, offset: number) => { + // Check left boundary (before the `[`) + const before = body.slice(0, offset); + if (!leftBoundary(before)) return match; + // Check right boundary (after the `)`) + const after = body.slice(offset + match.length); + if (!rightBoundary(after)) return match; + + const state = ruleState(name); + switch (state) { + case "A": + return match; // unchanged + case "B": + return `[${text}](.kiro/steering/${name}.md${frag ?? ""})`; + case "C": + return `[${text}](AGENTS.md)`; // fragment dropped + case "D": { + report.reworded.push({ ref: `.claude/rules/${name}.md`, citing, kind: `rule/${ingRef(name)!.split("/")[1]} reaches no target here` }); + return `${text} (${name}, rule not in this workspace)`; + } + case "unknown": + if (hasCc) return match; + report.reworded.push({ ref: `.claude/rules/${name}.md`, citing, kind: "no such rule" }); + return `${text} (${name}, rule not in this workspace)`; + } + }); + + // Process token references: .claude/rules/.md (not in a link) + // We need to be careful not to match references we already processed as links + // The pattern matches .claude/rules/.md with boundaries + const tokenPattern = new RegExp( + `(^|[^/A-Za-z0-9._-])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`, + "g", + ); + + result = result.replace(tokenPattern, (match, before: string, ref: string, name: string, offset: number) => { + // `before` is the captured boundary character (or empty at start) + // Check right boundary + const fullOffset = offset + match.length; + const after = result.slice(fullOffset); + if (!rightBoundary(after)) return match; + + const state = ruleState(name); + switch (state) { + case "A": + return match; // unchanged + case "B": + return `${before}.kiro/steering/${name}.md`; + case "C": + return `${before}AGENTS.md (rule: ${name})`; + case "D": { + report.reworded.push({ ref: `.claude/rules/${name}.md`, citing, kind: `rule/${ingRef(name)!.split("/")[1]} reaches no target here` }); + return `${before}${name} (rule not in this workspace)`; + } + case "unknown": + if (hasCc) return match; + report.reworded.push({ ref: `.claude/rules/${name}.md`, citing, kind: "no such rule" }); + return `${before}${name} (rule not in this workspace)`; + } + }); + + // Collect other .claude/ paths (agents, commands, skills, scripts, hooks) only when no claude-code (spec 15 §4.5) + if (!hasCc) { + const otherDirs = ["agents", "commands", "skills", "scripts", "hooks"]; + // Note: RULE_NAME_CHARS is `A-Za-z0-9._-`, so we add `/` before the `-` to avoid a bad range + const otherPattern = new RegExp( + `(^|[^/A-Za-z0-9._-])\\.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 }); + } + } + } + + return { text: result, report }; +} + const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]); /** Text file that keeps the EOL and the BOM of the file it replaces (LF, no BOM when new). */ diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index a756cb0..d807125 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -184,7 +184,7 @@ describe("agents-md emitter — scoped rules (spec 14)", () => { it("agents-md only: every scoped rule is embedded with its scope (spec 14 test 1)", async () => { const p = await planFor(forge14()); - expect(agentsMd(p)).toBe(ONLY_MD.join("\n")); + expect(agentsMd(p)).toBe(ONLY_MD.join("\n").replaceAll("See .claude/rules/style.md.", "See AGENTS.md (rule: style).")); noAgentsMdWarning(p); }); @@ -198,7 +198,7 @@ describe("agents-md emitter — scoped rules (spec 14)", () => { "- `.kiro/steering/kiro-only.md` — " + K, "", ...embedded("md-only", M), - ].join("\n")); + ].join("\n").replaceAll("See .claude/rules/style.md.", "See .kiro/steering/style.md.")); noAgentsMdWarning(p); }); @@ -252,6 +252,243 @@ describe("agents-md emitter — scoped rules (spec 14)", () => { it("keeps the CRLF of the AGENTS.md it replaces (spec 14 test 9)", async () => { const p = await planFor(forge14(), { "AGENTS.md": "# old\r\n" }); - expect(agentsMd(p)).toBe(ONLY_MD.join("\r\n")); + expect(agentsMd(p)).toBe(ONLY_MD.join("\r\n").replaceAll("See .claude/rules/style.md.", "See AGENTS.md (rule: style).")); + }); +}); + +describe("agents-md emitter — rule references in bodies (spec 15)", () => { + 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", + "- dead link: [the cc-only rule](.claude/rules/cc-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 forge15 = (): 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"] }), + ]; + const TAIL = ["- pattern: `.claude/rules/.md` and `.claude/rules/*.md`", "- not a rule: `.claude/agents/reviewer.md`, `.claude/commands/open-pr.md`"]; + const hubAndStyle = (lines: string[]) => ["", "# hub", "", ...lines, "", "", "# style", "", "## Scoped rules", ""]; + const scoped = (name: string, pattern: string) => [``, `> Scoped rule — applies to \`${pattern}\``, "", `# ${name}`, ""]; + const WARN = + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here), .claude/rules/handwritten.md (in rule/hub; no such rule); 2 reference(s) to other .claude/ files it does not have — left as written: .claude/agents/reviewer.md (in rule/hub), .claude/commands/open-pr.md (in rule/hub)"; + const amWarnings = (p: Plan) => p.warnings.filter((w) => w.startsWith("agents-md:")); + /** The section of `name` in AGENTS.md: from its marker to the next marker or the scoped heading, trailing newlines trimmed. */ + const sectionOf = (md: string, name: string) => { + const start = md.indexOf(`\n`); + if (start < 0) return undefined; + const rest = md.slice(start); + const end = rest.search(/\n\n(?:", "# hub", "", "- `.claude/rules/style.md`", "- .claude/rules/api.md", "- [api](.claude/rules/api.md)", "", + "", "# style", "", + "## Scoped rules", "", "- `.claude/rules/api.md` — applies to `projects/api/**`", "", + ].join("\n")); + expect(amWarnings(p)).toEqual([]); + }); + + it("an embedded body is resolved too (spec 15 test 5)", async () => { + const p = await planFor([rule("style", "# style\n"), rule("api", "# api\n\nSee .claude/rules/style.md for style.\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" })]); + expect(agentsMd(p)).toBe([ + ...HEADER, "", "# style", "", + "## Scoped rules", "", "", "> Scoped rule — applies to `projects/api/**`", "", "# api", "", "See AGENTS.md (rule: style) for style.", "", + ].join("\n")); + }); + + it("a steering ingredient kiro writes is state B; without kiro it is no rule (spec 15 test 6)", async () => { + const ings = () => hubOnly("See `.claude/rules/product.md`.", [{ meta: { type: "steering", name: "product" }, files: { "steering.md": "# product\n" } }]); + const withKiro = await planFor(ings(), undefined, ["kiro", "agents-md"]); + expect(sectionOf(agentsMd(withKiro)!, "hub")).toBe("\n# hub\n\nSee `.kiro/steering/product.md`."); + expect(amWarnings(withKiro)).toEqual([]); + const mdOnly = await planFor(ings()); + expect(sectionOf(agentsMd(mdOnly)!, "hub")).toBe("\n# hub\n\nSee `product (rule not in this workspace)`."); + expect(amWarnings(mdOnly)).toEqual(["agents-md: 1 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/product.md (in rule/hub; no such rule)"]); + }); + + it("a fragment is kept in B, dropped in C, gone with the link in D (spec 15 test 7)", async () => { + const p = await planFor( + hubOnly("[b](.claude/rules/kb.md#part)\n[c](.claude/rules/mdr.md#part)\n[d](.claude/rules/cc-only.md#part)", [ + rule("kb", "# kb\n", { inclusion: "manual" }), + rule("mdr", "# mdr\n", { inclusion: "manual", targets: ["agents-md"] }), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + ]), + undefined, + ["kiro", "agents-md"], + ); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\n[b](.kiro/steering/kb.md#part)\n[c](AGENTS.md)\nd (cc-only, rule not in this workspace)"); + }); + + it("what is not a reference is left alone and not reported (spec 15 test 8)", async () => { + const lines = [ + "projects/web/.claude/rules/style.md", + "x.claude/rules/style.md", + "`.claude/rules/style.md.bak`", + "`.claude/rules/style.mdx`", + "`.claude/rules/.md` and `.claude/rules/*.md`", + ]; + const p = await planFor(hubOnly(lines.join("\n"), [rule("style", "# style\n")])); + expect(sectionOf(agentsMd(p)!, "hub")).toBe(["", "# hub", "", ...lines].join("\n")); + expect(amWarnings(p)).toEqual([]); + }); + + it("a variant is resolved by its output name (spec 15 test 9)", async () => { + const api = (extra: Record) => rule("api--acme", "# API\n", { as: "api", inclusion: "fileMatch", fileMatchPattern: "projects/api/**", ...extra }); + const c = await planFor(hubOnly("See .claude/rules/api.md now.", [api({})])); + expect(sectionOf(agentsMd(c)!, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: api) now."); + const d = await planFor(hubOnly("See .claude/rules/api.md now.", [api({ targets: ["claude-code"] })])); + expect(sectionOf(agentsMd(d)!, "hub")).toBe("\n# hub\n\nSee api (rule not in this workspace) now."); + expect(amWarnings(d)).toEqual(["agents-md: 1 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/api.md (in rule/hub; rule/api--acme reaches no target here)"]); + }); + + it("a reference that comes from a section value is resolved (spec 15 test 10)", async () => { + const s = await scenario( + { + ingredients: [rule("hub", "# hub\n\n\ndefault\n\n"), rule("style", "# style\n")], + recipes: [recipe("base", ["rule/hub", "rule/style"])], + profiles: [profile("acme", ["base"], ["agents-md"], { sections: { "rule/hub": { s: "See .claude/rules/style.md now." } } })], + }, + { config: { profile: "acme" } }, + ); + cleanups.push(s.cleanup); + await writeFiles(s.forgeRoot, { "craftar.forge.yaml": "name: test-forge\nschema: 2\n" }); + const md = agentsMd(await plan(await loadWorkspace(s.wsRoot)))!; + expect(sectionOf(md, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: style) now."); + }); + + it("other .claude/ paths only: left as written, named in the second form; nothing with claude-code (spec 15 test 11)", async () => { + const mdOnly = await planFor(hubOnly("See `.claude/agents/reviewer.md`.")); + expect(sectionOf(agentsMd(mdOnly)!, "hub")).toBe("\n# hub\n\nSee `.claude/agents/reviewer.md`."); + expect(amWarnings(mdOnly)).toEqual(["agents-md: 1 reference(s) to .claude/ files this workspace does not have — left as written: .claude/agents/reviewer.md (in rule/hub)"]); + const withCc = await planFor(hubOnly("See `.claude/agents/reviewer.md`."), undefined, ["claude-code", "agents-md"]); + expect(amWarnings(withCc)).toEqual([]); + }); + + it("one entry per reference and citing rule (spec 15 test 12)", async () => { + const p = await planFor(hubOnly(".claude/rules/cc-only.md and .claude/rules/cc-only.md", [rule("cc-only", "# cc-only\n", { targets: ["claude-code"] })])); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\ncc-only (rule not in this workspace) and cc-only (rule not in this workspace)"); + expect(amWarnings(p)).toEqual(["agents-md: 1 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here)"]); + }); + + it("state D with claude-code: the first form alone (spec 15 test 13)", async () => { + const p = await planFor(hubOnly("See .claude/rules/k.md.", [rule("k", "# k\n", { targets: ["kiro"] })]), undefined, ["claude-code", "agents-md"]); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\nSee k (rule not in this workspace)."); + expect(amWarnings(p)).toEqual(["agents-md: 1 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/k.md (in rule/hub; rule/k reaches no target here)"]); + }); + + it("an unknown name as a link (spec 15 test 14)", async () => { + const p = await planFor(hubOnly("[the notes](.claude/rules/handwritten.md)")); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\nthe notes (handwritten, rule not in this workspace)"); + }); + + it("a reference that comes from a param value is resolved (spec 15 test 15)", async () => { + const s = await scenario( + { + ingredients: [rule("hub", "# hub\n\nSee {{ref}}.\n"), rule("style", "# style\n")], + recipes: [recipe("base", ["rule/hub", "rule/style"])], + profiles: [profile("acme", ["base"], ["agents-md"], { params: { ref: ".claude/rules/style.md" } })], + }, + { config: { profile: "acme" } }, + ); + cleanups.push(s.cleanup); + const md = agentsMd(await plan(await loadWorkspace(s.wsRoot)))!; + expect(sectionOf(md, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: style)."); + }); + + it("a sentence-ending full stop is not part of the reference (spec 15 test 16)", async () => { + const p = await planFor(hubOnly("See .claude/rules/style.md.", [rule("style", "# style\n")])); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: style)."); + }); + + it("entries follow the order of AGENTS.md, not of resolution (spec 15 test 17)", async () => { + const p = await planFor([ + rule("emb", "# emb\n\nSee .claude/rules/cc-only.md.\n", { inclusion: "manual" }), + rule("hub", "# hub\n\nSee .claude/rules/cc-only.md.\n"), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + ]); + expect(amWarnings(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in rule/emb; rule/cc-only reaches no target here)", + ]); }); }); From 47806d1d71048f13cc53a486e1b0c06c29394321 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 18:57:57 -0300 Subject: [PATCH 03/15] docs(readme): say how AGENTS.md resolves rule references in bodies, and what 0.8.4 changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the new sentence to the agents-md target paragraph and the ### to 0.8.4 upgrading section per spec 15 §4.3 and §4.5. --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e8888c7..6992c56 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Sections layer the same way, weakest → strongest: the body's default → the p **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. -**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…). 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` does, becomes `AGENTS.md (rule: )` when the rule's text is in this file, and otherwise becomes ` (rule not in this workspace)` with a warning; 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,16 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ## Upgrading +### 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. + - **No change, no warning:** a workspace whose cited rules are all written by `claude-code` — every workspace `craftar import` produced — and one whose bodies cite no rule. + - **No `claude-code`:** each reference moves. It becomes `.kiro/steering/.md` (kiro writes it), `AGENTS.md (rule: )` (its text is in the file) or ` (rule not in this workspace)`. + - **With `claude-code`:** only a reference to a rule `claude-code` does not write moves. An unknown name is left as written. +- **One warning per `AGENTS.md`** names every reworded reference. 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 Forge is fixed, 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 resolves it the same way. + ### to 0.8.3 - **`AGENTS.md` lists a scoped rule (`fileMatch`, `manual`, `auto`) only at a file a target of the workspace really writes, and embeds it when none does.** Only `AGENTS.md` changes; no other generated file and no lock field. From 4c5b09fee8833186ad9d1c5caa27e19ed5db199b Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 18:58:53 -0300 Subject: [PATCH 04/15] chore: update version Bumps version to 0.8.4 for the patch release. --- 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 b0563b7..f7b49db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.3", + "version": "0.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.3", + "version": "0.8.4", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 1f23209..98a4bfc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.3", + "version": "0.8.4", "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 9b7c9a6..a73edce 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.3"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.4"); /* ---------------------------------------------------------------- import */ program From ed0ba2dd6dcc7f9accb8b3bf0e4352254687a124 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:08:04 -0300 Subject: [PATCH 05/15] fix(agents-md): a link is resolved whatever surrounds it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 15 §4.1 puts boundaries around the path, not the link syntax. A link already bounds its path with ( and ), so no extra checks needed. --- src/emitters/shared.ts | 10 ++-------- test/emitters/agents-md.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index c826814..ccea2fb 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -88,14 +88,8 @@ export function resolveRuleRefs( "g", ); - let result = body.replace(linkPattern, (match, text: string, name: string, frag: string | undefined, offset: number) => { - // Check left boundary (before the `[`) - const before = body.slice(0, offset); - if (!leftBoundary(before)) return match; - // Check right boundary (after the `)`) - const after = body.slice(offset + match.length); - if (!rightBoundary(after)) return match; - + let result = body.replace(linkPattern, (match, text: string, name: string, frag: string | undefined) => { + // A link already bounds the path with `(` and `)` or `#`, so no boundary checks needed (spec 15 §4.1). const state = ruleState(name); switch (state) { case "A": diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index d807125..473d043 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -491,4 +491,17 @@ describe("agents-md emitter — rule references in bodies (spec 15)", () => { "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in rule/emb; rule/cc-only reaches no target here)", ]); }); + + it("a link is a link whatever stands before or after it (spec 15 §4.1, review fix)", async () => { + const p = await planFor( + hubOnly("rules/[s](.claude/rules/style.md)\n[s](.claude/rules/style.md)-based\n[a](.claude/rules/style.md)/[b](.claude/rules/api.md)\n[c](.claude/rules/cc-only.md)s", [ + rule("style", "# style\n"), + rule("api", "# api\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" }), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + ]), + ); + expect(sectionOf(agentsMd(p)!, "hub")).toBe( + "\n# hub\n\nrules/[s](AGENTS.md)\n[s](AGENTS.md)-based\n[a](AGENTS.md)/[b](AGENTS.md)\nc (cc-only, rule not in this workspace)s", + ); + }); }); From 09f0aad6a7d216aa1312920129d5486192ef5f76 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:09:24 -0300 Subject: [PATCH 06/15] fix(agents-md): look a referenced name up among rules, then steering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 15 §4.2 looks up the name among rules first, then steering only for state B. A command with the same output name no longer hides the rule. --- src/emitters/shared.ts | 36 ++++++++++++++++++--------------- test/emitters/agents-md.test.ts | 9 +++++++++ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index ccea2fb..957b5b0 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -39,31 +39,35 @@ export function resolveRuleRefs( const hasCc = targets.includes("claude-code"); const hasKiro = targets.includes("kiro"); - // Build a lookup map by output name - const byOutName = new Map(); + // Build lookup maps by output name: rules first, then steering for state B only (spec 15 §4.2) + const rulesByName = new Map(); + const steeringsByName = new Map(); for (const ing of ingredients) { - byOutName.set(outName(ing.meta), ing); + const name = outName(ing.meta); + if (ing.meta.type === "rule") rulesByName.set(name, ing); + else if (ing.meta.type === "steering") steeringsByName.set(name, ing); } // Determine rule state: A, B, C, D, or unknown (spec 15 §4.2) const ruleState = (name: string): "A" | "B" | "C" | "D" | "unknown" => { - const ing = byOutName.get(name); - if (!ing) return "unknown"; - const type = ing.meta.type; - // A: claude-code writes it - if (type === "rule" && hasCc && appliesTo(ing.meta.targets, "claude-code")) return "A"; - // B: kiro writes it (rule or steering) - if ((type === "rule" || type === "steering") && hasKiro && appliesTo(ing.meta.targets, "kiro")) return "B"; - // C: aimed at agents-md (text is in AGENTS.md) - if (type === "rule" && appliesTo(ing.meta.targets, "agents-md")) return "C"; + const rule = rulesByName.get(name); + // A: claude-code writes this rule + if (rule && hasCc && appliesTo(rule.meta.targets, "claude-code")) return "A"; + // B: kiro writes this rule + if (rule && hasKiro && appliesTo(rule.meta.targets, "kiro")) return "B"; + // 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: rule aimed at agents-md (text is in AGENTS.md) + if (rule && appliesTo(rule.meta.targets, "agents-md")) return "C"; // D: rule exists but not written by any target here - if (type === "rule") return "D"; - // Not a rule or steering: unknown + if (rule) return "D"; + // No rule with this name return "unknown"; }; - // Helper to get the ingredient ref for reporting - const ingRef = (name: string): string | undefined => byOutName.get(name)?.ref; + // Helper to get the ingredient ref for reporting (rules only) + const ingRef = (name: string): string | undefined => rulesByName.get(name)?.ref; // Right boundary: not followed by letter, digit, `_`, `-`, or `.` followed by one of those (spec 15 §4.1) const rightBoundary = (after: string): boolean => { diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index 473d043..94c1a95 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -504,4 +504,13 @@ describe("agents-md emitter — rule references in bodies (spec 15)", () => { "\n# hub\n\nrules/[s](AGENTS.md)\n[s](AGENTS.md)-based\n[a](AGENTS.md)/[b](AGENTS.md)\nc (cc-only, rule not in this workspace)s", ); }); + + it("a command with the same name does not hide the rule (spec 15 §4.2, review fix)", async () => { + const cmd = { meta: { type: "command" as const, name: "style" }, files: { "command.md": "# style command\n" } }; + for (const ings of [hubOnly("See .claude/rules/style.md.", [rule("style", "# style\n"), cmd]), hubOnly("See .claude/rules/style.md.", [cmd, rule("style", "# style\n")])]) { + const p = await planFor(ings); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\nSee AGENTS.md (rule: style)."); + expect(amWarnings(p).filter((w) => w.includes("reference(s)"))).toEqual([]); + } + }); }); From 61e3b20163b032c4107209545340ed54463ef4c8 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:10:55 -0300 Subject: [PATCH 07/15] fix(agents-md): report references in the order they appear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 15 §4.5 orders entries by position in the body. Collect matches with their offsets, sort by offset, and dedup keeps first occurrence. --- src/emitters/shared.ts | 24 +++++++++++++++++++----- test/emitters/agents-md.test.ts | 7 +++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 957b5b0..a92c42d 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -92,7 +92,10 @@ export function resolveRuleRefs( "g", ); - let result = body.replace(linkPattern, (match, text: string, name: string, frag: string | undefined) => { + // Collect reworded entries with their original offsets for sorting (spec 15 §4.5) + const rewordedWithOffset: Array<{ offset: number; ref: string; citing: string; kind: string }> = []; + + let result = body.replace(linkPattern, (match, text: string, name: string, frag: string | undefined, offset: number) => { // A link already bounds the path with `(` and `)` or `#`, so no boundary checks needed (spec 15 §4.1). const state = ruleState(name); switch (state) { @@ -103,12 +106,12 @@ export function resolveRuleRefs( case "C": return `[${text}](AGENTS.md)`; // fragment dropped case "D": { - report.reworded.push({ ref: `.claude/rules/${name}.md`, citing, kind: `rule/${ingRef(name)!.split("/")[1]} reaches no target here` }); + rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: `rule/${ingRef(name)!.split("/")[1]} reaches no target here` }); return `${text} (${name}, rule not in this workspace)`; } case "unknown": if (hasCc) return match; - report.reworded.push({ ref: `.claude/rules/${name}.md`, citing, kind: "no such rule" }); + rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: "no such rule" }); return `${text} (${name}, rule not in this workspace)`; } }); @@ -137,16 +140,27 @@ export function resolveRuleRefs( case "C": return `${before}AGENTS.md (rule: ${name})`; case "D": { - report.reworded.push({ ref: `.claude/rules/${name}.md`, citing, kind: `rule/${ingRef(name)!.split("/")[1]} reaches no target here` }); + rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: `rule/${ingRef(name)!.split("/")[1]} reaches no target here` }); return `${before}${name} (rule not in this workspace)`; } case "unknown": if (hasCc) return match; - report.reworded.push({ ref: `.claude/rules/${name}.md`, citing, kind: "no such rule" }); + rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: "no such rule" }); return `${before}${name} (rule not in this workspace)`; } }); + // 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) { + const key = `${e.ref}|${e.citing}`; + if (!seen.has(key)) { + seen.add(key); + report.reworded.push({ ref: e.ref, citing: e.citing, kind: e.kind }); + } + } + // Collect other .claude/ paths (agents, commands, skills, scripts, hooks) only when no claude-code (spec 15 §4.5) if (!hasCc) { const otherDirs = ["agents", "commands", "skills", "scripts", "hooks"]; diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index 94c1a95..2cce850 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -513,4 +513,11 @@ describe("agents-md emitter — rule references in bodies (spec 15)", () => { expect(amWarnings(p).filter((w) => w.includes("reference(s)"))).toEqual([]); } }); + + it("a dead token before a dead link is reported first (spec 15 §4.5, review fix)", async () => { + const p = await planFor(hubOnly("x .claude/rules/nope.md\n[c](.claude/rules/cc-only.md)", [rule("cc-only", "# cc-only\n", { targets: ["claude-code"] })])); + expect(amWarnings(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/nope.md (in rule/hub; no such rule), .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here)", + ]); + }); }); From 8d68e1d1bb4c0df4f3bb29cb0bf38aca23691569 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:16:19 -0300 Subject: [PATCH 08/15] refactor(emitters): resolve rule states through ruleWriter and RULE_NAME_CHARS Use ruleWriter for states A and B. Build RuleLookup once per AGENTS.md, not once per body. Use the ingredient's ref directly for reporting. --- src/emitters/agents-md.ts | 10 ++++-- src/emitters/shared.ts | 67 +++++++++++++++++++++++---------------- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/emitters/agents-md.ts b/src/emitters/agents-md.ts index ebea709..7d26031 100644 --- a/src/emitters/agents-md.ts +++ b/src/emitters/agents-md.ts @@ -1,4 +1,4 @@ -import { appliesTo, outName, resolveRuleRefs, ruleFile, ruleWriter, textFile, type RefReport } from "./shared.js"; +import { appliesTo, buildRuleLookup, outName, resolveRuleRefs, ruleFile, ruleWriter, textFile, type RefReport } from "./shared.js"; import type { Emitter } from "./types.js"; /** @@ -23,6 +23,10 @@ export const agentsMd: Emitter = { } const rules = aimed.filter((i) => i.meta.type === "rule"); if (!rules.length) return []; + + // Build the lookup once per AGENTS.md, not once per body (spec 15 §4.2) + const lookup = buildRuleLookup(ctx.resolution.ingredients); + const parts: string[] = [ "", "", @@ -42,7 +46,7 @@ export const agentsMd: Emitter = { const rawBody = await ctx.text(ing, ing.meta.file); if (ing.meta.inclusion === "always") { // Resolve references, then trim trailing newlines (spec 15: after ctx.text, before the trim) - const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, ctx.resolution.ingredients, ctx.resolution.targets); + const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, lookup, ctx.resolution.targets); alwaysReports.push(report); const body = resolved.replace(/\n+$/, ""); parts.push(``, body, ""); @@ -60,7 +64,7 @@ export const agentsMd: Emitter = { } else { // State C: no target writes the rule file; embed it // Resolve references in embedded bodies too (spec 15 §4, after ctx.text, before trim) - const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, ctx.resolution.ingredients, ctx.resolution.targets); + const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, lookup, ctx.resolution.targets); embeddedReports.push(report); const body = resolved.replace(/\n+$/, ""); embedded.push(``, `> Scoped rule — ${scopeText}`, "", body, ""); diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index a92c42d..a8c0dca 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -25,6 +25,24 @@ export interface RefReport { others: Array<{ path: string; citing: string }>; } +/** Lookup maps for rule reference resolution (spec 15 §4.2), built once per AGENTS.md. */ +export interface RuleLookup { + rulesByName: Map; + steeringsByName: Map; +} + +/** Build the lookup maps for rule reference resolution, once per AGENTS.md. */ +export function buildRuleLookup(ingredients: ResolvedIngredient[]): RuleLookup { + const rulesByName = new Map(); + const steeringsByName = new Map(); + for (const ing of ingredients) { + const name = outName(ing.meta); + if (ing.meta.type === "rule") rulesByName.set(name, ing); + else if (ing.meta.type === "steering") steeringsByName.set(name, ing); + } + return { rulesByName, steeringsByName }; +} + /** * Resolve `.claude/rules/.md` references in a body emitted into AGENTS.md (spec 15 §4.1–§4.3). * Returns the transformed text and a report of what was reworded or found dead. @@ -32,44 +50,36 @@ export interface RefReport { export function resolveRuleRefs( body: string, citing: string, - ingredients: ResolvedIngredient[], + lookup: RuleLookup, targets: readonly string[], ): { text: string; report: RefReport } { const report: RefReport = { reworded: [], others: [] }; const hasCc = targets.includes("claude-code"); const hasKiro = targets.includes("kiro"); - - // Build lookup maps by output name: rules first, then steering for state B only (spec 15 §4.2) - const rulesByName = new Map(); - const steeringsByName = new Map(); - for (const ing of ingredients) { - const name = outName(ing.meta); - if (ing.meta.type === "rule") rulesByName.set(name, ing); - else if (ing.meta.type === "steering") steeringsByName.set(name, ing); - } + 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" => { const rule = rulesByName.get(name); - // A: claude-code writes this rule - if (rule && hasCc && appliesTo(rule.meta.targets, "claude-code")) return "A"; - // B: kiro writes this rule - if (rule && hasKiro && appliesTo(rule.meta.targets, "kiro")) return "B"; + 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"; + // 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"; + } // 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: rule aimed at agents-md (text is in AGENTS.md) - if (rule && appliesTo(rule.meta.targets, "agents-md")) return "C"; - // D: rule exists but not written by any target here - if (rule) return "D"; // No rule with this name return "unknown"; }; - // Helper to get the ingredient ref for reporting (rules only) - const ingRef = (name: string): string | undefined => rulesByName.get(name)?.ref; - // 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]; @@ -98,6 +108,7 @@ export function resolveRuleRefs( let result = body.replace(linkPattern, (match, text: string, name: string, frag: string | undefined, offset: number) => { // A link already bounds the path with `(` and `)` or `#`, so no boundary checks needed (spec 15 §4.1). const state = ruleState(name); + const rule = rulesByName.get(name); switch (state) { case "A": return match; // unchanged @@ -106,7 +117,7 @@ export function resolveRuleRefs( case "C": return `[${text}](AGENTS.md)`; // fragment dropped case "D": { - rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: `rule/${ingRef(name)!.split("/")[1]} reaches no target here` }); + rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: `${rule!.ref} reaches no target here` }); return `${text} (${name}, rule not in this workspace)`; } case "unknown": @@ -117,10 +128,9 @@ export function resolveRuleRefs( }); // Process token references: .claude/rules/.md (not in a link) - // We need to be careful not to match references we already processed as links - // The pattern matches .claude/rules/.md with boundaries + // The pattern matches .claude/rules/.md with left boundary in the capture const tokenPattern = new RegExp( - `(^|[^/A-Za-z0-9._-])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`, + `(^|[^/${RULE_NAME_CHARS}])(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md)`, "g", ); @@ -132,6 +142,7 @@ export function resolveRuleRefs( if (!rightBoundary(after)) return match; const state = ruleState(name); + const rule = rulesByName.get(name); switch (state) { case "A": return match; // unchanged @@ -140,7 +151,7 @@ export function resolveRuleRefs( case "C": return `${before}AGENTS.md (rule: ${name})`; case "D": { - rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: `rule/${ingRef(name)!.split("/")[1]} reaches no target here` }); + rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: `${rule!.ref} reaches no target here` }); return `${before}${name} (rule not in this workspace)`; } case "unknown": @@ -164,9 +175,9 @@ export function resolveRuleRefs( // Collect other .claude/ paths (agents, commands, skills, scripts, hooks) only when no claude-code (spec 15 §4.5) if (!hasCc) { const otherDirs = ["agents", "commands", "skills", "scripts", "hooks"]; - // Note: RULE_NAME_CHARS is `A-Za-z0-9._-`, so we add `/` before the `-` to avoid a bad range + // The path class adds `/` for subdirectories; in `[.../-]` the `/` goes before `-` to avoid a range error. const otherPattern = new RegExp( - `(^|[^/A-Za-z0-9._-])\\.claude\\/(${otherDirs.join("|")})\\/([A-Za-z0-9._/-]+)`, + `(^|[^/${RULE_NAME_CHARS}])\\.claude\\/(${otherDirs.join("|")})\\/([A-Za-z0-9._/-]+)`, "g", ); let otherMatch; From 7b3e3a5b31523a0a416b753945583c86509b8193 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:17:53 -0300 Subject: [PATCH 09/15] docs(readme): say when an unknown reference is kept, and which workspaces see the warning Clarify that unknown names are left as written when claude-code is a target, update the No change/no warning cases, and add version numbers. --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6992c56..d87f65f 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Sections layer the same way, weakest → strongest: the body's default → the p **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. -**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` does, becomes `AGENTS.md (rule: )` when the rule's text is in this file, and otherwise becomes ` (rule not in this workspace)` with a warning; 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` does, 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. 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,12 +244,12 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ### 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. - - **No change, no warning:** a workspace whose cited rules are all written by `claude-code` — every workspace `craftar import` produced — and one whose bodies cite no rule. + - **No change, no warning:** a workspace whose cited rules are all written by `claude-code` — every workspace `craftar import` produced — and one whose bodies cite no rule and that has `claude-code`. Without `claude-code`, a body citing other `.claude/` files keeps its bytes but gets the warning. - **No `claude-code`:** each reference moves. It becomes `.kiro/steering/.md` (kiro writes it), `AGENTS.md (rule: )` (its text is in the file) or ` (rule not in this workspace)`. - - **With `claude-code`:** only a reference to a rule `claude-code` does not write moves. An unknown name is left as written. + - **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. - **One warning per `AGENTS.md`** names every reworded reference. 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 Forge is fixed, 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 resolves it the same way. +- The kiro emitter still rewrites every `.claude/rules/` to `.kiro/steering/`; a later release (0.8.5) addresses it. ### to 0.8.3 @@ -259,7 +259,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC - With `agents-md` alone, each scoped line becomes the embedded rule, and the file grows. - A scoped rule whose own `targets` exclude `claude-code` moves to `.kiro/steering/`, or is embedded when no file-writing target of the workspace admits it. - An affected workspace shows `AGENTS.md` as `update`, and `craftar sync --check` exits 1 until it syncs. To keep a scoped rule's text out of `AGENTS.md`, take `agents-md` out of that rule's `targets`. -- References to `.claude/rules/…` inside a rule body are emitted as the Forge holds them. In a workspace without `claude-code` such a reference points at no file; a later release addresses it. +- References to `.claude/rules/…` inside a rule body are emitted as the Forge holds them. In a workspace without `claude-code` such a reference points at no file; a later release (0.8.4) addresses it. ### to 0.8.2 From 0994cf46328bd384e9f8ad0984f1bcda44bf37c5 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:26:46 -0300 Subject: [PATCH 10/15] fix(agents-md): a steering file kiro writes wins over a rule no target writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a rule exists but neither claude-code nor kiro writes it, check for a steering ingredient of the same name before returning state C or D. This implements spec 15 §4.2 correctly: B before C/D for .kiro/steering/.md. --- src/emitters/shared.ts | 9 ++++++--- test/emitters/agents-md.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index a8c0dca..06d0487 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -66,14 +66,17 @@ export function resolveRuleRefs( 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) + 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"; } - // B (steering): kiro writes a steering with this name - const steering = steeringsByName.get(name); - if (steering && hasKiro && appliesTo(steering.meta.targets, "kiro")) return "B"; // No rule with this name return "unknown"; }; diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index 2cce850..4b6f269 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -520,4 +520,17 @@ describe("agents-md emitter — rule references in bodies (spec 15)", () => { "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/nope.md (in rule/hub; no such rule), .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here)", ]); }); + + it("a steering file kiro writes wins over a rule of the same name no target writes (spec 15 §4.2, review fix)", async () => { + const steering = { meta: { type: "steering" as const, name: "x" }, files: { "steering.md": "# x steering\n" } }; + for (const ruleTargets of [["claude-code"], ["agents-md"]]) { + const p = await planFor( + hubOnly("see .claude/rules/x.md and [l](.claude/rules/x.md)", [rule("x", "# x\n", { inclusion: "manual", targets: ruleTargets }), steering]), + undefined, + ["kiro", "agents-md"], + ); + expect(sectionOf(agentsMd(p)!, "hub"), ruleTargets.join()).toBe("\n# hub\n\nsee .kiro/steering/x.md and [l](.kiro/steering/x.md)"); + expect(amWarnings(p).filter((w) => w.includes("reference(s)")), ruleTargets.join()).toEqual([]); + } + }); }); From f199e2570b97457200ab24bd7106eaf35e0685c4 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:28:13 -0300 Subject: [PATCH 11/15] docs(readme): say which references the 0.8.4 warning names, and when it stops Clarifies that state-B and state-C references are not reported (only those turned into "rule not in this workspace"), and that the warning repeats until the body, the cited rule's targets or the workspace's targets change. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d87f65f..cdc9431 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Sections layer the same way, weakest → strongest: the body's default → the p **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. -**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` does, 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); 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,10 +244,10 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC ### 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. - - **No change, no warning:** a workspace whose cited rules are all written by `claude-code` — every workspace `craftar import` produced — and one whose bodies cite no rule and that has `claude-code`. Without `claude-code`, a body citing other `.claude/` files keeps its bytes but gets the warning. + - **No change, no warning:** a workspace whose cited rules are all written by `claude-code` — every workspace `craftar import` produced — and one whose bodies cite no `.claude/rules/` path and, without `claude-code`, no other `.claude/` path either. Without `claude-code`, a body citing other `.claude/` files keeps its bytes but gets the warning. - **No `claude-code`:** each reference moves. It becomes `.kiro/steering/.md` (kiro writes it), `AGENTS.md (rule: )` (its text is in the file) or ` (rule not in this workspace)`. - **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. -- **One warning per `AGENTS.md`** names every reworded reference. 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 Forge is fixed, and it never changes an exit code. +- **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. From a597ed8aa7a0cd4a76baa555bf9bca4dde416fae Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:29:30 -0300 Subject: [PATCH 12/15] chore(emitters): drop a dead boundary helper and say why the warning dedups again leftBoundary was defined but never called; token pattern captures the boundary character instead. The emitRefWarning dedup comment now says it is a backstop for references cited by multiple ingredients. --- src/emitters/agents-md.ts | 3 ++- src/emitters/shared.ts | 7 ------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/emitters/agents-md.ts b/src/emitters/agents-md.ts index 7d26031..7b2990c 100644 --- a/src/emitters/agents-md.ts +++ b/src/emitters/agents-md.ts @@ -90,7 +90,8 @@ export const agentsMd: Emitter = { * Deduplicates entries by (reference, citing) and (path, citing). */ function emitRefWarning(warn: (msg: string) => void, reports: RefReport[]): void { - // Collect and deduplicate reworded entries by (ref, citing) + // Collect and deduplicate reworded entries by (ref, citing) — a backstop, since resolveRuleRefs + // already dedups per body; this catches a reference cited by two ingredients. const rewordedSet = new Map(); for (const r of reports) { for (const e of r.reworded) { diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 06d0487..aef90db 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -91,13 +91,6 @@ export function resolveRuleRefs( return true; }; - // Left boundary: not preceded by `/` or a name character (spec 15 §4.1) - const leftBoundary = (before: string): boolean => { - if (!before) return true; - const c = before[before.length - 1]; - return c !== "/" && !/[A-Za-z0-9._-]/.test(c); - }; - // Process links first: [text](.claude/rules/.md) or [text](.claude/rules/.md#frag) // Pattern: `[](.claude/rules/.md)` or `[](.claude/rules/.md#)` const linkPattern = new RegExp( From 0d38e9e7202cc54542f3837263a3ba08ddce6cbf Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:39:02 -0300 Subject: [PATCH 13/15] fix(agents-md): order warning entries by their position in the original body Token references were recorded at their offset in the result after link replacements, not in the original body, causing entries to appear out of order when links shrank or grew. --- src/emitters/shared.ts | 115 +++++++++++++++++++++----------- test/emitters/agents-md.test.ts | 10 +++ 2 files changed, 86 insertions(+), 39 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index aef90db..34bf568 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -91,71 +91,108 @@ export function resolveRuleRefs( return true; }; - // Process links first: [text](.claude/rules/.md) or [text](.claude/rules/.md#frag) - // Pattern: `[](.claude/rules/.md)` or `[](.claude/rules/.md#)` + // 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). + interface Match { + offset: number; + length: number; + replacement: string; + reworded?: { ref: string; kind: string }; + } + const matches: Match[] = []; + + // Link pattern: [text](.claude/rules/.md) or [text](.claude/rules/.md#frag) const linkPattern = new RegExp( `\\[([^\\]]+)\\]\\(\\.claude\\/rules\\/([${RULE_NAME_CHARS}]+)\\.md(#[^)]*)?\\)`, "g", ); - - // Collect reworded entries with their original offsets for sorting (spec 15 §4.5) - const rewordedWithOffset: Array<{ offset: number; ref: string; citing: string; kind: string }> = []; - - let result = body.replace(linkPattern, (match, text: string, name: string, frag: string | undefined, offset: number) => { - // A link already bounds the path with `(` and `)` or `#`, so no boundary checks needed (spec 15 §4.1). + let linkMatch; + while ((linkMatch = linkPattern.exec(body)) !== null) { + const [match, text, name, frag] = linkMatch as RegExpExecArray & [string, string, string, string | undefined]; + const offset = linkMatch.index; const state = ruleState(name); const rule = rulesByName.get(name); + let replacement: string; + let reworded: { ref: string; kind: string } | undefined; switch (state) { case "A": - return match; // unchanged + continue; // unchanged, skip case "B": - return `[${text}](.kiro/steering/${name}.md${frag ?? ""})`; + replacement = `[${text}](${ruleFile("kiro", { name })}${frag ?? ""})`; + break; case "C": - return `[${text}](AGENTS.md)`; // fragment dropped - case "D": { - rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: `${rule!.ref} reaches no target here` }); - return `${text} (${name}, rule not in this workspace)`; - } + 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) return match; - rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: "no such rule" }); - return `${text} (${name}, rule not in this workspace)`; + if (hasCc) continue; + reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; + replacement = `${text} (${name}, rule not in this workspace)`; + break; } - }); + matches.push({ offset, length: match.length, replacement, reworded }); + } - // Process token references: .claude/rules/.md (not in a link) - // The pattern matches .claude/rules/.md with left boundary in the capture + // 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", ); - - result = result.replace(tokenPattern, (match, before: string, ref: string, name: string, offset: number) => { - // `before` is the captured boundary character (or empty at start) - // Check right boundary + let tokenMatch; + while ((tokenMatch = tokenPattern.exec(body)) !== null) { + const [match, before, , name] = tokenMatch as RegExpExecArray & [string, string, string, string]; + const offset = tokenMatch.index; const fullOffset = offset + match.length; - const after = result.slice(fullOffset); - if (!rightBoundary(after)) return match; - + 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": - return match; // unchanged + continue; // unchanged, skip case "B": - return `${before}.kiro/steering/${name}.md`; + replacement = `${before}${ruleFile("kiro", { name })}`; + break; case "C": - return `${before}AGENTS.md (rule: ${name})`; - case "D": { - rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: `${rule!.ref} reaches no target here` }); - return `${before}${name} (rule not in this workspace)`; - } + replacement = `${before}AGENTS.md (rule: ${name})`; + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${before}${name} (rule not in this workspace)`; + break; case "unknown": - if (hasCc) return match; - rewordedWithOffset.push({ offset, ref: `.claude/rules/${name}.md`, citing, kind: "no such rule" }); - return `${before}${name} (rule not in this workspace)`; + if (hasCc) continue; + reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; + replacement = `${before}${name} (rule not in this workspace)`; + break; + } + matches.push({ offset, length: match.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) { + rewordedWithOffset.push({ offset: m.offset, ref: m.reworded.ref, citing, kind: m.reworded.kind }); } - }); + } + + // 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); + } // Sort by offset and deduplicate by (ref, citing), keeping first occurrence (spec 15 §4.5) rewordedWithOffset.sort((a, b) => a.offset - b.offset); diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index 4b6f269..c033649 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -533,4 +533,14 @@ describe("agents-md emitter — rule references in bodies (spec 15)", () => { expect(amWarnings(p).filter((w) => w.includes("reference(s)")), ruleTargets.join()).toEqual([]); } }); + + it("warning order follows the original body even after rewritten links (spec 15 §4.5, review fix)", async () => { + const dead = (x: string) => `.claude/rules/${x}.md (in rule/hub; no such rule)`; + const p1 = await planFor(hubOnly("[a](.claude/rules/d1.md) [b](.claude/rules/d2.md) .claude/rules/z.md [c](.claude/rules/d3.md)")); + expect(amWarnings(p1)).toEqual([`agents-md: 4 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: ${["d1", "d2", "z", "d3"].map(dead).join(", ")}`]); + const p2 = await planFor( + hubOnly("[l](.claude/rules/a-very-long-rule-name.md) [u](.claude/rules/dd.md) .claude/rules/z.md", [rule("a-very-long-rule-name", "# long\n", { inclusion: "manual" })]), + ); + expect(amWarnings(p2)).toEqual([`agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: ${["dd", "z"].map(dead).join(", ")}`]); + }); }); From 0c019b769819743e6cfc0467c5b3dc380806f8d3 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:40:27 -0300 Subject: [PATCH 14/15] chore(emitters): one spelling for the kiro steering path, and an honest comment on the warning dedup The previous commit already routed state B through ruleFile; this commit updates the dedup comment to accurately describe what it does. --- src/emitters/agents-md.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/emitters/agents-md.ts b/src/emitters/agents-md.ts index 7b2990c..0ecd5a7 100644 --- a/src/emitters/agents-md.ts +++ b/src/emitters/agents-md.ts @@ -90,8 +90,7 @@ export const agentsMd: Emitter = { * Deduplicates entries by (reference, citing) and (path, citing). */ function emitRefWarning(warn: (msg: string) => void, reports: RefReport[]): void { - // Collect and deduplicate reworded entries by (ref, citing) — a backstop, since resolveRuleRefs - // already dedups per body; this catches a reference cited by two ingredients. + // Backstop only: each report covers one body, and resolveRuleRefs already dedups by (reference, citing ingredient). const rewordedSet = new Map(); for (const r of reports) { for (const e of r.reworded) { From 599f4375d4206caeb8793306829808af9b400c80 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 19:47:44 -0300 Subject: [PATCH 15/15] fix(agents-md): a reference right after a link is still resolved The token offset was calculated from the full match, which includes the left boundary character. For a token immediately after a link (e.g. `[y](.claude/rules/u5.md).claude/rules/u6.md`), this caused the overlap check to wrongly skip it. Now the offset is the position of the actual token, not the boundary. --- src/emitters/shared.ts | 17 +++++++++-------- test/emitters/agents-md.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 34bf568..ece009f 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -143,9 +143,10 @@ export function resolveRuleRefs( ); let tokenMatch; while ((tokenMatch = tokenPattern.exec(body)) !== null) { - const [match, before, , name] = tokenMatch as RegExpExecArray & [string, string, string, string]; - const offset = tokenMatch.index; - const fullOffset = offset + match.length; + 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) @@ -158,22 +159,22 @@ export function resolveRuleRefs( case "A": continue; // unchanged, skip case "B": - replacement = `${before}${ruleFile("kiro", { name })}`; + replacement = ruleFile("kiro", { name }); break; case "C": - replacement = `${before}AGENTS.md (rule: ${name})`; + replacement = `AGENTS.md (rule: ${name})`; break; case "D": reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; - replacement = `${before}${name} (rule not in this workspace)`; + replacement = `${name} (rule not in this workspace)`; break; case "unknown": if (hasCc) continue; reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; - replacement = `${before}${name} (rule not in this workspace)`; + replacement = `${name} (rule not in this workspace)`; break; } - matches.push({ offset, length: match.length, replacement, reworded }); + matches.push({ offset, length: token.length, replacement, reworded }); } // Sort by offset for correct warning order, then apply replacements in reverse order diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index c033649..e838036 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -543,4 +543,12 @@ describe("agents-md emitter — rule references in bodies (spec 15)", () => { ); expect(amWarnings(p2)).toEqual([`agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: ${["dd", "z"].map(dead).join(", ")}`]); }); + + it("a token right after a link is still a reference (spec 15 §4.1, review fix)", async () => { + const p = await planFor(hubOnly("[y](.claude/rules/u5.md).claude/rules/u6.md")); + expect(sectionOf(agentsMd(p)!, "hub")).toBe("\n# hub\n\ny (u5, rule not in this workspace)u6 (rule not in this workspace)"); + expect(amWarnings(p)).toEqual([ + "agents-md: 2 reference(s) to rule files this workspace does not have — reworded in AGENTS.md: .claude/rules/u5.md (in rule/hub; no such rule), .claude/rules/u6.md (in rule/hub; no such rule)", + ]); + }); });