opencode-goal-mode 0.3.3 → 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 +24 -0
- package/README.md +27 -1
- package/agents/goal-deep-researcher.md +1 -1
- package/agents/goal-diff-reviewer.md +1 -1
- package/agents/goal-doc-reviewer.md +1 -1
- package/agents/goal-doc-writer.md +1 -1
- package/agents/goal-final-auditor.md +1 -1
- package/agents/goal-ops-reviewer.md +1 -1
- package/agents/goal-prompt-auditor.md +1 -1
- package/agents/goal-reviewer.md +1 -1
- package/agents/goal-security-reviewer.md +1 -1
- package/agents/goal-test-reviewer.md +1 -1
- package/agents/goal-ux-reviewer.md +1 -1
- package/agents/goal-verifier.md +1 -1
- package/agents/goal-web-researcher.md +1 -1
- package/agents/goal.md +2 -2
- package/docs/benchmarks/latency.svg +3 -3
- package/docs/benchmarks/results.json +4 -4
- package/package.json +3 -2
- package/plugins/goal-guard/guard.js +329 -0
- package/plugins/goal-guard.js +8 -322
- package/plugins/goal-sidebar.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
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
|
+
|
|
3
27
|
## v0.3.3
|
|
4
28
|
|
|
5
29
|
- Release notes: the GitHub Release body is now generated from the matching
|
package/README.md
CHANGED
|
@@ -18,7 +18,33 @@ npm install -g opencode-goal-mode && opencode-goal-mode-install --global
|
|
|
18
18
|
|
|
19
19
|

|
|
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. The active
|
|
45
|
+
goal shows in the sidebar in yellow.
|
|
46
|
+
|
|
47
|
+
That's it. Everything below is detail.
|
|
22
48
|
|
|
23
49
|
See [ARCHITECTURE.md](ARCHITECTURE.md) for the design and [research/](research/)
|
|
24
50
|
for the platform reference, comparison, and threat model.
|
|
@@ -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
|
package/agents/goal-reviewer.md
CHANGED
package/agents/goal-verifier.md
CHANGED
package/agents/goal.md
CHANGED
|
@@ -26,8 +26,8 @@ permission:
|
|
|
26
26
|
external_directory:
|
|
27
27
|
"*": ask
|
|
28
28
|
"/projects/**": allow
|
|
29
|
-
"
|
|
30
|
-
"
|
|
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="
|
|
8
|
-
<text x="
|
|
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.
|
|
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":
|
|
79
|
-
"usPerCommand": 0.
|
|
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":
|
|
115
|
-
"usPerCommand": 1.
|
|
114
|
+
"opsPerSec": 876672,
|
|
115
|
+
"usPerCommand": 1.14
|
|
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
|
+
"version": "0.3.4",
|
|
4
4
|
"description": "Strict Goal Mode agents, commands, and guard plugin for OpenCode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@opencode-ai/plugin": "1.15.13",
|
|
86
|
-
"fast-check": "^4.8.0"
|
|
86
|
+
"fast-check": "^4.8.0",
|
|
87
|
+
"yaml": "^2.6.1"
|
|
87
88
|
}
|
|
88
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
|
+
};
|
package/plugins/goal-guard.js
CHANGED
|
@@ -1,329 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Goal Guard — OpenCode plugin entry point.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
};
|
package/plugins/goal-sidebar.js
CHANGED
|
@@ -95,10 +95,10 @@ function readModel(worktree, sessionId) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
const id = "goal-mode-sidebar";
|
|
99
99
|
|
|
100
100
|
/** @type {import("@opencode-ai/plugin/tui").TuiPlugin} */
|
|
101
|
-
|
|
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;
|