Browse Source

feat(compat): add OpenCodeAdapter emitting .opencode from canonical content

Emits .opencode/agent/** from content/agents/**, stripping the oac: block.
24 of 33 round-trip byte-identical; the 9 that differ do so by exactly one
obsolete comment the seeding commit removed inconsistently — a seeding
artifact, pinned so the drifted set cannot grow silently.

Emission is textual excision, not parse-and-redump. The agents are
hand-authored with quoting a YAML dumper renormalizes, so a dumping emitter
would make all 33 byte-different and destroy the drift gate. Files are still
parsed and validated, and output is re-parsed to verify the oac: block is
gone and permissions desugar to the same ordered rules.

Output paths derive from file path, not category+id: test-engineer.md
declares id "tester", which category+id would orphan.

Two documented facts turned out false, verified against real OpenCode source
(config.ts) and a live probe on the installed 1.18.1 binary:

- name: is legal and in the Agent schema's knownKeys (config.ts:636). It is
  not merely allowed but required — dropping it renames CoderAgent to
  coder-agent and breaks every task: rule naming it.
- OpenCode does not reject unknown frontmatter at all. The Agent schema ends
  in .catchall(z.any()) and sweeps unknowns into options (config.ts:633-658).
  A probe carrying an oac: block loaded fine.

So agent-metadata.json was not needed for the reason its own doc gives.
Stripping oac: remains correct hygiene, but the sidecar's stated premise
does not survive contact with the source.

Confirms the 02 §1.2.7 spec defect: the permission vocabulary is open
(.catchall(PermissionRule)), with skill and question as known keys. A parser
built to the documented closed enum would drop externalscout's skill deny-all
and silently grant every skill. Capabilities stay opaque strings; externalscout
is pinned by regression test.
darrenhinde 2 weeks ago
parent
commit
d100ccdbf7

+ 539 - 0
packages/compatibility-layer/src/adapters/OpenCodeAdapter.ts

@@ -0,0 +1,539 @@
+/**
+ * OpenCodeAdapter — emits `.opencode/agent/**` from the canonical `content/agents/**` tree.
+ *
+ * ## What this adapter does
+ *
+ * Exactly one thing: it removes the `oac:` block from a canonical agent file and leaves every
+ * other byte alone. That is the whole transform. The canonical file is *defined* as
+ * "OpenCode-legal frontmatter PLUS an `oac:` block", so the OpenCode emission is the canonical
+ * file minus that block — nothing to re-derive, nothing to re-format.
+ *
+ * ## Why it strips text instead of re-serializing YAML
+ *
+ * The obvious implementation — parse the frontmatter, delete `oac`, `yaml.dump()` the rest —
+ * cannot work here, and the reason is worth recording because it will look like an omission
+ * otherwise.
+ *
+ * The 33 shipped agents are hand-authored, and their YAML carries author decisions no dumper
+ * reproduces: `"bash .opencode/skills/task-management/router.sh complete*": "allow"` is quoted
+ * while the sibling `contextscout: "allow"` is not; actions are quoted (`"deny"`) where YAML
+ * does not require it. A round-trip through `yaml.dump()` renormalizes all of it and every one
+ * of the 33 files comes back byte-different. Since `oac build && git diff --exit-code` is the
+ * gate this refactor rests on (07 Stage 3), a renormalizing emitter would either fail the gate
+ * forever or force a 33-file reformatting commit that buries any real drift in noise.
+ *
+ * Textual excision makes byte-identity the *default* rather than something chased with
+ * special cases, and it keeps comments and blank lines — which a dumper silently discards.
+ *
+ * The parse still happens: the file is validated against {@link CanonicalAgentSchema} and the
+ * emitted result is re-parsed and checked (see {@link OpenCodeAdapter.fromCanonical}). Parsing
+ * decides *whether* to emit; it just does not decide the bytes.
+ *
+ * ## What OpenCode actually accepts — verified against source, not docs
+ *
+ * Checked against the OpenCode checkout at `/Users/darrenhinde/Documents/GitHub/opencode`
+ * (`packages/opencode/src/config/config.ts`, v1.1.48+118 / commit `b9aad20be`) and confirmed
+ * live against the installed 1.18.1 binary:
+ *
+ * - **`name:` is legal.** It is listed in the Agent schema's own `knownKeys` (config.ts:636),
+ *   and the markdown agent loader builds `{ name: <path-derived>, ...md.data }` (config.ts:336-338)
+ *   — the frontmatter spread comes *second*, so an authored `name:` OVERRIDES the path-derived
+ *   one and becomes the key the agent registers under (config.ts:343). Emitting `name:` is
+ *   therefore not merely allowed, it is REQUIRED to preserve identity: drop it and `CoderAgent`
+ *   silently becomes `coder-agent`, breaking every `task:` rule that names it.
+ *
+ * - **Unknown keys are NOT rejected.** The Agent schema ends in `.catchall(z.any())`
+ *   (config.ts:633) and sweeps anything outside `knownKeys` into `options` (config.ts:654-658).
+ *   So `oac:` would not actually break OpenCode. Stripping it is hygiene — keeping build-time
+ *   metadata out of an agent's runtime `options` — not a hard requirement. See the report in
+ *   the subtask notes: the premise that justified `agent-metadata.json` does not hold.
+ *
+ * - **The permission vocabulary is OPEN.** `Permission` is a `z.object({...}).catchall(
+ *   PermissionRule).or(PermissionAction)` (config.ts:553-580), and `skill` (config.ts:575) and
+ *   `question` (config.ts:569) are known keys. Capability names are therefore treated here as
+ *   opaque strings — never validated against a closed enum.
+ *
+ * - **Authored order is load-bearing.** `permissionTransform` (config.ts:541) rebuilds the
+ *   permission map from `__originalKeys` precisely to preserve the order the author wrote,
+ *   because resolution is last-match-wins. Textual passthrough preserves it exactly.
+ */
+
+import * as yaml from "js-yaml";
+import { BaseAdapter } from "./BaseAdapter.js";
+import {
+  CanonicalAgentSchema,
+  desugarPermission,
+  type ConversionResult,
+  type GranularPermission,
+  type OpenAgent,
+  type ToolCapabilities,
+} from "../types.js";
+
+// ============================================================================
+// Constants
+// ============================================================================
+
+/** Where OpenCode discovers agent files. Mirrors `AGENT_GLOB` (config.ts:311). */
+export const OPENCODE_AGENT_ROOT = ".opencode/agent";
+
+/**
+ * The top-level frontmatter keys OpenCode recognises, copied verbatim from the Agent schema's
+ * own `knownKeys` set (config.ts:635-652).
+ *
+ * Used only to WARN. OpenCode's `.catchall(z.any())` (config.ts:633) means an unknown key is
+ * swept into `options` rather than rejected, so refusing to emit one would be this build
+ * inventing a restriction the target does not have.
+ */
+const OPENCODE_KNOWN_KEYS: ReadonlySet<string> = new Set([
+  "name",
+  "model",
+  "variant",
+  "prompt",
+  "description",
+  "temperature",
+  "top_p",
+  "mode",
+  "hidden",
+  "color",
+  "steps",
+  "maxSteps",
+  "options",
+  "permission",
+  "disable",
+  "tools",
+]);
+
+/** The `oac:` key itself — stripped, so it is never reported as an unknown key. */
+const OAC_KEY = "oac";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/** The result of emitting one canonical agent file. */
+export interface CanonicalEmitResult {
+  /** The emitted file content, ready to write verbatim. */
+  content: string;
+  /** Semantics lost or worth flagging. Empty means a provably faithful emission. */
+  warnings: string[];
+}
+
+/** Options for {@link OpenCodeAdapter.fromCanonical}. */
+export interface FromCanonicalOptions {
+  /** Source path, used only to make error messages name the offending file. */
+  filePath?: string;
+}
+
+/** Raised when a canonical source cannot be emitted. Never thrown for a recoverable loss. */
+export class OpenCodeEmitError extends Error {
+  constructor(message: string, filePath?: string) {
+    super(filePath ? `${filePath}: ${message}` : message);
+    this.name = "OpenCodeEmitError";
+  }
+}
+
+// ============================================================================
+// Frontmatter text surgery
+// ============================================================================
+
+/** A canonical file split into the pieces the emitter manipulates, byte-preserving. */
+interface SplitFile {
+  /** Frontmatter lines, excluding both `---` delimiters. */
+  frontmatter: string[];
+  /** Everything after the closing `---` line, verbatim (including its leading newline). */
+  rest: string;
+}
+
+const BLANK = /^\s*$/;
+const INDENTED = /^[ \t]/;
+
+/** True for a top-level `key:` line — the only thing that can end a nested block. */
+function isTopLevelKey(line: string, key: string): boolean {
+  return new RegExp(`^${key}\\s*:`).test(line);
+}
+
+/**
+ * Split a canonical file at its FIRST closing `---`.
+ *
+ * The first delimiter matters: agent bodies routinely contain `---` horizontal rules
+ * (`coder-agent.md` has five), and a greedy match would swallow half the prompt into the
+ * frontmatter.
+ */
+function splitFrontmatter(source: string, filePath?: string): SplitFile {
+  const lines = source.split("\n");
+
+  if (lines[0] !== "---" && lines[0]?.trimEnd() !== "---") {
+    throw new OpenCodeEmitError(
+      "no YAML frontmatter: a canonical agent file must open with `---`.",
+      filePath
+    );
+  }
+
+  const close = lines.findIndex((line, index) => index > 0 && line.trimEnd() === "---");
+  if (close === -1) {
+    throw new OpenCodeEmitError("unterminated YAML frontmatter: no closing `---`.", filePath);
+  }
+
+  return {
+    frontmatter: lines.slice(1, close),
+    // Rebuilt from the closing delimiter onward so no byte after it is ever touched.
+    rest: lines.slice(close).join("\n"),
+  };
+}
+
+/**
+ * Remove the top-level `oac:` block from frontmatter lines.
+ *
+ * Consumes the `oac:` line plus its indented continuation. Blank lines are consumed only when
+ * more of the block follows them, so a blank SEPARATOR after the block survives — otherwise an
+ * `oac:` block sitting mid-frontmatter would silently eat the spacing around the next key.
+ * Flow style (`oac: {id: x}`) has no continuation and drops as a single line.
+ */
+function stripOacBlock(frontmatter: string[]): string[] {
+  const out: string[] = [];
+  let i = 0;
+
+  while (i < frontmatter.length) {
+    const line = frontmatter[i]!;
+
+    if (!isTopLevelKey(line, OAC_KEY)) {
+      out.push(line);
+      i += 1;
+      continue;
+    }
+
+    i += 1; // the `oac:` line itself
+    let end = i;
+    while (end < frontmatter.length) {
+      const next = frontmatter[end]!;
+      if (INDENTED.test(next) || BLANK.test(next)) {
+        end += 1;
+        continue;
+      }
+      break;
+    }
+    // Give back trailing blanks: they separate the next key, they are not part of the block.
+    while (end > i && BLANK.test(frontmatter[end - 1]!)) end -= 1;
+    i = end;
+  }
+
+  return out;
+}
+
+/** Reassemble a file from stripped frontmatter and an untouched remainder. */
+function rebuild(frontmatter: string[], rest: string): string {
+  return ["---", ...frontmatter].join("\n") + "\n" + rest;
+}
+
+// ============================================================================
+// Parsing helpers
+// ============================================================================
+
+/** Parse a frontmatter block into a plain object, or fail with a located message. */
+function parseFrontmatter(lines: string[], filePath?: string): Record<string, unknown> {
+  let parsed: unknown;
+  try {
+    parsed = yaml.load(lines.join("\n"));
+  } catch (cause) {
+    throw new OpenCodeEmitError(
+      `frontmatter is not valid YAML: ${cause instanceof Error ? cause.message : String(cause)}`,
+      filePath
+    );
+  }
+
+  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
+    throw new OpenCodeEmitError("frontmatter must be a YAML mapping.", filePath);
+  }
+
+  return parsed as Record<string, unknown>;
+}
+
+/** Desugar a `permission:` value into ordered rules, or `undefined` when absent. */
+function orderedPermissions(value: unknown): GranularPermission | undefined {
+  return value === undefined ? undefined : desugarPermission(value);
+}
+
+/** Compare two ordered permission specs for exact equality, order included. */
+function samePermissions(a?: GranularPermission, b?: GranularPermission): boolean {
+  return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
+}
+
+// ============================================================================
+// Adapter
+// ============================================================================
+
+/**
+ * Emits OpenCode agent files from canonical sources.
+ *
+ * @example
+ * ```ts
+ * const adapter = new OpenCodeAdapter();
+ * const { content, warnings } = await adapter.fromCanonical(
+ *   readFileSync("content/agents/subagents/code/coder-agent.md", "utf-8")
+ * );
+ * // -> the same file with its `oac:` block removed, byte-for-byte otherwise
+ * ```
+ */
+export class OpenCodeAdapter extends BaseAdapter {
+  readonly name = "opencode";
+  readonly displayName = "OpenCode";
+
+  constructor() {
+    super();
+  }
+
+  // ==========================================================================
+  // Canonical emission — the build path
+  // ==========================================================================
+
+  /**
+   * Emit the OpenCode agent file for one canonical source.
+   *
+   * Deterministic by construction: the output is a subsequence of the input's bytes. There is
+   * no clock, no host path, no directory read and no map iteration anywhere on this path, so
+   * two runs cannot differ.
+   *
+   * `async` deliberately: every failure below must surface as a REJECTED promise, not a
+   * synchronous throw. A method typed `Promise<T>` that throws synchronously breaks
+   * `fromCanonical(x).catch(...)` — the caller's handler is never installed and the error
+   * escapes as an uncaught exception, which for a build tool means a stack trace instead of
+   * "this file is missing its oac: block".
+   *
+   * @param source - a canonical agent file: OpenCode frontmatter + an `oac:` block.
+   * @returns the emitted content plus any warnings.
+   * @throws {OpenCodeEmitError} when the source is malformed, carries no `oac:` block, or when
+   * the emitted result would not round-trip its permissions.
+   */
+  async fromCanonical(
+    source: string,
+    options: FromCanonicalOptions = {}
+  ): Promise<CanonicalEmitResult> {
+    const { filePath } = options;
+    const { frontmatter, rest } = splitFrontmatter(source, filePath);
+    const parsed = parseFrontmatter(frontmatter, filePath);
+
+    if (!(OAC_KEY in parsed)) {
+      throw new OpenCodeEmitError(
+        "no `oac:` block. A canonical agent file must carry one; without it this file is " +
+          "already an OpenCode agent and does not belong in the canonical tree.",
+        filePath
+      );
+    }
+
+    // Validate the whole canonical shape before emitting. This is what rejects a bad `oac:`
+    // block, an integer-like permission scope (which ECMAScript would reorder, silently
+    // changing last-match-wins precedence) and a malformed permission map.
+    const validated = CanonicalAgentSchema.safeParse(parsed);
+    if (!validated.success) {
+      throw new OpenCodeEmitError(
+        `canonical validation failed:\n${validated.error.errors
+          .map((issue) => `  - ${issue.path.join(".") || "(root)"}: ${issue.message}`)
+          .join("\n")}`,
+        filePath
+      );
+    }
+
+    const stripped = stripOacBlock(frontmatter);
+    const content = rebuild(stripped, rest);
+
+    return {
+      content,
+      warnings: this.verifyEmission(content, parsed, filePath),
+    };
+  }
+
+  /**
+   * Re-parse the emitted file and prove the strip did what it claims.
+   *
+   * Text surgery earns this check. A parse-and-dump emitter is wrong in obvious, visible ways;
+   * a line-range strip is wrong in quiet ones — an off-by-one that clips the last permission
+   * rule produces a file that still parses, still loads, and silently grants less (or more)
+   * than the author wrote. So the postcondition is asserted rather than assumed.
+   *
+   * @returns warnings for recoverable observations; throws when the emission is not faithful.
+   */
+  private verifyEmission(
+    content: string,
+    source: Record<string, unknown>,
+    filePath?: string
+  ): string[] {
+    const emitted = parseFrontmatter(splitFrontmatter(content, filePath).frontmatter, filePath);
+
+    // 1. The oac block is gone — proven by parsing, not by grepping for "oac:".
+    if (OAC_KEY in emitted) {
+      throw new OpenCodeEmitError(
+        "internal error: emitted frontmatter still carries an `oac:` key.",
+        filePath
+      );
+    }
+
+    // 2. Every other key survived, with an identical value. Catches both a clipped block and
+    //    an over-eager strip that swallowed the key after `oac:`.
+    const expected = Object.keys(source).filter((key) => key !== OAC_KEY);
+    const actual = Object.keys(emitted);
+
+    if (JSON.stringify(expected) !== JSON.stringify(actual)) {
+      throw new OpenCodeEmitError(
+        `internal error: stripping \`oac:\` changed the frontmatter keys.\n` +
+          `  expected: ${expected.join(", ") || "(none)"}\n` +
+          `  emitted:  ${actual.join(", ") || "(none)"}`,
+        filePath
+      );
+    }
+
+    for (const key of expected) {
+      if (JSON.stringify(emitted[key]) !== JSON.stringify(source[key])) {
+        throw new OpenCodeEmitError(
+          `internal error: stripping \`oac:\` altered the value of \`${key}\`.`,
+          filePath
+        );
+      }
+    }
+
+    // 3. Permissions desugar to the same ORDERED rules. The deep-equal above already implies
+    //    this, but it is asserted in the canonical representation as well: order is the whole
+    //    security property here, and `JSON.stringify` comparing two objects is only
+    //    order-sensitive by accident of insertion order.
+    if (
+      !samePermissions(
+        orderedPermissions(source["permission"]),
+        orderedPermissions(emitted["permission"])
+      )
+    ) {
+      throw new OpenCodeEmitError(
+        "internal error: emitted permissions do not match the source in order or content.",
+        filePath
+      );
+    }
+
+    // 4. Report anything OpenCode would sweep into `options` rather than act on.
+    return actual
+      .filter((key) => !OPENCODE_KNOWN_KEYS.has(key))
+      .map(
+        (key) =>
+          `⚠️  Frontmatter key '${key}' is not part of OpenCode's agent schema ` +
+          `(config.ts:635-652). OpenCode will collect it into the agent's \`options\` rather ` +
+          `than act on it.`
+      );
+  }
+
+  /**
+   * The path an emitted agent is written to, relative to the project root.
+   *
+   * Derived from the source's path under the content root, NOT from `oac.category` + `oac.id`.
+   * That distinction is load-bearing: `content/agents/subagents/code/test-engineer.md` declares
+   * `id: tester`, so composing the path from the id would emit `subagents/code/tester.md` and
+   * orphan the `test-engineer.md` that OpenCode is already loading. The filesystem is the
+   * authority on layout; `oac.category` merely describes it.
+   *
+   * @param relativePath - POSIX path relative to the content root, e.g.
+   * `"subagents/code/coder-agent.md"`.
+   */
+  outputPath(relativePath: string): string {
+    return `${OPENCODE_AGENT_ROOT}/${relativePath}`;
+  }
+
+  // ==========================================================================
+  // BaseAdapter surface
+  // ==========================================================================
+
+  /**
+   * OpenCode agent files ARE the canonical format minus `oac:`, so importing one is a matter
+   * of reading its frontmatter. Identity that only the `oac:` block carries (id, category,
+   * version, …) cannot be recovered from an OpenCode file and is left to the caller.
+   */
+  toOAC(source: string): Promise<OpenAgent> {
+    const { frontmatter, rest } = splitFrontmatter(source);
+    const parsed = parseFrontmatter(frontmatter);
+    const body = rest.replace(/^---\r?\n?/, "");
+
+    const name = typeof parsed["name"] === "string" ? parsed["name"] : "opencode-agent";
+
+    return Promise.resolve({
+      frontmatter: {
+        name,
+        description:
+          typeof parsed["description"] === "string"
+            ? parsed["description"]
+            : "Agent imported from OpenCode",
+        mode:
+          parsed["mode"] === "primary" || parsed["mode"] === "all" ? parsed["mode"] : "subagent",
+        ...(typeof parsed["temperature"] === "number"
+          ? { temperature: parsed["temperature"] }
+          : {}),
+        ...(typeof parsed["model"] === "string" ? { model: parsed["model"] } : {}),
+      },
+      metadata: { name, tags: [], dependencies: [] },
+      systemPrompt: body.trim(),
+      contexts: [],
+    });
+  }
+
+  /**
+   * Legacy `OpenAgent` -> OpenCode conversion.
+   *
+   * Deliberately unimplemented. The canonical build uses {@link fromCanonical}, whose input is
+   * the authored file itself; `OpenAgent` is the *lossy* legacy shape, whose `permission` field
+   * is an unordered `Record`. Emitting OpenCode from it would mean re-serializing a map whose
+   * key order is an ECMAScript accident — exactly the last-match-wins corruption this refactor
+   * exists to remove. Failing loudly beats shipping a plausible reordering.
+   */
+  fromOAC(_agent: OpenAgent): Promise<ConversionResult> {
+    return Promise.resolve(
+      this.createErrorResult([
+        "OpenCodeAdapter does not implement fromOAC(). Use fromCanonical(source) instead: " +
+          "OpenAgent cannot carry ordered permission rules, so emitting OpenCode from it " +
+          "would risk silently reordering a last-match-wins permission block.",
+      ])
+    );
+  }
+
+  /** Agents live under `.opencode/agent/`; the per-agent path comes from {@link outputPath}. */
+  getConfigPath(): string {
+    return `${OPENCODE_AGENT_ROOT}/`;
+  }
+
+  /**
+   * OpenCode is the canonical target: it is the only one that carries ordered, scoped
+   * permissions without degradation, which is why this adapter has no projection logic.
+   */
+  getCapabilities(): ToolCapabilities {
+    return {
+      name: this.name,
+      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.",
+        "Permission rules keep their authored order; OpenCode resolves them last-match-wins.",
+      ],
+    };
+  }
+
+  /**
+   * Nothing is lost emitting a canonical agent to OpenCode — the frontmatter is already
+   * OpenCode's own — so this reports only what OpenCode would not act on.
+   */
+  validateConversion(agent: OpenAgent): string[] {
+    const warnings: string[] = [];
+
+    // `hooks` is an OAC concept with no counterpart in OpenCode's Agent schema
+    // (config.ts:635-652), so it would be swept into `options` and never fire.
+    if (agent.frontmatter.hooks?.length) {
+      warnings.push(
+        this.unsupportedFeatureWarning("hooks", `${agent.frontmatter.hooks.length} hook(s)`)
+      );
+    }
+
+    return warnings;
+  }
+}

+ 8 - 0
packages/compatibility-layer/src/core/AdapterRegistry.ts

@@ -336,6 +336,14 @@ export class AdapterRegistry {
     } 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
+    }
   }
 }
 

+ 460 - 0
packages/compatibility-layer/tests/unit/adapters/OpenCodeAdapter.test.ts

@@ -0,0 +1,460 @@
+/**
+ * OpenCodeAdapter — emitting `.opencode/agent/**` from `content/agents/**`.
+ *
+ * The adapter's contract is narrow enough to state in one line: the emitted file is the
+ * canonical file minus its `oac:` block. So the tests here are mostly about proving that
+ * "minus its `oac:` block" is *all* that happens — that nothing else is quietly reformatted,
+ * reordered or dropped along the way.
+ *
+ * The strongest gate in this file is `round-trip against the live corpus`: no expectation was
+ * authored for it. It rebuilds the 33 agent files OpenCode is loading today and demands the
+ * bytes already on disk. Nothing in it can be wrong-by-guess, and it is what makes subtask 09's
+ * seeding reviewable as a no-op diff.
+ */
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { relative, sep } from "node:path";
+import * as yaml from "js-yaml";
+import { OpenCodeAdapter, OpenCodeEmitError } from "../../../src/adapters/OpenCodeAdapter.js";
+import { desugarPermission } from "../../../src/types.js";
+import { listFiles, repoPath } from "../../support/pending.js";
+
+const CONTENT_ROOT = repoPath("content/agents");
+const OPENCODE_ROOT = repoPath(".opencode/agent");
+
+/** POSIX path relative to the canonical content root. */
+function contentRelative(absolute: string): string {
+  return relative(CONTENT_ROOT, absolute).split(sep).join("/");
+}
+
+/** Parse a frontmatter block out of an emitted file. */
+function frontmatterOf(content: string): Record<string, unknown> {
+  const match = /^---\n([\s\S]*?)\n---\n/.exec(content);
+  expect(match, "emitted file has no parseable frontmatter").not.toBeNull();
+  return yaml.load(match![1]!) as Record<string, unknown>;
+}
+
+/** The top-level frontmatter keys, in emitted order. */
+function keysOf(content: string): string[] {
+  return Object.keys(frontmatterOf(content));
+}
+
+const adapter = (): OpenCodeAdapter => new OpenCodeAdapter();
+
+const MINIMAL = [
+  "---",
+  "name: Probe",
+  "description: A probe agent.",
+  "mode: subagent",
+  "oac:",
+  "  id: probe",
+  "  name: Probe",
+  "  category: core",
+  "  type: subagent",
+  "---",
+  "",
+  "# Probe",
+  "",
+].join("\n");
+
+// ============================================================================
+// Identity
+// ============================================================================
+
+describe("OpenCodeAdapter identity", () => {
+  it("reports its name and display name", () => {
+    expect(adapter().name).toBe("opencode");
+    expect(adapter().displayName).toBe("OpenCode");
+  });
+
+  it("declares granular permission support — OpenCode is the one target that loses nothing", () => {
+    const capabilities = adapter().getCapabilities();
+
+    expect(capabilities.supportsGranularPermissions).toBe(true);
+    expect(capabilities.configFormat).toBe("markdown");
+    expect(capabilities.outputStructure).toBe("directory");
+  });
+
+  it("writes agents under .opencode/agent/", () => {
+    expect(adapter().getConfigPath()).toBe(".opencode/agent/");
+  });
+});
+
+// ============================================================================
+// Stripping the oac: block
+// ============================================================================
+
+describe("OpenCodeAdapter oac stripping", () => {
+  it("emits no oac key — asserted by parsing the YAML, not by searching the text", async () => {
+    const { content } = await adapter().fromCanonical(MINIMAL);
+
+    // A string search for "oac:" would be satisfied by a body that merely mentions it, and
+    // would false-positive on a prompt discussing the oac block. Parse instead.
+    expect(frontmatterOf(content)).not.toHaveProperty("oac");
+  });
+
+  it("keeps every OpenCode-legal field, in authored order", async () => {
+    const { content } = await adapter().fromCanonical(MINIMAL);
+
+    expect(keysOf(content)).toEqual(["name", "description", "mode"]);
+  });
+
+  it("leaves the body untouched, including its --- horizontal rules", async () => {
+    const source = [
+      "---",
+      "name: Probe",
+      "description: A probe agent.",
+      "mode: subagent",
+      "oac:",
+      "  id: probe",
+      "  name: Probe",
+      "  category: core",
+      "  type: subagent",
+      "---",
+      "",
+      "# Probe",
+      "",
+      "---",
+      "",
+      "A section after a horizontal rule.",
+      "",
+    ].join("\n");
+
+    const { content } = await adapter().fromCanonical(source);
+
+    // The body's own `---` must not be mistaken for the frontmatter terminator.
+    expect(content).toContain("A section after a horizontal rule.");
+    expect(content.endsWith("A section after a horizontal rule.\n")).toBe(true);
+  });
+
+  it("strips an oac: block that is not the last key, keeping the key after it", async () => {
+    const source = [
+      "---",
+      "name: Probe",
+      "oac:",
+      "  id: probe",
+      "  name: Probe",
+      "  category: core",
+      "  type: subagent",
+      "description: A probe agent.",
+      "mode: subagent",
+      "---",
+      "body",
+      "",
+    ].join("\n");
+
+    const { content } = await adapter().fromCanonical(source);
+
+    expect(keysOf(content)).toEqual(["name", "description", "mode"]);
+  });
+
+  it("preserves a blank separator line that follows the oac: block", async () => {
+    const source = [
+      "---",
+      "name: Probe",
+      "oac:",
+      "  id: probe",
+      "  name: Probe",
+      "  category: core",
+      "  type: subagent",
+      "",
+      "description: A probe agent.",
+      "mode: subagent",
+      "---",
+      "body",
+      "",
+    ].join("\n");
+
+    const { content } = await adapter().fromCanonical(source);
+
+    // The blank line separates the next key; swallowing it would reformat the author's file.
+    expect(content).toContain("name: Probe\n\ndescription: A probe agent.");
+  });
+
+  it("preserves YAML comments, which a dump-based emitter would silently discard", async () => {
+    const source = [
+      "---",
+      "# A comment the author wrote.",
+      "name: Probe",
+      "description: A probe agent.",
+      "mode: subagent",
+      "oac:",
+      "  id: probe",
+      "  name: Probe",
+      "  category: core",
+      "  type: subagent",
+      "---",
+      "body",
+      "",
+    ].join("\n");
+
+    const { content } = await adapter().fromCanonical(source);
+
+    expect(content).toContain("# A comment the author wrote.");
+  });
+});
+
+// ============================================================================
+// Permissions
+// ============================================================================
+
+describe("OpenCodeAdapter permissions", () => {
+  const planner = readFileSync(
+    repoPath("packages/compatibility-layer/tests/golden/fixtures/fixture-planner.md"),
+    "utf-8"
+  );
+
+  it("serializes ordered rules back to a YAML mapping in original author order", async () => {
+    const { content } = await adapter().fromCanonical(planner);
+
+    const permission = frontmatterOf(content)["permission"] as Record<
+      string,
+      Record<string, string>
+    >;
+
+    // Capability order, then rule order within `bash`. Both are semantic: OpenCode flattens
+    // the map and resolves last-match-wins, so a reordering here silently changes what the
+    // agent may run.
+    expect(Object.keys(permission)).toEqual(["read", "bash", "edit", "write"]);
+    expect(Object.keys(permission["bash"]!)).toEqual(["*", "git status", "git log*"]);
+  });
+
+  it("keeps a deny-all-then-allowlist resolving exactly as authored", async () => {
+    const { content } = await adapter().fromCanonical(planner);
+
+    const rules = desugarPermission(frontmatterOf(content)["permission"]);
+    const bash = rules.find((entry) => entry.capability === "bash");
+
+    // The catch-all deny comes FIRST and the allows come after it. Reverse them and
+    // `git status` resolves to deny; drop the allows and the agent is bricked.
+    expect(bash?.rules).toEqual([
+      { pattern: "*", action: "deny" },
+      { pattern: "git status", action: "allow" },
+      { pattern: "git log*", action: "allow" },
+    ]);
+  });
+
+  it("never widens a deny-all bash block to `bash: true`", async () => {
+    // Guards the specific live hazard: `PermissionMapper.mapPermissionsFromOAC` defaults to a
+    // "permissive" strategy whose record branch returns `hasAllow || !hasDeny`, which answers
+    // `true` for CoderAgent's deny-all-then-allowlist. If that mapper ever reaches this build
+    // path, this test is what catches it.
+    const { content } = await adapter().fromCanonical(
+      readFileSync(repoPath("content/agents/subagents/code/coder-agent.md"), "utf-8")
+    );
+
+    const permission = frontmatterOf(content)["permission"] as Record<string, unknown>;
+
+    expect(permission["bash"]).not.toBe(true);
+    expect((permission["bash"] as Record<string, string>)["*"]).toBe("deny");
+  });
+
+  it("preserves the `skill` capability, which a closed vocabulary would drop", async () => {
+    // ExternalScout denies all skills then re-allows context7. `skill` is absent from doc 02
+    // §1.2.7's closed vocabulary but IS a real OpenCode permission key (config.ts:575), so a
+    // parser built to that enum would drop this block and silently grant every skill.
+    const { content } = await adapter().fromCanonical(
+      readFileSync(repoPath("content/agents/subagents/core/externalscout.md"), "utf-8")
+    );
+
+    const permission = frontmatterOf(content)["permission"] as Record<
+      string,
+      Record<string, string>
+    >;
+
+    expect(permission).toHaveProperty("skill");
+    expect(Object.keys(permission["skill"]!)).toEqual(["*", "*context7*"]);
+    expect(permission["skill"]!["*"]).toBe("deny");
+  });
+});
+
+// ============================================================================
+// Round-trip against the live corpus
+// ============================================================================
+
+/**
+ * The obsolete header that 23 committed `.opencode/agent` files still carry — a comment
+ * pointing at the very sidecar this refactor dissolves.
+ *
+ * The seeding commit (`cf97d98`) dropped it from 9 of those 23 and kept it in the other 14.
+ * That inconsistency is a seeding artifact, not an adapter behaviour: the comment is absent
+ * from those 9 canonical sources, so no emitter can reproduce it.
+ *
+ * It is pinned here rather than papered over. The adapter is NOT special-cased to re-insert
+ * it — doing so would be forging bytes the source does not contain. Instead the 9 are named,
+ * so the drift is visible, cannot grow silently, and stays owned by whoever re-seeds
+ * `content/agents` (subtask 09). Resolving it means either restoring the comment in those 9
+ * sources or removing it from all 23 — a content decision, made once, deliberately.
+ */
+const SIDECAR_COMMENT =
+  [
+    "# OpenCode Agent Configuration",
+    "# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:",
+    "# .opencode/config/agent-metadata.json",
+    "",
+  ].join("\n") + "\n";
+
+/** The 9 sources whose committed output still carries {@link SIDECAR_COMMENT}. */
+const DRIFTED: ReadonlySet<string> = new Set([
+  "content/copywriter.md",
+  "content/technical-writer.md",
+  "meta/system-builder.md",
+  "subagents/planning/story-mapper.md",
+  "subagents/system-builder/agent-generator.md",
+  "subagents/system-builder/command-creator.md",
+  "subagents/system-builder/domain-analyzer.md",
+  "subagents/test/simple-responder.md",
+  "subagents/utils/image-specialist.md",
+]);
+
+describe("OpenCodeAdapter round-trip", () => {
+  const sources = listFiles(CONTENT_ROOT);
+
+  it("finds the canonical corpus", () => {
+    expect(sources.length).toBeGreaterThan(0);
+  });
+
+  it.each(sources.map((file) => [contentRelative(file), file] as const))(
+    "reproduces .opencode/agent/%s byte-for-byte",
+    async (rel, file) => {
+      const { content } = await adapter().fromCanonical(readFileSync(file, "utf-8"), {
+        filePath: file,
+      });
+      const committed = readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8");
+
+      // The drifted 9 must differ ONLY by the obsolete comment — re-inserting it recovers the
+      // committed bytes exactly. Any other difference in them still fails here.
+      const expected = DRIFTED.has(rel)
+        ? committed.replace(`---\n${SIDECAR_COMMENT}`, "---\n")
+        : committed;
+
+      expect(content).toBe(expected);
+    }
+  );
+
+  it("pins the drifted set, so it cannot grow unnoticed", async () => {
+    const drifted: string[] = [];
+
+    for (const file of sources) {
+      const rel = contentRelative(file);
+      const { content } = await adapter().fromCanonical(readFileSync(file, "utf-8"));
+      if (content !== readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8")) drifted.push(rel);
+    }
+
+    expect(drifted.sort()).toEqual([...DRIFTED].sort());
+  });
+
+  it("maps each source onto its committed output path", () => {
+    for (const file of sources) {
+      const rel = contentRelative(file);
+      expect(adapter().outputPath(rel)).toBe(`.opencode/agent/${rel}`);
+    }
+  });
+
+  it("derives the output path from the file, not from oac.category + oac.id", () => {
+    // `content/agents/subagents/code/test-engineer.md` declares `id: tester`. Composing the
+    // path from the id would emit `subagents/code/tester.md` and orphan the `test-engineer.md`
+    // OpenCode actually loads.
+    expect(adapter().outputPath("subagents/code/test-engineer.md")).toBe(
+      ".opencode/agent/subagents/code/test-engineer.md"
+    );
+  });
+});
+
+// ============================================================================
+// Determinism
+// ============================================================================
+
+describe("OpenCodeAdapter determinism", () => {
+  it("emits byte-identical output across runs and instances", async () => {
+    const source = readFileSync(repoPath("content/agents/core/openagent.md"), "utf-8");
+
+    const first = await adapter().fromCanonical(source);
+    const second = await adapter().fromCanonical(source);
+
+    expect(second.content).toBe(first.content);
+  });
+
+  it("emits no timestamp or absolute host path", async () => {
+    const { content } = await adapter().fromCanonical(
+      readFileSync(repoPath("content/agents/core/openagent.md"), "utf-8"),
+      { filePath: "/Users/someone/content/agents/core/openagent.md" }
+    );
+
+    // filePath is a diagnostic only — it must never leak into emitted bytes, or the output
+    // would depend on where the repo is checked out.
+    expect(content).not.toMatch(/\/Users\//);
+    expect(content).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
+  });
+});
+
+// ============================================================================
+// Rejection
+// ============================================================================
+
+describe("OpenCodeAdapter rejection", () => {
+  it("rejects a file with no oac: block", async () => {
+    const source = ["---", "name: Probe", "description: d", "mode: subagent", "---", "b", ""].join(
+      "\n"
+    );
+
+    await expect(adapter().fromCanonical(source)).rejects.toThrow(OpenCodeEmitError);
+    await expect(adapter().fromCanonical(source)).rejects.toThrow(/oac:/);
+  });
+
+  it("rejects a file with no frontmatter", async () => {
+    await expect(adapter().fromCanonical("# Just a document\n")).rejects.toThrow(
+      /frontmatter/
+    );
+  });
+
+  it("rejects unterminated frontmatter", async () => {
+    await expect(adapter().fromCanonical("---\nname: Probe\n")).rejects.toThrow(
+      /unterminated/i
+    );
+  });
+
+  it("rejects an integer-like permission scope that ECMAScript would reorder", async () => {
+    const source = [
+      "---",
+      "name: Probe",
+      "description: A probe agent.",
+      "mode: subagent",
+      "permission:",
+      "  bash:",
+      '    "*": "deny"',
+      '    "8080": "allow"',
+      "oac:",
+      "  id: probe",
+      "  name: Probe",
+      "  category: core",
+      "  type: subagent",
+      "---",
+      "body",
+      "",
+    ].join("\n");
+
+    // An integer-like key is hoisted to the front of the object by ECMAScript, which would
+    // silently invert last-match-wins precedence. Rejecting beats emitting a reordered file.
+    await expect(adapter().fromCanonical(source)).rejects.toThrow(/integer-like/);
+  });
+
+  it("names the file it rejected", async () => {
+    await expect(
+      adapter().fromCanonical("# no frontmatter\n", { filePath: "content/agents/broken.md" })
+    ).rejects.toThrow(/content\/agents\/broken\.md/);
+  });
+
+  it("refuses fromOAC() rather than reordering an unordered permission map", async () => {
+    const result = await adapter().fromOAC({
+      frontmatter: { name: "Probe", description: "d", mode: "subagent" },
+      metadata: { tags: [], dependencies: [] },
+      systemPrompt: "body",
+      contexts: [],
+    });
+
+    expect(result.success).toBe(false);
+    expect(result.errors?.[0]).toMatch(/fromCanonical/);
+  });
+});