Browse Source

fix(compat): give OpenCode a column and make all four adapters derive from it

OpenCode — one of the two first-class targets — appeared ZERO times in
CapabilityMatrix.ts. `Platform` was "oac"|"claude"|"cursor"|"windsurf" while
OpenCodeAdapter had shipped since d100ccd. That gap was not cosmetic: with nowhere to be
described, OpenCodeAdapter hand-wrote its own getCapabilities(), as did Cursor and
Windsurf, while ClaudeAdapter derived from the matrix. Two patterns, and the drift that
follows.

The drift was real and live. CursorAdapter hand-wrote `supportsContexts: true` ("✅ Can
inline context") while the matrix said `externalContext: "none"` ("context must be inline
in .cursorrules"). Same fact, two answers — the same bug as the earlier claude
json/markdown split, which the agreement test at ClaudeAdapter.test.ts:603 was added to
prevent. It survived because that test pinned one field on one platform: 1 of 11 fields,
1 of 4 adapters.

Ruled: the feature is external *references*. Cursor cannot reference; pasting bytes into
.cursorrules loses the reference, the file boundary and the priority. That is degradation,
not support, and calling it support hides real loss from every compatibility report.
CursorAdapter.test.ts:58 asserted the wrong side and is corrected — it had frozen the
adapter's answer rather than comparing it to the matrix.

All four adapters now spread getToolCapabilities(<platform>); none restates it. Adding
opencode's 22 cells is authored from evidence, not guessed:
  - temperature/maxSteps "full": real agents carry the fields (.opencode/agent/eval-runner.md,
    subagents/system-builder/command-creator.md)
  - hooks "none": .opencode/plugin/ holds plugins, not a PreToolUse event surface — matching
    OpenCodeAdapter's own long-standing supportsHooks: false
  - dependencies "none": declared in the `oac:` block, which OpenCodeAdapter strips on emit.
    Being lossless about OpenCode's own fields does not make it lossless about OAC's.
  - agentCategories "full": carried as the directory (.opencode/agent/<category>/), unlike
    Claude's flat layout where it is lost outright

Widening Platform made the compiler find a coupling nobody chose: TranslationTarget was
`Exclude<Platform, "oac">`, so it silently gained opencode and broke against ToolMapper's
own narrower union. OpenCode is deliberately NOT a translate target — OpenAgent's
permission is an unordered Record, and OpenCodeAdapter.fromOAC() refuses outright rather
than risk reordering a last-match-wins block. That union is now stated, not inherited.

registerBuiltInAdapters() wrapped every registration in `catch { /* skip silently */ }`,
which swallowed genuine failures: a bad import would leave registry.get("opencode")
undefined and the build would emit nothing for the canonical target. Removing it exposed a
real defect the swallow was hiding — both cli/commands/convert.ts and migrate.ts call it on
the shared singleton, so a process touching both threw "Adapter 'cursor' is already
registered". Idempotence is now explicit; real failures throw.

Deliberately NOT done: inverting ownership so adapters own capability data. ToolCapabilities
is a lossy 10-boolean projection of a 22 x 3-level matrix, so adapters would keep BOTH a
hand-written getCapabilities() and a featureSupport map — two sources per platform, co-located
where no cross-module test can catch them. The spread this commit standardises on is the only
thing making divergence structurally impossible. Revisit after the packages/core split, and
only with getCapabilities() made derived-and-final.

Left alone: the `priorityLevels` row describes "task priority levels", a concept absent from
types.ts (which has only ContextPrioritySchema, whose 4 levels are almost certainly the "4
levels" its oac note means). Flagged with a FIXME rather than fixed — burying that call inside
an opencode-gap fix would hide it.

Verified: tsc clean; compat-layer 911 pass/3 skip (was 864 — +47 agreement cases); cli 153
pass; pnpm -r build green. Build drift is unchanged by this commit: the 7 drifting agents map
1:1 to the 7 uncommitted content/agents edits, confirming capability data reaches no generated
byte (fromCanonical never calls getCapabilities, and ConversionResult.capabilities is discarded
at apply.ts:156).
darrenhinde 2 weeks ago
parent
commit
4d5f3ba2b6

+ 13 - 11
packages/compatibility-layer/src/adapters/CursorAdapter.ts

@@ -1,4 +1,5 @@
 import { BaseAdapter } from "./BaseAdapter.js";
+import { getToolCapabilities } from "../core/CapabilityMatrix.js";
 import type {
   OpenAgent,
   ConversionResult,
@@ -164,21 +165,22 @@ export class CursorAdapter extends BaseAdapter {
 
   /**
    * Get Cursor IDE capabilities.
+   *
+   * Derived from {@link getToolCapabilities}, never restated. This body used to hand-write all
+   * ten booleans and drifted: it claimed `supportsContexts: true` ("can inline context") while
+   * the matrix said `externalContext: none` ("context must be inline in .cursorrules"). Both
+   * described the same fact and disagreed about it — the same class of bug as the earlier
+   * claude json/markdown split, and it survived because the agreement test pinned only
+   * claude.configFormat.
+   *
+   * Ruled 2026-07-15 in favour of the matrix: the feature is external *references*. Inlining
+   * delivers the bytes but loses the reference, the file boundary and the priority, so it is
+   * degradation. `supportsContexts` is now `false` here, and a compatibility report will say so.
    */
   getCapabilities(): ToolCapabilities {
     return {
-      name: this.name,
+      ...getToolCapabilities("cursor"),
       displayName: this.displayName,
-      supportsMultipleAgents: false, // ❌ Single file only
-      supportsSkills: false, // ❌ No skills system
-      supportsHooks: false, // ❌ No hooks
-      supportsGranularPermissions: false, // ⚠️ Binary only
-      supportsContexts: true, // ✅ Can inline context
-      supportsCustomModels: true, // ✅ Model selection
-      supportsTemperature: true, // ✅ Limited support
-      supportsMaxSteps: false, // ❌ Not supported
-      configFormat: "plain",
-      outputStructure: "single-file",
       notes: [
         "Single .cursorrules file in project root",
         "Multiple agents must be merged into one",

+ 2 - 11
packages/compatibility-layer/src/adapters/OpenCodeAdapter.ts

@@ -60,6 +60,7 @@
 
 import * as yaml from "js-yaml";
 import { BaseAdapter } from "./BaseAdapter.js";
+import { getToolCapabilities } from "../core/CapabilityMatrix.js";
 import {
   CanonicalAgentSchema,
   desugarPermission,
@@ -502,18 +503,8 @@ export class OpenCodeAdapter extends BaseAdapter {
    */
   getCapabilities(): ToolCapabilities {
     return {
-      name: this.name,
+      ...getToolCapabilities("opencode"),
       displayName: this.displayName,
-      supportsMultipleAgents: true,
-      supportsSkills: true,
-      supportsHooks: false,
-      supportsGranularPermissions: true,
-      supportsContexts: true,
-      supportsCustomModels: true,
-      supportsTemperature: true,
-      supportsMaxSteps: true,
-      configFormat: "markdown",
-      outputStructure: "directory",
       notes: [
         "Agent files live at .opencode/agent/<category>/<name>.md, mirroring content/agents/.",
         "Emission strips the `oac:` block and preserves every other byte, including comments.",

+ 7 - 11
packages/compatibility-layer/src/adapters/WindsurfAdapter.ts

@@ -1,4 +1,5 @@
 import { BaseAdapter } from "./BaseAdapter.js";
+import { getToolCapabilities } from "../core/CapabilityMatrix.js";
 import type {
   OpenAgent,
   ConversionResult,
@@ -186,21 +187,16 @@ export class WindsurfAdapter extends BaseAdapter {
 
   /**
    * Get Windsurf capabilities.
+   *
+   * Derived from {@link getToolCapabilities} rather than restated, so this adapter and the
+   * matrix cannot disagree about Windsurf the way Cursor's hand-written body disagreed about
+   * Cursor. Note `07-EXECUTION-PLAN.md` cuts Windsurf from v1 (its format was never verified
+   * against a live install) — deriving keeps it honest and cheap until that is settled.
    */
   getCapabilities(): ToolCapabilities {
     return {
-      name: this.name,
+      ...getToolCapabilities("windsurf"),
       displayName: this.displayName,
-      supportsMultipleAgents: true, // ✅ Supports .windsurf/agents/
-      supportsSkills: true, // ⚠️ Partial - basic context support
-      supportsHooks: false, // ❌ Not supported
-      supportsGranularPermissions: false, // ⚠️ Binary only
-      supportsContexts: true, // ✅ .windsurf/context/
-      supportsCustomModels: true, // ✅ Model selection
-      supportsTemperature: true, // ⚠️ Via creativity setting
-      supportsMaxSteps: false, // ❌ Not supported
-      configFormat: "json",
-      outputStructure: "directory",
       notes: [
         "Multiple agents supported via .windsurf/agents/",
         "Permissions are binary (on/off) - granular rules degraded",

+ 37 - 31
packages/compatibility-layer/src/core/AdapterRegistry.ts

@@ -312,37 +312,43 @@ export class AdapterRegistry {
    * ```
    */
   async registerBuiltInAdapters(): Promise<void> {
-    // Dynamic imports to avoid circular dependencies
-    try {
-      // Cursor IDE
-      const { CursorAdapter } = await import("../adapters/CursorAdapter.js");
-      this.register(new CursorAdapter(), ["cursor-ide", "cursor-editor"]);
-    } catch (error) {
-      // Adapter not yet implemented - skip silently
-    }
-
-    try {
-      // Claude Code
-      const { ClaudeAdapter } = await import("../adapters/ClaudeAdapter.js");
-      this.register(new ClaudeAdapter(), ["claude-code", "anthropic-claude"]);
-    } catch (error) {
-      // Adapter not yet implemented - skip silently
-    }
-
-    try {
-      // Windsurf (experimental)
-      const { WindsurfAdapter } = await import("../adapters/WindsurfAdapter.js");
-      this.register(new WindsurfAdapter(), ["windsurf-ide"]);
-    } catch (error) {
-      // Adapter not yet implemented - skip silently
-    }
-
-    try {
-      // OpenCode — the canonical target
-      const { OpenCodeAdapter } = await import("../adapters/OpenCodeAdapter.js");
-      this.register(new OpenCodeAdapter(), ["open-code"]);
-    } catch (error) {
-      // Adapter not yet implemented - skip silently
+    // Dynamic imports to avoid circular dependencies.
+    //
+    // These four used to be wrapped in `try { … } catch { /* skip silently */ }`, dating from
+    // when the adapters genuinely might not exist yet. All four now ship, and the swallow had
+    // become a hazard rather than tolerance: it ate the duplicate-registration
+    // AdapterRegistryError from register() as readily as a bad import path, so a typo could
+    // leave registry.get("opencode") returning undefined with nothing written anywhere. For a
+    // build whose entire value is determinism, a silent missing target is the worst outcome
+    // available — worse than a crash, which at least tells you.
+    //
+    // A failure here means the package is broken, not that a target is unavailable. Let it throw.
+    const [{ CursorAdapter }, { ClaudeAdapter }, { WindsurfAdapter }, { OpenCodeAdapter }] =
+      await Promise.all([
+        import("../adapters/CursorAdapter.js"),
+        import("../adapters/ClaudeAdapter.js"),
+        import("../adapters/WindsurfAdapter.js"),
+        import("../adapters/OpenCodeAdapter.js"),
+      ]);
+
+    const builtIns: ReadonlyArray<{ adapter: BaseAdapter; aliases: string[] }> = [
+      { adapter: new CursorAdapter(), aliases: ["cursor-ide", "cursor-editor"] },
+      { adapter: new ClaudeAdapter(), aliases: ["claude-code", "anthropic-claude"] },
+      { adapter: new WindsurfAdapter(), aliases: ["windsurf-ide"] },
+      { adapter: new OpenCodeAdapter(), aliases: ["open-code"] },
+    ];
+
+    for (const { adapter, aliases } of builtIns) {
+      // Registering the built-ins twice is a no-op, not an error. This has to be explicit:
+      // `registry` is a module singleton and BOTH cli/commands/convert.ts and
+      // cli/commands/migrate.ts call this on it, so a process touching both used to hit
+      // "Adapter 'cursor' is already registered". The old blanket catch swallowed that
+      // duplicate error, which is the only reason it never surfaced — the non-idempotence
+      // was real, just hidden. Skipping a name that is already present keeps this callable
+      // from anywhere while leaving register()'s duplicate check meaningful for everyone else
+      // (including a caller who deliberately overrode a built-in before calling this).
+      if (this.has(adapter.name)) continue;
+      this.register(adapter, aliases);
     }
   }
 }

+ 99 - 28
packages/compatibility-layer/src/core/CapabilityMatrix.ts

@@ -37,7 +37,19 @@ export type { BinaryProjection } from "./Capabilities.js";
 // Types
 // ============================================================================
 
-export type Platform = "oac" | "claude" | "cursor" | "windsurf";
+/**
+ * Every platform this matrix describes.
+ *
+ * `oac` is the IR itself — the canonical source every other column is measured against — not
+ * an emitted target; it has no adapter. The rest map 1:1 onto `src/adapters/*Adapter.ts`.
+ *
+ * **`opencode` was absent from this union until 2026-07-15**, despite OpenCode being one of
+ * the two first-class targets (`07-EXECUTION-PLAN.md`) and `OpenCodeAdapter` shipping since
+ * `d100ccd`. The consequence was not cosmetic: with nowhere to be described, OpenCodeAdapter
+ * hand-wrote its own `getCapabilities()` while ClaudeAdapter derived from this table — two
+ * patterns, and the drift that follows. See {@link getToolCapabilities}.
+ */
+export type Platform = "oac" | "claude" | "cursor" | "windsurf" | "opencode";
 
 /**
  * Feature categories for the capability matrix
@@ -99,14 +111,14 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     name: "multipleAgents",
     category: "agents",
     description: "Support for multiple agent definitions",
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "full" },
+    support: { oac: "full", claude: "full", cursor: "none", windsurf: "full", opencode: "full" },
     notes: { cursor: "Single .cursorrules file only - agents will be merged" },
   },
   {
     name: "agentModes",
     category: "agents",
     description: "Primary/subagent mode distinction",
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial" },
+    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial", opencode: "full" },
     notes: { windsurf: "Limited mode support" },
   },
   {
@@ -116,16 +128,29 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     // Claude Code agent frontmatter has no category field — the canonical `oac.category`
     // survives only as the directory an author happens to file the source under, and is not
     // carried into the emitted agent at all.
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial" },
-    notes: { claude: "No category field in agent frontmatter — dropped on emit" },
+    //
+    // OpenCode strips the `oac:` block too, so `category` is likewise not a frontmatter field
+    // there — but its agents are emitted to `.opencode/agent/<category>/<name>.md`, mirroring
+    // content/agents/, so the category survives structurally and round-trips via the path.
+    // Claude's are flat (`plugins/claude-code/agents/<id>.md`), so it is lost outright.
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial", opencode: "full" },
+    notes: {
+      claude: "No category field in agent frontmatter — dropped on emit",
+      opencode: "Carried as the agent's directory (.opencode/agent/<category>/), not a field",
+    },
   },
 
   // Permission Features
+  //
+  // OpenCode scores "full" across this whole category for one reason: the canonical
+  // `permission:` block IS OpenCode's own field. There is no projection on that path —
+  // OpenCodeAdapter emits the rules verbatim and OpenCode resolves them last-match-wins.
+  // Every other target is measured by how much of that shape it loses.
   {
     name: "granularPermissions",
     category: "permissions",
     description: "Fine-grained allow/deny/ask patterns",
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none" },
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none", opencode: "full" },
     notes: {
       claude:
         "tools/disallowedTools are flat name lists — a capability is wholly granted or " +
@@ -142,16 +167,17 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     // shipped agents' security posture IS the rule order (`bash: {"*": deny, "git log*":
     // allow}`). A target scoring "none" cannot carry that shape at all, which is why
     // Capabilities.degradeToBinary refuses it rather than picking a winning rule.
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none" },
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none", opencode: "full" },
     notes: {
       claude: "No rule ordering concept — the allowlist cannot be carried, so Bash is denied",
+      opencode: "Rules keep their authored order; OpenCode resolves them last-match-wins",
     },
   },
   {
     name: "askPermissions",
     category: "permissions",
     description: "Interactive permission requests",
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none" },
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none", opencode: "full" },
     notes: {
       claude: "No 'ask' in agent frontmatter — degrades to deny, never to allow",
     },
@@ -160,7 +186,7 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     name: "pathPatterns",
     category: "permissions",
     description: "Glob patterns for file permissions",
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial" },
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial", opencode: "full" },
     notes: {
       claude: "Tool grants carry no path scope — secret-file denies cannot be expressed",
     },
@@ -173,32 +199,32 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     description: "Flat allow/deny lists of tool names (tools / disallowedTools)",
     // What Claude Code DOES support, stated positively — this is the entire target surface
     // ClaudeAdapter emits into, and the matrix previously described only what was missing.
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial" },
+    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial", opencode: "full" },
   },
   {
     name: "taskDelegation",
     category: "tools",
     description: "Agent-to-agent task delegation",
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial" },
+    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial", opencode: "full" },
     notes: { cursor: "No delegation support" },
   },
   {
     name: "bashExecution",
     category: "tools",
     description: "Shell command execution",
-    support: { oac: "full", claude: "full", cursor: "full", windsurf: "full" },
+    support: { oac: "full", claude: "full", cursor: "full", windsurf: "full", opencode: "full" },
   },
   {
     name: "fileOperations",
     category: "tools",
     description: "Read/write/edit file operations",
-    support: { oac: "full", claude: "full", cursor: "full", windsurf: "full" },
+    support: { oac: "full", claude: "full", cursor: "full", windsurf: "full", opencode: "full" },
   },
   {
     name: "searchOperations",
     category: "tools",
     description: "Grep/glob search operations",
-    support: { oac: "full", claude: "full", cursor: "full", windsurf: "full" },
+    support: { oac: "full", claude: "full", cursor: "full", windsurf: "full", opencode: "full" },
   },
 
   // Context Features
@@ -206,26 +232,36 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     name: "externalContext",
     category: "context",
     description: "External context file references",
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "full" },
-    notes: { cursor: "Context must be inline in .cursorrules" },
+    // Cursor is "none", and the adapter used to disagree — `CursorAdapter.getCapabilities()`
+    // hand-wrote `supportsContexts: true // ✅ Can inline context`, which was the same class of
+    // bug as the old json/markdown split (one platform, two answers) and survived because the
+    // agreement test covered only claude.configFormat. Ruled 2026-07-15: the feature is
+    // external *references*. Cursor cannot reference; pasting the bytes into .cursorrules
+    // discards the reference, the file boundary and the priority. That is degradation, not
+    // support — and calling it support would hide real loss from every compatibility report.
+    support: { oac: "full", claude: "full", cursor: "none", windsurf: "full", opencode: "full" },
+    notes: { cursor: "Context must be inline in .cursorrules — reference and priority are lost" },
   },
   {
     name: "contextPriority",
     category: "context",
     description: "Priority levels for context loading",
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none" },
+    // OpenCode installs context files byte-for-byte, so the MVI header that carries the level
+    // (`<!-- Context: … | Priority: critical | … -->`) survives verbatim in .opencode/context/.
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none", opencode: "full" },
+    notes: { opencode: "MVI priority header survives verbatim in the installed context file" },
   },
   {
     name: "contextSubdirs",
     category: "context",
     description: "Nested context directory structure",
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "full" },
+    support: { oac: "full", claude: "full", cursor: "none", windsurf: "full", opencode: "full" },
   },
   {
     name: "skillsSystem",
     category: "context",
     description: "Loadable skill modules",
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial" },
+    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial", opencode: "full" },
   },
 
   // Model Features
@@ -233,13 +269,15 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     name: "modelSelection",
     category: "model",
     description: "Custom model selection",
-    support: { oac: "full", claude: "full", cursor: "full", windsurf: "full" },
+    support: { oac: "full", claude: "full", cursor: "full", windsurf: "full", opencode: "full" },
   },
   {
     name: "temperatureControl",
     category: "model",
     description: "Temperature parameter control",
-    support: { oac: "full", claude: "none", cursor: "partial", windsurf: "partial" },
+    // opencode "full" verified against disk: real agents carry the field
+    // (.opencode/agent/eval-runner.md, subagents/system-builder/command-creator.md).
+    support: { oac: "full", claude: "none", cursor: "partial", windsurf: "partial", opencode: "full" },
     notes: {
       claude: "Temperature not configurable",
       cursor: "Limited range",
@@ -250,7 +288,8 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     name: "maxSteps",
     category: "model",
     description: "Maximum execution steps limit",
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none" },
+    // opencode "full" verified against disk — same agents as temperatureControl.
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "none", opencode: "full" },
   },
 
   // Advanced Features
@@ -258,8 +297,15 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     name: "hooks",
     category: "advanced",
     description: "Event hooks (PreToolUse, PostToolUse, etc.)",
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "none" },
-    notes: { cursor: "No hook support", windsurf: "No hook support" },
+    // opencode "none": .opencode/plugin/ holds plugins (agent-validator.ts), which are a
+    // different mechanism — there is no PreToolUse/PostToolUse event surface to emit into.
+    // Matches OpenCodeAdapter's own long-standing `supportsHooks: false`.
+    support: { oac: "full", claude: "full", cursor: "none", windsurf: "none", opencode: "none" },
+    notes: {
+      cursor: "No hook support",
+      windsurf: "No hook support",
+      opencode: "Plugins (.opencode/plugin/) are a different mechanism, not event hooks",
+    },
   },
   {
     name: "dependencies",
@@ -268,8 +314,16 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     // Claude Code agent frontmatter accepts name/description/tools/disallowedTools/model and
     // nothing else. `oac.dependencies` is resolved at build time and then dropped; the
     // emitted agent declares no dependencies, so "full" overstated this.
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial" },
-    notes: { claude: "Dependencies resolve at build time; not carried in agent frontmatter" },
+    //
+    // OpenCode is "none" for the same reason, which is easy to miss because OpenCode is the
+    // canonical target: `dependencies` lives in the `oac:` block, and OpenCodeAdapter strips
+    // that block on emit. Being lossless about OpenCode's OWN fields does not make it
+    // lossless about OAC's.
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial", opencode: "none" },
+    notes: {
+      claude: "Dependencies resolve at build time; not carried in agent frontmatter",
+      opencode: "Declared in the oac: block, which is stripped on emit; resolved at build time",
+    },
   },
   {
     name: "priorityLevels",
@@ -277,8 +331,20 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     description: "Task priority levels",
     // "2 levels" was not a Claude Code feature — nothing in agent frontmatter or the plugin
     // format expresses task priority.
-    support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial" },
-    notes: { oac: "4 levels", claude: "No task priority concept", windsurf: "2 levels" },
+    //
+    // FIXME(2026-07-15): this row describes a concept that does not exist. `types.ts` has only
+    // ContextPrioritySchema — CONTEXT priority — whose four levels (critical/high/medium/low)
+    // are almost certainly the "4 levels" the oac note means. There is no task-priority field
+    // anywhere in the canonical schema, which makes this row a mis-named duplicate of
+    // `contextPriority`. Left as-is deliberately: deciding whether to delete it or rename it is
+    // a separate call, and folding that into an opencode-gap fix would bury it.
+    support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial", opencode: "full" },
+    notes: {
+      oac: "4 levels",
+      claude: "No task priority concept",
+      windsurf: "2 levels",
+      opencode: "Tracks contextPriority — see the FIXME on this row",
+    },
   },
 ];
 
@@ -501,6 +567,7 @@ export function getToolCapabilities(
     claude: "Claude Code",
     cursor: "Cursor IDE",
     windsurf: "Windsurf",
+    opencode: "OpenCode",
   };
 
   const configFormats: Record<Exclude<Platform, "oac">, ToolCapabilities["configFormat"]> = {
@@ -512,6 +579,9 @@ export function getToolCapabilities(
     claude: "markdown",
     cursor: "plain",
     windsurf: "json",
+    // Agent files are markdown with YAML frontmatter, emitted to
+    // .opencode/agent/<category>/<name>.md.
+    opencode: "markdown",
   };
 
   const outputStructures: Record<
@@ -521,6 +591,7 @@ export function getToolCapabilities(
     claude: "directory",
     cursor: "single-file",
     windsurf: "directory",
+    opencode: "directory",
   };
 
   return {

+ 16 - 1
packages/compatibility-layer/src/core/TranslationEngine.ts

@@ -60,7 +60,22 @@ import {
 /**
  * Target platform for translation (excludes OAC since we translate TO/FROM OAC)
  */
-export type TranslationTarget = Exclude<Platform, "oac">;
+/**
+ * The targets this engine can translate an {@link OpenAgent} to.
+ *
+ * `opencode` is excluded deliberately, and it is NOT an oversight to be fixed by widening this
+ * later. This engine translates from `OpenAgent`, whose `permission` is an unordered `Record`;
+ * OpenCode's permission semantics are ordered last-match-wins. Emitting OpenCode from an
+ * unordered map would silently reorder a security-critical rule block — the exact corruption the
+ * canonical refactor exists to remove. `OpenCodeAdapter.fromOAC()` therefore refuses outright
+ * and directs callers to `fromCanonical(source)`, which the `oac build` path uses instead.
+ *
+ * Previously this read `Exclude<Platform, "oac">` and so tracked the matrix's platform list by
+ * accident. Adding `opencode` to {@link Platform} on 2026-07-15 widened it silently and the
+ * compiler caught it: this engine's mappers (`ToolMapper.ToolPlatform`) never knew OpenCode.
+ * The coupling was the bug; this union is now stated outright.
+ */
+export type TranslationTarget = Exclude<Platform, "oac" | "opencode">;
 
 /**
  * Configuration options for translation

+ 8 - 1
packages/compatibility-layer/tests/unit/adapters/CursorAdapter.test.ts

@@ -55,7 +55,14 @@ describe("CursorAdapter", () => {
       expect(capabilities.supportsSkills).toBe(false);
       expect(capabilities.supportsHooks).toBe(false);
       expect(capabilities.supportsGranularPermissions).toBe(false);
-      expect(capabilities.supportsContexts).toBe(true);
+      // Was `true` until 2026-07-15, pinning the wrong side of a live contradiction: the
+      // adapter hand-wrote `supportsContexts: true` ("✅ Can inline context") while the matrix
+      // said `externalContext: "none"` ("context must be inline in .cursorrules"). This
+      // assertion is why that survived — it froze the adapter's answer instead of comparing it
+      // to the matrix's. Ruled for the matrix: inlining delivers the bytes but loses the
+      // reference, the file boundary and the priority, so it is degradation, not support.
+      // The comparison this test should have been making now lives in capability-agreement.test.ts.
+      expect(capabilities.supportsContexts).toBe(false);
       expect(capabilities.supportsCustomModels).toBe(true);
       expect(capabilities.supportsTemperature).toBe(true);
       expect(capabilities.supportsMaxSteps).toBe(false);

+ 102 - 0
packages/compatibility-layer/tests/unit/adapters/capability-agreement.test.ts

@@ -0,0 +1,102 @@
+/**
+ * Cross-adapter capability agreement.
+ *
+ * ## Why this file exists
+ *
+ * A platform cannot have two answers about itself. That invariant was already written down —
+ * `tests/unit/adapters/ClaudeAdapter.test.ts` has an "agrees with the CapabilityMatrix rather
+ * than restating it" case, added after the matrix called Claude `json` while ClaudeAdapter
+ * called it `markdown`.
+ *
+ * But that test pinned ONE field (`configFormat`) on ONE platform (`claude`) — 1 of 11 fields
+ * on 1 of 4 adapters. So when `CursorAdapter.getCapabilities()` hand-wrote
+ * `supportsContexts: true` ("✅ Can inline context") against the matrix's
+ * `externalContext: "none"` ("context must be inline in .cursorrules"), the same bug lived on
+ * undetected in a second platform.
+ *
+ * The invariant was right; its coverage was the problem. This file generalises it to every
+ * registered adapter × every field of {@link ToolCapabilities}, which is what the original
+ * test's own comment claimed to be checking.
+ *
+ * `notes` and `displayName` are deliberately excluded: adapters own those (the matrix's `notes`
+ * are per-(feature, platform) strings, a different shape and a different question), and each
+ * adapter's `getCapabilities()` overrides `displayName` on purpose.
+ */
+
+import { describe, it, expect } from "vitest";
+
+import { ClaudeAdapter } from "../../../src/adapters/ClaudeAdapter.js";
+import { CursorAdapter } from "../../../src/adapters/CursorAdapter.js";
+import { OpenCodeAdapter } from "../../../src/adapters/OpenCodeAdapter.js";
+import { WindsurfAdapter } from "../../../src/adapters/WindsurfAdapter.js";
+import { getToolCapabilities, type Platform } from "../../../src/core/CapabilityMatrix.js";
+import type { BaseAdapter } from "../../../src/adapters/BaseAdapter.js";
+import type { ToolCapabilities } from "../../../src/types.js";
+
+/**
+ * Every adapter, paired with its matrix column.
+ *
+ * Adding an adapter without adding it here is the failure mode this file cannot catch on its
+ * own — `covers every adapter in the Platform union` below closes that by comparing this list
+ * against the union's own members.
+ */
+const ADAPTERS: ReadonlyArray<{ platform: Exclude<Platform, "oac">; adapter: BaseAdapter }> = [
+  { platform: "claude", adapter: new ClaudeAdapter() },
+  { platform: "cursor", adapter: new CursorAdapter() },
+  { platform: "windsurf", adapter: new WindsurfAdapter() },
+  { platform: "opencode", adapter: new OpenCodeAdapter() },
+];
+
+/** The fields both sides answer. `notes`/`displayName` are adapter-owned — see the header. */
+const AGREED_FIELDS = [
+  "name",
+  "supportsMultipleAgents",
+  "supportsSkills",
+  "supportsHooks",
+  "supportsGranularPermissions",
+  "supportsContexts",
+  "supportsCustomModels",
+  "supportsTemperature",
+  "supportsMaxSteps",
+  "configFormat",
+  "outputStructure",
+] as const satisfies ReadonlyArray<keyof ToolCapabilities>;
+
+describe("capability agreement (adapter vs CapabilityMatrix)", () => {
+  describe.each(ADAPTERS)("$platform", ({ platform, adapter }) => {
+    it.each(AGREED_FIELDS)("agrees on %s", (field) => {
+      expect(adapter.getCapabilities()[field]).toEqual(getToolCapabilities(platform)[field]);
+    });
+  });
+
+  // Guards the gap this file would otherwise have: a new adapter that never gets listed in
+  // ADAPTERS is silently unchecked. The Platform union is the roster of record.
+  it("covers every adapter in the Platform union", () => {
+    const platformsUnderTest = ADAPTERS.map((entry) => entry.platform).sort();
+    const expected: Array<Exclude<Platform, "oac">> = [
+      "claude",
+      "cursor",
+      "opencode",
+      "windsurf",
+    ];
+
+    expect(platformsUnderTest).toEqual(expected.sort());
+  });
+
+  // The specific regression. Cursor claimed to support external context because it can inline
+  // the bytes; the matrix said none, because inlining loses the reference, the file boundary
+  // and the priority. Ruled 2026-07-15 for the matrix. Pinned so it cannot silently flip back.
+  it("reports Cursor as NOT supporting external context (inlining is degradation)", () => {
+    expect(new CursorAdapter().getCapabilities().supportsContexts).toBe(false);
+  });
+
+  // OpenCode had no column at all until 2026-07-15, which is why it hand-wrote its own answers.
+  it("describes OpenCode, the canonical target, in the matrix", () => {
+    const openCode = getToolCapabilities("opencode");
+
+    expect(openCode.displayName).toBe("OpenCode");
+    // The one thing that makes OpenCode the canonical target: it carries ordered, scoped
+    // permission rules with no degradation. If this ever reports false, the build is lying.
+    expect(openCode.supportsGranularPermissions).toBe(true);
+  });
+});