Browse Source

feat(compat): add CanonicalAgentLoader, ProfileLoader and ReferenceResolver

CanonicalAgentLoader parses content/agents into {frontmatter, oac, body} and
resolves identity by oac.id only, never by filename — test-engineer.md has
id "tester", which registry.json, three profiles and five context docs
reference as subagent:tester. Duplicate ids throw rather than last-write-wins.

ReferenceResolver finds exactly the 4 known dead refs and, unlike
validate-registry.sh, reads on-disk frontmatter and walks profiles rather
than components alone. It mirrors install.sh's real matching rule (id OR
alias, not id alone — three components carry aliases).

ProfileLoader validates all 5 profile.json files. profile.json and
registry.json have drifted on every one and neither is a superset (advanced:
51 vs 68). install.sh reads registry.json only; profile.json is read solely
by check-dependencies.ts. resolveClosure validates the union, sound under
either belief, and drift() exposes the disagreement rather than hiding it.

Dead refs are detected, not fixed: the add-context.md refs are a namespace
mismatch and fixing them is a data decision, not a resolver rule.
darrenhinde 2 weeks ago
parent
commit
b77143cecb

+ 195 - 2
packages/compatibility-layer/src/core/AgentLoader.ts

@@ -1,8 +1,16 @@
 import { readFileSync, readdirSync } from "fs";
-import { join, extname, basename } from "path";
+import { join, extname, basename, relative, sep } from "path";
 import * as yaml from "js-yaml";
 import { ZodError } from "zod";
-import { OpenAgentSchema, AgentFrontmatterSchema, OpenAgent, AgentFrontmatter } from "../types.js";
+import {
+  OpenAgentSchema,
+  AgentFrontmatterSchema,
+  CanonicalAgentSchema,
+  OpenAgent,
+  AgentFrontmatter,
+  CanonicalAgent,
+  OacBlock,
+} from "../types.js";
 
 // ============================================================================
 // ERROR TYPES
@@ -37,6 +45,23 @@ export class FrontmatterParseError extends AgentLoadError {
   }
 }
 
+/**
+ * Error when a canonical agent file carries no `oac:` block.
+ *
+ * Its own type because it is the single most likely authoring mistake in the canonical tree,
+ * and "oac is required" buried in a Zod path list does not tell an author what to do.
+ */
+export class MissingOacBlockError extends AgentLoadError {
+  constructor(filePath: string) {
+    super(
+      `No \`oac:\` block in ${filePath}. A canonical agent file must declare an \`oac:\` ` +
+        `block in its frontmatter carrying at least { id, name, category, type }.`,
+      filePath
+    );
+    this.name = "MissingOacBlockError";
+  }
+}
+
 /**
  * Error when Zod validation fails
  */
@@ -361,6 +386,163 @@ export class AgentLoader {
   }
 }
 
+// ============================================================================
+// CANONICAL AGENT LOADING (`oac:` block)
+// ============================================================================
+
+/**
+ * The default content root. A default, not a hardcode: every canonical API takes an explicit
+ * root so the loader is testable against a fixture tree.
+ */
+export const DEFAULT_CONTENT_ROOT = "content/agents";
+
+/**
+ * A canonical agent file, decomposed into the three things a build needs.
+ *
+ * The split is deliberate: `oac` is OAC-only metadata that `oac build` STRIPS when emitting an
+ * OpenCode agent file, while `frontmatter` is the OpenCode-legal remainder that survives. Any
+ * consumer that had to re-derive that split would eventually get it wrong.
+ */
+export interface CanonicalAgentFile {
+  /** Absolute path on disk. */
+  filePath: string;
+  /** Path relative to the content root, POSIX-separated. Stable across platforms. */
+  relativePath: string;
+  /** The OpenCode-legal frontmatter — the canonical block minus `oac`. */
+  frontmatter: Omit<CanonicalAgent, "oac">;
+  /** The canonical `oac:` block, with defaults applied. */
+  oac: OacBlock;
+  /** The markdown body after the frontmatter. */
+  body: string;
+}
+
+/** Locale-independent ordering. `localeCompare` is locale-dependent — never use it here. */
+function compareStrings(a: string, b: string): number {
+  return a < b ? -1 : a > b ? 1 : 0;
+}
+
+/** Repo-relative, POSIX-separated path — stable across platforms. */
+function toPosixRelative(root: string, absolute: string): string {
+  return relative(root, absolute).split(sep).join("/");
+}
+
+/**
+ * Recursively list `.md` files under `dir`.
+ *
+ * Sorted at the end over full paths: `readdirSync` order is filesystem-dependent, and the
+ * determinism gate (subtask 11, `git diff --exit-code`) fails on any enumeration
+ * nondeterminism.
+ */
+function listMarkdownFiles(dir: string): string[] {
+  const walk = (current: string): string[] =>
+    readdirSync(current, { withFileTypes: true }).flatMap((entry) => {
+      const full = join(current, entry.name);
+      if (entry.isDirectory()) return walk(full);
+      return entry.isFile() && entry.name.endsWith(".md") ? [full] : [];
+    });
+
+  return walk(dir).sort(compareStrings);
+}
+
+/**
+ * Loads canonical agent files (OpenCode frontmatter + the `oac:` block) from a content tree.
+ *
+ * Separate from {@link AgentLoader} on purpose: `AgentLoader` speaks the legacy shape, where
+ * identity lives in the `.opencode/config/agent-metadata.json` sidecar and is inferred from
+ * the FILENAME. Canonical files carry their own identity in `oac.id`, and the two do not
+ * always agree — `content/agents/subagents/code/test-engineer.md` declares `id: tester`, which
+ * is what `registry.json`, the profiles and the context docs all reference. Resolving by
+ * filename here would silently mint a `test-engineer` that nothing refers to, so this class
+ * never infers identity from a path.
+ */
+export class CanonicalAgentLoader {
+  private readonly contentRoot: string;
+
+  /**
+   * @param contentRoot - Root of the canonical agent tree. Configurable, not hardcoded.
+   */
+  constructor(contentRoot: string = DEFAULT_CONTENT_ROOT) {
+    this.contentRoot = contentRoot;
+  }
+
+  /**
+   * Load and validate one canonical agent file.
+   *
+   * @param filePath - Path to the agent markdown file.
+   * @throws {MissingOacBlockError} when the file has no `oac:` block.
+   * @throws {ValidationError}      when the frontmatter fails {@link CanonicalAgentSchema}.
+   */
+  loadFromFile(filePath: string): CanonicalAgentFile {
+    let content: string;
+
+    try {
+      content = readFileSync(filePath, "utf-8");
+    } catch (error) {
+      throw new AgentLoadError(`Failed to read file: ${filePath}`, filePath, error);
+    }
+
+    const { frontmatter, body } = parseFrontmatter(content, filePath);
+
+    if (frontmatter === null || typeof frontmatter !== "object") {
+      throw new AgentLoadError(
+        `No frontmatter found in ${filePath}. Canonical agent files must have YAML frontmatter.`,
+        filePath
+      );
+    }
+
+    // Checked before Zod so the most common authoring mistake gets a sentence naming the file
+    // and the fix, rather than an `oac: Required` entry in a path list.
+    if (!("oac" in frontmatter)) {
+      throw new MissingOacBlockError(filePath);
+    }
+
+    const parsed = CanonicalAgentSchema.safeParse(frontmatter);
+    if (!parsed.success) {
+      throw new ValidationError(filePath, parsed.error);
+    }
+
+    const { oac, ...rest } = parsed.data;
+
+    return {
+      filePath,
+      relativePath: toPosixRelative(this.contentRoot, filePath),
+      frontmatter: rest,
+      oac,
+      body,
+    };
+  }
+
+  /**
+   * Load every canonical agent under the content root, recursively.
+   *
+   * Results are sorted by `oac.id`, so the order is a property of the CONTENT rather than of
+   * the filesystem — the same reason `listMarkdownFiles` sorts. Duplicate ids are an error:
+   * two agents claiming one id makes "install `subagent:tester`" ambiguous, and last-write-wins
+   * would resolve it silently and differently depending on enumeration order.
+   *
+   * @param root - Content root override. Defaults to the constructor's.
+   */
+  loadFromDirectory(root: string = this.contentRoot): Promise<CanonicalAgentFile[]> {
+    const loader = root === this.contentRoot ? this : new CanonicalAgentLoader(root);
+    const agents = listMarkdownFiles(root).map((file) => loader.loadFromFile(file));
+
+    const byId = new Map<string, CanonicalAgentFile>();
+    for (const agent of agents) {
+      const clash = byId.get(agent.oac.id);
+      if (clash !== undefined) {
+        throw new AgentLoadError(
+          `Duplicate oac.id "${agent.oac.id}": declared by both ${clash.relativePath} and ` +
+            `${agent.relativePath}. An id must name exactly one agent.`,
+          agent.filePath
+        );
+      }
+      byId.set(agent.oac.id, agent);
+    }
+
+    return Promise.resolve(agents.sort((a, b) => compareStrings(a.oac.id, b.oac.id)));
+  }
+}
+
 // ============================================================================
 // CONVENIENCE FUNCTIONS
 // ============================================================================
@@ -375,6 +557,17 @@ export async function loadAgent(filePath: string): Promise<OpenAgent> {
   return loader.loadFromFile(filePath);
 }
 
+/**
+ * Load every canonical agent from a content root (convenience function)
+ * @param root - Content root, defaults to {@link DEFAULT_CONTENT_ROOT}
+ * @returns Canonical agent files, sorted by `oac.id`
+ */
+export async function loadCanonicalAgents(
+  root: string = DEFAULT_CONTENT_ROOT
+): Promise<CanonicalAgentFile[]> {
+  return new CanonicalAgentLoader(root).loadFromDirectory();
+}
+
 /**
  * Load all agents from a directory (convenience function)
  * @param dirPath - Path to directory containing agents

+ 261 - 0
packages/compatibility-layer/src/core/ProfileLoader.ts

@@ -0,0 +1,261 @@
+/**
+ * Loads install profiles and computes their transitive closures.
+ *
+ * ─── What a profile promises ────────────────────────────────────────────────────────────
+ *
+ * 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 naming a live component that depends on a dead
+ * one is just as broken as one naming the dead component directly — the install fails at the
+ * same point, one level deeper. So the unit of validation is the CLOSURE, not the named list.
+ *
+ * ─── Two sources that disagree ──────────────────────────────────────────────────────────
+ *
+ * Profiles are authored in two places, and they have drifted apart (verified on disk
+ * 2026-07-15):
+ *
+ *   - `.opencode/profiles/<name>/profile.json` — the per-profile file. Read only by
+ *     `scripts/registry/check-dependencies.ts`.
+ *   - `registry.json` `.profiles.<name>.components` — what `install.sh` ACTUALLY reads
+ *     (`get_profile_components`, install.sh:292). This is the runtime authority.
+ *
+ * They disagree on every profile (e.g. `advanced`: 51 components on disk vs 68 in the
+ * registry). Neither is a superset of the other. Since no single source is both authoritative
+ * and complete, {@link ProfileLoader.resolveClosure} validates the UNION: a profile is only
+ * sound if it installs cleanly no matter which list a consumer believes. Use
+ * {@link ProfileLoader.drift} to inspect the disagreement itself.
+ */
+
+import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
+import { join } from "node:path";
+import { z } from "zod";
+import { ReferenceResolver, type Reference, type Resolution } from "./ReferenceResolver.js";
+
+// ============================================================================
+// SCHEMA
+// ============================================================================
+
+/**
+ * A `.opencode/profiles/<name>/profile.json` file.
+ *
+ * Strict: an unknown key is an error, never silently dropped. Verified against all 5 profiles
+ * on disk — `badge` appears on `developer` only, `additionalPaths` on `advanced` only.
+ */
+export const ProfileSchema = z
+  .object({
+    name: z.string().min(1),
+    description: z.string().min(1),
+    badge: z.string().optional(),
+    components: z.array(z.string()).min(1, "a profile that names no components installs nothing"),
+    additionalPaths: z.array(z.string()).default([]),
+  })
+  .strict();
+
+export type Profile = z.infer<typeof ProfileSchema>;
+
+/** A profile as loaded, with its identity and both component lists. */
+export interface LoadedProfile {
+  /** Directory name under `.opencode/profiles/`, e.g. `developer`. This is the profile id. */
+  id: string;
+  /** Repo-relative path of the `profile.json` that backs it. */
+  filePath: string;
+  /** The validated `profile.json` contents. */
+  profile: Profile;
+  /** Component refs from `profile.json`. */
+  components: string[];
+  /** Component refs from `registry.json` `.profiles.<id>.components` — what `install.sh` reads. */
+  registryComponents: string[];
+}
+
+export interface ClosureResult {
+  /** Every ref in the transitive closure, deduplicated and sorted. */
+  ids: string[];
+  /** The closure with source attribution, in discovery order. */
+  refs: Reference[];
+  /** The subset of the closure that does not resolve. */
+  missing: Resolution[];
+}
+
+/** How `profile.json` and `registry.json` disagree about one profile. */
+export interface ProfileDrift {
+  id: string;
+  /** Refs in `profile.json` that `registry.json` omits. */
+  onlyInProfileJson: string[];
+  /** Refs in `registry.json` that `profile.json` omits. `install.sh` installs these anyway. */
+  onlyInRegistry: string[];
+}
+
+const PROFILES_DIR = ".opencode/profiles";
+
+/** Locale-independent ordering. `localeCompare` is locale-dependent — never use it here. */
+function compare(a: string, b: string): number {
+  return a < b ? -1 : a > b ? 1 : 0;
+}
+
+export class ProfileLoadError extends Error {
+  public readonly filePath: string;
+  public override readonly cause?: unknown;
+
+  constructor(message: string, filePath: string, cause?: unknown) {
+    super(message);
+    this.filePath = filePath;
+    this.cause = cause;
+    this.name = "ProfileLoadError";
+  }
+}
+
+// ============================================================================
+// LOADER
+// ============================================================================
+
+export class ProfileLoader {
+  private readonly root: string;
+  private readonly resolver: ReferenceResolver;
+
+  /**
+   * @param root     - Repository root, so the loader is testable against a fixture tree.
+   * @param resolver - Reference resolver to validate closures with. Defaults to one rooted at
+   *                   `root`; injectable so a caller can share one index across loaders.
+   */
+  constructor(root: string, resolver: ReferenceResolver = new ReferenceResolver(root)) {
+    this.root = root;
+    this.resolver = resolver;
+  }
+
+  /**
+   * Every profile id on disk, sorted.
+   *
+   * Enumerated from the filesystem rather than hard-coded, so adding a profile directory is
+   * enough to make it real.
+   */
+  list(): Promise<string[]> {
+    const dir = join(this.root, PROFILES_DIR);
+
+    if (!existsSync(dir) || !statSync(dir).isDirectory()) {
+      throw new ProfileLoadError(`Profiles directory not found: ${PROFILES_DIR}`, dir);
+    }
+
+    const ids = readdirSync(dir, { withFileTypes: true })
+      .filter((entry) => entry.isDirectory())
+      .map((entry) => entry.name)
+      .filter((id) => existsSync(join(dir, id, "profile.json")))
+      .sort(compare);
+
+    return Promise.resolve(ids);
+  }
+
+  /** Load and validate one profile by id. */
+  load(id: string): Promise<LoadedProfile> {
+    const relativePath = `${PROFILES_DIR}/${id}/profile.json`;
+    const absolute = join(this.root, PROFILES_DIR, id, "profile.json");
+
+    let raw: unknown;
+    try {
+      raw = JSON.parse(readFileSync(absolute, "utf-8"));
+    } catch (cause) {
+      throw new ProfileLoadError(
+        `Failed to read or parse profile: ${relativePath}`,
+        relativePath,
+        cause
+      );
+    }
+
+    const parsed = ProfileSchema.safeParse(raw);
+    if (!parsed.success) {
+      throw new ProfileLoadError(
+        `Invalid profile ${relativePath}:\n${parsed.error.errors
+          .map((e) => `  - ${e.path.join(".")}: ${e.message}`)
+          .join("\n")}`,
+        relativePath,
+        parsed.error
+      );
+    }
+
+    return Promise.resolve({
+      id,
+      filePath: relativePath,
+      profile: parsed.data,
+      components: parsed.data.components,
+      registryComponents: this.resolver.registry().profiles[id]?.components ?? [],
+    });
+  }
+
+  /** Load and validate every profile, in id order. */
+  async loadAll(): Promise<LoadedProfile[]> {
+    const ids = await this.list();
+    return Promise.all(ids.map((id) => this.load(id)));
+  }
+
+  /**
+   * Expand a profile to its transitive closure and report what does not resolve.
+   *
+   * Seeds from the union of `profile.json` and `registry.json` (see the file header: they have
+   * drifted and neither is a superset), then follows each component's declared `dependencies`
+   * from the registry. Cycles terminate on the `seen` set.
+   */
+  async resolveClosure(id: string): Promise<ClosureResult> {
+    const loaded = await this.load(id);
+    const registry = this.resolver.registry();
+
+    // `type:id` -> component, so a ref can be followed to its dependencies.
+    const byRef = new Map<string, { dependencies?: string[] }>();
+    for (const [category, components] of Object.entries(registry.components)) {
+      // agents -> agent, contexts -> context, ...; `config` is already singular and stays put.
+      const type = category.replace(/s$/, "");
+      for (const component of components) byRef.set(`${type}:${component.id}`, component);
+    }
+
+    const seeds: Reference[] = [
+      ...loaded.components.map((ref) => ({
+        ref,
+        source: `${loaded.filePath} components`,
+      })),
+      ...loaded.registryComponents.map((ref) => ({
+        ref,
+        source: `registry.json profiles.${id}.components`,
+      })),
+    ];
+
+    const seen = new Set<string>();
+    const refs: Reference[] = [];
+    const queue = [...seeds];
+
+    while (queue.length > 0) {
+      const reference = queue.shift()!;
+      if (seen.has(reference.ref)) continue;
+      seen.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: [...seen].sort(compare),
+      refs,
+      missing: this.resolver.resolveMany(refs).filter((resolution) => !resolution.ok),
+    };
+  }
+
+  /**
+   * How `profile.json` and `registry.json` disagree about a profile.
+   *
+   * Not a validation step — a diagnostic. The two lists have genuinely drifted, and only
+   * `registry.json` is read at install time.
+   */
+  async drift(id: string): Promise<ProfileDrift> {
+    const { components, registryComponents } = await this.load(id);
+    const onDisk = new Set(components);
+    const inRegistry = new Set(registryComponents);
+
+    return {
+      id,
+      onlyInProfileJson: components.filter((ref) => !inRegistry.has(ref)).sort(compare),
+      onlyInRegistry: registryComponents.filter((ref) => !onDisk.has(ref)).sort(compare),
+    };
+  }
+}

+ 365 - 0
packages/compatibility-layer/src/core/ReferenceResolver.ts

@@ -0,0 +1,365 @@
+/**
+ * Resolves `type:id` references against `registry.json`.
+ *
+ * ─── Why this exists ────────────────────────────────────────────────────────────────────
+ *
+ * `scripts/registry/validate-registry.sh` prints "244/244 valid, 0 missing dependencies" and
+ * exits 0. That is a FALSE GREEN. It is blind in two structural ways:
+ *
+ *   1. It reads `registry.json` and nothing else, so component `dependencies:` authored in
+ *      on-disk frontmatter are never parsed. Drift between a registry entry and the file that
+ *      actually ships is undetectable — which is how three dead refs in
+ *      `.opencode/command/add-context.md` shipped unnoticed.
+ *   2. It walks `.components.*[].dependencies` only. `.profiles.*.components` is never
+ *      validated, which is how a wildcard expanding to zero matches sits in the `advanced`
+ *      profile unseen.
+ *
+ * This resolver reads all three sources the repo authors and applies the resolution rule the
+ * validator documents for itself. It is measured against the oracle in
+ * `tests/support/references.ts` (see `tests/unit/build/reference-resolution.test.ts`).
+ *
+ * ─── Corpus scope ───────────────────────────────────────────────────────────────────────
+ *
+ * {@link ReferenceResolver.findDeadReferences} covers the *legacy* corpus: registry component
+ * dependencies, registry profile component lists, and `.opencode/**` `dependencies:`
+ * frontmatter. That is deliberately the same corpus `registry.json` governs today, because
+ * `registry.json` is what `install.sh` actually reads at install time.
+ *
+ * The canonical `content/agents/**` tree is NOT part of that corpus — it is not yet wired
+ * into any install path. Scan it explicitly with {@link ReferenceResolver.resolveMany} if you
+ * want its verdict; be aware it surfaces `subagent:batch-executor`, which exists on disk and
+ * in `.opencode/config/agent-metadata.json` but is absent from `registry.json`.
+ */
+
+import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
+import { join, relative, sep } from "node:path";
+import matter from "gray-matter";
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+/** 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",
+};
+
+/** Where registry context `path` values are rooted. Wildcards expand against this prefix. */
+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;
+  aliases?: string[];
+  dependencies?: string[];
+}
+
+export interface Registry {
+  components: Record<string, RegistryComponent[]>;
+  profiles: Record<string, { components?: string[] }>;
+}
+
+/** A reference together with where it was authored. */
+export interface Reference {
+  /** The raw `type:id` string as authored. */
+  ref: string;
+  /** Human-readable origin, e.g. `.opencode/command/add-context.md`. */
+  source: string;
+}
+
+export interface ResolveResult {
+  ok: boolean;
+  status: ResolutionStatus;
+  /** Repo-relative path of the component backing this ref. Set only for a single `ok` match. */
+  path?: string;
+  /** Repo-relative paths a wildcard expanded to. Set only for an `ok` wildcard. */
+  matches?: string[];
+  /** Why it failed, in one sentence. Absent when `ok`. */
+  reason?: string;
+}
+
+export interface Resolution extends Reference, ResolveResult {}
+
+// ============================================================================
+// INDEX
+// ============================================================================
+
+interface RegistryIndex {
+  /** type -> set of ids (including aliases, which `install.sh` also matches on). */
+  idsByType: Map<string, Set<string>>;
+  /** `type:id` -> repo-relative path, for reporting what backs a reference. */
+  pathByRef: Map<string, string>;
+  /** Every registry context path, for wildcard expansion. */
+  contextPaths: Set<string>;
+}
+
+function buildIndex(registry: Registry): RegistryIndex {
+  const idsByType = new Map<string, Set<string>>();
+  const pathByRef = new Map<string, 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 (component.path !== undefined) pathByRef.set(`${type}:${component.id}`, component.path);
+
+      // `install.sh` resolve_dependencies matches `.id == $id or (.aliases | index($id))`,
+      // so an alias is a legitimate way to name a component. Mirror that or we would report
+      // refs as dead that the installer resolves fine.
+      for (const alias of component.aliases ?? []) {
+        ids.add(alias);
+        if (component.path !== undefined) pathByRef.set(`${type}:${alias}`, component.path);
+      }
+
+      if (type === "context" && component.path !== undefined) contextPaths.add(component.path);
+    }
+
+    idsByType.set(type, ids);
+  }
+
+  return { idsByType, pathByRef, contextPaths };
+}
+
+// ============================================================================
+// FILE WALKING
+// ============================================================================
+
+/** Recursively list files under `dir` matching `extension`, sorted for determinism. */
+function listFiles(dir: string, extension = ".md"): string[] {
+  if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
+
+  const walk = (current: string): string[] =>
+    readdirSync(current, { withFileTypes: true }).flatMap((entry) => {
+      const full = join(current, entry.name);
+      if (entry.isDirectory()) return walk(full);
+      return entry.isFile() && entry.name.endsWith(extension) ? [full] : [];
+    });
+
+  // Sort once at the end over full paths: readdir order is filesystem-dependent and the
+  // determinism gate (subtask 11) fails on any enumeration nondeterminism.
+  return walk(dir).sort(compare);
+}
+
+/** Locale-independent string ordering. `localeCompare` is locale-dependent — never use it here. */
+function compare(a: string, b: string): number {
+  return a < b ? -1 : a > b ? 1 : 0;
+}
+
+/** Repo-relative, POSIX-separated path — stable across platforms. */
+function toPosixRelative(root: string, absolute: string): string {
+  return relative(root, absolute).split(sep).join("/");
+}
+
+// ============================================================================
+// RESOLVER
+// ============================================================================
+
+export class ReferenceResolver {
+  private readonly root: string;
+  private registryCache?: Registry;
+  private indexCache?: RegistryIndex;
+
+  /**
+   * @param root - Repository root. Everything is resolved relative to this, so the resolver
+   *               is testable against a fixture tree rather than a hardcoded path.
+   */
+  constructor(root: string) {
+    this.root = root;
+  }
+
+  /** The parsed `registry.json`, read once per instance. */
+  registry(): Registry {
+    if (this.registryCache === undefined) {
+      this.registryCache = JSON.parse(
+        readFileSync(join(this.root, "registry.json"), "utf-8")
+      ) as Registry;
+    }
+    return this.registryCache;
+  }
+
+  private index(): RegistryIndex {
+    if (this.indexCache === undefined) this.indexCache = buildIndex(this.registry());
+    return this.indexCache;
+  }
+
+  /**
+   * Resolve a single `type:id` reference.
+   *
+   * The rule is the one `validate-registry.sh` documents for itself:
+   *   - exact registry id (or alias) 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>`.
+   *
+   * Deliberately NOT tolerated: an id that already ends in `.md`. Registry context ids are
+   * bare slugs — none contains `/` or ends in `.md`. Accepting `context:core/standards/mvi.md`
+   * would invent a second, undeclared namespace and paper over exactly the drift this resolver
+   * exists to catch.
+   */
+  resolve(ref: string): ResolveResult {
+    const registryIndex = this.index();
+    const separator = ref.indexOf(":");
+
+    if (separator <= 0 || separator === ref.length - 1) {
+      return {
+        ok: false,
+        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) {
+      const known = [...registryIndex.idsByType.keys()].sort(compare).join(", ");
+      return {
+        ok: false,
+        status: "unknown-type",
+        reason: `"${type}" is not a registry component type (known: ${known})`,
+      };
+    }
+
+    if (id.includes("*")) {
+      const prefix = id.slice(0, id.indexOf("*"));
+      const matches = [...registryIndex.contextPaths]
+        .filter((path) => path.startsWith(CONTEXT_ROOT + prefix))
+        .sort(compare);
+
+      return matches.length > 0
+        ? { ok: true, status: "ok", matches }
+        : {
+            ok: false,
+            status: "dead-wildcard",
+            reason:
+              `wildcard "${ref}" expands to 0 matches — no registry context path starts ` +
+              `with "${CONTEXT_ROOT}${prefix}"`,
+          };
+    }
+
+    if (ids.has(id)) {
+      const path = registryIndex.pathByRef.get(`${type}:${id}`);
+      return path === undefined ? { ok: true, status: "ok" } : { ok: true, status: "ok", path };
+    }
+
+    // A context may also be named by its path-without-extension, e.g. `context:ui/web/design-systems`.
+    const pathForm = `${CONTEXT_ROOT}${id}.md`;
+    if (type === "context" && registryIndex.contextPaths.has(pathForm)) {
+      return { ok: true, status: "ok", path: pathForm };
+    }
+
+    return {
+      ok: false,
+      status: "dead-id",
+      reason: `no registry component has id "${id}" of type "${type}"`,
+    };
+  }
+
+  /** Resolve a batch of references, preserving their source attribution. */
+  resolveMany(references: readonly Reference[]): Resolution[] {
+    return references.map((reference) => ({ ...reference, ...this.resolve(reference.ref) }));
+  }
+
+  // --------------------------------------------------------------------------
+  // Reference collection — the three sources the repo authors
+  // --------------------------------------------------------------------------
+
+  /** Every reference authored in `registry.json` component `dependencies` arrays. */
+  componentReferences(): Reference[] {
+    return Object.entries(this.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 — this is where the dead wildcard lives.
+   */
+  profileReferences(): Reference[] {
+    return Object.entries(this.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 `dir`.
+   * `validate-registry.sh` never looks here either — 3 of the 4 known dead refs live here.
+   */
+  frontmatterReferences(dir = ".opencode"): Reference[] {
+    return listFiles(join(this.root, dir)).flatMap((file) => {
+      const source = toPosixRelative(this.root, file);
+      let data: Record<string, unknown>;
+
+      try {
+        ({ data } = matter(readFileSync(file, "utf-8")));
+      } catch {
+        // Unparseable frontmatter is a schema defect, owned by the schema suite. Skipping it
+        // 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 in the legacy corpus `registry.json` governs. */
+  allReferences(): Reference[] {
+    return [
+      ...this.componentReferences(),
+      ...this.profileReferences(),
+      ...this.frontmatterReferences(),
+    ];
+  }
+
+  /**
+   * Every reference in the legacy corpus that does not resolve.
+   *
+   * Async because callers treat resolution as an I/O-bound build step; the work itself is
+   * synchronous file reads.
+   */
+  findDeadReferences(): Promise<Resolution[]> {
+    return Promise.resolve(this.resolveMany(this.allReferences()).filter((r) => !r.ok));
+  }
+}
+
+/** Render resolutions as one greppable line each, for failure messages. */
+export function formatResolutions(resolutions: readonly Resolution[]): string {
+  return resolutions.length === 0
+    ? "  (none)"
+    : resolutions
+        .map((r) => `  ${r.source}\n    ${r.ref} -> ${r.status}: ${r.reason ?? ""}`)
+        .join("\n");
+}

+ 59 - 0
packages/compatibility-layer/src/index.ts

@@ -70,9 +70,68 @@ export {
 export {
   AgentLoadError,
   FrontmatterParseError,
+  MissingOacBlockError,
   ValidationError,
 } from "./core/AgentLoader.js";
 
+// ============================================================================
+// CORE - Canonical Agent Loading (`oac:` block)
+// ============================================================================
+
+/**
+ * Loads canonical agent files — OpenCode frontmatter plus the `oac:` block — from a
+ * configurable content root (`content/agents/**` by default).
+ *
+ * Identity comes from `oac.id`, never the filename: `subagents/code/test-engineer.md`
+ * declares `id: tester`, and `tester` is what the registry, profiles and context docs
+ * reference.
+ *
+ * @example
+ * ```typescript
+ * const agents = await loadCanonicalAgents('content/agents');
+ * // sorted by oac.id, deterministic regardless of filesystem enumeration order
+ * ```
+ */
+export {
+  CanonicalAgentLoader,
+  loadCanonicalAgents,
+  DEFAULT_CONTENT_ROOT,
+} from "./core/AgentLoader.js";
+
+export type { CanonicalAgentFile } from "./core/AgentLoader.js";
+
+// ============================================================================
+// CORE - Reference Resolution & Profiles
+// ============================================================================
+
+/**
+ * Resolves `type:id` references against `registry.json`, catching the reference rot that
+ * `scripts/registry/validate-registry.sh` reports as 244/244 green.
+ */
+export { ReferenceResolver, formatResolutions } from "./core/ReferenceResolver.js";
+
+export type {
+  Reference,
+  Registry,
+  RegistryComponent,
+  Resolution,
+  ResolutionStatus,
+  ResolveResult,
+} from "./core/ReferenceResolver.js";
+
+/**
+ * Loads install profiles and computes their transitive closures — what "install this
+ * profile" actually means.
+ */
+export { ProfileLoader, ProfileLoadError, ProfileSchema } from "./core/ProfileLoader.js";
+
+export type {
+  ClosureResult,
+  LoadedProfile,
+  Profile,
+  ProfileDrift,
+} from "./core/ProfileLoader.js";
+
 // ============================================================================
 // CORE - Adapter Registry
 // ============================================================================