diff --git a/agents/roadmaps/road-to-v1-0.md b/agents/roadmaps/road-to-v1-0.md index 1126dd1..7ff87a4 100644 --- a/agents/roadmaps/road-to-v1-0.md +++ b/agents/roadmaps/road-to-v1-0.md @@ -187,7 +187,7 @@ complexity: heavy > **Goal.** Plugin can connect to arbitrary MCP servers (per `.agent-settings.yml::mcp.servers`). All 136 agent-config commands are callable via slash-picker. Memories work (lokal + optional `@event4u/agent-memory` MCP backend). Hooks (sessionStart/End/Stop) respect agent-config hook system. - [x] **T-1101 — MCP Client.** Hand-rolled minimal MCP stdio client (ADR-006, not `@modelcontextprotocol/sdk`). Reads `.agent-settings.yml::mcp.servers[]`, spawns each as subprocess, manages JSON-RPC connection. Tool registry includes MCP tools with prefix `:`. -- [x] **T-1102 — agent-config MCP server consumption.** Connect to the agent-config-shipped MCP server (`npx @event4u/agent-config mcp`). Use its tools (`memory_lookup`, `chat_history_read`, `list_skills`, `skill_read`, `command_read`) instead of our own filesystem readers for skill/rule/command content. +- [x] **T-1102 — agent-config MCP server consumption.** Connect to the agent-config-shipped MCP server (`npx @event4u/agent-config mcp`). Use its tools (`memory_lookup`, `chat_history_read`, `list_skills`, `skill_read`, `command_read`) instead of our own filesystem readers for skill/rule/command content. - [~] **T-1103 — All 136 commands callable.** Slash-picker shows all commands from the agent-config tree (filtered by user-set favourites if Phase 0 Spike 0.5 recommended favourites). Each command's procedure is loaded via the MCP `command_read` tool at invocation time. - [x] **T-1104 — Memories — local.** `packages/core/src/memory/local.ts` reads/writes `.event4u-agent/memories/` in agent-config's md+frontmatter+MEMORY.md format (ADR-006, not JSON). Two memory types in MVP: `user` (long-lived) and `feedback` (workflow corrections). - [x] **T-1105 — Memories — MCP backend.** Optional: if `@event4u/agent-memory` MCP server configured, route memory_lookup/memory_write calls there. Local fallback if MCP server unreachable. diff --git a/clients/jetbrains/src/main/kotlin/de/event4u/agent/protocol/Protocol.kt b/clients/jetbrains/src/main/kotlin/de/event4u/agent/protocol/Protocol.kt index d9b8371..26bf1e8 100644 --- a/clients/jetbrains/src/main/kotlin/de/event4u/agent/protocol/Protocol.kt +++ b/clients/jetbrains/src/main/kotlin/de/event4u/agent/protocol/Protocol.kt @@ -370,6 +370,29 @@ data class CommandReadResponse( val body: String, ) +/** An agent-config artifact's metadata (kind=skill/rule/command), for the IDE registry/picker. */ +@Serializable +data class ConfigSummary( + val kind: String, + val name: String, + val description: String, + val path: String, +) + +/** List agent-config artifacts; absent kind lists all (skills, rules, commands). limit clamped by Core. */ +@Serializable +data class ConfigListRequest( + val kind: String? = null, + val limit: Int? = null, +) + +/** Artifact summaries grouped by kind then name; total is the match count before the cap. */ +@Serializable +data class ConfigListResponse( + val items: List, + val total: Int, +) + /** Start an agentic chat turn (LLM + tool loop). maxIterations caps the loop; omitted = Core default. */ @Serializable data class AgentTurnRequest( diff --git a/docs/adr/ADR-050-config-list-skills-rules-data-path.md b/docs/adr/ADR-050-config-list-skills-rules-data-path.md new file mode 100644 index 0000000..511b5d2 --- /dev/null +++ b/docs/adr/ADR-050-config-list-skills-rules-data-path.md @@ -0,0 +1,117 @@ +--- +adr: 050 +title: Agent-Config Registry Data Path — Wiring The Dead indexByKind As A configList Protocol Method For Skills + Rules (T-401) +status: Proposed (drafted 2026-06-02 — awaits user sign-off before flip to Accepted) +deciders: solo-dev (event4u team lead) — sign-off required +consulted: AI Council — gemini-cli 0.41.2 answered Q0–Q7 = A/B/A/B/A/A/A/A (2026-06-02); codex-cli 0.134.0 was invoked with the same question set but hung >7 min producing zero output and was terminated (a recurring codex-exec flakiness this repo has hit before), so the second opinion is gemini + the host agent's own seam-hunt + independent caller verification. The one reasoned divergence from gemini is Q6 (see Decision). +related: sibling of ADR-048 (command-palette data path) for the artifact kinds it does not surface — skills + rules. The MCP path (`AgentConfigMcpClient.listSkills`/`skillRead`, T-1102) is IDE-gated (MCP server lifecycle is IDE-native); this is the local-walker authority/offline sibling, mirroring how `commandList` relates to the MCP `command_read`. Reuses the `walkAgentConfig` collaborator already live for the rules loader (ADR-043) and the command palette (ADR-048). +date: 2026-06-02 +--- + +# ADR-050 — Agent-Config Registry Data Path (configList, T-401) + +## Status + +**Proposed** — awaits sign-off. One branch / one PR, committed in logical +chunks (protocol+codegen → core handler+wiring → tests → docs), preserving +minimal-safe-diff. + +CI-verified locally: `task ci` exit 0 (lint, format, build, typecheck, test — +protocol 55, shared 5, vscode 40, core 1132 pass / 1 skip). `task +jetbrains:check` BUILD SUCCESSFUL (the +3 Kotlin DTOs compile). **No checkbox +flip** — the skill picker / rules viewer render remains an IDE surface; this is +the headless data path those surfaces will call. + +## Context + +The pure-core "wire a dead seam" runway after ADR-049 looked thin, but a fresh +Explore seam-hunt + independent caller verification found one genuinely-dead, +non-artificial core export: + +`packages/core/src/config/agent-config-walker.ts::indexByKind` groups a flat +`ConfigNode[]` into `{skill[], rule[], command[]}`. It is fully unit-tested and +has **zero live callers** (only its own test imports it — verified by a +whole-repo grep excluding `*.test.ts`/`dist`). The Explore agent's other +candidates were rejected on cross-check: `phaseRunsInMode` (IDE-gated, +already a recorded reject), `sortAndDedup` (live at `review/pipeline.ts:308`), +`dismissalFor` (live inside `review/dismissals.ts`). + +The capability gap is real, not invented. `walkAgentConfig` discovers **all** +agent-config artifacts — skills, rules, and commands — but the only protocol +data path that ships (ADR-048's `CommandHandler`) filters the walked tree to +`kind === 'command'`. Skills and rules — also walked, also in the index — have +**no** protocol data path. The IDE cannot populate a skill picker or a rules +viewer. `indexByKind` is exactly the grouping helper that path needs. + +## Decision + +Wire `indexByKind` over one new read-only protocol method, `configList +{kind?, limit?}` → `{items: ConfigSummary[], total}`, on a **dedicated +`ConfigHandler`**. `ConfigSummary {kind, name, description, path}` mirrors +`CommandSummary` and adds `kind` so one flat list can carry skills, rules, and +commands. Absent `kind` → every artifact (skills, then rules, then commands, +alphabetical within each kind as the walker sorts); a `kind` → that kind only. +`total` is the count before a `MAX_CONFIG_LIST_RESULTS=100` clamp so a browser +can show "showing N of M". Description falls back frontmatter `description` → +first heading → `''`, and a non-empty frontmatter `name` overrides the slug — +both identical to the command picker's projection. + +Council resolution (gemini Q0–Q7 = A/B/A/B/A/A/A/A): + +- **Q0=A** — wire it (the dead seam fills a real gap). +- **Q1=B** — return all three kinds; the unified registry is one call, and + commands appearing here (without the slash-palette fuzzy ranking) is + harmless. The kind filter narrows to skills or rules. +- **Q2=A** — optional `kind` filter; absent returns all. +- **Q3=B** — flat `{items, total}` with a `kind` field per item, mirroring + `commandList` rather than a grouped object. +- **Q4=A** — `{kind, name, description, path}`; absolute path for IDE + click-through, no `relPath`/`sourceRoot` bloat (YAGNI). +- **Q5=A** — lightweight summary only; no frontmatter map or body on the wire + (a body-read method can follow if a surface needs it). +- **Q6 — reasoned divergence.** Gemini chose A (extend `CommandHandler`, + reusing its cached walk). The host agent chose **B (a dedicated + `ConfigHandler`)** for two reasons: (1) it preserves the codebase's + one-handler-per-domain shape (`ChatHandler`, `GitHandler`, `CommandHandler`, + `CostReporter`, `TerminalHandler`) — overloading the command handler with + skill/rule listing muddies its contract; (2) **correctness** — gemini's + cache-reuse benefit would require a sidecar-level shared walk promise, which + would cache a *transient* FS error for the whole session, defeating each + handler's fail-open-and-retry cache. Each handler keeps its own + `loadNodes: () => walkAgentConfig(cwd)`. The cost is one extra tree walk on + first use; config trees are small (the same premise behind Q7), so this is + negligible and buys the fail-open resilience. +- **Q7=A** — hard `MAX_CONFIG_LIST_RESULTS=100` clamp + carry `total`, + sibling-consistent with `commandList` / `conversationList`. + +## Consequences + +- **+3 DTOs** (`ConfigSummary`, `ConfigListRequest`, `ConfigListResponse`), + 64 → 67 in codegen; +1 protocol method (`configList`), behind + `requireConfig()` → coded `config_not_configured` when absent. +- The skill picker / rules viewer now have a headless, offline-capable data + path that does not depend on the agent-config MCP server being up — Core + stays the resolution authority, exactly as for `commandList`. +- No behaviour change to any existing path; `CommandHandler` is untouched. +- The IDE render (skill picker overlay, rules viewer) remains the last-mile + surface; this ADR does not flip any roadmap checkbox. + +## Alternatives considered + +- **Leave `indexByKind` dead (Q0=B).** Rejected — it fills a real gap and the + wiring is a thin, low-risk pure-core slice. +- **Extend `CommandHandler` (Q6=A).** Rejected for the contract-clarity + + fail-open-resilience reasons above. +- **Skills only (Q1=C).** Rejected — rules also lack a UI data path; a unified + registry with a kind filter covers both at no extra cost. +- **Include frontmatter/body in the summary (Q5=B/C).** Deferred (YAGNI) — a + separate read method can fetch a body when a surface needs it. + +## References + +- `packages/core/src/config/handler.ts` — the new `ConfigHandler`. +- `packages/core/src/config/agent-config-walker.ts::indexByKind` — the wired seam. +- ADR-048 — the command-palette sibling this mirrors. +- ADR-043 — the `walkAgentConfig` live collaborator reused here. +- `agents/roadmaps/road-to-v1-0.md` T-401 — the walker; T-1102 — the MCP + skill/rule path this complements with a local-walker authority path. diff --git a/docs/adr/index.md b/docs/adr/index.md index b0642c1..c19ad01 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -53,6 +53,7 @@ | [ADR-047](ADR-047-embedding-cache-persistence.md) | Embedding-Cache Persistence — Wiring The EmbeddingCache `store?` Seam To Disk So A Cold Start Skips Re-Embedding (T-805) | Proposed | 2026-06-02 | road-to-v1-0 T-805/T-806 — the sidecar re-embeds the whole repo on every startup; the shipped `EmbeddingCache` was in-memory (its `store?` ctor seam was never seeded from disk) and `VectorStore.toBuffer`/`fromBuffer` were dead. Persists the content-hash cache (not the position-addressed vector store) so the startup re-walk hits it instead of re-embedding. Adds `EmbeddingCacheStore` (packed-Float32 Buffer, header guard `{cacheVersion, modelId, dimensions}`, atomic temp+rename), `cacheStore?` on ContextEngine + `embeddingCacheDir?` on WorkspaceCoordinator (default-off), load-before / fail-soft-persist-after the index walk; sidecar passes `/embeddings` only with a real embedder. Council UNANIMOUS Q0=A (cache over vector store), Q1=A (header guard), Q2=A (default-off threading), Q3=A (load-once, coalesced atomic write, fail-soft), Q5=A; Q4 traps — atomic write (codex) + persist only the working set touched this session (gemini, bounds growth). +16 tests; no protocol/codegen change; no checkbox flip (T-805 already `[x]`, this makes it true across restarts) | | [ADR-048](ADR-048-command-palette-data-path.md) | Command-Palette Data Path — Wiring The Dead Picker + Loader As commandList / commandRead Protocol Methods (T-402 / T-1103) | Proposed | 2026-06-02 | road-to-v1-0 T-402/T-1103 — the shipped-but-dead `commands/picker.ts` (`commandsToPickerItems`/`pickCommands`) + `commands/loader.ts` (`loadCommandProcedure`) had zero live callers; the live collaborator `walkAgentConfig` is already walked (rules loader, ADR-043). Adds two read-only methods on a new `CommandHandler` over the walk-once-cached command index: `commandList {query?}` (empty → all alphabetical, query → ranked; `total` = match count before a `MAX_COMMAND_LIST_RESULTS=100` cap) + `commandRead {name}` (MCP-first/local-fallback body), behind `requireCommands()`. Council UNANIMOUS Q0=A (wire the data path), Q1=B (both methods), Q2=A (one `commandList{query?}`), Q3=A (`CommandSummary {name,description,path}` = PickerItem), Q4=A (keep absolute path for IDE click-through), Q6=A; Q5 born-dead risk → core stays the resolution authority. +5 DTOs (59→64); `jetbrains:check` BUILD SUCCESSFUL; no checkbox flip (the overlay render + invocation UX stay IDE) | | [ADR-049](ADR-049-retire-dead-context-inject.md) | Retire The Dead Context-Injection Twin — context/inject.ts Superseded By The Live buildContextInjection (T-605 / T-606) | Proposed | 2026-06-02 | road-to-v1-0 T-605/T-606 — `context/inject.ts` (`buildContextBlock`/`injectContext`, user-message placement) was the recorded T-605/T-606 impl but had zero live callers; the live path is `chat/context-injection.ts::buildContextInjection` (T-MR13, ADR-025, system-prompt placement) used in both handlers. Retires the dead twin + its test. Council UNANIMOUS Q0=A (retire), Q1=A (dedicated ADR mirroring ADR-039), Q2=A (T-605/T-606 stay `[x]`, done-comments updated to cite the live impl), Q3=A (nothing to port — the fixed 8000-char cap supersedes the dynamic 20%-window budget), Q4 no trap. No protocol/codegen change; no checkbox flip (capability is live via the system-prompt placement) | +| [ADR-050](ADR-050-config-list-skills-rules-data-path.md) | Agent-Config Registry Data Path — Wiring The Dead indexByKind As A configList Protocol Method For Skills + Rules (T-401) | Proposed | 2026-06-02 | road-to-v1-0 T-401 — `agent-config-walker.ts::indexByKind` (groups walked `ConfigNode[]` into `{skill,rule,command}`) was unit-tested with zero live callers; the only shipped data path (ADR-048 `CommandHandler`) filters to `kind==='command'`, so skills + rules — also walked — had no protocol path for a skill picker / rules viewer. Adds read-only `configList {kind?, limit?}` → `{items: ConfigSummary[], total}` (`ConfigSummary {kind,name,description,path}`) on a dedicated `ConfigHandler` behind `requireConfig()`, the local-walker authority/offline sibling of the MCP `listSkills` (T-1102). Council (gemini-cli; codex-cli hung >7 min, terminated) Q0=A (wire it), Q1=B (all kinds, kind-filterable), Q2=A (optional `kind`), Q3=B (flat `{items,total}` + per-item kind), Q4=A (path for click-through), Q5=A (lightweight only), Q7=A (`MAX_CONFIG_LIST_RESULTS=100` + total); Q6 reasoned divergence — dedicated handler over extending `CommandHandler` (one-handler-per-domain + per-handler fail-open walk cache vs a shared promise caching a transient FS error). +3 DTOs (64→67); `task ci` exit 0; `jetbrains:check` BUILD SUCCESSFUL; no checkbox flip (the picker/viewer render stays IDE) | ## Status legend diff --git a/packages/core/src/config/handler.test.ts b/packages/core/src/config/handler.test.ts new file mode 100644 index 0000000..3289c5b --- /dev/null +++ b/packages/core/src/config/handler.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { ConfigKind, ConfigNode } from './agent-config-walker.js'; +import { ConfigHandler, MAX_CONFIG_LIST_RESULTS } from './handler.js'; + +function node( + kind: ConfigKind, + name: string, + opts: { description?: string; body?: string; fmName?: string } = {}, +): ConfigNode { + const frontmatter: Record = {}; + if (opts.description !== undefined) frontmatter.description = opts.description; + if (opts.fmName !== undefined) frontmatter.name = opts.fmName; + return { + kind, + name, + absPath: `/repo/.event4u-agent/${kind}s/${name}.md`, + relPath: `${kind}s/${name}.md`, + sourceRoot: '.event4u-agent', + frontmatter, + body: opts.body ?? '', + }; +} + +// The walker sorts by kind then name; the handler relies on that order. +const nodesOf = + (...nodes: ConfigNode[]) => + () => + Promise.resolve(nodes); + +describe('ConfigHandler.list', () => { + it('lists every kind grouped skill→rule→command for an absent kind filter', async () => { + const h = new ConfigHandler({ + loadNodes: nodesOf( + node('command', 'commit', { description: 'Create a commit' }), + node('rule', 'scope-control', { description: 'Stay in scope' }), + node('skill', 'laravel', { description: 'Write Laravel PHP' }), + ), + }); + const res = await h.list({}); + expect(res.items).toEqual([ + { + kind: 'skill', + name: 'laravel', + description: 'Write Laravel PHP', + path: res.items[0]!.path, + }, + { + kind: 'rule', + name: 'scope-control', + description: 'Stay in scope', + path: res.items[1]!.path, + }, + { kind: 'command', name: 'commit', description: 'Create a commit', path: res.items[2]!.path }, + ]); + expect(res.total).toBe(3); + }); + + it('filters to a single kind when requested, and total reflects that kind only', async () => { + // Fed in walker order (sorted by kind then name) — the handler preserves it. + const h = new ConfigHandler({ + loadNodes: nodesOf( + node('command', 'commit'), + node('rule', 'scope-control'), + node('skill', 'eloquent'), + node('skill', 'laravel'), + ), + }); + const res = await h.list({ kind: 'skill' }); + expect(res.items.map((i) => i.name)).toEqual(['eloquent', 'laravel']); + expect(res.items.every((i) => i.kind === 'skill')).toBe(true); + expect(res.total).toBe(2); + }); + + it('falls back to the first heading when frontmatter has no description', async () => { + const h = new ConfigHandler({ + loadNodes: nodesOf(node('skill', 'plan', { body: '# Plan the work\n...' })), + }); + const res = await h.list({}); + expect(res.items[0]!.description).toBe('Plan the work'); + }); + + it('leaves description empty when neither frontmatter nor a heading exists', async () => { + const h = new ConfigHandler({ + loadNodes: nodesOf(node('rule', 'bare', { body: 'no heading' })), + }); + const res = await h.list({}); + expect(res.items[0]!.description).toBe(''); + }); + + it('prefers a non-empty frontmatter name over the file slug', async () => { + const h = new ConfigHandler({ + loadNodes: nodesOf(node('skill', 'laravel-slug', { fmName: 'laravel' })), + }); + const res = await h.list({}); + expect(res.items[0]!.name).toBe('laravel'); + }); + + it('carries the absolute path for IDE click-through', async () => { + const h = new ConfigHandler({ loadNodes: nodesOf(node('skill', 'laravel')) }); + const res = await h.list({}); + expect(res.items[0]!.path).toBe('/repo/.event4u-agent/skills/laravel.md'); + }); + + it('clamps to the requested limit while total stays the full count', async () => { + const h = new ConfigHandler({ + loadNodes: nodesOf(node('skill', 'a'), node('skill', 'b'), node('rule', 'c')), + }); + const res = await h.list({ limit: 2 }); + expect(res.items).toHaveLength(2); + expect(res.total).toBe(3); + }); + + it('never returns more than the hard ceiling', async () => { + const many = Array.from({ length: MAX_CONFIG_LIST_RESULTS + 25 }, (_, i) => + node('skill', `skill-${String(i).padStart(3, '0')}`), + ); + const h = new ConfigHandler({ loadNodes: nodesOf(...many) }); + const res = await h.list({}); + expect(res.items).toHaveLength(MAX_CONFIG_LIST_RESULTS); + expect(res.total).toBe(MAX_CONFIG_LIST_RESULTS + 25); + }); + + it('fails open to an empty list when the walk throws (does not throw)', async () => { + const h = new ConfigHandler({ + loadNodes: () => Promise.reject(new Error('FS race')), + }); + const res = await h.list({}); + expect(res.items).toEqual([]); + expect(res.total).toBe(0); + }); +}); + +describe('ConfigHandler — walk-once cache', () => { + it('walks the agent-config tree once across multiple calls', async () => { + const loadNodes = vi.fn(nodesOf(node('skill', 'laravel'), node('rule', 'scope-control'))); + const h = new ConfigHandler({ loadNodes }); + await h.list({}); + await h.list({ kind: 'skill' }); + await h.list({ kind: 'rule' }); + expect(loadNodes).toHaveBeenCalledTimes(1); + }); + + it('does not cache a failed walk (retries next call)', async () => { + const loadNodes = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce([node('skill', 'laravel')]); + const h = new ConfigHandler({ loadNodes }); + expect((await h.list({})).items).toEqual([]); + expect((await h.list({})).items.map((i) => i.name)).toEqual(['laravel']); + expect(loadNodes).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/config/handler.ts b/packages/core/src/config/handler.ts new file mode 100644 index 0000000..c587b14 --- /dev/null +++ b/packages/core/src/config/handler.ts @@ -0,0 +1,117 @@ +import type { + ConfigKind, + ConfigListRequest, + ConfigListResponse, + ConfigSummary, +} from '@event4u-agent/protocol'; +import { type ConfigNode, indexByKind } from './agent-config-walker.js'; + +/** + * T-401 + ADR-050 — the agent-config registry data path. + * + * Wires the shipped-but-dead `indexByKind` (the agent-config-walker grouping + * helper, unit-tested with ZERO live callers) onto one read-only protocol + * method: + * - `configList {kind?, limit?}` → lightweight {@link ConfigSummary}s for the + * IDE's skill picker / rules viewer / unified artifact browser. + * + * The command-only fuzzy palette already lives on `commandList` (ADR-048); + * this is its sibling for the kinds that had NO protocol data path — skills + * and rules — and, for a unified browser, commands too (AI council 2026-06-02, + * gemini-cli 0.41.2, Q1=B). The overlay rendering remains an IDE surface; this + * is the headless data path it calls. Mirrors the established "Core returns + * lightweight summaries, the IDE renders" shape (`commandList`). + * + * A dedicated handler (not an extension of `CommandHandler`) keeps the + * one-handler-per-domain pattern and lets each handler own its independent + * fail-open walk cache — a sidecar-level shared promise would cache a + * transient FS error for the whole session. The marginal second walk is + * acceptable: config trees are small. + */ + +/** Hard ceiling on returned summaries so a large config tree can't bloat the NDJSON line. */ +export const MAX_CONFIG_LIST_RESULTS = 100; + +/** The order kinds appear in an unfiltered listing (alphabetical within each). */ +const ALL_KINDS: readonly ConfigKind[] = ['skill', 'rule', 'command']; + +/** Coded error so an absent config handler surfaces cleanly (mirrors CommandRequestError). */ +export class ConfigRequestError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = 'ConfigRequestError'; + } +} + +export interface ConfigHandlerDeps { + /** + * Walk the agent-config tree for the registry index. Called once and the + * result cached for the session (artifacts are session-static, like rules). + * The sidecar passes `() => walkAgentConfig(cwd)`. + */ + loadNodes: () => Promise; +} + +export class ConfigHandler { + private cachedNodes: readonly ConfigNode[] | undefined; + + constructor(private readonly deps: ConfigHandlerDeps) {} + + /** + * Walk-once-cache the registry index. Fail-open: a walk *error* degrades to + * an empty index WITHOUT caching, so a transient FS race retries next call + * rather than disabling the registry for the whole session (mirrors + * `CommandHandler`). + */ + private async nodes(): Promise { + if (this.cachedNodes !== undefined) return this.cachedNodes; + try { + this.cachedNodes = await this.deps.loadNodes(); + return this.cachedNodes; + } catch { + return []; + } + } + + /** + * List agent-config artifacts. Absent `kind` → skills, then rules, then + * commands (alphabetical within each kind, as the walker sorts); a `kind` → + * only that kind. `total` is the count before the cap so the IDE can show + * "showing N of M". + */ + async list(req: ConfigListRequest): Promise { + const grouped = indexByKind(await this.nodes()); + const kinds = req.kind ? [req.kind] : ALL_KINDS; + const all = kinds.flatMap((kind) => grouped[kind].map(toSummary)); + const cap = Math.min(req.limit ?? MAX_CONFIG_LIST_RESULTS, MAX_CONFIG_LIST_RESULTS); + return { items: all.slice(0, cap), total: all.length }; + } +} + +/** Project a walked node to its wire summary. Description falls back like the command picker. */ +function toSummary(node: ConfigNode): ConfigSummary { + const fm = node.frontmatter as { description?: unknown; name?: unknown }; + const description = + (typeof fm.description === 'string' && fm.description.trim().length > 0 + ? firstLine(fm.description) + : firstHeading(node.body)) ?? ''; + const name = typeof fm.name === 'string' && fm.name.trim().length > 0 ? fm.name : node.name; + return { kind: node.kind, name, description, path: node.absPath }; +} + +function firstLine(text: string): string { + return text.split('\n')[0]?.trim() ?? ''; +} + +function firstHeading(body: string): string | undefined { + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('#')) { + return trimmed.replace(/^#+\s*/, '').trim(); + } + } + return undefined; +} diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 6311e74..ccb5a6a 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -7,6 +7,8 @@ import { CommandListRequestSchema, type CommandReadResponse, CommandReadRequestSchema, + type ConfigListResponse, + ConfigListRequestSchema, type ConnectResponse, ConnectRequestSchema, type ConversationListResponse, @@ -46,6 +48,7 @@ import { WorkspaceCoordinator } from './context/workspace-coordinator.js'; import type { AgentTurnHandler } from './agent/turn-handler.js'; import { type ChatHandler, ChatRequestError, type EnvelopeSink } from './chat/handler.js'; import { type CommandHandler, CommandRequestError } from './commands/handler.js'; +import { type ConfigHandler, ConfigRequestError } from './config/handler.js'; import { type CostReporter, CostRequestError } from './cost/report.js'; import { type GitHandler, GitRequestError } from './git/handler.js'; import { type TerminalHandler, TerminalRequestError } from './terminal/handler.js'; @@ -87,6 +90,10 @@ export class Dispatcher { // loads bodies over the live agent-config walk; absent → the two command // methods return `commands_not_configured`. private readonly commandHandler?: CommandHandler, + // Agent-config registry data path (T-401 / ADR-050). Lists skills + rules + // (+ commands) over the live walk; absent → `configList` returns + // `config_not_configured`. + private readonly configHandler?: ConfigHandler, ) { this.handlers = { ping: (): PingResponse => ({ result: 'pong' }), @@ -141,6 +148,9 @@ export class Dispatcher { this.requireCommands().list(CommandListRequestSchema.parse(data ?? {})), commandRead: (data: unknown): Promise => this.requireCommands().read(CommandReadRequestSchema.parse(data ?? {})), + // Agent-config registry (T-401 / ADR-050): read-only skill/rule/command listing. + configList: (data: unknown): Promise => + this.requireConfig().list(ConfigListRequestSchema.parse(data ?? {})), // Live terminal: input + resize are plain request/response (T-PRD03); // `terminalSubscribe` is streaming and handled in `dispatch` below. terminalInput: (data: unknown): TerminalInputResponse => @@ -183,6 +193,17 @@ export class Dispatcher { return this.commandHandler; } + /** The config handler or a coded error so absent wiring surfaces cleanly. */ + private requireConfig(): ConfigHandler { + if (!this.configHandler) { + throw new ConfigRequestError( + 'config_not_configured', + 'No config handler is configured on this Core instance.', + ); + } + return this.configHandler; + } + /** The cost reporter or a coded error so absent wiring surfaces cleanly. */ private requireCost(): CostReporter { if (!this.costReporter) { diff --git a/packages/core/src/sidecar.test.ts b/packages/core/src/sidecar.test.ts index 2ed77b5..70b9989 100644 --- a/packages/core/src/sidecar.test.ts +++ b/packages/core/src/sidecar.test.ts @@ -359,3 +359,58 @@ describe('buildCoreDispatcher — command-palette wiring (T-402 / T-1103, ADR-04 } }); }); + +describe('buildCoreDispatcher — config-registry wiring (T-401, ADR-050)', () => { + it('serves configList LIVE over the walked tree, surfacing skills + rules (not config_not_configured)', async () => { + // A temp workspace with a skill and a rule the command palette never surfaces. + const dir = await mkdtemp(join(tmpdir(), 'sidecar-config-')); + try { + await mkdir(join(dir, '.event4u-agent', 'skills'), { recursive: true }); + await mkdir(join(dir, '.event4u-agent', 'rules'), { recursive: true }); + await mkdir(join(dir, '.event4u-agent', 'commands'), { recursive: true }); + await writeFile( + join(dir, '.event4u-agent', 'skills', 'laravel.md'), + '---\ndescription: Write Laravel PHP\n---\n# Laravel\n', + ); + await writeFile( + join(dir, '.event4u-agent', 'rules', 'scope-control.md'), + '---\ndescription: Stay in scope\n---\n# Scope\n', + ); + await writeFile( + join(dir, '.event4u-agent', 'commands', 'commit.md'), + '---\ndescription: Create a commit\n---\n# Commit\n', + ); + const dispatcher = buildCoreDispatcher({ + cwd: dir, + store: new InMemoryConversationStore(), + }); + + const all = await dispatcher.dispatch( + { messageId: 'm1', messageType: 'configList', data: {}, done: true }, + () => {}, + ); + expect(all.messageType).toBe('configList'); // proves a ConfigHandler IS wired + expect(all.data).toMatchObject({ + items: [ + { kind: 'skill', name: 'laravel', description: 'Write Laravel PHP' }, + { kind: 'rule', name: 'scope-control', description: 'Stay in scope' }, + { kind: 'command', name: 'commit', description: 'Create a commit' }, + ], + total: 3, + }); + + const skillsOnly = await dispatcher.dispatch( + { messageId: 'm2', messageType: 'configList', data: { kind: 'skill' }, done: true }, + () => {}, + ); + expect(skillsOnly.data).toMatchObject({ + items: [{ kind: 'skill', name: 'laravel' }], + total: 1, + }); + + dispatcher.dispose(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/sidecar.ts b/packages/core/src/sidecar.ts index 118f36b..1050c43 100644 --- a/packages/core/src/sidecar.ts +++ b/packages/core/src/sidecar.ts @@ -7,6 +7,7 @@ import type { LoadRules } from './chat/system-prompt.js'; import { CommandHandler } from './commands/handler.js'; import { createRulesLoader } from './commands/rules-loader.js'; import { walkAgentConfig } from './config/agent-config-walker.js'; +import { ConfigHandler } from './config/handler.js'; import { DailyBudgetTracker, type BudgetRecorder } from './cost/budget.js'; import { CalibrationLog } from './cost/reconcile.js'; import { DefaultCostReporter, type CostReporter } from './cost/report.js'; @@ -310,8 +311,16 @@ export function buildCoreDispatcher(options: BuildCoreOptions = {}): Dispatcher // resolves bodies from the local walker index (the documented offline path). const commandHandler = new CommandHandler({ loadNodes: () => walkAgentConfig(cwd) }); + // Agent-config registry data path (T-401 / ADR-050) — the sibling of the + // command palette for the kinds it does not surface (skills + rules) plus a + // unified browser. Its own fail-open walk cache (not shared with the command + // handler) so a transient FS error can't disable the registry for the whole + // session. + const configHandler = new ConfigHandler({ loadNodes: () => walkAgentConfig(cwd) }); + // `undefined` keeps the default live onboarding probes (6th ctor arg); the - // cost reporter is the 7th (ADR-035); the command handler is the 8th (ADR-048). + // cost reporter is the 7th (ADR-035); the command handler is the 8th (ADR-048); + // the config handler is the 9th (ADR-050). return new Dispatcher( coordinator, chatHandler, @@ -321,5 +330,6 @@ export function buildCoreDispatcher(options: BuildCoreOptions = {}): Dispatcher undefined, costReporter, commandHandler, + configHandler, ); } diff --git a/packages/protocol/src/schema.test.ts b/packages/protocol/src/schema.test.ts index 33bf487..9862311 100644 --- a/packages/protocol/src/schema.test.ts +++ b/packages/protocol/src/schema.test.ts @@ -349,6 +349,7 @@ describe('method registry', () => { 'chatSend', 'commandList', 'commandRead', + 'configList', 'connect', 'conversationList', 'conversationRewind', diff --git a/packages/protocol/src/schema.ts b/packages/protocol/src/schema.ts index 7ec1650..6e431f0 100644 --- a/packages/protocol/src/schema.ts +++ b/packages/protocol/src/schema.ts @@ -850,6 +850,61 @@ export const CommandReadResponseSchema = z.object({ }); export type CommandReadResponse = z.infer; +// --- agent-config registry (T-401 / ADR-050) ---------------------------- + +/** The artifact kinds the agent-config walker discovers. */ +export const ConfigKindSchema = z.enum(['skill', 'rule', 'command']); +export type ConfigKind = z.infer; + +/** + * One agent-config artifact's metadata, for the IDE's skill picker / rules + * viewer / unified registry browser. Mirrors {@link CommandSummary} (the + * command-only sibling) and adds a `kind` so a single flat list can carry + * skills, rules, and commands together. + */ +export const ConfigSummarySchema = z.object({ + /** `skill` / `rule` / `command`. */ + kind: ConfigKindSchema, + /** Artifact name (slug), e.g. `commit` or `verify-before-complete`. */ + name: z.string(), + /** First line of the frontmatter `description`, or the first heading; `''` if neither. */ + description: z.string(), + /** Absolute source path for IDE click-through (local sidecar ↔ local IDE). */ + path: z.string(), +}); +export type ConfigSummary = z.infer; + +export const ConfigListRequestSchema = z.object({ + /** + * Optional kind filter. Absent → every discovered artifact (skills, then + * rules, then commands, alphabetical within each kind); a kind → only that + * kind. The command-only fuzzy-ranked palette lives on `commandList`; this + * is the plain registry listing. + */ + kind: ConfigKindSchema.optional(), + /** + * Cap the number of results. Core additionally clamps to a hard ceiling so a + * large config tree cannot produce an oversized NDJSON line + * (sibling-consistency with `commandList` / `conversationList`). + */ + limit: z.number().int().positive().optional(), +}); +export type ConfigListRequest = z.infer; + +/** + * The wire projection of Core's agent-config index — the data path for the + * IDE's skill picker and rules viewer (the kinds `commandList` does not + * surface). Read-only: Core walks the agent-config tree once, groups it via + * `indexByKind`, and returns lightweight summaries; the IDE renders. `total` + * is the match count before the cap so a browser can show "showing N of M". + * Mirrors the established "Core returns data, the IDE renders it" shape. + */ +export const ConfigListResponseSchema = z.object({ + items: z.array(ConfigSummarySchema), + total: z.number().int().nonnegative(), +}); +export type ConfigListResponse = z.infer; + // --- tool-call lifecycle + approval (product-readiness Phase 1) ---------- /** @@ -1339,6 +1394,7 @@ export const Methods = { costReport: { request: CostReportRequestSchema, response: CostReportResponseSchema }, commandList: { request: CommandListRequestSchema, response: CommandListResponseSchema }, commandRead: { request: CommandReadRequestSchema, response: CommandReadResponseSchema }, + configList: { request: ConfigListRequestSchema, response: ConfigListResponseSchema }, } as const; export type MethodName = keyof typeof Methods; diff --git a/scripts/codegen.ts b/scripts/codegen.ts index d787f79..1a30996 100644 --- a/scripts/codegen.ts +++ b/scripts/codegen.ts @@ -427,6 +427,34 @@ const classes: DataClass[] = [ ], }, + // --- agent-config registry (T-401 / ADR-050) ------------------------ + { + name: 'ConfigSummary', + doc: "An agent-config artifact's metadata (kind=skill/rule/command), for the IDE registry/picker.", + fields: [ + { name: 'kind', kotlinType: 'String' }, + { name: 'name', kotlinType: 'String' }, + { name: 'description', kotlinType: 'String' }, + { name: 'path', kotlinType: 'String' }, + ], + }, + { + name: 'ConfigListRequest', + doc: 'List agent-config artifacts; absent kind lists all (skills, rules, commands). limit clamped by Core.', + fields: [ + { name: 'kind', kotlinType: 'String?', default: 'null' }, + { name: 'limit', kotlinType: 'Int?', default: 'null' }, + ], + }, + { + name: 'ConfigListResponse', + doc: 'Artifact summaries grouped by kind then name; total is the match count before the cap.', + fields: [ + { name: 'items', kotlinType: 'List' }, + { name: 'total', kotlinType: 'Int' }, + ], + }, + // --- agent turn: chat that edits files (product-readiness) ----------- { name: 'AgentTurnRequest',