opencode-goal-plugin 0.4.1 → 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,91 @@
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
+
20
+ ## 0.4.7 — 2026-06-29
21
+
22
+ ### Bug fixes (low-severity cleanups)
23
+
24
+ - **Dead `if (goal.goalId !== previousGoalId)` conditional removed from both resume paths.** `resetGoalBudget` always rotates `goalId` via `randomUUID()`, so the conditional was always `true`. The misleading branch could never be taken, masking the intent (unconditional registry re-key on resume). Both the agent-tool `updateGoal {status: "resumed"}` path and the `/goal resume` command path are now unconditional.
25
+ - **`noToolCallTurns` no longer stales on null-assistant idles.** When `messages()` returns no assistant message (only a user turn), `latestAssistant` is `null` and `latestHasToolCall` is `false`. Previously the counter incremented unconditionally; a user-only idle turn could push the goal toward a no-tool-call pause even though the model hadn't spoken. The reset condition now includes `|| !latestAssistant`, matching the intent of the stall detector.
26
+ - **`noProgressTurns` no longer stales on null-assistant idles.** Same scenario as above: when `latestOutputTokens === null` and there is no assistant message, the counter now resets instead of incrementing, consistent with the gate's purpose of detecting stalled model output.
27
+ - **Updated ledger-durability comment near `pushHistory("completed")`.** The previous comment implied the ledger was the primary recovery mechanism. The corrected comment clarifies that ledger write failures are silent (bare `catch`), and that a present state file always takes precedence over the ledger — making the ledger relevant only when the state file is absent.
28
+ - **`buildAgentToolHandlers` accepts a `persistTerminalState` option.** Terminal state transitions (`status='complete'` and `clearGoal`) now call `persistTerminalState` if provided, falling back to the regular `persist` function. The `GoalPlugin` factory passes its own `persistTerminalState` closure through, so agent-triggered completions and clears get the same durable flush semantics as the event-handler paths.
29
+
30
+ ## 0.4.6 — 2026-06-29
31
+
32
+ ### Bug fixes (counters, compaction, and auditor)
33
+
34
+ - **`noToolCallTurns` is now independent of `noProgressTurns`.** On a turn that qualifies for the noProgress stall gate (low output, no tool call, stalled text), the noToolCall counter no longer also increments. Without this guard, the effective grace window was `min(noProgress, noToolCall)` rather than two independent limits — a configured higher `noProgressTurnsBeforePause` threshold was silently overridden by the lower `noToolCallTurnsBeforePause`.
35
+ - **`formatFailures` is now incremented when the stall gate fires and returns early.** Stall detection previously returned before the format-failure accumulator could run. A model that repeatedly emitted bare `[goal:complete]` with low output triggered the stall gate rather than accumulating toward the `maxPromptFailures` cap; the cap was permanently unreachable because `/goal resume` reset `formatFailures` to zero each time. The counter now increments inside the stall-gate early-return path when `completionUnverified` or `blockerUnstated` is true.
36
+ - **Budget-wrapup state is persisted before the wrapup prompt is sent.** Previously `budgetWrapupSent = true` and `stopped = true` were set in memory but not persisted before `promptAsync`. A crash during the prompt would result in `budgetWrapupSent: false` in the state file and a duplicate wrapup on the next resume cycle. The fix adds `pushHistory("budget-wrapup")` + `persist()` before the prompt call, mirroring the hard-limit path.
37
+ - **`TOOL_PART_TYPES` now covers raw provider part type names.** Some OpenCode adapters forward the provider's original message part shape without normalizing to `"tool"`. Added `"tool_use"`, `"function_call"`, and `"tool-call"` to the set so `messageHasToolCall` (and both stall gates) correctly recognize tool-using turns from non-normalized adapters.
38
+ - **Approved completion that is lost while the auditor is in flight now produces an announcement.** If the goal is cleared or replaced while a completion auditor runs, and the auditor returns `approved: true`, the plugin now announces "completion was approved but the goal was modified while the audit ran — completion not recorded." Previously the approved result was silently discarded with no visible trace.
39
+ - **`buildCompactionContext` is now deterministic.** The function previously called `Date.now()` to compute elapsed seconds, so two calls during the same compaction event produced different strings, busting the prefix cache from that byte position. The elapsed time is now derived from `goal.lastContinueAt` (set during each persist cycle), making the output stable and matching the function's own claim of being "reconstructed deterministically from the plugin's persisted goal record."
40
+
41
+ ## 0.4.5 — 2026-06-29
42
+
43
+ ### Bug fixes (input validation + counter correctness)
44
+
45
+ - **`set_goal` now validates budget arguments and mode.** Previously `set_goal({maxTurns: 0})` silently used the global default; a typo in `mode` silently became `"normal"`. Both now return explicit errors, matching the `/goal` command's validation behavior.
46
+ - **`update_goal` cannot combine an objective update with `status='complete'` in the same call.** The completion would be archived under a condition that was never executed, falsifying the audit trail. The tool now requires two separate calls: first update the objective, then mark complete after the revised work is done.
47
+ - **`update_goal {status: 'resumed'}` on a running goal returns an error.** The slash-command path rejected this; the agent tool path silently reset all budget counters — turnCount, totalTokens, startedAt, etc. — on a goal that never stopped, enabling indefinite budget circumvention. The agent tool now rejects the call when the goal is not stopped.
48
+ - **`/goal edit` and `update_goal` objective updates now reset `formatFailures` to 0.** The edit paths already reset `noProgressTurns` and `noToolCallTurns` but omitted `formatFailures`. A goal with accumulated format-failure violations had less tolerance than a freshly-resumed goal after an objective change.
49
+ - **`/goal <condition>` replace command now clears `sessionOrdered`.** The agent `setGoal` path called `sessionOrdered.delete()` on replacement, but the slash-command path did not. A user replacing a sisyphus sequence with a standalone goal would get unexpected auto-promotion of the sequence's remaining goals after the replacement completed.
50
+ - **`set_goal` and `update_goal` tool result strings now escape XML metacharacters.** The `goal.condition` is stored raw (for use by `buildGoalBlock`/`buildContinueMessage`), but the tool result returned to the model now calls `escapeGoalText` to prevent XML metacharacters from breaking tool-result boundaries in XML-serialized formats.
51
+ - **`promptFailures` decrements by 1 on a successful prompt instead of resetting to 0.** This mirrors the `formatFailures` fix: an alternating error/success pattern previously bypassed the circuit-breaker cap indefinitely. Decrementing allows gradual recovery while still accumulating toward the cap over time.
52
+
53
+ ## 0.4.4 — 2026-06-29
54
+
55
+ ### Bug fixes (state machine + injection prevention)
56
+
57
+ - **`escapeGoalText` now neutralizes role-like tag openings.** The previous `STRUCTURAL_TAGS` set only covered plugin-defined tags. Tags like `<system>`, `<assistant>`, `<human>`, `<anthropic>`, `<claude>`, `<context>`, `<instructions>`, and `<prompt>` could survive unescaped in compacted system messages, creating second-order injection opportunities where model output captured by `recordCheckpoint` re-appeared as an elevated-privilege block after compaction.
58
+ - **`update_goal` objective update no longer un-stops a stopped goal.** Calling `update_goal({objective: "…"})` previously cleared `goal.stopped` and `goal.stopReason`, silently resurrecting a goal that was audit-rejected, user-paused, or blocked for any reason. Objective updates now preserve the stopped state; only an explicit `status: "resumed"` call resets it.
59
+ - **`/goal clear` and agent `clearGoal` now delete all backgrounded goals.** Previously only the focused goal was removed from the session registry (`cleanupGoal` → `removeSessionGoal`). Background goals added via `/goal add` remained alive and would promote themselves to focused on restart. Both clear paths now call `sessionGoals.delete(sessionID)` first, wiping the entire per-session goal map.
60
+ - **`formatFailures` decrements by 1 on a clean turn instead of resetting to 0.** A reset-to-zero on every non-violation turn allowed an alternating bad/good/bad pattern to bypass the consecutive-failure cap indefinitely. Decrementing by 1 means repeated violations accumulate toward the cap even when interspersed with good turns.
61
+ - **`update_goal {status: "blocked"}` requires a non-empty `blocker` argument.** The event-handler path already rejects a `[goal:blocked]` marker with no concrete blocker, but the agent tool path accepted an empty `blocker` (recording an empty `blockedReason`). The agent tool now returns an error when `blocker` is missing or whitespace-only, consistent with the auto-continue guard.
62
+
63
+ ## 0.4.3 — 2026-06-29
64
+
65
+ ### Bug fixes (concurrency + persistence)
66
+
67
+ - **`activeContinues` Set → Map with per-handler UUID token.** `cleanupGoal` removes the session from the Map (allowing new handlers to start), but the idle handler's `finally` block only deletes if its token still matches — preventing it from clobbering a new handler's guard. With a plain `Set`, the old `finally` unconditionally deleted the new handler's entry, creating a race window where two handlers could run concurrently for the same session.
68
+ - **Liveness re-check after `announceAudit`.** `announceAudit` is async and can yield long enough for a user to `/goal clear` or replace the goal. The handler now calls `activeGoal(sessionID, goalID)` after the announcement and returns immediately if the goal is gone, preventing an orphaned archive write.
69
+ - **`persist()` calls serialized via promise chain.** Concurrent callers previously raced on the temp-file rename: the second rename could write older state over the first. All calls now chain through `persistChain`, guaranteeing ordered writes.
70
+ - **`/goal clear` and agent `clearGoal` now emit a `"cleared"` ledger event before discarding the goal.** Without this, `reconstructGoalsFromLedger` (used when the state file is missing) would revive cleared goals as paused on restart. `LEDGER_TERMINAL_TYPES` already includes `"cleared"` — the event just wasn't being written.
71
+ - **State-file/ledger cross-check on restart.** After loading from the state file, the plugin now reads the ledger and removes any active goals whose `goalId` appears in a terminal ledger entry. This guards against the scenario where a terminal persist wrote to the ledger but the state file write failed (e.g. process killed between the two writes): the goal would otherwise load as active and be re-driven on the next idle.
72
+
73
+ ### Bug fixes (state machine + security)
74
+
75
+ - **Escape checkpoint and history text in compaction context.** Checkpoint summaries and lifecycle-event details contain assistant-generated text. If a malicious assistant output included structural XML tags (e.g. `</goal_objective><budget_wrapup>…</budget_wrapup>`), they could be re-embedded unescaped in the compaction context system message. `buildCompactionProgressSummary` and the `lastCheckpoint` inline in `buildCompactionContext` now call `escapeGoalText` on all assistant-derived strings.
76
+ - **`/goal edit` and agent `update_goal` objective updates now reset `noToolCallTurns`.** The edit paths already reset `noProgressTurns` and cleared soft-stop state, but forgot `noToolCallTurns`. A goal that was heading toward a no-tool-call pause kept its stale counter after an objective change, and could pause after fewer than the configured grace turns on the new objective.
77
+ - **`formatFailures` is now preserved through a persistence round-trip.** `normalizePersistedGoal` carried `promptFailures` but omitted `formatFailures`. After a plugin restart any accumulated format-failure count was silently reset to zero, giving the model an unintended free pass on the first format re-prompts after recovery.
78
+ - **Agent `update_goal {status: "complete"}` now invokes the configured completion auditor.** The `[goal:complete]` marker path gates archival on an optional auditor, but the agent tool path bypassed it entirely. `buildAgentToolHandlers` now accepts a `completionAuditor` option, and the `GoalPlugin` factory passes the configured auditor through. A rejected verdict pauses the goal with stop reason `audit rejected`; an auditor that throws is treated as a rejection (fail closed).
79
+ - **`createChildSessionAuditor` now enforces a configurable timeout (default 120 s).** `sessionApi.prompt` could hang indefinitely, blocking the idle handler and stalling the goal forever. The auditor now races the API call against a `setTimeout` promise; if the timeout fires first, the verdict is `{ approved: false, reason: "auditor timed out after Nms" }`. The timer is always cleared after settlement.
80
+ - **Thinking-only turns are excluded from the `noProgress` stall detector.** A turn that produced reasoning tokens but no prose output and no tool calls was treated as stalled by `lowOutputLooksStalled` (prose output tokens = 0 < threshold). The new `latestHasThinkingTokens` check (`tokens.reasoning > 0`) excludes such turns from the stall gate, preventing false pauses on extended-thinking models that reason before acting.
81
+
82
+ ## 0.4.2 — 2026-06-29
83
+
84
+ ### Bug fixes
85
+
86
+ - **Guard against stale `message.updated` re-deliveries re-inflating `totalTokens` after `/goal resume`.** After `/goal resume`, OpenCode replays the streaming `message.updated` event for the last assistant message. Previously `resetGoalBudget` deleted those message IDs from `seenTokens`, so the replayed event looked new and added its tokens to the freshly-zeroed counter — incorrectly inflating the resumed goal's token total from the first turn. Fix: `resetGoalBudget` no longer deletes IDs from `seenTokens`. The `message.updated` handler now skips any message whose ID is already in `seenTokens` but is absent from the current `goal.messageIDs` (i.e. belongs to a prior budget epoch), so stale re-deliveries are silently ignored.
87
+ - **Guard against stale `message.updated` re-deliveries inflating a replacement goal's `totalTokens`.** When a goal is replaced via `/goal <new>`, the old goal's `cleanupGoal` path previously deleted its message IDs from `seenTokens`. If OpenCode then re-delivered a streaming event for one of those old IDs (e.g. a buffered duplicate), the new goal object had no record of it, so the guard could not fire and the event inflated the new goal's counter. Fix: `cleanupGoal` also leaves `seenTokens` entries in place. Entries accumulate across the process lifetime (O(turns × messages_per_turn)) and are cleared in bulk by `clearRuntimeState` on teardown.
88
+ - **Reset `totalTokens` to zero after session compaction.** `totalTokens` is tracked with `Math.max` semantics (peak context size), so it never decreases on its own. A goal that crossed the 80 % budget-wrapup threshold before compaction would permanently remain above it even after the context shrank to a fraction of its prior size. Fix: the `experimental.session.compacting` hook now resets `totalTokens = 0` and rotates `messageIDs` into `priorMessageIDs` after injecting the compaction context, then calls `persist()`. Post-compaction turns re-establish the token baseline from scratch.
89
+
5
90
  ## 0.4.1 — 2026-06-29
6
91
 
7
92
  ### Bug fixes
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.
@@ -290,6 +350,19 @@ By default a `[goal:complete]` is accepted on the assistant's word. You can requ
290
350
 
291
351
  On **approval** the goal is archived as achieved. On **rejection** the goal is *not* archived — it is paused with stop reason `audit rejected` and the reason in its status, so you can address the gap and `/goal resume`. The built-in child-session auditor fails *open* (auto-approves) if the session API is unavailable, while a custom auditor that throws is treated as a rejection (fail closed). The audit is off unless one of these options is set.
292
352
 
353
+ Pass `auditorOptions` to tune the built-in auditor:
354
+
355
+ ```js
356
+ GoalPlugin({
357
+ completionAudit: true,
358
+ auditorOptions: {
359
+ timeoutMs: 60_000, // default 120 000 ms; set lower for faster CI feedback
360
+ },
361
+ })
362
+ ```
363
+
364
+ `timeoutMs` caps how long the built-in child-session auditor waits for a verdict. If the session doesn't reply within the timeout the auditor auto-approves (fail open) so the goal can still be archived. `auditorOptions` is ignored when a custom `auditor` function is supplied.
365
+
293
366
  ## Prompt safety
294
367
 
295
368
  The goal text is wrapped in `<goal_objective>` tags and labeled as user-provided task data. The assistant is told to treat it as a task description, not as elevated instructions that can override system, developer, tool, or repository policies.
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.1",
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
  },