diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b2579a..14061ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,8 @@ jobs: id: package env: RELEASE_TAG: ${{ github.event.release.tag_name }} - run: bun scripts/release.ts prepare --tag "$RELEASE_TAG" --output release-artifacts + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + run: bun scripts/release.ts prepare --tag "$RELEASE_TAG" --prerelease "$RELEASE_PRERELEASE" --output release-artifacts - name: Generate artifact attestation uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 @@ -70,6 +71,9 @@ jobs: env: ARCHIVE: ${{ steps.package.outputs.archive }} ARCHIVE_SHA256: ${{ steps.package.outputs.sha256 }} + DIST_TAG: ${{ steps.package.outputs.dist_tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} run: | - bun scripts/release.ts verify --archive "$ARCHIVE" --sha256 "$ARCHIVE_SHA256" - npm publish "$ARCHIVE" --access public + bun scripts/release.ts verify --archive "$ARCHIVE" --sha256 "$ARCHIVE_SHA256" --tag "$RELEASE_TAG" --dist-tag "$DIST_TAG" --prerelease "$RELEASE_PRERELEASE" + npm publish "$ARCHIVE" --access public --tag "$DIST_TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index d113070..a66507f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,18 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ## [Unreleased] +## [1.1.0-beta.1] + +### Added + +- Opt-in beta for OpenCode >=2.1.1 <2.2 and Beads >=1.3.1: the normal public plugin entrypoint registers the eleven accepted native Beads code-mode operations, 30 workflows and packaged guidance with 17 resource links. Native permissions, executor attribution, store guard, cancellation and structured partial/uncertain outcomes are preserved. +- Scope-bounded task execution, actor-owned checkpoints and recovery guidance from the accepted workflow-parity implementation. Plain nested permission decline can allow a later independent operation to ask again; explicit Stop/Cancel stops the composition. No cross-provider portability or multi-hour reliability claim is made. + +### Changed + +- Release preparation validates beta/next versus stable/latest and the GitHub prerelease flag. Publication uses the exact prepared and digest-verified archive with an explicit npm dist-tag; beta publication leaves stable latest unchanged. +- README documents exact beta and next-channel opt-in, prerequisites and stable opt-out. Publication and stable promotion still require separate approval. + ### Fixed - The release tooling and its tests now work on Windows: archive paths use `/`, archive listings with CRLF line endings are parsed, and the workflow assertion no longer requires LF line endings. CI now also runs the tests, typecheck, and package smoke test on Windows; the bd contract check stays Linux-only. @@ -321,7 +333,7 @@ Documentation-only release; the plugin code is unchanged from 1.0.0. It updates - Josh Thomas (maintainer) -[unreleased]: https://github.com/snarkipus/opencode-beads/compare/v1.0.2...HEAD +[unreleased]: https://github.com/snarkipus/opencode-beads/compare/v1.1.0-beta.1...HEAD [1.0.2]: https://github.com/snarkipus/opencode-beads/releases/tag/v1.0.2 [1.0.1]: https://github.com/snarkipus/opencode-beads/releases/tag/v1.0.1 [1.0.0]: https://github.com/snarkipus/opencode-beads/releases/tag/v1.0.0 @@ -351,3 +363,5 @@ Documentation-only release; the plugin code is unchanged from 1.0.0. It updates [0.5.5]: https://github.com/joshuadavidthomas/opencode-beads/releases/tag/v0.5.5 [0.6.0]: https://github.com/joshuadavidthomas/opencode-beads/releases/tag/v0.6.0 [0.7.0]: https://github.com/joshuadavidthomas/opencode-beads/releases/tag/v0.7.0 + +[1.1.0-beta.1]: https://github.com/snarkipus/opencode-beads/releases/tag/v1.1.0-beta.1 diff --git a/README.md b/README.md index 417879c..db7f6ba 100644 --- a/README.md +++ b/README.md @@ -11,21 +11,13 @@ This is an independent project maintained by Matt Jackson under the MIT license. It began as a fork of [Josh Thomas's `opencode-beads`](https://github.com/joshuadavidthomas/opencode-beads), and the original work remains credited to its author. 1.0 rebuilt the plugin for OpenCode 2, and development now continues here independently. It vendors nothing from upstream Beads; instead, every Beads release is checked against the `bd` behavior the plugin relies on (see [Tracking upstream Beads](#tracking-upstream-beads)). -This branch contains an unpublished **beta/next source candidate**, separate from the published 1.0.2 behavior described below. The [bounded installed/model evaluation](docs/opencode-installed-model-continuation-vuy9.md) now passes on worker evidence for exact OpenCode 2.1.1/Beads 1.3.1 and genuine OpenAI Sol/high; parent acceptance is complete for all 16 implementation tasks. See [final acceptance and beta release gates](docs/code-mode-final-acceptance-vuy9.md). All 19 requirements and the selected corpus plus CODE_MODE have evidence mappings. Its package includes `assets/oc-beads/` and explicitly registers the `oc-beads` skill at the installed package path, with all 15 corpus resources plus CODE_MODE and ADR (17 links) linked from that base. No manual skill copy is needed. The external `bd` CLI and project initialization remain prerequisites; the plugin does not install or upgrade them. - -The candidate registers all 29 selected `oc-beads:` workflows and `oc-beads:status` through one ordinary native command transform. Ordinary user configuration/file commands run later and win on initial load and rebuild; removing an override restores the package definition. Other plugin conflicts follow native ordering, with initial collisions diagnosed rather than withheld. Status reports registration and effective name/description mismatches; public owner identity and identical-description overrides are unobservable, and matching descriptions do not prove ownership. Ordinary typed continuity execution remains withheld. Live typed qualification used the existing internal dependency through a scratch-only bootstrap importing the unchanged actual installed package; it supplies no operator activation switch. The current report separates actual semantic/provider-native observations, reused cleanup/resource/Stop evidence, historical failed probes and unmeasured cross-route or long-duration behavior. No activation option, version bump or publication is supplied by this source work. - -The source candidate defines eleven fixed operations in the native `beads` -code-mode namespace: find, get, edges, memories, recall, claim, note, selected-field -update, close, create and remember. They declare structured results, preserve -per-operation shell permissions and report partial, failed or uncertain outcomes. -Native shell is direct-only; it cannot be called inside execute. Request guidance -reports observed execute/shell capability and the actual session-and-agent Beads -actor; direct shell writes use that same actor. Catalog discovery and leaf -permissions still determine which operations can run. Status reports unknown -capability when no request has been observed. Read [CODE_MODE.md](assets/oc-beads/resources/CODE_MODE.md) -for composition and recovery. Production typed activation remains withheld -pending separate owner approval after acceptance review. +This branch prepares **1.1.0-beta.1**, an opt-in beta for npm dist-tag `next`; publication remains pending owner approval. The beta's normal public entrypoint enables the eleven qualified operations in the native `beads` code-mode namespace on supported hosts: find, get, edges, memories, recall, claim, note, selected-field update, close, create and remember. No wrapper, internal dependency injection, manual skill copy or activation setting is needed. The opt-in is selecting the beta artifact. The published stable 1.0.2 package is unchanged. + +The beta registers 29 `oc-beads:` workflows plus `oc-beads:status` through ordinary native command registration and owns its packaged `oc-beads` skill at the installed path, with all 15 corpus resources plus CODE_MODE and ADR (17 links). User configuration/file commands retain native precedence on load and rebuild; removing an override restores the package definition. Other plugin conflicts follow native ordering. Status reports observed registration and name/description mismatches; identical-description overrides and public owner identity remain unobservable. + +Typed registration requires the host tool, permission and session interfaces. Status reports unavailable interfaces honestly; actual request capabilities, catalog discovery and per-operation permissions determine which operations can run. Native shell remains direct-only and cannot be called inside execute. Structured results preserve partial, failed and uncertain outcomes, and writes retain the actual session-and-agent Beads actor. Read [CODE_MODE.md](assets/oc-beads/resources/CODE_MODE.md) for composition and recovery. + +All 16 implementation tasks have parent acceptance on exact OpenCode 2.1.1 / Beads 1.3.1. The [final acceptance record](docs/code-mode-final-acceptance-vuy9.md) and [bounded installed/model evaluation](docs/opencode-installed-model-continuation-vuy9.md) retain their historical candidates and probes; this beta adds the public entrypoint and release-channel wiring. **Known limitation:** plain decline of a nested permission request does not stop the entire composition; a later independent operation can ask again. Use explicit Stop/Cancel to stop the composition. No token-saving, cross-provider checkpoint portability or multi-hour reliability claim is made. ## Design @@ -39,8 +31,7 @@ The beta/next candidate uses scope-bounded task execution: ## Installation -This plugin requires OpenCode 2 and `bd` 1.3.1 or later. This source branch prepares an opt-in beta/next candidate for OpenCode `>=2.1.1 <2.2`; the exact 2.1.1 released host now has bounded source/native/Stop and installed/model evidence, with final parent acceptance pending. Other 2.1 patches are not inferred qualified. The published stable package remains separately qualified against OpenCode 2.0.26. - +The beta requires OpenCode `>=2.1.1 <2.2` and `bd` 1.3.1 or later. Exact OpenCode 2.1.1 has bounded native, Stop and installed/model evidence; other admitted 2.1 patches are not inferred qualified. The external `bd` CLI and project initialization remain prerequisites; the plugin does not install or upgrade them. Stable 1.0.2 remains separately qualified against OpenCode 2.0.26. 1. Install the [`bd` CLI](https://github.com/gastownhall/beads/blob/main/docs/getting-started/installation.md) once on the host, then initialize each project: ```bash @@ -48,19 +39,21 @@ This plugin requires OpenCode 2 and `bd` 1.3.1 or later. This source branch prep bd init ``` - `bd init` owns project initialization and creates the canonical shared Beads skill at `.agents/skills/beads`, which OpenCode discovers. It also writes a minimal Beads section to `AGENTS.md` that points to `bd prime`, the same pointer Beads gives Claude Code in `CLAUDE.md`. The published stable package relies on that shared skill. The beta/next source candidate additionally owns its packaged `oc-beads` skill; it does not write startup files or change `.agents/skills/beads/SKILL.md`. + `bd init` owns project initialization and creates the canonical shared Beads skill at `.agents/skills/beads`, which OpenCode discovers. It also writes a minimal Beads section to `AGENTS.md` that points to `bd prime`, the same pointer Beads gives Claude Code in `CLAUDE.md`. The published stable package relies on that shared skill. The beta additionally owns its packaged `oc-beads` skill; it does not write startup files or change `.agents/skills/beads/SKILL.md`. -2. Add the version-pinned plugin to `~/.config/opencode/opencode.json` for every project, or to a project's `.opencode/opencode.json`: +2. After beta publication, add the version-pinned plugin to `~/.config/opencode/opencode.json` for every project, or to a project's `.opencode/opencode.json`: ```json { - "plugins": ["@snarkipus/opencode-beads@1.0.2"] + "plugins": ["@snarkipus/opencode-beads@1.1.0-beta.1"] } ``` + To follow the beta channel after publication, use `"plugins": ["@snarkipus/opencode-beads@next"]` instead. To stay on stable, use `"plugins": ["@snarkipus/opencode-beads@latest"]` (currently 1.0.2); do not select the beta. Stable promotion requires separate approval. + OpenCode loads configuration changes automatically. Update the pin explicitly when upgrading; an unpinned package entry is less reproducible. OpenCode 1.x plugins are not supported; use 0.10.x or earlier there. -3. Check the install: `opencode debug agents` lists `beads-task-agent`. +3. Check the install: `opencode debug agents` lists `beads-task-agent`; `/oc-beads:status` reports native registration and observed capabilities. Discover `beads` through execute on a host/agent with the required permissions. ### Plugin options @@ -70,7 +63,7 @@ Plugin options use the object form: { "plugins": [ { - "package": "@snarkipus/opencode-beads@1.0.2", + "package": "@snarkipus/opencode-beads@1.1.0-beta.1", "options": { "luna": true, "autoMigrate": false } } ] @@ -130,7 +123,7 @@ Published stable 1.0.2 registers no slash commands; `bd setup claude` installs n Remove the plugin's `plugins` entry to unload it. Cleanup aborts owned context/process work and its event observer, then awaits the scoped agent, skill, tool and hook disposers. It is idempotent and reports disposal failures. It never promises a final checkpoint after interruption. Reload starts with fresh session/location state. Package updates/removal belong to OpenCode's package lifecycle; this plugin has no installer or recursive asset watcher. -Existing `oc-beads` skill IDs or names are checked and diagnosed in each rebuilt candidate; removing a customization allows the packaged skill to return. There is no permanent setup-time skill gate. Ordinary user configuration/file commands retain their later native precedence; same-name other-plugin conflicts follow native order rather than universal collision prevention. Disposing a registration removes only the package's transform; it does not delete user command/skill bodies, shared Beads skills, project data, startup files or global settings. Do not delete those files as plugin cleanup. Installed resource reads still require normal host permissions: external-directory ask/deny and actual model use remain unexecuted live acceptance checks. Missing/unreadable packaged guidance is diagnosed and its skill is withheld. +Existing `oc-beads` skill IDs or names are checked and diagnosed in each rebuilt candidate; removing a customization allows the packaged skill to return. There is no permanent setup-time skill gate. Ordinary user configuration/file commands retain their later native precedence; same-name other-plugin conflicts follow native order rather than universal collision prevention. Disposing a registration removes only the package's transform; it does not delete user command/skill bodies, shared Beads skills, project data, startup files or global settings. Do not delete those files as plugin cleanup. Installed resource reads still require normal host permissions; bounded native external-directory ask/deny and actual model-use evidence is recorded in the acceptance reports. Missing/unreadable packaged guidance is diagnosed and its skill is withheld. ## Agent configuration @@ -167,12 +160,12 @@ The package vendors nothing from upstream. It depends on the installed `bd` CLI | Component | Expected range | Validated baseline | | --- | --- | --- | -| OpenCode (beta/next candidate) | `>=2.1.1 <2.2` | None (exact 2.1.1 target not qualified) | +| OpenCode (beta/next candidate) | `>=2.1.1 <2.2` | 2.1.1 (bounded accepted evidence) | | OpenCode (stable 1.0.2 historical) | 2.0.26 | 2.0.26 | | `bd` CLI | 1.3.1 through 1.x | 1.3.1 | | Bun (OpenCode host runtime) | >=1.3.14 | 1.4.2 | -The beta/next candidate type-checks against `@opencode/plugin` 2.1.1 and declares an optional peer range of `>=2.1.1 <2.2`. It checks `ctx.app.version` at startup and reports an unsupported-host diagnostic before registering Beads agents or hooks. This source guard does not qualify the host range; the exact 2.1.1 target remains untested, and future 2.1 patches are admitted candidates rather than tested versions. The stable 1.0.2 qualification on 2.0.26 is historical. See the [OpenCode V2 contract](docs/opencode-sdk-contract.md). The `bd` baseline was reviewed against Beads v1.3.1 (commit `c1c4b642ac1c08d8c828007a1c2f96e47e43ef7c`). +The beta/next candidate type-checks against `@opencode/plugin` 2.1.1 and declares an optional peer range of `>=2.1.1 <2.2`. It checks `ctx.app.version` at startup and reports an unsupported-host diagnostic before registering Beads agents or hooks. This source guard does not qualify the entire host range; exact 2.1.1 has bounded accepted evidence, and future 2.1 patches are admitted candidates rather than tested versions. The stable 1.0.2 qualification on 2.0.26 is historical. See the [OpenCode V2 contract](docs/opencode-sdk-contract.md). The `bd` baseline was reviewed against Beads v1.3.1 (commit `c1c4b642ac1c08d8c828007a1c2f96e47e43ef7c`). ## Troubleshooting diff --git a/docs/beta-release-preparation-jrm.md b/docs/beta-release-preparation-jrm.md new file mode 100644 index 0000000..d596183 --- /dev/null +++ b/docs/beta-release-preparation-jrm.md @@ -0,0 +1,36 @@ +# Beta preparation — opencode-beads-jrm + +## Parent-reviewed candidate + +- Package: `@snarkipus/opencode-beads@1.1.0-beta.1`. +- Intended npm dist-tag: **next**. Registry **latest remains 1.0.2**; no publication or tag mutation has occurred. +- Exact prepared archive: `/opt/data/cache/scratch/oc-beads-beta-preparation/archive/snarkipus-opencode-beads-1.1.0-beta.1.tgz`. +- SHA-256: `c78a1bb98e0703172205c4f46bc9e5ea26bf50eb1daf2280ff5ad09e169cceea`. +- Baseline: merged implementation `450176905e46ac96f0f2aab23c9b1cc5281cd62b`. +- Sole implementation executor: `codex:oc-beta`, Sol/high, session `01a16067-fbf7-75f1-a047-7513ae39d5b6`. + +The normal public plugin entrypoint now enables the already-qualified registration. Opt-in means selecting this beta artifact; no external wrapper, dependency injection or additional activation setting is required. Existing version admission, host interface checks, per-operation permissions, identity, guards, cancellation and outcomes remain intact. No argv/normalizer/runner changes were made. + +Release preparation supports stable/latest and beta/next explicitly. GitHub prerelease metadata must agree with the package version; conflicting or missing CLI metadata fails before an archive or publication. The protected release workflow verifies the one prepared archive and passes an explicit dist-tag to npm. Existing OIDC, attestation and immutable archive handling are retained. + +## Verification + +Parent independently ran **196 tests, zero failures**, typecheck, package smoke and exact-archive verification with `--tag v1.1.0-beta.1 --dist-tag next --prerelease true`. Packed-package tests exercise the ordinary public setup, eleven native code-mode definitions, 30 commands, 17 resource links and owned disposal. Channel tests cover stable/beta and mismatched metadata; the implementation worker retained RED/GREEN evidence under the scratch root. + +A short actual OpenCode **2.1.1** native run loaded the ordinary unchanged archive through package configuration, without wrapper or internal qualification injection. Native search observed all eleven `tools.beads.*` signatures; actual native execute returned an acknowledged structured get for the disposable `BETA_PUBLIC_ENTRY_READ_PROOF` issue. Controlled provider responses drive these entrypoint mechanics; prior genuine model semantics remain the accepted implementation evidence, not a repeated model campaign. Parent inspected the native messages and independently compared all **31 archive files** to installed and current source bytes. + +All **145 protected repository paths** remained unchanged. Parent independently confirmed owned listener absence. Operator executable/config/auth remained unchanged; the controlled fixture used no genuine provider account. Initial scratch harness attempts and their logs are retained separately; only the final `native4` observations are passing evidence. No registry or operator mutation is implied by local archive preparation. + +## Publication boundary + +This report is preparation evidence, not proof of a public release. Source/CI review precedes tag/GitHub prerelease/npm publication. The implementation worker did not commit, push, tag, publish or close the release task. Parent owns source integration; owner approval gates actual publication. After publication, verify `next=1.1.0-beta.1`, `latest=1.0.2`, registry/release archive digests and exact clean-consumer installation before closing the release task. + +Proposed owner installation after publication: + +```json +{ + "plugins": ["@snarkipus/opencode-beads@1.1.0-beta.1"] +} +``` + +`@next` follows the beta channel; the exact version is preferable for evaluation reproducibility. OpenCode `>=2.1.1 <2.2` and Beads `>=1.3.1` remain prerequisites. No operator runtime upgrade or installation is performed automatically. diff --git a/package.json b/package.json index 8756690..0c7da9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@snarkipus/opencode-beads", - "version": "1.0.2", + "version": "1.1.0-beta.1", "type": "module", "description": "A maintained OpenCode V2 integration for the Beads issue tracker: bd prime context injection with a schema-migration guard, and a bounded task agent.", "author": "Matt Jackson", diff --git a/scripts/release.ts b/scripts/release.ts index 425933f..33621db 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -72,22 +72,47 @@ function requireIdentity(manifest: PackageManifest): { name: string; version: st if (typeof manifest.name !== "string" || !manifest.name) { throw new Error("package.json name must be a non-empty string"); } - if ( - typeof manifest.version !== "string" || - !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version) - ) { + if (typeof manifest.version !== "string") { throw new Error("package.json version must be a valid semver release"); } + releaseChannel(manifest.version); return { name: manifest.name, version: manifest.version }; } +export interface ReleaseChannel { + distTag: string; + prerelease: boolean; +} + +/** Support stable/latest and the explicitly opted-in beta/next channel only. */ +export function releaseChannel(version: string): ReleaseChannel { + const numeric = "(?:0|[1-9]\\d*)"; + if (new RegExp(`^${numeric}\\.${numeric}\\.${numeric}$`).test(version)) { + return { distTag: "latest", prerelease: false }; + } + if (new RegExp(`^${numeric}\\.${numeric}\\.${numeric}-beta\\.[1-9]\\d*$`).test(version)) { + return { distTag: "next", prerelease: true }; + } + throw new Error(`Unsupported release channel/version: ${version}`); +} + +/** Refuse contradictory npm and GitHub channel metadata before building or publishing. */ +function validateChannel(version: string, metadata: ReleaseChannel): void { + const expected = releaseChannel(version); + if (metadata.distTag !== expected.distTag || metadata.prerelease !== expected.prerelease) { + throw new Error(`Release channel mismatch: ${version} requires --tag ${expected.distTag} and prerelease=${expected.prerelease}`); + } +} + /** Validate release identity across the Git tag and checked-in user-facing metadata. */ export async function validateReleaseMetadata( projectDirectory: string, - releaseTag: string + releaseTag: string, + channel?: ReleaseChannel ): Promise<{ name: string; version: string }> { const manifest = await readManifest(projectDirectory); const identity = requireIdentity(manifest); + if (channel) validateChannel(identity.version, channel); const expectedTag = `v${identity.version}`; if (releaseTag !== expectedTag) { throw new Error(`Release tag ${releaseTag} does not match package version ${expectedTag}`); @@ -112,8 +137,8 @@ export async function validateReleaseMetadata( const documentedVersions = [...releaseReferences.matchAll(referencePattern)].map( (match) => match[1] ); - if (documentedVersions.length === 0) { - throw new Error(`README.md has no versioned ${identity.name} installation example`); + if (!documentedVersions.includes(identity.version)) { + throw new Error(`README.md has no versioned ${identity.name} installation example; expected ${identity.version}`); } const staleVersion = documentedVersions.find((version) => version !== identity.version); if (staleVersion) { @@ -174,9 +199,10 @@ export async function inspectReleaseArchive( export async function prepareReleaseArchive( projectDirectory: string, releaseTag: string, - outputDirectory: string + outputDirectory: string, + channel?: ReleaseChannel ): Promise { - await validateReleaseMetadata(projectDirectory, releaseTag); + await validateReleaseMetadata(projectDirectory, releaseTag, channel); const output = path.resolve(projectDirectory, outputDirectory); await fs.mkdir(output, { recursive: true }); if ((await fs.readdir(output)).length !== 0) { @@ -215,25 +241,38 @@ function argument(args: string[], name: string): string { return value; } +/** Require an explicit GitHub prerelease boolean instead of treating missing values as false. */ +function prereleaseArgument(args: string[]): boolean { + const value = argument(args, "--prerelease"); + if (value !== "true" && value !== "false") throw new Error("--prerelease must be true or false"); + return value === "true"; +} + async function main(args: string[]): Promise { const command = args[0]; if (command === "prepare") { + const identity = requireIdentity(await readManifest(process.cwd())); + const channel = { ...releaseChannel(identity.version), prerelease: prereleaseArgument(args) }; const result = await prepareReleaseArchive( process.cwd(), argument(args, "--tag"), - argument(args, "--output") + argument(args, "--output"), + channel ); const archiveOutput = releaseOutputPath(process.cwd(), result.path); console.log(`Validated release archive ${archiveOutput} (${result.sha256})`); if (process.env.GITHUB_OUTPUT) { await fs.appendFile( process.env.GITHUB_OUTPUT, - `archive=${archiveOutput}\nsha256=${result.sha256}\n` + `archive=${archiveOutput}\nsha256=${result.sha256}\ndist_tag=${channel.distTag}\n` ); } return; } if (command === "verify") { + await validateReleaseMetadata(process.cwd(), argument(args, "--tag"), { + distTag: argument(args, "--dist-tag"), prerelease: prereleaseArgument(args), + }); const result = await verifyReleaseArchive( process.cwd(), argument(args, "--archive"), @@ -245,7 +284,7 @@ async function main(args: string[]): Promise { return; } throw new Error( - "Usage: bun scripts/release.ts prepare --tag --output | verify --archive --sha256 " + "Usage: bun scripts/release.ts prepare --tag --prerelease --output | verify --archive --sha256 --tag --dist-tag --prerelease " ); } diff --git a/scripts/test-package.ts b/scripts/test-package.ts index 27ca689..db85123 100644 --- a/scripts/test-package.ts +++ b/scripts/test-package.ts @@ -1,3 +1,4 @@ +import type { Info } from "@opencode/plugin/promise/tool"; import { createHash } from "node:crypto"; import type { CommandDefinition, CommandInvocation } from "@opencode/plugin/promise/command"; import * as fs from "node:fs/promises"; @@ -206,7 +207,7 @@ try { throw new Error("Packed package unexpectedly contains the companion CLI source"); } - // Exercise setup against a minimal V2 context; the context hook is registered but not invoked. + // Exercise the ordinary public default setup with native interfaces and no dependency injection. type PackedAgent = { name?: string; mode?: string; @@ -217,6 +218,8 @@ try { }; async function setupPacked(options: Record) { const registeredHooks: string[] = []; + const tools: Info[] = []; + const namespaces: Array<{ name: string }> = []; const agents = new Map(); const skills = new Map(); const userSkill = { id: "beads", name: "beads", content: "USER SHARED SKILL", path: "/user/SKILL.md" }; @@ -235,7 +238,7 @@ try { let invoking = false; const cleanup = await (plugin!.setup as (ctx: unknown) => Promise)({ app: { name: "OpenCode", version: "2.1.1", channel: "stable" }, - location: { directory: consumerDir }, + location: { directory: consumerDir, project: { directory: consumerDir } }, options, agent: { get: async () => ({ data: { mode: "primary" } }), @@ -264,7 +267,16 @@ try { return own("skill"); }, }, + permission: { assert: async () => {} }, + tool: { + hook: async () => own("tool:execute.after"), + transform: async (callback: (editor: { namespace(value: { name: string }): void; add(tool: Info): void }) => void) => { + callback({ namespace: (value) => namespaces.push(value), add: (tool) => tools.push(tool) }); + return own("tool:transform"); + }, + }, session: { + get: async () => ({ location: { directory: consumerDir } }), prompt: async (input: Record) => { if (!invoking) throw new Error("Setup must not submit a prompt"); prompts.push(input); @@ -280,6 +292,12 @@ try { }, }); if (typeof cleanup !== "function") throw new Error("Packed plugin setup returned no cleanup"); + const expectedTools = ["find", "get", "edges", "memories", "recall", "claim", "note", "update", "close", "create", "remember"].sort(); + if (namespaces.length !== 1 || namespaces[0]?.name !== "beads" + || tools.map(tool => tool.name).sort().join("\0") !== expectedTools.join("\0") + || !tools.every(tool => tool.options?.codemode === true && tool.options.namespace === "beads" && tool.options.permission === "shell")) { + throw new Error("Public packed setup did not register the eleven qualified code-mode operations"); + } const richSkill = skills.get("oc-beads"); if (richSkill?.path !== installedSkillPath || richSkill.content !== installedSkill.content) throw new Error("Installed skill registration differs from packed asset"); if (skills.get("beads") !== userSkill) throw new Error("Shared user skill was changed"); @@ -304,14 +322,16 @@ try { if (definition.name === "oc-beads:status") { const status = JSON.parse(submitted.text.split("\n")[1]!); if (status.commands.registration !== "registered" || status.commands.lookup !== "available" - || !status.commands.ownership.includes("identical-description") || status.liveQualification !== "not executed") { + || !status.commands.ownership.includes("identical-description") || status.liveQualification !== "not measured by status; see release acceptance evidence" + || status.typedContinuity !== "enabled: qualified beta registration" + || status.typedRegistration !== "registered: beads namespace, code-mode-only") { throw new Error("Packed status did not report observed registration and qualification limits"); } } else if (!submitted.text.includes(JSON.stringify(installedSkillPath))) throw new Error("Packed command uses the wrong JSON-quoted installed guidance base"); } if (prompts.length !== commands.size) throw new Error("Packed native commands did not each submit one prompt"); await cleanup(); - const expectedOwned = ["agent", "skill", "command", ...registeredHooks.map((name) => `hook:${name}`)].sort(); + const expectedOwned = ["agent", "skill", "command", "tool:execute.after", "tool:transform", ...registeredHooks.map((name) => `hook:${name}`)].sort(); if ([...owned].sort().join("\0") !== expectedOwned.join("\0") || [...disposed].sort().join("\0") !== expectedOwned.join("\0")) { throw new Error("Owned native registrations were not all disposed"); diff --git a/src/host.ts b/src/host.ts index 387206c..7f8ad50 100644 --- a/src/host.ts +++ b/src/host.ts @@ -34,9 +34,9 @@ export interface HostDependencies { diagnosticIntervalMs?: number; now?: () => number; retryMs?: number; - /** Internal qualification seam for tests; production remains false pending successful 1.1. */ + /** Enable the accepted code-mode registration; public beta entrypoint supplies true. Tests may omit it. */ continuityQualified?: boolean; - /** Overrides the same adapter dependencies used after qualification. */ + /** Test overrides for the accepted adapter dependencies; public setup uses production defaults. */ continuity?: ContinuityDependencies; /** Override package reads only; no user-facing activation or installation option. */ workflowAssets?: SkillLoadDependencies; @@ -138,7 +138,7 @@ export async function setupBeads( const references = new Map>(); const gaps = new Map>(); const requestCapabilities = new Map(); - let typedRegistration = deps.continuityQualified === true ? "unavailable: host tool/permission/session interface missing" : "withheld pending 1.1"; + let typedRegistration = deps.continuityQualified === true ? "unavailable: host tool/permission/session interface missing" : "disabled: registration not requested"; /** Retain distinct unresolved outcomes without a read, success or lifecycle cue erasing them. */ function recordGap(sessionID: string, detail: string): void { @@ -254,7 +254,7 @@ export async function setupBeads( try { effectiveSkills = ctx.skill ? (await ctx.skill.list()).data.map((entry) => String(entry.id)) : "unavailable"; } catch { effectiveSkills = "unavailable: registry lookup failed"; } return JSON.stringify({ - host: hostVersion, admission: SUPPORTED_OPENCODE_RANGE, liveQualification: "not executed", + host: hostVersion, admission: SUPPORTED_OPENCODE_RANGE, liveQualification: "not measured by status; see release acceptance evidence", commands: { registration: commandRegistration, lookup: effectiveCommands ? "available" : "failed", @@ -269,7 +269,7 @@ export async function setupBeads( ownership: "unavailable: public owner identity is not exposed; matching names/descriptions do not prove ownership; identical-description overrides cannot be detected", }, skill: { failure: skillFailure, effective: effectiveSkills }, - typedContinuity: deps.continuityQualified === true ? "internal qualification fixture" : "withheld pending 1.1", + typedContinuity: deps.continuityQualified === true ? "enabled: qualified beta registration" : "disabled: registration not requested", typedRegistration, requestCapability: requestCapabilities.get(sessionID) ?? "unknown: no actual request observation", leafAvailability: "unknown until native catalog discovery and per-operation permission checks; execute visibility alone does not prove leaf availability", @@ -316,8 +316,7 @@ export async function setupBeads( } } - // 1.1 remains unexecuted: ordinary setup has no product option that enables this path. - // Tests supply the qualification dependency and the same public host/runner interfaces. + // The public beta entrypoint enables this path; registration still requires native interfaces. if (deps.continuityQualified === true && ctx.tool && ctx.permission && ctx.session.get) { registrations.push(await ctx.tool.transform((editor) => { if (abort.signal.aborted) return; diff --git a/src/plugin.ts b/src/plugin.ts index bc2e44c..c2039a1 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,10 +3,11 @@ import type { Plugin } from "@opencode/plugin"; import { setupBeads } from "./host"; +// The prerelease artifact opts into the accepted code-mode registration. // A plain object satisfies Plugin.define's identity contract without a runtime import. const BeadsPlugin: Plugin.Plugin = { id: "opencode-beads", - setup: (ctx) => setupBeads(ctx), + setup: (ctx) => setupBeads(ctx, { continuityQualified: true }), }; export default BeadsPlugin; diff --git a/tests/docs.test.ts b/tests/docs.test.ts index 3e9a7f5..3edb82f 100644 --- a/tests/docs.test.ts +++ b/tests/docs.test.ts @@ -30,15 +30,24 @@ describe("documentation contracts", () => { const documentedVersions = [ ...readme.matchAll( - new RegExp(`${packageManifest.name.replace("/", "\\/")}@(\\d+\\.\\d+\\.\\d+)`, "g") + new RegExp(`${packageManifest.name.replace("/", "\\/")}@(\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?)`, "g") ), ].map((match) => match[1]); expect(documentedVersions.length).toBeGreaterThan(0); expect(new Set(documentedVersions)).toEqual(new Set([packageManifest.version])); }); + test("documents beta opt-in, normal public registration, stable opt-out and known Stop limits", () => { + expect(readme).toContain(`${packageManifest.name}@next`); + expect(readme).toContain(`${packageManifest.name}@latest`); + expect(readme).toContain("normal public entrypoint enables the eleven qualified operations"); + expect(readme).toContain("Use explicit Stop/Cancel"); + expect(readme).not.toContain("typed activation remains withheld"); + expect(readme).not.toContain("Ordinary typed continuity execution remains withheld"); + }); + test("presents the 1.x contract and credits the original project", () => { - expect(packageManifest.version).toMatch(/^1\.\d+\.\d+$/); + expect(packageManifest.version).toMatch(/^1\.\d+\.\d+(?:-beta\.[1-9]\d*)?$/); expect(readme).not.toContain("This plugin is intentionally small in scope"); expect(readme).not.toContain("limits its scope to bug fixes"); expect(readme).toContain("maintained OpenCode adapter"); @@ -70,7 +79,7 @@ describe("documentation contracts", () => { "bd init", "creates the canonical shared Beads skill", "`.agents/skills/beads`", - "The beta/next source candidate additionally owns its packaged `oc-beads` skill", + "The beta additionally owns its packaged `oc-beads` skill", "does not write startup files", "Update the pin explicitly when upgrading", "**Do not run `bd setup opencode`.**", @@ -138,7 +147,7 @@ describe("documentation contracts", () => { expect(sdkContract).toContain("The exact 2.1.1 runtime target has not been qualified"); expect(sdkContract).not.toContain("1.18."); expect(readme).toContain( - "| OpenCode (beta/next candidate) | `>=2.1.1 <2.2` | None (exact 2.1.1 target not qualified) |" + "| OpenCode (beta/next candidate) | `>=2.1.1 <2.2` | 2.1.1 (bounded accepted evidence) |" ); expect(readme).toContain("| OpenCode (stable 1.0.2 historical) | 2.0.26 | 2.0.26 |"); expect(readme).toContain("| `bd` CLI | 1.3.1 through 1.x | 1.3.1 |"); diff --git a/tests/host.test.ts b/tests/host.test.ts index 2be65f6..9f88d19 100644 --- a/tests/host.test.ts +++ b/tests/host.test.ts @@ -129,6 +129,45 @@ describe("OpenCode V2 entry", () => { }); }); +describe("public beta entrypoint", () => { + test("does not register typed operations when native permission or session interfaces are unavailable", async () => { + for (const missing of ["permission", "session"]) { + const f = fakeContext(); + const t = toolFixture(f); + if (missing === "session") Object.assign(f.ctx, { permission: { assert: async () => {} } }); + else Object.assign(f.ctx.session, { get: async () => ({ location: { directory: "/project" } }) }); + const cleanup = await pluginModule.default.setup(f.ctx as Parameters[0]); + expect(t.tools).toEqual([]); + await cleanup!(); + } + }); + + test("registers the eleven code-mode operations without injected dependencies and disposes owned tools", async () => { + const f = fakeContext(); + const tools: Info[] = []; + const namespaces: unknown[] = []; + const dispose = mock(async () => {}); + Object.assign(f.ctx, { + permission: { assert: async () => {} }, + tool: { + hook: async () => ({ dispose }), + transform: async (callback: Parameters[0]) => { + callback({ list: () => [], get: () => undefined, update: () => {}, remove: () => {}, + namespace: (namespace) => { namespaces.push(namespace); }, add: (tool) => { tools.push(tool); } }); + return { dispose }; + }, + }, + }); + Object.assign(f.ctx.session, { get: async () => ({ location: { directory: "/project" } }) }); + const cleanup = await pluginModule.default.setup(f.ctx as Parameters[0]); + expect(namespaces).toEqual([expect.objectContaining({ name: "beads" })]); + expect(tools.map(tool => tool.name).sort()).toEqual(["find", "get", "edges", "memories", "recall", "claim", "note", "update", "close", "create", "remember"].sort()); + expect(tools.every(tool => tool.options?.codemode === true && tool.options.namespace === "beads")).toBeTrue(); + await cleanup!(); + expect(dispose).toHaveBeenCalledTimes(2); + }); +}); + describe("setupBeads", () => { test("refreshes recovery state for external changes, isolates fork/move/audience and never restarts on interruption", async () => { const f = fakeContext(); diff --git a/tests/release.test.ts b/tests/release.test.ts index bebd93e..9bc6c65 100644 --- a/tests/release.test.ts +++ b/tests/release.test.ts @@ -7,6 +7,7 @@ import { inspectReleaseArchive, prepareReleaseArchive, releaseOutputPath, + releaseChannel, validateReleaseMetadata, verifyReleaseArchive, } from "../scripts/release"; @@ -69,6 +70,34 @@ describe("release artifact identity", () => { ); }); + test("routes stable and beta releases explicitly and refuses conflicting channel metadata", async () => { + expect(releaseChannel("1.2.3")).toEqual({ distTag: "latest", prerelease: false }); + expect(releaseChannel("1.1.0-beta.1")).toEqual({ distTag: "next", prerelease: true }); + const directory = await metadataFixture(); + await expect(validateReleaseMetadata(directory, "v1.2.3", { distTag: "latest", prerelease: false })).resolves.toMatchObject({ version: "1.2.3" }); + await expect(validateReleaseMetadata(directory, "v1.2.3", { distTag: "next", prerelease: true })).rejects.toThrow("channel"); + await fs.writeFile(join(directory, "package.json"), JSON.stringify({ name: "@scope/package", version: "1.1.0-beta.1" })); + await fs.writeFile(join(directory, "CHANGELOG.md"), "## [1.1.0-beta.1]\n"); + await fs.writeFile(join(directory, "README.md"), "install @scope/package@1.1.0-beta.1 or @scope/package@next\n"); + await expect(validateReleaseMetadata(directory, "v1.1.0-beta.1", { distTag: "next", prerelease: true })).resolves.toMatchObject({ version: "1.1.0-beta.1" }); + for (const metadata of [{ distTag: "latest", prerelease: true }, { distTag: "next", prerelease: false }]) { + await expect(validateReleaseMetadata(directory, "v1.1.0-beta.1", metadata)).rejects.toThrow("channel"); + } + await fs.writeFile(join(directory, "package.json"), JSON.stringify({ name: "@scope/package", version: "1.1.0-rc.1" })); + await expect(validateReleaseMetadata(directory, "v1.1.0-rc.1")).rejects.toThrow("Unsupported release channel"); + }); + + test("CLI refuses missing or ambiguous prerelease flags before producing an archive", async () => { + const directory = await metadataFixture(); + for (const flag of [[], ["--prerelease", "yes"], ["--prerelease", "true"]]) { + const child = Bun.spawn([process.execPath, join(projectDirectory, "scripts/release.ts"), "prepare", "--tag", "v1.2.3", "--output", "out", ...flag], { cwd: directory, stdout: "pipe", stderr: "pipe" }); + const error = await new Response(child.stderr).text(); + expect(await child.exited).toBe(1); + expect(error).toMatch(/prerelease|channel/); + expect(await fs.exists(join(directory, "out"))).toBeFalse(); + } + }); + test("allows only the pinned 0.8.0 removal command as a legacy README reference", async () => { const directory = await metadataFixture(); await fs.writeFile( @@ -213,7 +242,10 @@ describe("release artifact identity", () => { expect(workflow).toContain("id-token: write"); expect(workflow).not.toContain("NPM_TOKEN"); expect(workflow).not.toContain("NODE_AUTH_TOKEN"); - expect(workflow).toContain('npm publish "$ARCHIVE" --access public'); + expect(workflow).toContain('npm publish "$ARCHIVE" --access public --tag "$DIST_TAG"'); + expect(workflow).toContain('DIST_TAG: ${{ steps.package.outputs.dist_tag }}'); + expect(workflow).toContain('RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}'); + expect(workflow).toContain('--prerelease "$RELEASE_PRERELEASE"'); expect(workflow).not.toMatch(/npm publish\s*(?:\n|$)/); }); diff --git a/tests/workflows.test.ts b/tests/workflows.test.ts index 9afc6ed..58ad66f 100644 --- a/tests/workflows.test.ts +++ b/tests/workflows.test.ts @@ -303,10 +303,10 @@ describe("packaged native workflows", () => { } }); - test("package manifest includes owned assets without a version or dependency change", async () => { + test("beta package manifest includes owned assets at the selected prerelease version", async () => { const manifest = await Bun.file("package.json").json(); expect(manifest.files).toContain("assets/oc-beads"); - expect(manifest.version).toBe("1.0.2"); + expect(manifest.version).toBe("1.1.0-beta.1"); }); });