opencode-goal-plugin 0.6.1 → 0.6.2

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,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.6.2 — 2026-07-11
6
+
7
+ - Keep paused, blocked, and crash-recovered goals inert in model turns with a stopped-goal system guard, and enforce status/history/list/pause/clear control turns as read-only through the host's tool-execution hook so routed command text cannot mutate or resurrect work.
8
+ - Add installed-package TypeScript and full tool-surface contracts, critical mutation testing, generated lifecycle-model testing, Linux/macOS/Windows filesystem CI, CodeQL, dependency updates, and a verified release workflow.
9
+ - Preserve multi-goal creation order when a paused goal resumes; execution epochs rotate through `runId` while the public `goalId` remains stable.
10
+ - Document the supported compatibility surface, release integrity process, and private vulnerability reporting path.
11
+
5
12
  ## 0.6.1 — 2026-07-10
6
13
 
7
14
  - Default-deny verifier tools except read/glob/grep, and fail closed unless the owned verifier agent registers successfully.
@@ -9,7 +16,7 @@
9
16
  - Prevent mutating SDK shape retries, validate child-session ancestry, and make auditor timeout independent of a hanging abort.
10
17
  - Move context-budget reset to the successful `session.compacted` event and prevent ordered goals from reusing a prior goal's completion message.
11
18
  - Drain accepted persistence writes before disposal, suppress late disposed-instance writes, and re-check goal identity after asynchronous audits/announcements.
12
- - Harden persistence recovery with bounded hostile-state parsing, cross-session-safe ledger identities, multi-goal snapshots, archive restoration, corrupt-state quarantine, stale malformed-lock recovery, symlink-safe ledger appends, and exclusive one-project legacy migration with preserved backups.
19
+ - Improve persistence recovery with bounded hostile-state parsing, cross-session-safe ledger identities, multi-goal snapshots, archive restoration, corrupt-state quarantine, stale malformed-lock recovery, symlink-safe ledger appends, and exclusive one-project legacy migration with preserved backups.
13
20
  - Fail closed when neither terminal state nor ledger can be persisted; keep the goal paused instead of claiming archival.
14
21
  - Preserve paused time for queued/backgrounded goals, expose an opt-out for the tool-free heuristic, honor host `tokens.total`, and improve multi-step usage/cost availability accounting.
15
22
  - Add conditional TypeScript export mappings, an OpenCode engine range, honest auditor/visibility documentation, and cache-safe near-limit continuation warnings.
@@ -122,7 +129,7 @@
122
129
 
123
130
  ## 0.4.0 — 2026-06-21
124
131
 
125
- - **Expose agent-facing goal tools (`get_goal`, `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`)** when the host provides `@opencode-ai/plugin` (a new *optional* peer dependency, loaded via a cached dynamic import so the zero-runtime-dependency posture is preserved). `set_goal` is constrained by its description to explicit user requests (so the agent does not set goals on its own); it accepts optional `maxTurns` / `maxTokens` / `maxDurationMs` overrides plus `successCriteria` / `constraints` / `mode`. `update_goal` supports objective edits and `complete` / `blocked` / `paused` / `resumed` transitions (with `evidence` / `blocker`). Tools create, replace, and clear goals through the **multi-goal registry** (the same `buildGoalState` → `registerSessionGoal` → `focusGoal` path the `/goal` command uses), so tool-created goals persist, appear in `/goal list`, and are driven by the idle handler; `complete` archives with evidence and auto-promotes the next goal in an ordered (sisyphus) sequence. Registration is skipped gracefully when the package is absent or with `registerTools: false`. New `buildAgentToolHandlers` / `buildAgentTools` / `agentToolSessionID` helpers. Implements megalist items 7.1 and 7.2. _(This is the work the 0.3.0 changelog mistakenly listed as already shipped; it is now actually implemented and adapted to the current multi-goal architecture.)_
132
+ - **Expose agent-facing goal tools (`get_goal`, `get_goal_history`, `set_goal`, `update_goal`, `clear_goal`)** when the host provides `@opencode-ai/plugin` (a new *optional* peer dependency, loaded via a cached dynamic import so the zero-runtime-dependency posture is preserved). `set_goal` is constrained by its description to explicit user requests (so the agent does not set goals on its own); it accepts optional `maxTurns` / `maxTokens` / `maxDurationMs` overrides plus `successCriteria` / `constraints` / `mode`. `update_goal` supports objective edits and `complete` / `blocked` / `paused` / `resumed` transitions (with `evidence` / `blocker`). Tools create, replace, and clear goals through the **multi-goal registry** (the same `buildGoalState` → `registerSessionGoal` → `focusGoal` path the `/goal` command uses), so tool-created goals persist, appear in `/goal list`, and are driven by the idle handler; `complete` archives with evidence and auto-promotes the next goal in an ordered (sisyphus) sequence. Registration is skipped gracefully when the package is absent or with `registerTools: false`. New `buildAgentToolHandlers` / `buildAgentTools` / `agentToolSessionID` helpers. _(This is the work the 0.3.0 changelog mistakenly listed as already shipped; it is now actually implemented and adapted to the current multi-goal architecture.)_
126
133
  - **Fix a goal-registry leak when resuming.** `resetGoalBudget` rotates a goal's `goalId`, but the multi-goal registry is keyed by `goalId`, so resuming and then clearing/replacing left a stale entry behind (visible in `/goal list` and persisted). Both the `/goal resume` command path and the agent `update_goal {status:"resumed"}` path now re-key the registry to the new id (the focused pointer holds the same object). Regression test added for the command path.
127
134
 
128
135
  ## 0.3.0 — 2026-06-14
@@ -131,35 +138,35 @@
131
138
 
132
139
  ### Completion integrity & audit
133
140
 
134
- - **Require evidence to complete a goal and a concrete blocker to block one.** A `[goal:complete]` marker is now only honored when the assistant also supplies a non-empty `[goal:evidence] <summary>` line (on or before the completion marker); a `[goal:blocked]` is only honored when a concrete blocker is stated on the line before it. An unsubstantiated `[goal:complete]` or `[goal:blocked]` is rejected (not recorded / does not stop the goal) and the plugin sends a corrective continuation prompt demanding the missing evidence or blocker. The accepted evidence is stored on the result and shown in `/goal status` / `/goal history`. New `extractCompletionEvidence` helper, an `<evidence_required>` structural tag (added to the injection-escaping set), and continuation/system/compaction/creation prompts all updated to instruct the evidence requirement. Implements megalist item 2.1.
135
- - **Add an optional separate completion auditor that verifies before archival.** When a completion auditor is configured, a `[goal:complete]` (with evidence) is verified before the goal is archived: on approval it archives as achieved, on rejection the goal is *restored* (paused with stop reason `audit rejected` and the reason surfaced) rather than archived. Enable the built-in auditor — which spawns an independent OpenCode child session that replies `[audit:approved]`/`[audit:rejected]` — with `completionAudit: true`, or supply a custom `auditor({ goal, sessionID, latestText }) => { approved, reason }` (takes precedence). The built-in child-session auditor fails open if the session API is unavailable; a custom auditor that throws is treated as a rejection (fail closed). New `parseAuditVerdict` / `buildAuditPrompt` / `createChildSessionAuditor` helpers. Off by default. Implements megalist item 2.2.
136
- - **Announce completion/blocker audits with visible messages instead of silent background work.** When the assistant marks a goal complete or blocked, the plugin emits an audit-start and an audit-result message (e.g. "Auditing goal completion…" → "Audit result: completion accepted — goal archived"). Delivery defaults to OpenCode's structured log (`client.app.log`) and is pluggable via an `auditMessenger(sessionID, text)` option or disable-able with `auditMessages: false`. New `defaultAuditMessenger` helper. Implements megalist item 2.4.
141
+ - **Require evidence to complete a goal and a concrete blocker to block one.** A `[goal:complete]` marker is now only honored when the assistant also supplies a non-empty `[goal:evidence] <summary>` line (on or before the completion marker); a `[goal:blocked]` is only honored when a concrete blocker is stated on the line before it. An unsubstantiated `[goal:complete]` or `[goal:blocked]` is rejected (not recorded / does not stop the goal) and the plugin sends a corrective continuation prompt demanding the missing evidence or blocker. The accepted evidence is stored on the result and shown in `/goal status` / `/goal history`. New `extractCompletionEvidence` helper, an `<evidence_required>` structural tag (added to the injection-escaping set), and continuation/system/compaction/creation prompts all updated to instruct the evidence requirement.
142
+ - **Add an optional separate completion auditor that verifies before archival.** When a completion auditor is configured, a `[goal:complete]` (with evidence) is verified before the goal is archived: on approval it archives as achieved, on rejection the goal is *restored* (paused with stop reason `audit rejected` and the reason surfaced) rather than archived. Enable the built-in auditor — which spawns an independent OpenCode child session that replies `[audit:approved]`/`[audit:rejected]` — with `completionAudit: true`, or supply a custom `auditor({ goal, sessionID, latestText }) => { approved, reason }` (takes precedence). In this release, the built-in child-session auditor approved when the session API was unavailable; later releases changed operational failures to reject by default. A custom auditor that throws is treated as a rejection. New `parseAuditVerdict` / `buildAuditPrompt` / `createChildSessionAuditor` helpers. Off by default.
143
+ - **Announce completion/blocker audits with visible messages instead of silent background work.** When the assistant marks a goal complete or blocked, the plugin emits an audit-start and an audit-result message (e.g. "Auditing goal completion…" → "Audit result: completion accepted — goal archived"). Delivery defaults to OpenCode's structured log (`client.app.log`) and is pluggable via an `auditMessenger(sessionID, text)` option or disable-able with `auditMessages: false`. New `defaultAuditMessenger` helper.
137
144
 
138
145
  ### Durability
139
146
 
140
- - **Add an append-only JSONL lifecycle ledger with state reconstruction, and fail-closed terminal-state persistence.** Every lifecycle event (`pushHistory`) is also appended as one JSON line to `<stateFile>.ledger.jsonl` (synchronous, owner-only `0600`). Because in-memory history is capped, the ledger is the durable record: when the main state file is missing on startup, the plugin reconstructs still-active (non-`completed`/`cleared`) goals from the ledger and reloads them paused (new `reconstructed` load status). Terminal events are written to the ledger before the main state write, so a goal's terminal outcome survives a failed state write (fail-closed); `persistState` now returns success/failure and a failed terminal persist is logged at error level. Tied to `persistState`. New `appendLedgerLine` / `readLedgerEntries` / `reconstructGoalsFromLedger` helpers. Implements megalist items 2.3 and 2.5.
141
- - **Build the compaction summary deterministically from the persisted goal record.** `buildCompactionContext` folds in a reproducible progress summary — recent checkpoints and lifecycle events — derived from the goal's persisted `checkpoints`/`history` (new `buildCompactionProgressSummary` helper) rather than chat memory, and labels it as such. Implements megalist item 6.3.
147
+ - **Add an append-only JSONL lifecycle ledger with state reconstruction, and fail-closed terminal-state persistence.** Every lifecycle event (`pushHistory`) is also appended as one JSON line to `<stateFile>.ledger.jsonl` (synchronous, owner-only `0600`). Because in-memory history is capped, the ledger is the durable record: when the main state file is missing on startup, the plugin reconstructs still-active (non-`completed`/`cleared`) goals from the ledger and reloads them paused (new `reconstructed` load status). Terminal events are written to the ledger before the main state write, so a goal's terminal outcome survives a failed state write (fail-closed); `persistState` now returns success/failure and a failed terminal persist is logged at error level. Tied to `persistState`. New `appendLedgerLine` / `readLedgerEntries` / `reconstructGoalsFromLedger` helpers.
148
+ - **Build the compaction summary deterministically from the persisted goal record.** `buildCompactionContext` folds in a reproducible progress summary — recent checkpoints and lifecycle events — derived from the goal's persisted `checkpoints`/`history` (new `buildCompactionProgressSummary` helper) rather than chat memory, and labels it as such.
142
149
 
143
150
  ### Auto-continue guardrails
144
151
 
145
- - **Pause auto-continue on repeated tool-free continuation turns (no-tool-call gate).** Complementing the low-output no-progress check, the plugin tracks continuation turns whose assistant message has no tool calls (OpenCode `tool` / `subtask` parts) and, after `noToolCallTurnsBeforePause` consecutive such turns (default `2`), pauses with stop reason `no tool calls` to guard against self-chat loops. A tool-using turn resets the counter. Configurable via the `noToolCallTurnsBeforePause` option and `--no-tool-turns <n>` flag. New `messageHasToolCall` helper. Implements megalist item 5.1.
146
- - **Pause auto-continue when a real user message arrives ("latest instruction wins").** The idle handler detects a genuine human message that arrived after the plugin's most recent continuation and pauses the goal (stop reason `user intervention`) instead of talking over the user; `/goal resume` hands control back. Plugin-generated continuation prompts (user-role messages framed in `<goal_continuation>`) are ignored, and detection requires `turnCount > 0` plus a visible plugin continuation so the first idle and scrolled-out sessions are never misread. New `isPluginContinuationMessage` / `userInterventionDetected` helpers. Implements megalist items 5.2 and 5.3.
152
+ - **Pause auto-continue on repeated tool-free continuation turns (no-tool-call gate).** Complementing the low-output no-progress check, the plugin tracks continuation turns whose assistant message has no tool calls (OpenCode `tool` / `subtask` parts) and, after `noToolCallTurnsBeforePause` consecutive such turns (default `2`), pauses with stop reason `no tool calls` to guard against self-chat loops. A tool-using turn resets the counter. Configurable via the `noToolCallTurnsBeforePause` option and `--no-tool-turns <n>` flag. New `messageHasToolCall` helper.
153
+ - **Pause auto-continue when a real user message arrives ("latest instruction wins").** The idle handler detects a genuine human message that arrived after the plugin's most recent continuation and pauses the goal (stop reason `user intervention`) instead of talking over the user; `/goal resume` hands control back. Plugin-generated continuation prompts (user-role messages framed in `<goal_continuation>`) are ignored, and detection requires `turnCount > 0` plus a visible plugin continuation so the first idle and scrolled-out sessions are never misread. New `isPluginContinuationMessage` / `userInterventionDetected` helpers.
147
154
 
148
155
  ### Multiple goals
149
156
 
150
- - **Support multiple goals per session with `/goal add`, `/goal list`, and `/goal focus`.** A session can hold several live goals via a new `sessionGoals` registry; `goalStates` continues to track the single *focused* goal the idle handler drives. `/goal <condition>` replaces the focused goal; `/goal add <condition>` backgrounds the current goal and focuses a new one (only the focused goal auto-continues). `/goal list` shows numbered live goals plus a per-session archive of completed/cleared goals, and `/goal focus <number|id>` switches the active goal (numeric refs are index-only). Focus is tracked per session and persisted (state files gain a per-goal `focused` flag and an `archives` array; older single-goal files load with their goal focused). New `buildGoalState` / `formatGoalList` / session-registry helpers. Implements megalist items 3.1, 3.2, and 3.3.
151
- - **Add `/goal sisyphus` ordered goal sequences.** `/goal sisyphus <obj 1>; <obj 2>; …` sets up a strict execution sequence: the first objective is focused and the rest queued, and when the focused goal completes the plugin auto-promotes the next until the sequence is exhausted. The ordered flag is tracked per session, shown in `/goal list`, persisted (`orderedSessions`), and cleared by `/goal clear`. New `promoteNextOrderedGoal` helper. Implements megalist item 3.4.
157
+ - **Support multiple goals per session with `/goal add`, `/goal list`, and `/goal focus`.** A session can hold several live goals via a new `sessionGoals` registry; `goalStates` continues to track the single *focused* goal the idle handler drives. `/goal <condition>` replaces the focused goal; `/goal add <condition>` backgrounds the current goal and focuses a new one (only the focused goal auto-continues). `/goal list` shows numbered live goals plus a per-session archive of completed/cleared goals, and `/goal focus <number|id>` switches the active goal (numeric refs are index-only). Focus is tracked per session and persisted (state files gain a per-goal `focused` flag and an `archives` array; older single-goal files load with their goal focused). New `buildGoalState` / `formatGoalList` / session-registry helpers.
158
+ - **Add `/goal sisyphus` ordered goal sequences.** `/goal sisyphus <obj 1>; <obj 2>; …` sets up a strict execution sequence: the first objective is focused and the rest queued, and when the focused goal completes the plugin auto-promotes the next until the sequence is exhausted. The ordered flag is tracked per session, shown in `/goal list`, persisted (`orderedSessions`), and cleared by `/goal clear`. New `promoteNextOrderedGoal` helper.
152
159
 
153
160
  ### Schema & command UX
154
161
 
155
- - **Add success-criteria, constraints/non-goals, and mode to the goal schema.** A goal can carry `successCriteria` (`--success`), `constraints` (`--constraints` / `--non-goals`), and a `mode` of `normal` or `ordered` (`--mode`, `sisyphus` alias). These thread through state, persistence, the injected goal block (escaped, new `success_criteria` / `constraints` structural tags), creation output, and `/goal status`. New `normalizeMode` helper. Implements megalist items 4.1, 4.2, and 4.3.
156
- - **Add an inline `--budget <n>` flag** on the create command — a shorthand for the context-token limit accepting a plain integer or `k`/`m` suffix (e.g. `--budget 100k`). New `parseTokenBudget` helper. Implements megalist item 8.1.
157
- - **Make the slash command configurable (`commandName`) and optional (`registerCommand`).** `commandName` (default `goal`, leading slash tolerated) lets the plugin own e.g. `/objective`, with all user-facing hints following the configured name; `registerCommand: false` skips installing the command hook entirely. New `normalizeCommandOptions` helper. Implements megalist item 8.2.
162
+ - **Add success-criteria, constraints/non-goals, and mode to the goal schema.** A goal can carry `successCriteria` (`--success`), `constraints` (`--constraints` / `--non-goals`), and a `mode` of `normal` or `ordered` (`--mode`, `sisyphus` alias). These thread through state, persistence, the injected goal block (escaped, new `success_criteria` / `constraints` structural tags), creation output, and `/goal status`. New `normalizeMode` helper.
163
+ - **Add an inline `--budget <n>` flag** on the create command — a shorthand for the context-token limit accepting a plain integer or `k`/`m` suffix (e.g. `--budget 100k`). New `parseTokenBudget` helper.
164
+ - **Make the slash command configurable (`commandName`) and optional (`registerCommand`).** `commandName` (default `goal`, leading slash tolerated) lets the plugin own e.g. `/objective`, with all user-facing hints following the configured name; `registerCommand: false` skips installing the command hook entirely. New `normalizeCommandOptions` helper.
158
165
 
159
166
  ### Storage, tools & packaging
160
167
 
161
- - **Default goal state to a project-local path, with an env override and migration fallbacks.** State resolves as `stateFilePath` option → `OPENCODE_GOAL_STATE_PATH` env var → project-local `<cwd>/.opencode/goals/state.json` (previously `~/.opencode-goal-plugin/state.json`). When the default path is empty, the plugin migrates forward on first load from the legacy home path and the XDG path, then writes project-local. Explicit option/env paths are literal with no fallback; a present-but-corrupt primary is preserved. New `resolveStateFilePath` / `xdgStateFilePath` / `legacyStateFilePaths` helpers. Home-based fallback paths resolve from an injectable `env.HOME` (falling back to `os.homedir()`), making path resolution deterministic across platforms — `os.homedir()` ignores `$HOME` on macOS. Implements megalist items 6.1 and 6.2.
162
- - _**Correction (2026-06-21):** an earlier version of this entry claimed agent-facing goal tools shipped in 0.3.0. They did not — the work was on an unmerged branch (`wr/agent-tools`) and was never included in the 0.3.0 release. The feature now actually ships; see the **0.6.0** section above. Megalist items 7.1 and 7.2._
168
+ - **Default goal state to a project-local path, with an env override and migration fallbacks.** State resolves as `stateFilePath` option → `OPENCODE_GOAL_STATE_PATH` env var → project-local `<cwd>/.opencode/goals/state.json` (previously `~/.opencode-goal-plugin/state.json`). When the default path is empty, the plugin migrates forward on first load from the legacy home path and the XDG path, then writes project-local. Explicit option/env paths are literal with no fallback; a present-but-corrupt primary is preserved. New `resolveStateFilePath` / `xdgStateFilePath` / `legacyStateFilePaths` helpers. Home-based fallback paths resolve from an injectable `env.HOME` (falling back to `os.homedir()`), making path resolution deterministic across platforms — `os.homedir()` ignores `$HOME` on macOS.
169
+ - _**Correction (2026-06-21):** an earlier version of this entry claimed agent-facing goal tools shipped in 0.3.0. They did not — the work was on an unmerged branch (`wr/agent-tools`) and was never included in the 0.3.0 release. The feature now ships; see the **0.4.0** section above._
163
170
  - **Release automation note.** Development included a proposed npm Trusted Publishing workflow, but `.github/workflows/publish.yml` was not part of the final release history and is not present in the current repository. Releases therefore remain manual unless a separately reviewed publishing workflow is added. No package is published solely by the CI workflow documented in this repository.
164
171
 
165
172
  ## 0.2.0 — 2026-06-14
package/CONTRIBUTING.md CHANGED
@@ -15,8 +15,11 @@ Run the local checks before submitting changes:
15
15
  ```sh
16
16
  npm test
17
17
  npm run test:coverage
18
+ npm run type:check
19
+ npm run test:mutation
18
20
  npm run smoke
19
21
  npm run smoke:packed-host
22
+ npm run smoke:packed-tools
20
23
  npm run benchmark:behavior
21
24
  npm run verify
22
25
  npm run check
@@ -30,25 +33,19 @@ For behavior changes, add or update tests in `test/goal-plugin.test.js`.
30
33
  This plugin depends on OpenCode plugin hooks, including experimental hooks. When changing hook usage, command behavior, or system-prompt transforms:
31
34
 
32
35
  1. Check the current OpenCode plugin and command documentation.
33
- 2. Run `npm run smoke` and `npm run smoke:packed-host` to verify the source and installed-tarball host contracts.
36
+ 2. Run `npm run smoke`, `npm run smoke:packed-host`, `npm run smoke:packed-tools`, and `npm run type:check` to verify the source and installed-tarball contracts.
34
37
  3. Test against a real OpenCode install when possible.
35
38
  4. Update the README compatibility snapshot if the tested surface changes.
36
39
 
37
- `npm run smoke` verifies the package export path and `/goal` command hook without invoking a model. `npm run smoke:packed-host` installs the packed artifact in an isolated directory and checks the public hook/tool contract. `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 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.
38
41
 
39
42
  ## Release checklist
40
43
 
41
- Before publishing or tagging a release:
42
-
43
- - update `CHANGELOG.md`
44
- - run `npm test`
45
- - run `npm run test:coverage`
46
- - run `npm run smoke`
47
- - run `npm run smoke:packed-host`
48
- - run `npm run benchmark:behavior`
49
- - run `npm run verify`
50
- - run `npm run check`
51
- - run `npm run pack:check`
44
+ Before publishing or tagging a release, follow [the release process](docs/releasing.md). The required automated gate is:
45
+
46
+ - run `npm ci`
47
+ - update `CHANGELOG.md` and both package-version files
48
+ - run `npm run release:check`
52
49
  - perform at least one manual OpenCode smoke test if hook behavior changed
53
50
  - refresh compatibility notes if the tested OpenCode surface changed
54
51
 
package/README.md CHANGED
@@ -3,10 +3,10 @@
3
3
  [![npm version](https://img.shields.io/npm/v/opencode-goal-plugin)](https://www.npmjs.com/package/opencode-goal-plugin)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/opencode-goal-plugin)](https://www.npmjs.com/package/opencode-goal-plugin)
5
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-passing-brightgreen)](test/)
6
+ [![CodeQL](https://github.com/willytop8/OpenCode-goal-plugin/actions/workflows/codeql.yml/badge.svg)](https://github.com/willytop8/OpenCode-goal-plugin/actions/workflows/codeql.yml)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
8
 
9
- An experimental session-scoped `/goal` command for [OpenCode](https://opencode.ai/).
9
+ A session-scoped `/goal` workflow for [OpenCode](https://opencode.ai/).
10
10
 
11
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.
12
12
 
@@ -27,9 +27,13 @@ This project is independently implemented for OpenCode. Product names used elsew
27
27
  | Surface | Status |
28
28
  |---|---|
29
29
  | Node.js | Declared support: `>=18`; CI covers Node 18, 20, 22, and 24 |
30
- | Package entrypoint | `npm run smoke` verifies the package export path plus `/goal` command-hook behavior from a local install without invoking a model |
30
+ | Operating systems | Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows |
31
+ | Package entrypoint | Installed-tarball contracts verify both export paths, consumer TypeScript resolution, hooks, and all 11 tools |
31
32
  | Provider/backend quirks | Strict-template backends require the goal block to merge into the primary `system` message; covered by regression tests |
32
33
 
34
+ See the [compatibility policy](docs/compatibility.md) for the supported public
35
+ surface and versioning expectations.
36
+
33
37
  ### OpenCode version compatibility
34
38
 
35
39
  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:
@@ -380,7 +384,7 @@ The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided
380
384
 
381
385
  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.
382
386
 
383
- 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.
387
+ 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.
384
388
 
385
389
  The plugin depends on `experimental.chat.system.transform` and other OpenCode plugin hooks that may change between OpenCode versions.
386
390
 
@@ -396,7 +400,7 @@ If a goal does not continue:
396
400
  2. Run `/goal resume` only after resolving the reported reason. Resume creates a fresh local budget window; it does not erase the objective or history.
397
401
  3. Check OpenCode's structured logs for persistence, SDK-shape, prompt, or auditor errors.
398
402
  4. Confirm the configured project directory and state-path precedence described under [Safety limits](#safety-limits). A daemon started elsewhere can otherwise make a manually configured relative path surprising.
399
- 5. Run `npm run verify`, `npm run smoke`, and `npm run smoke:packed-host` against the installed source when diagnosing registration or packaging problems. `npm run benchmark:behavior` exercises completion, false-completion, loop, interruption, compaction, and restart behavior without a provider call.
403
+ 5. Run `npm run verify`, `npm run smoke`, and `npm run smoke:packed-host` against the installed source when diagnosing registration or packaging problems. Maintainers can run `npm run release:check` for the complete artifact and quality gate. `npm run benchmark:behavior` exercises completion, false-completion, loop, interruption, compaction, and restart behavior without a provider call.
400
404
 
401
405
  Do not paste `state.json`, its ledger, or verbose logs into a public issue without reviewing them first: they can contain goal text, assistant checkpoints, blockers, local paths, and command evidence. Prefer the bounded status/history output and redact project-specific content. There is intentionally no broad "dump diagnostics" tool: exposing process-wide session state or persistence paths to the model would add more privacy risk than troubleshooting value.
402
406
 
@@ -427,12 +431,16 @@ Keep test files outside OpenCode's auto-loaded plugin directory — OpenCode wil
427
431
  ```sh
428
432
  npm test # run the test suite
429
433
  npm run test:coverage # run tests with coverage
434
+ npm run type:check # compile installed-package consumers
435
+ npm run test:mutation # prove critical regressions are detected
430
436
  npm run smoke # verify package export + command hook without a model call
431
437
  npm run smoke:packed-host # install the packed tarball and exercise the host contract
438
+ npm run smoke:packed-tools # verify all tools from an installed tarball
432
439
  npm run benchmark:behavior # deterministic autonomy + token-efficiency scenarios
433
440
  npm run verify # verify the installed plugin hook surface
434
441
  npm run check # syntax check + tests
435
442
  npm run pack:check # verify package contents before publishing
443
+ npm run release:check # run the complete release gate
436
444
  ```
437
445
 
438
446
  ## License
package/SECURITY.md CHANGED
@@ -2,18 +2,16 @@
2
2
 
3
3
  ## Supported Versions
4
4
 
5
- This project is experimental. Security fixes are provided for the latest published version only.
5
+ Security fixes are provided for the latest published version only. Users should
6
+ upgrade to the newest patch release before reporting a vulnerability.
6
7
 
7
8
  ## Reporting a Vulnerability
8
9
 
9
- GitHub private vulnerability reporting may not always be enabled for this repository.
10
-
11
- Until a dedicated private reporting channel is documented here, do **not** open a public issue with exploit details, credentials, local paths, or reproduction steps that could expose user data or local system access.
12
-
13
- Instead:
14
-
15
- 1. open a minimal public issue asking for a private contact path, or
16
- 2. contact the maintainer through their GitHub profile and request a private handoff.
10
+ Use [GitHub's private vulnerability report](https://github.com/willytop8/OpenCode-goal-plugin/security/advisories/new).
11
+ Do **not** open a public issue with exploit details, credentials, local paths, or
12
+ reproduction steps that could expose user data or local system access. If private
13
+ reporting is temporarily unavailable, contact the maintainer through their GitHub
14
+ profile and request a private handoff.
17
15
 
18
16
  ## Scope
19
17
 
@@ -0,0 +1,41 @@
1
+ # Compatibility policy
2
+
3
+ ## Supported package surface
4
+
5
+ The latest published release is the supported line. Public compatibility covers:
6
+
7
+ - the package root and `opencode-goal-plugin/server` ESM exports
8
+ - the declarations exported by `index.d.ts`
9
+ - the documented `GoalPluginOptions` fields
10
+ - the documented OpenCode hook names
11
+ - the six canonical goal tools and five legacy tool aliases
12
+ - persisted-state recovery from versions documented in the changelog
13
+
14
+ The package requires Node.js 18 or newer and OpenCode 1.17.15 through the latest
15
+ compatible 1.x release. CI runs the complete unit suite on Node 18, 20, 22, and
16
+ 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
+
20
+ Filesystem-sensitive lifecycle tests run on Linux, macOS, and Windows. POSIX file
21
+ mode and symbolic-link protections are applied where the operating system supports
22
+ them; the plugin does not claim that Windows provides equivalent POSIX semantics.
23
+
24
+ ## OpenCode host compatibility
25
+
26
+ OpenCode's experimental hooks and SDK request shapes may change within the 1.x
27
+ line. Automated tests cover both current flattened session inputs and the legacy
28
+ generated-client shape, but a real-host smoke test remains required when hook or
29
+ SDK behavior changes. The current manual provider matrix is maintained in
30
+ [providers.md](providers.md).
31
+
32
+ ## Versioning
33
+
34
+ Semantic-versioning intent is:
35
+
36
+ - patch: compatible fixes, documentation, and stronger verification
37
+ - minor: backward-compatible options, hooks, commands, or tools
38
+ - major: removal or incompatible change to a documented public surface
39
+
40
+ `testInternals` is exported for diagnostics and the project's own tests; it is not
41
+ part of the semantic-version compatibility guarantee.
package/docs/providers.md CHANGED
@@ -20,9 +20,9 @@ vary by provider and model:
20
20
  See the [OpenCode version compatibility table](../README.md#opencode-version-compatibility)
21
21
  in the README for the current findings.
22
22
 
23
- All rows below were verified manually against the real OpenCode TUI (`tmux`
24
- + live provider credentials, no mocks) on OpenCode 1.17.15, driving the
25
- plugin through `/goal status`, `/goal <condition> --max-turns N`, and
23
+ 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
26
  inspecting the plugin's persisted state file to confirm state mutations
27
27
  (limit parsing, turn/stop accounting, evidence-gated completion detection)
28
28
  independent of what was rendered in the terminal.
@@ -31,10 +31,29 @@ independent of what was rendered in the terminal.
31
31
 
32
32
  | Provider | Model | Marker compliance | Notes |
33
33
  |---|---|---|---|
34
+ | `opencode` | `deepseek-v4-flash-free` | ✅ Canonical tools | OpenCode 1.17.15, isolated HOME/XDG/project, loading the exact 0.6.2 branch source by file URL. In a real interactive PTY, the model fixed an intentionally failing two-test project, ran the tests to 2/2 passing, and completed through `goal_complete`. A second goal checkpointed after writing `step-1`; the plugin ledger then recorded `auto-continue 1/2`, after which the model wrote and verified `step-2` and completed. Sessions: `ses_0b021d93affeCsNvWkmJWwZzF2` and `ses_0b01f3767ffej4Mn6DMSWft0aX`. `/goal status` text was routed to the model by this host version, which then called `goal_status`; the persisted ledger remained authoritative. |
34
35
  | `opencode-go` | `qwen3.7-plus` | ✅ Self-corrects | First attempt emitted bare `[goal:complete]` with no evidence line and was correctly rejected by the plugin. On the very next turn it read the `<evidence_required>` re-prompt, added a `[goal:evidence]` line, and completed cleanly — a good demonstration of the evidence gate actually improving behavior rather than just failing closed. |
35
36
  | `opencode-go` | `glm-5.2` | ✅ Clean | Emitted a correct `[goal:evidence] ... [goal:complete]` pair on the first attempt. (An earlier plugin version without the evidence requirement showed GLM-5.2 sometimes trailing extra text after a bare marker — the more structured `<completion_audit>` prompt this plugin version sends appears to help.) |
36
37
  | `deepseek` | `deepseek-chat` | ✅ Clean | Emitted a correct `[goal:evidence] ... [goal:complete]` pair on the first attempt, both in a short synthetic goal and in the full [demo](../demo/) (autonomously located and fixed a real bug, then reported evidence-backed completion). Correctly parses per-goal flags (`--max-turns`, etc.) out of the condition text. |
37
38
 
39
+ ## OpenCode 1.17.15 lifecycle canaries
40
+
41
+ The `opencode/deepseek-v4-flash-free` row was also exercised through an
42
+ isolated project, HOME, and XDG directories while loading the exact 0.6.2
43
+ source by `file://` URL. Persisted state, ledger entries, and file contents
44
+ were checked independently of the model's prose.
45
+
46
+ | Scenario | Result | Evidence |
47
+ |---|---|---|
48
+ | Normal completion | ✅ Pass | `ses_0b021d93affeCsNvWkmJWwZzF2`: fixed an intentionally failing project, reran 2/2 tests, and completed with structured evidence. |
49
+ | Idle auto-continuation | ✅ Pass | `ses_0b01f3767ffej4Mn6DMSWft0aX`: checkpointed `step-1`, ledger recorded auto-continue 1/2, then verified `step-2` and completed. |
50
+ | Pause and explicit resume across processes | ✅ Pass | `ses_0b01470c1ffeug0LX69fageiVm`: remained paused between OpenCode invocations; explicit resume released it and completion was archived. |
51
+ | Concrete blocker and restart | ✅ Pass | `ses_0b013a903ffeg3I6tGbArE2b5E`: stopped with the missing approval-file reason and did not auto-continue after a fresh process loaded it. |
52
+ | Hard process interruption and recovery | ✅ Pass | `ses_0b00b37a8ffeDrBshGDutFTqUm`: a running process was terminated during a shell wait; restart recovered paused, status blocked a stale `goal_resume`, the file stayed unchanged, and a later explicit resume completed. |
53
+ | Real host compaction | ✅ Pass | `ses_0b00958e3ffekLLH5ztkkvHIPL`: the documented session summarize endpoint returned `true`; exported session data contained a real compaction part plus injected goal/checkpoint context, token accounting reset, and the paused goal resumed cleanly. |
54
+ | Clear with stale conversation history | ✅ Pass | `ses_0affb9235ffeWl5LuZxXmvhSfM`: after command-side clearing, the routed model turn attempted `clear_goal` and `goal_resume`; the tool hook rejected all attempts, the file remained `before-clear`, and no goal survived in state. |
55
+ | Interactive Esc during a running shell tool | ⚠️ Not established | Esc sent through the automated PTY did not interrupt OpenCode's shell tool. Hard-process recovery is verified above and host-abort hooks have deterministic tests, but this specific TUI input path is not claimed as passed. |
56
+
38
57
  Untested at time of writing: `deepseek-reasoner`, `mistral/*`, `openrouter/*`,
39
58
  and any `nvidia`/`google` provider — add rows here as they're verified. See
40
59
  [Testing a new model](#testing-a-new-model) below.
@@ -0,0 +1,41 @@
1
+ # Release process
2
+
3
+ Releases are deliberately verified before they are published. GitHub Actions does
4
+ not publish to npm automatically; the maintainer reviews and publishes the exact
5
+ artifact after all checks pass.
6
+
7
+ ## Prepare
8
+
9
+ 1. Start from a clean branch based on `main`.
10
+ 2. Update the version in `package.json` and `package-lock.json` together.
11
+ 3. Move relevant entries from `Unreleased` into a dated changelog section.
12
+ 4. Run `npm ci` followed by `npm run release:check`.
13
+ 5. Inspect `npm pack --json` and the generated tarball before publishing.
14
+
15
+ `release:check` runs the unit and coverage suites, consumer type compilation,
16
+ critical mutation contract, behavior benchmark, source and installed-artifact
17
+ smoke tests, full optional-peer tool registration, and package-content check.
18
+
19
+ ## Publish
20
+
21
+ After the commit is reviewed and CI is green, create and push an annotated
22
+ `vX.Y.Z` tag at the same commit. The release workflow rejects a tag whose version
23
+ does not match `package.json`, reruns the complete release gate, and retains the
24
+ verified npm tarball as a workflow artifact.
25
+
26
+ Download that artifact, inspect it, and publish the tarball itself:
27
+
28
+ ```sh
29
+ npm publish opencode-goal-plugin-X.Y.Z.tgz --access public
30
+ ```
31
+
32
+ Confirm the registry digest and unpacked contents match the locally reviewed
33
+ artifact before drafting the GitHub release notes. Never rebuild or edit an
34
+ artifact after it has been published; prepare a new patch release instead.
35
+
36
+ ## Trusted publishing
37
+
38
+ For a future fully automated publish, configure npm Trusted Publishing for this
39
+ repository and a narrowly scoped GitHub Actions workflow, keep `id-token: write`
40
+ only on the publish job, require the release environment, and publish with
41
+ provenance. Do not add a long-lived npm token to repository secrets.
package/index.d.ts CHANGED
@@ -17,10 +17,58 @@ export interface CompletionAuditVerdict {
17
17
  reason?: string
18
18
  }
19
19
 
20
+ /** A timestamped lifecycle entry retained with an active or archived goal. */
21
+ export interface GoalHistoryEntry {
22
+ type: string
23
+ detail: string
24
+ timestamp: number
25
+ }
26
+
27
+ /** A bounded progress checkpoint retained with a goal. */
28
+ export interface GoalCheckpoint {
29
+ summary: string
30
+ timestamp: number
31
+ }
32
+
33
+ /** Normalized token and cost usage accumulated for the current goal run. */
34
+ export interface GoalUsage {
35
+ input: number
36
+ output: number
37
+ reasoning: number
38
+ cacheRead: number
39
+ cacheWrite: number
40
+ cost: number
41
+ costKnown: boolean
42
+ }
43
+
44
+ /** Read-only goal snapshot passed to custom completion auditors. */
45
+ export interface GoalAuditSnapshot {
46
+ goalId: string
47
+ runId: string
48
+ condition: string
49
+ successCriteria: string
50
+ constraints: string
51
+ mode: "normal" | "sisyphus"
52
+ sessionID: string
53
+ turnCount: number
54
+ startedAt: number
55
+ pausedAt: number
56
+ totalTokens: number
57
+ usage: Readonly<GoalUsage>
58
+ options: Readonly<GoalPluginOptions>
59
+ lastStatus: string
60
+ blockedReason: string
61
+ stopped: boolean
62
+ stopReason: string
63
+ history: readonly Readonly<GoalHistoryEntry>[]
64
+ checkpoints: readonly Readonly<GoalCheckpoint>[]
65
+ lastCheckpoint: Readonly<GoalCheckpoint> | null
66
+ }
67
+
20
68
  /** Arguments passed to a custom {@link GoalPluginOptions.auditor} function. */
21
69
  export interface CompletionAuditContext {
22
70
  /** The goal being audited (objective, budget usage, checkpoints, etc.). */
23
- goal: unknown
71
+ goal: Readonly<GoalAuditSnapshot>
24
72
  /** The OpenCode session ID the goal belongs to. */
25
73
  sessionID: string
26
74
  /** The assistant's latest response text, containing the `[goal:evidence]`/`[goal:complete]` claim. */
@@ -283,10 +331,10 @@ export interface GoalPluginOptions {
283
331
 
284
332
  /**
285
333
  * Custom sink for audit announcements. Defaults to routing through
286
- * OpenCode's structured log (`client.app.log`). Provide this to route
287
- * audit messages elsewhere, e.g. into the live conversation.
334
+ * OpenCode's structured log (`client.app.log`) and TUI toast when those
335
+ * host APIs are available. Provide this to route audit messages elsewhere.
288
336
  */
289
- auditMessenger?: (sessionID: string, text: string) => Promise<void>
337
+ auditMessenger?: (sessionID: string, text: string) => Promise<void> | void
290
338
  }
291
339
 
292
340
  /**
@@ -300,6 +348,8 @@ export interface GoalPluginHooks {
300
348
  config: (config: unknown) => Promise<void>
301
349
  /** Omitted entirely when {@link GoalPluginOptions.registerCommand} is `false`. */
302
350
  "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. */
352
+ "tool.execute.before": (input: unknown, output: unknown) => Promise<void>
303
353
  event: (input: unknown) => Promise<void>
304
354
  "experimental.chat.system.transform": (input: unknown, output: unknown) => Promise<void>
305
355
  "experimental.compaction.autocontinue": (input: unknown, output: unknown) => Promise<void>
@@ -312,7 +362,6 @@ export interface GoalPluginHooks {
312
362
  tool?: Record<string, unknown>
313
363
  /** Cancels pending continuation work and releases this plugin instance. */
314
364
  dispose: () => Promise<void>
315
- [hook: string]: unknown
316
365
  }
317
366
 
318
367
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-plugin",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Durable, guarded goal workflows for OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./src/goal-plugin.js",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "files": [
24
24
  "src",
25
- "scripts",
25
+ "scripts/verify.mjs",
26
26
  "examples",
27
27
  "demo",
28
28
  "docs",
@@ -36,13 +36,18 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "test": "node --test test/*.test.js",
39
+ "test:model": "node --test test/lifecycle-model.test.js",
40
+ "test:mutation": "node scripts/mutation-contract.mjs",
39
41
  "test:coverage": "node --test --experimental-test-coverage test/*.test.js",
42
+ "type:check": "node scripts/type-contract.mjs",
40
43
  "smoke": "node scripts/smoke-command-hook.mjs",
41
44
  "smoke:packed-host": "node scripts/packed-host-contract.mjs",
45
+ "smoke:packed-tools": "node scripts/packed-tool-contract.mjs",
42
46
  "benchmark:behavior": "node scripts/behavior-benchmark.mjs",
43
47
  "verify": "node scripts/verify.mjs",
44
48
  "check": "node -c src/goal-plugin.js && npm test",
45
- "pack:check": "npm pack --dry-run"
49
+ "pack:check": "npm pack --dry-run",
50
+ "release:check": "npm run check && npm run test:coverage && npm run type:check && npm run test:mutation && npm run benchmark:behavior && npm run smoke && npm run smoke:packed-host && npm run smoke:packed-tools && npm run verify && npm audit --omit=dev --audit-level=high && npm run pack:check"
46
51
  },
47
52
  "keywords": [
48
53
  "opencode",
@@ -73,5 +78,9 @@
73
78
  "homepage": "https://github.com/willytop8/OpenCode-goal-plugin#readme",
74
79
  "author": {
75
80
  "name": "willytop8"
81
+ },
82
+ "devDependencies": {
83
+ "@opencode-ai/plugin": "1.17.18",
84
+ "typescript": "5.9.3"
76
85
  }
77
86
  }
@@ -9,6 +9,7 @@ import assert from "node:assert/strict"
9
9
  const REQUIRED_HOOKS = [
10
10
  "config",
11
11
  "command.execute.before",
12
+ "tool.execute.before",
12
13
  "event",
13
14
  "experimental.chat.system.transform",
14
15
  "experimental.compaction.autocontinue",
@@ -88,6 +88,7 @@ function createRuntimeState() {
88
88
  activeContinues: new Map(),
89
89
  continuationControllers: new Map(),
90
90
  seenIdleEventIDs: new Set(),
91
+ readOnlyCommandGuards: new Set(),
91
92
  ledgerSink: null,
92
93
  persistenceLease: null,
93
94
  migrationLease: null,
@@ -143,6 +144,7 @@ const seenOutputTokens = runtimeCollection("seenOutputTokens")
143
144
  const activeContinues = runtimeCollection("activeContinues")
144
145
  const CLEAR_COMMANDS = new Set(["clear", "stop", "off", "reset", "none", "cancel"])
145
146
  const PAUSE_COMMANDS = new Set(["pause"])
147
+ const READ_ONLY_COMMAND_TOOLS = new Set(["goal_status", "get_goal", "get_goal_history", "read", "glob", "grep"])
146
148
  const GOAL_FLAG_SPECS = {
147
149
  "--max-turns": {
148
150
  optionKey: "maxTurns",
@@ -206,7 +208,7 @@ function messageHasToolCall(message) {
206
208
 
207
209
  const GOAL_MODES = new Set(["normal", "ordered"])
208
210
 
209
- // Goal "mode" field (item 4.3): normal vs ordered (a.k.a. sisyphus). `ordered`
211
+ // Goal mode: normal vs ordered (a.k.a. sisyphus). `ordered`
210
212
  // signals a strict execution sequence; `sisyphus` is accepted as an alias.
211
213
  // Returns the canonical mode or null when unrecognized.
212
214
  function normalizeMode(value) {
@@ -289,12 +291,12 @@ function makeHistoryEntry(type, detail, timestamp = Date.now()) {
289
291
  }
290
292
  }
291
293
 
292
- // Append-only lifecycle ledger (item 2.3). pushHistory emits every lifecycle
294
+ // Append-only lifecycle ledger. pushHistory emits every lifecycle
293
295
  // event to this sink, which a configured plugin instance points at a JSONL
294
296
  // file. Because the in-memory history is truncated to MAX_HISTORY_ENTRIES, the
295
297
  // ledger is the durable record used to reconstruct state if the main state file
296
298
  // is lost or corrupted, and it captures terminal events even when the main
297
- // state write fails (fail-closed, item 2.5).
299
+ // state write fails (fail closed).
298
300
  function setLedgerSink(sink) {
299
301
  currentRuntime().ledgerSink = typeof sink === "function" ? sink : null
300
302
  }
@@ -705,6 +707,7 @@ function clearRuntimeState() {
705
707
  activeContinues.clear()
706
708
  runtime.continuationControllers.clear()
707
709
  runtime.seenIdleEventIDs.clear()
710
+ runtime.readOnlyCommandGuards.clear()
708
711
  }
709
712
 
710
713
  function pruneGoalResults(options) {
@@ -1001,7 +1004,7 @@ async function assertSafeProjectPersistencePath({ stateFilePath, projectRoot, en
1001
1004
  }
1002
1005
  }
1003
1006
 
1004
- // Command surface options (item 8.2): `commandName` lets the plugin own a
1007
+ // Command surface options: `commandName` lets the plugin own a
1005
1008
  // different slash command (e.g. /objective) and `registerCommand: false` makes
1006
1009
  // the plugin skip the command hook entirely (agent/programmatic use only). A
1007
1010
  // leading slash in commandName is tolerated and stripped.
@@ -1418,7 +1421,7 @@ async function loadPersistedState(persistenceOptions, client) {
1418
1421
 
1419
1422
  // Last-resort recovery: when the main state file is absent, rebuild still-active
1420
1423
  // goals from the append-only ledger so a lost/rotated state file does not drop
1421
- // in-flight goals (item 2.3). Recovered goals are paused (via deserializeGoal).
1424
+ // in-flight goals. Recovered goals are paused (via deserializeGoal).
1422
1425
  async function reconstructFromLedger(persistenceOptions, client) {
1423
1426
  const entries = await readLedgerEntries(persistenceOptions.ledgerFilePath, {
1424
1427
  maxBytes: persistenceOptions.ledgerMaxBytes,
@@ -1788,7 +1791,7 @@ function buildContinueMessage(
1788
1791
 
1789
1792
  // Deterministic progress summary built from the plugin's persisted goal record
1790
1793
  // (checkpoints + lifecycle history) rather than from chat memory, so it is
1791
- // stable and reproducible across a compaction (item 6.3).
1794
+ // stable and reproducible across a compaction.
1792
1795
  function buildCompactionProgressSummary(goal, { maxCheckpoints = 3, maxEvents = 6 } = {}) {
1793
1796
  const lines = []
1794
1797
  const checkpoints = Array.isArray(goal.checkpoints) ? goal.checkpoints.slice(-maxCheckpoints) : []
@@ -2069,7 +2072,7 @@ function isPluginContinuationMessage(message) {
2069
2072
 
2070
2073
  // "Latest instruction wins": detect a real (human) user message that arrived
2071
2074
  // after the plugin's most recent continuation prompt. Plugin-generated
2072
- // continuation/audit messages are ignored (item 5.2). Detection requires the
2075
+ // continuation/audit messages are ignored. Detection requires the
2073
2076
  // loop to be running (turnCount > 0) and a plugin continuation to be visible in
2074
2077
  // the recent window, so the first idle after /goal set and sessions where the
2075
2078
  // continuations have scrolled out of view are never misread as intervention.
@@ -2139,7 +2142,7 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = "
2139
2142
  const AGENT_UPDATE_STATUSES = new Set(["complete", "blocked", "paused", "resumed"])
2140
2143
 
2141
2144
  // Programmatic equivalents of the /goal command, exposed to the agent as tools
2142
- // (megalist items 7.1 / 7.2). Each handler operates on a session id and mutates
2145
+ // Each handler operates on a session id and mutates
2143
2146
  // the same in-memory state the command path uses, persisting through the
2144
2147
  // provided `persist` callback, and returns a human-readable string for the tool
2145
2148
  // result. Goal creation/replacement routes through the multi-goal registry
@@ -2356,12 +2359,9 @@ function buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalSt
2356
2359
  } else if (status === "resumed") {
2357
2360
  if (!goal.stopped)
2358
2361
  return "Goal is already running. Pause or stop it first if you want to reset the budget window."
2359
- const previousGoalId = goal.goalId
2360
2362
  resetGoalBudget(goal)
2361
- // resetGoalBudget always rotates goalId via randomUUID; unconditionally
2362
- // re-key the registry so the goal stays findable by its new id.
2363
- removeSessionGoal(sessionID, previousGoalId)
2364
- registerSessionGoal(goal)
2363
+ // goalId is stable across budget windows; runId is the execution epoch.
2364
+ // Keeping the existing registry entry also preserves multi-goal order.
2365
2365
  focusGoal(sessionID, goal)
2366
2366
  goal.stopped = false
2367
2367
  goal.stopReason = ""
@@ -2594,7 +2594,7 @@ function formatGoalList(sessionID, commandName = "goal") {
2594
2594
  return lines.join("\n")
2595
2595
  }
2596
2596
 
2597
- // Visible audit messages (item 2.4): when the plugin audits a completion or
2597
+ // Visible audit messages: when the plugin audits a completion or
2598
2598
  // blocker it announces the audit and its result instead of doing the work
2599
2599
  // silently. Delivery is via this default messenger (structured app log, the
2600
2600
  // channel OpenCode surfaces to the user) or a caller-supplied `auditMessenger`
@@ -2623,7 +2623,7 @@ async function defaultAuditMessenger(client, sessionID, text) {
2623
2623
  }
2624
2624
  }
2625
2625
 
2626
- // Completion auditor (item 2.2). When an auditor is configured, a [goal:complete]
2626
+ // Completion auditor. When an auditor is configured, a [goal:complete]
2627
2627
  // is verified before the goal is archived: an approved verdict archives it, a
2628
2628
  // rejected verdict restores the goal (pauses it with the reason) instead of
2629
2629
  // archiving. The auditor is a function `({ goal, sessionID, latestText }) =>
@@ -2783,7 +2783,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2783
2783
  }
2784
2784
  runtime.drainPersistence = () => persistChain.catch(() => false)
2785
2785
 
2786
- // Fail-closed (item 2.5): when persisting a terminal state (complete/blocked)
2786
+ // Fail closed when persisting a terminal state (complete/blocked)
2787
2787
  // fails, surface it loudly. The terminal event is already in the append-only
2788
2788
  // ledger, so it stays recoverable across a restart even though the main state
2789
2789
  // file write did not land.
@@ -2810,7 +2810,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2810
2810
  setLedgerSink(null)
2811
2811
  }
2812
2812
 
2813
- // Visible audit announcements (item 2.4).
2813
+ // Visible audit announcements.
2814
2814
  const auditMessagesEnabled = pluginOptions.auditMessages !== false
2815
2815
  const auditMessenger =
2816
2816
  typeof pluginOptions.auditMessenger === "function"
@@ -2885,6 +2885,14 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2885
2885
  })
2886
2886
  if (pluginOptions.completionAudit) verifierRegistrationReady = true
2887
2887
  },
2888
+ "tool.execute.before": async (input) => {
2889
+ const sessionID = input?.sessionID
2890
+ if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return
2891
+ if (READ_ONLY_COMMAND_TOOLS.has(input?.tool)) return
2892
+ throw new Error(
2893
+ `This /${commandName} control command is read-only for the routed model turn. Tool "${input?.tool || "unknown"}" was blocked. Wait for a separate user turn; do not modify work or goal state now.`,
2894
+ )
2895
+ },
2888
2896
  "command.execute.before": async (input, output) => {
2889
2897
  if (!input || input.command !== commandName || !output) return
2890
2898
 
@@ -2898,10 +2906,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2898
2906
  }
2899
2907
  const args = input.arguments.trim()
2900
2908
  const sessionID = input.sessionID
2909
+ currentRuntime().readOnlyCommandGuards.delete(sessionID)
2901
2910
  pruneGoalResults(defaultGoalOptions)
2902
2911
 
2903
2912
  if (!args || args === "status") {
2904
2913
  const goal = goalStates.get(sessionID)
2914
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
2905
2915
  const lastResult = lastGoalResults.get(sessionID)
2906
2916
  output.parts = [
2907
2917
  makeTextPart(
@@ -2917,6 +2927,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2917
2927
 
2918
2928
  if (args === "history") {
2919
2929
  const goal = goalStates.get(sessionID)
2930
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
2920
2931
  const lastResult = lastGoalResults.get(sessionID)
2921
2932
  output.parts = [
2922
2933
  makeTextPart(
@@ -2943,6 +2954,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2943
2954
  }
2944
2955
 
2945
2956
  if (CLEAR_COMMANDS.has(args)) {
2957
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
2946
2958
  // Record the clear in the ledger before cleanupGoal removes the goal
2947
2959
  // object, so reconstructFromLedger can identify cleared goals and skip
2948
2960
  // them rather than reconstructing them after a missing state file.
@@ -2962,6 +2974,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2962
2974
  }
2963
2975
 
2964
2976
  if (PAUSE_COMMANDS.has(args)) {
2977
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
2965
2978
  const goal = goalStates.get(sessionID)
2966
2979
  if (!goal) {
2967
2980
  output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} <condition>\`.`)]
@@ -2987,12 +3000,9 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
2987
3000
  return
2988
3001
  }
2989
3002
 
2990
- const previousGoalId = goal.goalId
2991
3003
  resetGoalBudget(goal)
2992
- // resetGoalBudget always rotates goalId via randomUUID; unconditionally
2993
- // re-key the registry so a later clear/replace removes the right entry.
2994
- removeSessionGoal(sessionID, previousGoalId)
2995
- registerSessionGoal(goal)
3004
+ // goalId is stable across budget windows; runId is the execution epoch.
3005
+ // Keeping the existing registry entry also preserves multi-goal order.
2996
3006
  focusGoal(sessionID, goal)
2997
3007
  goal.stopped = false
2998
3008
  goal.stopReason = ""
@@ -3052,6 +3062,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3052
3062
  }
3053
3063
 
3054
3064
  if (args === "list") {
3065
+ currentRuntime().readOnlyCommandGuards.add(sessionID)
3055
3066
  output.parts = [makeTextPart(formatGoalList(sessionID, commandName))]
3056
3067
  return
3057
3068
  }
@@ -3394,6 +3405,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3394
3405
  if (!isIdleEvent(event)) return
3395
3406
 
3396
3407
  const sessionID = getSessionID(event)
3408
+ currentRuntime().readOnlyCommandGuards.delete(sessionID)
3397
3409
  const eventID = typeof event?.id === "string" ? event.id : ""
3398
3410
  const seenIdleEventIDs = currentRuntime().seenIdleEventIDs
3399
3411
  if (eventID && seenIdleEventIDs.has(eventID)) return
@@ -3478,7 +3490,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3478
3490
  // bail out without archiving — archiving a cleared goal would resurrect
3479
3491
  // it in memory and potentially in the persisted state.
3480
3492
  if (!activeGoal(sessionID, goalID, runID)) return
3481
- // Optional independent auditor (item 2.2): an approved verdict
3493
+ // Optional independent auditor: an approved verdict
3482
3494
  // archives; a rejected verdict restores (pauses) the goal instead.
3483
3495
  if (completionAuditor) {
3484
3496
  let verdict
@@ -3867,7 +3879,6 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3867
3879
 
3868
3880
  const goal = goalStates.get(input.sessionID)
3869
3881
  if (!goal) return
3870
- if (goal.stopped) return
3871
3882
  const systemBlocks = Array.isArray(output.system) ? [...output.system] : []
3872
3883
  if (systemBlocks.some((block) => systemBlockContainsGoal(block, goal.goalId))) return
3873
3884
 
@@ -3880,14 +3891,23 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3880
3891
  // on every continuation turn via buildContinueMessage (buildLimitWarning
3881
3892
  // and <progress_budget>), which is sufficient — the model doesn't need
3882
3893
  // them in the system prompt mid-turn.
3883
- const goalBlock = [
3884
- `<opencode_goal_plugin id="${goal.goalId}">`,
3885
- buildGoalBlock(goal),
3886
- "Keep working until the goal is fully satisfied.",
3887
- "When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.",
3888
- "If user input is required, explain the concrete blocker in the line immediately before `[goal:blocked]`. A `[goal:blocked]` without a concrete blocker is rejected.",
3889
- "</opencode_goal_plugin>",
3890
- ].join("\n")
3894
+ const goalBlock = goal.stopped
3895
+ ? [
3896
+ `<opencode_goal_plugin id="${goal.goalId}">`,
3897
+ "<goal_state>paused</goal_state>",
3898
+ "A goal exists for this session, but it is paused. Do not continue or modify work toward it, and do not call completion or blocker tools, unless the current user message explicitly asks to resume it.",
3899
+ "For status or history requests, only report the goal state; do not change files or goal state.",
3900
+ `To continue, the user can run /${commandName} resume or explicitly ask you to call goal_resume before doing any goal work.`,
3901
+ "</opencode_goal_plugin>",
3902
+ ].join("\n")
3903
+ : [
3904
+ `<opencode_goal_plugin id="${goal.goalId}">`,
3905
+ buildGoalBlock(goal),
3906
+ "Keep working until the goal is fully satisfied.",
3907
+ "When fully satisfied, put a `[goal:evidence]` line summarizing what you verified immediately before `[goal:complete]`. A `[goal:complete]` without evidence is rejected.",
3908
+ "If user input is required, explain the concrete blocker in the line immediately before `[goal:blocked]`. A `[goal:blocked]` without a concrete blocker is rejected.",
3909
+ "</opencode_goal_plugin>",
3910
+ ].join("\n")
3891
3911
 
3892
3912
  if (systemBlocks.length === 0) {
3893
3913
  output.system = [goalBlock]
@@ -3930,13 +3950,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {})
3930
3950
  },
3931
3951
  }
3932
3952
 
3933
- // register_command toggle (item 8.2): when disabled, the plugin does not own
3953
+ // register_command toggle: when disabled, the plugin does not own
3934
3954
  // a slash command and only the event/transform/compaction hooks remain.
3935
3955
  if (!registerCommand) {
3936
3956
  delete hooks["command.execute.before"]
3937
3957
  }
3938
3958
 
3939
- // Register agent-facing tools (megalist 7.1 / 7.2) when @opencode-ai/plugin is
3959
+ // Register agent-facing tools when @opencode-ai/plugin is
3940
3960
  // available (it provides the `tool` helper and zod-style schema). Disabled via
3941
3961
  // `registerTools: false`. When the helper is absent the command/event hooks
3942
3962
  // still work; only the programmatic tool surface is omitted, preserving the
@@ -1,272 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import assert from "node:assert/strict"
4
- import { promises as fs } from "node:fs"
5
- import { tmpdir } from "node:os"
6
- import { join } from "node:path"
7
- import { GoalPlugin } from "../src/goal-plugin.js"
8
-
9
- const startedAt = performance.now()
10
- const temporaryDirectories = []
11
-
12
- function assistantMessage(sessionID, text, id = `assistant-${sessionID}`) {
13
- return {
14
- info: {
15
- id,
16
- role: "assistant",
17
- sessionID,
18
- tokens: { input: 20, output: 120, reasoning: 0 },
19
- },
20
- parts: [{ type: "text", text }],
21
- }
22
- }
23
-
24
- function createHost(messageForSession = () => "Working with tools.") {
25
- const prompts = []
26
- const notices = []
27
- return {
28
- prompts,
29
- notices,
30
- client: {
31
- app: { log: async () => {} },
32
- session: {
33
- messages: async ({ path }) => ({
34
- data: [assistantMessage(path.id, messageForSession(path.id))],
35
- }),
36
- promptAsync: async (input) => {
37
- prompts.push(input)
38
- return {}
39
- },
40
- },
41
- },
42
- }
43
- }
44
-
45
- function promptCharacters(prompts) {
46
- return prompts.reduce(
47
- (total, prompt) => total + (prompt?.body?.parts || []).reduce(
48
- (partTotal, part) => partTotal + (typeof part?.text === "string" ? part.text.length : 0),
49
- 0,
50
- ),
51
- 0,
52
- )
53
- }
54
-
55
- async function makeDirectory(label) {
56
- const directory = await fs.mkdtemp(join(tmpdir(), `goal-benchmark-${label}-`))
57
- temporaryDirectories.push(directory)
58
- return directory
59
- }
60
-
61
- async function createHooks(host, options = {}) {
62
- return GoalPlugin(
63
- { client: host.client, directory: await makeDirectory("workspace") },
64
- {
65
- persistState: false,
66
- registerTools: false,
67
- registerAgents: false,
68
- minDelayMs: 1,
69
- noProgressTokenThreshold: 1,
70
- noProgressTurnsBeforePause: 10,
71
- noToolCallTurnsBeforePause: 2,
72
- ...options,
73
- },
74
- )
75
- }
76
-
77
- async function goalCommand(hooks, sessionID, argumentsText) {
78
- const output = { parts: [] }
79
- await hooks["command.execute.before"](
80
- { command: "goal", sessionID, arguments: argumentsText },
81
- output,
82
- )
83
- return output.parts.map((part) => part.text || "").join("\n")
84
- }
85
-
86
- async function idle(hooks, sessionID, id) {
87
- await hooks.event({
88
- event: {
89
- id,
90
- type: "session.status",
91
- properties: { sessionID, status: { type: "idle" } },
92
- },
93
- })
94
- }
95
-
96
- async function scenario(name, points, run) {
97
- const scenarioStartedAt = performance.now()
98
- try {
99
- const telemetry = await run()
100
- return {
101
- name,
102
- passed: true,
103
- points,
104
- durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
105
- ...telemetry,
106
- }
107
- } catch (error) {
108
- return {
109
- name,
110
- passed: false,
111
- points: 0,
112
- possiblePoints: points,
113
- durationMs: Number((performance.now() - scenarioStartedAt).toFixed(2)),
114
- error: error instanceof Error ? error.message : String(error),
115
- }
116
- }
117
- }
118
-
119
- const results = []
120
-
121
- results.push(await scenario("verified-success", 20, async () => {
122
- const sessionID = "benchmark-success"
123
- const host = createHost(() => "Tests pass.\n[goal:evidence] npm test: 210/210\n[goal:complete]")
124
- const hooks = await createHooks(host, {
125
- auditor: async () => ({ approved: true, reason: "evidence independently accepted" }),
126
- })
127
- await goalCommand(hooks, sessionID, "ship a verified release")
128
- await idle(hooks, sessionID, "success-idle")
129
- const status = await goalCommand(hooks, sessionID, "status")
130
- assert.match(status, /State: achieved/)
131
- await hooks.dispose()
132
- return {
133
- continuationPrompts: host.prompts.length,
134
- continuationCharacters: promptCharacters(host.prompts),
135
- status: "archived",
136
- }
137
- }))
138
-
139
- results.push(await scenario("false-completion", 20, async () => {
140
- const sessionID = "benchmark-false-completion"
141
- const host = createHost(() => "Looks done.\n[goal:evidence] guessed from source\n[goal:complete]")
142
- const hooks = await createHooks(host, {
143
- auditor: async () => ({ approved: false, reason: "no executed verification" }),
144
- })
145
- await goalCommand(hooks, sessionID, "do not accept an unverified claim")
146
- await idle(hooks, sessionID, "false-idle")
147
- const status = await goalCommand(hooks, sessionID, "status")
148
- assert.match(status, /audit rejected/i)
149
- assert.doesNotMatch(status, /No active goal/)
150
- await hooks.dispose()
151
- return {
152
- continuationPrompts: host.prompts.length,
153
- continuationCharacters: promptCharacters(host.prompts),
154
- status: "rejected",
155
- }
156
- }))
157
-
158
- results.push(await scenario("loop-circuit-breaker", 15, async () => {
159
- const sessionID = "benchmark-loop"
160
- let turn = 0
161
- const host = createHost(() => `Still discussing the work, turn ${turn++}.`)
162
- const hooks = await createHooks(host)
163
- await goalCommand(hooks, sessionID, "stop self-chat loops")
164
- await idle(hooks, sessionID, "loop-1")
165
- await idle(hooks, sessionID, "loop-2")
166
- await idle(hooks, sessionID, "loop-3")
167
- const status = await goalCommand(hooks, sessionID, "status")
168
- assert.match(status, /no tool calls|self-chat loop/i)
169
- assert.equal(host.prompts.length, 2)
170
- await hooks.dispose()
171
- return {
172
- continuationPrompts: host.prompts.length,
173
- continuationCharacters: promptCharacters(host.prompts),
174
- status: "paused",
175
- }
176
- }))
177
-
178
- results.push(await scenario("human-interruption", 15, async () => {
179
- const sessionID = "benchmark-interruption"
180
- const host = createHost()
181
- const hooks = await createHooks(host)
182
- await goalCommand(hooks, sessionID, "respect explicit interruption")
183
- await hooks.event({
184
- event: {
185
- type: "session.error",
186
- properties: {
187
- sessionID,
188
- error: { name: "MessageAbortedError", message: "aborted by user" },
189
- },
190
- },
191
- })
192
- await idle(hooks, sessionID, "interruption-idle")
193
- assert.equal(host.prompts.length, 0)
194
- assert.match(await goalCommand(hooks, sessionID, "status"), /abort|paused|stopped/i)
195
- await hooks.dispose()
196
- return { continuationPrompts: 0, status: "paused" }
197
- }))
198
-
199
- results.push(await scenario("compaction-continuity", 15, async () => {
200
- const sessionID = "benchmark-compaction"
201
- const host = createHost()
202
- const hooks = await createHooks(host)
203
- await goalCommand(hooks, sessionID, "preserve the objective across compaction")
204
- const output = { context: [] }
205
- await hooks["experimental.session.compacting"]({ sessionID }, output)
206
- assert.equal(output.context.length, 1)
207
- assert.match(output.context[0], /preserve the objective across compaction/)
208
- assert.ok(output.context[0].length < 2_000, "compaction context exceeded token-efficient size cap")
209
- await hooks.dispose()
210
- return {
211
- contextCharacters: output.context[0].length,
212
- estimatedContextTokens: Math.ceil(output.context[0].length / 4),
213
- status: "preserved",
214
- }
215
- }))
216
-
217
- results.push(await scenario("restart-recovery", 15, async () => {
218
- const sessionID = "benchmark-restart"
219
- const directory = await makeDirectory("restart")
220
- const stateFilePath = join(directory, "state.json")
221
- const host = createHost()
222
- const first = await GoalPlugin(
223
- { client: host.client, directory },
224
- { persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
225
- )
226
- await goalCommand(first, sessionID, "recover safely after restart")
227
- await first.dispose()
228
- const second = await GoalPlugin(
229
- { client: host.client, directory },
230
- { persistState: true, stateFilePath, registerTools: false, registerAgents: false, minDelayMs: 1 },
231
- )
232
- const status = await goalCommand(second, sessionID, "status")
233
- assert.match(status, /Recovered persisted goal state|recovered after restart/i)
234
- await idle(second, sessionID, "restart-idle")
235
- assert.equal(host.prompts.length, 0, "recovered goals must not resume without user consent")
236
- const stateBytes = (await fs.stat(stateFilePath)).size
237
- await second.dispose()
238
- return { continuationPrompts: 0, persistedStateBytes: stateBytes, status: "recovered-paused" }
239
- }))
240
-
241
- const score = results.reduce((total, result) => total + result.points, 0)
242
- const possibleScore = 100
243
- const continuationCharacters = results.reduce(
244
- (total, result) => total + (result.continuationCharacters || 0),
245
- 0,
246
- )
247
- const report = {
248
- schemaVersion: 1,
249
- benchmark: "opencode-goal-plugin-behavior",
250
- score,
251
- possibleScore,
252
- passed: score === possibleScore,
253
- durationMs: Number((performance.now() - startedAt).toFixed(2)),
254
- efficiency: {
255
- totalContinuationPrompts: results.reduce(
256
- (total, result) => total + (result.continuationPrompts || 0),
257
- 0,
258
- ),
259
- continuationCharacters,
260
- estimatedContinuationTokens: Math.ceil(continuationCharacters / 4),
261
- modelCalls: 0,
262
- externalRequests: 0,
263
- },
264
- scenarios: results,
265
- }
266
-
267
- for (const directory of temporaryDirectories) {
268
- await fs.rm(directory, { recursive: true, force: true })
269
- }
270
-
271
- process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
272
- if (!report.passed) process.exitCode = 1
@@ -1,160 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import { execFileSync } from "node:child_process"
3
- import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"
4
- import { tmpdir } from "node:os"
5
- import { join } from "node:path"
6
- import { pathToFileURL } from "node:url"
7
-
8
- const repository = new URL("..", import.meta.url)
9
- const root = await mkdtemp(join(tmpdir(), "opencode-goal-plugin-packed-host-"))
10
- const packDirectory = join(root, "pack")
11
- const projectDirectory = join(root, "host-project")
12
- const cacheDirectory = join(root, "npm-cache")
13
- const npmEnvironment = { ...process.env, npm_config_cache: cacheDirectory }
14
-
15
- try {
16
- await Promise.all([
17
- mkdir(packDirectory, { recursive: true }),
18
- mkdir(projectDirectory, { recursive: true }),
19
- mkdir(cacheDirectory, { recursive: true }),
20
- ])
21
- await writeFile(
22
- join(projectDirectory, "package.json"),
23
- JSON.stringify({ private: true, type: "module" }),
24
- )
25
-
26
- const packResult = JSON.parse(
27
- execFileSync(
28
- "npm",
29
- ["pack", "--json", "--pack-destination", packDirectory],
30
- { cwd: repository, encoding: "utf8", env: npmEnvironment },
31
- ),
32
- )
33
- assert.equal(packResult.length, 1)
34
- const tarball = join(packDirectory, packResult[0].filename)
35
-
36
- // Install only the artifact npm produced. The optional peer is omitted so this
37
- // contract test is offline-safe and cannot mutate the user's OpenCode install.
38
- execFileSync(
39
- "npm",
40
- [
41
- "install",
42
- "--ignore-scripts",
43
- "--no-audit",
44
- "--no-fund",
45
- "--no-package-lock",
46
- "--omit=peer",
47
- "--offline",
48
- "--cache",
49
- cacheDirectory,
50
- tarball,
51
- ],
52
- { cwd: projectDirectory, encoding: "utf8", env: npmEnvironment },
53
- )
54
-
55
- const installedManifestPath = join(
56
- projectDirectory,
57
- "node_modules",
58
- "opencode-goal-plugin",
59
- "package.json",
60
- )
61
- const installedManifest = JSON.parse(await readFile(installedManifestPath, "utf8"))
62
- const installedEntry = join(
63
- projectDirectory,
64
- "node_modules",
65
- "opencode-goal-plugin",
66
- installedManifest.main,
67
- )
68
- const installed = await import(pathToFileURL(installedEntry).href)
69
-
70
- assert.equal(installed.default.id, "opencode-goal-plugin")
71
- assert.equal(installed.default.server, installed.GoalPlugin)
72
-
73
- const sessionID = "packed-host-contract"
74
- const promptCalls = []
75
- const client = {
76
- app: { log: async () => {} },
77
- session: {
78
- messages: async ({ path }) => ({
79
- data: [
80
- {
81
- info: {
82
- id: "assistant-packed-contract",
83
- role: "assistant",
84
- sessionID: path.id,
85
- tokens: { input: 1, output: 1, reasoning: 0 },
86
- },
87
- parts: [{ type: "text", text: "Work remains." }],
88
- },
89
- ],
90
- }),
91
- promptAsync: async (input) => {
92
- promptCalls.push(input)
93
- return {}
94
- },
95
- },
96
- }
97
- const hooks = await installed.GoalPlugin(
98
- { client, directory: projectDirectory },
99
- {
100
- persistState: false,
101
- registerTools: false,
102
- minDelayMs: 1,
103
- noToolCallTurnsBeforePause: 10,
104
- },
105
- )
106
-
107
- for (const hook of [
108
- "config",
109
- "command.execute.before",
110
- "event",
111
- "experimental.chat.system.transform",
112
- "experimental.session.compacting",
113
- "experimental.compaction.autocontinue",
114
- "dispose",
115
- ]) {
116
- assert.equal(typeof hooks[hook], "function", `${hook} must be callable`)
117
- }
118
- const config = {}
119
- await hooks.config(config)
120
- assert.equal(config.agent.goal.mode, "primary")
121
- assert.equal(config.agent["goal-verify"].tools.edit, false)
122
-
123
- const output = { parts: [] }
124
- await hooks["command.execute.before"](
125
- { command: "goal", sessionID, arguments: "verify the installed artifact --max-turns 1" },
126
- output,
127
- )
128
- assert.match(output.parts[0]?.text, /New active goal/)
129
-
130
- // Let the configured throttle window elapse before idle. This avoids leaving
131
- // the contract dependent on the host's event-loop/timer shutdown behavior.
132
- await new Promise((resolve) => setTimeout(resolve, 5))
133
-
134
- await hooks.event({
135
- event: {
136
- type: "session.status",
137
- properties: { sessionID, status: { type: "idle" } },
138
- },
139
- })
140
-
141
- assert.equal(promptCalls.length, 1)
142
- // PluginInput currently supplies OpenCode's generated legacy client shape:
143
- // session.promptAsync({ path, body }). The standalone adapter suite covers
144
- // flattened v2 clients separately.
145
- assert.deepEqual(promptCalls[0].path, { id: sessionID })
146
- assert.equal(promptCalls[0].body.parts.length, 1)
147
- assert.deepEqual(promptCalls[0].body.parts[0].metadata, {
148
- "opencode-goal-plugin": { kind: "continuation" },
149
- })
150
- assert.equal(promptCalls[0].body.parts[0].synthetic, true)
151
-
152
- await hooks.dispose()
153
- await hooks.dispose()
154
-
155
- console.log(
156
- `packed host contract passed (${installedManifest.name}@${installedManifest.version}; ${packResult[0].size} byte tarball)`,
157
- )
158
- } finally {
159
- await rm(root, { recursive: true, force: true })
160
- }
@@ -1,51 +0,0 @@
1
- import assert from "node:assert/strict"
2
- import pluginModule, { GoalPlugin } from "opencode-goal-plugin"
3
-
4
- const sessionID = `smoke-${Date.now()}`
5
- const promptCalls = []
6
- const logCalls = []
7
-
8
- const client = {
9
- app: {
10
- log: async (input) => {
11
- logCalls.push(input)
12
- },
13
- },
14
- session: {
15
- messages: async () => ({ data: [] }),
16
- promptAsync: async (input) => {
17
- promptCalls.push(input)
18
- return {}
19
- },
20
- },
21
- }
22
-
23
- assert.equal(pluginModule.id, "opencode-goal-plugin")
24
- assert.equal(pluginModule.server, GoalPlugin)
25
-
26
- // persistState:false keeps the smoke test from reading or overwriting the
27
- // user's real ~/.opencode-goal-plugin/state.json.
28
- const hooks = await GoalPlugin({ client }, { minDelayMs: 1, persistState: false })
29
- assert.equal(typeof hooks["command.execute.before"], "function")
30
- assert.equal(typeof hooks.event, "function")
31
- assert.equal(typeof hooks["experimental.chat.system.transform"], "function")
32
-
33
- const commandHook = hooks["command.execute.before"]
34
-
35
- async function runGoalCommand(args) {
36
- const output = { parts: [] }
37
- await commandHook({ command: "goal", sessionID, arguments: args }, output)
38
- assert.equal(output.parts.length, 1)
39
- assert.equal(output.parts[0].type, "text")
40
- return output.parts[0].text
41
- }
42
-
43
- assert.match(await runGoalCommand("status"), /No active goal/)
44
- assert.match(await runGoalCommand("ship a smoke test --max-turns 1"), /New active goal/)
45
- assert.match(await runGoalCommand("status"), /Active goal: ship a smoke test/)
46
- assert.match(await runGoalCommand("clear"), /Goal cleared/)
47
- assert.match(await runGoalCommand("status"), /No active goal/)
48
- assert.equal(promptCalls.length, 0)
49
- assert.equal(logCalls.length, 0)
50
-
51
- console.log("opencode-goal-plugin command hook smoke passed")