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
14 changes: 12 additions & 2 deletions 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 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.
**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…). A `.claude/rules/<x>.md` reference inside a body follows the rule it names: it is kept when `claude-code` writes that rule, becomes `.kiro/steering/<x>.md` when only `kiro` writes that file (from the rule or from a `steering` ingredient of that name), becomes `AGENTS.md (rule: <x>)` when the rule's text is in this file, and otherwise becomes `<x> (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.

Expand Down Expand Up @@ -241,6 +241,16 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC

## Upgrading

### to 0.8.4

- **A `.claude/rules/<x>.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 `.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/<x>.md` (kiro writes it), `AGENTS.md (rule: <x>)` (its text is in the file) or `<x> (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.
- **At most one warning per `AGENTS.md`** names every reference turned into `<x> (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.

### 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.
Expand All @@ -249,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

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.3",
"version": "0.8.4",
"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.3");
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.4");

/* ---------------------------------------------------------------- import */
program
Expand Down
77 changes: 70 additions & 7 deletions src/emitters/agents-md.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { appliesTo, outName, ruleFile, textFile } from "./shared.js";
import { appliesTo, buildRuleLookup, outName, resolveRuleRefs, ruleFile, ruleWriter, textFile, type RefReport } from "./shared.js";
import type { Emitter } from "./types.js";

/**
Expand All @@ -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[] = [
"<!-- GENERATED by craftar from the Forge. Edit the rule ingredients, not this file. -->",
"",
Expand All @@ -34,26 +38,35 @@ 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, lookup, ctx.resolution.targets);
alwaysReports.push(report);
const body = resolved.replace(/\n+$/, "");
parts.push(`<!-- rule: ${outName(ing.meta)} -->`, 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
// Resolve references in embedded bodies too (spec 15 §4, after ctx.text, before trim)
const { text: resolved, report } = resolveRuleRefs(rawBody, ing.ref, lookup, ctx.resolution.targets);
embeddedReports.push(report);
const body = resolved.replace(/\n+$/, "");
embedded.push(`<!-- rule: ${outName(ing.meta)} -->`, `> Scoped rule — ${scopeText}`, "", body, "");
}
}
Expand All @@ -63,6 +76,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 {
// Backstop only: each report covers one body, and resolveRuleRefs already dedups by (reference, citing ingredient).
const rewordedSet = new Map<string, { ref: string; citing: string; kind: string }>();
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<string, { path: string; citing: string }>();
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);
}
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, 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";

Expand Down Expand Up @@ -152,7 +152,7 @@ export function agentResources(agentName: string, text: string, known: Set<strin
/** Rule names referenced as `.claude/rules/<name>.md` or `.kiro/steering/<name>.md`, in order of first appearance. */
export function referencedRules(text: string, known: Set<string>): 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);
}
Expand Down
Loading
Loading