Kaynağa Gözat

feat(compat): retarget ClaudeAdapter to the plugins/claude-code layout

fromCanonical emits plugins/claude-code/agents/<id>.md with flat frontmatter
in committed key order. .claude/ is gone from every output path, and
config.json is gone entirely — Claude Code has no such agent-config file, so
the ~25 tests pinning its structure were deleted rather than retargeted; they
described a format nothing reads. Tool order is Read, Write, Edit, Glob,
Grep, Bash, WebFetch, Task — the only total order the 7 live agents admit.

Permissions route only through Capabilities.ts, never PermissionMapper, whose
mapPermissionsFromOAC defaults to a permissive strategy and would return
bash: true for coder-agent's deny-all.

Regenerating the 7 shipped agents surfaced a live security finding: 4 of 7
are strictly MORE permissive than canonical intent.

  coder-agent      shipped grants Edit unscoped; canonical denies **/*.env*,
                   **/*.key, **/*.secret
  context-manager  shipped grants Write + Bash unscoped with no
                   disallowedTools; canonical scopes both to .opencode/context/**
  external-scout   shipped grants Read/Write/Bash/WebFetch unscoped; canonical
                   allows only curl context7 and jq
  test-engineer    shipped grants Edit + Bash unscoped; canonical denies sudo *
                   and asks on rm -rf *

They were hand-written before a build existed, which is the drift this design
removes by construction. Nothing was widened to force byte-identity.

CapabilityMatrix gains orderedPermissionRules (claude: none — the refactor's
central fact, previously unrepresented) and binaryToolGrants. Corrects
overstatements: agentCategories and priorityLevels partial->none,
dependencies full->none, claude configFormat json->markdown; the matrix and
the adapter had contradicted each other about the same platform.
darrenhinde 2 hafta önce
ebeveyn
işleme
627b9ce1d2

+ 372 - 324
packages/compatibility-layer/src/adapters/ClaudeAdapter.ts

@@ -1,25 +1,56 @@
+import matter from "gray-matter";
+import { dump } from "js-yaml";
 import { BaseAdapter } from "./BaseAdapter.js";
-import type {
-  OpenAgent,
-  ConversionResult,
-  ToolCapabilities,
-  ToolConfig,
-  AgentFrontmatter,
-  SkillReference,
-  HookDefinition,
-  HookEvent,
+import { projectToFlatTools, rulesFor, type ToolBinding } from "../core/Capabilities.js";
+import { getToolCapabilities } from "../core/CapabilityMatrix.js";
+import {
+  CanonicalAgentSchema,
+  desugarPermission,
+  type AgentFrontmatter,
+  type ConversionResult,
+  type GranularPermission,
+  type HookDefinition,
+  type HookEvent,
+  type OpenAgent,
+  type SkillReference,
+  type ToolCapabilities,
+  type ToolConfig,
 } from "../types.js";
 
 /**
- * Claude Code adapter for converting between OpenAgents Control and Claude Code formats.
- * 
- * Claude Code uses:
- * - `.claude/config.json` for main agent configuration
- * - `.claude/agents/*.md` for subagents (YAML frontmatter + markdown)
- * - `.claude/skills/` for Skills (context/knowledge injection)
- * 
+ * Claude Code adapter — emits the `plugins/claude-code/` plugin layout.
+ *
+ * Claude Code agents are one markdown file per agent under `plugins/claude-code/agents/`,
+ * carrying flat YAML frontmatter:
+ *
+ * ```yaml
+ * name: code-reviewer          # the canonical `oac.id`, NOT the authored display name
+ * description: …
+ * tools: Read, Glob, Grep
+ * disallowedTools: Write, Edit, Bash, Task
+ * model: sonnet
+ * ```
+ *
+ * ## Why this is not `.claude/`
+ *
+ * This adapter previously emitted `.claude/config.json` + `.claude/agents/*.md`. The
+ * `config.json` half was fabricated — Claude Code has no such agent-config file — and the
+ * real target is the plugin tree that already ships in this repo. `plugins/claude-code/`
+ * is therefore the only output root; `.claude/` appears nowhere in what this adapter emits.
+ *
+ * ## Permissions are NOT decided here
+ *
+ * Claude Code's frontmatter carries two flat lists and no scoping: no ordered globs, no
+ * `ask`, no last-match-wins. Canonical agents rely on all three. Collapsing that safely is
+ * a security decision, so it lives in exactly one place — {@link projectToFlatTools} in
+ * `core/Capabilities.ts`, which fails CLOSED. This class contributes tool NAMING and
+ * ORDERING only.
+ *
+ * Concretely, `PermissionMapper.mapPermissionsFromOAC` must never be used here: it defaults
+ * to `strategy="permissive"` (`hasAllow || !hasDeny`), which answers `bash: true` for
+ * coder-agent's deny-all-then-allowlist block and would hand Claude Code unrestricted Bash.
+ *
  * @see https://code.claude.com/docs/en/sub-agents
- * @see https://code.claude.com/docs/en/skills
  */
 export class ClaudeAdapter extends BaseAdapter {
   readonly name = "claude";
@@ -30,126 +61,162 @@ export class ClaudeAdapter extends BaseAdapter {
   }
 
   // ============================================================================
-  // CONVERSION METHODS
+  // CANONICAL EMISSION (fromCanonical) — the `oac build` path
+  // ============================================================================
+
+  /**
+   * Emit one canonical agent file as its Claude Code plugin agent file.
+   *
+   * `async` rather than a plain `Promise` return so a malformed source REJECTS instead of
+   * throwing synchronously — a caller doing `adapter.fromCanonical(x).catch(…)` must not be
+   * bypassed by the parse failing before the promise is ever constructed.
+   *
+   * @param source raw canonical `.md` — OpenCode-legal frontmatter plus the `oac:` block
+   * @returns the emitted path, its exact bytes, and one warning per semantic actually lost
+   * @throws {Error} if the source does not parse against {@link CanonicalAgentSchema}
+   */
+  async fromCanonical(source: string): Promise<ClaudeEmission> {
+    const parsed = CanonicalAgentSchema.safeParse(structuredClone(matter(source).data));
+
+    if (!parsed.success) {
+      throw new Error(
+        `ClaudeAdapter: source is not a canonical agent file: ${parsed.error.issues
+          .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
+          .join("; ")}`
+      );
+    }
+
+    const agent = parsed.data;
+    const warnings: string[] = [];
+    const body = matter(source).content.trim();
+
+    const frontmatter = this.buildAgentFrontmatter(
+      {
+        name: agent.oac.id,
+        description: agent.description,
+        model: agent.model,
+        temperature: agent.temperature,
+        maxSteps: agent.maxSteps,
+        permission: agent.permission,
+      },
+      warnings
+    );
+
+    return {
+      path: this.agentPath(agent.oac.id),
+      content: `---\n${frontmatter}---\n\n${body}\n`,
+      warnings,
+    };
+  }
+
+  /**
+   * Where a canonical id lands in the plugin tree.
+   *
+   * Keyed by `oac.id`, never by source filename or display name: the canonical ids and the
+   * Claude Code filenames genuinely differ (`contextscout` → `context-scout.md`,
+   * `reviewer` → `code-reviewer.md`, `tester` → `test-engineer.md`), and only the id is
+   * stable identity.
+   */
+  agentPath(id: string): string {
+    return `plugins/claude-code/agents/${id}.md`;
+  }
+
+  // ============================================================================
+  // CONVERSION METHODS (legacy OpenAgent interface)
   // ============================================================================
 
   /**
    * Convert Claude Code format TO OpenAgents Control format.
-   * 
-   * Expects either:
-   * - A JSON string (config.json content)
-   * - A markdown string with YAML frontmatter (agent.md content)
-   * 
-   * @param source - Claude config.json or agent.md content
-   * @returns OpenAgent object
+   *
+   * This is the IMPORT direction — it ingests whatever a user already has, including the
+   * older `.claude/` shapes, so it deliberately still accepts `config.json`. Nothing here
+   * decides where output is written.
+   *
+   * @param source Claude agent markdown, or a legacy `config.json`
    */
   toOAC(source: string): Promise<OpenAgent> {
-    // Try parsing as JSON first (config.json)
     if (source.trim().startsWith("{")) {
       return Promise.resolve(this.parseClaudeConfig(source));
     }
 
-    // Otherwise, parse as markdown with frontmatter (agent.md)
     return Promise.resolve(this.parseClaudeAgent(source));
   }
 
   /**
-   * Convert FROM OpenAgents Control format to Claude Code format.
-   * 
-   * Generates:
-   * - `.claude/config.json` for primary agents
-   * - `.claude/agents/{name}.md` for subagents
-   * - Skills conversion (contexts → skills)
-   * 
-   * @param agent - OpenAgent to convert
-   * @returns ConversionResult with generated files and warnings
+   * Convert FROM an in-memory OpenAgent to the Claude Code plugin layout.
+   *
+   * Every agent — primary or subagent — emits exactly one
+   * `plugins/claude-code/agents/{name}.md`. The old primary/subagent split existed only to
+   * choose between `config.json` and an agent file; with `config.json` gone there is one
+   * shape, which is what Claude Code actually reads.
    */
   fromOAC(agent: OpenAgent): Promise<ConversionResult> {
-    const warnings: string[] = [];
+    const warnings: string[] = [...this.validateConversion(agent)];
     const configs: ToolConfig[] = [];
 
-    // Validate conversion
-    const validationWarnings = this.validateConversion(agent);
-    warnings.push(...validationWarnings);
-
-    // Check for unsupported features
-    if (agent.frontmatter.temperature !== undefined) {
-      warnings.push(
-        this.unsupportedFeatureWarning("temperature", agent.frontmatter.temperature)
-      );
-    }
-
-    if (agent.frontmatter.maxSteps !== undefined) {
-      warnings.push(
-        this.unsupportedFeatureWarning("maxSteps", agent.frontmatter.maxSteps)
-      );
-    }
-
-    // Determine if this is a subagent or primary agent
-    const isSubagent = agent.frontmatter.mode === "subagent";
+    const frontmatter = this.buildAgentFrontmatter(
+      {
+        name: agent.frontmatter.name,
+        description: agent.frontmatter.description,
+        model: agent.frontmatter.model,
+        temperature: agent.frontmatter.temperature,
+        maxSteps: agent.frontmatter.maxSteps,
+        permission: agent.frontmatter.permission
+          ? desugarPermission(agent.frontmatter.permission)
+          : undefined,
+        tools: agent.frontmatter.tools,
+      },
+      warnings
+    );
 
-    if (isSubagent) {
-      // Generate subagent markdown file
-      const agentMd = this.generateClaudeAgentMarkdown(agent, warnings);
-      configs.push({
-        fileName: `.claude/agents/${agent.frontmatter.name}.md`,
-        content: agentMd,
-        encoding: "utf-8",
-      });
-    } else {
-      // Generate primary agent config.json
-      const configJson = this.generateClaudeConfig(agent, warnings);
-      configs.push({
-        fileName: ".claude/config.json",
-        content: JSON.stringify(configJson, null, 2),
-        encoding: "utf-8",
-      });
-    }
+    configs.push({
+      fileName: this.agentPath(agent.frontmatter.name),
+      content: `---\n${frontmatter}---\n\n${agent.systemPrompt.trim()}\n`,
+      encoding: "utf-8",
+    });
 
-    // Generate skills from contexts if present
     if (agent.contexts && agent.contexts.length > 0) {
-      const skillConfigs = this.generateSkillsFromContexts(agent.contexts);
-      configs.push(...skillConfigs);
+      configs.push(...this.generateSkillsFromContexts(agent.contexts));
     }
 
     return Promise.resolve(this.createSuccessResult(configs, warnings));
   }
 
   /**
-   * Get the configuration path for Claude Code.
+   * Get the configuration root for Claude Code.
    */
   getConfigPath(): string {
-    return ".claude/";
+    return "plugins/claude-code/";
   }
 
   /**
    * Get Claude Code capabilities.
+   *
+   * Delegates to the {@link getToolCapabilities} matrix rather than restating it: the two
+   * previously disagreed (the matrix called Claude `json`, this class called it `markdown`),
+   * and a platform cannot have two answers about itself. The matrix is the single row set;
+   * only the prose notes are added here.
    */
   getCapabilities(): ToolCapabilities {
     return {
-      name: this.name,
+      ...getToolCapabilities("claude"),
       displayName: this.displayName,
-      supportsMultipleAgents: true,
-      supportsSkills: true,
-      supportsHooks: true,
-      supportsGranularPermissions: false, // Only binary/simplified permissions
-      supportsContexts: true,
-      supportsCustomModels: true,
-      supportsTemperature: false, // ⚠️ Not supported
-      supportsMaxSteps: false,
-      configFormat: "markdown",
-      outputStructure: "directory",
       notes: [
-        "Permissions are binary (on/off) - granular OAC permissions degrade to permissionMode",
-        "Temperature control not supported - use creativity settings instead",
-        "Hooks support: PreToolUse, PostToolUse, PermissionRequest, AgentStart, AgentEnd",
-        "Skills system provides context injection similar to OAC contexts",
+        "Agents emit to plugins/claude-code/agents/<id>.md — one flat markdown file each",
+        "Permissions are two flat lists (tools/disallowedTools) — ordered rules, path globs " +
+          "and 'ask' have no equivalent and degrade fail-closed to disallowedTools",
+        "Temperature and maxSteps are not expressible in agent frontmatter",
+        "Skills provide context injection similar to OAC contexts",
       ],
     };
   }
 
   /**
    * Validate if an agent can be converted with full fidelity.
+   *
+   * Reports only what this adapter can see without projecting permissions; the lossy
+   * permission detail comes from {@link projectToFlatTools} at emit time, so it is not
+   * duplicated (and cannot drift) here.
    */
   validateConversion(agent: OpenAgent): string[] {
     const warnings: string[] = [];
@@ -162,7 +229,6 @@ export class ClaudeAdapter extends BaseAdapter {
       warnings.push("⚠️  Agent description is required for Claude Code");
     }
 
-    // Check for granular permissions that will be degraded
     if (agent.frontmatter.permission) {
       const hasGranularPerms = Object.values(agent.frontmatter.permission).some(
         (perm) => typeof perm === "object" && !Array.isArray(perm)
@@ -172,8 +238,8 @@ export class ClaudeAdapter extends BaseAdapter {
         warnings.push(
           this.degradedFeatureWarning(
             "granular permissions",
-            "allow/deny/ask per operation",
-            "binary permissionMode (default/acceptEdits/dontAsk/bypassPermissions)"
+            "ordered allow/deny/ask rules per operation",
+            "flat tools/disallowedTools lists (fail-closed)"
           )
         );
       }
@@ -182,12 +248,134 @@ export class ClaudeAdapter extends BaseAdapter {
     return warnings;
   }
 
+  // ============================================================================
+  // GENERATION HELPERS
+  // ============================================================================
+
+  /**
+   * Build the YAML frontmatter block shared by both emit paths.
+   *
+   * Key order is fixed (`name, description, tools, disallowedTools, model`) to match the
+   * committed corpus and to keep output deterministic — it must never depend on the source's
+   * own key order.
+   */
+  private buildAgentFrontmatter(input: ClaudeAgentInput, warnings: string[]): string {
+    const lines = [
+      yamlLine("name", input.name),
+      yamlLine("description", input.description),
+    ];
+
+    const { tools, disallowedTools } = this.resolveTools(input, warnings);
+
+    if (tools.length > 0) lines.push(yamlLine("tools", tools.join(", ")));
+    if (disallowedTools.length > 0) {
+      lines.push(yamlLine("disallowedTools", disallowedTools.join(", ")));
+    }
+    if (input.model) lines.push(yamlLine("model", input.model));
+
+    if (input.temperature !== undefined) {
+      warnings.push(this.unsupportedFeatureWarning("temperature", input.temperature));
+    }
+    if (input.maxSteps !== undefined) {
+      warnings.push(this.unsupportedFeatureWarning("maxSteps", input.maxSteps));
+    }
+
+    return lines.join("");
+  }
+
+  /**
+   * Decide which Claude Code tools an agent gets, and which it is explicitly denied.
+   *
+   * The allow/deny decision is entirely {@link projectToFlatTools}'s; this method only picks
+   * which tools are in play and hands back the two lists in {@link CLAUDE_TOOL_BINDINGS}
+   * order.
+   */
+  private resolveTools(
+    input: ClaudeAgentInput,
+    warnings: string[]
+  ): { tools: string[]; disallowedTools: string[] } {
+    if (input.permission) {
+      const bindings = CLAUDE_TOOL_BINDINGS.filter(
+        (binding) => rulesFor(input.permission!, binding.capability).length > 0
+      );
+
+      warnings.push(...unmappableCapabilityWarnings(input.permission));
+
+      const projection = projectToFlatTools(input.permission, bindings, {
+        target: "Claude Code",
+      });
+
+      warnings.push(...projection.warnings);
+      return { tools: projection.tools, disallowedTools: projection.disallowedTools };
+    }
+
+    // No permission block: fall back to the authored `tools:` on/off map, which carries no
+    // scoping to lose and therefore needs no projection.
+    if (input.tools) {
+      const enabled = new Set(
+        Object.entries(input.tools)
+          .filter(([, on]) => on)
+          .map(([tool]) => tool)
+      );
+
+      return {
+        tools: CLAUDE_TOOL_BINDINGS.filter((b) => enabled.has(b.capability)).map((b) => b.tool),
+        disallowedTools: [],
+      };
+    }
+
+    return { tools: [], disallowedTools: [] };
+  }
+
+  /**
+   * Generate Skills from OAC contexts.
+   *
+   * Phase 1 does not wire this into `oac build` (agents only) — the path is kept so the
+   * `oac-compat convert` CLI keeps working.
+   */
+  private generateSkillsFromContexts(
+    contexts: Array<{ path: string; priority?: string; description?: string }>
+  ): ToolConfig[] {
+    return contexts.map((ctx) => {
+      const skillName =
+        ctx.path
+          .split("/")
+          .pop()
+          ?.replace(/\.md$/, "")
+          .toLowerCase()
+          .replace(/\s+/g, "-") || "context-skill";
+
+      const skillContent = `---
+name: ${skillName}
+description: ${ctx.description || `Context from ${ctx.path}`}
+---
+
+# ${skillName}
+
+This skill provides context from: \`${ctx.path}\`
+
+Priority: ${ctx.priority || "medium"}
+
+Load the full context file for detailed information:
+\`\`\`bash
+cat ${ctx.path}
+\`\`\`
+`;
+
+      return {
+        fileName: `plugins/claude-code/skills/${skillName}/SKILL.md`,
+        content: skillContent,
+        encoding: "utf-8" as const,
+      };
+    });
+  }
+
   // ============================================================================
   // PARSING HELPERS (toOAC)
   // ============================================================================
 
   /**
-   * Parse Claude config.json to OpenAgent.
+   * Parse a legacy Claude config.json to OpenAgent.
    */
   private parseClaudeConfig(source: string): OpenAgent {
     const config = this.safeParseJSON(source, "config.json");
@@ -220,7 +408,7 @@ export class ClaudeAdapter extends BaseAdapter {
   }
 
   /**
-   * Parse Claude agent.md (subagent) to OpenAgent.
+   * Parse a Claude agent markdown file to OpenAgent.
    */
   private parseClaudeAgent(source: string): OpenAgent {
     const { frontmatter, body } = this.parseFrontmatter(source);
@@ -285,149 +473,6 @@ export class ClaudeAdapter extends BaseAdapter {
     return { frontmatter, body };
   }
 
-  // ============================================================================
-  // GENERATION HELPERS (fromOAC)
-  // ============================================================================
-
-  /**
-   * Generate Claude config.json from OpenAgent.
-   */
-  private generateClaudeConfig(
-    agent: OpenAgent,
-    warnings: string[]
-  ): Record<string, unknown> {
-    const config: Record<string, unknown> = {
-      name: agent.frontmatter.name,
-      description: agent.frontmatter.description,
-      systemPrompt: agent.systemPrompt,
-    };
-
-    // Model mapping
-    if (agent.frontmatter.model) {
-      config.model = this.mapOACModelToClaude(agent.frontmatter.model);
-    }
-
-    // Tools mapping
-    if (agent.frontmatter.tools) {
-      config.tools = this.mapOACToolsToClaude(agent.frontmatter.tools);
-    }
-
-    // Skills mapping
-    if (agent.frontmatter.skills && agent.frontmatter.skills.length > 0) {
-      config.skills = agent.frontmatter.skills.map((skill) =>
-        typeof skill === "string" ? skill : skill.name
-      );
-    }
-
-    // Hooks mapping
-    if (agent.frontmatter.hooks && agent.frontmatter.hooks.length > 0) {
-      config.hooks = this.mapOACHooksToClaude(agent.frontmatter.hooks);
-    }
-
-    // Permission mode mapping
-    if (agent.frontmatter.permission) {
-      config.permissionMode = this.mapOACPermissionsToClaude(
-        agent.frontmatter.permission,
-        warnings
-      );
-    }
-
-    return config;
-  }
-
-  /**
-   * Generate Claude agent.md (subagent) from OpenAgent.
-   */
-  private generateClaudeAgentMarkdown(
-    agent: OpenAgent,
-    warnings: string[]
-  ): string {
-    const frontmatter: Record<string, unknown> = {
-      name: agent.frontmatter.name,
-      description: agent.frontmatter.description,
-    };
-
-    // Tools
-    if (agent.frontmatter.tools) {
-      const tools = this.mapOACToolsToClaude(agent.frontmatter.tools);
-      frontmatter.tools = tools.join(", ");
-    }
-
-    // Model
-    if (agent.frontmatter.model) {
-      frontmatter.model = this.mapOACModelToClaude(agent.frontmatter.model);
-    }
-
-    // Permission mode
-    if (agent.frontmatter.permission) {
-      frontmatter.permissionMode = this.mapOACPermissionsToClaude(
-        agent.frontmatter.permission,
-        warnings
-      );
-    }
-
-    // Skills
-    if (agent.frontmatter.skills && agent.frontmatter.skills.length > 0) {
-      frontmatter.skills = agent.frontmatter.skills.map((skill) =>
-        typeof skill === "string" ? skill : skill.name
-      );
-    }
-
-    // Hooks
-    if (agent.frontmatter.hooks && agent.frontmatter.hooks.length > 0) {
-      frontmatter.hooks = this.mapOACHooksToClaude(agent.frontmatter.hooks);
-    }
-
-    // Generate YAML frontmatter
-    const yamlLines = Object.entries(frontmatter).map(([key, value]) => {
-      if (Array.isArray(value)) {
-        return `${key}: [${value.map((v) => `"${v}"`).join(", ")}]`;
-      }
-      return `${key}: "${String(value)}"`;
-    });
-
-    return `---\n${yamlLines.join("\n")}\n---\n\n${agent.systemPrompt}`;
-  }
-
-  /**
-   * Generate Skills from OAC contexts.
-   */
-  private generateSkillsFromContexts(
-    contexts: Array<{ path: string; priority?: string; description?: string }>
-  ): ToolConfig[] {
-    return contexts.map((ctx) => {
-      const skillName = ctx.path
-        .split("/")
-        .pop()
-        ?.replace(/\.md$/, "")
-        .toLowerCase()
-        .replace(/\s+/g, "-") || "context-skill";
-
-      const skillContent = `---
-name: ${skillName}
-description: ${ctx.description || `Context from ${ctx.path}`}
----
-
-# ${skillName}
-
-This skill provides context from: \`${ctx.path}\`
-
-Priority: ${ctx.priority || "medium"}
-
-Load the full context file for detailed information:
-\`\`\`bash
-cat ${ctx.path}
-\`\`\`
-`;
-
-      return {
-        fileName: `.claude/skills/${skillName}/SKILL.md`,
-        content: skillContent,
-        encoding: "utf-8" as const,
-      };
-    });
-  }
-
   // ============================================================================
   // MAPPING HELPERS
   // ============================================================================
@@ -450,19 +495,6 @@ cat ${ctx.path}
     return modelMap[model] || model;
   }
 
-  /**
-   * Map OAC model ID to Claude model ID.
-   */
-  private mapOACModelToClaude(model: string): string {
-    const modelMap: Record<string, string> = {
-      "claude-sonnet-4": "claude-sonnet-4-20250514",
-      "claude-opus-4": "claude-opus-4",
-      "claude-haiku-4": "claude-haiku-4",
-    };
-
-    return modelMap[model] || "sonnet"; // Default to sonnet alias
-  }
-
   /**
    * Parse Claude tools to OAC ToolAccess.
    */
@@ -472,34 +504,18 @@ cat ${ctx.path}
     const toolAccess: Record<string, boolean> = {};
 
     if (typeof tools === "string") {
-      // Parse comma-separated string: "Read, Write, Bash"
       tools.split(",").forEach((tool) => {
-        const toolName = tool.trim().toLowerCase();
-        toolAccess[toolName] = true;
+        toolAccess[tool.trim().toLowerCase()] = true;
       });
     } else if (Array.isArray(tools)) {
-      // Parse array: ["Read", "Write", "Bash"]
       tools.forEach((tool) => {
-        const toolName = String(tool).toLowerCase();
-        toolAccess[toolName] = true;
+        toolAccess[String(tool).toLowerCase()] = true;
       });
     }
 
     return Object.keys(toolAccess).length > 0 ? toolAccess : undefined;
   }
 
-  /**
-   * Map OAC ToolAccess to Claude tools array.
-   */
-  private mapOACToolsToClaude(tools: Record<string, boolean>): string[] {
-    return Object.entries(tools)
-      .filter(([, enabled]) => enabled)
-      .map(([tool]) => {
-        // Capitalize first letter for Claude format
-        return tool.charAt(0).toUpperCase() + tool.slice(1);
-      });
-  }
-
   /**
    * Parse Claude skills to OAC SkillReference array.
    */
@@ -526,7 +542,6 @@ cat ${ctx.path}
     const hookDefinitions: HookDefinition[] = [];
     const hooksObj = hooks as Record<string, unknown>;
 
-    // Claude hooks format: { PreToolUse: [...], PostToolUse: [...] }
     for (const [event, hookList] of Object.entries(hooksObj)) {
       if (!Array.isArray(hookList)) continue;
 
@@ -535,9 +550,7 @@ cat ${ctx.path}
           const hookObj = hook as Record<string, unknown>;
           hookDefinitions.push({
             event: event as HookEvent,
-            matchers: hookObj.matcher
-              ? [String(hookObj.matcher)]
-              : undefined,
+            matchers: hookObj.matcher ? [String(hookObj.matcher)] : undefined,
             commands: hookObj.hooks
               ? (hookObj.hooks as Array<{ type: "command"; command: string }>)
               : [],
@@ -548,56 +561,91 @@ cat ${ctx.path}
 
     return hookDefinitions.length > 0 ? hookDefinitions : undefined;
   }
+}
 
-  /**
-   * Map OAC hooks to Claude hooks format.
-   */
-  private mapOACHooksToClaude(
-    hooks: HookDefinition[]
-  ): Record<string, unknown[]> {
-    const claudeHooks: Record<string, unknown[]> = {};
-
-    hooks.forEach((hook) => {
-      const event = hook.event;
-      if (!claudeHooks[event]) {
-        claudeHooks[event] = [];
-      }
+// ============================================================================
+// Module-private helpers
+// ============================================================================
+
+/** What {@link ClaudeAdapter.fromCanonical} produces for one agent. */
+export interface ClaudeEmission {
+  /** Repo-relative destination, e.g. `plugins/claude-code/agents/code-reviewer.md`. */
+  path: string;
+  /** The exact bytes to write. */
+  content: string;
+  /** One entry per semantic that could not be carried. Empty means a lossless projection. */
+  warnings: string[];
+}
 
-      claudeHooks[event].push({
-        matcher: hook.matchers?.[0] || "*",
-        hooks: hook.commands,
-      });
-    });
+/** The frontmatter inputs both emit paths share. */
+interface ClaudeAgentInput {
+  name: string;
+  description: string;
+  model?: string;
+  temperature?: number;
+  maxSteps?: number;
+  permission?: GranularPermission;
+  tools?: Record<string, boolean | undefined>;
+}
 
-    return claudeHooks;
-  }
+/**
+ * Claude Code's tool names bound to the canonical capability governing each, **in the order
+ * they are emitted**.
+ *
+ * The order is not a preference — it is recovered from the 7 committed agents in
+ * `plugins/claude-code/agents/`. All 10 of their `tools:`/`disallowedTools:` lists are
+ * consistent with it, and it is the only total order that is: alphabetical is refuted by
+ * `context-manager.md` (`Read, Write, Glob, Grep, Bash`), and so is `ToolAccessSchema` field
+ * order (which would put Bash before Glob/Grep). Changing it silently breaks reproduction of
+ * every committed agent.
+ */
+const CLAUDE_TOOL_BINDINGS: readonly ToolBinding[] = [
+  { tool: "Read", capability: "read" },
+  { tool: "Write", capability: "write" },
+  { tool: "Edit", capability: "edit" },
+  { tool: "Glob", capability: "glob" },
+  { tool: "Grep", capability: "grep" },
+  { tool: "Bash", capability: "bash" },
+  { tool: "WebFetch", capability: "webfetch" },
+  { tool: "Task", capability: "task" },
+];
+
+/** Capabilities that bind to a Claude Code tool. Anything else cannot be carried. */
+const MAPPED_CAPABILITIES = new Set(CLAUDE_TOOL_BINDINGS.map((binding) => binding.capability));
 
-  /**
-   * Map OAC granular permissions to Claude permissionMode.
-   */
-  private mapOACPermissionsToClaude(
-    permissions: Record<string, unknown>,
-    warnings: string[]
-  ): string {
-    // Analyze permission patterns to determine best permissionMode
-    const values = Object.values(permissions);
-    const hasAllAllow = values.every((v) => v === "allow" || v === true);
-    const hasAllDeny = values.every((v) => v === "deny" || v === false);
-    const hasAsk = values.some((v) => v === "ask");
-
-    if (hasAllAllow) {
-      return "bypassPermissions"; // Full access
-    } else if (hasAllDeny) {
-      return "dontAsk"; // Auto-deny
-    } else if (hasAsk) {
-      return "default"; // Prompt for permission
-    } else {
-      // Mixed or granular permissions - default to standard mode
-      warnings.push(
-        "⚠️  Complex permission rules degraded to 'default' permissionMode. " +
-          "Claude Code does not support granular allow/deny/ask per operation."
-      );
-      return "default";
-    }
-  }
+/**
+ * Warn for each authored capability Claude Code has no tool for.
+ *
+ * Silence here would be a real loss: `externalscout`'s `skill: { "*": deny, "*context7*":
+ * allow }` restricts which skills it may invoke, and Claude Code cannot express that at all.
+ * Dropping it without a word is exactly the class of silent widening this adapter exists to
+ * prevent. Wildcard capabilities are skipped — they bind to every tool, so nothing is lost.
+ */
+function unmappableCapabilityWarnings(permissions: GranularPermission): string[] {
+  return permissions
+    .filter(
+      (entry) =>
+        !MAPPED_CAPABILITIES.has(entry.capability) &&
+        !entry.capability.includes("*") &&
+        entry.rules.length > 0
+    )
+    .map(
+      (entry) =>
+        `⚠️  Permission '${entry.capability}' has no Claude Code tool: ` +
+        `${entry.rules.length} rule(s) are dropped because Claude Code exposes no tool this ` +
+        `capability maps to. Claude Code will not enforce them.`
+    );
+}
+
+/**
+ * Render one frontmatter line, letting js-yaml decide the scalar style.
+ *
+ * Delegating quoting is deliberate: a hand-rolled `key: "value"` either over-quotes (the
+ * committed corpus uses plain scalars) or breaks on a description containing `: `, `#` or a
+ * leading `*`. js-yaml also renders a value with a trailing newline as a `|` block scalar
+ * and one without as `|-`, which is precisely the distinction the committed multi-line
+ * descriptions rely on.
+ */
+function yamlLine(key: string, value: string): string {
+  return `${key}: ${dump(value, { lineWidth: -1 }).trimEnd()}\n`;
 }

+ 3 - 1
packages/compatibility-layer/src/cli/__tests__/convert.test.ts

@@ -83,7 +83,9 @@ describe("convert command", () => {
       expect(result.success).toBe(true);
       expect(result.data).toBeDefined();
       expect(result.data?.configs).toBeDefined();
-      expect(result.data?.configs[0].fileName).toContain(".claude/");
+      // Claude Code's real target is the committed plugin tree, not `.claude/`. The old
+      // `.claude/config.json` was a fabricated agent-config file that nothing reads.
+      expect(result.data?.configs[0].fileName).toContain("plugins/claude-code/agents/");
     });
 
     it("converts Cursor to OAC format", async () => {

+ 6 - 4
packages/compatibility-layer/src/cli/__tests__/info.test.ts

@@ -515,7 +515,7 @@ describe("info command", () => {
       expect(result.data?.platform?.capabilities?.outputStructure).toBe("single-file");
     });
 
-    it("Claude uses JSON format and directory structure", async () => {
+    it("Claude uses markdown format and directory structure", async () => {
       // Act
       const result = await executeInfo(
         "claude",
@@ -525,9 +525,11 @@ describe("info command", () => {
 
       // Assert
       expect(result.success).toBe(true);
-      // Note: CapabilityMatrix defines Claude as 'json' format (not markdown)
-      // because Claude Code settings.json uses JSON configuration
-      expect(result.data?.platform?.capabilities?.configFormat).toBe("json");
+      // Claude Code agents are markdown files with YAML frontmatter under
+      // plugins/claude-code/agents/. This previously asserted 'json' on the reasoning that
+      // settings.json is JSON — but settings.json is not what the adapter emits, and
+      // ClaudeAdapter reported 'markdown' at the same time. The emitted artifact decides.
+      expect(result.data?.platform?.capabilities?.configFormat).toBe("markdown");
       expect(result.data?.platform?.capabilities?.outputStructure).toBe("directory");
     });
   });

+ 60 - 6
packages/compatibility-layer/src/core/CapabilityMatrix.ts

@@ -19,6 +19,16 @@ import type { OpenAgent, ToolCapabilities } from "../types.js";
  * second, divergent resolver. Callers reach `degradeToBinary` here because a per-capability
  * grant is the matrix's own question ("what survives on this platform?"), answered by the
  * resolver rather than duplicated against it.
+ *
+ * ## Ownership (settled by subtask 07; subtask 04 flagged the overlap)
+ *
+ * `Capabilities.ts` is the sole IMPLEMENTATION — one resolver, one fail-closed projection.
+ * This module is a re-export SURFACE and nothing more: no logic, no wrapper, no defaults.
+ * The re-export is load-bearing rather than convenience —
+ * `tests/unit/build/permission-ordering.test.ts` imports `degradeToBinary` from this path by
+ * name and pins it to subtask 07, so deleting it would break a green gate. New code should
+ * prefer importing from `./Capabilities.js` directly; adapters that need the flat-list
+ * projection want `projectToFlatTools`, which is only exported there.
  */
 export { degradeToBinary } from "./Capabilities.js";
 export type { BinaryProjection } from "./Capabilities.js";
@@ -103,7 +113,11 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     name: "agentCategories",
     category: "agents",
     description: "Agent categorization (core, development, etc.)",
-    support: { oac: "full", claude: "partial", cursor: "none", windsurf: "partial" },
+    // 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" },
   },
 
   // Permission Features
@@ -113,25 +127,54 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     description: "Fine-grained allow/deny/ask patterns",
     support: { oac: "full", claude: "none", cursor: "none", windsurf: "none" },
     notes: {
-      claude: "Binary on/off only",
+      claude:
+        "tools/disallowedTools are flat name lists — a capability is wholly granted or " +
+        "wholly denied, so anything scoped degrades fail-closed to disallowedTools",
       cursor: "Binary on/off only",
       windsurf: "Binary on/off only",
     },
   },
+  {
+    name: "orderedPermissionRules",
+    category: "permissions",
+    description: "Ordered rule lists resolved last-match-wins (deny-all-then-allowlist)",
+    // The central fact of the canonical refactor, and previously unrepresented here: the
+    // 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" },
+    notes: {
+      claude: "No rule ordering concept — the allowlist cannot be carried, so Bash is denied",
+    },
+  },
   {
     name: "askPermissions",
     category: "permissions",
     description: "Interactive permission requests",
     support: { oac: "full", claude: "none", cursor: "none", windsurf: "none" },
+    notes: {
+      claude: "No 'ask' in agent frontmatter — degrades to deny, never to allow",
+    },
   },
   {
     name: "pathPatterns",
     category: "permissions",
     description: "Glob patterns for file permissions",
     support: { oac: "full", claude: "none", cursor: "none", windsurf: "partial" },
+    notes: {
+      claude: "Tool grants carry no path scope — secret-file denies cannot be expressed",
+    },
   },
 
   // Tool Features
+  {
+    name: "binaryToolGrants",
+    category: "tools",
+    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" },
+  },
   {
     name: "taskDelegation",
     category: "tools",
@@ -222,14 +265,20 @@ const CAPABILITY_MATRIX: FeatureDefinition[] = [
     name: "dependencies",
     category: "advanced",
     description: "Agent dependency declarations",
-    support: { oac: "full", claude: "full", cursor: "none", windsurf: "partial" },
+    // 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" },
   },
   {
     name: "priorityLevels",
     category: "advanced",
     description: "Task priority levels",
-    support: { oac: "full", claude: "partial", cursor: "none", windsurf: "partial" },
-    notes: { oac: "4 levels", claude: "2 levels", windsurf: "2 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" },
   },
 ];
 
@@ -455,7 +504,12 @@ export function getToolCapabilities(
   };
 
   const configFormats: Record<Exclude<Platform, "oac">, ToolCapabilities["configFormat"]> = {
-    claude: "json",
+    // Claude Code agents are markdown files with YAML frontmatter
+    // (`plugins/claude-code/agents/<id>.md`). This row previously read "json" on the
+    // reasoning that `settings.json` is JSON — but settings.json is not what any adapter
+    // emits, and ClaudeAdapter.getCapabilities() simultaneously reported "markdown". One
+    // platform cannot have two answers about itself; the emitted artifact decides.
+    claude: "markdown",
     cursor: "plain",
     windsurf: "json",
   };

+ 616 - 1194
packages/compatibility-layer/tests/unit/adapters/ClaudeAdapter.test.ts

@@ -1,25 +1,61 @@
-import { describe, it, expect, beforeEach } from "vitest";
-import { ClaudeAdapter } from "../../../src/adapters/ClaudeAdapter";
-import type {
-  OpenAgent,
-  AgentFrontmatter,
-  HookDefinition,
-  SkillReference,
-} from "../../../src/types";
-
 /**
- * Unit tests for ClaudeAdapter with 80%+ coverage
+ * Unit tests for ClaudeAdapter — the `plugins/claude-code/` emitter.
+ *
+ * ## What changed, and why the old suite could not simply be edited
  *
- * Test strategy:
- * 1. toOAC() - Parse Claude formats to OpenAgent
- * 2. fromOAC() - Convert OpenAgent to Claude formats
- * 3. getCapabilities() - Feature matrix validation
- * 4. validateConversion() - Validation and warnings
- * 5. Helper methods - Model/tool/hook/skill mapping
- * 6. Edge cases - Invalid input, missing fields, empty values
- * 7. Roundtrip - Data integrity checks
+ * This adapter used to emit `.claude/config.json` + `.claude/agents/*.md`. The `config.json`
+ * half was fabricated (Claude Code has no such agent-config file) and the real target is the
+ * plugin tree committed at `plugins/claude-code/`. Roughly half the old suite asserted the
+ * shape of a file that should never have existed, so those tests are gone rather than
+ * retargeted — keeping them would pin a format nothing reads.
+ *
+ * ## The one rule these tests exist to defend
+ *
+ * Claude Code's frontmatter has two flat lists and no scoping: no ordered globs, no `ask`,
+ * no last-match-wins. Canonical agents depend on all three. Emitting MORE permission than
+ * canonical specifies is the single unacceptable outcome, so every projection here is
+ * checked to fail CLOSED and to say out loud what it dropped. A silent widening is the bug
+ * class this file is aimed at — `PermissionMapper`'s permissive default (`hasAllow ||
+ * !hasDeny`) answers `bash: true` for a deny-all-then-allowlist block, which is exactly why
+ * the adapter routes through `core/Capabilities.ts` instead.
  */
 
+import { describe, it, expect, beforeEach } from "vitest";
+import { readFileSync } from "node:fs";
+import { ClaudeAdapter } from "../../../src/adapters/ClaudeAdapter";
+import { packagePath } from "../../support/pending.js";
+import type { OpenAgent, AgentFrontmatter, HookDefinition } from "../../../src/types";
+
+const FIXTURE_REVIEWER = packagePath("tests/golden/fixtures/fixture-reviewer.md");
+const FIXTURE_PLANNER = packagePath("tests/golden/fixtures/fixture-planner.md");
+
+function fixture(path: string): string {
+  return readFileSync(path, "utf-8");
+}
+
+/** A canonical agent file built around one permission block, for targeted projection tests. */
+function canonical(permission: string, extra = ""): string {
+  return `---
+name: ProbeAgent
+description: A probe agent.
+mode: subagent
+${extra}permission:
+${permission}
+oac:
+  id: probe-agent
+  name: ProbeAgent
+  category: subagents/test
+  type: subagent
+  targets:
+    - claude-code
+---
+
+# ProbeAgent
+
+Body.
+`;
+}
+
 describe("ClaudeAdapter", () => {
   let adapter: ClaudeAdapter;
 
@@ -40,1447 +76,833 @@ describe("ClaudeAdapter", () => {
       expect(adapter.displayName).toBe("Claude Code");
     });
 
-    it("returns correct config path", () => {
-      expect(adapter.getConfigPath()).toBe(".claude/");
+    it("returns the plugin tree as its config path, not .claude/", () => {
+      expect(adapter.getConfigPath()).toBe("plugins/claude-code/");
     });
   });
 
   // ============================================================================
-  // CAPABILITIES
+  // OUTPUT LAYOUT
   // ============================================================================
 
-  describe("getCapabilities()", () => {
-    it("returns correct capabilities object", () => {
-      const capabilities = adapter.getCapabilities();
+  describe("output layout", () => {
+    it("emits agents under plugins/claude-code/agents/", async () => {
+      const { path } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
 
-      expect(capabilities.name).toBe("claude");
-      expect(capabilities.displayName).toBe("Claude Code");
-      expect(capabilities.supportsMultipleAgents).toBe(true);
-      expect(capabilities.supportsSkills).toBe(true);
-      expect(capabilities.supportsHooks).toBe(true);
-      expect(capabilities.supportsGranularPermissions).toBe(false);
-      expect(capabilities.supportsContexts).toBe(true);
-      expect(capabilities.supportsCustomModels).toBe(true);
-      expect(capabilities.supportsTemperature).toBe(false);
-      expect(capabilities.supportsMaxSteps).toBe(false);
-      expect(capabilities.configFormat).toBe("markdown");
-      expect(capabilities.outputStructure).toBe("directory");
+      expect(path).toBe("plugins/claude-code/agents/fixture-reviewer.md");
     });
 
-    it("includes appropriate notes", () => {
-      const capabilities = adapter.getCapabilities();
+    it("keys the emitted path on oac.id, not the authored display name", async () => {
+      // The canonical ids and the Claude Code filenames genuinely differ across the corpus
+      // (`contextscout` -> `context-scout.md`, `reviewer` -> `code-reviewer.md`). Only the
+      // id is stable identity, so resolving by `name:` would emit the wrong filename.
+      const { path, content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
 
-      expect(capabilities.notes).toBeDefined();
-      expect(capabilities.notes?.length).toBeGreaterThan(0);
-      expect(capabilities.notes?.some((n) => n.includes("permissions"))).toBe(
-        true
-      );
+      expect(path).toContain("fixture-reviewer.md"); // oac.id
+      expect(path).not.toContain("FixtureReviewer"); // frontmatter name
+      expect(content).toMatch(/^name: fixture-reviewer$/m);
     });
-  });
-
-  // ============================================================================
-  // toOAC() - PARSING CLAUDE CONFIG.JSON
-  // ============================================================================
 
-  describe("toOAC() - parsing config.json", () => {
-    it("parses minimal config.json", async () => {
-      const source = JSON.stringify({
-        name: "TestAgent",
-        description: "Test description",
-        systemPrompt: "You are helpful",
+    it("emits no .claude/ path from any conversion", async () => {
+      const canonicalResult = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
+      const oacResult = await adapter.fromOAC({
+        frontmatter: { name: "Agent", description: "Test", mode: "primary" },
+        metadata: { name: "Agent", category: "core", type: "agent" },
+        systemPrompt: "Prompt",
+        contexts: [{ path: "context/a.md", description: "A" }],
       });
 
-      const result = await adapter.toOAC(source);
-
-      expect(result.frontmatter.name).toBe("TestAgent");
-      expect(result.frontmatter.description).toBe("Test description");
-      expect(result.systemPrompt).toBe("You are helpful");
-      expect(result.frontmatter.mode).toBe("primary");
+      expect(canonicalResult.path).not.toContain(".claude/");
+      for (const config of oacResult.configs) {
+        expect(config.fileName, `${config.fileName} still targets the old layout`).not.toContain(
+          ".claude/"
+        );
+        expect(config.fileName).toMatch(/^plugins\/claude-code\//);
+      }
     });
 
-    it("parses config with model", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        model: "claude-sonnet-4-20250514",
+    it("never emits a config.json", async () => {
+      const result = await adapter.fromOAC({
+        frontmatter: { name: "Agent", description: "Test", mode: "primary" },
+        metadata: { name: "Agent", category: "core", type: "agent" },
         systemPrompt: "Prompt",
+        contexts: [],
       });
 
-      const result = await adapter.toOAC(source);
-
-      expect(result.frontmatter.model).toBe("claude-sonnet-4");
+      expect(result.configs.map((c) => c.fileName)).toEqual([
+        "plugins/claude-code/agents/Agent.md",
+      ]);
     });
 
-    it("parses config with tools array", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        tools: ["Read", "Write", "Bash"],
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
+    it("emits one agent file for a primary agent, same as a subagent", async () => {
+      // The old primary/subagent split existed only to choose between config.json and an
+      // agent file. With config.json gone there is exactly one shape.
+      const primary = await adapter.fromCanonical(fixture(FIXTURE_PLANNER)); // mode: primary
 
-      expect(result.frontmatter.tools).toEqual({
-        read: true,
-        write: true,
-        bash: true,
-      });
+      expect(primary.path).toBe("plugins/claude-code/agents/fixture-planner.md");
+      expect(primary.content).toMatch(/^---\nname: fixture-planner$/m);
     });
+  });
 
-    it("parses config with tools string", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        tools: "Read, Write, Edit",
-        systemPrompt: "Prompt",
-      });
+  // ============================================================================
+  // FRONTMATTER SHAPE
+  // ============================================================================
 
-      const result = await adapter.toOAC(source);
+  describe("frontmatter shape", () => {
+    it("emits keys in the committed order: name, description, tools, disallowedTools, model", async () => {
+      const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
+      const keys = content
+        .split("---")[1]!
+        .trim()
+        .split("\n")
+        .map((line) => line.split(":")[0]);
 
-      expect(result.frontmatter.tools).toEqual({
-        read: true,
-        write: true,
-        edit: true,
-      });
+      expect(keys).toEqual(["name", "description", "tools", "disallowedTools", "model"]);
     });
 
-    it("parses config with skills", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        skills: ["skill1", "skill2"],
-        systemPrompt: "Prompt",
-      });
+    it("reproduces the committed frontmatter shape byte-for-byte", async () => {
+      const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
 
-      const result = await adapter.toOAC(source);
+      expect(content.split("---\n\n")[0]).toBe(
+        `---\nname: fixture-reviewer\n` +
+          `description: Reviews code for correctness. A golden-file fixture, not a shipped agent.\n` +
+          `tools: Read, Glob, Grep\n` +
+          `disallowedTools: Write, Edit, Bash, Task\n` +
+          `model: haiku\n`
+      );
+    });
 
-      expect(result.frontmatter.skills).toEqual(["skill1", "skill2"]);
+    it("passes the model through unmapped", async () => {
+      // The committed corpus uses Claude Code's own aliases (`sonnet`, `haiku`). Expanding
+      // them to dated ids (`claude-sonnet-4-20250514`) would break every committed agent.
+      const { content } = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
+
+      expect(content).toMatch(/^model: sonnet$/m);
     });
 
-    it("parses config with hooks", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        hooks: {
-          PreToolUse: [
-            {
-              matcher: "*.txt",
-              hooks: [{ type: "command", command: "validate" }],
-            },
-          ],
-        },
-        systemPrompt: "Prompt",
-      });
+    it("omits model when the source declares none", async () => {
+      const { content } = await adapter.fromCanonical(canonical(`  read:\n    "*": "allow"\n`));
 
-      const result = await adapter.toOAC(source);
+      expect(content).not.toMatch(/^model:/m);
+    });
 
-      expect(result.frontmatter.hooks).toBeDefined();
-      expect(result.frontmatter.hooks?.length).toBe(1);
-      expect(result.frontmatter.hooks?.[0].event).toBe("PreToolUse");
+    it("omits an empty tools list rather than emitting a bare key", async () => {
+      // `tools:` with no value means something different to Claude Code than an absent key.
+      const { content } = await adapter.fromCanonical(canonical(`  bash:\n    "*": "deny"\n`));
+
+      expect(content).not.toMatch(/^tools:\s*$/m);
+      expect(content).toMatch(/^disallowedTools: Bash$/m);
     });
 
-    it("handles missing optional fields gracefully", async () => {
-      const source = JSON.stringify({
-        name: "MinimalAgent",
-        description: "Minimal",
-      });
+    it("omits an empty disallowedTools list", async () => {
+      const { content } = await adapter.fromCanonical(canonical(`  read:\n    "*": "allow"\n`));
 
-      const result = await adapter.toOAC(source);
+      expect(content).toMatch(/^tools: Read$/m);
+      expect(content).not.toMatch(/^disallowedTools:/m);
+    });
 
-      expect(result.frontmatter.name).toBe("MinimalAgent");
-      expect(result.systemPrompt).toBe("");
-      expect(result.frontmatter.tools).toBeUndefined();
-      expect(result.frontmatter.skills).toBeUndefined();
+    it("preserves the body verbatim after the frontmatter", async () => {
+      const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
+
+      expect(content).toContain("# FixtureReviewer");
+      expect(content).toContain("- Report findings, do not fix them.");
+      expect(content.endsWith("- Report findings, do not fix them.\n")).toBe(true);
     });
 
-    it("parses invalid JSON as markdown (subagent fallback)", async () => {
-      const source = "not valid json";
+    it("renders a multi-line description as a YAML block scalar", async () => {
+      // The committed agents carry multi-line `description: |` blocks with <example> tags.
+      // A naive `key: "value"` would emit a broken single line.
+      const source = canonical(`  read:\n    "*": "allow"\n`).replace(
+        "description: A probe agent.",
+        'description: |\n  First line.\n  user: "quoted colon"\n'
+      );
 
-      const result = await adapter.toOAC(source);
+      const { content } = await adapter.fromCanonical(source);
 
-      // Falls back to markdown parsing
-      expect(result.frontmatter.mode).toBe("subagent");
-      expect(result.systemPrompt).toBe("not valid json");
+      expect(content).toContain('description: |\n  First line.\n  user: "quoted colon"\n');
     });
 
-    it("parses non-object JSON string as markdown (subagent fallback)", async () => {
-      const source = JSON.stringify("just a string");
+    it("quotes a description that would otherwise be ambiguous YAML", async () => {
+      const source = canonical(`  read:\n    "*": "allow"\n`).replace(
+        "description: A probe agent.",
+        'description: "*starts with a star"'
+      );
 
-      const result = await adapter.toOAC(source);
+      const { content } = await adapter.fromCanonical(source);
 
-      // Falls back to markdown parsing
-      expect(result.frontmatter.mode).toBe("subagent");
+      expect(content).toMatch(/^description: '\*starts with a star'$/m);
     });
   });
 
   // ============================================================================
-  // toOAC() - PARSING CLAUDE AGENT.MD (SUBAGENTS)
-  // ============================================================================
-
-  describe("toOAC() - parsing agent.md with YAML frontmatter", () => {
-    it("parses agent.md with minimal frontmatter", async () => {
-      const source = `---
-name: SubAgent
-description: A subagent
----
-
-This is the system prompt for the agent.`;
-
-      const result = await adapter.toOAC(source);
+  // TOOL ORDERING
+  // ============================================================================
+
+  describe("tool ordering", () => {
+    it("emits tools in the canonical Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task order", async () => {
+      // Recovered from the 7 committed agents: all 10 of their lists fit this order and it
+      // is the only total order that does. Alphabetical is refuted by context-manager.md
+      // (`Read, Write, Glob, Grep, Bash`); so is ToolAccessSchema field order.
+      const { content } = await adapter.fromCanonical(
+        canonical(
+          `  task:\n    "*": "allow"\n` +
+            `  bash:\n    "*": "allow"\n` +
+            `  grep:\n    "*": "allow"\n` +
+            `  glob:\n    "*": "allow"\n` +
+            `  edit:\n    "*": "allow"\n` +
+            `  write:\n    "*": "allow"\n` +
+            `  read:\n    "*": "allow"\n` +
+            `  webfetch:\n    "*": "allow"\n`
+        )
+      );
 
-      expect(result.frontmatter.name).toBe("SubAgent");
-      expect(result.frontmatter.description).toBe("A subagent");
-      expect(result.frontmatter.mode).toBe("subagent");
-      expect(result.systemPrompt).toBe("This is the system prompt for the agent.");
+      expect(content).toMatch(
+        /^tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task$/m
+      );
     });
 
-    it("parses agent.md with model in frontmatter", async () => {
-      const source = `---
-name: Agent
-description: Test
-model: claude-opus-4
----
+    it("orders disallowedTools by the same rule", async () => {
+      const { content } = await adapter.fromCanonical(
+        canonical(
+          `  task:\n    "*": "deny"\n` +
+            `  bash:\n    "*": "deny"\n` +
+            `  edit:\n    "*": "deny"\n` +
+            `  write:\n    "*": "deny"\n`
+        )
+      );
 
-Prompt`;
+      expect(content).toMatch(/^disallowedTools: Write, Edit, Bash, Task$/m);
+    });
 
-      const result = await adapter.toOAC(source);
+    it("does not emit a tool for a capability the source never mentions", async () => {
+      // Ratified rule (02 §1.2.5 case 1): an absent capability means "the target's own
+      // default", so naming it in either list would invent an intent the author never had.
+      const { content } = await adapter.fromCanonical(canonical(`  read:\n    "*": "allow"\n`));
 
-      expect(result.frontmatter.model).toBe("claude-opus-4");
+      for (const tool of ["Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "Task"]) {
+        expect(content, `${tool} was invented from silence`).not.toContain(tool);
+      }
     });
+  });
 
-    it("parses agent.md with tools array in frontmatter", async () => {
-      const source = `---
-name: Agent
-description: Test
-tools: ["Read", "Write", "Bash"]
----
-
-Prompt`;
+  // ============================================================================
+  // PERMISSION PROJECTION — fails closed
+  // ============================================================================
 
-      const result = await adapter.toOAC(source);
+  describe("permission projection", () => {
+    it("fails closed on a deny-all-then-allowlist bash block", async () => {
+      // The live shape: `bash: {"*": deny, "git log*": allow}`. Claude Code has no ordered
+      // -glob equivalent. Answering `tools: Bash` because "an allow rule exists" would hand
+      // it unrestricted shell — the precise failure PermissionMapper's permissive default
+      // produces, and the reason this adapter does not use it.
+      const { content } = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
 
-      expect(result.frontmatter.tools).toEqual({
-        read: true,
-        write: true,
-        bash: true,
-      });
+      expect(content).toMatch(/^disallowedTools:.*\bBash\b/m);
+      expect(content).not.toMatch(/^tools:.*\bBash\b/m);
     });
 
-    it("parses agent.md with skills in frontmatter", async () => {
-      const source = `---
-name: Agent
-description: Test
-skills: ["skill1", "skill2"]
----
+    it("degrades 'ask' to deny, never to allow", async () => {
+      const { content } = await adapter.fromCanonical(canonical(`  bash:\n    "*": "ask"\n`));
 
-Prompt`;
+      expect(content).toMatch(/^disallowedTools: Bash$/m);
+      expect(content).not.toMatch(/^tools:.*Bash/m);
+    });
 
-      const result = await adapter.toOAC(source);
+    it("does not treat an allow-with-exceptions as a plain allow", async () => {
+      const { content } = await adapter.fromCanonical(
+        canonical(`  edit:\n    "*": "allow"\n    "**/*.env*": "deny"\n`)
+      );
 
-      expect(result.frontmatter.skills).toEqual(["skill1", "skill2"]);
+      expect(content).toMatch(/^disallowedTools: Edit$/m);
     });
 
-    it("handles agent.md without frontmatter as markdown content", async () => {
-      const source = "No frontmatter here, just markdown content";
-
-      const result = await adapter.toOAC(source);
+    it("carries a provably uniform allow through as a grant", async () => {
+      const { content, warnings } = await adapter.fromCanonical(
+        canonical(`  read:\n    "*": "allow"\n`)
+      );
 
-      expect(result.systemPrompt).toBe("No frontmatter here, just markdown content");
-      expect(result.frontmatter.mode).toBe("subagent");
+      expect(content).toMatch(/^tools: Read$/m);
+      expect(warnings).toEqual([]);
     });
 
-    it("preserves multiline system prompt", async () => {
-      const source = `---
-name: Agent
-description: Test
----
+    it("carries a provably uniform deny through as a denial, silently", async () => {
+      // An exact projection loses nothing, so it must not warn — warnings mean loss, and
+      // noise here would train readers to ignore the real ones.
+      const { content, warnings } = await adapter.fromCanonical(
+        canonical(`  bash:\n    "*": "deny"\n`)
+      );
 
-You are a helpful assistant.
-You should be friendly and professional.
-Always provide detailed responses.`;
+      expect(content).toMatch(/^disallowedTools: Bash$/m);
+      expect(warnings).toEqual([]);
+    });
+
+    it("never grants a tool whose rules contain any deny", async () => {
+      // Property check over every mixed shape in the live corpus.
+      const shapes = [
+        `  bash:\n    "*": "deny"\n    "git log*": "allow"\n`,
+        `  bash:\n    "git log*": "allow"\n    "*": "deny"\n`,
+        `  edit:\n    "**/*.env*": "deny"\n    "**/*.key": "deny"\n`,
+        `  read:\n    "**/*": "deny"\n    ".tmp/**": "allow"\n`,
+      ];
 
-      const result = await adapter.toOAC(source);
+      for (const shape of shapes) {
+        const { content } = await adapter.fromCanonical(canonical(shape));
+        const tools = /^tools: (.*)$/m.exec(content)?.[1] ?? "";
 
-      expect(result.systemPrompt).toContain("You are a helpful assistant");
-      expect(result.systemPrompt).toContain("Always provide detailed responses");
+        expect(tools, `${shape} leaked a grant`).toBe("");
+      }
     });
   });
 
   // ============================================================================
-  // fromOAC() - CONVERTING TO CLAUDE CONFIG.JSON
+  // WARNINGS — one per lossy projection
   // ============================================================================
 
-  describe("fromOAC() - converting to config.json", () => {
-    const createOpenAgent = (overrides?: Partial<OpenAgent>): OpenAgent => ({
-      frontmatter: {
-        name: "TestAgent",
-        description: "Test agent",
-        mode: "primary",
-        model: "claude-sonnet-4",
-        tools: { read: true, write: true },
-        ...overrides?.frontmatter,
-      },
-      metadata: {
-        name: "TestAgent",
-        category: "core",
-        type: "agent",
-      },
-      systemPrompt: "You are helpful",
-      contexts: [],
-      ...overrides,
+  describe("warnings", () => {
+    it("emits exactly one warning for a single unrepresentable capability", async () => {
+      const { warnings } = await adapter.fromCanonical(
+        canonical(`  bash:\n    "*": "deny"\n    "git log*": "allow"\n`)
+      );
+
+      expect(warnings).toHaveLength(1);
+      expect(warnings[0]).toMatch(/bash/i);
+      expect(warnings[0]).toMatch(/fail-closed/);
     });
 
-    it("converts primary agent to config.json", async () => {
-      const agent = createOpenAgent();
+    it("counts one warning per lossy capability, and none for the lossless ones", async () => {
+      // read/glob are exact; bash and edit are not. Two losses, two warnings.
+      const { warnings } = await adapter.fromCanonical(
+        canonical(
+          `  read:\n    "*": "allow"\n` +
+            `  glob:\n    "*": "allow"\n` +
+            `  bash:\n    "*": "deny"\n    "git log*": "allow"\n` +
+            `  edit:\n    "*": "allow"\n    "**/*.key": "deny"\n`
+        )
+      );
 
-      const result = await adapter.fromOAC(agent);
+      expect(warnings).toHaveLength(2);
+      expect(warnings.filter((w) => /'bash'/.test(w))).toHaveLength(1);
+      expect(warnings.filter((w) => /'edit'/.test(w))).toHaveLength(1);
+    });
 
-      expect(result.success).toBe(true);
-      expect(result.configs).toHaveLength(1);
-      expect(result.configs[0].fileName).toBe(".claude/config.json");
+    it("adds a second warning naming 'ask' when a mixed list contains one", async () => {
+      // test-engineer's real block: a test-runner allowlist plus `rm -rf *: ask`.
+      const { warnings } = await adapter.fromCanonical(
+        canonical(`  bash:\n    "npx vitest *": "allow"\n    "rm -rf *": "ask"\n    "*": "deny"\n`)
+      );
 
-      const config = JSON.parse(result.configs[0].content);
-      expect(config.name).toBe("TestAgent");
-      expect(config.description).toBe("Test agent");
-      expect(config.model).toBe("claude-sonnet-4-20250514");
+      expect(warnings).toHaveLength(2);
+      expect(warnings.some((w) => /cannot express/.test(w) && /ask/.test(w))).toBe(true);
     });
 
-    it("maps tools correctly in config.json", async () => {
-      const agent = createOpenAgent({
-        frontmatter: {
-          tools: { read: true, write: true, bash: true },
-        },
-      });
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+    it("warns when a rule list has no recoverable default", async () => {
+      // context-manager's real `write` block: allow + deny with no "*" rule.
+      const { warnings } = await adapter.fromCanonical(
+        canonical(
+          `  write:\n    ".opencode/context/**/*.md": "allow"\n    "**/*.env*": "deny"\n`
+        )
+      );
 
-      expect(config.tools).toEqual(["Read", "Write", "Bash"]);
+      expect(warnings).toHaveLength(2);
+      expect(warnings.some((w) => /ambiguous/.test(w))).toBe(true);
     });
 
-    it("includes skills in config.json", async () => {
-      const agent = createOpenAgent({
-        frontmatter: {
-          skills: ["skill1", "skill2"],
-        },
-      });
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+    it("warns when a capability has no Claude Code tool at all", async () => {
+      // externalscout's real `skill` block restricts which skills it may invoke. Claude Code
+      // cannot express that; dropping it silently is the widening this suite guards against.
+      const { warnings } = await adapter.fromCanonical(
+        canonical(`  skill:\n    "*": "deny"\n    "*context7*": "allow"\n`)
+      );
 
-      expect(config.skills).toEqual(["skill1", "skill2"]);
+      expect(warnings).toHaveLength(1);
+      expect(warnings[0]).toMatch(/'skill' has no Claude Code tool/);
     });
 
-    it("includes hooks in config.json", async () => {
-      const hook: HookDefinition = {
-        event: "PreToolUse",
-        matchers: ["*.txt"],
-        commands: [{ type: "command", command: "validate" }],
-      };
+    it("warns that temperature and maxSteps cannot be carried", async () => {
+      const { warnings } = await adapter.fromCanonical(
+        canonical(`  read:\n    "*": "allow"\n`, "temperature: 0.1\nmaxSteps: 10\n")
+      );
 
-      const agent = createOpenAgent({
-        frontmatter: {
-          hooks: [hook],
-        },
-      });
+      expect(warnings).toHaveLength(2);
+      expect(warnings.some((w) => w.includes("temperature"))).toBe(true);
+      expect(warnings.some((w) => w.includes("maxSteps"))).toBe(true);
+    });
 
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+    it("reports no permission loss for an agent whose every capability projects exactly", async () => {
+      // fixture-reviewer's block is uniform-per-capability, so nothing about its permissions
+      // is lost. Its `temperature: 0.1` still is — and that one warning is the whole list.
+      const { warnings } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
 
-      expect(config.hooks).toBeDefined();
-      expect(config.hooks.PreToolUse).toBeDefined();
+      expect(warnings).toHaveLength(1);
+      expect(warnings[0]).toContain("temperature");
     });
+  });
 
-    it("warns when temperature is set (unsupported)", async () => {
-      const agent = createOpenAgent({
-        frontmatter: {
-          temperature: 0.7,
-        },
-      });
+  // ============================================================================
+  // DETERMINISM
+  // ============================================================================
 
-      const result = await adapter.fromOAC(agent);
+  describe("determinism", () => {
+    it("emits identical bytes regardless of the source's key order", async () => {
+      const rules = {
+        read: `  read:\n    "*": "allow"\n`,
+        bash: `  bash:\n    "*": "deny"\n`,
+        write: `  write:\n    "*": "deny"\n`,
+      };
 
-      expect(result.warnings.some((w) => w.includes("temperature"))).toBe(true);
-    });
+      const forward = await adapter.fromCanonical(
+        canonical(rules.read + rules.bash + rules.write)
+      );
+      const reversed = await adapter.fromCanonical(
+        canonical(rules.write + rules.bash + rules.read)
+      );
 
-    it("warns when maxSteps is set (unsupported)", async () => {
-      const agent = createOpenAgent({
-        frontmatter: {
-          maxSteps: 10,
-        },
-      });
+      expect(reversed.content).toBe(forward.content);
+    });
 
-      const result = await adapter.fromOAC(agent);
+    it("emits identical bytes across separate adapter instances", async () => {
+      const source = fixture(FIXTURE_REVIEWER);
 
-      expect(result.warnings.some((w) => w.includes("maxSteps"))).toBe(true);
+      expect((await new ClaudeAdapter().fromCanonical(source)).content).toBe(
+        (await new ClaudeAdapter().fromCanonical(source)).content
+      );
     });
+  });
 
-    it("includes validationWarnings in result", async () => {
-      const agent = createOpenAgent({
-        frontmatter: {
-          name: "",
-          description: "",
-        },
-      });
+  // ============================================================================
+  // INPUT VALIDATION
+  // ============================================================================
 
-      const result = await adapter.fromOAC(agent);
+  describe("input validation", () => {
+    it("throws a named error when the source lacks an oac: block", async () => {
+      const source = `---\nname: X\ndescription: Y\nmode: subagent\n---\n\nBody\n`;
 
-      expect(result.warnings.length).toBeGreaterThan(0);
+      await expect(adapter.fromCanonical(source)).rejects.toThrow(/not a canonical agent file/);
     });
 
-    it("handles mixed permission rules gracefully", async () => {
-      const agent = createOpenAgent({
-        frontmatter: {
-          permission: {
-            read: "allow",
-            write: "ask",
-            bash: "deny",
-          },
-        },
-      });
-
-      const result = await adapter.fromOAC(agent);
+    it("names the offending field when the oac: block is malformed", async () => {
+      const source = canonical(`  read:\n    "*": "allow"\n`).replace(
+        "id: probe-agent",
+        "id: Probe_Agent"
+      );
 
-      // Should include some form of warning or degradation
-      expect(result.warnings.length).toBeGreaterThanOrEqual(0);
-      expect(result.success).toBe(true);
+      await expect(adapter.fromCanonical(source)).rejects.toThrow(/oac\.id/);
     });
   });
 
   // ============================================================================
-  // fromOAC() - CONVERTING TO CLAUDE AGENT.MD (SUBAGENTS)
+  // CAPABILITIES
   // ============================================================================
 
-  describe("fromOAC() - converting to agent.md for subagents", () => {
-    const createSubagent = (overrides?: Partial<OpenAgent>): OpenAgent => ({
-      frontmatter: {
-        name: "CodeAnalyzer",
-        description: "Analyzes code",
-        mode: "subagent",
-        model: "claude-sonnet-4",
-        ...overrides?.frontmatter,
-      },
-      metadata: {
-        name: "CodeAnalyzer",
-        category: "specialist",
-        type: "subagent",
-      },
-      systemPrompt: "Analyze code quality",
-      contexts: [],
-      ...overrides,
-    });
+  describe("getCapabilities()", () => {
+    it("returns correct capabilities object", () => {
+      const capabilities = adapter.getCapabilities();
 
-    it("converts subagent to agent.md file", async () => {
-      const agent = createSubagent();
+      expect(capabilities.name).toBe("claude");
+      expect(capabilities.displayName).toBe("Claude Code");
+      expect(capabilities.supportsMultipleAgents).toBe(true);
+      expect(capabilities.supportsSkills).toBe(true);
+      expect(capabilities.supportsHooks).toBe(true);
+      expect(capabilities.supportsGranularPermissions).toBe(false);
+      expect(capabilities.supportsContexts).toBe(true);
+      expect(capabilities.supportsCustomModels).toBe(true);
+      expect(capabilities.supportsTemperature).toBe(false);
+      expect(capabilities.supportsMaxSteps).toBe(false);
+      expect(capabilities.configFormat).toBe("markdown");
+      expect(capabilities.outputStructure).toBe("directory");
+    });
 
-      const result = await adapter.fromOAC(agent);
+    it("agrees with the CapabilityMatrix rather than restating it", async () => {
+      // These two disagreed before: the matrix called Claude `json`, the adapter `markdown`.
+      // A platform cannot have two answers about itself.
+      const { getToolCapabilities } = await import("../../../src/core/CapabilityMatrix.js");
 
-      expect(result.success).toBe(true);
-      expect(result.configs).toHaveLength(1);
-      expect(result.configs[0].fileName).toBe(".claude/agents/CodeAnalyzer.md");
+      expect(adapter.getCapabilities().configFormat).toBe(
+        getToolCapabilities("claude").configFormat
+      );
     });
 
-    it("generates proper YAML frontmatter in agent.md", async () => {
-      const agent = createSubagent();
-
-      const result = await adapter.fromOAC(agent);
-      const content = result.configs[0].content;
+    it("includes appropriate notes", () => {
+      const capabilities = adapter.getCapabilities();
 
-      expect(content).toMatch(/^---/);
-      expect(content).toMatch(/name: "CodeAnalyzer"/);
-      expect(content).toMatch(/description: "Analyzes code"/);
-      expect(content).toContain("---\n\n");
+      expect(capabilities.notes?.length).toBeGreaterThan(0);
+      expect(capabilities.notes?.some((n) => /permission/i.test(n))).toBe(true);
     });
+  });
 
-    it("includes system prompt in agent.md body", async () => {
-      const agent = createSubagent({
-        systemPrompt: "Analyze code quality\nCheck for best practices",
-      });
+  // ============================================================================
+  // toOAC() — the IMPORT direction (still accepts legacy .claude/ shapes)
+  // ============================================================================
 
-      const result = await adapter.fromOAC(agent);
-      const content = result.configs[0].content;
+  describe("toOAC() - parsing config.json", () => {
+    it("parses minimal config.json", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({
+          name: "TestAgent",
+          description: "Test description",
+          systemPrompt: "You are helpful",
+        })
+      );
 
-      expect(content).toContain("Analyze code quality");
-      expect(content).toContain("Check for best practices");
+      expect(result.frontmatter.name).toBe("TestAgent");
+      expect(result.frontmatter.description).toBe("Test description");
+      expect(result.systemPrompt).toBe("You are helpful");
+      expect(result.frontmatter.mode).toBe("primary");
     });
 
-    it("includes tools in agent.md frontmatter", async () => {
-      const agent = createSubagent({
-        frontmatter: {
-          tools: { read: true, bash: true },
-        },
-      });
-
-      const result = await adapter.fromOAC(agent);
-      const content = result.configs[0].content;
+    it("parses config with tools array", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({ name: "Agent", description: "Test", tools: ["Read", "Write", "Bash"] })
+      );
 
-      expect(content).toContain("Read");
-      expect(content).toContain("Bash");
-      expect(content).toContain("tools");
+      expect(result.frontmatter.tools).toEqual({ read: true, write: true, bash: true });
     });
 
-    it("includes model in agent.md frontmatter", async () => {
-      const agent = createSubagent({
-        frontmatter: {
-          model: "claude-opus-4",
-        },
-      });
-
-      const result = await adapter.fromOAC(agent);
-      const content = result.configs[0].content;
+    it("parses config with tools string", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({ name: "Agent", description: "Test", tools: "Read, Write, Edit" })
+      );
 
-      expect(content).toContain("model");
-      expect(content).toContain("claude-opus-4");
+      expect(result.frontmatter.tools).toEqual({ read: true, write: true, edit: true });
     });
 
-    it("includes permission mode in agent.md frontmatter", async () => {
-      const agent = createSubagent({
-        frontmatter: {
-          permission: { read: "allow", write: "allow" },
-        },
-      });
-
-      const result = await adapter.fromOAC(agent);
-      const content = result.configs[0].content;
+    it("parses config with skills", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({ name: "Agent", description: "Test", skills: ["skill1", "skill2"] })
+      );
 
-      expect(content).toContain("permissionMode");
-      expect(content).toContain("bypassPermissions");
+      expect(result.frontmatter.skills).toEqual(["skill1", "skill2"]);
     });
-  });
-
-  // ============================================================================
-  // fromOAC() - SKILLS GENERATION FROM CONTEXTS
-  // ============================================================================
 
-  describe("fromOAC() - generating skills from contexts", () => {
-    it("generates skill files from contexts", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
+    it("parses config with hooks", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({
           name: "Agent",
           description: "Test",
-          mode: "primary",
-        },
-        metadata: {
-          name: "Agent",
-          category: "core",
-          type: "agent",
-        },
-        systemPrompt: "Prompt",
-        contexts: [
-          {
-            path: ".opencode/context/skills/python-best-practices.md",
-            description: "Python coding standards",
+          hooks: {
+            PreToolUse: [{ matcher: "*.txt", hooks: [{ type: "command", command: "validate" }] }],
           },
-        ],
-      };
-
-      const result = await adapter.fromOAC(agent);
-
-      expect(result.configs.length).toBeGreaterThan(1);
-      const skillConfig = result.configs.find((c) =>
-        c.fileName.includes(".claude/skills/")
+        })
       );
-      expect(skillConfig).toBeDefined();
-      expect(skillConfig?.fileName).toMatch(/\.claude\/skills\/.*\/SKILL\.md/);
+
+      expect(result.frontmatter.hooks?.length).toBe(1);
+      expect(result.frontmatter.hooks?.[0].event).toBe("PreToolUse");
+      expect(result.frontmatter.hooks?.[0].matchers).toEqual(["*.txt"]);
     });
 
-    it("generates correct skill name from context path", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: {
-          name: "Agent",
-          category: "core",
-          type: "agent",
-        },
-        systemPrompt: "Prompt",
-        contexts: [
-          {
-            path: "docs/React Hooks Guide.md",
-            description: "React hooks documentation",
-          },
-        ],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const skillConfig = result.configs.find((c) =>
-        c.fileName.includes(".claude/skills/")
+    it("handles missing optional fields gracefully", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({ name: "MinimalAgent", description: "Minimal" })
       );
 
-      expect(skillConfig?.fileName).toMatch(/react-hooks-guide/);
+      expect(result.frontmatter.name).toBe("MinimalAgent");
+      expect(result.systemPrompt).toBe("");
+      expect(result.frontmatter.tools).toBeUndefined();
+      expect(result.frontmatter.skills).toBeUndefined();
     });
 
-    it("includes context priority in skill content", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: {
-          name: "Agent",
-          category: "core",
-          type: "agent",
-        },
-        systemPrompt: "Prompt",
-        contexts: [
-          {
-            path: "context/important.md",
-            priority: "high",
-            description: "Important context",
-          },
-        ],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const skillConfig = result.configs.find((c) =>
-        c.fileName.includes(".claude/skills/")
-      );
+    it("parses invalid JSON as markdown (subagent fallback)", async () => {
+      const result = await adapter.toOAC("not valid json");
 
-      expect(skillConfig?.content).toContain("Priority: high");
+      expect(result.frontmatter.mode).toBe("subagent");
+      expect(result.systemPrompt).toBe("not valid json");
     });
 
-    it("generates multiple skills from multiple contexts", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: {
-          name: "Agent",
-          category: "core",
-          type: "agent",
-        },
-        systemPrompt: "Prompt",
-        contexts: [
-          { path: "context1.md", description: "First" },
-          { path: "context2.md", description: "Second" },
-          { path: "context3.md", description: "Third" },
-        ],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const skillConfigs = result.configs.filter((c) =>
-        c.fileName.includes(".claude/skills/")
+    it("handles null system prompt", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({ name: "Agent", description: "Test", systemPrompt: null })
       );
 
-      expect(skillConfigs).toHaveLength(3);
+      expect(result.systemPrompt).toBe("");
     });
+  });
 
-    it("includes skill metadata when context lacks description", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: {
-          name: "Agent",
-          category: "core",
-          type: "agent",
-        },
-        systemPrompt: "Prompt",
-        contexts: [{ path: ".opencode/context/styles.md" }],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const skillConfig = result.configs.find((c) =>
-        c.fileName.includes(".claude/skills/")
+  describe("toOAC() - parsing agent.md with YAML frontmatter", () => {
+    it("parses agent.md with minimal frontmatter", async () => {
+      const result = await adapter.toOAC(
+        `---\nname: SubAgent\ndescription: A subagent\n---\n\nThis is the system prompt.`
       );
 
-      expect(skillConfig?.content).toContain("Context from");
-      expect(skillConfig?.content).toContain("styles.md");
+      expect(result.frontmatter.name).toBe("SubAgent");
+      expect(result.frontmatter.description).toBe("A subagent");
+      expect(result.frontmatter.mode).toBe("subagent");
+      expect(result.systemPrompt).toBe("This is the system prompt.");
     });
 
-    it("handles skill names with special characters", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: {
-          name: "Agent",
-          category: "core",
-          type: "agent",
-        },
-        systemPrompt: "Prompt",
-        contexts: [
-          { path: "docs/Type Script Advanced Rules.md", description: "Test" },
-        ],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const skillConfig = result.configs.find((c) =>
-        c.fileName.includes(".claude/skills/")
+    it("parses a committed plugin agent's flat tools list", async () => {
+      const result = await adapter.toOAC(
+        `---\nname: code-reviewer\ndescription: Reviews\ntools: Read, Glob, Grep\nmodel: sonnet\n---\n\nPrompt`
       );
 
-      expect(skillConfig).toBeDefined();
-      // Should have lowercase and dashed name from path
-      expect(skillConfig?.fileName).toMatch(/type-script-advanced-rules/);
-    });
-  });
-
-  // ============================================================================
-  // VALIDATION
-  // ============================================================================
-
-  describe("validateConversion()", () => {
-    const createAgent = (overrides?: Partial<AgentFrontmatter>): OpenAgent => ({
-      frontmatter: {
-        name: "Agent",
-        description: "Test",
-        mode: "primary",
-        ...overrides,
-      },
-      metadata: { name: "Agent", category: "core", type: "agent" },
-      systemPrompt: "Prompt",
-      contexts: [],
-    });
-
-    it("returns no warnings for valid agent", () => {
-      const agent = createAgent();
-
-      const warnings = adapter.validateConversion(agent);
-
-      expect(warnings).toHaveLength(0);
-    });
-
-    it("warns when name is missing", () => {
-      const agent = createAgent({ name: "" });
-
-      const warnings = adapter.validateConversion(agent);
-
-      expect(warnings.some((w) => w.includes("name"))).toBe(true);
-    });
-
-    it("warns when description is missing", () => {
-      const agent = createAgent({ description: "" });
-
-      const warnings = adapter.validateConversion(agent);
-
-      expect(warnings.some((w) => w.includes("description"))).toBe(true);
+      expect(result.frontmatter.tools).toEqual({ read: true, glob: true, grep: true });
+      expect(result.frontmatter.model).toBe("claude-sonnet-4");
     });
 
-    it("warns about granular permission degradation", () => {
-      const agent = createAgent({
-        permission: {
-          read: { "file1.txt": "allow", "file2.txt": "deny" },
-        },
-      });
-
-      const warnings = adapter.validateConversion(agent);
+    it("handles agent.md without frontmatter as markdown content", async () => {
+      const result = await adapter.toOAC("No frontmatter here, just markdown content");
 
-      expect(
-        warnings.some((w) => w.includes("granular permissions"))
-      ).toBe(true);
+      expect(result.systemPrompt).toBe("No frontmatter here, just markdown content");
+      expect(result.frontmatter.mode).toBe("subagent");
     });
 
-    it("does not warn about simple permission rules", () => {
-      const agent = createAgent({
-        permission: { read: "allow", write: "allow" },
-      });
-
-      const warnings = adapter.validateConversion(agent);
+    it("preserves multiline system prompt", async () => {
+      const result = await adapter.toOAC(
+        `---\nname: Agent\ndescription: Test\n---\n\nLine one.\nLine two.\nLine three.`
+      );
 
-      expect(
-        warnings.some((w) => w.includes("granular permissions"))
-      ).toBe(false);
+      expect(result.systemPrompt).toContain("Line one.");
+      expect(result.systemPrompt).toContain("Line three.");
     });
   });
 
-  // ============================================================================
-  // MODEL MAPPING
-  // ============================================================================
-
   describe("model mapping (Claude to OAC)", () => {
-    it("maps claude-sonnet-4-20250514 to claude-sonnet-4", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        model: "claude-sonnet-4-20250514",
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
+    it("maps dated sonnet id to claude-sonnet-4", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({ name: "A", description: "T", model: "claude-sonnet-4-20250514" })
+      );
 
       expect(result.frontmatter.model).toBe("claude-sonnet-4");
     });
 
-    it("maps short model names", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        model: "opus",
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
+    it("maps short model aliases", async () => {
+      const result = await adapter.toOAC(
+        JSON.stringify({ name: "A", description: "T", model: "opus" })
+      );
 
       expect(result.frontmatter.model).toBe("claude-opus-4");
     });
 
     it("preserves unknown models", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        model: "claude-custom-model",
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
+      const result = await adapter.toOAC(
+        JSON.stringify({ name: "A", description: "T", model: "claude-custom-model" })
+      );
 
       expect(result.frontmatter.model).toBe("claude-custom-model");
     });
 
     it("handles missing model gracefully", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
+      const result = await adapter.toOAC(JSON.stringify({ name: "A", description: "T" }));
 
       expect(result.frontmatter.model).toBeUndefined();
     });
   });
 
   // ============================================================================
-  // MODEL MAPPING (OAC to Claude)
+  // fromOAC() — legacy in-memory interface, retargeted to the plugin layout
   // ============================================================================
 
-  describe("model mapping (OAC to Claude)", () => {
-    it("maps claude-sonnet-4 to full version", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          model: "claude-sonnet-4",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      expect(config.model).toBe("claude-sonnet-4-20250514");
-    });
-
-    it("preserves other model IDs", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          model: "claude-opus-4",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      expect(config.model).toBe("claude-opus-4");
+  describe("fromOAC()", () => {
+    const createAgent = (overrides?: Partial<AgentFrontmatter>): OpenAgent => ({
+      frontmatter: {
+        name: "CodeAnalyzer",
+        description: "Analyzes code",
+        mode: "subagent",
+        ...overrides,
+      },
+      metadata: { name: "CodeAnalyzer", category: "specialist", type: "subagent" },
+      systemPrompt: "Analyze code quality",
+      contexts: [],
     });
 
-    it("does not set model when not provided", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      expect(config.model).toBeUndefined();
-    });
-  });
+    it("emits a single agent markdown file", async () => {
+      const result = await adapter.fromOAC(createAgent());
 
-  // ============================================================================
-  // TOOL MAPPING
-  // ============================================================================
-
-  describe("tool mapping and parsing", () => {
-    it("parses comma-separated tools string", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        tools: "Read, Write, Edit, Bash",
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
-
-      expect(result.frontmatter.tools).toEqual({
-        read: true,
-        write: true,
-        edit: true,
-        bash: true,
-      });
+      expect(result.success).toBe(true);
+      expect(result.configs).toHaveLength(1);
+      expect(result.configs[0].fileName).toBe("plugins/claude-code/agents/CodeAnalyzer.md");
+      expect(result.configs[0].encoding).toBe("utf-8");
     });
 
-    it("normalizes tool names to lowercase", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        tools: ["Read", "WRITE", "BaSh"],
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
+    it("generates flat frontmatter and includes the system prompt", async () => {
+      const result = await adapter.fromOAC(createAgent());
+      const content = result.configs[0].content;
 
-      expect(Object.keys(result.frontmatter.tools || {})).toContain("read");
-      expect(Object.keys(result.frontmatter.tools || {})).toContain("write");
-      expect(Object.keys(result.frontmatter.tools || {})).toContain("bash");
+      expect(content).toMatch(/^---\nname: CodeAnalyzer\ndescription: Analyzes code\n/);
+      expect(content).toContain("---\n\n");
+      expect(content).toContain("Analyze code quality");
     });
 
-    it("capitalizes tools for Claude format", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          tools: { read: true, write: true, bash: true },
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      expect(config.tools).toEqual(
-        expect.arrayContaining(["Read", "Write", "Bash"])
+    it("maps an authored tools map through the canonical ordering", async () => {
+      const result = await adapter.fromOAC(
+        createAgent({ tools: { bash: true, read: true, write: false } })
       );
-    });
-
-    it("omits disabled tools", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          tools: { read: true, write: false, bash: true },
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      expect(config.tools).not.toContain("Write");
-      expect(config.tools).toContain("Read");
-    });
-  });
 
-  // ============================================================================
-  // PERMISSION MAPPING
-  // ============================================================================
-
-  describe("permission mapping", () => {
-    it("maps all-allow permissions to bypassPermissions", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          permission: { read: "allow", write: "allow", bash: true },
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      expect(config.permissionMode).toBe("bypassPermissions");
+      expect(result.configs[0].content).toMatch(/^tools: Read, Bash$/m);
     });
 
-    it("maps all-deny permissions to dontAsk", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          permission: { read: "deny", write: "deny", bash: false },
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+    it("projects an authored permission map fail-closed", async () => {
+      const result = await adapter.fromOAC(
+        createAgent({ permission: { bash: { "*": "deny", "git log*": "allow" } } })
+      );
 
-      expect(config.permissionMode).toBe("dontAsk");
+      expect(result.configs[0].content).toMatch(/^disallowedTools: Bash$/m);
+      expect(result.warnings.some((w) => /bash/i.test(w))).toBe(true);
     });
 
-    it("maps ask permissions to default", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          permission: { read: "ask", write: "allow" },
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+    it("does not emit a permissionMode — Claude Code has no such agent field", async () => {
+      const result = await adapter.fromOAC(
+        createAgent({ permission: { read: "allow", write: "allow" } })
+      );
 
-      expect(config.permissionMode).toBe("default");
+      expect(result.configs[0].content).not.toContain("permissionMode");
+      expect(result.configs[0].content).not.toContain("bypassPermissions");
     });
 
-    it("warns on mixed granular permissions", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          permission: {
-            read: "allow",
-            "write.file1": "deny",
-          },
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
+    it("warns when temperature is set (unsupported)", async () => {
+      const result = await adapter.fromOAC(createAgent({ temperature: 0.7 }));
 
-      expect(result.warnings.some((w) => w.includes("permission"))).toBe(true);
+      expect(result.warnings.some((w) => w.includes("temperature"))).toBe(true);
     });
-  });
-
-  // ============================================================================
-  // SKILL MAPPING
-  // ============================================================================
-
-  describe("skill parsing and mapping", () => {
-    it("parses skills as comma-separated string", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        skills: "skill1, skill2, skill3",
-        systemPrompt: "Prompt",
-      });
 
-      const result = await adapter.toOAC(source);
+    it("warns when maxSteps is set (unsupported)", async () => {
+      const result = await adapter.fromOAC(createAgent({ maxSteps: 10 }));
 
-      expect(result.frontmatter.skills).toEqual(["skill1", "skill2", "skill3"]);
+      expect(result.warnings.some((w) => w.includes("maxSteps"))).toBe(true);
     });
 
-    it("parses skills as array", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        skills: ["skill1", "skill2"],
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
+    it("includes validation warnings for a nameless agent", async () => {
+      const result = await adapter.fromOAC(createAgent({ name: "", description: "" }));
 
-      expect(result.frontmatter.skills).toEqual(["skill1", "skill2"]);
+      expect(result.warnings.length).toBeGreaterThan(0);
     });
 
-    it("handles empty skills gracefully", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        skills: [],
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
+    it("includes capabilities in the result", async () => {
+      const result = await adapter.fromOAC(createAgent());
 
-      expect(result.frontmatter.skills).toEqual([]);
+      expect(result.capabilities?.name).toBe("claude");
     });
 
-    it("includes skills in config.json", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          skills: ["skill1", "skill2"],
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+    it("handles an empty system prompt", async () => {
+      const result = await adapter.fromOAC({ ...createAgent(), systemPrompt: "" });
 
-      expect(config.skills).toEqual(["skill1", "skill2"]);
+      expect(result.success).toBe(true);
+      expect(result.configs[0].content).toBe(
+        "---\nname: CodeAnalyzer\ndescription: Analyzes code\n---\n\n\n"
+      );
     });
 
-    it("handles skill objects with name property", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          skills: [{ name: "skill1" }],
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
+    it("carries hooks nowhere in agent frontmatter", async () => {
+      // Claude Code agent frontmatter accepts name/description/tools/disallowedTools/model
+      // and nothing else. The old adapter wrote a `hooks:` key that Claude Code silently
+      // ignores, which reads as support that does not exist.
+      const hook: HookDefinition = {
+        event: "PreToolUse",
+        matchers: ["*.txt"],
+        commands: [{ type: "command", command: "validate" }],
       };
 
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+      const result = await adapter.fromOAC(createAgent({ hooks: [hook] }));
 
-      expect(config.skills).toContain("skill1");
+      expect(result.configs[0].content).not.toContain("hooks");
     });
   });
 
   // ============================================================================
-  // HOOK MAPPING
+  // SKILLS GENERATION FROM CONTEXTS
   // ============================================================================
 
-  describe("hook parsing and mapping", () => {
-    it("parses hooks with matchers", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        hooks: {
-          PreToolUse: [
-            {
-              matcher: "*.txt",
-              hooks: [{ type: "command", command: "validate" }],
-            },
-          ],
-        },
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
-
-      expect(result.frontmatter.hooks).toBeDefined();
-      expect(result.frontmatter.hooks![0].matchers).toEqual(["*.txt"]);
-    });
-
-    it("maps hook commands correctly", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        hooks: {
-          PostToolUse: [
-            {
-              matcher: "*",
-              hooks: [
-                { type: "command", command: "log" },
-                { type: "command", command: "notify" },
-              ],
-            },
-          ],
-        },
-        systemPrompt: "Prompt",
-      });
-
-      const result = await adapter.toOAC(source);
-
-      expect(result.frontmatter.hooks![0].commands.length).toBe(2);
+  describe("fromOAC() - generating skills from contexts", () => {
+    const withContexts = (
+      contexts: Array<{ path: string; priority?: string; description?: string }>
+    ): OpenAgent => ({
+      frontmatter: { name: "Agent", description: "Test", mode: "primary" },
+      metadata: { name: "Agent", category: "core", type: "agent" },
+      systemPrompt: "Prompt",
+      contexts,
     });
 
-    it("converts hooks back to Claude format", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          hooks: [
-            {
-              event: "PreToolUse",
-              matchers: ["*.txt"],
-              commands: [{ type: "command", command: "validate" }],
-            },
-          ],
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+    it("generates skill files under the plugin tree", async () => {
+      const result = await adapter.fromOAC(
+        withContexts([
+          { path: ".opencode/context/skills/python.md", description: "Python standards" },
+        ])
+      );
 
-      expect(config.hooks.PreToolUse).toBeDefined();
-      expect(config.hooks.PreToolUse[0].matcher).toBe("*.txt");
+      const skill = result.configs.find((c) => c.fileName.includes("/skills/"));
+      expect(skill?.fileName).toBe("plugins/claude-code/skills/python/SKILL.md");
     });
 
-    it("defaults matcher to wildcard", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          hooks: [
-            {
-              event: "AgentStart",
-              commands: [{ type: "command", command: "init" }],
-            },
-          ],
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
+    it("generates a slugified skill name from the context path", async () => {
+      const result = await adapter.fromOAC(
+        withContexts([{ path: "docs/React Hooks Guide.md", description: "React docs" }])
+      );
 
-      expect(config.hooks.AgentStart[0].matcher).toBe("*");
+      expect(result.configs.find((c) => c.fileName.includes("/skills/"))?.fileName).toMatch(
+        /react-hooks-guide/
+      );
     });
-  });
-
-  // ============================================================================
-  // ROUNDTRIP CONVERSION
-  // ============================================================================
-
-  describe("roundtrip conversion", () => {
-    it("roundtrip primary agent config", async () => {
-      // Start with OAC
-      const original: OpenAgent = {
-        frontmatter: {
-          name: "TestAgent",
-          description: "A test agent",
-          mode: "primary",
-          model: "claude-sonnet-4",
-          tools: { read: true, write: true },
-          skills: ["skill1"],
-        },
-        metadata: { name: "TestAgent", category: "core", type: "agent" },
-        systemPrompt: "You are helpful",
-        contexts: [],
-      };
 
-      // Convert to Claude
-      const toClaudeResult = await adapter.fromOAC(original);
-      const claudeConfig = JSON.parse(toClaudeResult.configs[0].content);
-
-      // Convert back to OAC
-      const backToOAC = await adapter.toOAC(JSON.stringify(claudeConfig));
+    it("includes context priority in skill content", async () => {
+      const result = await adapter.fromOAC(
+        withContexts([{ path: "context/important.md", priority: "high", description: "Ctx" }])
+      );
 
-      // Verify core properties are preserved
-      expect(backToOAC.frontmatter.name).toBe(original.frontmatter.name);
-      expect(backToOAC.frontmatter.description).toBe(
-        original.frontmatter.description
+      expect(result.configs.find((c) => c.fileName.includes("/skills/"))?.content).toContain(
+        "Priority: high"
       );
-      expect(backToOAC.systemPrompt).toBe(original.systemPrompt);
-      expect(backToOAC.frontmatter.tools).toEqual(original.frontmatter.tools);
     });
 
-    it("roundtrip preserves model through conversion", async () => {
-      const original: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          model: "claude-opus-4",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
+    it("generates one skill per context", async () => {
+      const result = await adapter.fromOAC(
+        withContexts([{ path: "a.md" }, { path: "b.md" }, { path: "c.md" }])
+      );
 
-      const toClaudeResult = await adapter.fromOAC(original);
-      const claudeConfig = JSON.parse(toClaudeResult.configs[0].content);
-      const backToOAC = await adapter.toOAC(JSON.stringify(claudeConfig));
+      expect(result.configs.filter((c) => c.fileName.includes("/skills/"))).toHaveLength(3);
+    });
+
+    it("falls back to a generated description when the context lacks one", async () => {
+      const result = await adapter.fromOAC(withContexts([{ path: ".opencode/context/styles.md" }]));
+      const skill = result.configs.find((c) => c.fileName.includes("/skills/"));
 
-      expect(backToOAC.frontmatter.model).toBe("claude-opus-4");
+      expect(skill?.content).toContain("Context from");
+      expect(skill?.content).toContain("styles.md");
     });
   });
 
   // ============================================================================
-  // EDGE CASES & ERROR HANDLING
+  // VALIDATION
   // ============================================================================
 
-  describe("edge cases and error handling", () => {
-    it("omits empty tools from config", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-          tools: {},
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      // Empty tools should not be included in config
-      expect(config.tools === undefined || config.tools?.length === 0).toBe(true);
-    });
-
-    it("handles null system prompt", async () => {
-      const source = JSON.stringify({
-        name: "Agent",
-        description: "Test",
-        systemPrompt: null,
-      });
-
-      const result = await adapter.toOAC(source);
-
-      expect(result.systemPrompt).toBe("");
-    });
-
-    it("handles empty system prompt", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      expect(config.systemPrompt).toBe("");
-    });
-
-    it("handles special characters in names", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent-With-Dashes_and_underscores",
-          description: "Test with special chars: @#$",
-          mode: "primary",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-
-      expect(result.success).toBe(true);
-      const config = JSON.parse(result.configs[0].content);
-      expect(config.name).toContain("-");
+  describe("validateConversion()", () => {
+    const createAgent = (overrides?: Partial<AgentFrontmatter>): OpenAgent => ({
+      frontmatter: { name: "Agent", description: "Test", mode: "primary", ...overrides },
+      metadata: { name: "Agent", category: "core", type: "agent" },
+      systemPrompt: "Prompt",
+      contexts: [],
     });
 
-    it("handles very long system prompt", async () => {
-      const longPrompt = "A".repeat(5000);
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: longPrompt,
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
-      const config = JSON.parse(result.configs[0].content);
-
-      expect(config.systemPrompt.length).toBe(5000);
+    it("returns no warnings for valid agent", () => {
+      expect(adapter.validateConversion(createAgent())).toHaveLength(0);
     });
 
-    it("handles multiline YAML values in frontmatter", async () => {
-      const source = `---
-name: Agent
-description: Test description
----
-
-System prompt here`;
-
-      const result = await adapter.toOAC(source);
-
-      expect(result.frontmatter.name).toBe("Agent");
-      expect(result.systemPrompt).toBe("System prompt here");
+    it("warns when name is missing", () => {
+      expect(adapter.validateConversion(createAgent({ name: "" })).some((w) => w.includes("name"))).toBe(
+        true
+      );
     });
-  });
-
-  // ============================================================================
-  // CONVERSION RESULT STRUCTURE
-  // ============================================================================
-
-  describe("conversion result structure", () => {
-    it("returns success true on valid conversion", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
 
-      expect(result.success).toBe(true);
+    it("warns when description is missing", () => {
+      expect(
+        adapter
+          .validateConversion(createAgent({ description: "" }))
+          .some((w) => w.includes("description"))
+      ).toBe(true);
     });
 
-    it("includes capabilities in result", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
+    it("warns about granular permission degradation", () => {
+      const warnings = adapter.validateConversion(
+        createAgent({ permission: { read: { "file1.txt": "allow", "file2.txt": "deny" } } })
+      );
 
-      expect(result.capabilities).toBeDefined();
-      expect(result.capabilities?.name).toBe("claude");
+      expect(warnings.some((w) => w.includes("granular permissions"))).toBe(true);
     });
 
-    it("includes encoding in tool config", async () => {
-      const agent: OpenAgent = {
-        frontmatter: {
-          name: "Agent",
-          description: "Test",
-          mode: "primary",
-        },
-        metadata: { name: "Agent", category: "core", type: "agent" },
-        systemPrompt: "Prompt",
-        contexts: [],
-      };
-
-      const result = await adapter.fromOAC(agent);
+    it("does not warn about simple permission rules", () => {
+      const warnings = adapter.validateConversion(
+        createAgent({ permission: { read: "allow", write: "allow" } })
+      );
 
-      expect(result.configs[0].encoding).toBe("utf-8");
+      expect(warnings.some((w) => w.includes("granular permissions"))).toBe(false);
     });
   });
 });

+ 6 - 1
packages/compatibility-layer/tests/unit/core/CapabilityMatrix.test.ts

@@ -391,7 +391,12 @@ describe("CapabilityMatrix", () => {
         expect(caps.supportsSkills).toBe(true);
         expect(caps.supportsHooks).toBe(true);
         expect(caps.supportsGranularPermissions).toBe(false);
-        expect(caps.configFormat).toBe("json");
+        // Claude Code agents are markdown files with YAML frontmatter
+        // (`plugins/claude-code/agents/<id>.md`). This asserted "json" on the reasoning that
+        // settings.json is JSON — but settings.json is not what any adapter emits, and
+        // ClaudeAdapter.getCapabilities() simultaneously reported "markdown". The emitted
+        // artifact decides; the adapter now reads this row rather than restating it.
+        expect(caps.configFormat).toBe("markdown");
         expect(caps.outputStructure).toBe("directory");
       });
     });

+ 435 - 0
packages/compatibility-layer/tests/unit/core/RegistryEmitter.test.ts

@@ -0,0 +1,435 @@
+/**
+ * Registry emission — `registry.json` is GENERATED from `content/agents/**`, not hand-edited.
+ *
+ * ─── What is actually at stake ──────────────────────────────────────────────────────────
+ *
+ * `install.sh` reads `registry.json` and is the only shipping install path. Every assertion
+ * here is really a statement about whether a real user can install this repo, which is why
+ * this suite leans on the LIVE corpus rather than fixtures alone: a fixture tree cannot tell
+ * us that the thing we ship is intact.
+ *
+ * Three properties matter, in this order:
+ *
+ *   1. Nothing that install.sh reads is lost (carry-through, aliases, eval-runner).
+ *   2. The output is byte-stable and a fixed point over itself, or the subtask-11
+ *      `oac build && git diff --exit-code` gate is a coin flip.
+ *   3. The generated entries actually come from the canonical files.
+ *
+ * ─── Two snapshots of known defects live here ───────────────────────────────────────────
+ *
+ * {@link MISSING_FROM_REGISTRY} and {@link REGISTRY_ONLY_DEPENDENCIES} pin bugs, not
+ * invariants — same pattern as `KNOWN_DEAD` in `reference-resolution.test.ts`. When a subtask
+ * repairs one, the test turns red and forces a deliberate edit here. That is the point: the
+ * drift these record is exactly the kind that accumulated silently for months because the only
+ * validator in the repo could not see it.
+ */
+
+import { describe, it, expect } from "vitest";
+import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+  RegistryEmitter,
+  emitRegistry,
+  serializeRegistry,
+  entryForAgent,
+  type RegistryDocument,
+  type RegistryEntry,
+} from "../../../src/core/RegistryEmitter.js";
+import { CanonicalAgentLoader } from "../../../src/core/AgentLoader.js";
+import { repoPath } from "../../support/pending.js";
+
+const COMMITTED_JSON = readFileSync(repoPath("registry.json"), "utf-8");
+const COMMITTED = JSON.parse(COMMITTED_JSON) as RegistryDocument;
+
+/** Categories phase 1 generates. Everything else is carried through verbatim. */
+const GENERATED_CATEGORIES = ["agents", "subagents"] as const;
+
+/** Anything that would make two runs differ. */
+const NONDETERMINISM = [
+  { name: "an ISO timestamp", pattern: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ },
+  { name: "a 'generated at' stamp", pattern: /generated (at|on)[:\s]/i },
+  { name: "an absolute home path", pattern: /\/(Users|home)\/[^/\s"]+/ },
+];
+
+/**
+ * Agents that ship on disk, are referenced, and are ABSENT from the committed registry.
+ *
+ * Verified on disk 2026-07-15. `batch-executor` is the load-bearing one: both `openagent` and
+ * `opencoder` declare `subagent:batch-executor`, so the registry has advertised a dependency
+ * on a component it does not contain. The other six are in neither the registry nor
+ * `.opencode/config/agent-metadata.json` — they were added to `.opencode/agent/` and nothing
+ * ever noticed. `auto-detect-components.sh --dry-run` reports all 7 as "New Components" today.
+ *
+ * The emitter fixes all of these BY CONSTRUCTION, which is the whole argument for generating.
+ */
+const MISSING_FROM_REGISTRY = [
+  "adr-manager",
+  "architecture-analyzer",
+  "batch-executor",
+  "contract-manager",
+  "prioritization-engine",
+  "stage-orchestrator",
+  "story-mapper",
+] as const;
+
+/**
+ * Dependency edges that exist ONLY in `registry.json` — no agent file and no sidecar entry
+ * declares them, so generating from the canonical tree DROPS them.
+ *
+ * This is a real regression, not a cleanup: `install.sh` resolves these transitively today
+ * (`resolve_dependencies`, install.sh:420), so installing `subagent:externalscout` currently
+ * pulls in `skill:context7` and would stop doing so. They were hand-authored straight into
+ * `registry.json` and never made it into any file, which is precisely the drift this refactor
+ * exists to end — but the fix is to backfill `oac.dependencies` in `content/agents/**`, not to
+ * let the emitter delete them silently.
+ *
+ * Pinned here so subtask 10 cannot emit for real while the loss is unresolved.
+ */
+const REGISTRY_ONLY_DEPENDENCIES: Readonly<Record<string, readonly string[]>> = {
+  contextscout: [
+    "command:check-context-deps",
+    "context:registry-dependencies",
+    "context:context-system",
+    "context:mvi",
+    "context:structure",
+    "context:workflows",
+    "subagent:externalscout",
+    "context:root-navigation",
+    "context:context-paths-config",
+  ],
+  externalscout: ["skill:context7", "context:context-system"],
+  "image-specialist": ["tool:gemini"],
+  "context-organizer": ["context:core/context-system/*"],
+};
+
+function entries(document: RegistryDocument, category: string): RegistryEntry[] {
+  return document.components[category] ?? [];
+}
+
+function byId(document: RegistryDocument, category: string): Map<string, RegistryEntry> {
+  return new Map(entries(document, category).map((entry) => [entry.id, entry]));
+}
+
+/** A throwaway repo root containing only a `registry.json`, for fixed-point testing. */
+function scratchRoot(registryJson: string): string {
+  const root = mkdtempSync(join(tmpdir(), "oac-registry-"));
+  writeFileSync(join(root, "registry.json"), registryJson);
+  return root;
+}
+
+// ============================================================================
+// SERIALISATION — the diff must be semantic, never formatting
+// ============================================================================
+
+describe("serializeRegistry", () => {
+  it("reproduces the committed registry.json byte-for-byte from its own parse", () => {
+    // If this drifts, every generated-vs-committed diff drowns in whitespace and escaping
+    // noise and the subtask-11 gate stops meaning anything. It also pins the non-ASCII
+    // escaping: the committed file writes `—` and two emoji as escapes, which bare
+    // `JSON.stringify` would emit as raw UTF-8.
+    expect(serializeRegistry(COMMITTED)).toBe(COMMITTED_JSON);
+  });
+
+  it("escapes non-ASCII rather than emitting raw UTF-8", () => {
+    const json = serializeRegistry({ components: {}, note: "em—dash ⛔" } as RegistryDocument);
+
+    expect(json).toContain("em\\u2014dash \\u26d4");
+    expect(json).not.toContain("—");
+  });
+
+  it("ends with exactly one trailing newline", () => {
+    expect(serializeRegistry(COMMITTED).endsWith("}\n")).toBe(true);
+    expect(serializeRegistry(COMMITTED).endsWith("}\n\n")).toBe(false);
+  });
+});
+
+// ============================================================================
+// DETERMINISM
+// ============================================================================
+
+describe("determinism", () => {
+  it("emits byte-identical output across two runs", async () => {
+    expect(await emitRegistry(repoPath())).toBe(await emitRegistry(repoPath()));
+  });
+
+  it("emits nothing that varies between runs", async () => {
+    const json = await emitRegistry(repoPath());
+
+    for (const { name, pattern } of NONDETERMINISM) {
+      expect(json, `generated registry.json contains ${name}`).not.toMatch(pattern);
+    }
+  });
+
+  it("carries metadata.lastUpdated from the base instead of stamping the clock", async () => {
+    // auto-detect-components.sh:872 did `now | strftime("%Y-%m-%d")`, which alone would make
+    // every rebuild a diff and every `git diff --exit-code` gate fail on an unchanged tree.
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    expect(document.metadata).toEqual(COMMITTED.metadata);
+  });
+
+  it("sorts generated entries by id, not by filesystem or insertion order", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    for (const category of GENERATED_CATEGORIES) {
+      const ids = entries(document, category).map((entry) => entry.id);
+      expect(ids, `${category} is not id-sorted`).toEqual([...ids].sort());
+    }
+  });
+
+  it("is a fixed point over its own output", async () => {
+    // Subtask 10 writes this output back to registry.json, and the emitter then reads that as
+    // its own base. If emitting were not idempotent the tree would oscillate and the diff gate
+    // would fail forever on a tree nobody touched.
+    const first = await emitRegistry(repoPath());
+    const root = scratchRoot(first);
+
+    try {
+      const second = await new RegistryEmitter(root, {
+        contentRoot: repoPath("content/agents"),
+      }).emitJson();
+
+      expect(second).toBe(first);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});
+
+// ============================================================================
+// CARRY-THROUGH — phase 1 owns agents only
+// ============================================================================
+
+describe("carry-through of everything the canonical tree does not own", () => {
+  it("leaves non-agent component categories byte-identical", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+    const carried = Object.keys(COMMITTED.components).filter(
+      (category) => !GENERATED_CATEGORIES.includes(category as (typeof GENERATED_CATEGORIES)[number])
+    );
+
+    expect(carried).toContain("contexts");
+    for (const category of carried) {
+      expect(entries(document, category), `${category} was modified`).toEqual(
+        entries(COMMITTED, category)
+      );
+    }
+  });
+
+  it("leaves every non-components top-level key untouched, in order", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    expect(Object.keys(document)).toEqual(Object.keys(COMMITTED));
+    for (const key of Object.keys(COMMITTED).filter((k) => k !== "components")) {
+      expect(document[key], `top-level "${key}" was modified`).toEqual(COMMITTED[key]);
+    }
+  });
+
+  it("preserves the component category order", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    expect(Object.keys(document.components)).toEqual(Object.keys(COMMITTED.components));
+  });
+
+  it("preserves context aliases", async () => {
+    // install.sh resolve_dependencies matches `.id == $id or (.aliases | index($id))`
+    // (install.sh:420). Dropping an alias silently uninstalls a component for anyone naming
+    // it the aliased way.
+    const document = await new RegistryEmitter(repoPath()).emit();
+    const aliased = entries(document, "contexts").filter((entry) => entry.aliases !== undefined);
+
+    expect(aliased.map((entry) => entry.id).sort()).toEqual([
+      "component-planning",
+      "feature-breakdown",
+      "session-management",
+    ]);
+  });
+
+  it("keeps registry profiles verbatim and never consults profile.json", async () => {
+    // `.profiles` is what install.sh reads (get_profile_components, install.sh:292).
+    // `.opencode/profiles/<name>/profile.json` has drifted from it on all 5 profiles and is
+    // read only by check-dependencies.ts, which no install path invokes. Reconciling them is
+    // a content decision; generating from the wrong one silently changes what users install.
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    expect(document.profiles).toEqual(COMMITTED.profiles);
+  });
+
+  it("leaves the dead advanced-profile wildcard exactly as authored", async () => {
+    // `context:context-system/*` expands to zero registry paths. Repairing it to
+    // `context:core/context-system/*` would change what `--profile advanced` installs, so it
+    // stays a known-dead ref that ReferenceResolver reports rather than an emitter rewrite.
+    const document = await new RegistryEmitter(repoPath()).emit();
+    const advanced = document.profiles as Record<string, { components: string[] }>;
+
+    expect(advanced.advanced?.components).toContain("context:context-system/*");
+  });
+
+  it("carries agents that have no canonical counterpart", async () => {
+    // `agent:eval-runner` ships in `.opencode/agent/eval-runner.md` and is in the committed
+    // registry, but `content/agents/` does not carry it yet. Generating agents purely from the
+    // canonical tree would delete it and uninstall the eval harness.
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    expect(byId(document, "agents").get("eval-runner")).toEqual(
+      byId(COMMITTED, "agents").get("eval-runner")
+    );
+  });
+
+  it("never drops an id the committed registry publishes", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    for (const category of Object.keys(COMMITTED.components)) {
+      const generated = byId(document, category);
+      const dropped = entries(COMMITTED, category)
+        .map((entry) => entry.id)
+        .filter((id) => !generated.has(id));
+
+      expect(dropped, `${category} ids dropped by the emitter`).toEqual([]);
+    }
+  });
+});
+
+// ============================================================================
+// GENERATION — entries come from the canonical files
+// ============================================================================
+
+describe("generation from the canonical tree", () => {
+  it("derives every field of an entry from the canonical file", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+    const agents = await new CanonicalAgentLoader(repoPath("content/agents")).loadFromDirectory();
+    const tester = agents.find((agent) => agent.oac.id === "tester");
+
+    expect(tester).toBeDefined();
+    expect(byId(document, "subagents").get("tester")).toEqual({
+      id: "tester",
+      name: "TestEngineer",
+      type: "subagent",
+      // Identity is oac.id, never the filename: the file is `test-engineer.md` but the
+      // registry, the profiles and every context doc reference `tester`.
+      path: ".opencode/agent/subagents/code/test-engineer.md",
+      version: "1.0.0",
+      description: tester!.frontmatter.description,
+      tags: ["testing", "tdd", "quality"],
+      dependencies: ["context:standards-tests"],
+      category: "subagents/code",
+    });
+  });
+
+  it("emits entry keys in the committed field order", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    for (const category of GENERATED_CATEGORIES) {
+      for (const entry of entries(document, category)) {
+        if (entry.id === "eval-runner") continue; // carried through, keeps its own shape
+
+        expect(Object.keys(entry), `${entry.id} field order`).toEqual([
+          "id",
+          "name",
+          "type",
+          "path",
+          "version",
+          "description",
+          "tags",
+          "dependencies",
+          "category",
+        ]);
+      }
+    }
+  });
+
+  it("points path at the built install tree, not the canonical source", async () => {
+    // install.sh copies `.path` verbatim. If it named `content/agents/**` the installer would
+    // fetch files that do not exist in the published layout.
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    for (const category of GENERATED_CATEGORIES) {
+      for (const entry of entries(document, category)) {
+        expect(entry.path, `${entry.id} path`).toMatch(/^\.opencode\/agent\//);
+      }
+    }
+  });
+
+  it("registers every canonical agent", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+    const agents = await new CanonicalAgentLoader(repoPath("content/agents")).loadFromDirectory();
+    const registered = new Set([
+      ...byId(document, "agents").keys(),
+      ...byId(document, "subagents").keys(),
+    ]);
+
+    const unregistered = agents.map((a) => a.oac.id).filter((id) => !registered.has(id));
+    expect(unregistered, "canonical agents the emitter failed to register").toEqual([]);
+  });
+
+  it("files an agent under the components key its oac.type names", async () => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    for (const entry of entries(document, "agents")) expect(entry.type).toBe("agent");
+    for (const entry of entries(document, "subagents")) expect(entry.type).toBe("subagent");
+  });
+
+  it("resolves a fixture tree independently of the live corpus", async () => {
+    const agents = await new CanonicalAgentLoader(repoPath("content/agents")).loadFromDirectory();
+    const openagent = agents.find((agent) => agent.oac.id === "openagent");
+
+    expect(entryForAgent(openagent!, ".opencode/agent")).toMatchObject({
+      id: "openagent",
+      type: "agent",
+      path: ".opencode/agent/core/openagent.md",
+      category: "core",
+    });
+    expect(entryForAgent(openagent!, "custom/root").path).toBe("custom/root/core/openagent.md");
+  });
+});
+
+// ============================================================================
+// DEFECTS THE EMITTER FIXES, AND ONE IT WOULD CAUSE
+// ============================================================================
+
+describe("registry defects", () => {
+  it.each(MISSING_FROM_REGISTRY)("registers %s, which the committed registry omits", async (id) => {
+    const document = await new RegistryEmitter(repoPath()).emit();
+
+    expect(byId(COMMITTED, "subagents").has(id), `${id} is unexpectedly already registered`).toBe(
+      false
+    );
+    expect(byId(document, "subagents").get(id)?.path).toContain(id);
+  });
+
+  it("makes subagent:batch-executor resolvable for openagent and opencoder", async () => {
+    // Both declare `subagent:batch-executor`; the committed registry contains no such
+    // component, so install.sh resolves the dependency to nothing and silently installs an
+    // orchestrator whose parallel executor is missing.
+    const document = await new RegistryEmitter(repoPath()).emit();
+    const agents = byId(document, "agents");
+
+    expect(agents.get("openagent")?.dependencies).toContain("subagent:batch-executor");
+    expect(agents.get("opencoder")?.dependencies).toContain("subagent:batch-executor");
+    expect(byId(document, "subagents").has("batch-executor")).toBe(true);
+  });
+
+  it("still drops the dependency edges that only registry.json declares", async () => {
+    // A SNAPSHOT OF A KNOWN REGRESSION, not an invariant. These edges are hand-authored in
+    // registry.json and absent from both the agent files and agent-metadata.json, so
+    // generating from the canonical tree loses them. When subtask 05 backfills
+    // `oac.dependencies` in `content/agents/**`, this test turns red — which is correct:
+    // repairing the loss must be a deliberate edit here, never a silent drift.
+    const document = await new RegistryEmitter(repoPath()).emit();
+    const subagents = byId(document, "subagents");
+
+    for (const [id, lost] of Object.entries(REGISTRY_ONLY_DEPENDENCIES)) {
+      const before = new Set(byId(COMMITTED, "subagents").get(id)?.dependencies ?? []);
+      const after = new Set(subagents.get(id)?.dependencies ?? []);
+
+      for (const dependency of lost) {
+        expect(before.has(dependency), `${id} -> ${dependency} vanished from registry.json`).toBe(
+          true
+        );
+        expect(after.has(dependency), `${id} -> ${dependency} is no longer lost — update this snapshot`).toBe(
+          false
+        );
+      }
+    }
+  });
+});