diff --git a/README.md b/README.md index cdc9431..373fb5a 100644 --- a/README.md +++ b/README.md @@ -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/.md` when `claude-code` writes it, `AGENTS.md (rule: )` when only `AGENTS.md` holds it, otherwise ` (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/.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. @@ -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/.md` when `claude-code` writes the rule, becomes `AGENTS.md (rule: )` when only `AGENTS.md` holds it, or else ` (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/.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. @@ -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 ` (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 diff --git a/package-lock.json b/package-lock.json index f7b49db..a3cb02d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "craftar", - "version": "0.8.4", + "version": "0.8.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "craftar", - "version": "0.8.4", + "version": "0.8.5", "license": "MIT", "dependencies": { "commander": "^13.1.0", diff --git a/package.json b/package.json index 98a4bfc..3487231 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli.ts b/src/cli.ts index a73edce..e4f5fe4 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.4"); +program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.8.5"); /* ---------------------------------------------------------------- import */ program diff --git a/src/emitters/kiro.ts b/src/emitters/kiro.ts index 46a1d4e..cecde7f 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, 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"; @@ -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 → ` (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. */ @@ -24,13 +26,23 @@ export const kiro: Emitter = { const banner = String(ctx.resolution.params["kiro.banner"] ?? ""); 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` @@ -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; @@ -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/*")); @@ -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(); + const seenUnknown = new Set(); + + 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/"); } @@ -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 { - 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 { + 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 }; } diff --git a/src/emitters/shared.ts b/src/emitters/shared.ts index ece009f..d0997b6 100644 --- a/src/emitters/shared.ts +++ b/src/emitters/shared.ts @@ -25,6 +25,9 @@ export interface RefReport { others: Array<{ path: string; citing: string }>; } +/** The kind string for an unknown name (no rule or steering with that name). */ +export const UNKNOWN_NAME_KIND = ""; + /** Lookup maps for rule reference resolution (spec 15 §4.2), built once per AGENTS.md. */ export interface RuleLookup { rulesByName: Map; @@ -44,7 +47,8 @@ export function buildRuleLookup(ingredients: ResolvedIngredient[]): RuleLookup { } /** - * Resolve `.claude/rules/.md` references in a body emitted into AGENTS.md (spec 15 §4.1–§4.3). + * Resolve `.claude/rules/.md` references in a body (spec 15 §4.1–§4.3, spec 17 §4.2–§4.3). + * Mode "agents-md" (default) is for AGENTS.md; mode "kiro" is for kiro files. * Returns the transformed text and a report of what was reworded or found dead. */ export function resolveRuleRefs( @@ -52,17 +56,36 @@ export function resolveRuleRefs( citing: string, lookup: RuleLookup, targets: readonly string[], + mode: "agents-md" | "kiro" = "agents-md", ): { text: string; report: RefReport } { const report: RefReport = { reworded: [], others: [] }; const hasCc = targets.includes("claude-code"); const hasKiro = targets.includes("kiro"); + const hasMd = targets.includes("agents-md"); 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" => { + // Determine rule state (spec 15 §4.2 for agents-md, spec 17 §4.2 for kiro) + // agents-md: A (claude-code writes), B (kiro writes), C (agents-md), D (rule exists), unknown + // kiro: K1 (kiro writes), K2 (claude-code writes), K3 (agents-md has it), D, unknown + type State = "A" | "B" | "C" | "D" | "K1" | "K2" | "K3" | "unknown"; + const ruleState = (name: string): State => { const rule = rulesByName.get(name); + if (mode === "kiro") { + // K1: kiro writes it — a rule or steering that admits kiro (spec 17 §4.2) + if (rule && hasKiro && appliesTo(rule.meta.targets, "kiro")) return "K1"; + const steering = steeringsByName.get(name); + if (steering && hasKiro && appliesTo(steering.meta.targets, "kiro")) return "K1"; + // K2: a rule and claude-code writes it + if (rule && hasCc && appliesTo(rule.meta.targets, "claude-code")) return "K2"; + // K3: a rule aimed at agents-md, and agents-md is a workspace target + if (rule && hasMd && appliesTo(rule.meta.targets, "agents-md")) return "K3"; + // D: any other rule + if (rule) return "D"; + // unknown: anything else + return "unknown"; + } + // agents-md mode (spec 15 §4.2) 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"; @@ -70,14 +93,10 @@ export function resolveRuleRefs( // 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"; } - // No rule with this name return "unknown"; }; @@ -91,8 +110,12 @@ export function resolveRuleRefs( return true; }; - // 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). + // Blanket rewrite for kiro mode: .claude/rules/ → .kiro/steering/ + const kiroRewrite = (s: string): string => s.replace(/\.claude\/rules\//g, ".kiro/steering/"); + + // Collect all matches from the original body with their offsets. Warning order follows the + // original body (spec 15 §4.5); matches are applied in order, and in kiro mode the gaps between + // them get the directory rewrite. interface Match { offset: number; length: number; @@ -114,24 +137,57 @@ export function resolveRuleRefs( const rule = rulesByName.get(name); let replacement: string; let reworded: { ref: string; kind: string } | undefined; - switch (state) { - case "A": - continue; // unchanged, skip - case "B": - replacement = `[${text}](${ruleFile("kiro", { name })}${frag ?? ""})`; - break; - case "C": - 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) continue; - reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; - replacement = `${text} (${name}, rule not in this workspace)`; - break; + + if (mode === "kiro") { + // Kiro mode (spec 17 §4.3) + switch (state) { + case "K1": + // Fragment gets the blanket rewrite too (spec 17 §4.3) + replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; + break; + case "K2": + // unchanged — exempt from directory rewrite; keep original text exactly + replacement = match; + break; + case "K3": + replacement = `[${kiroRewrite(text)}](AGENTS.md)`; // fragment dropped + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${kiroRewrite(text)} (${name}, rule not in this workspace)`; + break; + case "unknown": + // Unknown name gets blanket rewrite and is reported (no kind string) + // Fragment gets the blanket rewrite too (spec 17 §4.3) + reworded = { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }; + replacement = `[${kiroRewrite(text)}](${ruleFile("kiro", { name })}${kiroRewrite(frag ?? "")})`; + break; + default: + continue; + } + } else { + // agents-md mode (spec 15 §4.3) + switch (state) { + case "A": + continue; + case "B": + replacement = `[${text}](${ruleFile("kiro", { name })}${frag ?? ""})`; + break; + case "C": + replacement = `[${text}](AGENTS.md)`; + 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) continue; + reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; + replacement = `${text} (${name}, rule not in this workspace)`; + break; + default: + continue; + } } matches.push({ offset, length: match.length, replacement, reworded }); } @@ -155,29 +211,60 @@ export function resolveRuleRefs( const rule = rulesByName.get(name); let replacement: string; let reworded: { ref: string; kind: string } | undefined; - switch (state) { - case "A": - continue; // unchanged, skip - case "B": - replacement = ruleFile("kiro", { name }); - break; - case "C": - replacement = `AGENTS.md (rule: ${name})`; - break; - case "D": - reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; - replacement = `${name} (rule not in this workspace)`; - break; - case "unknown": - if (hasCc) continue; - reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; - replacement = `${name} (rule not in this workspace)`; - break; + + if (mode === "kiro") { + // Kiro mode (spec 17 §4.3) + switch (state) { + case "K1": + replacement = ruleFile("kiro", { name }); + break; + case "K2": + // unchanged — exempt from directory rewrite; keep original text exactly + replacement = token; + break; + case "K3": + replacement = `AGENTS.md (rule: ${name})`; + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${name} (rule not in this workspace)`; + break; + case "unknown": + // Unknown name gets blanket rewrite and is reported (no kind string) + reworded = { ref: `.claude/rules/${name}.md`, kind: UNKNOWN_NAME_KIND }; + replacement = ruleFile("kiro", { name }); + break; + default: + continue; + } + } else { + // agents-md mode (spec 15 §4.3) + switch (state) { + case "A": + continue; + case "B": + replacement = ruleFile("kiro", { name }); + break; + case "C": + replacement = `AGENTS.md (rule: ${name})`; + break; + case "D": + reworded = { ref: `.claude/rules/${name}.md`, kind: `${rule!.ref} reaches no target here` }; + replacement = `${name} (rule not in this workspace)`; + break; + case "unknown": + if (hasCc) continue; + reworded = { ref: `.claude/rules/${name}.md`, kind: "no such rule" }; + replacement = `${name} (rule not in this workspace)`; + break; + default: + continue; + } } matches.push({ offset, length: token.length, replacement, reworded }); } - // Sort by offset for correct warning order, then apply replacements in reverse order + // Sort by offset for correct warning order matches.sort((a, b) => a.offset - b.offset); // Collect reworded entries with their original offsets for sorting (spec 15 §4.5) @@ -188,12 +275,17 @@ export function resolveRuleRefs( } } - // 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); + // Build the result segment by segment: gaps get the blanket rewrite in kiro mode + let result = ""; + let lastEnd = 0; + for (const m of matches) { + const gap = body.slice(lastEnd, m.offset); + result += mode === "kiro" ? kiroRewrite(gap) : gap; + result += m.replacement; + lastEnd = m.offset + m.length; } + const tail = body.slice(lastEnd); + result += mode === "kiro" ? kiroRewrite(tail) : tail; // Sort by offset and deduplicate by (ref, citing), keeping first occurrence (spec 15 §4.5) rewordedWithOffset.sort((a, b) => a.offset - b.offset); @@ -206,8 +298,8 @@ export function resolveRuleRefs( } } - // Collect other .claude/ paths (agents, commands, skills, scripts, hooks) only when no claude-code (spec 15 §4.5) - if (!hasCc) { + // Collect other .claude/ paths only in agents-md mode (spec 15 §4.5) — not in kiro mode (spec 17 §2) + if (mode === "agents-md" && !hasCc) { const otherDirs = ["agents", "commands", "skills", "scripts", "hooks"]; // The path class adds `/` for subdirectories; in `[.../-]` the `/` goes before `-` to avoid a range error. const otherPattern = new RegExp( diff --git a/test/emitters/kiro.test.ts b/test/emitters/kiro.test.ts index b4e8a37..cf4b483 100644 --- a/test/emitters/kiro.test.ts +++ b/test/emitters/kiro.test.ts @@ -8,12 +8,12 @@ afterEach(async () => { while (cleanups.length) await cleanups.pop()!(); }); -async function planFor(ingredients: IngredientSpec[], profileExtra: Record = {}): Promise { +async function planFor(ingredients: IngredientSpec[], profileExtra: Record = {}, targets: string[] = ["kiro"]): Promise { const s = await scenario( { ingredients, recipes: [recipe("base", ingredients.map((i) => `${i.meta.type}/${i.meta.name}`))], - profiles: [profile("acme", ["base"], ["kiro"], profileExtra)], + profiles: [profile("acme", ["base"], targets, profileExtra)], }, { config: { profile: "acme" } }, ); @@ -141,3 +141,202 @@ describe("kiro emitter — sections (spec 11 §10.2, AC 2)", () => { expect(JSON.parse(file(p, ".kiro/agents/a.json")!.content.toString("utf8")).prompt).toBe("# T\nvalue\nsee .kiro/steering/r.md"); }); }); + +describe("kiro emitter — rule references (spec 17)", () => { + const lf = (p: Plan, rel: string) => file(p, rel)?.content.toString("utf8").replace(/\r\n/g, "\n"); + const kw = (p: Plan) => p.warnings.filter((w) => w.startsWith("kiro:")); + 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", + "- 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 LINE = "cites `.claude/rules/style.md` (kiro writes), `.claude/rules/cc-only.md` (kiro does not), `.claude/rules/handwritten.md` (no rule), `.claude/rules/product.md` (steering), `.claude/rules/*.md` (pattern), `.claude/agents/reviewer.md`."; + const LINE_K = "cites `.kiro/steering/style.md` (kiro writes), `cc-only (rule not in this workspace)` (kiro does not), `.kiro/steering/handwritten.md` (no rule), `.kiro/steering/product.md` (steering), `.kiro/steering/*.md` (pattern), `.claude/agents/reviewer.md`."; + const LINE_KC = LINE_K.replace("`cc-only (rule not in this workspace)`", "`.claude/rules/cc-only.md`"); + const forge17 = (): 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"] }), + { meta: { type: "agent", name: "reviewer", description: "Reviews per .claude/rules/cc-only.md" }, files: { "agent.md": `Agent ${LINE}\n` } }, + { meta: { type: "command", name: "open-pr", description: "Opens per .claude/rules/cc-only.md" }, files: { "command.md": `Command ${LINE}\n` } }, + { meta: { type: "skill", name: "howto" }, files: { "SKILL.md": `Skill ${LINE}\n` } }, + { meta: { type: "steering", name: "product" }, files: { "steering.md": `Steering ${LINE}\n` } }, + ]; + const hubFile = (list: string[]) => + ["---", "inclusion: always", "---", "", "", "", "# hub", "", ...list, ""].join("\n"); + const TODAY = { + backticks: "- backticks: see `.kiro/steering/style.md` and `.kiro/steering/api.md`.", + unknown: "- unknown name: `.kiro/steering/handwritten.md`", + pattern: "- pattern: `.kiro/steering/.md` and `.kiro/steering/*.md`", + notRule: "- not a rule: `.claude/agents/reviewer.md`, `.claude/commands/open-pr.md`", + }; + const DEAD_LINK = "- link: the md-only rule (md-only, rule not in this workspace)."; + const HANDWRITTEN = ["rule/hub", "agent/reviewer", "command/open-pr", "skill/howto"].map((c) => `.claude/rules/handwritten.md (in ${c})`).join(", "); + const W1 = + "kiro: 5 reference(s) to rule files this workspace does not have — reworded: .claude/rules/md-only.md (in rule/hub; rule/md-only reaches no target here), .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in agent/reviewer; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in command/open-pr; rule/cc-only reaches no target here), .claude/rules/cc-only.md (in skill/howto; rule/cc-only reaches no target here); 4 reference(s) to names that are no rule or steering here — rewritten to .kiro/steering/ as before: " + + HANDWRITTEN; + + it("kiro only: §4.4's first block and §4.5's warning (spec 17 test 1)", async () => { + const p = await planFor(forge17()); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile([TODAY.backticks, DEAD_LINK, "- bare: cc-only (rule not in this workspace) and .kiro/steering/kiro-only.md", TODAY.unknown, TODAY.pattern, TODAY.notRule])); + expect(kw(p)).toEqual([W1]); + }); + + it("claude-code + kiro: a rule claude-code writes keeps its .claude/rules/ path (spec 17 test 2)", async () => { + const p = await planFor(forge17(), {}, ["claude-code", "kiro"]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile([TODAY.backticks, DEAD_LINK, "- bare: .claude/rules/cc-only.md and .kiro/steering/kiro-only.md", TODAY.unknown, TODAY.pattern, TODAY.notRule])); + expect(kw(p)).toEqual([ + "kiro: 1 reference(s) to rule files this workspace does not have — reworded: .claude/rules/md-only.md (in rule/hub; rule/md-only reaches no target here); 4 reference(s) to names that are no rule or steering here — rewritten to .kiro/steering/ as before: " + HANDWRITTEN, + ]); + }); + + it("kiro + agents-md: a rule only AGENTS.md holds points there (spec 17 test 3)", async () => { + const p = await planFor(forge17(), {}, ["kiro", "agents-md"]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile([TODAY.backticks, "- link: [the md-only rule](AGENTS.md).", "- bare: cc-only (rule not in this workspace) and .kiro/steering/kiro-only.md", TODAY.unknown, TODAY.pattern, TODAY.notRule])); + }); + + it("agent prompt, command body and skill file are resolved the same way (spec 17 test 4)", async () => { + const k = await planFor(forge17()); + expect(JSON.parse(lf(k, ".kiro/agents/reviewer.json")!).prompt).toBe(`Agent ${LINE_K}`); + expect(lf(k, ".kiro/steering/commands/open-pr.md")).toBe(`---\ninclusion: manual\n---\n\n---\ndescription: Opens per cc-only (rule not in this workspace)\n---\nCommand ${LINE_K}\n`); + expect(lf(k, ".kiro/skills/howto/SKILL.md")).toBe(`Skill ${LINE_K}\n`); + const kc = await planFor(forge17(), {}, ["claude-code", "kiro"]); + expect(JSON.parse(lf(kc, ".kiro/agents/reviewer.json")!).prompt).toBe(`Agent ${LINE_KC}`); + expect(lf(kc, ".kiro/steering/commands/open-pr.md")).toBe(`---\ninclusion: manual\n---\n\n---\ndescription: Opens per .claude/rules/cc-only.md\n---\nCommand ${LINE_KC}\n`); + expect(lf(kc, ".kiro/skills/howto/SKILL.md")).toBe(`Skill ${LINE_KC}\n`); + }); + + it("descriptions are resolved; a hand-written command description gets the kiro rewrite too (spec 17 test 5)", async () => { + const k = await planFor(forge17()); + expect(lf(k, ".kiro/agents/reviewer.json")).toContain('\n "description": "Reviews per cc-only (rule not in this workspace)",\n'); + expect(lf(k, ".kiro/steering/commands/open-pr.md")).toContain("\ndescription: Opens per cc-only (rule not in this workspace)\n"); + const kc = await planFor(forge17(), {}, ["claude-code", "kiro"]); + expect(lf(kc, ".kiro/agents/reviewer.json")).toContain('\n "description": "Reviews per .claude/rules/cc-only.md",\n'); + expect(lf(kc, ".kiro/steering/commands/open-pr.md")).toContain("\ndescription: Opens per .claude/rules/cc-only.md\n"); + const c2 = await planFor([ + rule("style", "# style\n"), + { meta: { type: "command", name: "c2", description: "Uses .claude/rules/style.md, .claude/rules/nope.md and .claude/rules/*.md" }, files: { "command.md": "C2\n" } }, + ]); + expect(lf(c2, ".kiro/steering/commands/c2.md")).toContain("\ndescription: Uses .kiro/steering/style.md, .kiro/steering/nope.md and .kiro/steering/*.md\n"); + expect(kw(c2)).toEqual(["kiro: 1 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/nope.md (in command/c2)"]); + }); + + it("a Forge of the shape import produces keeps every kiro byte (spec 17 test 6)", async () => { + const p = await planFor( + [ + rule("hub", "# hub\n\nsee `.claude/rules/style.md`, [api](.claude/rules/api.md), .claude/rules/product.md and `.claude/rules/*.md`\n"), + rule("style", "# style\n"), + rule("api", "# api\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**" }), + { meta: { type: "steering", name: "product" }, files: { "steering.md": "# product\n" } }, + ], + {}, + ["claude-code", "kiro"], + ); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["see `.kiro/steering/style.md`, [api](.kiro/steering/api.md), .kiro/steering/product.md and `.kiro/steering/*.md`"])); + expect(kw(p)).toEqual([]); + }); + + it("patterns and non-references keep the directory rewrite (spec 17 test 7)", async () => { + const p = await planFor([rule("hub", "# hub\n\n.claude/rules/*.md .claude/rules/.md projects/web/.claude/rules/style.md .claude/rules/style.md.bak\n"), rule("style", "# style\n")]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile([".kiro/steering/*.md .kiro/steering/.md projects/web/.kiro/steering/style.md .kiro/steering/style.md.bak"])); + expect(kw(p)).toEqual([]); + }); + + it("an agent's resources do not move, while its prompt is resolved (spec 17 test 8)", async () => { + const p = await planFor([ + rule("backend-api", "# backend-api\n", { inclusion: "fileMatch", fileMatchPattern: "projects/api/**", targets: ["claude-code"] }), + { meta: { type: "agent", name: "backend-reviewer" }, files: { "agent.md": "Follow .claude/rules/backend-api.md.\n" } }, + ]); + const json = JSON.parse(lf(p, ".kiro/agents/backend-reviewer.json")!); + expect(json.resources).toEqual(["file://.kiro/steering/backend-api.md"]); + expect(json.prompt).toBe("Follow backend-api (rule not in this workspace)."); + }); + + it("an unknown name alone gives the second form (spec 17 test 9)", async () => { + const p = await planFor([rule("hub", "# hub\n\nSee .claude/rules/handwritten.md.\n")]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["See .kiro/steering/handwritten.md."])); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/handwritten.md (in rule/hub)"]); + }); + + it("a steering body is emitted as written and reports nothing (spec 17 test 10)", async () => { + const p = await planFor([{ meta: { type: "steering", name: "product" }, files: { "steering.md": `Steering ${LINE}\n` } }, rule("style", "# style\n")]); + expect(lf(p, ".kiro/steering/product.md")).toBe(`Steering ${LINE}\n`); + expect(kw(p)).toEqual([]); + }); + + it("a raw frontmatter block is resolved (spec 17 test 11)", async () => { + const p = await planFor([ + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + { meta: { type: "command", name: "raw", frontmatterRaw: "description: Uses .claude/rules/cc-only.md" }, files: { "command.md": "Raw\n" } }, + ]); + expect(lf(p, ".kiro/steering/commands/raw.md")).toContain("description: Uses cc-only (rule not in this workspace)"); + }); + + it("K3 as a token (spec 17 test 12)", async () => { + const p = await planFor([rule("hub", "# hub\n\nSee `.claude/rules/md-only.md`.\n"), rule("md-only", "# md-only\n", { inclusion: "manual", targets: ["agents-md"] })], {}, ["kiro", "agents-md"]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["See `AGENTS.md (rule: md-only)`."])); + }); + + it("K2 as a link is left as the Forge wrote it (spec 17 test 13)", async () => { + const p = await planFor([rule("hub", "# hub\n\n[the rule](.claude/rules/cc-only.md)\n"), rule("cc-only", "# cc-only\n", { targets: ["claude-code"] })], {}, ["claude-code", "kiro"]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["[the rule](.claude/rules/cc-only.md)"])); + expect(kw(p)).toEqual([]); + }); + + it("a dead reference alone gives the first form (spec 17 test 14)", async () => { + const p = await planFor([rule("hub", "# hub\n\nSee .claude/rules/cc-only.md.\n"), rule("cc-only", "# cc-only\n", { targets: ["claude-code"] })]); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to rule files this workspace does not have — reworded: .claude/rules/cc-only.md (in rule/hub; rule/cc-only reaches no target here)"]); + }); + + it("fragments: kept in K1, dropped in K3 (spec 17 test 15)", async () => { + const p = await planFor( + [rule("hub", "# hub\n\n[t](.claude/rules/style.md#part) [u](.claude/rules/md-only.md#part)\n"), rule("style", "# style\n"), rule("md-only", "# md-only\n", { inclusion: "manual", targets: ["agents-md"] })], + {}, + ["kiro", "agents-md"], + ); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["[t](.kiro/steering/style.md#part) [u](AGENTS.md)"])); + }); + + it("entries follow the order of each ingredient's texts (spec 17 test 16)", async () => { + const p = await planFor([ + rule("md-only", "# md-only\n", { inclusion: "manual", targets: ["agents-md"] }), + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + { meta: { type: "agent", name: "a1", description: "Per .claude/rules/md-only.md" }, files: { "agent.md": "Per .claude/rules/cc-only.md\n" } }, + { meta: { type: "command", name: "c1", description: "Per .claude/rules/cc-only.md" }, files: { "command.md": "Per .claude/rules/md-only.md\n" } }, + ]); + const e = (x: string, c: string) => `.claude/rules/${x}.md (in ${c}; rule/${x} reaches no target here)`; + expect(kw(p)).toEqual([`kiro: 4 reference(s) to rule files this workspace does not have — reworded: ${[e("md-only", "agent/a1"), e("cc-only", "agent/a1"), e("cc-only", "command/c1"), e("md-only", "command/c1")].join(", ")}`]); + }); + + it("a skill's other text files are resolved; one entry per skill (spec 17 test 17)", async () => { + const p = await planFor([ + rule("cc-only", "# cc-only\n", { targets: ["claude-code"] }), + { meta: { type: "skill", name: "s1" }, files: { "SKILL.md": "See .claude/rules/cc-only.md.\n", "notes.md": "See .claude/rules/cc-only.md.\n" } }, + ]); + expect(lf(p, ".kiro/skills/s1/SKILL.md")).toBe("See cc-only (rule not in this workspace).\n"); + expect(lf(p, ".kiro/skills/s1/notes.md")).toBe("See cc-only (rule not in this workspace).\n"); + expect(kw(p)).toEqual(["kiro: 1 reference(s) to rule files this workspace does not have — reworded: .claude/rules/cc-only.md (in skill/s1; rule/cc-only reaches no target here)"]); + }); + + it("a raw frontmatter block wins: the unused description is neither emitted nor reported (spec 17 §4.5, review fix)", async () => { + const p = await planFor([ + { meta: { type: "command", name: "raw2", description: "per .claude/rules/q-dead.md", frontmatterRaw: "argument-hint: per .claude/rules/z-dead.md\ndescription: per .claude/rules/a-dead.md" }, files: { "command.md": "Raw\n" } }, + ]); + expect(lf(p, ".kiro/steering/commands/raw2.md")).toBe("---\ninclusion: manual\n---\n\n---\nargument-hint: per .kiro/steering/z-dead.md\ndescription: per .kiro/steering/a-dead.md\n---\nRaw\n"); + expect(kw(p)).toEqual([ + "kiro: 2 reference(s) to names that are no rule or steering of this workspace — rewritten to .kiro/steering/ as before: .claude/rules/z-dead.md (in command/raw2), .claude/rules/a-dead.md (in command/raw2)", + ]); + }); + + it("a link fragment keeps the directory rewrite, as on 0.8.4 (spec 17 §4.3, review fix)", async () => { + const p = await planFor([rule("hub", "# hub\n\n[t](.claude/rules/style.md#see-.claude/rules/z) [u](.claude/rules/nope.md#see-.claude/rules/z)\n"), rule("style", "# style\n")]); + expect(lf(p, ".kiro/steering/hub.md")).toBe(hubFile(["[t](.kiro/steering/style.md#see-.kiro/steering/z) [u](.kiro/steering/nope.md#see-.kiro/steering/z)"])); + }); +}); \ No newline at end of file