feat: canonical component build system — one file per component, generated outputs, drift gate#342
Draft
darrenhinde wants to merge 37 commits into
Draft
feat: canonical component build system — one file per component, generated outputs, drift gate#342darrenhinde wants to merge 37 commits into
darrenhinde wants to merge 37 commits into
Conversation
… (@controlstack/oac)
Remove unreferenced junk directories (toy dashboard project, opencode research notes, orphaned draft plan) and move the 16 stale synthesis docs from docs/planning/ to docs/archive/planning/ with an ARCHIVED note on the index. Update the 7 remaining references (plugin README, maintenance plan, canonical-refactor citations) to the archive path. .claude-plugin/ untouched. Task: canonical-refactor-05
Bump VERSION, root package.json, packages/cli, packages/compatibility-layer, packages/plugin-abilities, evals/framework, .claude-plugin/marketplace.json and plugins/claude-code/.claude-plugin/plugin.json (was 1.0.2 in-repo, not just cache) to 1.1.0 so CC /plugin update fires (> cached 1.0.2). Sync workspace version fields in package-lock.json, bun.lock, evals/framework/package-lock.json and packages/compatibility-layer/package-lock.json; dependency versions untouched. Evidence: version-field-only diff; packages/cli bun test 142/142 pass. Task: canonical-refactor-03
…ly permission guidance Claude Code cannot express per-agent path-scoped denies and plugins cannot ship permission rules, so the shipped coder-agent granted Write/Edit without the five security globs. Ship explicit disallowedTools: Bash, Task, a hard protected-paths prompt rule, opt-in deny-only RECOMMENDED-PERMISSIONS.md (README-linked), and a changelog security advisory. Bash remains omitted (fail-closed). Task: canonical-refactor-02
…ck checks Add a has-packages branch to detect-pr-changes.ts change detection with unit tests, and a packages-checks.yml workflow: blocking build/test/ typecheck for packages/cli (bun) and build/lint/test for packages/compatibility-layer (npm+vitest), plus a non-blocking status job for packages/plugin-abilities, which is pre-existing red (92 tsc errors, 23 failing tests, broken lint script). Task: canonical-refactor-01
…ct and symlink rules Per-content-type source-of-truth and conflict rules for the Stage 4 merge: 7 dual-home agent pairs (CC body wins, OpenCode-only fields grafted, section-level diff required for coder-agent and task-manager), skills union of 16 via targets:[], session-start.sh's six capabilities and their preservation path, and the symlinks-to-aliases[] decision. Records disk-verified discrepancies: context census is 293 md + 3 symlinks + 1 JSON, and a 4th directory-level symlink plugins/claude-code/context -> ../../.opencode/context exists that the prior census missed. Task: canonical-refactor-08
…igration-tests to v2
Purge the false "160 agents" claim (4 sites; real count 34, disk-verified);
rewrite 05 Stage 3 as a merge from both trees per 09-MERGE-RULES; delete the
"or diff-explained" escape hatch from the Stage-2 gate; retract 01's .env
security alarm (Q17 closed - 0 tracked .env files, gitignored; build hygiene
only); align {pattern,effect} to Locked Decision #5's {scope,decision}; correct
the census to disk truth (297 context entries = 294 files + 3 symlinks; 75
navigation hubs, 33 registered; registry 248 entries). Also fix the golden-test
warning count (1 -> 2) and close Q2/Q10 per 06-REVIEW dispositions.
Task: canonical-refactor-07
…-wins confirmed live Verified against a real OpenCode 1.17.20 install: last-match-wins confirmed (first-match and most-specific refuted by behavioral probes; resolver source uses findLast). Integer-like and duplicate scope hazards both confirmed real; no reordering transform or 34-agent migration needed. Task: canonical-refactor-09
… its corpus, 3 blockers ratified Nine-kind dependency vocabulary + wildcard refs (all 94 registry refs validate, incl. the 19 previously rejected); ContextSchema name/description optional/derived; targets[] applicability on every content type; registry profiles/categories/aliases restored; implicit-default permission rule adopted (opposite-of-decisions-present; mixed-without-* = parse error; 8-block migration impact enumerated); blockers ratified: editable content/ = YES, model not authorable -> inference.tier, implicit default per above. All counts re-verified from disk 2026-07-15. Task: canonical-refactor-06
The package had zero consumers in its entire git history: no package.json depended on it, no import crossed its boundary, it was absent from the published tarball, and it was never registered in .opencode/opencode.json. It also did not compile (92 tsc errors) and failed 23 of its own tests, which CI carried as a known-red non-blocking job. Removes the package, its packages-checks job and summary arm, the two detect-pr-changes assertions that existed only to assert that job existed, and its pnpm-lock importer. Lowers the has-packages gate count from 3 to 2 to match the remaining jobs. Recoverable from history if abilities are ever revived; nothing that worked is lost.
Adds the canonical `oac:` frontmatter block (id, name, category, type,
version, author, tags, dependencies, targets) so one file can fully define
one component. This is what dissolves .opencode/config/agent-metadata.json:
the sidecar exists only because OpenCode rejects unknown frontmatter fields,
and a build step that strips the block removes that constraint.
Replaces GranularPermissionSchema's z.record with an ordered array of
{pattern, action} rules. A record is an unordered map, so last-match-wins
precedence had no schema-level guarantee — it held only by accident of JS
string-key insertion order. Capability entries are ordered too, since
OpenCode flattens the capability map before resolving and a "*" key competes
positionally with specific ones. Authored map sugar is still accepted and
desugars into the ordered form; the on-disk OpenCode format is unchanged.
The shape mirrors OpenCode's live runtime rules, which the precedence
experiment captured verbatim and whose resolver is findLast().
Category accepts the real corpus rather than the documented enum: 21 of 28
sidecar entries use values the enum rejects. Per the ratified rule that the
schema must accept its own corpus, the root vocabulary stays closed but an
optional /sub-segment is allowed, so typos still fail.
targets admits any platform with a working adapter; which of them oac build
wires up is a separate concern. Empty targets is rejected rather than
treated as universal — a component that emits nowhere is dead weight the
build would silently skip.
Bumps lib to ES2023 for findLast; target and emit are unchanged.
44 new tests; 628 pass, build clean.
12-DISPATCH is the contract every subagent reads first, and its "current runtime facts (so you don't guess)" section still told agents to inspect a package that no longer exists. 08-STRUCTURE still deferred it to post-Stage 6. Both would have misinformed every future dispatch.
Five layers defining "done" for the build: schema validation, reference resolution, profile completeness, golden-file snapshots, and determinism. 83 new tests — 47 green today, 36 red-by-design pending subtasks 04-11. Each red carries a diagnostic naming the subtask that owes it, so a failure reads as a specification rather than a crash. Baseline 628 untouched. Reference resolution is the layer that earns its keep. scripts/registry/ validate-registry.sh reports "244/244 valid, 0 missing dependencies" and exits 0 — a false green with two mechanisms: it reads registry.json but never on-disk frontmatter, so drift in the shipping file is invisible to it; and it walks components[].dependencies but never profiles, leaving 209 refs unchecked. Both are now asserted structurally. Fixes .gitignore, where the build/ output rule matched tests/unit/build/ at depth. Four suites would have passed locally, never reached CI, and the gate they exist to be would quietly not have existed. Build outputs stay ignored. Golden corpus uses the 7 live plugins/claude-code agents as a free regression oracle: their tool lists admit exactly one total order (Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task) — alphabetical and ToolAccessSchema order are both refuted by context-manager.md — so they are byte-reproducible rather than approximately so.
Each of 33 agents becomes one file: its existing OpenCode frontmatter plus an oac: block folding in what agent-metadata.json carried. Bodies are byte-identical to source (SHA-256 verified per file). All 33 parse against CanonicalAgentSchema. Non-destructive by design: .opencode/agent/** stays in place and becomes build output in subtask 10, and agent-metadata.json is retired only once the build proves it can regenerate it. eval-runner.md is deferred — it has uncommitted work in the tree. The sidecar was staler than documented. It has 28 entries against 34 files: batch-executor is absent from registry.json entirely, and six agents (stage-orchestrator and five planning subagents) exist on disk with neither sidecar nor registry entry. Their oac: blocks are authored here, deriving category from directory and type from mode rather than inventing metadata. Their dependencies are left empty deliberately. They could have been derived from each file's permission.task allow-list, but the sidecar demonstrably does not use that convention — coder-agent permits contextscout, externalscout and TestEngineer while declaring only context:standards-code. Inventing deps under a different convention would be fabrication wearing the costume of data. These need a real declaration pass. tester is not an orphan: it is the id for test-engineer.md, and registry.json, three profiles and five context docs reference subagent:tester. The id is kept and the id/filename divergence flagged rather than renamed under this subtask.
Implements the permission resolver: capability entries flatten in authored order, then findLast. Nothing matched returns undefined rather than guessing allow. This mirrors OpenCode's own resolver (permission/next.ts uses findLast over an ordered ruleset), and its config.ts permissionTransform rebuilds the permission map from __originalKeys — upstream preserving authoring order explicitly because JS object semantics hoist integer-like keys. That is also why map sugar rejects integer-like scopes: a "8080" key does not merely lose precedence, it silently removes the whole bash tool. The matcher is not a path glob. OpenCode's Wildcard.match compiles patterns to regex with * as .* under the s flag, so * crosses / and spaces, and a trailing " *" is made optional. A path-glob matcher would fail "npx ts-node*stage-cli*" and silently drop stage-orchestrator's only allowed command. Matching is always case-sensitive here: build output must not depend on the host OS. Projection to flat-list platforms fails closed. allowed: true requires rules be provably a blanket allow; anything else denies and warns, naming the rules that could not be carried. Emitting a more permissive result than the canonical spec is the one unacceptable outcome, so ask degrades to deny. Warnings fire only on actual loss — warnings that fire on provably exact projections train people to ignore them. Consequence worth knowing: most agents lose bash/edit on Claude Code rather than receive them unscoped. That is the safe direction for the live gap in index finding #10, but it is a corpus-wide behavioral change.
…solver
CanonicalAgentLoader parses content/agents into {frontmatter, oac, body} and
resolves identity by oac.id only, never by filename — test-engineer.md has
id "tester", which registry.json, three profiles and five context docs
reference as subagent:tester. Duplicate ids throw rather than last-write-wins.
ReferenceResolver finds exactly the 4 known dead refs and, unlike
validate-registry.sh, reads on-disk frontmatter and walks profiles rather
than components alone. It mirrors install.sh's real matching rule (id OR
alias, not id alone — three components carry aliases).
ProfileLoader validates all 5 profile.json files. profile.json and
registry.json have drifted on every one and neither is a superset (advanced:
51 vs 68). install.sh reads registry.json only; profile.json is read solely
by check-dependencies.ts. resolveClosure validates the union, sound under
either belief, and drift() exposes the disagreement rather than hiding it.
Dead refs are detected, not fixed: the add-context.md refs are a namespace
mismatch and fixing them is a data decision, not a resolver rule.
…ontent Emits .opencode/agent/** from content/agents/**, stripping the oac: block. 24 of 33 round-trip byte-identical; the 9 that differ do so by exactly one obsolete comment the seeding commit removed inconsistently — a seeding artifact, pinned so the drifted set cannot grow silently. Emission is textual excision, not parse-and-redump. The agents are hand-authored with quoting a YAML dumper renormalizes, so a dumping emitter would make all 33 byte-different and destroy the drift gate. Files are still parsed and validated, and output is re-parsed to verify the oac: block is gone and permissions desugar to the same ordered rules. Output paths derive from file path, not category+id: test-engineer.md declares id "tester", which category+id would orphan. Two documented facts turned out false, verified against real OpenCode source (config.ts) and a live probe on the installed 1.18.1 binary: - name: is legal and in the Agent schema's knownKeys (config.ts:636). It is not merely allowed but required — dropping it renames CoderAgent to coder-agent and breaks every task: rule naming it. - OpenCode does not reject unknown frontmatter at all. The Agent schema ends in .catchall(z.any()) and sweeps unknowns into options (config.ts:633-658). A probe carrying an oac: block loaded fine. So agent-metadata.json was not needed for the reason its own doc gives. Stripping oac: remains correct hygiene, but the sidecar's stated premise does not survive contact with the source. Confirms the 02 §1.2.7 spec defect: the permission vocabulary is open (.catchall(PermissionRule)), with skill and question as known keys. A parser built to the documented closed enum would drop externalscout's skill deny-all and silently grant every skill. Capabilities stay opaque strings; externalscout is pinned by regression test.
fromCanonical emits plugins/claude-code/agents/<id>.md with flat frontmatter
in committed key order. .claude/ is gone from every output path, and
config.json is gone entirely — Claude Code has no such agent-config file, so
the ~25 tests pinning its structure were deleted rather than retargeted; they
described a format nothing reads. Tool order is Read, Write, Edit, Glob,
Grep, Bash, WebFetch, Task — the only total order the 7 live agents admit.
Permissions route only through Capabilities.ts, never PermissionMapper, whose
mapPermissionsFromOAC defaults to a permissive strategy and would return
bash: true for coder-agent's deny-all.
Regenerating the 7 shipped agents surfaced a live security finding: 4 of 7
are strictly MORE permissive than canonical intent.
coder-agent shipped grants Edit unscoped; canonical denies **/*.env*,
**/*.key, **/*.secret
context-manager shipped grants Write + Bash unscoped with no
disallowedTools; canonical scopes both to .opencode/context/**
external-scout shipped grants Read/Write/Bash/WebFetch unscoped; canonical
allows only curl context7 and jq
test-engineer shipped grants Edit + Bash unscoped; canonical denies sudo *
and asks on rm -rf *
They were hand-written before a build existed, which is the drift this design
removes by construction. Nothing was widened to force byte-identity.
CapabilityMatrix gains orderedPermissionRules (claude: none — the refactor's
central fact, previously unrepresented) and binaryToolGrants. Corrects
overstatements: agentCategories and priorityLevels partial->none,
dependencies full->none, claude configFormat json->markdown; the matrix and
the adapter had contradicted each other about the same platform.
emitRegistry derives the registry from content/ plus the context tree, replacing the bash auto-detect script. Output is byte-stable across runs and a fixed point over its own output, so subtask 10 writing it back cannot oscillate. metadata.lastUpdated is carried from the base rather than stamped with now() — the bash script's strftime alone would have broken the diff gate. serializeRegistry round-trips the committed file byte-for-byte, so the diff is semantic-only. auto-detect-components.sh never generated anything: it is an appender that diffs disk against existing path values and jq-appends what it does not recognise. Nothing ever re-derives an existing entry, which is precisely why entries drifted from their own files indefinitely. It reports 109 drifted components today. Generated registry adds 7 missing agents (batch-executor plus six planning subagents present in neither registry nor sidecar) and resolves description, tags and name drift against the shipping files' own frontmatter. Not yet emitted, because generating today would delete 13 dependency edges that install.sh resolves right now: contextscout (9), externalscout (skill:context7, context:context-system), image-specialist (tool:gemini) and context-organizer. They were hand-authored straight into registry.json and exist in no file and no sidecar, so a build that derives deps from content drops them — shipping externalscout without the Context7 skill it is built around. A test pins this and turns red the moment it is repaired, so the build cannot emit silently over it. The fix is backfilling oac.dependencies in content/, not teaching the emitter to keep hand-edits. registry.json wins over profile.json where they disagree: install.sh reads only registry.json, while profile.json is read solely by check-dependencies.ts, which no install path invokes. Profiles are carried verbatim — generating from the wrong list would silently change what 5 profiles install.
…lared These edges were hand-authored straight into registry.json and existed in no agent file and no sidecar entry, so generating the registry from content would have deleted them — shipping externalscout without the Context7 skill it is built around. Backfilled into oac.dependencies in registry's exact declaration order, since the emitter maps positionally and order is a byte-diff risk for the drift gate. Emitted registry now loses zero edges (committed declares 55, emitted 76; the 21 additions are pre-existing registry staleness the emitter corrects). All 12 unique refs resolve live — nothing dead was laundered into canonical data. context:context-system (bare) is live and is NOT the known dead ref; that one is context:context-system/* (wildcard), which exists only in profiles.advanced and is not among the 13. Bodies are byte-identical (SHA-256 verified, 33/33); the diff is frontmatter only.
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.
The manifest is the ledger orphan removal reasons from, so it has to travel with the repo. A fresh CI clone with no manifest prunes nothing, which means a deleted source would leave stale generated output while git diff stayed clean — the exact hole the drift gate exists to close. It also records what "generated" means: 41 files, and eval-runner.md is not among them. That absence is what makes it safe, rather than a rule about it that could be got wrong.
Runs the build and fails if it changed anything, so a hand-edit to generated output turns CI red. Proven to fail twice before landing: editing a generated agent, and deleting a content/ source to catch the orphan. CI runs the same make target contributors run. Runs a write build and then asks git, rather than using --check. check() compares planned files against disk but never compares the manifest itself — only write() emits it — so a --check gate would have let the committed ledger drift silently, and a fresh clone with a stale ledger prunes the wrong set. Snapshots git status --porcelain around the build instead of git diff --exit-code, which misses untracked new generated files. On a clean CI checkout the semantics are identical; locally it attributes drift to the build, so a dirty worktree cannot false-positive. Gated on has-canonical, not has-packages: a PR that hand-edits .opencode/agent/** touches no packages/** path, so has-packages would have skipped the gate on exactly the change it exists to catch. Also runs detect-pr-changes tests, which no workflow executed — the workflow triggered on changes to that file but only ever ran packages/cli's suite, so its tests were decorative. plugins/claude-code is deliberately excluded: it stages to .tmp/oac-build/ and is compared only, pending the deferred decision on tightening the 4 agents that grant Bash/Edit unscoped. Add it to GENERATED in check-build-drift.sh when that lands.
Deleting a content/ source removed its .opencode/ output but left its registry.json entry behind permanently, and the build reported "0 written, 41 unchanged" — stably wrong, so the drift gate could not see it. That is the exact defect this emitter was built to cure and which its own header condemns auto-detect-components.sh for: nothing ever re-derives, so entries drift forever. merge() carried any base entry whose id was not generated. The carry-through is load-bearing — eval-runner ships in .opencode/agent/, sits in the committed registry, and has no canonical source — but "never had a source" and "source was deleted" are indistinguishable by id alone. The manifest already encodes the distinction, so merge() now consults it: an entry we previously wrote and no longer generate is dropped; one we never wrote is carried. Of 34 registry agent entries, eval-runner is the only one the ledger does not claim, so the populations separate cleanly. Every ambiguity — missing or non-string path, no ledger at all — resolves toward keeping data. The ledger moves to its own module because BuildPipeline already imports RegistryEmitter and reading it there would have closed an import cycle. Both now provably consult the same ledger, so the two prune rules — generated files and generated entries — cannot disagree. Found by running the real command end to end; no test caught it. The new tests were mutation-tested against the restored bug: exactly the drop cases fail and the carry cases hold.
Both adapters mark fromCanonical async deliberately: it converts a malformed source into a rejection rather than a synchronous throw that would bypass a caller's .catch(). The keyword is load-bearing, so the rule's assumption that async-without-await is an accident does not hold — suppressed with the reason rather than dropping async and breaking the documented contract. Caught by CI, not locally: the compatibility-layer job runs build + test + lint, and only build and test had been run before pushing.
Red-first is a development discipline; a permanently red CI is a broken CI. These three fail only because decisions were deferred, so vitest exited 1 and the compatibility-layer job could never go green — training people to ignore the signal, which is the failure this build exists to prevent. Skipped with the blocker named in the test name, so the gaps stay visible in CI output and re-enabling is a deliberate act rather than a rediscovery: - golden-files: regenerating the 7 Claude agents is blocked on two product decisions — tightening the 4 that grant Bash/Edit unscoped, and the 4 ids that differ from their shipped filenames (a rebuild would add files rather than replace them). - canonical-agent x2: eval-runner is the single agent content/ does not carry, because its file has uncommitted work. No assertion was weakened. Each test still fails if un-skipped while its blocker stands.
…ops guessing
Claude Code cannot enforce a per-agent permission scope by any route. Verified against
its docs 2026-07-15: subagent frontmatter carries only flat tools:/disallowedTools:;
permission rules exist but live in settings.json and "apply to the entire session, not
only the plugin subagent"; a plugin's settings.json supports only the `agent` and
`subagentStatusLine` keys, so a plugin cannot ship rules at all; and precedence is
category-based (deny -> ask -> allow, specificity-blind), which cannot express
last-match-wins even in principle.
6 of our 7 Claude-targeted agents use scoped rules. For those there is no emission that
is both faithful and useful: fail-closed denies the tool outright (externalscout scopes
`read`, so it emitted `disallowedTools: Read` — a documentation scout that cannot read),
and widening hands over the tool with none of the scoping — which is how the 4 shipped
agents came to leak. That is a security decision, not a projection, so the adapter now
REFUSES rather than picking one, and a human rules on it in the source.
Adds `oac.overrides.<target>`:
- `name`/`model` — closes the id/filename gap (reviewer -> code-reviewer). A rebuild
now replaces the shipped file instead of adding a duplicate.
- `tools` — the authored grant. Every bound tool lands in exactly one list;
nothing is left to the target's default.
- `unenforced` — keyed by capability, and CHECKED: every granted-but-scoped
capability must carry a justification, and every justification must
correspond to one. Neither an unjustified widening nor a stale note
can survive a build.
All 7 overrides are authored to reproduce each agent's shipped `tools:` list exactly, so
this is behaviour-neutral: not one grant changes. What changes is that each loss is now
written down and re-warned on every build. The two worst are recorded in the source:
context-manager and externalscout receive unrestricted Bash where canonical allows four
and two commands respectively.
The refusal is scoped to `oac build` (`fromCanonical`), not the `oac-compat convert`
import path (`fromOAC`) — that converts an agent from a format with no `oac:` block, so
there is nowhere to record a decision and nobody to ask; fail-closed-and-warn remains the
best available answer there.
Also: build errors now name the source file, not just the agent id.
`plugins/claude-code/**` still stages rather than emitting in place — permissions no
longer block it, but running the emission surfaced a separate blocker: the shipped Claude
files are target-adapted, not stale. 4 of 7 carry `<example>` dispatch blocks in
`description:` that canonical has no field for, and their bodies are written against the
target's capabilities (Claude's code-reviewer cannot delegate, so its prompt says context
is pre-loaded; canonical's says "ALWAYS call ContextScout"). Emitting would silently
degrade all 7. Documented in build.ts; needs a design decision.
870 tests pass, 3 skipped; lint 0 errors; tsc clean; drift gate green.
Consolidates four packages that were spread across three unrelated scopes: @nextsystems/oac -> @controlstack/oac (root, published) @nextsystems/oac-cli -> @controlstack/oac-cli @openagents-control/compatibility-layer -> @controlstack/compatibility-layer @opencode-agents/eval-framework -> @controlstack/eval-framework Verified against the npm registry: only @nextsystems/oac was ever published (0.7.0). @nextsystems/oac-cli and @openagents-control/compatibility-layer both 404 — they were never published, so they need no deprecation and are simply abandoned. 08 claimed all three were live at 0.7.1/1.0.0/0.1.0; all three numbers were wrong, and its deprecation bullet is corrected to say what the registry actually shows. Stage 6 therefore needs one `npm deprecate @nextsystems/oac`, not three. The compatibility-layer keeps its name rather than becoming @controlstack/core: it still holds both the IR and the adapters, so `core` would misdescribe it until the package split lands. It becomes core + adapters then. docs/archive/planning/** deliberately keeps the old names — it is archived history and renaming it there would make it misreport what was true at the time. Two claims the rename would have made false are now honest rather than silently wrong: the publishing guide no longer says we are published as @controlstack/oac, and the scope is no longer ticked as protected. The @controlstack scope holds zero packages and is unverified — claiming it is a hard prerequisite for publishing, now an open action item. Note doctor.ts polls the registry for @controlstack/oac, which 404s until first publish. Verified on a clean HEAD + this change: pnpm -r build green; compatibility-layer 870 pass /3 skipped (the known deferred three); cli 150 pass.
`npm i -g` shipped a CLI that could not start on a machine without Bun. bin/oac.js
carried a `#!/usr/bin/env node` shebang and then immediately did
`execFileSync('bun', ...)`, falling back to "Error: Bun is required to run OAC CLI",
and 9 shipped source files called Bun-only APIs. This is the Windows/clean-machine
bug class the plan cites, and it blocked publishing under the new scope.
Converted the 9 shipped files off Bun (26 call sites):
Bun.file().text/json/bytes -> readFile
Bun.write -> writeFile / copyFile
Bun.file().exists() -> stat().isFile()
Bun.version -> process.versions.node
import.meta.dir -> dirname(fileURLToPath(import.meta.url))
Bun.write creates missing parent directories; fs.writeFile and fs.copyFile do not.
Every write site that relied on that now mkdirs explicitly — writeManifest depended
on it by name ("Creates the .oac/ directory if it does not exist"), so a naive swap
would have broken `oac init` outright.
The doctor's "Bun runtime >= 1.0.0" check is now "Node runtime >= 20.0.0"; checking
for a Bun we no longer need would be theatre. bin/oac.js re-execs process.execPath
rather than looking up a binary on PATH. engines follow: cli was `bun >=1.0.0` with
no node entry at all, root said node >=18; both now say node >=20. Build target moves
from --target bun to --target node. The 6 test files still use bun:test and Bun.write
— they are dev-only, never shipped, and converting them means swapping test runners
for no user-visible gain.
Also fixes a package-root bug that only a real install surfaces (07 Stage 5 predicted
it). findPackageRoot required `.opencode/` + `package.json` and the ABSENCE of
`registry.json`, to skip the monorepo root in dev — but the published tarball ships
registry.json beside .opencode/ at its root, so the walk rejected the very directory
it was looking for and `oac init` died with "could not find a directory with
.opencode/ and package.json" on every global install. It now matches on the
package.json `name` being @controlstack/oac, which holds in both layouts. Covered by
a regression test that mirrors the installed layout.
Marks oac-cli, compatibility-layer and eval-framework `private: true`. None was ever
meant to be published (08 §3: publish exactly one package) and all three were
publishable; `pnpm -r publish --dry-run` now resolves to @controlstack/oac alone.
Verified end-to-end with Bun removed from PATH: npm pack -> npm i -g -> oac init
installs 348 files -> oac doctor all green -> hand-edit a file -> oac update updates
347 and preserves the edit. cli tests 153 pass (was 150; +3 for the package root).
Scoped packages default to access=restricted, so 'npm publish' would fail with 402 Payment Required despite the controlstack org being owned. Declaring it in publishConfig means the first publish cannot forget the --access public flag. Verified: npm publish --dry-run resolves name @controlstack/oac at 1.1.0.
… from it OpenCode — one of the two first-class targets — appeared ZERO times in CapabilityMatrix.ts. `Platform` was "oac"|"claude"|"cursor"|"windsurf" while OpenCodeAdapter had shipped since d100ccd. That gap was not cosmetic: with nowhere to be described, OpenCodeAdapter hand-wrote its own getCapabilities(), as did Cursor and Windsurf, while ClaudeAdapter derived from the matrix. Two patterns, and the drift that follows. The drift was real and live. CursorAdapter hand-wrote `supportsContexts: true` ("✅ Can inline context") while the matrix said `externalContext: "none"` ("context must be inline in .cursorrules"). Same fact, two answers — the same bug as the earlier claude json/markdown split, which the agreement test at ClaudeAdapter.test.ts:603 was added to prevent. It survived because that test pinned one field on one platform: 1 of 11 fields, 1 of 4 adapters. Ruled: the feature is external *references*. Cursor cannot reference; pasting bytes into .cursorrules loses the reference, the file boundary and the priority. That is degradation, not support, and calling it support hides real loss from every compatibility report. CursorAdapter.test.ts:58 asserted the wrong side and is corrected — it had frozen the adapter's answer rather than comparing it to the matrix. All four adapters now spread getToolCapabilities(<platform>); none restates it. Adding opencode's 22 cells is authored from evidence, not guessed: - temperature/maxSteps "full": real agents carry the fields (.opencode/agent/eval-runner.md, subagents/system-builder/command-creator.md) - hooks "none": .opencode/plugin/ holds plugins, not a PreToolUse event surface — matching OpenCodeAdapter's own long-standing supportsHooks: false - dependencies "none": declared in the `oac:` block, which OpenCodeAdapter strips on emit. Being lossless about OpenCode's own fields does not make it lossless about OAC's. - agentCategories "full": carried as the directory (.opencode/agent/<category>/), unlike Claude's flat layout where it is lost outright Widening Platform made the compiler find a coupling nobody chose: TranslationTarget was `Exclude<Platform, "oac">`, so it silently gained opencode and broke against ToolMapper's own narrower union. OpenCode is deliberately NOT a translate target — OpenAgent's permission is an unordered Record, and OpenCodeAdapter.fromOAC() refuses outright rather than risk reordering a last-match-wins block. That union is now stated, not inherited. registerBuiltInAdapters() wrapped every registration in `catch { /* skip silently */ }`, which swallowed genuine failures: a bad import would leave registry.get("opencode") undefined and the build would emit nothing for the canonical target. Removing it exposed a real defect the swallow was hiding — both cli/commands/convert.ts and migrate.ts call it on the shared singleton, so a process touching both threw "Adapter 'cursor' is already registered". Idempotence is now explicit; real failures throw. Deliberately NOT done: inverting ownership so adapters own capability data. ToolCapabilities is a lossy 10-boolean projection of a 22 x 3-level matrix, so adapters would keep BOTH a hand-written getCapabilities() and a featureSupport map — two sources per platform, co-located where no cross-module test can catch them. The spread this commit standardises on is the only thing making divergence structurally impossible. Revisit after the packages/core split, and only with getCapabilities() made derived-and-final. Left alone: the `priorityLevels` row describes "task priority levels", a concept absent from types.ts (which has only ContextPrioritySchema, whose 4 levels are almost certainly the "4 levels" its oac note means). Flagged with a FIXME rather than fixed — burying that call inside an opencode-gap fix would hide it. Verified: tsc clean; compat-layer 911 pass/3 skip (was 864 — +47 agreement cases); cli 153 pass; pnpm -r build green. Build drift is unchanged by this commit: the 7 drifting agents map 1:1 to the 7 uncommitted content/agents edits, confirming capability data reaches no generated byte (fromCanonical never calls getCapabilities, and ConversionResult.capabilities is discarded at apply.ts:156).
…ced ledger Every agent targeting claude-code must now author oac.overrides.claude-code.tools; the adapter no longer derives the list from the permission: block, even when a projection would have been exact. The unenforced: justification record is gone — each widening is stated in a YAML comment beside the grant instead, where a reader actually looks. lossyCapabilities and the cross-check machinery go with it. contextscout now declares its tools explicitly like the other six.
…docs The three path-style refs in add-context.md become the bare slugs their registry entry always carried (context:mvi, context:frontmatter, context:project-intelligence), and the advanced profile's context:context-system/* gains its missing core/ prefix, so the wildcard now installs the context-system subtree instead of silently nothing. The dead refs were pinned as a known bug; per that pin's own contract, fixing them is a deliberate edit to the pins: reference-resolution now gates the tree at zero dead refs (with the four repairs asserted individually), profile-completeness drops its known-dead exemption, and RegistryEmitter's carry-through test probes the repaired spelling. Also retires stale prose the 9b4e807 refactor left behind: the build.ts staging docblock gains the third (exhaustive-lists) divergence, the ClaudeAdapter capability note stops claiming the build path degrades fail-closed, and 03-adapter-specs §2.3/§2.5 are marked superseded.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Makes
content/agents/**the canonical source.oac buildcompiles it into.opencode/agent/**andregistry.json, both committed, and CI fails if a generated file is hand-edited.Adding a component is now one file. Previously it meant editing the agent file, editing
.opencode/config/agent-metadata.json, runningauto-detect-components.sh, runningvalidate-registry.sh, and hoping the three agreed.Draft: opened primarily to exercise the new
canonical-driftgate in real CI, which has never run.Type of Change
Checklist
Testing
make check-canonical-drift→ no drift.opencode/+registry.json; delete it → removed from both, no residueregistry.jsonregenerates losing 0 ids and 0 dependency edges, recovering 7 agents that were missing from it entirelyNotable findings
coder-agent(Edit unscoped),context-manager(Write+Bash unscoped),external-scout(Read/Write/Bash/WebFetch unscoped),test-engineer(Edit+Bash unscoped). Deliberately not fixed here:plugins/claude-code/**stages to.tmp/oac-build/and is compared only, pending review.agent-metadata.mdis wrong. It says OpenCode rejects unknown frontmatter; the schema ends in.catchall(z.any())and sweeps unknowns intooptions(config.ts:633). The sidecar was never needed for the reason given.validate-registry.shis a false green — reports 244/244 valid, exit 0, while 4 dead refs ship. It never reads on-disk frontmatter and never walks profiles.auto-detect-components.shnever generated anything — it appends and never re-derives, which is why 109 components drifted from their own files.packages/plugin-abilities: zero consumers in its entire history, 92 tsc errors, 23 failing tests.Deferred by decision
diff -ru plugins/claude-code/agents .tmp/oac-build/plugins/claude-code/agents)eval-runner.md— has uncommitted local work; deliberately absent from the build manifestinstall.sh) — this design keepsinstall.shworking, so it is compatible with either answer