diff --git a/agents/roadmaps/road-to-v1-0.md b/agents/roadmaps/road-to-v1-0.md index 7ff87a4..2088f0b 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 26bf1e8..141754b 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 @@ -393,6 +393,22 @@ data class ConfigListResponse( val total: Int, ) +/** Load one artifact's body by (kind, name); kind is required as names are not unique. */ +@Serializable +data class ConfigReadRequest( + val kind: String, + val name: String, +) + +/** An artifact body read from the local walk index; source is local/missing (no mcp). */ +@Serializable +data class ConfigReadResponse( + val kind: String, + val name: String, + val source: String, + val body: String, +) + /** 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-052-config-read-body-sibling.md b/docs/adr/ADR-052-config-read-body-sibling.md new file mode 100644 index 0000000..9a0eb62 --- /dev/null +++ b/docs/adr/ADR-052-config-read-body-sibling.md @@ -0,0 +1,119 @@ +--- +adr: 052 +title: Agent-Config Body Read — Completing The configList Contract With A Local-Only configRead {kind,name} Sibling (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 — codex-cli 0.134.0 + gemini-cli 0.41.2 (2026-06-02), both run serially per the documented stdin gotcha. UNANIMOUS Q0–Q3 = A across both members; Q4/Q5 traps reconciled in the Decision. +related: the read half of ADR-050 (configList). Mirrors the list+read PAIR that ADR-048 shipped for commands (`commandList` + `commandRead`); the command council had explicitly required BOTH because list-without-read is "half a contract". Reuses the `walkAgentConfig` collaborator already live for the rules loader (ADR-043), the command palette (ADR-048), and `configList` (ADR-050). +date: 2026-06-02 +--- + +# ADR-052 — Agent-Config Body Read (configRead, 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 — +core 1147 pass / 1 skip, +15). `task jetbrains:check` BUILD SUCCESSFUL (the +2 +Kotlin DTOs compile, 67 → 69). **No checkbox flip** — the skill picker / rules +viewer detail pane render remains an IDE surface; this is the headless body +data path those surfaces will call. + +## Context + +ADR-050 shipped `configList {kind?, limit?}` → lightweight `ConfigSummary`s for +the IDE's skill picker / rules viewer, but **without a read sibling**. The +command-palette council (ADR-048) had explicitly chosen BOTH `commandList` + +`commandRead` because "half a contract otherwise" — a list whose items the IDE +cannot open into a detail pane is incomplete. `configList` is exactly that same +half-contract for skills + rules + commands. + +The `ConfigHandler` (ADR-050) walks the agent-config tree once and caches it; +every walked `ConfigNode` already carries `{kind, name, absPath, frontmatter, +body}` — the body is parsed from disk during the walk for **all three** kinds. +So the body the IDE needs already exists in the cached index; nothing new must +be read from disk. The agent-config MCP client (`AgentConfigMcpClient`) exposes +`skill_read` and `command_read` tools, but **no** `rule_read`. + +## Decision + +Add a read-only `configRead {kind, name}` → `{kind, name, source, body}` +protocol method on the existing `ConfigHandler`, reading the body straight off +the same cached walk `configList` groups. + +Council (codex-cli + gemini-cli, both serial, UNANIMOUS Q0–Q3 = A): + +- **Q0=A** — wire it now. Completes the `configList` contract; mirrors + `commandRead`; the IDE skill/rule detail pane needs the body; Core stays the + resolution authority. +- **Q1=A** — the request key is `{kind, name}`, not `{name}`. A name is **not** + unique across kinds (a skill and a command can share a slug), so `kind` is the + mandatory discriminator. +- **Q2=A** — **LOCAL-ONLY**. Read `node.body` from the cached walk; uniform + across all three kinds. An MCP-first path (like `commandRead`) would be + asymmetric — MCP has `skill_read`/`command_read` but no `rule_read` — and + would add an MCP dependency to a handler deliberately designed without one + (ADR-050 Q6). Hence `ConfigSourceSchema = 'local' | 'missing'` (no `mcp` + member, unlike `CommandSource`). +- **Q3=A** — response `{kind, name, source: 'local'|'missing', body}` mirrors + `commandRead`; a miss is `source: 'missing'` with an empty body (graceful, no + throw), consistent with `commandRead`. +- **Q4 — full body, uncapped.** codex: match `commandRead` (which returns the + full body uncapped); add a cap only behind evidence of an oversized-line + problem. gemini flagged NDJSON line length as the theoretical risk. Resolved + in favour of consistency with `commandRead`: a skill body is comparable in + size to a command procedure the transport already carries; a special-case cap + here would be an asymmetric surprise. (If a future skill body proves + pathological, a cap can land uniformly across both read methods.) +- **Q5 — traps, both satisfied by design.** codex: list and read must not + disagree after a file change → guaranteed, because `read` goes through the + **same** `this.nodes()` walk-once cache as `list`. gemini: never interpolate + `name` into a disk path (traversal) → guaranteed, because `read` matches + against the in-memory walked nodes and never touches the filesystem with the + request value. + +One correctness refinement beyond the council: `read` matches on the artifact's +**display name** (`displayName` = a non-empty frontmatter `name`, else the file +slug) — i.e. exactly the `name` `configList` returned — not the raw file slug. +The shared `displayName` helper is now used by both `toSummary` (list) and +`read`, so the two can never key on different names. + +## Consequences + +- **+2 DTOs** (`ConfigReadRequest`, `ConfigReadResponse`) + `ConfigSource` + enum, 67 → 69 in codegen; +1 protocol method (`configRead`), behind the + existing `requireConfig()` → coded `config_not_configured` when absent. +- The skill picker / rules viewer detail pane now has a headless, + offline-capable body data path that does not depend on the agent-config MCP + server being up — Core stays the resolution authority, exactly as for + `configList` and `commandRead`. +- No behaviour change to any existing path; `list` is refactored only to share + the new `displayName` helper (byte-identical projection). +- The IDE render remains the last-mile surface; this ADR does not flip any + roadmap checkbox. + +## Alternatives considered + +- **Leave `configList` without a read sibling (Q0=B).** Rejected — it is the + documented half-contract; the IDE cannot open a listed artifact. +- **`{name}`-only key (Q1=B).** Rejected — names collide across kinds; the + result would be ambiguous. +- **MCP-first like `commandRead` (Q2=B).** Rejected — asymmetric (no + `rule_read`) and pulls an MCP dep into a local-only handler for no gain, since + the body is already on every walked node. +- **Cap the body length (Q4).** Deferred (YAGNI) — match `commandRead`'s + uncapped body; revisit uniformly if evidence of an oversized line appears. + +## References + +- `packages/core/src/config/handler.ts` — `ConfigHandler.read` + the shared + `displayName` helper. +- `packages/protocol/src/schema.ts` — `ConfigReadRequest/Response`, + `ConfigSource`. +- ADR-050 — the `configList` half this completes. +- ADR-048 — the `commandList` + `commandRead` PAIR this mirrors. +- ADR-043 — the `walkAgentConfig` live collaborator reused here. +- `agents/roadmaps/road-to-v1-0.md` T-401 — the registry data path. diff --git a/docs/adr/index.md b/docs/adr/index.md index 2efadde..dca62dd 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -55,6 +55,7 @@ | [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) | | [ADR-051](ADR-051-review-config-rules-wiring.md) | Review Config + Rules Wiring — Threading The Dead Phase-5 loadReviewRules / ReviewSettings Into The Live reviewSummary Path (T-CR-501/502) | Proposed | 2026-06-02 | road-to-code-review Phase 5 — `loadReviewRules` + `ReviewSettings` helpers (`resolveReviewSettings`/`voteOptionsFromSettings`/`applySeverityFloor`) shipped tested with zero live callers; `GitHandler.reviewSummary` (ADR-042) ran with hard-coded defaults and ignored both. Threads project `review-rules.md` into the Stage-1 prompt (T-CR-501) and the `.agent-settings.yml :: review` block into the group-vote options + severity floor applied BEFORE `summarizeReview` (T-CR-502). Council (gemini + codex, both clean) Q0=A (wire rules+settings together), Q2=A (floor before summarise), Q3=A (unconditional best-effort load), Q4=A (inject optional `loadReviewRules?`/`loadReviewSettings?` readers into `GitHandlerDeps`); Q1 divergence (gemini C inject / codex B no config→review coupling) synthesised — default reader lives in `review/settings-source.ts` (no `AgentSettingsSchema` extension) and is injected as a dep. Trap both flagged: severity floor must never hide a `security` finding — `applySeverityFloor` exempts it, regression-tested. Pure-core (no DTO/codegen change); `task ci` exit 0 (core +11 tests); no checkbox flip (T-CR-501/502 already `[x]`; done-comments cite this wiring) | +| [ADR-052](ADR-052-config-read-body-sibling.md) | Agent-Config Body Read — Completing The configList Contract With A Local-Only configRead {kind,name} Sibling (T-401) | Proposed | 2026-06-02 | road-to-v1-0 T-401 — `configList` (ADR-050) shipped without a read sibling, the same half-contract the command council (ADR-048) rejected by requiring BOTH `commandList`+`commandRead`. Adds read-only `configRead {kind, name}` → `{kind, name, source, body}` on the existing `ConfigHandler`, reading the body off the SAME cached walk `configList` groups. Council (codex + gemini, both serial) UNANIMOUS Q0–Q3=A: Q0 wire now (complete the contract), Q1 `{kind,name}` key (names collide across kinds), Q2 LOCAL-ONLY (body already on every walked node; MCP has no `rule_read` so MCP-first would be asymmetric → `ConfigSource = 'local'\|'missing'`, no `mcp`), Q3 mirror `commandRead` (miss → `source:'missing'`, empty body, no throw); Q4 full body uncapped (consistency with `commandRead`), Q5 traps satisfied by design (read shares the walk-once cache → list/read never disagree; matches in-memory nodes → no path traversal). Refinement: `read` matches the DISPLAY name (frontmatter `name` ?? slug) via a shared `displayName` helper, so list/read key identically. +2 DTOs (67→69); `task ci` exit 0 (core 1147, +15); `jetbrains:check` BUILD SUCCESSFUL; no checkbox flip (detail-pane render stays IDE) | ## Status legend diff --git a/packages/core/src/config/handler.test.ts b/packages/core/src/config/handler.test.ts index 3289c5b..1ac5f2e 100644 --- a/packages/core/src/config/handler.test.ts +++ b/packages/core/src/config/handler.test.ts @@ -130,6 +130,62 @@ describe('ConfigHandler.list', () => { }); }); +describe('ConfigHandler.read', () => { + it('reads an artifact body by {kind, name} with source local', async () => { + const h = new ConfigHandler({ + loadNodes: nodesOf(node('skill', 'laravel', { body: '# Laravel\nWrite PHP.' })), + }); + const res = await h.read({ kind: 'skill', name: 'laravel' }); + expect(res).toEqual({ + kind: 'skill', + name: 'laravel', + source: 'local', + body: '# Laravel\nWrite PHP.', + }); + }); + + it('disambiguates by kind — same slug, different kinds, returns the requested one', async () => { + const h = new ConfigHandler({ + loadNodes: nodesOf( + node('command', 'review', { body: 'command body' }), + node('skill', 'review', { body: 'skill body' }), + ), + }); + expect((await h.read({ kind: 'command', name: 'review' })).body).toBe('command body'); + expect((await h.read({ kind: 'skill', name: 'review' })).body).toBe('skill body'); + }); + + it('matches the DISPLAY name (frontmatter name) the listing returned, not the file slug', async () => { + const h = new ConfigHandler({ + loadNodes: nodesOf(node('skill', 'laravel-slug', { fmName: 'laravel', body: 'B' })), + }); + // list surfaces it as `laravel`; read must find it under that same name. + expect((await h.read({ kind: 'skill', name: 'laravel' })).body).toBe('B'); + expect((await h.read({ kind: 'skill', name: 'laravel-slug' })).source).toBe('missing'); + }); + + it('returns source missing + empty body for an unknown kind/name (does not throw)', async () => { + const h = new ConfigHandler({ loadNodes: nodesOf(node('skill', 'laravel')) }); + const res = await h.read({ kind: 'rule', name: 'laravel' }); + expect(res).toEqual({ kind: 'rule', name: 'laravel', source: 'missing', body: '' }); + }); + + it('fails open to a missing result when the walk throws (does not throw)', async () => { + const h = new ConfigHandler({ loadNodes: () => Promise.reject(new Error('FS race')) }); + const res = await h.read({ kind: 'skill', name: 'laravel' }); + expect(res).toMatchObject({ source: 'missing', body: '' }); + }); + + it('shares the walk-once cache with list (read after list does not re-walk)', async () => { + const loadNodes = vi.fn(nodesOf(node('skill', 'laravel', { body: 'B' }))); + const h = new ConfigHandler({ loadNodes }); + await h.list({}); + const res = await h.read({ kind: 'skill', name: 'laravel' }); + expect(res.body).toBe('B'); + expect(loadNodes).toHaveBeenCalledTimes(1); + }); +}); + 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'))); diff --git a/packages/core/src/config/handler.ts b/packages/core/src/config/handler.ts index c587b14..2db3056 100644 --- a/packages/core/src/config/handler.ts +++ b/packages/core/src/config/handler.ts @@ -2,6 +2,8 @@ import type { ConfigKind, ConfigListRequest, ConfigListResponse, + ConfigReadRequest, + ConfigReadResponse, ConfigSummary, } from '@event4u-agent/protocol'; import { type ConfigNode, indexByKind } from './agent-config-walker.js'; @@ -89,17 +91,40 @@ export class ConfigHandler { const cap = Math.min(req.limit ?? MAX_CONFIG_LIST_RESULTS, MAX_CONFIG_LIST_RESULTS); return { items: all.slice(0, cap), total: all.length }; } + + /** + * Read one artifact's body by `{kind, name}`. The body is read straight off + * the cached walk index — the SAME walk `list` groups — so list and read can + * never disagree (AI council 2026-06-02 Q5). The match is on the artifact's + * DISPLAY name (`displayName`, frontmatter `name` ?? slug), i.e. exactly the + * `name` `list` returned, not the raw file slug. Local-only: a node hit is + * `source: 'local'`, a miss is `source: 'missing'` with an empty body (no + * throw — mirrors `commandRead`). Full body, uncapped (Q4). + */ + async read(req: ConfigReadRequest): Promise { + const node = (await this.nodes()).find( + (n) => n.kind === req.kind && displayName(n) === req.name, + ); + return node + ? { kind: req.kind, name: req.name, source: 'local', body: node.body } + : { kind: req.kind, name: req.name, source: 'missing', body: '' }; + } } /** 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 fm = node.frontmatter as { description?: 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 }; + return { kind: node.kind, name: displayName(node), description, path: node.absPath }; +} + +/** The name a node is listed under: a non-empty frontmatter `name`, else the file slug. */ +function displayName(node: ConfigNode): string { + const fm = node.frontmatter as { name?: unknown }; + return typeof fm.name === 'string' && fm.name.trim().length > 0 ? fm.name : node.name; } function firstLine(text: string): string { diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index ccb5a6a..f10c099 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -9,6 +9,8 @@ import { CommandReadRequestSchema, type ConfigListResponse, ConfigListRequestSchema, + type ConfigReadResponse, + ConfigReadRequestSchema, type ConnectResponse, ConnectRequestSchema, type ConversationListResponse, @@ -148,9 +150,12 @@ 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. + // Agent-config registry (T-401 / ADR-050): read-only skill/rule/command + // listing + body read (the configList contract's read sibling, ADR-052). configList: (data: unknown): Promise => this.requireConfig().list(ConfigListRequestSchema.parse(data ?? {})), + configRead: (data: unknown): Promise => + this.requireConfig().read(ConfigReadRequestSchema.parse(data ?? {})), // Live terminal: input + resize are plain request/response (T-PRD03); // `terminalSubscribe` is streaming and handled in `dispatch` below. terminalInput: (data: unknown): TerminalInputResponse => diff --git a/packages/core/src/sidecar.test.ts b/packages/core/src/sidecar.test.ts index 70b9989..2d52433 100644 --- a/packages/core/src/sidecar.test.ts +++ b/packages/core/src/sidecar.test.ts @@ -408,6 +408,36 @@ describe('buildCoreDispatcher — config-registry wiring (T-401, ADR-050)', () = total: 1, }); + // configRead serves the body off the SAME walk (ADR-052, the read sibling). + const body = await dispatcher.dispatch( + { + messageId: 'm3', + messageType: 'configRead', + data: { kind: 'skill', name: 'laravel' }, + done: true, + }, + () => {}, + ); + expect(body.messageType).toBe('configRead'); + expect(body.data).toMatchObject({ + kind: 'skill', + name: 'laravel', + source: 'local', + body: '# Laravel\n', + }); + + // A kind/name miss is graceful: source 'missing', empty body (no throw). + const missing = await dispatcher.dispatch( + { + messageId: 'm4', + messageType: 'configRead', + data: { kind: 'rule', name: 'laravel' }, + done: true, + }, + () => {}, + ); + expect(missing.data).toMatchObject({ kind: 'rule', source: 'missing', body: '' }); + dispatcher.dispose(); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/packages/protocol/src/schema.test.ts b/packages/protocol/src/schema.test.ts index 9862311..5144627 100644 --- a/packages/protocol/src/schema.test.ts +++ b/packages/protocol/src/schema.test.ts @@ -15,6 +15,8 @@ import { CommandListResponseSchema, CommandReadRequestSchema, CommandReadResponseSchema, + ConfigReadRequestSchema, + ConfigReadResponseSchema, ConnectRequestSchema, CodeSuggestionAnnotationSchema, ContextScopeSchema, @@ -350,6 +352,7 @@ describe('method registry', () => { 'commandList', 'commandRead', 'configList', + 'configRead', 'connect', 'conversationList', 'conversationRewind', @@ -551,6 +554,33 @@ describe('method registry', () => { ).toThrow(); }); + it('configRead keys on {kind,name}, round-trips a local body, and rejects mcp/unknown source', () => { + const req = ConfigReadRequestSchema.parse({ kind: 'skill', name: 'laravel' }); + expect(req.kind).toBe('skill'); + expect(req.name).toBe('laravel'); + // kind is required — name alone is not unique across kinds. + expect(() => ConfigReadRequestSchema.parse({ name: 'laravel' })).toThrow(); + + const local = ConfigReadResponseSchema.parse({ + kind: 'skill', + name: 'laravel', + source: 'local', + body: '# Laravel', + }); + expect(local.source).toBe('local'); + const missing = ConfigReadResponseSchema.parse({ + kind: 'rule', + name: 'nope', + source: 'missing', + body: '', + }); + expect(missing.body).toBe(''); + // Local-only: no `mcp` member (unlike CommandSource), and unknown rejects. + expect(() => + ConfigReadResponseSchema.parse({ kind: 'skill', name: 'x', source: 'mcp', body: '' }), + ).toThrow(); + }); + it('costReport request/response round-trip the aggregate shape', () => { const req = CostReportRequestSchema.parse({ since: '2026-06-01T00:00:00.000Z' }); expect(req.since).toBe('2026-06-01T00:00:00.000Z'); diff --git a/packages/protocol/src/schema.ts b/packages/protocol/src/schema.ts index 6e431f0..41c186a 100644 --- a/packages/protocol/src/schema.ts +++ b/packages/protocol/src/schema.ts @@ -905,6 +905,43 @@ export const ConfigListResponseSchema = z.object({ }); export type ConfigListResponse = z.infer; +/** + * Where a config artifact body was resolved from. Unlike {@link CommandSource} + * there is no `mcp` member: `ConfigHandler` is local-only — the body is already + * parsed onto every walked node for all three kinds, and the agent-config MCP + * server exposes `skill_read` / `command_read` but no `rule_read`, so an + * MCP-first path would be asymmetric (AI council 2026-06-02 Q2=A). `missing` + * ⇒ no artifact of that `kind` with that `name`. + */ +export const ConfigSourceSchema = z.enum(['local', 'missing']); +export type ConfigSource = z.infer; + +export const ConfigReadRequestSchema = z.object({ + /** The artifact kind — required, because a name is NOT unique across kinds. */ + kind: ConfigKindSchema, + /** Artifact name (slug), as returned by `configList`. */ + name: z.string(), +}); +export type ConfigReadRequest = z.infer; + +/** + * The body sibling of {@link ConfigListResponse} — the read half of the + * agent-config registry contract (the command-only equivalent is + * `commandRead`). Core reads the body straight off its cached walk index + * (the same walk `configList` groups), so list and read can never disagree. + * Local-only by design (see {@link ConfigSourceSchema}); `missing` ⇒ empty + * body. Full body, uncapped — consistent with `commandRead` (AI council + * 2026-06-02 Q4). + */ +export const ConfigReadResponseSchema = z.object({ + kind: ConfigKindSchema, + name: z.string(), + source: ConfigSourceSchema, + /** Artifact body; empty string when `source` is `missing`. */ + body: z.string(), +}); +export type ConfigReadResponse = z.infer; + // --- tool-call lifecycle + approval (product-readiness Phase 1) ---------- /** @@ -1395,6 +1432,7 @@ export const Methods = { commandList: { request: CommandListRequestSchema, response: CommandListResponseSchema }, commandRead: { request: CommandReadRequestSchema, response: CommandReadResponseSchema }, configList: { request: ConfigListRequestSchema, response: ConfigListResponseSchema }, + configRead: { request: ConfigReadRequestSchema, response: ConfigReadResponseSchema }, } as const; export type MethodName = keyof typeof Methods; diff --git a/scripts/codegen.ts b/scripts/codegen.ts index 1a30996..fedbda8 100644 --- a/scripts/codegen.ts +++ b/scripts/codegen.ts @@ -454,6 +454,24 @@ const classes: DataClass[] = [ { name: 'total', kotlinType: 'Int' }, ], }, + { + name: 'ConfigReadRequest', + doc: "Load one artifact's body by (kind, name); kind is required as names are not unique.", + fields: [ + { name: 'kind', kotlinType: 'String' }, + { name: 'name', kotlinType: 'String' }, + ], + }, + { + name: 'ConfigReadResponse', + doc: 'An artifact body read from the local walk index; source is local/missing (no mcp).', + fields: [ + { name: 'kind', kotlinType: 'String' }, + { name: 'name', kotlinType: 'String' }, + { name: 'source', kotlinType: 'String' }, + { name: 'body', kotlinType: 'String' }, + ], + }, // --- agent turn: chat that edits files (product-readiness) ----------- {