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.
Files changed (53) hide show
  1. package/ARCHITECTURE.md +180 -0
  2. package/README.md +158 -52
  3. package/agents/goal-api-reviewer.md +0 -2
  4. package/agents/goal-architect.md +0 -2
  5. package/agents/goal-commentator.md +0 -2
  6. package/agents/goal-completion-guard.md +0 -2
  7. package/agents/goal-coordinator.md +0 -2
  8. package/agents/goal-data-reviewer.md +0 -2
  9. package/agents/goal-deep-researcher.md +0 -2
  10. package/agents/goal-diff-reviewer.md +0 -2
  11. package/agents/goal-doc-reviewer.md +0 -2
  12. package/agents/goal-doc-writer.md +0 -2
  13. package/agents/goal-explorer.md +9 -8
  14. package/agents/goal-final-auditor.md +0 -2
  15. package/agents/goal-implementer.md +0 -2
  16. package/agents/goal-mapper.md +0 -2
  17. package/agents/goal-ops-reviewer.md +0 -2
  18. package/agents/goal-perf-reviewer.md +0 -2
  19. package/agents/goal-planner.md +10 -5
  20. package/agents/goal-prompt-auditor.md +0 -2
  21. package/agents/goal-quality-gate.md +0 -2
  22. package/agents/goal-researcher.md +8 -7
  23. package/agents/goal-reviewer.md +0 -2
  24. package/agents/goal-security-reviewer.md +0 -2
  25. package/agents/goal-test-reviewer.md +0 -2
  26. package/agents/goal-ux-reviewer.md +0 -2
  27. package/agents/goal-verifier.md +0 -2
  28. package/agents/goal-web-researcher.md +0 -2
  29. package/agents/goal.md +9 -8
  30. package/package.json +13 -9
  31. package/plugins/goal-guard/agents.js +132 -0
  32. package/plugins/goal-guard/completion.js +64 -0
  33. package/plugins/goal-guard/config.js +87 -0
  34. package/plugins/goal-guard/events.js +65 -0
  35. package/plugins/goal-guard/gates.js +85 -0
  36. package/plugins/goal-guard/logger.js +36 -0
  37. package/plugins/goal-guard/persistence.js +122 -0
  38. package/plugins/goal-guard/shell.js +1159 -0
  39. package/plugins/goal-guard/state.js +182 -0
  40. package/plugins/goal-guard/summary.js +46 -0
  41. package/plugins/goal-guard/system.js +43 -0
  42. package/plugins/goal-guard/tools.js +129 -0
  43. package/plugins/goal-guard/verdicts.js +87 -0
  44. package/plugins/goal-guard.js +267 -379
  45. package/scripts/install.mjs +170 -36
  46. package/docs/research-report.md +0 -37
  47. package/scripts/check-npm-publish-ready.mjs +0 -54
  48. package/scripts/validate-opencode-config.mjs +0 -82
  49. package/tests/agents.test.mjs +0 -70
  50. package/tests/commands.test.mjs +0 -23
  51. package/tests/helpers.mjs +0 -23
  52. package/tests/install.test.mjs +0 -64
  53. package/tests/plugin.test.mjs +0 -195
@@ -1,426 +1,314 @@
1
- import { createHash } from "node:crypto";
2
-
3
- const WRITE_TOOLS = new Set(["edit", "write", "apply_patch"]);
4
-
5
- const MUTATING_BASH_PATTERNS = [
6
- /(^|&&|;|\|\|)\s*(sudo\s+)?(rm|mv|cp|mkdir|rmdir|touch|ln)\b/i,
7
- /(^|&&|;|\|\|)\s*(sudo\s+)?(tee|xargs\s+(rm|mv|cp))\b/i,
8
- /(^|&&|;|\|\|)\s*[^|]*\s(>|>>)\s*(?!\/dev\/null\b)\S+/i,
9
- /(^|&&|;|\|\|)\s*(perl\s+-pi|sed\s+-i)\b/i,
10
- /(^|&&|;|\|\|)\s*(npm|pnpm|yarn|bun)\s+(install|ci|add|remove|update)\b/i,
11
- /(^|&&|;|\|\|)\s*(npm|pnpm|yarn|bun)\s+(run\s+)?(format|fix|lint:fix)\b/i,
12
- /\b((npx|pnpm\s+exec|yarn)\s+)?(prettier|eslint)\b.*\s(--write|--fix)\b/i,
13
- /\b(node|python3?)\b.*\b(writeFile|appendFile|copyFile|rename|unlink|rmSync|mkdir|rmdir|openSync)\b/i,
14
- ];
15
-
16
- const REVIEW_AGENTS = new Set([
17
- "goal-reviewer",
18
- "goal-prompt-auditor",
19
- "goal-diff-reviewer",
20
- "goal-verifier",
21
- "goal-test-reviewer",
22
- "goal-security-reviewer",
23
- "goal-ux-reviewer",
24
- "goal-ops-reviewer",
25
- "goal-doc-reviewer",
26
- "goal-final-auditor",
27
- "goal-api-reviewer",
28
- "goal-data-reviewer",
29
- "goal-perf-reviewer",
30
- "goal-quality-gate",
31
- ]);
32
-
33
- const GOAL_AGENTS = new Set([
34
- "goal",
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 createState() {
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 commandFingerprint(command) {
190
- return createHash("sha256").update(String(command || "")).digest("hex").slice(0, 12);
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
- function latestVerdictFor(state, agent) {
194
- const entries = state.verdicts.filter((entry) => entry.agent === agent);
195
- if (!entries.length) return null;
196
- return entries.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))[0];
197
- }
198
-
199
- function recordReviewVerdict(state, agent, verdict, at) {
200
- state.verdicts.push({ agent, verdict, at });
201
- state.latestVerdict[agent] = { verdict, at };
202
- state.lastReviewAt = at;
203
- if (agent === "goal-final-auditor") {
204
- state.reviewCycles += 1;
205
- }
206
- }
207
-
208
- function verdictAfter(state, agent, since) {
209
- const latest = latestVerdictFor(state, agent);
210
- if (!latest) return false;
211
- if (latest.verdict !== "PASS") return false;
212
- if (!since) return true;
213
- return latest.at >= since;
214
- }
215
-
216
- const BASE_GATES = [
217
- "goal-prompt-auditor",
218
- "goal-reviewer",
219
- "goal-diff-reviewer",
220
- "goal-verifier",
221
- "goal-final-auditor",
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
- function missingGates(state) {
257
- const { since, gates } = requiredGates(state);
258
- return gates.filter((agent) => !verdictAfter(state, agent, since));
259
- }
260
-
261
- function completionAllowed(state) {
262
- return state.active && missingGates(state).length === 0;
263
- }
264
-
265
- function summarizeState(state) {
266
- const verdictSummary = state.verdicts.slice(-8).map((v) => `${v.agent}:${v.verdict}`).join(", ") || "none";
267
- return [
268
- `dirty=${state.dirty}`,
269
- `reviewCycles=${state.reviewCycles}`,
270
- `lastEditAt=${state.lastEditAt || "none"}`,
271
- `lastReviewAt=${state.lastReviewAt || "none"}`,
272
- `recentVerdicts=${verdictSummary}`,
273
- `dirtyReasons=${state.dirtyReasons.slice(-5).join(" | ") || "none"}`,
274
- ].join("; ");
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 "tool.execute.before"(input, output) {
289
- const state = stateFor(input.sessionID);
290
- const command = output?.args?.command || input?.args?.command;
291
- if (input.tool === "bash" && looksLikeDestructiveBash(command)) {
292
- state.active = true;
293
- state.dirtyReasons.push(`blocked risky bash fingerprint:${commandFingerprint(command)}`);
294
- throw new Error(
295
- "Goal Guard blocked a destructive or high-risk bash command. Ask the user or use a safer command."
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 "tool.execute.after"(input, output) {
306
- const state = stateFor(input.sessionID);
307
- const agent = state.currentAgent;
308
- const invokedReviewAgent = normalizedAgent(input);
309
- const at = nowIso();
310
- let recordedReviewAgent = null;
311
-
312
- if (agent && GOAL_AGENTS.has(agent)) state.active = true;
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
- if (WRITE_TOOLS.has(input.tool)) {
315
- state.dirty = true;
316
- state.lastEditAt = at;
317
- state.dirtyReasons.push(`${input.tool} at ${at}`);
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
- const isReviewing = REVIEW_AGENTS.has(state.currentAgent);
321
- if (input.tool === "bash") {
322
- const command = String(input?.args?.command || "");
323
- if (isVerification(command) && !isReviewing) {
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
- if (!looksLikeDestructiveBash(command) && looksLikeMutatingBash(command) && !isReviewing) {
328
- state.dirty = true;
329
- state.lastEditAt = at;
330
- state.dirtyReasons.push(`bash mutation fingerprint:${commandFingerprint(command)}`);
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
- if (input.tool === "task" && REVIEW_AGENTS.has(invokedReviewAgent)) {
335
- const out = textOf(output);
336
- const failFirst = isFail(out);
337
- const passAfter = isPass(out) && !failFirst;
338
- if (!failFirst && !passAfter) return;
339
- const verdict = passAfter ? "PASS" : "FAIL";
340
- recordReviewVerdict(state, invokedReviewAgent, verdict, at);
341
- recordedReviewAgent = invokedReviewAgent;
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
- if (agent && REVIEW_AGENTS.has(agent)) {
345
- const out = textOf(output);
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
- if (
357
- recordedReviewAgent === "goal-final-auditor" &&
358
- latestVerdictFor(state, recordedReviewAgent)?.verdict === "PASS" &&
359
- completionAllowed(state)
360
- ) {
361
- state.dirty = false;
362
- state.dirtyReasons = [];
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"(input, output) {
367
- const state = stateFor(input.sessionID);
368
- output.context.push(`Goal Guard state: ${summarizeState(state)}. Preserve Goal Contract, Verification Ledger, Review Ledger, review cycle count, dirty state, and open findings across compaction.`);
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 "experimental.text.complete"(input, output) {
372
- const state = stateFor(input.sessionID);
373
- const text = output.text || "";
374
- const claimsCompletion = /Goal Completed/i.test(text);
375
- const completedMatch = text.match(/Review cycles:\s*(\d+)/i);
376
- const claimedCycles = completedMatch ? parseInt(completedMatch[1], 10) : -1;
377
-
378
- if (!claimsCompletion) return;
379
-
380
- if (claimedCycles < 0) {
381
- state.completedBlocked += 1;
382
- output.text = text.replace(/Goal Completed/i, "Goal Not Completed");
383
- output.text += `\n\nGoal Guard blocked completion: missing required Review cycles line. State: ${summarizeState(state)}`;
384
- } else if (state.reviewCycles === 0) {
385
- state.completedBlocked += 1;
386
- output.text = text.replace(/Goal Completed/i, "Goal Not Completed");
387
- output.text += `\n\nGoal Guard blocked completion: no review cycles recorded. State: ${summarizeState(state)}`;
388
- } else if (claimedCycles !== state.reviewCycles) {
389
- state.completedBlocked += 1;
390
- output.text = text.replace(/Goal Completed/i, "Goal Not Completed");
391
- output.text += `\n\nGoal Guard blocked completion: claimed review cycles (${claimedCycles}) do not match recorded review cycles (${state.reviewCycles}). State: ${summarizeState(state)}`;
392
- } else if (!completionAllowed(state)) {
393
- state.completedBlocked += 1;
394
- output.text = text.replace(/Goal Completed/i, "Goal Not Completed");
395
- output.text += `\n\nGoal Guard blocked completion: required review gates are missing or stale (${missingGates(state).join(", ") || "goal session not active"}). State: ${summarizeState(state)}`;
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 event({ event }) {
400
- if (event?.type === "session.idle" && event?.properties?.sessionID) {
401
- const state = stateFor(event.properties.sessionID);
402
- if (state.dirty) {
403
- await client.app.log({
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
- stateFor,
421
- sessions,
306
+ resolveConfig,
307
+ analyzeCommand,
422
308
  looksLikeDestructiveBash,
423
309
  looksLikeMutatingBash,
424
310
  isVerification,
425
311
  summarizeState,
312
+ completionAllowed,
313
+ missingGates,
426
314
  };