Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.md` when `claude-code` writes it, else `.kiro/steering/<name>.md` when `kiro` does), or, when no target writes it, embedded in full under a `> Scoped rule — <scope>` 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.

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 27 additions & 7 deletions src/emitters/agents-md.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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(`<!-- rule: ${outName(ing.meta)} -->`, 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(`<!-- rule: ${outName(ing.meta)} -->`, `> 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/*")];
},
};
4 changes: 2 additions & 2 deletions src/emitters/claude-code.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/emitters/kiro.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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":
Expand Down
5 changes: 5 additions & 0 deletions src/emitters/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 105 additions & 4 deletions test/emitters/agents-md.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!-- rule: a -->\n# A\n\nalways on\n");
expect(doc).toContain("## Scoped rules\n\n- `.claude/rules/b.md` — applies to `projects/api/**`\n");
Expand All @@ -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,
"<!-- rule: style -->",
Expand All @@ -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 () => {
Expand All @@ -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, "<!-- rule: workflow -->", "# W", "", "## Scoped rules", "", "- `.claude/rules/api.md` — applies to `projects/api/**`", ""].join("\n");
expect(agentsMd(p)).toBe(expected);
expect(agentsMd(p)).not.toContain("--acme");
Expand Down Expand Up @@ -154,3 +154,104 @@ describe("agents-md emitter — sections (spec 11 §10.2, AC 2)", () => {
expect(md).toBe([...HEADER, "<!-- rule: r -->", "# R\n\nbody\nvalue", "", "<!-- rule: q -->", "# 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, "<!-- rule: style -->", "# style", "", "Body of style. See .claude/rules/style.md.", ""];
const embedded = (n: string, scope: string) => [`<!-- rule: ${n} -->`, `> 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", "", "<!-- rule: api -->", "> 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<!-- craftar:section s -->\ndefault\n<!-- /craftar:section -->\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", "", "<!-- rule: r -->", "> 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"));
});
});
Loading