opencode-goal-mode 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +180 -0
- package/README.md +158 -52
- package/agents/goal-api-reviewer.md +0 -2
- package/agents/goal-architect.md +0 -2
- package/agents/goal-commentator.md +0 -2
- package/agents/goal-completion-guard.md +0 -2
- package/agents/goal-coordinator.md +0 -2
- package/agents/goal-data-reviewer.md +0 -2
- package/agents/goal-deep-researcher.md +0 -2
- package/agents/goal-diff-reviewer.md +0 -2
- package/agents/goal-doc-reviewer.md +0 -2
- package/agents/goal-doc-writer.md +0 -2
- package/agents/goal-explorer.md +9 -8
- package/agents/goal-final-auditor.md +0 -2
- package/agents/goal-implementer.md +0 -2
- package/agents/goal-mapper.md +0 -2
- package/agents/goal-ops-reviewer.md +0 -2
- package/agents/goal-perf-reviewer.md +0 -2
- package/agents/goal-planner.md +10 -5
- package/agents/goal-prompt-auditor.md +0 -2
- package/agents/goal-quality-gate.md +0 -2
- package/agents/goal-researcher.md +8 -7
- package/agents/goal-reviewer.md +0 -2
- package/agents/goal-security-reviewer.md +0 -2
- package/agents/goal-test-reviewer.md +0 -2
- package/agents/goal-ux-reviewer.md +0 -2
- package/agents/goal-verifier.md +0 -2
- package/agents/goal-web-researcher.md +0 -2
- package/agents/goal.md +9 -8
- package/package.json +13 -9
- package/plugins/goal-guard/agents.js +132 -0
- package/plugins/goal-guard/completion.js +64 -0
- package/plugins/goal-guard/config.js +87 -0
- package/plugins/goal-guard/events.js +65 -0
- package/plugins/goal-guard/gates.js +85 -0
- package/plugins/goal-guard/logger.js +36 -0
- package/plugins/goal-guard/persistence.js +122 -0
- package/plugins/goal-guard/shell.js +1159 -0
- package/plugins/goal-guard/state.js +182 -0
- package/plugins/goal-guard/summary.js +46 -0
- package/plugins/goal-guard/system.js +43 -0
- package/plugins/goal-guard/tools.js +129 -0
- package/plugins/goal-guard/verdicts.js +87 -0
- package/plugins/goal-guard.js +267 -379
- package/scripts/install.mjs +170 -36
- package/docs/research-report.md +0 -37
- package/scripts/check-npm-publish-ready.mjs +0 -54
- package/scripts/validate-opencode-config.mjs +0 -82
- package/tests/agents.test.mjs +0 -70
- package/tests/commands.test.mjs +0 -23
- package/tests/helpers.mjs +0 -23
- package/tests/install.test.mjs +0 -64
- package/tests/plugin.test.mjs +0 -195
package/plugins/goal-guard.js
CHANGED
|
@@ -1,426 +1,314 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"goal-implementer",
|
|
36
|
-
"goal-reviewer",
|
|
37
|
-
"goal-prompt-auditor",
|
|
38
|
-
"goal-diff-reviewer",
|
|
39
|
-
"goal-verifier",
|
|
40
|
-
"goal-test-reviewer",
|
|
41
|
-
"goal-security-reviewer",
|
|
42
|
-
"goal-ux-reviewer",
|
|
43
|
-
"goal-ops-reviewer",
|
|
44
|
-
"goal-doc-reviewer",
|
|
45
|
-
"goal-final-auditor",
|
|
46
|
-
"goal-deep-researcher",
|
|
47
|
-
"goal-web-researcher",
|
|
48
|
-
"goal-architect",
|
|
49
|
-
"goal-mapper",
|
|
50
|
-
"goal-planner",
|
|
51
|
-
"goal-coordinator",
|
|
52
|
-
"goal-doc-writer",
|
|
53
|
-
"goal-commentator",
|
|
54
|
-
"goal-api-reviewer",
|
|
55
|
-
"goal-data-reviewer",
|
|
56
|
-
"goal-perf-reviewer",
|
|
57
|
-
"goal-quality-gate",
|
|
58
|
-
]);
|
|
59
|
-
|
|
60
|
-
function normalizedAgent(input) {
|
|
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 "./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 } 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) {
|
|
61
35
|
if (!input) return undefined;
|
|
62
36
|
const agent = String(input.agent || input.args?.subagent_type || "").trim();
|
|
63
37
|
return agent || undefined;
|
|
64
38
|
}
|
|
65
39
|
|
|
66
|
-
function
|
|
67
|
-
return
|
|
68
|
-
active: false,
|
|
69
|
-
dirty: false,
|
|
70
|
-
dirtyReasons: [],
|
|
71
|
-
reviewCycles: 0,
|
|
72
|
-
lastReviewAt: null,
|
|
73
|
-
lastEditAt: null,
|
|
74
|
-
lastVerificationAt: null,
|
|
75
|
-
verdicts: [],
|
|
76
|
-
latestVerdict: {},
|
|
77
|
-
currentAgent: undefined,
|
|
78
|
-
completedBlocked: 0,
|
|
79
|
-
verificationSeen: false,
|
|
80
|
-
lastCompletionRejectAt: null,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const sessions = new Map();
|
|
85
|
-
const MAX_SESSIONS = 200;
|
|
86
|
-
|
|
87
|
-
function evictOldestSession() {
|
|
88
|
-
if (sessions.size < MAX_SESSIONS) return;
|
|
89
|
-
let oldestKey = null;
|
|
90
|
-
let oldestTime = Infinity;
|
|
91
|
-
for (const [key, state] of sessions) {
|
|
92
|
-
const t = new Date(state.lastEditAt || state.lastReviewAt || 0).getTime();
|
|
93
|
-
if (t < oldestTime) {
|
|
94
|
-
oldestTime = t;
|
|
95
|
-
oldestKey = key;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
if (oldestKey) sessions.delete(oldestKey);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function stateFor(sessionID) {
|
|
102
|
-
const key = String(sessionID || "default").trim() || "default";
|
|
103
|
-
if (!sessions.has(key)) {
|
|
104
|
-
while (sessions.size >= MAX_SESSIONS) evictOldestSession();
|
|
105
|
-
sessions.set(key, createState());
|
|
106
|
-
}
|
|
107
|
-
return sessions.get(key);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function nowIso() {
|
|
111
|
-
return new Date().toISOString();
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function textOf(output) {
|
|
115
|
-
const raw = output?.output || output?.text || output?.message || "";
|
|
116
|
-
if (typeof raw === "string") return raw;
|
|
117
|
-
if (typeof raw === "object" && raw?.output) return String(raw.output);
|
|
118
|
-
if (typeof raw === "object" && raw?.text) return String(raw.text);
|
|
119
|
-
return JSON.stringify(raw || "");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function isPass(text) {
|
|
123
|
-
return /Verdict:\s*PASS\b/i.test(text);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function isFail(text) {
|
|
127
|
-
return /Verdict:\s*FAIL\b/i.test(text);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function isVerification(command) {
|
|
131
|
-
const normalized = String(command || "").trim();
|
|
132
|
-
return [
|
|
133
|
-
/\bnpm\s+test\b/,
|
|
134
|
-
/\bnpm\s+run\s+test\b/,
|
|
135
|
-
/\bnpm\s+run\s+validate\b/,
|
|
136
|
-
/\bnpm\s+run\s+check\b/,
|
|
137
|
-
/\bnpm\s+run\s+lint\b/,
|
|
138
|
-
/\bnpm\s+run\s+typecheck\b/,
|
|
139
|
-
/\bnpm\s+run\s+build\b/,
|
|
140
|
-
/\bnpm\s+run\s+unit\b/,
|
|
141
|
-
/\bnpm\s+run\s+integration\b/,
|
|
142
|
-
/\bjest\b/,
|
|
143
|
-
/\bmocha\b/,
|
|
144
|
-
/\bvite\s+test\b/,
|
|
145
|
-
/\bvitest\b/,
|
|
146
|
-
/\bpnpm\s+test\b/,
|
|
147
|
-
/\byarn\s+test\b/,
|
|
148
|
-
/\bbun\s+test\b/,
|
|
149
|
-
/\bgo\s+test\b/,
|
|
150
|
-
/\bcargo\s+test\b/,
|
|
151
|
-
/\bpytest\b/,
|
|
152
|
-
/\bpython\s+-m\s+pytest\b/,
|
|
153
|
-
/\bpython\s+-m\s+unittest\b/,
|
|
154
|
-
/\bphpunit\b/,
|
|
155
|
-
/\bmake\s+test\b/,
|
|
156
|
-
/\bmake\s+check\b/,
|
|
157
|
-
/\bmake\s+validate\b/,
|
|
158
|
-
].some((pattern) => pattern.test(normalized));
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function looksLikeDestructiveBash(command) {
|
|
162
|
-
const normalized = String(command || "").trim();
|
|
163
|
-
return [
|
|
164
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?rm\s+-[a-zA-Z]*[rR][a-zA-Z]*[rfRF]?\b/,
|
|
165
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?rm\s+(--recursive|--force|--recursive\s+--force|-rf|-fr|-r)\b/,
|
|
166
|
-
/(^|&&|;|\|\|)\s*git\s+reset\b/,
|
|
167
|
-
/(^|&&|;|\|\|)\s*git\s+clean\b/,
|
|
168
|
-
/(^|&&|;|\|\|)\s*git\s+checkout\b/,
|
|
169
|
-
/(^|&&|;|\|\|)\s*git\s+restore\b/,
|
|
170
|
-
/(^|&&|;|\|\|)\s*git\s+switch\b/,
|
|
171
|
-
/(^|&&|;|\|\|)\s*git\s+push\b/,
|
|
172
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?find\b.*\s-delete\b/,
|
|
173
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?find\b.*\s-exec\s+rm\b/,
|
|
174
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?dd\b.*\bof=\/dev\//,
|
|
175
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?mkfs(\.|\s|$)/,
|
|
176
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?shred\b/,
|
|
177
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?truncate\b/,
|
|
178
|
-
/(^|&&|;|\|\|)\s*(sudo\s+)?chmod\s+-[a-zA-Z]*[rR][a-zA-Z]*[wW][a-zA-Z]*[xX][a-zA-Z]*\s+\/\b/,
|
|
179
|
-
].some((pattern) => pattern.test(normalized));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function looksLikeMutatingBash(command) {
|
|
183
|
-
const normalized = String(command || "").trim();
|
|
184
|
-
if (!normalized) return false;
|
|
185
|
-
if (looksLikeDestructiveBash(normalized)) return true;
|
|
186
|
-
return MUTATING_BASH_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
40
|
+
function commandOf(input, output) {
|
|
41
|
+
return String(output?.args?.command ?? input?.args?.command ?? "");
|
|
187
42
|
}
|
|
188
43
|
|
|
189
|
-
function
|
|
190
|
-
|
|
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();
|
|
191
51
|
}
|
|
192
52
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
];
|
|
223
|
-
|
|
224
|
-
const CONTEXTUAL_GATES = {
|
|
225
|
-
security: "goal-security-reviewer",
|
|
226
|
-
permissions: "goal-security-reviewer",
|
|
227
|
-
auth: "goal-security-reviewer",
|
|
228
|
-
shell: "goal-security-reviewer",
|
|
229
|
-
test: "goal-test-reviewer",
|
|
230
|
-
coverage: "goal-test-reviewer",
|
|
231
|
-
ops: "goal-ops-reviewer",
|
|
232
|
-
restart: "goal-ops-reviewer",
|
|
233
|
-
install: "goal-ops-reviewer",
|
|
234
|
-
api: "goal-api-reviewer",
|
|
235
|
-
endpoint: "goal-api-reviewer",
|
|
236
|
-
schema: "goal-api-reviewer",
|
|
237
|
-
data: "goal-data-reviewer",
|
|
238
|
-
database: "goal-data-reviewer",
|
|
239
|
-
migration: "goal-data-reviewer",
|
|
240
|
-
performance: "goal-perf-reviewer",
|
|
241
|
-
latency: "goal-perf-reviewer",
|
|
242
|
-
quality: "goal-quality-gate",
|
|
243
|
-
standard: "goal-quality-gate",
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
function requiredGates(state, promptText, changedFilesText) {
|
|
247
|
-
const since = [state.lastEditAt, state.lastVerificationAt].filter(Boolean).sort().at(-1);
|
|
248
|
-
const text = `${promptText || ""} ${(changedFilesText || state.dirtyReasons.join(" ") || "")}`.toLowerCase();
|
|
249
|
-
const gates = [...BASE_GATES];
|
|
250
|
-
for (const [keyword, agent] of Object.entries(CONTEXTUAL_GATES)) {
|
|
251
|
-
if (text.includes(keyword) && !gates.includes(agent)) gates.push(agent);
|
|
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 */
|
|
252
82
|
}
|
|
253
|
-
return { since, gates };
|
|
254
|
-
}
|
|
255
83
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export async function GoalGuardPlugin({ client }) {
|
|
278
|
-
return {
|
|
279
|
-
async "chat.params"(input) {
|
|
280
|
-
if (!input?.sessionID || typeof input.sessionID !== "string") return;
|
|
281
|
-
const normalized = input.sessionID.trim();
|
|
282
|
-
if (!normalized) return;
|
|
283
|
-
const state = stateFor(normalized);
|
|
284
|
-
state.currentAgent = input.agent;
|
|
285
|
-
if (GOAL_AGENTS.has(input.agent)) state.active = true;
|
|
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
|
+
}
|
|
286
104
|
},
|
|
287
105
|
|
|
288
|
-
async "
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
state
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (input.tool === "write" || input.tool === "edit" || input.tool === "apply_patch") {
|
|
299
|
-
state.dirty = true;
|
|
300
|
-
state.lastEditAt = nowIso();
|
|
301
|
-
state.dirtyReasons.push(`${input.tool} at ${state.lastEditAt}`);
|
|
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 */
|
|
302
116
|
}
|
|
303
117
|
},
|
|
304
118
|
|
|
305
|
-
async "
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
},
|
|
313
130
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
}
|
|
318
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);
|
|
319
156
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
state.verificationSeen = true;
|
|
325
|
-
state.lastVerificationAt = at;
|
|
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()}`);
|
|
326
161
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
}
|
|
331
172
|
}
|
|
332
|
-
}
|
|
333
173
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
+
let recordedAgent = null;
|
|
183
|
+
if (tool === "task") {
|
|
184
|
+
const sub = normalizedSubagent(inp);
|
|
185
|
+
if (isReviewAgent(sub)) {
|
|
186
|
+
const verdict = parseVerdict(textOf(out));
|
|
187
|
+
if (verdict) {
|
|
188
|
+
recordVerdict(store, state, sub, verdict);
|
|
189
|
+
recordedAgent = sub;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} else if (isReviewAgent(state.currentAgent)) {
|
|
193
|
+
const verdict = parseVerdict(textOf(out));
|
|
194
|
+
if (verdict) {
|
|
195
|
+
recordVerdict(store, state, state.currentAgent, verdict);
|
|
196
|
+
recordedAgent = state.currentAgent;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
343
199
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
if (/Verdict:\s*(PASS|FAIL)\b/i.test(out)) {
|
|
347
|
-
const failFirst = isFail(out);
|
|
348
|
-
const passAfter = isPass(out) && !failFirst;
|
|
349
|
-
if (!failFirst && !passAfter) return;
|
|
350
|
-
const verdict = passAfter ? "PASS" : "FAIL";
|
|
351
|
-
recordReviewVerdict(state, agent, verdict, at);
|
|
352
|
-
recordedReviewAgent = agent;
|
|
200
|
+
if (recordedAgent === CYCLE_CLOSING_AGENT) {
|
|
201
|
+
maybeClearDirtyOnFinalPass(state, config);
|
|
353
202
|
}
|
|
203
|
+
persist();
|
|
204
|
+
} catch {
|
|
205
|
+
/* never break a turn */
|
|
354
206
|
}
|
|
207
|
+
},
|
|
355
208
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
209
|
+
async "experimental.text.complete"(inp, out) {
|
|
210
|
+
try {
|
|
211
|
+
if (!config.enforceCompletion) return;
|
|
212
|
+
if (!inp?.sessionID || !out || typeof out.text !== "string") return;
|
|
213
|
+
const state = store.stateFor(inp.sessionID);
|
|
214
|
+
const decision = evaluateCompletionClaim(state, config, out.text);
|
|
215
|
+
if (decision.blocked) {
|
|
216
|
+
state.completedBlocked += 1;
|
|
217
|
+
state.lastCompletionRejectAt = store.nowIso();
|
|
218
|
+
state.completionRejections.push({ at: state.lastCompletionRejectAt, reason: decision.reason });
|
|
219
|
+
out.text = decision.replacement;
|
|
220
|
+
if (config.toastOnBlock) logger.toast(`Goal Guard blocked premature completion: ${decision.reason}`, "warning");
|
|
221
|
+
persist();
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
/* ignore */
|
|
363
225
|
}
|
|
364
226
|
},
|
|
365
227
|
|
|
366
|
-
async "experimental.session.compacting"(
|
|
367
|
-
|
|
368
|
-
|
|
228
|
+
async "experimental.session.compacting"(inp, out) {
|
|
229
|
+
try {
|
|
230
|
+
if (!inp?.sessionID || !out || !Array.isArray(out.context)) return;
|
|
231
|
+
const state = store.stateFor(inp.sessionID);
|
|
232
|
+
out.context.push(
|
|
233
|
+
`Goal Guard state: ${summarizeState(state, config)}. Preserve Goal Contract, Verification Ledger, ` +
|
|
234
|
+
`Review Ledger, review cycle count, dirty state, and open findings across compaction.`,
|
|
235
|
+
);
|
|
236
|
+
} catch {
|
|
237
|
+
/* ignore */
|
|
238
|
+
}
|
|
369
239
|
},
|
|
370
240
|
|
|
371
|
-
async
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
241
|
+
async event({ event } = {}) {
|
|
242
|
+
try {
|
|
243
|
+
if (!event) return;
|
|
244
|
+
if (event.type === "file.edited") {
|
|
245
|
+
const file = event.properties?.file || event.properties?.path || event.properties?.filename;
|
|
246
|
+
if (!file) return;
|
|
247
|
+
// The event is project-scoped and carries no sessionID, so attribute
|
|
248
|
+
// it to every active goal session in this project (a subagent edit in
|
|
249
|
+
// a child session must still dirty the goal it serves).
|
|
250
|
+
let touched = false;
|
|
251
|
+
for (const st of store.sessions.values()) {
|
|
252
|
+
if (st.active) {
|
|
253
|
+
markFileChanged(store, st, file);
|
|
254
|
+
refreshStickyGates(st);
|
|
255
|
+
touched = true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (touched) persist();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (event.type === "session.idle" && event.properties?.sessionID) {
|
|
262
|
+
const state = store.stateFor(event.properties.sessionID);
|
|
263
|
+
persistence.flush(() => store.snapshot());
|
|
264
|
+
if (state.dirty) {
|
|
265
|
+
await logger.warn("Goal session idle while dirty or review-stale", { state: summarizeState(state, config) });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
/* ignore */
|
|
396
270
|
}
|
|
397
271
|
},
|
|
398
272
|
|
|
399
|
-
async
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
body: {
|
|
405
|
-
service: "goal-guard",
|
|
406
|
-
level: "warn",
|
|
407
|
-
message: "Goal session idle while dirty or review-stale",
|
|
408
|
-
extra: { state: summarizeState(state) },
|
|
409
|
-
},
|
|
410
|
-
});
|
|
411
|
-
}
|
|
273
|
+
async dispose() {
|
|
274
|
+
try {
|
|
275
|
+
persistence.flush(() => store.snapshot());
|
|
276
|
+
} catch {
|
|
277
|
+
/* ignore */
|
|
412
278
|
}
|
|
413
279
|
},
|
|
414
280
|
};
|
|
281
|
+
|
|
282
|
+
return { hooks, store, config, persistence, logger, persist };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** OpenCode plugin factory (default export). */
|
|
286
|
+
export async function GoalGuardPlugin(input, options) {
|
|
287
|
+
const guard = createGuard(input || {}, options || {});
|
|
288
|
+
// Register custom goal_* tools, isolated so a resolution failure of
|
|
289
|
+
// @opencode-ai/plugin cannot prevent the core guard hooks from loading.
|
|
290
|
+
try {
|
|
291
|
+
const { createGoalTools } = await import("./goal-guard/tools.js");
|
|
292
|
+
guard.hooks.tool = createGoalTools({ store: guard.store, config: guard.config, persist: guard.persist });
|
|
293
|
+
} catch {
|
|
294
|
+
/* tools are optional */
|
|
295
|
+
}
|
|
296
|
+
return guard.hooks;
|
|
415
297
|
}
|
|
416
298
|
|
|
417
299
|
export default GoalGuardPlugin;
|
|
300
|
+
|
|
301
|
+
/** Stable test surface. */
|
|
418
302
|
export const __test = {
|
|
303
|
+
createGuard,
|
|
304
|
+
createStore,
|
|
419
305
|
createState,
|
|
420
|
-
|
|
421
|
-
|
|
306
|
+
resolveConfig,
|
|
307
|
+
analyzeCommand,
|
|
422
308
|
looksLikeDestructiveBash,
|
|
423
309
|
looksLikeMutatingBash,
|
|
424
310
|
isVerification,
|
|
425
311
|
summarizeState,
|
|
312
|
+
completionAllowed,
|
|
313
|
+
missingGates,
|
|
426
314
|
};
|