浏览代码

feat(build): add oac build, generating .opencode and registry.json

The keystone: content/agents/** is now compiled rather than hand-maintained.
plan() computes, write() reconciles, check() compares — --check and --dry-run
share the build's exact code path instead of re-implementing it.

First emission produces exactly the two expected diffs and nothing else:
9 agents lose an obsolete 4-line frontmatter comment (0 insertions,
36 deletions total), the other 24 are byte-identical, and registry.json
regenerates losing 0 ids and 0 dependency edges while recovering 7 agents
that were missing from it entirely. All 5 profiles resolve 100% via
install.sh's own jq query, and the existing validator still passes.

plugins/claude-code is NOT written — staged to .tmp/oac-build/ and compared
only. Regenerating would tighten 4 shipped agents that grant Bash/Edit
unscoped; that behavioral change is deferred pending review.

Orphan removal is manifest-gated rather than scan-based. Candidates are
enumerated only from the previous build's manifest, never from a directory
scan, so a file the build never generated is not enumerable as a candidate —
eval-runner.md is not protected by a rule that could be got wrong, it simply
never reaches the decision. A generated file edited by hand fails its sha256
check and is kept and reported rather than deleted. The first build, having
no ledger, prunes nothing.

Directory pruning uses rmdirSync, not rmSync with recursive: false — the
latter cannot remove a directory at all, so it threw EFAULT after deleting
the file. Found by running the real command; no test caught it. rmdirSync
fails closed on a non-empty directory, letting the syscall enforce the
emptiness guard rather than trusting a prior check.

Dead references and degradations are reported, and fatal only under --strict:
default-fatal would exit 1 on the current tree's 4 pinned dead refs and 30
degradations, which would make the CI gate unusable on day one.

Build is deterministic and a fixed point over its own output: a second build
reports 0 written, 41 unchanged, 0 removed.
darrenhinde 2 周之前
父节点
当前提交
1a5f1f82ab

+ 0 - 4
.opencode/agent/content/copywriter.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: OpenCopywriter
 description: "Expert in persuasive writing, marketing copy, and brand messaging"
 mode: primary

+ 0 - 4
.opencode/agent/content/technical-writer.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: OpenTechnicalWriter
 description: "Expert in documentation, API docs, and technical communication"
 mode: primary

+ 0 - 4
.opencode/agent/meta/system-builder.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: OpenSystemBuilder
 description: "Main orchestrator for building complete context-aware AI systems from user requirements"
 mode: primary

+ 0 - 4
.opencode/agent/subagents/planning/story-mapper.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: StoryMapper
 description: "User journey mapping specialist transforming user needs into epics, stories, and vertical slices with bounded context alignment"
 mode: subagent

+ 0 - 4
.opencode/agent/subagents/system-builder/agent-generator.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: AgentGenerator
 description: "Generates XML-optimized agent files (orchestrator and subagents) following research-backed patterns"
 mode: subagent

+ 0 - 4
.opencode/agent/subagents/system-builder/command-creator.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: CommandCreator
 description: "Creates custom slash commands that route to appropriate agents with clear syntax and examples"
 mode: subagent

+ 0 - 4
.opencode/agent/subagents/system-builder/domain-analyzer.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: DomainAnalyzer
 description: "Analyzes user domains to identify core concepts, recommended agents, and context structure"
 mode: subagent

+ 0 - 4
.opencode/agent/subagents/test/simple-responder.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: Simple Responder
 description: "Test agent that responds with 'AWESOME TESTING' - for eval framework testing"
 mode: subagent

+ 0 - 4
.opencode/agent/subagents/utils/image-specialist.md

@@ -1,8 +1,4 @@
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 name: Image Specialist
 description: "Specialized agent for image editing and analysis using Gemini AI tools"
 mode: subagent

+ 63 - 0
packages/cli/src/__tests__/build.test.ts

@@ -0,0 +1,63 @@
+/**
+ * `oac build` wiring.
+ *
+ * The pipeline itself is tested in `packages/compatibility-layer` under vitest — loading,
+ * adapting, determinism and the orphan-removal safety envelope all live there. What is left
+ * here is the part this package actually owns: which targets a flag selects, and which of them
+ * are staged rather than emitted in place.
+ *
+ * The staging assertion is not a formality. `plugins/claude-code/agents/**` must NOT be
+ * emitted in place: regenerating it changes what 4 shipped agents may do, and that tightening
+ * is pending review. A refactor that quietly flips the default is exactly what this test is
+ * here to catch.
+ */
+
+import { describe, test, expect } from 'bun:test';
+import { CLAUDE_STAGING_ROOT, outputRootsFor, selectTargets } from '../commands/build.js';
+
+// ── selectTargets ─────────────────────────────────────────────────────────────
+
+describe('selectTargets', () => {
+  test('defaults to every wired target', () => {
+    expect(selectTargets(undefined)).toEqual(['opencode', 'claude-code']);
+    expect(selectTargets([])).toEqual(['opencode', 'claude-code']);
+  });
+
+  test('honours an explicit --target', () => {
+    expect(selectTargets(['opencode'])).toEqual(['opencode']);
+  });
+
+  test('rejects an unknown target by name, listing the known ones', () => {
+    expect(() => selectTargets(['emacs'])).toThrow(/unknown target\(s\): emacs/);
+    expect(() => selectTargets(['emacs'])).toThrow(/opencode, claude-code/);
+  });
+});
+
+// ── outputRootsFor ────────────────────────────────────────────────────────────
+
+describe('outputRootsFor', () => {
+  test('emits opencode in place — it has no staging root', () => {
+    expect(outputRootsFor(['opencode'], CLAUDE_STAGING_ROOT)).toEqual({});
+  });
+
+  test('stages claude-code rather than emitting it in place', () => {
+    expect(outputRootsFor(['claude-code'], CLAUDE_STAGING_ROOT)).toEqual({
+      'claude-code': CLAUDE_STAGING_ROOT,
+    });
+  });
+
+  test('stages claude-code even when every target is built', () => {
+    const roots = outputRootsFor(['opencode', 'claude-code'], CLAUDE_STAGING_ROOT);
+
+    expect(roots['claude-code']).toBe(CLAUDE_STAGING_ROOT);
+    expect(roots.opencode).toBeUndefined();
+  });
+
+  test('honours a --stage override', () => {
+    expect(outputRootsFor(['claude-code'], 'build/out')).toEqual({ 'claude-code': 'build/out' });
+  });
+
+  test('the staging root is gitignored, so a build never dirties the tree', () => {
+    expect(CLAUDE_STAGING_ROOT.startsWith('.tmp/')).toBe(true);
+  });
+});

+ 299 - 0
packages/cli/src/commands/build.ts

@@ -0,0 +1,299 @@
+/**
+ * `oac build` — generate every target's tree from the canonical `content/agents/**` source.
+ *
+ * ## This file is wiring, not logic
+ *
+ * The pipeline itself lives in `@openagents-control/compatibility-layer`
+ * (`src/core/BuildPipeline.ts`): loading, adapting, orphan pruning and drift detection are all
+ * there, under vitest, Node-clean. This module parses flags, chooses roots, and prints. That
+ * split is deliberate — `packages/cli` still runs on Bun (12-DISPATCH, Stage 5 owns the
+ * removal), and the build must not acquire a Bun dependency on its way to the user. Nothing
+ * here uses a Bun API.
+ *
+ * ## Why Claude Code is staged rather than emitted
+ *
+ * `.opencode/agent/**` and `registry.json` are emitted IN PLACE: they are build output, and
+ * the subtask-11 CI gate rebuilds them and diffs.
+ *
+ * `plugins/claude-code/agents/**` is NOT. Regenerating it today would change what 4 shipped
+ * agents are allowed to do — `coder-agent`, `context-manager`, `external-scout` and
+ * `test-engineer` currently ship `Bash`/`Edit` unscoped, and the canonical sources project
+ * those to a fail-closed deny (correctly: see `degradeToBinary`). That is a real security
+ * tightening and a real behavioural change, and it is pending review rather than something a
+ * build command should slip into a diff. So the target builds to {@link CLAUDE_STAGING_ROOT}
+ * and the command REPORTS the comparison. There is deliberately no flag to emit it in place;
+ * when the change is approved, the staging default is removed in one reviewed commit.
+ */
+
+import { type Command } from 'commander'
+import { existsSync, readFileSync, readdirSync } from 'node:fs'
+import { join } from 'node:path'
+
+import {
+  BUILD_TARGETS,
+  check as checkPlan,
+  plan as planBuild,
+  ReferenceResolver,
+  write as writePlan,
+  type BuildPlan,
+  type BuildTarget,
+  type Drift,
+  type OutputRoots,
+} from '@openagents-control/compatibility-layer'
+
+import { bold, dim, error, info, log, setVerbose, success, verbose, warn } from '../ui/logger.js'
+
+// ── Constants ────────────────────────────────────────────────────────────────
+
+/**
+ * Where `claude-code` output is staged. Gitignored (`.tmp/*`), so a build never dirties the
+ * tree with output nobody has agreed to ship yet.
+ */
+export const CLAUDE_STAGING_ROOT = '.tmp/oac-build'
+
+/** Targets emitted in place. Everything else stages. */
+const IN_PLACE_TARGETS: readonly BuildTarget[] = ['opencode']
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+export type BuildOptions = {
+  target?: string[]
+  check: boolean
+  dryRun: boolean
+  strict: boolean
+  prune: boolean
+  stage: string
+  verbose: boolean
+  json: boolean
+}
+
+/** How one staged target compares to the tree it is not allowed to overwrite. */
+type StagedComparison = {
+  target: BuildTarget
+  identical: string[]
+  differing: string[]
+  /** The build would emit these; the committed tree has no such file. */
+  onlyStaged: string[]
+  /** The committed tree ships these; the build claims no source for them. */
+  onlyShipped: string[]
+}
+
+/** The in-place tree each staged target is being compared AGAINST. */
+const SHIPPED_ROOTS: Readonly<Record<BuildTarget, string>> = {
+  opencode: '.opencode/agent',
+  'claude-code': 'plugins/claude-code/agents',
+}
+
+// ── Target selection (pure) ──────────────────────────────────────────────────
+
+/** Validate `--target` against what the build actually wires up. Pure. */
+export const selectTargets = (requested: readonly string[] | undefined): BuildTarget[] => {
+  if (requested === undefined || requested.length === 0) return [...BUILD_TARGETS]
+
+  const unknown = requested.filter((name) => !BUILD_TARGETS.includes(name as BuildTarget))
+  if (unknown.length > 0) {
+    throw new Error(
+      `unknown target(s): ${unknown.join(', ')}. Known targets: ${BUILD_TARGETS.join(', ')}`,
+    )
+  }
+
+  return requested as BuildTarget[]
+}
+
+/** The staging map: every selected target that is not emitted in place. Pure. */
+export const outputRootsFor = (targets: readonly BuildTarget[], stage: string): OutputRoots => {
+  const roots: OutputRoots = {}
+  for (const target of targets) {
+    if (!IN_PLACE_TARGETS.includes(target)) roots[target] = stage
+  }
+  return roots
+}
+
+// ── Staged comparison ────────────────────────────────────────────────────────
+
+/** Every `.md` under `dir`, as repo-relative POSIX paths, sorted. `[]` when absent. */
+const listShipped = (root: string, dir: string): string[] => {
+  const absolute = join(root, dir)
+  if (!existsSync(absolute)) return []
+
+  return readdirSync(absolute, { withFileTypes: true, recursive: true })
+    .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
+    .map((entry) => `${dir}/${entry.name}`)
+    .sort()
+}
+
+/**
+ * Compare what a staged target WOULD emit against what is committed today.
+ *
+ * Read-only on both sides. This is the whole point of staging: the reviewer sees the change
+ * the build wants to make, and nothing on disk moves until someone agrees to it.
+ *
+ * `onlyShipped` matters as much as the other three. A committed file the build claims no
+ * source for is not noise — it means the canonical tree and the shipped tree disagree about
+ * an agent's identity (`oac.id: reviewer` emits `reviewer.md`, but `code-reviewer.md` is what
+ * ships). Reporting only what the build would write would hide exactly half of that.
+ */
+const compareStaged = (built: BuildPlan, root: string, target: BuildTarget): StagedComparison => {
+  const comparison: StagedComparison = {
+    target,
+    identical: [],
+    differing: [],
+    onlyStaged: [],
+    onlyShipped: [],
+  }
+
+  const emitted = built.files.filter((file) => file.target === target)
+  const claimed = new Set(emitted.map((file) => file.path))
+
+  for (const file of emitted) {
+    const shipped = join(root, file.path)
+    if (!existsSync(shipped)) {
+      comparison.onlyStaged.push(file.path)
+    } else if (readFileSync(shipped, 'utf-8') === file.content) {
+      comparison.identical.push(file.path)
+    } else {
+      comparison.differing.push(file.path)
+    }
+  }
+
+  comparison.onlyShipped = listShipped(root, SHIPPED_ROOTS[target]).filter(
+    (path) => !claimed.has(path),
+  )
+
+  return comparison
+}
+
+// ── Reporting ────────────────────────────────────────────────────────────────
+
+const reportWarnings = (built: BuildPlan): void => {
+  for (const warning of built.warnings) verbose(`${warning.source}: ${warning.reason}`)
+}
+
+/**
+ * Dead `type:id` references in the tree, reported with their source.
+ *
+ * Read against the registry ON DISK, so this describes the tree as it stands. Reported rather
+ * than fatal by default: 4 dead references are known and pinned in
+ * `tests/unit/build/reference-resolution.test.ts`, and one of them (`context:context-system/*`)
+ * is deliberately carried through until whoever owns profiles decides what it should say.
+ * `--strict` makes them fatal.
+ */
+const reportDeadReferences = async (root: string): Promise<number> => {
+  const dead = await new ReferenceResolver(root).findDeadReferences()
+  for (const resolution of dead) {
+    warn(`${resolution.source}: ${resolution.ref} — ${resolution.reason ?? resolution.status}`)
+  }
+  return dead.length
+}
+
+const reportDrift = (drift: readonly Drift[]): void => {
+  for (const entry of drift) error(`${entry.status.padEnd(8)} ${entry.path}`)
+}
+
+const reportComparison = (comparison: StagedComparison, stage: string): void => {
+  log('')
+  bold(`  ${comparison.target} — staged to ${stage}/ (NOT emitted in place)`)
+  info(`${comparison.identical.length} identical to the committed tree`)
+
+  for (const path of comparison.differing) warn(`would change: ${path}`)
+  for (const path of comparison.onlyStaged) warn(`would add:    ${path}`)
+  for (const path of comparison.onlyShipped) warn(`unclaimed:    ${path}`)
+
+  if (comparison.differing.length + comparison.onlyStaged.length > 0) {
+    dim(
+      `    Review with: diff -ru plugins/claude-code/agents ` +
+        `${stage}/plugins/claude-code/agents`,
+    )
+  }
+}
+
+// ── Command ──────────────────────────────────────────────────────────────────
+
+export const runBuild = async (options: BuildOptions, root: string): Promise<number> => {
+  setVerbose(options.verbose)
+
+  const targets = selectTargets(options.target)
+  const stage = options.stage
+  const outputRoots = outputRootsFor(targets, stage)
+
+  const built = await planBuild({ root, targets })
+  reportWarnings(built)
+
+  const deadCount = await reportDeadReferences(root)
+  const warningCount = built.warnings.length
+
+  bold('\n  oac build')
+  info(`${built.agents.length} canonical agents -> ${built.files.length} files`)
+
+  // --check and --dry-run share the plan above; neither writes. A check that re-derived the
+  // build would eventually disagree with the build, and CI would trust the wrong one.
+  if (options.check || options.dryRun) {
+    const drift = checkPlan(built, { root, outputRoots })
+
+    if (drift.length === 0) info('no drift — generated trees match the canonical source')
+    else reportDrift(drift)
+
+    for (const target of targets) {
+      if (outputRoots[target] !== undefined) reportComparison(compareStaged(built, root, target), stage)
+    }
+
+    summarize(warningCount, deadCount)
+
+    if (options.check && drift.length > 0) return 1
+    return options.strict && warningCount + deadCount > 0 ? 1 : 0
+  }
+
+  const result = writePlan(built, { root, outputRoots, prune: options.prune })
+
+  for (const path of result.changed) success(`wrote   ${path}`)
+  for (const path of result.removed) success(`removed ${path} (orphan — source is gone)`)
+  for (const { path, reason } of result.kept) warn(`kept    ${path}: ${reason}`)
+
+  info(
+    `${result.changed.length} written, ${result.unchanged.length} unchanged, ` +
+      `${result.removed.length} removed`,
+  )
+
+  for (const target of targets) {
+    if (outputRoots[target] !== undefined) reportComparison(compareStaged(built, root, target), stage)
+  }
+
+  summarize(warningCount, deadCount)
+
+  return options.strict && warningCount + deadCount > 0 ? 1 : 0
+}
+
+const summarize = (warningCount: number, deadCount: number): void => {
+  log('')
+  if (warningCount === 0 && deadCount === 0) {
+    success('0 warnings')
+    return
+  }
+  warn(`${warningCount} degradation warning(s), ${deadCount} dead reference(s)`)
+  dim('    Re-run with --verbose to list every warning, or --strict to fail on them.')
+}
+
+export const registerBuildCommand = (program: Command): void => {
+  program
+    .command('build')
+    .description('Generate tool output from the canonical content/ source')
+    .option(
+      '--target <tool...>',
+      `build only these targets (${BUILD_TARGETS.join(', ')})`,
+    )
+    .option('--check', 'report drift and write nothing; exit 1 if the tree is stale', false)
+    .option('--dry-run', 'preview the build and write nothing', false)
+    .option('--strict', 'exit 1 if there is any warning or dead reference', false)
+    .option('--no-prune', 'keep generated files whose canonical source is gone')
+    .option('--stage <dir>', 'where staged targets are written', CLAUDE_STAGING_ROOT)
+    .option('--verbose', 'list every degradation warning', false)
+    .option('--json', 'reserved for machine-readable output', false)
+    .action(async (options: BuildOptions) => {
+      try {
+        process.exitCode = await runBuild(options, process.cwd())
+      } catch (cause) {
+        error(cause instanceof Error ? cause.message : String(cause))
+        process.exitCode = 1
+      }
+    })
+}

+ 3 - 0
packages/cli/src/index.ts

@@ -27,6 +27,7 @@ async function main(): Promise<void> {
     { registerUpdateCommand },
     { registerAddCommand },
     { registerApplyCommand },
+    { registerBuildCommand },
     { registerDoctorCommand },
     { registerListCommand },
     { registerStatusCommand },
@@ -35,6 +36,7 @@ async function main(): Promise<void> {
     import('./commands/update.js'),
     import('./commands/add.js'),
     import('./commands/apply.js'),
+    import('./commands/build.js'),
     import('./commands/doctor.js'),
     import('./commands/list.js'),
     import('./commands/status.js'),
@@ -44,6 +46,7 @@ async function main(): Promise<void> {
   registerUpdateCommand(program)
   registerAddCommand(program) // also registers `remove`
   registerApplyCommand(program)
+  registerBuildCommand(program)
   registerDoctorCommand(program)
   registerListCommand(program)
   registerStatusCommand(program)

+ 576 - 0
packages/compatibility-layer/src/core/BuildPipeline.ts

@@ -0,0 +1,576 @@
+/**
+ * BuildPipeline — the whole of `oac build`, as a pure function of the canonical tree.
+ *
+ * ─── What it does ───────────────────────────────────────────────────────────────────────
+ *
+ * Load `content/agents/**`, ask each agent which targets it declares, run the matching
+ * adapter, and collect the result as an ordered set of (path, bytes) pairs. Emitting
+ * `registry.json` is folded in as a target of its own, because it is generated from exactly
+ * the same input and must be gated by exactly the same determinism rules.
+ *
+ * ─── Why planning and writing are separate ──────────────────────────────────────────────
+ *
+ * {@link plan} computes what the tree SHOULD contain and touches nothing. {@link write} takes
+ * a plan and reconciles the disk to it. Splitting them is what makes `--check` and `--dry-run`
+ * honest rather than best-effort: they run the identical code path and simply stop before the
+ * write. A `--check` that re-implements the build is a `--check` that eventually disagrees
+ * with it, and CI trusts the wrong one.
+ *
+ * ─── Determinism ────────────────────────────────────────────────────────────────────────
+ *
+ * `oac build && git diff --exit-code` is the gate the refactor rests on (07 Stage 3), so any
+ * per-run variation turns it into a coin flip. Everything ordered here is ordered by CONTENT:
+ * agents arrive from {@link CanonicalAgentLoader} sorted by `oac.id`, targets are iterated in
+ * a declared literal order rather than the authored `targets:` order, and the plan is sorted
+ * by path before it is returned. No clock, no host path, no `readdir` order, no map-insertion
+ * order reaches the output.
+ *
+ * ─── Orphan removal, and why it is manifest-gated ───────────────────────────────────────
+ *
+ * Deleting a source file must delete its generated output, or the subtask-11 CI gate is
+ * defeated silently: the stale file just sits there and `git diff` stays clean. So the build
+ * prunes. But "prune anything under `.opencode/agent/` without a `content/` source" would
+ * delete `.opencode/agent/eval-runner.md`, a real, shipped, hand-authored agent that has
+ * deliberately not been canonicalised yet. A build that eats files it did not write is worse
+ * than no pruning at all.
+ *
+ * The rule is therefore inverted: the build removes a file only if IT PREVIOUSLY WROTE THAT
+ * FILE. {@link BuildManifest} is the ledger — every write records its path and the sha256 of
+ * the bytes written. Pruning considers only paths the previous manifest claims, and never the
+ * filesystem. A file the build has never generated is not in the ledger, cannot become a
+ * candidate, and is invisible to pruning no matter where it sits. See {@link prunable} for
+ * the four conditions, each of which must hold.
+ */
+
+import { createHash } from "node:crypto";
+import {
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  readdirSync,
+  rmSync,
+  rmdirSync,
+  writeFileSync,
+} from "node:fs";
+import { dirname, join, relative, resolve, sep } from "node:path";
+import { CanonicalAgentLoader, type CanonicalAgentFile } from "./AgentLoader.js";
+import { RegistryEmitter } from "./RegistryEmitter.js";
+import { ClaudeAdapter } from "../adapters/ClaudeAdapter.js";
+import { OpenCodeAdapter } from "../adapters/OpenCodeAdapter.js";
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+/** The targets `oac build` wires up today. A subset of `BuildTargetSchema`'s vocabulary. */
+export const BUILD_TARGETS = ["opencode", "claude-code"] as const;
+
+export type BuildTarget = (typeof BUILD_TARGETS)[number];
+
+/** One file the build produces. */
+export interface BuildFile {
+  /** Repo-relative POSIX path the file lands at when emitted in place. */
+  path: string;
+  /** The exact bytes to write. */
+  content: string;
+  /** The target that produced it; `"registry"` for `registry.json`. */
+  target: BuildTarget | "registry";
+  /** `oac.id` of the source agent, or `undefined` for whole-tree artefacts. */
+  agentId?: string;
+  /** Semantics the target could not carry. Never fatal on their own. */
+  warnings: BuildWarning[];
+}
+
+/** A warning, always carrying the file it came from so it is actionable. */
+export interface BuildWarning {
+  /** Repo-relative path of the SOURCE that caused it, not the emitted file. */
+  source: string;
+  reason: string;
+}
+
+/** Everything a build produced, before anything is written. */
+export interface BuildPlan {
+  /** Emitted files, sorted by path. */
+  files: BuildFile[];
+  /** Every warning across every file, in file order. */
+  warnings: BuildWarning[];
+  /** Canonical agents loaded, sorted by `oac.id`. */
+  agents: CanonicalAgentFile[];
+}
+
+export interface BuildOptions {
+  /** Repository root. Everything resolves against it — no hardcoded paths. */
+  root: string;
+  /** Restrict the build to these targets. Defaults to {@link BUILD_TARGETS} plus the registry. */
+  targets?: readonly BuildTarget[];
+  /** Skip `registry.json`. Defaults to false. */
+  skipRegistry?: boolean;
+  /**
+   * Accepted and ignored: {@link plan} never writes, so a dry run IS a plan. Present because
+   * `dryRun: true` is how callers say what they mean, and because a flag that silently means
+   * nothing is safer than one that silently means something else.
+   */
+  dryRun?: boolean;
+}
+
+/** Where a target's files are written, when not in place. */
+export type OutputRoots = Partial<Record<BuildFile["target"], string>>;
+
+export interface WriteOptions {
+  root: string;
+  /**
+   * Per-target root override, repo-relative. A target listed here is REBASED under the given
+   * directory instead of being written in place — the mechanism `plugins/claude-code/**` is
+   * staged with, so a build can be compared against the shipped tree without touching it.
+   */
+  outputRoots?: OutputRoots;
+  /**
+   * Remove generated files whose source is gone. Only ever considers paths the previous
+   * manifest claims — see {@link prunable}. Defaults to true.
+   */
+  prune?: boolean;
+}
+
+export interface WriteResult {
+  /** Paths written whose bytes changed (or that did not exist). */
+  changed: string[];
+  /** Paths written whose bytes already matched. */
+  unchanged: string[];
+  /** Paths removed as orphans. */
+  removed: string[];
+  /** Orphan candidates left alone, with the reason. */
+  kept: Array<{ path: string; reason: string }>;
+}
+
+/** One line of the ledger: what the build wrote at a path, and where it was allowed to. */
+export interface ManifestEntry {
+  /** sha256 of the bytes the build wrote. */
+  sha256: string;
+  /** The target that produced it. */
+  target: string;
+  /**
+   * The output root this file was written under — `TARGET_ROOTS[target]`, rebased if the
+   * target was staged. Recorded rather than recomputed so pruning can bound itself without
+   * having to be told which staging layout a PREVIOUS build happened to use.
+   */
+  root: string;
+}
+
+/** The build's ledger of what it wrote. Deterministic: sorted keys, no timestamps. */
+export interface BuildManifest {
+  /** Ledger format version, so a future shape change is detectable rather than silent. */
+  version: 1;
+  /** Repo-relative POSIX path -> what the build wrote there. */
+  files: Record<string, ManifestEntry>;
+}
+
+// ============================================================================
+// PATHS AND CONSTANTS
+// ============================================================================
+
+const DEFAULTS = {
+  contentRoot: "content/agents",
+  manifestFile: ".oac/build-manifest.json",
+} as const;
+
+/**
+ * The roots each target is permitted to write, and therefore the only roots pruning may ever
+ * touch. Defence in depth: the manifest is the gate, but a manifest that has been corrupted,
+ * hand-edited or carried over from a different layout must not be able to talk the build into
+ * deleting `src/`. A prune candidate outside its target's root is refused and reported.
+ */
+const TARGET_ROOTS: Readonly<Record<BuildFile["target"], string>> = {
+  opencode: ".opencode/agent",
+  "claude-code": "plugins/claude-code/agents",
+  registry: "registry.json",
+};
+
+/** 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;
+}
+
+/** POSIX-separated, so a plan built on Windows and one built on macOS agree. */
+function toPosix(path: string): string {
+  return path.split(sep).join("/");
+}
+
+/** True when `path` is `root` itself or sits underneath it. Segment-aware, not `startsWith`. */
+function isUnder(path: string, root: string): boolean {
+  return path === root || path.startsWith(`${root}/`);
+}
+
+function sha256(content: string): string {
+  return createHash("sha256").update(content, "utf-8").digest("hex");
+}
+
+// ============================================================================
+// PLANNING
+// ============================================================================
+
+/**
+ * Emit one agent for one target, or `null` when the agent does not declare that target.
+ *
+ * `oac.targets` is honoured here and nowhere else: an agent with `targets: ["opencode"]`
+ * produces no Claude Code output because this function returns `null`, not because some later
+ * filter drops it.
+ */
+async function emitAgent(
+  agent: CanonicalAgentFile,
+  target: BuildTarget
+): Promise<BuildFile | null> {
+  if (!agent.oac.targets.includes(target)) return null;
+
+  const source = readFileSync(agent.filePath, "utf-8");
+  const sourcePath = `${DEFAULTS.contentRoot}/${agent.relativePath}`;
+
+  if (target === "opencode") {
+    const adapter = new OpenCodeAdapter();
+    const { content, warnings } = await adapter.fromCanonical(source, { filePath: sourcePath });
+    return {
+      path: adapter.outputPath(agent.relativePath),
+      content,
+      target,
+      agentId: agent.oac.id,
+      warnings: warnings.map((reason) => ({ source: sourcePath, reason })),
+    };
+  }
+
+  const adapter = new ClaudeAdapter();
+  const { path, content, warnings } = await adapter.fromCanonical(source);
+  return {
+    path,
+    content,
+    target,
+    agentId: agent.oac.id,
+    warnings: warnings.map((reason) => ({ source: sourcePath, reason })),
+  };
+}
+
+/**
+ * Compute the full build. Reads the canonical tree; writes nothing, ever.
+ *
+ * A failure anywhere — a schema violation, an unrepresentable permission block an adapter
+ * refuses to widen — rejects. Fail-closed is the whole point: a capability that cannot be
+ * expressed on a target is an error, never a silent grant.
+ */
+export async function plan(options: BuildOptions): Promise<BuildPlan> {
+  const root = resolve(options.root);
+  const targets = options.targets ?? BUILD_TARGETS;
+  const agents = await new CanonicalAgentLoader(join(root, DEFAULTS.contentRoot))
+    .loadFromDirectory();
+
+  const files: BuildFile[] = [];
+
+  // Agents outer, targets inner, both in a content-determined order. Iterating `oac.targets`
+  // instead would make output order depend on the order an author happened to list them in.
+  for (const agent of agents) {
+    for (const target of targets) {
+      const file = await emitAgent(agent, target);
+      if (file !== null) files.push(file);
+    }
+  }
+
+  if (options.skipRegistry !== true) {
+    files.push({
+      path: "registry.json",
+      content: await new RegistryEmitter(root).emitJson(),
+      target: "registry",
+      warnings: [],
+    });
+  }
+
+  files.sort((a, b) => compare(a.path, b.path));
+
+  return { files, agents, warnings: files.flatMap((file) => file.warnings) };
+}
+
+/**
+ * The build as a plain path -> bytes map.
+ *
+ * The entry point `tests/unit/build/determinism.test.ts` drives: two calls over one tree must
+ * produce identical maps.
+ */
+export async function build(options: BuildOptions): Promise<Map<string, string>> {
+  const { files } = await plan(options);
+  return new Map(files.map((file) => [file.path, file.content]));
+}
+
+/**
+ * Emit one agent, named by `oac.id`, for one target.
+ *
+ * Identity is `oac.id` and never a filename: `content/agents/subagents/code/test-engineer.md`
+ * declares `id: tester`, and `tester` is what `registry.json`, the profiles and the context
+ * docs all reference. Resolving by path here would mint an id nothing refers to.
+ *
+ * @throws when no agent declares `id`, or when that agent does not declare `target`.
+ */
+export async function buildAgent(id: string, target: BuildTarget): Promise<string> {
+  return buildAgentIn(process.cwd(), id, target);
+}
+
+/** {@link buildAgent} against an explicit root — the testable form. */
+export async function buildAgentIn(
+  root: string,
+  id: string,
+  target: BuildTarget
+): Promise<string> {
+  const agents = await new CanonicalAgentLoader(join(resolve(root), DEFAULTS.contentRoot))
+    .loadFromDirectory();
+  const agent = agents.find((candidate) => candidate.oac.id === id);
+
+  if (agent === undefined) {
+    throw new Error(
+      `No canonical agent declares oac.id "${id}". Known ids: ` +
+        `${agents.map((candidate) => candidate.oac.id).join(", ")}`
+    );
+  }
+
+  const file = await emitAgent(agent, target);
+  if (file === null) {
+    throw new Error(
+      `Agent "${id}" does not declare target "${target}" (declares: ` +
+        `${agent.oac.targets.join(", ")}), so it emits nothing there.`
+    );
+  }
+
+  return file.content;
+}
+
+// ============================================================================
+// MANIFEST
+// ============================================================================
+
+/** Read the previous manifest, or an empty one. An absent ledger prunes nothing — safe. */
+export function readManifest(root: string): BuildManifest {
+  const path = join(resolve(root), DEFAULTS.manifestFile);
+  if (!existsSync(path)) return { version: 1, files: {} };
+
+  try {
+    const parsed = JSON.parse(readFileSync(path, "utf-8")) as BuildManifest;
+    // A ledger we cannot vouch for prunes nothing rather than pruning wrongly.
+    if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null) {
+      return { version: 1, files: {} };
+    }
+    return parsed;
+  } catch {
+    return { version: 1, files: {} };
+  }
+}
+
+/** Serialise a manifest with sorted keys and a trailing newline. No clock, by construction. */
+export function serializeManifest(manifest: BuildManifest): string {
+  const files: BuildManifest["files"] = {};
+  for (const path of Object.keys(manifest.files).sort(compare)) {
+    files[path] = manifest.files[path]!;
+  }
+  return `${JSON.stringify({ version: manifest.version, files }, null, 2)}\n`;
+}
+
+/** The manifest a plan implies, given where each target is actually written. */
+function manifestFor(files: readonly BuildFile[], outputRoots: OutputRoots): BuildManifest {
+  const entries: BuildManifest["files"] = {};
+  for (const file of files) {
+    entries[rebase(file.path, file.target, outputRoots)] = {
+      sha256: sha256(file.content),
+      target: file.target,
+      root: rebase(TARGET_ROOTS[file.target], file.target, outputRoots),
+    };
+  }
+  return { version: 1, files: entries };
+}
+
+/** Where a file actually lands, honouring a staging override for its target. */
+function rebase(path: string, target: BuildFile["target"], outputRoots: OutputRoots): string {
+  const override = outputRoots[target];
+  return override === undefined ? path : toPosix(join(override, path));
+}
+
+// ============================================================================
+// PRUNING
+// ============================================================================
+
+/**
+ * Decide whether an orphan candidate may be deleted.
+ *
+ * Called only for paths the PREVIOUS manifest claims — that gate happens in {@link write} and
+ * is the load-bearing one. Everything here is a second line of defence, because the cost of a
+ * wrong answer is an unrecoverable deletion of someone's work:
+ *
+ *   1. **The manifest claims it.** (Enforced by the caller.) The build wrote this exact path
+ *      on a previous run. `.opencode/agent/eval-runner.md` has no `content/` source, was never
+ *      emitted, is not in the ledger, and therefore never reaches this function at all.
+ *   2. **The current build does not produce it.** Otherwise it is not an orphan, it is output.
+ *   3. **It sits under an output root its own target could legitimately have written.** The
+ *      manifest records that root, but the record is VERIFIED rather than trusted: it must be
+ *      the target's canonical root, or that root rebased under a staging directory. A ledger
+ *      that has been corrupted, hand-edited, or carried over from another layout therefore
+ *      cannot talk the build into deleting `src/`.
+ *   4. **Its bytes still match what the build wrote.** If a human edited a generated file, the
+ *      hash diverges and we refuse: their edit is misplaced, but it is theirs, and reporting it
+ *      is strictly better than destroying it.
+ *
+ * @returns `null` when the file may be removed, or the reason it is being kept.
+ */
+function prunable(absolute: string, path: string, entry: ManifestEntry): string | null {
+  const canonical = TARGET_ROOTS[entry.target as BuildFile["target"]];
+
+  if (canonical === undefined) {
+    return `manifest names an unknown target "${entry.target}"`;
+  }
+  // The recorded root is either the canonical one or the canonical one under a staging dir.
+  // Anything else means the ledger is not describing a tree this build owns.
+  if (entry.root !== canonical && !entry.root.endsWith(`/${canonical}`)) {
+    return `manifest records root "${entry.root}", which is not the ${entry.target} output root`;
+  }
+  if (!isUnder(path, entry.root)) {
+    return `manifest entry sits outside its recorded output root (${entry.root})`;
+  }
+  if (!existsSync(absolute)) {
+    return "already gone";
+  }
+  if (sha256(readFileSync(absolute, "utf-8")) !== entry.sha256) {
+    return "modified since it was generated — refusing to delete a file someone has edited";
+  }
+
+  return null;
+}
+
+/**
+ * Remove a file and every directory it leaves empty, stopping at its own output root.
+ *
+ * The root itself is never removed: an empty `.opencode/agent/` is a legitimate state, and
+ * deleting the directory a target is defined by would be a surprise well beyond "prune".
+ *
+ * `rmdirSync`, never `rmSync`: `rmSync` without `recursive` refuses a directory outright, and
+ * WITH `recursive` it would delete a non-empty tree — the emptiness check above it is the only
+ * thing standing between "tidy up" and "remove the subtree". `rmdirSync` fails closed on a
+ * non-empty directory, so the guard is enforced by the syscall rather than only by us.
+ *
+ * Directory tidying is best-effort: the file removal has already succeeded, which is the part
+ * that matters. A concurrent write that refills the directory must not turn a correct build
+ * into a failed one.
+ */
+function removeAndPruneDirs(root: string, path: string, outputRoot: string): void {
+  rmSync(join(root, path));
+
+  const stopAt = join(root, outputRoot);
+  let dir = dirname(join(root, path));
+
+  while (dir !== stopAt && isUnder(toPosix(dir), toPosix(stopAt))) {
+    try {
+      if (readdirSync(dir).length > 0) return;
+      rmdirSync(dir);
+    } catch {
+      return;
+    }
+    dir = dirname(dir);
+  }
+}
+
+// ============================================================================
+// WRITING
+// ============================================================================
+
+/**
+ * Reconcile the disk to a plan: write every file, prune the orphans, record the ledger.
+ *
+ * Writes are content-conditional — a file whose bytes already match is not rewritten, so a
+ * no-op build does not churn mtimes and `--check` has something meaningful to report.
+ */
+export function write(plan: BuildPlan, options: WriteOptions): WriteResult {
+  const root = resolve(options.root);
+  const outputRoots = options.outputRoots ?? {};
+  const result: WriteResult = { changed: [], unchanged: [], removed: [], kept: [] };
+
+  for (const file of plan.files) {
+    const path = rebase(file.path, file.target, outputRoots);
+    const absolute = join(root, path);
+    const exists = existsSync(absolute);
+
+    if (exists && readFileSync(absolute, "utf-8") === file.content) {
+      result.unchanged.push(path);
+      continue;
+    }
+
+    mkdirSync(dirname(absolute), { recursive: true });
+    writeFileSync(absolute, file.content, "utf-8");
+    result.changed.push(path);
+  }
+
+  const next = manifestFor(plan.files, outputRoots);
+
+  if (options.prune !== false) {
+    const previous = readManifest(root);
+    // THE gate: candidates come from the ledger, never from a directory scan. A file this
+    // build has not written and no previous build wrote is not enumerable here.
+    for (const path of Object.keys(previous.files).sort(compare)) {
+      if (path in next.files) continue;
+
+      const entry = previous.files[path]!;
+      const reason = prunable(join(root, path), path, entry);
+
+      if (reason === null) {
+        removeAndPruneDirs(root, path, entry.root);
+        result.removed.push(path);
+      } else if (reason !== "already gone") {
+        result.kept.push({ path, reason });
+      }
+    }
+  }
+
+  const manifestPath = join(root, DEFAULTS.manifestFile);
+  mkdirSync(dirname(manifestPath), { recursive: true });
+  writeFileSync(manifestPath, serializeManifest(next), "utf-8");
+
+  result.changed.sort(compare);
+  result.unchanged.sort(compare);
+
+  return result;
+}
+
+// ============================================================================
+// CHECKING
+// ============================================================================
+
+/** One file whose on-disk bytes disagree with the plan. */
+export interface Drift {
+  path: string;
+  status: "missing" | "modified" | "orphan";
+}
+
+/**
+ * Compare a plan against the disk without touching it — the engine behind `--check`.
+ *
+ * Orphans are reported from the manifest for the same reason pruning takes them from there:
+ * a directory scan would report `eval-runner.md` as drift on every run.
+ */
+export function check(plan: BuildPlan, options: WriteOptions): Drift[] {
+  const root = resolve(options.root);
+  const outputRoots = options.outputRoots ?? {};
+  const drift: Drift[] = [];
+  const next = manifestFor(plan.files, outputRoots);
+
+  for (const file of plan.files) {
+    const path = rebase(file.path, file.target, outputRoots);
+    const absolute = join(root, path);
+
+    if (!existsSync(absolute)) drift.push({ path, status: "missing" });
+    else if (readFileSync(absolute, "utf-8") !== file.content) {
+      drift.push({ path, status: "modified" });
+    }
+  }
+
+  for (const path of Object.keys(readManifest(root).files)) {
+    if (!(path in next.files) && existsSync(join(root, path))) {
+      drift.push({ path, status: "orphan" });
+    }
+  }
+
+  return drift.sort((a, b) => compare(a.path, b.path));
+}
+
+/** Repo-relative path of a file, POSIX-separated. Exported for the CLI's reporting. */
+export function repoRelative(root: string, absolute: string): string {
+  return toPosix(relative(resolve(root), absolute));
+}

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

@@ -134,6 +134,61 @@ export type {
   RegistryEmitterOptions,
 } from "./core/RegistryEmitter.js";
 
+// ============================================================================
+// CORE - Build Pipeline
+// ============================================================================
+
+/**
+ * The whole of `oac build`: load `content/agents/**`, honour each agent's `oac.targets`, run
+ * the matching adapter, and reconcile the generated trees.
+ *
+ * {@link plan} computes; {@link write} reconciles; {@link check} compares without touching
+ * anything. Splitting them is what makes `--check` and `--dry-run` share the build's code path
+ * rather than re-implement it.
+ *
+ * Orphan removal is gated on {@link BuildManifest}: the build removes a file only if a
+ * previous build recorded writing that exact path with those exact bytes. A file it never
+ * generated can never become a prune candidate.
+ *
+ * @example
+ * ```typescript
+ * import { plan, write } from '@openagents-control/compatibility-layer';
+ *
+ * const built = await plan({ root: process.cwd() });
+ * // Emit .opencode/** and registry.json in place; stage claude-code for review.
+ * const result = write(built, {
+ *   root: process.cwd(),
+ *   outputRoots: { "claude-code": ".tmp/oac-build" },
+ * });
+ * ```
+ */
+export {
+  BUILD_TARGETS,
+  build,
+  buildAgent,
+  buildAgentIn,
+  check,
+  plan,
+  readManifest,
+  repoRelative,
+  serializeManifest,
+  write,
+} from "./core/BuildPipeline.js";
+
+export type {
+  BuildFile,
+  BuildManifest,
+  BuildOptions,
+  BuildPlan,
+  BuildTarget,
+  BuildWarning,
+  Drift,
+  ManifestEntry,
+  OutputRoots,
+  WriteOptions,
+  WriteResult,
+} from "./core/BuildPipeline.js";
+
 // ============================================================================
 // CORE - Reference Resolution & Profiles
 // ============================================================================

+ 21 - 40
packages/compatibility-layer/tests/unit/adapters/OpenCodeAdapter.test.ts

@@ -274,39 +274,24 @@ describe("OpenCodeAdapter permissions", () => {
 // ============================================================================
 
 /**
- * The obsolete header that 23 committed `.opencode/agent` files still carry — a comment
- * pointing at the very sidecar this refactor dissolves.
+ * ─── The drifted 9, and why there is no longer an exception list here ────────────────────
  *
- * The seeding commit (`cf97d98`) dropped it from 9 of those 23 and kept it in the other 14.
- * That inconsistency is a seeding artifact, not an adapter behaviour: the comment is absent
- * from those 9 canonical sources, so no emitter can reproduce it.
+ * 23 committed `.opencode/agent` files used to carry an obsolete 4-line header pointing at the
+ * `agent-metadata.json` sidecar this refactor dissolves. The seeding commit (`cf97d98`) dropped
+ * it from 9 of the canonical sources and kept it in the other 14, so those 9 could not be
+ * reproduced byte-for-byte: the comment simply was not in the source any more.
  *
- * It is pinned here rather than papered over. The adapter is NOT special-cased to re-insert
- * it — doing so would be forging bytes the source does not contain. Instead the 9 are named,
- * so the drift is visible, cannot grow silently, and stays owned by whoever re-seeds
- * `content/agents` (subtask 09). Resolving it means either restoring the comment in those 9
- * sources or removing it from all 23 — a content decision, made once, deliberately.
+ * That was pinned here as a named set rather than papered over — the adapter was never
+ * special-cased to re-insert the comment, because emitting bytes the source does not contain is
+ * forging. The note left two ways out: restore the comment in the 9 sources, or remove it from
+ * the emitted files.
+ *
+ * Subtask 10 took the second by running `oac build`, which emits `.opencode/agent/**` in place.
+ * Those 9 files no longer carry the comment, the other 24 were already byte-identical, and the
+ * exception machinery is gone with the exception. The round-trip below now compares every
+ * source against its committed output directly, with nothing carved out for anybody — which is
+ * exactly the property the subtask-11 `oac build && git diff --exit-code` gate needs.
  */
-const SIDECAR_COMMENT =
-  [
-    "# OpenCode Agent Configuration",
-    "# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:",
-    "# .opencode/config/agent-metadata.json",
-    "",
-  ].join("\n") + "\n";
-
-/** The 9 sources whose committed output still carries {@link SIDECAR_COMMENT}. */
-const DRIFTED: ReadonlySet<string> = new Set([
-  "content/copywriter.md",
-  "content/technical-writer.md",
-  "meta/system-builder.md",
-  "subagents/planning/story-mapper.md",
-  "subagents/system-builder/agent-generator.md",
-  "subagents/system-builder/command-creator.md",
-  "subagents/system-builder/domain-analyzer.md",
-  "subagents/test/simple-responder.md",
-  "subagents/utils/image-specialist.md",
-]);
 
 describe("OpenCodeAdapter round-trip", () => {
   const sources = listFiles(CONTENT_ROOT);
@@ -321,19 +306,15 @@ describe("OpenCodeAdapter round-trip", () => {
       const { content } = await adapter().fromCanonical(readFileSync(file, "utf-8"), {
         filePath: file,
       });
-      const committed = readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8");
-
-      // The drifted 9 must differ ONLY by the obsolete comment — re-inserting it recovers the
-      // committed bytes exactly. Any other difference in them still fails here.
-      const expected = DRIFTED.has(rel)
-        ? committed.replace(`---\n${SIDECAR_COMMENT}`, "---\n")
-        : committed;
 
-      expect(content).toBe(expected);
+      expect(content).toBe(readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8"));
     }
   );
 
-  it("pins the drifted set, so it cannot grow unnoticed", async () => {
+  it("leaves no source drifting from its committed output", async () => {
+    // The aggregate form of the per-file assertion above. It earns its place by naming every
+    // drifted file in ONE failure: a rebuild that regresses 9 files should say so once, not
+    // scroll 9 separate byte-diffs past whoever is reading CI.
     const drifted: string[] = [];
 
     for (const file of sources) {
@@ -342,7 +323,7 @@ describe("OpenCodeAdapter round-trip", () => {
       if (content !== readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8")) drifted.push(rel);
     }
 
-    expect(drifted.sort()).toEqual([...DRIFTED].sort());
+    expect(drifted.sort()).toEqual([]);
   });
 
   it("maps each source onto its committed output path", () => {

+ 345 - 0
packages/compatibility-layer/tests/unit/build/build-pipeline.test.ts

@@ -0,0 +1,345 @@
+/**
+ * BuildPipeline — targets, determinism, and the orphan-removal safety envelope.
+ *
+ * ─── Why the pruning tests are the important half of this file ───────────────────────────
+ *
+ * Orphan removal is the one part of `oac build` that DELETES. Everything else, if wrong,
+ * produces a bad file someone notices in review; this, if wrong, destroys work that was never
+ * committed and is not recoverable.
+ *
+ * The live case is not hypothetical. `.opencode/agent/eval-runner.md` is a real, shipped,
+ * hand-authored agent with no `content/agents/` counterpart — deliberately, until it is
+ * canonicalised. The obvious pruning rule ("delete anything under `.opencode/agent/` with no
+ * canonical source") deletes it. So the rule is inverted: the build removes only what a
+ * PREVIOUS build recorded writing. These tests pin that inversion from both sides — that a
+ * genuine orphan does go, and that an unclaimed file does not — because a pruner that only
+ * ever gets tested on the happy path is a pruner that eats someone's afternoon exactly once.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { tmpdir } from "node:os";
+import {
+  build,
+  buildAgentIn,
+  check,
+  plan,
+  readManifest,
+  serializeManifest,
+  write,
+} from "../../../src/core/BuildPipeline.js";
+import { repoPath } from "../../support/pending.js";
+
+const REPO = repoPath();
+
+// ============================================================================
+// A throwaway tree, so nothing here can touch the real repo
+// ============================================================================
+
+let root: string;
+
+/** A minimal canonical agent. `targets` is the knob most of these tests turn. */
+function canonicalAgent(id: string, targets: readonly string[]): string {
+  return [
+    "---",
+    `name: ${id}`,
+    `description: Fixture agent ${id}.`,
+    "mode: subagent",
+    "permission:",
+    "  read:",
+    '    "*": "allow"',
+    "oac:",
+    `  id: ${id}`,
+    `  name: ${id}`,
+    "  category: subagents/test",
+    "  type: subagent",
+    '  version: "1.0.0"',
+    "  author: opencode",
+    "  targets:",
+    ...targets.map((target) => `    - ${target}`),
+    "---",
+    "",
+    `# ${id}`,
+    "",
+    "Body.",
+    "",
+  ].join("\n");
+}
+
+function put(relativePath: string, content: string): void {
+  const absolute = join(root, relativePath);
+  mkdirSync(dirname(absolute), { recursive: true });
+  writeFileSync(absolute, content, "utf-8");
+}
+
+/** A registry the emitter can carry non-agent data through from. */
+const BASE_REGISTRY = {
+  version: "1.0.0",
+  metadata: { lastUpdated: "2026-01-01" },
+  components: { agents: [], subagents: [], contexts: [] },
+  profiles: {},
+};
+
+beforeEach(() => {
+  root = mkdtempSync(join(tmpdir(), "oac-build-"));
+  put("registry.json", `${JSON.stringify(BASE_REGISTRY, null, 2)}\n`);
+  put("content/agents/subagents/test/alpha.md", canonicalAgent("alpha", ["opencode"]));
+  put("content/agents/subagents/test/beta.md", canonicalAgent("beta", ["opencode", "claude-code"]));
+});
+
+afterEach(() => {
+  rmSync(root, { recursive: true, force: true });
+});
+
+// ============================================================================
+// targets:
+// ============================================================================
+
+describe("oac.targets", () => {
+  it("emits an agent only to the targets it declares", async () => {
+    const files = await build({ root });
+
+    expect(files.has(".opencode/agent/subagents/test/alpha.md")).toBe(true);
+    expect(files.has(".opencode/agent/subagents/test/beta.md")).toBe(true);
+    expect(files.has("plugins/claude-code/agents/beta.md")).toBe(true);
+    // alpha declares targets: [opencode] only — it must produce NO claude-code output.
+    expect(files.has("plugins/claude-code/agents/alpha.md")).toBe(false);
+  });
+
+  it("restricting --target narrows the build without changing the bytes", async () => {
+    const all = await build({ root });
+    const only = await build({ root, targets: ["opencode"], skipRegistry: true });
+
+    expect([...only.keys()]).toEqual(
+      [...all.keys()].filter((path) => path.startsWith(".opencode/")),
+    );
+    for (const [path, content] of only) expect(content).toBe(all.get(path));
+  });
+
+  it("strips the oac: block from OpenCode output but keeps the body", async () => {
+    const files = await build({ root, targets: ["opencode"], skipRegistry: true });
+    const emitted = files.get(".opencode/agent/subagents/test/alpha.md") ?? "";
+
+    expect(emitted).not.toContain("oac:");
+    expect(emitted).toContain("name: alpha");
+    expect(emitted).toContain("Body.");
+  });
+});
+
+// ============================================================================
+// buildAgent
+// ============================================================================
+
+describe("buildAgent", () => {
+  it("emits one agent by its oac.id", async () => {
+    expect(await buildAgentIn(root, "beta", "claude-code")).toContain("name: beta");
+  });
+
+  it("names the known ids when asked for one that does not exist", async () => {
+    await expect(buildAgentIn(root, "nope", "opencode")).rejects.toThrow(/alpha, beta/);
+  });
+
+  it("refuses a target the agent does not declare, rather than inventing output", async () => {
+    await expect(buildAgentIn(root, "alpha", "claude-code")).rejects.toThrow(
+      /does not declare target "claude-code"/,
+    );
+  });
+});
+
+// ============================================================================
+// Determinism
+// ============================================================================
+
+describe("determinism", () => {
+  it("is a fixed point: building over its own output changes nothing", async () => {
+    const first = write(await plan({ root }), { root });
+    expect(first.changed.length).toBeGreaterThan(0);
+
+    const second = write(await plan({ root }), { root });
+    expect(second.changed, "a second build rewrote files").toEqual([]);
+    expect(check(await plan({ root }), { root })).toEqual([]);
+  });
+
+  it("writes a manifest with sorted keys and no timestamp", async () => {
+    write(await plan({ root }), { root });
+    const manifest = readFileSync(join(root, ".oac/build-manifest.json"), "utf-8");
+
+    expect(manifest).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
+    expect(Object.keys(readManifest(root).files)).toEqual(
+      [...Object.keys(readManifest(root).files)].sort(),
+    );
+    expect(serializeManifest(readManifest(root))).toBe(manifest);
+  });
+});
+
+// ============================================================================
+// Staging
+// ============================================================================
+
+describe("staged targets", () => {
+  it("rebases a staged target and leaves the in-place tree alone", async () => {
+    write(await plan({ root }), { root, outputRoots: { "claude-code": ".tmp/stage" } });
+
+    expect(existsSync(join(root, ".tmp/stage/plugins/claude-code/agents/beta.md"))).toBe(true);
+    expect(existsSync(join(root, "plugins/claude-code/agents/beta.md"))).toBe(false);
+  });
+});
+
+// ============================================================================
+// Orphan removal — the part that deletes
+// ============================================================================
+
+describe("orphan removal", () => {
+  const ALPHA_OUT = ".opencode/agent/subagents/test/alpha.md";
+
+  it("removes generated output when its canonical source is deleted", async () => {
+    write(await plan({ root }), { root });
+    expect(existsSync(join(root, ALPHA_OUT))).toBe(true);
+
+    rmSync(join(root, "content/agents/subagents/test/alpha.md"));
+    const result = write(await plan({ root }), { root });
+
+    expect(result.removed).toContain(ALPHA_OUT);
+    expect(existsSync(join(root, ALPHA_OUT))).toBe(false);
+  });
+
+  it("NEVER removes a file it did not generate, however orphan-shaped it looks", async () => {
+    // This is `.opencode/agent/eval-runner.md`: a real agent, in the output tree, with no
+    // canonical source, deliberately not canonicalised yet. It is not in the ledger, so it is
+    // not enumerable as a candidate — no rule, guard or heuristic ever gets a vote.
+    put(".opencode/agent/eval-runner.md", "---\nname: eval-runner\n---\n\nUncommitted work.\n");
+
+    write(await plan({ root }), { root });
+    write(await plan({ root }), { root });
+
+    expect(existsSync(join(root, ".opencode/agent/eval-runner.md"))).toBe(true);
+    expect(readFileSync(join(root, ".opencode/agent/eval-runner.md"), "utf-8")).toContain(
+      "Uncommitted work.",
+    );
+  });
+
+  it("removes the directory an orphan leaves empty, but never the target's own root", async () => {
+    // Regression: this used to throw EFAULT — `rmSync` without `recursive` refuses to remove a
+    // directory at all, so the tidy-up aborted the whole build AFTER it had already deleted
+    // the file. Caught by running the real command, not by any test that existed at the time.
+    write(await plan({ root }), { root });
+    rmSync(join(root, "content/agents/subagents/test/alpha.md"));
+    rmSync(join(root, "content/agents/subagents/test/beta.md"));
+
+    const result = write(await plan({ root }), { root });
+
+    expect(result.removed).toContain(ALPHA_OUT);
+    expect(existsSync(join(root, ".opencode/agent/subagents/test"))).toBe(false);
+    expect(existsSync(join(root, ".opencode/agent")), "the output root itself").toBe(true);
+  });
+
+  it("leaves a directory alone while it still holds a file the build does not own", async () => {
+    put(".opencode/agent/subagents/test/notes.md", "hand-written, not generated\n");
+    write(await plan({ root }), { root });
+    rmSync(join(root, "content/agents/subagents/test/alpha.md"));
+    rmSync(join(root, "content/agents/subagents/test/beta.md"));
+
+    write(await plan({ root }), { root });
+
+    expect(existsSync(join(root, ".opencode/agent/subagents/test/notes.md"))).toBe(true);
+  });
+
+  it("prunes nothing on a first build, when there is no ledger to prune from", async () => {
+    put(".opencode/agent/stranger.md", "not ours\n");
+
+    const result = write(await plan({ root }), { root });
+
+    expect(result.removed).toEqual([]);
+    expect(existsSync(join(root, ".opencode/agent/stranger.md"))).toBe(true);
+  });
+
+  it("keeps — and reports — an orphan a human has edited since it was generated", async () => {
+    write(await plan({ root }), { root });
+    rmSync(join(root, "content/agents/subagents/test/alpha.md"));
+    writeFileSync(join(root, ALPHA_OUT), "hand-edited, and not by the build\n", "utf-8");
+
+    const result = write(await plan({ root }), { root });
+
+    expect(result.removed).not.toContain(ALPHA_OUT);
+    expect(result.kept.map((entry) => entry.path)).toContain(ALPHA_OUT);
+    expect(result.kept[0]?.reason).toMatch(/modified since it was generated/);
+    expect(existsSync(join(root, ALPHA_OUT))).toBe(true);
+  });
+
+  it("refuses a manifest that points a delete outside its target's output root", async () => {
+    write(await plan({ root }), { root });
+    put("src/precious.ts", "export const x = 1;\n");
+
+    // A corrupted / hand-edited ledger claiming the build wrote into src/.
+    const manifest = readManifest(root);
+    manifest.files["src/precious.ts"] = {
+      sha256: "0".repeat(64),
+      target: "opencode",
+      root: "src",
+    };
+    put(".oac/build-manifest.json", serializeManifest(manifest));
+
+    const result = write(await plan({ root }), { root });
+
+    expect(result.removed).not.toContain("src/precious.ts");
+    expect(existsSync(join(root, "src/precious.ts"))).toBe(true);
+  });
+
+  it("--no-prune leaves orphans in place", async () => {
+    write(await plan({ root }), { root });
+    rmSync(join(root, "content/agents/subagents/test/alpha.md"));
+
+    const result = write(await plan({ root }), { root, prune: false });
+
+    expect(result.removed).toEqual([]);
+    expect(existsSync(join(root, ALPHA_OUT))).toBe(true);
+  });
+
+  it("reports an orphan as drift under check() without removing it", async () => {
+    write(await plan({ root }), { root });
+    rmSync(join(root, "content/agents/subagents/test/alpha.md"));
+
+    const drift = check(await plan({ root }), { root });
+
+    expect(drift).toContainEqual({ path: ALPHA_OUT, status: "orphan" });
+    expect(existsSync(join(root, ALPHA_OUT))).toBe(true);
+  });
+});
+
+// ============================================================================
+// Fail-closed
+// ============================================================================
+
+describe("failure handling", () => {
+  it("rejects the whole build on a schema violation rather than emitting a partial tree", async () => {
+    put("content/agents/subagents/test/broken.md", "---\nname: broken\n---\n\nNo oac block.\n");
+
+    await expect(plan({ root })).rejects.toThrow();
+  });
+
+  it("warns rather than silently widening when a target cannot carry a scoped rule", async () => {
+    put(
+      "content/agents/subagents/test/gamma.md",
+      canonicalAgent("gamma", ["claude-code"]).replace(
+        '  read:\n    "*": "allow"',
+        '  bash:\n    "*": "deny"\n    "git status": "allow"',
+      ),
+    );
+
+    const built = await plan({ root, targets: ["claude-code"], skipRegistry: true });
+    const emitted = built.files.find((file) => file.agentId === "gamma");
+
+    expect(emitted?.content).toMatch(/^disallowedTools:.*\bBash\b/m);
+    expect(emitted?.content).not.toMatch(/^tools:.*\bBash\b/m);
+    expect(built.warnings.map((warning) => warning.reason).join("\n")).toMatch(/bash/i);
+  });
+
+  it("attaches the source path to every warning, so a warning is actionable", async () => {
+    const built = await plan({ root: REPO });
+
+    for (const warning of built.warnings) {
+      expect(warning.source, warning.reason).toMatch(/^content\/agents\/.+\.md$/);
+    }
+  });
+});

+ 26 - 13
packages/compatibility-layer/tests/unit/core/RegistryEmitter.test.ts

@@ -53,17 +53,26 @@ const NONDETERMINISM = [
 ];
 
 /**
- * Agents that ship on disk, are referenced, and are ABSENT from the committed registry.
+ * Agents that ship on disk, are referenced, and were ABSENT from the registry until subtask 10
+ * generated it.
  *
  * Verified on disk 2026-07-15. `batch-executor` is the load-bearing one: both `openagent` and
- * `opencoder` declare `subagent:batch-executor`, so the registry has advertised a dependency
- * on a component it does not contain. The other six are in neither the registry nor
+ * `opencoder` declare `subagent:batch-executor`, so the registry advertised a dependency on a
+ * component it did not contain. The other six were in neither the registry nor
  * `.opencode/config/agent-metadata.json` — they were added to `.opencode/agent/` and nothing
- * ever noticed. `auto-detect-components.sh --dry-run` reports all 7 as "New Components" today.
+ * ever noticed. `auto-detect-components.sh --dry-run` reported all 7 as "New Components".
  *
- * The emitter fixes all of these BY CONSTRUCTION, which is the whole argument for generating.
+ * ─── Updated by subtask 10, deliberately ────────────────────────────────────────────────
+ *
+ * This list used to assert the committed registry OMITS these 7 — it pinned the bug. Subtask
+ * 10 ran `oac build`, which emits `registry.json` in place, so all 7 are now registered and
+ * that assertion inverted. Per this file's own contract ("when a subtask repairs one, the test
+ * turns red and forces a deliberate edit here"), the edit is made rather than the test
+ * relaxed: the list now guards that generation KEEPS registering them. It stops being a
+ * snapshot of a loss and becomes a regression guard, exactly as
+ * {@link REGISTRY_ONLY_DEPENDENCIES} did when subtask 09b backfilled the 13 edges.
  */
-const MISSING_FROM_REGISTRY = [
+const RECOVERED_BY_GENERATION = [
   "adr-manager",
   "architecture-analyzer",
   "batch-executor",
@@ -392,19 +401,23 @@ describe("generation from the canonical tree", () => {
 // ============================================================================
 
 describe("registry defects", () => {
-  it.each(MISSING_FROM_REGISTRY)("registers %s, which the committed registry omits", async (id) => {
+  it.each(RECOVERED_BY_GENERATION)("keeps %s registered, which only generation ever added", async (id) => {
+    // Both sides are asserted on purpose. The generated document proves the emitter still
+    // derives the entry from the canonical file; the committed one proves the emit actually
+    // reached disk. Checking only the emitter would let `registry.json` silently regress to a
+    // hand-edited copy that drops these 7 again — the exact failure this refactor ends.
     const document = await new RegistryEmitter(repoPath()).emit();
 
-    expect(byId(COMMITTED, "subagents").has(id), `${id} is unexpectedly already registered`).toBe(
-      false
-    );
     expect(byId(document, "subagents").get(id)?.path).toContain(id);
+    expect(byId(COMMITTED, "subagents").has(id), `${id} is missing from the committed registry`).toBe(
+      true
+    );
   });
 
   it("makes subagent:batch-executor resolvable for openagent and opencoder", async () => {
-    // Both declare `subagent:batch-executor`; the committed registry contains no such
-    // component, so install.sh resolves the dependency to nothing and silently installs an
-    // orchestrator whose parallel executor is missing.
+    // Both declare `subagent:batch-executor`. The hand-maintained registry contained no such
+    // component, so install.sh resolved the dependency to nothing and silently installed an
+    // orchestrator whose parallel executor was missing. Generation is what closed that.
     const document = await new RegistryEmitter(repoPath()).emit();
     const agents = byId(document, "agents");
 

+ 387 - 212
registry.json

@@ -12,53 +12,34 @@
   "components": {
     "agents": [
       {
-        "id": "system-builder",
-        "name": "OpenSystemBuilder",
+        "id": "copywriter",
+        "name": "Copywriter",
         "type": "agent",
-        "path": ".opencode/agent/meta/system-builder.md",
-        "description": "Generates complete context-aware AI systems from user requirements",
+        "path": ".opencode/agent/content/copywriter.md",
+        "version": "1.0.0",
+        "description": "Expert in persuasive writing, marketing copy, and brand messaging",
         "tags": [
-          "system-generation",
-          "architecture",
-          "orchestration",
-          "meta"
+          "content",
+          "marketing",
+          "writing"
         ],
         "dependencies": [
-          "subagent:domain-analyzer",
-          "subagent:agent-generator",
-          "subagent:context-organizer",
-          "subagent:workflow-designer",
-          "subagent:command-creator"
+          "context:standards-docs"
         ],
-        "category": "meta"
-      },
-      {
-        "id": "technical-writer",
-        "name": "OpenTechnicalWriter",
-        "type": "agent",
-        "path": ".opencode/agent/content/technical-writer.md",
-        "description": "Expert in documentation, API docs, and technical communication",
-        "tags": [],
-        "dependencies": [],
-        "category": "content"
-      },
-      {
-        "id": "copywriter",
-        "name": "OpenCopywriter",
-        "type": "agent",
-        "path": ".opencode/agent/content/copywriter.md",
-        "description": "Expert in persuasive writing, marketing copy, and brand messaging",
-        "tags": [],
-        "dependencies": [],
         "category": "content"
       },
       {
         "id": "data-analyst",
-        "name": "OpenDataAnalyst",
+        "name": "Data Analyst",
         "type": "agent",
         "path": ".opencode/agent/data/data-analyst.md",
+        "version": "1.0.0",
         "description": "Expert in data analysis, visualization, and statistical insights",
-        "tags": [],
+        "tags": [
+          "data",
+          "analysis",
+          "visualization"
+        ],
         "dependencies": [],
         "category": "data"
       },
@@ -75,20 +56,38 @@
         "category": "standard"
       },
       {
-        "id": "repo-manager",
-        "name": "OpenRepoManager",
+        "id": "openagent",
+        "name": "OpenAgent",
         "type": "agent",
-        "path": ".opencode/agent/meta/repo-manager.md",
-        "description": "Meta agent for managing OpenAgents Control repository development with context-aware planning, task breakdown, and automatic documentation",
-        "tags": [],
-        "dependencies": [],
-        "category": "meta"
+        "path": ".opencode/agent/core/openagent.md",
+        "version": "1.0.0",
+        "description": "Universal agent for answering queries, executing tasks, and coordinating workflows across any domain",
+        "tags": [
+          "universal",
+          "coordination",
+          "primary"
+        ],
+        "dependencies": [
+          "subagent:task-manager",
+          "subagent:batch-executor",
+          "subagent:documentation",
+          "subagent:contextscout",
+          "subagent:externalscout",
+          "context:standards-code",
+          "context:standards-docs",
+          "context:standards-tests",
+          "context:review-ref",
+          "context:delegation-ref",
+          "context:external-libraries-workflow"
+        ],
+        "category": "core"
       },
       {
         "id": "opencoder",
         "name": "OpenCoder",
         "type": "agent",
         "path": ".opencode/agent/core/opencoder.md",
+        "version": "1.0.0",
         "description": "Orchestration agent for complex coding, architecture, and multi-file refactoring",
         "tags": [
           "development",
@@ -97,6 +96,8 @@
         ],
         "dependencies": [
           "subagent:documentation",
+          "subagent:task-manager",
+          "subagent:batch-executor",
           "subagent:coder-agent",
           "subagent:tester",
           "subagent:reviewer",
@@ -111,238 +112,235 @@
         "category": "core"
       },
       {
-        "id": "openagent",
-        "name": "OpenAgent",
+        "id": "repo-manager",
+        "name": "Repo Manager",
         "type": "agent",
-        "path": ".opencode/agent/core/openagent.md",
-        "description": "Universal agent for answering queries, executing tasks, and coordinating workflows across any domain",
+        "path": ".opencode/agent/meta/repo-manager.md",
+        "version": "1.0.0",
+        "description": "Meta agent for managing OpenAgents Control repository development with lazy context loading, smart delegation, and automatic documentation",
         "tags": [
-          "universal",
-          "coordination",
-          "primary"
+          "repository",
+          "management",
+          "orchestration"
         ],
         "dependencies": [
           "subagent:task-manager",
-          "subagent:documentation",
           "subagent:contextscout",
-          "subagent:externalscout",
-          "context:standards-code",
-          "context:standards-docs",
-          "context:standards-tests",
-          "context:review-ref",
-          "context:delegation-ref",
-          "context:external-libraries-workflow"
+          "subagent:documentation",
+          "subagent:coder-agent",
+          "subagent:tester",
+          "subagent:reviewer",
+          "subagent:build-agent"
         ],
-        "category": "core"
-      }
-    ],
-    "subagents": [
+        "category": "meta"
+      },
       {
-        "id": "task-manager",
-        "name": "TaskManager",
-        "type": "subagent",
-        "path": ".opencode/agent/subagents/core/task-manager.md",
-        "description": "Breaks down complex features into small, verifiable subtasks",
+        "id": "system-builder",
+        "name": "System Builder",
+        "type": "agent",
+        "path": ".opencode/agent/meta/system-builder.md",
+        "version": "1.0.0",
+        "description": "Main orchestrator for building complete context-aware AI systems from user requirements",
         "tags": [
-          "planning",
-          "organization",
-          "task-management"
+          "system-generation",
+          "architecture",
+          "scaffolding"
         ],
-        "dependencies": [],
-        "category": "essential"
+        "dependencies": [
+          "subagent:agent-generator",
+          "subagent:command-creator",
+          "subagent:domain-analyzer",
+          "subagent:context-organizer",
+          "subagent:workflow-designer"
+        ],
+        "category": "meta"
       },
       {
-        "id": "image-specialist",
-        "name": "Image Specialist",
-        "type": "subagent",
-        "path": ".opencode/agent/subagents/utils/image-specialist.md",
-        "description": "Generates and edits images using Gemini AI",
+        "id": "technical-writer",
+        "name": "Technical Writer",
+        "type": "agent",
+        "path": ".opencode/agent/content/technical-writer.md",
+        "version": "1.0.0",
+        "description": "Expert in documentation, API docs, and technical communication",
         "tags": [
-          "images",
-          "ai",
-          "generation"
+          "documentation",
+          "technical",
+          "writing"
         ],
         "dependencies": [
-          "tool:gemini"
+          "context:standards-docs"
         ],
-        "category": "utils"
-      },
+        "category": "content"
+      }
+    ],
+    "subagents": [
       {
-        "id": "reviewer",
-        "name": "CodeReviewer",
+        "id": "adr-manager",
+        "name": "ADRManager",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/code/reviewer.md",
-        "description": "Performs code review with security and quality checks",
+        "path": ".opencode/agent/subagents/planning/adr-manager.md",
+        "version": "1.0.0",
+        "description": "Architecture Decision Record specialist capturing decisions, context, alternatives, and consequences in lightweight ADR format",
         "tags": [
-          "review",
-          "security",
-          "quality"
+          "adr",
+          "architecture",
+          "decisions"
         ],
         "dependencies": [],
-        "category": "standard"
+        "category": "subagents/planning"
       },
       {
-        "id": "tester",
-        "name": "TestEngineer",
+        "id": "agent-generator",
+        "name": "AgentGenerator",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/code/test-engineer.md",
-        "description": "Writes unit and integration tests",
+        "path": ".opencode/agent/subagents/system-builder/agent-generator.md",
+        "version": "1.0.0",
+        "description": "Generates XML-optimized agent files (orchestrator and subagents) following research-backed patterns",
         "tags": [
-          "testing",
-          "tdd",
-          "quality"
+          "generation",
+          "agents",
+          "scaffolding"
         ],
         "dependencies": [],
-        "category": "standard"
+        "category": "subagents/system-builder"
       },
       {
-        "id": "documentation",
-        "name": "DocWriter",
+        "id": "architecture-analyzer",
+        "name": "ArchitectureAnalyzer",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/core/documentation.md",
-        "description": "Creates and updates documentation",
+        "path": ".opencode/agent/subagents/planning/architecture-analyzer.md",
+        "version": "1.0.0",
+        "description": "DDD-driven architecture analyzer identifying bounded contexts, module boundaries, and domain relationships for multi-stage orchestration",
         "tags": [
-          "docs",
-          "documentation",
-          "writing"
+          "architecture",
+          "analysis",
+          "ddd"
         ],
         "dependencies": [],
-        "category": "essential"
+        "category": "subagents/planning"
       },
       {
-        "id": "coder-agent",
-        "name": "CoderAgent",
+        "id": "batch-executor",
+        "name": "BatchExecutor",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/code/coder-agent.md",
-        "description": "Executes coding subtasks in sequence",
+        "path": ".opencode/agent/subagents/core/batch-executor.md",
+        "version": "1.0.0",
+        "description": "Execute multiple tasks in parallel batches, managing simultaneous CoderAgent delegations and tracking batch completion",
         "tags": [
-          "coding",
-          "implementation",
-          "execution"
+          "parallel-execution",
+          "batch-management",
+          "coordination"
         ],
-        "dependencies": [],
-        "category": "standard"
+        "dependencies": [
+          "subagent:coder-agent",
+          "subagent:task-manager"
+        ],
+        "category": "subagents/core"
       },
       {
         "id": "build-agent",
         "name": "BuildAgent",
         "type": "subagent",
         "path": ".opencode/agent/subagents/code/build-agent.md",
-        "description": "Type checks and validates builds",
+        "version": "1.0.0",
+        "description": "Type check and build validation agent",
         "tags": [
           "build",
           "validation",
           "type-checking"
         ],
         "dependencies": [],
-        "category": "standard"
+        "category": "subagents/code"
       },
       {
-        "id": "frontend-specialist",
-        "name": "OpenFrontendSpecialist",
-        "type": "subagent",
-        "path": ".opencode/agent/subagents/development/frontend-specialist.md",
-        "description": "Expert in React, Vue, and modern CSS architecture",
-        "tags": [],
-        "dependencies": [],
-        "category": "development"
-      },
-      {
-        "id": "devops-specialist",
-        "name": "OpenDevopsSpecialist",
+        "id": "coder-agent",
+        "name": "CoderAgent",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/development/devops-specialist.md",
-        "description": "Expert in CI/CD, infrastructure as code, and deployment automation",
-        "tags": [],
-        "dependencies": [],
-        "category": "development"
+        "path": ".opencode/agent/subagents/code/coder-agent.md",
+        "version": "1.0.0",
+        "description": "Executes coding subtasks in sequence, ensuring completion as specified",
+        "tags": [
+          "coding",
+          "implementation"
+        ],
+        "dependencies": [
+          "context:standards-code"
+        ],
+        "category": "subagents/code"
       },
       {
-        "id": "domain-analyzer",
-        "name": "DomainAnalyzer",
+        "id": "command-creator",
+        "name": "CommandCreator",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/system-builder/domain-analyzer.md",
-        "description": "Analyzes user domains and recommends agent architectures",
+        "path": ".opencode/agent/subagents/system-builder/command-creator.md",
+        "version": "1.0.0",
+        "description": "Creates custom slash commands that route to appropriate agents with clear syntax and examples",
         "tags": [
-          "analysis",
-          "domain-modeling",
-          "architecture"
+          "commands",
+          "generation",
+          "scaffolding"
         ],
         "dependencies": [],
-        "category": "meta"
+        "category": "subagents/system-builder"
       },
       {
-        "id": "agent-generator",
-        "name": "AgentGenerator",
+        "id": "context-manager",
+        "name": "ContextManager",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/system-builder/agent-generator.md",
-        "description": "Generates XML-optimized agent files following research-backed patterns",
+        "path": ".opencode/agent/subagents/core/context-manager.md",
+        "version": "1.0.0",
+        "description": "Context organization and lifecycle management specialist - discovers, catalogs, validates, and maintains project context structure with dependency tracking",
         "tags": [
-          "generation",
-          "xml-optimization",
-          "agents"
+          "context",
+          "management",
+          "organization"
         ],
         "dependencies": [],
-        "category": "meta"
+        "category": "subagents/core"
       },
       {
         "id": "context-organizer",
         "name": "ContextOrganizer",
         "type": "subagent",
         "path": ".opencode/agent/subagents/system-builder/context-organizer.md",
-        "description": "Organizes and generates modular context files",
+        "version": "1.0.0",
+        "description": "Organizes and generates context files (domain, processes, standards, templates) for optimal knowledge management",
         "tags": [
           "context",
           "organization",
-          "knowledge-management"
+          "structure"
         ],
         "dependencies": [
           "context:core/context-system/*"
         ],
-        "category": "meta"
+        "category": "subagents/system-builder"
       },
       {
-        "id": "workflow-designer",
-        "name": "WorkflowDesigner",
-        "type": "subagent",
-        "path": ".opencode/agent/subagents/system-builder/workflow-designer.md",
-        "description": "Designs complete workflow definitions with context dependencies",
-        "tags": [
-          "workflows",
-          "design",
-          "orchestration"
-        ],
-        "dependencies": [],
-        "category": "meta"
-      },
-      {
-        "id": "command-creator",
-        "name": "CommandCreator",
+        "id": "context-retriever",
+        "name": "Context Retriever",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/system-builder/command-creator.md",
-        "description": "Creates custom slash commands with clear syntax and examples",
+        "path": ".opencode/agent/subagents/core/context-retriever.md",
+        "version": "1.0.0",
+        "description": "Generic context search and retrieval specialist for finding relevant context files, standards, and guides in any repository",
         "tags": [
-          "commands",
-          "generation",
-          "cli"
+          "context",
+          "retrieval",
+          "search"
         ],
         "dependencies": [],
-        "category": "meta"
+        "category": "subagents/core"
       },
       {
         "id": "contextscout",
         "name": "ContextScout",
         "type": "subagent",
         "path": ".opencode/agent/subagents/core/contextscout.md",
-        "version": "5.1.0",
-        "description": "Get accurate context FIRST before diving deep\u2014save time, energy, and avoid rework. ContextScout intelligently discovers and retrieves the exact context files you need with precise file paths and line ranges, so you start with the right information instead of guessing. Optimized for multi-model compatibility (Claude, Gemini, GPT-4) with 20% token reduction.",
+        "version": "1.0.0",
+        "description": "Discovers and recommends context files from .opencode/context/ ranked by priority. Suggests ExternalScout when a framework/library is mentioned but not found internally.",
         "tags": [
           "context",
-          "search",
           "discovery",
-          "navigation",
-          "subagent",
-          "optimized"
+          "search"
         ],
         "dependencies": [
           "command:check-context-deps",
@@ -355,69 +353,246 @@
           "context:root-navigation",
           "context:context-paths-config"
         ],
-        "category": "core"
+        "category": "subagents/core"
+      },
+      {
+        "id": "contract-manager",
+        "name": "ContractManager",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/planning/contract-manager.md",
+        "version": "1.0.0",
+        "description": "API contract management specialist enabling parallel development through contract-first design with OpenAPI/Swagger support",
+        "tags": [
+          "contracts",
+          "api",
+          "openapi"
+        ],
+        "dependencies": [],
+        "category": "subagents/planning"
+      },
+      {
+        "id": "devops-specialist",
+        "name": "OpenDevopsSpecialist",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/development/devops-specialist.md",
+        "version": "1.0.0",
+        "description": "DevOps specialist subagent - CI/CD, infrastructure as code, deployment automation",
+        "tags": [
+          "devops",
+          "ci-cd",
+          "infrastructure"
+        ],
+        "dependencies": [],
+        "category": "subagents/development"
+      },
+      {
+        "id": "documentation",
+        "name": "DocWriter",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/core/documentation.md",
+        "version": "1.0.0",
+        "description": "Documentation authoring agent",
+        "tags": [
+          "documentation",
+          "writing"
+        ],
+        "dependencies": [
+          "context:standards-docs"
+        ],
+        "category": "subagents/core"
+      },
+      {
+        "id": "domain-analyzer",
+        "name": "DomainAnalyzer",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/system-builder/domain-analyzer.md",
+        "version": "1.0.0",
+        "description": "Analyzes user domains to identify core concepts, recommended agents, and context structure",
+        "tags": [
+          "analysis",
+          "domain",
+          "architecture"
+        ],
+        "dependencies": [],
+        "category": "subagents/system-builder"
       },
       {
         "id": "externalscout",
         "name": "ExternalScout",
         "type": "subagent",
         "path": ".opencode/agent/subagents/core/externalscout.md",
-        "version": "2.0.0",
-        "description": "Fetches live, version-specific documentation for external libraries and frameworks using Context7 and other sources. Optimized with 42% token reduction and enhanced references to prompt engineering and context system docs.",
+        "version": "1.0.0",
+        "description": "Fetches live, version-specific documentation for external libraries and frameworks using Context7 and other sources. Filters, sorts, and returns relevant documentation.",
         "tags": [
-          "external-docs",
-          "libraries",
-          "frameworks",
-          "context7",
-          "subagent",
-          "optimized"
+          "external",
+          "documentation",
+          "search"
         ],
         "dependencies": [
           "skill:context7",
           "context:context-system"
         ],
-        "category": "core"
+        "category": "subagents/core"
       },
       {
-        "id": "context-retriever",
-        "name": "Context Retriever",
+        "id": "frontend-specialist",
+        "name": "OpenFrontendSpecialist",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/core/context-retriever.md",
-        "description": "Generic context search and retrieval specialist for finding relevant context files, standards, and guides in any repository",
+        "path": ".opencode/agent/subagents/development/frontend-specialist.md",
+        "version": "1.0.0",
+        "description": "Frontend UI design specialist - subagent for design systems, themes, animations",
         "tags": [
-          "context",
-          "search",
-          "retrieval",
-          "subagent"
+          "frontend",
+          "ui",
+          "design"
+        ],
+        "dependencies": [
+          "context:standards-code"
+        ],
+        "category": "subagents/development"
+      },
+      {
+        "id": "image-specialist",
+        "name": "Image Specialist",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/utils/image-specialist.md",
+        "version": "1.0.0",
+        "description": "Specialized agent for image editing and analysis using Gemini AI tools",
+        "tags": [
+          "images",
+          "editing",
+          "generation"
+        ],
+        "dependencies": [
+          "tool:gemini"
+        ],
+        "category": "subagents/utils"
+      },
+      {
+        "id": "prioritization-engine",
+        "name": "PrioritizationEngine",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/planning/prioritization-engine.md",
+        "version": "1.0.0",
+        "description": "Scores and prioritizes backlog items using RICE/WSJF frameworks with MVP/post-MVP release slicing",
+        "tags": [
+          "prioritization",
+          "backlog",
+          "planning"
         ],
         "dependencies": [],
-        "category": "core"
+        "category": "subagents/planning"
+      },
+      {
+        "id": "reviewer",
+        "name": "CodeReviewer",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/code/reviewer.md",
+        "version": "1.0.0",
+        "description": "Code review, security, and quality assurance agent",
+        "tags": [
+          "review",
+          "security",
+          "quality"
+        ],
+        "dependencies": [
+          "context:standards-code",
+          "context:review-ref"
+        ],
+        "category": "subagents/code"
       },
       {
         "id": "simple-responder",
         "name": "Simple Responder",
         "type": "subagent",
         "path": ".opencode/agent/subagents/test/simple-responder.md",
+        "version": "1.0.0",
         "description": "Test agent that responds with 'AWESOME TESTING' - for eval framework testing",
-        "tags": [],
+        "tags": [
+          "testing",
+          "evaluation"
+        ],
         "dependencies": [],
-        "category": "test"
+        "category": "subagents/test"
       },
       {
-        "id": "context-manager",
-        "name": "ContextManager",
+        "id": "stage-orchestrator",
+        "name": "StageOrchestrator",
         "type": "subagent",
-        "path": ".opencode/agent/subagents/core/context-manager.md",
-        "description": "Context organization and lifecycle management specialist - discovers, catalogs, validates, and maintains project context structure with dependency tracking",
+        "path": ".opencode/agent/subagents/core/stage-orchestrator.md",
+        "version": "1.0.0",
+        "description": "Multi-stage workflow orchestrator managing stage transitions, gating rules, validation, and rollback for complex feature development",
         "tags": [
-          "context",
-          "organization",
-          "management",
-          "lifecycle",
-          "catalog"
+          "orchestration",
+          "workflow",
+          "stages"
         ],
         "dependencies": [],
-        "category": "core"
+        "category": "subagents/core"
+      },
+      {
+        "id": "story-mapper",
+        "name": "StoryMapper",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/planning/story-mapper.md",
+        "version": "1.0.0",
+        "description": "User journey mapping specialist transforming user needs into epics, stories, and vertical slices with bounded context alignment",
+        "tags": [
+          "stories",
+          "user-journey",
+          "planning"
+        ],
+        "dependencies": [],
+        "category": "subagents/planning"
+      },
+      {
+        "id": "task-manager",
+        "name": "TaskManager",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/core/task-manager.md",
+        "version": "2.0.0",
+        "description": "JSON-driven task breakdown specialist transforming complex features into atomic, verifiable subtasks with dependency tracking and CLI integration",
+        "tags": [
+          "task-breakdown",
+          "planning",
+          "coordination"
+        ],
+        "dependencies": [
+          "context:task-delegation-basics"
+        ],
+        "category": "subagents/core"
+      },
+      {
+        "id": "tester",
+        "name": "TestEngineer",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/code/test-engineer.md",
+        "version": "1.0.0",
+        "description": "Test authoring and TDD agent",
+        "tags": [
+          "testing",
+          "tdd",
+          "quality"
+        ],
+        "dependencies": [
+          "context:standards-tests"
+        ],
+        "category": "subagents/code"
+      },
+      {
+        "id": "workflow-designer",
+        "name": "WorkflowDesigner",
+        "type": "subagent",
+        "path": ".opencode/agent/subagents/system-builder/workflow-designer.md",
+        "version": "1.0.0",
+        "description": "Designs complete workflow definitions with context dependencies and success criteria",
+        "tags": [
+          "workflow",
+          "design",
+          "architecture"
+        ],
+        "dependencies": [],
+        "category": "subagents/system-builder"
       }
     ],
     "commands": [