From 75c58171389d71d1c9b654ce5a43e9a1b8116d6f Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Sun, 1 Nov 2026 21:32:16 +0000 Subject: [PATCH 1/2] fix(release): let the guard first-publish new packages without code changes The Release job for the auto-advisor merge failed: npm returned 404 for the never-published package, and the guard failed closed on any non-200. It also kept a second, hard-coded copy of the package policy, so every new plugin needed a guard edit, and its messages still said "six". tools/release-channels.json is now the only package list. The guard checks it against the workspace and names any package missing from either side, validates each entry's shape, and rejects a release class above the current OpenCode major. A 404 from npm plans a first publish, still bound to the class major; every other failure still stops the release. CI now runs `bun run release:check`, which reads npm and publishes nothing, so a release the guard would reject fails on the PR instead of on main. Also: auto-advisor in the PR and issue templates and in the downloads worker, and a 30 s limit for the timeline test that timed out on CI. --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 1 + .github/pull_request_template.md | 1 + .github/workflows/ci.yml | 5 + CONTRIBUTING.md | 3 +- .../scripts/timeline.test.ts | 3 +- tools/release-channel-guard.test.ts | 194 ++++++++++++++++-- tools/release-channel-guard.ts | 107 ++++------ tools/total-downloads-worker.js | 1 + 9 files changed, 237 insertions(+), 79 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5665f89..10733e6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -29,6 +29,7 @@ body: - opencode-ram-monitor - opencode-agent-prompt-inheritance - opencode-adversarial-review + - opencode-auto-advisor - opencode-plugins (this monorepo itself) - Other validations: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d150207..9bb98cf 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -29,6 +29,7 @@ body: - opencode-ram-monitor - opencode-agent-prompt-inheritance - opencode-adversarial-review + - opencode-auto-advisor - opencode-plugins (this monorepo itself) validations: required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9e476d6..edb15d2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,6 +14,7 @@ - [ ] opencode-ram-monitor - [ ] opencode-agent-prompt-inheritance - [ ] opencode-adversarial-review +- [ ] opencode-auto-advisor - [ ] opencode-plugins (this monorepo itself) - [ ] Other: _______ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47bd382..3f4e289 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,3 +67,8 @@ jobs: - name: Test run: bun run test + + # Reads npm and publishes nothing: shows what the Release job would publish, and fails here, + # before merge, on anything the guard would reject there. + - name: Release plan + run: bun run release:check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 961fc7c..641529c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,8 @@ For changes intended for the next package release, add a changeset with `bun run Plugin majors track OpenCode majors, not strict semver. A package in release class `vN` (see `tools/release-channels.json`) only ever publishes major N, so: - Use `patch` for fixes and `minor` for features **and for breaking changes** within the same OpenCode major. Say "Breaking" in the changeset text so it lands in the changelog. -- Use `major` only to move a package to a new OpenCode major. Change its `releaseClass` in `tools/release-channels.json` and the guard's `APPROVED_POLICY` in the same PR. +- Use `major` only to move a package to a new OpenCode major. Change its `releaseClass` in `tools/release-channels.json` in the same PR (the guard rejects a class above the current OpenCode major, `CURRENT_OPENCODE_MAJOR` in `tools/release-channel-guard.ts`). +- A new package needs one entry in `tools/release-channels.json`; the guard rejects a workspace package without one. Its first release is a first publish (npm has never seen it), which CI's `Release plan` step shows before merge. `bun run release:changesets` checks pending changesets and package manifests against this rule without touching the registry. The Changesets workflow runs it on pull requests. diff --git a/packages/opencode-auto-advisor/scripts/timeline.test.ts b/packages/opencode-auto-advisor/scripts/timeline.test.ts index 8ebeaba..d6875c4 100644 --- a/packages/opencode-auto-advisor/scripts/timeline.test.ts +++ b/packages/opencode-auto-advisor/scripts/timeline.test.ts @@ -108,7 +108,8 @@ describe("fetchMessages", () => { expect(messages.map((m) => m.id)).toEqual(["m1", "m2", "m3", "m4", "m5", "m6"]); expect(messages.every((m) => m.text?.length === 1_000_003)).toBe(true); } - }); + // Five rounds of 3 MB pages through real subprocesses: well over 5 s on a slow CI runner. + }, 30_000); }); describe("bun run timeline", () => { diff --git a/tools/release-channel-guard.test.ts b/tools/release-channel-guard.test.ts index 46e10aa..58691db 100644 --- a/tools/release-channel-guard.test.ts +++ b/tools/release-channel-guard.test.ts @@ -20,6 +20,7 @@ import { checkClassMajor, checkPendingChangesets, createChangesetsEnvironment, + createPublicRegistryClient, PUBLIC_REGISTRY, type RegistryClient, type RegistrySnapshot, @@ -70,13 +71,14 @@ function snapshot( }; } +/** An override of `null` is a package npm has never seen (a 404). */ function createRegistryClient( - overrides: Partial> = {}, + overrides: Record = {}, ): RegistryClient { return async (name) => { - const override = overrides[name as PackageName]; + const override = overrides[name]; if (override instanceof Error) throw override; - if (override) return override; + if (override !== undefined) return override; const version = registryVersions[name as PackageName]; return snapshot([version], version); }; @@ -371,7 +373,7 @@ describe("release-channel-guard policy matrix", () => { ); }); - test("the retired frozen policy fails exact-match validation", async () => { + test("the retired frozen policy entry is rejected as malformed", async () => { const cwd = await createFixture(); const policyFile = path.join(cwd, "tools", "release-channels.json"); const policy = JSON.parse(await readFile(policyFile, "utf8")) as { @@ -386,7 +388,7 @@ describe("release-channel-guard policy matrix", () => { await expectGuardRejects( buildReleasePlan({ cwd, registryClient: createRegistryClient() }), - "does not match the approved release class", + `invalid policy entry for ${packageNames.agents}`, ); }); @@ -473,8 +475,8 @@ describe("release-channel-guard policy matrix", () => { expect(invocations).toBe(0); }); - test("R1 the policy requires the approved V2 class for ram-monitor", async () => { - const cwd = await createFixture(); + test("R1 a V1 class cannot claim a package already on major 2", async () => { + const cwd = await createFixture({ [packageNames.ram]: "2.1.0" }); const policyFile = path.join(cwd, "tools", "release-channels.json"); const policy = JSON.parse(await readFile(policyFile, "utf8")) as { packages: Record; @@ -484,7 +486,7 @@ describe("release-channel-guard policy matrix", () => { await expectGuardRejects( buildReleasePlan({ cwd, registryClient: createRegistryClient() }), - "does not match the approved release class", + "must remain on V1 major 1", ); }); @@ -1380,10 +1382,12 @@ class LoopbackRegistry { private server: Server | undefined; private address = ""; - constructor() { + /** `unpublished` packages start absent, so their metadata is a 404 until published. */ + constructor(unpublished: readonly string[] = []) { for (const [name, version] of Object.entries(registryVersions) as Array< [PackageName, string] >) { + if (unpublished.includes(name)) continue; this.packages.set(name, { versions: new Set([version]), distTags: { latest: version }, @@ -1434,7 +1438,9 @@ class LoopbackRegistry { return; } if (request.method === "GET") { - this.respondJson(response, 200, this.readPackument(pathname)); + const packument = this.readPackument(pathname); + if (packument) this.respondJson(response, 200, packument); + else this.respondJson(response, 404, { error: "Not found" }); return; } if (request.method === "PUT") { @@ -1449,7 +1455,7 @@ class LoopbackRegistry { private readPackument(pathname: string) { const name = decodeURIComponent(pathname.slice(1)) as PackageName; const packageState = this.packages.get(name); - if (!packageState) return { error: "E404" }; + if (!packageState) return undefined; return { name, "dist-tags": packageState.distTags, @@ -1469,8 +1475,11 @@ class LoopbackRegistry { const version = versions.at(-1); if (!version) throw new Error(`loopback publish omitted version for ${name}`); const tag = Object.keys(body["dist-tags"] ?? {})[0] ?? "latest"; - const packageState = this.packages.get(name as PackageName); - if (!packageState) throw new Error(`loopback publish unknown package ${name}`); + let packageState = this.packages.get(name as PackageName); + if (!packageState) { + packageState = { versions: new Set(), distTags: {} }; + this.packages.set(name as PackageName, packageState); + } packageState.versions.add(version); if (body["dist-tags"]) Object.assign(packageState.distTags, body["dist-tags"]); this.publishes.push({ name, version, tag }); @@ -1593,6 +1602,165 @@ describe("R11 disposable real Changesets publication", () => { await registry.close(); } }, 20_000); + + test("first-publishes a package the registry has never seen", async () => { + const registry = new LoopbackRegistry([packageNames.advisor]); + const registryUrl = await registry.start(); + const fixture = await createFixture(); + await linkChangesetsCli(fixture); + await writeFile( + path.join(fixture, "bun.lock"), + '{\n "lockfileVersion": 1,\n "workspaces": {}\n}\n', + ); + const evidenceRoot = await createScratch("release-channel-registry-"); + await mkdir(path.join(evidenceRoot, "home"), { recursive: true }); + await mkdir(path.join(evidenceRoot, "npm-cache"), { recursive: true }); + const env = { ...publishEnvironment(), ...runtimeEnvironment(evidenceRoot) }; + + try { + const plan = await runReleaseGuard({ + cwd: fixture, + mode: "publish", + env, + registryClient: createPublicRegistryClient(`${registryUrl}/`), + runChangesets: runRealChangesets(registryUrl), + testOnlyRegistry: registryUrl, + testOnlyNoGitTag: true, + }); + + expect(plan.unpublished.map((item) => [item.name, item.firstPublish])).toEqual([ + [packageNames.advisor, true], + ]); + expect(registry.publishes).toEqual([ + { name: packageNames.advisor, version: "2.0.0", tag: "latest" }, + ]); + expect(registry.packages.get(packageNames.advisor)?.distTags.latest).toBe("2.0.0"); + } finally { + await registry.close(); + } + }, 20_000); +}); + +describe("new plugins", () => { + const newPlugin = "@capybearista/opencode-new-plugin"; + + async function addPolicyEntry(cwd: string, name: string, entry: Record) { + const policyFile = path.join(cwd, "tools", "release-channels.json"); + const policy = JSON.parse(await readFile(policyFile, "utf8")) as { + packages: Record; + }; + policy.packages[name] = entry; + await writeJson(policyFile, policy); + } + + test("a package npm has never seen is planned as a first publish", async () => { + const cwd = await createFixture(); + const output: string[] = []; + + const plan = await runReleaseGuard({ + cwd, + mode: "check", + registryClient: createRegistryClient({ [packageNames.advisor]: null }), + output: (line) => output.push(line), + }); + + expect(plan.channel).toBe("latest"); + expect(plan.unpublished.map((item) => [item.name, item.firstPublish])).toEqual([ + [packageNames.advisor, true], + ]); + expect(output.join("\n")).toContain(`${packageNames.advisor}@2.0.0 first publish -> latest`); + }); + + test("a first publish must still use its class major", async () => { + const cwd = await createFixture({ [packageNames.advisor]: "1.0.0" }); + + await expectGuardRejects( + buildReleasePlan({ + cwd, + registryClient: createRegistryClient({ [packageNames.advisor]: null }), + }), + "may only publish major 2 from the v2 release class", + ); + }); + + test("a new plugin needs only its policy entry, not a guard change", async () => { + const cwd = await createFixture({}, { extraPackage: { name: newPlugin, version: "2.0.0" } }); + await addPolicyEntry(cwd, newPlugin, { releaseClass: "v2", channel: "latest" }); + + const plan = await buildReleasePlan({ + cwd, + registryClient: createRegistryClient({ [newPlugin]: null }), + }); + + expect(plan.unpublished.map((item) => item.name)).toEqual([newPlugin]); + expect(plan.channel).toBe("latest"); + }); + + test("a workspace package missing from the policy is named in the error", async () => { + const cwd = await createFixture({}, { extraPackage: { name: newPlugin, version: "2.0.0" } }); + + await expectGuardRejects( + buildReleasePlan({ cwd, registryClient: createRegistryClient() }), + `not in tools/release-channels.json: ${newPlugin}`, + ); + }); + + test("a policy entry without a workspace package is named in the error", async () => { + const cwd = await createFixture({}, { omit: packageNames.review }); + + await expectGuardRejects( + buildReleasePlan({ cwd, registryClient: createRegistryClient() }), + `not in the workspace: ${packageNames.review}`, + ); + }); + + test("a release class above the current OpenCode major is rejected", async () => { + const cwd = await createFixture({ [packageNames.advisor]: "3.0.0" }); + await addPolicyEntry(cwd, packageNames.advisor, { releaseClass: "v3", channel: "latest" }); + + await expectGuardRejects( + buildReleasePlan({ + cwd, + registryClient: createRegistryClient({ [packageNames.advisor]: null }), + }), + "OpenCode 2", + ); + }); + + test("malformed policy entries are rejected", async () => { + for (const entry of [ + { releaseClass: "2", channel: "latest" }, + { releaseClass: "v2", channel: "beta" }, + { releaseClass: "v2", channel: "latest", extra: true }, + ]) { + const cwd = await createFixture(); + await addPolicyEntry(cwd, packageNames.advisor, entry); + await expectGuardRejects( + buildReleasePlan({ cwd, registryClient: createRegistryClient() }), + `invalid policy entry for ${packageNames.advisor}`, + ); + } + }); + + test("the npm client reads a 404 as never published and fails on other statuses", async () => { + const statuses: Record = { "/missing": 404, "/down": 503, "/auth": 401 }; + const server = createServer((request, response) => { + const status = statuses[request.url ?? ""] ?? 500; + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "status" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("test server did not bind"); + const client = createPublicRegistryClient(`http://127.0.0.1:${address.port}/`); + try { + expect(await client("missing")).toBeNull(); + await expect(client("down")).rejects.toThrow("HTTP 503"); + await expect(client("auth")).rejects.toThrow("HTTP 401"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); }); test("the policy points at the public npm registry", () => { diff --git a/tools/release-channel-guard.ts b/tools/release-channel-guard.ts index 278fcb2..19037b8 100644 --- a/tools/release-channel-guard.ts +++ b/tools/release-channel-guard.ts @@ -19,38 +19,13 @@ const MAX_PACKUMENT_BYTES = 2_000_000; const NPM_CONFIG_TIMEOUT_MS = 10_000; const MAX_NPM_OUTPUT_BYTES = 8 * 1024; const execFileAsync = promisify(execFile); -const APPROVED_POLICY: Record = { - "@capybearista/opencode-agents-loader": { - releaseClass: "v2", - channel: "latest", - }, - "@capybearista/opencode-auto-advisor": { - releaseClass: "v2", - channel: "latest", - }, - "@capybearista/opencode-double-tap-timeline": { - releaseClass: "v2", - channel: "latest", - }, - "@capybearista/opencode-agent-prompt-inheritance": { - releaseClass: "v1", - channel: "latest", - }, - "@capybearista/opencode-output-styles": { - releaseClass: "v2", - channel: "latest", - }, - "@capybearista/opencode-ram-monitor": { - releaseClass: "v2", - channel: "latest", - }, - "@capybearista/opencode-adversarial-review": { - releaseClass: "v2", - channel: "latest", - }, -}; +/** + * The newest OpenCode major. A release class above it would publish a plugin major that is + * reserved for an OpenCode release that does not exist yet (3.x is for OpenCode V3). + */ +const CURRENT_OPENCODE_MAJOR = 2; +const RELEASE_CHANNELS: readonly string[] = ["latest", "opencode2"]; -// A class names the OpenCode major its packages target; class vN publishes only major N. type ReleaseClass = `v${number}`; type ReleaseChannel = "noop" | "latest" | "opencode2"; @@ -69,7 +44,8 @@ export type RegistrySnapshot = { distTags: Record; }; -export type RegistryClient = (name: string) => Promise; +/** Resolves to `null` when the registry has never seen the package (a first publish). */ +export type RegistryClient = (name: string) => Promise; export type ReleasePackage = { name: string; @@ -77,6 +53,8 @@ export type ReleasePackage = { releaseClass: ReleaseClass; channel: Exclude; published: boolean; + /** Not on the registry at all yet. */ + firstPublish: boolean; }; export type ReleasePlan = { @@ -163,10 +141,10 @@ export async function buildReleasePlan(options: BuildPlanOptions = {}): Promise< timeoutMs, `registry metadata query timed out for ${item.name}`, ); - validateRegistrySnapshot(item.name, snapshot); + if (snapshot !== null) validateRegistrySnapshot(item.name, snapshot); const policyEntry = policy.packages[item.name]; - const published = hasVersion(snapshot, item.version); + const published = snapshot !== null && hasVersion(snapshot, item.version); const classMajorVersion = classMajor(policyEntry.releaseClass); if (!published && majorVersion(item.version) !== classMajorVersion) { throw new ReleaseGuardError( @@ -180,6 +158,7 @@ export async function buildReleasePlan(options: BuildPlanOptions = {}): Promise< releaseClass: policyEntry.releaseClass, channel: policyEntry.channel, published, + firstPublish: snapshot === null, }); } @@ -334,7 +313,11 @@ function printPlan(plan: ReleasePlan, output: (line: string) => void) { } for (const item of plan.packages) { output( - `${item.name}@${item.version} ${item.published ? "published" : `unpublished -> ${plan.channel}`}`, + `${item.name}@${item.version} ${ + item.published + ? "published" + : `${item.firstPublish ? "first publish" : "unpublished"} -> ${plan.channel}` + }`, ); } } @@ -359,29 +342,21 @@ function validatePolicy(policy: ReleasePolicy) { if (normalizeRegistry(policy.registry) !== normalizeRegistry(PUBLIC_REGISTRY)) { throw new ReleaseGuardError("release channel policy must use the public npm registry"); } - const entries = Object.entries(policy.packages); - const approvedNames = Object.keys(APPROVED_POLICY).sort(); - if ( - entries.length !== approvedNames.length || - entries - .map(([name]) => name) - .sort() - .some((name, index) => name !== approvedNames[index]) - ) { - throw new ReleaseGuardError( - "release channel policy must contain exactly six approved packages", - ); - } - for (const [name, rawEntry] of entries) { - if (!isPlainObject(rawEntry)) throw new ReleaseGuardError(`invalid policy entry for ${name}`); - const expected = APPROVED_POLICY[name]; + // The workspace check in readWorkspace makes this list match packages/ exactly. + for (const [name, entry] of Object.entries(policy.packages)) { if ( - rawEntry.releaseClass !== expected.releaseClass || - rawEntry.channel !== expected.channel || - Object.hasOwn(rawEntry, "frozenLatest") + !isPlainObject(entry) || + Object.keys(entry).sort().join() !== "channel,releaseClass" || + typeof entry.releaseClass !== "string" || + !/^v[1-9]\d*$/.test(entry.releaseClass) || + typeof entry.channel !== "string" || + !RELEASE_CHANNELS.includes(entry.channel) ) { + throw new ReleaseGuardError(`invalid policy entry for ${name}`); + } + if (classMajor(entry.releaseClass as ReleaseClass) > CURRENT_OPENCODE_MAJOR) { throw new ReleaseGuardError( - `policy entry for ${name} does not match the approved release class`, + `${name} is in release class ${entry.releaseClass}, above the current OpenCode ${CURRENT_OPENCODE_MAJOR}`, ); } } @@ -431,14 +406,16 @@ async function readWorkspace(cwd: string, policy: ReleasePolicy): Promise name !== [...expected].sort()[index]) - ) { - throw new ReleaseGuardError( - "workspace policy mismatch: expected exactly the six approved packages", - ); + const unlisted = [...manifests.keys()].filter((name) => !Object.hasOwn(policy.packages, name)); + const missing = expected.filter((name) => !manifests.has(name)); + if (unlisted.length > 0 || missing.length > 0) { + const problems = [ + unlisted.length > 0 + ? `not in tools/release-channels.json: ${unlisted.sort().join(", ")}` + : "", + missing.length > 0 ? `not in the workspace: ${missing.sort().join(", ")}` : "", + ].filter(Boolean); + throw new ReleaseGuardError(`workspace policy mismatch: ${problems.join("; ")}`); } return expected.map((name) => { @@ -534,7 +511,8 @@ async function validateRegistryConfiguration( } } -function createPublicRegistryClient(registry: string): RegistryClient { +/** npm's metadata API; a 404 is a package it has never seen. Any other failure fails closed. */ +export function createPublicRegistryClient(registry: string): RegistryClient { return async (name) => { const url = `${registry}${encodeURIComponent(name)}`; let response: Response; @@ -546,6 +524,7 @@ function createPublicRegistryClient(registry: string): RegistryClient { } catch { throw new ReleaseGuardError(`registry metadata request failed for ${name}`); } + if (response.status === 404) return null; if (!response.ok) throw new ReleaseGuardError( `registry metadata request for ${name} returned HTTP ${response.status}`, diff --git a/tools/total-downloads-worker.js b/tools/total-downloads-worker.js index 06dea66..7a6c2e3 100644 --- a/tools/total-downloads-worker.js +++ b/tools/total-downloads-worker.js @@ -4,6 +4,7 @@ export default { "@capybearista/opencode-adversarial-review", "@capybearista/opencode-agent-prompt-inheritance", "@capybearista/opencode-agents-loader", + "@capybearista/opencode-auto-advisor", "@capybearista/opencode-double-tap-timeline", "@capybearista/opencode-output-styles", "@capybearista/opencode-ram-monitor", From 9ef7b52741157eedebf8aafd6f059a185ac8ac04 Mon Sep 17 00:00:00 2001 From: Arjun Pramanik Date: Sun, 1 Nov 2026 21:34:53 +0000 Subject: [PATCH 2/2] docs: point the release-class rule at release-channels.json --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 75c31db..4379349 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Five packages have V2 ports, one is V2-only, and one remains V1-only. [`docs/v1- - **MUST** keep V1 and V2 lines apart. The loader and timeline V1 releases are frozen but available by exact pin. Both lines may share the default config directory, so use separate profiles (`OPENCODE_CONFIG_DIR`) for side-by-side testing. - **SHOULD** manage V2 targets with `opencode plugin check` / `opencode plugin update`; a restart or reload alone does not upgrade a cached target, and exact pins stay fixed. - **MUST** keep plugin majors in step with OpenCode majors: a package in release class `vN` only ever publishes major N. Ship breaking changes within an OpenCode major as a `minor` changeset, never `major`. 3.x is reserved for OpenCode V3. `bun run release:changesets` (CI: Changesets workflow) and the publish guard enforce this. -- When a package moves to a new OpenCode major, change its `releaseClass` in both `tools/release-channels.json` and the guard's `APPROVED_POLICY` in the same PR as its `major` changeset. That bump is the only `major` the guard allows. +- When a package moves to a new OpenCode major, change its `releaseClass` in `tools/release-channels.json` in the same PR as its `major` changeset; the guard rejects a class above `CURRENT_OPENCODE_MAJOR`. That bump is the only `major` the guard allows. - Channel source of truth: [`tools/release-channels.json`](tools/release-channels.json), enforced by [`tools/release-channel-guard.ts`](tools/release-channel-guard.ts). - **MUST** keep `@opencode/plugin` a required peer in server plugins: the server only serves its own copy from OpenCode 2.0.25, and older hosts fail with `Cannot find package '@opencode/plugin'` when the plugin lacks one. TUI-only plugins mark it optional (`peerDependenciesMeta`) because every 2.x TUI serves `@opencode/plugin/tui` itself; a combined server + TUI package (output-styles) keeps it required. Details: [`docs/opencode-v2-plugin-runtime.md`](docs/opencode-v2-plugin-runtime.md).