From 3f7b85d40d600d1cc0d3213c9c288025c99dd2c9 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 16:54:55 -0300 Subject: [PATCH 1/4] refactor(emitters): name a rule's output path per target in one place Add ruleFile() to shared.ts and use it in claude-code.ts and kiro.ts. The path AGENTS.md will list is now computed by the same function for all three emitters, preventing drift. --- src/emitters/claude-code.ts | 4 ++-- src/emitters/kiro.ts | 4 ++-- src/emitters/shared.ts | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/emitters/claude-code.ts b/src/emitters/claude-code.ts index 0ad55a3..2b14040 100644 --- a/src/emitters/claude-code.ts +++ b/src/emitters/claude-code.ts @@ -1,6 +1,6 @@ import { serializeFrontmatter } from "../core/frontmatter.js"; import { listFiles } from "../core/forge.js"; -import { appliesTo, mcpServers, outName, textFile } from "./shared.js"; +import { appliesTo, mcpServers, outName, ruleFile, textFile } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; /** @@ -18,7 +18,7 @@ export const claudeCode: Emitter = { const m = ing.meta; switch (m.type) { case "rule": - out.push(await textFile(ctx, `.claude/rules/${outName(m)}.md`, await ctx.text(ing, m.file), t, ing.ref)); + out.push(await textFile(ctx, ruleFile("claude-code", m), await ctx.text(ing, m.file), t, ing.ref)); break; case "agent": { const body = await ctx.text(ing, m.file); diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index d0b7d77..1d7fd98 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 } from "./shared.js"; +import { appliesTo, mcpServers, outName, ruleFile } from "./shared.js"; import type { Emitter, EmitContext, PlannedFile } from "./types.js"; import type { ResolvedIngredient } from "../core/resolve.js"; @@ -36,7 +36,7 @@ export const kiro: Emitter = { ? `---\ninclusion: fileMatch\nfileMatchPattern: ${JSON.stringify(Array.isArray(m.fileMatchPattern) ? m.fileMatchPattern.join(",") : m.fileMatchPattern ?? "**")}\n---\n\n` : `---\ninclusion: ${m.inclusion}\n---\n\n`; const head = banner.replace("{{source}}", `.claude/rules/${outName(m)}.md`) + "\n\n"; - out.push(crlf(`.kiro/steering/${outName(m)}.md`, fm + head + body, ing.ref)); + out.push(crlf(ruleFile("kiro", m), fm + head + body, ing.ref)); break; } case "steering": diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index 67d3a27..5a97045 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -21,6 +21,11 @@ export function outName(m: { name: string; as?: string }): string { return m.as ?? m.name; } +/** The file a target writes for a rule: the path AGENTS.md lists when that target writes it (spec 14). */ +export function ruleFile(target: "claude-code" | "kiro", m: { name: string; as?: string }): string { + return target === "claude-code" ? `.claude/rules/${outName(m)}.md` : `.kiro/steering/${outName(m)}.md`; +} + /** * The MCP servers a target writes into its one JSON file, keyed by `outName` so a variant keeps the * server name its workspace uses. The file holds one entry per name, so when two ingredients write From 794628a5d018ba7636f10ad549ca5c736fd2d498 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 17:18:19 -0300 Subject: [PATCH 2/4] fix(agents-md): point scoped rules at the file a target writes, or embed them Spec 14: a scoped rule names .claude/rules/ when claude-code writes it, .kiro/steering/ when only kiro does, and is embedded with its scope when no target writes it. Workspaces in state A keep their bytes. --- src/emitters/agents-md.ts | 34 ++++++++-- test/emitters/agents-md.test.ts | 109 ++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/emitters/agents-md.ts b/src/emitters/agents-md.ts index e03e12c..90071fe 100644 --- a/src/emitters/agents-md.ts +++ b/src/emitters/agents-md.ts @@ -1,10 +1,11 @@ -import { appliesTo, outName, textFile } from "./shared.js"; +import { appliesTo, outName, ruleFile, textFile } from "./shared.js"; import type { Emitter } from "./types.js"; /** * AGENTS.md target (open standard read by Codex, Cursor, Warp, Copilot, Kiro, Kimi…). - * Concatenates the `always` rules into one file; `fileMatch`/`manual` rules are listed with their scope - * so agents that only read AGENTS.md still know where the detailed conventions live. + * Concatenates the `always` rules into one file. Scoped rules (`fileMatch`, `manual`, `auto`) + * are either listed with a path (when `claude-code` or `kiro` writes them) or embedded in full + * (when no target writes them), so agents that only read AGENTS.md see every convention (spec 14). */ export const agentsMd: Emitter = { target: "agents-md", @@ -31,18 +32,37 @@ export const agentsMd: Emitter = { "scoped rules are listed at the end with the paths they apply to.", "", ]; - const scoped: string[] = []; + const pathLines: string[] = []; + const embedded: string[] = []; for (const ing of rules) { if (ing.meta.type !== "rule") continue; const body = (await ctx.text(ing, ing.meta.file)).replace(/\n+$/, ""); if (ing.meta.inclusion === "always") { parts.push(``, body, ""); } else { - const scope = ing.meta.inclusion === "fileMatch" ? ` — applies to \`${[ing.meta.fileMatchPattern].flat().join("`, `")}\`` : ` — ${ing.meta.inclusion}`; - scoped.push(`- \`.claude/rules/${outName(ing.meta)}.md\`${scope}`); + // 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 scopeText = + ing.meta.inclusion === "fileMatch" + ? `applies to \`${[ing.meta.fileMatchPattern].flat().join("`, `")}\`` + : ing.meta.inclusion; + if (writers.includes("claude-code")) { + pathLines.push(`- \`${ruleFile("claude-code", ing.meta)}\` — ${scopeText}`); + } else if (writers.includes("kiro")) { + pathLines.push(`- \`${ruleFile("kiro", ing.meta)}\` — ${scopeText}`); + } else { + // State C: no target writes the rule file; embed it + embedded.push(``, `> Scoped rule — ${scopeText}`, "", body, ""); + } } } - if (scoped.length) parts.push("## Scoped rules", "", ...scoped, ""); + if (pathLines.length || embedded.length) { + parts.push("## Scoped rules", "", ...pathLines); + if (pathLines.length) parts.push(""); + parts.push(...embedded); + } return [await textFile(ctx, "AGENTS.md", parts.join("\n"), "agents-md", "rule/*")]; }, }; diff --git a/test/emitters/agents-md.test.ts b/test/emitters/agents-md.test.ts index d137280..a756cb0 100644 --- a/test/emitters/agents-md.test.ts +++ b/test/emitters/agents-md.test.ts @@ -31,7 +31,7 @@ const HEADER = [ describe("agents-md emitter", () => { it("concatenates always rules and lists scoped rules with their pattern", async () => { - const p = await planFor([rule("a", "# A\n\nalways on\n"), rule("b", "# B\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" })]); + const p = await planFor([rule("a", "# A\n\nalways on\n"), rule("b", "# B\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" })], undefined, ["claude-code", "agents-md"]); const doc = agentsMd(p)!; expect(doc).toContain("\n# A\n\nalways on\n"); expect(doc).toContain("## Scoped rules\n\n- `.claude/rules/b.md` — applies to `projects/api/**`\n"); @@ -44,7 +44,7 @@ describe("agents-md emitter", () => { rule("api", "# API\n", { inclusion: "fileMatch", fileMatchPattern: ["projects/api/**", "projects/sdk/**"] }), rule("commits", "# Commits\n\nConventional Commits.\n"), rule("release", "# Release\n", { inclusion: "manual" }), - ]); + ], undefined, ["claude-code", "agents-md"]); const expected = [ ...HEADER, "", @@ -64,7 +64,7 @@ describe("agents-md emitter", () => { "", ].join("\n"); expect(agentsMd(p)).toBe(expected); - expect(p.files.map((f) => f.path)).toEqual(["AGENTS.md"]); + expect(p.files.map((f) => f.path)).toEqual([".claude/rules/api.md", ".claude/rules/commits.md", ".claude/rules/release.md", ".claude/rules/style.md", "AGENTS.md"]); }); it("emits no AGENTS.md when no rule applies", async () => { @@ -76,7 +76,7 @@ describe("agents-md emitter", () => { const p = await planFor([ rule("workflow--acme", "# W\n", { as: "workflow" }), rule("api--acme", "# API\n", { as: "api", inclusion: "fileMatch", fileMatchPattern: "projects/api/**" }), - ]); + ], undefined, ["claude-code", "agents-md"]); const expected = [...HEADER, "", "# W", "", "## Scoped rules", "", "- `.claude/rules/api.md` — applies to `projects/api/**`", ""].join("\n"); expect(agentsMd(p)).toBe(expected); expect(agentsMd(p)).not.toContain("--acme"); @@ -154,3 +154,104 @@ describe("agents-md emitter — sections (spec 11 §10.2, AC 2)", () => { expect(md).toBe([...HEADER, "", "# R\n\nbody\nvalue", "", "", "# R\n\nbody", ""].join("\n")); }); }); + +describe("agents-md emitter — scoped rules (spec 14)", () => { + const body = (n: string) => `# ${n}\n\nBody of ${n}. See .claude/rules/style.md.\n`; + const forge14 = (): IngredientSpec[] => [ + rule("style", body("style")), + rule("api", body("api"), { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" }), + rule("release", body("release"), { inclusion: "manual" }), + rule("helper", body("helper"), { inclusion: "auto" }), + rule("kiro-only", body("kiro-only"), { inclusion: "fileMatch", fileMatchPattern: "projects/k/**", targets: ["kiro", "agents-md"] }), + rule("md-only", body("md-only"), { inclusion: "fileMatch", fileMatchPattern: "projects/m/**", targets: ["agents-md"] }), + ]; + const STYLE = [...HEADER, "", "# style", "", "Body of style. See .claude/rules/style.md.", ""]; + const embedded = (n: string, scope: string) => [``, `> Scoped rule — ${scope}`, "", `# ${n}`, "", `Body of ${n}. See .claude/rules/style.md.`, ""]; + const API = "applies to `projects/api/**`"; + const K = "applies to `projects/k/**`"; + const M = "applies to `projects/m/**`"; + const ONLY_MD = [...STYLE, "## Scoped rules", "", ...embedded("api", API), ...embedded("release", "manual"), ...embedded("helper", "auto"), ...embedded("kiro-only", K), ...embedded("md-only", M)]; + const ALL3 = [ + ...STYLE, "## Scoped rules", "", + "- `.claude/rules/api.md` — " + API, + "- `.claude/rules/release.md` — manual", + "- `.claude/rules/helper.md` — auto", + "- `.kiro/steering/kiro-only.md` — " + K, + "", + ...embedded("md-only", M), + ]; + const noAgentsMdWarning = (p: Plan) => expect(p.warnings.filter((w) => w.startsWith("agents-md:"))).toEqual([]); + + 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")); + noAgentsMdWarning(p); + }); + + it("kiro + agents-md: scoped rules point at .kiro/steering, a rule kiro does not write is embedded (spec 14 test 2)", async () => { + const p = await planFor(forge14(), undefined, ["kiro", "agents-md"]); + expect(agentsMd(p)).toBe([ + ...STYLE, "## Scoped rules", "", + "- `.kiro/steering/api.md` — " + API, + "- `.kiro/steering/release.md` — manual", + "- `.kiro/steering/helper.md` — auto", + "- `.kiro/steering/kiro-only.md` — " + K, + "", + ...embedded("md-only", M), + ].join("\n")); + noAgentsMdWarning(p); + }); + + it("claude-code + kiro + agents-md: one file, three states (spec 14 test 3)", async () => { + const p = await planFor(forge14(), undefined, ["claude-code", "kiro", "agents-md"]); + expect(agentsMd(p)).toBe(ALL3.join("\n")); + noAgentsMdWarning(p); + }); + + it("claude-code + agents-md: rules claude-code does not write are embedded (spec 14 test 4)", async () => { + const p = await planFor(forge14(), undefined, ["claude-code", "agents-md"]); + expect(agentsMd(p)).toBe([ + ...STYLE, "## Scoped rules", "", + "- `.claude/rules/api.md` — " + API, + "- `.claude/rules/release.md` — manual", + "- `.claude/rules/helper.md` — auto", + "", + ...embedded("kiro-only", K), + ...embedded("md-only", M), + ].join("\n")); + noAgentsMdWarning(p); + }); + + it("the order of the workspace's targets does not matter (spec 14 test 5)", async () => { + const p = await planFor(forge14(), undefined, ["agents-md", "kiro", "claude-code"]); + expect(agentsMd(p)).toBe(ALL3.join("\n")); + }); + + it("an embedded variant is named by its original name (spec 14 test 6)", async () => { + const p = await planFor([rule("api--acme", "# API\n", { as: "api", inclusion: "fileMatch", fileMatchPattern: "projects/api/**" })]); + expect(agentsMd(p)).toBe([...HEADER, "## Scoped rules", "", "", "> Scoped rule — " + API, "", "# API", ""].join("\n")); + expect(agentsMd(p)).not.toContain("--acme"); + }); + + it("an embedded rule's sections are expanded (spec 14 test 7)", async () => { + const SEC = "# R\n\nbody\n\ndefault\n\n"; + const s = await scenario( + { + ingredients: [rule("r", SEC, { inclusion: "manual" })], + recipes: [recipe("base", ["rule/r"])], + profiles: [profile("acme", ["base"], ["agents-md"], { sections: { "rule/r": { s: "value" } } })], + }, + { 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(md).not.toContain("craftar:section"); + expect(md).toBe([...HEADER, "## Scoped rules", "", "", "> Scoped rule — manual", "", "# R\n\nbody\nvalue", ""].join("\n")); + }); + + 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")); + }); +}); From 234553b0586f840336ecb9f2c06ea07497ad6fb6 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 17:19:55 -0300 Subject: [PATCH 3/4] docs(readme): say where AGENTS.md lists or embeds scoped rules, and what 0.8.3 changes Update the Targets description for agents-md to specify where each scoped rule is listed or when it is embedded. Add the 0.8.3 upgrade notes. --- README.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5281511..e8888c7 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 listed, 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…). 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.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. + - A workspace with `claude-code` whose scoped rules keep the default `targets` — every workspace `craftar import` produced — sees **no change**, and neither does one with no scoped rule aimed at `agents-md`. + - With `kiro` and `agents-md` but no `claude-code`, each scoped line moves from `.claude/rules/` to `.kiro/steering/`. + - 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. + ### to 0.8.2 - **A file no target emits (e.g. notes beside `rule.md`) is no longer read for sections or `{{param}}` citations** — a malformed marker there no longer fails `sync`/`status`/`diff`/`explain`/`ls`/`import` or trips the `schema: 1` gate, and a `{{key}}` there no longer makes `unify`/`import` refuse. No emitted byte changes. From ad10cc204ec71a2c10bf5735182c0d46d22f98e3 Mon Sep 17 00:00:00 2001 From: Luiz Lima Date: Sun, 4 Oct 2026 17:21:10 -0300 Subject: [PATCH 4/4] chore: update version Bump version to 0.8.3 for the agents-md scoped-rules 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 5a5a650..b0563b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.2", + "version": "0.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.2", + "version": "0.8.3", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index c1ff576..1f23209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "craftar", - "version": "0.8.2", + "version": "0.8.3", "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 d00b8cb..9b7c9a6 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.2"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.3"); /* ---------------------------------------------------------------- import */ program