diff --git a/.claude/rules/01-standards/1-documentation.md b/.claude/rules/01-standards/1-documentation.md
new file mode 100644
index 000000000..d47157fb9
--- /dev/null
+++ b/.claude/rules/01-standards/1-documentation.md
@@ -0,0 +1,20 @@
+---
+paths:
+ - "**/README.md"
+ - "**/ARCHITECTURE.md"
+ - "docs/**/*.md"
+ - "**/aidd_docs/memory/**/*.md"
+ - "**/GUIDELINES.md"
+ - "**/CONTRIBUTING.md"
+---
+
+# Concise documentation
+
+- 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.
+- 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/.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/backlog-link.json b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/backlog-link.json
new file mode 100644
index 000000000..a6c7b81f2
--- /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-07T13:49:41.828285Z",
+ "written_by": "aidd-dev:01-plan"
+}
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..0953e16bf
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/challenge.md
@@ -0,0 +1,6 @@
+# Challenge
+No blocker within the [reviewed scope](./review.md).
+
+One shared adapter and existing delivery paths satisfy the need. Real-host effects and failing counterproofs support the result.
+
+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
new file mode 100644
index 000000000..aa54fa992
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/kilo-verification.md
@@ -0,0 +1,34 @@
+# Kilo verification
+
+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).
+
+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.
+
+## Tests
+
+- [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.
+
+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
+```
+
+The ordinary suite skips the opt-in host case; its dedicated CI job runs it explicitly.
+
+## Counterproofs and repairs
+
+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 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).
+
+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
new file mode 100644
index 000000000..993a70f71
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/phase-1.md
@@ -0,0 +1,19 @@
+---
+status: done
+---
+
+# 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.
+
+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.
+
+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.
+
+## 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
new file mode 100644
index 000000000..40ffc2303
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/plan.md
@@ -0,0 +1,13 @@
+---
+status: implemented
+---
+
+# 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.
+
+- [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.
+
+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
new file mode 100644
index 000000000..ccd48d374
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/review.md
@@ -0,0 +1,49 @@
+# Review: OpenCode loading and Kilo lifecycle
+
+- **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
+
+### Phase 1: OpenCode compatibility and delivery
+
+- [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.
+
+### Authorized extensions
+
+- [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.
+
+## Findings
+
+| Sev | Kind | Phase | Location | Issue | Fix |
+| --- | --- | --- | --- | --- | --- |
+
+## Verification
+
+| 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/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..ab22d8716
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_07_opencode-plugin-loading/verification.md
@@ -0,0 +1,20 @@
+# OpenCode proof
+
+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.
+
+Before: V2 reported `Missing key at ["default"]` despite exit zero; memory unchanged, journal empty. Exit status alone could not prove success.
+
+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.
+
+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.
+
+Re-run the committed adapter, payload, generated-layout and delivery regressions:
+
+```sh
+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 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/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/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/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..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
@@ -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,10 @@ 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);
+ 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;
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/kilo/kilo-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts
index 07c29a8df..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
@@ -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,13 +34,13 @@ 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];
})
);
}
@@ -52,21 +53,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 +90,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 +102,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/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 510d8cc0d..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
@@ -2,21 +2,17 @@
* 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
- * 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.
- * - `Stop` maps to `session.idle`, delivered once per turn.
+ * - `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.
@@ -124,6 +120,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";
@@ -210,12 +207,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 +224,11 @@ export const ${ident} = async (input) => {
${ident}.stopCallsFor = stopCallsFor;
${ident}.postToolUseCallsFor = postToolUseCallsFor;
+
+export default {
+ id: ${JSON.stringify(`${plugin}-hooks`)},
+ server: ${ident},
+ 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
+ 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);
+ });
+});
diff --git a/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts b/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts
index 47fbff596..28255b31b 100644
--- a/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts
+++ b/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts
@@ -7,9 +7,9 @@ import { CleanUserScopeUseCase } from "../../../../../src/contexts/framework/app
import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js";
import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js";
import { UserMarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/ownership/user-marketplace-remove-use-case.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 { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js";
import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js";
import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js";
@@ -243,7 +243,7 @@ for (const [toolId, catalogPath] of [
user,
new UserPluginFileUpdater(
updateFs,
- new UserPluginDistributionLoader(
+ new PluginDistributionLoader(
new FixturePluginFetcher(),
new PluginDistributionReaderAdapter(updateFs)
),
@@ -271,7 +271,7 @@ for (const [toolId, catalogPath] of [
user,
new UserPluginFileUpdater(
updateFs,
- new UserPluginDistributionLoader(
+ new PluginDistributionLoader(
new FixturePluginFetcher(),
new PluginDistributionReaderAdapter(updateFs)
),
diff --git a/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts b/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts
index b95b6ddf3..7cc1d4789 100644
--- a/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts
+++ b/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts
@@ -1,3 +1,4 @@
+import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js";
/** A hook a native tool delivers is not a skip but a component with a precondition: Codex
* gates every hook behind a per-hook trust grant it can decline in silence. */
import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js";
@@ -24,8 +25,7 @@ async function installWithLogger(toolId: "codex" | "claude") {
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,
new InMemoryMarketplaceRegistry(),
diff --git a/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts b/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts
index 25ac41a91..11ee72702 100644
--- a/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts
+++ b/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts
@@ -1,3 +1,4 @@
+import { PluginDistributionLoader } from "../../../../../src/contexts/framework/application/plugin/plugin-distribution-loader.js";
/** Installing a plugin carrying hooks/ against OpenCode delivers the script, namespaced under
* .opencode/hooks// 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/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/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..5052688eb
--- /dev/null
+++ b/cli/tests/contexts/framework/application/plugin/plugin-runtime-files.integration.test.ts
@@ -0,0 +1,140 @@
+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";
+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 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("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");
+ 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/kilo/kilo-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.unit.test.ts
index 1df97a930..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
@@ -1,19 +1,17 @@
-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 {
- 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: [
@@ -26,17 +24,182 @@ 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("returns no module when hooks.json has no replayable SessionStart command", () => {
+ it("trims replayable commands and separates multiple whitespace arguments", () => {
+ expect(
+ generateKiloHooksBridge(
+ JSON.stringify({
+ hooks: {
+ SessionStart: [
+ {
+ hooks: [
+ { command: ` node ${ROOT}/hooks/update_memory.js --quiet\t--tool kilo ` },
+ {},
+ ],
+ },
+ ],
+ },
+ }),
+ "probe"
+ )
+ ).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(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")
).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"); 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 ?? "");
+ 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 emit({ type: "session.deleted", properties: { sessionID: "ses_one" } });
+ await emit(created);
+ await vi.waitFor(
+ async () => expect((await readFile(output, "utf8")).trim().split("\n")).toHaveLength(5),
+ { timeout: 5000 }
+ );
+ await delay(100);
+ const calls = (await readFile(output, "utf8"))
+ .trim()
+ .split("\n")
+ .map((line) => JSON.parse(line));
+ expect(calls).toHaveLength(5);
+ 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);
+ expect(calls.filter((call) => call.payload.hook_event_name === "SessionStart")).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 +215,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 +244,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 +276,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/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 c633609e0..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
@@ -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");
@@ -33,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";
@@ -119,12 +129,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 +146,12 @@ describe("generateOpencodeHooksBridge", () => {
AiddSampleHooks.stopCallsFor = stopCallsFor;
AiddSampleHooks.postToolUseCallsFor = postToolUseCallsFor;
+
+ export default {
+ id: "aidd-sample-hooks",
+ server: AiddSampleHooks,
+ 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/e2e/kilo-runtime.e2e.test.ts b/cli/tests/e2e/kilo-runtime.e2e.test.ts
index da25298f1..22b4ecea6 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,85 @@ 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");
+ const originalConfig = JSON.stringify(config);
+ await writeFile(configPath, originalConfig);
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 +308,175 @@ 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"; 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"),
+ 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"; 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");
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);
+ await stopKilo(child);
+ child = undefined;
+ 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).toHaveLength(3);
+ 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(await readFile(configPath, "utf8")).toBe(deliveredConfig);
} 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/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..7add7ffbf 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": "7c38ebdedacd85321f3f475769ef814ba355271254a14e9b44cdcd684467ab71",
+ ".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",
@@ -1939,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",
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.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.
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 486617d92..abe4c6e43 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,90 +1,58 @@
-# 🏛️ Architecture
+# Architecture
-How the AI-Driven Dev Framework composes inside Claude Code.
+AIDD packages AI-assisted work into plugins, each owning one concern.
-## 🗺️ High-level
+## Marketplace and installation
```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
+flowchart TB
+ 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"]
```
-## 🧩 Anatomy of a plugin
-
-```txt
-plugins//
-├── .claude-plugin/plugin.json # manifest (name, version, description, skills[], $schema)
-├── 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)
-```
-
-Only `skills/` and the manifest are universal; a plugin ships any subset of the rest.
-
-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__/`.
-
-`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.
-
-## 🪝 Bundled hooks
+| 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. |
-Declared in `plugins//hooks/hooks.json`. They run Node, so users need `node` on their `PATH`:
+Marketplace and plugins version independently. See the [marketplace guide](MARKETPLACE.md) for registration, scopes and updates.
-| 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 |
-
-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:
-
-| 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 | — | 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 |
-
-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
+## 🧩 Anatomy of a plugin
-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.
+Manifest and skills are required in AIDD; other capabilities are optional. Locations are relative to the plugin directory.
-The line is therefore **not** "plugin or CLI". It is what the code is answering to:
+| Component | Location | Role |
+| --- | --- | --- |
+| 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 `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. |
-| | 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 |
-| Answering | a person or a skill, once | irrelevant | the `aidd` CLI |
+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.
-Two consequences, both already paid for:
+
+Package validation
-- 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.
+| 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. |
-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.
+
-## 🧠 Plugin concerns and layers
+## Responsibilities
-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.
+Place each capability in its owning concern and delegate to it.
| Plugin | Concern | Layer |
| ------------------- | -------------------- | ------------ |
@@ -94,79 +62,99 @@ Every capability lives in exactly one plugin, chosen by **concern**. This taxono
| `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 |
-`aidd-ui` is alpha: smoke-test only, off the curated install path.
+### Layer boundaries
-`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`.
+| 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. |
-`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.
+### Measurement boundary
-**Observation** writes only *about* the other layers, never the artifact it describes, and nothing may depend on it.
+| 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. |
-- **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.
-- `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.
+See the [journal contract](../aidd_docs/runs/README.md).
-## 🔀 Skills are routers
+## Execution model
-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.
+### Skills and actions
```mermaid
----
-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
+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"]
```
-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.
-
-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.
-
-## 🤖 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.
-
-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.
-
-## 🔗 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.
-
-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.
-
-Two paths are 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.
-
-## 🔎 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.
+### Agents and delegation
+
+| 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
+
+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 |
+| `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 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
+
+| 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
+
+| 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
+
+| 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? |
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
diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md
index 5090f6168..f0f351db7 100644
--- a/plugins/aidd-telemetry/README.md
+++ b/plugins/aidd-telemetry/README.md
@@ -2,142 +2,103 @@
# aidd-telemetry
-Know what a piece of work cost: which skill, which step and which task spent the tokens.
+Understand token usage by skill and task to improve workflows.
-> 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.
-
-## 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.
-
-```text
-period 2026-08-21 to 2026-08-21
+## Getting started
- sessions 1
- requests 3
- tokens 116,678 80% cache
- cost amount unknown
+Recording requires only `node`; enabling and reporting require `aidd`.
- 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
+```sh
+npm install -g @ai-driven-dev/cli
+aidd plugin install aidd-telemetry
```
-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.
-
-## Why it exists
+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.
-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.
+| 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 |
-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.
+## Reading a report
-## How it works
+### Report construction
-Two sources exist already. The plugin adds the one thing that joins them.
+AIDD connects provider counts to units of work.
```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"]
```
-- **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.
+Tools write their own transcripts without AIDD skill knowledge. Reports join these with
+hook observations by session.
-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.
+### Result interpretation
-## Getting started
-
-```sh
-npm install -g @ai-driven-dev/cli
-aidd plugin install aidd-telemetry
-```
+Usage groups: step, model, task, flow, tool and person. Attribution states its evidence:
-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 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 |
+- `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 | ✅ |
| **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 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 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.
-
-## 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
- [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.
-
-## 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.
+- **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.
+
+## 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
+
+- **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.
+
+## 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
- `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.
diff --git a/plugins/aidd-telemetry/hooks/opencode-plugin.js b/plugins/aidd-telemetry/hooks/opencode-plugin.js
index 26d30144b..96041c814 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
@@ -38,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
@@ -81,7 +76,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 +98,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") {
@@ -120,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);
@@ -161,3 +149,9 @@ export const AiddTelemetry = async (input) => ({
// journalCallFor's own comment for why a second export is ruled out.
AiddTelemetry.journalCallFor = journalCallFor;
AiddTelemetry.journalCallsFor = journalCallsFor;
+
+export default {
+ id: "aidd-telemetry",
+ server: AiddTelemetry,
+ setup: (ctx) => setupOpencodeEvents(ctx, AiddTelemetry),
+};
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..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);
}
@@ -49,10 +57,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__/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 384f07c23..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;
@@ -143,3 +147,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");
+ }
+});