opencode-goal-plugin 0.6.5 → 0.6.7

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.7 — 2026-08-01
4
+
5
+ - Fix `/goal` routing on OpenCode 1.17.15 and 1.18.10 by replacing the host-retained prompt parts in place, authenticating each resolved command turn (including host-expanded file attachments), framing control results with direct reporting instructions, blocking tools during those reporting turns, and excluding control responses from goal completion and progress analysis. Unreadable command attachments now become read-only error reports and pause safely without losing command provenance.
6
+ - Register all 11 agent-facing goal tools in a standard clean install through the package's direct Zod dependency; a separate optional `@opencode-ai/plugin` install is no longer required.
7
+
8
+ ## 0.6.6 — 2026-07-29
9
+
10
+ - Shard persisted goal state and lifecycle ledgers by hashed OpenCode session ID so independent sessions can run concurrently in one project. Keep same-session single-writer leases, lazy session loading, crash recovery, and safe migration of aggregate, legacy, and XDG state.
11
+
3
12
  ## 0.6.5 — 2026-07-12
4
13
 
5
14
  - Make `/goal sequence` the canonical ordered multi-goal command, retain the previous command and mode spelling as input-only compatibility aliases, correct the public auditor snapshot mode type to `"normal" | "ordered"`, and align README archive and compatibility claims with verified behavior.
package/CONTRIBUTING.md CHANGED
@@ -37,7 +37,7 @@ This plugin depends on OpenCode plugin hooks, including experimental hooks. When
37
37
  3. Test against a real OpenCode install when possible.
38
38
  4. Update the README compatibility snapshot if the tested surface changes.
39
39
 
40
- `npm run smoke` verifies the package export path and `/goal` command hook without invoking a model. The packed-host and packed-tool checks install the npm artifact in isolated consumer projects and verify the public hook and complete optional-peer tool contracts. `npm run type:check` compiles installed-package consumers with NodeNext and Bundler resolution. `npm run benchmark:behavior` covers deterministic autonomy and token-efficiency scenarios. None replaces a real OpenCode smoke test after hook, SDK, or command behavior changes.
40
+ `npm run smoke` verifies the package export path and `/goal` command hook without invoking a model. The packed-host and packed-tool checks install the npm artifact in isolated consumer projects and verify the public hooks and clean-install default tool contracts. `npm run type:check` compiles installed-package consumers with NodeNext and Bundler resolution. `npm run benchmark:behavior` covers deterministic autonomy and token-efficiency scenarios. None replaces a real OpenCode smoke test after hook, SDK, or command behavior changes.
41
41
 
42
42
  ## Release checklist
43
43
 
package/README.md CHANGED
@@ -38,7 +38,7 @@ surface and versioning expectations.
38
38
 
39
39
  Tested against real OpenCode 1.17.15 processes with live provider credentials and no mocked plugin hooks. State, ledger entries, and workspace files were checked independently of terminal or model prose:
40
40
 
41
- | OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | Hook Output Display |
41
+ | OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | v0.6.6 Hook Output Display |
42
42
  |---|---|---|---|---|---|
43
43
  | 1.17.15 | opencode (`deepseek-v4-flash-free`) | ✅ Canonical tool | ✅ Checkpoint + idle continuation | ✅ Structured `goal_complete` claim | ⚠️ Command text routed to model; mutation guard verified |
44
44
  | 1.17.15 | opencode-go (`qwen3.7-plus`) | ✅ | ✅ | ✅ Self-corrected after one rejection (bare `[goal:complete]` with no evidence), then completed cleanly | ⚠️ Not displayed |
@@ -47,7 +47,7 @@ Tested against real OpenCode 1.17.15 processes with live provider credentials an
47
47
 
48
48
  `/goal status` and auto-continue are graded on **state correctness** (verified directly against persisted state: correct limits, turn/stop accounting, completion state, and file effects), not on terminal rendering. The `deepseek-v4-flash-free` canary suite additionally covers pause/resume across processes, blocker/restart, hard-process recovery, real host compaction, and stale-history clear enforcement. See [`docs/providers.md`](docs/providers.md) for the complete lifecycle matrix and session evidence.
49
49
 
50
- **Note:** Hook output display depends on OpenCode version on 1.17.15, `command.execute.before`'s `output.parts` text is not rendered in the TUI for any provider tested; the raw command argument is instead routed to the model as a normal chat turn (see [Limitations](#limitations)). State mutations always work regardless of display: goal creation, flag parsing, auto-continue, limit enforcement, and evidence-gated completion detection were all verified correct via the persisted state file in every combination above. Re-test against your own OpenCode build before relying on unattended runs, and see [`docs/providers.md`](docs/providers.md) for the full per-model marker-compliance notes.
50
+ **Note:** The table records the v0.6.6 live-provider matrix. In that release, OpenCode 1.17.15 retained the original command-parts array, so assigning a new `output.parts` array did not replace the raw command argument sent to the model. The current implementation mutates that retained array in place, making the plugin-generated command result the prompt for the turn. OpenCode custom commands still run through the model rather than rendering hook output directly, so the visible response may summarize or paraphrase the result (see [Limitations](#limitations)). Re-test against the exact OpenCode build and provider/backend stack you rely on for unattended work, and see [`docs/providers.md`](docs/providers.md) for the full historical model matrix.
51
51
 
52
52
  ## Install
53
53
 
@@ -193,8 +193,8 @@ An ordered sequence, run as a strict pipeline:
193
193
 
194
194
  ## How it works
195
195
 
196
- 1. When you set a goal, the plugin stores it in session memory and injects it into the system prompt so the assistant keeps it in view on every turn.
197
- 2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done. Continuations retain the agent, provider/model, and variant that initiated the goal. Before sending after a cooldown, the plugin re-checks that the session is still idle and no human message, newer assistant turn, Plan-agent switch, rejected permission, abort, or provider error has superseded the request.
196
+ 1. When you set a goal, the plugin stores it in per-session state and replaces the custom-command turn with a plugin-generated work instruction containing that objective. On hosts that invoke `experimental.chat.system.transform`, it also reinforces the active goal in the system prompt; command correctness does not rely on that experimental hook.
197
+ 2. Each time the session goes idle, the plugin sends a continuation prompt containing the remaining budget and completion audit while the original goal remains in conversation history. Continuations retain the agent, provider/model, and variant that initiated the goal. Before sending after a cooldown, the plugin re-checks that the session is still idle and no human message, newer assistant turn, Plan-agent switch, rejected permission, abort, or provider error has superseded the request.
198
198
  3. The plugin stops auto-continuing when the assistant ends a response with a substantiated `[goal:complete]` or `[goal:blocked]`, or when a safety limit is reached. A `[goal:complete]` is only honored when it is preceded by a `[goal:evidence]` line; a `[goal:blocked]` is only honored when a concrete blocker is stated. Unsubstantiated claims are rejected and the plugin re-prompts for the missing evidence or blocker.
199
199
  4. If OpenCode compacts the session, the plugin injects a deterministic summary into the compaction context so the goal survives the compaction and the assistant keeps the thread. The summary — objective, status, budget usage, recent checkpoints, and recent lifecycle events — is reconstructed from the plugin's persisted goal record rather than from chat memory, so it is stable and reproducible. While a goal is active, the plugin also disables OpenCode's generic post-compaction auto-continue so it does not race the plugin's own continuation.
200
200
  5. If you send a message of your own while the goal is running, the plugin treats it as the latest instruction, pauses auto-continue, and asks OpenCode to abort an already accepted continuation so it does not talk over you. The plugin's own continuation prompts are ignored for this check (they are not "your" messages). A durable claim on the source assistant turn also prevents different idle event IDs from sending the same continuation twice. Run `/goal resume` to hand control back to the goal loop.
@@ -239,7 +239,7 @@ Markers must appear on their own final line. The bracketed form is canonical, bu
239
239
 
240
240
  **Wrap-up vs. hard stop.** When a limit is reached, the plugin sends one final prompt asking the assistant to summarize what is done, what remains, and the next concrete step — rather than stopping silently. Use `/goal resume` to continue after any stop, including limit stops and no-progress pauses.
241
241
 
242
- Goal state is persisted by default to a **project-local** path, `.opencode/goals/state.json` relative to the working directory, so goals follow the project rather than your home directory. It is only a local workflow checkpoint and is not synchronized across machines or OpenCode instances. You may want to add `.opencode/goals/` to your `.gitignore`.
242
+ Goal state is persisted by default to a **project-local** namespace rooted at `.opencode/goals/state.json` relative to the working directory, so goals follow the project rather than your home directory. Each OpenCode session gets a separate hashed shard at `<stateFilePath>.sessions/<sha256(sessionID)>/state.json`, allowing unrelated sessions in the same project to run concurrently. The state is local and is not synchronized across machines. You may want to add `.opencode/goals/` to your `.gitignore`.
243
243
 
244
244
  The state-file location is resolved with this precedence:
245
245
 
@@ -247,11 +247,11 @@ The state-file location is resolved with this precedence:
247
247
  2. the `OPENCODE_GOAL_STATE_PATH` environment variable, if set;
248
248
  3. the project-local default `<cwd>/.opencode/goals/state.json`.
249
249
 
250
- When the default path has no state yet, the plugin migrates forward from older locations on first load: the legacy `~/.opencode-goal-plugin/state.json` and the XDG path `${XDG_STATE_HOME:-~/.local/state}/opencode-goal-plugin/state.json`. Migration is exclusively claimed; after the project-local write succeeds, the legacy file is retired to a timestamped `.migrated…` backup so another project cannot import the same private goal state. An explicit `stateFilePath` or `OPENCODE_GOAL_STATE_PATH` is used literally with no migration fallback.
250
+ When a project has no shard namespace yet, the plugin migrates all sessions from older locations on first session access: the legacy `~/.opencode-goal-plugin/state.json` and the XDG path `${XDG_STATE_HOME:-~/.local/state}/opencode-goal-plugin/state.json`. Migration is exclusively claimed; after every session shard is written, the source files are retired to timestamped `.migrated…` backups so another project cannot import the same private goal state. An explicit `stateFilePath` or `OPENCODE_GOAL_STATE_PATH` is used as the shard namespace root and has no migration fallback.
251
251
 
252
252
  The state directory is created with owner-only permissions, and the JSON state file is written as `0600` because it may contain goal text, assistant checkpoints, and workflow history.
253
253
 
254
- Alongside the state file the plugin keeps an **append-only lifecycle ledger** (`<stateFile>.ledger.jsonl`, also `0600`). Every lifecycle event — set, edit, auto-continue, pause, resume, blocked, completed, limit — is appended as one JSON line. Because the in-memory history is capped, the ledger is the durable record: if the main state file is missing or corrupted, the plugin reconstructs still-active (non-completed) goals from the ledger on startup and reloads them in the paused recovery state. Terminal events (complete/blocked) are written to the ledger *before* the main state write, so a goal's terminal outcome survives even if that write fails (**fail-closed**); such a failure is logged at error level.
254
+ Alongside each session shard the plugin keeps an **append-only lifecycle ledger** (`<shard>/state.json.ledger.jsonl`, also `0600`). Every lifecycle event — set, edit, auto-continue, pause, resume, blocked, completed, limit — is appended as one JSON line. Because the in-memory history is capped, the ledger is the durable record: if a session state file is missing or corrupted, the plugin reconstructs still-active (non-completed) goals from that session's ledger on startup and reloads them in the paused recovery state. Terminal events (complete/blocked) are written to the ledger *before* the state write, so a goal's terminal outcome survives even if that write fails (**fail-closed**); such a failure is logged at error level.
255
255
 
256
256
  Recovered active goals are loaded in a **paused** state with a recovery note, so unattended auto-continue does not resume blindly after a restart. Set `"persistState": false` to keep purely in-memory behavior (this also disables the ledger).
257
257
 
@@ -323,19 +323,19 @@ Additional plugin-level options:
323
323
  - `warnTurnsRemaining` / `warnDurationMsRemaining` / `warnTokensRemaining` — thresholds at which the auto-continue prompt appends a "limits are near" warning (default `3` turns, `60000` ms, `25000` context tokens). Lower them to warn closer to the limit, or raise them to warn earlier.
324
324
  - `commandName` — the slash command the plugin owns (default `goal`). Set it to e.g. `objective` to drive the workflow with `/objective` instead of `/goal`; a leading slash is tolerated. Remember to register the matching command name in your OpenCode `command` config. User-facing hints (`/goal status`, `/goal resume`, …) follow the configured name.
325
325
  - `registerCommand` — whether the plugin installs its `command.execute.before` hook at all (default `true`). Set it to `false` if you only want the auto-continue/persistence behavior driven programmatically and don't want the plugin to own a slash command.
326
- - `registerTools` — whether the plugin registers the agent-facing goal tools (default `true`). Requires the optional `@opencode-ai/plugin` peer dependency to be present; when it is absent, tool registration is skipped and the command/event hooks still work. Set to `false` to omit the programmatic tool surface entirely. See [Agent tools](#agent-tools-optional).
326
+ - `registerTools` — whether the plugin registers the agent-facing goal tools (default `true`). Set to `false` to omit the programmatic tool surface entirely. See [Agent tools](#agent-tools).
327
327
  - `registerAgents` — whether the config hook adds native `goal` and `goal-verify` agents (default `true`). Existing agents with those names are preserved unchanged; the plugin never changes your default agent.
328
328
  - `goalAgentName` / `verifierAgentName` — customize the registered native agent names (defaults `goal` and `goal-verify`). The verifier is a hidden subagent with a default-deny tool policy; only `read`, `glob`, and `grep` are allowed.
329
329
  - `sdkShape` — OpenCode session-client argument shape: `legacy` (the default generated `PluginInput` client using `{ path, body, query }`) or `flat` (clients using `{ sessionID, ... }`). Read-only `messages`/`get` calls may probe the alternate shape after an argument/schema `TypeError`; mutating calls are never replayed, so set this option correctly for embedded clients.
330
330
  - `persistState` — whether to persist active goals and recent goal results to disk.
331
- - `stateFilePath` — where the persisted state JSON is written. Overrides the default project-local path and the `OPENCODE_GOAL_STATE_PATH` env var. Useful if you want a fixed or ephemeral location. When unset, the default is `<cwd>/.opencode/goals/state.json` (see the persistence section above), and `OPENCODE_GOAL_STATE_PATH` can override it without editing config.
331
+ - `stateFilePath` — root path for the persisted session-shard namespace. Overrides the default project-local path and the `OPENCODE_GOAL_STATE_PATH` env var. Useful if you want a fixed or ephemeral location. When unset, the default root is `<cwd>/.opencode/goals/state.json`; shards are written below `<stateFilePath>.sessions/` (see the persistence section above).
332
332
  - `ledgerMaxBytes` / `ledgerRetentionFiles` — bound the lifecycle ledger to 2 MiB per generation and three rotated generations by default. Set retention to `0` to discard the active ledger when it reaches the size ceiling.
333
333
  - `resultRetentionMs` — how long a completed goal summary remains available through `/goal status` after the goal leaves active memory.
334
334
  - `maxStoredResults` — maximum number of completed-goal summaries retained in process memory before the oldest ones are evicted.
335
335
 
336
- ## Agent tools (optional)
336
+ ## Agent tools
337
337
 
338
- In addition to the `/goal` command, the plugin can expose the same workflow to the model as callable tools, so the agent can inspect and manage the goal itself. This requires the optional `@opencode-ai/plugin` peer dependency (it provides the `tool` helper and schema). When that package isn't installed the tools are simply not registered and everything else keeps working. Disable them explicitly with `registerTools: false`.
338
+ In addition to the `/goal` command, the plugin registers the same workflow as callable model tools by default, so the agent can inspect and manage the goal itself. A normal `opencode-goal-plugin` install includes the schema dependency needed for these definitions; no separate OpenCode helper package is required. Disable the tool surface explicitly with `registerTools: false`.
339
339
 
340
340
  Registered tools:
341
341
 
@@ -346,7 +346,7 @@ Registered tools:
346
346
 
347
347
  These operate on the same per-session multi-goal state as the command path: a tool-set goal persists, shows up in `/goal list`, and is driven by the idle auto-continue; completing a goal in an ordered sequence auto-promotes the next.
348
348
 
349
- > Integration note: the tool execute-context shape (`ctx.sessionID`) and the `tool.schema` surface follow the OpenCode plugin docs. The tool **logic** is unit-tested independently, but the live registration should be confirmed against a real OpenCode run (see the smoke-test checklist).
349
+ > Integration note: the tool execute-context shape (`ctx.sessionID`) and Zod argument definitions follow the OpenCode plugin docs. The tool **logic** is unit-tested independently, but live registration should still be confirmed against the exact OpenCode host used in production (see the smoke-test checklist).
350
350
 
351
351
  ## Audit messages
352
352
 
@@ -385,11 +385,15 @@ The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided
385
385
 
386
386
  The assistant still signals candidate outcomes with `[goal:complete]` or `[goal:blocked]`. Completion can additionally be checked by a custom `auditor` callback or the built-in child-session auditor before the goal becomes terminal. Marker quality therefore remains model-dependent when auditing is disabled, and audit quality depends on the configured verifier model and evidence available in the session. The built-in verifier performs static inspection with `read`, `glob`, and `grep`; it cannot execute shell commands.
387
387
 
388
- OpenCode's current `command.execute.before` hook does not fully intercept command text. The plugin can update in-memory goal state as a side effect, but the goal text may still be routed into the normal assistant conversation alongside the state update. The plugin therefore guards `/goal status`, `/goal history`, `/goal list`, `/goal pause`, and `/goal clear` (including its aliases) with `tool.execute.before`: inspection tools remain available, while mutation-capable tools are rejected for that routed command turn. Paused goals also inject a system guard that omits the objective and requires an explicit resume before goal work continues.
388
+ OpenCode custom commands are prompts, not direct plugin-rendered TUI responses. After `command.execute.before` runs, OpenCode sends its retained command-parts array through a normal model turn. The plugin mutates that array in place so the model receives the deterministic plugin-generated result instead of the raw `/goal` argument. The model still produces the visible response and may summarize or paraphrase that result.
389
389
 
390
- The plugin depends on `experimental.chat.system.transform` and other OpenCode plugin hooks that may change between OpenCode versions.
390
+ Objective-bearing commands preserve file attachments. OpenCode may expand those files into synthetic Read/MCP text and file parts before `chat.message`; the plugin accepts that expansion only when it matches the one-shot command correlation, retained-file count, and generated message/session identity. Other mixed text is treated as a new human instruction and pauses an active loop. If OpenCode reports an attachment-read error during that expansion, the goal pauses with `attachment resolution error` while retaining the correct command provenance.
391
391
 
392
- Only one persistence-enabled OpenCode plugin instance may own a given `stateFilePath` at a time. A second process fails initialization with the owning PID/host instead of risking last-writer-wins state loss. Dispose the first host or configure a different path.
392
+ For `/goal status`, `/goal history`, `/goal list`, `/goal pause`, and `/goal clear` (including aliases), the rewritten user turn carries an escaped control-result envelope with direct instructions to report the supplied data without treating it as new work. `tool.execute.before` rejects every tool call for that reporting turn, and the parent-correlated assistant response is excluded from checkpoint, completion, blocker, and stall analysis. These protections do not depend on the model following the reporting instruction.
393
+
394
+ The plugin still registers `experimental.chat.system.transform` as defense in depth for hosts that invoke it. Real OpenCode 1.17.15 and 1.18.10 do not call that hook, so the command-control protections above are deliberately self-contained. Other OpenCode plugin hooks may change between versions.
395
+
396
+ Distinct OpenCode sessions may own shards under the same `stateFilePath` concurrently. A second process using the same session shard is still rejected with the owning PID/host instead of risking last-writer-wins state loss; use the original session or wait for its owner to dispose.
393
397
 
394
398
  ## Diagnostics and recovery
395
399
 
@@ -10,16 +10,20 @@ The latest published release is the supported line. Public compatibility covers:
10
10
  - the documented OpenCode hook names
11
11
  - the six canonical goal tools and five legacy tool aliases
12
12
  - persisted-state recovery from versions documented in the changelog
13
+ - concurrent persistence for distinct OpenCode sessions in one project, with
14
+ single-writer protection retained per session
13
15
 
14
16
  The package requires Node.js 18 or newer and OpenCode 1.17.15 through the latest
15
17
  compatible 1.x release. CI runs the complete unit suite on Node 18, 20, 22, and
16
18
  24. Installed-package contracts compile TypeScript consumers using both NodeNext
17
- and Bundler resolution and load the npm tarball with and without the optional
18
- `@opencode-ai/plugin` peer.
19
+ and Bundler resolution and require a clean npm-tarball install to expose the
20
+ default agent-tool surface without a separately installed OpenCode helper package.
19
21
 
20
22
  Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows. POSIX file
21
23
  mode and symbolic-link protections are applied where the operating system supports
22
24
  them; the plugin does not claim that Windows provides equivalent POSIX semantics.
25
+ The Windows job also runs the installed-package type, host, and tool contracts
26
+ so their portable npm launcher path is exercised in CI.
23
27
 
24
28
  ## OpenCode host compatibility
25
29
 
@@ -29,6 +33,26 @@ generated-client shape, but a real-host smoke test remains required when hook or
29
33
  SDK behavior changes. The current manual provider matrix is maintained in
30
34
  [providers.md](providers.md).
31
35
 
36
+ OpenCode custom commands still become model turns. The plugin handles `/goal`
37
+ arguments in `command.execute.before` and mutates the host-retained parts array
38
+ in place so the turn contains the plugin-generated command result rather than
39
+ raw command text. This makes command routing deterministic, but does not turn the
40
+ hook into a direct-render API: the selected model remains responsible for the
41
+ visible response.
42
+
43
+ For objective-bearing commands, retained file attachments may be expanded by
44
+ OpenCode into synthetic Read/MCP text and file parts before `chat.message`. The
45
+ plugin correlates that host-resolved shape to the exact one-shot command and
46
+ generated message/session before treating it as plugin-owned. Each retained
47
+ file must yield at least one resolved companion part; a host-reported read error
48
+ pauses the goal without reclassifying the command as human intervention.
49
+
50
+ OpenCode 1.17.15 and 1.18.10 do not invoke
51
+ `experimental.chat.system.transform`. Control-command correctness therefore
52
+ comes from the rewritten turn's escaped reporting frame, fail-closed tool
53
+ blocking, and parent-correlated lifecycle suppression. The system transform
54
+ remains registered as additional protection for hosts that support it.
55
+
32
56
  ## Versioning
33
57
 
34
58
  Semantic-versioning intent is:
package/docs/providers.md CHANGED
@@ -12,20 +12,23 @@ vary by provider and model:
12
12
  real safety net, not just documentation: a model that initially skips the
13
13
  evidence line gets one automatic correction cycle before hitting its
14
14
  turn/time/token budget.
15
- 2. **Hook output display** — whether the plugin's own response text (e.g.
16
- `No active goal. Set one with...`) is rendered in the OpenCode TUI, or
17
- whether the raw command argument is instead routed to the model as a
18
- normal chat turn. This is an OpenCode-host behavior, not model-specific,
19
- but is included here since it's most visible while testing a new model.
20
- See the [OpenCode version compatibility table](../README.md#opencode-version-compatibility)
21
- in the README for the current findings.
15
+ 2. **Custom-command presentation** — OpenCode sends custom commands through a
16
+ normal model turn rather than directly rendering plugin-hook output. The
17
+ plugin now mutates OpenCode's retained command-parts array in place, so that
18
+ turn contains the deterministic plugin-generated result instead of raw
19
+ `$ARGUMENTS`. Control results include an escaped reporting frame, backed by
20
+ tool blocking and lifecycle suppression; the model may still summarize or
21
+ paraphrase the supplied data.
22
+ This is primarily host behavior, but it remains worth checking with each
23
+ provider/backend. See the [OpenCode version compatibility table](../README.md#opencode-version-compatibility)
24
+ for the historical v0.6.6 findings.
22
25
 
23
26
  All rows below were verified against real OpenCode processes with live
24
- provider credentials and no mocked plugin hooks on OpenCode 1.17.15, driving
25
- the plugin through `/goal status`, `/goal <condition> --max-turns N`, and
26
- inspecting the plugin's persisted state file to confirm state mutations
27
- (limit parsing, turn/stop accounting, evidence-gated completion detection)
28
- independent of what was rendered in the terminal.
27
+ provider credentials and no mocked plugin hooks on OpenCode 1.17.15 before the
28
+ in-place command-parts change. They drove the plugin through `/goal status`,
29
+ `/goal <condition> --max-turns N`, and inspected the persisted state file to
30
+ confirm state mutations (limit parsing, turn/stop accounting, evidence-gated
31
+ completion detection) independent of what was rendered in the terminal.
29
32
 
30
33
  ## Tested models
31
34
 
@@ -64,9 +67,11 @@ Some backends (notably certain Qwen deployments on vLLM, and several
64
67
  Llama.cpp/Mistral chat templates) reject a `system` role message that isn't
65
68
  the very first message in the conversation, with an error like `"System
66
69
  message must be at the beginning."` opencode-goal-plugin's
67
- `experimental.chat.system.transform` hook merges the goal continuation block
68
- into the primary system entry instead of appending a separate one, which
69
- avoids this. This is covered by regression tests in
70
+ `experimental.chat.system.transform` hook, on hosts that invoke it, merges the
71
+ goal continuation block into the primary system entry instead of appending a
72
+ separate one, which avoids this. OpenCode 1.17.15 and 1.18.10 do not invoke that
73
+ experimental hook; command control on those releases uses the self-contained
74
+ reporting frame described above. The merge behavior is covered by regression tests in
70
75
  [`test/goal-plugin.test.js`](../test/goal-plugin.test.js) and does not
71
76
  require any provider-specific configuration.
72
77
 
@@ -91,9 +96,10 @@ specific provider/model:
91
96
  before `[goal:complete]`. If not, confirm the plugin rejects it and
92
97
  re-prompts with `<evidence_required>` — then check whether the model
93
98
  self-corrects within its remaining turn budget, or exhausts it.
94
- 5. Run `/goal status` and check whether the plugin's text (e.g. `No active
95
- goal...` or `Active goal: ...`) is rendered directly, or whether the
96
- command text was instead routed to the model as a chat turn.
99
+ 5. Run `/goal status` and confirm the model turn reports the plugin-generated
100
+ status rather than interpreting the raw word `status` as a new request.
101
+ The final wording may be summarized or paraphrased because OpenCode custom
102
+ commands still run through the selected model.
97
103
  6. Cross-check state mutations directly against the persisted state file
98
104
  (`.opencode/goals/state.json`, project-local by default) to confirm the
99
105
  goal's `options` reflect the flags you passed and `state`/`stopReason`
package/docs/releasing.md CHANGED
@@ -14,7 +14,7 @@ artifact after all checks pass.
14
14
 
15
15
  `release:check` runs the unit and coverage suites, consumer type compilation,
16
16
  critical mutation contract, behavior benchmark, source and installed-artifact
17
- smoke tests, full optional-peer tool registration, and package-content check.
17
+ smoke tests, clean-install default tool registration, and package-content check.
18
18
 
19
19
  ## Publish
20
20
 
package/index.d.ts CHANGED
@@ -222,17 +222,19 @@ export interface GoalPluginOptions {
222
222
  persistState?: boolean
223
223
 
224
224
  /**
225
- * Filesystem path where persisted goal state is written when
226
- * `persistState` is enabled. Overrides both the project-local default
227
- * and the `OPENCODE_GOAL_STATE_PATH` environment variable.
225
+ * Root filesystem path for persisted session-shard state when
226
+ * `persistState` is enabled. Each session is written below
227
+ * `<stateFilePath>.sessions/<sha256(sessionID)>/state.json`. Overrides both
228
+ * the project-local default and the `OPENCODE_GOAL_STATE_PATH` environment
229
+ * variable.
228
230
  * @default "<cwd>/.opencode/goals/state.json"
229
231
  */
230
232
  stateFilePath?: string
231
233
 
232
234
  /**
233
- * Filesystem path for the append-only lifecycle ledger
234
- * (`<event> per line`, used to reconstruct active goals if the main
235
- * state file is missing or corrupted).
235
+ * Legacy aggregate filesystem path for the append-only lifecycle ledger.
236
+ * During migration, events are partitioned into per-session shard ledgers;
237
+ * new writes use `<shard>/state.json.ledger.jsonl`.
236
238
  * @default "<stateFilePath>.ledger.jsonl"
237
239
  */
238
240
  ledgerFilePath?: string
@@ -280,9 +282,9 @@ export interface GoalPluginOptions {
280
282
  * (canonical `goal_status`, `goal_set`, `goal_pause`, `goal_resume`,
281
283
  * `goal_block`, `goal_complete`, plus legacy `get_goal`,
282
284
  * `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`).
283
- * Canonical tools return versioned JSON envelopes. Requires the optional `@opencode-ai/plugin` peer
284
- * dependency; when it is absent, tool registration is silently skipped
285
- * and the command/event hooks still work.
285
+ * Canonical tools return versioned JSON envelopes. The tools are registered
286
+ * by default from dependencies included with this package; set this to
287
+ * `false` to omit the tool surface.
286
288
  * @default true
287
289
  */
288
290
  registerTools?: boolean
@@ -346,9 +348,19 @@ export interface GoalPluginOptions {
346
348
  export interface GoalPluginHooks {
347
349
  /** Registers collision-safe native goal and verifier agents. */
348
350
  config: (config: unknown) => Promise<void>
349
- /** Omitted entirely when {@link GoalPluginOptions.registerCommand} is `false`. */
351
+ /** Remembers the initiating agent, model, and variant for later continuation turns. */
352
+ "chat.params": (input: unknown) => Promise<void>
353
+ /** Distinguishes real user input from plugin-generated command and continuation messages. */
354
+ "chat.message": (input: unknown, output: unknown) => Promise<void>
355
+ /**
356
+ * Handles the configured OpenCode custom command by replacing its retained
357
+ * prompt parts in place. OpenCode still submits those parts as a model turn
358
+ * rather than rendering the hook result directly; handled control results
359
+ * include a self-contained reporting frame. Omitted entirely when
360
+ * {@link GoalPluginOptions.registerCommand} is `false`.
361
+ */
350
362
  "command.execute.before"?: (input: unknown, output: unknown) => Promise<void>
351
- /** Enforces read-only tool behavior when inspection, pause, or clear command text is routed to the model. */
363
+ /** Blocks tool execution while an already-handled control-command result is being reported. */
352
364
  "tool.execute.before": (input: unknown, output: unknown) => Promise<void>
353
365
  event: (input: unknown) => Promise<void>
354
366
  "experimental.chat.system.transform": (input: unknown, output: unknown) => Promise<void>
@@ -356,8 +368,7 @@ export interface GoalPluginHooks {
356
368
  "experimental.session.compacting": (input: unknown, output: unknown) => Promise<void>
357
369
  /**
358
370
  * Agent-facing tool definitions, present only when
359
- * {@link GoalPluginOptions.registerTools} is enabled (default) and the
360
- * optional `@opencode-ai/plugin` peer dependency is installed.
371
+ * {@link GoalPluginOptions.registerTools} is enabled (default).
361
372
  */
362
373
  tool?: Record<string, unknown>
363
374
  /** Cancels pending continuation work and releases this plugin instance. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "Durable, guarded goal workflows for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -55,14 +55,6 @@
55
55
  "agent",
56
56
  "goal"
57
57
  ],
58
- "peerDependencies": {
59
- "@opencode-ai/plugin": ">=1.17.15 <2"
60
- },
61
- "peerDependenciesMeta": {
62
- "@opencode-ai/plugin": {
63
- "optional": true
64
- }
65
- },
66
58
  "license": "MIT",
67
59
  "engines": {
68
60
  "node": ">=18",
@@ -80,7 +72,9 @@
80
72
  "name": "willytop8"
81
73
  },
82
74
  "devDependencies": {
83
- "@opencode-ai/plugin": "1.17.18",
84
75
  "typescript": "5.9.3"
76
+ },
77
+ "dependencies": {
78
+ "zod": "4.1.8"
85
79
  }
86
80
  }
@@ -8,6 +8,8 @@ import assert from "node:assert/strict"
8
8
 
9
9
  const REQUIRED_HOOKS = [
10
10
  "config",
11
+ "chat.params",
12
+ "chat.message",
11
13
  "command.execute.before",
12
14
  "tool.execute.before",
13
15
  "event",
@@ -16,6 +18,20 @@ const REQUIRED_HOOKS = [
16
18
  "experimental.session.compacting",
17
19
  ]
18
20
 
21
+ const EXPECTED_TOOLS = [
22
+ "clear_goal",
23
+ "get_goal",
24
+ "get_goal_history",
25
+ "goal_block",
26
+ "goal_complete",
27
+ "goal_pause",
28
+ "goal_resume",
29
+ "goal_set",
30
+ "goal_status",
31
+ "set_goal",
32
+ "update_goal",
33
+ ]
34
+
19
35
  const results = []
20
36
 
21
37
  function check(name, fn) {
@@ -70,11 +86,9 @@ const client = {
70
86
  }
71
87
 
72
88
  let hooks
89
+ let commandMessageCounter = 0
73
90
 
74
91
  await check(`plugin initializes and registers all ${REQUIRED_HOOKS.length} required hooks`, async () => {
75
- // registerTools defaults to true but silently no-ops without the optional
76
- // @opencode-ai/plugin peer dependency, so it is not asserted here — the
77
- // These hooks are always present regardless of that peer dependency.
78
92
  hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
79
93
  for (const hookName of REQUIRED_HOOKS) {
80
94
  assert.equal(
@@ -83,16 +97,42 @@ await check(`plugin initializes and registers all ${REQUIRED_HOOKS.length} requi
83
97
  `missing or non-function hook: ${hookName}`,
84
98
  )
85
99
  }
100
+ assert.deepEqual(Object.keys(hooks.tool ?? {}).sort(), EXPECTED_TOOLS)
101
+ for (const name of EXPECTED_TOOLS) {
102
+ assert.equal(typeof hooks.tool[name].execute, "function", `${name} must be executable`)
103
+ }
86
104
  })
87
105
 
88
106
  async function runGoalCommand(args) {
89
- const output = { parts: [] }
107
+ // Match OpenCode's host contract: it retains this exact array after the hook
108
+ // returns, so replacing output.parts would leave the raw command untouched.
109
+ const hostParts = [{ type: "text", text: args }]
110
+ commandMessageCounter += 1
111
+ const messageID = `verify-command-${commandMessageCounter}`
112
+ const output = {
113
+ message: { id: messageID, role: "user", sessionID },
114
+ parts: hostParts,
115
+ }
90
116
  await hooks["command.execute.before"](
91
117
  { command: "goal", sessionID, arguments: args },
92
118
  output,
93
119
  )
94
- assert.equal(output.parts.length, 1)
95
- assert.equal(output.parts[0].type, "text")
120
+ assert.strictEqual(output.parts, hostParts)
121
+ assert.equal(hostParts.length, 1)
122
+ assert.equal(hostParts[0].type, "text")
123
+ assert.equal(hostParts[0].synthetic, true)
124
+ assert.equal(hostParts[0].metadata?.["opencode-goal-plugin"]?.kind, "command")
125
+ assert.match(hostParts[0].metadata?.["opencode-goal-plugin"]?.id, /^[0-9a-f-]{36}$/)
126
+ output.parts = hostParts.map((part, index) => ({
127
+ ...part,
128
+ id: `verify-command-part-${commandMessageCounter}-${index}`,
129
+ messageID,
130
+ sessionID,
131
+ }))
132
+ await hooks["chat.message"](
133
+ { sessionID, messageID, agent: "build", model: { providerID: "test", modelID: "test" } },
134
+ output,
135
+ )
96
136
  return output.parts[0].text
97
137
  }
98
138
 
@@ -106,6 +146,7 @@ await check("/goal set works", async () => {
106
146
  assert.match(text, /New active goal: verify the installation/)
107
147
  const statusText = await runGoalCommand("status")
108
148
  assert.match(statusText, /Active goal: verify the installation/)
149
+ assert.doesNotMatch(statusText, /State: Paused/)
109
150
  })
110
151
 
111
152
  await check("no model calls were made during verification", () => {