opencode-goal-mode 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.5
4
+
5
+ - Verified the package against the **current** OpenCode plugin API
6
+ (`@opencode-ai/plugin@1.17.6`, matching OpenCode 1.17.6, now the dev pin): all
7
+ guard hooks (`chat.message`/`params`, `tool.execute.before`/`after`,
8
+ `experimental.chat.system.transform`/`text.complete`/`session.compacting`)
9
+ exist; the guard plugin loads with zero errors; and in a real OpenCode the
10
+ agent list shows `goal` as the only user-selectable agent with 26 subagents and
11
+ reviewer `edit`/`task: deny` applied. The enforcement core is unchanged and
12
+ fully intact.
13
+ - Declared `@opentui/solid`, `solid-js`, `@opencode-ai/plugin` as **optional**
14
+ peer dependencies (the TUI runtime the sidebar uses).
15
+ - Docs: stated the sidebar's verification status honestly — the experimental TUI
16
+ banner is verified to load and to render in a real headless OpenTUI test, but
17
+ its live in-session render depends on your OpenCode build's file-based
18
+ TUI-plugin support; it never errors and never affects the enforcement core.
19
+
20
+ ## v0.3.4
21
+
22
+ Critical fixes found by testing against a real OpenCode (1.17.6) install — the
23
+ prior releases did not actually work end-to-end.
24
+
25
+ - **FIX: the guard plugin did not load.** OpenCode loads *every* export of a
26
+ plugin file as a plugin factory, so `goal-guard.js` exporting a test helper and
27
+ extra factories made OpenCode fail with `Plugin export is not a function` — the
28
+ entire enforcement layer was silently dead. The entry now exports **only** the
29
+ default plugin; the factory and test surface moved to
30
+ `plugins/goal-guard/guard.js` (nested, so OpenCode does not load it directly).
31
+ - **FIX: 14 of 27 agents had invalid YAML frontmatter** (an invalid `\.` escape in
32
+ `external_directory` globs, and an unquoted `: ` in one description). OpenCode
33
+ silently dropped those agents to `mode: all` with **no permissions applied** —
34
+ they became user-selectable and the review gates' `edit: deny` / `task: deny`
35
+ were ignored. Fixed; verified in OpenCode that `goal` is the only `primary`
36
+ agent and all 26 specialists are `subagent` with their deny-permissions applied.
37
+ - **Hardened the validator** so this can't regress: it now parses each agent's
38
+ frontmatter as real YAML (not regex), enforces reviewer `edit`/`task: deny` from
39
+ parsed values, and asserts the plugin entry exports only a default function.
40
+ - `goal-sidebar.js` now `export default { id, tui }` only (the supported TUI
41
+ plugin shape), matching working OpenCode TUI plugins.
42
+ - README: a "Quick start" (install → verify → use) at the top.
43
+
3
44
  ## v0.3.3
4
45
 
5
46
  - Release notes: the GitHub Release body is now generated from the matching
package/README.md CHANGED
@@ -18,7 +18,34 @@ npm install -g opencode-goal-mode && opencode-goal-mode-install --global
18
18
 
19
19
  ![OpenCode Goal Mode sidebar banner](docs/sidebar-demo.svg)
20
20
 
21
- **[Install](#install) · [Why it's different](#why-its-different) · [Benchmarks](#benchmarks-honest-edition) · [TUI integration](#tui-integration) · [Configuration](#configuration) · [Releasing](#releasing) · [Architecture](ARCHITECTURE.md)**
21
+ **[Quick start](#quick-start) · [Install](#install) · [Why it's different](#why-its-different) · [Benchmarks](#benchmarks-honest-edition) · [TUI integration](#tui-integration) · [Configuration](#configuration) · [Releasing](#releasing) · [Architecture](ARCHITECTURE.md)**
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ # 1. Install (needs Node 20.11+ and OpenCode)
27
+ npm install -g opencode-goal-mode
28
+ opencode-goal-mode-install --global
29
+
30
+ # 2. Restart OpenCode, then verify it loaded — you should see ONLY `goal (primary)`,
31
+ # with every specialist as a (subagent):
32
+ opencode agent list | grep goal
33
+ ```
34
+
35
+ 3. In OpenCode, start a goal:
36
+
37
+ ```
38
+ /goal add rate limiting to the login endpoint and prove it works
39
+ ```
40
+
41
+ The `goal` agent writes a contract, delegates research/review to subagents, and
42
+ **cannot** answer `Goal Completed` until every required review gate passes — the
43
+ guard rewrites a premature claim to `Goal Not Completed`. Try a destructive
44
+ command mid-session (e.g. `rm -rf build`) and watch it get blocked. If your
45
+ OpenCode build supports TUI plugins, the active goal also appears in the sidebar
46
+ in yellow (experimental — see [TUI integration](#tui-integration)).
47
+
48
+ That's it. Everything below is detail.
22
49
 
23
50
  See [ARCHITECTURE.md](ARCHITECTURE.md) for the design and [research/](research/)
24
51
  for the platform reference, comparison, and threat model.
@@ -148,14 +175,21 @@ enforcement and writes its state to disk, and an experimental TUI plugin
148
175
  - **Sidebar goal banner (experimental).** The current goal renders in shining
149
176
  yellow in the sidebar (`sidebar_content` slot), with a `passing/total gates ·
150
177
  dirty/ready` status line, and updates as reviews land. When a task is running
151
- but **no goal is set**, it shows a clean grey `No goal` and nothing else. It
152
- requires a TUI-plugin-capable OpenCode (one exposing `api.slots.register`); on
153
- any older runtime it silently no-ops, so it can never break your TUI. Set
178
+ but **no goal is set**, it shows a clean grey `No goal`. Set
154
179
  `sidebarBanner: false` (or `GOAL_GUARD_SIDEBAR_BANNER=0`) to disable,
155
180
  `sidebarColor` to recolour the goal, or `sidebarMutedColor` for the "No goal"
156
- line. It is rendered-and-asserted headlessly by the
157
- [visual test](tools/visual-test/README.md) (`npm run test:visual`); still worth
158
- a glance in your own TUI.
181
+ line.
182
+
183
+ **Verification status, honestly:** the component is rendered and asserted
184
+ (text + exact colours) by a real headless OpenTUI renderer in the
185
+ [visual test](tools/visual-test/README.md) (`npm run test:visual`, 17/17), and
186
+ OpenCode discovers and boots it as a plugin without error. It needs a recent
187
+ OpenCode that mounts file-based TUI plugins into the `sidebar_content` slot and
188
+ provides the `@opentui/solid` runtime; on a build without that, it simply does
189
+ not appear (it never errors or breaks the TUI — the enforcement core is a
190
+ separate server plugin and is unaffected). The banner appears in a **session**
191
+ view, not the home screen. If it doesn't show, that's the TUI-plugin runtime,
192
+ not Goal Mode's enforcement.
159
193
  - **Toasts.** Review verdicts and completion-unlock events surface as toasts
160
194
  (`toastOnReview`), and blocked destructive commands / premature completions
161
195
  toast as before (`toastOnBlock`).
@@ -20,7 +20,7 @@ permission:
20
20
  external_directory:
21
21
  "*": ask
22
22
  "/projects/**": allow
23
- "~/\.config/opencode/**": allow
23
+ "~/.config/opencode/**": allow
24
24
  todowrite: deny
25
25
  question: allow
26
26
  webfetch: allow
@@ -24,7 +24,7 @@ permission:
24
24
  external_directory:
25
25
  "*": ask
26
26
  "/projects/**": allow
27
- "~/\.config/opencode/**": allow
27
+ "~/.config/opencode/**": allow
28
28
  todowrite: deny
29
29
  question: ask
30
30
  webfetch: allow
@@ -14,7 +14,7 @@ permission:
14
14
  external_directory:
15
15
  "*": ask
16
16
  "/projects/**": allow
17
- "~/\.config/opencode/**": allow
17
+ "~/.config/opencode/**": allow
18
18
  todowrite: deny
19
19
  question: ask
20
20
  webfetch: allow
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Use proactively for generating, updating, and improving documentation: READMEs, API docs, manuals, runbooks, inline help, release notes, and ADRs.
2
+ description: "Use proactively for generating, updating, and improving documentation: READMEs, API docs, manuals, runbooks, inline help, release notes, and ADRs."
3
3
  mode: subagent
4
4
  temperature: 0
5
5
  color: info
@@ -14,7 +14,7 @@ permission:
14
14
  external_directory:
15
15
  "*": ask
16
16
  "/projects/**": allow
17
- "~/\.config/opencode/**": allow
17
+ "~/.config/opencode/**": allow
18
18
  todowrite: deny
19
19
  question: ask
20
20
  webfetch: allow
@@ -14,7 +14,7 @@ permission:
14
14
  external_directory:
15
15
  "*": ask
16
16
  "/projects/**": allow
17
- "~/\.config/opencode/**": allow
17
+ "~/.config/opencode/**": allow
18
18
  todowrite: deny
19
19
  question: ask
20
20
  webfetch: allow
@@ -14,7 +14,7 @@ permission:
14
14
  external_directory:
15
15
  "*": ask
16
16
  "/projects/**": allow
17
- "~/\.config/opencode/**": allow
17
+ "~/.config/opencode/**": allow
18
18
  todowrite: deny
19
19
  question: ask
20
20
  webfetch: allow
@@ -28,7 +28,7 @@ permission:
28
28
  external_directory:
29
29
  "*": ask
30
30
  "/projects/**": allow
31
- "~/\.config/opencode/**": allow
31
+ "~/.config/opencode/**": allow
32
32
  todowrite: deny
33
33
  question: ask
34
34
  webfetch: allow
@@ -14,7 +14,7 @@ permission:
14
14
  external_directory:
15
15
  "*": ask
16
16
  "/projects/**": allow
17
- "~/\.config/opencode/**": allow
17
+ "~/.config/opencode/**": allow
18
18
  todowrite: deny
19
19
  question: ask
20
20
  webfetch: allow
@@ -23,7 +23,7 @@ permission:
23
23
  external_directory:
24
24
  "*": ask
25
25
  "/projects/**": allow
26
- "~/\.config/opencode/**": allow
26
+ "~/.config/opencode/**": allow
27
27
  todowrite: deny
28
28
  question: ask
29
29
  webfetch: allow
@@ -14,7 +14,7 @@ permission:
14
14
  external_directory:
15
15
  "*": ask
16
16
  "/projects/**": allow
17
- "~/\.config/opencode/**": allow
17
+ "~/.config/opencode/**": allow
18
18
  todowrite: deny
19
19
  question: ask
20
20
  webfetch: allow
@@ -23,7 +23,7 @@ permission:
23
23
  external_directory:
24
24
  "*": ask
25
25
  "/projects/**": allow
26
- "~/\.config/opencode/**": allow
26
+ "~/.config/opencode/**": allow
27
27
  todowrite: deny
28
28
  question: ask
29
29
  webfetch: allow
@@ -20,7 +20,7 @@ permission:
20
20
  external_directory:
21
21
  "*": ask
22
22
  "/projects/**": allow
23
- "~/\.config/opencode/**": allow
23
+ "~/.config/opencode/**": allow
24
24
  todowrite: deny
25
25
  question: allow
26
26
  webfetch: allow
package/agents/goal.md CHANGED
@@ -26,8 +26,8 @@ permission:
26
26
  external_directory:
27
27
  "*": ask
28
28
  "/projects/**": allow
29
- "~/\.config/opencode/**": allow
30
- "~/\.local/share/opencode/tool-output/**": allow
29
+ "~/.config/opencode/**": allow
30
+ "~/.local/share/opencode/tool-output/**": allow
31
31
  todowrite: allow
32
32
  question: allow
33
33
  webfetch: allow
@@ -4,10 +4,10 @@
4
4
  <text x="20" y="47" font-size="12" fill="#656d76">Microseconds to classify one command. Both are negligible for a tool-call guard.</text>
5
5
  <text x="218" y="87" font-size="12" text-anchor="end" fill="#1f2328">Legacy regex guard</text>
6
6
  <rect x="230" y="70" width="420" height="22" rx="3" fill="#eaeef2"/>
7
- <rect x="230" y="70" width="214.5" height="22" rx="3" fill="#9aa0a6"/>
8
- <text x="452.5" y="87" font-size="12" font-weight="600" fill="#1f2328">0.79 µs</text>
7
+ <rect x="230" y="70" width="217.1" height="22" rx="3" fill="#9aa0a6"/>
8
+ <text x="455.1" y="87" font-size="12" font-weight="600" fill="#1f2328">0.75 µs</text>
9
9
  <text x="218" y="125" font-size="12" text-anchor="end" fill="#1f2328">Goal Mode analyzer</text>
10
10
  <rect x="230" y="108" width="420" height="22" rx="3" fill="#eaeef2"/>
11
11
  <rect x="230" y="108" width="300.0" height="22" rx="3" fill="#2da44e"/>
12
- <text x="538.0" y="125" font-size="12" font-weight="600" fill="#1f2328">1.11 µs</text>
12
+ <text x="538.0" y="125" font-size="12" font-weight="600" fill="#1f2328">1.03 µs</text>
13
13
  </svg>
@@ -75,8 +75,8 @@
75
75
  "safeFalsePos": 5
76
76
  }
77
77
  },
78
- "opsPerSec": 1260371,
79
- "usPerCommand": 0.79
78
+ "opsPerSec": 1341168,
79
+ "usPerCommand": 0.75
80
80
  },
81
81
  "current": {
82
82
  "detectionRate": 100,
@@ -111,8 +111,8 @@
111
111
  "safeFalsePos": 0
112
112
  }
113
113
  },
114
- "opsPerSec": 901050,
115
- "usPerCommand": 1.11
114
+ "opsPerSec": 970526,
115
+ "usPerCommand": 1.03
116
116
  }
117
117
  },
118
118
  "completionFixtures": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -82,7 +82,18 @@
82
82
  "registry": "https://registry.npmjs.org/"
83
83
  },
84
84
  "devDependencies": {
85
- "@opencode-ai/plugin": "1.15.13",
86
- "fast-check": "^4.8.0"
85
+ "@opencode-ai/plugin": "1.17.6",
86
+ "fast-check": "^4.8.0",
87
+ "yaml": "^2.6.1"
88
+ },
89
+ "peerDependencies": {
90
+ "@opencode-ai/plugin": ">=1.15.0",
91
+ "@opentui/solid": "*",
92
+ "solid-js": "*"
93
+ },
94
+ "peerDependenciesMeta": {
95
+ "@opencode-ai/plugin": { "optional": true },
96
+ "@opentui/solid": { "optional": true },
97
+ "solid-js": { "optional": true }
87
98
  }
88
99
  }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Goal Guard — OpenCode plugin entry point.
3
+ *
4
+ * This thin module wires the focused modules under `goal-guard/` into the
5
+ * OpenCode plugin hooks. All real logic (shell analysis, gating, verdicts,
6
+ * persistence, completion enforcement) lives in those modules and is unit
7
+ * tested in isolation; the entry is just orchestration.
8
+ *
9
+ * Design notes (verified against @opencode-ai/plugin@1.15.13 source):
10
+ * - State is created PER PLUGIN INSTANCE (no module globals), so concurrent
11
+ * projects cannot cross-contaminate, and is persisted to the XDG state dir
12
+ * so it survives OpenCode restarts.
13
+ * - Destructive bash is blocked by THROWING in `tool.execute.before` (the
14
+ * `permission.ask` hook is dormant in this version and cannot be relied on).
15
+ * - `chat.message` captures the goal text that drives contextual review gates;
16
+ * `file.edited` events catch edits made inside subagent child sessions.
17
+ * - `experimental.chat.system.transform` injects live gate state into the
18
+ * prompt; custom `goal_*` tools give the model structured control.
19
+ */
20
+
21
+ import { resolveConfig } from "./config.js";
22
+ import { createStore, createState } from "./state.js";
23
+ import { createPersistence } from "./persistence.js";
24
+ import { createLogger } from "./logger.js";
25
+ import { analyzeCommand, looksLikeDestructiveBash, looksLikeMutatingBash, isVerification } from "./shell.js";
26
+ import { isPrimaryAgent, isReviewAgent, CYCLE_CLOSING_AGENT, prettyAgentName } from "./agents.js";
27
+ import { textOf, parseVerdict, recordVerdict } from "./verdicts.js";
28
+ import { completionAllowed, missingGates, refreshStickyGates } from "./gates.js";
29
+ import { evaluateCompletionClaim } from "./completion.js";
30
+ import { summarizeState } from "./summary.js";
31
+ import { buildSystemInjection } from "./system.js";
32
+ import { markEdit, markVerification, markFileChanged, maybeClearDirtyOnFinalPass } from "./events.js";
33
+
34
+ function normalizedSubagent(input) {
35
+ if (!input) return undefined;
36
+ const agent = String(input.agent || input.args?.subagent_type || "").trim();
37
+ return agent || undefined;
38
+ }
39
+
40
+ function commandOf(input, output) {
41
+ return String(output?.args?.command ?? input?.args?.command ?? "");
42
+ }
43
+
44
+ function partsText(parts) {
45
+ if (!Array.isArray(parts)) return "";
46
+ return parts
47
+ .filter((p) => p && (p.type === "text" || typeof p.text === "string"))
48
+ .map((p) => p.text || "")
49
+ .join(" ")
50
+ .trim();
51
+ }
52
+
53
+ /**
54
+ * Build a guard instance. Exposed for tests; the default export wraps it.
55
+ *
56
+ * @param {object} input PluginInput ({ client, directory, worktree, ... }).
57
+ * @param {object} options Plugin options (2nd factory arg).
58
+ * @param {object} overrides Test seams: { config, store, persistence, env, clock, setTimer, clearTimer }.
59
+ */
60
+ export function createGuard(input = {}, options = {}, overrides = {}) {
61
+ const config = overrides.config || resolveConfig(options, overrides.env);
62
+ const store =
63
+ overrides.store ||
64
+ createStore({ maxSessions: config.maxSessions, ttlMs: config.sessionTtlMs, clock: overrides.clock });
65
+ const logger = createLogger(input.client);
66
+ const persistence =
67
+ overrides.persistence ||
68
+ createPersistence({
69
+ worktree: input.worktree || input.directory,
70
+ enabled: config.persist,
71
+ env: overrides.env,
72
+ setTimer: overrides.setTimer,
73
+ clearTimer: overrides.clearTimer,
74
+ });
75
+
76
+ // Rehydrate any prior state for this project.
77
+ try {
78
+ const data = persistence.load();
79
+ if (data) store.restore(data);
80
+ } catch {
81
+ /* ignore corrupt state */
82
+ }
83
+
84
+ const persist = () => persistence.save(() => store.snapshot());
85
+
86
+ const hooks = {
87
+ async "chat.message"(inp, out) {
88
+ try {
89
+ if (!inp?.sessionID) return;
90
+ const state = store.stateFor(inp.sessionID);
91
+ if (isPrimaryAgent(inp.agent)) state.active = true;
92
+ const text = partsText(out?.parts);
93
+ if (text && state.active) {
94
+ // Accumulate goal text (bounded) so contextual gates can be derived.
95
+ state.goalText = `${state.goalText} ${text}`.trim().slice(-8000);
96
+ // Resolve contextual gates eagerly into the sticky set so truncating
97
+ // the rolling buffer later cannot drop an already-required gate.
98
+ refreshStickyGates(state);
99
+ persist();
100
+ }
101
+ } catch {
102
+ /* never break a turn */
103
+ }
104
+ },
105
+
106
+ async "chat.params"(inp) {
107
+ try {
108
+ if (!inp?.sessionID || typeof inp.sessionID !== "string") return;
109
+ const normalized = inp.sessionID.trim();
110
+ if (!normalized) return;
111
+ const state = store.stateFor(normalized);
112
+ state.currentAgent = inp.agent;
113
+ if (isPrimaryAgent(inp.agent)) state.active = true;
114
+ } catch {
115
+ /* ignore */
116
+ }
117
+ },
118
+
119
+ async "experimental.chat.system.transform"(inp, out) {
120
+ try {
121
+ if (!config.injectSystemState) return;
122
+ if (!inp?.sessionID || !out || !Array.isArray(out.system)) return;
123
+ const state = store.stateFor(inp.sessionID);
124
+ const block = buildSystemInjection(state, config);
125
+ if (block) out.system.push(block);
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ },
130
+
131
+ async "tool.execute.before"(inp, out) {
132
+ const state = store.stateFor(inp?.sessionID);
133
+ if (inp?.tool === "bash") {
134
+ const command = commandOf(inp, out);
135
+ const analysis = analyzeCommand(command);
136
+ const blockDestructive = config.blockDestructive && analysis.destructive;
137
+ const blockNetwork = config.blockNetworkExec && analysis.networkExec;
138
+ if (blockDestructive || blockNetwork) {
139
+ state.active = true;
140
+ state.dirtyReasons.push(`blocked risky bash: ${analysis.reasons.join("; ") || "destructive"}`);
141
+ if (config.toastOnBlock) logger.toast("Goal Guard blocked a destructive command", "error");
142
+ persist();
143
+ throw new Error(
144
+ `Goal Guard blocked a destructive or high-risk bash command (${analysis.reasons.join("; ") || "destructive"}). ` +
145
+ "Use a safer, reversible command or ask the user to confirm.",
146
+ );
147
+ }
148
+ }
149
+ },
150
+
151
+ async "tool.execute.after"(inp, out) {
152
+ try {
153
+ const state = store.stateFor(inp?.sessionID);
154
+ const tool = inp?.tool;
155
+ const isReviewing = isReviewAgent(state.currentAgent);
156
+
157
+ // Edits dirty the session (a read-only reviewer never edits, but guard
158
+ // symmetrically with the bash path so review-time writes don't dirty it).
159
+ if ((tool === "write" || tool === "edit" || tool === "apply_patch") && !isReviewing) {
160
+ markEdit(store, state, `${tool} at ${store.nowIso()}`);
161
+ }
162
+
163
+ if (tool === "bash") {
164
+ const command = String(inp?.args?.command || "");
165
+ const analysis = analyzeCommand(command);
166
+ if (analysis.verification && !isReviewing) {
167
+ markVerification(store, state);
168
+ }
169
+ if ((analysis.destructive || analysis.mutating) && !isReviewing) {
170
+ markEdit(store, state, `bash mutation: ${analysis.reasons.join("; ") || "mutation"}`);
171
+ }
172
+ }
173
+
174
+ // Verdict capture. The PRIMARY mechanism is the task path: when the goal
175
+ // agent spawns a reviewer via the task tool, the parent session sees the
176
+ // subagent's result here and the verdict is recorded against the parent
177
+ // goal — correct cross-session attribution. The agent path is a fallback
178
+ // for a reviewer's verdict surfacing in its own session's tool output; it
179
+ // records against that same session (never another), so it can neither
180
+ // mis-credit a sibling session nor break the parent goal, which the task
181
+ // path already covers. Split by tool type so the two never double-count.
182
+ const wasAllowed = completionAllowed(state, config);
183
+ let recordedAgent = null;
184
+ let recordedVerdict = null;
185
+ if (tool === "task") {
186
+ const sub = normalizedSubagent(inp);
187
+ if (isReviewAgent(sub)) {
188
+ const text = textOf(out);
189
+ const verdict = parseVerdict(text);
190
+ if (verdict) {
191
+ recordVerdict(store, state, sub, verdict, text);
192
+ recordedAgent = sub;
193
+ recordedVerdict = verdict;
194
+ }
195
+ }
196
+ } else if (isReviewAgent(state.currentAgent)) {
197
+ const text = textOf(out);
198
+ const verdict = parseVerdict(text);
199
+ if (verdict) {
200
+ recordVerdict(store, state, state.currentAgent, verdict, text);
201
+ recordedAgent = state.currentAgent;
202
+ recordedVerdict = verdict;
203
+ }
204
+ }
205
+
206
+ if (recordedAgent === CYCLE_CLOSING_AGENT) {
207
+ maybeClearDirtyOnFinalPass(state, config);
208
+ }
209
+
210
+ // Surface review progress in the TUI: a toast per recorded verdict, and a
211
+ // single celebratory toast the moment the last required gate clears.
212
+ if (recordedAgent && recordedVerdict && config.toastOnReview) {
213
+ logger.toast(`${prettyAgentName(recordedAgent)} → ${recordedVerdict}`, recordedVerdict === "PASS" ? "success" : "warning");
214
+ if (!wasAllowed && completionAllowed(state, config)) {
215
+ logger.toast("All required gates passed — completion unlocked", "success");
216
+ }
217
+ }
218
+ persist();
219
+ } catch {
220
+ /* never break a turn */
221
+ }
222
+ },
223
+
224
+ async "experimental.text.complete"(inp, out) {
225
+ try {
226
+ if (!config.enforceCompletion) return;
227
+ if (!inp?.sessionID || !out || typeof out.text !== "string") return;
228
+ const state = store.stateFor(inp.sessionID);
229
+ const decision = evaluateCompletionClaim(state, config, out.text);
230
+ if (decision.blocked) {
231
+ state.completedBlocked += 1;
232
+ state.lastCompletionRejectAt = store.nowIso();
233
+ state.completionRejections.push({ at: state.lastCompletionRejectAt, reason: decision.reason });
234
+ out.text = decision.replacement;
235
+ if (config.toastOnBlock) logger.toast(`Goal Guard blocked premature completion: ${decision.reason}`, "warning");
236
+ persist();
237
+ }
238
+ } catch {
239
+ /* ignore */
240
+ }
241
+ },
242
+
243
+ async "experimental.session.compacting"(inp, out) {
244
+ try {
245
+ if (!inp?.sessionID || !out || !Array.isArray(out.context)) return;
246
+ const state = store.stateFor(inp.sessionID);
247
+ out.context.push(
248
+ `Goal Guard state: ${summarizeState(state, config)}. Preserve Goal Contract, Verification Ledger, ` +
249
+ `Review Ledger, Reviewer Memory, review cycle count, dirty state, and open findings across compaction.`,
250
+ );
251
+ } catch {
252
+ /* ignore */
253
+ }
254
+ },
255
+
256
+ async event({ event } = {}) {
257
+ try {
258
+ if (!event) return;
259
+ if (event.type === "file.edited") {
260
+ const file = event.properties?.file || event.properties?.path || event.properties?.filename;
261
+ if (!file) return;
262
+ // The event is project-scoped and carries no sessionID, so attribute
263
+ // it to every active goal session in this project (a subagent edit in
264
+ // a child session must still dirty the goal it serves).
265
+ let touched = false;
266
+ for (const st of store.sessions.values()) {
267
+ if (st.active) {
268
+ markFileChanged(store, st, file);
269
+ refreshStickyGates(st);
270
+ touched = true;
271
+ }
272
+ }
273
+ if (touched) persist();
274
+ return;
275
+ }
276
+ if (event.type === "session.idle" && event.properties?.sessionID) {
277
+ const state = store.stateFor(event.properties.sessionID);
278
+ persistence.flush(() => store.snapshot());
279
+ if (state.dirty) {
280
+ await logger.warn("Goal session idle while dirty or review-stale", { state: summarizeState(state, config) });
281
+ }
282
+ }
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ },
287
+
288
+ async dispose() {
289
+ try {
290
+ persistence.flush(() => store.snapshot());
291
+ } catch {
292
+ /* ignore */
293
+ }
294
+ },
295
+ };
296
+
297
+ return { hooks, store, config, persistence, logger, persist };
298
+ }
299
+
300
+ /** OpenCode plugin factory (default export). */
301
+ export async function GoalGuardPlugin(input, options) {
302
+ const guard = createGuard(input || {}, options || {});
303
+ // Register custom goal_* tools, isolated so a resolution failure of
304
+ // @opencode-ai/plugin cannot prevent the core guard hooks from loading.
305
+ try {
306
+ const { createGoalTools } = await import("./tools.js");
307
+ guard.hooks.tool = createGoalTools({ store: guard.store, config: guard.config, persist: guard.persist });
308
+ } catch {
309
+ /* tools are optional */
310
+ }
311
+ return guard.hooks;
312
+ }
313
+
314
+ export default GoalGuardPlugin;
315
+
316
+ /** Stable test surface. */
317
+ export const __test = {
318
+ createGuard,
319
+ createStore,
320
+ createState,
321
+ resolveConfig,
322
+ analyzeCommand,
323
+ looksLikeDestructiveBash,
324
+ looksLikeMutatingBash,
325
+ isVerification,
326
+ summarizeState,
327
+ completionAllowed,
328
+ missingGates,
329
+ };
@@ -1,329 +1,15 @@
1
1
  /**
2
2
  * Goal Guard — OpenCode plugin entry point.
3
3
  *
4
- * This thin module wires the focused modules under `goal-guard/` into the
5
- * OpenCode plugin hooks. All real logic (shell analysis, gating, verdicts,
6
- * persistence, completion enforcement) lives in those modules and is unit
7
- * tested in isolation; the entry is just orchestration.
8
- *
9
- * Design notes (verified against @opencode-ai/plugin@1.15.13 source):
10
- * - State is created PER PLUGIN INSTANCE (no module globals), so concurrent
11
- * projects cannot cross-contaminate, and is persisted to the XDG state dir
12
- * so it survives OpenCode restarts.
13
- * - Destructive bash is blocked by THROWING in `tool.execute.before` (the
14
- * `permission.ask` hook is dormant in this version and cannot be relied on).
15
- * - `chat.message` captures the goal text that drives contextual review gates;
16
- * `file.edited` events catch edits made inside subagent child sessions.
17
- * - `experimental.chat.system.transform` injects live gate state into the
18
- * prompt; custom `goal_*` tools give the model structured control.
19
- */
20
-
21
- import { resolveConfig } from "./goal-guard/config.js";
22
- import { createStore, createState } from "./goal-guard/state.js";
23
- import { createPersistence } from "./goal-guard/persistence.js";
24
- import { createLogger } from "./goal-guard/logger.js";
25
- import { analyzeCommand, looksLikeDestructiveBash, looksLikeMutatingBash, isVerification } from "./goal-guard/shell.js";
26
- import { isPrimaryAgent, isReviewAgent, CYCLE_CLOSING_AGENT, prettyAgentName } from "./goal-guard/agents.js";
27
- import { textOf, parseVerdict, recordVerdict } from "./goal-guard/verdicts.js";
28
- import { completionAllowed, missingGates, refreshStickyGates } from "./goal-guard/gates.js";
29
- import { evaluateCompletionClaim } from "./goal-guard/completion.js";
30
- import { summarizeState } from "./goal-guard/summary.js";
31
- import { buildSystemInjection } from "./goal-guard/system.js";
32
- import { markEdit, markVerification, markFileChanged, maybeClearDirtyOnFinalPass } from "./goal-guard/events.js";
33
-
34
- function normalizedSubagent(input) {
35
- if (!input) return undefined;
36
- const agent = String(input.agent || input.args?.subagent_type || "").trim();
37
- return agent || undefined;
38
- }
39
-
40
- function commandOf(input, output) {
41
- return String(output?.args?.command ?? input?.args?.command ?? "");
42
- }
43
-
44
- function partsText(parts) {
45
- if (!Array.isArray(parts)) return "";
46
- return parts
47
- .filter((p) => p && (p.type === "text" || typeof p.text === "string"))
48
- .map((p) => p.text || "")
49
- .join(" ")
50
- .trim();
51
- }
52
-
53
- /**
54
- * Build a guard instance. Exposed for tests; the default export wraps it.
55
- *
56
- * @param {object} input PluginInput ({ client, directory, worktree, ... }).
57
- * @param {object} options Plugin options (2nd factory arg).
58
- * @param {object} overrides Test seams: { config, store, persistence, env, clock, setTimer, clearTimer }.
4
+ * OpenCode loads a plugin file by importing it and treating EVERY export as a
5
+ * plugin factory (see @opencode-ai/plugin's example: `export const X = async …`).
6
+ * A non-function export (or a second factory) makes OpenCode fail with
7
+ * "Plugin export is not a function" / double-register. So this entry exports
8
+ * EXACTLY ONE thing — the default plugin factory. All implementation and the
9
+ * test surface live in ./goal-guard/guard.js, which is nested one level deeper
10
+ * and therefore not matched by OpenCode's `{plugin,plugins}/*.{ts,js}` glob.
59
11
  */
60
- export function createGuard(input = {}, options = {}, overrides = {}) {
61
- const config = overrides.config || resolveConfig(options, overrides.env);
62
- const store =
63
- overrides.store ||
64
- createStore({ maxSessions: config.maxSessions, ttlMs: config.sessionTtlMs, clock: overrides.clock });
65
- const logger = createLogger(input.client);
66
- const persistence =
67
- overrides.persistence ||
68
- createPersistence({
69
- worktree: input.worktree || input.directory,
70
- enabled: config.persist,
71
- env: overrides.env,
72
- setTimer: overrides.setTimer,
73
- clearTimer: overrides.clearTimer,
74
- });
75
-
76
- // Rehydrate any prior state for this project.
77
- try {
78
- const data = persistence.load();
79
- if (data) store.restore(data);
80
- } catch {
81
- /* ignore corrupt state */
82
- }
83
-
84
- const persist = () => persistence.save(() => store.snapshot());
85
-
86
- const hooks = {
87
- async "chat.message"(inp, out) {
88
- try {
89
- if (!inp?.sessionID) return;
90
- const state = store.stateFor(inp.sessionID);
91
- if (isPrimaryAgent(inp.agent)) state.active = true;
92
- const text = partsText(out?.parts);
93
- if (text && state.active) {
94
- // Accumulate goal text (bounded) so contextual gates can be derived.
95
- state.goalText = `${state.goalText} ${text}`.trim().slice(-8000);
96
- // Resolve contextual gates eagerly into the sticky set so truncating
97
- // the rolling buffer later cannot drop an already-required gate.
98
- refreshStickyGates(state);
99
- persist();
100
- }
101
- } catch {
102
- /* never break a turn */
103
- }
104
- },
105
-
106
- async "chat.params"(inp) {
107
- try {
108
- if (!inp?.sessionID || typeof inp.sessionID !== "string") return;
109
- const normalized = inp.sessionID.trim();
110
- if (!normalized) return;
111
- const state = store.stateFor(normalized);
112
- state.currentAgent = inp.agent;
113
- if (isPrimaryAgent(inp.agent)) state.active = true;
114
- } catch {
115
- /* ignore */
116
- }
117
- },
118
-
119
- async "experimental.chat.system.transform"(inp, out) {
120
- try {
121
- if (!config.injectSystemState) return;
122
- if (!inp?.sessionID || !out || !Array.isArray(out.system)) return;
123
- const state = store.stateFor(inp.sessionID);
124
- const block = buildSystemInjection(state, config);
125
- if (block) out.system.push(block);
126
- } catch {
127
- /* ignore */
128
- }
129
- },
130
12
 
131
- async "tool.execute.before"(inp, out) {
132
- const state = store.stateFor(inp?.sessionID);
133
- if (inp?.tool === "bash") {
134
- const command = commandOf(inp, out);
135
- const analysis = analyzeCommand(command);
136
- const blockDestructive = config.blockDestructive && analysis.destructive;
137
- const blockNetwork = config.blockNetworkExec && analysis.networkExec;
138
- if (blockDestructive || blockNetwork) {
139
- state.active = true;
140
- state.dirtyReasons.push(`blocked risky bash: ${analysis.reasons.join("; ") || "destructive"}`);
141
- if (config.toastOnBlock) logger.toast("Goal Guard blocked a destructive command", "error");
142
- persist();
143
- throw new Error(
144
- `Goal Guard blocked a destructive or high-risk bash command (${analysis.reasons.join("; ") || "destructive"}). ` +
145
- "Use a safer, reversible command or ask the user to confirm.",
146
- );
147
- }
148
- }
149
- },
150
-
151
- async "tool.execute.after"(inp, out) {
152
- try {
153
- const state = store.stateFor(inp?.sessionID);
154
- const tool = inp?.tool;
155
- const isReviewing = isReviewAgent(state.currentAgent);
156
-
157
- // Edits dirty the session (a read-only reviewer never edits, but guard
158
- // symmetrically with the bash path so review-time writes don't dirty it).
159
- if ((tool === "write" || tool === "edit" || tool === "apply_patch") && !isReviewing) {
160
- markEdit(store, state, `${tool} at ${store.nowIso()}`);
161
- }
162
-
163
- if (tool === "bash") {
164
- const command = String(inp?.args?.command || "");
165
- const analysis = analyzeCommand(command);
166
- if (analysis.verification && !isReviewing) {
167
- markVerification(store, state);
168
- }
169
- if ((analysis.destructive || analysis.mutating) && !isReviewing) {
170
- markEdit(store, state, `bash mutation: ${analysis.reasons.join("; ") || "mutation"}`);
171
- }
172
- }
173
-
174
- // Verdict capture. The PRIMARY mechanism is the task path: when the goal
175
- // agent spawns a reviewer via the task tool, the parent session sees the
176
- // subagent's result here and the verdict is recorded against the parent
177
- // goal — correct cross-session attribution. The agent path is a fallback
178
- // for a reviewer's verdict surfacing in its own session's tool output; it
179
- // records against that same session (never another), so it can neither
180
- // mis-credit a sibling session nor break the parent goal, which the task
181
- // path already covers. Split by tool type so the two never double-count.
182
- const wasAllowed = completionAllowed(state, config);
183
- let recordedAgent = null;
184
- let recordedVerdict = null;
185
- if (tool === "task") {
186
- const sub = normalizedSubagent(inp);
187
- if (isReviewAgent(sub)) {
188
- const text = textOf(out);
189
- const verdict = parseVerdict(text);
190
- if (verdict) {
191
- recordVerdict(store, state, sub, verdict, text);
192
- recordedAgent = sub;
193
- recordedVerdict = verdict;
194
- }
195
- }
196
- } else if (isReviewAgent(state.currentAgent)) {
197
- const text = textOf(out);
198
- const verdict = parseVerdict(text);
199
- if (verdict) {
200
- recordVerdict(store, state, state.currentAgent, verdict, text);
201
- recordedAgent = state.currentAgent;
202
- recordedVerdict = verdict;
203
- }
204
- }
205
-
206
- if (recordedAgent === CYCLE_CLOSING_AGENT) {
207
- maybeClearDirtyOnFinalPass(state, config);
208
- }
209
-
210
- // Surface review progress in the TUI: a toast per recorded verdict, and a
211
- // single celebratory toast the moment the last required gate clears.
212
- if (recordedAgent && recordedVerdict && config.toastOnReview) {
213
- logger.toast(`${prettyAgentName(recordedAgent)} → ${recordedVerdict}`, recordedVerdict === "PASS" ? "success" : "warning");
214
- if (!wasAllowed && completionAllowed(state, config)) {
215
- logger.toast("All required gates passed — completion unlocked", "success");
216
- }
217
- }
218
- persist();
219
- } catch {
220
- /* never break a turn */
221
- }
222
- },
223
-
224
- async "experimental.text.complete"(inp, out) {
225
- try {
226
- if (!config.enforceCompletion) return;
227
- if (!inp?.sessionID || !out || typeof out.text !== "string") return;
228
- const state = store.stateFor(inp.sessionID);
229
- const decision = evaluateCompletionClaim(state, config, out.text);
230
- if (decision.blocked) {
231
- state.completedBlocked += 1;
232
- state.lastCompletionRejectAt = store.nowIso();
233
- state.completionRejections.push({ at: state.lastCompletionRejectAt, reason: decision.reason });
234
- out.text = decision.replacement;
235
- if (config.toastOnBlock) logger.toast(`Goal Guard blocked premature completion: ${decision.reason}`, "warning");
236
- persist();
237
- }
238
- } catch {
239
- /* ignore */
240
- }
241
- },
242
-
243
- async "experimental.session.compacting"(inp, out) {
244
- try {
245
- if (!inp?.sessionID || !out || !Array.isArray(out.context)) return;
246
- const state = store.stateFor(inp.sessionID);
247
- out.context.push(
248
- `Goal Guard state: ${summarizeState(state, config)}. Preserve Goal Contract, Verification Ledger, ` +
249
- `Review Ledger, Reviewer Memory, review cycle count, dirty state, and open findings across compaction.`,
250
- );
251
- } catch {
252
- /* ignore */
253
- }
254
- },
255
-
256
- async event({ event } = {}) {
257
- try {
258
- if (!event) return;
259
- if (event.type === "file.edited") {
260
- const file = event.properties?.file || event.properties?.path || event.properties?.filename;
261
- if (!file) return;
262
- // The event is project-scoped and carries no sessionID, so attribute
263
- // it to every active goal session in this project (a subagent edit in
264
- // a child session must still dirty the goal it serves).
265
- let touched = false;
266
- for (const st of store.sessions.values()) {
267
- if (st.active) {
268
- markFileChanged(store, st, file);
269
- refreshStickyGates(st);
270
- touched = true;
271
- }
272
- }
273
- if (touched) persist();
274
- return;
275
- }
276
- if (event.type === "session.idle" && event.properties?.sessionID) {
277
- const state = store.stateFor(event.properties.sessionID);
278
- persistence.flush(() => store.snapshot());
279
- if (state.dirty) {
280
- await logger.warn("Goal session idle while dirty or review-stale", { state: summarizeState(state, config) });
281
- }
282
- }
283
- } catch {
284
- /* ignore */
285
- }
286
- },
287
-
288
- async dispose() {
289
- try {
290
- persistence.flush(() => store.snapshot());
291
- } catch {
292
- /* ignore */
293
- }
294
- },
295
- };
296
-
297
- return { hooks, store, config, persistence, logger, persist };
298
- }
299
-
300
- /** OpenCode plugin factory (default export). */
301
- export async function GoalGuardPlugin(input, options) {
302
- const guard = createGuard(input || {}, options || {});
303
- // Register custom goal_* tools, isolated so a resolution failure of
304
- // @opencode-ai/plugin cannot prevent the core guard hooks from loading.
305
- try {
306
- const { createGoalTools } = await import("./goal-guard/tools.js");
307
- guard.hooks.tool = createGoalTools({ store: guard.store, config: guard.config, persist: guard.persist });
308
- } catch {
309
- /* tools are optional */
310
- }
311
- return guard.hooks;
312
- }
13
+ import { GoalGuardPlugin } from "./goal-guard/guard.js";
313
14
 
314
15
  export default GoalGuardPlugin;
315
-
316
- /** Stable test surface. */
317
- export const __test = {
318
- createGuard,
319
- createStore,
320
- createState,
321
- resolveConfig,
322
- analyzeCommand,
323
- looksLikeDestructiveBash,
324
- looksLikeMutatingBash,
325
- isVerification,
326
- summarizeState,
327
- completionAllowed,
328
- missingGates,
329
- };
@@ -95,10 +95,10 @@ function readModel(worktree, sessionId) {
95
95
  }
96
96
  }
97
97
 
98
- export const id = "goal-mode-sidebar";
98
+ const id = "goal-mode-sidebar";
99
99
 
100
100
  /** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
101
- export const tui = async (api, options) => {
101
+ const tui = async (api, options) => {
102
102
  try {
103
103
  const { enabled, color, muted } = resolveOptions(options, typeof process !== "undefined" ? process.env : {});
104
104
  if (!enabled) return;