From b4dffc668d678d4995321fe6ca2a2c0a0f8d9814 Mon Sep 17 00:00:00 2001 From: Black Sun <18490995+BlandineRdl@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:44:44 +0200 Subject: [PATCH 1/4] feat(cli): emit rules in Kilo flat builds A Kilo flat archive dropped canonical plugin Rules although Kilo loads Markdown Rules listed in kilo.json[c] instructions. All-files Rules are now materialized under .kilo/rules/ and appended to instructions after user entries, idempotently. A path-scoped Rule fails the build instead of being silently broadened. The bundle budget rises to 725 KB. Closes #868 Co-Authored-By: Claude Opus 5.5 --- .../2026_10_20_kilo-flat-rules/phase-1.md | 83 ++++++++++ .../2026_10_20_kilo-flat-rules/phase-2.md | 63 ++++++++ .../2026_10_20_kilo-flat-rules/plan.md | 37 +++++ cli/README.md | 2 + cli/package.json | 2 +- cli/pnpm-lock.yaml | 19 +++ cli/scripts/check-bundle-size.mjs | 2 + .../tools/domain/profiles/kilo/build.ts | 72 ++++++++- .../strategies/build-output-strategy.ts | 2 + .../strategies/flat-build-strategy.ts | 51 ++++++- .../strategies/marketplace-build-strategy.ts | 4 + .../translate/application/translate-source.ts | 9 +- cli/src/kernel/errors.ts | 10 ++ cli/src/kernel/materialization/flat-paths.ts | 9 ++ .../materialization/relative-link-rewrite.ts | 45 ++++++ ...stall-runtime-config-use-case.unit.test.ts | 20 +++ .../domain/profiles/kilo/build.unit.test.ts | 81 +++++++++- ...amework-build-use-case.integration.test.ts | 19 +++ .../flat-build-strategy.integration.test.ts | 144 +++++++++++++++++- cli/tests/e2e/framework-build.e2e.test.ts | 68 +++++++-- cli/tests/e2e/kilo-runtime.e2e.test.ts | 28 ++-- .../materialization/flat-paths.unit.test.ts | 15 ++ .../relative-link-rewrite.unit.test.ts | 48 +++++- docs/ARCHITECTURE.md | 2 +- 24 files changed, 796 insertions(+), 39 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-2.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/plan.md diff --git a/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-1.md new file mode 100644 index 000000000..b82b396b7 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-1.md @@ -0,0 +1,83 @@ +--- +status: done +--- + +# Instruction: Flat Rule emission for Kilo + +## Architecture projection +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/ +│ ├── kernel/errors.ts ✏️ add a path-scoped-Rule error naming Rule and scope +│ ├── kernel/materialization/flat-paths.ts ✏️ add genericFlatRulePath(prefix, plugin, rel) +│ ├── contexts/tools/domain/profiles/kilo/build.ts ✏️ rules: supported, path, transform rejecting `paths`, link rewrite +│ ├── contexts/translate/application/strategies/build-output-strategy.ts ✏️ add writeRules +│ ├── contexts/translate/application/strategies/flat-build-strategy.ts ✏️ implement writeRules (collision check, transform, link rewrite) +│ ├── contexts/translate/application/strategies/marketplace-build-strategy.ts ✏️ writeRules returns 0 +│ └── contexts/translate/application/translate-source.ts ✏️ call writeRules; warn only for unsupported sections +└── tests/ + ├── contexts/tools/domain/profiles/kilo/build.unit.test.ts ✏️ rule path, transform, rejection + └── contexts/translate/application/strategies/flat-build-strategy*.test.ts ✏️ rule writing, multiple plugins, link rewrite +``` + +## User Journey +```mermaid +flowchart TD + A[aidd framework build --target kilo --flat] --> B{Rule has paths?} + B -- no --> C[.kilo/rules/plugin-group-name.md written, links rewritten] + B -- yes --> D[Build fails naming the Rule and its paths] +``` + +## Test Scope +```mermaid +--- +title: Test scope +--- +journey + section Setup + Fixture plugins with an all-files Rule linking a sibling file => sources ready: 5: system + section Happy path + Build kilo flat => Rule written under .kilo/rules with plugin-prefixed name: 5: cli + Build kilo flat => relative links in the Rule resolve from its new location: 5: cli + section Edge case - multiple plugins + Two plugins with same Rule name => build kilo flat => two distinct files: 1: cli + section Edge case - path-scoped Rule + Rule declares paths => build kilo flat => error names the Rule and its paths, nothing emitted: 1: cli + section Edge case - other targets + Plugin with rules => build opencode flat => skipped-rules warning still shown: 1: cli +``` + +## Tasks to do +### `1)` Rule path and error +> Deterministic, collision-free flat Rule path and a named error. + +1. Add `genericFlatRulePath` in `flat-paths.ts`: `${prefix}${plugin}-${rel without rules/, "/" → "-"}`. +2. Add a domain error in `kernel/errors.ts` carrying Rule path and its `paths` values. + +### `2)` Kilo rules contract +> Kilo declares rules as supported. + +1. In `buildKiloFlatContract()`, set `rules` to `filteredTree` over `rules`, `.md`, path `.kilo/rules/…`. +2. Transform: parse frontmatter, throw the new error when `paths` is present, rewrite relative links for the materialized location, keep content otherwise. + +### `3)` Generic flat writer +> The flat strategy writes rules when the contract supports them. + +1. Add `writeRules` to `BuildOutputStrategy`; marketplace returns 0. +2. Implement in `FlatBuildStrategy` mirroring `writeAgents` (collision check, `force`). +3. Call it in `translate-source.ts`; skip the out-of-scope warning for a section the contract supports. + +### `4)` Tests +> Cover the scope above at unit level. + +1. Extend Kilo build unit tests and flat strategy tests per Test Scope. + +## Test acceptance criteria +| Task | Acceptance criteria | +| --- | --- | +| 1 | `genericFlatRulePath(".kilo/rules/", "aidd-test", "rules/01-standards/naming.md")` returns `.kilo/rules/aidd-test-01-standards-naming.md`. | +| 2 | A Rule with `paths` makes the build fail with a message naming the Rule and its paths; no file is written for it. | +| 3 | An all-files Rule is emitted under `.kilo/rules/`; relative links in it resolve; other flat targets still warn and skip rules. | +| 4 | `pnpm --filter ./cli test` (or the repo's test command) passes. | diff --git a/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-2.md new file mode 100644 index 000000000..8f477c945 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-2.md @@ -0,0 +1,63 @@ +--- +status: done +--- + +# Instruction: Kilo `instructions` merge and installed-archive proof + +## Architecture projection +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ +├── src/contexts/tools/domain/profiles/kilo/build.ts ✏️ emitConfigArtifact appends generated Rule paths to instructions +├── tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts ✏️ JSON, JSONC, existing instructions, repeat build +├── tests/e2e/framework-build.e2e.test.ts ✏️ kilo flat build ships rules + instructions +└── tests/golden/snapshots/framework-build/golden.json ✏️ only if the golden captures kilo output +``` + +## User Journey +```mermaid +flowchart TD + A[Build kilo flat] --> B[Resolve kilo.json or kilo.jsonc] + B --> C[Keep user instructions in order] + C --> D[Append sorted generated Rule paths not already present] + D --> E[Rebuild leaves files and entries unchanged] +``` + +## Test Scope +```mermaid +--- +title: Test scope +--- +journey + section Setup + Project with kilo.jsonc holding user instructions => config ready: 5: system + section Happy path + Build kilo flat => instructions = user entries then generated Rule paths: 5: cli + section Edge case - JSON config + Existing .kilo/kilo.json => build kilo flat => instructions merged in that file: 1: cli + section Edge case - repeated build + Build twice with --force => no duplicate Rule file or instruction entry: 1: cli + section Edge case - clean install + Flat archive installed into empty project => kilo config references the emitted Rules: 1: cli +``` + +## Tasks to do +### `1)` Merge generated instructions +> The resolved Kilo config references every emitted Rule. + +1. In `emitConfigArtifact`, list each built plugin's `.md` Rules, map them with the Phase 1 path, sort. +2. Pass them as base `instructions` with `mergedKeys: ["instructions"]` to `buildOpencodeFlatConfig`. + +### `2)` Installed-archive proof +> A clean install keeps the generated instructions. + +1. Check that installing a flat Kilo archive into a clean project keeps `instructions` (`install-runtime-config-use-case.ts` rewrites the config); fix if it drops them. +2. Extend the kilo flat e2e in `framework-build.e2e.test.ts`. +3. Run `tests/e2e/kilo-runtime.e2e.test.ts` when a Kilo binary is available; otherwise report it as not run. + +## Test acceptance criteria +| Task | Acceptance criteria | +| --- | --- | +| 1 | User `instructions` keep values and order; generated paths follow them, sorted; a rebuild changes nothing. | +| 2 | A clean installed archive has a Kilo config whose `instructions` include each emitted Rule; the Kilo runtime check is run or reported as not runnable. | diff --git a/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/plan.md b/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/plan.md new file mode 100644 index 000000000..f22c455b0 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/plan.md @@ -0,0 +1,37 @@ +--- +status: implemented +--- + +# Plan: Emit plugin Rules in Kilo flat builds + +## Overview +| Field | Value | +| --- | --- | +| **Goal** | `aidd framework build --target kilo --flat` materializes all-files plugin Rules under `.kilo/rules/` and references them from the resolved `kilo.json[c].instructions`. | +| **Source** | GitHub issue ai-driven-dev/framework#868 | + +## Phases +| # | Phase | File | +| --- | --- | --- | +| 1 | Flat Rule emission for Kilo | [`phase-1.md`](./phase-1.md) | +| 2 | Kilo `instructions` merge and installed-archive proof | [`phase-2.md`](./phase-2.md) | + +## Resources +| Source | Verified | +| --- | --- | +| https://kilo.ai/docs/customize/custom-rules | Project Rules are file paths or globs in `kilo.jsonc` `instructions`, written project-root relative (`.kilo/rules/x.md`); `.kilo/rules/` is the documented Rules directory. | +| `cli/src/contexts/tools/domain/profiles/kilo/build.ts` | `buildKiloFlatContract()` declares `rules: { supported: false }`; `emitConfigArtifact` already resolves and rewrites the Kilo config via `buildOpencodeFlatConfig`. | +| `cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts` | `buildOpencodeFlatConfig(base, existing, incoming, mergedKeys)` already appends base array values after existing ones, skipping duplicates: the `instructions` merge seam. | +| `cli/src/contexts/translate/application/translate-source.ts` | Builds call `writeAgents/Skills/Hooks/Mcp` only; `rules` is in `OUT_OF_SCOPE_PLUGIN_SECTIONS` and always warned as skipped. | +| `cli/src/contexts/translate/application/strategies/flat-build-strategy.ts` | Owns flat file writing, collision checks, and relative-link rewriting; no rule writer exists. | +| `cli/tests/fixtures/framework/plugins/aidd-test/rules/01-standards/naming.md` | Canonical Rules nest under `rules//` and scope with `paths` frontmatter. | +| `which kilo` | No Kilo binary on this machine: the real-runtime e2e (`tests/e2e/kilo-runtime.e2e.test.ts`) cannot run locally. | + +## Decisions +| Decision | Why | +| --- | --- | +| Generic `writeRules` on `BuildOutputStrategy`, consuming `contract.artifacts.rules`. | Keeps file emission in the flat strategy, the seam #789 (OpenCode) can reuse; marketplace strategy returns 0. | +| Flat Rule path `.kilo/rules/-.md`. | Plugin prefix + flattened group path is collision-free and deterministic, matching the existing flat naming convention. | +| Path-scoped Rule rejection lives in the Kilo Rule transform, throwing a named domain error. | Scope support is a Kilo capability, not a generic flat one; transform already runs per Rule with plugin and name. | +| `instructions` merge reuses `buildOpencodeFlatConfig` `mergedKeys`. | Existing user entries keep order; generated entries append sorted and are never duplicated, giving idempotence for free. | +| The skipped-section warning fires only when the target does not support that section. | Kilo now ships rules; other targets keep the current warning. | diff --git a/cli/README.md b/cli/README.md index ea60e90e5..a9718c5df 100644 --- a/cli/README.md +++ b/cli/README.md @@ -142,6 +142,8 @@ Two output layouts, chosen by `--as`: OpenCode and Kilo Code declare no marketplace contract, so they are flat only. Every other target accepts both layouts. +A Kilo flat build also writes each plugin Rule to `.kilo/rules/` and appends it to the Kilo config's `instructions`, after any entries already there. Kilo loads those for every file, so a Rule scoped by `paths` fails the build. Every other target skips `rules/` with a warning. + ## Environment variables | Variable | Effect | diff --git a/cli/package.json b/cli/package.json index 5fdddf646..fda7a860f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 709, + "bundleBudgetKB": 725, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml index e243c9d61..7ede0e6ea 100644 --- a/cli/pnpm-lock.yaml +++ b/cli/pnpm-lock.yaml @@ -6,6 +6,9 @@ importers: .: configDependencies: {} packageManagerDependencies: + '@pnpm/exe': + specifier: 12.3.4 + version: 12.3.4 pnpm: specifier: 12.3.4 version: 12.3.4 @@ -56,6 +59,11 @@ packages: cpu: [x64] os: [win32] + '@pnpm/exe@12.3.4': + resolution: {integrity: sha512-Rq7JokWAyYF9IFfn8zofB/q2AgnZlmoMuH5fMHB2+Ta61I9aD7sNm+woZpx6iH+5vb38chdSoYRvOtSqlH72YQ==} + engines: {node: '>=18.*'} + hasBin: true + pnpm@12.3.4: resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} engines: {node: '>=18.*'} @@ -87,6 +95,17 @@ snapshots: '@pnpm/exe.win32-x64@12.3.4': optional: true + '@pnpm/exe@12.3.4': + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 + pnpm@12.3.4: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.3.4 diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index bbec74123..efba89fbd 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -46,6 +46,8 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 709 KB: 695.3 KB, telemetry keeping what an ingest measured and could not store, ledger lines // no reader knows, and a bounded re-read of a call whose consent cannot be read yet (#1046, // +1.3 KB over next's 694.0 KB, after 1.7 KB of savings in telemetry code); 2.0% measured headroom. +// 725 KB: 710.8 KB, Kilo flat Rules: emission, link relocation and `instructions` merge (#868, +// +4.8 KB over next's 706.0 KB); 2.0% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/src/contexts/tools/domain/profiles/kilo/build.ts b/cli/src/contexts/tools/domain/profiles/kilo/build.ts index 1703cb4f4..109a363c5 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/build.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/build.ts @@ -1,15 +1,20 @@ /** Kilo's project distribution is flat. Its config and MCP format follow the OpenCode runtime, * but Kilo has its own .kilo/ discovery paths and bundled base configuration. */ -import { join } from "node:path"; +import { join, relative } from "node:path"; +import { PathScopedRuleUnsupportedError } from "../../../../../kernel/errors.js"; import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; import { flatHooksPathWithLoaderEntry, flatMcpKeyPrefix, genericFlatAgentPath, + genericFlatRulePath, genericFlatSkillTreePath, } from "../../../../../kernel/materialization/flat-paths.js"; -import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; +import { + relocateRelativeLinks, + rewriteRelativeLinks, +} from "../../../../../kernel/materialization/relative-link-rewrite.js"; import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; import type { ToolBuildContract } from "../../build-contract.js"; @@ -40,10 +45,19 @@ function kiloFlatSkillPath(plugin: string, rel: string): string { return genericFlatSkillTreePath(`${KILO_DIRECTORY}skills/`, plugin, rel.replace(/^skills\//, "")); } -function kiloFlatResolveTarget(plugin: string, rel: string): string { +function kiloFlatRulePath(plugin: string, rel: string): string { + return genericFlatRulePath(`${KILO_DIRECTORY}rules/`, plugin, rel); +} + +function kiloFlatEmittedTarget(plugin: string, rel: string): string | null { if (rel.startsWith("agents/")) return kiloFlatAgentPath(plugin, rel); if (rel.startsWith("skills/")) return kiloFlatSkillPath(plugin, rel); - return rel; + if (rel.startsWith("rules/") && rel.endsWith(".md")) return kiloFlatRulePath(plugin, rel); + return null; +} + +function kiloFlatResolveTarget(plugin: string, rel: string): string { + return kiloFlatEmittedTarget(plugin, rel) ?? rel; } function kiloFlatHooksPath(plugin: string, rel: string): string { @@ -68,6 +82,38 @@ function transformKiloFlatAgent(content: string, plugin: string, outName: string ); } +// Kilo loads `instructions` for every session: a `paths` scope would silently widen to all files. +function transformKiloFlatRule(content: string, plugin: string, ruleRel: string): string { + const sourceRel = `rules/${ruleRel}`; + const { frontmatter } = parseFrontmatter(content); + if (frontmatter.paths !== undefined) { + throw new PathScopedRuleUnsupportedError(plugin, sourceRel, frontmatter.paths); + } + return relocateRelativeLinks(content, { + sourceFilePluginRelative: sourceRel, + materializedFileRelative: kiloFlatRulePath(plugin, sourceRel), + resolveTargetPath: (rel) => kiloFlatEmittedTarget(plugin, rel), + }); +} + +async function collectKiloRuleInstructions( + builtPlugins: readonly string[], + sourceDir: string, + fs: FsType +): Promise { + const instructions: string[] = []; + for (const plugin of builtPlugins) { + const rulesSrc = join(sourceDir, "plugins", plugin, "rules"); + if (!(await fs.fileExists(rulesSrc))) continue; + for (const absPath of await fs.listFilesRecursive(rulesSrc)) { + if (!absPath.endsWith(".md")) continue; + const rel = relative(rulesSrc, absPath).replace(/\\/g, "/"); + instructions.push(kiloFlatRulePath(plugin, `rules/${rel}`)); + } + } + return instructions.sort(); +} + async function collectKiloMcp( builtPlugins: readonly string[], sourceDir: string, @@ -117,7 +163,12 @@ export function buildKiloFlatContract(): ToolBuildContract { skipIfSourceHas: KILO_PLUGIN_ENTRY_BASENAME, }, }, - rules: { supported: false }, + rules: { + supported: true, + source: { kind: "filteredTree", srcDir: "rules", inputExt: ".md" }, + path: kiloFlatRulePath, + transform: transformKiloFlatRule, + }, commands: { supported: false }, }, buildMarketplaceCatalog: null, @@ -127,8 +178,15 @@ export function buildKiloFlatContract(): ToolBuildContract { const existing = (await fs.fileExists(configPath)) ? await fs.readFile(configPath) : null; const incoming = await collectKiloMcp(builtPlugins, sourceDir, fs); const asset = assetProvider.loadConfigAsset("kilo", "kilo.json"); - const base = typeof asset === "string" ? asset : JSON.stringify(asset); - await fs.writeFile(configPath, buildOpencodeFlatConfig(base, existing, incoming)); + const assetJson = typeof asset === "string" ? asset : JSON.stringify(asset); + const instructions = await collectKiloRuleInstructions(builtPlugins, sourceDir, fs); + // Generated Rule paths follow the user's own `instructions`, never duplicated on rebuild. + const base = + instructions.length > 0 + ? JSON.stringify({ ...JSON.parse(assetJson), instructions }) + : assetJson; + const mergedKeys = instructions.length > 0 ? ["instructions"] : []; + await fs.writeFile(configPath, buildOpencodeFlatConfig(base, existing, incoming, mergedKeys)); return 1; }, }; diff --git a/cli/src/contexts/translate/application/strategies/build-output-strategy.ts b/cli/src/contexts/translate/application/strategies/build-output-strategy.ts index a81e3ce35..586737bb9 100644 --- a/cli/src/contexts/translate/application/strategies/build-output-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/build-output-strategy.ts @@ -26,6 +26,8 @@ export interface BuildOutputStrategy { writeSkills(pluginName: string, pluginSrc: string, outDir: string): Promise; + writeRules(pluginName: string, pluginSrc: string, outDir: string): Promise; + writeHooks(pluginName: string, pluginSrc: string, outDir: string): Promise; writeMcp(pluginName: string, pluginSrc: string, outDir: string): Promise; diff --git a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts index 93c9acc0d..5d0352874 100644 --- a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts @@ -3,7 +3,10 @@ import { FlatTargetExistsError, OutDirNotDirectoryError } from "../../../../kern import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; import { rewriteClaudeRootInJson } from "../../../../kernel/materialization/claude-root-path-rewrite.js"; import { flatMcpKeyPrefix } from "../../../../kernel/materialization/flat-paths.js"; -import { rewriteRelativeLinks } from "../../../../kernel/materialization/relative-link-rewrite.js"; +import { + relocateRelativeLinks, + rewriteRelativeLinks, +} from "../../../../kernel/materialization/relative-link-rewrite.js"; import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; @@ -20,6 +23,7 @@ import type { BuildOutputStrategy, SourceMarketplace } from "./build-output-stra export class FlatBuildStrategy implements BuildOutputStrategy { private sourceDir = ""; + private readonly ruleDestinations = new Set(); constructor( private readonly fs: FileReader & FileWriter, @@ -36,6 +40,7 @@ export class FlatBuildStrategy implements BuildOutputStrategy { const exists = await this.fs.fileExists(outDir); if (!exists || !(await this.isDirectory(outDir))) throw new OutDirNotDirectoryError(outDir); this.sourceDir = sourceDir; + this.ruleDestinations.clear(); } async writePluginManifest(): Promise { @@ -69,6 +74,21 @@ export class FlatBuildStrategy implements BuildOutputStrategy { return count; } + async writeRules(pluginName: string, pluginSrc: string): Promise { + const artifact = this.contract.artifacts.rules; + if (!artifact.supported) return 0; + const rulesSrc = join(pluginSrc, "rules"); + if (!(await this.fs.fileExists(rulesSrc))) return 0; + const files = (await this.fs.listFilesRecursive(rulesSrc)).filter((p) => p.endsWith(".md")); + // Every Rule is transformed before any is written: a rejected Rule leaves none behind. + const outputs: { destPath: string; content: string }[] = []; + for (const absPath of files) { + outputs.push(await this.prepareFlatRule(artifact, pluginName, absPath, rulesSrc)); + } + for (const { destPath, content } of outputs) await this.fs.writeFile(destPath, content); + return outputs.length; + } + async writeHooks(pluginName: string, pluginSrc: string): Promise { const artifact = this.contract.artifacts.hooks; if (!artifact.supported) { @@ -134,6 +154,32 @@ export class FlatBuildStrategy implements BuildOutputStrategy { return 1; } + private async prepareFlatRule( + artifact: Extract, + pluginName: string, + absPath: string, + rulesSrc: string + ): Promise<{ destPath: string; content: string }> { + const ruleRel = relative(rulesSrc, absPath).replace(/\\/g, "/"); + const srcRelPath = `rules/${ruleRel}`; + const content = await this.fs.readFile(absPath); + assertNoToolsPlaceholder(content, pluginName, srcRelPath); + const flatRelPath = artifact.path(pluginName, srcRelPath); + const destPath = join(this.absOut, flatRelPath); + // Two Rules flattening to one name would overwrite each other even under --force. + if (this.ruleDestinations.has(destPath)) throw new FlatTargetExistsError(destPath, pluginName); + this.ruleDestinations.add(destPath); + await this.checkCollision(destPath, pluginName); + const outContent = artifact.transform + ? artifact.transform(content, pluginName, ruleRel) + : relocateRelativeLinks(content, { + sourceFilePluginRelative: srcRelPath, + materializedFileRelative: flatRelPath, + resolveTargetPath: (rel) => this.resolveTargetForMd(pluginName, rel), + }); + return { destPath, content: outContent }; + } + private rewriteAgentForFlat(content: string, flatRelPath: string, pluginName: string): string { const { frontmatter, body } = parseFrontmatter(content); const rewrittenBody = rewriteRelativeLinks(body, { @@ -353,6 +399,9 @@ export class FlatBuildStrategy implements BuildOutputStrategy { if (pluginRelPath.startsWith("skills/") && art.skills.supported) { return art.skills.path(pluginName, pluginRelPath); } + if (pluginRelPath.startsWith("rules/") && art.rules.supported) { + return art.rules.path(pluginName, pluginRelPath); + } return pluginRelPath; } } diff --git a/cli/src/contexts/translate/application/strategies/marketplace-build-strategy.ts b/cli/src/contexts/translate/application/strategies/marketplace-build-strategy.ts index 1aac8e977..9ecd71826 100644 --- a/cli/src/contexts/translate/application/strategies/marketplace-build-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/marketplace-build-strategy.ts @@ -90,6 +90,10 @@ export class MarketplaceBuildStrategy implements BuildOutputStrategy { return writeSkillTree(this.fs, pluginName, pluginSrc, pluginOut, artifact.transform); } + async writeRules(): Promise { + return 0; // marketplace layouts ship no Rules yet + } + async writeHooks(pluginName: string, pluginSrc: string, outDir: string): Promise { const artifact = this.contract.artifacts.hooks; if (!artifact.supported) return 0; diff --git a/cli/src/contexts/translate/application/translate-source.ts b/cli/src/contexts/translate/application/translate-source.ts index ef29f4921..2a64155fb 100644 --- a/cli/src/contexts/translate/application/translate-source.ts +++ b/cli/src/contexts/translate/application/translate-source.ts @@ -105,9 +105,12 @@ export class FrameworkBuildUseCase implements FrameworkBuild { filesWritten += await this.strategy.writePluginManifest(entry.name, pluginSrc, outDir); filesWritten += await this.strategy.writeAgents(entry.name, pluginSrc, outDir); filesWritten += await this.strategy.writeSkills(entry.name, pluginSrc, outDir); + const rulesWritten = await this.strategy.writeRules(entry.name, pluginSrc, outDir); + filesWritten += rulesWritten; filesWritten += await this.strategy.writeHooks(entry.name, pluginSrc, outDir); filesWritten += await this.strategy.writeMcp(entry.name, pluginSrc, outDir); - const skippedSections = await this.warnOutOfScopeSections(entry.name, pluginSrc); + const emitted = rulesWritten > 0 ? ["rules"] : []; + const skippedSections = await this.warnOutOfScopeSections(entry.name, pluginSrc, emitted); return { name: entry.name, filesWritten, skippedSections }; } @@ -120,10 +123,12 @@ export class FrameworkBuildUseCase implements FrameworkBuild { private async warnOutOfScopeSections( pluginName: string, - pluginSrc: string + pluginSrc: string, + emittedSections: readonly string[] ): Promise { const skipped: string[] = []; for (const section of OUT_OF_SCOPE_PLUGIN_SECTIONS) { + if (emittedSections.includes(section)) continue; if (await this.fs.fileExists(join(pluginSrc, section))) { this.logger.warn(`Skipping ${section}/ in plugin '${pluginName}' (out of scope for MVP1).`); skipped.push(section); diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts index b38753311..363faa2fa 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -426,6 +426,16 @@ export class FlatTargetExistsError extends Error { } } +export class PathScopedRuleUnsupportedError extends Error { + constructor(pluginName: string, rulePath: string, paths: unknown) { + super( + `Rule '${rulePath}' in plugin '${pluginName}' is scoped by paths ${JSON.stringify(paths)}, ` + + "which this target cannot express: only all-files Rules (no 'paths' frontmatter) are supported." + ); + this.name = "PathScopedRuleUnsupportedError"; + } +} + export class MarketplaceOutDirNotEmptyError extends Error { constructor(outDir: string) { super( diff --git a/cli/src/kernel/materialization/flat-paths.ts b/cli/src/kernel/materialization/flat-paths.ts index fe9fbd785..c431a1a2e 100644 --- a/cli/src/kernel/materialization/flat-paths.ts +++ b/cli/src/kernel/materialization/flat-paths.ts @@ -41,6 +41,15 @@ export function genericFlatSkillTreePath( return `${skillsPrefix}${plugin}/${skillRelPath}`; } +/** Group folders fold into the filename: one flat Rules directory, plugin origin kept. */ +export function genericFlatRulePath( + rulesPrefix: string, + plugin: string, + ruleRelPath: string +): string { + return `${rulesPrefix}${plugin}-${ruleRelPath.replace(/^rules\//, "").replaceAll("/", "-")}`; +} + export function genericFlatHooksFile(hooksPrefix: string, plugin: string): string { return `${hooksPrefix}${plugin}.hooks.json`; } diff --git a/cli/src/kernel/materialization/relative-link-rewrite.ts b/cli/src/kernel/materialization/relative-link-rewrite.ts index b3f4c1ccc..4a113f964 100644 --- a/cli/src/kernel/materialization/relative-link-rewrite.ts +++ b/cli/src/kernel/materialization/relative-link-rewrite.ts @@ -34,6 +34,51 @@ export function rewriteRelativeLinks( ); } +const MARKDOWN_LINK_TARGET_RE = /\]\(([^)\s]+)\)/g; +const RELATIVE_AT_RE = new RegExp(`@(\\.{1,2}\\/${REFERENCE_CHAR_CLASS})`, "g"); +const NON_RELATIVE_TARGET_RE = /^(?:[a-z][a-z0-9+.-]*:|\/|#|<|@)/i; + +export interface RelocateRelativeLinksOptions { + readonly sourceFilePluginRelative: string; + readonly materializedFileRelative: string; + /** Where a plugin-relative target lands; `null` leaves a link to it untouched. */ + readonly resolveTargetPath: (pluginRelPath: string) => string | null; +} + +/** For a file that moves on materialization: its relative links keep pointing at the same targets. */ +export function relocateRelativeLinks( + content: string, + options: RelocateRelativeLinksOptions +): string { + const relocate = (ref: string): string | null => { + const hashAt = ref.indexOf("#"); + const path = hashAt === -1 ? ref : ref.slice(0, hashAt); + const fragment = hashAt === -1 ? "" : ref.slice(hashAt); + const fromSource = posix.normalize( + posix.join(posix.dirname(options.sourceFilePluginRelative), path) + ); + if (fromSource.startsWith("../")) return null; + const resolved = options.resolveTargetPath(fromSource); + if (resolved === null) return null; + let linkPath = posix.relative(posix.dirname(options.materializedFileRelative), resolved); + if (!linkPath.startsWith(".")) linkPath = `./${linkPath}`; + return `${linkPath}${fragment}`; + }; + const afterMarkdown = content.replace(MARKDOWN_LINK_TARGET_RE, (match, ref: string) => { + if (NON_RELATIVE_TARGET_RE.test(ref)) return match; + const relocated = relocate(ref); + return relocated === null ? match : `](${relocated})`; + }); + const afterAt = afterMarkdown.replace(RELATIVE_AT_RE, (match, ref: string) => { + const relocated = relocate(ref); + return relocated === null ? match : `[${basename(ref)}](${relocated})`; + }); + return rewriteRelativeLinks(afterAt, { + currentFilePluginRelative: options.materializedFileRelative, + resolveTargetPath: (rel) => options.resolveTargetPath(rel) ?? rel, + }); +} + function rewriteClaudeRootRef( targetPluginRel: string, currentFilePluginRelative: string, diff --git a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts index 3584a5b41..8e0e4df68 100644 --- a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts @@ -206,6 +206,26 @@ describe("InstallRuntimeConfigUseCase", () => { expect(deps.fs.has(join(PROJECT_ROOT, "kilo.json"))).toBe(false); }); + it("keeps the Rule instructions an extracted Kilo flat archive ships", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const shipped = { instructions: [".kilo/rules/aidd-test-style.md"] }; + await deps.fs.writeFile(join(PROJECT_ROOT, ".kilo/kilo.jsonc"), JSON.stringify(shipped)); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + + for (const force of [false, true]) { + await buildUseCase(deps).execute({ + toolId: "kilo", + projectRoot: PROJECT_ROOT, + manifest, + force, + version: "1.0.0", + }); + const config = JSON.parse(await deps.fs.readFile(join(PROJECT_ROOT, ".kilo/kilo.jsonc"))); + expect(config.instructions).toEqual(shipped.instructions); + } + }); + it("reuses Kilo's existing root JSONC config instead of creating a project-local config", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts index 4534e9652..78b79f201 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import type { ArtifactContract } from "../../../../../../src/contexts/tools/domain/build-contract.js"; import { buildKiloFlatContract } from "../../../../../../src/contexts/tools/domain/profiles/kilo/build.js"; -import { KiloDualConfigError } from "../../../../../../src/kernel/errors.js"; +import { + KiloDualConfigError, + PathScopedRuleUnsupportedError, +} from "../../../../../../src/kernel/errors.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; function supported(artifact: ArtifactContract): Extract { @@ -132,3 +135,79 @@ describe("buildKiloFlatContract", () => { expect(fs.getFile("/out/.kilo/kilo.jsonc")).toBe("{}"); }); }); + +describe("Kilo flat Rules", () => { + const rules = () => supported(buildKiloFlatContract().artifacts.rules); + const asset = { + loadConfigAsset: () => ({ $schema: "https://app.kilo.ai/config.json" }), + loadSchema: () => ({}), + }; + const validator = { validate: () => undefined }; + + it("emits each all-files Rule as one plugin-prefixed file under .kilo/rules", () => { + expect(rules().path("aidd-test", "rules/01-standards/naming.md")).toBe( + ".kilo/rules/aidd-test-01-standards-naming.md" + ); + }); + + it("keeps an all-files Rule's content and repoints its relative links", () => { + const content = + "---\ndescription: naming\n---\nSee [errors](./errors.md) and @./errors.md first\n"; + expect(rules().transform?.(content, "aidd-test", "01-standards/naming.md")).toBe( + "---\ndescription: naming\n---\nSee [errors](./aidd-test-01-standards-errors.md) and " + + "[errors.md](./aidd-test-01-standards-errors.md) first\n" + ); + }); + + it("refuses a path-scoped Rule, naming the Rule and its scope", () => { + const content = '---\npaths:\n - "src/**/*.ts"\n---\nTyped code only.\n'; + const transform = () => rules().transform?.(content, "aidd-test", "01-standards/ts.md"); + expect(transform).toThrow(PathScopedRuleUnsupportedError); + expect(transform).toThrow( + /'rules\/01-standards\/ts\.md' in plugin 'aidd-test'.*src\/\*\*\/\*\.ts/ + ); + }); + + it("appends sorted Rule paths after the user's JSONC instructions, unchanged on rebuild", async () => { + const fs = new InMemoryFileAdapter({ + "/out/.kilo/kilo.jsonc": '{\n // mine\n "instructions": ["z.md", "a.md"],\n}', + "/source/plugins/aidd-test/rules/02-b/two.md": "two", + "/source/plugins/aidd-test/rules/01-a/one.md": "one", + "/source/plugins/aidd-test/rules/01-a/notes.txt": "not a Rule", + "/source/plugins/aidd-dev/rules/style.md": "style", + }); + const emit = buildKiloFlatContract().emitConfigArtifact; + await emit?.(["aidd-test", "aidd-dev"], "/out", "/source", fs, validator, asset); + const once = fs.getFile("/out/.kilo/kilo.jsonc"); + await emit?.(["aidd-test", "aidd-dev"], "/out", "/source", fs, validator, asset); + + expect(JSON.parse(once ?? "null").instructions).toEqual([ + "z.md", + "a.md", + ".kilo/rules/aidd-dev-style.md", + ".kilo/rules/aidd-test-01-a-one.md", + ".kilo/rules/aidd-test-02-b-two.md", + ]); + expect(fs.getFile("/out/.kilo/kilo.jsonc")).toBe(once); + }); + + it("merges the Rule paths into an existing .kilo/kilo.json", async () => { + const fs = new InMemoryFileAdapter({ + "/out/.kilo/kilo.json": JSON.stringify({ instructions: ["mine.md"] }), + "/source/plugins/aidd-test/rules/style.md": "style", + }); + await buildKiloFlatContract().emitConfigArtifact?.( + ["aidd-test"], + "/out", + "/source", + fs, + validator, + asset + ); + expect(JSON.parse(fs.getFile("/out/.kilo/kilo.json") ?? "null").instructions).toEqual([ + "mine.md", + ".kilo/rules/aidd-test-style.md", + ]); + expect(fs.getFile("/out/.kilo/kilo.jsonc")).toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts b/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts index abfcdea7d..9d5dba375 100644 --- a/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts +++ b/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts @@ -552,6 +552,7 @@ const countingStrategy: BuildOutputStrategy = { writePluginManifest: () => Promise.resolve(1), writeAgents: () => Promise.resolve(2), writeSkills: () => Promise.resolve(4), + writeRules: () => Promise.resolve(0), writeHooks: () => Promise.resolve(8), writeMcp: () => Promise.resolve(16), postBuild: () => Promise.resolve(32), @@ -575,6 +576,24 @@ describe("what a build reports having written", () => { }); }); + it("reports rules/ as emitted, not skipped, once the layout wrote any Rule", async () => { + const fs = await makeSeededFs(); + const logger = new CapturingLogger(); + const strategy = { ...countingStrategy, writeRules: () => Promise.resolve(64) }; + const uc = new FrameworkBuildUseCase( + fs, + makeValidator(), + makeAssetProvider(), + logger, + strategy + ); + const result = await uc.execute({ sourceDir: SOURCE_DIR, outDir: OUT_DIR, target: "kilo" }); + expect(result.plugins).toEqual([ + { name: "aidd-test", filesWritten: 95, skippedSections: ["commands"] }, + ]); + expect(logger.warnMessages.some((m) => m.includes("rules/"))).toBe(false); + }); + it("reports no skipped section for a plugin shipping neither commands nor rules", async () => { const fs = await makeSeededFs(); for (const section of ["commands", "rules"]) { diff --git a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts index b99cfab9d..052f1a1e6 100644 --- a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts @@ -1,4 +1,4 @@ -import { basename, resolve } from "node:path"; +import { basename, dirname, posix, resolve } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; import type { ArtifactContract, @@ -6,6 +6,7 @@ import type { } from "../../../../../src/contexts/tools/domain/build-contract.js"; import type { JsonSchemaValidator } from "../../../../../src/contexts/tools/domain/ports/schema-validator.js"; import { buildCopilotFlatContract } from "../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { buildKiloFlatContract } from "../../../../../src/contexts/tools/domain/profiles/kilo/build.js"; import { buildOpencodeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; import { FlatBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/flat-build-strategy.js"; import { FrameworkBuildUseCase } from "../../../../../src/contexts/translate/application/translate-source.js"; @@ -15,6 +16,7 @@ import { FrameworkPlaceholderInPluginError, JsonSchemaValidationError, OutDirNotDirectoryError, + PathScopedRuleUnsupportedError, } from "../../../../../src/kernel/errors.js"; import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; @@ -62,6 +64,7 @@ function makeAssetProvider(): AssetProvider { if (fileName === "opencode-events.js") { return "export async function setupOpencodeEvents() {}"; } + if (fileName === "kilo.json") return { $schema: "https://app.kilo.ai/config.json" }; if (fileName === "opencode.json") { return { $schema: "https://opencode.ai/config.json", @@ -768,3 +771,142 @@ describe("what a flat layout writes once every plugin is built", () => { expect(seen).toEqual([{ names: ["aidd-test"], outDir: STUB_OUT, sourceDir: "/src" }]); }); }); + +describe("the Rules a flat layout writes", () => { + const RULES_SRC = `${FIXTURE_DIR}/plugins/${PLUGIN}/rules/01-standards`; + + function fullBuild( + memFs: InMemoryFileAdapter, + contract: ToolBuildContract, + target: "kilo" | "opencode", + logger = new CapturingLogger() + ) { + const ap = makeAssetProvider(); + const strategy = new FlatBuildStrategy( + memFs, + new AjvSchemaValidatorAdapter(), + ap, + contract, + true, + ABS_OUT, + makeIsDirectory(memFs), + logger + ); + const uc = new FrameworkBuildUseCase(memFs, makeValidator(), ap, logger, strategy); + return uc.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target }); + } + + // The fixture's own Rule is path-scoped; these replace it with two all-files Rules. + async function seedAllFilesRules(): Promise { + const memFs = await makeSeededFs(); + memFs.setFile(`${RULES_SRC}/naming.md`, "# Naming\nSee [errors](./errors.md).\n"); + memFs.setFile(`${RULES_SRC}/errors.md`, "# Errors\n"); + return memFs; + } + + it("emits an all-files Rule under .kilo/rules whose sibling link still resolves", async () => { + const memFs = await seedAllFilesRules(); + const result = await fullBuild(memFs, buildKiloFlatContract(), "kilo"); + + const rulePath = `${ABS_OUT}/.kilo/rules/aidd-test-01-standards-naming.md`; + const emitted = memFs.getFile(rulePath) ?? ""; + const link = /\]\(([^)]+)\)/.exec(emitted)?.[1] ?? ""; + expect(link).toBe("./aidd-test-01-standards-errors.md"); + expect(memFs.has(posix.join(dirname(rulePath).replaceAll("\\", "/"), link))).toBe(true); + expect(result.plugins[0]?.skippedSections).toEqual(["commands"]); + const config = JSON.parse(memFs.getFile(`${ABS_OUT}/.kilo/kilo.jsonc`) ?? "{}") as { + instructions: string[]; + }; + expect(config.instructions).toEqual([ + ".kilo/rules/aidd-test-01-standards-errors.md", + ".kilo/rules/aidd-test-01-standards-naming.md", + ]); + }); + + it("rebuilds with --force without duplicating Rule files or instruction entries", async () => { + const memFs = await seedAllFilesRules(); + await fullBuild(memFs, buildKiloFlatContract(), "kilo"); + const config = memFs.getFile(`${ABS_OUT}/.kilo/kilo.jsonc`); + const rules = memFs.listUnder(`${ABS_OUT}/.kilo/rules`); + await fullBuild(memFs, buildKiloFlatContract(), "kilo"); + expect(memFs.getFile(`${ABS_OUT}/.kilo/kilo.jsonc`)).toBe(config); + expect(rules).toHaveLength(2); + expect(memFs.listUnder(`${ABS_OUT}/.kilo/rules`)).toEqual(rules); + }); + + it("keeps warning and skipping rules/ for a flat target that hosts no Rule", async () => { + const memFs = await makeSeededFs(); + const logger = new CapturingLogger(); + const result = await fullBuild(memFs, buildOpencodeFlatContract(), "opencode", logger); + expect(result.plugins[0]?.skippedSections).toContain("rules"); + expect(logger.warnMessages.some((m) => m.includes("Skipping rules/"))).toBe(true); + }); + + it("writes two plugins' same-named Rules as two distinct files", async () => { + const memFs = new InMemoryFileAdapter({ + "/src/plugins/aidd-a/rules/style.md": "a", + "/src/plugins/aidd-b/rules/style.md": "b", + }); + const strategy = stubStrategy(memFs, buildKiloFlatContract()); + await strategy.writeRules("aidd-a", "/src/plugins/aidd-a"); + await strategy.writeRules("aidd-b", "/src/plugins/aidd-b"); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({ + [`${STUB_OUT}/.kilo/rules/aidd-a-style.md`]: "a", + [`${STUB_OUT}/.kilo/rules/aidd-b-style.md`]: "b", + }); + }); + + it("fails on a path-scoped Rule and emits none of the plugin's Rules", async () => { + const memFs = new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/rules/all.md`]: "everywhere", + [`${STUB_PLUGIN_SRC}/rules/ts.md`]: "---\npaths:\n - src/**/*.ts\n---\nTyped.\n", + }); + await expect( + stubStrategy(memFs, buildKiloFlatContract()).writeRules("aidd-test", STUB_PLUGIN_SRC) + ).rejects.toThrow( + new PathScopedRuleUnsupportedError("aidd-test", "rules/ts.md", ["src/**/*.ts"]) + ); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({}); + }); + + it("relocates a Rule's sibling link for a layout declaring no Rule transform", async () => { + const memFs = new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/rules/g/a.md`]: "See [b](./b.md).", + [`${STUB_PLUGIN_SRC}/rules/g/b.md`]: "B", + }); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + rules: supportedArtifact( + (plugin, rel) => `.stub/rules/${plugin}-${rel.slice(6).replaceAll("/", "-")}` + ), + }, + }); + expect(await stubStrategy(memFs, contract).writeRules("aidd-test", STUB_PLUGIN_SRC)).toBe(2); + expect(memFs.getFile(`${STUB_OUT}/.stub/rules/aidd-test-g-a.md`)).toBe( + "See [b](./aidd-test-g-b.md)." + ); + }); + + it("refuses to overwrite an existing Rule file without --force", async () => { + const memFs = new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/rules/style.md`]: "new", + [`${STUB_OUT}/.kilo/rules/aidd-test-style.md`]: "user-owned", + }); + await expect( + stubStrategy(memFs, buildKiloFlatContract()).writeRules("aidd-test", STUB_PLUGIN_SRC) + ).rejects.toThrow(FlatTargetExistsError); + expect(memFs.getFile(`${STUB_OUT}/.kilo/rules/aidd-test-style.md`)).toBe("user-owned"); + }); + + it("refuses two Rules flattening to one filename", async () => { + const memFs = new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/rules/a/b-c.md`]: "one", + [`${STUB_PLUGIN_SRC}/rules/a-b/c.md`]: "two", + }); + await expect( + stubStrategy(memFs, buildKiloFlatContract()).writeRules("aidd-test", STUB_PLUGIN_SRC) + ).rejects.toThrow(FlatTargetExistsError); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({}); + }); +}); diff --git a/cli/tests/e2e/framework-build.e2e.test.ts b/cli/tests/e2e/framework-build.e2e.test.ts index 46129a745..94fcf820c 100644 --- a/cli/tests/e2e/framework-build.e2e.test.ts +++ b/cli/tests/e2e/framework-build.e2e.test.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestEnv, FRAMEWORK_PATH, initProject, runCli } from "./helpers.js"; @@ -593,8 +593,55 @@ describe.concurrent("E2E: aidd translate", () => { } }); - it("--target kilo --flat emits its agents, skills, generated hooks bridge and project config", async () => { + it("--target kilo --flat emits its agents, skills, Rules, hooks bridge and project config", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-flat-kilo"); + try { + // The canonical fixture Rule is path-scoped; this copy turns it into an all-files Rule. + const source = join(tempDir, "source"); + await cp(FRAMEWORK_PATH, source, { recursive: true }); + const rulesDir = join(source, "plugins", "aidd-test", "rules", "01-standards"); + await writeFile(join(rulesDir, "naming.md"), "# Naming\nSee [errors](./errors.md).\n"); + await writeFile(join(rulesDir, "errors.md"), "# Errors\n"); + const projRoot = join(tempDir, "proj"); + await mkdir(join(projRoot, ".kilo"), { recursive: true }); + const configPath = join(projRoot, ".kilo", "kilo.jsonc"); + await writeFile(configPath, '{\n // user-owned\n "instructions": ["z.md", "a.md"],\n}'); + const args = ["translate", source, "--to", "kilo", "--as", "flat", "--out", projRoot]; + + const result = await runCli(args, projectDir, fakeHome); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).not.toContain("Skipping rules/"); + expect(existsSync(join(projRoot, ".kilo", "agents"))).toBe(true); + expect(existsSync(join(projRoot, ".kilo", "skills"))).toBe(true); + expect(existsSync(join(projRoot, ".kilo", "plugin", "aidd-test-hooks.js"))).toBe(true); + const rulePath = join(projRoot, ".kilo", "rules", "aidd-test-01-standards-naming.md"); + const link = /\]\(([^)]+)\)/.exec(await readFile(rulePath, "utf-8"))?.[1] ?? ""; + expect(existsSync(join(dirname(rulePath), link)), link).toBe(true); + const once = await readFile(configPath, "utf-8"); + const kilo = JSON.parse(once) as Record; + expect(kilo.$schema).toBe("https://app.kilo.ai/config.json"); + expect(kilo.instructions).toEqual([ + "z.md", + "a.md", + ".kilo/rules/aidd-test-01-standards-errors.md", + ".kilo/rules/aidd-test-01-standards-naming.md", + ]); + expect(kilo.mcp).toBeDefined(); + + const rebuilt = await runCli([...args, "--force"], projectDir, fakeHome); + expect(rebuilt.exitCode, rebuilt.stderr).toBe(0); + expect(await readFile(configPath, "utf-8")).toBe(once); + expect((await readdir(join(projRoot, ".kilo", "rules"))).sort()).toEqual([ + "aidd-test-01-standards-errors.md", + "aidd-test-01-standards-naming.md", + ]); + } finally { + await cleanup(); + } + }); + + it("--target kilo --flat fails on a path-scoped Rule, naming it and its scope", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-flat-kilo-scoped"); try { const projRoot = join(tempDir, "proj"); await mkdir(projRoot, { recursive: true }); @@ -603,16 +650,11 @@ describe.concurrent("E2E: aidd translate", () => { projectDir, fakeHome ); - expect(result.exitCode).toBe(0); - expect(existsSync(join(projRoot, ".kilo", "agents"))).toBe(true); - expect(existsSync(join(projRoot, ".kilo", "skills"))).toBe(true); - expect(existsSync(join(projRoot, ".kilo", "plugin", "aidd-test-hooks.js"))).toBe(true); - const kilo = JSON.parse( - await readFile(join(projRoot, ".kilo", "kilo.jsonc"), "utf-8") - ) as Record; - expect(kilo.$schema).toBe("https://app.kilo.ai/config.json"); - expect(kilo.instructions).toBeUndefined(); - expect(kilo.mcp).toBeDefined(); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("rules/01-standards/naming.md"); + expect(result.stderr).toContain("src/**/*.ts"); + expect(existsSync(join(projRoot, ".kilo", "rules"))).toBe(false); + expect(existsSync(join(projRoot, ".kilo", "kilo.jsonc"))).toBe(false); } finally { await cleanup(); } diff --git a/cli/tests/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts index 22b4ecea6..8f325180c 100644 --- a/cli/tests/e2e/kilo-runtime.e2e.test.ts +++ b/cli/tests/e2e/kilo-runtime.e2e.test.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { delimiter, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; @@ -285,17 +285,17 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { const mcpProject = join(tempDir, "generated-mcp"); await mkdir(mcpProject, { recursive: true }); + // Kilo refuses the fixture's path-scoped Rule; this copy keeps it all-files. + const mcpSource = join(tempDir, "mcp-source"); + await cp(join(REPOSITORY_ROOT, "cli/tests/fixtures/framework"), mcpSource, { + recursive: true, + }); + await writeFile( + join(mcpSource, "plugins", "aidd-test", "rules", "01-standards", "naming.md"), + "# Naming\n" + ); const mcpBuild = await runCli( - [ - "translate", - join(REPOSITORY_ROOT, "cli/tests/fixtures/framework"), - "--to", - "kilo", - "--as", - "flat", - "--out", - mcpProject, - ], + ["translate", mcpSource, "--to", "kilo", "--as", "flat", "--out", mcpProject], projectDir, fakeHome ); @@ -355,6 +355,8 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { join(proofPlugin, "hooks", "capture.js"), `import { appendFileSync } from "node:fs"; import { join } from "node:path"; let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { const payload = JSON.parse(input); appendFileSync(join(payload.cwd, "payloads.jsonl"), JSON.stringify({ pid: process.pid, payload }) + "\\n"); });` ); + await mkdir(join(proofPlugin, "rules")); + await writeFile(join(proofPlugin, "rules", "runtime.md"), "KILO_RULE_MARKER_868\n"); await writeFile( join(proofPlugin, "hooks", "hooks.json"), JSON.stringify({ @@ -407,6 +409,10 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { } ); expect(run.stdout).toContain("KILO_LOCAL_READ_CONFIRMED"); + expect(JSON.parse(deliveredConfig).instructions).toContain( + ".kilo/rules/runtime-proof-runtime.md" + ); + expect(JSON.stringify(model.requests)).toContain("KILO_RULE_MARKER_868"); expect(model.requests.flatMap((request) => request.messages)).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/cli/tests/kernel/materialization/flat-paths.unit.test.ts b/cli/tests/kernel/materialization/flat-paths.unit.test.ts index 51cf170e3..f1c7b89e5 100644 --- a/cli/tests/kernel/materialization/flat-paths.unit.test.ts +++ b/cli/tests/kernel/materialization/flat-paths.unit.test.ts @@ -5,6 +5,7 @@ import { genericFlatAgentPath, genericFlatHooksFile, genericFlatHooksScriptPath, + genericFlatRulePath, genericFlatSkillPath, genericFlatSkillTreePath, } from "../../../src/kernel/materialization/flat-paths.js"; @@ -172,3 +173,17 @@ describe("flatMcpKeyPrefix", () => { expect(flatMcpKeyPrefix("my-awesome-plugin")).toBe("my-awesome-plugin-"); }); }); + +describe("genericFlatRulePath", () => { + it("folds the Rule's group folders into one plugin-prefixed filename", () => { + expect(genericFlatRulePath(".kilo/rules/", "aidd-test", "rules/01-standards/naming.md")).toBe( + ".kilo/rules/aidd-test-01-standards-naming.md" + ); + }); + + it("keeps a top-level Rule's own name", () => { + expect(genericFlatRulePath(".kilo/rules/", "aidd-dev", "rules/style.md")).toBe( + ".kilo/rules/aidd-dev-style.md" + ); + }); +}); diff --git a/cli/tests/kernel/materialization/relative-link-rewrite.unit.test.ts b/cli/tests/kernel/materialization/relative-link-rewrite.unit.test.ts index d9db38c0e..dde7cbbca 100644 --- a/cli/tests/kernel/materialization/relative-link-rewrite.unit.test.ts +++ b/cli/tests/kernel/materialization/relative-link-rewrite.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { rewriteRelativeLinks } from "../../../src/kernel/materialization/relative-link-rewrite.js"; +import { + relocateRelativeLinks, + rewriteRelativeLinks, +} from "../../../src/kernel/materialization/relative-link-rewrite.js"; // One options object serves them all: the third branch is not reached by @./ or @../. const STABLE_OPTS = { currentFilePluginRelative: "skills/foo/SKILL.md" }; @@ -144,3 +147,46 @@ describe("rewriteRelativeLinks", () => { }); }); }); + +describe("relocateRelativeLinks", () => { + const options = { + sourceFilePluginRelative: "rules/01-standards/naming.md", + materializedFileRelative: ".kilo/rules/aidd-test-01-standards-naming.md", + resolveTargetPath: (rel: string): string | null => { + if (rel.startsWith("rules/")) + return `.kilo/rules/aidd-test-${rel.slice(6).replaceAll("/", "-")}`; + if (rel.startsWith("skills/")) return `.kilo/skills/aidd-test/${rel.slice(7)}`; + return null; + }, + }; + + it("repoints a markdown link at the sibling's materialized location, fragment kept", () => { + expect(relocateRelativeLinks("See [errors](./errors.md#codes).", options)).toBe( + "See [errors](./aidd-test-01-standards-errors.md#codes)." + ); + }); + + it("repoints a link climbing out of the Rule tree into another artifact", () => { + expect(relocateRelativeLinks("Use [plan](../../skills/01-plan/SKILL.md).", options)).toBe( + "Use [plan](../skills/aidd-test/01-plan/SKILL.md)." + ); + }); + + it("turns an @./ reference into a link to the materialized target", () => { + expect(relocateRelativeLinks("Read @./errors.md first.", options)).toBe( + "Read [errors.md](./aidd-test-01-standards-errors.md) first." + ); + }); + + it("repoints a plugin-root reference from the materialized location", () => { + expect(relocateRelativeLinks(`See @${CLAUDE_ROOT}/rules/style.md now`, options)).toBe( + "See [style.md](./aidd-test-style.md) now" + ); + }); + + it("leaves external, absolute, anchor-only and unmaterialized targets alone", () => { + const content = + "[a](https://x.dev/a.md) [b](/abs.md) [c](#top) [d](../../docs/x.md) [e](../../../out.md)"; + expect(relocateRelativeLinks(content, options)).toBe(content); + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a8976344d..29c89c5b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -121,7 +121,7 @@ Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on ` | Surface | Contract | Reference | | --- | --- | --- | -| CLI output | Supported target capabilities only; `aidd translate` warns and skips rules and commands. | [Output layouts](../cli/README.md#translate) | +| CLI output | Supported target capabilities only; `aidd translate` warns and skips commands, and rules on every target but Kilo flat, which emits all-files Rules and refuses path-scoped ones. | [Output layouts](../cli/README.md#translate) | | Skills | Links stay inside the skill directory. Flat distribution renames skills `-`; marketplace installation preserves the tree. | [Portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) | | Bundled scripts | Named plugin-relative in backticks, never linked. | [Portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) | | Hook adapters | CLI owns OpenCode's shared host protocol; plugins own payload mapping. | [Delivery and compatibility](../cli/ARCHITECTURE.md#hook-adaptation) | From 0c754a2c0872bdb481328834434939c42a462f0e Mon Sep 17 00:00:00 2001 From: Black Sun <18490995+BlandineRdl@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:52:06 +0200 Subject: [PATCH 2/4] fix(cli): name both Rules on a flat Rule name clash Two Rules flattening to one Kilo filename raised FlatTargetExistsError, whose --force hint cannot resolve the clash. A dedicated error now names both source Rules and the shared destination. The README states that a Rule's links to files the build does not emit stay unchanged. Refs #868 Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 2 +- .../strategies/flat-build-strategy.ts | 14 ++++++--- cli/src/kernel/errors.ts | 10 +++++++ .../flat-build-strategy.integration.test.ts | 30 +++++++++++++++++-- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/cli/README.md b/cli/README.md index a9718c5df..1b1b20586 100644 --- a/cli/README.md +++ b/cli/README.md @@ -142,7 +142,7 @@ Two output layouts, chosen by `--as`: OpenCode and Kilo Code declare no marketplace contract, so they are flat only. Every other target accepts both layouts. -A Kilo flat build also writes each plugin Rule to `.kilo/rules/` and appends it to the Kilo config's `instructions`, after any entries already there. Kilo loads those for every file, so a Rule scoped by `paths` fails the build. Every other target skips `rules/` with a warning. +A Kilo flat build also writes each plugin Rule to `.kilo/rules/` and appends it to the Kilo config's `instructions`, after any entries already there. Kilo loads those for every file, so a Rule scoped by `paths` fails the build. Every other target skips `rules/` with a warning. A Rule's links to files the build does not emit, such as non-`.md` siblings or plugin files outside `agents/`, `skills/` and `rules/`, are left unchanged. ## Environment variables diff --git a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts index 5d0352874..ecb34bc10 100644 --- a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts @@ -1,5 +1,9 @@ import { basename, dirname, join, relative } from "node:path"; -import { FlatTargetExistsError, OutDirNotDirectoryError } from "../../../../kernel/errors.js"; +import { + FlatRuleNameClashError, + FlatTargetExistsError, + OutDirNotDirectoryError, +} from "../../../../kernel/errors.js"; import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; import { rewriteClaudeRootInJson } from "../../../../kernel/materialization/claude-root-path-rewrite.js"; import { flatMcpKeyPrefix } from "../../../../kernel/materialization/flat-paths.js"; @@ -23,7 +27,7 @@ import type { BuildOutputStrategy, SourceMarketplace } from "./build-output-stra export class FlatBuildStrategy implements BuildOutputStrategy { private sourceDir = ""; - private readonly ruleDestinations = new Set(); + private readonly ruleDestinations = new Map(); constructor( private readonly fs: FileReader & FileWriter, @@ -167,8 +171,10 @@ export class FlatBuildStrategy implements BuildOutputStrategy { const flatRelPath = artifact.path(pluginName, srcRelPath); const destPath = join(this.absOut, flatRelPath); // Two Rules flattening to one name would overwrite each other even under --force. - if (this.ruleDestinations.has(destPath)) throw new FlatTargetExistsError(destPath, pluginName); - this.ruleDestinations.add(destPath); + const ruleId = `${pluginName}/${srcRelPath}`; + const clashing = this.ruleDestinations.get(destPath); + if (clashing !== undefined) throw new FlatRuleNameClashError(clashing, ruleId, flatRelPath); + this.ruleDestinations.set(destPath, ruleId); await this.checkCollision(destPath, pluginName); const outContent = artifact.transform ? artifact.transform(content, pluginName, ruleRel) diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts index 363faa2fa..feb23244f 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -436,6 +436,16 @@ export class PathScopedRuleUnsupportedError extends Error { } } +export class FlatRuleNameClashError extends Error { + constructor(first: string, second: string, destination: string) { + super( + `Flat build conflict: Rules '${first}' and '${second}' both flatten to '${destination}'. ` + + "Rename one of them." + ); + this.name = "FlatRuleNameClashError"; + } +} + export class MarketplaceOutDirNotEmptyError extends Error { constructor(outDir: string) { super( diff --git a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts index 052f1a1e6..83713dd26 100644 --- a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts @@ -12,6 +12,7 @@ import { FlatBuildStrategy } from "../../../../../src/contexts/translate/applica import { FrameworkBuildUseCase } from "../../../../../src/contexts/translate/application/translate-source.js"; import { AjvSchemaValidatorAdapter } from "../../../../../src/contexts/translate/infrastructure/schema-validator.js"; import { + FlatRuleNameClashError, FlatTargetExistsError, FrameworkPlaceholderInPluginError, JsonSchemaValidationError, @@ -904,9 +905,32 @@ describe("the Rules a flat layout writes", () => { [`${STUB_PLUGIN_SRC}/rules/a/b-c.md`]: "one", [`${STUB_PLUGIN_SRC}/rules/a-b/c.md`]: "two", }); - await expect( - stubStrategy(memFs, buildKiloFlatContract()).writeRules("aidd-test", STUB_PLUGIN_SRC) - ).rejects.toThrow(FlatTargetExistsError); + const error = await stubStrategy(memFs, buildKiloFlatContract()) + .writeRules("aidd-test", STUB_PLUGIN_SRC) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(FlatRuleNameClashError); + const message = (error as Error).message; + expect(message).toContain("aidd-test/rules/a/b-c.md"); + expect(message).toContain("aidd-test/rules/a-b/c.md"); + expect(message).toContain(".kilo/rules/aidd-test-a-b-c.md"); + expect(message).not.toContain("--force"); expect(writtenUnder(memFs, STUB_OUT)).toEqual({}); }); + + it("names both plugins' Rules when they flatten to one filename", async () => { + const memFs = new InMemoryFileAdapter({ + "/src/plugins/aidd/rules/x-y.md": "one", + "/src/plugins/aidd-x/rules/y.md": "two", + }); + const strategy = stubStrategy(memFs, buildKiloFlatContract()); + await strategy.writeRules("aidd", "/src/plugins/aidd"); + await expect(strategy.writeRules("aidd-x", "/src/plugins/aidd-x")).rejects.toThrow( + new FlatRuleNameClashError( + "aidd/rules/x-y.md", + "aidd-x/rules/y.md", + ".kilo/rules/aidd-x-y.md" + ) + ); + expect(memFs.getFile(`${STUB_OUT}/.kilo/rules/aidd-x-y.md`)).toBe("one"); + }); }); From 1db6907aa2893887bb9dbc1f6e41e7d4d5598e81 Mon Sep 17 00:00:00 2001 From: Black Sun <18490995+BlandineRdl@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:02:31 +0200 Subject: [PATCH 3/4] fix(cli): skip path-scoped rules with a warning on kilo install Plugin install, setup and remove for Kilo build their cache tree through the flat strategy, so a Rule scoped by `paths` started failing them where `rules/` used to be skipped. The flat strategy now takes the caller's policy for a Rule the target cannot scope: `aidd translate` still fails the build, while the install cache build skips the Rule, names it with its paths in a warning on the real logger, and installs the rest. The Kilo contract declares the scope check instead of throwing from its transform, and `instructions` never lists a skipped Rule. The runtime e2e marker loses its tracker id, and the bundle budget entry drops the issue number. Refs #868 Co-Authored-By: Claude Opus 5.5 --- cli/README.md | 2 +- cli/scripts/check-bundle-size.mjs | 4 +- .../contexts/tools/domain/build-contract.ts | 2 + .../tools/domain/profiles/kilo/build.ts | 11 ++-- .../strategies/flat-build-strategy.ts | 25 ++++++- cli/src/runtime/wiring/framework.ts | 3 +- cli/src/runtime/wiring/translate.ts | 10 ++- .../orchestrator-deps.arch.test.ts | 4 +- .../domain/profiles/kilo/build.unit.test.ts | 32 ++++++--- .../flat-build-strategy.integration.test.ts | 65 +++++++++++++++++-- cli/tests/e2e/kilo-runtime.e2e.test.ts | 4 +- .../framework-build-force.integration.test.ts | 52 ++++++++++++++- docs/ARCHITECTURE.md | 2 +- 13 files changed, 182 insertions(+), 34 deletions(-) diff --git a/cli/README.md b/cli/README.md index 1b1b20586..0807b9e65 100644 --- a/cli/README.md +++ b/cli/README.md @@ -142,7 +142,7 @@ Two output layouts, chosen by `--as`: OpenCode and Kilo Code declare no marketplace contract, so they are flat only. Every other target accepts both layouts. -A Kilo flat build also writes each plugin Rule to `.kilo/rules/` and appends it to the Kilo config's `instructions`, after any entries already there. Kilo loads those for every file, so a Rule scoped by `paths` fails the build. Every other target skips `rules/` with a warning. A Rule's links to files the build does not emit, such as non-`.md` siblings or plugin files outside `agents/`, `skills/` and `rules/`, are left unchanged. +A Kilo flat build also writes each plugin Rule to `.kilo/rules/` and appends it to the Kilo config's `instructions`, after any entries already there. Kilo loads those for every file, so a Rule scoped by `paths` fails the build. A Kilo plugin install skips such a Rule with a warning and installs the rest. Every other target skips `rules/` with a warning. A Rule's links to files the build does not emit, such as non-`.md` siblings or plugin files outside `agents/`, `skills/` and `rules/`, are left unchanged. ## Environment variables diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index efba89fbd..109d069f3 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -46,8 +46,8 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 709 KB: 695.3 KB, telemetry keeping what an ingest measured and could not store, ledger lines // no reader knows, and a bounded re-read of a call whose consent cannot be read yet (#1046, // +1.3 KB over next's 694.0 KB, after 1.7 KB of savings in telemetry code); 2.0% measured headroom. -// 725 KB: 710.8 KB, Kilo flat Rules: emission, link relocation and `instructions` merge (#868, -// +4.8 KB over next's 706.0 KB); 2.0% measured headroom. +// 725 KB: 711.7 KB, Kilo flat Rules: emission, link relocation, `instructions` merge, install skip +// (+5.7 KB over next's 706.0 KB); 1.9% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/src/contexts/tools/domain/build-contract.ts b/cli/src/contexts/tools/domain/build-contract.ts index 799c6c337..9e1318a28 100644 --- a/cli/src/contexts/tools/domain/build-contract.ts +++ b/cli/src/contexts/tools/domain/build-contract.ts @@ -21,6 +21,8 @@ export type ArtifactContract = readonly ext?: string; /** Defaults to identity, a byte-copy. */ readonly transform?: (content: string, plugin: string, basename: string) => string; + /** The scope a file declares that this target cannot express, or `undefined` when it has none. */ + readonly inexpressibleScope?: (content: string) => unknown; /** VS Code Copilot discovers a skill by its parent folder name, so flat mode rewrites * SKILL.md's `name` frontmatter to match. Meaningless outside a flat skill artifact. */ readonly rewriteSkillName?: boolean; diff --git a/cli/src/contexts/tools/domain/profiles/kilo/build.ts b/cli/src/contexts/tools/domain/profiles/kilo/build.ts index 109a363c5..a65318a67 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/build.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/build.ts @@ -2,7 +2,6 @@ * but Kilo has its own .kilo/ discovery paths and bundled base configuration. */ import { join, relative } from "node:path"; -import { PathScopedRuleUnsupportedError } from "../../../../../kernel/errors.js"; import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; import { flatHooksPathWithLoaderEntry, @@ -83,12 +82,12 @@ function transformKiloFlatAgent(content: string, plugin: string, outName: string } // Kilo loads `instructions` for every session: a `paths` scope would silently widen to all files. +function kiloRulePathScope(content: string): unknown { + return parseFrontmatter(content).frontmatter.paths; +} + function transformKiloFlatRule(content: string, plugin: string, ruleRel: string): string { const sourceRel = `rules/${ruleRel}`; - const { frontmatter } = parseFrontmatter(content); - if (frontmatter.paths !== undefined) { - throw new PathScopedRuleUnsupportedError(plugin, sourceRel, frontmatter.paths); - } return relocateRelativeLinks(content, { sourceFilePluginRelative: sourceRel, materializedFileRelative: kiloFlatRulePath(plugin, sourceRel), @@ -107,6 +106,7 @@ async function collectKiloRuleInstructions( if (!(await fs.fileExists(rulesSrc))) continue; for (const absPath of await fs.listFilesRecursive(rulesSrc)) { if (!absPath.endsWith(".md")) continue; + if (kiloRulePathScope(await fs.readFile(absPath)) !== undefined) continue; const rel = relative(rulesSrc, absPath).replace(/\\/g, "/"); instructions.push(kiloFlatRulePath(plugin, `rules/${rel}`)); } @@ -167,6 +167,7 @@ export function buildKiloFlatContract(): ToolBuildContract { supported: true, source: { kind: "filteredTree", srcDir: "rules", inputExt: ".md" }, path: kiloFlatRulePath, + inexpressibleScope: kiloRulePathScope, transform: transformKiloFlatRule, }, commands: { supported: false }, diff --git a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts index ecb34bc10..0ab43b7d6 100644 --- a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts @@ -3,6 +3,7 @@ import { FlatRuleNameClashError, FlatTargetExistsError, OutDirNotDirectoryError, + PathScopedRuleUnsupportedError, } from "../../../../kernel/errors.js"; import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; import { rewriteClaudeRootInJson } from "../../../../kernel/materialization/claude-root-path-rewrite.js"; @@ -25,6 +26,11 @@ import { import { assertNoToolsPlaceholder } from "../shared-plugin-helpers.js"; import type { BuildOutputStrategy, SourceMarketplace } from "./build-output-strategy.js"; +/** A Rule scoped beyond what the target expresses fails the build, or is skipped with a warning. */ +export type InexpressibleRulePolicy = + | { readonly kind: "fail" } + | { readonly kind: "skip"; readonly warn: Logger }; + export class FlatBuildStrategy implements BuildOutputStrategy { private sourceDir = ""; private readonly ruleDestinations = new Map(); @@ -37,7 +43,8 @@ export class FlatBuildStrategy implements BuildOutputStrategy { private readonly force: boolean, private readonly absOut: string, private readonly isDirectory: (path: string) => Promise, - private readonly logger?: Logger + private readonly logger?: Logger, + private readonly inexpressibleRules: InexpressibleRulePolicy = { kind: "fail" } ) {} async preBuild(outDir: string, sourceDir: string): Promise { @@ -87,7 +94,8 @@ export class FlatBuildStrategy implements BuildOutputStrategy { // Every Rule is transformed before any is written: a rejected Rule leaves none behind. const outputs: { destPath: string; content: string }[] = []; for (const absPath of files) { - outputs.push(await this.prepareFlatRule(artifact, pluginName, absPath, rulesSrc)); + const output = await this.prepareFlatRule(artifact, pluginName, absPath, rulesSrc); + if (output !== null) outputs.push(output); } for (const { destPath, content } of outputs) await this.fs.writeFile(destPath, content); return outputs.length; @@ -163,11 +171,22 @@ export class FlatBuildStrategy implements BuildOutputStrategy { pluginName: string, absPath: string, rulesSrc: string - ): Promise<{ destPath: string; content: string }> { + ): Promise<{ destPath: string; content: string } | null> { const ruleRel = relative(rulesSrc, absPath).replace(/\\/g, "/"); const srcRelPath = `rules/${ruleRel}`; const content = await this.fs.readFile(absPath); assertNoToolsPlaceholder(content, pluginName, srcRelPath); + const scope = artifact.inexpressibleScope?.(content); + if (scope !== undefined) { + if (this.inexpressibleRules.kind === "fail") { + throw new PathScopedRuleUnsupportedError(pluginName, srcRelPath, scope); + } + this.inexpressibleRules.warn.warn( + `Skipping Rule '${srcRelPath}' in plugin '${pluginName}': scoped by paths ` + + `${JSON.stringify(scope)}, which this target cannot express.` + ); + return null; + } const flatRelPath = artifact.path(pluginName, srcRelPath); const destPath = join(this.absOut, flatRelPath); // Two Rules flattening to one name would overwrite each other even under --force. diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index 59ce0367e..8c985daa3 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -331,7 +331,8 @@ export async function createDeps( const frameworkBuildFor: FrameworkBuildFor = (target, mode, outDir) => createFrameworkBuildUseCase( { fs, assetProvider, logger: cacheBuildLogger }, - { target, mode, outDir, force: true } + // An install keeps the rest of a plugin: a Rule the tool cannot scope is skipped, said aloud. + { target, mode, outDir, force: true, inexpressibleRules: { kind: "skip", warn: logger } } ); const ensureBuiltMarketplaceUseCase = new EnsureBuiltMarketplaceUseCase( fs, diff --git a/cli/src/runtime/wiring/translate.ts b/cli/src/runtime/wiring/translate.ts index 5c7ee0c41..8e929b8a4 100644 --- a/cli/src/runtime/wiring/translate.ts +++ b/cli/src/runtime/wiring/translate.ts @@ -11,7 +11,10 @@ import "../../contexts/tools/domain/profiles/vscode/profile.js"; import type { ToolBuildContract } from "../../contexts/tools/domain/build-contract.js"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; import { buildContractFor } from "../../contexts/tools/domain/registry.js"; -import { FlatBuildStrategy } from "../../contexts/translate/application/strategies/flat-build-strategy.js"; +import { + FlatBuildStrategy, + type InexpressibleRulePolicy, +} from "../../contexts/translate/application/strategies/flat-build-strategy.js"; import { MarketplaceBuildStrategy } from "../../contexts/translate/application/strategies/marketplace-build-strategy.js"; import { FrameworkBuildUseCase } from "../../contexts/translate/application/translate-source.js"; import { frameworkBuildTargetModes } from "../../contexts/translate/domain/build-target.js"; @@ -32,6 +35,8 @@ export interface FrameworkBuildContext { readonly mode: string; readonly outDir: string; readonly force: boolean; + /** Absent fails the build, as `aidd translate` must. */ + readonly inexpressibleRules?: InexpressibleRulePolicy; } type FrameworkBuildFactory = ( @@ -88,7 +93,8 @@ function frameworkBuildFactoryFor( ctx.force, ctx.outDir, isDirectory, - d.logger + d.logger, + ctx.inexpressibleRules ) ); } diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 783d3405e..02fcab887 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -49,7 +49,9 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] injected: 10, }, { path: "src/contexts/framework/application/plugin/plugin-add-use-case.ts", injected: 9 }, - { path: "src/contexts/translate/application/strategies/flat-build-strategy.ts", injected: 8 }, + // Ports and caller-chosen values, no use case: the ninth is the caller's policy for a Rule the + // target cannot scope, which a translate fails on and an install skips with a warning. + { path: "src/contexts/translate/application/strategies/flat-build-strategy.ts", injected: 9 }, { path: "src/contexts/framework/application/doctor/doctor-registration-use-case.ts", injected: 7, diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts index 78b79f201..f3edcc745 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "vitest"; import type { ArtifactContract } from "../../../../../../src/contexts/tools/domain/build-contract.js"; import { buildKiloFlatContract } from "../../../../../../src/contexts/tools/domain/profiles/kilo/build.js"; -import { - KiloDualConfigError, - PathScopedRuleUnsupportedError, -} from "../../../../../../src/kernel/errors.js"; +import { KiloDualConfigError } from "../../../../../../src/kernel/errors.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; function supported(artifact: ArtifactContract): Extract { @@ -159,13 +156,28 @@ describe("Kilo flat Rules", () => { ); }); - it("refuses a path-scoped Rule, naming the Rule and its scope", () => { - const content = '---\npaths:\n - "src/**/*.ts"\n---\nTyped code only.\n'; - const transform = () => rules().transform?.(content, "aidd-test", "01-standards/ts.md"); - expect(transform).toThrow(PathScopedRuleUnsupportedError); - expect(transform).toThrow( - /'rules\/01-standards\/ts\.md' in plugin 'aidd-test'.*src\/\*\*\/\*\.ts/ + it("reports a path-scoped Rule's scope as one Kilo cannot express, and none for an all-files Rule", () => { + const scoped = '---\npaths:\n - "src/**/*.ts"\n---\nTyped code only.\n'; + expect(rules().inexpressibleScope?.(scoped)).toEqual(["src/**/*.ts"]); + expect(rules().inexpressibleScope?.("---\ndescription: x\n---\nAll.\n")).toBeUndefined(); + }); + + it("never lists a path-scoped Rule in instructions", async () => { + const fs = new InMemoryFileAdapter({ + "/source/plugins/aidd-test/rules/all.md": "all", + "/source/plugins/aidd-test/rules/ts.md": "---\npaths:\n - src/**/*.ts\n---\nTyped.\n", + }); + await buildKiloFlatContract().emitConfigArtifact?.( + ["aidd-test"], + "/out", + "/source", + fs, + validator, + asset ); + expect(JSON.parse(fs.getFile("/out/.kilo/kilo.jsonc") ?? "null").instructions).toEqual([ + ".kilo/rules/aidd-test-all.md", + ]); }); it("appends sorted Rule paths after the user's JSONC instructions, unchanged on rebuild", async () => { diff --git a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts index 83713dd26..37a4950ea 100644 --- a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts @@ -8,7 +8,10 @@ import type { JsonSchemaValidator } from "../../../../../src/contexts/tools/doma import { buildCopilotFlatContract } from "../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; import { buildKiloFlatContract } from "../../../../../src/contexts/tools/domain/profiles/kilo/build.js"; import { buildOpencodeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; -import { FlatBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/flat-build-strategy.js"; +import { + FlatBuildStrategy, + type InexpressibleRulePolicy, +} from "../../../../../src/contexts/translate/application/strategies/flat-build-strategy.js"; import { FrameworkBuildUseCase } from "../../../../../src/contexts/translate/application/translate-source.js"; import { AjvSchemaValidatorAdapter } from "../../../../../src/contexts/translate/infrastructure/schema-validator.js"; import { @@ -449,7 +452,8 @@ function stubContract(over: Partial = {}): ToolBuildContract function stubStrategy( memFs: InMemoryFileAdapter, contract: ToolBuildContract, - logger?: CapturingLogger + logger?: CapturingLogger, + inexpressibleRules?: InexpressibleRulePolicy ): FlatBuildStrategy { return new FlatBuildStrategy( memFs, @@ -459,7 +463,8 @@ function stubStrategy( false, STUB_OUT, makeIsDirectory(memFs), - logger + logger, + inexpressibleRules ); } @@ -780,7 +785,8 @@ describe("the Rules a flat layout writes", () => { memFs: InMemoryFileAdapter, contract: ToolBuildContract, target: "kilo" | "opencode", - logger = new CapturingLogger() + logger = new CapturingLogger(), + inexpressibleRules?: InexpressibleRulePolicy ) { const ap = makeAssetProvider(); const strategy = new FlatBuildStrategy( @@ -791,7 +797,8 @@ describe("the Rules a flat layout writes", () => { true, ABS_OUT, makeIsDirectory(memFs), - logger + logger, + inexpressibleRules ); const uc = new FrameworkBuildUseCase(memFs, makeValidator(), ap, logger, strategy); return uc.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target }); @@ -900,6 +907,54 @@ describe("the Rules a flat layout writes", () => { expect(memFs.getFile(`${STUB_OUT}/.kilo/rules/aidd-test-style.md`)).toBe("user-owned"); }); + it("skips and names a path-scoped Rule when the caller asks to skip it", async () => { + const memFs = new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/rules/all.md`]: "everywhere", + [`${STUB_PLUGIN_SRC}/rules/ts.md`]: "---\npaths:\n - src/**/*.ts\n---\nTyped.\n", + }); + const warnings = new CapturingLogger(); + const written = await stubStrategy(memFs, buildKiloFlatContract(), undefined, { + kind: "skip", + warn: warnings, + }).writeRules("aidd-test", STUB_PLUGIN_SRC); + expect(written).toBe(1); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({ + [`${STUB_OUT}/.kilo/rules/aidd-test-all.md`]: "everywhere", + }); + expect(warnings.warnMessages).toHaveLength(1); + expect(warnings.warnMessages[0]).toContain("'rules/ts.md'"); + expect(warnings.warnMessages[0]).toContain("'aidd-test'"); + expect(warnings.warnMessages[0]).toContain("src/**/*.ts"); + }); + + it("lists only the emitted Rules in instructions when path-scoped ones are skipped", async () => { + const memFs = await makeSeededFs(); + memFs.setFile(`${RULES_SRC}/errors.md`, "# Errors\n"); + const warnings = new CapturingLogger(); + await fullBuild(memFs, buildKiloFlatContract(), "kilo", new CapturingLogger(), { + kind: "skip", + warn: warnings, + }); + expect(memFs.listUnder(`${ABS_OUT}/.kilo/rules`)).toEqual([ + `${ABS_OUT.replaceAll("\\", "/")}/.kilo/rules/aidd-test-01-standards-errors.md`, + ]); + const config = JSON.parse(memFs.getFile(`${ABS_OUT}/.kilo/kilo.jsonc`) ?? "{}") as { + instructions: string[]; + }; + expect(config.instructions).toEqual([".kilo/rules/aidd-test-01-standards-errors.md"]); + expect(warnings.warnMessages.some((m) => m.includes("rules/01-standards/naming.md"))).toBe( + true + ); + }); + + it("still fails the build on a path-scoped Rule by default", async () => { + const memFs = await makeSeededFs(); + await expect(fullBuild(memFs, buildKiloFlatContract(), "kilo")).rejects.toThrow( + PathScopedRuleUnsupportedError + ); + expect(memFs.listUnder(`${ABS_OUT}/.kilo/rules`)).toEqual([]); + }); + it("refuses two Rules flattening to one filename", async () => { const memFs = new InMemoryFileAdapter({ [`${STUB_PLUGIN_SRC}/rules/a/b-c.md`]: "one", diff --git a/cli/tests/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts index 8f325180c..009b586a2 100644 --- a/cli/tests/e2e/kilo-runtime.e2e.test.ts +++ b/cli/tests/e2e/kilo-runtime.e2e.test.ts @@ -356,7 +356,7 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { `import { appendFileSync } from "node:fs"; import { join } from "node:path"; let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { const payload = JSON.parse(input); appendFileSync(join(payload.cwd, "payloads.jsonl"), JSON.stringify({ pid: process.pid, payload }) + "\\n"); });` ); await mkdir(join(proofPlugin, "rules")); - await writeFile(join(proofPlugin, "rules", "runtime.md"), "KILO_RULE_MARKER_868\n"); + await writeFile(join(proofPlugin, "rules", "runtime.md"), "KILO_RULE_MARKER\n"); await writeFile( join(proofPlugin, "hooks", "hooks.json"), JSON.stringify({ @@ -412,7 +412,7 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { expect(JSON.parse(deliveredConfig).instructions).toContain( ".kilo/rules/runtime-proof-runtime.md" ); - expect(JSON.stringify(model.requests)).toContain("KILO_RULE_MARKER_868"); + expect(JSON.stringify(model.requests)).toContain("KILO_RULE_MARKER"); expect(model.requests.flatMap((request) => request.messages)).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/cli/tests/runtime/wiring/framework-build-force.integration.test.ts b/cli/tests/runtime/wiring/framework-build-force.integration.test.ts index e55656f40..4d1c8bd08 100644 --- a/cli/tests/runtime/wiring/framework-build-force.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-build-force.integration.test.ts @@ -2,7 +2,10 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { FlatTargetExistsError } from "../../../src/kernel/errors.js"; +import { + FlatTargetExistsError, + PathScopedRuleUnsupportedError, +} from "../../../src/kernel/errors.js"; import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; import { createFrameworkBuildUseCase, @@ -74,3 +77,50 @@ describe("createFrameworkBuildUseCase threads the caller's --force through to th expect(written).toContain("aidd-test-code-reviewer"); }); }); + +describe("createFrameworkBuildUseCase threads the caller's policy for a Rule Kilo cannot scope", () => { + let outDir: string; + let fs: InMemoryFileAdapter; + + beforeEach(async () => { + outDir = await mkdtemp(join(tmpdir(), "aidd-rule-policy-wiring-")); + fs = new InMemoryFileAdapter(); + await seedFromDirectory(fs, FIXTURE_DIR, { useAbsolutePaths: true }); + fs.setFile(`${outDir}/.keep`, ""); + }); + + afterEach(async () => { + await rm(outDir, { recursive: true, force: true }); + }); + + it("fails on the fixture's path-scoped Rule when the caller names no policy", async () => { + const useCase = createFrameworkBuildUseCase(makeDeps(fs), { + target: "kilo", + mode: "flat", + outDir, + force: true, + }); + if (useCase === undefined) throw new Error("kilo:flat must be wired in the registry"); + await expect( + useCase.execute({ sourceDir: FIXTURE_DIR, outDir, target: "kilo" }) + ).rejects.toBeInstanceOf(PathScopedRuleUnsupportedError); + }); + + it("skips that Rule with a warning, and builds the rest, when the caller asks to skip", async () => { + const warnings = new CapturingLogger(); + const useCase = createFrameworkBuildUseCase(makeDeps(fs), { + target: "kilo", + mode: "flat", + outDir, + force: true, + inexpressibleRules: { kind: "skip", warn: warnings }, + }); + if (useCase === undefined) throw new Error("kilo:flat must be wired in the registry"); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir, target: "kilo" }); + expect(warnings.warnMessages.some((m) => m.includes("rules/01-standards/naming.md"))).toBe( + true + ); + expect(fs.listUnder(`${outDir}/.kilo/rules`)).toEqual([]); + expect(fs.listUnder(`${outDir}/.kilo/agents`).length).toBeGreaterThan(0); + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 29c89c5b1..7f2e75b8e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -121,7 +121,7 @@ Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on ` | Surface | Contract | Reference | | --- | --- | --- | -| CLI output | Supported target capabilities only; `aidd translate` warns and skips commands, and rules on every target but Kilo flat, which emits all-files Rules and refuses path-scoped ones. | [Output layouts](../cli/README.md#translate) | +| CLI output | Supported target capabilities only; `aidd translate` warns and skips commands, and rules on every target but Kilo flat, which emits all-files Rules and refuses path-scoped ones; a Kilo install skips those with a warning. | [Output layouts](../cli/README.md#translate) | | Skills | Links stay inside the skill directory. Flat distribution renames skills `-`; marketplace installation preserves the tree. | [Portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) | | Bundled scripts | Named plugin-relative in backticks, never linked. | [Portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) | | Hook adapters | CLI owns OpenCode's shared host protocol; plugins own payload mapping. | [Delivery and compatibility](../cli/ARCHITECTURE.md#hook-adaptation) | From fdfcf6edcaee1d2b364e0ac61c08ed46785673b5 Mon Sep 17 00:00:00 2001 From: Black Sun <18490995+BlandineRdl@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:20:33 +0200 Subject: [PATCH 4/4] refactor(cli): pass flat build options as one named object A positional `force` boolean reads as a bare true or false at every call site, and overwrite and the policy for an unscopable Rule are both the caller's choices for one build. One named options object carries them, so the strategy's dependency count stays at what the ratchet admits and the ratchet keeps meaning a new collaborator. Co-Authored-By: Claude Opus 5.5 --- .../strategies/flat-build-strategy.ts | 19 +++++++++++++--- cli/src/runtime/wiring/translate.ts | 5 ++--- .../orchestrator-deps.arch.test.ts | 4 +--- ...t-marketplace-use-case.integration.test.ts | 2 +- ...t-build-strategy.hooks.integration.test.ts | 6 ++--- .../flat-build-strategy.integration.test.ts | 22 +++++++++---------- 6 files changed, 33 insertions(+), 25 deletions(-) diff --git a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts index 0ab43b7d6..0b4c2a34c 100644 --- a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts @@ -31,6 +31,12 @@ export type InexpressibleRulePolicy = | { readonly kind: "fail" } | { readonly kind: "skip"; readonly warn: Logger }; +/** The caller's choices for one flat build: overwrite existing files, and treat an unscopable Rule. */ +export interface FlatBuildOptions { + readonly force: boolean; + readonly inexpressibleRules?: InexpressibleRulePolicy; +} + export class FlatBuildStrategy implements BuildOutputStrategy { private sourceDir = ""; private readonly ruleDestinations = new Map(); @@ -40,13 +46,20 @@ export class FlatBuildStrategy implements BuildOutputStrategy { private readonly jsonSchemaValidator: JsonSchemaValidator, private readonly assetProvider: AssetProvider, private readonly contract: ToolBuildContract, - private readonly force: boolean, + private readonly options: FlatBuildOptions, private readonly absOut: string, private readonly isDirectory: (path: string) => Promise, - private readonly logger?: Logger, - private readonly inexpressibleRules: InexpressibleRulePolicy = { kind: "fail" } + private readonly logger?: Logger ) {} + private get force(): boolean { + return this.options.force; + } + + private get inexpressibleRules(): InexpressibleRulePolicy { + return this.options.inexpressibleRules ?? { kind: "fail" }; + } + async preBuild(outDir: string, sourceDir: string): Promise { const exists = await this.fs.fileExists(outDir); if (!exists || !(await this.isDirectory(outDir))) throw new OutDirNotDirectoryError(outDir); diff --git a/cli/src/runtime/wiring/translate.ts b/cli/src/runtime/wiring/translate.ts index 8e929b8a4..0e079582c 100644 --- a/cli/src/runtime/wiring/translate.ts +++ b/cli/src/runtime/wiring/translate.ts @@ -90,11 +90,10 @@ function frameworkBuildFactoryFor( av, d.assetProvider, buildContract(), - ctx.force, + { force: ctx.force, inexpressibleRules: ctx.inexpressibleRules }, ctx.outDir, isDirectory, - d.logger, - ctx.inexpressibleRules + d.logger ) ); } diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index 02fcab887..783d3405e 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -49,9 +49,7 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] injected: 10, }, { path: "src/contexts/framework/application/plugin/plugin-add-use-case.ts", injected: 9 }, - // Ports and caller-chosen values, no use case: the ninth is the caller's policy for a Rule the - // target cannot scope, which a translate fails on and an install skips with a warning. - { path: "src/contexts/translate/application/strategies/flat-build-strategy.ts", injected: 9 }, + { path: "src/contexts/translate/application/strategies/flat-build-strategy.ts", injected: 8 }, { path: "src/contexts/framework/application/doctor/doctor-registration-use-case.ts", injected: 7, diff --git a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts index 55f6023ce..5132186f9 100644 --- a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts @@ -462,7 +462,7 @@ describe("force behavior at the cache-rebuild path", () => { validator, assetProvider, buildCopilotFlatContract(), - true, // force:true — mirrors deps.ts wiring for every *:flat target + { force: true }, // mirrors deps.ts wiring for every *:flat target outDir, makeIsDirectory(memFs), new CapturingLogger() diff --git a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.hooks.integration.test.ts b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.hooks.integration.test.ts index b1b55fe8a..478f31d42 100644 --- a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.hooks.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.hooks.integration.test.ts @@ -71,7 +71,7 @@ function makeStrategy( new AjvSchemaValidatorAdapter(), makeAssetProvider(), contractFn(), - false, + { force: false }, ABS_OUT, makeIsDirectory(memFs), logger @@ -188,7 +188,7 @@ describe("cursor flat hooks", () => { new AjvSchemaValidatorAdapter(), makeAssetProvider(), buildCursorFlatContract(), - false, + { force: false }, ABS_OUT, makeIsDirectory(memFs), logger @@ -318,7 +318,7 @@ describe("opencode flat hooks", () => { new AjvSchemaValidatorAdapter(), makeOpencodeAssetProvider(), buildOpencodeFlatContract(), - false, + { force: false }, ABS_OUT, makeIsDirectory(memFs), logger diff --git a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts index 37a4950ea..e89f265d0 100644 --- a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts @@ -114,7 +114,7 @@ function makeUseCase( av, ap, buildCopilotFlatContract(), - force, + { force }, ABS_OUT, makeIsDirectory(memFs) ); @@ -249,7 +249,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), ap, buildCopilotFlatContract(), - false, + { force: false }, "/nonexistent", makeIsDirectory(emptyFs) ); @@ -270,7 +270,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), ap2, buildCopilotFlatContract(), - false, + { force: false }, ABS_OUT, makeIsDirectory(fileFs) ); @@ -326,7 +326,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), makeAssetProvider(), buildCopilotFlatContract(), - false, + { force: false }, ABS_OUT, makeIsDirectory(memFs) ); @@ -345,7 +345,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), ap, buildOpencodeFlatContract(), - force, + { force }, ABS_OUT, makeIsDirectory(fs) ); @@ -395,7 +395,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), makeAssetProvider(), { ...base, artifacts: { ...base.artifacts, hooks: { supported: false } } }, - false, + { force: false }, ABS_OUT, makeIsDirectory(memFs), captLogger @@ -460,11 +460,10 @@ function stubStrategy( new AjvSchemaValidatorAdapter(), makeAssetProvider(), contract, - false, + { force: false, inexpressibleRules }, STUB_OUT, makeIsDirectory(memFs), - logger, - inexpressibleRules + logger ); } @@ -794,11 +793,10 @@ describe("the Rules a flat layout writes", () => { new AjvSchemaValidatorAdapter(), ap, contract, - true, + { force: true, inexpressibleRules }, ABS_OUT, makeIsDirectory(memFs), - logger, - inexpressibleRules + logger ); const uc = new FrameworkBuildUseCase(memFs, makeValidator(), ap, logger, strategy); return uc.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target });