| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541 |
- /**
- * 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 {
- MANIFEST_FILE,
- readManifest,
- serializeManifest,
- type BuildManifest,
- type ManifestEntry,
- } from "./BuildManifest.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 }>;
- }
- // The ledger lives in `./BuildManifest.js` because `RegistryEmitter` needs it too and this
- // module already imports `RegistryEmitter`. Re-exported here so it stays part of the build
- // pipeline's public surface — `readManifest`/`serializeManifest`/`BuildManifest` have always
- // been importable from this module and callers should not have to care that it moved.
- export { readManifest, serializeManifest } from "./BuildManifest.js";
- export type { BuildManifest, ManifestEntry } from "./BuildManifest.js";
- // ============================================================================
- // PATHS AND CONSTANTS
- // ============================================================================
- const DEFAULTS = {
- contentRoot: "content/agents",
- } 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
- // ============================================================================
- /** 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, MANIFEST_FILE);
- 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));
- }
|