@bybrawe/opencode-loop 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.3 - 2026-10-02
4
+
5
+ - Fix native OpenCode 2 fresh installs on Windows/Linux by registering the exact published Loop package in plural `plugins` instead of copying a loose global `plugins/opencode-loop` directory.
6
+ - Let OpenCode resolve `@opencode/plugin` from Loop's published production dependency graph, eliminating the reproduced `Cannot find package '@opencode/plugin'` startup failure from the 0.6.1/0.6.2 loose-install layout.
7
+ - Automatically migrate known loose V2 Loop copies on update while preserving project Loop state, unrelated plugin registrations, JSONC comments and configured package options.
8
+ - Add installer regressions for fresh package registration, idempotence and migration of a broken loose V2 copy.
9
+ - Keep the dedicated OpenCode Goal 1.3.42 companion unchanged and retain explicit `--legacy-v1` as the isolated compatibility path.
10
+
3
11
  ## 0.6.2 - 2026-09-30
4
12
 
5
13
  - Conform the native OpenCode 2 runtime to the migration-guide location/options boundary: project scope comes only from `ctx.location.directory`; plugin `ctx.options` can no longer masquerade as project location.
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # OpenCode Loop
2
2
 
3
- ## Native OpenCode 2 (0.6.2)
3
+ ## Native OpenCode 2 (0.6.3)
4
4
 
5
- The npm and local-file V2 entrypoints now use the official `@opencode/plugin` `Plugin.define()` contract. The local installer writes a discovered plugin package directory with the generated runtime as a supporting module, while explicit `--legacy-v1` remains a separate compatibility path.
5
+ The npm and V2 entrypoints use the official `@opencode/plugin` `Plugin.define()` contract. The installer now registers the exact `@bybrawe/opencode-loop` package in OpenCode's plural `plugins` configuration instead of copying a loose global plugin directory. OpenCode therefore resolves `@opencode/plugin` from Loop's own production dependency graph; explicit `--legacy-v1` remains a separate compatibility path.
6
6
 
7
- The installer now defaults to the standalone native V2 plugin. It needs no V1 SDK or legacy command files for a local installation. Existing npm registrations migrate from `plugin` to `plugins` without dropping object options. Use `--legacy-v1` explicitly for the compatibility installation.
7
+ The installer defaults to the native V2 package plugin. It needs no loose local V2 copy, V1 SDK, or legacy command files. Existing npm registrations migrate from `plugin` to `plugins` without dropping object options, and 0.6.1/0.6.2 loose `plugins/opencode-loop` installs are removed on update. Use `--legacy-v1` explicitly for the compatibility installation.
8
8
 
9
9
  V2 prompt admission and durable inbox/execution/compaction events own scheduling. Core-generated user-role messages never authorize a session abort. An unfinished dedicated Goal reserves its session, including paused and handed-off states; Loop does not wake or replace that Goal. Soft iteration timeouts do not abort a native model/tool/compaction turn.
10
10
 
@@ -17,32 +17,48 @@ Prompt/command timers, watch and stop conditions, verification, preflight/postru
17
17
 
18
18
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
19
19
 
20
- > **Current stable release: `0.6.2`.**
20
+ > **Current stable release: `0.6.3`.**
21
21
 
22
22
  ## Install or update
23
23
 
24
- Recommended:
24
+ ### Recommended OpenCode 2 setup
25
+
26
+ Install the dedicated Goal plugin first:
27
+
28
+ ```bash
29
+ npx -y @bybrawe/opencode-goal@latest
30
+ ```
31
+
32
+ Then install Loop by itself and disable Loop's older experimental Goal command files:
25
33
 
26
34
  ```bash
27
- npx -y @bybrawe/opencode-loop@latest
35
+ npx -y @bybrawe/opencode-loop@latest --loop-only --without-loop-goals
28
36
  ```
29
37
 
30
- Run the same command again to update. Then **fully restart OpenCode** and verify:
38
+ This is the recommended split for OpenCode 2:
39
+
40
+ - **OpenCode Goal** owns `/goal`, durable outcome-driven work, evidence, verification, recovery, and semantic completion.
41
+ - **OpenCode Loop** owns `/loop`, scheduling, repeated prompts/commands/shell work, and `opencode-loopd`.
42
+ - `--loop-only` prevents the Loop installer from installing/updating the Goal companion.
43
+ - `--without-loop-goals` removes only Loop's older experimental `/loop-goal*` command files; it does not remove the dedicated OpenCode Goal plugin.
44
+
45
+ Run the same two commands again to update both packages. Loop 0.6.3 also migrates the earlier 0.6.1/0.6.2 loose global V2 install that could fail with `Cannot find package '@opencode/plugin'`; the update removes that loose copy and pins the published package in `plugins` instead. Then **fully restart OpenCode** and verify:
31
46
 
32
47
  ```text
48
+ /goal status
33
49
  /loop-help
34
50
  /loop-doctor
35
51
  ```
36
52
 
37
- Install/update Loop and the dedicated Goals companion together:
53
+ Convenience alternative: let the Loop installer also install/update the dedicated Goal companion:
38
54
 
39
55
  ```bash
40
56
  npx -y @bybrawe/opencode-loop@latest --with-goals --without-loop-goals
41
57
  ```
42
58
 
43
- `--without-loop-goals` removes only Loop's older experimental `/loop-goal*` command files. It keeps normal `/loop`, command/shell scheduling, daemon support, and the separate `/goal` plugin.
59
+ For new OpenCode 2 installations, the explicit two-command setup above is preferred because ownership is clear and each package can be updated independently.
44
60
 
45
- Install only Loop and skip companion network work:
61
+ Install only Loop without touching Goal:
46
62
 
47
63
  ```bash
48
64
  npx -y @bybrawe/opencode-loop@latest --loop-only
@@ -0,0 +1,71 @@
1
+ # OpenCode Loop 0.6.3
2
+
3
+ OpenCode 2 installer dependency-resolution fix, paired with OpenCode Goal 1.3.42.
4
+
5
+ ## What changed
6
+
7
+ OpenCode Loop now installs natively as an exact published package registration:
8
+
9
+ ```json
10
+ {
11
+ "plugins": [
12
+ "@bybrawe/opencode-loop@0.6.3"
13
+ ]
14
+ }
15
+ ```
16
+
17
+ The default V2 installer no longer copies a loose global
18
+ `~/.config/opencode/plugins/opencode-loop` package directory.
19
+
20
+ This fixes the reproduced OpenCode 2.0.21 startup failure:
21
+
22
+ ```text
23
+ Cannot find package '@opencode/plugin' imported from
24
+ .../.config/opencode/plugins/opencode-loop/index.js
25
+ ```
26
+
27
+ The V2 entrypoint still uses the official `@opencode/plugin`
28
+ `Plugin.define({ id, setup })` contract. The difference is distribution:
29
+ OpenCode now resolves the entrypoint and `@opencode/plugin` from the same
30
+ published package dependency graph.
31
+
32
+ ## Upgrade behavior
33
+
34
+ Running the 0.6.3 installer over 0.6.1/0.6.2 automatically:
35
+
36
+ - adds or pins the exact Loop package in plural `plugins`;
37
+ - removes known loose `plugins/opencode-loop`, `opencode-loop.ts` and
38
+ `opencode-loop.js` copies;
39
+ - preserves unrelated plugin registrations and object options;
40
+ - preserves project state under `.opencode/opencode-loop/`;
41
+ - keeps explicit `--legacy-v1` behavior isolated.
42
+
43
+ ## Paired Goal release
44
+
45
+ Publication remains paired with OpenCode Goal 1.3.42 from immutable source
46
+ `1dd58abf1e7c08594c2085883e28acea3dbe0418`.
47
+
48
+ ## Verification
49
+
50
+ The release gate runs Windows/Linux installer regressions, package smoke tests,
51
+ exact-main CI, native V2 CI and the production Goal+Loop host pair before npm
52
+ publication.
53
+
54
+ Install or update:
55
+
56
+ ```sh
57
+ npx -y @bybrawe/opencode-goal@1.3.42
58
+ npx -y @bybrawe/opencode-loop@0.6.3 --loop-only --without-loop-goals
59
+ ```
60
+
61
+ Then fully restart OpenCode and verify:
62
+
63
+ ```text
64
+ /goal status
65
+ /loop-help
66
+ /loop-doctor
67
+ ```
68
+
69
+ If OpenCode itself reports that managed service port 49374 is already in use,
70
+ that is a host service collision rather than this plugin dependency bug. Stop or
71
+ reconfigure the OpenCode service before testing the commands.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Claude Code/Codex style /loop and experimental goal mode for OpenCode: heartbeat scheduler, idle-safe loops, scheduled commands, compact scheduling, verification, checkpoints, and persistent coding goals.",
5
5
  "type": "module",
6
6
  "main": "src/plugin.js",
@@ -10,6 +10,9 @@ import { fileURLToPath, pathToFileURL } from "node:url"
10
10
 
11
11
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
12
12
  const isWindows = process.platform === "win32"
13
+ const SERVER_READY_TIMEOUT_MS = isWindows ? 60_000 : 30_000
14
+ const API_BOOTSTRAP_TIMEOUT_MS = isWindows ? 90_000 : 30_000
15
+ const PREWARM_TIMEOUT_MS = isWindows ? 120_000 : 60_000
13
16
  const GOAL_OBJECTIVE = "prove dedicated Goal owns continuation over Loop"
14
17
  const GOAL_PROMPT_MARKER = "Continue working toward the active OpenCode goal."
15
18
  const LOOP_PROMPT_MARKER = "AUTONOMOUS OPENCODE LOOP ITERATION"
@@ -301,28 +304,41 @@ async function main() {
301
304
  }
302
305
 
303
306
  try {
304
- const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: 60_000 })
307
+ const prewarm = await runOpenCode(["debug", "config"], { cwd: workspace, env, timeoutMs: PREWARM_TIMEOUT_MS })
305
308
  assert.match(prewarm.stdout, /\{[\s\S]*\}/, `OpenCode config prewarm returned no JSON\n${prewarm.stdout}\n${prewarm.stderr}`)
306
309
 
307
310
  const port = await reservePort()
308
311
  server = spawnOpenCode(["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: workspace, env })
309
312
  server.stdout?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
310
313
  server.stderr?.on("data", (chunk) => { serverLog = appendLog(serverLog, chunk) })
311
- await waitForTcp(port, server, () => serverLog)
314
+ await waitForTcp(port, server, () => serverLog, SERVER_READY_TIMEOUT_MS)
312
315
 
313
316
  const baseURL = `http://127.0.0.1:${port}`
314
317
  const directoryQuery = `directory=${encodeURIComponent(workspace)}`
315
318
  const api = async (pathname, init = {}) => {
316
319
  const separator = pathname.includes("?") ? "&" : "?"
317
- const response = await fetch(`${baseURL}${pathname}${separator}${directoryQuery}`, {
318
- ...init,
319
- headers: { "content-type": "application/json", ...(init.headers ?? {}) },
320
- signal: init.signal ?? AbortSignal.timeout(30_000),
321
- })
322
- const text = await response.text()
323
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
324
- if (!text) return null
325
- try { return JSON.parse(text) } catch { return text }
320
+ const url = `${baseURL}${pathname}${separator}${directoryQuery}`
321
+ const ownTimeout = !init.signal
322
+ const timeoutMs = API_BOOTSTRAP_TIMEOUT_MS
323
+ try {
324
+ const response = await fetch(url, {
325
+ ...init,
326
+ headers: { "content-type": "application/json", ...(init.headers ?? {}) },
327
+ signal: init.signal ?? AbortSignal.timeout(timeoutMs),
328
+ })
329
+ const text = await response.text()
330
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${text}`)
331
+ if (!text) return null
332
+ try { return JSON.parse(text) } catch { return text }
333
+ } catch (error) {
334
+ const method = init.method ?? "GET"
335
+ const boundary = ownTimeout ? ` after ${timeoutMs}ms bootstrap timeout` : " with caller-owned signal"
336
+ throw new Error(
337
+ `OpenCode API ${method} ${pathname} failed${boundary}: ${error?.name ?? "Error"}: ${error?.message ?? error}\n`
338
+ + `provider=${JSON.stringify(provider.stats)}\nserver log:\n${serverLog}`,
339
+ { cause: error },
340
+ )
341
+ }
326
342
  }
327
343
 
328
344
  const sessionsPayload = await api("/session", { method: "GET" })
@@ -22,7 +22,7 @@ const installerArgs = rawInstallerArgs.filter((arg) => arg !== "--legacy-v1")
22
22
  const uninstallRequested = installerArgs.length === 1 && ["--uninstall", "uninstall", "--remove"].includes(installerArgs[0] || "")
23
23
 
24
24
  if (installerArgs.includes("--help") || installerArgs.includes("-h")) {
25
- console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update defaults to native OpenCode 2: no V1 SDK or legacy command files are needed for a local install. Existing npm registrations are pinned to the exact version and migrated to plugins while retaining object options. Use --legacy-v1 to install the compatibility loader and legacy command files explicitly.\nUninstall removes Loop package registrations plus known local plugin/command/agent files while preserving project Loop state.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
25
+ console.log(`OpenCode Loop installer/updater\n\nUsage:\n opencode-loop\n npx -y @bybrawe/opencode-loop@latest\n npx -y @bybrawe/opencode-loop@latest --uninstall\n\nInstall/update defaults to native OpenCode 2: the exact package version is registered in the plural plugins config so OpenCode resolves the package and its V2 runtime dependencies together. No loose local V2 plugin copy, V1 SDK, or legacy command files are needed. Existing npm registrations are pinned to the exact version and migrated to plugins while retaining object options. Use --legacy-v1 to install the compatibility loader and legacy command files explicitly.\nUninstall removes Loop package registrations plus known local plugin/command/agent files while preserving project Loop state.\n\nSet OPENCODE_CONFIG_DIR to target a non-default OpenCode config directory.`)
26
26
  process.exit(0)
27
27
  }
28
28
 
@@ -277,9 +277,13 @@ async function configurePackagePlugin() {
277
277
  }
278
278
  } else {
279
279
  const pinned = pinRegistration(own)
280
- if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec)) updated = rewriteExistingPluginArray(updated, parsed.plugin.filter((entry) => !isPackageSpec(entry)), "plugin")
280
+ if (Array.isArray(parsed.plugin) && parsed.plugin.some(isPackageSpec)) {
281
+ updated = rewriteExistingPluginArray(updated, parsed.plugin.filter((entry) => !isPackageSpec(entry)), "plugin")
282
+ }
281
283
  const next = [...(parsed.plugins || []).filter((entry) => !isPackageSpec(entry)), pinned]
282
- updated = parsed.plugins === undefined ? appendPluginProperty(updated, next, "plugins") : rewriteExistingPluginArray(updated, next, "plugins")
284
+ updated = parsed.plugins === undefined
285
+ ? appendPluginProperty(updated, next, "plugins")
286
+ : rewriteExistingPluginArray(updated, next, "plugins")
283
287
  }
284
288
  }
285
289
  parseJsonc(updated)
@@ -288,8 +292,35 @@ async function configurePackagePlugin() {
288
292
  if (error?.code !== "ENOENT") throw new Error(`Could not inspect ${target}: ${error.message}`)
289
293
  }
290
294
  }
295
+
296
+ // Native V2 installs are package registrations, not loose discovered copies.
297
+ // The published package owns @opencode/plugin as a production dependency, so
298
+ // OpenCode resolves the entrypoint and SDK from the same package install.
299
+ // This also migrates broken 0.6.1/0.6.2 loose installs on the next update.
300
+ if (!configured && !legacyV1) {
301
+ if (plans.length) {
302
+ const plan = plans[0]
303
+ const parsed = parseJsonc(plan.updated)
304
+ const entries = parsed.plugins || []
305
+ if (!Array.isArray(entries)) throw new Error("OpenCode config 'plugins' must be an array")
306
+ plan.updated = parsed.plugins === undefined
307
+ ? appendPluginProperty(plan.updated, [...entries, packageSpec], "plugins")
308
+ : rewriteExistingPluginArray(plan.updated, [...entries, packageSpec], "plugins")
309
+ parseJsonc(plan.updated)
310
+ } else {
311
+ const target = join(config, "opencode.json")
312
+ const updated = JSON.stringify({
313
+ $schema: "https://opencode.ai/config.json",
314
+ plugins: [packageSpec],
315
+ }, null, 2) + "\n"
316
+ plans.push({ target, source: "", updated })
317
+ }
318
+ configured = true
319
+ }
320
+
291
321
  // Validate every candidate before writing any config. Malformed later files
292
- // must not leave the earlier file migrated and the plugin half-installed.
322
+ // must not leave an earlier file migrated and the plugin half-installed.
323
+ for (const plan of plans) parseJsonc(plan.updated)
293
324
  for (const plan of plans) if (plan.source !== plan.updated) await writeFile(plan.target, plan.updated, "utf8")
294
325
  return { configured, updatedFiles: plans.filter((plan) => plan.source !== plan.updated).map((plan) => plan.target) }
295
326
  }
@@ -367,14 +398,7 @@ async function installOrUpdate() {
367
398
  await copyFile(join(root, "src", "index.js"), join(pluginDir, "opencode-loop.ts"))
368
399
  await rm(join(pluginDir, "opencode-loop.js"), { force: true })
369
400
  } else {
370
- // V2 local plugins use the same Plugin.define entrypoint as the npm package.
371
- // Keep the generated native runtime beside it as a supporting module instead
372
- // of auto-discovering that implementation object as a second plugin.
373
- await rm(join(pluginDir, "opencode-loop.ts"), { force: true })
374
- await rm(join(pluginDir, "opencode-loop.js"), { force: true })
375
- await mkdir(nativePluginDir, { recursive: true })
376
- await copyFile(join(root, "src", "v2.js"), join(nativePluginDir, "index.js"))
377
- await copyFile(join(root, "src", "native.js"), join(nativePluginDir, "native.js"))
401
+ throw new Error("Native OpenCode 2 package registration was not created")
378
402
  }
379
403
  if (legacyV1) {
380
404
  await mkdir(commandDir, { recursive: true })
@@ -388,11 +412,11 @@ async function installOrUpdate() {
388
412
  if (packageConfig.configured) {
389
413
  const pinResult = packageConfig.updatedFiles.length ? `pinned the config entry to ${packageSpec}` : `the config entry is already pinned to ${packageSpec}`
390
414
  console.log(`OpenCode Loop is already configured as a package in ${config}; ${pinResult} and removed the duplicate local plugin copy.`)
391
- } else console.log(`Installed OpenCode Loop plugin to ${legacyV1 ? join(pluginDir, "opencode-loop.ts") : nativePluginDir}`)
415
+ } else console.log(`Installed OpenCode Loop compatibility plugin to ${join(pluginDir, "opencode-loop.ts")}`)
392
416
  if (legacyV1) {
393
417
  console.log(`Installed ${packageName} commands to ${commandDir}`)
394
418
  console.log(`Installed ${packageName} local command agent to ${agentDir}`)
395
- } else console.log("Native OpenCode 2 commands are registered by the plugin; no legacy command files or V1 SDK are needed for a local install.")
419
+ } else console.log("Native OpenCode 2 is registered as an exact package plugin; OpenCode resolves @opencode/plugin from the package dependency graph. No loose local plugin copy, legacy command files, or V1 SDK are needed.")
396
420
  console.log("Restart OpenCode, then run: /loop-help")
397
421
  }
398
422
 
@@ -28,22 +28,33 @@ try {
28
28
  run(fresh)
29
29
  const native = await readFile(path.join(root, "src/native.js"), "utf8")
30
30
  const v2 = await readFile(path.join(root, "src/v2.js"), "utf8")
31
- assert.equal(await readFile(path.join(fresh, "plugins/opencode-loop/index.js"), "utf8"), v2)
32
- assert.equal(await readFile(path.join(fresh, "plugins/opencode-loop/native.js"), "utf8"), native)
31
+ const readConfig = async (directory) => JSON.parse(await readFile(path.join(directory, "opencode.json"), "utf8"))
32
+ assert.deepEqual((await readConfig(fresh)).plugins, [spec])
33
33
  assert.match(v2, /from\s+["']@opencode\/plugin["']/)
34
34
  assert.match(v2, /Plugin\.define\s*\(/)
35
35
  assert.doesNotMatch(native, /(?:from\s*|import\s*\()["']@opencode-ai\/plugin/)
36
+ assert.equal(await exists(path.join(fresh, "plugins/opencode-loop")), false)
36
37
  assert.equal(await exists(path.join(fresh, "plugins/opencode-loop.ts")), false)
37
38
  assert.equal(await exists(path.join(fresh, "package.json")), false)
38
39
  assert.equal(await exists(path.join(fresh, "commands/loop.md")), false)
39
40
  assert.equal(await exists(path.join(fresh, "agents/opencode-loop-local.md")), false)
41
+ const freshBefore = await readFile(path.join(fresh, "opencode.json"), "utf8")
40
42
  run(fresh)
43
+ assert.equal(await readFile(path.join(fresh, "opencode.json"), "utf8"), freshBefore)
44
+ cases++
45
+
46
+ const brokenLoose = await config("broken-loose")
47
+ await mkdir(path.join(brokenLoose, "plugins/opencode-loop"), { recursive: true })
48
+ await writeFile(path.join(brokenLoose, "plugins/opencode-loop/index.js"), 'import { Plugin } from "@opencode/plugin"\nexport default Plugin.define({ id: "broken", setup() {} })\n')
49
+ await writeFile(path.join(brokenLoose, "plugins/opencode-loop/native.js"), "export default {}\n")
50
+ run(brokenLoose)
51
+ assert.deepEqual((await readConfig(brokenLoose)).plugins, [spec])
52
+ assert.equal(await exists(path.join(brokenLoose, "plugins/opencode-loop")), false, "native update must remove the old loose V2 copy that cannot resolve package dependencies")
41
53
  cases++
42
54
 
43
55
  const options = { package: "@bybrawe/opencode-loop@0.5.38", options: { quiet: true, nested: { value: "preserved" } }, enabled: true }
44
56
  const obj = await config("object", { plugins: ["other-plugin", options] })
45
57
  run(obj)
46
- const readConfig = async (directory) => JSON.parse(await readFile(path.join(directory, "opencode.json"), "utf8"))
47
58
  assert.deepEqual((await readConfig(obj)).plugins, ["other-plugin", { ...options, package: spec }])
48
59
  const before = await readFile(path.join(obj, "opencode.json"), "utf8")
49
60
  run(obj)
@@ -88,8 +99,8 @@ try {
88
99
  run(upgrade)
89
100
  assert.equal(await exists(path.join(upgrade, "commands/loop.md")), false)
90
101
  assert.equal(await readFile(path.join(upgrade, "commands/loop-help.md"), "utf8"), "This is a custom user command; keep it.")
91
- assert.equal(await readFile(path.join(upgrade, "plugins/opencode-loop/index.js"), "utf8"), v2)
92
- assert.equal(await readFile(path.join(upgrade, "plugins/opencode-loop/native.js"), "utf8"), native)
102
+ assert.deepEqual((await readConfig(upgrade)).plugins, [spec])
103
+ assert.equal(await exists(path.join(upgrade, "plugins/opencode-loop")), false)
93
104
  assert.equal(await exists(path.join(upgrade, "plugins/opencode-loop.ts")), false)
94
105
  cases++
95
106
 
@@ -7,11 +7,11 @@ export function validateRelease(request, pkg, env) {
7
7
  assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
8
8
  assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
9
9
  assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
10
- assert.equal(request?.version, "0.6.2", "release version is not authorized")
10
+ assert.equal(request?.version, "0.6.3", "release version is not authorized")
11
11
  assert.equal(request?.name, "@bybrawe/opencode-loop")
12
12
  assert.equal(pkg.name, request.name)
13
13
  assert.equal(pkg.version, request.version)
14
- assert.equal(request.predecessor, "0.6.1")
14
+ assert.equal(request.predecessor, "0.6.2")
15
15
  assert.equal(request.goal?.version, "1.3.42")
16
16
  assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
17
17
  return request
@@ -6,6 +6,7 @@ import { validateRelease, releaseDecision, assertPublishedSource, waitForPublish
6
6
 
7
7
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
8
8
  const workflow = await readFile(path.join(repoRoot, ".github/workflows/publish-npm.yml"), "utf8")
9
+ const nativeV2Workflow = await readFile(path.join(repoRoot, ".github/workflows/native-v2-ci.yml"), "utf8")
9
10
  assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "checkout must use the immutable trigger SHA")
10
11
  assert.doesNotMatch(workflow, /ref:\s*main\b/, "publication cannot checkout moving main")
11
12
  assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/)
@@ -28,6 +29,8 @@ assert.match(workflow, /native-release\.mjs verify/)
28
29
  assert.match(workflow, /node_modules\/@bybrawe\/opencode-loop\/scripts\/opencode2-loop-canary.mjs/)
29
30
  assert.match(workflow, /node_modules\/@bybrawe\/opencode-goal\/dist\/server.js/)
30
31
  assert.match(workflow, /ref: \$\{\{ steps.release.outputs.goal_sha \}\}/)
32
+ assert.match(workflow, /npm install --no-save --legacy-peer-deps @opencode\/plugin@2\.0\.18/, "immutable Goal companion must build against its pinned OpenCode 2.0.18 plugin contract")
33
+ assert.match(nativeV2Workflow, /npm install --no-save --legacy-peer-deps @opencode\/plugin@2\.0\.18/, "Native V2 CI must pin the immutable Goal companion to the same OpenCode 2.0.18 plugin contract")
31
34
  assert.doesNotMatch(workflow, /secrets\.NPM_TOKEN/)
32
35
 
33
36
  const publishIndex = workflow.indexOf("npm publish --access public")
@@ -41,14 +44,14 @@ const publisher = workflow.slice(workflow.indexOf(" publish:\n"), workflow.inde
41
44
  assert.doesNotMatch(publisher, /contents:\s*write/, "OIDC publisher must not gain repository mutation privileges")
42
45
 
43
46
  const sha = "a".repeat(40)
44
- const request = { name: "@bybrawe/opencode-loop", version: "0.6.2", predecessor: "0.6.1", goal: { version: "1.3.42", sha: "b".repeat(40) } }
47
+ const request = { name: "@bybrawe/opencode-loop", version: "0.6.3", predecessor: "0.6.2", goal: { version: "1.3.42", sha: "b".repeat(40) } }
45
48
  const pkg = { name: request.name, version: request.version }
46
49
  const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
47
50
  assert.strictEqual(validateRelease(request, pkg, env), request)
48
51
  for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
49
52
  assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
50
- assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.1" }, env))
51
- assert.equal(releaseDecision(404, null, request, sha, "0.6.1"), true)
53
+ assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.2" }, env))
54
+ assert.equal(releaseDecision(404, null, request, sha, "0.6.2"), true)
52
55
  for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
53
56
  assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
54
57
  const manifest = { ...pkg, gitHead: sha }