@bybrawe/opencode-loop 0.5.29 → 0.5.30

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.30
4
+
5
+ - Harden npm publishing so a one-shot release publishes the immutable trigger SHA, verifies that commit belongs to `main`, serializes releases, confirms the package is readable from npm, and tags exactly the published snapshot.
6
+ - Extend the experimental OpenCode 2 command registry with read-only `/loop-export`, `/loop-help`, and `/loop-doctor` diagnostics.
7
+ - Keep OpenCode 2 diagnostics conservative: help advertises only implemented V2 behavior, doctor reports local runtime/state information, and full stable-plugin parity remains explicitly unclaimed.
8
+ - Expand V2 lifecycle, real `@next` command-registry, general CI, and generated-bundle gates around the new diagnostics and release-safety contracts.
9
+
3
10
  ## 0.5.29
4
11
 
5
12
  - Fix `--no-now` first-run semantics end to end: delayed jobs now wait until `createdAt + interval`, and stable `/loop-status` reports that first due time correctly instead of showing the job as immediately due.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, safe long-running continuation helpers, and the `opencode-loopd` background daemon.
6
6
 
7
- > **Current release: `0.5.29`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
7
+ > **Current release: `0.5.30`.** Loop also contains an older experimental `/loop-goal` mode, but for strong persistent Goal contracts and host-verified completion, use the separate **OpenCode Goals** plugin described below.
8
8
 
9
9
  ## Install or update
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bybrawe/opencode-loop",
3
- "version": "0.5.29",
3
+ "version": "0.5.30",
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/index.js",
@@ -21,8 +21,8 @@
21
21
  "build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool",
22
22
  "build:plugin:npm": "npm run build:plugin",
23
23
  "prepack": "node --check src/index.js",
24
- "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs",
25
- "test": "node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
24
+ "check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check scripts/install-node.mjs && node --check scripts/loopd.mjs && node --check scripts/install-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs",
25
+ "test": "node scripts/publish-workflow-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/install-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
26
26
  "canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
27
27
  "install:global": "node scripts/install-node.mjs",
28
28
  "pack:zip": "node scripts/make-zip.mjs"
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict"
2
+ import { readFile } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
7
+ const workflowPath = path.join(repoRoot, ".github", "workflows", "publish-npm.yml")
8
+ const workflow = await readFile(workflowPath, "utf8")
9
+
10
+ assert.match(workflow, /ref:\s*\$\{\{ github\.sha \}\}/, "publish workflow must checkout the release trigger SHA")
11
+ assert.doesNotMatch(workflow, /ref:\s*main\b/, "publish workflow must not checkout a moving main branch")
12
+ assert.match(workflow, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/, "release source must be verified against main history")
13
+ assert.match(workflow, /COMMIT_SHA="\$GITHUB_SHA"/, "release tag must target the immutable trigger SHA")
14
+ assert.match(workflow, /group:\s*npm-release/, "npm publication must be serialized")
15
+ assert.match(workflow, /cancel-in-progress:\s*false/, "a newer release must not cancel an in-flight publication")
16
+
17
+ const publishIndex = workflow.indexOf("npm publish --access public")
18
+ const verifyIndex = workflow.indexOf("Verify published version is readable from npm")
19
+ const tagIndex = workflow.indexOf("Create tag and GitHub release")
20
+ const cleanupIndex = workflow.indexOf("Remove one-shot release branch")
21
+ assert.ok(publishIndex >= 0 && verifyIndex > publishIndex, "npm publication must be verified after publish")
22
+ assert.ok(tagIndex > verifyIndex, "tag/release creation must happen only after npm verification")
23
+ assert.ok(cleanupIndex > tagIndex, "release branch cleanup must be the final release step")
24
+
25
+ console.log("publish workflow tests passed")
@@ -0,0 +1,74 @@
1
+ import assert from "node:assert/strict"
2
+ import { createOpenCode2DiagnosticsRuntime, OPENCODE_LOOP_V2_HELP_TEXT } from "../src/source/opencode2/diagnostics.js"
3
+
4
+ const prompts = []
5
+ const reads = []
6
+ const state = {
7
+ version: 1,
8
+ jobs: [{ id: "job_export", name: "exported", runCount: 2, paused: false }],
9
+ }
10
+ const runtime = createOpenCode2DiagnosticsRuntime({
11
+ prompt: async (request) => { prompts.push(request) },
12
+ readState: async (directory, sessionID) => {
13
+ reads.push([directory, sessionID])
14
+ return structuredClone(state)
15
+ },
16
+ runtimeVersion: "v-test",
17
+ runtimePlatform: "test-platform",
18
+ })
19
+
20
+ const unrelated = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-status", sessionID: "ses", directory: "/work" })
21
+ assert.equal(unrelated.handled, false)
22
+ assert.equal(prompts.length, 0)
23
+
24
+ const missing = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-export", sessionID: "ses" })
25
+ assert.equal(missing.handled, false)
26
+ assert.equal(missing.reason, "missing-scope")
27
+ assert.equal(prompts.length, 0)
28
+
29
+ const exported = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-export", sessionID: "ses", directory: "/work" })
30
+ assert.equal(exported.handled, true)
31
+ assert.equal(exported.accepted, true)
32
+ assert.deepEqual(reads, [["/work", "ses"]])
33
+ assert.equal(prompts.length, 1)
34
+ assert.equal(prompts[0].sessionID, "ses")
35
+ assert.equal(prompts[0].noReply, true)
36
+ assert.match(prompts[0].text, /^OpenCode loop state export:\n```json\n/)
37
+ assert.match(prompts[0].text, /"name": "exported"/)
38
+ assert.match(prompts[0].text, /"runCount": 2/)
39
+ assert.match(prompts[0].text, /\n```$/)
40
+
41
+ const help = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-help", sessionID: "ses", directory: "/work" })
42
+ assert.equal(help.handled, true)
43
+ assert.equal(help.accepted, true)
44
+ assert.equal(reads.length, 1, "loop-help must not read or mutate Loop state")
45
+ assert.equal(prompts.length, 2)
46
+ assert.equal(prompts[1].noReply, true)
47
+ assert.equal(prompts[1].text, OPENCODE_LOOP_V2_HELP_TEXT)
48
+ assert.match(prompts[1].text, /^OpenCode Loop V2 experimental help:/)
49
+ assert.match(prompts[1].text, /\/loop-status/)
50
+ assert.match(prompts[1].text, /\/loop-export/)
51
+ assert.match(prompts[1].text, /\/loop-doctor/)
52
+ assert.match(prompts[1].text, /does not yet claim full stable-plugin parity/)
53
+ assert.doesNotMatch(prompts[1].text, /\/loop-goal/)
54
+ assert.doesNotMatch(prompts[1].text, /--verify/)
55
+
56
+ const doctor = await runtime.onEvent({ kind: "command", action: "executed", name: "loop-doctor", sessionID: "ses", directory: "/work" })
57
+ assert.equal(doctor.handled, true)
58
+ assert.equal(doctor.accepted, true)
59
+ assert.equal(reads.length, 2, "loop-doctor may read state but must not mutate it")
60
+ assert.equal(prompts.length, 3)
61
+ assert.equal(prompts[2].noReply, true)
62
+ assert.match(prompts[2].text, /^OpenCode Loop V2 doctor:/)
63
+ assert.match(prompts[2].text, /- plugin: bybrawe\.opencode-loop\.v2\.experimental/)
64
+ assert.match(prompts[2].text, /- project directory: \/work/)
65
+ assert.match(prompts[2].text, /- state directory:/)
66
+ assert.match(prompts[2].text, /- active jobs: 1/)
67
+ assert.match(prompts[2].text, /- node: v-test/)
68
+ assert.match(prompts[2].text, /- platform: test-platform/)
69
+ assert.match(prompts[2].text, /- full stable parity: not claimed/)
70
+ assert.match(prompts[2].text, /- smoke test: \/loop 0s --max-runs 1/)
71
+
72
+ assert.throws(() => createOpenCode2DiagnosticsRuntime({}), /requires prompt\(\)/)
73
+
74
+ console.log("OpenCode 2 diagnostics contract passed")
@@ -30,6 +30,9 @@ assert.deepEqual(Object.keys(OPENCODE_LOOP_V2_COMMANDS), [
30
30
  "loop-clear",
31
31
  "loop-status",
32
32
  "loop-now",
33
+ "loop-export",
34
+ "loop-help",
35
+ "loop-doctor",
33
36
  ])
34
37
  assert.deepEqual(
35
38
  parseOpenCode2LoopCommandText(`${OPENCODE_LOOP_V2_COMMANDS["loop-now"].template}\n\nsecond`),
@@ -31,6 +31,18 @@ export const OPENCODE_LOOP_V2_COMMANDS = Object.freeze({
31
31
  description: "Run matching OpenCode Loop jobs on the next idle boundary.",
32
32
  template: "OpenCode Loop run-now command handled locally. Reply exactly: OK.",
33
33
  }),
34
+ "loop-export": Object.freeze({
35
+ description: "Export OpenCode Loop state for the current session.",
36
+ template: "OpenCode Loop export command handled locally. Reply exactly: OK.",
37
+ }),
38
+ "loop-help": Object.freeze({
39
+ description: "Show the experimental OpenCode 2 Loop command help.",
40
+ template: "OpenCode Loop help command handled locally. Reply exactly: OK.",
41
+ }),
42
+ "loop-doctor": Object.freeze({
43
+ description: "Show experimental OpenCode 2 Loop diagnostics.",
44
+ template: "OpenCode Loop doctor command handled locally. Reply exactly: OK.",
45
+ }),
34
46
  })
35
47
 
36
48
  export function registerOpenCode2LoopCommands(draft) {
@@ -0,0 +1,80 @@
1
+ import { readState as defaultReadState, stateDir } from "../core/state.js"
2
+
3
+ export const OPENCODE_LOOP_V2_HELP_TEXT = [
4
+ "OpenCode Loop V2 experimental help:",
5
+ "/loop 0s --max-runs 2 <prompt> autonomous prompt loop",
6
+ "/loop 5m --no-now --name later --multi <prompt> delayed named prompt loop",
7
+ "/loop 0s --command /review slash-command loop when the host exposes session.command",
8
+ "/loop-status show current Loop jobs",
9
+ "/loop-now [target] run matching jobs on the next idle boundary",
10
+ "/loop-pause [target] | /loop-resume [target] pause or resume jobs",
11
+ "/loop-stop [target] | /loop-remove [target] remove matching jobs",
12
+ "/loop-clear clear all jobs",
13
+ "/loop-export export current session Loop state as JSON",
14
+ "/loop-help show this experimental V2 help",
15
+ "/loop-doctor show local V2 diagnostics",
16
+ "Experimental V2 does not yet claim full stable-plugin parity; unsupported options fail closed.",
17
+ ].join("\n")
18
+
19
+ function scopeFrom(event) {
20
+ const directory = typeof event?.directory === "string" ? event.directory.trim() : ""
21
+ const sessionID = String(event?.sessionID || "").trim()
22
+ if (!directory || !sessionID) return undefined
23
+ return { directory, sessionID }
24
+ }
25
+
26
+ export function createOpenCode2DiagnosticsRuntime(options = {}) {
27
+ if (typeof options.prompt !== "function") throw new TypeError("V2 diagnostics runtime requires prompt()")
28
+ const readState = typeof options.readState === "function" ? options.readState : defaultReadState
29
+ const runtimeVersion = options.runtimeVersion || process.version
30
+ const runtimePlatform = options.runtimePlatform || process.platform
31
+
32
+ async function exportState(event) {
33
+ const scope = scopeFrom(event)
34
+ if (!scope) return { handled: false, reason: "missing-scope" }
35
+ const state = await readState(scope.directory, scope.sessionID)
36
+ const text = `OpenCode loop state export:\n\`\`\`json\n${JSON.stringify(state, null, 2)}\n\`\`\``
37
+ const request = { sessionID: scope.sessionID, text, noReply: true }
38
+ await options.prompt(request)
39
+ return { handled: true, accepted: true, state, request }
40
+ }
41
+
42
+ async function help(event) {
43
+ const scope = scopeFrom(event)
44
+ if (!scope) return { handled: false, reason: "missing-scope" }
45
+ const request = { sessionID: scope.sessionID, text: OPENCODE_LOOP_V2_HELP_TEXT, noReply: true }
46
+ await options.prompt(request)
47
+ return { handled: true, accepted: true, request }
48
+ }
49
+
50
+ async function doctor(event) {
51
+ const scope = scopeFrom(event)
52
+ if (!scope) return { handled: false, reason: "missing-scope" }
53
+ const state = await readState(scope.directory, scope.sessionID)
54
+ const text = [
55
+ "OpenCode Loop V2 doctor:",
56
+ "- plugin: bybrawe.opencode-loop.v2.experimental",
57
+ `- project directory: ${scope.directory}`,
58
+ `- state directory: ${stateDir(scope.directory)}`,
59
+ `- active jobs: ${(state.jobs || []).length}`,
60
+ `- node: ${runtimeVersion}`,
61
+ `- platform: ${runtimePlatform}`,
62
+ "- full stable parity: not claimed",
63
+ "- smoke test: /loop 0s --max-runs 1 continue the current task",
64
+ ].join("\n")
65
+ const request = { sessionID: scope.sessionID, text, noReply: true }
66
+ await options.prompt(request)
67
+ return { handled: true, accepted: true, state, request }
68
+ }
69
+
70
+ async function onEvent(event) {
71
+ if (event?.kind === "command" && event?.action === "executed") {
72
+ if (event?.name === "loop-export") return await exportState(event)
73
+ if (event?.name === "loop-help") return await help(event)
74
+ if (event?.name === "loop-doctor") return await doctor(event)
75
+ }
76
+ return { handled: false }
77
+ }
78
+
79
+ return Object.freeze({ onEvent, exportState, help, doctor })
80
+ }
@@ -1,5 +1,6 @@
1
1
  import { inspectOpenCode2Context } from "./capabilities.js"
2
2
  import { OPENCODE_LOOP_V2_COMMANDS, registerOpenCode2LoopCommands } from "./commands.js"
3
+ import { createOpenCode2DiagnosticsRuntime } from "./diagnostics.js"
3
4
  import { createOpenCode2PromptRuntime } from "./prompt-runtime.js"
4
5
  import { createOpenCode2RuntimeAdapter } from "./runtime-adapter.js"
5
6
 
@@ -21,12 +22,20 @@ export const OpenCodeLoopV2ExperimentalPlugin = {
21
22
  }
22
23
 
23
24
  let promptRuntime
24
- const onRuntimeEvent = async (event) => promptRuntime?.onEvent(event)
25
+ let diagnosticsRuntime
26
+ const onRuntimeEvent = async (event) => {
27
+ const promptResult = await promptRuntime?.onEvent(event)
28
+ if (promptResult?.handled) return promptResult
29
+ return await diagnosticsRuntime?.onEvent(event) ?? promptResult
30
+ }
25
31
  const runtime = createOpenCode2RuntimeAdapter(ctx, { onEvent: onRuntimeEvent })
26
32
  promptRuntime = createOpenCode2PromptRuntime({
27
33
  prompt: (request) => runtime.prompt(request),
28
34
  command: capabilities.sessionCommand ? (request) => runtime.command(request) : undefined,
29
35
  })
36
+ diagnosticsRuntime = createOpenCode2DiagnosticsRuntime({
37
+ prompt: (request) => runtime.prompt(request),
38
+ })
30
39
 
31
40
  try {
32
41
  await runtime.start()