opencode-goal-mode 0.3.2 → 0.3.4

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,42 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.3.4
4
+
5
+ Critical fixes found by testing against a real OpenCode (1.17.6) install — the
6
+ prior releases did not actually work end-to-end.
7
+
8
+ - **FIX: the guard plugin did not load.** OpenCode loads *every* export of a
9
+ plugin file as a plugin factory, so `goal-guard.js` exporting a test helper and
10
+ extra factories made OpenCode fail with `Plugin export is not a function` — the
11
+ entire enforcement layer was silently dead. The entry now exports **only** the
12
+ default plugin; the factory and test surface moved to
13
+ `plugins/goal-guard/guard.js` (nested, so OpenCode does not load it directly).
14
+ - **FIX: 14 of 27 agents had invalid YAML frontmatter** (an invalid `\.` escape in
15
+ `external_directory` globs, and an unquoted `: ` in one description). OpenCode
16
+ silently dropped those agents to `mode: all` with **no permissions applied** —
17
+ they became user-selectable and the review gates' `edit: deny` / `task: deny`
18
+ were ignored. Fixed; verified in OpenCode that `goal` is the only `primary`
19
+ agent and all 26 specialists are `subagent` with their deny-permissions applied.
20
+ - **Hardened the validator** so this can't regress: it now parses each agent's
21
+ frontmatter as real YAML (not regex), enforces reviewer `edit`/`task: deny` from
22
+ parsed values, and asserts the plugin entry exports only a default function.
23
+ - `goal-sidebar.js` now `export default { id, tui }` only (the supported TUI
24
+ plugin shape), matching working OpenCode TUI plugins.
25
+ - README: a "Quick start" (install → verify → use) at the top.
26
+
27
+ ## v0.3.3
28
+
29
+ - Release notes: the GitHub Release body is now generated from the matching
30
+ `CHANGELOG.md` section (`scripts/release-notes.mjs`), so releases always ship
31
+ real notes instead of an empty auto-summary.
32
+ - Richer repository: README badges (npm version/downloads, CI, release, license,
33
+ node), a quick-links bar, and a terminal-style sidebar demo
34
+ (`docs/sidebar-demo.svg`); a descriptive repo summary and topics; and
35
+ community health files (CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, issue and PR
36
+ templates).
37
+ - Stronger npm discoverability: expanded `keywords` (opencode-plugin,
38
+ opencode-tui-plugin, guardrails, review-gates, completion-enforcement, …).
39
+
3
40
  ## v0.3.2
4
41
 
5
42
  - Only the `goal` agent is user-selectable. The structural validator now requires
package/README.md CHANGED
@@ -1,10 +1,51 @@
1
1
  # OpenCode Goal Mode
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/opencode-goal-mode?color=2da44e&label=npm)](https://www.npmjs.com/package/opencode-goal-mode)
4
+ [![npm downloads](https://img.shields.io/npm/dm/opencode-goal-mode?color=2da44e)](https://www.npmjs.com/package/opencode-goal-mode)
5
+ [![CI](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/ci.yml)
6
+ [![Release](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml/badge.svg)](https://github.com/devinoldenburg/opencode-goal-mode/actions/workflows/publish.yml)
7
+ [![license](https://img.shields.io/npm/l/opencode-goal-mode?color=2da44e)](LICENSE)
8
+ [![node](https://img.shields.io/node/v/opencode-goal-mode?color=2da44e)](package.json)
9
+
3
10
  Strict Goal Mode for OpenCode: a primary `goal` agent, a matrix of specialized
4
11
  review subagents, slash commands, and a `goal-guard` plugin that enforces review
5
12
  discipline, blocks destructive shell commands, and preserves goal state across
6
13
  compaction **and** restarts.
7
14
 
15
+ ```bash
16
+ npm install -g opencode-goal-mode && opencode-goal-mode-install --global
17
+ ```
18
+
19
+ ![OpenCode Goal Mode sidebar banner](docs/sidebar-demo.svg)
20
+
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. The active
45
+ goal shows in the sidebar in yellow.
46
+
47
+ That's it. Everything below is detail.
48
+
8
49
  See [ARCHITECTURE.md](ARCHITECTURE.md) for the design and [research/](research/)
9
50
  for the platform reference, comparison, and threat model.
10
51
 
@@ -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="212.3" height="22" rx="3" fill="#9aa0a6"/>
8
+ <text x="450.3" y="87" font-size="12" font-weight="600" fill="#1f2328">0.81 µ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.14 µ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": 1238863,
79
+ "usPerCommand": 0.81
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": 876672,
115
+ "usPerCommand": 1.14
116
116
  }
117
117
  },
118
118
  "completionFixtures": {
@@ -0,0 +1,54 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="760" height="280" viewBox="0 0 760 280" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" role="img" aria-label="OpenCode Goal Mode sidebar banner: the active goal in yellow with a gate-status line, and a grey No goal state.">
2
+ <defs>
3
+ <style>
4
+ .win { fill: #0d1117; stroke: #30363d; stroke-width: 1; }
5
+ .bar { fill: #161b22; }
6
+ .dot { stroke-width: 0; }
7
+ .title { fill: #8b949e; font-size: 12px; }
8
+ .label { fill: #8b949e; font-size: 12px; }
9
+ .goal { fill: #FFD700; font-size: 13px; }
10
+ .goalb { fill: #FFD700; font-size: 13px; font-weight: 700; }
11
+ .status { fill: #FFD700; font-size: 12px; }
12
+ .muted { fill: #808080; font-size: 13px; }
13
+ .chat { fill: #c9d1d9; font-size: 12px; }
14
+ .dim { fill: #6e7681; font-size: 12px; }
15
+ .ok { fill: #2da44e; font-size: 12px; }
16
+ .div { stroke: #30363d; stroke-width: 1; }
17
+ </style>
18
+ </defs>
19
+
20
+ <!-- window -->
21
+ <rect class="win" x="1" y="1" width="758" height="278" rx="8"/>
22
+ <rect class="bar" x="1" y="1" width="758" height="30" rx="8"/>
23
+ <rect class="bar" x="1" y="20" width="758" height="11"/>
24
+ <circle class="dot" cx="20" cy="16" r="5" fill="#ff5f56"/>
25
+ <circle class="dot" cx="38" cy="16" r="5" fill="#ffbd2e"/>
26
+ <circle class="dot" cx="56" cy="16" r="5" fill="#27c93f"/>
27
+ <text class="title" x="86" y="20">opencode — goal mode</text>
28
+
29
+ <!-- vertical divider between chat and sidebar -->
30
+ <line class="div" x1="470" y1="31" x2="470" y2="279"/>
31
+
32
+ <!-- chat pane (left) -->
33
+ <text class="dim" x="20" y="58">▌ goal</text>
34
+ <text class="chat" x="20" y="82">▸ Goal Contract recorded (4 acceptance criteria)</text>
35
+ <text class="chat" x="20" y="104">▸ implementing… running verification</text>
36
+ <text class="ok" x="20" y="126">✓ Security Reviewer → PASS</text>
37
+ <text class="ok" x="20" y="148">✓ Verifier → PASS</text>
38
+ <text class="dim" x="20" y="170">▸ Diff Reviewer running…</text>
39
+
40
+ <!-- sidebar pane (right) -->
41
+ <text class="label" x="490" y="58">SESSION</text>
42
+ <line class="div" x1="490" y1="68" x2="740" y2="68"/>
43
+
44
+ <!-- goal banner (active) -->
45
+ <text x="490" y="98"><tspan class="goal">◆ </tspan><tspan class="goalb">GOAL</tspan><tspan class="goal"> Ship the OAuth</tspan></text>
46
+ <text class="goal" x="490" y="116">refactor</text>
47
+ <text class="status" x="490" y="138">3/5 gates · dirty</text>
48
+
49
+ <line class="div" x1="490" y1="170" x2="740" y2="170"/>
50
+
51
+ <!-- no-goal state -->
52
+ <text class="label" x="490" y="196">when no goal is set</text>
53
+ <text class="muted" x="490" y="222">No goal</text>
54
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-goal-mode",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,9 +48,24 @@
48
48
  },
49
49
  "keywords": [
50
50
  "opencode",
51
+ "opencode-plugin",
52
+ "opencode-tui-plugin",
51
53
  "goal-mode",
52
54
  "agents",
53
- "review",
55
+ "ai-agents",
56
+ "agentic",
57
+ "subagents",
58
+ "code-review",
59
+ "review-gates",
60
+ "guardrails",
61
+ "completion-enforcement",
62
+ "shell-guard",
63
+ "destructive-command",
64
+ "tui",
65
+ "llm",
66
+ "claude",
67
+ "coding-agent",
68
+ "developer-tools",
54
69
  "plugin"
55
70
  ],
56
71
  "repository": {
@@ -68,6 +83,7 @@
68
83
  },
69
84
  "devDependencies": {
70
85
  "@opencode-ai/plugin": "1.15.13",
71
- "fast-check": "^4.8.0"
86
+ "fast-check": "^4.8.0",
87
+ "yaml": "^2.6.1"
72
88
  }
73
89
  }
@@ -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;