Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-1.md
Original file line number Diff line number Diff line change
@@ -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. |
63 changes: 63 additions & 0 deletions aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/phase-2.md
Original file line number Diff line number Diff line change
@@ -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. |
37 changes: 37 additions & 0 deletions aidd_docs/tasks/2026_10/2026_10_20_kilo-flat-rules/plan.md
Original file line number Diff line number Diff line change
@@ -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/<group>/` 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/<plugin>-<rel path with / as ->.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. |
2 changes: 2 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions cli/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions cli/scripts/check-bundle-size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions cli/src/contexts/tools/domain/build-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
73 changes: 66 additions & 7 deletions cli/src/contexts/tools/domain/profiles/kilo/build.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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<string[]> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface BuildOutputStrategy {

writeSkills(pluginName: string, pluginSrc: string, outDir: string): Promise<number>;

writeRules(pluginName: string, pluginSrc: string, outDir: string): Promise<number>;

writeHooks(pluginName: string, pluginSrc: string, outDir: string): Promise<number>;

writeMcp(pluginName: string, pluginSrc: string, outDir: string): Promise<number>;
Expand Down
Loading
Loading