BuildPipeline.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. /**
  2. * BuildPipeline — the whole of `oac build`, as a pure function of the canonical tree.
  3. *
  4. * ─── What it does ───────────────────────────────────────────────────────────────────────
  5. *
  6. * Load `content/agents/**`, ask each agent which targets it declares, run the matching
  7. * adapter, and collect the result as an ordered set of (path, bytes) pairs. Emitting
  8. * `registry.json` is folded in as a target of its own, because it is generated from exactly
  9. * the same input and must be gated by exactly the same determinism rules.
  10. *
  11. * ─── Why planning and writing are separate ──────────────────────────────────────────────
  12. *
  13. * {@link plan} computes what the tree SHOULD contain and touches nothing. {@link write} takes
  14. * a plan and reconciles the disk to it. Splitting them is what makes `--check` and `--dry-run`
  15. * honest rather than best-effort: they run the identical code path and simply stop before the
  16. * write. A `--check` that re-implements the build is a `--check` that eventually disagrees
  17. * with it, and CI trusts the wrong one.
  18. *
  19. * ─── Determinism ────────────────────────────────────────────────────────────────────────
  20. *
  21. * `oac build && git diff --exit-code` is the gate the refactor rests on (07 Stage 3), so any
  22. * per-run variation turns it into a coin flip. Everything ordered here is ordered by CONTENT:
  23. * agents arrive from {@link CanonicalAgentLoader} sorted by `oac.id`, targets are iterated in
  24. * a declared literal order rather than the authored `targets:` order, and the plan is sorted
  25. * by path before it is returned. No clock, no host path, no `readdir` order, no map-insertion
  26. * order reaches the output.
  27. *
  28. * ─── Orphan removal, and why it is manifest-gated ───────────────────────────────────────
  29. *
  30. * Deleting a source file must delete its generated output, or the subtask-11 CI gate is
  31. * defeated silently: the stale file just sits there and `git diff` stays clean. So the build
  32. * prunes. But "prune anything under `.opencode/agent/` without a `content/` source" would
  33. * delete `.opencode/agent/eval-runner.md`, a real, shipped, hand-authored agent that has
  34. * deliberately not been canonicalised yet. A build that eats files it did not write is worse
  35. * than no pruning at all.
  36. *
  37. * The rule is therefore inverted: the build removes a file only if IT PREVIOUSLY WROTE THAT
  38. * FILE. {@link BuildManifest} is the ledger — every write records its path and the sha256 of
  39. * the bytes written. Pruning considers only paths the previous manifest claims, and never the
  40. * filesystem. A file the build has never generated is not in the ledger, cannot become a
  41. * candidate, and is invisible to pruning no matter where it sits. See {@link prunable} for
  42. * the four conditions, each of which must hold.
  43. */
  44. import { createHash } from "node:crypto";
  45. import {
  46. existsSync,
  47. mkdirSync,
  48. readFileSync,
  49. readdirSync,
  50. rmSync,
  51. rmdirSync,
  52. writeFileSync,
  53. } from "node:fs";
  54. import { dirname, join, relative, resolve, sep } from "node:path";
  55. import { CanonicalAgentLoader, type CanonicalAgentFile } from "./AgentLoader.js";
  56. import {
  57. MANIFEST_FILE,
  58. readManifest,
  59. serializeManifest,
  60. type BuildManifest,
  61. type ManifestEntry,
  62. } from "./BuildManifest.js";
  63. import { RegistryEmitter } from "./RegistryEmitter.js";
  64. import { ClaudeAdapter } from "../adapters/ClaudeAdapter.js";
  65. import { OpenCodeAdapter } from "../adapters/OpenCodeAdapter.js";
  66. // ============================================================================
  67. // TYPES
  68. // ============================================================================
  69. /** The targets `oac build` wires up today. A subset of `BuildTargetSchema`'s vocabulary. */
  70. export const BUILD_TARGETS = ["opencode", "claude-code"] as const;
  71. export type BuildTarget = (typeof BUILD_TARGETS)[number];
  72. /** One file the build produces. */
  73. export interface BuildFile {
  74. /** Repo-relative POSIX path the file lands at when emitted in place. */
  75. path: string;
  76. /** The exact bytes to write. */
  77. content: string;
  78. /** The target that produced it; `"registry"` for `registry.json`. */
  79. target: BuildTarget | "registry";
  80. /** `oac.id` of the source agent, or `undefined` for whole-tree artefacts. */
  81. agentId?: string;
  82. /** Semantics the target could not carry. Never fatal on their own. */
  83. warnings: BuildWarning[];
  84. }
  85. /** A warning, always carrying the file it came from so it is actionable. */
  86. export interface BuildWarning {
  87. /** Repo-relative path of the SOURCE that caused it, not the emitted file. */
  88. source: string;
  89. reason: string;
  90. }
  91. /** Everything a build produced, before anything is written. */
  92. export interface BuildPlan {
  93. /** Emitted files, sorted by path. */
  94. files: BuildFile[];
  95. /** Every warning across every file, in file order. */
  96. warnings: BuildWarning[];
  97. /** Canonical agents loaded, sorted by `oac.id`. */
  98. agents: CanonicalAgentFile[];
  99. }
  100. export interface BuildOptions {
  101. /** Repository root. Everything resolves against it — no hardcoded paths. */
  102. root: string;
  103. /** Restrict the build to these targets. Defaults to {@link BUILD_TARGETS} plus the registry. */
  104. targets?: readonly BuildTarget[];
  105. /** Skip `registry.json`. Defaults to false. */
  106. skipRegistry?: boolean;
  107. /**
  108. * Accepted and ignored: {@link plan} never writes, so a dry run IS a plan. Present because
  109. * `dryRun: true` is how callers say what they mean, and because a flag that silently means
  110. * nothing is safer than one that silently means something else.
  111. */
  112. dryRun?: boolean;
  113. }
  114. /** Where a target's files are written, when not in place. */
  115. export type OutputRoots = Partial<Record<BuildFile["target"], string>>;
  116. export interface WriteOptions {
  117. root: string;
  118. /**
  119. * Per-target root override, repo-relative. A target listed here is REBASED under the given
  120. * directory instead of being written in place — the mechanism `plugins/claude-code/**` is
  121. * staged with, so a build can be compared against the shipped tree without touching it.
  122. */
  123. outputRoots?: OutputRoots;
  124. /**
  125. * Remove generated files whose source is gone. Only ever considers paths the previous
  126. * manifest claims — see {@link prunable}. Defaults to true.
  127. */
  128. prune?: boolean;
  129. }
  130. export interface WriteResult {
  131. /** Paths written whose bytes changed (or that did not exist). */
  132. changed: string[];
  133. /** Paths written whose bytes already matched. */
  134. unchanged: string[];
  135. /** Paths removed as orphans. */
  136. removed: string[];
  137. /** Orphan candidates left alone, with the reason. */
  138. kept: Array<{ path: string; reason: string }>;
  139. }
  140. // The ledger lives in `./BuildManifest.js` because `RegistryEmitter` needs it too and this
  141. // module already imports `RegistryEmitter`. Re-exported here so it stays part of the build
  142. // pipeline's public surface — `readManifest`/`serializeManifest`/`BuildManifest` have always
  143. // been importable from this module and callers should not have to care that it moved.
  144. export { readManifest, serializeManifest } from "./BuildManifest.js";
  145. export type { BuildManifest, ManifestEntry } from "./BuildManifest.js";
  146. // ============================================================================
  147. // PATHS AND CONSTANTS
  148. // ============================================================================
  149. const DEFAULTS = {
  150. contentRoot: "content/agents",
  151. } as const;
  152. /**
  153. * The roots each target is permitted to write, and therefore the only roots pruning may ever
  154. * touch. Defence in depth: the manifest is the gate, but a manifest that has been corrupted,
  155. * hand-edited or carried over from a different layout must not be able to talk the build into
  156. * deleting `src/`. A prune candidate outside its target's root is refused and reported.
  157. */
  158. const TARGET_ROOTS: Readonly<Record<BuildFile["target"], string>> = {
  159. opencode: ".opencode/agent",
  160. "claude-code": "plugins/claude-code/agents",
  161. registry: "registry.json",
  162. };
  163. /** Locale-independent ordering. `localeCompare` is locale-dependent — never use it here. */
  164. function compare(a: string, b: string): number {
  165. return a < b ? -1 : a > b ? 1 : 0;
  166. }
  167. /** POSIX-separated, so a plan built on Windows and one built on macOS agree. */
  168. function toPosix(path: string): string {
  169. return path.split(sep).join("/");
  170. }
  171. /** True when `path` is `root` itself or sits underneath it. Segment-aware, not `startsWith`. */
  172. function isUnder(path: string, root: string): boolean {
  173. return path === root || path.startsWith(`${root}/`);
  174. }
  175. function sha256(content: string): string {
  176. return createHash("sha256").update(content, "utf-8").digest("hex");
  177. }
  178. // ============================================================================
  179. // PLANNING
  180. // ============================================================================
  181. /**
  182. * Emit one agent for one target, or `null` when the agent does not declare that target.
  183. *
  184. * `oac.targets` is honoured here and nowhere else: an agent with `targets: ["opencode"]`
  185. * produces no Claude Code output because this function returns `null`, not because some later
  186. * filter drops it.
  187. */
  188. async function emitAgent(
  189. agent: CanonicalAgentFile,
  190. target: BuildTarget
  191. ): Promise<BuildFile | null> {
  192. if (!agent.oac.targets.includes(target)) return null;
  193. const source = readFileSync(agent.filePath, "utf-8");
  194. const sourcePath = `${DEFAULTS.contentRoot}/${agent.relativePath}`;
  195. if (target === "opencode") {
  196. const adapter = new OpenCodeAdapter();
  197. const { content, warnings } = await adapter.fromCanonical(source, { filePath: sourcePath });
  198. return {
  199. path: adapter.outputPath(agent.relativePath),
  200. content,
  201. target,
  202. agentId: agent.oac.id,
  203. warnings: warnings.map((reason) => ({ source: sourcePath, reason })),
  204. };
  205. }
  206. const adapter = new ClaudeAdapter();
  207. const { path, content, warnings } = await adapter.fromCanonical(source);
  208. return {
  209. path,
  210. content,
  211. target,
  212. agentId: agent.oac.id,
  213. warnings: warnings.map((reason) => ({ source: sourcePath, reason })),
  214. };
  215. }
  216. /**
  217. * Compute the full build. Reads the canonical tree; writes nothing, ever.
  218. *
  219. * A failure anywhere — a schema violation, an unrepresentable permission block an adapter
  220. * refuses to widen — rejects. Fail-closed is the whole point: a capability that cannot be
  221. * expressed on a target is an error, never a silent grant.
  222. */
  223. export async function plan(options: BuildOptions): Promise<BuildPlan> {
  224. const root = resolve(options.root);
  225. const targets = options.targets ?? BUILD_TARGETS;
  226. const agents = await new CanonicalAgentLoader(join(root, DEFAULTS.contentRoot))
  227. .loadFromDirectory();
  228. const files: BuildFile[] = [];
  229. // Agents outer, targets inner, both in a content-determined order. Iterating `oac.targets`
  230. // instead would make output order depend on the order an author happened to list them in.
  231. for (const agent of agents) {
  232. for (const target of targets) {
  233. const file = await emitAgent(agent, target);
  234. if (file !== null) files.push(file);
  235. }
  236. }
  237. if (options.skipRegistry !== true) {
  238. files.push({
  239. path: "registry.json",
  240. content: await new RegistryEmitter(root).emitJson(),
  241. target: "registry",
  242. warnings: [],
  243. });
  244. }
  245. files.sort((a, b) => compare(a.path, b.path));
  246. return { files, agents, warnings: files.flatMap((file) => file.warnings) };
  247. }
  248. /**
  249. * The build as a plain path -> bytes map.
  250. *
  251. * The entry point `tests/unit/build/determinism.test.ts` drives: two calls over one tree must
  252. * produce identical maps.
  253. */
  254. export async function build(options: BuildOptions): Promise<Map<string, string>> {
  255. const { files } = await plan(options);
  256. return new Map(files.map((file) => [file.path, file.content]));
  257. }
  258. /**
  259. * Emit one agent, named by `oac.id`, for one target.
  260. *
  261. * Identity is `oac.id` and never a filename: `content/agents/subagents/code/test-engineer.md`
  262. * declares `id: tester`, and `tester` is what `registry.json`, the profiles and the context
  263. * docs all reference. Resolving by path here would mint an id nothing refers to.
  264. *
  265. * @throws when no agent declares `id`, or when that agent does not declare `target`.
  266. */
  267. export async function buildAgent(id: string, target: BuildTarget): Promise<string> {
  268. return buildAgentIn(process.cwd(), id, target);
  269. }
  270. /** {@link buildAgent} against an explicit root — the testable form. */
  271. export async function buildAgentIn(
  272. root: string,
  273. id: string,
  274. target: BuildTarget
  275. ): Promise<string> {
  276. const agents = await new CanonicalAgentLoader(join(resolve(root), DEFAULTS.contentRoot))
  277. .loadFromDirectory();
  278. const agent = agents.find((candidate) => candidate.oac.id === id);
  279. if (agent === undefined) {
  280. throw new Error(
  281. `No canonical agent declares oac.id "${id}". Known ids: ` +
  282. `${agents.map((candidate) => candidate.oac.id).join(", ")}`
  283. );
  284. }
  285. const file = await emitAgent(agent, target);
  286. if (file === null) {
  287. throw new Error(
  288. `Agent "${id}" does not declare target "${target}" (declares: ` +
  289. `${agent.oac.targets.join(", ")}), so it emits nothing there.`
  290. );
  291. }
  292. return file.content;
  293. }
  294. // ============================================================================
  295. // MANIFEST
  296. // ============================================================================
  297. /** The manifest a plan implies, given where each target is actually written. */
  298. function manifestFor(files: readonly BuildFile[], outputRoots: OutputRoots): BuildManifest {
  299. const entries: BuildManifest["files"] = {};
  300. for (const file of files) {
  301. entries[rebase(file.path, file.target, outputRoots)] = {
  302. sha256: sha256(file.content),
  303. target: file.target,
  304. root: rebase(TARGET_ROOTS[file.target], file.target, outputRoots),
  305. };
  306. }
  307. return { version: 1, files: entries };
  308. }
  309. /** Where a file actually lands, honouring a staging override for its target. */
  310. function rebase(path: string, target: BuildFile["target"], outputRoots: OutputRoots): string {
  311. const override = outputRoots[target];
  312. return override === undefined ? path : toPosix(join(override, path));
  313. }
  314. // ============================================================================
  315. // PRUNING
  316. // ============================================================================
  317. /**
  318. * Decide whether an orphan candidate may be deleted.
  319. *
  320. * Called only for paths the PREVIOUS manifest claims — that gate happens in {@link write} and
  321. * is the load-bearing one. Everything here is a second line of defence, because the cost of a
  322. * wrong answer is an unrecoverable deletion of someone's work:
  323. *
  324. * 1. **The manifest claims it.** (Enforced by the caller.) The build wrote this exact path
  325. * on a previous run. `.opencode/agent/eval-runner.md` has no `content/` source, was never
  326. * emitted, is not in the ledger, and therefore never reaches this function at all.
  327. * 2. **The current build does not produce it.** Otherwise it is not an orphan, it is output.
  328. * 3. **It sits under an output root its own target could legitimately have written.** The
  329. * manifest records that root, but the record is VERIFIED rather than trusted: it must be
  330. * the target's canonical root, or that root rebased under a staging directory. A ledger
  331. * that has been corrupted, hand-edited, or carried over from another layout therefore
  332. * cannot talk the build into deleting `src/`.
  333. * 4. **Its bytes still match what the build wrote.** If a human edited a generated file, the
  334. * hash diverges and we refuse: their edit is misplaced, but it is theirs, and reporting it
  335. * is strictly better than destroying it.
  336. *
  337. * @returns `null` when the file may be removed, or the reason it is being kept.
  338. */
  339. function prunable(absolute: string, path: string, entry: ManifestEntry): string | null {
  340. const canonical = TARGET_ROOTS[entry.target as BuildFile["target"]];
  341. if (canonical === undefined) {
  342. return `manifest names an unknown target "${entry.target}"`;
  343. }
  344. // The recorded root is either the canonical one or the canonical one under a staging dir.
  345. // Anything else means the ledger is not describing a tree this build owns.
  346. if (entry.root !== canonical && !entry.root.endsWith(`/${canonical}`)) {
  347. return `manifest records root "${entry.root}", which is not the ${entry.target} output root`;
  348. }
  349. if (!isUnder(path, entry.root)) {
  350. return `manifest entry sits outside its recorded output root (${entry.root})`;
  351. }
  352. if (!existsSync(absolute)) {
  353. return "already gone";
  354. }
  355. if (sha256(readFileSync(absolute, "utf-8")) !== entry.sha256) {
  356. return "modified since it was generated — refusing to delete a file someone has edited";
  357. }
  358. return null;
  359. }
  360. /**
  361. * Remove a file and every directory it leaves empty, stopping at its own output root.
  362. *
  363. * The root itself is never removed: an empty `.opencode/agent/` is a legitimate state, and
  364. * deleting the directory a target is defined by would be a surprise well beyond "prune".
  365. *
  366. * `rmdirSync`, never `rmSync`: `rmSync` without `recursive` refuses a directory outright, and
  367. * WITH `recursive` it would delete a non-empty tree — the emptiness check above it is the only
  368. * thing standing between "tidy up" and "remove the subtree". `rmdirSync` fails closed on a
  369. * non-empty directory, so the guard is enforced by the syscall rather than only by us.
  370. *
  371. * Directory tidying is best-effort: the file removal has already succeeded, which is the part
  372. * that matters. A concurrent write that refills the directory must not turn a correct build
  373. * into a failed one.
  374. */
  375. function removeAndPruneDirs(root: string, path: string, outputRoot: string): void {
  376. rmSync(join(root, path));
  377. const stopAt = join(root, outputRoot);
  378. let dir = dirname(join(root, path));
  379. while (dir !== stopAt && isUnder(toPosix(dir), toPosix(stopAt))) {
  380. try {
  381. if (readdirSync(dir).length > 0) return;
  382. rmdirSync(dir);
  383. } catch {
  384. return;
  385. }
  386. dir = dirname(dir);
  387. }
  388. }
  389. // ============================================================================
  390. // WRITING
  391. // ============================================================================
  392. /**
  393. * Reconcile the disk to a plan: write every file, prune the orphans, record the ledger.
  394. *
  395. * Writes are content-conditional — a file whose bytes already match is not rewritten, so a
  396. * no-op build does not churn mtimes and `--check` has something meaningful to report.
  397. */
  398. export function write(plan: BuildPlan, options: WriteOptions): WriteResult {
  399. const root = resolve(options.root);
  400. const outputRoots = options.outputRoots ?? {};
  401. const result: WriteResult = { changed: [], unchanged: [], removed: [], kept: [] };
  402. for (const file of plan.files) {
  403. const path = rebase(file.path, file.target, outputRoots);
  404. const absolute = join(root, path);
  405. const exists = existsSync(absolute);
  406. if (exists && readFileSync(absolute, "utf-8") === file.content) {
  407. result.unchanged.push(path);
  408. continue;
  409. }
  410. mkdirSync(dirname(absolute), { recursive: true });
  411. writeFileSync(absolute, file.content, "utf-8");
  412. result.changed.push(path);
  413. }
  414. const next = manifestFor(plan.files, outputRoots);
  415. if (options.prune !== false) {
  416. const previous = readManifest(root);
  417. // THE gate: candidates come from the ledger, never from a directory scan. A file this
  418. // build has not written and no previous build wrote is not enumerable here.
  419. for (const path of Object.keys(previous.files).sort(compare)) {
  420. if (path in next.files) continue;
  421. const entry = previous.files[path]!;
  422. const reason = prunable(join(root, path), path, entry);
  423. if (reason === null) {
  424. removeAndPruneDirs(root, path, entry.root);
  425. result.removed.push(path);
  426. } else if (reason !== "already gone") {
  427. result.kept.push({ path, reason });
  428. }
  429. }
  430. }
  431. const manifestPath = join(root, MANIFEST_FILE);
  432. mkdirSync(dirname(manifestPath), { recursive: true });
  433. writeFileSync(manifestPath, serializeManifest(next), "utf-8");
  434. result.changed.sort(compare);
  435. result.unchanged.sort(compare);
  436. return result;
  437. }
  438. // ============================================================================
  439. // CHECKING
  440. // ============================================================================
  441. /** One file whose on-disk bytes disagree with the plan. */
  442. export interface Drift {
  443. path: string;
  444. status: "missing" | "modified" | "orphan";
  445. }
  446. /**
  447. * Compare a plan against the disk without touching it — the engine behind `--check`.
  448. *
  449. * Orphans are reported from the manifest for the same reason pruning takes them from there:
  450. * a directory scan would report `eval-runner.md` as drift on every run.
  451. */
  452. export function check(plan: BuildPlan, options: WriteOptions): Drift[] {
  453. const root = resolve(options.root);
  454. const outputRoots = options.outputRoots ?? {};
  455. const drift: Drift[] = [];
  456. const next = manifestFor(plan.files, outputRoots);
  457. for (const file of plan.files) {
  458. const path = rebase(file.path, file.target, outputRoots);
  459. const absolute = join(root, path);
  460. if (!existsSync(absolute)) drift.push({ path, status: "missing" });
  461. else if (readFileSync(absolute, "utf-8") !== file.content) {
  462. drift.push({ path, status: "modified" });
  463. }
  464. }
  465. for (const path of Object.keys(readManifest(root).files)) {
  466. if (!(path in next.files) && existsSync(join(root, path))) {
  467. drift.push({ path, status: "orphan" });
  468. }
  469. }
  470. return drift.sort((a, b) => compare(a.path, b.path));
  471. }
  472. /** Repo-relative path of a file, POSIX-separated. Exported for the CLI's reporting. */
  473. export function repoRelative(root: string, absolute: string): string {
  474. return toPosix(relative(resolve(root), absolute));
  475. }