Skip to content

feat: canonical component build system — one file per component, generated outputs, drift gate#342

Draft
darrenhinde wants to merge 37 commits into
mainfrom
refactor/canonical
Draft

feat: canonical component build system — one file per component, generated outputs, drift gate#342
darrenhinde wants to merge 37 commits into
mainfrom
refactor/canonical

Conversation

@darrenhinde

Copy link
Copy Markdown
Owner

Description

Makes content/agents/** the canonical source. oac build compiles it into .opencode/agent/** and registry.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, running auto-detect-components.sh, running validate-registry.sh, and hoping the three agreed.

Draft: opened primarily to exercise the new canonical-drift gate in real CI, which has never run.

Type of Change

  • New feature (agent, command, tool)
  • Bug fix
  • Refactoring
  • CI/CD

Checklist

  • Tests pass locally
  • Documentation updated (if needed) — README deferred pending the distribution decision
  • Follows CONTRIBUTING.md

Testing

  • compatibility-layer 854 pass, 3 red-by-design (deferred decisions below); baseline was 584
  • packages/cli 150 pass, tsc clean, make check-canonical-drift → no drift
  • Drift gate proven to fail before landing: hand-editing a generated agent and deleting a source both turn it red
  • Round-trip proven end to end: add one file → appears in .opencode/ + registry.json; delete it → removed from both, no residue
  • registry.json regenerates losing 0 ids and 0 dependency edges, recovering 7 agents that were missing from it entirely

Notable findings

  • 4 of 7 shipped Claude Code agents grant more permission than canonical intentcoder-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.md is wrong. It says OpenCode rejects unknown frontmatter; the schema ends in .catchall(z.any()) and sweeps unknowns into options (config.ts:633). The sidecar was never needed for the reason given.
  • validate-registry.sh is 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.sh never generated anything — it appends and never re-derives, which is why 109 components drifted from their own files.
  • Deletes packages/plugin-abilities: zero consumers in its entire history, 92 tsc errors, 23 failing tests.

Deferred by decision

  • The 4 permissive agents (review via 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 manifest
  • Stages 5-6 (npm vs install.sh) — this design keeps install.sh working, so it is compatible with either answer

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant