From 9b72aac253cd434d417fa22c373fba24d21cc429 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 16:12:13 +0200 Subject: [PATCH 01/23] fix(opencode): load hooks and telemetry in both plugin runtimes OpenCode V2 requires a default definition and emits a different event lifecycle. Keep the V1 server entrypoint while adapting measured V2 tool and execution events. Closes #953 AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../backlog-link.json | 5 + .../phase-1.md | 75 ++++++++ .../plan.md | 42 +++++ .../verification.md | 92 ++++++++++ .../opencode/opencode-hooks-bridge.ts | 78 +++++++- .../opencode-hooks-bridge.unit.test.ts | 83 ++++++++- ...pencode-hooks-bridge-generated.e2e.test.ts | 100 ++++++++-- .../snapshots/framework-build/golden.json | 2 +- docs/ARCHITECTURE.md | 9 +- plugins/aidd-telemetry/README.md | 11 +- .../aidd-telemetry/hooks/opencode-plugin.js | 99 +++++++++- .../aidd-telemetry-cost-skill.test.js | 18 +- .../aidd-telemetry-opencode-payloads.test.js | 5 +- scripts/__tests__/opencode-plugin.test.js | 172 ++++++++++++++++++ 14 files changed, 751 insertions(+), 40 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json create mode 100644 aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json new file mode 100644 index 000000000..a769590ff --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json @@ -0,0 +1,5 @@ +{ + "backlog": "ai-driven-dev/framework#953", + "written_at": "2026-10-07T00:00:00Z", + "written_by": "aidd-dev:01-plan" +} diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md new file mode 100644 index 000000000..550aa441d --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md @@ -0,0 +1,75 @@ +--- +status: done +--- + +# Instruction: Preserve AIDD hook behavior across OpenCode versions + +## Architecture projection +```txt +cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts modify: generated plugin definition and V2 subscription +plugins/aidd-telemetry/hooks/opencode-plugin.js modify: telemetry definition and V2 subscription +cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts modify: generated entrypoint and behavioral tests +cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts modify: default server/setup consumption in real translate output +cli/tests/golden/snapshots/framework-build/golden.json modify: only the emitted OpenCode bridge hash +scripts/__tests__/opencode-plugin.test.js modify: actual journal effects through both entrypoints and cleanup +scripts/__tests__/aidd-telemetry-opencode-payloads.test.js modify if required: loader export contract and event mappings +scripts/__tests__/aidd-telemetry-cost-skill.test.js modify: distinguish V1 omission from observed V2 session announcements +docs/ARCHITECTURE.md modify: replace outdated single-factory contract +plugins/aidd-telemetry/README.md modify: supported OpenCode versions and measured behavior +aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md create: research and observed real runtime evidence +``` +No files deleted. Add runtime regression coverage only where needed to reproduce a measured V2 difference. + +## User Journey +```mermaid +flowchart TD + Build[Translate framework for OpenCode] --> Start[Start V1 or V2 in an isolated project] + Start --> Memory[Context hook refreshes project memory] + Memory --> Tool[Session performs a task-document tool call] + Tool --> Journal[Journal records session start and task declaration] + Journal --> Idle[Completed turn records turn end] +``` + +## Test Scope +```mermaid +journey + section Setup + Translate fresh framework into temporary project => generated plugins present: 5: cli + section Happy path + Start OpenCode 2.0.22 => plugins load and memory refresh executes: 5: cli + Complete task-document tool call => session and task journal records appear: 5: cli + Finish turn => turn_end appears with correct session identity: 5: cli + section Edge case - V1 compatibility + Start OpenCode 1.18.29 => server hooks execute once and journal works: 5: cli + section Edge case - Cleanup + Unload plugin => event subscription aborts without unhandled rejection: 5: system + section Teardown + Stop temporary host and model processes => personal profile unchanged: 5: system +``` + +## Wireframe +Not applicable: no UI change. + +## Tasks to do +### 1) Compatible exports and event handling +1. Write regression tests and observe the expected failure. +2. Add default id/server/setup definitions to both modules, preserving named factory seams if the V1 loader permits them without duplicate execution. +3. Register V2 event handling with cancellable cleanup. Translate data/location session events, correlate granular tool events into completed tool calls, and handle execution completion as turn-end. +4. Update existing coverage and documentation; run coding and architecture assertions. + +### 2) Real local compatibility proof +1. Translate the actual framework with the built CLI. +2. Run released V1 and V2 binaries in isolated temporary environments. +3. Assert actual memory refresh, task declaration and turn-end records; retain concise versioned evidence and the reproduction command. +4. Run independent review and outcome challenge before opening the draft PR against next. + +## Test acceptance criteria +| Task | Acceptance criteria | +| --- | --- | +| 1 | Both emitted modules have default id/setup/server; import does not spawn hooks. | +| 1 | V1 invokes its handler once; V2 setup returns promptly, forwards events and returns cleanup that aborts its subscription. | +| 1 | Malformed/unrelated events do not crash the host; completed tool, session-start and idle semantics remain correct. | +| 1 | Relevant tests and repository coding/architecture gates pass. | +| 2 | Real OpenCode V2 2.0.22 loads both translated modules with no plugin load failure and produces observable memory and journal effects. | +| 2 | Real OpenCode V1 1.18.29 retains those effects with no duplicate session-start. | +| 2 | Evidence names exact commands, versions, results and limitations; no paid model or personal configuration modification. | diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md new file mode 100644 index 000000000..37d1249f8 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md @@ -0,0 +1,42 @@ +--- +status: in-progress +--- + +# Plan: Load AIDD plugins in OpenCode V1 and V2 + +## Overview +| Field | Value | +| --- | --- | +| **Goal** | Generated context hooks and telemetry load and execute in OpenCode V2 while preserving V1 behavior. | +| **Source** | https://github.com/ai-driven-dev/framework/issues/953, its V1 compatibility comment, and the user's request for prior web research and real local tests. | + +## Phases +| # | Phase | File | +| --- | --- | --- | +| 1 | Compatible plugin entrypoints and live verification | [phase-1.md](./phase-1.md) | + +## Resources +| Source | Verified | +| --- | --- | +| https://opencode.ai/v2/docs/build/plugins/migrate-v1 | On 2026-10-07: default definition with id/setup for V2 and server for V1; object compatibility requires V1 1.18.29 or later. | +| https://opencode.ai/v2/docs/build/plugins | On 2026-10-07: setup receives location.directory; event.subscribe returns an asynchronous stream of events and takes an AbortSignal; cleanup cancels the stream. | +| https://opencode.ai/v2/docs | On 2026-10-07: @opencode/cli is the V2 package; version 2.0.22 exists in npm. | +| https://unpkg.com/@opencode/client@2.0.22/dist/promise/generated/types.d.ts | Released V2 events use data/location and granular tool/execution events. The live probe determines which declared events are actually emitted. | +| https://github.com/anomalyco/opencode/blob/v1.18.29/packages/opencode/src/plugin/index.ts | A recognized default.server is called once and the loader returns before its named-export fallback. | +| docs/ARCHITECTURE.md | Generic declarative hooks belong to the CLI bridge; telemetry retains its own journal adapter. No new runtime dependency is needed. | + +## Decisions +| Decision | Why | +| --- | --- | +| Keep existing V1 factories and payload mapping; expose them through server on a default definition. | Preserve behavior and avoid running both entrypoints for one host. Verify against the actual V1 loader. | +| Correlate V2 tool input/start, call and success events before reusing the V1 handler; map execution completion to turn-end. | The real 2.0.22 run emits granular events, not the declared content snapshots or session.idle. Tool names and inputs arrive before completion. Remove pending calls after success or failure. | +| Pin real tests to V1 1.18.29 and V2 2.0.22. | Covers the documented compatibility boundary and the failing V2 release named in the issue. | +| Run real hosts in fresh temporary profiles and projects against a local deterministic model endpoint. | Exercise actual plugin loading, events, memory refresh and journal writes without changing personal configuration or paying for inference. | +| Require observable hook effects, not only the absence of loader errors. | A successfully imported plugin can still do no work. | + +## Constraints and risks +- No unrelated changes to the user's .gitignore or .hermes.md. +- No plugin-local test files: bundled hook tests remain in scripts/__tests__. +- Tests must fail before implementation for the entrypoint or behavior they name. +- V2 events may differ from V1; capture real events and preserve session identity and task declaration. +- OpenCode releases older than 1.18.29 do not support the documented default object contract; document the minimum instead of claiming untested backward compatibility. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md new file mode 100644 index 000000000..440ff4acc --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md @@ -0,0 +1,92 @@ +# OpenCode plugin loading verification + +Source: [framework issue 953](https://github.com/ai-driven-dev/framework/issues/953). +Research and local runtime checks performed on 2026-10-07, macOS ARM64, Node 24.20.0. + +## Research findings + +- [The official migration guide](https://opencode.ai/v2/docs/build/plugins/migrate-v1) requires a default definition with id/setup for V2 and documents server compatibility beginning with V1 1.18.29. +- [The plugin API](https://opencode.ai/v2/docs/build/plugins) supplies location.directory, an asynchronous event subscription, and cleanup through an AbortSignal. +- [Published client 2.0.22 types](https://unpkg.com/@opencode/client@2.0.22/dist/promise/generated/types.d.ts) use data/location instead of V1 properties. A declared event is not proof that the runtime emits it. +- [The V1 1.18.29 loader](https://github.com/anomalyco/opencode/blob/v1.18.29/packages/opencode/src/plugin/index.ts) prefers default.server and returns before processing named factories. Keeping the named test seams does not execute the plugin twice. +- [The V2 2.0.22 execution coordinator](https://github.com/anomalyco/opencode/blob/v2.0.22/packages/core/src/session/execution.ts) emits succeeded, failed, or interrupted. A shutdown interruption preserves the execution claim for a later restart; it must not close the journal turn. + +## Real reproduction before the fix + +The built CLI translated the unchanged framework into a temporary project. The released +OpenCode V2 2.0.22 binary ran a real session and read a task document using a local +OpenAI-compatible deterministic provider. + +Both modules failed with `Plugin must export a default definition` and +`Missing key at ["default"]`. The process nevertheless exited zero. Memory was unchanged; +the journal contained no records. Exit status alone would have falsely passed this test. + +The same unchanged framework under V1 1.18.29 refreshed memory and wrote session_start, +task_declared and turn_end. + +## Real runtime results after the fix + +The temporary source copy added marker hooks to the context plugin's existing hooks.json +for SessionStart, Stop, and PostToolUse with matcher read. The actual built CLI translated +that source; no generated adapter was repaired by hand. The telemetry module was unchanged +from the candidate source. + +Both released binaries exited zero and produced all of these effects: + +- The AGENTS.md memory block referenced the seeded architecture.md. +- Generic hooks fired exactly once each, in order: SessionStart, PostToolUse, Stop. +- PostToolUse carried tool_name read and the actual task-document argument. +- Exactly one session_start, one task_declared and one turn_end were written. +- The session_start vendor_id matched the actual session ID reported by OpenCode. +- Neither adapter produced a plugin load error. + +The V2 observation stream contained session.created, session.tool.input.started, +session.tool.called, session.tool.success and session.execution.succeeded. It contained +neither session.idle nor session.message.content.updated. The adapter therefore correlates +the measured tool events and consumes each success once; merely adding a default export +or adapting the declared content snapshot would not have satisfied this reproduction. + +## Local commands and evidence + +Temporary harness and logs are under /tmp/aidd-953-runtime-ryTstm. The harness preserves +its model requests, stdout, stderr, passive V2 event capture and JSON summaries per case. +It launches the real host with a restricted environment, relocated profile directories, +a temporary git project and a loopback model endpoint. No personal credentials are passed. + +```sh +npm install --prefix /tmp/aidd-953-runtime-ryTstm/v1 --no-audit --no-fund opencode-ai@1.18.29 +npm install --prefix /tmp/aidd-953-runtime-ryTstm/v2 --no-audit --no-fund @opencode/cli@2.0.22 +pnpm --dir cli build +python3 /tmp/aidd-953-runtime-ryTstm/prepare.py +node cli/dist/cli.js translate /tmp/aidd-953-runtime-ryTstm/fixture-source --to opencode --as flat --out /tmp/aidd-953-runtime-ryTstm/candidate --force +python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candidate v1 +python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candidate v2 +``` + +The V2 command uses run --standalone; neither test starts a persistent background service. +The installed personal OpenCode 1.14.20 and its profile were not upgraded or used. + +## Automated validation + +- Full CLI suite: 6,807 passed, one opt-in Kilo runtime test skipped; 529 files passed. +- Focused OpenCode and cost-documentation scripts: 48 passed, after observing the stale documentation assertion fail first. +- All 140 architecture checks passed without increasing comment or empty-catch baselines. +- Lint, TypeScript, type honesty, knip, duplicate-code budget and whitespace checks passed. Lint retains one existing unused-private-member warning in uninstall-use-case.ts, outside this change. +- Built CLI: 730.3 KB, below the existing 734 KB budget. +- Both emitted plugin files passed node --check. +- An isolated mutation removing success consumption made the duplicate-task regression fail; the production source was not mutated. + +Architecture conformance: no macro violations; generic translation stays in the tools +profile, telemetry observes through its existing adapter, and hook tests remain outside +shipped plugin trees. No micro violations: the source generator remains a pure transform +and the generated runtime gains no package dependency or cross-plugin import. + +## Scope of the evidence + +Real runtime coverage is a single-project successful session under V1 1.18.29 and V2 +2.0.22. Regression tests exercise stream cleanup, malformed events, incomplete and failed +tools, repeated success, concurrent tool identities, and execution termination behavior. +Paid inference, global-profile installation, other operating systems and a running host's +hot reload are not claimed by this local proof. The local deterministic model substitutes +only inference; OpenCode's plugin loader, event stream, read tool, hook processes and +journal are real. diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts index 510d8cc0d..76742bed0 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts @@ -2,7 +2,7 @@ * Generates OpenCode's event bridge for one plugin's hooks.json. OpenCode's loader scans no * "hooks" family and this profile writes no hooks.json, so without this module a plugin's * declared hooks have no trigger on OpenCode at all. The generated file is a real OpenCode - * plugin, one function-valued export, spawning the same scripts every other host's hooks.json + * plugin with V1/server and V2/setup entrypoints, spawning the scripts every host's hooks.json * already names over the stdin-JSON contract those scripts already read. * * Only three events map; anything else is dropped, OpenCode's plugin surface delivering no @@ -12,7 +12,7 @@ * server/directory, not once per session, since `session.created` is published on OpenCode's * bus but was never observed delivered to a plugin's `event` hook. Safe only for an * idempotent hook, which every `SessionStart` hook this generator sees today is. - * - `Stop` maps to `session.idle`, delivered once per turn. + * - `Stop` maps to V1's `session.idle` or V2's execution terminal, once per turn. * - `PostToolUse` maps to `message.part.updated` whose `part.state.status === "completed"`, the * one shape measured live: `part.tool` names the tool, `part.state.input` its arguments. * `tool.execute.after` reads cleaner in OpenCode's own docs but is a separate named hook @@ -210,12 +210,13 @@ export const ${ident} = async (input) => { return { event: async ({ event }) => { try { + const directory = event?.properties?.directory ?? input.directory; const calls = [ - ...stopCallsFor(event, input.directory), - ...postToolUseCallsFor(event, input.directory), + ...stopCallsFor(event, directory), + ...postToolUseCallsFor(event, directory), ]; for (const call of calls) { - runHook(call.script, call.args, call.payload, input.directory); + runHook(call.script, call.args, call.payload, directory); } } catch { // Silent on purpose - see above. @@ -226,5 +227,72 @@ export const ${ident} = async (input) => { ${ident}.stopCallsFor = stopCallsFor; ${ident}.postToolUseCallsFor = postToolUseCallsFor; + +// V2's tool lifecycle splits the name, arguments and completion across three events. +// Keep only in-flight calls; consuming success once prevents duplicate PostToolUse calls. +function eventsForV2(event, toolCalls) { + const data = event?.data; + if (typeof data?.sessionID !== "string") return []; + const properties = { sessionID: data.sessionID, directory: event.location?.directory }; + if (event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || + event.type === "session.execution.interrupted") { + for (const [key, call] of toolCalls) { + if (call.sessionID === data.sessionID) toolCalls.delete(key); + } + if (event.type === "session.execution.interrupted" && data.reason === "shutdown") return []; + return [{ type: "session.idle", properties }]; + } + if (typeof data.assistantMessageID !== "string" || typeof data.id !== "string") return []; + const key = JSON.stringify([data.sessionID, data.assistantMessageID, data.id]); + if (event.type === "session.tool.input.started" && typeof data.name === "string") { + toolCalls.set(key, { sessionID: data.sessionID, name: data.name, directory: properties.directory }); + } else if (event.type === "session.tool.called") { + const call = toolCalls.get(key); + if (call) { + call.input = data.input; + call.directory = properties.directory ?? call.directory; + } + } else if (event.type === "session.tool.success") { + const call = toolCalls.get(key); + toolCalls.delete(key); + if (call?.input === undefined) return []; + return [{ type: "message.part.updated", properties: { + ...properties, directory: properties.directory ?? call.directory, + part: { type: "tool", tool: call.name, state: { status: "completed", input: call.input } }, + } }]; + } else if (event.type === "session.tool.failed") { + toolCalls.delete(key); + } + return []; +} + +export default { + id: ${JSON.stringify(`${plugin}-hooks`)}, + server: ${ident}, + setup: async (ctx) => { + const controller = new AbortController(); + const toolCalls = new Map(); + const hooks = await ${ident}({ directory: ctx.location.directory }); + // The subscription lives until cleanup. Awaiting it here would block plugin startup. + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (controller.signal.aborted) break; + for (const mapped of eventsForV2(event, toolCalls)) { + await hooks.event({ event: mapped }); + } + } + } catch { + // Stream failures and cancellation must not reject into the host. + controller.abort(); + toolCalls.clear(); + } + })(); + return () => { + controller.abort(); + toolCalls.clear(); + }; + }, +}; `; } diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts index c633609e0..4e2e847a1 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts @@ -24,6 +24,15 @@ const THREE_EVENT_HOOKS_JSON = JSON.stringify({ }); describe("generateOpencodeHooksBridge", () => { + it("defines the V1 server and V2 setup entrypoints on the default plugin", () => { + const generated = generateOpencodeHooksBridge(THREE_EVENT_HOOKS_JSON, "aidd-sample"); + + expect(generated).toContain("export default {"); + expect(generated).toContain('id: "aidd-sample-hooks"'); + expect(generated).toContain("server: AiddSampleHooks"); + expect(generated).toContain("setup:"); + }); + it("generates a bridge module for a hooks.json naming all three mapped events", () => { const generated = generateOpencodeHooksBridge(THREE_EVENT_HOOKS_JSON, "aidd-sample"); @@ -119,12 +128,13 @@ describe("generateOpencodeHooksBridge", () => { return { event: async ({ event }) => { try { + const directory = event?.properties?.directory ?? input.directory; const calls = [ - ...stopCallsFor(event, input.directory), - ...postToolUseCallsFor(event, input.directory), + ...stopCallsFor(event, directory), + ...postToolUseCallsFor(event, directory), ]; for (const call of calls) { - runHook(call.script, call.args, call.payload, input.directory); + runHook(call.script, call.args, call.payload, directory); } } catch { // Silent on purpose - see above. @@ -135,6 +145,73 @@ describe("generateOpencodeHooksBridge", () => { AiddSampleHooks.stopCallsFor = stopCallsFor; AiddSampleHooks.postToolUseCallsFor = postToolUseCallsFor; + + // V2's tool lifecycle splits the name, arguments and completion across three events. + // Keep only in-flight calls; consuming success once prevents duplicate PostToolUse calls. + function eventsForV2(event, toolCalls) { + const data = event?.data; + if (typeof data?.sessionID !== "string") return []; + const properties = { sessionID: data.sessionID, directory: event.location?.directory }; + if (event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || + event.type === "session.execution.interrupted") { + for (const [key, call] of toolCalls) { + if (call.sessionID === data.sessionID) toolCalls.delete(key); + } + if (event.type === "session.execution.interrupted" && data.reason === "shutdown") return []; + return [{ type: "session.idle", properties }]; + } + if (typeof data.assistantMessageID !== "string" || typeof data.id !== "string") return []; + const key = JSON.stringify([data.sessionID, data.assistantMessageID, data.id]); + if (event.type === "session.tool.input.started" && typeof data.name === "string") { + toolCalls.set(key, { sessionID: data.sessionID, name: data.name, directory: properties.directory }); + } else if (event.type === "session.tool.called") { + const call = toolCalls.get(key); + if (call) { + call.input = data.input; + call.directory = properties.directory ?? call.directory; + } + } else if (event.type === "session.tool.success") { + const call = toolCalls.get(key); + toolCalls.delete(key); + if (call?.input === undefined) return []; + return [{ type: "message.part.updated", properties: { + ...properties, directory: properties.directory ?? call.directory, + part: { type: "tool", tool: call.name, state: { status: "completed", input: call.input } }, + } }]; + } else if (event.type === "session.tool.failed") { + toolCalls.delete(key); + } + return []; + } + + export default { + id: "aidd-sample-hooks", + server: AiddSampleHooks, + setup: async (ctx) => { + const controller = new AbortController(); + const toolCalls = new Map(); + const hooks = await AiddSampleHooks({ directory: ctx.location.directory }); + // The subscription lives until cleanup. Awaiting it here would block plugin startup. + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (controller.signal.aborted) break; + for (const mapped of eventsForV2(event, toolCalls)) { + await hooks.event({ event: mapped }); + } + } + } catch { + // Stream failures and cancellation must not reject into the host. + controller.abort(); + toolCalls.clear(); + } + })(); + return () => { + controller.abort(); + toolCalls.clear(); + }; + }, + }; " `); }); diff --git a/cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts b/cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts index ab1485454..816a66c16 100644 --- a/cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts +++ b/cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts @@ -13,7 +13,11 @@ import { createTestEnv, FRAMEWORK_PATH, runCli } from "./helpers.js"; const execFileAsync = promisify(execFile); -const STOP_SCRIPT = 'require("node:fs").writeFileSync("marker.txt", "spawned");\n'; +const HOOK_SCRIPT = `let input = ""; +process.stdin.on("data", (chunk) => { input += chunk; }); +process.stdin.on("end", () => { + require("node:fs").appendFileSync("marker.jsonl", input + "\\n"); +});\n`; // Concatenated, since biome reads a plain string holding "${...}" as a forgotten template // literal. @@ -33,20 +37,29 @@ async function waitForFile(path: string, timeoutMs: number): Promise { } describe("opencode's generated event bridge, against the real build", () => { - it("imports safely with a live argv, spawns nothing on import, and spawns the Stop script on session.idle", async () => { + it("imports safely and executes SessionStart, Stop and PostToolUse through V1 server and V2 setup", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("oc-hooks-bridge"); try { const sourceDir = join(tempDir, "source"); await cp(FRAMEWORK_PATH, sourceDir, { recursive: true }); const hooksDir = join(sourceDir, "plugins", "aidd-test", "hooks"); - await writeFile(join(hooksDir, "marker.js"), STOP_SCRIPT, "utf-8"); + await writeFile(join(hooksDir, "marker.js"), HOOK_SCRIPT, "utf-8"); await writeFile( join(hooksDir, "hooks.json"), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: "command", command: `${ROOT}/hooks/check.sh` }] }], + SessionStart: [ + { hooks: [{ type: "command", command: `node ${ROOT}/hooks/marker.js` }] }, + ], Stop: [{ hooks: [{ type: "command", command: `node ${ROOT}/hooks/marker.js` }] }], + PostToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: `node ${ROOT}/hooks/marker.js` }], + }, + ], }, }), "utf-8" @@ -73,25 +86,86 @@ describe("opencode's generated event bridge, against the real build", () => { { cwd: outDir, timeout: 5000 } ); expect(stdout).toContain("HOST ALIVE"); - expect(existsSync(join(outDir, "marker.txt"))).toBe(false); + expect(existsSync(join(outDir, "marker.jsonl"))).toBe(false); // Driven in its own child, so the marker file lands relative to a cwd this test // controls. const driverPath = join(tempDir, "drive-session-idle.mjs"); await writeFile( driverPath, - `const mod = await import(${JSON.stringify(pathToFileURL(bridgePath).href)}); - const factories = Object.keys(mod).filter((k) => typeof mod[k] === "function"); - const hooks = await mod[factories[0]]({ directory: process.argv[2] }); - await hooks.event({ event: { type: "session.idle", properties: { sessionID: "s1" } } }); + `import assert from "node:assert/strict"; + const { default: plugin } = await import(${JSON.stringify(pathToFileURL(bridgePath).href)}); + assert.equal(plugin.id, "aidd-test-hooks"); + const events = [null, { type: "server.connected" }, + { type: "session.idle", properties: { sessionID: "s1" } }, + { type: "message.part.updated", properties: { sessionID: "s1", part: { + type: "tool", tool: "Bash", state: { status: "completed", input: { command: "echo task" } } + } } }]; + if (process.argv[3] === "v1") { + const hooks = await plugin.server({ directory: process.argv[2] }); + for (const event of events) await hooks.event({ event }); + } else { + const call = { sessionID: "s1", assistantMessageID: "msg1", id: "call1" }; + const stale = { ...call, id: "stale" }; + const v2Events = [null, { type: "server.connected" }, + { type: "session.tool.input.started", data: { ...stale, name: "Bash" } }, + { type: "session.tool.called", data: { ...stale, input: { command: "echo stale" }, executed: false } }, + { type: "session.execution.interrupted", data: { sessionID: "s1", reason: "shutdown" } }, + { type: "session.tool.success", data: { ...stale, content: [], executed: false } }, + { type: "session.tool.input.started", data: { ...call, name: "Bash" } }, + { type: "session.tool.called", data: { ...call, input: { command: "echo task" }, executed: false } }, + { type: "session.tool.success", data: { ...call, content: [], executed: false } }, + { type: "session.tool.success", data: { ...call, content: [], executed: false } }, + { type: "session.execution.succeeded", data: { sessionID: "s1" } }]; + let signal; + let closed = false; + let done; + const processed = new Promise((resolve) => { done = resolve; }); + const cleanup = await plugin.setup({ location: { directory: process.argv[2] }, event: { + subscribe(options) { + signal = options.signal; + return (async function* () { + try { + for (const event of v2Events) yield event; + done(); + await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true })); + throw new Error("subscription aborted"); + } finally { closed = true; } + })(); + } + } }); + assert.equal(typeof cleanup, "function"); + await processed; + cleanup(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(signal.aborted, true); + assert.equal(closed, true); + } `, "utf-8" ); - await execFileAsync(process.execPath, [driverPath, outDir], { timeout: 5000 }); - - const markerWritten = await waitForFile(join(outDir, "marker.txt"), 4000); - expect(markerWritten).toBe(true); - expect(await readFile(join(outDir, "marker.txt"), "utf-8")).toBe("spawned"); + for (const host of ["v1", "v2"]) { + const directory = join(outDir, host); + await mkdir(directory); + await execFileAsync(process.execPath, [driverPath, directory, host], { timeout: 5000 }); + const marker = join(directory, "marker.jsonl"); + expect(await waitForFile(marker, 4000)).toBe(true); + const calls = (await readFile(marker, "utf-8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls.map((call) => call.hook_event_name).sort()).toEqual([ + "PostToolUse", + "SessionStart", + "Stop", + ]); + expect(calls.every((call) => call.cwd === directory)).toBe(true); + expect(calls.find((call) => call.hook_event_name === "PostToolUse")).toMatchObject({ + session_id: "s1", + tool_name: "Bash", + tool_input: { command: "echo task" }, + }); + } } finally { await cleanup(); } diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index bd0495052..2ebea61b4 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -1748,7 +1748,7 @@ ".opencode/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".opencode/plugin/aidd-context-hooks.js": "7c38ebdedacd85321f3f475769ef814ba355271254a14e9b44cdcd684467ab71", + ".opencode/plugin/aidd-context-hooks.js": "fe994ab2dac7061b8062a53beaecb8661de957b714e1fd9851efa1d6d7924ba9", ".opencode/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".opencode/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", ".opencode/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 486617d92..fdff43349 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,7 +50,14 @@ A hook is authored once, with `${CLAUDE_PLUGIN_ROOT}`, and the installer rewrite | Codex | yes | `${PLUGIN_ROOT}` | Measured: it expands `${CLAUDE_PLUGIN_ROOT}` too, and will not run a hook it has not been asked to trust | | GitHub Copilot| yes | `${PLUGIN_ROOT}` | Declared, never observed against a running hook | | Cursor | declared | `./` | Its own hook format: the converter rewrites the root to a path relative to the plugin before the declared token is ever substituted. Two headless probes fired no plugin hook at all, and what registers a plugin sitting in Cursor's own plugin directory was not identified | -| OpenCode | no, by a second route | β€” | A declarative `hooks.json` means nothing to it β€” its plugin runtime is JS modules, so every other plugin's `hooks.json` is translated into one at build time (`opencode-hooks-bridge.ts`, one generated `-hooks.js` per plugin, `SessionStart`/`Stop`/`PostToolUse` only). `aidd-telemetry` ships its own hand-written entry instead (`plugins/aidd-telemetry/hooks/opencode-plugin.js`, no generated bridge for it) because its journal needs a stdin dialect the generated one does not speak: `session.created` maps to session-start, `session.idle` to turn-end, and (2026-08-31) a completed tool part on `message.part.updated` to tool-used. The column above is about the declarative axis alone; a tool answering `no` there is not a tool that cannot journal | +| OpenCode | no, by a second route | β€” | Declarative hooks are translated by `opencode-hooks-bridge.ts` into `-hooks.js` modules for `SessionStart`, `Stop`, and `PostToolUse`. Telemetry supplies its own adapter in `plugins/aidd-telemetry/hooks/opencode-plugin.js` because its journal uses a different stdin dialect. Both expose a default definition: `server` for V1 1.18.29 or later, `setup` for V2. The declarative hook support column does not describe this module route. | + +OpenCode V1 delivers `session.idle` and completed `message.part.updated` tool parts to an +`event` hook. V2 adapters subscribe to `data`-based events with an abortable stream, correlate +tool name and input with tool success, and translate execution terminals into turn-end. +Shutdown interruptions leave the turn open because OpenCode resumes that execution. +The generic bridge fires its idempotent `SessionStart` hooks when either host initializes +the module; telemetry records sessions separately from actual session events. A tool that runs no hook says why, and an install that carries one tells whoever ran it what was skipped. diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index 5090f6168..f05fa09dd 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -102,11 +102,12 @@ A limit a reader has to look up gets read as a zero, so each one is named here: - **Codex needs one interactive approval.** Its hook trust is per entry and a headless run never sees the prompt, so a Codex session journals nothing until someone approves once. -- **OpenCode never announces a session, so the plugin opens it.** `session.created` is - published on its bus but never reaches the hook, so the first call a session produces - opens it, under the directory that call was going to use. What is lost: on a server - serving several directories, a session it never announced is - journalled under the plugin's own init-time directory. +- **OpenCode V1 can omit the session announcement.** The first journalable event opens + an unannounced session. Without a known session directory, it uses the plugin's startup + directory, which can be wrong for a server serving several projects. V2 session events + provide their own directory; tool completion and execution completion reach the journal + through the V2 event subscription. The adapter targets V1 1.18.29 or later and V2; + older V1 releases do not support its default plugin definition. - **OpenCode never names a step.** Its plugin forwards task paths and nothing else, so no skill invocation reaches the journal and every OpenCode request is unattributed by step. - **These are raw counters, not your tool's usage screen.** A vendor's page weights a cached diff --git a/plugins/aidd-telemetry/hooks/opencode-plugin.js b/plugins/aidd-telemetry/hooks/opencode-plugin.js index 26d30144b..0dc2abf94 100644 --- a/plugins/aidd-telemetry/hooks/opencode-plugin.js +++ b/plugins/aidd-telemetry/hooks/opencode-plugin.js @@ -81,7 +81,7 @@ function declaredTaskCallFor(event, sessionDirectories, fallbackDirectory) { if (part?.type !== "tool" || part.state?.status !== "completed") return null; if (!mightDeclareATask(part.state.input)) return null; const sessionId = event.properties.sessionID; - const cwd = sessionDirectories.get(sessionId) ?? fallbackDirectory; + const cwd = event.properties.directory ?? sessionDirectories.get(sessionId) ?? fallbackDirectory; return { script: "tool-used", payload: { tool: "opencode", session_id: sessionId, cwd, tool_input: part.state.input }, @@ -103,7 +103,8 @@ function journalCallFor(event, sessionDirectories, fallbackDirectory) { } if (event.type === "session.idle") { const sessionId = event.properties.sessionID; - const cwd = sessionDirectories.get(sessionId) ?? fallbackDirectory; + const cwd = + event.properties.directory ?? sessionDirectories.get(sessionId) ?? fallbackDirectory; return { script: "turn-end", payload: { tool: "opencode", session_id: sessionId, cwd } }; } if (event.type === "message.part.updated") { @@ -161,3 +162,97 @@ export const AiddTelemetry = async (input) => ({ // journalCallFor's own comment for why a second export is ruled out. AiddTelemetry.journalCallFor = journalCallFor; AiddTelemetry.journalCallsFor = journalCallsFor; + +// V2's tool lifecycle splits the name, arguments and completion across three events. +// Keep only in-flight calls; consuming success once prevents duplicate PostToolUse calls. +function eventsForV2(event, toolCalls, directory) { + const data = event?.data; + if (typeof data?.sessionID !== "string") return []; + if (event.type === "session.created") { + return [ + { + type: "session.created", + properties: { + info: { id: data.sessionID, directory: data.location?.directory ?? directory }, + }, + }, + ]; + } + const properties = { sessionID: data.sessionID, directory: event.location?.directory }; + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) { + for (const [key, call] of toolCalls) { + if (call.sessionID === data.sessionID) toolCalls.delete(key); + } + if (event.type === "session.execution.interrupted" && data.reason === "shutdown") return []; + return [{ type: "session.idle", properties }]; + } + if (typeof data.assistantMessageID !== "string" || typeof data.id !== "string") return []; + const key = JSON.stringify([data.sessionID, data.assistantMessageID, data.id]); + if (event.type === "session.tool.input.started" && typeof data.name === "string") { + toolCalls.set(key, { + sessionID: data.sessionID, + name: data.name, + directory: properties.directory, + }); + } else if (event.type === "session.tool.called") { + const call = toolCalls.get(key); + if (call) { + call.input = data.input; + call.directory = properties.directory ?? call.directory; + } + } else if (event.type === "session.tool.success") { + const call = toolCalls.get(key); + toolCalls.delete(key); + if (call?.input === undefined) return []; + return [ + { + type: "message.part.updated", + properties: { + ...properties, + directory: properties.directory ?? call.directory, + part: { + type: "tool", + tool: call.name, + state: { status: "completed", input: call.input }, + }, + }, + }, + ]; + } else if (event.type === "session.tool.failed") { + toolCalls.delete(key); + } + return []; +} + +export default { + id: "aidd-telemetry", + server: AiddTelemetry, + setup: async (ctx) => { + const controller = new AbortController(); + const toolCalls = new Map(); + const hooks = await AiddTelemetry({ directory: ctx.location.directory }); + // The subscription lives until cleanup. Awaiting it here would block plugin startup. + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (controller.signal.aborted) break; + for (const mapped of eventsForV2(event, toolCalls, ctx.location.directory)) { + await hooks.event({ event: mapped }); + } + } + } catch { + // Stream failures and cancellation must not reject into the host. + controller.abort(); + toolCalls.clear(); + } + })(); + return () => { + controller.abort(); + toolCalls.clear(); + }; + }, +}; diff --git a/scripts/__tests__/aidd-telemetry-cost-skill.test.js b/scripts/__tests__/aidd-telemetry-cost-skill.test.js index 5e9f646fc..914f716d9 100644 --- a/scripts/__tests__/aidd-telemetry-cost-skill.test.js +++ b/scripts/__tests__/aidd-telemetry-cost-skill.test.js @@ -179,17 +179,18 @@ test("the plugin README gives every partly-measurable tool its reason, not just assert.ok(readme.includes(tool), `${tool} is named`); assert.ok(readme.includes(reason), `${tool}'s reason, not just its name`); } - // OpenCode's limit shrank rather than vanished: the plugin now opens a session OpenCode - // never announced, so a run is journalled and readable - but a session it never announced - // is journalled under the plugin's own directory, which is only right when the server - // serves one. Both halves are pinned: the fact, and what it still costs. + // V1 can omit an announcement; V2 supplies the session directory. Keep V1's fallback + // and its multi-project cost explicit instead of presenting both versions as equivalent. + const readmeText = readme.replace(/\s+/gu, " "); assert.ok( - readme.includes("OpenCode never announces a session"), - "OpenCode's unannounced session is named, not silently dropped once it could declare a task" + readmeText.includes("OpenCode V1 can omit the session announcement"), + "OpenCode's missing announcement is explicitly scoped to V1" ); assert.ok( - readme.includes("journalled under the plugin's own init-time directory"), - "what an unannounced session still costs is named, not left as a solved problem" + readmeText.includes( + "Without a known session directory, it uses the plugin's startup directory, which can be wrong for a server serving several projects." + ), + "the startup-directory fallback and its multi-project cost remain explicit" ); }); @@ -381,4 +382,3 @@ test("the cost skill writes an artefact to a file when a file is what was asked "an artefact must name the period and axis it came from" ); }); - diff --git a/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js b/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js index 1d3653b03..5585bf2a9 100644 --- a/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js +++ b/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js @@ -49,10 +49,13 @@ async function journalCallsFor() { // factory of its own, and a second such export returning `null` kills `opencode run` before // any session starts. A non-function export is ignored by that same loader, which is why the // spawn-free seam rides on the plugin function as a property. -test("the plugin file exports one plugin factory, never a second one OpenCode would call", async () => { +test("the default definition selects its one V1 factory and exposes V2 setup", async () => { const exported = await pluginModule(); const factories = Object.keys(exported).filter((name) => typeof exported[name] === "function"); assert.deepEqual(factories, ["AiddTelemetry"]); + assert.equal(exported.default.id, "aidd-telemetry"); + assert.equal(exported.default.server, exported.AiddTelemetry); + assert.equal(typeof exported.default.setup, "function"); }); // `opencode run` is always a session OpenCode never announced: `session.created` is published diff --git a/scripts/__tests__/opencode-plugin.test.js b/scripts/__tests__/opencode-plugin.test.js index 384f07c23..dab826e30 100644 --- a/scripts/__tests__/opencode-plugin.test.js +++ b/scripts/__tests__/opencode-plugin.test.js @@ -143,3 +143,175 @@ test("opencode-plugin.js: an event whose own shape breaks journal call resolutio await assert.doesNotReject(hooks.event({ event: null })); assert.deepEqual(readRunLines(repo), [], "a swallowed error must write no journal line either"); }); + +test("opencode-plugin.js: default server preserves V1 journal writes", async () => { + const { repo, esmTwin } = makeInstalledRepo(); + const { default: plugin } = await import(pathToFileURL(esmTwin).href); + + assert.equal(plugin.id, "aidd-telemetry"); + assert.deepEqual(readRunLines(repo), []); + const hooks = await plugin.server({ directory: repo }); + await hooks.event({ + event: { type: "session.idle", properties: { sessionID: "ses_default_v1" } }, + }); + + assert.deepEqual( + readRunLines(repo).map((line) => line.type), + ["session_start", "turn_end"] + ); +}); + +test("opencode-plugin.js: V2 setup returns promptly, handles raw events and cancels its stream", { + timeout: 5000, +}, async () => { + const { repo, esmTwin } = makeInstalledRepo(); + const { default: plugin } = await import(pathToFileURL(esmTwin).href); + let signal; + let streamClosed = false; + const cleanup = await plugin.setup({ + location: { directory: repo }, + event: { + subscribe(options) { + signal = options.signal; + return (async function* () { + try { + yield null; + yield { type: "server.connected" }; + yield { + type: "session.created", + data: { sessionID: "ses_default_v2", location: { directory: repo } }, + }; + const call = { sessionID: "ses_default_v2", assistantMessageID: "msg_1", id: "call_1" }; + const failed = { ...call, id: "call_failed" }; + yield { type: "session.tool.input.started", data: { ...failed, name: "read" } }; + yield { + type: "session.tool.called", + data: { + ...failed, + input: { path: "aidd_docs/tasks/2026_10/failed/plan.md" }, + executed: false, + }, + }; + yield { type: "session.tool.failed", data: { ...failed, error: "read failed" } }; + yield { + type: "session.tool.success", + data: { ...failed, content: [], executed: false }, + }; + yield { type: "session.tool.input.started", data: { ...call, name: "read" } }; + yield { + type: "session.tool.called", + data: { + ...call, + input: { path: "aidd_docs/tasks/2026_10/plugin-loading/plan.md" }, + executed: false, + }, + }; + yield { + type: "session.tool.success", + data: { ...call, content: [{ type: "text", text: "task" }], executed: false }, + }; + yield { + type: "session.tool.success", + data: { ...call, content: [{ type: "text", text: "task" }], executed: false }, + }; + yield { type: "session.execution.succeeded", data: { sessionID: "ses_default_v2" } }; + await new Promise((resolve) => + signal.addEventListener("abort", resolve, { once: true }) + ); + throw new Error("subscription aborted"); + } finally { + streamClosed = true; + } + })(); + }, + }, + }); + + assert.equal(typeof cleanup, "function"); + for (let attempt = 0; attempt < 100 && readRunLines(repo).length < 3; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + const lines = readRunLines(repo); + assert.deepEqual( + lines.map((line) => line.type), + ["session_start", "task_declared", "turn_end"] + ); + assert.equal(lines[0].vendor_id, "ses_default_v2"); + assert.equal(lines[1].path, "aidd_docs/tasks/2026_10/plugin-loading/plan.md"); + cleanup(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(signal.aborted, true); + assert.equal(streamClosed, true); +}); + +test("opencode-plugin.js: a failed V2 subscription never rejects into the host", async () => { + const { repo, esmTwin } = makeInstalledRepo(); + const { default: plugin } = await import(pathToFileURL(esmTwin).href); + let signal; + const cleanup = await plugin.setup({ + location: { directory: repo }, + event: { + subscribe: async function* (options) { + signal = options.signal; + yield { type: "server.connected" }; + throw new Error("stream unavailable"); + }, + }, + }); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(signal.aborted, true); + cleanup(); + assert.deepEqual(readRunLines(repo), []); +}); + +test("opencode-plugin.js: V2 failed and interrupted turns end on their own session, shutdown leaves it resumable", async () => { + for (const [type, reason, expected] of [ + ["session.execution.failed", undefined, ["session_start", "turn_end"]], + ["session.execution.interrupted", "user", ["session_start", "turn_end"]], + ["session.execution.interrupted", "shutdown", ["session_start"]], + ]) { + const { repo, esmTwin } = makeInstalledRepo(); + const { default: plugin } = await import(pathToFileURL(esmTwin).href); + let done; + const streamed = new Promise((resolve) => { + done = resolve; + }); + const cleanup = await plugin.setup({ + location: { directory: repo }, + event: { + subscribe: async function* () { + yield { + type: "session.created", + data: { sessionID: "ses_child", location: { directory: repo } }, + }; + const call = { + sessionID: "ses_child", + assistantMessageID: "msg_pending", + id: "call_pending", + }; + yield { type: "session.tool.input.started", data: { ...call, name: "read" } }; + yield { + type: "session.tool.called", + data: { + ...call, + input: { path: "aidd_docs/tasks/2026_10/stale/plan.md" }, + executed: false, + }, + }; + yield { type, data: { sessionID: "ses_child", reason, error: "tool failed" } }; + yield { type: "session.tool.success", data: { ...call, content: [], executed: false } }; + done(); + }, + }, + }); + await streamed; + cleanup(); + const lines = readRunLines(repo); + assert.deepEqual( + lines.map((line) => line.type), + expected + ); + assert.equal(lines[0].vendor_id, "ses_child"); + } +}); From 3a2c3df69b1509e94b7417e0a735a9f3ab651e2d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 16:14:21 +0200 Subject: [PATCH 02/23] docs(framework): record completed OpenCode compatibility validation AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md | 2 +- .../2026_10/2026_10_07_opencode-plugin-loading/verification.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md index 37d1249f8..5659f3406 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md @@ -1,5 +1,5 @@ --- -status: in-progress +status: implemented --- # Plan: Load AIDD plugins in OpenCode V1 and V2 diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md index 440ff4acc..7ab4f8693 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md @@ -68,6 +68,7 @@ The installed personal OpenCode 1.14.20 and its profile were not upgraded or use ## Automated validation +- Full repository scripts suite through the normal commit hook: 554 passed, zero failures; the hook also verified that tests left git hooks unchanged. - Full CLI suite: 6,807 passed, one opt-in Kilo runtime test skipped; 529 files passed. - Focused OpenCode and cost-documentation scripts: 48 passed, after observing the stale documentation assertion fail first. - All 140 architecture checks passed without increasing comment or empty-catch baselines. From ae500bfb0b09bf3f2ea225ec030749aad156bbc2 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 16:46:10 +0200 Subject: [PATCH 03/23] fix(opencode): share runtime adapter across plugin delivery paths AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../configs/opencode/opencode-events.js.txt | 89 +++++++++++++ .../install-runtime-config-use-case.ts | 22 +++- .../user-plugin-distribution-loader.ts | 22 ---- .../ownership/user-plugin-file-updater.ts | 4 +- .../application/plugin/plugin-add-use-case.ts | 14 +- .../plugin/plugin-distribution-loader.ts | 30 +++++ .../plugin/plugin-update-use-case.ts | 14 +- .../generate-tool-distribution-use-case.ts | 3 +- cli/src/contexts/tools/domain/contracts.ts | 2 + .../tools/domain/profiles/opencode/build.ts | 12 ++ .../opencode/opencode-hooks-bridge.ts | 64 +-------- .../profiles/opencode/opencode-paths.ts | 3 + .../tools/domain/profiles/opencode/profile.ts | 3 + cli/src/runtime/assets/asset-loader.ts | 3 +- cli/src/runtime/assets/text-assets.d.ts | 5 + cli/src/runtime/wiring/framework.ts | 32 ++--- .../native-plugin-claims.integration.test.ts | 6 +- ...add-hooks-trust-notice.integration.test.ts | 4 +- ...opencode-hooks-install.integration.test.ts | 7 +- .../user-plugin-file-updater.unit.test.ts | 4 +- .../user-plugin-update.integration.test.ts | 4 +- .../plugin/plugin-add-mcp.unit.test.ts | 4 +- .../plugin-add-skip-warn.integration.test.ts | 7 +- .../plugin/plugin-add-use-case.unit.test.ts | 85 +++++++----- .../plugin-distribution-loader.unit.test.ts | 52 ++++++++ ...all-from-marketplace-use-case.unit.test.ts | 4 +- .../plugin-remove-use-case.unit.test.ts | 4 +- .../plugin-runtime-files.integration.test.ts | 121 ++++++++++++++++++ .../plugin-update-built-tree.unit.test.ts | 9 +- ...gin-update-mode-a-marketplace.unit.test.ts | 7 +- .../plugin-update-use-case.unit.test.ts | 7 +- .../restore-all-use-case.unit.test.ts | 7 +- .../application/restore-use-case.unit.test.ts | 4 +- ...te-tool-distribution-use-case.unit.test.ts | 8 ++ ...apply-plugin-files-built-tree.unit.test.ts | 4 +- ...ugin-files-mode-a-marketplace.unit.test.ts | 4 +- .../application/uninstall-plugin.unit.test.ts | 4 +- .../profiles/opencode/build.unit.test.ts | 26 ++++ ...e-hooks-bridge-mapping.integration.test.ts | 13 +- .../opencode-hooks-bridge.unit.test.ts | 64 +-------- ...t-build-strategy.hooks.integration.test.ts | 6 +- .../flat-build-strategy.integration.test.ts | 3 + .../snapshots/framework-build/golden.json | 3 +- .../prompts/plugin-pick-use-case.unit.test.ts | 4 +- .../runtime/assets/asset-loader.unit.test.ts | 7 + cli/tsup.config.ts | 1 + cli/vitest.config.ts | 2 +- cli/vitest.workspace.ts | 2 +- docs/ARCHITECTURE.md | 10 +- .../aidd-telemetry/hooks/opencode-plugin.js | 91 +------------ .../aidd-telemetry-opencode-payloads.test.js | 14 +- .../comments-name-files-that-exist.test.js | 16 ++- scripts/__tests__/opencode-plugin.test.js | 4 + 53 files changed, 582 insertions(+), 362 deletions(-) create mode 100644 cli/assets/configs/opencode/opencode-events.js.txt delete mode 100644 cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts create mode 100644 cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts create mode 100644 cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts diff --git a/cli/assets/configs/opencode/opencode-events.js.txt b/cli/assets/configs/opencode/opencode-events.js.txt new file mode 100644 index 000000000..da543c0f0 --- /dev/null +++ b/cli/assets/configs/opencode/opencode-events.js.txt @@ -0,0 +1,89 @@ +// V2's tool lifecycle splits the name, arguments and completion across three events. +// Keep only in-flight calls; consuming success once prevents duplicate PostToolUse calls. +function eventsForV2(event, toolCalls, directory) { + const data = event?.data; + if (typeof data?.sessionID !== "string") return []; + if (event.type === "session.created") { + return [ + { + type: "session.created", + properties: { + info: { id: data.sessionID, directory: data.location?.directory ?? directory }, + }, + }, + ]; + } + const properties = { sessionID: data.sessionID, directory: event.location?.directory }; + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) { + for (const [key, call] of toolCalls) { + if (call.sessionID === data.sessionID) toolCalls.delete(key); + } + if (event.type === "session.execution.interrupted" && data.reason === "shutdown") return []; + return [{ type: "session.idle", properties }]; + } + if (typeof data.assistantMessageID !== "string" || typeof data.id !== "string") return []; + const key = JSON.stringify([data.sessionID, data.assistantMessageID, data.id]); + if (event.type === "session.tool.input.started" && typeof data.name === "string") { + toolCalls.set(key, { + sessionID: data.sessionID, + name: data.name, + directory: properties.directory, + }); + } else if (event.type === "session.tool.called") { + const call = toolCalls.get(key); + if (call) { + call.input = data.input; + call.directory = properties.directory ?? call.directory; + } + } else if (event.type === "session.tool.success") { + const call = toolCalls.get(key); + toolCalls.delete(key); + if (call?.input === undefined) return []; + return [ + { + type: "message.part.updated", + properties: { + ...properties, + directory: properties.directory ?? call.directory, + part: { + type: "tool", + tool: call.name, + state: { status: "completed", input: call.input }, + }, + }, + }, + ]; + } else if (event.type === "session.tool.failed") { + toolCalls.delete(key); + } + return []; +} + +export async function setupOpencodeEvents(ctx, factory) { + const controller = new AbortController(); + const toolCalls = new Map(); + const hooks = await factory({ directory: ctx.location.directory }); + // The subscription lives until cleanup. Awaiting it here would block plugin startup. + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (controller.signal.aborted) break; + for (const mapped of eventsForV2(event, toolCalls, ctx.location.directory)) { + await hooks.event({ event: mapped }); + } + } + } catch { + // Stream failures and cancellation must not reject into the host. + controller.abort(); + toolCalls.clear(); + } + })(); + return () => { + controller.abort(); + toolCalls.clear(); + }; +} diff --git a/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts b/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts index b866fb0e4..31abe1fc8 100644 --- a/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts +++ b/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts @@ -54,6 +54,23 @@ export class InstallRuntimeConfigUseCase { return { toolId, fileCount: allFiles.length, files: allFiles, skipped: false, warnings: [] }; } + async ensurePluginRuntimeFiles( + toolId: AiToolId, + projectRoot: string, + manifest: Manifest + ): Promise { + const toolConfig = getToolConfig(toolId); + if (!isAiTool(toolConfig)) return; + for (const [fileName, relativePath] of Object.entries(toolConfig.pluginRuntimeFiles ?? {})) { + const path = join(projectRoot, relativePath); + if (await this.fs.fileExists(path)) continue; + const asset = this.assets.loadConfigAsset(toolId, fileName); + const content = typeof asset === "string" ? asset : JSON.stringify(asset, null, 2); + await this.fs.writeFile(path, content); + manifest.updateTrackedFileHash(toolId, relativePath, this.hasher.hash(content)); + } + } + private async applyAndTrack( regularFiles: InstallationFile[], mergeFiles: InstallationFile[], @@ -77,9 +94,10 @@ export class InstallRuntimeConfigUseCase { options: InstallRuntimeConfigOptions ): Promise { const toolConfig = getToolConfig(options.toolId); - if (!isAiTool(toolConfig) || !toolConfig.configOutputPaths) return []; + if (!isAiTool(toolConfig)) return []; const files: InstallationFile[] = []; - for (const [fileName, outputPath] of Object.entries(toolConfig.configOutputPaths)) { + const outputPaths = { ...toolConfig.configOutputPaths, ...toolConfig.pluginRuntimeFiles }; + for (const [fileName, outputPath] of Object.entries(outputPaths)) { const resolvedPath = await this.resolveConfigPath(toolConfig, fileName, outputPath, options); const asset = this.assets.loadConfigAsset(options.toolId, fileName); let content = typeof asset === "string" ? asset : JSON.stringify(asset, null, 2); diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts b/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts deleted file mode 100644 index 03fb25508..000000000 --- a/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { join } from "node:path"; -import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; -import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; -import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; -import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; -import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; - -export class UserPluginDistributionLoader { - constructor( - private readonly fetcher: PluginFetcher, - private readonly reader: PluginDistributionReader - ) {} - - async loadLatest(plugin: InstalledPlugin, projectRoot: string): Promise { - const localPath = await this.fetcher.fetch( - plugin.source, - join(projectRoot, PLUGIN_CACHE_SUBDIR), - { forceRefresh: true } - ); - return this.reader.read(localPath); - } -} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts index 3d4c99d4c..5966418f4 100644 --- a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -20,6 +20,7 @@ import { withoutHooksPrefix, } from "../framework/translator/built-tree-materialization-translator.js"; import { withoutHooks } from "../framework/translator/project-hooks-materializer.js"; +import type { PluginDistributionLoader } from "../plugin/plugin-distribution-loader.js"; import { deleteOldFiles, writePluginFiles } from "../plugin/plugin-helpers.js"; import { resolveBaseDirFromRecord } from "../plugin/plugin-target-resolution.js"; import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; @@ -27,7 +28,6 @@ import { assertUserScopeWriteBoundary, userScopeFilesSafeToDelete, } from "../shared/user-scope-plugin-files.js"; -import type { UserPluginDistributionLoader } from "./user-plugin-distribution-loader.js"; export interface PlannedUserPluginFileUpdate { readonly plugin: InstalledPlugin; @@ -42,7 +42,7 @@ export interface PlannedUserPluginFileUpdate { export class UserPluginFileUpdater { constructor( private readonly fs: FileReader & FileWriter, - private readonly loader: UserPluginDistributionLoader, + private readonly loader: PluginDistributionLoader, private readonly hasher: Hasher, private readonly builtDeps?: BuiltMaterializationDeps ) {} diff --git a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts index f69c8cc8b..940f4849b 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts @@ -15,7 +15,6 @@ import type { Logger } from "../../../../kernel/ports/logger.js"; import type { PluginSource } from "../../../../kernel/source.js"; import type { AiToolId } from "../../../../kernel/tool.js"; import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; -import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; import type { ReadonlyNoticeList } from "../../../tools/domain/models/plugin-install-notice.js"; import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; @@ -27,12 +26,13 @@ import { type ProjectHooksProvenance, } from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; +import type { InstallRuntimeConfigUseCase } from "../install/install-runtime-config-use-case.js"; import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js"; import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; import { assertProjectHooksRemovable } from "../shared/remove-project-hooks.js"; +import type { PluginDistributionLoader } from "./plugin-distribution-loader.js"; import { loadPluginManifest, writePluginFiles } from "./plugin-helpers.js"; import { resolveBaseDirFromRecord, @@ -60,13 +60,13 @@ export class PluginAddUseCase implements PluginAdd { constructor( private readonly fs: FileWriter & FileReader, private readonly manifestRepo: ManifestRepository, - private readonly pluginFetcher: PluginFetcher, - private readonly pluginDistributionReader: PluginDistributionReader, + private readonly distributionLoader: PluginDistributionLoader, private readonly hasher: Hasher, private readonly logger: Logger, private readonly marketplaceRegistry: MarketplaceRegistry, private readonly ensureBuilt: EnsureBuiltMarketplace, - private readonly userManifestRepo: ManifestRepository + private readonly userManifestRepo: ManifestRepository, + private readonly pluginRuntime?: Pick ) {} async execute(options: PluginAddOptions): Promise { @@ -186,8 +186,7 @@ export class PluginAddUseCase implements PluginAdd { projectRoot: string ): Promise { const cacheDir = join(projectRoot, PLUGIN_CACHE_SUBDIR); - const localPath = await this.pluginFetcher.fetch(source, cacheDir); - return this.pluginDistributionReader.read(localPath); + return this.distributionLoader.load(source, cacheDir); } private async addLocalPlugin( @@ -282,6 +281,7 @@ export class PluginAddUseCase implements PluginAdd { const allSkipped: ReadonlySkipList[] = []; const allNotices: ReadonlyNoticeList[] = []; for (const toolId of toolIds) { + await this.pluginRuntime?.ensurePluginRuntimeFiles(toolId, projectRoot, manifest); const foreignDir = await this.userScopeDirNotInstalledHere( dist.manifest.name, toolId, diff --git a/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts b/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts new file mode 100644 index 000000000..d02888ea9 --- /dev/null +++ b/cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts @@ -0,0 +1,30 @@ +import { join } from "node:path"; +import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import type { PluginSource } from "../../../../kernel/source.js"; +import type { + PluginFetcher, + PluginFetchOptions, +} from "../../../distribution/domain/ports/plugin-fetcher.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; + +export class PluginDistributionLoader { + constructor( + private readonly fetcher: PluginFetcher, + private readonly reader: PluginDistributionReader + ) {} + + async load( + source: PluginSource, + cacheDir: string, + options?: PluginFetchOptions + ): Promise { + const localPath = await this.fetcher.fetch(source, cacheDir, options); + return this.reader.read(localPath); + } + + loadLatest(plugin: InstalledPlugin, projectRoot: string): Promise { + return this.load(plugin.source, join(projectRoot, PLUGIN_CACHE_SUBDIR), { forceRefresh: true }); + } +} diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts index efeaf37f5..ceb55decd 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts @@ -7,17 +7,17 @@ import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; import { compareSemver } from "../../../../kernel/semver.js"; import type { AiToolId } from "../../../../kernel/tool.js"; -import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; import { getToolConfig, type ToolConfig } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; import type { Manifest } from "../../domain/manifest.js"; import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; +import type { InstallRuntimeConfigUseCase } from "../install/install-runtime-config-use-case.js"; import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; +import type { PluginDistributionLoader } from "./plugin-distribution-loader.js"; import { deleteOldFiles, loadPluginManifest, @@ -37,10 +37,10 @@ export class PluginUpdateUseCase { constructor( private readonly fs: FileReader & FileWriter, private readonly manifestRepo: ManifestRepository, - private readonly pluginFetcher: PluginFetcher, - private readonly pluginDistributionReader: PluginDistributionReader, + private readonly distributionLoader: PluginDistributionLoader, private readonly hasher: Hasher, - private readonly builtDeps?: BuiltMaterializationDeps + private readonly builtDeps?: BuiltMaterializationDeps, + private readonly pluginRuntime?: Pick ) {} async execute(options: PluginUpdateOptions): Promise { @@ -92,10 +92,9 @@ export class PluginUpdateUseCase { manifest: Manifest ): Promise { if (plugin.scope === "user") throw new InvalidPluginScopeError(toolId, "project", "user"); - const localPath = await this.pluginFetcher.fetch(plugin.source, cacheDir, { + const dist = await this.distributionLoader.load(plugin.source, cacheDir, { forceRefresh: true, }); - const dist = await this.pluginDistributionReader.read(localPath); if (compareSemver(dist.manifest.version, plugin.version) <= 0) return false; await this.replacePluginFiles(plugin, dist, toolId, projectRoot, manifest); return true; @@ -109,6 +108,7 @@ export class PluginUpdateUseCase { manifest: Manifest ): Promise { const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir); + await this.pluginRuntime?.ensurePluginRuntimeFiles(toolId, projectRoot, manifest); await deleteOldFiles(plugin.files, baseDir, this.fs); const toolConfig = getToolConfig(toolId); const translator = this.resolveTranslator(toolConfig); diff --git a/cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts b/cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts index 2c6ac83c6..ca50dcba0 100644 --- a/cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts +++ b/cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts @@ -87,8 +87,7 @@ export class GenerateToolDistributionUseCase { private buildConfigOutputPathFiles(config: AiTool): InstallationFile[] { if (this.assetProvider === undefined) return []; - const outputPaths = config.configOutputPaths; - if (outputPaths === undefined) return []; + const outputPaths = { ...config.configOutputPaths, ...config.pluginRuntimeFiles }; const files: InstallationFile[] = []; for (const [fileName, outputPath] of Object.entries(outputPaths)) { const asset = this.assetProvider.loadConfigAsset(config.toolId as AiToolId, fileName); diff --git a/cli/src/contexts/tools/domain/contracts.ts b/cli/src/contexts/tools/domain/contracts.ts index 7e375b5fc..588aed6f2 100644 --- a/cli/src/contexts/tools/domain/contracts.ts +++ b/cli/src/contexts/tools/domain/contracts.ts @@ -68,6 +68,8 @@ export interface AiTool { readonly signalDir: string | null; readonly capabilities: C; readonly configOutputPaths?: Readonly>; + /** Bundled runtime modules required by plugins, owned by the tool rather than one plugin. */ + readonly pluginRuntimeFiles?: Readonly>; /** The tool's framework-build contracts, one per supported build mode, so the build registry * is derived from the registered tools instead of a hand-kept list of tool/mode pairs. */ readonly buildContracts?: { diff --git a/cli/src/contexts/tools/domain/profiles/opencode/build.ts b/cli/src/contexts/tools/domain/profiles/opencode/build.ts index 23c384738..c3c7d31be 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/build.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/build.ts @@ -20,6 +20,8 @@ import { buildOpencodeFlatConfig } from "../../formats/opencode-mcp-merge.js"; import { generateOpencodeHooksBridge } from "./opencode-hooks-bridge.js"; import { makeOpencodeHooksBridgePath, + OPENCODE_EVENTS_ASSET, + OPENCODE_EVENTS_PATH, OPENCODE_FLAT_HOOKS_DIR, OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, @@ -197,6 +199,16 @@ export function buildOpencodeFlatContract(): ToolBuildContract { const baseAsset = assetProvider.loadConfigAsset("opencode", "opencode.json"); const base = typeof baseAsset === "string" ? baseAsset : JSON.stringify(baseAsset); await fs.writeFile(configPath, buildOpencodeFlatConfig(base, existing, incoming)); + for (const plugin of builtPlugins) { + const native = `${outDir}/${OPENCODE_FLAT_HOOKS_DIR}${plugin}.js`; + const generated = `${outDir}/${makeOpencodeHooksBridgePath(plugin)}`; + if (!(await fs.fileExists(native)) && !(await fs.fileExists(generated))) continue; + const events = assetProvider.loadConfigAsset("opencode", OPENCODE_EVENTS_ASSET); + if (typeof events !== "string") + throw new TypeError("OpenCode event adapter must be source text"); + await fs.writeFile(`${outDir}/${OPENCODE_EVENTS_PATH}`, events); + return 2; + } return 1; }, }; diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts index 76742bed0..46592be65 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts @@ -124,6 +124,7 @@ export function generateOpencodeHooksBridge(rawHooksJson: string, plugin: string // (build.ts's skipHooksJson, translated here rather than skipped) - this file is the only // trigger this plugin's declared hooks have on OpenCode. See opencode-hooks-bridge.ts for // the mapping this generator applies and the measurements behind it. +import { setupOpencodeEvents } from "../hooks/opencode-events.js"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -228,71 +229,10 @@ export const ${ident} = async (input) => { ${ident}.stopCallsFor = stopCallsFor; ${ident}.postToolUseCallsFor = postToolUseCallsFor; -// V2's tool lifecycle splits the name, arguments and completion across three events. -// Keep only in-flight calls; consuming success once prevents duplicate PostToolUse calls. -function eventsForV2(event, toolCalls) { - const data = event?.data; - if (typeof data?.sessionID !== "string") return []; - const properties = { sessionID: data.sessionID, directory: event.location?.directory }; - if (event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || - event.type === "session.execution.interrupted") { - for (const [key, call] of toolCalls) { - if (call.sessionID === data.sessionID) toolCalls.delete(key); - } - if (event.type === "session.execution.interrupted" && data.reason === "shutdown") return []; - return [{ type: "session.idle", properties }]; - } - if (typeof data.assistantMessageID !== "string" || typeof data.id !== "string") return []; - const key = JSON.stringify([data.sessionID, data.assistantMessageID, data.id]); - if (event.type === "session.tool.input.started" && typeof data.name === "string") { - toolCalls.set(key, { sessionID: data.sessionID, name: data.name, directory: properties.directory }); - } else if (event.type === "session.tool.called") { - const call = toolCalls.get(key); - if (call) { - call.input = data.input; - call.directory = properties.directory ?? call.directory; - } - } else if (event.type === "session.tool.success") { - const call = toolCalls.get(key); - toolCalls.delete(key); - if (call?.input === undefined) return []; - return [{ type: "message.part.updated", properties: { - ...properties, directory: properties.directory ?? call.directory, - part: { type: "tool", tool: call.name, state: { status: "completed", input: call.input } }, - } }]; - } else if (event.type === "session.tool.failed") { - toolCalls.delete(key); - } - return []; -} - export default { id: ${JSON.stringify(`${plugin}-hooks`)}, server: ${ident}, - setup: async (ctx) => { - const controller = new AbortController(); - const toolCalls = new Map(); - const hooks = await ${ident}({ directory: ctx.location.directory }); - // The subscription lives until cleanup. Awaiting it here would block plugin startup. - void (async () => { - try { - for await (const event of ctx.event.subscribe({ signal: controller.signal })) { - if (controller.signal.aborted) break; - for (const mapped of eventsForV2(event, toolCalls)) { - await hooks.event({ event: mapped }); - } - } - } catch { - // Stream failures and cancellation must not reject into the host. - controller.abort(); - toolCalls.clear(); - } - })(); - return () => { - controller.abort(); - toolCalls.clear(); - }; - }, + setup: (ctx) => setupOpencodeEvents(ctx, ${ident}), }; `; } diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts index 97e9b5e91..7380bcbdd 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -16,6 +16,9 @@ export const OPENCODE_FLAT_HOOKS_DIR = `${OPENCODE_DIRECTORY}plugin/`; * scans is named "hooks", so nothing here is ever imported. */ export const OPENCODE_HOOKS_DIR = `${OPENCODE_DIRECTORY}hooks/`; +export const OPENCODE_EVENTS_ASSET = "opencode-events.js"; +export const OPENCODE_EVENTS_PATH = `${OPENCODE_HOOKS_DIR}${OPENCODE_EVENTS_ASSET}`; + /** The one hook filename that is, by convention, a plugin's own OpenCode plugin module β€” the * runtime the loader is meant to import β€” rather than a script an external bridge must run. */ export const OPENCODE_PLUGIN_ENTRY_BASENAME = "opencode-plugin.js"; diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts index c32b5ece2..d12add210 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -27,6 +27,8 @@ import { generateOpencodeHooksBridge } from "./opencode-hooks-bridge.js"; import { makeOpencodeHooksBridgePath, OPENCODE_DIRECTORY, + OPENCODE_EVENTS_ASSET, + OPENCODE_EVENTS_PATH, OPENCODE_FLAT_HOOKS_DIR, OPENCODE_HOOKS_DIR, OPENCODE_PLUGIN_ENTRY_BASENAME, @@ -66,6 +68,7 @@ export const opencode: AiTool< telemetryJournalHost: "opencode", signalDir: ".opencode/commands", configOutputPaths: { "opencode.json": "opencode.json" }, + pluginRuntimeFiles: { [OPENCODE_EVENTS_ASSET]: OPENCODE_EVENTS_PATH }, buildContracts: { flat: buildOpencodeFlatContract }, capabilities: { diff --git a/cli/src/runtime/assets/asset-loader.ts b/cli/src/runtime/assets/asset-loader.ts index 2377e545a..65e75bb4d 100644 --- a/cli/src/runtime/assets/asset-loader.ts +++ b/cli/src/runtime/assets/asset-loader.ts @@ -8,6 +8,7 @@ import copilotVscodeSettings from "../../../assets/configs/copilot/vscode-settin import cursorSettings from "../../../assets/configs/cursor/settings.json" with { type: "json" }; import kiloJson from "../../../assets/configs/kilo/kilo.json" with { type: "json" }; import opencodeJson from "../../../assets/configs/opencode/opencode.json" with { type: "json" }; +import opencodeEvents from "../../../assets/configs/opencode/opencode-events.js.txt"; import vscodeExtensions from "../../../assets/configs/vscode/extensions.json" with { type: "json" }; import vscodeKeybindings from "../../../assets/configs/vscode/keybindings.json" with { type: "json", @@ -28,7 +29,7 @@ const CONFIG_ASSETS: Readonly/ like .claude/ and .cursor/ already are. Never .opencode/plugin/, * which OpenCode's own loader imports in-process, where a plain hook script kills the host. */ @@ -24,8 +25,7 @@ async function installSamplePlugin() { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, capturingLogger, registry, @@ -78,5 +78,8 @@ describe("PluginAddUseCase OpenCode event bridge (Lot B)", () => { expect(writtenPaths).toContain( posix.join(PROJECT_ROOT, ".opencode", "plugin", "sample-plugin-hooks.js") ); + expect(writtenPaths).toContain( + posix.join(PROJECT_ROOT, ".opencode", "hooks", "opencode-events.js") + ); }); }); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts index 909d1664a..e20db6c2b 100644 --- a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts @@ -2,8 +2,8 @@ import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; @@ -25,7 +25,7 @@ function fixture(installedVersion = "0.0.1") { }); const updater = new UserPluginFileUpdater( fs, - new UserPluginDistributionLoader(new FixturePluginFetcher(), { read: async () => dist }), + new PluginDistributionLoader(new FixturePluginFetcher(), { read: async () => dist }), hasher ); const plugin = InstalledPlugin.fromMetadata( diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts index 335fb97e3..6c62cfff3 100644 --- a/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts @@ -2,9 +2,9 @@ import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { describe, expect, it } from "vitest"; import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; -import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; @@ -59,7 +59,7 @@ function fixture( repo, new UserPluginFileUpdater( fs, - new UserPluginDistributionLoader( + new PluginDistributionLoader( new FixturePluginFetcher(), new PluginDistributionReaderAdapter(fs) ), diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts index 0481635ec..0cfee030b 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import type { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { @@ -43,8 +44,7 @@ async function buildOpencodeProject(): Promise<{ const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, logger, deps.marketplaceRegistry, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts index 02b79fa4c..b6c2d33a8 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts @@ -1,3 +1,4 @@ +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; /** Every registered tool now runs what a plugin's `hooks/` ships, so no live fixture reaches * `collectHooksSkips`'s non-empty branch; the warn format is pinned tool-agnostically below. */ import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; @@ -26,8 +27,10 @@ describe("PluginAddUseCase skip warnings", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, capturingLogger, registry, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts index 9a071f111..646fcde9b 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import type { PluginDistributionReader } from "../../../../../src/contexts/framework/domain/ports/plugin-distribution-reader.js"; @@ -49,8 +50,7 @@ function buildAddUseCase( return new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, logger, registry, @@ -150,8 +150,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -194,8 +196,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -235,8 +239,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -293,8 +299,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, await makeGithubRegistry(PROJECT_ROOT), @@ -332,8 +340,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -370,8 +380,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -534,8 +546,10 @@ describe("PluginAddUseCase", () => { await new PluginAddUseCase( deps.fs, projectBRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, logger, registryB, @@ -572,8 +586,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -605,8 +621,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -640,8 +658,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -672,8 +692,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -707,8 +729,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -759,8 +783,10 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), deps.hasher, deps.logger, registry, @@ -812,8 +838,7 @@ describe("PluginAddUseCase", () => { const useCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - zeroFilesReader, + new PluginDistributionLoader(deps.pluginFetcher, zeroFilesReader), deps.hasher, deps.logger, registry, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts new file mode 100644 index 000000000..d0ac34647 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-distribution-loader.unit.test.ts @@ -0,0 +1,52 @@ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { PLUGIN_CACHE_SUBDIR } from "../../../../../src/kernel/paths.js"; + +const source = { kind: "local" as const, path: "/source" }; +const dist = new PluginDistribution({ + manifest: { name: "sample", version: "1.0.0" }, + format: "claude", + files: [], + components: { commands: [], agents: [], rules: [], skills: [], hooks: [], mcp: [] }, +}); + +describe("PluginDistributionLoader", () => { + it("fetches the requested source and reads its fetched path", async () => { + const fetch = vi.fn().mockResolvedValue("/cache/fetched"); + const read = vi.fn().mockResolvedValue(dist); + const loader = new PluginDistributionLoader({ fetch }, { read }); + + expect(await loader.load(source, "/cache", { forceRefresh: true })).toBe(dist); + expect(fetch).toHaveBeenCalledWith(source, "/cache", { forceRefresh: true }); + expect(read).toHaveBeenCalledWith("/cache/fetched"); + }); + + it("propagates a fetch failure without reading an unfetched distribution", async () => { + const read = vi.fn(); + const loader = new PluginDistributionLoader( + { + fetch: async () => { + throw new Error("cannot fetch"); + }, + }, + { read } + ); + + await expect(loader.load(source, "/cache")).rejects.toThrow("cannot fetch"); + expect(read).not.toHaveBeenCalled(); + }); + + it("refreshes an installed plugin in the existing project cache", async () => { + const fetch = vi.fn().mockResolvedValue("/cache/fetched"); + const loader = new PluginDistributionLoader({ fetch }, { read: async () => dist }); + const plugin = InstalledPlugin.fromMetadata("sample", "0.0.1", source, false, "user"); + + expect(await loader.loadLatest(plugin, "/project")).toBe(dist); + expect(fetch).toHaveBeenCalledWith(source, join("/project", PLUGIN_CACHE_SUBDIR), { + forceRefresh: true, + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts index fcecbce57..0a91d94df 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts @@ -5,6 +5,7 @@ import { ResolveMarketplaceUseCase } from "../../../../../src/contexts/distribut import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginCatalogRepositoryAdapter } from "../../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { PluginInstallFromMarketplaceUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { @@ -49,8 +50,7 @@ async function buildUseCase(options: { logger?: Logger | null; prompter?: Prompt const pluginAdd = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, deps.logger, registry, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts index 6dfe4ba3a..01cc68678 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; @@ -65,8 +66,7 @@ async function installPlugin( const addUseCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, deps.logger, deps.marketplaceRegistry, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts new file mode 100644 index 000000000..137722ba2 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts @@ -0,0 +1,121 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; +import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const ROOT = "/test-project"; +const FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const HELPER = ".opencode/hooks/opencode-events.js"; +const USER_CONFIG = '{"model":"user/model","permission":{"bash":"ask"}}'; + +async function legacyProject() { + const deps = await buildUnitDeps(ROOT); + const manifest = Manifest.create(); + manifest.addTool("opencode", "legacy", [ + new InstallationFile({ + relativePath: "opencode.json", + content: USER_CONFIG, + hash: deps.hasher.hash(USER_CONFIG), + }), + ]); + await deps.manifestRepo.save(manifest); + await deps.fs.writeFile(join(ROOT, "opencode.json"), USER_CONFIG); + await seedFromDirectory(deps.fs, FIXTURE, { useAbsolutePaths: true }); + const add = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + new PluginDistributionLoader(deps.pluginFetcher, deps.pluginDistributionReader), + deps.hasher, + deps.logger, + deps.marketplaceRegistry, + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo, + deps.installRuntimeConfigUseCase + ); + const update = new PluginUpdateUseCase( + deps.fs, + deps.manifestRepo, + new PluginDistributionLoader(deps.pluginFetcher, deps.pluginDistributionReader), + deps.hasher, + undefined, + deps.installRuntimeConfigUseCase + ); + const addPlugin = () => + add.execute({ + source: { kind: "local", path: FIXTURE }, + toolIds: ["opencode"], + projectRoot: ROOT, + interactive: false, + }); + return { deps, addPlugin, update }; +} + +describe("plugin runtime files on an existing tool", () => { + it("backfills a missing shared adapter during plugin install without replacing user configuration", async () => { + const { deps, addPlugin } = await legacyProject(); + await addPlugin(); + expect(await deps.fs.readFile(join(ROOT, HELPER))).toContain("setupOpencodeEvents"); + expect(await deps.fs.readFile(join(ROOT, "opencode.json"))).toBe(USER_CONFIG); + const manifest = deps.manifestRepo.getCurrent(); + expect(manifest?.getToolVersion("opencode")).toBe("legacy"); + expect(manifest?.getToolFiles("opencode").map((file) => file.relativePath)).toEqual([ + "opencode.json", + HELPER, + ]); + expect(manifest?.getPlugins("opencode")[0]?.files.has(HELPER)).toBe(false); + }); + + it("backfills during plugin update and retains tool ownership after plugin replacement", async () => { + const { deps, addPlugin, update } = await legacyProject(); + await addPlugin(); + await deps.fs.deleteFile(join(ROOT, HELPER)); + const manifest = deps.manifestRepo.getCurrent(); + const plugin = manifest?.getPlugins("opencode")[0]; + if (!manifest || !plugin) throw new Error("fixture plugin was not installed"); + manifest.updatePlugin("opencode", plugin.withVersion("0.0.0")); + await update.execute({ toolIds: ["opencode"], projectRoot: ROOT }); + expect(await deps.fs.readFile(join(ROOT, HELPER))).toContain("setupOpencodeEvents"); + expect(await deps.fs.readFile(join(ROOT, "opencode.json"))).toBe(USER_CONFIG); + expect( + deps.manifestRepo + .getCurrent() + ?.getToolFiles("opencode") + .filter((file) => file.relativePath === HELPER) + ).toHaveLength(1); + }); + + it("preserves and does not claim an existing untracked helper", async () => { + const { deps, addPlugin } = await legacyProject(); + await deps.fs.writeFile(join(ROOT, HELPER), "// user's helper"); + await addPlugin(); + expect(await deps.fs.readFile(join(ROOT, HELPER))).toBe("// user's helper"); + expect( + deps.manifestRepo + .getCurrent() + ?.getToolFiles("opencode") + .some((file) => file.relativePath === HELPER) + ).toBe(false); + }); + + it("leaves edits to an already tracked helper and its recorded hash intact", async () => { + const { deps, addPlugin } = await legacyProject(); + await deps.fs.writeFile(join(ROOT, HELPER), "// user's edit"); + const manifest = deps.manifestRepo.getCurrent(); + const originalHash = deps.hasher.hash("// original helper"); + manifest?.updateTrackedFileHash("opencode", HELPER, originalHash); + await addPlugin(); + expect(await deps.fs.readFile(join(ROOT, HELPER))).toBe("// user's edit"); + expect( + deps.manifestRepo + .getCurrent() + ?.getToolFiles("opencode") + .find((file) => file.relativePath === HELPER)?.hash + ).toEqual(originalHash); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts index 6a000ab65..37a7d7073 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it, vi } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; -import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; @@ -58,7 +58,7 @@ function makeUpdateUseCase( deps.userManifestRepo, new UserPluginFileUpdater( deps.fs, - new UserPluginDistributionLoader( + new PluginDistributionLoader( deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs) ), @@ -86,8 +86,7 @@ async function installStalePlugin( await new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, deps.logger, registry, @@ -644,7 +643,7 @@ describe("PluginUpdateUseCase β€” built-tree materialization", () => { ); const updater = new UserPluginFileUpdater( deps.fs, - new UserPluginDistributionLoader( + new PluginDistributionLoader( deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs) ), diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts index c55d9e66b..0c7b07d6e 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; @@ -45,8 +46,7 @@ function makeUpdateUseCase(deps: Deps, registry: InMemoryMarketplaceRegistry): P return new PluginUpdateUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, { ensureBuilt: fakeEnsureBuiltMarketplace(), @@ -69,8 +69,7 @@ async function installStaleGithubPlugin( await new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, deps.logger, registry, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts index 6312d5143..da36322a7 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import type { PluginFetchOptions } from "../../../../../src/contexts/distribution/domain/ports/plugin-fetcher.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; import type { BuiltMaterializationDeps } from "../../../../../src/contexts/framework/application/shared/apply-plugin-files-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; @@ -43,8 +44,7 @@ async function setup( const addUseCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - reader, + new PluginDistributionLoader(deps.pluginFetcher, reader), deps.hasher, deps.logger, deps.marketplaceRegistry, @@ -54,8 +54,7 @@ async function setup( const updateUseCase = new PluginUpdateUseCase( deps.fs, deps.manifestRepo, - options.fetcher ?? deps.pluginFetcher, - reader, + new PluginDistributionLoader(options.fetcher ?? deps.pluginFetcher, reader), deps.hasher, options.builtDeps ); diff --git a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts index b7633554f..e5f56cfc5 100644 --- a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { RestoreAllUseCase } from "../../../../src/contexts/framework/application/global/restore-all-use-case.js"; import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { RestoreUseCase } from "../../../../src/contexts/framework/application/restore/restore-use-case.js"; import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; @@ -42,8 +43,7 @@ async function installPlugin( await new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - pluginReader, + new PluginDistributionLoader(deps.pluginFetcher, pluginReader), deps.hasher, deps.logger, deps.marketplaceRegistry, @@ -289,8 +289,7 @@ describe("RestoreAllUseCase β€” plugin materialization", () => { await new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - reader, + new PluginDistributionLoader(deps.pluginFetcher, reader), deps.hasher, deps.logger, deps.marketplaceRegistry, diff --git a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts index 72c8f48cb..276f2a2f5 100644 --- a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts @@ -1,6 +1,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { RestoreUseCase } from "../../../../src/contexts/framework/application/restore/restore-use-case.js"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; @@ -33,8 +34,7 @@ async function installPlugin( await new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - pluginReader, + new PluginDistributionLoader(deps.pluginFetcher, pluginReader), deps.hasher, deps.logger, deps.marketplaceRegistry, diff --git a/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts index e1176f1be..4a8b77e11 100644 --- a/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { GenerateToolDistributionUseCase } from "../../../../../src/contexts/framework/application/restore/generate-tool-distribution-use-case.js"; import { CONFIG_VSCODE_SETTINGS } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; @@ -90,6 +91,13 @@ describe("GenerateToolDistributionUseCase β€” content sections", () => { }); describe("GenerateToolDistributionUseCase β€” a tool's own config assets", () => { + it("regenerates the shared OpenCode plugin runtime alongside tool configuration", async () => { + const files = await generate(getToolConfig("opencode"), assets); + expect( + files.find((file) => file.relativePath === ".opencode/hooks/opencode-events.js")?.content + ).toBe(assets.loadConfigAsset("opencode", "opencode-events.js")); + }); + it("writes a JSON asset pretty-printed at the path the tool declares", async () => { const files = await generate(getToolConfig("claude"), assets); const content = JSON.stringify(assets.loadConfigAsset("claude", "settings.json"), null, 2); diff --git a/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts index 125197399..6bd9c4805 100644 --- a/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { RestoreAllPluginsUseCase } from "../../../../../src/contexts/framework/application/restore/restore-all-plugins-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; @@ -68,8 +69,7 @@ async function installMarketplacePlugin( await new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, deps.logger, registry, diff --git a/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts index 85ea9d9b8..3f21ac48c 100644 --- a/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { RestoreAllPluginsUseCase } from "../../../../../src/contexts/framework/application/restore/restore-all-plugins-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; @@ -68,8 +69,7 @@ async function installGithubPlugin( await new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, deps.logger, registry, diff --git a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts index 4412ee454..f420f95ff 100644 --- a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { PluginDistributionLoader } from "../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; @@ -32,8 +33,7 @@ describe("UninstallUseCase β€” plugin scope", () => { await new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - reader, + new PluginDistributionLoader(deps.pluginFetcher, reader), deps.hasher, deps.logger, deps.marketplaceRegistry, diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts index 6eabd24ca..ea4bef401 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts @@ -10,6 +10,7 @@ import { transformMcpToOpencode, } from "../../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { StubAssetProvider } from "../../../../../helpers/ports/stub-asset-provider.js"; import { REPOSITORY_ROOT } from "../../../../../helpers/repository-root.js"; const OPENCODE_PLUGIN_MODULE = readFileSync( @@ -88,6 +89,31 @@ describe("transformMcpToOpencode()", () => { }); describe("buildOpencodeFlatContract()", () => { + it.each(["aidd-dev.js", "aidd-dev-hooks.js"])( + "delivers one shared adapter outside plugin discovery when %s was built", + async (entry) => { + const adapter = "export async function setupOpencodeEvents() {}"; + const fs = new InMemoryFileAdapter({ [`/out/.opencode/plugin/${entry}`]: "plugin" }); + const assets = new StubAssetProvider({ + "opencode/opencode.json": {}, + "opencode/opencode-events.js": adapter, + }); + + const written = await buildOpencodeFlatContract().emitConfigArtifact?.( + ["aidd-dev"], + "/out", + "/src", + fs, + { validate: () => undefined }, + assets + ); + + expect(written).toBe(2); + expect(fs.getFile("/out/.opencode/hooks/opencode-events.js")).toBe(adapter); + expect(fs.has("/out/.opencode/plugin/opencode-events.js")).toBe(false); + } + ); + it("writes no manifest and no marketplace of its own", () => { const contract = buildOpencodeFlatContract(); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-mapping.integration.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-mapping.integration.test.ts index 7c752800e..bcbe7cbc9 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-mapping.integration.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-mapping.integration.test.ts @@ -1,7 +1,7 @@ // The mapping exists only as generated text a real ESM module must expose as a property of its // factory, so proving it reaches one means writing and importing that file β€” integration, not unit. import { execFileSync } from "node:child_process"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -41,7 +41,16 @@ async function importGeneratedModule(): Promise<{ if (generated === null) throw new Error("expected a generated module"); const dir = await mkdtemp(join(tmpdir(), "aidd-opencode-bridge-mapping-")); tempDirs.push(dir); - const modulePath = join(dir, "bridge.mjs"); + await mkdir(join(dir, "plugin")); + await mkdir(join(dir, "hooks")); + await writeFile( + join(dir, "hooks", "opencode-events.js"), + await readFile( + join(REPOSITORY_ROOT, "cli/assets/configs/opencode/opencode-events.js.txt"), + "utf8" + ) + ); + const modulePath = join(dir, "plugin", "bridge.mjs"); await writeFile(modulePath, generated, "utf8"); const mod: Record = await import(pathToFileURL(modulePath).href); const factory = mod.AiddSampleHooks; diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts index 4e2e847a1..c281acb9e 100644 --- a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts @@ -42,6 +42,7 @@ describe("generateOpencodeHooksBridge", () => { // (build.ts's skipHooksJson, translated here rather than skipped) - this file is the only // trigger this plugin's declared hooks have on OpenCode. See opencode-hooks-bridge.ts for // the mapping this generator applies and the measurements behind it. + import { setupOpencodeEvents } from "../hooks/opencode-events.js"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -146,71 +147,10 @@ describe("generateOpencodeHooksBridge", () => { AiddSampleHooks.stopCallsFor = stopCallsFor; AiddSampleHooks.postToolUseCallsFor = postToolUseCallsFor; - // V2's tool lifecycle splits the name, arguments and completion across three events. - // Keep only in-flight calls; consuming success once prevents duplicate PostToolUse calls. - function eventsForV2(event, toolCalls) { - const data = event?.data; - if (typeof data?.sessionID !== "string") return []; - const properties = { sessionID: data.sessionID, directory: event.location?.directory }; - if (event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || - event.type === "session.execution.interrupted") { - for (const [key, call] of toolCalls) { - if (call.sessionID === data.sessionID) toolCalls.delete(key); - } - if (event.type === "session.execution.interrupted" && data.reason === "shutdown") return []; - return [{ type: "session.idle", properties }]; - } - if (typeof data.assistantMessageID !== "string" || typeof data.id !== "string") return []; - const key = JSON.stringify([data.sessionID, data.assistantMessageID, data.id]); - if (event.type === "session.tool.input.started" && typeof data.name === "string") { - toolCalls.set(key, { sessionID: data.sessionID, name: data.name, directory: properties.directory }); - } else if (event.type === "session.tool.called") { - const call = toolCalls.get(key); - if (call) { - call.input = data.input; - call.directory = properties.directory ?? call.directory; - } - } else if (event.type === "session.tool.success") { - const call = toolCalls.get(key); - toolCalls.delete(key); - if (call?.input === undefined) return []; - return [{ type: "message.part.updated", properties: { - ...properties, directory: properties.directory ?? call.directory, - part: { type: "tool", tool: call.name, state: { status: "completed", input: call.input } }, - } }]; - } else if (event.type === "session.tool.failed") { - toolCalls.delete(key); - } - return []; - } - export default { id: "aidd-sample-hooks", server: AiddSampleHooks, - setup: async (ctx) => { - const controller = new AbortController(); - const toolCalls = new Map(); - const hooks = await AiddSampleHooks({ directory: ctx.location.directory }); - // The subscription lives until cleanup. Awaiting it here would block plugin startup. - void (async () => { - try { - for await (const event of ctx.event.subscribe({ signal: controller.signal })) { - if (controller.signal.aborted) break; - for (const mapped of eventsForV2(event, toolCalls)) { - await hooks.event({ event: mapped }); - } - } - } catch { - // Stream failures and cancellation must not reject into the host. - controller.abort(); - toolCalls.clear(); - } - })(); - return () => { - controller.abort(); - toolCalls.clear(); - }; - }, + setup: (ctx) => setupOpencodeEvents(ctx, AiddSampleHooks), }; " `); 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 63fe9b4c4..b1b55fe8a 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 @@ -38,7 +38,11 @@ function makeAssetProvider(): AssetProvider { // Opencode's postBuild step reads a base opencode.json asset unconditionally β€” the other // tools' emitConfigArtifact never touches loadConfigAsset, so only this one needs it. function makeOpencodeAssetProvider(): AssetProvider { - return { ...makeAssetProvider(), loadConfigAsset: () => "{}" }; + return { + ...makeAssetProvider(), + loadConfigAsset: (_tool, name) => + name === "opencode-events.js" ? "export async function setupOpencodeEvents() {}" : "{}", + }; } function makeIsDirectory(fs: InMemoryFileAdapter): (path: string) => Promise { 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 e55422a1e..b99cfab9d 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 @@ -59,6 +59,9 @@ function makeValidator(fail = false): JsonSchemaValidator { function makeAssetProvider(): AssetProvider { return { loadConfigAsset: (_toolId, fileName) => { + if (fileName === "opencode-events.js") { + return "export async function setupOpencodeEvents() {}"; + } if (fileName === "opencode.json") { return { $schema: "https://opencode.ai/config.json", diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index 2ebea61b4..0886608f8 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -1748,7 +1748,8 @@ ".opencode/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".opencode/plugin/aidd-context-hooks.js": "fe994ab2dac7061b8062a53beaecb8661de957b714e1fd9851efa1d6d7924ba9", + ".opencode/plugin/aidd-context-hooks.js": "f8f9e775dd223c0385b330f3833e26343f5d19975a06cdec9ae981977d140fed", + ".opencode/hooks/opencode-events.js": "ce9363e18f1f5b11c401a30bd1d8643dcdd72ced0182d97baae763276216babf", ".opencode/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".opencode/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", ".opencode/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", diff --git a/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts b/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts index b95714583..85e28cc0e 100644 --- a/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts +++ b/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts @@ -5,6 +5,7 @@ import { ResolveMarketplaceUseCase } from "../../../src/contexts/distribution/ap import { Marketplace } from "../../../src/contexts/distribution/domain/marketplace.js"; import { PluginCatalogRepositoryAdapter } from "../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; import { PluginAddUseCase } from "../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; import { PluginDistributionReaderAdapter } from "../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { InteractiveOnlyError, @@ -57,8 +58,7 @@ async function buildUseCase(prompter: Prompter = new KeepPrompter()) { const pluginAdd = new PluginAddUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), + new PluginDistributionLoader(deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs)), deps.hasher, deps.logger, registry, diff --git a/cli/tests/runtime/assets/asset-loader.unit.test.ts b/cli/tests/runtime/assets/asset-loader.unit.test.ts index 20e58faf1..baf462cff 100644 --- a/cli/tests/runtime/assets/asset-loader.unit.test.ts +++ b/cli/tests/runtime/assets/asset-loader.unit.test.ts @@ -13,6 +13,13 @@ describe("BundledAssetProviderAdapter.loadConfigAsset", () => { }); describe("opencode", () => { + it("bundles the shared event adapter as executable source text", () => { + const asset = provider.loadConfigAsset("opencode", "opencode-events.js"); + + expect(typeof asset).toBe("string"); + expect(asset).toContain("export async function setupOpencodeEvents"); + }); + it("returns parsed opencode.json with instructions array", () => { const asset = provider.loadConfigAsset("opencode", "opencode.json") as Record< string, diff --git a/cli/tsup.config.ts b/cli/tsup.config.ts index 2639b2acf..259e75774 100644 --- a/cli/tsup.config.ts +++ b/cli/tsup.config.ts @@ -60,6 +60,7 @@ export default defineConfig({ ...options.loader, ".md": "text", ".toml": "text", + ".txt": "text", }; options.minifySyntax = true; options.minifyWhitespace = true; diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index e62ddcea6..5ec0fda33 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; import { textLoader } from "./tests/helpers/vitest-text-loader.js"; export default defineConfig({ - plugins: [textLoader([".md", ".toml"])], + plugins: [textLoader([".md", ".toml", ".txt"])], test: { globals: false, environment: "node", diff --git a/cli/vitest.workspace.ts b/cli/vitest.workspace.ts index b40ac73a2..730bb48ea 100644 --- a/cli/vitest.workspace.ts +++ b/cli/vitest.workspace.ts @@ -1,7 +1,7 @@ import { defineWorkspace } from "vitest/config"; import { textLoader } from "./tests/helpers/vitest-text-loader.js"; -const TEXT_EXTENSIONS = [".md", ".toml"] as const; +const TEXT_EXTENSIONS = [".md", ".toml", ".txt"] as const; export default defineWorkspace([ { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fdff43349..24fcd2091 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -53,12 +53,18 @@ A hook is authored once, with `${CLAUDE_PLUGIN_ROOT}`, and the installer rewrite | OpenCode | no, by a second route | β€” | Declarative hooks are translated by `opencode-hooks-bridge.ts` into `-hooks.js` modules for `SessionStart`, `Stop`, and `PostToolUse`. Telemetry supplies its own adapter in `plugins/aidd-telemetry/hooks/opencode-plugin.js` because its journal uses a different stdin dialect. Both expose a default definition: `server` for V1 1.18.29 or later, `setup` for V2. The declarative hook support column does not describe this module route. | OpenCode V1 delivers `session.idle` and completed `message.part.updated` tool parts to an -`event` hook. V2 adapters subscribe to `data`-based events with an abortable stream, correlate -tool name and input with tool success, and translate execution terminals into turn-end. +`event` hook. One CLI-owned runtime module subscribes to V2 `data`-based events with an abortable stream, correlates +tool name and input with tool success, and translates execution terminals into turn-end. Shutdown interruptions leave the turn open because OpenCode resumes that execution. The generic bridge fires its idempotent `SessionStart` hooks when either host initializes the module; telemetry records sessions separately from actual session events. +The CLI embeds this shared module and delivers it under `.opencode/hooks/opencode-events.js`, +outside plugin discovery. Translation emits it once; tool installation tracks it as a tool +file. Plugin installation and update backfill a missing module on an existing tool without +rewriting user configuration or claiming an existing untracked module. Each plugin keeps +its own V1 payload adapter and imports this common host protocol for V2. + A tool that runs no hook says why, and an install that carries one tells whoever ran it what was skipped. ## βš–οΈ What runs on every event, and what runs when someone asks diff --git a/plugins/aidd-telemetry/hooks/opencode-plugin.js b/plugins/aidd-telemetry/hooks/opencode-plugin.js index 0dc2abf94..072294c22 100644 --- a/plugins/aidd-telemetry/hooks/opencode-plugin.js +++ b/plugins/aidd-telemetry/hooks/opencode-plugin.js @@ -15,6 +15,7 @@ // specifier resolved against its own cwd - so fileURLToPath is what makes the spawn work. import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { setupOpencodeEvents } from "../hooks/opencode-events.js"; // Not a sibling: OpenCode's loader scans `plugin/` one level deep, so the build delivers this // module there alone and every other hook script under `hooks//`. That is the same @@ -163,96 +164,8 @@ export const AiddTelemetry = async (input) => ({ AiddTelemetry.journalCallFor = journalCallFor; AiddTelemetry.journalCallsFor = journalCallsFor; -// V2's tool lifecycle splits the name, arguments and completion across three events. -// Keep only in-flight calls; consuming success once prevents duplicate PostToolUse calls. -function eventsForV2(event, toolCalls, directory) { - const data = event?.data; - if (typeof data?.sessionID !== "string") return []; - if (event.type === "session.created") { - return [ - { - type: "session.created", - properties: { - info: { id: data.sessionID, directory: data.location?.directory ?? directory }, - }, - }, - ]; - } - const properties = { sessionID: data.sessionID, directory: event.location?.directory }; - if ( - event.type === "session.execution.succeeded" || - event.type === "session.execution.failed" || - event.type === "session.execution.interrupted" - ) { - for (const [key, call] of toolCalls) { - if (call.sessionID === data.sessionID) toolCalls.delete(key); - } - if (event.type === "session.execution.interrupted" && data.reason === "shutdown") return []; - return [{ type: "session.idle", properties }]; - } - if (typeof data.assistantMessageID !== "string" || typeof data.id !== "string") return []; - const key = JSON.stringify([data.sessionID, data.assistantMessageID, data.id]); - if (event.type === "session.tool.input.started" && typeof data.name === "string") { - toolCalls.set(key, { - sessionID: data.sessionID, - name: data.name, - directory: properties.directory, - }); - } else if (event.type === "session.tool.called") { - const call = toolCalls.get(key); - if (call) { - call.input = data.input; - call.directory = properties.directory ?? call.directory; - } - } else if (event.type === "session.tool.success") { - const call = toolCalls.get(key); - toolCalls.delete(key); - if (call?.input === undefined) return []; - return [ - { - type: "message.part.updated", - properties: { - ...properties, - directory: properties.directory ?? call.directory, - part: { - type: "tool", - tool: call.name, - state: { status: "completed", input: call.input }, - }, - }, - }, - ]; - } else if (event.type === "session.tool.failed") { - toolCalls.delete(key); - } - return []; -} - export default { id: "aidd-telemetry", server: AiddTelemetry, - setup: async (ctx) => { - const controller = new AbortController(); - const toolCalls = new Map(); - const hooks = await AiddTelemetry({ directory: ctx.location.directory }); - // The subscription lives until cleanup. Awaiting it here would block plugin startup. - void (async () => { - try { - for await (const event of ctx.event.subscribe({ signal: controller.signal })) { - if (controller.signal.aborted) break; - for (const mapped of eventsForV2(event, toolCalls, ctx.location.directory)) { - await hooks.event({ event: mapped }); - } - } - } catch { - // Stream failures and cancellation must not reject into the host. - controller.abort(); - toolCalls.clear(); - } - })(); - return () => { - controller.abort(); - toolCalls.clear(); - }; - }, + setup: (ctx) => setupOpencodeEvents(ctx, AiddTelemetry), }; diff --git a/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js b/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js index 5585bf2a9..c20951d39 100644 --- a/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js +++ b/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js @@ -25,12 +25,20 @@ function loadFixture(name) { // `export` syntax. OpenCode's own loader consults no such field, and the extension is the // only thing that differs from what ships. let pluginModulePromise; +let pluginTempDir; +test.after(() => { + if (pluginTempDir) fs.rmSync(pluginTempDir, { recursive: true, force: true }); +}); async function pluginModule() { if (!pluginModulePromise) { - const twin = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), "aidd-opencode-payloads-")), - "opencode-plugin.mjs" + pluginTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-opencode-payloads-")); + fs.mkdirSync(path.join(pluginTempDir, "plugin")); + fs.mkdirSync(path.join(pluginTempDir, "hooks")); + fs.copyFileSync( + path.resolve(__dirname, "../../cli/assets/configs/opencode/opencode-events.js.txt"), + path.join(pluginTempDir, "hooks", "opencode-events.js") ); + const twin = path.join(pluginTempDir, "plugin", "opencode-plugin.mjs"); fs.copyFileSync(PLUGIN_SOURCE, twin); pluginModulePromise = import(pathToFileURL(twin).href); } diff --git a/scripts/__tests__/comments-name-files-that-exist.test.js b/scripts/__tests__/comments-name-files-that-exist.test.js index 7b4a27547..b3aeff925 100644 --- a/scripts/__tests__/comments-name-files-that-exist.test.js +++ b/scripts/__tests__/comments-name-files-that-exist.test.js @@ -83,6 +83,16 @@ function trackedFiles() { return cp.execSync("git ls-files", { cwd: ROOT, encoding: "utf8" }).trim().split("\n"); } +function repositoryBasenames(tracked) { + return new Set([...tracked].flatMap((file) => { + const basename = path.basename(file); + // Config assets embed executable modules as text; the delivered filename drops .txt. + return file.startsWith("cli/assets/configs/") && file.endsWith(".js.txt") + ? [basename, basename.slice(0, -4)] + : [basename]; + })); +} + function findFiles(command) { return cp .execSync(command, { cwd: ROOT, encoding: "utf8" }) @@ -159,7 +169,7 @@ describe("a comment about the hooks names .cjs where the file is .cjs", () => { it("names no .js that the hooks do not actually ship", () => { const tracked = new Set(trackedFiles()); const shipped = hookJsFiles(tracked); - const basenames = new Set([...tracked].map((file) => path.basename(file))); + const basenames = repositoryBasenames(tracked); const scanned = [...tracked].filter( (file) => file.startsWith("plugins/aidd-telemetry/hooks/") || @@ -205,7 +215,7 @@ const SELF = "scripts/__tests__/comments-name-files-that-exist.test.js"; describe("a comment that names a source file names one that exists", () => { it("names no file the repository does not hold, outside the mentions listed as history", () => { const tracked = new Set(trackedFiles()); - const basenames = new Set([...tracked].map((file) => path.basename(file))); + const basenames = repositoryBasenames(tracked); const dangling = []; for (const file of scannedFiles().filter((candidate) => candidate !== SELF)) { @@ -236,7 +246,7 @@ describe("a comment that names a source file names one that exists", () => { it("lists no allowance for a file that does exist, so the list cannot outlive its reason", () => { const tracked = new Set(trackedFiles()); - const basenames = new Set([...tracked].map((file) => path.basename(file))); + const basenames = repositoryBasenames(tracked); const stale = []; for (const [file, tokens] of Object.entries(NAMED_AS_HISTORY)) { diff --git a/scripts/__tests__/opencode-plugin.test.js b/scripts/__tests__/opencode-plugin.test.js index dab826e30..d1b6694cc 100644 --- a/scripts/__tests__/opencode-plugin.test.js +++ b/scripts/__tests__/opencode-plugin.test.js @@ -47,6 +47,10 @@ function makeInstalledRepo() { const scriptsDir = path.join(repo, ".opencode", "hooks", "aidd-telemetry"); fs.mkdirSync(pluginDir, { recursive: true }); fs.mkdirSync(scriptsDir, { recursive: true }); + fs.copyFileSync( + path.resolve(__dirname, "../../cli/assets/configs/opencode/opencode-events.js.txt"), + path.join(repo, ".opencode", "hooks", "opencode-events.js") + ); const hooksSrc = path.dirname(PLUGIN_SOURCE); for (const entry of fs.readdirSync(hooksSrc, { withFileTypes: true })) { if (entry.name === "hooks.json") continue; From ffa9c44a2e02d0f4520ce9336318ba25a803f74d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 16:48:48 +0200 Subject: [PATCH 04/23] docs(framework): record shared runtime delivery and final validation AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../backlog-link.json | 2 +- .../phase-1.md | 23 ++++++++++--- .../plan.md | 1 + .../verification.md | 34 ++++++++++++++----- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json index a769590ff..a6c7b81f2 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json @@ -1,5 +1,5 @@ { "backlog": "ai-driven-dev/framework#953", - "written_at": "2026-10-07T00:00:00Z", + "written_at": "2026-10-07T13:49:41.828285Z", "written_by": "aidd-dev:01-plan" } diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md index 550aa441d..a4d0a7389 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md @@ -6,11 +6,24 @@ status: done ## Architecture projection ```txt -cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts modify: generated plugin definition and V2 subscription -plugins/aidd-telemetry/hooks/opencode-plugin.js modify: telemetry definition and V2 subscription +cli/assets/configs/opencode/opencode-events.js.txt create: canonical V2 host adapter +cli/src/runtime/assets/asset-loader.ts modify: embed shared host adapter +cli/tsup.config.ts modify: bundle text asset +cli/src/contexts/tools/domain/profiles/opencode/build.ts modify: emit shared adapter outside plugin discovery +cli/src/contexts/tools/domain/contracts.ts modify: tool-owned plugin runtime files +cli/src/contexts/tools/domain/profiles/opencode/profile.ts modify: shared runtime file declaration +cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts modify: install and backfill missing runtime files +cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts modify: retain runtime files in restoration output +cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts move/generalize: shared source retrieval entrypoint for project and user plugins +cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts modify: ensure tool runtime before plugin delivery +cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts modify: ensure tool runtime before plugin replacement +cli/src/runtime/wiring/framework.ts modify: wire shared source retrieval and runtime delivery +cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts create: existing installation and update coverage +cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts modify: generated plugin definition using shared host adapter +plugins/aidd-telemetry/hooks/opencode-plugin.js modify: telemetry definition using shared host adapter cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts modify: generated entrypoint and behavioral tests cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts modify: default server/setup consumption in real translate output -cli/tests/golden/snapshots/framework-build/golden.json modify: only the emitted OpenCode bridge hash +cli/tests/golden/snapshots/framework-build/golden.json modify: OpenCode bridge hash and shared adapter artifact scripts/__tests__/opencode-plugin.test.js modify: actual journal effects through both entrypoints and cleanup scripts/__tests__/aidd-telemetry-opencode-payloads.test.js modify if required: loader export contract and event mappings scripts/__tests__/aidd-telemetry-cost-skill.test.js modify: distinguish V1 omission from observed V2 session announcements @@ -18,7 +31,7 @@ docs/ARCHITECTURE.md modify: replace outdated single-factory contract plugins/aidd-telemetry/README.md modify: supported OpenCode versions and measured behavior aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md create: research and observed real runtime evidence ``` -No files deleted. Add runtime regression coverage only where needed to reproduce a measured V2 difference. +The existing user-plugin source loader moves to the shared application layer; its callers and constructor fixtures follow the generalized entrypoint. Add runtime regression coverage only where needed to reproduce a measured V2 difference. ## User Journey ```mermaid @@ -54,7 +67,7 @@ Not applicable: no UI change. ### 1) Compatible exports and event handling 1. Write regression tests and observe the expected failure. 2. Add default id/server/setup definitions to both modules, preserving named factory seams if the V1 loader permits them without duplicate execution. -3. Register V2 event handling with cancellable cleanup. Translate data/location session events, correlate granular tool events into completed tool calls, and handle execution completion as turn-end. +3. Register V2 event handling through one shared host adapter with cancellable cleanup. Translate data/location session events, correlate granular tool events into completed tool calls, and handle execution completion as turn-end. Emit the helper outside the plugin discovery directory and test its installed layout. 4. Update existing coverage and documentation; run coding and architecture assertions. ### 2) Real local compatibility proof diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md index 5659f3406..d46557013 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md @@ -29,6 +29,7 @@ status: implemented | Decision | Why | | --- | --- | | Keep existing V1 factories and payload mapping; expose them through server on a default definition. | Preserve behavior and avoid running both entrypoints for one host. Verify against the actual V1 loader. | +| Emit one CLI-owned host adapter under .opencode/hooks, shared by both plugin definitions. | Keep protocol normalization and subscription lifecycle in one place, outside plugin discovery, while each plugin retains its own hook payload mapping. | | Correlate V2 tool input/start, call and success events before reusing the V1 handler; map execution completion to turn-end. | The real 2.0.22 run emits granular events, not the declared content snapshots or session.idle. Tool names and inputs arrive before completion. Remove pending calls after success or failure. | | Pin real tests to V1 1.18.29 and V2 2.0.22. | Covers the documented compatibility boundary and the failing V2 release named in the issue. | | Run real hosts in fresh temporary profiles and projects against a local deterministic model endpoint. | Exercise actual plugin loading, events, memory refresh and journal writes without changing personal configuration or paying for inference. | diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md index 7ab4f8693..caf1a08ae 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md @@ -40,6 +40,18 @@ Both released binaries exited zero and produced all of these effects: - The session_start vendor_id matched the actual session ID reported by OpenCode. - Neither adapter produced a plugin load error. +After the independent review's duplication finding, the V2 host protocol and subscription +lifecycle were extracted into one embedded CLI asset. Both modules import the emitted +`.opencode/hooks/opencode-events.js`; the real V1/V2 runs above were repeated with this +layout. A fresh `setup` installation, using the temporary local framework source, also +passed a real V2 session with the same observable effects. + +A second project simulated an existing tool manifest without the shared runtime file. +The actual `plugin install` command installed context and telemetry from the temporary +local source, backfilled the helper and recorded it once as tool-owned. The preexisting +model and permission configuration remained byte-identical. A real V2 session then +produced the same memory, hook and journal effects, with its actual session identity. + The V2 observation stream contained session.created, session.tool.input.started, session.tool.called, session.tool.success and session.execution.succeeded. It contained neither session.idle nor session.message.content.updated. The adapter therefore correlates @@ -61,6 +73,8 @@ python3 /tmp/aidd-953-runtime-ryTstm/prepare.py node cli/dist/cli.js translate /tmp/aidd-953-runtime-ryTstm/fixture-source --to opencode --as flat --out /tmp/aidd-953-runtime-ryTstm/candidate --force python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candidate v1 python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candidate v2 +python3 /tmp/aidd-953-runtime-ryTstm/install-existing.py +python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candidate-existing v2 ``` The V2 command uses run --standalone; neither test starts a persistent background service. @@ -69,24 +83,28 @@ The installed personal OpenCode 1.14.20 and its profile were not upgraded or use ## Automated validation - Full repository scripts suite through the normal commit hook: 554 passed, zero failures; the hook also verified that tests left git hooks unchanged. -- Full CLI suite: 6,807 passed, one opt-in Kilo runtime test skipped; 529 files passed. +- Full CLI suite through the final normal push hook: 6,818 passed, one opt-in Kilo runtime test skipped; 531 files passed. Evidence: push-shared.log in the temporary harness directory. - Focused OpenCode and cost-documentation scripts: 48 passed, after observing the stale documentation assertion fail first. - All 140 architecture checks passed without increasing comment or empty-catch baselines. - Lint, TypeScript, type honesty, knip, duplicate-code budget and whitespace checks passed. Lint retains one existing unused-private-member warning in uninstall-use-case.ts, outside this change. -- Built CLI: 730.3 KB, below the existing 734 KB budget. -- Both emitted plugin files passed node --check. +- Built CLI: 732.2 KB, below the existing 734 KB budget. +- Both emitted plugin files and the shared runtime module passed node --check. - An isolated mutation removing success consumption made the duplicate-task regression fail; the production source was not mutated. +- After extraction, the same mutation against a copied canonical helper failed the duplicate-task regression; raw failure retained in mutation-shared.log. Existing-installation support tests first failed for missing helpers, then passed for installation, update and preservation of existing helper contents/hashes. Restoration first omitted the helper, then its regression passed after merging the runtime asset declaration. -Architecture conformance: no macro violations; generic translation stays in the tools -profile, telemetry observes through its existing adapter, and hook tests remain outside -shipped plugin trees. No micro violations: the source generator remains a pure transform -and the generated runtime gains no package dependency or cross-plugin import. +Architecture conformance: generic translation and the canonical host protocol belong to +the CLI; telemetry observes through its own payload adapter, and hook tests remain outside +shipped plugin trees. The shared helper is tool-owned, outside plugin discovery, with no +package dependency or cross-plugin import. Existing-tool plugin installation and update +backfill only a missing helper, preserving user configuration, existing helper contents, +ownership and recorded drift hashes. Source fetching and parsing use one shared framework +entrypoint so the plugin orchestrators gain no extra collaborator. ## Scope of the evidence Real runtime coverage is a single-project successful session under V1 1.18.29 and V2 2.0.22. Regression tests exercise stream cleanup, malformed events, incomplete and failed -tools, repeated success, concurrent tool identities, and execution termination behavior. +tools, repeated success, and execution termination behavior. Paid inference, global-profile installation, other operating systems and a running host's hot reload are not claimed by this local proof. The local deterministic model substitutes only inference; OpenCode's plugin loader, event stream, read tool, hook processes and From b1a37fe056fcdf316dab29b2239014e9202bc828 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 16:53:11 +0200 Subject: [PATCH 05/23] docs(framework): record independent OpenCode review and challenge AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../challenge.md | 15 +++++++ .../review.md | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md new file mode 100644 index 000000000..6f2667772 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md @@ -0,0 +1,15 @@ +My confidence level of correctness now: 95% + +# Correctness (100%) +- The actual need is fulfilled: generated context and telemetry modules load and execute under real OpenCode V2 2.0.22, while the documented V1 compatibility boundary, 1.18.29, retains execution once. Default exports alone would have left V2 journal behavior incomplete; observed granular tool and execution events are normalized before existing payload handlers run. +- The shared protocol has one CLI-owned source, emitted under `.opencode/hooks/opencode-events.js`, outside plugin discovery. Each plugin keeps its own payload responsibility. Setup, translation, existing-tool installation/update and restoration account for this new dependency; existing configuration, untracked helper contents and tracked helper drift hashes are preserved. +- All four real-host cases were independently checked against final module bytes, actual stdout session IDs, hook payloads, refreshed memory and journal records. Each contains exactly one session_start, task_declared and turn_end. Evidence: `/tmp/aidd-953-runtime-ryTstm/{candidate-v1,candidate-v2,candidate-install-v2,candidate-existing-v2}`; existing-install-summary.json confirms byte-preserved configuration and helper ownership once. +- The model response is deterministic loopback inference. Plugin loading, host event delivery, the read tool, hook processes and journal effects are real. Coverage is one successful project/session per case; older V1, other releases/operating systems, paid inference, personal/global installation and live hot reload are not claimed. Failure, interruption, deduplication and subscription cancellation are regression-tested rather than demonstrated as live-host failures. +- The final normal gates report 6,818 CLI tests and 531 files passed, one opt-in Kilo test skipped, knip passed, 554 script tests passed and 140 architecture checks passed. Independent focused adapter/payload run: 26 passed, zero failed. The canonical-helper mutation yields an extra task_declared and fails the intended test. Final bundle is 732.2 KB under the unchanged 734 KB budget. +- Research-before-code and initial failing tests are backed by timestamped local tool transcripts, not only a written assertion. The final reviewed candidate is `origin/next...ffa9c44a`, with code at `ae500bfb`. No remaining contradiction, duplication finding or user-outcome failure was found; every consequential choice serves the requested outcome within the documented support boundary. + +# Deal breakers +- None. + +# Suggestions (enhancements only) +- Add an interleaved-call regression using the same tool id across different assistantMessageID/sessionID values if this adapter is extended. The current tuple key distinguishes those identities by inspection, but existing tests do not specifically protect that distinction. This is additional coverage, not an unmet acceptance criterion or a claim about current real-host evidence. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md new file mode 100644 index 000000000..8f0c978c1 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md @@ -0,0 +1,42 @@ +# Review: OpenCode plugin loading (#953) +- **Verdict**: approve +- **Diff**: `origin/next...ffa9c44a` (code `ae500bfb`, evidence `ffa9c44a`) +- **Axes run**: code, functional, relevancy +- **Date**: 2026_10_07 +- **Findings**: 0 critical, 0 warning, 0 minor + +## Phases +### Phase 1 β€” Compatible plugin entrypoints and live verification +- [x] Both emitted modules have default id/setup/server; import does not spawn hooks. β€” `cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts:232`, `plugins/aidd-telemetry/hooks/opencode-plugin.js:167`, `cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts:76`, `scripts/__tests__/opencode-plugin.test.js:156`. +- [x] V1 invokes its handler once; V2 setup returns promptly, forwards events and returns cleanup that aborts its subscription. β€” `cli/assets/configs/opencode/opencode-events.js.txt:66`, `scripts/__tests__/opencode-plugin.test.js:168`, `cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts:124`. Each real host case has exactly one SessionStart, PostToolUse and Stop. The [tagged V1 loader](https://github.com/anomalyco/opencode/blob/v1.18.29/packages/opencode/src/plugin/index.ts#L106-L116) returns after default.server. +- [x] Malformed/unrelated events do not crash the host; completed tool, session-start and idle semantics remain correct. β€” `cli/assets/configs/opencode/opencode-events.js.txt:3`, `scripts/__tests__/opencode-plugin.test.js:270`. One canonical protocol mapper correlates sessionID, assistantMessageID and tool id; success consumes pending state, failures and terminals discard it. Shutdown does not close a resumable turn, consistent with the [tagged execution coordinator](https://github.com/anomalyco/opencode/blob/v2.0.22/packages/core/src/session/execution.ts#L112-L139). +- [x] Relevant tests and repository coding/architecture gates pass. β€” `/tmp/aidd-953-runtime-ryTstm/commit-shared-final.log:54`: 140 architecture tests; `commit-shared-final.log:714`: 554 script tests; `push-shared.log`: 6,818 CLI tests and 531 files passed, one opt-in Kilo test skipped, knip passed. Independent `node --test scripts/__tests__/opencode-plugin.test.js scripts/__tests__/aidd-telemetry-opencode-payloads.test.js`: 26 passed, zero failures. `git diff --check origin/next...ffa9c44a`: exit 0. +- [x] Real OpenCode V2 2.0.22 loads both translated modules without plugin load failure and produces observable memory and journal effects. β€” `candidate-v2/{summary.json,stdout.log,events.jsonl,project/AGENTS.md,project/aidd_docs/runs/*.jsonl}` under the temporary evidence directory. Exactly session_start/task_declared/turn_end with vendor_id and hook session_id matching actual stdout session `ses_ee936cc1effeZlQEZZQWcz1C7z`. Baseline V2 exits zero despite both load errors and no effects; exit status alone was not accepted. +- [x] Real OpenCode V1 1.18.29 retains those effects with no duplicate session-start. β€” `candidate-v1` raw summary/stdout/journal/marker artifacts: exactly one of each record, matching session `ses_ee936b4a1ffeUtLo53jLIRbwGv`. +- [x] Evidence names exact commands, versions, results and limitations; no paid model or personal configuration modification. β€” `verification.md`, `probe.py`, `prepare.py`, `install-existing.py`, model requests and raw artifacts. Unsupported concurrent-identity coverage claim removed. HOME/XDG/profile/config directories isolated; inference uses a deterministic loopback provider. Claims distinguish real host/tool/plugin/journal execution from substituted inference. +- [x] Official research precedes code, as explicitly requested. β€” Parent transcript `rollout-2026-10-07T15-38-13-01a11696-08a4-78b1-b05c-c456b9fbceb3.jsonl`, entries 97/104 and 154/161: official requests/results at 13:45:59–13:47:39 UTC. First production patch follows at 13:51:56 UTC. Independently verified [official migration contract](https://opencode.ai/v2/docs/build/plugins/migrate-v1) and [plugin API](https://opencode.ai/v2/docs/build/plugins). +- [x] Project test-first and guard-mutation requirements have observed evidence. β€” Executor transcript `rollout-2026-10-07T15-49-59-01a116a0-cda7-74c3-9bbe-51865ae26720.jsonl`, entries 78/83/94: tests added 13:51:21 UTC, named undefined id/setup failures 13:51:28, production patch 13:51:56. Initial mutation entries 343/346 copy plugin/test, verify deletion anchor, assert targeted failure and clean scratch. Canonical-helper mutation raw failure is retained in `mutation-shared.log` with duplicate task_declared. Parent transcript entries 1146/1162 retain existing-tool missing-helper failures followed by four passing migration/preservation tests. + +## Findings +| Sev | Kind | Phase | Location | Issue | Fix | +| --- | --- | --- | --- | --- | --- | +| β€” | β€” | β€” | β€” | None. | β€” | + +## Verification +| Metric | Value | +| --- | --- | +| Verified | 100% (9/9 phase and stated-process criteria); plan criteria 7/7 | +| Checker score | 100%: all nine criteria and four baseline checks fulfilled, 13/13. No severity adjustment. Caller owns its acceptance threshold. | +| Files checked | Every changed line in all 60 files in `origin/next...ffa9c44a`, including task artifacts, canonical JS text asset, bridge and telemetry wrappers, runtime-file metadata/build/install/restore/wiring, generalized distribution loader and all constructor migrations, tests/golden/configuration, filename guard and documentation. Root/CLI assertions and applicable architecture/convention rules also checked. | +| No information duplication | Fulfilled: V2 protocol and lifecycle have one home, `cli/assets/configs/opencode/opencode-events.js.txt`; both wrappers reuse it. Source fetching/parsing likewise reuses the generalized existing loader. Plugin-specific payload adapters retain separate responsibilities. | +| No incoherence or contradiction | Fulfilled: version-qualified docs match source and observed events; unsupported concurrency coverage removed; final counts and bundle size match logs. Existing-helper preservation and tool ownership are explicit. | +| No over-engineering | Fulfilled: one emitted helper outside discovery, existing asset pipeline and runtime installer reused; missing-file backfill avoids rewriting user config or existing helper. Existing distribution loader generalized rather than adding an unused abstraction. Constructor fixture edits follow the actual production dependency change; collaborator baselines unchanged. | +| No dead code or debug leftovers | Fulfilled: helper/setup/server, distribution loading and delivery paths are exercised; no debug logs, TODO or commented-out code added. Temporary inference/observer/marker harness stays outside shipped source. | +| Delivery and ownership | Translation emits the helper once; setup tracks it as tool-owned; add/update backfill only missing runtime files; restore generates the same artifact. Four migration tests cover missing install/update helpers, an untracked existing helper and tracked helper edits/hash preservation. Raw existing-install summary confirms configuration byte preservation and one tool-owned record. | +| Runtime freshness and identity | Independently compared executed telemetry bytes with current repository source, helper bytes with canonical asset, and executed context bytes with final CLI-translated candidate across all four cases. All effects/session IDs/task arguments verified: translated V1 `ses_ee936b4a1ffeUtLo53jLIRbwGv`; translated V2 `ses_ee936cc1effeZlQEZZQWcz1C7z`; setup-installed V2 `ses_ee93675e4ffeXe9XxjPANgDAQH`; existing-tool backfilled V2 `ses_ee92fc668ffekZp0PjzqAcnJah`. | +| Runtime evidence boundary | Released hosts V1 1.18.29 and V2 2.0.22; real discovery/loading, event stream, read tool, child hooks and journal; local deterministic inference. One successful project/session per case. Older V1 unsupported; other releases, paid inference, personal/global installation, other operating systems and live hot reload not demonstrated. Cleanup/failure/interruption/dedup exercised through regression tests, not claimed as live-host failure scenarios. | +| Evidence provenance | Raw runtime/gate/mutation files inspected directly; prior research and initial red tests verified from timestamped tool calls/results in local Codex transcript files under `/Users/baptistelafourcade/.codex/sessions/2026/10/07/`, not inferred from verification prose. Runtime/gate directory: `/tmp/aidd-953-runtime-ryTstm`. | +| Additional checks | Normal commit lint/typecheck/type-honesty/documentation gates pass; final clone budget unchanged at 596 duplicated lines (1.41%); built CLI 732.2 KB below 734 KB budget. Asset embedding/text loader and filename guard reviewed; helper stays outside scanned plugin directory. | +| Scope preservation | Preexisting `.gitignore` modification and untracked `.hermes.md` excluded from diff and untouched. No implementation/validator edits by checker. No source changed after code candidate `ae500bfb`; `ffa9c44a` changes only task documentation. | +| Unchecked | none | +| Unplanned | none; runtime delivery and loader relocation recorded in revised architecture projection | From d609721e34149edb7cbf615cc766f2286c7c575e Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 17:26:58 +0200 Subject: [PATCH 06/23] fix(cli): verify Kilo hooks and repair mutation asset loading AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .github/workflows/cli-ci.yml | 4 +- .../kilo-verification.md | 87 +++++ .../plan.md | 11 + .../verification.md | 7 +- .../domain/profiles/kilo/kilo-hooks-bridge.ts | 78 +++-- .../kilo-plugin-delivery.integration.test.ts | 117 +++++++ .../kilo/kilo-hooks-bridge.unit.test.ts | 158 ++++++++- cli/tests/e2e/kilo-runtime.e2e.test.ts | 308 ++++++++++++++++-- cli/vitest.mutation.config.ts | 2 +- 9 files changed, 713 insertions(+), 59 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md create mode 100644 cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index fa042e3de..072eb4439 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -339,8 +339,8 @@ jobs: key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - # The test starts Kilo's local server and asks it to load the generated project plugin. - # No model or account is needed for this protocol-level smoke. + # Exercise generated hooks and memory with the released Kilo server and read tool. + # Inference uses a deterministic loopback endpoint; no provider account is needed. - name: Install Kilo Code CLI run: npm install -g @kilocode/cli@7.7.5 - run: cd cli && pnpm test:e2e:kilo diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md new file mode 100644 index 000000000..319bb77ed --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -0,0 +1,87 @@ +# Kilo runtime verification and CI repair + +## Initial need +The user requested stronger evidence after the original Kilo smoke test was found to +replace update_memory.js with a marker and stop at session creation. A loaded plugin and +a zero exit code did not prove memory refresh or hooks during a complete turn. The user +also requested correction of the red checks on pull request 971. + +## Implemented behavior +The Kilo bridge retains its default id/server descriptor and now supports replayable +SessionStart, PostToolUse and Stop commands. It obtains the real session ID and project +directory from released events, dispatches completed tool parts with their name/input, +filters exact or pipe-separated tool matchers, consumes each tool part once, and resets +idle suppression on the next busy turn. Session deletion removes tracked state. Hook +failure remains reported without blocking the host. + +The mutation Vitest configuration now loads .txt assets as source text, matching the +ordinary test configuration. Previously six mutation jobs failed their initial tests +before producing a mutation score because the shared OpenCode adapter was a URL string. +No mutation threshold or bundle budget was changed. + +## Added and strengthened tests +- Two added bridge unit cases verify Stop/PostToolUse-only declarations and a released + session/tool/idle event sequence. The sequence covers real identity and cwd, malformed + events, incomplete/failed tools, matcher filtering, duplicate events, another busy + turn and deletion of session state. Existing failed-hook coverage expands to nonzero + exit, spawn failure and dispatch failure, each reported once without blocking. This + adds four executed unit cases overall; the bridge suite contains eight cases. +- Six added delivery integration cases cover setup, plugin installation and update for + each of kilo.jsonc and .kilo/kilo.jsonc. They preserve exact JSONC bytes, comments, + model, permissions and user MCP configuration. Installation checks the delivered + script, generated bridge and manifest version; update changes script/version and + restores a deleted bridge. These execute real application use cases with filesystem + and fetch doubles; they do not claim execution of the built CLI or Kilo. +- The existing opt-in runtime case now runs Kilo 7.7.5 through a complete model turn. + The CLI translate command delivers the tested hook modules. The context memory script + remains byte-identical to its source and must update AGENTS.md. A deterministic + loopback model endpoint requests the real read tool and receives its actual result. + An independent observer records host events; captured hooks must contain exactly one + SessionStart, PostToolUse(read) and Stop with the actual session ID and input. The + test retains skills, agents and MCP discovery checks, checks unchanged project + configuration and verifies shutdown of the server and captured hook processes. + +Sources of these tests: +- [Bridge unit tests](../../../../cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts) +- [Delivery integration tests](../../../../cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts) +- [Real Kilo runtime test](../../../../cli/tests/e2e/kilo-runtime.e2e.test.ts) + +## Strategy and observed failures +Released behavior was researched before extending the bridge. The +[official plugin documentation](https://kilo.ai/docs/automate/extending/plugins), +[custom provider documentation](https://kilo.ai/docs/code-with-ai/agents/custom-models) +and [published SDK 7.7.5 types](https://unpkg.com/@kilocode/sdk@7.7.5/dist/v2/gen/types.gen.d.ts) +were checked against a real isolated runtime. Its read part emitted pending, running, +running and completed updates; session.idle ended the turn. Declared types alone were +not treated as execution evidence. + +Before the bridge change, the real turn refreshed memory and read the file, but failed +with `expected 1 to be 3`: only SessionStart fired. Bridge regressions also failed before +implementation. The completed runtime case then passed. A first model harness assertion +incorrectly expected exactly two requests; Kilo also makes a title request. The corrected +proof checks actual tool-result content instead of assuming a total request count. + +The initial-test CI defect was reproduced with the mutation configuration: two existing +installation/update regressions received the adapter path instead of its source. After +adding .txt, all four runtime-file tests passed. A complete local OpenCode mutation run +then passed with score 96.4 against the unchanged floor of 94. + +```sh +pnpm --dir cli test:e2e:kilo +pnpm --dir cli exec vitest run --config vitest.mutation.config.ts tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts +pnpm --dir cli test:mutation:tools-opencode +``` + +Local raw evidence includes /tmp/kilo-hooks-runtime-red.log, +/tmp/kilo-runtime-final.log and /tmp/aidd-971-repair/mutation-opencode-local.log. +The research capture used an allowlisted environment with relocated HOME/XDG paths and +no inherited authentication variables. The committed runtime test follows the same +isolation approach. Only inference is substituted; Kilo, its event bus, read tool, +CLI delivery and spawned hooks execute normally. + +## Limits +Kilo telemetry and journal/cost attribution remain unsupported. This proof covers one +successful local turn, plus controlled unit event sequences and application delivery +tests. Paid providers, global installation, hot reload, exhaustive process-tree auditing +and Kilo runtime execution on Windows are not claimed. The ordinary suite deliberately +skips the opt-in host case; the dedicated CI job runs it explicitly. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md index d46557013..7d8ae0c63 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md @@ -41,3 +41,14 @@ status: implemented - Tests must fail before implementation for the entrypoint or behavior they name. - V2 events may differ from V1; capture real events and preserve session identity and task declaration. - OpenCode releases older than 1.18.29 do not support the documented default object contract; document the minimum instead of claiming untested backward compatibility. + +## Accepted verification extension +The user subsequently requested stronger Kilo evidence and correction of the red checks +on the existing pull request. Keep mutation thresholds unchanged and repair the shared +initial-test failure before judging mutation scores. Exercise the pinned Kilo 7.7.5 +runtime with the real memory script, a deterministic local model turn, observable hook +payloads and process cleanup. Capture released event shapes before extending supported +hooks. Validate existing configuration preservation through CLI delivery paths. Kilo +telemetry remains unsupported; this extension does not claim journal or cost coverage. +Record commands, observed failures, results and remaining limits in the verification +report. Re-enter independent review after the new candidate, then update the same PR. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md index caf1a08ae..97de119e0 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md @@ -80,7 +80,7 @@ python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candi The V2 command uses run --standalone; neither test starts a persistent background service. The installed personal OpenCode 1.14.20 and its profile were not upgraded or used. -## Automated validation +## Original OpenCode candidate validation - Full repository scripts suite through the normal commit hook: 554 passed, zero failures; the hook also verified that tests left git hooks unchanged. - Full CLI suite through the final normal push hook: 6,818 passed, one opt-in Kilo runtime test skipped; 531 files passed. Evidence: push-shared.log in the temporary harness directory. @@ -109,3 +109,8 @@ Paid inference, global-profile installation, other operating systems and a runni hot reload are not claimed by this local proof. The local deterministic model substitutes only inference; OpenCode's plugin loader, event stream, read tool, hook processes and journal are real. + +## Subsequent verification extension +The accepted Kilo runtime extension and mutation CI repair are documented in +[Kilo verification](./kilo-verification.md), with the initial need, implemented behavior, +added tests, observed failures, commands and limits. diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts index 07c29a8df..e27060068 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts @@ -1,9 +1,8 @@ /** * Kilo loads JavaScript modules from `.kilo/plugin/` and requires a default descriptor with * `{ id, server }`. Declarative hooks.json has no Kilo runtime, so this generator turns its - * replayable SessionStart commands into that native module. Kilo documents `session.created` as - * the session lifecycle event; other Claude hook events deliberately remain unsupported until - * their Kilo payloads have been captured. + * replayable commands into that native module. Kilo 7.7.5 delivers session.created, + * completed tool parts and session.idle through its event hook. */ interface ClaudeHookItem { @@ -11,6 +10,7 @@ interface ClaudeHookItem { } interface ClaudeMatcherGroup { + readonly matcher?: string; readonly hooks?: readonly ClaudeHookItem[]; } @@ -21,6 +21,7 @@ interface ClaudeHooksShape { interface ParsedHookCall { readonly script: string; readonly args: readonly string[]; + readonly matcher?: string; } const COMMAND_PATTERN = /^node\s+\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+)(.*)$/; @@ -33,17 +34,21 @@ function parseCommand(command: string | undefined): ParsedHookCall | null { return { script, args: rest.trim().length === 0 ? [] : rest.trim().split(/\s+/) }; } -/** Parses only the one declarative event whose Kilo mapping is documented and intended here. */ -export function parseKiloSessionStartHooks(rawHooksJson: string): readonly ParsedHookCall[] { - const parsed = JSON.parse(rawHooksJson) as ClaudeHooksShape; - return (parsed.hooks?.SessionStart ?? []).flatMap((group) => +function parseGroups(groups: readonly ClaudeMatcherGroup[] = []): readonly ParsedHookCall[] { + return groups.flatMap((group) => (group.hooks ?? []).flatMap((hook) => { const parsedHook = parseCommand(hook.command); - return parsedHook === null ? [] : [parsedHook]; + return parsedHook === null + ? [] + : [group.matcher ? { ...parsedHook, matcher: group.matcher } : parsedHook]; }) ); } +export function parseKiloSessionStartHooks(rawHooksJson: string): readonly ParsedHookCall[] { + return parseGroups((JSON.parse(rawHooksJson) as ClaudeHooksShape).hooks?.SessionStart); +} + function toIdentifier(plugin: string): string { return `${plugin .split("-") @@ -52,21 +57,25 @@ function toIdentifier(plugin: string): string { .join("")}KiloHooks`; } -/** Generates Kilo's default plugin descriptor, or no file where hooks.json has no replayable - * SessionStart command. The generated module keeps errors non-blocking so a failed memory - * refresh cannot prevent Kilo from creating a session. */ +/** Generates Kilo's default descriptor; failed hooks report without blocking its session. */ export function generateKiloHooksBridge(rawHooksJson: string, plugin: string): string | null { - const sessionStart = parseKiloSessionStartHooks(rawHooksJson); - if (sessionStart.length === 0) return null; + const hooks = (JSON.parse(rawHooksJson) as ClaudeHooksShape).hooks; + const sessionStart = parseGroups(hooks?.SessionStart); + const stop = parseGroups(hooks?.Stop); + const postToolUse = parseGroups(hooks?.PostToolUse); + if (sessionStart.length + stop.length + postToolUse.length === 0) return null; const identifier = toIdentifier(plugin); - return `// Generated by aidd from plugins/${plugin}/hooks/hooks.json - do not edit by hand. + return `// Generated by aidd: ${plugin}/hooks/hooks.json. import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; const HOOKS_DIR = fileURLToPath(new URL("../hooks/${plugin}/", import.meta.url)); const SESSION_START = ${JSON.stringify(sessionStart)}; +const STOP = ${JSON.stringify(stop)}; +const POST_TOOL_USE = ${JSON.stringify(postToolUse)}; function runHook(script, args, payload, directory) { + const body = JSON.stringify(payload); const child = spawn("node", [HOOKS_DIR + script, ...args], { cwd: directory, stdio: ["pipe", "ignore", "ignore"], @@ -85,11 +94,11 @@ function runHook(script, args, payload, directory) { } }); child.stdin.on("error", report); - child.stdin.end(JSON.stringify(payload)); + child.stdin.end(body); } function reportHookFailure(script, error) { - console.warn("[aidd] Kilo session-start hook " + script + " failed: " + String(error)); + console.warn("[aidd] Kilo hook " + script + " failed: " + String(error)); } function sessionStartCallsFor(event, directory) { @@ -97,21 +106,46 @@ function sessionStartCallsFor(event, directory) { return SESSION_START.map((hook) => ({ script: hook.script, args: hook.args, - payload: { hook_event_name: "SessionStart", session_id: null, cwd: directory }, + payload: { hook_event_name: "SessionStart", session_id: event.properties?.sessionID ?? null, cwd: event.properties?.info?.directory ?? directory }, })); } -const ${identifier} = async ({ directory }) => ({ +const ${identifier} = async ({ directory }) => { + const sessions = new Map(); + return { event: async ({ event }) => { try { - for (const call of sessionStartCallsFor(event, directory)) { - runHook(call.script, call.args, call.payload, directory); + const properties = event?.properties; + const id = properties?.sessionID; + if (typeof id !== "string") return; + if (event?.type === "session.deleted") { sessions.delete(id); return; } + if (!sessions.has(id)) sessions.set(id, { started: false, idle: false, tools: new Set(), directory }); + const session = sessions.get(id); + let calls = []; + if (event?.type === "session.created" && !session.started) { + session.started = true; + session.directory = properties?.info?.directory ?? directory; + calls = sessionStartCallsFor(event, session.directory); + } else if (event?.type === "session.status" && properties?.status?.type === "busy") { + session.idle = false; + } else if (event?.type === "session.idle" && !session.idle) { + session.idle = true; + calls = STOP.map(hook => ({ ...hook, payload: { hook_event_name: "Stop", session_id: id, cwd: session.directory } })); + } else if (event?.type === "message.part.updated") { + const part = properties?.part; + if (part?.type !== "tool" || part.state?.status !== "completed" || !part.tool || !part.id || session.tools.has(part.id)) return; + session.tools.add(part.id); + calls = POST_TOOL_USE.filter(hook => !hook.matcher || hook.matcher.split("|").includes(part.tool)).map(hook => ({ ...hook, payload: { hook_event_name: "PostToolUse", session_id: id, cwd: session.directory, tool_name: part.tool, tool_input: part.state.input } })); + } + for (const call of calls) { + runHook(call.script, call.args, call.payload, call.payload.cwd); } } catch (error) { - console.warn("[aidd] Kilo session-start hook dispatch failed: " + String(error)); + console.warn("[aidd] Kilo hook dispatch failed: " + String(error)); } }, -}); + }; +}; ${identifier}.sessionStartCallsFor = sessionStartCallsFor; diff --git a/cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts b/cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts new file mode 100644 index 000000000..c8f1f999d --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts @@ -0,0 +1,117 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js"; +import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import { SetupToolsUseCase } from "../../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; +import { buildUnitDeps, initProject } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; +import { REPOSITORY_ROOT } from "../../../../helpers/repository-root.js"; + +const ROOT = "/test-project"; +const FIXTURE = join(REPOSITORY_ROOT, "cli/tests/fixtures/plugins/claude-format/sample-plugin"); +const HOOK = ".kilo/hooks/sample-plugin/update_memory.js"; +const BRIDGE = ".kilo/plugin/sample-plugin-hooks.js"; +const USER_CONFIG = `{ + // Keep this comment and the user's formatting. + "model": "user/model", + "permission": { "bash": "ask" }, + "mcp": { "user": { "type": "local", "command": ["node", "user.js"] } } +}\n`; + +async function existingProject(configPath: string) { + const deps = await buildUnitDeps(ROOT); + await initProject(deps, ROOT); + await deps.fs.writeFile(join(ROOT, configPath), USER_CONFIG); + const setup = new SetupToolsUseCase( + deps.manifestRepo, + deps.installRuntimeConfigUseCase, + deps.installIdeConfigUseCase + ); + const setupOptions = { + projectRoot: ROOT, + aiTools: ["kilo" as const], + ideTools: [], + force: false, + version: "test", + }; + await setup.execute(setupOptions); + await seedFromDirectory(deps.fs, FIXTURE, { useAbsolutePaths: true }); + const distribution = new PluginDistributionLoader( + deps.pluginFetcher, + deps.pluginDistributionReader + ); + const add = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + distribution, + deps.hasher, + deps.logger, + deps.marketplaceRegistry, + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo, + deps.installRuntimeConfigUseCase + ); + const install = () => + add.execute({ + source: { kind: "local", path: FIXTURE }, + toolIds: ["kilo"], + projectRoot: ROOT, + interactive: false, + }); + const update = new PluginUpdateUseCase( + deps.fs, + deps.manifestRepo, + distribution, + deps.hasher, + undefined, + deps.installRuntimeConfigUseCase + ); + return { deps, setup, setupOptions, install, update }; +} + +describe.each(["kilo.jsonc", ".kilo/kilo.jsonc"])( + "Kilo delivery with a user-owned %s", + (configPath) => { + it("keeps the exact JSONC bytes during first and repeated tool setup", async () => { + const { deps, setup, setupOptions } = await existingProject(configPath); + expect(await deps.fs.readFile(join(ROOT, configPath))).toBe(USER_CONFIG); + expect(deps.manifestRepo.getCurrent()?.hasTool("kilo")).toBe(true); + await setup.execute(setupOptions); + expect(await deps.fs.readFile(join(ROOT, configPath))).toBe(USER_CONFIG); + const other = configPath === "kilo.jsonc" ? ".kilo/kilo.jsonc" : "kilo.jsonc"; + expect(await deps.fs.fileExists(join(ROOT, other))).toBe(false); + }); + + it("installs the hook and its generated bridge without changing user configuration", async () => { + const { deps, install } = await existingProject(configPath); + await install(); + expect(await deps.fs.readFile(join(ROOT, configPath))).toBe(USER_CONFIG); + expect(await deps.fs.readFile(join(ROOT, HOOK))).toBe( + await deps.fs.readFile(join(FIXTURE, "hooks/update_memory.js")) + ); + expect(await deps.fs.readFile(join(ROOT, BRIDGE))).toContain('id: "sample-plugin-hooks"'); + expect(deps.manifestRepo.getCurrent()?.getPlugins("kilo")[0]?.version).toBe("1.0.0"); + }); + + it("updates the hook and bridge while retaining the exact user configuration", async () => { + const { deps, install, update } = await existingProject(configPath); + await install(); + const upgradedHook = 'console.log("upgraded memory hook");\n'; + await deps.fs.writeFile(join(FIXTURE, "hooks/update_memory.js"), upgradedHook); + await deps.fs.writeFile( + join(FIXTURE, ".claude-plugin/plugin.json"), + '{"name":"sample-plugin","version":"2.0.0"}' + ); + await deps.fs.deleteFile(join(ROOT, BRIDGE)); + expect(await update.execute({ toolIds: ["kilo"], projectRoot: ROOT })).toEqual([ + "sample-plugin", + ]); + expect(await deps.fs.readFile(join(ROOT, configPath))).toBe(USER_CONFIG); + expect(await deps.fs.readFile(join(ROOT, HOOK))).toBe(upgradedHook); + expect(await deps.fs.readFile(join(ROOT, BRIDGE))).toContain('id: "sample-plugin-hooks"'); + expect(deps.manifestRepo.getCurrent()?.getPlugins("kilo")[0]?.version).toBe("2.0.0"); + }); + } +); diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts index 1df97a930..d9fb0beba 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts @@ -1,6 +1,7 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { @@ -31,12 +32,129 @@ describe("Kilo hooks bridge", () => { ).toEqual([{ script: "update_memory.js", args: ["--quiet"] }]); }); - it("returns no module when hooks.json has no replayable SessionStart command", () => { + it("returns no module when hooks.json has no supported replayable hooks", () => { expect( generateKiloHooksBridge(JSON.stringify({ hooks: { Stop: [] } }), "aidd-context") ).toBeNull(); }); + it("generates a bridge when only Stop or PostToolUse is declared", () => { + for (const event of ["Stop", "PostToolUse"]) { + expect( + generateKiloHooksBridge( + JSON.stringify({ + hooks: { + [event]: [{ hooks: [{ command: `node ${ROOT}/hooks/capture.cjs` }] }], + }, + }), + "probe" + ) + ).not.toBeNull(); + } + }); + + it("dispatches real-shaped session/tool/idle events once and resets on the next busy turn", async () => { + const directory = await mkdtemp(join(tmpdir(), "aidd-kilo-events-")); + const output = join(directory, "calls.jsonl"); + const call = { command: `node ${ROOT}/hooks/capture.cjs --quiet` }; + const generated = generateKiloHooksBridge( + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [call] }], + Stop: [{ hooks: [call] }], + PostToolUse: [{ matcher: "read|write", hooks: [call] }], + }, + }), + "probe" + ); + try { + await mkdir(join(directory, "hooks", "probe"), { recursive: true }); + await mkdir(join(directory, "plugin")); + await writeFile( + join(directory, "hooks", "probe", "capture.cjs"), + `const fs = require("node:fs"); let body = ""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => fs.appendFileSync(${JSON.stringify(output)}, JSON.stringify({ args: process.argv.slice(2), payload: JSON.parse(body) }) + "\\n"));` + ); + const modulePath = join(directory, "plugin", "bridge.mjs"); + await writeFile(modulePath, generated ?? ""); + const module = await import(pathToFileURL(modulePath).href); + const server = await module.default.server({ directory: "/fallback" }); + const emit = (event: unknown) => server.event({ event }); + const created = { + type: "session.created", + properties: { sessionID: "ses_one", info: { directory } }, + }; + await emit(created); + await emit(created); + const part = { + id: "prt_one", + sessionID: "ses_one", + messageID: "msg_one", + callID: "call_one", + type: "tool", + tool: "read", + state: { status: "completed", input: { filePath: "probe.txt" } }, + }; + const updated = (value: unknown) => ({ + type: "message.part.updated", + properties: { sessionID: "ses_one", part: value }, + }); + await emit(updated({ ...part, state: { status: "running" } })); + await emit(updated({ ...part, type: "text" })); + await emit(updated({ ...part, tool: "" })); + await emit(updated({ ...part, id: "" })); + await emit(updated({})); + await emit(undefined); + await emit(updated({ ...part, id: "other", tool: "bash" })); + await emit(updated({ ...part, id: "failed", state: { status: "error" } })); + await emit(updated(part)); + await emit(updated(part)); + const idle = { type: "session.idle", properties: { sessionID: "ses_one" } }; + await emit(idle); + await emit(idle); + await emit({ + type: "session.status", + properties: { sessionID: "ses_one", status: { type: "busy" } }, + }); + await emit(idle); + await emit({ type: "unknown" }); + await vi.waitFor( + async () => expect((await readFile(output, "utf8")).trim().split("\n")).toHaveLength(4), + { timeout: 5000 } + ); + await delay(100); + const calls = (await readFile(output, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls).toHaveLength(4); + expect(calls).toEqual( + expect.arrayContaining([ + { + args: ["--quiet"], + payload: { hook_event_name: "SessionStart", session_id: "ses_one", cwd: directory }, + }, + { + args: ["--quiet"], + payload: { + hook_event_name: "PostToolUse", + session_id: "ses_one", + cwd: directory, + tool_name: "read", + tool_input: { filePath: "probe.txt" }, + }, + }, + { + args: ["--quiet"], + payload: { hook_event_name: "Stop", session_id: "ses_one", cwd: directory }, + }, + ]) + ); + expect(calls.filter((call) => call.payload.hook_event_name === "Stop")).toHaveLength(2); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + it("generates an importable Kilo default descriptor and maps SessionStart to session.created", async () => { const generated = generateKiloHooksBridge( JSON.stringify({ @@ -52,7 +170,7 @@ describe("Kilo hooks bridge", () => { ); expect(generated).toContain('event?.type !== "session.created"'); expect(generated).toContain('hook_event_name: "SessionStart"'); - expect(generated).toContain("Kilo session-start hook"); + expect(generated).toContain("Kilo hook"); const directory = await mkdtemp(join(tmpdir(), "aidd-kilo-hooks-bridge-")); try { @@ -81,7 +199,11 @@ describe("Kilo hooks bridge", () => { } }); - it("reports a hook that exits unsuccessfully without blocking the session", async () => { + it.each([ + { kind: "nonzero exit", cwdMissing: false, corruptEvent: false, error: "exited with code 7" }, + { kind: "spawn failure", cwdMissing: true, corruptEvent: false, error: "ENOENT" }, + { kind: "dispatch failure", cwdMissing: false, corruptEvent: true, error: "broken event" }, + ])("reports $kind without blocking the session", async ({ cwdMissing, corruptEvent, error }) => { const generated = generateKiloHooksBridge( JSON.stringify({ hooks: { @@ -109,12 +231,28 @@ describe("Kilo hooks bridge", () => { }) => Promise<{ event: (input: { event: unknown }) => Promise }>; }; }; - const server = await module.default.server({ directory }); - await server.event({ event: { type: "session.created" } }); - await vi.waitFor( - () => expect(warn).toHaveBeenCalledWith(expect.stringContaining("exited with code 7")), - { timeout: 5000, interval: 20 } - ); + const server = await module.default.server({ + directory: cwdMissing ? join(directory, "absent") : directory, + }); + if (corruptEvent) { + await server.event({ + event: { + get properties() { + throw new Error("broken event"); + }, + }, + }); + } else { + await server.event({ + event: { type: "session.created", properties: { sessionID: "failed" } }, + }); + } + await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining(error)), { + timeout: 5000, + interval: 20, + }); + await delay(100); + expect(warn).toHaveBeenCalledTimes(1); } finally { warn.mockRestore(); await rm(directory, { recursive: true, force: true }); diff --git a/cli/tests/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts index da25298f1..8bb043473 100644 --- a/cli/tests/e2e/kilo-runtime.e2e.test.ts +++ b/cli/tests/e2e/kilo-runtime.e2e.test.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { 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"; import { describe, expect, it } from "vitest"; @@ -63,13 +64,64 @@ async function markerContent(path: string): Promise { } } -async function waitForMarker(path: string): Promise { - for (let attempt = 0; attempt < 50; attempt += 1) { - const content = await markerContent(path); - if (content !== "") return content; - await delay(100); +async function localModel() { + const requests: Array<{ messages: Array<{ role: string; content: unknown }> }> = []; + let readSent = false; + const server = createServer((request, response) => { + let body = ""; + request.on("data", (chunk) => { + body += String(chunk); + }); + request.on("end", () => { + if (request.url !== "/v1/chat/completions") { + response.writeHead(404).end(); + return; + } + const input = JSON.parse(body) as { + messages: Array<{ role: string; content: unknown }>; + tools?: Array<{ function: { name: string } }>; + }; + requests.push(input); + response.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta: unknown, finish_reason: string | null = null) => { + response.write( + `data: ${JSON.stringify({ id: "chatcmpl-local", object: "chat.completion.chunk", created: 1, model: "fake", choices: [{ index: 0, delta, finish_reason }] })}\n\n` + ); + }; + chunk({ role: "assistant" }); + if (!readSent && input.tools?.some((tool) => tool.function.name === "read")) { + readSent = true; + chunk({ + tool_calls: [ + { + index: 0, + id: "call_local_read", + type: "function", + function: { name: "read", arguments: '{"filePath":"probe.txt"}' }, + }, + ], + }); + chunk({}, "tool_calls"); + } else { + chunk({ content: "KILO_LOCAL_READ_CONFIRMED" }); + chunk({}, "stop"); + } + response.end("data: [DONE]\n\n"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("Missing model port"); + return { server, requests, url: `http://127.0.0.1:${address.port}/v1` }; +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; } - return markerContent(path); } async function startKilo(directory: string, env: NodeJS.ProcessEnv) { @@ -77,6 +129,7 @@ async function startKilo(directory: string, env: NodeJS.ProcessEnv) { cwd: directory, env, shell: KILO_SHELL, + detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], }); const output = deferred(); @@ -88,6 +141,10 @@ async function startKilo(directory: string, env: NodeJS.ProcessEnv) { }; child.stdout.on("data", collect); child.stderr.on("data", collect); + child.on("error", output.reject); + child.once("exit", (code, signal) => + output.reject(new Error(`Kilo exited ${code ?? signal}: ${text}`)) + ); const timer = setTimeout(() => output.reject(new Error(`Kilo did not start:\n${text}`)), 15000); try { const port = await output.promise; @@ -103,10 +160,8 @@ async function startKilo(directory: string, env: NodeJS.ProcessEnv) { async function stopKilo(child: ReturnType): Promise { const exited = deferred(); child.once("exit", () => exited.resolve()); - if (child.exitCode === null) { - child.kill("SIGTERM"); - } - if (child.exitCode !== null) exited.resolve(); + if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM"); + else exited.resolve(); if (KILO_SHELL && process.platform === "win32" && child.pid !== undefined) { try { await execFileAsync("taskkill", ["/pid", String(child.pid), "/t", "/f"]); @@ -122,23 +177,84 @@ async function stopKilo(child: ReturnType): Promise { } } await Promise.race([exited.promise, delay(5000)]); + if (process.platform !== "win32" && child.pid !== undefined) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + await expect.poll(() => child.exitCode !== null || child.signalCode !== null).toBe(true); + if (child.pid !== undefined) expect(alive(child.pid)).toBe(false); } describeKiloRuntime("E2E: real Kilo runtime", () => { - it("loads the generated plugin and fires session.created through Kilo", async () => { + it("runs memory and declared hooks exactly once through a real local read-tool turn", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("kilo-runtime"); let child: ReturnType | undefined; + let kiloUrl: string | undefined; + const model = await localModel(); try { const generatedProject = join(tempDir, "generated"); await mkdir(generatedProject, { recursive: true }); + const config = { + model: "openai-compatible/fake", + instructions: ["AGENTS.md"], + provider: { + "openai-compatible": { + options: { baseURL: model.url }, + models: { + fake: { name: "Local fake", tool_call: true, limit: { context: 8192, output: 256 } }, + }, + }, + }, + }; + const configPath = join(generatedProject, "kilo.jsonc"); + await writeFile(configPath, JSON.stringify(config)); const build = await runCli( ["translate", REPOSITORY_ROOT, "--to", "kilo", "--as", "flat", "--out", generatedProject], projectDir, fakeHome ); expect(build.exitCode, build.stderr).toBe(0); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject(config); - const env = sandboxedEnv(fakeHome); + const allowed = new Set([ + "PATH", + "Path", + "HOME", + "USERPROFILE", + "APPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "SystemRoot", + "SYSTEMROOT", + "windir", + "WINDIR", + "ComSpec", + "COMSPEC", + "PATHEXT", + "TEMP", + "TMP", + "LANG", + "LC_ALL", + "TZ", + "KILO_SERVER_PASSWORD", + ]); + const env = Object.fromEntries( + Object.entries( + sandboxedEnv(fakeHome, { + XDG_CACHE_HOME: join(fakeHome, ".cache"), + XDG_DATA_HOME: join(fakeHome, ".local", "share"), + XDG_STATE_HOME: join(fakeHome, ".local", "state"), + KILO_SERVER_PASSWORD: "", + }) + ).filter(([key]) => allowed.has(key)) + ); + const version = await execFileAsync(KILO_BIN, ["--version"], { env, shell: KILO_SHELL }); + expect(version.stdout.trim()).toBe("7.7.5"); const skills = await execFileAsync(KILO_BIN, ["debug", "skill"], { cwd: generatedProject, env, @@ -191,25 +307,171 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { }); expect(mcp.stdout).toContain("aidd-test-aidd-test-server"); - const marker = join(generatedProject, "session-start.marker"); + const marker = join(generatedProject, "AGENTS.md"); + await mkdir(join(generatedProject, "aidd_docs", "memory"), { recursive: true }); + await writeFile(join(generatedProject, "aidd_docs", "memory", "runtime.md"), "Kilo memory\n"); await writeFile( - join(generatedProject, ".kilo", "hooks", "aidd-context", "update_memory.js"), - `require("node:fs").appendFileSync(${JSON.stringify(marker)}, "fired\\n");\n` + marker, + "# Runtime\n\n\n\n" ); const bridgePath = join(generatedProject, ".kilo", "plugin", "aidd-context-hooks.js"); expect(existsSync(bridgePath)).toBe(true); + const memoryScript = join( + generatedProject, + ".kilo", + "hooks", + "aidd-context", + "update_memory.js" + ); + expect(await readFile(memoryScript, "utf8")).toBe( + await readFile( + join(REPOSITORY_ROOT, "plugins", "aidd-context", "hooks", "update_memory.js"), + "utf8" + ) + ); + await writeFile(join(generatedProject, "probe.txt"), "KILO_LOCAL_READ_OK\n"); + const payloadPath = join(generatedProject, "payloads.jsonl"); + const eventPath = join(generatedProject, "events.jsonl"); + const root = "$" + "{CLAUDE_PLUGIN_ROOT}"; + const capture = { command: `node ${root}/hooks/capture.js` }; + const proofSource = join(tempDir, "proof-source"); + const proofPlugin = join(proofSource, "plugins", "runtime-proof"); + await mkdir(join(proofSource, ".claude-plugin"), { recursive: true }); + await mkdir(join(proofPlugin, ".claude-plugin"), { recursive: true }); + await mkdir(join(proofPlugin, "hooks")); + await writeFile( + join(proofSource, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name: "runtime-proof", + plugins: [{ name: "runtime-proof", source: "./plugins/runtime-proof" }], + }) + ); + await writeFile( + join(proofPlugin, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "runtime-proof", version: "1.0.0" }) + ); + await writeFile( + join(proofPlugin, "hooks", "capture.js"), + `import { appendFileSync } from "node:fs"; let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => appendFileSync(${JSON.stringify(payloadPath)}, JSON.stringify({ pid: process.pid, payload: JSON.parse(input) }) + "\\n"));` + ); + await writeFile( + join(proofPlugin, "hooks", "hooks.json"), + JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [capture] }], + PostToolUse: [{ matcher: "read", hooks: [capture] }], + Stop: [{ hooks: [capture] }], + }, + }) + ); + const proofBuild = await runCli( + ["translate", proofSource, "--to", "kilo", "--as", "flat", "--out", generatedProject], + projectDir, + fakeHome + ); + expect(proofBuild.exitCode, proofBuild.stderr).toBe(0); + expect(existsSync(join(generatedProject, ".kilo", "plugin", "runtime-proof-hooks.js"))).toBe( + true + ); + await writeFile( + join(generatedProject, ".kilo", "plugin", "observer.js"), + `import { appendFileSync } from "node:fs"; export default { id: "runtime-observer", server: async () => ({ event: async ({ event }) => appendFileSync(${JSON.stringify(eventPath)}, JSON.stringify(event) + "\\n") }) };` + ); const started = await startKilo(generatedProject, env); child = started.child; - const response = await fetch(`http://127.0.0.1:${started.port}/session`, { - method: "POST", - headers: { "x-kilo-directory": generatedProject }, - }); - expect(response.ok).toBe(true); - expect(await waitForMarker(marker)).toBe("fired\n"); + kiloUrl = `http://127.0.0.1:${started.port}`; + const run = await execFileAsync( + KILO_BIN, + [ + "run", + "--attach", + kiloUrl, + "--dir", + generatedProject, + "--format", + "json", + "--auto", + "--model", + "openai-compatible/fake", + "Read probe.txt with the read tool.", + ], + { + cwd: generatedProject, + env, + shell: KILO_SHELL, + timeout: 60000, + maxBuffer: 8 * 1024 * 1024, + } + ); + expect(run.stdout).toContain("KILO_LOCAL_READ_CONFIRMED"); + expect(model.requests.flatMap((request) => request.messages)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + role: "tool", + content: expect.stringContaining("KILO_LOCAL_READ_OK"), + }), + ]) + ); + await expect + .poll(() => markerContent(marker), { timeout: 5000 }) + .toContain("[aidd_docs/memory/runtime.md](aidd_docs/memory/runtime.md)"); + await expect + .poll(async () => (await markerContent(payloadPath)).trim().split("\n").length, { + timeout: 5000, + }) + .toBe(3); + const events = (await readFile(eventPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const created = events.find((event) => event.type === "session.created"); + const sessionId = created.properties.sessionID; + expect(sessionId).toEqual(expect.any(String)); + const completed = events.filter( + (event) => + event.type === "message.part.updated" && + event.properties.part?.type === "tool" && + event.properties.part.state.status === "completed" + ); + expect(completed).toHaveLength(1); + expect(completed[0].properties.part.state.output).toContain("KILO_LOCAL_READ_OK"); + expect(events.filter((event) => event.type === "session.idle")).toHaveLength(1); + const calls = (await readFile(payloadPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(calls.map((call) => call.payload)).toEqual( + expect.arrayContaining([ + { hook_event_name: "SessionStart", session_id: sessionId, cwd: generatedProject }, + { + hook_event_name: "PostToolUse", + session_id: sessionId, + cwd: generatedProject, + tool_name: "read", + tool_input: { filePath: "probe.txt" }, + }, + { hook_event_name: "Stop", session_id: sessionId, cwd: generatedProject }, + ]) + ); + for (const call of calls) await expect.poll(() => alive(call.pid)).toBe(false); + expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject(config); } finally { - if (child) await stopKilo(child); - await cleanup(); + try { + if (child) await stopKilo(child); + if (kiloUrl) + await expect(fetch(kiloUrl, { signal: AbortSignal.timeout(1000) })).rejects.toThrow(); + } finally { + try { + model.server.closeAllConnections(); + await new Promise((resolve, reject) => + model.server.close((error) => (error ? reject(error) : resolve())) + ); + expect(model.server.listening).toBe(false); + } finally { + await cleanup(); + } + } } }, 120000); }); diff --git a/cli/vitest.mutation.config.ts b/cli/vitest.mutation.config.ts index d8e43eb1f..50194ca3b 100644 --- a/cli/vitest.mutation.config.ts +++ b/cli/vitest.mutation.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vitest/config"; import { UnloadableFileAsFailedTest } from "./tests/helpers/unloadable-file-as-failed-test.js"; import { textLoader } from "./tests/helpers/vitest-text-loader.js"; -const TEXT_EXTENSIONS = [".md", ".toml"] as const; +const TEXT_EXTENSIONS = [".md", ".toml", ".txt"] as const; /** * The projects a mutation run may use: the two that measure behaviour. From 23dc0ce72411b129ecd67a47b424cde41ca49404 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 17:31:08 +0200 Subject: [PATCH 07/23] test(cli): cover Kilo parser and session lifecycle mutations AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../kilo-verification.md | 30 ++++++++++-- .../kilo/kilo-hooks-bridge.unit.test.ts | 49 ++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md index 319bb77ed..a8acb32b9 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -23,9 +23,11 @@ No mutation threshold or bundle budget was changed. - Two added bridge unit cases verify Stop/PostToolUse-only declarations and a released session/tool/idle event sequence. The sequence covers real identity and cwd, malformed events, incomplete/failed tools, matcher filtering, duplicate events, another busy - turn and deletion of session state. Existing failed-hook coverage expands to nonzero + turn and session deletion/recreation. Five further cases cover missing hook tables, + missing commands, whitespace/argument parsing and balanced hook counts across events. + Existing failed-hook coverage expands to nonzero exit, spawn failure and dispatch failure, each reported once without blocking. This - adds four executed unit cases overall; the bridge suite contains eight cases. + adds nine executed unit cases overall; the bridge suite contains thirteen cases. - Six added delivery integration cases cover setup, plugin installation and update for each of kilo.jsonc and .kilo/kilo.jsonc. They preserve exact JSONC bytes, comments, model, permissions and user MCP configuration. Installation checks the delivered @@ -66,19 +68,41 @@ installation/update regressions received the adapter path instead of its source. adding .txt, all four runtime-file tests passed. A complete local OpenCode mutation run then passed with score 96.4 against the unchanged floor of 94. +The first local Kilo mutation measurement was 63.7, below its existing floor of 64. +Additional parser and lifecycle cases raised the score to 67.7. The final scope run +tested 175 mutants and reused 48 already measured results, 223 total. A separate copied +bridge with session-state deletion disabled made its lifecycle assertion fail with +`1 !== 2`; production source was never altered for that counterproof. + ```sh pnpm --dir cli test:e2e:kilo pnpm --dir cli exec vitest run --config vitest.mutation.config.ts tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts pnpm --dir cli test:mutation:tools-opencode +pnpm --dir cli test:mutation:tools-kilo ``` Local raw evidence includes /tmp/kilo-hooks-runtime-red.log, -/tmp/kilo-runtime-final.log and /tmp/aidd-971-repair/mutation-opencode-local.log. +/tmp/kilo-runtime-final.log, /tmp/aidd-971-repair/mutation-opencode-local.log, +/tmp/aidd-971-repair/mutation-kilo-repair.log and kilo-delete-mutant.log in that directory. The research capture used an allowlisted environment with relocated HOME/XDG paths and no inherited authentication variables. The committed runtime test follows the same isolation approach. Only inference is substituted; Kilo, its event bus, read tool, CLI delivery and spawned hooks execute normally. +## Local validation of the extension +- Real pinned runtime: one complete case passed, including actual read-tool results and + all three hook payloads. +- Kilo profile suites: 21 passed, including 13 bridge cases. Delivery integration: six + passed under the mutation configuration. +- Normal commit checks: 554 repository script tests and 140 architecture checks passed; + lint, TypeScript, type honesty, documentation and whitespace checks passed. The lint + warning about an unused private member in uninstall-use-case.ts is preexisting. +- Built CLI: 751,591 bytes against the unchanged 751,616-byte budget. This passes with + 25 bytes of headroom; future changes need to account for the remaining margin. +- The dedicated Kilo CI job explicitly runs the host case; the required cli / gate also + depends on every selected mutation job. Final remote status is available on + [pull request 971](https://github.com/ai-driven-dev/framework/pull/971/checks). + ## Limits Kilo telemetry and journal/cost attribution remain unsupported. This proof covers one successful local turn, plus controlled unit event sequences and application delivery diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts index d9fb0beba..d5c0adfbe 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts @@ -32,6 +32,46 @@ describe("Kilo hooks bridge", () => { ).toEqual([{ script: "update_memory.js", args: ["--quiet"] }]); }); + it("trims replayable commands and separates multiple whitespace arguments", () => { + expect( + parseKiloSessionStartHooks( + JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { command: ` node ${ROOT}/hooks/update_memory.js --quiet\t--tool kilo ` }, + {}, + ], + }, + ], + }, + }) + ) + ).toEqual([{ script: "update_memory.js", args: ["--quiet", "--tool", "kilo"] }]); + }); + + it("accepts absent hook tables, events and empty groups", () => { + for (const value of [{}, { hooks: {} }, { hooks: { SessionStart: [{}] } }]) { + expect(parseKiloSessionStartHooks(JSON.stringify(value))).toEqual([]); + expect(generateKiloHooksBridge(JSON.stringify(value), "probe")).toBeNull(); + } + }); + + it.each([ + ["SessionStart", "Stop"], + ["SessionStart", "PostToolUse"], + ["Stop", "PostToolUse"], + ])("delivers equally sized %s and %s hook groups together", (first, second) => { + const group = [{ hooks: [{ command: `node ${ROOT}/hooks/capture.cjs` }] }]; + expect( + generateKiloHooksBridge( + JSON.stringify({ hooks: { [first]: group, [second]: group } }), + "probe" + ) + ).not.toBeNull(); + }); + it("returns no module when hooks.json has no supported replayable hooks", () => { expect( generateKiloHooksBridge(JSON.stringify({ hooks: { Stop: [] } }), "aidd-context") @@ -117,8 +157,10 @@ describe("Kilo hooks bridge", () => { }); await emit(idle); await emit({ type: "unknown" }); + await emit({ type: "session.deleted", properties: { sessionID: "ses_one" } }); + await emit(created); await vi.waitFor( - async () => expect((await readFile(output, "utf8")).trim().split("\n")).toHaveLength(4), + async () => expect((await readFile(output, "utf8")).trim().split("\n")).toHaveLength(5), { timeout: 5000 } ); await delay(100); @@ -126,7 +168,7 @@ describe("Kilo hooks bridge", () => { .trim() .split("\n") .map((line) => JSON.parse(line)); - expect(calls).toHaveLength(4); + expect(calls).toHaveLength(5); expect(calls).toEqual( expect.arrayContaining([ { @@ -150,6 +192,9 @@ describe("Kilo hooks bridge", () => { ]) ); expect(calls.filter((call) => call.payload.hook_event_name === "Stop")).toHaveLength(2); + expect(calls.filter((call) => call.payload.hook_event_name === "SessionStart")).toHaveLength( + 2 + ); } finally { await rm(directory, { recursive: true, force: true }); } From 3e762c311ae34c9bf69dc20e38a4867d9f44b2fd Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 17:39:03 +0200 Subject: [PATCH 08/23] test(cli): assert final Kilo hook counts after shutdown AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../kilo-verification.md | 23 +++++++++++++++---- cli/tests/e2e/kilo-runtime.e2e.test.ts | 9 ++++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md index a8acb32b9..5c8aa7dbb 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -40,8 +40,14 @@ No mutation threshold or bundle budget was changed. loopback model endpoint requests the real read tool and receives its actual result. An independent observer records host events; captured hooks must contain exactly one SessionStart, PostToolUse(read) and Stop with the actual session ID and input. The - test retains skills, agents and MCP discovery checks, checks unchanged project - configuration and verifies shutdown of the server and captured hook processes. + test retains skills, agents and MCP discovery checks and verifies shutdown of the + server and captured hook processes. Final hook counts and payloads are read after + server-process-group shutdown, preventing a late extra record from passing a transient + count check. Translation intentionally normalizes JSON and adds its schema; the test + checks preservation of custom values during delivery, then snapshots the delivered + configuration and verifies byte-identical contents after host execution. Exact + preservation of original JSONC bytes during setup/install/update belongs to the six + application integration cases above. Sources of these tests: - [Bridge unit tests](../../../../cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts) @@ -74,6 +80,13 @@ tested 175 mutants and reused 48 already measured results, 223 total. A separate bridge with session-state deletion disabled made its lifecycle assertion fail with `1 !== 2`; production source was never altered for that counterproof. +Independent review found that a transient three-record poll followed by arrayContaining +could miss a late fourth hook. The corrected test reads its final snapshot after process +group shutdown and asserts exactly three records. A copied E2E case injected a fourth +record at that snapshot and failed with `length of 3 but got 4`; the original test's +SHA256 was unchanged and the temporary copy was removed. Configuration comparison was +also scoped to delivered bytes, respecting translation's intentional JSON normalization. + ```sh pnpm --dir cli test:e2e:kilo pnpm --dir cli exec vitest run --config vitest.mutation.config.ts tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts @@ -81,9 +94,9 @@ pnpm --dir cli test:mutation:tools-opencode pnpm --dir cli test:mutation:tools-kilo ``` -Local raw evidence includes /tmp/kilo-hooks-runtime-red.log, -/tmp/kilo-runtime-final.log, /tmp/aidd-971-repair/mutation-opencode-local.log, -/tmp/aidd-971-repair/mutation-kilo-repair.log and kilo-delete-mutant.log in that directory. +Local raw evidence includes /tmp/kilo-hooks-runtime-red.log and the following files in +/tmp/aidd-971-repair: kilo-quiescent-runtime-green.log, mutation-opencode-local.log, +mutation-kilo-repair.log, kilo-delete-mutant.log and kilo-late-hook-counterproof.log. The research capture used an allowlisted environment with relocated HOME/XDG paths and no inherited authentication variables. The committed runtime test follows the same isolation approach. Only inference is substituted; Kilo, its event bus, read tool, diff --git a/cli/tests/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts index 8bb043473..cfd959135 100644 --- a/cli/tests/e2e/kilo-runtime.e2e.test.ts +++ b/cli/tests/e2e/kilo-runtime.e2e.test.ts @@ -210,7 +210,8 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { }, }; const configPath = join(generatedProject, "kilo.jsonc"); - await writeFile(configPath, JSON.stringify(config)); + const originalConfig = JSON.stringify(config); + await writeFile(configPath, originalConfig); const build = await runCli( ["translate", REPOSITORY_ROOT, "--to", "kilo", "--as", "flat", "--out", generatedProject], projectDir, @@ -378,6 +379,7 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { `import { appendFileSync } from "node:fs"; export default { id: "runtime-observer", server: async () => ({ event: async ({ event }) => appendFileSync(${JSON.stringify(eventPath)}, JSON.stringify(event) + "\\n") }) };` ); + const deliveredConfig = await readFile(configPath, "utf8"); const started = await startKilo(generatedProject, env); child = started.child; kiloUrl = `http://127.0.0.1:${started.port}`; @@ -421,6 +423,8 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { timeout: 5000, }) .toBe(3); + await stopKilo(child); + child = undefined; const events = (await readFile(eventPath, "utf8")) .trim() .split("\n") @@ -441,6 +445,7 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { .trim() .split("\n") .map((line) => JSON.parse(line)); + expect(calls).toHaveLength(3); expect(calls.map((call) => call.payload)).toEqual( expect.arrayContaining([ { hook_event_name: "SessionStart", session_id: sessionId, cwd: generatedProject }, @@ -455,7 +460,7 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { ]) ); for (const call of calls) await expect.poll(() => alive(call.pid)).toBe(false); - expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject(config); + expect(await readFile(configPath, "utf8")).toBe(deliveredConfig); } finally { try { if (child) await stopKilo(child); From 0528e5ceeddcf6497bc3931468d79530470613cd Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 17:40:50 +0200 Subject: [PATCH 09/23] docs(framework): record independent Kilo verification review AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../challenge.md | 9 ++++-- .../review.md | 29 +++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md index 6f2667772..069c24289 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md @@ -5,11 +5,16 @@ My confidence level of correctness now: 95% - The shared protocol has one CLI-owned source, emitted under `.opencode/hooks/opencode-events.js`, outside plugin discovery. Each plugin keeps its own payload responsibility. Setup, translation, existing-tool installation/update and restoration account for this new dependency; existing configuration, untracked helper contents and tracked helper drift hashes are preserved. - All four real-host cases were independently checked against final module bytes, actual stdout session IDs, hook payloads, refreshed memory and journal records. Each contains exactly one session_start, task_declared and turn_end. Evidence: `/tmp/aidd-953-runtime-ryTstm/{candidate-v1,candidate-v2,candidate-install-v2,candidate-existing-v2}`; existing-install-summary.json confirms byte-preserved configuration and helper ownership once. - The model response is deterministic loopback inference. Plugin loading, host event delivery, the read tool, hook processes and journal effects are real. Coverage is one successful project/session per case; older V1, other releases/operating systems, paid inference, personal/global installation and live hot reload are not claimed. Failure, interruption, deduplication and subscription cancellation are regression-tested rather than demonstrated as live-host failures. -- The final normal gates report 6,818 CLI tests and 531 files passed, one opt-in Kilo test skipped, knip passed, 554 script tests passed and 140 architecture checks passed. Independent focused adapter/payload run: 26 passed, zero failed. The canonical-helper mutation yields an extra task_declared and fails the intended test. Final bundle is 732.2 KB under the unchanged 734 KB budget. -- Research-before-code and initial failing tests are backed by timestamped local tool transcripts, not only a written assertion. The final reviewed candidate is `origin/next...ffa9c44a`, with code at `ae500bfb`. No remaining contradiction, duplication finding or user-outcome failure was found; every consequential choice serves the requested outcome within the documented support boundary. +- For the original OpenCode candidate, normal gates report 6,818 CLI tests and 531 files passed, one opt-in Kilo test skipped, knip passed, 554 script tests passed and 140 architecture checks passed. Independent focused adapter/payload run: 26 passed, zero failed. The canonical-helper mutation yields an extra task_declared and fails the intended test. Its bundle is 732.2 KB under the unchanged 734 KB budget. +- Original research-before-code and initial failing tests are backed by timestamped local tool transcripts, not only a written assertion. The original reviewed candidate is `origin/next...ffa9c44a`, with code at `ae500bfb`. No contradiction, duplication finding or user-outcome failure was found within that documented support boundary. +- The accepted verification extension is independently reviewed at `b1a37fe0..3e762c31`. It serves the request for more credible Kilo evidence: CLI translation supplies the modules, the byte-identical memory script updates AGENTS.md, and released Kilo 7.7.5 executes an actual read-tool turn with host-observed session identity. Only inference is substituted. Independent host and focused/profile runs passed; detailed commands, raw logs and limits have one home in [Kilo verification](./kilo-verification.md). +- The review found and closed a proof gap: a transient three-record count could accept a late duplicate. Final payload reading now follows process-group shutdown and requires exactly three records and the exact SessionStart, PostToolUse and Stop payloads. The corrected host passes, while a copied fourth-record case fails the new assertion; original test bytes remain unchanged by the counterproof. Captured child PIDs, closed host port and local model shutdown are asserted. +- Configuration evidence is scoped honestly: setup, installation and update preserve original JSONC bytes in six application tests; translation may normalize JSON and add schema, after which the real-host case requires byte-identical configuration. Kilo telemetry remains unsupported. Other operating systems, hot reload, paid providers and exhaustive process-tree auditing are not inferred from the single released-host turn. +- The shared mutation initial-test failure is repaired by source-loading .txt assets in the mutation configuration. Existing asset-delivery regressions now pass there; raw local mutation reports show OpenCode 96.4 against floor 94 and Kilo 67.7 against floor 64, with neither floors nor bundle budget reduced. These local results do not establish green remote CI: the parent must verify the actual new required gate before declaring the user's red-check request complete. # Deal breakers - None. # Suggestions (enhancements only) - Add an interleaved-call regression using the same tool id across different assistantMessageID/sessionID values if this adapter is extended. The current tuple key distinguishes those identities by inspection, but existing tests do not specifically protect that distinction. This is additional coverage, not an unmet acceptance criterion or a claim about current real-host evidence. +- Preserve the remaining 25-byte CLI bundle headroom when adding further behavior; the current measured 751,591 bytes satisfy the unchanged 751,616-byte budget. This is a future maintenance constraint, not a reason to weaken the current gate or expand this ticket. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md index 8f0c978c1..8dce73483 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md @@ -1,6 +1,6 @@ # Review: OpenCode plugin loading (#953) - **Verdict**: approve -- **Diff**: `origin/next...ffa9c44a` (code `ae500bfb`, evidence `ffa9c44a`) +- **Diff**: original review `origin/next...ffa9c44a`; extension `b1a37fe0..3e762c31` - **Axes run**: code, functional, relevancy - **Date**: 2026_10_07 - **Findings**: 0 critical, 0 warning, 0 minor @@ -17,6 +17,16 @@ - [x] Official research precedes code, as explicitly requested. β€” Parent transcript `rollout-2026-10-07T15-38-13-01a11696-08a4-78b1-b05c-c456b9fbceb3.jsonl`, entries 97/104 and 154/161: official requests/results at 13:45:59–13:47:39 UTC. First production patch follows at 13:51:56 UTC. Independently verified [official migration contract](https://opencode.ai/v2/docs/build/plugins/migrate-v1) and [plugin API](https://opencode.ai/v2/docs/build/plugins). - [x] Project test-first and guard-mutation requirements have observed evidence. β€” Executor transcript `rollout-2026-10-07T15-49-59-01a116a0-cda7-74c3-9bbe-51865ae26720.jsonl`, entries 78/83/94: tests added 13:51:21 UTC, named undefined id/setup failures 13:51:28, production patch 13:51:56. Initial mutation entries 343/346 copy plugin/test, verify deletion anchor, assert targeted failure and clean scratch. Canonical-helper mutation raw failure is retained in `mutation-shared.log` with duplicate task_declared. Parent transcript entries 1146/1162 retain existing-tool missing-helper failures followed by four passing migration/preservation tests. +### Phase 2 β€” Accepted Kilo verification and mutation repair +- [x] Repair the shared mutation initial-test failure without reducing floors. β€” `cli/vitest.mutation.config.ts:5`; independent mutation-config run: 23 passed, including the four runtime-file regressions. Raw OpenCode mutation score 96.4/floor 94 and Kilo score 67.7/floor 64 inspected. +- [x] Run the released pinned host with the actual delivered memory script. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:228`, `cli/tests/e2e/kilo-runtime.e2e.test.ts:310`; independent `pnpm --dir cli test:e2e:kilo`: one passed, 18.7 seconds; source bytes and AGENTS.md memory link asserted. +- [x] Execute a complete model turn with real read output and released session identity. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:383`; observer, model tool-result message and three exact payload shapes asserted. Inference alone uses the deterministic loopback endpoint. +- [x] Prove each hook occurs exactly once in the final runtime trace. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:426`, `cli/tests/e2e/kilo-runtime.e2e.test.ts:448`: stop the host process group before the final snapshot, assert exactly three records and check all three exact payloads. Corrected host case passes in 14.819 seconds; copied fourth-record counterproof fails at the intended assertion with length 3 but got 4. Initial review finding fixed in `3e762c31`. +- [x] Support captured lifecycle events, suppress replay and release deleted session state. β€” `cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts:118`; independent 13 bridge cases passed; copied deletion mutant fails with actual 1 versus expected 2. Real pre-implementation turn fails with expected 1 to be 3 in `/tmp/kilo-hooks-runtime-red.log`. +- [x] Preserve configuration through setup, installation and update. β€” `cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts:78`; six cases passed for both configuration locations, exact JSONC bytes and regenerated bridge. These are application use cases with port doubles; actual CLI translation is separately exercised by the host test. +- [x] Verify server, captured children and local model shutdown. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:168`, `cli/tests/e2e/kilo-runtime.e2e.test.ts:457`; independent real-host case passed with process/PID assertions, process-group cleanup, closed host port and model server. +- [x] Keep unsupported telemetry and evidence limits explicit. β€” `kilo-verification.md`; unchanged Kilo profile declares unsupported telemetry. Journal host detection does not claim the generated Kilo payloads. Other operating systems, paid providers, hot reload and exhaustive process-tree auditing remain unclaimed. + ## Findings | Sev | Kind | Phase | Location | Issue | Fix | | --- | --- | --- | --- | --- | --- | @@ -25,8 +35,8 @@ ## Verification | Metric | Value | | --- | --- | -| Verified | 100% (9/9 phase and stated-process criteria); plan criteria 7/7 | -| Checker score | 100%: all nine criteria and four baseline checks fulfilled, 13/13. No severity adjustment. Caller owns its acceptance threshold. | +| Original verified | 100% (9/9 phase and stated-process criteria); plan criteria 7/7 | +| Original checker score | 100%: all nine original criteria and four baseline checks fulfilled, 13/13. No severity adjustment. Caller owns its acceptance threshold. | | Files checked | Every changed line in all 60 files in `origin/next...ffa9c44a`, including task artifacts, canonical JS text asset, bridge and telemetry wrappers, runtime-file metadata/build/install/restore/wiring, generalized distribution loader and all constructor migrations, tests/golden/configuration, filename guard and documentation. Root/CLI assertions and applicable architecture/convention rules also checked. | | No information duplication | Fulfilled: V2 protocol and lifecycle have one home, `cli/assets/configs/opencode/opencode-events.js.txt`; both wrappers reuse it. Source fetching/parsing likewise reuses the generalized existing loader. Plugin-specific payload adapters retain separate responsibilities. | | No incoherence or contradiction | Fulfilled: version-qualified docs match source and observed events; unsupported concurrency coverage removed; final counts and bundle size match logs. Existing-helper preservation and tool ownership are explicit. | @@ -36,7 +46,16 @@ | Runtime freshness and identity | Independently compared executed telemetry bytes with current repository source, helper bytes with canonical asset, and executed context bytes with final CLI-translated candidate across all four cases. All effects/session IDs/task arguments verified: translated V1 `ses_ee936b4a1ffeUtLo53jLIRbwGv`; translated V2 `ses_ee936cc1effeZlQEZZQWcz1C7z`; setup-installed V2 `ses_ee93675e4ffeXe9XxjPANgDAQH`; existing-tool backfilled V2 `ses_ee92fc668ffekZp0PjzqAcnJah`. | | Runtime evidence boundary | Released hosts V1 1.18.29 and V2 2.0.22; real discovery/loading, event stream, read tool, child hooks and journal; local deterministic inference. One successful project/session per case. Older V1 unsupported; other releases, paid inference, personal/global installation, other operating systems and live hot reload not demonstrated. Cleanup/failure/interruption/dedup exercised through regression tests, not claimed as live-host failure scenarios. | | Evidence provenance | Raw runtime/gate/mutation files inspected directly; prior research and initial red tests verified from timestamped tool calls/results in local Codex transcript files under `/Users/baptistelafourcade/.codex/sessions/2026/10/07/`, not inferred from verification prose. Runtime/gate directory: `/tmp/aidd-953-runtime-ryTstm`. | -| Additional checks | Normal commit lint/typecheck/type-honesty/documentation gates pass; final clone budget unchanged at 596 duplicated lines (1.41%); built CLI 732.2 KB below 734 KB budget. Asset embedding/text loader and filename guard reviewed; helper stays outside scanned plugin directory. | -| Scope preservation | Preexisting `.gitignore` modification and untracked `.hermes.md` excluded from diff and untouched. No implementation/validator edits by checker. No source changed after code candidate `ae500bfb`; `ffa9c44a` changes only task documentation. | +| Original additional checks | Normal commit lint/typecheck/type-honesty/documentation gates pass; final clone budget unchanged at 596 duplicated lines (1.41%); built CLI 732.2 KB below 734 KB budget. Asset embedding/text loader and filename guard reviewed; helper stays outside scanned plugin directory. | +| Original scope preservation | Preexisting `.gitignore` modification and untracked `.hermes.md` excluded from diff and untouched. No implementation/validator edits by checker. No source changed after original code candidate `ae500bfb` before original evidence candidate `ffa9c44a`, which changes only task documentation. | | Unchecked | none | | Unplanned | none; runtime delivery and loader relocation recorded in revised architecture projection | +| Extension verified | 100% (8/8); original Phase 1 evidence above is retained as historical evidence, not a claim that remote CI for the extension is green. | +| Extension checker score | 100% (12/12: all eight extension acceptance checks and four baseline checks fulfilled); no severity adjustment. Caller owns the acceptance threshold. | +| Extension files checked | All changed lines in the nine extension files, generated bridge, runtime harness, six delivery cases, configuration loader, workflow and task evidence; root/CLI coding and testing assertions. | +| Extension baseline | DRY, consistency within documented host/telemetry limits, minimum relevant scope and no dead/debug production code fulfilled. Observer and deterministic inference remain test-only. | +| Extension commands run | Independent mutation-config focused tests: 23 passed; complete Kilo profile tests: 21 passed; real Kilo 7.7.5 host: one passed. Final corrected-host green, typecheck and targeted counterproof logs inspected directly after reviewing the correction. | +| Extension fixed finding | The final snapshot now rejects delayed fourth records. Evidence: `/tmp/aidd-971-repair/kilo-quiescent-runtime-green.log` and `kilo-late-hook-counterproof.log`; temporary copied case removed. Reviewed committed test SHA256: 5f53e7f983654fc777b035840ab34a8fc5dbc5b2127d0b2983151eef5f964559. | +| Extension configuration proof | Six application use cases preserve original JSONC bytes through setup/add/update. CLI translation intentionally normalizes JSON and adds schema, so the runtime test checks custom values during delivery and exact preservation of the delivered configuration after host execution. | +| Extension pending evidence | Full current CLI suite and remote required gate not yet established by this checker. No claim that all red CI jobs are repaired remotely; parent must inspect the actual new run. | +| Extension resource boundary | Built bytes 751,591 against unchanged 751,616 budget; 25 bytes remaining. Thresholds and budgets unchanged; user-owned .gitignore and .hermes.md excluded. | From 16d8b223420168c31e92bea3eb9a45391b457967 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 17:44:15 +0200 Subject: [PATCH 10/23] test(cli): refresh Kilo bridge golden after lifecycle support AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../2026_10_07_opencode-plugin-loading/kilo-verification.md | 6 ++++++ cli/tests/golden/snapshots/framework-build/golden.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md index 5c8aa7dbb..9970c2276 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -87,6 +87,12 @@ record at that snapshot and failed with `length of 3 but got 4`; the original te SHA256 was unchanged and the temporary copy was removed. Configuration comparison was also scoped to delivered bytes, respecting translation's intentional JSON normalization. +The complete normal pre-push suite then caught one stale golden baseline for the Kilo +context bridge, with 6,832 tests passing and that single assertion failing. The official +recapture changed only that generated file's stored hash; the other nine matrix cells +and all file lists remained unchanged. All three golden tests subsequently passed in +comparison mode, including deterministic output and the complete ten-cell matrix. + ```sh pnpm --dir cli test:e2e:kilo pnpm --dir cli exec vitest run --config vitest.mutation.config.ts tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index 0886608f8..7add7ffbf 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -1940,7 +1940,7 @@ ".kilo/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".kilo/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".kilo/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".kilo/plugin/aidd-context-hooks.js": "135970897458426190377542adc66dea0d42eca4badf9161d671ff34a1275ede", + ".kilo/plugin/aidd-context-hooks.js": "1ccdf9253492b1a3663e418d58a542c7d97046964070e332c7bc110d960c45b4", ".kilo/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".kilo/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", ".kilo/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", From debb074ad2f5a7205bd8e653fafccf9fe0c4d58d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 17:46:47 +0200 Subject: [PATCH 11/23] docs(framework): verify corrected Kilo golden baseline AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../2026_10/2026_10_07_opencode-plugin-loading/challenge.md | 3 ++- .../2026_10/2026_10_07_opencode-plugin-loading/review.md | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md index 069c24289..6f6f71a29 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md @@ -7,10 +7,11 @@ My confidence level of correctness now: 95% - The model response is deterministic loopback inference. Plugin loading, host event delivery, the read tool, hook processes and journal effects are real. Coverage is one successful project/session per case; older V1, other releases/operating systems, paid inference, personal/global installation and live hot reload are not claimed. Failure, interruption, deduplication and subscription cancellation are regression-tested rather than demonstrated as live-host failures. - For the original OpenCode candidate, normal gates report 6,818 CLI tests and 531 files passed, one opt-in Kilo test skipped, knip passed, 554 script tests passed and 140 architecture checks passed. Independent focused adapter/payload run: 26 passed, zero failed. The canonical-helper mutation yields an extra task_declared and fails the intended test. Its bundle is 732.2 KB under the unchanged 734 KB budget. - Original research-before-code and initial failing tests are backed by timestamped local tool transcripts, not only a written assertion. The original reviewed candidate is `origin/next...ffa9c44a`, with code at `ae500bfb`. No contradiction, duplication finding or user-outcome failure was found within that documented support boundary. -- The accepted verification extension is independently reviewed at `b1a37fe0..3e762c31`. It serves the request for more credible Kilo evidence: CLI translation supplies the modules, the byte-identical memory script updates AGENTS.md, and released Kilo 7.7.5 executes an actual read-tool turn with host-observed session identity. Only inference is substituted. Independent host and focused/profile runs passed; detailed commands, raw logs and limits have one home in [Kilo verification](./kilo-verification.md). +- The accepted verification extension is independently reviewed at `b1a37fe0..16d8b223`. It serves the request for more credible Kilo evidence: CLI translation supplies the modules, the byte-identical memory script updates AGENTS.md, and released Kilo 7.7.5 executes an actual read-tool turn with host-observed session identity. Only inference is substituted. Independent host and focused/profile runs passed; detailed commands, raw logs and limits have one home in [Kilo verification](./kilo-verification.md). - The review found and closed a proof gap: a transient three-record count could accept a late duplicate. Final payload reading now follows process-group shutdown and requires exactly three records and the exact SessionStart, PostToolUse and Stop payloads. The corrected host passes, while a copied fourth-record case fails the new assertion; original test bytes remain unchanged by the counterproof. Captured child PIDs, closed host port and local model shutdown are asserted. - Configuration evidence is scoped honestly: setup, installation and update preserve original JSONC bytes in six application tests; translation may normalize JSON and add schema, after which the real-host case requires byte-identical configuration. Kilo telemetry remains unsupported. Other operating systems, hot reload, paid providers and exhaustive process-tree auditing are not inferred from the single released-host turn. - The shared mutation initial-test failure is repaired by source-loading .txt assets in the mutation configuration. Existing asset-delivery regressions now pass there; raw local mutation reports show OpenCode 96.4 against floor 94 and Kilo 67.7 against floor 64, with neither floors nor bundle budget reduced. These local results do not establish green remote CI: the parent must verify the actual new required gate before declaring the user's red-check request complete. +- The normal pre-push correctly stopped on a stale Kilo golden hash. The reviewed repair changes only the expected generated bridge hash and its evidence note; it leaves production, assertions, mutation floors and all ten file lists unchanged. Independent comparison-mode execution passes all three golden cases, with no recapture mode active. The replacement hash therefore represents the already-authorized lifecycle behavior rather than concealing an additional change. A new full-suite run and the actual remote required gate remain pending. # Deal breakers - None. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md index 8dce73483..1c10cbb6f 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md @@ -1,6 +1,6 @@ # Review: OpenCode plugin loading (#953) - **Verdict**: approve -- **Diff**: original review `origin/next...ffa9c44a`; extension `b1a37fe0..3e762c31` +- **Diff**: original review `origin/next...ffa9c44a`; extension `b1a37fe0..16d8b223` - **Axes run**: code, functional, relevancy - **Date**: 2026_10_07 - **Findings**: 0 critical, 0 warning, 0 minor @@ -52,10 +52,12 @@ | Unplanned | none; runtime delivery and loader relocation recorded in revised architecture projection | | Extension verified | 100% (8/8); original Phase 1 evidence above is retained as historical evidence, not a claim that remote CI for the extension is green. | | Extension checker score | 100% (12/12: all eight extension acceptance checks and four baseline checks fulfilled); no severity adjustment. Caller owns the acceptance threshold. | -| Extension files checked | All changed lines in the nine extension files, generated bridge, runtime harness, six delivery cases, configuration loader, workflow and task evidence; root/CLI coding and testing assertions. | +| Extension files checked | All changed lines in the ten implementation/evidence files, generated bridge, runtime harness, six delivery cases, configuration loader, workflow, golden baseline and task evidence; root/CLI coding and testing assertions. Subsequent review-artifact commit adds no production changes. | | Extension baseline | DRY, consistency within documented host/telemetry limits, minimum relevant scope and no dead/debug production code fulfilled. Observer and deterministic inference remain test-only. | | Extension commands run | Independent mutation-config focused tests: 23 passed; complete Kilo profile tests: 21 passed; real Kilo 7.7.5 host: one passed. Final corrected-host green, typecheck and targeted counterproof logs inspected directly after reviewing the correction. | | Extension fixed finding | The final snapshot now rejects delayed fourth records. Evidence: `/tmp/aidd-971-repair/kilo-quiescent-runtime-green.log` and `kilo-late-hook-counterproof.log`; temporary copied case removed. Reviewed committed test SHA256: 5f53e7f983654fc777b035840ab34a8fc5dbc5b2127d0b2983151eef5f964559. | | Extension configuration proof | Six application use cases preserve original JSONC bytes through setup/add/update. CLI translation intentionally normalizes JSON and adds schema, so the runtime test checks custom values during delivery and exact preservation of the delivered configuration after host execution. | +| Extension golden repair | The complete pre-push run was withheld with 6,832 passed, one failed and one skipped; sole failure was the stale Kilo generated-context bridge hash. The delta `0528e5ce..16d8b223` changes only that stored hash and its verification note, no source/assertions/floors. Independent structured comparison confirms ten cells, identical file lists and one changed hash: `kilo:flat/.kilo/plugin/aidd-context-hooks.js`, now 1ccdf9253492b1a3663e418d58a542c7d97046964070e332c7bc110d960c45b4. | +| Extension golden validation | Independent `pnpm --dir cli exec vitest run --project=e2e tests/golden/framework-build-golden.e2e.test.ts`, without recapture: three passed in comparison mode, including deterministic outputs and all ten stored baselines. Raw log: `/tmp/aidd-971-repair/checker-golden-comparison.log`. Runtime test blob remains cfd95913537eb6b22371583b905797190557602c; unchanged source/host/mutation checks were not rerun. | | Extension pending evidence | Full current CLI suite and remote required gate not yet established by this checker. No claim that all red CI jobs are repaired remotely; parent must inspect the actual new run. | | Extension resource boundary | Built bytes 751,591 against unchanged 751,616 budget; 25 bytes remaining. Thresholds and budgets unchanged; user-owned .gitignore and .hermes.md excluded. | From 116f791eda2c886fa63d117d29f27e7ded294389 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 17:59:24 +0200 Subject: [PATCH 12/23] fix(cli): keep embedded configuration bytes stable on Windows AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../kilo-verification.md | 20 ++++++- cli/.gitattributes | 3 ++ .../bundled-config-checkout.arch.test.ts | 52 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 cli/tests/architecture/bundled-config-checkout.arch.test.ts diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md index 9970c2276..7981e6d5e 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -48,11 +48,16 @@ No mutation threshold or bundle budget was changed. configuration and verifies byte-identical contents after host execution. Exact preservation of original JSONC bytes during setup/install/update belongs to the six application integration cases above. +- Two architecture cases exercise a real temporary Git checkout with core.autocrlf=true. + They check exact LF bytes for both embedded configuration assets and deliberately + remove their attribute rule to prove that the guard detects CRLF conversion. The + tests relocate profiles and use an independent temporary .git directory. Sources of these tests: - [Bridge unit tests](../../../../cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts) - [Delivery integration tests](../../../../cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts) - [Real Kilo runtime test](../../../../cli/tests/e2e/kilo-runtime.e2e.test.ts) +- [Embedded-config checkout tests](../../../../cli/tests/architecture/bundled-config-checkout.arch.test.ts) ## Strategy and observed failures Released behavior was researched before extending the bridge. The @@ -93,11 +98,22 @@ recapture changed only that generated file's stored hash; the other nine matrix and all file lists remained unchanged. All three golden tests subsequently passed in comparison mode, including deterministic output and the complete ten-cell matrix. +The first updated remote run passed the actual Kilo test on Ubuntu but exposed a Windows +bundle failure. A separate local clone reproduced its checkout behavior: 89 CRLF lines +in the OpenCode asset and one in the Codex TOML added 180 escaped bytes to the bundle. +Identical source built to 751,771 bytes with CRLF and 751,591 with LF. The existing +cli/.gitattributes now declares assets/configs/** text eol=lf. No algorithm, source +behavior, dependency or budget changed. The checkout guard failed before this correction +and passed afterward; removing the rule in its isolated fixture detects both changed +assets. Raw logs are bundle-crlf-build.log, bundle-lf-build.log and +bundle-eol-guard-before.log in the evidence directory below. + ```sh pnpm --dir cli test:e2e:kilo pnpm --dir cli exec vitest run --config vitest.mutation.config.ts tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts pnpm --dir cli test:mutation:tools-opencode pnpm --dir cli test:mutation:tools-kilo +pnpm --dir cli test:arch ``` Local raw evidence includes /tmp/kilo-hooks-runtime-red.log and the following files in @@ -113,7 +129,9 @@ CLI delivery and spawned hooks execute normally. all three hook payloads. - Kilo profile suites: 21 passed, including 13 bridge cases. Delivery integration: six passed under the mutation configuration. -- Normal commit checks: 554 repository script tests and 140 architecture checks passed; +- Normal commit checks: 554 repository script tests passed; final architecture suite: + 142 passed, including the two added checkout guards. Full pre-push before the checkout + extension: 6,833 passed, one opt-in case skipped, 532 files passed; knip passed. lint, TypeScript, type honesty, documentation and whitespace checks passed. The lint warning about an unused private member in uninstall-use-case.ts is preexisting. - Built CLI: 751,591 bytes against the unchanged 751,616-byte budget. This passes with diff --git a/cli/.gitattributes b/cli/.gitattributes index fe567a6c8..1fd2a4415 100644 --- a/cli/.gitattributes +++ b/cli/.gitattributes @@ -7,3 +7,6 @@ # "a test reads this file's raw bytes", which is true of any fixture format, not a property # of JSON specifically. tests/fixtures/** text eol=lf + +# Raw config assets are embedded in the bundle; CRLF changes their bytes and its size. +assets/configs/** text eol=lf diff --git a/cli/tests/architecture/bundled-config-checkout.arch.test.ts b/cli/tests/architecture/bundled-config-checkout.arch.test.ts new file mode 100644 index 000000000..021394bf2 --- /dev/null +++ b/cli/tests/architecture/bundled-config-checkout.arch.test.ts @@ -0,0 +1,52 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { read } from "./helpers.js"; + +const ASSETS = [ + "assets/configs/opencode/opencode-events.js.txt", + "assets/configs/codex/config.toml", +]; + +function changedByWindowsCheckout(attributes: string): string[] { + const root = mkdtempSync(join(tmpdir(), "aidd-config-checkout-")); + const env = { ...environmentWithoutGitVariables(), HOME: root, USERPROFILE: root }; + const git = (...args: string[]) => + execFileSync("git", ["-c", "core.autocrlf=true", ...args], { + cwd: root, + env, + stdio: "pipe", + }); + try { + git("init", "--quiet"); + writeFileSync(join(root, ".gitattributes"), attributes); + const expected = new Map(ASSETS.map((path) => [path, read(path).replace(/\r\n/g, "\n")])); + for (const [path, content] of expected) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); + } + git("add", "--", ".gitattributes", ...ASSETS); + for (const path of ASSETS) rmSync(join(root, path)); + git("checkout-index", "--", ...ASSETS); + return ASSETS.filter((path) => readFileSync(join(root, path), "utf8") !== expected.get(path)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("bundled config assets in a Windows checkout", () => { + it("keeps the exact LF bytes that the bundle embeds", () => { + expect(changedByWindowsCheckout(read(".gitattributes"))).toEqual([]); + }); + + it("detects changed bytes in both real assets when their LF rule is removed", () => { + const withoutRule = read(".gitattributes") + .split("\n") + .filter((line) => !line.startsWith("assets/configs/")) + .join("\n"); + expect(changedByWindowsCheckout(withoutRule)).toEqual(ASSETS); + }); +}); From cdd2e591917aaec6e778d70dc9f65520112892cf Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 18:04:38 +0200 Subject: [PATCH 13/23] docs(framework): record Windows checkout verification AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../2026_10_07_opencode-plugin-loading/challenge.md | 3 ++- .../kilo-verification.md | 2 +- .../2026_10_07_opencode-plugin-loading/review.md | 12 ++++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md index 6f6f71a29..e97bdb3a1 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md @@ -7,11 +7,12 @@ My confidence level of correctness now: 95% - The model response is deterministic loopback inference. Plugin loading, host event delivery, the read tool, hook processes and journal effects are real. Coverage is one successful project/session per case; older V1, other releases/operating systems, paid inference, personal/global installation and live hot reload are not claimed. Failure, interruption, deduplication and subscription cancellation are regression-tested rather than demonstrated as live-host failures. - For the original OpenCode candidate, normal gates report 6,818 CLI tests and 531 files passed, one opt-in Kilo test skipped, knip passed, 554 script tests passed and 140 architecture checks passed. Independent focused adapter/payload run: 26 passed, zero failed. The canonical-helper mutation yields an extra task_declared and fails the intended test. Its bundle is 732.2 KB under the unchanged 734 KB budget. - Original research-before-code and initial failing tests are backed by timestamped local tool transcripts, not only a written assertion. The original reviewed candidate is `origin/next...ffa9c44a`, with code at `ae500bfb`. No contradiction, duplication finding or user-outcome failure was found within that documented support boundary. -- The accepted verification extension is independently reviewed at `b1a37fe0..16d8b223`. It serves the request for more credible Kilo evidence: CLI translation supplies the modules, the byte-identical memory script updates AGENTS.md, and released Kilo 7.7.5 executes an actual read-tool turn with host-observed session identity. Only inference is substituted. Independent host and focused/profile runs passed; detailed commands, raw logs and limits have one home in [Kilo verification](./kilo-verification.md). +- The accepted verification extension is independently reviewed at `b1a37fe0..116f791e`. It serves the request for more credible Kilo evidence: CLI translation supplies the modules, the byte-identical memory script updates AGENTS.md, and released Kilo 7.7.5 executes an actual read-tool turn with host-observed session identity. Only inference is substituted. Independent host and focused/profile runs passed; detailed commands, raw logs and limits have one home in [Kilo verification](./kilo-verification.md). - The review found and closed a proof gap: a transient three-record count could accept a late duplicate. Final payload reading now follows process-group shutdown and requires exactly three records and the exact SessionStart, PostToolUse and Stop payloads. The corrected host passes, while a copied fourth-record case fails the new assertion; original test bytes remain unchanged by the counterproof. Captured child PIDs, closed host port and local model shutdown are asserted. - Configuration evidence is scoped honestly: setup, installation and update preserve original JSONC bytes in six application tests; translation may normalize JSON and add schema, after which the real-host case requires byte-identical configuration. Kilo telemetry remains unsupported. Other operating systems, hot reload, paid providers and exhaustive process-tree auditing are not inferred from the single released-host turn. - The shared mutation initial-test failure is repaired by source-loading .txt assets in the mutation configuration. Existing asset-delivery regressions now pass there; raw local mutation reports show OpenCode 96.4 against floor 94 and Kilo 67.7 against floor 64, with neither floors nor bundle budget reduced. These local results do not establish green remote CI: the parent must verify the actual new required gate before declaring the user's red-check request complete. - The normal pre-push correctly stopped on a stale Kilo golden hash. The reviewed repair changes only the expected generated bridge hash and its evidence note; it leaves production, assertions, mutation floors and all ten file lists unchanged. Independent comparison-mode execution passes all three golden cases, with no recapture mode active. The replacement hash therefore represents the already-authorized lifecycle behavior rather than concealing an additional change. A new full-suite run and the actual remote required gate remain pending. +- The subsequent Windows bundle failure has a checkout-byte cause rather than an algorithm or budget defect. The reviewed follow-up extends the existing CLI attribute policy for embedded text assets; raw Windows and cloned CRLF/LF build logs support that boundary. Two independently executed real Git checkout cases pass, including a planted removal that converts both representative assets and defeats byte preservation. Isolation uses an independent temporary Git index and sanitized Git variables, with cleanup in finally. Source, floors and budget are unchanged. The final full suite and new remote Windows/required gate must still be observed before declaring all red checks resolved. # Deal breakers - None. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md index 7981e6d5e..b591c817c 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -132,7 +132,7 @@ CLI delivery and spawned hooks execute normally. - Normal commit checks: 554 repository script tests passed; final architecture suite: 142 passed, including the two added checkout guards. Full pre-push before the checkout extension: 6,833 passed, one opt-in case skipped, 532 files passed; knip passed. - lint, TypeScript, type honesty, documentation and whitespace checks passed. The lint + Lint, TypeScript, type honesty, documentation and whitespace checks passed. The lint warning about an unused private member in uninstall-use-case.ts is preexisting. - Built CLI: 751,591 bytes against the unchanged 751,616-byte budget. This passes with 25 bytes of headroom; future changes need to account for the remaining margin. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md index 1c10cbb6f..f22624484 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md @@ -1,6 +1,6 @@ # Review: OpenCode plugin loading (#953) - **Verdict**: approve -- **Diff**: original review `origin/next...ffa9c44a`; extension `b1a37fe0..16d8b223` +- **Diff**: original review `origin/next...ffa9c44a`; extension `b1a37fe0..116f791e` - **Axes run**: code, functional, relevancy - **Date**: 2026_10_07 - **Findings**: 0 critical, 0 warning, 0 minor @@ -27,6 +27,11 @@ - [x] Verify server, captured children and local model shutdown. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:168`, `cli/tests/e2e/kilo-runtime.e2e.test.ts:457`; independent real-host case passed with process/PID assertions, process-group cleanup, closed host port and model server. - [x] Keep unsupported telemetry and evidence limits explicit. β€” `kilo-verification.md`; unchanged Kilo profile declares unsupported telemetry. Journal host detection does not claim the generated Kilo payloads. Other operating systems, paid providers, hot reload and exhaustive process-tree auditing remain unclaimed. +### Phase 3 β€” Windows embedded-asset checkout repair +- [x] Repair checkout-dependent bundle growth at the source-byte boundary. β€” `cli/.gitattributes:12`: extend the existing CLI-owned line-ending policy to embedded configuration assets. Every current asset in this directory is text; no parallel policy, production algorithm, dependency, mutation floor or bundle-budget change. +- [x] Use an effective guard with real isolated checkout and a removal counterproof. β€” `cli/tests/architecture/bundled-config-checkout.arch.test.ts:15`: temporary independent Git index, core.autocrlf=true, relocated HOME/USERPROFILE, shared Git-environment sanitizer and cleanup in finally. Both real assets retain LF; removing their rule causes both byte comparisons to change. Independent focused command: two passed. +- [x] Tie the repair to the actual red Windows check and reproduced build behavior. β€” `/tmp/aidd-971-repair/windows-ci.log:3766`: Windows build exceeds 734 KB; `bundle-crlf-build.log` reproduces failure, while `bundle-lf-build.log` passes. The LF clone bundle stat is 751,591 bytes under the unchanged 751,616 budget. Before/after guard logs and the final 142-check architecture log inspected; remote success for this new candidate remains pending. + ## Findings | Sev | Kind | Phase | Location | Issue | Fix | | --- | --- | --- | --- | --- | --- | @@ -52,7 +57,7 @@ | Unplanned | none; runtime delivery and loader relocation recorded in revised architecture projection | | Extension verified | 100% (8/8); original Phase 1 evidence above is retained as historical evidence, not a claim that remote CI for the extension is green. | | Extension checker score | 100% (12/12: all eight extension acceptance checks and four baseline checks fulfilled); no severity adjustment. Caller owns the acceptance threshold. | -| Extension files checked | All changed lines in the ten implementation/evidence files, generated bridge, runtime harness, six delivery cases, configuration loader, workflow, golden baseline and task evidence; root/CLI coding and testing assertions. Subsequent review-artifact commit adds no production changes. | +| Extension files checked | All changed lines in the twelve implementation/evidence files, generated bridge, runtime harness, six delivery cases, configuration loader, workflow, golden baseline, checkout attributes/guard and task evidence; root/CLI coding and testing assertions. Subsequent review-artifact commits add no production changes. | | Extension baseline | DRY, consistency within documented host/telemetry limits, minimum relevant scope and no dead/debug production code fulfilled. Observer and deterministic inference remain test-only. | | Extension commands run | Independent mutation-config focused tests: 23 passed; complete Kilo profile tests: 21 passed; real Kilo 7.7.5 host: one passed. Final corrected-host green, typecheck and targeted counterproof logs inspected directly after reviewing the correction. | | Extension fixed finding | The final snapshot now rejects delayed fourth records. Evidence: `/tmp/aidd-971-repair/kilo-quiescent-runtime-green.log` and `kilo-late-hook-counterproof.log`; temporary copied case removed. Reviewed committed test SHA256: 5f53e7f983654fc777b035840ab34a8fc5dbc5b2127d0b2983151eef5f964559. | @@ -61,3 +66,6 @@ | Extension golden validation | Independent `pnpm --dir cli exec vitest run --project=e2e tests/golden/framework-build-golden.e2e.test.ts`, without recapture: three passed in comparison mode, including deterministic outputs and all ten stored baselines. Raw log: `/tmp/aidd-971-repair/checker-golden-comparison.log`. Runtime test blob remains cfd95913537eb6b22371583b905797190557602c; unchanged source/host/mutation checks were not rerun. | | Extension pending evidence | Full current CLI suite and remote required gate not yet established by this checker. No claim that all red CI jobs are repaired remotely; parent must inspect the actual new run. | | Extension resource boundary | Built bytes 751,591 against unchanged 751,616 budget; 25 bytes remaining. Thresholds and budgets unchanged; user-owned .gitignore and .hermes.md excluded. | +| Windows follow-up score | 100% (3/3 scoped acceptance checks and 4/4 baseline checks); no findings. Existing attribute policy is extended, naming/docs match the byte boundary, two representative real assets suffice for the generic rule, and there are no debug or unused abstractions. | +| Windows follow-up command | Independent `pnpm --dir cli exec vitest run --project=architecture tests/architecture/bundled-config-checkout.arch.test.ts`: two passed in 55 ms. Raw log: `/tmp/aidd-971-repair/checker-bundle-eol-guard.log`. Full architecture evidence reports 142 passed. | +| Windows follow-up scope and limits | Candidate `116f791e` changes only CLI checkout attributes, the two checkout cases and their evidence beyond the already reviewed work. Production source, floors and budget unchanged. New full suite and actual Windows/required remote gate remain pending; an isolated Git checkout on this machine does not claim final Windows CI success. | From 6daf59f5cbe50017762211453e9ebca16b958a17 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 20:05:52 +0200 Subject: [PATCH 14/23] test(cli): remove code interpolation from Kilo probes AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../kilo-verification.md | 19 +++++++++++++++++++ .../kilo/kilo-hooks-bridge.unit.test.ts | 2 +- cli/tests/e2e/kilo-runtime.e2e.test.ts | 4 ++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md index b591c817c..2b5761b9c 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -140,6 +140,25 @@ CLI delivery and spawned hooks execute normally. depends on every selected mutation job. Final remote status is available on [pull request 971](https://github.com/ai-driven-dev/framework/pull/971/checks). +## CodeQL review follow-up +The issue author confirmed the requested V1 and real-process acceptance criteria in +[issue comment](https://github.com/ai-driven-dev/framework/issues/953#issuecomment-6041453916). +GitHub Advanced Security also raised alerts 102, 103 and 104 on generated test probes: +their JavaScript embedded temporary output paths through `JSON.stringify`. These paths +were locally generated and the files were executed by Node/Kilo, not embedded in HTML; +this review does not establish attacker-controlled injection. Nevertheless, the fixtures +now contain static JavaScript: the CommonJS capture resolves from `__dirname`, the ESM +capture uses the hook payload's `cwd`, and the observer uses the host's project directory. +No path is interpolated into executable source and no alert was dismissed or suppressed. +The [CodeQL rule](https://codeql.github.com/codeql-query-help/javascript/js-bad-code-sanitization/) +documents why JSON encoding alone is insufficient for every JavaScript embedding context. + +After this change, the 13 bridge unit cases and the real Kilo 7.7.5 runtime case passed; +the final three-record assertion, payload checks, observed real read and process shutdown +checks remain intact. Targeted Biome, TypeScript and whitespace checks also passed. +Remote CodeQL resolution is to be verified on the resulting candidate; prior green CI +alone does not establish absence of security findings. + ## Limits Kilo telemetry and journal/cost attribution remain unsupported. This proof covers one successful local turn, plus controlled unit event sequences and application delivery diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts index d5c0adfbe..bc2bff76c 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts @@ -112,7 +112,7 @@ describe("Kilo hooks bridge", () => { await mkdir(join(directory, "plugin")); await writeFile( join(directory, "hooks", "probe", "capture.cjs"), - `const fs = require("node:fs"); let body = ""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => fs.appendFileSync(${JSON.stringify(output)}, JSON.stringify({ args: process.argv.slice(2), payload: JSON.parse(body) }) + "\\n"));` + `const fs = require("node:fs"); const { resolve } = require("node:path"); let body = ""; process.stdin.on("data", chunk => body += chunk); process.stdin.on("end", () => fs.appendFileSync(resolve(__dirname, "../../calls.jsonl"), JSON.stringify({ args: process.argv.slice(2), payload: JSON.parse(body) }) + "\\n"));` ); const modulePath = join(directory, "plugin", "bridge.mjs"); await writeFile(modulePath, generated ?? ""); diff --git a/cli/tests/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts index cfd959135..22b4ecea6 100644 --- a/cli/tests/e2e/kilo-runtime.e2e.test.ts +++ b/cli/tests/e2e/kilo-runtime.e2e.test.ts @@ -353,7 +353,7 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { ); await writeFile( join(proofPlugin, "hooks", "capture.js"), - `import { appendFileSync } from "node:fs"; let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => appendFileSync(${JSON.stringify(payloadPath)}, JSON.stringify({ pid: process.pid, payload: JSON.parse(input) }) + "\\n"));` + `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 writeFile( join(proofPlugin, "hooks", "hooks.json"), @@ -376,7 +376,7 @@ describeKiloRuntime("E2E: real Kilo runtime", () => { ); await writeFile( join(generatedProject, ".kilo", "plugin", "observer.js"), - `import { appendFileSync } from "node:fs"; export default { id: "runtime-observer", server: async () => ({ event: async ({ event }) => appendFileSync(${JSON.stringify(eventPath)}, JSON.stringify(event) + "\\n") }) };` + `import { appendFileSync } from "node:fs"; import { join } from "node:path"; export default { id: "runtime-observer", server: async ({ directory }) => ({ event: async ({ event }) => appendFileSync(join(directory, "events.jsonl"), JSON.stringify(event) + "\\n") }) };` ); const deliveredConfig = await readFile(configPath, "utf8"); From 2b866ddd905129349131e6cc3c3ba19995fa6727 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 20:29:35 +0200 Subject: [PATCH 15/23] refactor(cli): remove obsolete Kilo parser and stale hook comments AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../kilo-verification.md | 23 ++++++++++++++-- .../domain/profiles/kilo/kilo-hooks-bridge.ts | 4 --- .../opencode/opencode-hooks-bridge.ts | 14 ++++------ .../kilo/kilo-hooks-bridge.unit.test.ts | 22 ++++++++-------- .../aidd-telemetry/hooks/opencode-plugin.js | 26 +++++-------------- 5 files changed, 43 insertions(+), 46 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md index 2b5761b9c..13314d51c 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -156,8 +156,27 @@ documents why JSON encoding alone is insufficient for every JavaScript embedding After this change, the 13 bridge unit cases and the real Kilo 7.7.5 runtime case passed; the final three-record assertion, payload checks, observed real read and process shutdown checks remain intact. Targeted Biome, TypeScript and whitespace checks also passed. -Remote CodeQL resolution is to be verified on the resulting candidate; prior green CI -alone does not establish absence of security findings. +On `6daf59f5`, [CodeQL](https://github.com/ai-driven-dev/framework/actions/runs/37664345407) +passed; all three alerts are `fixed` on `refs/pull/971/merge` and the open-alert API returns +`[]`. [CLI run](https://github.com/ai-driven-dev/framework/actions/runs/37664345321) also +finished with 29 successful jobs. Its coverage job was retried after the first runner +stalled during Ubuntu package installation, before launching tests; the successful retry +executed the full coverage gate. No test or security finding was skipped to get that result. + +## Final code and comment review +Independent inspection of all changed production/configuration/documentation and test +files found two remnants: a Kilo parser wrapper with no production caller and comments +describing the observed V1 session-announcement fallback as a universal OpenCode limit. +The wrapper is removed. Its three existing test cases now exercise the delivered +`generateKiloHooksBridge` entrypoint; all 13 bridge cases remain. Comments are shortened +and distinguish the V1 fallback from V2 session announcements. + +The corrective review reports no remaining finding. Targeted CLI validation passes 30 +cases and root OpenCode validation passes 26; Biome, TypeScript and whitespace checks pass. +Comparing the previous and current generators on six representative inputs each produces +byte-identical Kilo and OpenCode modules. No generated runtime template, golden, mutation +floor or bundle budget changed. The cleanup's complete normal gates and remote checks are +to be confirmed on the resulting candidate before calling the development finished. ## Limits Kilo telemetry and journal/cost attribution remain unsupported. This proof covers one diff --git a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts index e27060068..e9d126b8b 100644 --- a/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts +++ b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts @@ -45,10 +45,6 @@ function parseGroups(groups: readonly ClaudeMatcherGroup[] = []): readonly Parse ); } -export function parseKiloSessionStartHooks(rawHooksJson: string): readonly ParsedHookCall[] { - return parseGroups((JSON.parse(rawHooksJson) as ClaudeHooksShape).hooks?.SessionStart); -} - function toIdentifier(plugin: string): string { return `${plugin .split("-") diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts index 46592be65..d81ce5cd8 100644 --- a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts @@ -5,18 +5,14 @@ * plugin with V1/server and V2/setup entrypoints, spawning the scripts every host's hooks.json * already names over the stdin-JSON contract those scripts already read. * - * Only three events map; anything else is dropped, OpenCode's plugin surface delivering no - * event those hooks could ride on: + * Only three hook events are replayed: * - * - `SessionStart` runs when the generated plugin's own factory is called β€” once per - * server/directory, not once per session, since `session.created` is published on OpenCode's - * bus but was never observed delivered to a plugin's `event` hook. Safe only for an - * idempotent hook, which every `SessionStart` hook this generator sees today is. + * - `SessionStart` runs once per server/directory at factory initialization and requires + * idempotent commands. V1 did not deliver `session.created` to this event hook; + * the V2 adapter delivers it for telemetry's session tracking. * - `Stop` maps to V1's `session.idle` or V2's execution terminal, once per turn. * - `PostToolUse` maps to `message.part.updated` whose `part.state.status === "completed"`, the - * one shape measured live: `part.tool` names the tool, `part.state.input` its arguments. - * `tool.execute.after` reads cleaner in OpenCode's own docs but is a separate named hook - * `(input, output)`, never an `event({event})` payload, and nothing here has captured it. + * shape measured live: `part.tool` names the tool, `part.state.input` its arguments. * * A `matcher` on a `PostToolUse` group filters by tool name, exact or pipe-separated * alternation; absent, every tool matches. diff --git a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts index bc2bff76c..9b940d6e4 100644 --- a/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts @@ -4,17 +4,14 @@ import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; -import { - generateKiloHooksBridge, - parseKiloSessionStartHooks, -} from "../../../../../../src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.js"; +import { generateKiloHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.js"; const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; describe("Kilo hooks bridge", () => { it("keeps only replayable SessionStart commands", () => { expect( - parseKiloSessionStartHooks( + generateKiloHooksBridge( JSON.stringify({ hooks: { SessionStart: [ @@ -27,14 +24,15 @@ describe("Kilo hooks bridge", () => { ], Stop: [{ hooks: [{ command: `node ${ROOT}/hooks/stop.js` }] }], }, - }) + }), + "probe" ) - ).toEqual([{ script: "update_memory.js", args: ["--quiet"] }]); + ).toContain('const SESSION_START = [{"script":"update_memory.js","args":["--quiet"]}];'); }); it("trims replayable commands and separates multiple whitespace arguments", () => { expect( - parseKiloSessionStartHooks( + generateKiloHooksBridge( JSON.stringify({ hooks: { SessionStart: [ @@ -46,14 +44,16 @@ describe("Kilo hooks bridge", () => { }, ], }, - }) + }), + "probe" ) - ).toEqual([{ script: "update_memory.js", args: ["--quiet", "--tool", "kilo"] }]); + ).toContain( + 'const SESSION_START = [{"script":"update_memory.js","args":["--quiet","--tool","kilo"]}];' + ); }); it("accepts absent hook tables, events and empty groups", () => { for (const value of [{}, { hooks: {} }, { hooks: { SessionStart: [{}] } }]) { - expect(parseKiloSessionStartHooks(JSON.stringify(value))).toEqual([]); expect(generateKiloHooksBridge(JSON.stringify(value), "probe")).toBeNull(); } }); diff --git a/plugins/aidd-telemetry/hooks/opencode-plugin.js b/plugins/aidd-telemetry/hooks/opencode-plugin.js index 072294c22..96041c814 100644 --- a/plugins/aidd-telemetry/hooks/opencode-plugin.js +++ b/plugins/aidd-telemetry/hooks/opencode-plugin.js @@ -39,15 +39,9 @@ function runJournal(event, payload) { }); } -// Only `session.created` carries the session's own `info.directory`; the other events carry -// `sessionID` alone. A single server can outlive many sessions and serve more than one -// directory, so this plugin's fixed init-time directory is not a safe stand-in - a turn-end -// written to the wrong project's journal finds no run file and silently no-ops. -// -// `session.created` was never observed reaching this hook, and every `opencode run` is a -// session OpenCode never announced, so the later events fall back to the init-time directory, -// which is correct for the single-directory case `opencode run` is. `journalCallsFor` writes -// it back here, so the session is opened once and every later event reads the same directory. +// V2's adapter delivers `session.created` with `info.directory`; retain it per session. +// V1 did not deliver that event, so its first journal call uses and retains the factory's +// directory, valid for the observed single-directory `opencode run` path. const directoryBySessionId = new Map(); // Mirrors `lib/task-declared.cjs`'s own `TASK_PATH_PATTERN`, duplicated rather than @@ -122,17 +116,9 @@ function sessionIdOf(event) { return event.properties?.sessionID; } -/** Every journal call one OpenCode event produces, in the order the journal must receive - * them. - * - * OpenCode publishes `session.created` on its own bus and never delivers it to a plugin's - * event hook, and `opencode run` is always such a session β€” so `journalCallFor` alone leaves - * the journal with no `session_start`, no run file, and every later line dropped, while the - * tool still reads as covered. - * - * So the first call for a session nobody announced opens it, carrying the directory that - * call was already going to use rather than a new guess. An announced session is untouched, - * and no session is opened twice. */ +/** V1 fallback: open an unannounced session before its first journal call, using that call's + * directory. V2's adapter delivers `session.created`, so announced sessions pass through + * without a duplicate opening. */ function journalCallsFor(event, sessionDirectories, fallbackDirectory) { const sessionId = sessionIdOf(event); const announced = sessionId !== undefined && sessionDirectories.has(sessionId); From fde96059ccc243890e1378f02b3a12dbc44f7171 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 22:59:50 +0200 Subject: [PATCH 16/23] docs(framework): shorten documentation and add concise Claude rules Preserve operational contracts, verification commands and Kanban metadata while removing repetition and transient snapshots. Refs #953. AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .claude/rules/01-standards/1-documentation.md | 29 +++ .../challenge.md | 24 +-- .../kilo-verification.md | 184 ++---------------- .../phase-1.md | 93 ++------- .../plan.md | 55 +----- .../review.md | 89 +++------ .../verification.md | 118 ++--------- docs/ARCHITECTURE.md | 116 +++++------ plugins/aidd-telemetry/README.md | 131 +++++-------- 9 files changed, 201 insertions(+), 638 deletions(-) create mode 100644 .claude/rules/01-standards/1-documentation.md diff --git a/.claude/rules/01-standards/1-documentation.md b/.claude/rules/01-standards/1-documentation.md new file mode 100644 index 000000000..400ff2665 --- /dev/null +++ b/.claude/rules/01-standards/1-documentation.md @@ -0,0 +1,29 @@ +--- +paths: + - "**/README.md" + - "**/ARCHITECTURE.md" + - "docs/**/*.md" + - "**/aidd_docs/memory/**/*.md" + - "**/GUIDELINES.md" + - "**/CONTRIBUTING.md" +--- + +# Concise documentation + +- Apply only to durable, authored Markdown. +- Revise existing documents individually. +- Exclude historical, generated, and fixture content. +- Preserve contracts, decisions, limits, and procedures. +- State actions and verification commands. +- Link canonical sources for supporting details. +- Keep memory's operational safeguards self-contained. +- Remove filler, repetition, and narrated history. +- Exclude dates, counters, ticket/run identifiers. +- Exclude snapshots and temporary evidence. +- Drop measured timings, ratios, and comparisons. +- Keep rationale; never invent performance requirements. +- Retain required versions, constants, and examples. +- Add examples only to clarify behavior. +- Preserve headings, anchors, and generator markers. +- Read back; check headings, contracts, and commands. +- Verify links before declaring completion. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md index e97bdb3a1..0953e16bf 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md @@ -1,22 +1,6 @@ -My confidence level of correctness now: 95% +# Challenge +No blocker within the [reviewed scope](./review.md). -# Correctness (100%) -- The actual need is fulfilled: generated context and telemetry modules load and execute under real OpenCode V2 2.0.22, while the documented V1 compatibility boundary, 1.18.29, retains execution once. Default exports alone would have left V2 journal behavior incomplete; observed granular tool and execution events are normalized before existing payload handlers run. -- The shared protocol has one CLI-owned source, emitted under `.opencode/hooks/opencode-events.js`, outside plugin discovery. Each plugin keeps its own payload responsibility. Setup, translation, existing-tool installation/update and restoration account for this new dependency; existing configuration, untracked helper contents and tracked helper drift hashes are preserved. -- All four real-host cases were independently checked against final module bytes, actual stdout session IDs, hook payloads, refreshed memory and journal records. Each contains exactly one session_start, task_declared and turn_end. Evidence: `/tmp/aidd-953-runtime-ryTstm/{candidate-v1,candidate-v2,candidate-install-v2,candidate-existing-v2}`; existing-install-summary.json confirms byte-preserved configuration and helper ownership once. -- The model response is deterministic loopback inference. Plugin loading, host event delivery, the read tool, hook processes and journal effects are real. Coverage is one successful project/session per case; older V1, other releases/operating systems, paid inference, personal/global installation and live hot reload are not claimed. Failure, interruption, deduplication and subscription cancellation are regression-tested rather than demonstrated as live-host failures. -- For the original OpenCode candidate, normal gates report 6,818 CLI tests and 531 files passed, one opt-in Kilo test skipped, knip passed, 554 script tests passed and 140 architecture checks passed. Independent focused adapter/payload run: 26 passed, zero failed. The canonical-helper mutation yields an extra task_declared and fails the intended test. Its bundle is 732.2 KB under the unchanged 734 KB budget. -- Original research-before-code and initial failing tests are backed by timestamped local tool transcripts, not only a written assertion. The original reviewed candidate is `origin/next...ffa9c44a`, with code at `ae500bfb`. No contradiction, duplication finding or user-outcome failure was found within that documented support boundary. -- The accepted verification extension is independently reviewed at `b1a37fe0..116f791e`. It serves the request for more credible Kilo evidence: CLI translation supplies the modules, the byte-identical memory script updates AGENTS.md, and released Kilo 7.7.5 executes an actual read-tool turn with host-observed session identity. Only inference is substituted. Independent host and focused/profile runs passed; detailed commands, raw logs and limits have one home in [Kilo verification](./kilo-verification.md). -- The review found and closed a proof gap: a transient three-record count could accept a late duplicate. Final payload reading now follows process-group shutdown and requires exactly three records and the exact SessionStart, PostToolUse and Stop payloads. The corrected host passes, while a copied fourth-record case fails the new assertion; original test bytes remain unchanged by the counterproof. Captured child PIDs, closed host port and local model shutdown are asserted. -- Configuration evidence is scoped honestly: setup, installation and update preserve original JSONC bytes in six application tests; translation may normalize JSON and add schema, after which the real-host case requires byte-identical configuration. Kilo telemetry remains unsupported. Other operating systems, hot reload, paid providers and exhaustive process-tree auditing are not inferred from the single released-host turn. -- The shared mutation initial-test failure is repaired by source-loading .txt assets in the mutation configuration. Existing asset-delivery regressions now pass there; raw local mutation reports show OpenCode 96.4 against floor 94 and Kilo 67.7 against floor 64, with neither floors nor bundle budget reduced. These local results do not establish green remote CI: the parent must verify the actual new required gate before declaring the user's red-check request complete. -- The normal pre-push correctly stopped on a stale Kilo golden hash. The reviewed repair changes only the expected generated bridge hash and its evidence note; it leaves production, assertions, mutation floors and all ten file lists unchanged. Independent comparison-mode execution passes all three golden cases, with no recapture mode active. The replacement hash therefore represents the already-authorized lifecycle behavior rather than concealing an additional change. A new full-suite run and the actual remote required gate remain pending. -- The subsequent Windows bundle failure has a checkout-byte cause rather than an algorithm or budget defect. The reviewed follow-up extends the existing CLI attribute policy for embedded text assets; raw Windows and cloned CRLF/LF build logs support that boundary. Two independently executed real Git checkout cases pass, including a planted removal that converts both representative assets and defeats byte preservation. Isolation uses an independent temporary Git index and sanitized Git variables, with cleanup in finally. Source, floors and budget are unchanged. The final full suite and new remote Windows/required gate must still be observed before declaring all red checks resolved. +One shared adapter and existing delivery paths satisfy the need. Real-host effects and failing counterproofs support the result. -# Deal breakers -- None. - -# Suggestions (enhancements only) -- Add an interleaved-call regression using the same tool id across different assistantMessageID/sessionID values if this adapter is extended. The current tuple key distinguishes those identities by inspection, but existing tests do not specifically protect that distinction. This is additional coverage, not an unmet acceptance criterion or a claim about current real-host evidence. -- Preserve the remaining 25-byte CLI bundle headroom when adding further behavior; the current measured 751,591 bytes satisfy the unchanged 751,616-byte budget. This is a future maintenance constraint, not a reason to weaken the current gate or expand this ticket. +Runtime boundaries: [OpenCode](./verification.md), [Kilo](./kilo-verification.md). Keep the bundle-size gate. If event handling expands, add interleaved calls sharing a tool ID across different session/message identities; that scenario has no dedicated regression. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md index 13314d51c..aa54fa992 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md @@ -1,186 +1,34 @@ -# Kilo runtime verification and CI repair +# Kilo verification -## Initial need -The user requested stronger evidence after the original Kilo smoke test was found to -replace update_memory.js with a marker and stop at session creation. A loaded plugin and -a zero exit code did not prove memory refresh or hooks during a complete turn. The user -also requested correction of the red checks on pull request 971. +Need: replace a session-only smoke that substituted the memory script with a complete real turn and repair red CI. Research preceded implementation: [plugin contract](https://kilo.ai/docs/automate/extending/plugins), [custom provider](https://kilo.ai/docs/code-with-ai/agents/custom-models), [SDK 7.7.5 types](https://unpkg.com/@kilocode/sdk@7.7.5/dist/v2/gen/types.gen.d.ts). -## Implemented behavior -The Kilo bridge retains its default id/server descriptor and now supports replayable -SessionStart, PostToolUse and Stop commands. It obtains the real session ID and project -directory from released events, dispatches completed tool parts with their name/input, -filters exact or pipe-separated tool matchers, consumes each tool part once, and resets -idle suppression on the next busy turn. Session deletion removes tracked state. Hook -failure remains reported without blocking the host. +Implementation: map session creation, completed tools and idle to `SessionStart/PostToolUse/Stop`. Preserve actual identity/cwd, parse commands, filter exact or pipe-separated matchers, consume each tool part once, reset idle suppression on busy and release deleted sessions. Report failed hooks without blocking the host. -The mutation Vitest configuration now loads .txt assets as source text, matching the -ordinary test configuration. Previously six mutation jobs failed their initial tests -before producing a mutation score because the shared OpenCode adapter was a URL string. -No mutation threshold or bundle budget was changed. +## Tests -## Added and strengthened tests -- Two added bridge unit cases verify Stop/PostToolUse-only declarations and a released - session/tool/idle event sequence. The sequence covers real identity and cwd, malformed - events, incomplete/failed tools, matcher filtering, duplicate events, another busy - turn and session deletion/recreation. Five further cases cover missing hook tables, - missing commands, whitespace/argument parsing and balanced hook counts across events. - Existing failed-hook coverage expands to nonzero - exit, spawn failure and dispatch failure, each reported once without blocking. This - adds nine executed unit cases overall; the bridge suite contains thirteen cases. -- Six added delivery integration cases cover setup, plugin installation and update for - each of kilo.jsonc and .kilo/kilo.jsonc. They preserve exact JSONC bytes, comments, - model, permissions and user MCP configuration. Installation checks the delivered - script, generated bridge and manifest version; update changes script/version and - restores a deleted bridge. These execute real application use cases with filesystem - and fetch doubles; they do not claim execution of the built CLI or Kilo. -- The existing opt-in runtime case now runs Kilo 7.7.5 through a complete model turn. - The CLI translate command delivers the tested hook modules. The context memory script - remains byte-identical to its source and must update AGENTS.md. A deterministic - loopback model endpoint requests the real read tool and receives its actual result. - An independent observer records host events; captured hooks must contain exactly one - SessionStart, PostToolUse(read) and Stop with the actual session ID and input. The - test retains skills, agents and MCP discovery checks and verifies shutdown of the - server and captured hook processes. Final hook counts and payloads are read after - server-process-group shutdown, preventing a late extra record from passing a transient - count check. Translation intentionally normalizes JSON and adds its schema; the test - checks preservation of custom values during delivery, then snapshots the delivered - configuration and verifies byte-identical contents after host execution. Exact - preservation of original JSONC bytes during setup/install/update belongs to the six - application integration cases above. -- Two architecture cases exercise a real temporary Git checkout with core.autocrlf=true. - They check exact LF bytes for both embedded configuration assets and deliberately - remove their attribute rule to prove that the guard detects CRLF conversion. The - tests relocate profiles and use an independent temporary .git directory. +- [Bridge](../../../../cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts): missing declarations/commands, argument parsing, released lifecycle events, malformed/incomplete/failed tools, matchers, replay, new turns and deleted-session recreation. Nonzero exit, spawn and dispatch failures are reported once. +- [Delivery](../../../../cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts): setup/install/update for `kilo.jsonc` and `.kilo/kilo.jsonc`; exact JSONC bytes, comments, model, permissions and MCP preserved. Verify scripts/manifest version and restore a deleted bridge. These are application use cases with filesystem/fetch doubles. +- [Checkout](../../../../cli/tests/architecture/bundled-config-checkout.arch.test.ts): isolated real Git checkout with `core.autocrlf=true` preserves LF for embedded assets; removing the attribute rule defeats preservation. +- [Real Kilo 7.7.5](../../../../cli/tests/e2e/kilo-runtime.e2e.test.ts): actual CLI translation, byte-identical memory script updating `AGENTS.md`, skills/agents/MCP discovery, real read/result and exact hook payloads. Independent host observation and a final snapshot after process-group shutdown reject late duplicates. Assert server/captured-process shutdown and model cleanup. Profiles are isolated; only inference is substituted. Translation may normalize JSON; delivered configuration remains byte-identical during execution. -Sources of these tests: -- [Bridge unit tests](../../../../cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts) -- [Delivery integration tests](../../../../cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts) -- [Real Kilo runtime test](../../../../cli/tests/e2e/kilo-runtime.e2e.test.ts) -- [Embedded-config checkout tests](../../../../cli/tests/architecture/bundled-config-checkout.arch.test.ts) - -## Strategy and observed failures -Released behavior was researched before extending the bridge. The -[official plugin documentation](https://kilo.ai/docs/automate/extending/plugins), -[custom provider documentation](https://kilo.ai/docs/code-with-ai/agents/custom-models) -and [published SDK 7.7.5 types](https://unpkg.com/@kilocode/sdk@7.7.5/dist/v2/gen/types.gen.d.ts) -were checked against a real isolated runtime. Its read part emitted pending, running, -running and completed updates; session.idle ended the turn. Declared types alone were -not treated as execution evidence. - -Before the bridge change, the real turn refreshed memory and read the file, but failed -with `expected 1 to be 3`: only SessionStart fired. Bridge regressions also failed before -implementation. The completed runtime case then passed. A first model harness assertion -incorrectly expected exactly two requests; Kilo also makes a title request. The corrected -proof checks actual tool-result content instead of assuming a total request count. - -The initial-test CI defect was reproduced with the mutation configuration: two existing -installation/update regressions received the adapter path instead of its source. After -adding .txt, all four runtime-file tests passed. A complete local OpenCode mutation run -then passed with score 96.4 against the unchanged floor of 94. - -The first local Kilo mutation measurement was 63.7, below its existing floor of 64. -Additional parser and lifecycle cases raised the score to 67.7. The final scope run -tested 175 mutants and reused 48 already measured results, 223 total. A separate copied -bridge with session-state deletion disabled made its lifecycle assertion fail with -`1 !== 2`; production source was never altered for that counterproof. - -Independent review found that a transient three-record poll followed by arrayContaining -could miss a late fourth hook. The corrected test reads its final snapshot after process -group shutdown and asserts exactly three records. A copied E2E case injected a fourth -record at that snapshot and failed with `length of 3 but got 4`; the original test's -SHA256 was unchanged and the temporary copy was removed. Configuration comparison was -also scoped to delivered bytes, respecting translation's intentional JSON normalization. - -The complete normal pre-push suite then caught one stale golden baseline for the Kilo -context bridge, with 6,832 tests passing and that single assertion failing. The official -recapture changed only that generated file's stored hash; the other nine matrix cells -and all file lists remained unchanged. All three golden tests subsequently passed in -comparison mode, including deterministic output and the complete ten-cell matrix. - -The first updated remote run passed the actual Kilo test on Ubuntu but exposed a Windows -bundle failure. A separate local clone reproduced its checkout behavior: 89 CRLF lines -in the OpenCode asset and one in the Codex TOML added 180 escaped bytes to the bundle. -Identical source built to 751,771 bytes with CRLF and 751,591 with LF. The existing -cli/.gitattributes now declares assets/configs/** text eol=lf. No algorithm, source -behavior, dependency or budget changed. The checkout guard failed before this correction -and passed afterward; removing the rule in its isolated fixture detects both changed -assets. Raw logs are bundle-crlf-build.log, bundle-lf-build.log and -bundle-eol-guard-before.log in the evidence directory below. +Re-run in a disposable environment, as [CI](../../../../.github/workflows/cli-ci.yml) does; the install command replaces its global Kilo executable: ```sh +npm install -g @kilocode/cli@7.7.5 pnpm --dir cli test:e2e:kilo +pnpm --dir cli exec vitest run --project=unit tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts pnpm --dir cli exec vitest run --config vitest.mutation.config.ts tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts pnpm --dir cli test:mutation:tools-opencode pnpm --dir cli test:mutation:tools-kilo pnpm --dir cli test:arch ``` -Local raw evidence includes /tmp/kilo-hooks-runtime-red.log and the following files in -/tmp/aidd-971-repair: kilo-quiescent-runtime-green.log, mutation-opencode-local.log, -mutation-kilo-repair.log, kilo-delete-mutant.log and kilo-late-hook-counterproof.log. -The research capture used an allowlisted environment with relocated HOME/XDG paths and -no inherited authentication variables. The committed runtime test follows the same -isolation approach. Only inference is substituted; Kilo, its event bus, read tool, -CLI delivery and spawned hooks execute normally. - -## Local validation of the extension -- Real pinned runtime: one complete case passed, including actual read-tool results and - all three hook payloads. -- Kilo profile suites: 21 passed, including 13 bridge cases. Delivery integration: six - passed under the mutation configuration. -- Normal commit checks: 554 repository script tests passed; final architecture suite: - 142 passed, including the two added checkout guards. Full pre-push before the checkout - extension: 6,833 passed, one opt-in case skipped, 532 files passed; knip passed. - Lint, TypeScript, type honesty, documentation and whitespace checks passed. The lint - warning about an unused private member in uninstall-use-case.ts is preexisting. -- Built CLI: 751,591 bytes against the unchanged 751,616-byte budget. This passes with - 25 bytes of headroom; future changes need to account for the remaining margin. -- The dedicated Kilo CI job explicitly runs the host case; the required cli / gate also - depends on every selected mutation job. Final remote status is available on - [pull request 971](https://github.com/ai-driven-dev/framework/pull/971/checks). - -## CodeQL review follow-up -The issue author confirmed the requested V1 and real-process acceptance criteria in -[issue comment](https://github.com/ai-driven-dev/framework/issues/953#issuecomment-6041453916). -GitHub Advanced Security also raised alerts 102, 103 and 104 on generated test probes: -their JavaScript embedded temporary output paths through `JSON.stringify`. These paths -were locally generated and the files were executed by Node/Kilo, not embedded in HTML; -this review does not establish attacker-controlled injection. Nevertheless, the fixtures -now contain static JavaScript: the CommonJS capture resolves from `__dirname`, the ESM -capture uses the hook payload's `cwd`, and the observer uses the host's project directory. -No path is interpolated into executable source and no alert was dismissed or suppressed. -The [CodeQL rule](https://codeql.github.com/codeql-query-help/javascript/js-bad-code-sanitization/) -documents why JSON encoding alone is insufficient for every JavaScript embedding context. +The ordinary suite skips the opt-in host case; its dedicated CI job runs it explicitly. -After this change, the 13 bridge unit cases and the real Kilo 7.7.5 runtime case passed; -the final three-record assertion, payload checks, observed real read and process shutdown -checks remain intact. Targeted Biome, TypeScript and whitespace checks also passed. -On `6daf59f5`, [CodeQL](https://github.com/ai-driven-dev/framework/actions/runs/37664345407) -passed; all three alerts are `fixed` on `refs/pull/971/merge` and the open-alert API returns -`[]`. [CLI run](https://github.com/ai-driven-dev/framework/actions/runs/37664345321) also -finished with 29 successful jobs. Its coverage job was retried after the first runner -stalled during Ubuntu package installation, before launching tests; the successful retry -executed the full coverage gate. No test or security finding was skipped to get that result. +## Counterproofs and repairs -## Final code and comment review -Independent inspection of all changed production/configuration/documentation and test -files found two remnants: a Kilo parser wrapper with no production caller and comments -describing the observed V1 session-announcement fallback as a universal OpenCode limit. -The wrapper is removed. Its three existing test cases now exercise the delivered -`generateKiloHooksBridge` entrypoint; all 13 bridge cases remain. Comments are shortened -and distinguish the V1 fallback from V2 session announcements. +Before implementation, the real turn fired only `SessionStart`; bridge regressions also failed. A copied deleted-session mutant failed recreation; injecting an extra final hook failed the exact count assertion. Assertions check the actual tool result rather than request totals because Kilo also requests a title. -The corrective review reports no remaining finding. Targeted CLI validation passes 30 -cases and root OpenCode validation passes 26; Biome, TypeScript and whitespace checks pass. -Comparing the previous and current generators on six representative inputs each produces -byte-identical Kilo and OpenCode modules. No generated runtime template, golden, mutation -floor or bundle budget changed. The cleanup's complete normal gates and remote checks are -to be confirmed on the resulting candidate before calling the development finished. +The mutation loader reads `.txt` assets as source, matching normal tests. LF checkout fixes Windows bundle growth; thresholds and budget stay unchanged. A stale golden bridge hash was recaptured without changing file lists; comparison mode then passed. Test probes use static code rather than interpolated paths, addressing the [CodeQL embedding rule](https://codeql.github.com/codeql-query-help/javascript/js-bad-code-sanitization/). No alert was suppressed. The obsolete parser wrapper was removed; representative generator inputs produced byte-identical modules after cleanup. [Final gates](./review.md). -## Limits -Kilo telemetry and journal/cost attribution remain unsupported. This proof covers one -successful local turn, plus controlled unit event sequences and application delivery -tests. Paid providers, global installation, hot reload, exhaustive process-tree auditing -and Kilo runtime execution on Windows are not claimed. The ordinary suite deliberately -skips the opt-in host case; the dedicated CI job runs it explicitly. +Kilo telemetry remains unsupported. Paid providers, global-profile operation, hot reload, exhaustive process-tree auditing and Windows Kilo runtime remain outside this proof. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md index a4d0a7389..993a70f71 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md @@ -2,87 +2,18 @@ status: done --- -# Instruction: Preserve AIDD hook behavior across OpenCode versions +# OpenCode contract +Both plugins export default `id/server/setup` (V1 >= 1.18.29; V2). V1 returns after `server`, without invoking named factories again. A dependency-free CLI-owned adapter lives at `.opencode/hooks/opencode-events.js`, outside plugin discovery; payload mapping stays plugin-specific. -## Architecture projection -```txt -cli/assets/configs/opencode/opencode-events.js.txt create: canonical V2 host adapter -cli/src/runtime/assets/asset-loader.ts modify: embed shared host adapter -cli/tsup.config.ts modify: bundle text asset -cli/src/contexts/tools/domain/profiles/opencode/build.ts modify: emit shared adapter outside plugin discovery -cli/src/contexts/tools/domain/contracts.ts modify: tool-owned plugin runtime files -cli/src/contexts/tools/domain/profiles/opencode/profile.ts modify: shared runtime file declaration -cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts modify: install and backfill missing runtime files -cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts modify: retain runtime files in restoration output -cli/src/contexts/framework/application/plugin/plugin-distribution-loader.ts move/generalize: shared source retrieval entrypoint for project and user plugins -cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts modify: ensure tool runtime before plugin delivery -cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts modify: ensure tool runtime before plugin replacement -cli/src/runtime/wiring/framework.ts modify: wire shared source retrieval and runtime delivery -cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts create: existing installation and update coverage -cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts modify: generated plugin definition using shared host adapter -plugins/aidd-telemetry/hooks/opencode-plugin.js modify: telemetry definition using shared host adapter -cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts modify: generated entrypoint and behavioral tests -cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts modify: default server/setup consumption in real translate output -cli/tests/golden/snapshots/framework-build/golden.json modify: OpenCode bridge hash and shared adapter artifact -scripts/__tests__/opencode-plugin.test.js modify: actual journal effects through both entrypoints and cleanup -scripts/__tests__/aidd-telemetry-opencode-payloads.test.js modify if required: loader export contract and event mappings -scripts/__tests__/aidd-telemetry-cost-skill.test.js modify: distinguish V1 omission from observed V2 session announcements -docs/ARCHITECTURE.md modify: replace outdated single-factory contract -plugins/aidd-telemetry/README.md modify: supported OpenCode versions and measured behavior -aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md create: research and observed real runtime evidence -``` -The existing user-plugin source loader moves to the shared application layer; its callers and constructor fixtures follow the generalized entrypoint. Add runtime regression coverage only where needed to reproduce a measured V2 difference. +Translation emits the helper once; setup tracks tool ownership; restoration includes it. Plugin install/update backfill missing helpers without replacing existing configuration, existing helper contents or recorded ownership/drift hashes. Source retrieval reuses one loader. -## User Journey -```mermaid -flowchart TD - Build[Translate framework for OpenCode] --> Start[Start V1 or V2 in an isolated project] - Start --> Memory[Context hook refreshes project memory] - Memory --> Tool[Session performs a task-document tool call] - Tool --> Journal[Journal records session start and task declaration] - Journal --> Idle[Completed turn records turn end] -``` +V2 setup subscribes with cancellable cleanup. Its `data/location` events correlate tool input and success by session/message/tool identity. Success consumes pending state; failures and terminal events discard it. Completion closes the turn; shutdown interruption leaves it resumable. -## Test Scope -```mermaid -journey - section Setup - Translate fresh framework into temporary project => generated plugins present: 5: cli - section Happy path - Start OpenCode 2.0.22 => plugins load and memory refresh executes: 5: cli - Complete task-document tool call => session and task journal records appear: 5: cli - Finish turn => turn_end appears with correct session identity: 5: cli - section Edge case - V1 compatibility - Start OpenCode 1.18.29 => server hooks execute once and journal works: 5: cli - section Edge case - Cleanup - Unload plugin => event subscription aborts without unhandled rejection: 5: system - section Teardown - Stop temporary host and model processes => personal profile unchanged: 5: system -``` - -## Wireframe -Not applicable: no UI change. - -## Tasks to do -### 1) Compatible exports and event handling -1. Write regression tests and observe the expected failure. -2. Add default id/server/setup definitions to both modules, preserving named factory seams if the V1 loader permits them without duplicate execution. -3. Register V2 event handling through one shared host adapter with cancellable cleanup. Translate data/location session events, correlate granular tool events into completed tool calls, and handle execution completion as turn-end. Emit the helper outside the plugin discovery directory and test its installed layout. -4. Update existing coverage and documentation; run coding and architecture assertions. - -### 2) Real local compatibility proof -1. Translate the actual framework with the built CLI. -2. Run released V1 and V2 binaries in isolated temporary environments. -3. Assert actual memory refresh, task declaration and turn-end records; retain concise versioned evidence and the reproduction command. -4. Run independent review and outcome challenge before opening the draft PR against next. - -## Test acceptance criteria -| Task | Acceptance criteria | -| --- | --- | -| 1 | Both emitted modules have default id/setup/server; import does not spawn hooks. | -| 1 | V1 invokes its handler once; V2 setup returns promptly, forwards events and returns cleanup that aborts its subscription. | -| 1 | Malformed/unrelated events do not crash the host; completed tool, session-start and idle semantics remain correct. | -| 1 | Relevant tests and repository coding/architecture gates pass. | -| 2 | Real OpenCode V2 2.0.22 loads both translated modules with no plugin load failure and produces observable memory and journal effects. | -| 2 | Real OpenCode V1 1.18.29 retains those effects with no duplicate session-start. | -| 2 | Evidence names exact commands, versions, results and limitations; no paid model or personal configuration modification. | +## Acceptance +- Import spawns no hooks. +- V1 invokes once; V2 setup returns promptly and cleanup aborts its stream. +- Malformed events, failed tools and repeated success are safe. +- Coding and architecture gates pass. +- Real V2 loads both plugins and updates memory/journal. +- Real V1 preserves effects without duplicate start. +- Verification distinguishes real-host effects from controlled regressions and substituted inference. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md index 7d8ae0c63..40ffc2303 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md @@ -2,53 +2,12 @@ status: implemented --- -# Plan: Load AIDD plugins in OpenCode V1 and V2 +# Plan β€” OpenCode plugin loading +Load context and telemetry plugins on OpenCode V2 while preserving V1 compatibility. Require observable effects, prior web research and failing regressions before implementation. -## Overview -| Field | Value | -| --- | --- | -| **Goal** | Generated context hooks and telemetry load and execute in OpenCode V2 while preserving V1 behavior. | -| **Source** | https://github.com/ai-driven-dev/framework/issues/953, its V1 compatibility comment, and the user's request for prior web research and real local tests. | +- [OpenCode contract](./phase-1.md): compatible entrypoints, shared adapter and safe delivery. +- [Runtime proof](./verification.md): research first, failing regression, real hosts. +- [Accepted Kilo extension](./kilo-verification.md): complete hooks, configuration preservation and CI repair. +- [Final review](./review.md): quality and required gates. -## Phases -| # | Phase | File | -| --- | --- | --- | -| 1 | Compatible plugin entrypoints and live verification | [phase-1.md](./phase-1.md) | - -## Resources -| Source | Verified | -| --- | --- | -| https://opencode.ai/v2/docs/build/plugins/migrate-v1 | On 2026-10-07: default definition with id/setup for V2 and server for V1; object compatibility requires V1 1.18.29 or later. | -| https://opencode.ai/v2/docs/build/plugins | On 2026-10-07: setup receives location.directory; event.subscribe returns an asynchronous stream of events and takes an AbortSignal; cleanup cancels the stream. | -| https://opencode.ai/v2/docs | On 2026-10-07: @opencode/cli is the V2 package; version 2.0.22 exists in npm. | -| https://unpkg.com/@opencode/client@2.0.22/dist/promise/generated/types.d.ts | Released V2 events use data/location and granular tool/execution events. The live probe determines which declared events are actually emitted. | -| https://github.com/anomalyco/opencode/blob/v1.18.29/packages/opencode/src/plugin/index.ts | A recognized default.server is called once and the loader returns before its named-export fallback. | -| docs/ARCHITECTURE.md | Generic declarative hooks belong to the CLI bridge; telemetry retains its own journal adapter. No new runtime dependency is needed. | - -## Decisions -| Decision | Why | -| --- | --- | -| Keep existing V1 factories and payload mapping; expose them through server on a default definition. | Preserve behavior and avoid running both entrypoints for one host. Verify against the actual V1 loader. | -| Emit one CLI-owned host adapter under .opencode/hooks, shared by both plugin definitions. | Keep protocol normalization and subscription lifecycle in one place, outside plugin discovery, while each plugin retains its own hook payload mapping. | -| Correlate V2 tool input/start, call and success events before reusing the V1 handler; map execution completion to turn-end. | The real 2.0.22 run emits granular events, not the declared content snapshots or session.idle. Tool names and inputs arrive before completion. Remove pending calls after success or failure. | -| Pin real tests to V1 1.18.29 and V2 2.0.22. | Covers the documented compatibility boundary and the failing V2 release named in the issue. | -| Run real hosts in fresh temporary profiles and projects against a local deterministic model endpoint. | Exercise actual plugin loading, events, memory refresh and journal writes without changing personal configuration or paying for inference. | -| Require observable hook effects, not only the absence of loader errors. | A successfully imported plugin can still do no work. | - -## Constraints and risks -- No unrelated changes to the user's .gitignore or .hermes.md. -- No plugin-local test files: bundled hook tests remain in scripts/__tests__. -- Tests must fail before implementation for the entrypoint or behavior they name. -- V2 events may differ from V1; capture real events and preserve session identity and task declaration. -- OpenCode releases older than 1.18.29 do not support the documented default object contract; document the minimum instead of claiming untested backward compatibility. - -## Accepted verification extension -The user subsequently requested stronger Kilo evidence and correction of the red checks -on the existing pull request. Keep mutation thresholds unchanged and repair the shared -initial-test failure before judging mutation scores. Exercise the pinned Kilo 7.7.5 -runtime with the real memory script, a deterministic local model turn, observable hook -payloads and process cleanup. Capture released event shapes before extending supported -hooks. Validate existing configuration preservation through CLI delivery paths. Kilo -telemetry remains unsupported; this extension does not claim journal or cost coverage. -Record commands, observed failures, results and remaining limits in the verification -report. Re-enter independent review after the new candidate, then update the same PR. +Constraints: isolated profiles, local inference, unchanged mutation thresholds and bundle budget, user files untouched. Generic host adaptation belongs to the CLI; plugin-specific payload mapping stays with each plugin. Keep hook tests outside shipped plugin trees. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md index f22624484..416d6ce5f 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md @@ -1,71 +1,32 @@ -# Review: OpenCode plugin loading (#953) -- **Verdict**: approve -- **Diff**: original review `origin/next...ffa9c44a`; extension `b1a37fe0..116f791e` -- **Axes run**: code, functional, relevancy -- **Date**: 2026_10_07 -- **Findings**: 0 critical, 0 warning, 0 minor +# Review -## Phases -### Phase 1 β€” Compatible plugin entrypoints and live verification -- [x] Both emitted modules have default id/setup/server; import does not spawn hooks. β€” `cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts:232`, `plugins/aidd-telemetry/hooks/opencode-plugin.js:167`, `cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts:76`, `scripts/__tests__/opencode-plugin.test.js:156`. -- [x] V1 invokes its handler once; V2 setup returns promptly, forwards events and returns cleanup that aborts its subscription. β€” `cli/assets/configs/opencode/opencode-events.js.txt:66`, `scripts/__tests__/opencode-plugin.test.js:168`, `cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts:124`. Each real host case has exactly one SessionStart, PostToolUse and Stop. The [tagged V1 loader](https://github.com/anomalyco/opencode/blob/v1.18.29/packages/opencode/src/plugin/index.ts#L106-L116) returns after default.server. -- [x] Malformed/unrelated events do not crash the host; completed tool, session-start and idle semantics remain correct. β€” `cli/assets/configs/opencode/opencode-events.js.txt:3`, `scripts/__tests__/opencode-plugin.test.js:270`. One canonical protocol mapper correlates sessionID, assistantMessageID and tool id; success consumes pending state, failures and terminals discard it. Shutdown does not close a resumable turn, consistent with the [tagged execution coordinator](https://github.com/anomalyco/opencode/blob/v2.0.22/packages/core/src/session/execution.ts#L112-L139). -- [x] Relevant tests and repository coding/architecture gates pass. β€” `/tmp/aidd-953-runtime-ryTstm/commit-shared-final.log:54`: 140 architecture tests; `commit-shared-final.log:714`: 554 script tests; `push-shared.log`: 6,818 CLI tests and 531 files passed, one opt-in Kilo test skipped, knip passed. Independent `node --test scripts/__tests__/opencode-plugin.test.js scripts/__tests__/aidd-telemetry-opencode-payloads.test.js`: 26 passed, zero failures. `git diff --check origin/next...ffa9c44a`: exit 0. -- [x] Real OpenCode V2 2.0.22 loads both translated modules without plugin load failure and produces observable memory and journal effects. β€” `candidate-v2/{summary.json,stdout.log,events.jsonl,project/AGENTS.md,project/aidd_docs/runs/*.jsonl}` under the temporary evidence directory. Exactly session_start/task_declared/turn_end with vendor_id and hook session_id matching actual stdout session `ses_ee936cc1effeZlQEZZQWcz1C7z`. Baseline V2 exits zero despite both load errors and no effects; exit status alone was not accepted. -- [x] Real OpenCode V1 1.18.29 retains those effects with no duplicate session-start. β€” `candidate-v1` raw summary/stdout/journal/marker artifacts: exactly one of each record, matching session `ses_ee936b4a1ffeUtLo53jLIRbwGv`. -- [x] Evidence names exact commands, versions, results and limitations; no paid model or personal configuration modification. β€” `verification.md`, `probe.py`, `prepare.py`, `install-existing.py`, model requests and raw artifacts. Unsupported concurrent-identity coverage claim removed. HOME/XDG/profile/config directories isolated; inference uses a deterministic loopback provider. Claims distinguish real host/tool/plugin/journal execution from substituted inference. -- [x] Official research precedes code, as explicitly requested. β€” Parent transcript `rollout-2026-10-07T15-38-13-01a11696-08a4-78b1-b05c-c456b9fbceb3.jsonl`, entries 97/104 and 154/161: official requests/results at 13:45:59–13:47:39 UTC. First production patch follows at 13:51:56 UTC. Independently verified [official migration contract](https://opencode.ai/v2/docs/build/plugins/migrate-v1) and [plugin API](https://opencode.ai/v2/docs/build/plugins). -- [x] Project test-first and guard-mutation requirements have observed evidence. β€” Executor transcript `rollout-2026-10-07T15-49-59-01a116a0-cda7-74c3-9bbe-51865ae26720.jsonl`, entries 78/83/94: tests added 13:51:21 UTC, named undefined id/setup failures 13:51:28, production patch 13:51:56. Initial mutation entries 343/346 copy plugin/test, verify deletion anchor, assert targeted failure and clean scratch. Canonical-helper mutation raw failure is retained in `mutation-shared.log` with duplicate task_declared. Parent transcript entries 1146/1162 retain existing-tool missing-helper failures followed by four passing migration/preservation tests. +Verdict: approve within documented runtime limits. Axes: code, functional, relevancy. -### Phase 2 β€” Accepted Kilo verification and mutation repair -- [x] Repair the shared mutation initial-test failure without reducing floors. β€” `cli/vitest.mutation.config.ts:5`; independent mutation-config run: 23 passed, including the four runtime-file regressions. Raw OpenCode mutation score 96.4/floor 94 and Kilo score 67.7/floor 64 inspected. -- [x] Run the released pinned host with the actual delivered memory script. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:228`, `cli/tests/e2e/kilo-runtime.e2e.test.ts:310`; independent `pnpm --dir cli test:e2e:kilo`: one passed, 18.7 seconds; source bytes and AGENTS.md memory link asserted. -- [x] Execute a complete model turn with real read output and released session identity. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:383`; observer, model tool-result message and three exact payload shapes asserted. Inference alone uses the deterministic loopback endpoint. -- [x] Prove each hook occurs exactly once in the final runtime trace. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:426`, `cli/tests/e2e/kilo-runtime.e2e.test.ts:448`: stop the host process group before the final snapshot, assert exactly three records and check all three exact payloads. Corrected host case passes in 14.819 seconds; copied fourth-record counterproof fails at the intended assertion with length 3 but got 4. Initial review finding fixed in `3e762c31`. -- [x] Support captured lifecycle events, suppress replay and release deleted session state. β€” `cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts:118`; independent 13 bridge cases passed; copied deletion mutant fails with actual 1 versus expected 2. Real pre-implementation turn fails with expected 1 to be 3 in `/tmp/kilo-hooks-runtime-red.log`. -- [x] Preserve configuration through setup, installation and update. β€” `cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts:78`; six cases passed for both configuration locations, exact JSONC bytes and regenerated bridge. These are application use cases with port doubles; actual CLI translation is separately exercised by the host test. -- [x] Verify server, captured children and local model shutdown. β€” `cli/tests/e2e/kilo-runtime.e2e.test.ts:168`, `cli/tests/e2e/kilo-runtime.e2e.test.ts:457`; independent real-host case passed with process/PID assertions, process-group cleanup, closed host port and model server. -- [x] Keep unsupported telemetry and evidence limits explicit. β€” `kilo-verification.md`; unchanged Kilo profile declares unsupported telemetry. Journal host detection does not claim the generated Kilo payloads. Other operating systems, paid providers, hot reload and exhaustive process-tree auditing remain unclaimed. +## Phases -### Phase 3 β€” Windows embedded-asset checkout repair -- [x] Repair checkout-dependent bundle growth at the source-byte boundary. β€” `cli/.gitattributes:12`: extend the existing CLI-owned line-ending policy to embedded configuration assets. Every current asset in this directory is text; no parallel policy, production algorithm, dependency, mutation floor or bundle-budget change. -- [x] Use an effective guard with real isolated checkout and a removal counterproof. β€” `cli/tests/architecture/bundled-config-checkout.arch.test.ts:15`: temporary independent Git index, core.autocrlf=true, relocated HOME/USERPROFILE, shared Git-environment sanitizer and cleanup in finally. Both real assets retain LF; removing their rule causes both byte comparisons to change. Independent focused command: two passed. -- [x] Tie the repair to the actual red Windows check and reproduced build behavior. β€” `/tmp/aidd-971-repair/windows-ci.log:3766`: Windows build exceeds 734 KB; `bundle-crlf-build.log` reproduces failure, while `bundle-lf-build.log` passes. The LF clone bundle stat is 751,591 bytes under the unchanged 751,616 budget. Before/after guard logs and the final 142-check architecture log inspected; remote success for this new candidate remains pending. +- [x] [OpenCode](./phase-1.md): compatible exports, shared adapter, cancellation, safe delivery and observable V1/V2 effects. +- [x] [Kilo](./kilo-verification.md): complete real turn, exact payloads after shutdown, replay/deletion handling and configuration preservation. +- [x] Windows: checkout preserves embedded LF bytes; removal counterproof detects drift. +- [x] Quality: coding, architecture, dead-code, mutation and bundle gates pass without weakening thresholds. ## Findings -| Sev | Kind | Phase | Location | Issue | Fix | -| --- | --- | --- | --- | --- | --- | -| β€” | β€” | β€” | β€” | None. | β€” | + +None remaining. Obsolete parser removed; comments distinguish V1 fallback from V2 announcements. Independent reviews found no unused addition, unjustified duplication or weakened assertion. Cleanup preserves generated module bytes on representative inputs. ## Verification -| Metric | Value | -| --- | --- | -| Original verified | 100% (9/9 phase and stated-process criteria); plan criteria 7/7 | -| Original checker score | 100%: all nine original criteria and four baseline checks fulfilled, 13/13. No severity adjustment. Caller owns its acceptance threshold. | -| Files checked | Every changed line in all 60 files in `origin/next...ffa9c44a`, including task artifacts, canonical JS text asset, bridge and telemetry wrappers, runtime-file metadata/build/install/restore/wiring, generalized distribution loader and all constructor migrations, tests/golden/configuration, filename guard and documentation. Root/CLI assertions and applicable architecture/convention rules also checked. | -| No information duplication | Fulfilled: V2 protocol and lifecycle have one home, `cli/assets/configs/opencode/opencode-events.js.txt`; both wrappers reuse it. Source fetching/parsing likewise reuses the generalized existing loader. Plugin-specific payload adapters retain separate responsibilities. | -| No incoherence or contradiction | Fulfilled: version-qualified docs match source and observed events; unsupported concurrency coverage removed; final counts and bundle size match logs. Existing-helper preservation and tool ownership are explicit. | -| No over-engineering | Fulfilled: one emitted helper outside discovery, existing asset pipeline and runtime installer reused; missing-file backfill avoids rewriting user config or existing helper. Existing distribution loader generalized rather than adding an unused abstraction. Constructor fixture edits follow the actual production dependency change; collaborator baselines unchanged. | -| No dead code or debug leftovers | Fulfilled: helper/setup/server, distribution loading and delivery paths are exercised; no debug logs, TODO or commented-out code added. Temporary inference/observer/marker harness stays outside shipped source. | -| Delivery and ownership | Translation emits the helper once; setup tracks it as tool-owned; add/update backfill only missing runtime files; restore generates the same artifact. Four migration tests cover missing install/update helpers, an untracked existing helper and tracked helper edits/hash preservation. Raw existing-install summary confirms configuration byte preservation and one tool-owned record. | -| Runtime freshness and identity | Independently compared executed telemetry bytes with current repository source, helper bytes with canonical asset, and executed context bytes with final CLI-translated candidate across all four cases. All effects/session IDs/task arguments verified: translated V1 `ses_ee936b4a1ffeUtLo53jLIRbwGv`; translated V2 `ses_ee936cc1effeZlQEZZQWcz1C7z`; setup-installed V2 `ses_ee93675e4ffeXe9XxjPANgDAQH`; existing-tool backfilled V2 `ses_ee92fc668ffekZp0PjzqAcnJah`. | -| Runtime evidence boundary | Released hosts V1 1.18.29 and V2 2.0.22; real discovery/loading, event stream, read tool, child hooks and journal; local deterministic inference. One successful project/session per case. Older V1 unsupported; other releases, paid inference, personal/global installation, other operating systems and live hot reload not demonstrated. Cleanup/failure/interruption/dedup exercised through regression tests, not claimed as live-host failure scenarios. | -| Evidence provenance | Raw runtime/gate/mutation files inspected directly; prior research and initial red tests verified from timestamped tool calls/results in local Codex transcript files under `/Users/baptistelafourcade/.codex/sessions/2026/10/07/`, not inferred from verification prose. Runtime/gate directory: `/tmp/aidd-953-runtime-ryTstm`. | -| Original additional checks | Normal commit lint/typecheck/type-honesty/documentation gates pass; final clone budget unchanged at 596 duplicated lines (1.41%); built CLI 732.2 KB below 734 KB budget. Asset embedding/text loader and filename guard reviewed; helper stays outside scanned plugin directory. | -| Original scope preservation | Preexisting `.gitignore` modification and untracked `.hermes.md` excluded from diff and untouched. No implementation/validator edits by checker. No source changed after original code candidate `ae500bfb` before original evidence candidate `ffa9c44a`, which changes only task documentation. | -| Unchecked | none | -| Unplanned | none; runtime delivery and loader relocation recorded in revised architecture projection | -| Extension verified | 100% (8/8); original Phase 1 evidence above is retained as historical evidence, not a claim that remote CI for the extension is green. | -| Extension checker score | 100% (12/12: all eight extension acceptance checks and four baseline checks fulfilled); no severity adjustment. Caller owns the acceptance threshold. | -| Extension files checked | All changed lines in the twelve implementation/evidence files, generated bridge, runtime harness, six delivery cases, configuration loader, workflow, golden baseline, checkout attributes/guard and task evidence; root/CLI coding and testing assertions. Subsequent review-artifact commits add no production changes. | -| Extension baseline | DRY, consistency within documented host/telemetry limits, minimum relevant scope and no dead/debug production code fulfilled. Observer and deterministic inference remain test-only. | -| Extension commands run | Independent mutation-config focused tests: 23 passed; complete Kilo profile tests: 21 passed; real Kilo 7.7.5 host: one passed. Final corrected-host green, typecheck and targeted counterproof logs inspected directly after reviewing the correction. | -| Extension fixed finding | The final snapshot now rejects delayed fourth records. Evidence: `/tmp/aidd-971-repair/kilo-quiescent-runtime-green.log` and `kilo-late-hook-counterproof.log`; temporary copied case removed. Reviewed committed test SHA256: 5f53e7f983654fc777b035840ab34a8fc5dbc5b2127d0b2983151eef5f964559. | -| Extension configuration proof | Six application use cases preserve original JSONC bytes through setup/add/update. CLI translation intentionally normalizes JSON and adds schema, so the runtime test checks custom values during delivery and exact preservation of the delivered configuration after host execution. | -| Extension golden repair | The complete pre-push run was withheld with 6,832 passed, one failed and one skipped; sole failure was the stale Kilo generated-context bridge hash. The delta `0528e5ce..16d8b223` changes only that stored hash and its verification note, no source/assertions/floors. Independent structured comparison confirms ten cells, identical file lists and one changed hash: `kilo:flat/.kilo/plugin/aidd-context-hooks.js`, now 1ccdf9253492b1a3663e418d58a542c7d97046964070e332c7bc110d960c45b4. | -| Extension golden validation | Independent `pnpm --dir cli exec vitest run --project=e2e tests/golden/framework-build-golden.e2e.test.ts`, without recapture: three passed in comparison mode, including deterministic outputs and all ten stored baselines. Raw log: `/tmp/aidd-971-repair/checker-golden-comparison.log`. Runtime test blob remains cfd95913537eb6b22371583b905797190557602c; unchanged source/host/mutation checks were not rerun. | -| Extension pending evidence | Full current CLI suite and remote required gate not yet established by this checker. No claim that all red CI jobs are repaired remotely; parent must inspect the actual new run. | -| Extension resource boundary | Built bytes 751,591 against unchanged 751,616 budget; 25 bytes remaining. Thresholds and budgets unchanged; user-owned .gitignore and .hermes.md excluded. | -| Windows follow-up score | 100% (3/3 scoped acceptance checks and 4/4 baseline checks); no findings. Existing attribute policy is extended, naming/docs match the byte boundary, two representative real assets suffice for the generic rule, and there are no debug or unused abstractions. | -| Windows follow-up command | Independent `pnpm --dir cli exec vitest run --project=architecture tests/architecture/bundled-config-checkout.arch.test.ts`: two passed in 55 ms. Raw log: `/tmp/aidd-971-repair/checker-bundle-eol-guard.log`. Full architecture evidence reports 142 passed. | -| Windows follow-up scope and limits | Candidate `116f791e` changes only CLI checkout attributes, the two checkout cases and their evidence beyond the already reviewed work. Production source, floors and budget unchanged. New full suite and actual Windows/required remote gate remain pending; an isolated Git checkout on this machine does not claim final Windows CI success. | + +Local CLI/root/architecture suites, knip, lint and types passed. [CLI CI](../../../../.github/workflows/cli-ci.yml) passed, including Windows, the dedicated real Kilo case, selected mutations and required gate. [CodeQL](../../../../.github/workflows/codeql.yml) passed with no remaining open alert. These are observed results; consult the PR's current checks after each new commit. + +Re-run normal gates: + +```sh +node scripts/check-tests-leave-git-alone.js -- node --test scripts/__tests__/*.test.js +pnpm --dir cli test +pnpm --dir cli test:arch +pnpm --dir cli lint +pnpm --dir cli typecheck +pnpm --dir cli knip +pnpm --dir cli build +``` + +The normal suite skips the opt-in Kilo host case; its explicit command and mutation commands are in [Kilo verification](./kilo-verification.md). [OpenCode verification](./verification.md) distinguishes committed regressions from temporary real-host evidence. Unchecked acceptance criteria: none within that scope. diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md index 97de119e0..ab22d8716 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md @@ -1,116 +1,20 @@ -# OpenCode plugin loading verification +# OpenCode proof -Source: [framework issue 953](https://github.com/ai-driven-dev/framework/issues/953). -Research and local runtime checks performed on 2026-10-07, macOS ARM64, Node 24.20.0. +Research: [migration](https://opencode.ai/v2/docs/build/plugins/migrate-v1), [plugin API](https://opencode.ai/v2/docs/build/plugins), [released event types](https://unpkg.com/@opencode/client@2.0.22/dist/promise/generated/types.d.ts). The [V1 loader](https://github.com/anomalyco/opencode/blob/v1.18.29/packages/opencode/src/plugin/index.ts) returns after `default.server`; the [V2 coordinator](https://github.com/anomalyco/opencode/blob/v2.0.22/packages/core/src/session/execution.ts) preserves shutdown-interrupted executions for restart. -## Research findings +Before: V2 reported `Missing key at ["default"]` despite exit zero; memory unchanged, journal empty. Exit status alone could not prove success. -- [The official migration guide](https://opencode.ai/v2/docs/build/plugins/migrate-v1) requires a default definition with id/setup for V2 and documents server compatibility beginning with V1 1.18.29. -- [The plugin API](https://opencode.ai/v2/docs/build/plugins) supplies location.directory, an asynchronous event subscription, and cleanup through an AbortSignal. -- [Published client 2.0.22 types](https://unpkg.com/@opencode/client@2.0.22/dist/promise/generated/types.d.ts) use data/location instead of V1 properties. A declared event is not proof that the runtime emits it. -- [The V1 1.18.29 loader](https://github.com/anomalyco/opencode/blob/v1.18.29/packages/opencode/src/plugin/index.ts) prefers default.server and returns before processing named factories. Keeping the named test seams does not execute the plugin twice. -- [The V2 2.0.22 execution coordinator](https://github.com/anomalyco/opencode/blob/v2.0.22/packages/core/src/session/execution.ts) emits succeeded, failed, or interrupted. A shutdown interruption preserves the execution claim for a later restart; it must not close the journal turn. +After: real macOS runs passed for translated V1 1.18.29, translated V2 2.0.22, fresh V2 setup and existing V2 installation missing its helper. Each refreshed the seeded memory reference, fired `SessionStart/PostToolUse(read)/Stop` exactly once and wrote `session_start/task_declared/turn_end`. Payloads carried the actual session identity and task-document argument. No loader error; existing configuration preserved byte-for-byte and the helper recorded once as tool-owned. -## Real reproduction before the fix +Observed V2 events were granular tool input/call/success and execution completion, without `session.idle` or content snapshots. The shared adapter correlates inputs, consumes success once and preserves shutdown-interrupted turns. Controlled regressions cover malformed/incomplete/failed events, terminal cleanup and subscription cancellation; these are not live-host failure scenarios. A copied success-consumption mutant failed the duplicate-task assertion. Missing-helper regressions failed before backfill; existing contents and hashes remain protected. -The built CLI translated the unchanged framework into a temporary project. The released -OpenCode V2 2.0.22 binary ran a real session and read a task document using a local -OpenAI-compatible deterministic provider. - -Both modules failed with `Plugin must export a default definition` and -`Missing key at ["default"]`. The process nevertheless exited zero. Memory was unchanged; -the journal contained no records. Exit status alone would have falsely passed this test. - -The same unchanged framework under V1 1.18.29 refreshed memory and wrote session_start, -task_declared and turn_end. - -## Real runtime results after the fix - -The temporary source copy added marker hooks to the context plugin's existing hooks.json -for SessionStart, Stop, and PostToolUse with matcher read. The actual built CLI translated -that source; no generated adapter was repaired by hand. The telemetry module was unchanged -from the candidate source. - -Both released binaries exited zero and produced all of these effects: - -- The AGENTS.md memory block referenced the seeded architecture.md. -- Generic hooks fired exactly once each, in order: SessionStart, PostToolUse, Stop. -- PostToolUse carried tool_name read and the actual task-document argument. -- Exactly one session_start, one task_declared and one turn_end were written. -- The session_start vendor_id matched the actual session ID reported by OpenCode. -- Neither adapter produced a plugin load error. - -After the independent review's duplication finding, the V2 host protocol and subscription -lifecycle were extracted into one embedded CLI asset. Both modules import the emitted -`.opencode/hooks/opencode-events.js`; the real V1/V2 runs above were repeated with this -layout. A fresh `setup` installation, using the temporary local framework source, also -passed a real V2 session with the same observable effects. - -A second project simulated an existing tool manifest without the shared runtime file. -The actual `plugin install` command installed context and telemetry from the temporary -local source, backfilled the helper and recorded it once as tool-owned. The preexisting -model and permission configuration remained byte-identical. A real V2 session then -produced the same memory, hook and journal effects, with its actual session identity. - -The V2 observation stream contained session.created, session.tool.input.started, -session.tool.called, session.tool.success and session.execution.succeeded. It contained -neither session.idle nor session.message.content.updated. The adapter therefore correlates -the measured tool events and consumes each success once; merely adding a default export -or adapting the declared content snapshot would not have satisfied this reproduction. - -## Local commands and evidence - -Temporary harness and logs are under /tmp/aidd-953-runtime-ryTstm. The harness preserves -its model requests, stdout, stderr, passive V2 event capture and JSON summaries per case. -It launches the real host with a restricted environment, relocated profile directories, -a temporary git project and a loopback model endpoint. No personal credentials are passed. +Re-run the committed adapter, payload, generated-layout and delivery regressions: ```sh -npm install --prefix /tmp/aidd-953-runtime-ryTstm/v1 --no-audit --no-fund opencode-ai@1.18.29 -npm install --prefix /tmp/aidd-953-runtime-ryTstm/v2 --no-audit --no-fund @opencode/cli@2.0.22 -pnpm --dir cli build -python3 /tmp/aidd-953-runtime-ryTstm/prepare.py -node cli/dist/cli.js translate /tmp/aidd-953-runtime-ryTstm/fixture-source --to opencode --as flat --out /tmp/aidd-953-runtime-ryTstm/candidate --force -python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candidate v1 -python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candidate v2 -python3 /tmp/aidd-953-runtime-ryTstm/install-existing.py -python3 /tmp/aidd-953-runtime-ryTstm/probe.py /tmp/aidd-953-runtime-ryTstm/candidate-existing v2 +node scripts/check-tests-leave-git-alone.js -- node --test scripts/__tests__/opencode-plugin.test.js scripts/__tests__/aidd-telemetry-opencode-payloads.test.js +pnpm --dir cli exec vitest run --project=unit tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts +pnpm --dir cli exec vitest run --project=integration tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts +pnpm --dir cli exec vitest run --project=e2e tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts ``` -The V2 command uses run --standalone; neither test starts a persistent background service. -The installed personal OpenCode 1.14.20 and its profile were not upgraded or used. - -## Original OpenCode candidate validation - -- Full repository scripts suite through the normal commit hook: 554 passed, zero failures; the hook also verified that tests left git hooks unchanged. -- Full CLI suite through the final normal push hook: 6,818 passed, one opt-in Kilo runtime test skipped; 531 files passed. Evidence: push-shared.log in the temporary harness directory. -- Focused OpenCode and cost-documentation scripts: 48 passed, after observing the stale documentation assertion fail first. -- All 140 architecture checks passed without increasing comment or empty-catch baselines. -- Lint, TypeScript, type honesty, knip, duplicate-code budget and whitespace checks passed. Lint retains one existing unused-private-member warning in uninstall-use-case.ts, outside this change. -- Built CLI: 732.2 KB, below the existing 734 KB budget. -- Both emitted plugin files and the shared runtime module passed node --check. -- An isolated mutation removing success consumption made the duplicate-task regression fail; the production source was not mutated. -- After extraction, the same mutation against a copied canonical helper failed the duplicate-task regression; raw failure retained in mutation-shared.log. Existing-installation support tests first failed for missing helpers, then passed for installation, update and preservation of existing helper contents/hashes. Restoration first omitted the helper, then its regression passed after merging the runtime asset declaration. - -Architecture conformance: generic translation and the canonical host protocol belong to -the CLI; telemetry observes through its own payload adapter, and hook tests remain outside -shipped plugin trees. The shared helper is tool-owned, outside plugin discovery, with no -package dependency or cross-plugin import. Existing-tool plugin installation and update -backfill only a missing helper, preserving user configuration, existing helper contents, -ownership and recorded drift hashes. Source fetching and parsing use one shared framework -entrypoint so the plugin orchestrators gain no extra collaborator. - -## Scope of the evidence - -Real runtime coverage is a single-project successful session under V1 1.18.29 and V2 -2.0.22. Regression tests exercise stream cleanup, malformed events, incomplete and failed -tools, repeated success, and execution termination behavior. -Paid inference, global-profile installation, other operating systems and a running host's -hot reload are not claimed by this local proof. The local deterministic model substitutes -only inference; OpenCode's plugin loader, event stream, read tool, hook processes and -journal are real. - -## Subsequent verification extension -The accepted Kilo runtime extension and mutation CI repair are documented in -[Kilo verification](./kilo-verification.md), with the initial need, implemented behavior, -added tests, observed failures, commands and limits. +The real-host probe was temporary and is not a committed reproduction harness. Commands above exercise adapters and delivery, without launching released OpenCode hosts. The observed host runs isolated profiles and substituted only inference with a loopback provider; plugin loading, events, the read tool, hooks and journal were real. Global installation, paid providers, hot reload, other releases/OS and concurrent sessions were not exercised. [Kilo proof](./kilo-verification.md); [final gates](./review.md). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 24fcd2091..9245d7e11 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,77 +27,57 @@ plugins// β”œβ”€β”€ agents/ Β· commands/ Β· hooks/hooks.json Β· rules/ Β· .mcp.json (all optional) ``` -Only `skills/` and the manifest are universal; a plugin ships any subset of the rest. +Only `skills/` and the manifest are universal; the rest is optional. -A plugin never contains its own tests: the build copies `hooks/` recursively into every user project, so a test folder there would ship to them. Tests for a bundled script live in `scripts/__tests__/`. +A plugin never contains its own tests: the build copies `hooks/` recursively into every user project. Tests for a bundled script live in `scripts/__tests__/`. -`plugin.json` and `marketplace.json` are validated against their [plugin](https://www.schemastore.org/claude-code-plugin-manifest.json) and [marketplace](https://www.schemastore.org/claude-code-marketplace.json) schemas, in the `lefthook` pre-commit hook and again in the `validate` workflow. +`plugin.json` and `marketplace.json` are validated against their [plugin](https://www.schemastore.org/claude-code-plugin-manifest.json) and [marketplace](https://www.schemastore.org/claude-code-marketplace.json) schemas, in the `lefthook` pre-commit hook and in the `validate` workflow. ## πŸͺ Bundled hooks Declared in `plugins//hooks/hooks.json`. They run Node, so users need `node` on their `PATH`: -| Plugin | Event | Runs | Purpose | -| ---------------- | ----------------------------------------- | ------------------------- | --------------------------------------------------------------------- | -| `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | -| `aidd-telemetry` | `SessionStart` Β· `Stop` Β· `PostToolUse` | `hooks/journal.cjs` | Journal every session so a unit of work can be tied to what it cost | +| Plugin | Event | Runs | Purpose | +| ---------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------- | +| `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | +| `aidd-telemetry` | `SessionStart` Β· `Stop` Β· `PostToolUse` | `hooks/journal.cjs` | Journal every session so a unit of work can be tied to its cost | -A hook is authored once, with `${CLAUDE_PLUGIN_ROOT}`, and the installer rewrites it to whatever the target tool expands. Which tools run a bundled hook at all, and what each resolves: +A hook is authored once with `${CLAUDE_PLUGIN_ROOT}`; the installer rewrites it to the target tool's spelling: -| Tool | Runs bundled hooks | Resolves the plugin root as | Notes | -| ------------- | ------------------ | --------------------------- | ---------------------------------------------------------------------------------------- | -| Claude Code | yes | `${CLAUDE_PLUGIN_ROOT}` | The spelling every plugin is authored in, so nothing is substituted | -| Codex | yes | `${PLUGIN_ROOT}` | Measured: it expands `${CLAUDE_PLUGIN_ROOT}` too, and will not run a hook it has not been asked to trust | -| GitHub Copilot| yes | `${PLUGIN_ROOT}` | Declared, never observed against a running hook | -| Cursor | declared | `./` | Its own hook format: the converter rewrites the root to a path relative to the plugin before the declared token is ever substituted. Two headless probes fired no plugin hook at all, and what registers a plugin sitting in Cursor's own plugin directory was not identified | -| OpenCode | no, by a second route | β€” | Declarative hooks are translated by `opencode-hooks-bridge.ts` into `-hooks.js` modules for `SessionStart`, `Stop`, and `PostToolUse`. Telemetry supplies its own adapter in `plugins/aidd-telemetry/hooks/opencode-plugin.js` because its journal uses a different stdin dialect. Both expose a default definition: `server` for V1 1.18.29 or later, `setup` for V2. The declarative hook support column does not describe this module route. | +| Tool | Runs bundled hooks | Plugin root | Notes | +| -------------- | --------------------- | ---------------------- | ----- | +| Claude Code | yes | `${CLAUDE_PLUGIN_ROOT}` | Authoring spelling, nothing substituted | +| Codex | yes | `${PLUGIN_ROOT}` | Also expands `${CLAUDE_PLUGIN_ROOT}`; runs a hook only once trusted | +| GitHub Copilot | yes | `${PLUGIN_ROOT}` | Declared, never observed running | +| Cursor | declared | `./` | Own hook format: the converter rewrites the root to a plugin-relative path before token substitution. No plugin hook observed firing headless; what registers a plugin in Cursor's plugin directory is unknown | +| OpenCode | no, by a second route | β€” | See below | -OpenCode V1 delivers `session.idle` and completed `message.part.updated` tool parts to an -`event` hook. One CLI-owned runtime module subscribes to V2 `data`-based events with an abortable stream, correlates -tool name and input with tool success, and translates execution terminals into turn-end. -Shutdown interruptions leave the turn open because OpenCode resumes that execution. -The generic bridge fires its idempotent `SessionStart` hooks when either host initializes -the module; telemetry records sessions separately from actual session events. +OpenCode runs no declarative hook. Instead, `opencode-hooks-bridge.ts` translates them into `-hooks.js` modules for `SessionStart`, `Stop` and `PostToolUse`. Telemetry ships its own adapter, `plugins/aidd-telemetry/hooks/opencode-plugin.js`, because its journal uses a different stdin dialect. Both export a default definition: `server` for V1 1.18.29 or later, `setup` for V2. -The CLI embeds this shared module and delivers it under `.opencode/hooks/opencode-events.js`, -outside plugin discovery. Translation emits it once; tool installation tracks it as a tool -file. Plugin installation and update backfill a missing module on an existing tool without -rewriting user configuration or claiming an existing untracked module. Each plugin keeps -its own V1 payload adapter and imports this common host protocol for V2. +- **V1** delivers `session.idle` and completed `message.part.updated` tool parts to an `event` hook. Each plugin keeps its own V1 payload adapter. +- **V2** uses one shared module, embedded and delivered by the CLI at `.opencode/hooks/opencode-events.js`, outside plugin discovery. Each plugin imports it for V2. It subscribes to `data`-based events over an abortable stream, correlates tool name and input with tool success, and maps execution terminals to turn-end. A shutdown interruption leaves the turn open, since OpenCode resumes it. +- The bridge fires its idempotent `SessionStart` hooks when either host initializes the module; telemetry records sessions separately from session events. +- Translation emits the module once and tool installation tracks it as a tool file. Plugin install and update backfill a missing module without rewriting user configuration or claiming an existing untracked one. -A tool that runs no hook says why, and an install that carries one tells whoever ran it what was skipped. +A tool that runs no hook says why, and an install that carries one reports what was skipped. ## βš–οΈ What runs on every event, and what runs when someone asks -Measured on one machine, 12 runs each, median: the bundled hook starts in **27 ms**, the CLI -in **180 ms** β€” 6.7Γ— β€” and `PostToolUse` fires on every tool call a session makes. A -thousand tool calls is 153 seconds of added latency, so the difference is not a preference. - -The line is therefore **not** "plugin or CLI". It is what the code is answering to: +`PostToolUse` fires on every tool call, so the code on that path is a bundled hook, not the CLI. The line is therefore **not** "plugin or CLI" but what the code answers to: | | Triggered by | Latency | Runs as | | --- | --- | --- | --- | -| Observing | a tool event, thousands of times a session | must not be felt | plain Node in `hooks/`, no install, no dependency | +| Observing | each tool event | must not be felt | plain Node in `hooks/`, no install, no dependency | | Answering | a person or a skill, once | irrelevant | the `aidd` CLI | -Two consequences, both already paid for: - -- A capability that answers belongs in the CLI even when a plugin is what asks for it. The - telemetry pivot deleted 25 files and 4,355 lines of skill-owned scripts on that argument: - one implementation cannot drift from a copy of itself, and the copies had drifted. -- A skill that needs the CLI must say so out loud when it is absent, never quietly do - nothing. The wording is pinned identically across every such skill by - `scripts/__tests__/telemetry-cli-required.test.js`, so a fourth skill cannot invent a - fourth phrasing. +- A capability that answers belongs in the CLI even when a plugin asks for it: one implementation cannot drift from a copy of itself. +- A skill that needs the CLI must say so when it is absent, never silently do nothing. The wording is pinned across those skills by `scripts/__tests__/telemetry-cli-required.test.js`. -The cost of the pivot is real and is stated rather than argued away: a plugin that once -promised "no npm install, no CLI, no account" now needs `node` to measure and `aidd` to -answer. Writing that a hook can move to the CLI, or that a skill may keep its own script -because it is small, re-opens a question that was settled with numbers. +Telemetry requires `node` to measure and `aidd` to answer. ## 🧠 Plugin concerns and layers -Every capability lives in exactly one plugin, chosen by **concern**. This taxonomy decides placement; it is only implicit in each `plugin.json`, so it is canonical here. +Every capability lives in exactly one plugin, chosen by **concern**. This taxonomy is only implicit in each `plugin.json`, so it is canonical here. | Plugin | Concern | Layer | | ------------------- | -------------------- | ------------ | @@ -111,22 +91,22 @@ Every capability lives in exactly one plugin, chosen by **concern**. This taxono | `aidd-telemetry` πŸ§ͺ | Measurement | Observation | | `aidd-qa` πŸ†• | Acceptance QA | Execution | -`aidd-ui` is alpha: smoke-test only, off the curated install path. +Off the curated install path: -`aidd-qa` is new, off the curated install path until it is proven outside this repository. It validates observable behavior against acceptance criteria and drives a browser to record evidence, so it sits in the Execution layer alongside `aidd-dev`. - -`aidd-telemetry` is beta, off the curated install path: opt-in only β€” a repository must commit `.aidd/config.json` with `telemetry.enabled: true`. Each session appends observations, one JSON object per line, to its own `aidd_docs/runs/__.jsonl`, created on demand and git-ignored; that directory's presence is a location, not a permission. A line is never rewritten, only appended β€” `session_start`, `turn_end`, `file_written`, `step_start`, `step_end`, `task_declared` and `unrecognised_payload` (a path is repository-relative, never a task_id: task identity is a derivation, and belongs to whatever reads the log). Never a measurement; tokens and cost are joined afterwards from the provider's telemetry. +- `aidd-ui`: alpha, smoke-test only. +- `aidd-qa`: new, until proven outside this repository. Validates observable behavior against acceptance criteria and records browser evidence, hence Execution. +- `aidd-telemetry`: beta, opt-in. A repository must commit `.aidd/config.json` with `telemetry.enabled: true`. Each session appends one JSON object per line to its own git-ignored `aidd_docs/runs/__.jsonl`, created on demand; the directory's presence is a location, not a permission. Lines are append-only: `session_start`, `turn_end`, `file_written`, `step_start`, `step_end`, `task_declared`, `unrecognised_payload`. Paths are repository-relative, never a task_id: task identity is derived by the reader. It records no measurement; tokens and cost are joined later from the provider's telemetry. **Observation** writes only *about* the other layers, never the artifact it describes, and nothing may depend on it. -- **Knowledge vs execution is a firewall.** Knowledge plugins produce artifacts you *read* and never write or run application source. `aidd-context`'s bootstrap deliberately creates no `package.json`. Real code belongs to `aidd-dev` or an orchestrator's own setup actions. -- **Concern decides placement, not existence.** A missing capability goes in the plugin whose concern owns it, then the caller delegates. Never reimplement it in the calling plugin because the right home lacks it today. -- **Orchestration = sequencing across concerns** with little domain logic. Delegating a sub-step once does not make a skill an orchestrator. The orchestrator owns only glue and hands off through a seam artifact, for example an `INSTALL.md` one plugin produces and another consumes. +- **Knowledge vs execution is a firewall.** Knowledge plugins produce artifacts you *read*; they never write or run application source. `aidd-context`'s bootstrap creates no `package.json`. Real code belongs to `aidd-dev` or an orchestrator's own setup actions. +- **Concern decides placement, not existence.** A missing capability goes in the plugin whose concern owns it, and the caller delegates. Never reimplement it in the caller. +- **Orchestration = sequencing across concerns** with little domain logic. Delegating one sub-step does not make a skill an orchestrator. The orchestrator owns only glue and hands off through a seam artifact, for example an `INSTALL.md` one plugin produces and another consumes. - `aidd-orchestrator:02-backlog` owns the cross-artifact flow. Each artifact's contract stays in its `aidd-pm` skill, so a direct PM call follows the same rules as an orchestrated one. ## πŸ”€ Skills are routers -A skill's `SKILL.md` is a manifest plus a router. Claude Code loads the SKILL.md when the skill is invoked; the body decides which local action or orchestration protocol to run. +A skill's `SKILL.md` is a manifest plus a router, loaded on invocation; its body picks the local action or orchestration protocol to run. ```mermaid --- @@ -149,34 +129,32 @@ flowchart LR ActionN --> Out ``` -Recipe skills route to self-contained actions with inputs, outputs, process steps, and tests. An orchestrator with no domain logic may instead route through numbered reference protocols that define handoffs and delegate the work to capabilities discovered at runtime. +Recipe skills route to self-contained actions with inputs, outputs, process steps and tests. An orchestrator with no domain logic may instead route through numbered reference protocols that define handoffs and delegate to capabilities discovered at runtime. -A skill never links outside itself (`scripts/__tests__/a-skill-links-only-inside-itself.test.js`). The same tree ships flat, where the skill folder is renamed `-`, or as a marketplace, so no relative path survives both. A bundled script is named plugin-relative in backticks, never linked. +A skill never links outside itself (`scripts/__tests__/a-skill-links-only-inside-itself.test.js`): the tree ships flat, with the skill folder renamed `-`, or as a marketplace, and no relative path survives both. A bundled script is named plugin-relative in backticks, never linked. ## πŸ€– Skills and agents -- A **skill** is a caller-agnostic recipe; it runs in the context of whoever invokes it. -- An **agent** is an isolated executor; it runs in its own context and returns only a result. +- A **skill** is a caller-agnostic recipe running in the invoker's context. +- An **agent** is an isolated executor with its own context, returning only a result. Choose by context, not complexity: keep the work visible to the caller β†’ skill; isolate it and take only the result β†’ agent. -- **Spawning is authorized by the high-level orchestrator, never invented by a recipe skill.** A recipe skill normally runs in the caller's context. A bounded fan-out capability may mechanically spawn leaf agents only when the orchestrator explicitly delegates that responsibility and retains routing ownership. -- An orchestrator spawns each isolated step as a leaf agent that runs a recipe, or runs the recipe itself when isolation is unnecessary. The SDLC owns planning, delegates delivery to `executor`, and delegates independent judgments to a fresh `checker`. For independent repair findings, it may explicitly delegate bounded fan-out to `10-todo`; Todo's leaf executors return their results to the SDLC. A recipe invoked inside an agent never spawns again. -- An agent invokes only the recipe skills it declares under `# Skills you may invoke`, never an orchestrator skill, and never reads a skill's files. It names every skill by its canonical `/plugin:folder` address so its permissions are explicit and auditable. -- An agent never delegates flow work to another agent and never invokes an orchestrator skill. It may spawn a read-only recon helper (for example `Explore`) that mutates nothing and spawns nothing. So the write path stays two layers deep and delegation can never cycle. +- **Only the high-level orchestrator authorizes spawning.** A recipe skill runs in the caller's context. A bounded fan-out capability may spawn leaf agents only when the orchestrator explicitly delegates that and keeps routing ownership. +- An orchestrator spawns each isolated step as a leaf agent running a recipe, or runs the recipe itself when isolation is unnecessary. The SDLC owns planning, delegates delivery to `executor` and independent judgments to a fresh `checker`. For independent repair findings it may delegate bounded fan-out to `10-todo`, whose leaf executors report back to the SDLC. A recipe invoked inside an agent never spawns again. +- An agent invokes only the recipe skills listed under `# Skills you may invoke`, by canonical `/plugin:folder` address, never an orchestrator skill, and never reads a skill's files. +- An agent never delegates flow work to another agent. It may spawn a read-only recon helper (for example `Explore`) that mutates and spawns nothing, so the write path stays two layers deep and delegation cannot cycle. ## πŸ”— Capability addressing -Address a capability only where the dispatch is declared: a router's `## Actions` table, an agent's `# Skills you may invoke` list. Everywhere else, name the concept the capability owns, never the skill that owns it. +Address a capability only where dispatch is declared: a router's `## Actions` table, an agent's `# Skills you may invoke` list. Elsewhere, name the concept, never the owning skill. -Recipe skills never hardcode a sibling provider. They discover cross-plugin capabilities at runtime through description matching. Agent permission lists and orchestration references are responsibility maps, so they name the current provider with its canonical `/plugin:folder` or `@plugin:agent` address. The orchestrator must verify that provider is installed before calling it. +Recipe skills never hardcode a sibling provider; they discover cross-plugin capabilities at runtime by description matching. Agent permission lists and orchestration references are responsibility maps, so they name the current provider by canonical `/plugin:folder` or `@plugin:agent` address, and the orchestrator verifies it is installed before calling it. This keeps recipe plugins swappable and orchestration handoffs auditable. -Two paths are exempt, both in `isExemptFromOrthogonality`: +Exempt, both in `isExemptFromOrthogonality`: - `plugins/aidd-orchestrator/**`, whose references are responsibility maps. -- `plugins/aidd-context/skills/00-onboard/**`, whose menus name addresses a person types. Temporary: it ends when that skill resolves its providers at runtime. - -This distinction keeps recipe plugins swappable while making orchestration handoffs explicit and auditable. +- `plugins/aidd-context/skills/00-onboard/**`, whose menus name addresses a person types. Temporary, until that skill resolves providers at runtime. ## πŸ”Ž See also diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index f05fa09dd..e150d30e7 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -2,47 +2,28 @@ # aidd-telemetry -Know what a piece of work cost: which skill, which step and which task spent the tokens. +Measure tokens by skill, step and task. -> Status: beta. Proven end to end on Claude Code; the other four tools are covered to the -> extent their own files allow. Off the curated install path until it has run on other -> people's machines. +> Beta. Proven end to end on Claude Code; other tools depend on their recorded data. +> Excluded from curated installation pending validation on other users' machines. ## What it is -Your provider can tell you a developer burned four million tokens on Tuesday. This plugin -tells you which skill spent them, on which task. +Reports attribute usage to work, with explicit evidence: -```text -period 2026-08-21 to 2026-08-21 +- `stated by the tool`: exact attribution. +- `from a journal interval`: inferred attribution. +- `unattributed`: neither source identifies a step; it does not mean no step ran. - sessions 1 - requests 3 - tokens 116,678 80% cache - cost amount unknown - - by step of tokens - aidd-ui:01-hello 67% 78,188 tokens stated by the tool - aidd-ui:01-hello 33% 38,490 tokens from a journal interval -``` - -Every figure says how it was attributed. `stated by the tool` is exact, `from a journal -interval` is an inference, `unattributed` means neither source could say β€” never "no step -ran". An unknown is named, never shown as a zero, and no figure is in currency: pricing -tokens is a separate service's job. +Unknown values remain unknown, never zero. Reports count tokens; a separate service prices them. ## Why it exists -A provider meters an account. Only the framework knows its own units of work β€” the skill, -the step, the task, the flow β€” so only it can say what one piece of work cost. - -Three things it never does: it never sends anything anywhere, it never stores a prompt, a -diff or a line of code, and it never records until you turn it on. +Providers meter accounts; AIDD identifies skills, steps, tasks and flows. Measurement is +opt-in, local, and excludes prompts, diffs and code. ## How it works -Two sources exist already. The plugin adds the one thing that joins them. - ```mermaid flowchart LR Tool["Your AI tool
(Claude Code, Codex, Copilot, OpenCode, Cursor)"] @@ -60,17 +41,16 @@ flowchart LR CLI -->|joins by session, keeps a record| Store --> Answer ``` -- **The hooks journal.** Every session appends one line per observation to - `aidd_docs/runs/__.jsonl`, git-ignored, never rewritten. No token, no - cost, no model lands there. -- **Your tool writes its own transcript**, in its own place and format. It holds the tokens - and knows nothing about skills. -- **`aidd telemetry report` joins the two**, by session, and keeps the result under - `~/.config/aidd/telemetry/`. The join cannot happen live: when a hook fires, the tokens - for that turn are not written yet. +- **Hooks** append one line per observation to git-ignored + `aidd_docs/runs/__.jsonl`. Journals are never rewritten and contain no + tokens, cost or model. +- **Tools** write transcripts in their own locations and formats: tokens without AIDD skills. +- **`aidd telemetry report`** joins both by session and stores results under + `~/.config/aidd/telemetry/`. Reporting cannot run live: hooks fire before turn tokens are + durably written. -Recording depends on nothing but `node`, so a session is measured whether or not `aidd` is -installed. Allowing and answering go through the CLI, so the figure is computed once. +Recording requires only `node`, even without `aidd`. Enabling measurement and computing +reports require the CLI. ## Getting started @@ -79,14 +59,13 @@ npm install -g @ai-driven-dev/cli aidd plugin install aidd-telemetry ``` -Then ask your AI tool for a skill. Each one stops with the reason if `aidd` does not answer, -rather than reporting an empty figure. +Ask your AI tool for a skill. Each stops with an explanation if `aidd` cannot answer. | Ask your tool for | It runs | You get | | --- | --- | --- | -| `00-init` | `aidd telemetry on`, then reads a run file back | measurement allowed for this project, and proof a session is journalled | -| `01-cost` | `aidd telemetry report` | what a period or one task consumed, by step, model, task, flow, tool or person | -| `02-check` | `aidd telemetry check` | whether the chain is actually recording, and what to fix if not | +| `00-init` | `aidd telemetry on`, then reads a run file back | project opt-in and recording proof | +| `01-cost` | `aidd telemetry report` | period or task usage by step, model, task, flow, tool or person | +| `02-check` | `aidd telemetry check` | recording status and required repairs | ## Coverage @@ -95,50 +74,40 @@ rather than reporting an empty figure. | **Claude Code** | βœ… proven on live sessions | βœ… stated by the tool, and by interval | βœ… | | **Codex** | βœ… on captured rollouts | βœ… by interval | βœ… | | **OpenCode** | βœ… | ❌ no skill call reaches its plugin | βœ… | -| **Copilot** | ⚠️ session total only, no per-request figure (one cumulative total at shutdown) | βœ… by interval | βœ… | +| **Copilot** | ⚠️ session total only, no per-request figure (cumulative at shutdown) | βœ… by interval | βœ… | | **Cursor** | ❌ no token count in any file it writes | βœ… | βœ… | -A limit a reader has to look up gets read as a zero, so each one is named here: - -- **Codex needs one interactive approval.** Its hook trust is per entry and a headless run - never sees the prompt, so a Codex session journals nothing until someone approves once. -- **OpenCode V1 can omit the session announcement.** The first journalable event opens - an unannounced session. Without a known session directory, it uses the plugin's startup - directory, which can be wrong for a server serving several projects. V2 session events - provide their own directory; tool completion and execution completion reach the journal - through the V2 event subscription. The adapter targets V1 1.18.29 or later and V2; - older V1 releases do not support its default plugin definition. -- **OpenCode never names a step.** Its plugin forwards task paths and nothing else, so no - skill invocation reaches the journal and every OpenCode request is unattributed by step. -- **These are raw counters, not your tool's usage screen.** A vendor's page weights a cached - token by what it charges for it; these are the counts the tool wrote down. The two - disagree on cache lines by construction, and neither is wrong. -- **A period means when the work ran**, not when it was billed, and nothing reconstructs - work done before you turned measurement on. +- **Codex:** approve each hook entry interactively. Headless runs cannot show the trust + prompt; sessions record nothing until approval. +- **OpenCode:** supports V1 β‰₯ 1.18.29 and V2; older V1 lacks the default plugin definition. + OpenCode V1 can omit the session announcement; the first journalable event then opens it. + Without a known session directory, it uses the plugin's startup directory, which can be + wrong for a server serving several projects. + V2 supplies session directories and journals tool and execution completions through its + event subscription. +- **OpenCode steps:** only task paths reach the journal, never skill calls. Every request + remains unattributed by step. +- **Counts:** raw transcript tokens differ from vendor usage screens, which weight cached + tokens by price. Neither count is wrong. +- **Periods:** work time, not billing time. Activity before opt-in cannot be reconstructed. ## Privacy -- **Nothing leaves the machine.** Every code path that once could is deleted. On a machine - where an older version configured an export endpoint, `aidd telemetry check` and - `aidd telemetry off` both detect it and name what to remove by hand. -- **No prompt, no code, no diff.** The stored shape is an allowlist, field by field, in +- **No export.** `aidd telemetry check` and `aidd telemetry off` detect legacy + export endpoints and identify required manual removal. +- **No prompts, code or diffs.** Stored fields follow [the record contract](../../aidd_docs/product/metrics-contract.md). -- **The switch is a file you commit or do not**, per project (`.aidd/config.json`). Once - committed on, it applies to everyone who clones. Refuse it for yourself alone with - `AIDD_TELEMETRY=0`, which overrides the file unconditionally. -- **`off` keeps what you measured**; `aidd telemetry forget` removes it β€” this project's - journal, this machine's records and its identity file β€” and removes nothing without - `--yes`. -- **Your identity is yours to attach**, through `aidd telemetry identity`. To share figures - across a team, point `AIDD_TELEMETRY_DIR` at a shared directory β€” never - `AIDD_USER_CONFIG_DIR`, which also relocates `auth.json` and its GitHub token. +- **Project opt-in:** committing `.aidd/config.json` with measurement enabled affects all + clones. `AIDD_TELEMETRY=0` unconditionally overrides it for yourself. +- **Retention:** `off` keeps records. `aidd telemetry forget` removes the project's journal, + this machine's records and identity file; deletion requires `--yes`. +- **Identity:** attach it optionally through `aidd telemetry identity`. Share figures using + `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`: the latter also relocates `auth.json` + and its GitHub token. ## Where things are written down -- [`aidd_docs/runs/README.md`](../../aidd_docs/runs/README.md): what the journal records, - and what it deliberately does not. +- [`aidd_docs/runs/README.md`](../../aidd_docs/runs/README.md): journal contract. - [`cost-report-contract.md`](../../aidd_docs/product/cost-report-contract.md): the object - `report --json` prints, and the `backlog-link.json` a task folder may carry to say which - backlog item it delivers. -- [`metrics-contract.md`](../../aidd_docs/product/metrics-contract.md): one stored line, for - a service that prices them. + printed by `report --json`; optional `backlog-link.json` maps a task to its backlog item. +- [`metrics-contract.md`](../../aidd_docs/product/metrics-contract.md): stored records for pricing. From 52e7e88f1fe2e44cbd9cb2aee7197ce699e10de2 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 7 Oct 2026 23:30:58 +0200 Subject: [PATCH 17/23] docs(framework): keep architecture focused on boundaries Link implementation protocols to canonical sources instead of duplicating them in the framework overview. Refs #953. AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .claude/rules/01-standards/1-documentation.md | 1 + docs/ARCHITECTURE.md | 40 +++++-------------- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/.claude/rules/01-standards/1-documentation.md b/.claude/rules/01-standards/1-documentation.md index 400ff2665..6c4de0fdf 100644 --- a/.claude/rules/01-standards/1-documentation.md +++ b/.claude/rules/01-standards/1-documentation.md @@ -14,6 +14,7 @@ paths: - Revise existing documents individually. - Exclude historical, generated, and fixture content. - Preserve contracts, decisions, limits, and procedures. +- Keep architecture to boundaries and invariants. - State actions and verification commands. - Link canonical sources for supporting details. - Keep memory's operational safeguards self-contained. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9245d7e11..605c4582c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -52,28 +52,17 @@ A hook is authored once with `${CLAUDE_PLUGIN_ROOT}`; the installer rewrites it | Cursor | declared | `./` | Own hook format: the converter rewrites the root to a plugin-relative path before token substitution. No plugin hook observed firing headless; what registers a plugin in Cursor's plugin directory is unknown | | OpenCode | no, by a second route | β€” | See below | -OpenCode runs no declarative hook. Instead, `opencode-hooks-bridge.ts` translates them into `-hooks.js` modules for `SessionStart`, `Stop` and `PostToolUse`. Telemetry ships its own adapter, `plugins/aidd-telemetry/hooks/opencode-plugin.js`, because its journal uses a different stdin dialect. Both export a default definition: `server` for V1 1.18.29 or later, `setup` for V2. +OpenCode uses JS adapters. The CLI owns the shared host protocol; plugins own payload mapping ([telemetry adapter](../plugins/aidd-telemetry/hooks/opencode-plugin.js)). Its helper, `.opencode/hooks/opencode-events.js`, is delivered once outside plugin discovery and tracked as a tool file. Backfill creates only missing helpers, without rewriting user configuration or claiming ownership of existing untracked helpers. Generic `SessionStart` hooks run at host initialization and must be idempotent; telemetry follows actual session events. -- **V1** delivers `session.idle` and completed `message.part.updated` tool parts to an `event` hook. Each plugin keeps its own V1 payload adapter. -- **V2** uses one shared module, embedded and delivered by the CLI at `.opencode/hooks/opencode-events.js`, outside plugin discovery. Each plugin imports it for V2. It subscribes to `data`-based events over an abortable stream, correlates tool name and input with tool success, and maps execution terminals to turn-end. A shutdown interruption leaves the turn open, since OpenCode resumes it. -- The bridge fires its idempotent `SessionStart` hooks when either host initializes the module; telemetry records sessions separately from session events. -- Translation emits the module once and tool installation tracks it as a tool file. Plugin install and update backfill a missing module without rewriting user configuration or claiming an existing untracked one. +Entrypoints, event mapping and lifecycle belong to the [hook bridge](../cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts) and [V2 adapter](../cli/assets/configs/opencode/opencode-events.js.txt). Supported versions and limitations are in [telemetry coverage](../plugins/aidd-telemetry/README.md#coverage). A tool that runs no hook says why, and an install that carries one reports what was skipped. ## βš–οΈ What runs on every event, and what runs when someone asks -`PostToolUse` fires on every tool call, so the code on that path is a bundled hook, not the CLI. The line is therefore **not** "plugin or CLI" but what the code answers to: +Event hooks run dependency-free Node to keep repeated callbacks lightweight. Capabilities that answer a person or skill live in the CLI, with one implementation. -| | Triggered by | Latency | Runs as | -| --- | --- | --- | --- | -| Observing | each tool event | must not be felt | plain Node in `hooks/`, no install, no dependency | -| Answering | a person or a skill, once | irrelevant | the `aidd` CLI | - -- A capability that answers belongs in the CLI even when a plugin asks for it: one implementation cannot drift from a copy of itself. -- A skill that needs the CLI must say so when it is absent, never silently do nothing. The wording is pinned across those skills by `scripts/__tests__/telemetry-cli-required.test.js`. - -Telemetry requires `node` to measure and `aidd` to answer. +A CLI-backed skill must explicitly report a missing `aidd`, never silently do nothing; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) pins that behavior. ## 🧠 Plugin concerns and layers @@ -95,7 +84,7 @@ Off the curated install path: - `aidd-ui`: alpha, smoke-test only. - `aidd-qa`: new, until proven outside this repository. Validates observable behavior against acceptance criteria and records browser evidence, hence Execution. -- `aidd-telemetry`: beta, opt-in. A repository must commit `.aidd/config.json` with `telemetry.enabled: true`. Each session appends one JSON object per line to its own git-ignored `aidd_docs/runs/__.jsonl`, created on demand; the directory's presence is a location, not a permission. Lines are append-only: `session_start`, `turn_end`, `file_written`, `step_start`, `step_end`, `task_declared`, `unrecognised_payload`. Paths are repository-relative, never a task_id: task identity is derived by the reader. It records no measurement; tokens and cost are joined later from the provider's telemetry. +- `aidd-telemetry`: beta, opt-in through committed `.aidd/config.json` with `telemetry.enabled: true`; a journal directory grants no permission. Session journals are git-ignored, append-only observations. Readers derive task identity and join provider measurements. Format and events belong to the [journal contract](../aidd_docs/runs/README.md). **Observation** writes only *about* the other layers, never the artifact it describes, and nothing may depend on it. @@ -113,20 +102,9 @@ A skill's `SKILL.md` is a manifest plus a router, loaded on invocation; its body title: skill router pattern --- flowchart LR - User["User: '/skill-name'"] - Skill["/skill-name"] - Action1["actions/01-step.md"] - Action2["actions/02-step.md"] - ActionN["actions/NN-step.md"] - Out["Outputs: files, labels, PRs, audit logs"] - - User --> Skill - Skill -->|"choose 1..N"| Action1 - Skill -->|"choose 1..N"| Action2 - Skill -->|"choose 1..N"| ActionN - Action1 --> Out - Action2 --> Out - ActionN --> Out + User["User: '/skill-name'"] --> Skill["SKILL.md router"] + Skill -->|select action| Action["actions/NN-step.md"] + Action --> Out["Outputs: files, labels, PRs, audit logs"] ``` Recipe skills route to self-contained actions with inputs, outputs, process steps and tests. An orchestrator with no domain logic may instead route through numbered reference protocols that define handoffs and delegate to capabilities discovered at runtime. @@ -141,7 +119,7 @@ A skill never links outside itself (`scripts/__tests__/a-skill-links-only-inside Choose by context, not complexity: keep the work visible to the caller β†’ skill; isolate it and take only the result β†’ agent. - **Only the high-level orchestrator authorizes spawning.** A recipe skill runs in the caller's context. A bounded fan-out capability may spawn leaf agents only when the orchestrator explicitly delegates that and keeps routing ownership. -- An orchestrator spawns each isolated step as a leaf agent running a recipe, or runs the recipe itself when isolation is unnecessary. The SDLC owns planning, delegates delivery to `executor` and independent judgments to a fresh `checker`. For independent repair findings it may delegate bounded fan-out to `10-todo`, whose leaf executors report back to the SDLC. A recipe invoked inside an agent never spawns again. +- An orchestrator runs recipes directly or isolates them in leaf agents. The SDLC owns planning; delivery, independent review and bounded repair follow its [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) contracts. Isolated recipes never spawn flow agents. - An agent invokes only the recipe skills listed under `# Skills you may invoke`, by canonical `/plugin:folder` address, never an orchestrator skill, and never reads a skill's files. - An agent never delegates flow work to another agent. It may spawn a read-only recon helper (for example `Explore`) that mutates and spawns nothing, so the write path stays two layers deep and delegation cannot cycle. From 551ce971bea693cab37e759e8c298e64765e1a7a Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 8 Oct 2026 06:36:05 +0200 Subject: [PATCH 18/23] docs(framework): clarify architecture and telemetry for all readers Explain core concepts visually and keep operational safeguards visible while linking detailed implementation contracts. Refs #953. AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .claude/rules/01-standards/1-documentation.md | 5 +- docs/ARCHITECTURE.md | 120 ++++++++---------- plugins/aidd-telemetry/README.md | 50 +++----- 3 files changed, 76 insertions(+), 99 deletions(-) diff --git a/.claude/rules/01-standards/1-documentation.md b/.claude/rules/01-standards/1-documentation.md index 6c4de0fdf..a4483bd8d 100644 --- a/.claude/rules/01-standards/1-documentation.md +++ b/.claude/rules/01-standards/1-documentation.md @@ -14,7 +14,8 @@ paths: - Revise existing documents individually. - Exclude historical, generated, and fixture content. - Preserve contracts, decisions, limits, and procedures. -- Keep architecture to boundaries and invariants. +- Architecture explains components, boundaries, flows, invariants. +- README explains purpose, usage, and limits. - State actions and verification commands. - Link canonical sources for supporting details. - Keep memory's operational safeguards self-contained. @@ -24,7 +25,7 @@ paths: - Drop measured timings, ratios, and comparisons. - Keep rationale; never invent performance requirements. - Retain required versions, constants, and examples. -- Add examples only to clarify behavior. +- Use examples and diagrams only to clarify. - Preserve headings, anchors, and generator markers. - Read back; check headings, contracts, and commands. - Verify links before declaring completion. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 605c4582c..921bb59f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,49 +1,55 @@ # πŸ›οΈ Architecture -How the AI-Driven Dev Framework composes inside Claude Code. +The framework packages AI-assisted work into plugins, each owning one concern. Skills guide work, agents isolate it, and hooks react to tool events. ## πŸ—ΊοΈ High-level +Plugins use Claude Code's native format. The CLI translates and installs them for supported AI tools; unavailable surfaces are reported, not silently ignored. + ```mermaid flowchart LR - Editor["Claude Code session"] -->|"marketplace add"| Manifest[".claude-plugin/marketplace.json"] - Manifest -->|lists| Plugins["plugins/*"] - Editor -->|"plugin install"| Plugins - Plugins -->|ships| Surfaces["skills Β· agents Β· commands Β· hooks Β· rules Β· .mcp.json"] - Editor -->|invokes| Surfaces + Catalog["Marketplace catalog"] --> Plugins["Plugins: one concern each"] + Plugins -->|native install| Claude["Claude Code"] + Plugins --> CLI["CLI: translate and install"] + CLI --> Tools["Supported AI tools"] ``` ## 🧩 Anatomy of a plugin -```txt +A plugin requires a manifest and skills. Agents, commands, hooks, rules and MCP servers are optional. + +
+Package and validation reference + +```text plugins// -β”œβ”€β”€ .claude-plugin/plugin.json # manifest (name, version, description, skills[], $schema) +β”œβ”€β”€ .claude-plugin/plugin.json β”œβ”€β”€ README.md Β· CATALOG.md Β· CHANGELOG.md β”œβ”€β”€ skills/-/ -β”‚ β”œβ”€β”€ SKILL.md # router: frontmatter, flow, actions table, transversal rules -β”‚ β”œβ”€β”€ actions/ # the atomic steps the router dispatches to -β”‚ β”œβ”€β”€ assets/ # templates and static files -β”‚ └── references/ # one responsibility per file, linked from this skill only -β”œβ”€β”€ agents/ Β· commands/ Β· hooks/hooks.json Β· rules/ Β· .mcp.json (all optional) +β”‚ β”œβ”€β”€ SKILL.md # manifest and router +β”‚ β”œβ”€β”€ actions/ # self-contained steps +β”‚ β”œβ”€β”€ assets/ # templates and static files +β”‚ └── references/ # local protocols +└── agents/ Β· commands/ Β· hooks/ Β· rules/ Β· .mcp.json ``` -Only `skills/` and the manifest are universal; the rest is optional. +[Plugin](https://www.schemastore.org/claude-code-plugin-manifest.json) and [marketplace](https://www.schemastore.org/claude-code-marketplace.json) manifests are validated by `lefthook` and the `validate` workflow. Plugin tests belong in `scripts/__tests__/`, outside shipped trees: `hooks/` is copied recursively into user projects. -A plugin never contains its own tests: the build copies `hooks/` recursively into every user project. Tests for a bundled script live in `scripts/__tests__/`. - -`plugin.json` and `marketplace.json` are validated against their [plugin](https://www.schemastore.org/claude-code-plugin-manifest.json) and [marketplace](https://www.schemastore.org/claude-code-marketplace.json) schemas, in the `lefthook` pre-commit hook and in the `validate` workflow. +
## πŸͺ Bundled hooks -Declared in `plugins//hooks/hooks.json`. They run Node, so users need `node` on their `PATH`: - +Hooks declare deterministic lifecycle work in `hooks/hooks.json` and require `node` on `PATH`. | Plugin | Event | Runs | Purpose | | ---------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------- | | `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | | `aidd-telemetry` | `SessionStart` Β· `Stop` Β· `PostToolUse` | `hooks/journal.cjs` | Journal every session so a unit of work can be tied to its cost | +OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization; telemetry follows actual sessions. -A hook is authored once with `${CLAUDE_PLUGIN_ROOT}`; the installer rewrites it to the target tool's spelling: +
+Tool compatibility and adapter contracts +Hooks are authored with `${CLAUDE_PLUGIN_ROOT}`; the installer translates the root for each tool. | Tool | Runs bundled hooks | Plugin root | Notes | | -------------- | --------------------- | ---------------------- | ----- | | Claude Code | yes | `${CLAUDE_PLUGIN_ROOT}` | Authoring spelling, nothing substituted | @@ -51,23 +57,17 @@ A hook is authored once with `${CLAUDE_PLUGIN_ROOT}`; the installer rewrites it | GitHub Copilot | yes | `${PLUGIN_ROOT}` | Declared, never observed running | | Cursor | declared | `./` | Own hook format: the converter rewrites the root to a plugin-relative path before token substitution. No plugin hook observed firing headless; what registers a plugin in Cursor's plugin directory is unknown | | OpenCode | no, by a second route | β€” | See below | +Runtime contracts: [hook bridge](../cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts), [shared V2 adapter](../cli/assets/configs/opencode/opencode-events.js.txt), [telemetry payload adapter](../plugins/aidd-telemetry/hooks/opencode-plugin.js). The shared helper is `.opencode/hooks/opencode-events.js`. [Telemetry coverage](../plugins/aidd-telemetry/README.md#coverage) states supported versions and limitations. Unsupported hooks and skipped installation surfaces must be reported. -OpenCode uses JS adapters. The CLI owns the shared host protocol; plugins own payload mapping ([telemetry adapter](../plugins/aidd-telemetry/hooks/opencode-plugin.js)). Its helper, `.opencode/hooks/opencode-events.js`, is delivered once outside plugin discovery and tracked as a tool file. Backfill creates only missing helpers, without rewriting user configuration or claiming ownership of existing untracked helpers. Generic `SessionStart` hooks run at host initialization and must be idempotent; telemetry follows actual session events. - -Entrypoints, event mapping and lifecycle belong to the [hook bridge](../cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts) and [V2 adapter](../cli/assets/configs/opencode/opencode-events.js.txt). Supported versions and limitations are in [telemetry coverage](../plugins/aidd-telemetry/README.md#coverage). - -A tool that runs no hook says why, and an install that carries one reports what was skipped. +
## βš–οΈ What runs on every event, and what runs when someone asks -Event hooks run dependency-free Node to keep repeated callbacks lightweight. Capabilities that answer a person or skill live in the CLI, with one implementation. - -A CLI-backed skill must explicitly report a missing `aidd`, never silently do nothing; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) pins that behavior. +Repeated event work runs as dependency-free Node hooks. Queries and reports use one CLI implementation, preventing duplicated logic. CLI-backed skills must explicitly report a missing `aidd`; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) enforces this. ## 🧠 Plugin concerns and layers -Every capability lives in exactly one plugin, chosen by **concern**. This taxonomy is only implicit in each `plugin.json`, so it is canonical here. - +This table is the canonical placement map: put a capability in its owning concern and delegate to it. | Plugin | Concern | Layer | | ------------------- | -------------------- | ------------ | | `aidd-context` | Knowledge production | Knowledge | @@ -79,63 +79,51 @@ Every capability lives in exactly one plugin, chosen by **concern**. This taxono | `aidd-ui` 🚧 | UI/UX design | Execution | | `aidd-telemetry` πŸ§ͺ | Measurement | Observation | | `aidd-qa` πŸ†• | Acceptance QA | Execution | +- **Knowledge** produces context and specifications, never writes or runs application source. Context bootstrap creates no `package.json`. +- **Execution** changes or validates source. **External** owns version control. +- **Coordination** sequences work through artifacts (for example `INSTALL.md`), keeping domain logic and artifact contracts with their owning plugins. Direct and orchestrated calls obey the same contracts; the backlog flow belongs to `aidd-orchestrator:02-backlog`. +- **Observation** records work without changing the artifacts it describes. No productive flow depends on it. -Off the curated install path: +`aidd-ui` is alpha and smoke-test only; `aidd-qa` requires validation outside this repository; `aidd-telemetry` is beta and opt-in. All remain outside curated installation. -- `aidd-ui`: alpha, smoke-test only. -- `aidd-qa`: new, until proven outside this repository. Validates observable behavior against acceptance criteria and records browser evidence, hence Execution. -- `aidd-telemetry`: beta, opt-in through committed `.aidd/config.json` with `telemetry.enabled: true`; a journal directory grants no permission. Session journals are git-ignored, append-only observations. Readers derive task identity and join provider measurements. Format and events belong to the [journal contract](../aidd_docs/runs/README.md). - -**Observation** writes only *about* the other layers, never the artifact it describes, and nothing may depend on it. - -- **Knowledge vs execution is a firewall.** Knowledge plugins produce artifacts you *read*; they never write or run application source. `aidd-context`'s bootstrap creates no `package.json`. Real code belongs to `aidd-dev` or an orchestrator's own setup actions. -- **Concern decides placement, not existence.** A missing capability goes in the plugin whose concern owns it, and the caller delegates. Never reimplement it in the caller. -- **Orchestration = sequencing across concerns** with little domain logic. Delegating one sub-step does not make a skill an orchestrator. The orchestrator owns only glue and hands off through a seam artifact, for example an `INSTALL.md` one plugin produces and another consumes. -- `aidd-orchestrator:02-backlog` owns the cross-artifact flow. Each artifact's contract stays in its `aidd-pm` skill, so a direct PM call follows the same rules as an orchestrated one. +Telemetry requires committed `.aidd/config.json` with `telemetry.enabled: true`; a directory grants no permission. Session journals are git-ignored, append-only observations. Readers derive task identity and join provider measurements. See the [journal contract](../aidd_docs/runs/README.md). ## πŸ”€ Skills are routers -A skill's `SKILL.md` is a manifest plus a router, loaded on invocation; its body picks the local action or orchestration protocol to run. +A skill's `SKILL.md` is loaded on invocation and selects its action or orchestration protocol. ```mermaid ---- -title: skill router pattern ---- flowchart LR - User["User: '/skill-name'"] --> Skill["SKILL.md router"] - Skill -->|select action| Action["actions/NN-step.md"] - Action --> Out["Outputs: files, labels, PRs, audit logs"] + Caller["User or agent"] --> Router["Skill router"] + Router -->|select| Action["Self-contained action"] + Action --> Result["Result or artifact"] ``` -Recipe skills route to self-contained actions with inputs, outputs, process steps and tests. An orchestrator with no domain logic may instead route through numbered reference protocols that define handoffs and delegate to capabilities discovered at runtime. +Actions define inputs, outputs, steps and checks. Orchestrators may instead route through reference protocols defining handoffs and discovered providers. -A skill never links outside itself (`scripts/__tests__/a-skill-links-only-inside-itself.test.js`): the tree ships flat, with the skill folder renamed `-`, or as a marketplace, and no relative path survives both. A bundled script is named plugin-relative in backticks, never linked. +Skills link only inside their own directory: flat distribution renames them `-`, while marketplace installation preserves the tree. Bundled scripts are named plugin-relative in backticks, never linked; the [portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) verifies this. ## πŸ€– Skills and agents -- A **skill** is a caller-agnostic recipe running in the invoker's context. -- An **agent** is an isolated executor with its own context, returning only a result. +Choose by context: a **skill** runs in its caller's context; an **agent** isolates work and returns a result. -Choose by context, not complexity: keep the work visible to the caller β†’ skill; isolate it and take only the result β†’ agent. +- Orchestrators retain routing ownership and authorize isolated work or bounded fan-out. Recipes never invent spawning; isolated recipes never delegate flow work. +- Agents invoke only their declared canonical recipe skills, never orchestrators, and never read skill files. +- Read-only reconnaissance may be delegated only to helpers that neither mutate nor spawn. The write path stays two layers deep, without delegation cycles. -- **Only the high-level orchestrator authorizes spawning.** A recipe skill runs in the caller's context. A bounded fan-out capability may spawn leaf agents only when the orchestrator explicitly delegates that and keeps routing ownership. -- An orchestrator runs recipes directly or isolates them in leaf agents. The SDLC owns planning; delivery, independent review and bounded repair follow its [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) contracts. Isolated recipes never spawn flow agents. -- An agent invokes only the recipe skills listed under `# Skills you may invoke`, by canonical `/plugin:folder` address, never an orchestrator skill, and never reads a skill's files. -- An agent never delegates flow work to another agent. It may spawn a read-only recon helper (for example `Explore`) that mutates and spawns nothing, so the write path stays two layers deep and delegation cannot cycle. +The SDLC owns planning. Its [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) contracts govern direct execution, leaf executors, independent judgment and bounded repair. ## πŸ”— Capability addressing -Address a capability only where dispatch is declared: a router's `## Actions` table, an agent's `# Skills you may invoke` list. Elsewhere, name the concept, never the owning skill. - -Recipe skills never hardcode a sibling provider; they discover cross-plugin capabilities at runtime by description matching. Agent permission lists and orchestration references are responsibility maps, so they name the current provider by canonical `/plugin:folder` or `@plugin:agent` address, and the orchestrator verifies it is installed before calling it. This keeps recipe plugins swappable and orchestration handoffs auditable. +Dispatch tables (`## Actions`) and agent permission lists (`# Skills you may invoke`) use canonical addresses. Elsewhere, name the responsibility. -Exempt, both in `isExemptFromOrthogonality`: +Recipes discover cross-plugin providers by description rather than hardcoding siblings. Orchestration references and agent permissions name the current provider as `/plugin:folder` or `@plugin:agent`; orchestrators verify installation before dispatch. -- `plugins/aidd-orchestrator/**`, whose references are responsibility maps. -- `plugins/aidd-context/skills/00-onboard/**`, whose menus name addresses a person types. Temporary, until that skill resolves providers at runtime. +`isExemptFromOrthogonality` exempts orchestration responsibility maps (`plugins/aidd-orchestrator/**`) and onboarding menus (`plugins/aidd-context/skills/00-onboard/**`). The onboarding exemption ends when it discovers providers at runtime. ## πŸ”Ž See also -- [`CREATE_PLUGIN.md`](CREATE_PLUGIN.md) - build and publish your own plugin. -- [`GLOSSARY.md`](GLOSSARY.md) - terminology used across the framework. -- [`../CONTRIBUTING.md`](../CONTRIBUTING.md) - contribution flow. +- [Create a plugin](CREATE_PLUGIN.md): authoring and publication. +- [Glossary](GLOSSARY.md): terminology. +- [CLI architecture](../cli/ARCHITECTURE.md): translation and installation. +- [Contributing](../CONTRIBUTING.md): contribution flow. diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index e150d30e7..ba3dc69f9 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -2,14 +2,15 @@ # aidd-telemetry -Measure tokens by skill, step and task. +See which skills and tasks consume tokens, then choose where to improve your workflow. > Beta. Proven end to end on Claude Code; other tools depend on their recorded data. > Excluded from curated installation pending validation on other users' machines. ## What it is -Reports attribute usage to work, with explicit evidence: +Usage reports group work by step, model, task, flow, tool or person. Each attribution states +its evidence: - `stated by the tool`: exact attribution. - `from a journal interval`: inferred attribution. @@ -19,38 +20,24 @@ Unknown values remain unknown, never zero. Reports count tokens; a separate serv ## Why it exists -Providers meter accounts; AIDD identifies skills, steps, tasks and flows. Measurement is -opt-in, local, and excludes prompts, diffs and code. +Provider totals describe accounts. AIDD connects those counts to your units of work. ## How it works ```mermaid flowchart LR - Tool["Your AI tool
(Claude Code, Codex, Copilot, OpenCode, Cursor)"] - Hooks["Plugin hooks
node, no dependency"] - Journal["Run journal
aidd_docs/runs/*.jsonl
which skill ran, when, which task folder"] - Transcript["The tool's own transcript
tokens and model, no AIDD knowledge"] - CLI["aidd telemetry report"] - Store["Figures
~/.config/aidd/telemetry/"] - Answer["tokens per step, task, flow, model, person"] - - Tool -->|"SessionStart, PostToolUse, Stop"| Hooks -->|append one line| Journal - Tool -->|writes itself| Transcript - Journal --> CLI - Transcript --> CLI - CLI -->|joins by session, keeps a record| Store --> Answer + Journal["Hook journal: work observed"] --> Report["aidd telemetry report: join by session"] + Transcript["Tool transcript: tokens and model"] --> Report + Report --> Results["Local usage by step, task, flow, model, tool or person"] ``` -- **Hooks** append one line per observation to git-ignored - `aidd_docs/runs/__.jsonl`. Journals are never rewritten and contain no - tokens, cost or model. -- **Tools** write transcripts in their own locations and formats: tokens without AIDD skills. -- **`aidd telemetry report`** joins both by session and stores results under - `~/.config/aidd/telemetry/`. Reporting cannot run live: hooks fire before turn tokens are - durably written. +Hooks append one line per observation to git-ignored +`aidd_docs/runs/__.jsonl`, never rewriting it or recording tokens, cost +or model. Tools write their own transcripts without AIDD skill knowledge. Reports store +joined results under `~/.config/aidd/telemetry/`. -Recording requires only `node`, even without `aidd`. Enabling measurement and computing -reports require the CLI. +Recording requires only `node`; enabling and reporting require `aidd`. Report after the +turn: hooks fire before its tokens are durably written. ## Getting started @@ -59,12 +46,13 @@ npm install -g @ai-driven-dev/cli aidd plugin install aidd-telemetry ``` -Ask your AI tool for a skill. Each stops with an explanation if `aidd` cannot answer. +Enable measurement, work in a session, then report and verify. Use these skills or their +CLI commands; skills stop with an explanation if `aidd` cannot answer. | Ask your tool for | It runs | You get | | --- | --- | --- | | `00-init` | `aidd telemetry on`, then reads a run file back | project opt-in and recording proof | -| `01-cost` | `aidd telemetry report` | period or task usage by step, model, task, flow, tool or person | +| `01-cost` | `aidd telemetry report` | period or task usage | | `02-check` | `aidd telemetry check` | recording status and required repairs | ## Coverage @@ -87,13 +75,13 @@ Ask your AI tool for a skill. Each stops with an explanation if `aidd` cannot an event subscription. - **OpenCode steps:** only task paths reach the journal, never skill calls. Every request remains unattributed by step. -- **Counts:** raw transcript tokens differ from vendor usage screens, which weight cached - tokens by price. Neither count is wrong. +- **Counts:** raw tokens differ from vendor usage screens, which weight cached tokens by + price. Neither count is wrong. - **Periods:** work time, not billing time. Activity before opt-in cannot be reconstructed. ## Privacy -- **No export.** `aidd telemetry check` and `aidd telemetry off` detect legacy +- **Local, opt-in recording; no export.** `aidd telemetry check` and `aidd telemetry off` detect legacy export endpoints and identify required manual removal. - **No prompts, code or diffs.** Stored fields follow [the record contract](../../aidd_docs/product/metrics-contract.md). From 4eca8883dea1ad729cbdff4965725cdfa5a7f64a Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 8 Oct 2026 06:58:06 +0200 Subject: [PATCH 19/23] docs(framework): organize documentation by section purpose Keep responsibilities, usage, interpretation and safeguards in their own sections while preserving linked anchors and contracts. Refs #953. AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .claude/rules/01-standards/1-documentation.md | 7 +- docs/ARCHITECTURE.md | 140 ++++++++++-------- plugins/aidd-telemetry/README.md | 87 +++++------ 3 files changed, 127 insertions(+), 107 deletions(-) diff --git a/.claude/rules/01-standards/1-documentation.md b/.claude/rules/01-standards/1-documentation.md index a4483bd8d..c599c90c3 100644 --- a/.claude/rules/01-standards/1-documentation.md +++ b/.claude/rules/01-standards/1-documentation.md @@ -16,6 +16,9 @@ paths: - Preserve contracts, decisions, limits, and procedures. - Architecture explains components, boundaries, flows, invariants. - README explains purpose, usage, and limits. +- Give each section a clear purpose and descriptive heading. +- Put only relevant information under that heading; place each fact once per document. +- Group related sections under a shared parent; avoid headings that add no structure. - State actions and verification commands. - Link canonical sources for supporting details. - Keep memory's operational safeguards self-contained. @@ -26,6 +29,6 @@ paths: - Keep rationale; never invent performance requirements. - Retain required versions, constants, and examples. - Use examples and diagrams only to clarify. -- Preserve headings, anchors, and generator markers. -- Read back; check headings, contracts, and commands. +- Preserve linked anchors and generator markers when restructuring. +- Read back; check section scope, contracts, and commands. - Verify links before declaring completion. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 921bb59f0..e5cbe7d6f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,8 +1,73 @@ -# πŸ›οΈ Architecture +# Architecture The framework packages AI-assisted work into plugins, each owning one concern. Skills guide work, agents isolate it, and hooks react to tool events. -## πŸ—ΊοΈ High-level +## Responsibilities + +This table is the canonical placement map: put a capability in its owning concern and delegate to it. +| Plugin | Concern | Layer | +| ------------------- | -------------------- | ------------ | +| `aidd-context` | Knowledge production | Knowledge | +| `aidd-pm` | Product management | Knowledge | +| `aidd-refine` | Meta-cognition | Knowledge | +| `aidd-dev` | Code transformation | Execution | +| `aidd-vcs` | Version control | External | +| `aidd-orchestrator` | Orchestration | Coordination | +| `aidd-ui` 🚧 | UI/UX design | Execution | +| `aidd-telemetry` πŸ§ͺ | Measurement | Observation | +| `aidd-qa` πŸ†• | Acceptance QA | Execution | + +### Layer boundaries + +- **Knowledge** produces context and specifications, never writes or runs application source. Context bootstrap creates no `package.json`. +- **Execution** changes or validates source. **External** owns version control. +- **Coordination** sequences work through artifacts (for example `INSTALL.md`), keeping domain logic and artifact contracts with their owning plugins. Direct and orchestrated calls obey the same contracts; the backlog flow belongs to `aidd-orchestrator:02-backlog`. +- **Observation** records work without changing the artifacts it describes. No productive flow depends on it. + +### Measurement boundary + +Telemetry requires committed `.aidd/config.json` with `telemetry.enabled: true`; a directory grants no permission. Session journals are git-ignored, append-only observations. Readers derive task identity and join provider measurements. See the [journal contract](../aidd_docs/runs/README.md). + +## Execution model + +### Skills and actions + +A skill's `SKILL.md` is loaded on invocation and selects its action or orchestration protocol. + +```mermaid +flowchart LR + Caller["User or agent"] --> Router["Skill router"] + Router -->|select| Action["Self-contained action"] + Action --> Result["Result or artifact"] +``` + +Actions define inputs, outputs, steps and checks. Orchestrators may instead route through reference protocols defining handoffs and discovered providers. + +### Agents and delegation + +Choose by context: a **skill** runs in its caller's context; an **agent** isolates work and returns a result. + +- Orchestrators retain routing ownership and authorize isolated work or bounded fan-out. Recipes never invent spawning; isolated recipes never delegate flow work. +- Agents invoke only their declared canonical recipe skills, never orchestrators, and never read skill files. +- Read-only reconnaissance may be delegated only to helpers that neither mutate nor spawn. The write path stays two layers deep, without delegation cycles. + +The SDLC owns planning. Its [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) contracts govern direct execution, leaf executors, independent judgment and bounded repair. + +### πŸͺ Bundled hooks + +Hooks declare deterministic lifecycle work in `hooks/hooks.json`. Repeated event work runs as dependency-free Node scripts and requires `node` on `PATH`. +| Plugin | Event | Runs | Purpose | +| ---------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------- | +| `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | +| `aidd-telemetry` | `SessionStart` Β· `Stop` Β· `PostToolUse` | `hooks/journal.cjs` | Journal every session so a unit of work can be tied to its cost | + +### CLI queries + +Queries and reports use one CLI implementation, preventing duplicated logic. CLI-backed skills must explicitly report a missing `aidd`; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) enforces this. + +## Distribution + +### Installation routes Plugins use Claude Code's native format. The CLI translates and installs them for supported AI tools; unavailable surfaces are reported, not silently ignored. @@ -14,7 +79,9 @@ flowchart LR CLI --> Tools["Supported AI tools"] ``` -## 🧩 Anatomy of a plugin +`aidd-ui` is alpha and smoke-test only; `aidd-qa` requires validation outside this repository; `aidd-telemetry` is beta and opt-in. All remain outside curated installation. + +### 🧩 Anatomy of a plugin A plugin requires a manifest and skills. Agents, commands, hooks, rules and MCP servers are optional. @@ -37,13 +104,12 @@ plugins// -## πŸͺ Bundled hooks +### Skill portability + +Skills link only inside their own directory: flat distribution renames them `-`, while marketplace installation preserves the tree. Bundled scripts are named plugin-relative in backticks, never linked; the [portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) verifies this. + +### Hook adapters -Hooks declare deterministic lifecycle work in `hooks/hooks.json` and require `node` on `PATH`. -| Plugin | Event | Runs | Purpose | -| ---------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------- | -| `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | -| `aidd-telemetry` | `SessionStart` Β· `Stop` Β· `PostToolUse` | `hooks/journal.cjs` | Journal every session so a unit of work can be tied to its cost | OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization; telemetry follows actual sessions.
@@ -61,59 +127,7 @@ Runtime contracts: [hook bridge](../cli/src/contexts/tools/domain/profiles/openc
-## βš–οΈ What runs on every event, and what runs when someone asks - -Repeated event work runs as dependency-free Node hooks. Queries and reports use one CLI implementation, preventing duplicated logic. CLI-backed skills must explicitly report a missing `aidd`; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) enforces this. - -## 🧠 Plugin concerns and layers - -This table is the canonical placement map: put a capability in its owning concern and delegate to it. -| Plugin | Concern | Layer | -| ------------------- | -------------------- | ------------ | -| `aidd-context` | Knowledge production | Knowledge | -| `aidd-pm` | Product management | Knowledge | -| `aidd-refine` | Meta-cognition | Knowledge | -| `aidd-dev` | Code transformation | Execution | -| `aidd-vcs` | Version control | External | -| `aidd-orchestrator` | Orchestration | Coordination | -| `aidd-ui` 🚧 | UI/UX design | Execution | -| `aidd-telemetry` πŸ§ͺ | Measurement | Observation | -| `aidd-qa` πŸ†• | Acceptance QA | Execution | -- **Knowledge** produces context and specifications, never writes or runs application source. Context bootstrap creates no `package.json`. -- **Execution** changes or validates source. **External** owns version control. -- **Coordination** sequences work through artifacts (for example `INSTALL.md`), keeping domain logic and artifact contracts with their owning plugins. Direct and orchestrated calls obey the same contracts; the backlog flow belongs to `aidd-orchestrator:02-backlog`. -- **Observation** records work without changing the artifacts it describes. No productive flow depends on it. - -`aidd-ui` is alpha and smoke-test only; `aidd-qa` requires validation outside this repository; `aidd-telemetry` is beta and opt-in. All remain outside curated installation. - -Telemetry requires committed `.aidd/config.json` with `telemetry.enabled: true`; a directory grants no permission. Session journals are git-ignored, append-only observations. Readers derive task identity and join provider measurements. See the [journal contract](../aidd_docs/runs/README.md). - -## πŸ”€ Skills are routers - -A skill's `SKILL.md` is loaded on invocation and selects its action or orchestration protocol. - -```mermaid -flowchart LR - Caller["User or agent"] --> Router["Skill router"] - Router -->|select| Action["Self-contained action"] - Action --> Result["Result or artifact"] -``` - -Actions define inputs, outputs, steps and checks. Orchestrators may instead route through reference protocols defining handoffs and discovered providers. - -Skills link only inside their own directory: flat distribution renames them `-`, while marketplace installation preserves the tree. Bundled scripts are named plugin-relative in backticks, never linked; the [portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) verifies this. - -## πŸ€– Skills and agents - -Choose by context: a **skill** runs in its caller's context; an **agent** isolates work and returns a result. - -- Orchestrators retain routing ownership and authorize isolated work or bounded fan-out. Recipes never invent spawning; isolated recipes never delegate flow work. -- Agents invoke only their declared canonical recipe skills, never orchestrators, and never read skill files. -- Read-only reconnaissance may be delegated only to helpers that neither mutate nor spawn. The write path stays two layers deep, without delegation cycles. - -The SDLC owns planning. Its [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) contracts govern direct execution, leaf executors, independent judgment and bounded repair. - -## πŸ”— Capability addressing +## Capability discovery and addressing Dispatch tables (`## Actions`) and agent permission lists (`# Skills you may invoke`) use canonical addresses. Elsewhere, name the responsibility. @@ -121,7 +135,7 @@ Recipes discover cross-plugin providers by description rather than hardcoding si `isExemptFromOrthogonality` exempts orchestration responsibility maps (`plugins/aidd-orchestrator/**`) and onboarding menus (`plugins/aidd-context/skills/00-onboard/**`). The onboarding exemption ends when it discovers providers at runtime. -## πŸ”Ž See also +## References - [Create a plugin](CREATE_PLUGIN.md): authoring and publication. - [Glossary](GLOSSARY.md): terminology. diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index ba3dc69f9..f0f351db7 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -2,27 +2,32 @@ # aidd-telemetry -See which skills and tasks consume tokens, then choose where to improve your workflow. +Understand token usage by skill and task to improve workflows. -> Beta. Proven end to end on Claude Code; other tools depend on their recorded data. -> Excluded from curated installation pending validation on other users' machines. +## Getting started -## What it is +Recording requires only `node`; enabling and reporting require `aidd`. -Usage reports group work by step, model, task, flow, tool or person. Each attribution states -its evidence: +```sh +npm install -g @ai-driven-dev/cli +aidd plugin install aidd-telemetry +``` -- `stated by the tool`: exact attribution. -- `from a journal interval`: inferred attribution. -- `unattributed`: neither source identifies a step; it does not mean no step ran. +Enable measurement, work in a session, then report and verify. Use skills or CLI commands; +skills stop and explain when `aidd` cannot answer. +Report after the turn: hooks fire before its tokens are durably written. -Unknown values remain unknown, never zero. Reports count tokens; a separate service prices them. +| Ask your tool for | It runs | You get | +| --- | --- | --- | +| `00-init` | `aidd telemetry on`, then reads a run file back | project opt-in and recording proof | +| `01-cost` | `aidd telemetry report` | period or task usage | +| `02-check` | `aidd telemetry check` | recording status and required repairs | -## Why it exists +## Reading a report -Provider totals describe accounts. AIDD connects those counts to your units of work. +### Report construction -## How it works +AIDD connects provider counts to units of work. ```mermaid flowchart LR @@ -31,32 +36,26 @@ flowchart LR Report --> Results["Local usage by step, task, flow, model, tool or person"] ``` -Hooks append one line per observation to git-ignored -`aidd_docs/runs/__.jsonl`, never rewriting it or recording tokens, cost -or model. Tools write their own transcripts without AIDD skill knowledge. Reports store -joined results under `~/.config/aidd/telemetry/`. - -Recording requires only `node`; enabling and reporting require `aidd`. Report after the -turn: hooks fire before its tokens are durably written. - -## Getting started +Tools write their own transcripts without AIDD skill knowledge. Reports join these with +hook observations by session. -```sh -npm install -g @ai-driven-dev/cli -aidd plugin install aidd-telemetry -``` +### Result interpretation -Enable measurement, work in a session, then report and verify. Use these skills or their -CLI commands; skills stop with an explanation if `aidd` cannot answer. +Usage groups: step, model, task, flow, tool and person. Attribution states its evidence: -| Ask your tool for | It runs | You get | -| --- | --- | --- | -| `00-init` | `aidd telemetry on`, then reads a run file back | project opt-in and recording proof | -| `01-cost` | `aidd telemetry report` | period or task usage | -| `02-check` | `aidd telemetry check` | recording status and required repairs | +- `stated by the tool`: exact attribution. +- `from a journal interval`: inferred attribution. +- `unattributed`: neither source identifies a step; it does not mean no step ran. +- **Unknown values** remain unknown, never zero. +- **Counts** are raw tokens, not currency; a separate service prices them. Vendor usage + screens weight cached tokens by price, so different counts do not mean either is wrong. +- **Periods** reflect work time, not billing time. Activity before opt-in cannot be reconstructed. ## Coverage +> Beta. Proven end to end on Claude Code; other tools depend on their recorded data. +> Excluded from curated installation pending validation on other users' machines. + | Tool | Tokens | Step | Task | | --- | --- | --- | --- | | **Claude Code** | βœ… proven on live sessions | βœ… stated by the tool, and by interval | βœ… | @@ -75,25 +74,29 @@ CLI commands; skills stop with an explanation if `aidd` cannot answer. event subscription. - **OpenCode steps:** only task paths reach the journal, never skill calls. Every request remains unattributed by step. -- **Counts:** raw tokens differ from vendor usage screens, which weight cached tokens by - price. Neither count is wrong. -- **Periods:** work time, not billing time. Activity before opt-in cannot be reconstructed. -## Privacy +## Data and privacy + +### Stored data + +Recording is local and opt-in, with no export, prompts, code or diffs. Hooks append one +line per observation to git-ignored `aidd_docs/runs/__.jsonl`, never +rewriting it or recording tokens, cost or model. Joined measurement records are stored under +`~/.config/aidd/telemetry/` according to [the record contract](../../aidd_docs/product/metrics-contract.md). + +### Privacy controls -- **Local, opt-in recording; no export.** `aidd telemetry check` and `aidd telemetry off` detect legacy - export endpoints and identify required manual removal. -- **No prompts, code or diffs.** Stored fields follow - [the record contract](../../aidd_docs/product/metrics-contract.md). - **Project opt-in:** committing `.aidd/config.json` with measurement enabled affects all clones. `AIDD_TELEMETRY=0` unconditionally overrides it for yourself. - **Retention:** `off` keeps records. `aidd telemetry forget` removes the project's journal, this machine's records and identity file; deletion requires `--yes`. +- **Legacy exports:** `aidd telemetry check` and `aidd telemetry off` detect old endpoints + and identify required manual removal. - **Identity:** attach it optionally through `aidd telemetry identity`. Share figures using `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`: the latter also relocates `auth.json` and its GitHub token. -## Where things are written down +## Reference contracts - [`aidd_docs/runs/README.md`](../../aidd_docs/runs/README.md): journal contract. - [`cost-report-contract.md`](../../aidd_docs/product/cost-report-contract.md): the object From 70834350c4ebf61238e062676b7eb6cd947ec950 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 8 Oct 2026 07:02:09 +0200 Subject: [PATCH 20/23] docs(framework): consolidate concise documentation rules Keep the same documentation safeguards in fewer instructions. Refs #953. AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .claude/rules/01-standards/1-documentation.md | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/.claude/rules/01-standards/1-documentation.md b/.claude/rules/01-standards/1-documentation.md index c599c90c3..57049fc85 100644 --- a/.claude/rules/01-standards/1-documentation.md +++ b/.claude/rules/01-standards/1-documentation.md @@ -10,25 +10,11 @@ paths: # Concise documentation -- Apply only to durable, authored Markdown. -- Revise existing documents individually. -- Exclude historical, generated, and fixture content. -- Preserve contracts, decisions, limits, and procedures. -- Architecture explains components, boundaries, flows, invariants. -- README explains purpose, usage, and limits. -- Give each section a clear purpose and descriptive heading. -- Put only relevant information under that heading; place each fact once per document. -- Group related sections under a shared parent; avoid headings that add no structure. -- State actions and verification commands. -- Link canonical sources for supporting details. -- Keep memory's operational safeguards self-contained. -- Remove filler, repetition, and narrated history. -- Exclude dates, counters, ticket/run identifiers. -- Exclude snapshots and temporary evidence. -- Drop measured timings, ratios, and comparisons. -- Keep rationale; never invent performance requirements. -- Retain required versions, constants, and examples. -- Use examples and diagrams only to clarify. -- Preserve linked anchors and generator markers when restructuring. -- Read back; check section scope, contracts, and commands. -- Verify links before declaring completion. +- Edit durable, authored Markdown individually; exclude history, generated content and fixtures. +- Architecture: components, boundaries, flows, invariants. README: purpose, usage, limits. +- Use descriptive, hierarchical headings; include only relevant facts, once per document. +- Preserve contracts, decisions, rationale, limits, procedures, linked anchors and generator markers. Keep memory safeguards self-contained; never invent performance requirements. +- Remove filler, repetition, narrated history, dates, counters, ticket/run IDs, snapshots, temporary evidence and measured timings, ratios or comparisons. +- Keep required versions and constants; use examples and diagrams only to clarify. +- State actions and verification commands; link canonical sources for supporting details. +- Read back: check section scope, preserved information, commands and links. From 61d75009b7fd5bbdcf1b02ffe3ed32e8be51741d Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 8 Oct 2026 07:12:14 +0200 Subject: [PATCH 21/23] docs(framework): explain architecture from marketplace to components Give each plugin component a clear role, keep adapter details with the CLI, and correct conflicting public documentation. Refs #953. AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- cli/ARCHITECTURE.md | 21 +++++++- docs/ARCHITECTURE.md | 112 +++++++++++++++++++++---------------------- docs/FAQ.md | 2 +- docs/GLOSSARY.md | 4 +- 4 files changed, 78 insertions(+), 61 deletions(-) diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md index eefdc0fd3..c25fa2ffd 100644 --- a/cli/ARCHITECTURE.md +++ b/cli/ARCHITECTURE.md @@ -104,7 +104,26 @@ Memory ownership (CLAUDE.md, AGENTS.md, copilot-instructions.md) is delegated to ## Translate (author-side) -`aidd translate` (renamed from `framework build` in phase 18) converts a Claude-format framework source into a target-native distribution. Five targets (`claude`, `cursor`, `copilot`, `codex`, `opencode`) Γ— two modes (`marketplace`, `--as flat`); `opencode` is flat-only, so 9 build cells. The orchestrators (`MarketplaceBuildStrategy`, `FlatBuildStrategy`) read a per-tool `ToolBuildContract` β€” no per-tool branching. **Scope:** skills, agents, mcp, and hooks are emitted; `rules` and `commands` are currently out of scope (warn + skip per plugin). See `README.md` β†’ `aidd translate` for the per-tool layout matrix. +`aidd translate` converts a Claude-format framework source into a target-native distribution. The orchestrators (`MarketplaceBuildStrategy`, `FlatBuildStrategy`) read a per-tool `ToolBuildContract` without per-tool branching. **Scope:** skills, agents, mcp, and hooks are emitted; `rules` and `commands` are out of scope (warn + skip per plugin). The [CLI reference](README.md#translate) owns the supported targets and output layout matrix. + +## Hook adaptation + +OpenCode needs JS adapters. The CLI owns the shared host protocol; plugins own payload mapping. Its helper is delivered once outside plugin discovery, tracked as a tool file, and backfilled only when missing. Installation must neither rewrite user configuration nor claim existing untracked helpers. Generic `SessionStart` runs idempotently at host initialization; telemetry follows actual sessions. + +
+Tool compatibility and adapter contracts + +Hooks are authored with `${CLAUDE_PLUGIN_ROOT}`; the installer translates the root for each tool. +| Tool | Runs bundled hooks | Plugin root | Notes | +| -------------- | --------------------- | ---------------------- | ----- | +| Claude Code | yes | `${CLAUDE_PLUGIN_ROOT}` | Authoring spelling, nothing substituted | +| Codex | yes | `${PLUGIN_ROOT}` | Also expands `${CLAUDE_PLUGIN_ROOT}`; runs a hook only once trusted | +| GitHub Copilot | yes | `${PLUGIN_ROOT}` | Declared, never observed running | +| Cursor | declared | `./` | Own hook format: the converter rewrites the root to a plugin-relative path before token substitution. No plugin hook observed firing headless; what registers a plugin in Cursor's plugin directory is unknown | +| OpenCode | no, by a second route | β€” | See below | +Runtime contracts: [hook bridge](src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts), [shared V2 adapter](assets/configs/opencode/opencode-events.js.txt), [telemetry payload adapter](../plugins/aidd-telemetry/hooks/opencode-plugin.js). The shared helper is `.opencode/hooks/opencode-events.js`. [Telemetry coverage](../plugins/aidd-telemetry/README.md#coverage) states supported versions and limitations. Unsupported hooks and skipped installation surfaces must be reported. + +
## Dependency Wiring diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5cbe7d6f..d751740de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,50 @@ # Architecture -The framework packages AI-assisted work into plugins, each owning one concern. Skills guide work, agents isolate it, and hooks react to tool events. +AIDD is a marketplace of plugins for AI-assisted work. Each plugin owns one concern; the AI tool loads its capabilities and executes the work. + +## Marketplace and installation + +The repository publishes a catalog in `.claude-plugin/marketplace.json`: it lists plugins and where to find them. Each `plugins//` package has its own manifest and version. The marketplace and plugins version independently. + +Registration, installation scopes and updates are covered in the [marketplace guide](MARKETPLACE.md). + +Plugins use Claude Code's native format. The CLI translates and installs them for supported AI tools; unavailable surfaces are reported, not silently ignored. + +```mermaid +flowchart TB + Catalog["Marketplace catalog"] --> Plugins["Plugins: one concern each"] + Plugins -->|native install| Claude["Claude Code"] + Plugins --> CLI["CLI: translate and install"] + CLI --> Tools["Supported AI tools"] +``` + +`aidd-ui` is alpha and smoke-test only; `aidd-qa` requires validation outside this repository; `aidd-telemetry` is beta and opt-in. All remain outside curated installation. + +## 🧩 Anatomy of a plugin + +AIDD plugins contain a manifest and skills. Other capabilities are optional; they need not all be present in one plugin. + +| Component | Location | Role | +| --- | --- | --- | +| Manifest | `.claude-plugin/` | `plugin.json` identifies the plugin, its version and declared capabilities. | +| Skill | `skills//SKILL.md` | Entry point that routes a request to actions or a protocol. | +| Actions | `skills//actions/` | Workflow steps with inputs, outputs, instructions and checks. | +| Assets | `skills//assets/` | Templates and static files used by the skill. | +| References | `skills//references/` | Supporting documentation and protocols, including orchestration handoffs. | +| Agents | `agents/` | Specialized roles that perform isolated work and return a result. | +| Commands | `commands/` | Flat prompts invoked as slash commands. | +| Hooks | `hooks/hooks.json` and scripts in `hooks/` | Programs triggered by tool lifecycle events. | +| MCP configuration | `.mcp.json` | Connects the AI tool to external tools and data through Model Context Protocol servers. | +| Documentation | `README.md` · `CATALOG.md` · `CHANGELOG.md` | Usage, capability inventory and release history. | + +Rules govern project behavior in the host's rules directory, such as `.claude/rules/`. `aidd-context` generates them as project context, outside native Claude plugin surfaces. Native component behavior is defined in the [Claude plugin reference](https://code.claude.com/docs/en/plugins-reference). + +
+Package validation + +[Plugin](https://www.schemastore.org/claude-code-plugin-manifest.json) and [marketplace](https://www.schemastore.org/claude-code-marketplace.json) manifests are validated by `lefthook` and the `validate` workflow. Plugin tests belong in `scripts/__tests__/`, outside shipped trees: `hooks/` is copied recursively into user projects. + +
## Responsibilities @@ -32,7 +76,7 @@ Telemetry requires committed `.aidd/config.json` with `telemetry.enabled: true`; ### Skills and actions -A skill's `SKILL.md` is loaded on invocation and selects its action or orchestration protocol. +The host loads `SKILL.md` on invocation. The caller follows its selected action or orchestration protocol. ```mermaid flowchart LR @@ -41,7 +85,7 @@ flowchart LR Action --> Result["Result or artifact"] ``` -Actions define inputs, outputs, steps and checks. Orchestrators may instead route through reference protocols defining handoffs and discovered providers. +An orchestrator can follow a reference protocol instead of an action, with explicit handoffs to discovered providers. ### Agents and delegation @@ -65,44 +109,9 @@ Hooks declare deterministic lifecycle work in `hooks/hooks.json`. Repeated event Queries and reports use one CLI implementation, preventing duplicated logic. CLI-backed skills must explicitly report a missing `aidd`; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) enforces this. -## Distribution +## Portability -### Installation routes - -Plugins use Claude Code's native format. The CLI translates and installs them for supported AI tools; unavailable surfaces are reported, not silently ignored. - -```mermaid -flowchart LR - Catalog["Marketplace catalog"] --> Plugins["Plugins: one concern each"] - Plugins -->|native install| Claude["Claude Code"] - Plugins --> CLI["CLI: translate and install"] - CLI --> Tools["Supported AI tools"] -``` - -`aidd-ui` is alpha and smoke-test only; `aidd-qa` requires validation outside this repository; `aidd-telemetry` is beta and opt-in. All remain outside curated installation. - -### 🧩 Anatomy of a plugin - -A plugin requires a manifest and skills. Agents, commands, hooks, rules and MCP servers are optional. - -
-Package and validation reference - -```text -plugins// -β”œβ”€β”€ .claude-plugin/plugin.json -β”œβ”€β”€ README.md Β· CATALOG.md Β· CHANGELOG.md -β”œβ”€β”€ skills/-/ -β”‚ β”œβ”€β”€ SKILL.md # manifest and router -β”‚ β”œβ”€β”€ actions/ # self-contained steps -β”‚ β”œβ”€β”€ assets/ # templates and static files -β”‚ └── references/ # local protocols -└── agents/ Β· commands/ Β· hooks/ Β· rules/ Β· .mcp.json -``` - -[Plugin](https://www.schemastore.org/claude-code-plugin-manifest.json) and [marketplace](https://www.schemastore.org/claude-code-marketplace.json) manifests are validated by `lefthook` and the `validate` workflow. Plugin tests belong in `scripts/__tests__/`, outside shipped trees: `hooks/` is copied recursively into user projects. - -
+The CLI translates capabilities supported by each target. `aidd translate` skips rules and commands with a warning; the [CLI reference](../cli/README.md#translate) owns the output layout matrix. ### Skill portability @@ -110,22 +119,7 @@ Skills link only inside their own directory: flat distribution renames them ` -Tool compatibility and adapter contracts - -Hooks are authored with `${CLAUDE_PLUGIN_ROOT}`; the installer translates the root for each tool. -| Tool | Runs bundled hooks | Plugin root | Notes | -| -------------- | --------------------- | ---------------------- | ----- | -| Claude Code | yes | `${CLAUDE_PLUGIN_ROOT}` | Authoring spelling, nothing substituted | -| Codex | yes | `${PLUGIN_ROOT}` | Also expands `${CLAUDE_PLUGIN_ROOT}`; runs a hook only once trusted | -| GitHub Copilot | yes | `${PLUGIN_ROOT}` | Declared, never observed running | -| Cursor | declared | `./` | Own hook format: the converter rewrites the root to a plugin-relative path before token substitution. No plugin hook observed firing headless; what registers a plugin in Cursor's plugin directory is unknown | -| OpenCode | no, by a second route | β€” | See below | -Runtime contracts: [hook bridge](../cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts), [shared V2 adapter](../cli/assets/configs/opencode/opencode-events.js.txt), [telemetry payload adapter](../plugins/aidd-telemetry/hooks/opencode-plugin.js). The shared helper is `.opencode/hooks/opencode-events.js`. [Telemetry coverage](../plugins/aidd-telemetry/README.md#coverage) states supported versions and limitations. Unsupported hooks and skipped installation surfaces must be reported. - - +The CLI owns OpenCode's shared host protocol; plugins own payload mapping. The [CLI architecture](../cli/ARCHITECTURE.md#hook-adaptation) defines adapter delivery and compatibility. [Telemetry coverage](../plugins/aidd-telemetry/README.md#coverage) states measurement limits. ## Capability discovery and addressing @@ -137,7 +131,11 @@ Recipes discover cross-plugin providers by description rather than hardcoding si ## References +- [Framework README](../README.md): discover capabilities, install and start. +- [Marketplace guide](MARKETPLACE.md): registration, scopes and updates. - [Create a plugin](CREATE_PLUGIN.md): authoring and publication. - [Glossary](GLOSSARY.md): terminology. -- [CLI architecture](../cli/ARCHITECTURE.md): translation and installation. +- [CLI reference](../cli/README.md): commands and output layouts. +- [CLI architecture](../cli/ARCHITECTURE.md): translation and installation internals. - [Contributing](../CONTRIBUTING.md): contribution flow. +- [Maintainers guide](MAINTAINERS.md): repository operations and releases. diff --git a/docs/FAQ.md b/docs/FAQ.md index 84d394d63..f7e4ae3e8 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -38,7 +38,7 @@ You can write your own Claude Code skills β€” nothing stops you. AIDD exists bec ## 🚧 Limitations (what AIDD does not do) -- **Not autonomous by default.** Skills run under human supervision; you drive each step. +- **Execution depends on the workflow.** The SDLC flow is autonomous by default; `interactive` pauses for human review at its contract, plan and outcome checkpoints. - **Authored for Claude Code.** Other tools install via their native mechanism from the release archives ([Other tools](../README.md#other-tools)); public-marketplace publishing is on the way, native parity is a roadmap item. - **Plugins assume their own context.** A skill that expects a git repo, a `package.json`, or a ticketing tool won't work without it β€” check the plugin's README. - **No hosted service.** AIDD is prompt content you install into your own tool; there is no AIDD server and no account. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 4f56f4119..f4da8dbe7 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -4,7 +4,7 @@ Definitions for the terms the framework uses without re-explaining each time. On ## πŸ“¦ Plugin -A `plugins//` directory installable from this marketplace into Claude Code. Each plugin owns one domain (context, dev, vcs, pm, orchestrator, refine, ui), ships its own README, CATALOG, and skills, and may add any Claude Code surface: agents, commands, hooks, rules, and MCP servers (`.mcp.json`). Plugins version independently via `release-please`. +A package under `plugins//` that owns one concern and groups skills with optional agents, commands, hooks and MCP configuration. AI tools load it natively or through CLI translation. Its README explains usage and CATALOG lists capabilities. Plugins version independently via `release-please`; see [Architecture](ARCHITECTURE.md#-anatomy-of-a-plugin) for their composition. ## πŸͺ Marketplace @@ -36,7 +36,7 @@ A coding standard the AI loads automatically on relevant files. Rules live under ## πŸͺ Hook -A program declared in `plugins//hooks/hooks.json` that Claude Code runs at lifecycle events (pre-commit, post-tool, etc.). Hooks are how a plugin triggers deterministic side effects rather than asking the model to remember. +A program declared in `plugins//hooks/hooks.json` that Claude Code runs at lifecycle events such as `SessionStart` or `PostToolUse`. Hooks trigger deterministic side effects rather than asking the model to remember. ## πŸ”– Bracket ID From 144de00730f4373986e797cf9366d85a423102bf Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 8 Oct 2026 11:52:52 +0200 Subject: [PATCH 22/23] docs(framework): prefer architecture diagrams and tables Keep the architecture focused on stable relationships and responsibilities, with concise prose and preserved safeguards. Refs #953. AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .claude/rules/01-standards/1-documentation.md | 2 +- docs/ARCHITECTURE.md | 153 ++++++++++-------- 2 files changed, 87 insertions(+), 68 deletions(-) diff --git a/.claude/rules/01-standards/1-documentation.md b/.claude/rules/01-standards/1-documentation.md index 57049fc85..d47157fb9 100644 --- a/.claude/rules/01-standards/1-documentation.md +++ b/.claude/rules/01-standards/1-documentation.md @@ -15,6 +15,6 @@ paths: - Use descriptive, hierarchical headings; include only relevant facts, once per document. - Preserve contracts, decisions, rationale, limits, procedures, linked anchors and generator markers. Keep memory safeguards self-contained; never invent performance requirements. - Remove filler, repetition, narrated history, dates, counters, ticket/run IDs, snapshots, temporary evidence and measured timings, ratios or comparisons. -- Keep required versions and constants; use examples and diagrams only to clarify. +- Prefer diagrams for relationships and tables for roles; keep necessary prose concise. Retain required versions, constants and useful examples. - State actions and verification commands; link canonical sources for supporting details. - Read back: check section scope, preserved information, commands and links. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d751740de..abe4c6e43 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,54 +1,59 @@ # Architecture -AIDD is a marketplace of plugins for AI-assisted work. Each plugin owns one concern; the AI tool loads its capabilities and executes the work. +AIDD packages AI-assisted work into plugins, each owning one concern. ## Marketplace and installation -The repository publishes a catalog in `.claude-plugin/marketplace.json`: it lists plugins and where to find them. Each `plugins//` package has its own manifest and version. The marketplace and plugins version independently. - -Registration, installation scopes and updates are covered in the [marketplace guide](MARKETPLACE.md). - -Plugins use Claude Code's native format. The CLI translates and installs them for supported AI tools; unavailable surfaces are reported, not silently ignored. - ```mermaid flowchart TB - Catalog["Marketplace catalog"] --> Plugins["Plugins: one concern each"] + Catalog["Marketplace catalog"] -->|lists sources| Plugins["Plugin packages"] Plugins -->|native install| Claude["Claude Code"] Plugins --> CLI["CLI: translate and install"] CLI --> Tools["Supported AI tools"] ``` -`aidd-ui` is alpha and smoke-test only; `aidd-qa` requires validation outside this repository; `aidd-telemetry` is beta and opt-in. All remain outside curated installation. +| Element | Responsibility | +| --- | --- | +| `.claude-plugin/marketplace.json` | Lists plugins and their sources. | +| `plugins//` | Holds a plugin's manifest and capabilities. | +| AI tool | Loads capabilities and executes work. | +| CLI | Translates supported capabilities; reports unavailable surfaces. | + +Marketplace and plugins version independently. See the [marketplace guide](MARKETPLACE.md) for registration, scopes and updates. ## 🧩 Anatomy of a plugin -AIDD plugins contain a manifest and skills. Other capabilities are optional; they need not all be present in one plugin. +Manifest and skills are required in AIDD; other capabilities are optional. Locations are relative to the plugin directory. | Component | Location | Role | | --- | --- | --- | -| Manifest | `.claude-plugin/` | `plugin.json` identifies the plugin, its version and declared capabilities. | -| Skill | `skills//SKILL.md` | Entry point that routes a request to actions or a protocol. | -| Actions | `skills//actions/` | Workflow steps with inputs, outputs, instructions and checks. | -| Assets | `skills//assets/` | Templates and static files used by the skill. | -| References | `skills//references/` | Supporting documentation and protocols, including orchestration handoffs. | -| Agents | `agents/` | Specialized roles that perform isolated work and return a result. | +| Manifest | `.claude-plugin/` | `plugin.json`: identity, version and declared capabilities. | +| Skill | `skills//SKILL.md` | Routes requests to actions or a protocol. | +| Actions | `skills//actions/` | Inputs, outputs, procedure and checks. | +| Assets | `skills//assets/` | Reusable templates and static files. | +| References | `skills//references/` | Supporting documentation and handoff protocols. | +| Agents | `agents/` | Isolated specialist roles. | | Commands | `commands/` | Flat prompts invoked as slash commands. | -| Hooks | `hooks/hooks.json` and scripts in `hooks/` | Programs triggered by tool lifecycle events. | -| MCP configuration | `.mcp.json` | Connects the AI tool to external tools and data through Model Context Protocol servers. | +| Hooks | `hooks/hooks.json` and `hooks/` scripts | Deterministic programs triggered by lifecycle events. | +| MCP configuration | `.mcp.json` | External tools and data via Model Context Protocol servers. | | Documentation | `README.md` Β· `CATALOG.md` Β· `CHANGELOG.md` | Usage, capability inventory and release history. | -Rules govern project behavior in the host's rules directory, such as `.claude/rules/`. `aidd-context` generates them as project context, outside native Claude plugin surfaces. Native component behavior is defined in the [Claude plugin reference](https://code.claude.com/docs/en/plugins-reference). +Project rules belong in the host's rules directory, such as `.claude/rules/`. `aidd-context` generates them as project context, outside native Claude plugin surfaces.
Package validation -[Plugin](https://www.schemastore.org/claude-code-plugin-manifest.json) and [marketplace](https://www.schemastore.org/claude-code-marketplace.json) manifests are validated by `lefthook` and the `validate` workflow. Plugin tests belong in `scripts/__tests__/`, outside shipped trees: `hooks/` is copied recursively into user projects. +| Check | Contract | +| --- | --- | +| Manifests | [Plugin](https://www.schemastore.org/claude-code-plugin-manifest.json) and [marketplace](https://www.schemastore.org/claude-code-marketplace.json) schemas; validated by `lefthook` and the `validate` workflow. | +| Tests | Keep in `scripts/__tests__/`, outside shipped trees: `hooks/` is copied recursively into user projects. |
## Responsibilities -This table is the canonical placement map: put a capability in its owning concern and delegate to it. +Place each capability in its owning concern and delegate to it. + | Plugin | Concern | Layer | | ------------------- | -------------------- | ------------ | | `aidd-context` | Knowledge production | Knowledge | @@ -57,49 +62,62 @@ This table is the canonical placement map: put a capability in its owning concer | `aidd-dev` | Code transformation | Execution | | `aidd-vcs` | Version control | External | | `aidd-orchestrator` | Orchestration | Coordination | -| `aidd-ui` 🚧 | UI/UX design | Execution | -| `aidd-telemetry` πŸ§ͺ | Measurement | Observation | -| `aidd-qa` πŸ†• | Acceptance QA | Execution | +| `aidd-ui` | UI/UX design | Execution | +| `aidd-telemetry` | Measurement | Observation | +| `aidd-qa` | Acceptance QA | Execution | ### Layer boundaries -- **Knowledge** produces context and specifications, never writes or runs application source. Context bootstrap creates no `package.json`. -- **Execution** changes or validates source. **External** owns version control. -- **Coordination** sequences work through artifacts (for example `INSTALL.md`), keeping domain logic and artifact contracts with their owning plugins. Direct and orchestrated calls obey the same contracts; the backlog flow belongs to `aidd-orchestrator:02-backlog`. -- **Observation** records work without changing the artifacts it describes. No productive flow depends on it. +| Layer | Boundary | +| --- | --- | +| Knowledge | Produces context and specifications; never writes or runs application source. Context bootstrap creates no `package.json`. | +| Execution | Changes or validates application source. | +| External | Owns version control. | +| Coordination | Sequences artifacts (for example `INSTALL.md`). Domain logic and artifact contracts stay with their owners; direct and orchestrated calls obey the same contracts. | +| Observation | Records work without changing observed artifacts. Productive flows never depend on it. | ### Measurement boundary -Telemetry requires committed `.aidd/config.json` with `telemetry.enabled: true`; a directory grants no permission. Session journals are git-ignored, append-only observations. Readers derive task identity and join provider measurements. See the [journal contract](../aidd_docs/runs/README.md). +| Concern | Contract | +| --- | --- | +| Consent | Committed `.aidd/config.json` with `telemetry.enabled: true`; a directory grants no permission. | +| Writing | Git-ignored, append-only session observations. | +| Reading | Derive task identity and join provider measurements. | + +See the [journal contract](../aidd_docs/runs/README.md). ## Execution model ### Skills and actions -The host loads `SKILL.md` on invocation. The caller follows its selected action or orchestration protocol. - ```mermaid -flowchart LR - Caller["User or agent"] --> Router["Skill router"] - Router -->|select| Action["Self-contained action"] +flowchart TB + User["User"] --> Router["Orchestrator SKILL.md"] + Router --> Protocol["Orchestration protocol"] + User -->|direct invocation| Recipe["Recipe SKILL.md"] + Protocol -->|discover and invoke provider| Recipe + Protocol -->|authorized isolation| Agent["Agent"] + Agent -->|declared recipes only| Recipe + Recipe --> Action["Self-contained action"] Action --> Result["Result or artifact"] ``` -An orchestrator can follow a reference protocol instead of an action, with explicit handoffs to discovered providers. - ### Agents and delegation -Choose by context: a **skill** runs in its caller's context; an **agent** isolates work and returns a result. - -- Orchestrators retain routing ownership and authorize isolated work or bounded fan-out. Recipes never invent spawning; isolated recipes never delegate flow work. -- Agents invoke only their declared canonical recipe skills, never orchestrators, and never read skill files. -- Read-only reconnaissance may be delegated only to helpers that neither mutate nor spawn. The write path stays two layers deep, without delegation cycles. - -The SDLC owns planning. Its [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) contracts govern direct execution, leaf executors, independent judgment and bounded repair. +| Role | Contract | +| --- | --- | +| Skill | Host loads `SKILL.md` on invocation; work runs in the caller's context. | +| Orchestrator | Owns routing; authorizes isolation or bounded fan-out. | +| Agent | Isolates work and returns a result. Invokes only declared canonical recipe skills; never invokes orchestrators or reads skill files. | +| Recipe | Never invents spawning; an isolated recipe never delegates flow work. | +| Reconnaissance helper | Read-only; neither mutates nor spawns. | +| Write path | At most two delegation layers; no cycles. | +| SDLC | Owns planning; [delivery](../plugins/aidd-orchestrator/skills/01-sdlc/references/02-deliver.md) and [check](../plugins/aidd-orchestrator/skills/01-sdlc/references/03-check.md) govern execution, leaf executors, independent judgment and bounded repair. | ### πŸͺ Bundled hooks -Hooks declare deterministic lifecycle work in `hooks/hooks.json`. Repeated event work runs as dependency-free Node scripts and requires `node` on `PATH`. +Dependency-free Node scripts declared in `hooks/hooks.json`; `node` must be on `PATH`. + | Plugin | Event | Runs | Purpose | | ---------------- | --------------------------------------- | ------------------------ | ----------------------------------------------------------- | | `aidd-context` | `SessionStart` | `hooks/update_memory.js` | Refresh the project memory block in the AI context files | @@ -107,35 +125,36 @@ Hooks declare deterministic lifecycle work in `hooks/hooks.json`. Repeated event ### CLI queries -Queries and reports use one CLI implementation, preventing duplicated logic. CLI-backed skills must explicitly report a missing `aidd`; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) enforces this. +Queries and reports share one CLI implementation to avoid duplicated logic. CLI-backed skills must explain a missing `aidd`; the [dependency guard](../scripts/__tests__/telemetry-cli-required.test.js) enforces this. ## Portability -The CLI translates capabilities supported by each target. `aidd translate` skips rules and commands with a warning; the [CLI reference](../cli/README.md#translate) owns the output layout matrix. - -### Skill portability - -Skills link only inside their own directory: flat distribution renames them `-`, while marketplace installation preserves the tree. Bundled scripts are named plugin-relative in backticks, never linked; the [portability guard](../scripts/__tests__/a-skill-links-only-inside-itself.test.js) verifies this. - -### Hook adapters - -The CLI owns OpenCode's shared host protocol; plugins own payload mapping. The [CLI architecture](../cli/ARCHITECTURE.md#hook-adaptation) defines adapter delivery and compatibility. [Telemetry coverage](../plugins/aidd-telemetry/README.md#coverage) states measurement limits. +| Surface | Contract | Reference | +| --- | --- | --- | +| CLI output | Supported target capabilities only; `aidd translate` warns and skips rules and commands. | [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); [measurement coverage](../plugins/aidd-telemetry/README.md#coverage) | ## Capability discovery and addressing -Dispatch tables (`## Actions`) and agent permission lists (`# Skills you may invoke`) use canonical addresses. Elsewhere, name the responsibility. - -Recipes discover cross-plugin providers by description rather than hardcoding siblings. Orchestration references and agent permissions name the current provider as `/plugin:folder` or `@plugin:agent`; orchestrators verify installation before dispatch. - -`isExemptFromOrthogonality` exempts orchestration responsibility maps (`plugins/aidd-orchestrator/**`) and onboarding menus (`plugins/aidd-context/skills/00-onboard/**`). The onboarding exemption ends when it discovers providers at runtime. +| Context | Rule | +| --- | --- | +| Dispatch tables (`## Actions`), orchestration references and agent permissions (`# Skills you may invoke`) | Canonical `/plugin:folder` or `@plugin:agent` addresses. Elsewhere, name the responsibility. | +| Cross-plugin providers | Recipes discover by description rather than hardcoding siblings; orchestrators verify installation before dispatch. | +| Backlog flow | Owned by `aidd-orchestrator:02-backlog`. | +| Orthogonality exceptions | `isExemptFromOrthogonality` permits responsibility maps in `plugins/aidd-orchestrator/**` and onboarding menus in `plugins/aidd-context/skills/00-onboard/**`. The onboarding exemption ends with runtime provider discovery. | ## References -- [Framework README](../README.md): discover capabilities, install and start. -- [Marketplace guide](MARKETPLACE.md): registration, scopes and updates. -- [Create a plugin](CREATE_PLUGIN.md): authoring and publication. -- [Glossary](GLOSSARY.md): terminology. -- [CLI reference](../cli/README.md): commands and output layouts. -- [CLI architecture](../cli/ARCHITECTURE.md): translation and installation internals. -- [Contributing](../CONTRIBUTING.md): contribution flow. -- [Maintainers guide](MAINTAINERS.md): repository operations and releases. +| Document | Question answered | +| --- | --- | +| [Framework README](../README.md) | What can I use, and how do I start? | +| [Marketplace guide](MARKETPLACE.md) | How do registration, scopes and updates work? | +| [Create a plugin](CREATE_PLUGIN.md) | How do I author and publish a plugin? | +| [Glossary](GLOSSARY.md) | What do the terms mean? | +| [CLI reference](../cli/README.md) | Which commands and output layouts are supported? | +| [CLI architecture](../cli/ARCHITECTURE.md) | How are translation and installation implemented? | +| [Contributing](../CONTRIBUTING.md) | How do I contribute to this repository? | +| [Maintainers guide](MAINTAINERS.md) | How are repository operations and releases managed? | +| [Claude plugin reference](https://code.claude.com/docs/en/plugins-reference) | How do native plugin components behave? | From bf400ebb1256c85b65f56274f82273738d0f6810 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 8 Oct 2026 20:02:21 +0200 Subject: [PATCH 23/23] fix(cli): repair runtime files for current plugins Refs #953. AIDD-Session-Id: 01a11696-08a4-78b1-b05c-c456b9fbceb3 --- .../review.md | 59 ++++++++++++------- .../plugin/plugin-update-use-case.ts | 2 +- .../plugin-runtime-files.integration.test.ts | 21 ++++++- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md index 416d6ce5f..ccd48d374 100644 --- a/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md +++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md @@ -1,32 +1,49 @@ -# Review +# Review: OpenCode loading and Kilo lifecycle -Verdict: approve within documented runtime limits. Axes: code, functional, relevancy. +- **Verdict**: approve within the runtime limits below +- **Diff**: `origin/next...fix/opencode-plugin-loading`, including the final repair +- **Axes run**: code, functional, relevancy; fresh reviewers received no prior verdict +- **Date**: 2026_10_08 +- **Findings**: 0 critical, 0 warning, 0 minor remaining ## Phases -- [x] [OpenCode](./phase-1.md): compatible exports, shared adapter, cancellation, safe delivery and observable V1/V2 effects. -- [x] [Kilo](./kilo-verification.md): complete real turn, exact payloads after shutdown, replay/deletion handling and configuration preservation. -- [x] Windows: checkout preserves embedded LF bytes; removal counterproof detects drift. -- [x] Quality: coding, architecture, dead-code, mutation and bundle gates pass without weakening thresholds. +### Phase 1: OpenCode compatibility and delivery -## Findings +- [x] Import starts no hook β€” `cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts`. +- [x] V1 invokes once; V2 setup returns promptly and cancels its stream β€” `scripts/__tests__/aidd-telemetry-opencode-payloads.test.js:60`, `scripts/__tests__/opencode-plugin.test.js:168` and released loader/SDK sources in [proof](./verification.md). +- [x] Malformed events, failed tools and repeated success stay safe β€” `scripts/__tests__/opencode-plugin.test.js:138,272`, generated-module regressions and [counterproof](./verification.md). +- [x] Coding and architecture gates pass β€” prior-head CI and fresh local checks below; final-head gates required before merge. +- [x] Real V2 loads both plugins and changes memory/journal β€” [observed effects](./verification.md). +- [x] Real V1 preserves effects without duplicate start β€” same proof, released host 1.18.29. +- [x] Verification distinguishes real-host effects, controlled regressions and substituted inference β€” same proof. -None remaining. Obsolete parser removed; comments distinguish V1 fallback from V2 announcements. Independent reviews found no unused addition, unjustified duplication or weakened assertion. Cleanup preserves generated module bytes on representative inputs. +### Authorized extensions -## Verification +- [x] Helper outside discovery, unique ownership and safe install/update repair β€” `cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts:61,75,94,112,125`. +- [x] Kilo lifecycle, matchers, replay, new turns, deleted-session recreation and failure reporting β€” `cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts:33,96,203`. +- [x] Kilo JSONC preservation and real complete turn after shutdown β€” `cli/tests/contexts/framework/application/plugin/kilo-plugin-delivery.integration.test.ts:77,87,98`, `cli/tests/e2e/kilo-runtime.e2e.test.ts:192`. +- [x] Windows LF preservation and drift counterproof β€” `cli/tests/architecture/bundled-config-checkout.arch.test.ts:41,45`. +- [x] No unused addition or weakened gate identified β€” static review of 62 implementation/test/CI files; prior-head CLI CI passed all 29 jobs. +- [x] Architecture/docs match responsibilities; concise rule remains Claude-only; project memories untouched β€” complete diff checked for relevance and contradictions. +- [ ] Historical research β†’ regression β†’ implementation chronology β€” not reconstructed independently; not applicable to current-state review. -Local CLI/root/architecture suites, knip, lint and types passed. [CLI CI](../../../../.github/workflows/cli-ci.yml) passed, including Windows, the dedicated real Kilo case, selected mutations and required gate. [CodeQL](../../../../.github/workflows/codeql.yml) passed with no remaining open alert. These are observed results; consult the PR's current checks after each new commit. +## Findings -Re-run normal gates: +| Sev | Kind | Phase | Location | Issue | Fix | +| --- | --- | --- | --- | --- | --- | -```sh -node scripts/check-tests-leave-git-alone.js -- node --test scripts/__tests__/*.test.js -pnpm --dir cli test -pnpm --dir cli test:arch -pnpm --dir cli lint -pnpm --dir cli typecheck -pnpm --dir cli knip -pnpm --dir cli build -``` +## Verification -The normal suite skips the opt-in Kilo host case; its explicit command and mutation commands are in [Kilo verification](./kilo-verification.md). [OpenCode verification](./verification.md) distinguishes committed regressions from temporary real-host evidence. Unchecked acceptance criteria: none within that scope. +| Metric | Value | +| --- | --- | +| Verified | Functional reviewer corroborated 22/23 checks (96%); historical chronology excluded from current-state verdict | +| Files checked | Entire PR diff; 62 implementation/test/CI files checked for code quality; documentation checked for relevance and contradictions | +| Resolved | Same-version update skipped helper repair: repair now precedes version comparison. Fixture depended on working directory: uses `REPOSITORY_ROOT`. Both fixes independently rechecked. | +| Regression | Missing-helper test failed before repair; 36 selected update/runtime tests then passed. | +| Built CLI | Isolated setup β†’ install β†’ delete helper β†’ same-version update restored exact helper bytes; preserved configuration/plugin record and unique ownership. | +| Local checks | Changed-file Biome, diff whitespace and build passed within unchanged budget. | +| Merge gates | Required local hooks and [exact-head PR checks](https://github.com/ai-driven-dev/framework/pull/971/checks) must pass before merge. Prior head `144de007` passed all executed checks. | +| Unchecked | Historical chronology: not applicable. Temporary real OpenCode probes and limits documented in [proof](./verification.md). | +| Runtime limits | Inference substituted locally; paid providers, global profiles, hot reload and Windows Kilo runtime untested. Kilo telemetry unsupported. | +| Unplanned | None beyond user-authorized Kilo, verification and documentation extensions. | diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts index ceb55decd..2673b27cd 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts @@ -95,6 +95,7 @@ export class PluginUpdateUseCase { const dist = await this.distributionLoader.load(plugin.source, cacheDir, { forceRefresh: true, }); + await this.pluginRuntime?.ensurePluginRuntimeFiles(toolId, projectRoot, manifest); if (compareSemver(dist.manifest.version, plugin.version) <= 0) return false; await this.replacePluginFiles(plugin, dist, toolId, projectRoot, manifest); return true; @@ -108,7 +109,6 @@ export class PluginUpdateUseCase { manifest: Manifest ): Promise { const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir); - await this.pluginRuntime?.ensurePluginRuntimeFiles(toolId, projectRoot, manifest); await deleteOldFiles(plugin.files, baseDir, this.fs); const toolConfig = getToolConfig(toolId); const translator = this.resolveTranslator(toolConfig); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts index 137722ba2..5052688eb 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts @@ -8,9 +8,10 @@ import { InstallationFile } from "../../../../../src/kernel/file.js"; import { buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; +import { REPOSITORY_ROOT } from "../../../../helpers/repository-root.js"; const ROOT = "/test-project"; -const FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const FIXTURE = join(REPOSITORY_ROOT, "cli/tests/fixtures/plugins/claude-format/sample-plugin"); const HELPER = ".opencode/hooks/opencode-events.js"; const USER_CONFIG = '{"model":"user/model","permission":{"bash":"ask"}}'; @@ -90,6 +91,24 @@ describe("plugin runtime files on an existing tool", () => { ).toHaveLength(1); }); + it("restores a missing helper when the plugin version is already current", async () => { + const { deps, addPlugin, update } = await legacyProject(); + await addPlugin(); + const plugin = deps.manifestRepo.getCurrent()?.getPlugins("opencode")[0]; + await deps.fs.deleteFile(join(ROOT, HELPER)); + + expect(await update.execute({ toolIds: ["opencode"], projectRoot: ROOT })).toEqual([]); + expect(await deps.fs.readFile(join(ROOT, HELPER))).toContain("setupOpencodeEvents"); + expect(await deps.fs.readFile(join(ROOT, "opencode.json"))).toBe(USER_CONFIG); + expect(deps.manifestRepo.getCurrent()?.getPlugins("opencode")[0]).toEqual(plugin); + expect( + deps.manifestRepo + .getCurrent() + ?.getToolFiles("opencode") + .filter((file) => file.relativePath === HELPER) + ).toHaveLength(1); + }); + it("preserves and does not claim an existing untracked helper", async () => { const { deps, addPlugin } = await legacyProject(); await deps.fs.writeFile(join(ROOT, HELPER), "// user's helper");