Skip to content
Merged
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Sections layer the same way, weakest → strongest: the body's default → the p
**claude-code** — emits `.claude/rules|agents|commands|skills|scripts|hooks` and `.mcp.json` verbatim from the Forge (frontmatter kept byte-for-byte). Each MCP server is written exactly as the Forge holds it, undeclared keys included, in its own key order. Existing files keep their line endings and BOM; new files are LF without a BOM. Steering is Kiro-only (its `targets` default to `[kiro]`); one aimed at `claude-code` explicitly (`targets: "*"` or a list naming it) is skipped with a warning.

**kiro** — reproduces, then extends, the hand-written `sync-steering.ps1` script it replaces:
steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/`, 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.
steering = `inclusion` frontmatter + `GENERATED` banner + rule body, with `.claude/rules/` rewritten to `.kiro/steering/` — except a reference to a rule kiro does not write, which follows that rule: kept as `.claude/rules/<x>.md` when `claude-code` writes it, `AGENTS.md (rule: <x>)` when only `AGENTS.md` holds it, otherwise `<x> (rule not in this workspace)` with a warning; an unknown name is still rewritten and is reported. Files are UTF-8 without BOM, CRLF. The same applies to agent prompts and descriptions, command bodies and descriptions, and skill text files; a `steering` ingredient is emitted as written. On top of what the script did, it also generates `.kiro/agents/*.json` (tools mapped to Kiro names, `resources` bound to the agent's stack rule + `repo-discovery`, or `**/*.md` for generic agents), `.kiro/steering/commands/*.md`, `.kiro/skills/*/SKILL.md` and `.kiro/settings/mcp.json`. `.kiro/settings/mcp.json` receives each MCP server exactly as the Forge holds it, in its own key order — including keys Kiro may not use (`type` always went through). The banner text is a parameter (`kiro.banner`) so existing workspaces can adopt without a rewrite. Scripts and hooks have no Kiro equivalent: they are skipped with a warning.

**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.

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

## Upgrading

### to 0.8.5

- **The kiro target stops turning a reference to a rule it does not write into a `.kiro/steering/` path that does not exist.** Only files under `.kiro/` change; no other generated file and no lock field.
- **No byte change:** a workspace in which every rule a kiro text cites is one kiro writes, and whose hand-written commands cite no `.claude/rules/` path in their `description`. Every workspace `craftar import` produced is in this case. A cited unknown name now adds a warning.
- **A cited rule kiro does not write:** the reference goes back to `.claude/rules/<x>.md` when `claude-code` writes the rule, becomes `AGENTS.md (rule: <x>)` when only `AGENTS.md` holds it, or else `<x> (rule not in this workspace)`.
- **A command written by hand in the Forge (no raw frontmatter):** its `description` is now resolved like its body, and that includes the directory rewrite.
- **At most one `kiro:` warning about rule references per plan.** It names every reference reworded to "rule not in this workspace", and every unknown name, which is still rewritten to `.kiro/steering/`. It never changes an exit code.
- An affected workspace shows those `.kiro/` files as `update`, and `craftar sync --check` exits 1 until it syncs.
- An agent's `resources` do not change.

### 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.
Expand All @@ -249,7 +259,7 @@ Next: `craftar init` from a profile; profile-driven integrations (PM tool → MC
- **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.
- The kiro emitter still rewrites every `.claude/rules/` to `.kiro/steering/`; 0.8.5 addresses it in part — see *to 0.8.5*.

### to 0.8.3

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

/* ---------------------------------------------------------------- import */
program
Expand Down
96 changes: 83 additions & 13 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, RULE_NAME_CHARS } from "./shared.js";
import { appliesTo, buildRuleLookup, mcpServers, outName, resolveRuleRefs, ruleFile, RULE_NAME_CHARS, UNKNOWN_NAME_KIND, type RefReport } from "./shared.js";
import type { Emitter, EmitContext, PlannedFile } from "./types.js";
import type { ResolvedIngredient } from "../core/resolve.js";

Expand All @@ -12,7 +12,9 @@ export const KIRO_TEXT_EXT = /\.(md|txt|json|ya?ml)$/i;
* Kiro target. Reproduces, then extends, the behaviour of the hand-written
* `.claude/scripts/sync-steering.ps1` this target was extracted from:
* - steering = frontmatter(inclusion) + GENERATED banner + rule body
* - `.claude/rules/` references are rewritten to `.kiro/steering/`
* - `.claude/rules/` references are resolved: kiro-written rules → `.kiro/steering/`,
* claude-code-written rules → unchanged, agents-md-only rules → `AGENTS.md`,
* dead rules → `<x> (rule not in this workspace)`, unknown → `.kiro/steering/` with a warning (spec 17)
* - UTF-8 without BOM, CRLF (the exact shape Kiro already consumes)
* and additionally generates what the script never did: agents JSON, commands and skills.
*/
Expand All @@ -24,13 +26,23 @@ export const kiro: Emitter = {
const banner = String(ctx.resolution.params["kiro.banner"] ?? "<!-- GENERATED from {{source}} by craftar -- do not edit. -->");
const ruleNames = new Set(ctx.resolution.ingredients.filter((i) => i.meta.type === "rule").map((i) => outName(i.meta)));
const scopedRules = ctx.resolution.ingredients.filter((i) => i.meta.type === "rule" && i.meta.inclusion === "fileMatch").map((i) => outName(i.meta));
const targets = ctx.resolution.targets;
const lookup = buildRuleLookup(ctx.resolution.ingredients);

// Collect reports for the warning (spec 17 §4.5)
const reports: RefReport[] = [];
const resolve = (text: string, citing: string): string => {
const { text: resolved, report } = resolveRuleRefs(text, citing, lookup, targets, "kiro");
reports.push(report);
return resolved;
};

for (const ing of ctx.resolution.ingredients) {
if (!appliesTo(ing.meta.targets, t)) continue;
const m = ing.meta;
switch (m.type) {
case "rule": {
const body = rewrite(await ctx.text(ing, m.file));
const body = resolve(await ctx.text(ing, m.file), ing.ref);
const fm =
m.inclusion === "fileMatch"
? `---\ninclusion: fileMatch\nfileMatchPattern: ${JSON.stringify(Array.isArray(m.fileMatchPattern) ? m.fileMatchPattern.join(",") : m.fileMatchPattern ?? "**")}\n---\n\n`
Expand All @@ -40,34 +52,44 @@ export const kiro: Emitter = {
break;
}
case "steering":
// Steering bodies are emitted as written (spec 17 §2)
out.push(crlf(`.kiro/steering/${outName(m)}.md`, await ctx.text(ing, m.file), ing.ref));
break;
case "agent": {
const body = rewrite(await ctx.text(ing, m.file));
const description = rewrite(m.description ?? "");
// Resolve description first, then body (spec 17 §4.5 order: description before prompt)
const description = resolve(m.description ?? "", ing.ref);
// Read the body once and derive both versions from it
const rawBody = await ctx.text(ing, m.file);
const body = resolve(rawBody, ing.ref);
const tools = mapTools(m.tools, ctx);
const resources = m.resources ?? agentResources(outName(m), description + "\n" + body, ruleNames, scopedRules);
// agentResources reads the blanket-rewritten text, not the resolved text (spec 17 §4.6)
const resources = m.resources ?? agentResources(outName(m), rewrite(m.description ?? "") + "\n" + rewrite(rawBody), ruleNames, scopedRules);
const json = JSON.stringify({ name: outName(m), description, prompt: body.replace(/^\n+/, "").replace(/\n+$/, ""), tools, allowedTools: tools, resources }, null, 2) + "\n";
out.push(crlf(`.kiro/agents/${outName(m)}.json`, json, ing.ref));
break;
}
case "command": {
const body = rewrite(await ctx.text(ing, m.file));
// Resolve description and raw frontmatter first, then body (spec 17 §4.5 order: frontmatter before body)
// When frontmatterRaw is set, the description field is unused (serializeFrontmatter ignores it),
// so we skip resolving it to avoid spurious reports.
const resolvedDescription = !m.frontmatterRaw && m.description !== undefined ? resolve(m.description, ing.ref) : undefined;
const resolvedFm = m.frontmatterRaw ? resolve(m.frontmatterRaw, ing.ref) : null;
const body = resolve(await ctx.text(ing, m.file), ing.ref);
const doc = serializeFrontmatter(
{ description: m.description, "argument-hint": m.argumentHint, "allowed-tools": m.allowedTools },
{ description: resolvedDescription, "argument-hint": m.argumentHint, "allowed-tools": m.allowedTools },
body,
{ raw: m.frontmatterRaw ? rewrite(m.frontmatterRaw) : null },
{ raw: resolvedFm },
);
out.push(crlf(`.kiro/steering/commands/${outName(m)}.md`, `---\ninclusion: manual\n---\n\n` + doc, ing.ref));
break;
}
case "skill": {
if (m.layout === "file") {
out.push(crlf(`.kiro/skills/${outName(m)}/SKILL.md`, rewrite(await ctx.text(ing, "SKILL.md")), ing.ref));
out.push(crlf(`.kiro/skills/${outName(m)}/SKILL.md`, resolve(await ctx.text(ing, "SKILL.md"), ing.ref), ing.ref));
} else {
for (const f of await listFiles(ing.dir)) {
if (f === "ingredient.yaml") continue;
out.push(await copy(ctx, ing, f, `.kiro/skills/${outName(m)}/${f}`));
out.push(await copy(ctx, ing, f, `.kiro/skills/${outName(m)}/${f}`, resolve));
}
}
break;
Expand All @@ -82,6 +104,9 @@ export const kiro: Emitter = {
}
}

// Emit one warning for all dead and unknown references (spec 17 §4.5)
emitKiroWarning(ctx, reports);

const servers = mcpServers(ctx, t, ".kiro/settings/mcp.json");
if (Object.keys(servers).length) {
out.push(crlf(".kiro/settings/mcp.json", JSON.stringify({ mcpServers: servers }, null, 2) + "\n", "mcp/*"));
Expand All @@ -90,6 +115,51 @@ export const kiro: Emitter = {
},
};

/** Emit the kiro warning for dead and unknown references (spec 17 §4.5). */
function emitKiroWarning(ctx: EmitContext, reports: RefReport[]): void {
// Collect entries: D (has kind with "reaches no target") and unknown (empty kind)
const dead: Array<{ ref: string; citing: string; kind: string }> = [];
const unknown: Array<{ ref: string; citing: string }> = [];
const seenDead = new Set<string>();
const seenUnknown = new Set<string>();

for (const report of reports) {
for (const e of report.reworded) {
const key = `${e.ref}|${e.citing}`;
if (e.kind !== UNKNOWN_NAME_KIND) {
// D: has a kind string (e.g. "rule/x reaches no target here")
if (!seenDead.has(key)) {
seenDead.add(key);
dead.push(e);
}
} else {
// unknown: empty kind string (UNKNOWN_NAME_KIND)
if (!seenUnknown.has(key)) {
seenUnknown.add(key);
unknown.push({ ref: e.ref, citing: e.citing });
}
}
}
}

if (dead.length === 0 && unknown.length === 0) return;

const parts: string[] = [];
if (dead.length > 0) {
const entries = dead.map((e) => `${e.ref} (in ${e.citing}; ${e.kind})`).join(", ");
parts.push(`${dead.length} reference(s) to rule files this workspace does not have — reworded: ${entries}`);
}
if (unknown.length > 0) {
const entries = unknown.map((e) => `${e.ref} (in ${e.citing})`).join(", ");
if (dead.length > 0) {
parts.push(`${unknown.length} reference(s) to names that are no rule or steering here — rewritten to .kiro/steering/ as before: ${entries}`);
} else {
parts.push(`${unknown.length} reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: ${entries}`);
}
}
ctx.warn(`kiro: ${parts.join("; ")}`);
}

export function rewrite(text: string): string {
return text.replace(/\.claude\/rules\//g, ".kiro/steering/");
}
Expand All @@ -98,8 +168,8 @@ function crlf(path: string, text: string, ingredient: string): PlannedFile {
return { path, content: Buffer.from(toCrlf(text), "utf8"), target: "kiro", ingredient };
}

async function copy(ctx: EmitContext, ing: ResolvedIngredient, file: string, relPath: string): Promise<PlannedFile> {
if (KIRO_TEXT_EXT.test(file)) return crlf(relPath, rewrite(await ctx.text(ing, file)), ing.ref);
async function copy(ctx: EmitContext, ing: ResolvedIngredient, file: string, relPath: string, resolve: (text: string, citing: string) => string): Promise<PlannedFile> {
if (KIRO_TEXT_EXT.test(file)) return crlf(relPath, resolve(await ctx.text(ing, file), ing.ref), ing.ref);
return { path: relPath, content: await ctx.bytes(ing, file), target: "kiro", ingredient: ing.ref };
}

Expand Down
Loading
Loading