opencode-workflow-guard 1.9.2 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.9.2",
3
+ "version": "1.10.0",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -74,6 +74,10 @@ export class ToolOutcomeTracker {
74
74
  return { sessionID, callID, tool, status: "completed", durationMs };
75
75
  }
76
76
 
77
+ getFailureCount(sessionID: string): number {
78
+ return this.failures.get(sessionID)?.count ?? 0;
79
+ }
80
+
77
81
  clearSession(sessionID: string): void {
78
82
  this.failures.delete(sessionID);
79
83
  this.terminalCalls.delete(sessionID);
@@ -83,6 +83,13 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
83
83
  }
84
84
  generatedIDs.add(messageID);
85
85
  state.generatedMessageIDs.set(sessionID, generatedIDs);
86
+ const activeTasks = (effective.todos ?? [])
87
+ .filter((t) => t.status === "in_progress" || t.status === "pending")
88
+ .slice(0, 10);
89
+ const tasksSummary = activeTasks.length > 0
90
+ ? "\nRemaining active tasks:\n" + activeTasks.map((t) => `- [${t.status === "in_progress" ? "IN_PROGRESS" : "PENDING"}] ${t.content}`).join("\n")
91
+ : "";
92
+
86
93
  try {
87
94
  await session.promptAsync({
88
95
  path: { id: sessionID },
@@ -90,7 +97,7 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
90
97
  messageID,
91
98
  parts: [{
92
99
  type: "text",
93
- text: "Workflow Guard: unfinished todos remain. Continue working through them. If you need user input, use the question tool instead of ending the run.",
100
+ text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run.`,
94
101
  synthetic: true,
95
102
  }],
96
103
  },
@@ -414,7 +414,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
414
414
  });
415
415
  if (reason !== undefined) {
416
416
  await emitBlockFeedback(reason);
417
- throw new Error(`[workflow-guard] ${reason}`);
417
+ const failureCount = input.sessionID ? toolOutcomes.getFailureCount(input.sessionID) : 0;
418
+ const circuitBreakerSuffix = failureCount >= 2
419
+ ? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly.]"
420
+ : "";
421
+ throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
418
422
  }
419
423
  toolLifecycle.start(input.sessionID, input.callID);
420
424
  if (input.tool === "read") {
@@ -606,13 +610,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
606
610
 
607
611
  // Keep tool descriptions honest: todowrite's description reflects lifecycle
608
612
  // and finalization gates so the model is not surprised by preventable blocks.
613
+ // Similarly, mutating tools reflect their active-todo, feature-branch, and prior-read requirements.
609
614
  "tool.definition": async (input, output) => {
610
- if (input.toolID !== "todowrite") return;
611
- const description = typeof output.description === "string" ? output.description : "";
612
- if (description.includes("verification evidence")) return;
613
- output.description =
614
- description +
615
- "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
615
+ if (input.toolID === "todowrite") {
616
+ const description = typeof output.description === "string" ? output.description : "";
617
+ if (description.includes("verification evidence")) return;
618
+ output.description =
619
+ description +
620
+ "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
621
+ return;
622
+ }
623
+ if (EDIT_TOOL_NAMES.has(input.toolID)) {
624
+ const description = typeof output.description === "string" ? output.description : "";
625
+ if (description.includes("Workflow Guard requirement")) return;
626
+ output.description =
627
+ description +
628
+ "\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
629
+ return;
630
+ }
616
631
  },
617
632
 
618
633
  "chat.message": async (input) => {
package/test/test-e2e.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdtempSync, rmSync, existsSync, readFileSync, copyFileSync, cpSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { parse as parseJsonc } from "jsonc-parser";
@@ -143,9 +143,30 @@ if (opencodeCheck.status !== 0) {
143
143
  }
144
144
  console.log(` Found OpenCode version: ${opencodeCheck.stdout.trim()}`);
145
145
 
146
- const pluginsDir = join(testDir, ".opencode", "plugins");
146
+ const opencodeDir = join(testDir, ".opencode");
147
+ const pluginsDir = join(opencodeDir, "plugins");
147
148
  mkdirSync(pluginsDir, { recursive: true });
148
149
 
150
+ // Pre-seed .opencode with dependencies already installed into testDir
151
+ // so opencode does not execute a cold network install at runtime startup.
152
+ const opencodeVersion = opencodeCheck.stdout.trim();
153
+ const lockPath = join(testDir, "package-lock.json");
154
+ if (existsSync(lockPath) && existsSync(join(testDir, "node_modules"))) {
155
+ try {
156
+ const lock = JSON.parse(readFileSync(lockPath, "utf8"));
157
+ lock.name = ".opencode";
158
+ if (lock.packages?.["node_modules/@opencode-ai/plugin"]) {
159
+ lock.packages["node_modules/@opencode-ai/plugin"].version = opencodeVersion;
160
+ }
161
+ if (lock.packages?.[""]) {
162
+ lock.packages[""].dependencies = { "@opencode-ai/plugin": opencodeVersion };
163
+ }
164
+ writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
165
+ writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies: { "@opencode-ai/plugin": opencodeVersion } }, null, 2) + "\n");
166
+ cpSync(join(testDir, "node_modules"), join(opencodeDir, "node_modules"), { recursive: true });
167
+ } catch {}
168
+ }
169
+
149
170
  const sourcePlugin = join(import.meta.dirname, "..", "src", "workflow-guard.ts");
150
171
  const sourceDir = join(testDir, ".opencode", "workflow-guard-source");
151
172
  mkdirSync(sourceDir, { recursive: true });
@@ -189,9 +210,43 @@ const runtimeEnv: NodeJS.ProcessEnv = {
189
210
  };
190
211
  delete runtimeEnv.OPENCODE_PID;
191
212
  delete runtimeEnv.OPENCODE_PURE;
213
+ delete runtimeEnv.OPENCODE;
192
214
  const runOpenCode = (args: string[], timeout: number) =>
193
215
  spawnSync("opencode", ["run", "--dir", testDir, ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
194
- const configProbe = spawnSync("opencode", ["debug", "config"], { cwd: testDir, encoding: "utf8", timeout: 30_000, env: runtimeEnv });
216
+ const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
217
+ const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: runtimeEnv });
218
+ let stdout = "";
219
+ let stderr = "";
220
+ let timer: NodeJS.Timeout | undefined;
221
+ let pollInterval: NodeJS.Timeout | undefined;
222
+
223
+ const done = (status: number | null) => {
224
+ if (timer) clearTimeout(timer);
225
+ if (pollInterval) clearInterval(pollInterval);
226
+ try { child.kill("SIGTERM"); } catch {}
227
+ resolve({ status: status ?? 0, stdout, stderr });
228
+ };
229
+
230
+ const checkReady = () => {
231
+ try {
232
+ if (JSON.parse(stdout)?.plugin_origins && existsSync(initializedMarker)) {
233
+ done(0);
234
+ }
235
+ } catch {}
236
+ };
237
+
238
+ pollInterval = setInterval(checkReady, 50);
239
+
240
+ child.stdout?.on("data", (d) => {
241
+ stdout += d.toString();
242
+ checkReady();
243
+ });
244
+ child.stderr?.on("data", (d) => { stderr += d.toString(); });
245
+ child.on("close", (code) => done(code));
246
+ child.on("error", () => done(null));
247
+
248
+ timer = setTimeout(() => done(null), 30_000);
249
+ });
195
250
  let configLoadsLocalPlugin = false;
196
251
  try {
197
252
  const config = JSON.parse(configProbe.stdout);
package/test/test.mts CHANGED
@@ -1096,6 +1096,14 @@ if (typeof cmdEvt.event === "function") {
1096
1096
  }
1097
1097
  check("tool.execute.after provides deduplicated fallback outcome telemetry", getRecentAuditEntries(10).filter((entry) => entry.callID === "after-only-outcome" && entry.phase === "outcome").length === 1);
1098
1098
 
1099
+ let circuitBreakerMessage = "";
1100
+ try {
1101
+ await cmdEvt["tool.execute.before"]?.({ tool: "edit", sessionID: "s-outcome", callID: "call-cb" } as any, { args: { filePath: join(root, "file.txt"), content: "hello" } } as any);
1102
+ } catch (err) {
1103
+ circuitBreakerMessage = (err as Error).message;
1104
+ }
1105
+ check("repeated failures trigger circuit breaker guidance in block error", circuitBreakerMessage.includes("Workflow Guard Circuit Breaker") && circuitBreakerMessage.includes("Repeated failures detected"));
1106
+
1099
1107
  const outcomeTracker = new ToolOutcomeTracker();
1100
1108
  const failedPart = (callID: string, error: string) => ({ type: "tool", sessionID: "s-tracker", callID, tool: "bash", state: { status: "error", error } } as const);
1101
1109
  check("duplicate terminal tool updates are ignored", !!outcomeTracker.record(failedPart("duplicate", "same failure")) && outcomeTracker.record(failedPart("duplicate", "same failure")) === undefined);
@@ -3141,6 +3149,13 @@ const idemDef = { description: todoDef.description, parameters: {} };
3141
3149
  await defPlugin["tool.definition"]?.({ toolID: "todowrite" } as any, idemDef);
3142
3150
  check("tool.definition enrichment is idempotent", idemDef.description === todoDef.description);
3143
3151
 
3152
+ const editDef = { description: "Edit a file.", parameters: {} };
3153
+ await defPlugin["tool.definition"]?.({ toolID: "edit" } as any, editDef);
3154
+ check("tool.definition enriches edit tool with preconditions", editDef.description.includes("Workflow Guard requirement") && editDef.description.includes("todowrite"));
3155
+ const editIdemDef = { description: editDef.description, parameters: {} };
3156
+ await defPlugin["tool.definition"]?.({ toolID: "edit" } as any, editIdemDef);
3157
+ check("tool.definition edit enrichment is idempotent", editIdemDef.description === editDef.description);
3158
+
3144
3159
  // Socratic learning engine: evidence is explicit and interventions favor
3145
3160
  // relevant gaps without repeatedly interrupting demonstrated knowledge.
3146
3161
  const learner = createLearnerProfile();