opencode-workflow-guard 1.9.3 → 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 +1 -1
- package/src/lib/tool-outcomes.ts +4 -0
- package/src/policies/continuation.ts +8 -1
- package/src/workflow-guard.ts +22 -7
- package/test/test-e2e.mts +58 -3
- package/test/test.mts +15 -0
package/package.json
CHANGED
package/src/lib/tool-outcomes.ts
CHANGED
|
@@ -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:
|
|
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
|
},
|
package/src/workflow-guard.ts
CHANGED
|
@@ -414,7 +414,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
414
414
|
});
|
|
415
415
|
if (reason !== undefined) {
|
|
416
416
|
await emitBlockFeedback(reason);
|
|
417
|
-
|
|
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
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
|
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 =
|
|
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();
|