opencode-goal-plugin 0.4.7 → 0.5.0

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
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.0 — 2026-07-08
6
+
7
+ - Replace the single-line "Compatibility snapshot" in the README with an OpenCode version compatibility table, manually verified via `tmux` + the OpenCode TUI against the persisted state file for each provider/model combination.
8
+ - Add `docs/providers.md`, a provider/model compatibility guide covering evidence-gated marker-compliance behavior for `opencode-go/qwen3.7-plus`, `opencode-go/glm-5.2`, and `deepseek/deepseek-chat` (manually verified via the OpenCode TUI against real provider credentials on OpenCode 1.17.15), plus a step-by-step guide for testing new models.
9
+ - Add a reproducible `demo/` directory: a minimal Node project with a deliberately buggy `add()` function, a test that catches it, and an `opencode.json` wired to the local plugin source. Verified end-to-end via the OpenCode TUI.
10
+ - Scope `npm test`/`npm run test:coverage` to `test/*.test.js` explicitly, since Node's test runner otherwise recursively discovers `demo/test/*.test.js` too, which would fail the root suite whenever the demo's deliberate bug is (correctly) unfixed.
11
+ - **Fix project-local state persistence to actually use the active session's directory.** `GoalPlugin` previously ignored the `directory` field OpenCode passes in its `PluginInput`, so the default `.opencode/goals/state.json` path resolved against the Node process's own `process.cwd()` instead. This works fine for a one-shot CLI invocation, but silently breaks when OpenCode runs as a persistent server/daemon serving multiple projects: `process.cwd()` stays wherever the server booted, not the active session's project. Confirmed live via the OpenCode TUI — a goal set in a project directory never persisted to disk at all. `GoalPlugin` now reads `directory` from its `PluginInput` and uses it as the default `cwd` for state-path resolution (an explicit `cwd` plugin option, mainly for tests, still takes precedence).
12
+ - Add Node 24 to the CI matrix, a weekly scheduled CI run (Mondays 08:00 UTC) to catch upstream drift, a `test:coverage` step, and npm/CI/tests/license badges to the README.
13
+ - Add GitHub issue templates for bug reports (OpenCode version, provider/model, Node version, relevant plugin options, repro steps) and feature requests (problem solved, scope fit against the current multi-goal/audit feature set).
14
+ - Add an Examples section to the README with copy-pasteable `/goal` commands: common workflows, success criteria/constraints/budget shorthand, and an ordered (sisyphus) sequence.
15
+ - Add a Comparison section to the README benchmarking `/goal` support, auto-continue, per-goal overrides, no-progress/no-tool-call detection, safety limits, history, persistence, multi-goal/sisyphus sequences, evidence-gated completion, the optional completion auditor, budget wrap-up, and license against Claude Code and Codex.
16
+ - Add `npm run verify` / `npx opencode-goal-plugin` installation verification command (`scripts/verify.mjs`). Checks Node >= 18, the plugin module shape, that all 4 hooks (`command.execute.before`, `event`, `experimental.chat.system.transform`, `experimental.compaction.autocontinue`) register, and that `/goal status`/`/goal set` work — entirely via mock clients, with zero model calls.
17
+ - Add TypeScript declarations (`index.d.ts`) covering the full current `GoalPluginOptions` surface — budgets, persistence/ledger paths, `commandName`/`registerCommand`/`registerTools`, and the completion-audit options (`completionAudit`, `auditor`, `auditorOptions`, `auditMessages`, `auditMessenger`) — plus the plugin's hook map and default export. `package.json`'s `types` field points at it.
18
+ - Warn when `/goal <condition>` replaces the focused goal instead of silently discarding it. The response now leads with `⚠️ Replacing active goal: "<old condition>"` and points at `/goal add <condition>` as the non-destructive alternative that backgrounds the current goal instead.
19
+
5
20
  ## 0.4.7 — 2026-06-29
6
21
 
7
22
  ### Bug fixes (low-severity cleanups)
package/README.md CHANGED
@@ -1,20 +1,57 @@
1
1
  # opencode-goal-plugin
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/opencode-goal-plugin)](https://www.npmjs.com/package/opencode-goal-plugin)
4
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-goal-plugin)](https://www.npmjs.com/package/opencode-goal-plugin)
5
+ [![CI](https://github.com/willytop8/OpenCode-goal-plugin/actions/workflows/ci.yml/badge.svg)](https://github.com/willytop8/OpenCode-goal-plugin/actions/workflows/ci.yml)
6
+ [![Tests](https://img.shields.io/badge/tests-185%20passing-brightgreen)](test/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
+
3
9
  An experimental session-scoped `/goal` command for [OpenCode](https://opencode.ai/).
4
10
 
5
11
  Set a goal and the plugin keeps it in context, auto-continues the session whenever the assistant goes idle, and stops when the goal is marked complete, a blocker is reported, or a safety limit is reached.
6
12
 
7
13
  Compatibility: this plugin relies on experimental OpenCode hooks. Re-test against the exact OpenCode build and provider/backend stack you plan to use for unattended work.
8
14
 
15
+ ## Comparison
16
+
17
+ | Feature | Claude Code | Codex | opencode-goal-plugin |
18
+ |---|---|---|---|
19
+ | `/goal` command | ✅ Native | ✅ Native | ✅ Plugin |
20
+ | Auto-continue | ✅ | ✅ | ✅ |
21
+ | Per-goal flag overrides | ❌ | ❌ | ✅ |
22
+ | No-progress / no-tool-call detection | ❌ | ❌ | ✅ Both |
23
+ | Configurable safety limits | Limited | Limited | ✅ All tunable |
24
+ | Goal history | ✅ | ❌ | ✅ `/goal history` |
25
+ | Goal persistence | ❌ | ❌ | ✅ Survives restart, ledger-backed |
26
+ | Multiple concurrent goals | ❌ | ❌ | ✅ `/goal add` / `/goal focus` |
27
+ | Ordered goal sequences | ❌ | ❌ | ✅ `/goal sisyphus` |
28
+ | Evidence-gated completion | ❌ | ❌ | ✅ `[goal:evidence]` required |
29
+ | Independent completion audit | ✅ | ❌ | ✅ Optional child-session auditor |
30
+ | Budget wrap-up prompts | ❌ | ❌ | ✅ 80% threshold |
31
+ | Open source | ❌ | ❌ | ✅ MIT |
32
+
9
33
  ## Compatibility snapshot
10
34
 
11
35
  | Surface | Status |
12
36
  |---|---|
13
- | Node.js | Declared support: `>=18`; CI covers Node 18, 20, and 22 |
37
+ | Node.js | Declared support: `>=18`; CI covers Node 18, 20, 22, and 24 |
14
38
  | Package entrypoint | `npm run smoke` verifies the package export path plus `/goal` command-hook behavior from a local install without invoking a model |
15
- | OpenCode host | Manually smoke-tested against OpenCode 1.15.10 using the `opencode-go` provider (`qwen3.7-plus`) on this repo's local hardening branch; re-test your own version/provider stack before relying on unattended runs |
16
39
  | Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
17
40
 
41
+ ### OpenCode version compatibility
42
+
43
+ Manually tested via the OpenCode TUI (`tmux` + real provider credentials, no mocks), verified against the plugin's own persisted state rather than terminal display alone:
44
+
45
+ | OpenCode Version | Provider Tested | `/goal status` | Auto-continue | Evidence-gated completion | Hook Output Display |
46
+ |---|---|---|---|---|---|
47
+ | 1.17.15 | opencode-go (`qwen3.7-plus`) | ✅ | ✅ | ✅ Self-corrected after one rejection (bare `[goal:complete]` with no evidence), then completed cleanly | ⚠️ Not displayed |
48
+ | 1.17.15 | opencode-go (`glm-5.2`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt | ⚠️ Not displayed |
49
+ | 1.17.15 | deepseek (`deepseek-chat`) | ✅ | ✅ | ✅ Clean `[goal:evidence]` + `[goal:complete]` on the first attempt; also verified end-to-end via the [demo](demo/) — autonomously fixed a real bug and reported evidence-backed completion | ⚠️ Not displayed |
50
+
51
+ `/goal status` and auto-continue are graded on **state correctness** (verified directly against the plugin's persisted state file: correct limits parsed, correct turn/stop accounting, correct completion detection) — not on what's rendered in the terminal, since that's tracked separately as Hook Output Display.
52
+
53
+ **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.
54
+
18
55
  ## Install
19
56
 
20
57
  ```sh
@@ -122,6 +159,29 @@ A session can hold more than one goal. `/goal <condition>` replaces the focused
122
159
 
123
160
  The first goal is focused and the rest are queued. `/goal list` marks the session as ordered. Auto-promotion stops when the sequence is exhausted; `/goal clear` ends the sequence.
124
161
 
162
+ ## Examples
163
+
164
+ Copy-pasteable goals for common workflows:
165
+
166
+ ```
167
+ /goal "fix the failing tests" --max-turns 10
168
+ /goal "refactor auth to use new API" --max-minutes 30
169
+ /goal "audit for security issues" --max-turns 3
170
+ /goal "migrate class components to functional" --max-minutes 60 --max-tokens 400000
171
+ ```
172
+
173
+ With success criteria, constraints, and a token budget shorthand:
174
+
175
+ ```
176
+ /goal "ship the release" --success "tests pass and changelog updated" --constraints "do not touch the public API" --budget 150k
177
+ ```
178
+
179
+ An ordered sequence, run as a strict pipeline:
180
+
181
+ ```
182
+ /goal sisyphus build the parser; write the tests; ship the release
183
+ ```
184
+
125
185
  ## How it works
126
186
 
127
187
  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.
package/index.d.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Type declarations for opencode-goal-plugin.
3
+ *
4
+ * These describe the plugin-level configuration object accepted in
5
+ * `opencode.json` under `plugin: [["opencode-goal-plugin", { ... }]]`,
6
+ * and the shape of the module's exports.
7
+ */
8
+
9
+ /**
10
+ * Verdict returned by a completion auditor (built-in or custom). See
11
+ * {@link GoalPluginOptions.auditor} and {@link GoalPluginOptions.completionAudit}.
12
+ */
13
+ export interface CompletionAuditVerdict {
14
+ /** `true` to archive the goal as achieved; `false` to reject the completion. */
15
+ approved: boolean
16
+ /** Human-readable reason, surfaced in the goal's status when rejected. */
17
+ reason?: string
18
+ }
19
+
20
+ /** Arguments passed to a custom {@link GoalPluginOptions.auditor} function. */
21
+ export interface CompletionAuditContext {
22
+ /** The goal being audited (objective, budget usage, checkpoints, etc.). */
23
+ goal: unknown
24
+ /** The OpenCode session ID the goal belongs to. */
25
+ sessionID: string
26
+ /** The assistant's latest response text, containing the `[goal:evidence]`/`[goal:complete]` claim. */
27
+ latestText: string
28
+ }
29
+
30
+ /** Options for the built-in child-session completion auditor (`completionAudit: true`). */
31
+ export interface CompletionAuditorOptions {
32
+ /**
33
+ * How long, in milliseconds, the built-in auditor waits for a verdict from
34
+ * its child OpenCode session before failing open (auto-approving).
35
+ * @default 120000
36
+ */
37
+ timeoutMs?: number
38
+ }
39
+
40
+ /**
41
+ * Configuration options for opencode-goal-plugin. All fields are optional;
42
+ * unset fields fall back to the plugin's built-in defaults. These act as
43
+ * the default limits for every goal set in a session, and most of the
44
+ * budget/behavior fields can be overridden per-goal via `/goal` command
45
+ * flags (e.g. `--max-turns`, `--success`, `--mode`).
46
+ */
47
+ export interface GoalPluginOptions {
48
+ /**
49
+ * Maximum number of auto-continue turns sent toward a goal before it is
50
+ * stopped for exceeding limits. Overridable per-goal with `--max-turns`.
51
+ * @default 10
52
+ */
53
+ maxTurns?: number
54
+
55
+ /**
56
+ * Maximum wall-clock duration, in milliseconds, a goal may run before it
57
+ * is stopped for exceeding limits. Overridable per-goal with
58
+ * `--max-duration-ms` or `--max-minutes`.
59
+ * @default 900000
60
+ */
61
+ maxDurationMs?: number
62
+
63
+ /**
64
+ * Maximum context token budget a goal may consume before it is stopped
65
+ * for exceeding limits. Overridable per-goal with `--max-tokens` or the
66
+ * `--budget` shorthand (accepts a `k`/`m` suffix, e.g. `100k`, `1.5m`).
67
+ * @default 200000
68
+ */
69
+ maxTokens?: number
70
+
71
+ /**
72
+ * Minimum delay, in milliseconds, enforced between consecutive
73
+ * auto-continue prompts. Overridable per-goal with `--cooldown-ms`.
74
+ * @default 1500
75
+ */
76
+ minDelayMs?: number
77
+
78
+ /**
79
+ * How many recent session messages to scan when looking for the latest
80
+ * assistant turn before auto-continuing. Higher values make long,
81
+ * tool-heavy sessions less likely to lose the most recent assistant
82
+ * response.
83
+ * @default 50
84
+ */
85
+ maxRecentMessages?: number
86
+
87
+ /**
88
+ * Output token floor below which a turn is considered "low-output" for
89
+ * no-progress detection. Overridable per-goal with
90
+ * `--no-progress-threshold`.
91
+ * @default 50
92
+ */
93
+ noProgressTokenThreshold?: number
94
+
95
+ /**
96
+ * Grace window for low-output stalls: the goal is paused only after this
97
+ * many consecutive stalled low-output turns, rather than on the first
98
+ * one. Overridable per-goal with `--no-progress-turns`.
99
+ * @default 2
100
+ */
101
+ noProgressTurnsBeforePause?: number
102
+
103
+ /**
104
+ * Grace window for tool-free continuation turns (a "talk only" turn that
105
+ * calls no tool). Complements the no-progress check by catching
106
+ * self-chat loops that still produce output. Overridable per-goal with
107
+ * `--no-tool-turns`.
108
+ * @default 2
109
+ */
110
+ noToolCallTurnsBeforePause?: number
111
+
112
+ /**
113
+ * Fraction (between 0 and 1, exclusive) of any budget (turns, duration,
114
+ * or tokens) at which the plugin sends a one-time "wrap up" prompt
115
+ * nudging the model to finish before the hard limit is hit.
116
+ * @default 0.8
117
+ */
118
+ budgetWrapupRatio?: number
119
+
120
+ /**
121
+ * Number of remaining auto-continue turns at which a limit-approaching
122
+ * warning is included in status output.
123
+ * @default 3
124
+ */
125
+ warnTurnsRemaining?: number
126
+
127
+ /**
128
+ * Remaining duration, in milliseconds, at which a limit-approaching
129
+ * warning is included in status output.
130
+ * @default 60000
131
+ */
132
+ warnDurationMsRemaining?: number
133
+
134
+ /**
135
+ * Remaining context tokens at which a limit-approaching warning is
136
+ * included in status output.
137
+ * @default 25000
138
+ */
139
+ warnTokensRemaining?: number
140
+
141
+ /**
142
+ * Maximum number of consecutive prompt failures (e.g. transport errors
143
+ * sending the auto-continue prompt, or repeated missing-evidence /
144
+ * missing-blocker format violations) tolerated before the goal is
145
+ * stopped.
146
+ * @default 3
147
+ */
148
+ maxPromptFailures?: number
149
+
150
+ /**
151
+ * Whether to persist active/backgrounded goals and recent goal results
152
+ * to disk so they survive a restart. Recovered active goals are loaded
153
+ * in a paused state. Set to `false` for purely in-memory behavior (this
154
+ * also disables the lifecycle ledger).
155
+ * @default true
156
+ */
157
+ persistState?: boolean
158
+
159
+ /**
160
+ * Filesystem path where persisted goal state is written when
161
+ * `persistState` is enabled. Overrides both the project-local default
162
+ * and the `OPENCODE_GOAL_STATE_PATH` environment variable.
163
+ * @default "<cwd>/.opencode/goals/state.json"
164
+ */
165
+ stateFilePath?: string
166
+
167
+ /**
168
+ * Filesystem path for the append-only lifecycle ledger
169
+ * (`<event> per line`, used to reconstruct active goals if the main
170
+ * state file is missing or corrupted).
171
+ * @default "<stateFilePath>.ledger.jsonl"
172
+ */
173
+ ledgerFilePath?: string
174
+
175
+ /**
176
+ * How long, in milliseconds, a completed goal's summary remains
177
+ * available through `/goal status` after the goal leaves active memory.
178
+ * @default 604800000
179
+ */
180
+ resultRetentionMs?: number
181
+
182
+ /**
183
+ * Maximum number of completed-goal summaries retained in process memory
184
+ * before the oldest ones are evicted.
185
+ * @default 200
186
+ */
187
+ maxStoredResults?: number
188
+
189
+ /**
190
+ * The slash command the plugin owns. Set to e.g. `"objective"` to drive
191
+ * the workflow with `/objective` instead of `/goal`; a leading slash is
192
+ * tolerated and stripped. Remember to register the matching command
193
+ * name in your OpenCode `command` config.
194
+ * @default "goal"
195
+ */
196
+ commandName?: string
197
+
198
+ /**
199
+ * Whether the plugin installs its `command.execute.before` hook at all.
200
+ * Set to `false` if you only want the auto-continue/persistence
201
+ * behavior driven programmatically (e.g. via {@link registerTools})
202
+ * and don't want the plugin to own a slash command.
203
+ * @default true
204
+ */
205
+ registerCommand?: boolean
206
+
207
+ /**
208
+ * Whether the plugin registers the agent-facing goal tools
209
+ * (`get_goal`, `get_goal_history`, `set_goal`, `update_goal`,
210
+ * `clear_goal`). Requires the optional `@opencode-ai/plugin` peer
211
+ * dependency; when it is absent, tool registration is silently skipped
212
+ * and the command/event hooks still work.
213
+ * @default true
214
+ */
215
+ registerTools?: boolean
216
+
217
+ /**
218
+ * Enables the built-in child-session completion auditor: before a
219
+ * `[goal:complete]` is archived, the plugin spawns an independent
220
+ * OpenCode session to verify the completion against the goal and
221
+ * workspace. Ignored if {@link auditor} is also set (the custom
222
+ * auditor takes precedence). Tune the built-in auditor with
223
+ * {@link auditorOptions}.
224
+ * @default false
225
+ */
226
+ completionAudit?: boolean
227
+
228
+ /**
229
+ * Supply a custom completion auditor instead of the built-in
230
+ * child-session one. Takes precedence over `completionAudit: true`.
231
+ * A verdict of `{ approved: false }` pauses the goal (stop reason
232
+ * `"audit rejected"`) instead of archiving it. A thrown error is
233
+ * treated as a rejection (fail closed).
234
+ */
235
+ auditor?: (context: CompletionAuditContext) => Promise<CompletionAuditVerdict>
236
+
237
+ /**
238
+ * Tuning options for the built-in child-session auditor. Ignored when
239
+ * a custom {@link auditor} is supplied.
240
+ */
241
+ auditorOptions?: CompletionAuditorOptions
242
+
243
+ /**
244
+ * Whether the plugin announces completion/blocked audits (an
245
+ * audit-start and an audit-result message) instead of running silently.
246
+ * @default true
247
+ */
248
+ auditMessages?: boolean
249
+
250
+ /**
251
+ * Custom sink for audit announcements. Defaults to routing through
252
+ * OpenCode's structured log (`client.app.log`). Provide this to route
253
+ * audit messages elsewhere, e.g. into the live conversation.
254
+ */
255
+ auditMessenger?: (sessionID: string, text: string) => Promise<void>
256
+ }
257
+
258
+ /**
259
+ * OpenCode plugin hook map returned by the plugin's `server` factory.
260
+ * Matches OpenCode's plugin hook contract; kept loose (`unknown`
261
+ * input/output) since hook payload shapes are defined by OpenCode itself,
262
+ * not by this package.
263
+ */
264
+ export interface GoalPluginHooks {
265
+ /** Omitted entirely when {@link GoalPluginOptions.registerCommand} is `false`. */
266
+ "command.execute.before"?: (input: unknown, output: unknown) => Promise<void>
267
+ event: (input: unknown) => Promise<void>
268
+ "experimental.chat.system.transform": (input: unknown, output: unknown) => Promise<void>
269
+ "experimental.compaction.autocontinue": (input: unknown, output: unknown) => Promise<void>
270
+ /**
271
+ * Agent-facing tool definitions, present only when
272
+ * {@link GoalPluginOptions.registerTools} is enabled (default) and the
273
+ * optional `@opencode-ai/plugin` peer dependency is installed.
274
+ */
275
+ tool?: Record<string, unknown>
276
+ [hook: string]: unknown
277
+ }
278
+
279
+ /**
280
+ * The plugin's `server` factory. OpenCode calls this with a client bound
281
+ * to the running session and the resolved plugin options from
282
+ * `opencode.json`.
283
+ */
284
+ export function GoalPlugin(
285
+ context: { client: unknown },
286
+ options?: GoalPluginOptions,
287
+ ): Promise<GoalPluginHooks>
288
+
289
+ /**
290
+ * Default export consumed by OpenCode's plugin loader:
291
+ * `{ "opencode-goal-plugin": { ... } }` in `opencode.json` resolves `id`
292
+ * and calls `server` to obtain the plugin's hooks.
293
+ */
294
+ declare const goalPlugin: {
295
+ id: "opencode-goal-plugin"
296
+ server: typeof GoalPlugin
297
+ }
298
+
299
+ export default goalPlugin
package/package.json CHANGED
@@ -1,17 +1,22 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "description": "Session-scoped /goal workflow for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
7
+ "types": "./index.d.ts",
7
8
  "exports": {
8
9
  ".": "./src/goal-plugin.js",
9
10
  "./server": "./src/goal-plugin.js"
10
11
  },
12
+ "bin": {
13
+ "opencode-goal-plugin": "./scripts/verify.mjs"
14
+ },
11
15
  "files": [
12
16
  "src",
13
17
  "scripts",
14
18
  "examples",
19
+ "index.d.ts",
15
20
  "README.md",
16
21
  "CHANGELOG.md",
17
22
  "CONTRIBUTING.md",
@@ -20,9 +25,10 @@
20
25
  ".nvmrc"
21
26
  ],
22
27
  "scripts": {
23
- "test": "node --test",
24
- "test:coverage": "node --test --experimental-test-coverage",
28
+ "test": "node --test test/*.test.js",
29
+ "test:coverage": "node --test --experimental-test-coverage test/*.test.js",
25
30
  "smoke": "node scripts/smoke-command-hook.mjs",
31
+ "verify": "node scripts/verify.mjs",
26
32
  "check": "node -c src/goal-plugin.js && npm test",
27
33
  "pack:check": "npm pack --dry-run"
28
34
  },
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ // Installation verification for opencode-goal-plugin.
3
+ // Checks the plugin can be loaded and wired up correctly without ever
4
+ // invoking a model — every check below uses the same mock-client approach
5
+ // as scripts/smoke-command-hook.mjs.
6
+
7
+ import assert from "node:assert/strict"
8
+
9
+ const REQUIRED_HOOKS = [
10
+ "command.execute.before",
11
+ "event",
12
+ "experimental.chat.system.transform",
13
+ "experimental.compaction.autocontinue",
14
+ ]
15
+
16
+ const results = []
17
+
18
+ function check(name, fn) {
19
+ return Promise.resolve()
20
+ .then(fn)
21
+ .then(() => {
22
+ results.push({ name, ok: true })
23
+ console.log(` ✅ ${name}`)
24
+ })
25
+ .catch((error) => {
26
+ results.push({ name, ok: false, error })
27
+ console.log(` ❌ ${name}`)
28
+ console.log(` ${error.message}`)
29
+ })
30
+ }
31
+
32
+ console.log("opencode-goal-plugin installation verification\n")
33
+
34
+ await check("Node.js >= 18", () => {
35
+ const major = Number(process.versions.node.split(".")[0])
36
+ assert.ok(major >= 18, `Node ${process.versions.node} is below the required >=18`)
37
+ })
38
+
39
+ let pluginModule
40
+ let GoalPlugin
41
+
42
+ await check("plugin module resolves and exposes expected shape", async () => {
43
+ pluginModule = await import("opencode-goal-plugin")
44
+ GoalPlugin = pluginModule.GoalPlugin
45
+ assert.equal(pluginModule.default.id, "opencode-goal-plugin")
46
+ assert.equal(typeof pluginModule.default.server, "function")
47
+ assert.equal(typeof GoalPlugin, "function")
48
+ })
49
+
50
+ const sessionID = `verify-${process.pid}`
51
+ const promptCalls = []
52
+ const logCalls = []
53
+
54
+ const client = {
55
+ app: {
56
+ log: async (input) => {
57
+ logCalls.push(input)
58
+ },
59
+ },
60
+ session: {
61
+ messages: async () => ({ data: [] }),
62
+ promptAsync: async (input) => {
63
+ promptCalls.push(input)
64
+ return {}
65
+ },
66
+ },
67
+ }
68
+
69
+ let hooks
70
+
71
+ await check("plugin initializes and registers all 4 required hooks", async () => {
72
+ // registerTools defaults to true but silently no-ops without the optional
73
+ // @opencode-ai/plugin peer dependency, so it is not asserted here — the
74
+ // 4 hooks below are always present regardless of that peer dependency.
75
+ hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
76
+ for (const hookName of REQUIRED_HOOKS) {
77
+ assert.equal(
78
+ typeof hooks[hookName],
79
+ "function",
80
+ `missing or non-function hook: ${hookName}`,
81
+ )
82
+ }
83
+ })
84
+
85
+ async function runGoalCommand(args) {
86
+ const output = { parts: [] }
87
+ await hooks["command.execute.before"](
88
+ { command: "goal", sessionID, arguments: args },
89
+ output,
90
+ )
91
+ assert.equal(output.parts.length, 1)
92
+ assert.equal(output.parts[0].type, "text")
93
+ return output.parts[0].text
94
+ }
95
+
96
+ await check("/goal status works", async () => {
97
+ const text = await runGoalCommand("status")
98
+ assert.match(text, /No active goal/)
99
+ })
100
+
101
+ await check("/goal set works", async () => {
102
+ const text = await runGoalCommand("verify the installation --max-turns 1")
103
+ assert.match(text, /New active goal: verify the installation/)
104
+ const statusText = await runGoalCommand("status")
105
+ assert.match(statusText, /Active goal: verify the installation/)
106
+ })
107
+
108
+ await check("no model calls were made during verification", () => {
109
+ assert.equal(promptCalls.length, 0, "expected zero promptAsync calls")
110
+ })
111
+
112
+ // Clean up the goal created above so this script has no side effects.
113
+ await runGoalCommand("clear")
114
+
115
+ console.log()
116
+
117
+ const failed = results.filter((r) => !r.ok)
118
+ if (failed.length > 0) {
119
+ console.log(`${failed.length}/${results.length} checks failed.`)
120
+ process.exit(1)
121
+ }
122
+
123
+ console.log(`All ${results.length} checks passed. opencode-goal-plugin is installed correctly.`)
@@ -2109,11 +2109,19 @@ function createChildSessionAuditor(client, { agent = "build", timeoutMs = 120_00
2109
2109
  }
2110
2110
  }
2111
2111
 
2112
- export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2112
+ export const GoalPlugin = async ({ client, directory } = {}, pluginOptions = {}) => {
2113
2113
  const defaultGoalOptions = normalizeOptions(pluginOptions)
2114
+ // OpenCode's PluginInput carries the active session's project directory
2115
+ // separately from the Node process's own process.cwd(), which — when
2116
+ // OpenCode runs as a persistent server/daemon serving multiple
2117
+ // projects/sessions — does NOT track the session's directory. Falling back
2118
+ // to process.cwd() here would silently resolve the project-local state
2119
+ // path against wherever the server happened to boot, not the project the
2120
+ // user is actually working in. An explicit `cwd` plugin option (mainly for
2121
+ // tests) still takes precedence.
2114
2122
  const persistenceOptions = normalizePersistenceOptions(pluginOptions, {
2115
2123
  env: pluginOptions.env,
2116
- cwd: pluginOptions.cwd,
2124
+ cwd: pluginOptions.cwd || directory,
2117
2125
  })
2118
2126
  const { commandName, registerCommand } = normalizeCommandOptions(pluginOptions)
2119
2127
  // Serialize all persist() calls through a promise chain so concurrent callers
@@ -2531,6 +2539,7 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2531
2539
  // goal and add another. Clear any ordered-sequence flag so the new
2532
2540
  // standalone goal does not trigger sisyphus auto-promotion of old sequence
2533
2541
  // goals that may still be in the registry (matches the agent setGoal path).
2542
+ const replacedGoal = goalStates.get(sessionID)
2534
2543
  sessionOrdered.delete(sessionID)
2535
2544
  cleanupGoal(sessionID)
2536
2545
  lastGoalResults.delete(sessionID)
@@ -2540,6 +2549,13 @@ export const GoalPlugin = async ({ client }, pluginOptions = {}) => {
2540
2549
  output.parts = [
2541
2550
  makeTextPart(
2542
2551
  [
2552
+ ...(replacedGoal
2553
+ ? [
2554
+ `⚠️ Replacing active goal: "${replacedGoal.condition}"`,
2555
+ `Use \`/${commandName} add <condition>\` instead to keep it running in the background.`,
2556
+ "",
2557
+ ]
2558
+ : []),
2543
2559
  `New active goal: ${goal.condition}`,
2544
2560
  goal.successCriteria ? `Success criteria: ${goal.successCriteria}` : null,
2545
2561
  goal.constraints ? `Constraints / non-goals: ${goal.constraints}` : null,