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..0807b9e65 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. 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 | 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..109d069f3 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: 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 1703cb4f4..a65318a67 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/build.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/build.ts @@ -1,15 +1,19 @@ /** 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 { 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 +44,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 +81,39 @@ 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}`; + 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; + if (kiloRulePathScope(await fs.readFile(absPath)) !== undefined) 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,13 @@ export function buildKiloFlatContract(): ToolBuildContract { skipIfSourceHas: KILO_PLUGIN_ENTRY_BASENAME, }, }, - rules: { supported: false }, + rules: { + supported: true, + source: { kind: "filteredTree", srcDir: "rules", inputExt: ".md" }, + path: kiloFlatRulePath, + inexpressibleScope: kiloRulePathScope, + transform: transformKiloFlatRule, + }, commands: { supported: false }, }, buildMarketplaceCatalog: null, @@ -127,8 +179,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..0b4c2a34c 100644 --- a/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts @@ -1,9 +1,17 @@ import { basename, dirname, join, relative } from "node:path"; -import { FlatTargetExistsError, OutDirNotDirectoryError } from "../../../../kernel/errors.js"; +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"; 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"; @@ -18,24 +26,45 @@ 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 }; + +/** 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(); constructor( private readonly fs: FileReader & FileWriter, 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 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); this.sourceDir = sourceDir; + this.ruleDestinations.clear(); } async writePluginManifest(): Promise { @@ -69,6 +98,22 @@ 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) { + 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; + } + async writeHooks(pluginName: string, pluginSrc: string): Promise { const artifact = this.contract.artifacts.hooks; if (!artifact.supported) { @@ -134,6 +179,45 @@ export class FlatBuildStrategy implements BuildOutputStrategy { return 1; } + private async prepareFlatRule( + artifact: Extract, + pluginName: string, + absPath: string, + rulesSrc: 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. + 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) + : 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 +437,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..feb23244f 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -426,6 +426,26 @@ 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 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/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/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..0e079582c 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 = ( @@ -85,7 +90,7 @@ function frameworkBuildFactoryFor( av, d.assetProvider, buildContract(), - ctx.force, + { force: ctx.force, inexpressibleRules: ctx.inexpressibleRules }, ctx.outDir, isDirectory, d.logger 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/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/tools/domain/profiles/kilo/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/build.unit.test.ts index 4534e9652..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 @@ -132,3 +132,94 @@ 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("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 () => { + 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.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 b99cfab9d..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 @@ -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,15 +6,21 @@ 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 { + 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 { + FlatRuleNameClashError, FlatTargetExistsError, 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 +68,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", @@ -107,7 +114,7 @@ function makeUseCase( av, ap, buildCopilotFlatContract(), - force, + { force }, ABS_OUT, makeIsDirectory(memFs) ); @@ -242,7 +249,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), ap, buildCopilotFlatContract(), - false, + { force: false }, "/nonexistent", makeIsDirectory(emptyFs) ); @@ -263,7 +270,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), ap2, buildCopilotFlatContract(), - false, + { force: false }, ABS_OUT, makeIsDirectory(fileFs) ); @@ -319,7 +326,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), makeAssetProvider(), buildCopilotFlatContract(), - false, + { force: false }, ABS_OUT, makeIsDirectory(memFs) ); @@ -338,7 +345,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), ap, buildOpencodeFlatContract(), - force, + { force }, ABS_OUT, makeIsDirectory(fs) ); @@ -388,7 +395,7 @@ describe("FlatOutputStrategy integration", () => { new AjvSchemaValidatorAdapter(), makeAssetProvider(), { ...base, artifacts: { ...base.artifacts, hooks: { supported: false } } }, - false, + { force: false }, ABS_OUT, makeIsDirectory(memFs), captLogger @@ -445,14 +452,15 @@ function stubContract(over: Partial = {}): ToolBuildContract function stubStrategy( memFs: InMemoryFileAdapter, contract: ToolBuildContract, - logger?: CapturingLogger + logger?: CapturingLogger, + inexpressibleRules?: InexpressibleRulePolicy ): FlatBuildStrategy { return new FlatBuildStrategy( memFs, new AjvSchemaValidatorAdapter(), makeAssetProvider(), contract, - false, + { force: false, inexpressibleRules }, STUB_OUT, makeIsDirectory(memFs), logger @@ -768,3 +776,214 @@ 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(), + inexpressibleRules?: InexpressibleRulePolicy + ) { + const ap = makeAssetProvider(); + const strategy = new FlatBuildStrategy( + memFs, + new AjvSchemaValidatorAdapter(), + ap, + contract, + { force: true, inexpressibleRules }, + 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("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", + [`${STUB_PLUGIN_SRC}/rules/a-b/c.md`]: "two", + }); + 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"); + }); +}); 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..009b586a2 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\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"); 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/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 a8976344d..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 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; 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) |