opencode-workflow-guard 1.9.0 → 1.9.1

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.
@@ -0,0 +1,84 @@
1
+ # Troubleshooting Guide
2
+
3
+ Common issues, root causes, and solutions when using `opencode-workflow-guard`.
4
+
5
+ ---
6
+
7
+ ### 1. `plugin config hook failed` / `Unexpected server error` on startup
8
+
9
+ - **Symptoms:** OpenCode crashes or reports `Unexpected server error` when starting a session, with logs showing `TypeError: undefined is not an object (evaluating '...event')`.
10
+ - **Root Cause:** OpenCode 1.18+'s legacy loader treats *every exported function* in a plugin module as a separate plugin instance. If a plugin exports helper functions (like `guardToolCall` or `setWorkspaceRoot`) alongside a default function, OpenCode calls them as plugins, pushes `undefined` into the hook registry, and crashes event dispatch on `session.created`.
11
+ - **Solution:** Ensure `src/workflow-guard.ts` uses the V1 `PluginModule` export format (`export default { id: "workflow-guard", server: WorkflowGuard } satisfies PluginModule`). File plugins require an explicit `id` string.
12
+
13
+ ---
14
+
15
+ ### 2. Edits Blocked: `Blocked: no active todo item`
16
+
17
+ - **Symptoms:** The agent attempts an edit (`edit`, `write`, `apply_patch`) and receives a block message directing it to create a task list.
18
+ - **Root Cause:** Policy 1 blocks file-editing tools until the session's native todo list (`GET /session/:id/todo`) contains at least one task with status `pending` or `in_progress`.
19
+ - **Solution:** The agent must call `todowrite` first. Once all tasks are marked `completed` or `cancelled`, edits will block again until a new breakdown is created for subsequent work.
20
+
21
+ ---
22
+
23
+ ### 3. Multiple tasks are `in_progress`
24
+
25
+ - **Symptoms:** A task list contains multiple `in_progress` items during parallel work.
26
+ - **Root Cause:** This is supported. Policy 1 permits multiple tasks to be `in_progress` so independent work and subagents can proceed concurrently.
27
+ - **Solution:** No correction is required. Keep each active item in every replacement list until it is explicitly marked `completed` or `cancelled`.
28
+
29
+ ---
30
+
31
+ ### 4. `Blocked todowrite: active task '...' was removed`
32
+
33
+ - **Symptoms:** `todowrite` is blocked after an active item disappears from the submitted list.
34
+ - **Root Cause:** Each `todowrite` call replaces the complete list. Policy 1 therefore requires active tasks to be explicitly completed or cancelled rather than silently omitted. Independent items may otherwise be completed out of order.
35
+ - **Solution:** Keep the task in the list and mark it `completed` or `cancelled` before removing it in a later request.
36
+
37
+ ---
38
+
39
+ ### 5. Edits Blocked: `Blocked: the workspace is on a protected branch (main/master)`
40
+
41
+ - **Symptoms:** Direct edits or git commit/merge commands are rejected.
42
+ - **Root Cause:** Policy 7 requires all code modifications to happen on a feature branch.
43
+ - **Solution:** Switch to a feature branch (`git switch -c feat/my-feature`) before making changes.
44
+
45
+ ---
46
+
47
+ ### 6. Edits Blocked: `Blocked: file path '...' escapes workspace root`
48
+
49
+ - **Symptoms:** Edits targeting files with `../` or external absolute paths are rejected.
50
+ - **Root Cause:** Policy 8 prevents directory traversal outside the workspace root.
51
+ - **Solution:** Ensure all target file paths resolve within the project directory.
52
+
53
+ ---
54
+
55
+ ### 7. Subagents Cannot Edit Files
56
+
57
+ - **Symptoms:** A subagent spawned via `task` fails to edit files.
58
+ - **Root Cause:** OpenCode disables `todowrite` for subagents by default.
59
+ - **Solution:** The guard automatically walks up the `parentID` session hierarchy so subagents inherit the parent session's active tasks. Ensure the parent session has active tasks before a subagent attempts guarded mutations.
60
+
61
+ ---
62
+
63
+ ### 8. Destructive Commands Blocked (`kubectl`, `terraform`, `psql`, etc.)
64
+
65
+ - **Symptoms:** A CLI command is rejected with a live-system mutation warning.
66
+ - **Root Cause:** Policy 4 blocks destructive cloud, database, and infrastructure operations.
67
+ - **Solution:** To intentionally run a live command, set `WORKFLOW_GUARD_ALLOW_LIVE=1` in the environment before launching OpenCode. There is no in-command override; an agent cannot grant this permission to itself.
68
+
69
+ ---
70
+
71
+ ### 9. TUI plugin is missing or remains on an older release
72
+
73
+ - **Symptoms:** Workflow Guard does not appear in the TUI, or npm reports a newer Workflow Guard release while OpenCode still runs an older one.
74
+ - **Root Cause:** OpenCode caches npm plugins. A bare package name is resolved as `@latest`, but an already-populated package cache can continue using the version that originally populated that cache key. Running `npm update` in `~/.config/opencode` does not update this OpenCode-managed plugin installation.
75
+ - **Solution:** Use OpenCode's documented plugin installer with the explicit version you want. `--force` replaces the configured plugin version, and the explicit version gives OpenCode a new package-cache key:
76
+
77
+ ```bash
78
+ VERSION=$(npm view opencode-workflow-guard version)
79
+ opencode plugin "opencode-workflow-guard@$VERSION" --global --force
80
+ ```
81
+
82
+ OpenCode documents `opencode plugin <module>` as installing a plugin and updating its config, with `--global` for global config and `--force` to replace an existing plugin version: https://opencode.ai/docs/cli/#plugin. Its plugin documentation also describes npm plugins as OpenCode-managed, cached installations: https://opencode.ai/docs/plugins/#how-plugins-are-installed.
83
+
84
+ After the command reports `Detected server + tui targets`, confirm it reports replacements/additions for both the OpenCode and TUI config files, then restart OpenCode. Manual cache deletion should not be necessary.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,7 +22,10 @@
22
22
  },
23
23
  "files": [
24
24
  "bin/",
25
+ "docs/",
25
26
  "src/",
27
+ "test/",
28
+ "tsconfig.json",
26
29
  "README.md",
27
30
  "LICENSE",
28
31
  "ACKNOWLEDGEMENTS.md"
@@ -0,0 +1,13 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const testHome = mkdtempSync(join(tmpdir(), "workflow-guard-test-home-"));
6
+ process.env.XDG_STATE_HOME = join(testHome, "state");
7
+ process.env.XDG_DATA_HOME = join(testHome, "data");
8
+
9
+ try {
10
+ await import("./test.mts");
11
+ } finally {
12
+ rmSync(testHome, { recursive: true, force: true });
13
+ }
@@ -0,0 +1,288 @@
1
+ import { mkdtempSync, rmSync, existsSync, readFileSync, copyFileSync, cpSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { parse as parseJsonc } from "jsonc-parser";
6
+
7
+ let pass = 0;
8
+ let fail = 0;
9
+ let unavailable = 0;
10
+ const check = (name: string, cond: unknown): void => {
11
+ cond ? (pass++, console.log(" ok " + name)) : (fail++, console.log("FAIL " + name));
12
+ };
13
+ const providerUnavailable = (output: string): boolean =>
14
+ /available credits|insufficient credits|rate limit|too many requests|capacity|overloaded/i.test(output);
15
+ const checkLive = (name: string, cond: unknown, output: string): boolean => {
16
+ if (!cond && providerUnavailable(output)) {
17
+ unavailable++;
18
+ console.log("SKIP: " + name + " (model provider unavailable before guard behavior could be exercised)");
19
+ return true;
20
+ }
21
+ check(name, cond);
22
+ return Boolean(cond);
23
+ };
24
+
25
+ console.log("- OpenCode Plugin Installation & Runtime Load Test -");
26
+
27
+ // 1. Set up a fresh isolated project with plugin installed. These package
28
+ // checks run even when the optional live OpenCode binary is unavailable.
29
+ const testDir = mkdtempSync(join(tmpdir(), "wg-install-test-"));
30
+
31
+ // Verify the publish artifact resolves its modular entrypoint, not only the
32
+ // checkout/local-copy layout used by the live plugin test below.
33
+ const packResult = spawnSync("npm", ["pack", "--pack-destination", testDir], {
34
+ cwd: join(import.meta.dirname, ".."),
35
+ encoding: "utf8",
36
+ });
37
+ const tarballName = packResult.stdout.trim().split("\n").at(-1) ?? "";
38
+ const tarballPath = join(testDir, tarballName);
39
+ const installResult = spawnSync("npm", ["install", "--ignore-scripts", tarballPath], {
40
+ cwd: testDir,
41
+ encoding: "utf8",
42
+ });
43
+ const packageEntry = join(testDir, "node_modules", "opencode-workflow-guard", "src", "workflow-guard.ts");
44
+ const installedPackageDir = join(testDir, "node_modules", "opencode-workflow-guard");
45
+ const installedPackageJson = JSON.parse(readFileSync(join(testDir, "node_modules", "opencode-workflow-guard", "package.json"), "utf8"));
46
+ check(
47
+ "npm package keeps direct dependencies on latest",
48
+ Object.keys(installedPackageJson.dependencies ?? {}).length > 0 &&
49
+ Object.values(installedPackageJson.dependencies).every((spec) => spec === "latest") &&
50
+ Object.keys(installedPackageJson.devDependencies ?? {}).length > 0 &&
51
+ Object.values(installedPackageJson.devDependencies).every((spec) => spec === "latest"),
52
+ );
53
+ check(
54
+ "npm tarball installs modular plugin entrypoint",
55
+ packResult.status === 0 && installResult.status === 0 && existsSync(packageEntry),
56
+ );
57
+ check("npm package exposes OpenCode server entrypoint", installedPackageJson.exports?.["./server"] === "./src/workflow-guard.ts");
58
+ check("npm package exposes OpenCode TUI entrypoint", installedPackageJson.exports?.["./tui"] === "./src/workflow-guard-ui.ts");
59
+ check("npm package does not expose ambiguous /ui entrypoint", installedPackageJson.exports?.["./ui"] === undefined);
60
+ check("npm package exposes setup CLI", installedPackageJson.bin?.["opencode-workflow-guard"] === "./bin/opencode-workflow-guard.mjs");
61
+ check(
62
+ "npm package includes documented development and test files",
63
+ existsSync(join(installedPackageDir, "test", "run-test.mjs")) &&
64
+ existsSync(join(installedPackageDir, "test", "test-e2e.mts")) &&
65
+ existsSync(join(installedPackageDir, "tsconfig.json")) &&
66
+ existsSync(join(installedPackageDir, "docs", "testing.md")),
67
+ );
68
+
69
+ const setupHome = join(testDir, "setup-home");
70
+ const setupConfigDir = join(setupHome, ".config", "opencode");
71
+ mkdirSync(setupConfigDir, { recursive: true });
72
+ writeFileSync(join(setupConfigDir, "opencode.jsonc"), `{
73
+ // Keep existing user settings intact.
74
+ "model": "test/provider",
75
+ "plugin": [
76
+ "existing-plugin", // Keep this plugin comment.
77
+ ],
78
+ }\n`);
79
+ writeFileSync(join(setupConfigDir, "tui.json"), `{ "plugin": ["opencode-workflow-guard/tui", ["opencode-workflow-guard/tui@1.5.0", { "legacy": true }]] }\n`);
80
+ const setupEnv = { ...process.env, HOME: setupHome, XDG_CONFIG_HOME: join(setupHome, ".config") };
81
+ const setupCli = join(testDir, "node_modules", ".bin", process.platform === "win32" ? "opencode-workflow-guard.cmd" : "opencode-workflow-guard");
82
+ const firstSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: setupEnv });
83
+ const secondSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: setupEnv });
84
+ const setupServerSource = readFileSync(join(setupConfigDir, "opencode.jsonc"), "utf8");
85
+ const setupServer = parseJsonc(setupServerSource);
86
+ const setupTui = JSON.parse(readFileSync(join(setupConfigDir, "tui.json"), "utf8"));
87
+ check("setup CLI succeeds and is idempotent", firstSetup.status === 0 && secondSetup.status === 0);
88
+ check("setup CLI registers cache-safe versioned server plugin once", setupServer.plugin?.filter((entry: unknown) => entry === `opencode-workflow-guard@${installedPackageJson.version}`).length === 1);
89
+ check("setup CLI registers cache-safe versioned TUI plugin once", setupTui.plugin?.filter((entry: unknown) => entry === `opencode-workflow-guard@${installedPackageJson.version}`).length === 1);
90
+ check("setup CLI replaces legacy TUI subpath specs", !setupTui.plugin?.some((entry: unknown) => (typeof entry === "string" && entry.startsWith("opencode-workflow-guard/tui")) || (Array.isArray(entry) && typeof entry[0] === "string" && entry[0].startsWith("opencode-workflow-guard/tui"))));
91
+ check("setup CLI preserves existing JSONC settings and comments", setupServer.model === "test/provider" && setupServer.plugin?.includes("existing-plugin") && setupServerSource.includes("Keep existing user settings intact.") && setupServerSource.includes("Keep this plugin comment."));
92
+
93
+ const pinnedHome = join(testDir, "pinned-setup-home");
94
+ const pinnedConfigDir = join(pinnedHome, ".config", "opencode");
95
+ mkdirSync(pinnedConfigDir, { recursive: true });
96
+ writeFileSync(join(pinnedConfigDir, "opencode.json"), `{ "plugin": ["opencode-workflow-guard@1.7.2"] }\n`);
97
+ writeFileSync(join(pinnedConfigDir, "tui.json"), `{ "plugin": [["opencode-workflow-guard@1.7.2", { "option": true }]] }\n`);
98
+ const pinnedSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: { ...process.env, HOME: pinnedHome, XDG_CONFIG_HOME: join(pinnedHome, ".config") } });
99
+ const pinnedServer = JSON.parse(readFileSync(join(pinnedConfigDir, "opencode.json"), "utf8"));
100
+ const pinnedTui = JSON.parse(readFileSync(join(pinnedConfigDir, "tui.json"), "utf8"));
101
+ check("setup CLI recognizes documented version-pinned plugin specs", pinnedSetup.status === 0 && pinnedServer.plugin?.length === 1 && pinnedServer.plugin[0] === "opencode-workflow-guard@1.7.2" && pinnedTui.plugin?.length === 1 && pinnedTui.plugin[0]?.[0] === "opencode-workflow-guard@1.7.2");
102
+
103
+ const invalidHome = join(testDir, "invalid-setup-home");
104
+ const invalidConfigDir = join(invalidHome, ".config", "opencode");
105
+ mkdirSync(invalidConfigDir, { recursive: true });
106
+ const invalidServerPath = join(invalidConfigDir, "opencode.json");
107
+ writeFileSync(invalidServerPath, `{ "plugin": ["existing-plugin"] }\n`);
108
+ writeFileSync(join(invalidConfigDir, "tui.jsonc"), `{ "plugin": [\n`);
109
+ const invalidServerBefore = readFileSync(invalidServerPath, "utf8");
110
+ const invalidSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: { ...process.env, HOME: invalidHome, XDG_CONFIG_HOME: join(invalidHome, ".config") } });
111
+ check("setup CLI validates both configs before writing either", invalidSetup.status !== 0 && readFileSync(invalidServerPath, "utf8") === invalidServerBefore);
112
+
113
+ const ambiguousHome = join(testDir, "ambiguous-setup-home");
114
+ const ambiguousConfigDir = join(ambiguousHome, ".config", "opencode");
115
+ mkdirSync(ambiguousConfigDir, { recursive: true });
116
+ writeFileSync(join(ambiguousConfigDir, "opencode.json"), `{ "plugin": [] }\n`);
117
+ writeFileSync(join(ambiguousConfigDir, "opencode.jsonc"), `{ "plugin": [] }\n`);
118
+ const ambiguousSetup = spawnSync(setupCli, ["setup"], { encoding: "utf8", env: { ...process.env, HOME: ambiguousHome, XDG_CONFIG_HOME: join(ambiguousHome, ".config") } });
119
+ check("setup CLI refuses ambiguous json/jsonc configs", ambiguousSetup.status !== 0 && ambiguousSetup.stderr.includes("Cannot choose between"));
120
+
121
+ // Verify the export map resolves both entrypoints. OpenCode loads the package's
122
+ // raw TypeScript entrypoints with its own module loader at runtime, while plain
123
+ // Node will not type-strip .ts files under node_modules; resolution (not direct
124
+ // import) is therefore the correct invariant to assert here.
125
+ const serverImport = spawnSync("node", ["--input-type=module", "-e", `
126
+ const serverUrl = import.meta.resolve('opencode-workflow-guard/server', 'file://' + process.cwd() + '/dummy.js');
127
+ const tuiUrl = import.meta.resolve('opencode-workflow-guard/tui', 'file://' + process.cwd() + '/dummy.js');
128
+ if (!serverUrl.endsWith('/src/workflow-guard.ts') || !tuiUrl.endsWith('/src/workflow-guard-ui.ts')) process.exit(2);
129
+ `], {
130
+ cwd: testDir,
131
+ encoding: "utf8",
132
+ });
133
+ check("packed server and TUI entrypoints resolve correctly", serverImport.status === 0);
134
+ check("server and TUI package specs resolve to different modules", installedPackageJson.exports?.["."] !== installedPackageJson.exports?.["./tui"]);
135
+
136
+ // 2. Verify opencode binary is available for live runtime tests.
137
+ const opencodeCheck = spawnSync("opencode", ["--version"], { encoding: "utf8" });
138
+ if (opencodeCheck.status !== 0) {
139
+ console.log("SKIP: opencode CLI is not available in PATH. Package checks passed; skipping live runtime load tests.");
140
+ rmSync(testDir, { recursive: true, force: true });
141
+ console.log(`\n${pass} passed, ${fail} failed`);
142
+ process.exit(fail > 0 ? 1 : 0);
143
+ }
144
+ console.log(` Found OpenCode version: ${opencodeCheck.stdout.trim()}`);
145
+
146
+ const pluginsDir = join(testDir, ".opencode", "plugins");
147
+ mkdirSync(pluginsDir, { recursive: true });
148
+
149
+ const sourcePlugin = join(import.meta.dirname, "..", "src", "workflow-guard.ts");
150
+ const sourceDir = join(testDir, ".opencode", "workflow-guard-source");
151
+ mkdirSync(sourceDir, { recursive: true });
152
+ const targetPlugin = join(sourceDir, "workflow-guard.ts");
153
+ copyFileSync(sourcePlugin, targetPlugin);
154
+ cpSync(join(import.meta.dirname, "..", "src", "lib"), join(sourceDir, "lib"), { recursive: true });
155
+ cpSync(join(import.meta.dirname, "..", "src", "policies"), join(sourceDir, "policies"), { recursive: true });
156
+ const localAdapter = join(pluginsDir, "workflow-guard.ts");
157
+ const importedMarker = join(testDir, ".workflow-guard-imported");
158
+ const initializedMarker = join(testDir, ".workflow-guard-initialized");
159
+ const accountabilityMarker = join(testDir, ".workflow-guard-accountability");
160
+ writeFileSync(localAdapter, `import { writeFileSync } from "node:fs";
161
+
162
+ writeFileSync(${JSON.stringify(importedMarker)}, "imported\\n");
163
+
164
+ export const WorkflowGuardE2E = async (ctx: any) => {
165
+ \tconst { WorkflowGuard } = await import("../workflow-guard-source/workflow-guard.ts");
166
+ \tconst hooks = await WorkflowGuard(ctx);
167
+ \tconst status = await hooks.tool?.guard_status?.execute({}, { sessionID: "headless-e2e", directory: ctx.directory, worktree: ctx.worktree });
168
+ \tconst why = await hooks.tool?.guard_why?.execute({ tool: "bash", input: { command: "git push origin main" } }, { sessionID: "headless-e2e", directory: ctx.directory, worktree: ctx.worktree });
169
+ \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: JSON.parse(String(why)) }) + "\\n");
170
+ \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
171
+ \treturn hooks;
172
+ };
173
+
174
+ export default { id: "workflow-guard-e2e", server: WorkflowGuardE2E };
175
+ `);
176
+ writeFileSync(join(testDir, ".opencode", "opencode.json"), `${JSON.stringify({ plugin: ["./plugins/workflow-guard.ts"] }, null, 2)}\n`);
177
+ check("local plugin adapter and source copied successfully", existsSync(localAdapter) && existsSync(targetPlugin));
178
+
179
+ // Initialize git repository on a feature branch
180
+ spawnSync("git", ["init", "-b", "feat/install-verification"], { cwd: testDir });
181
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: testDir });
182
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: testDir });
183
+
184
+ const runtimeEnv: NodeJS.ProcessEnv = {
185
+ ...process.env,
186
+ XDG_CONFIG_HOME: join(testDir, "runtime-config"),
187
+ XDG_STATE_HOME: join(testDir, "runtime-state"),
188
+ XDG_DATA_HOME: join(testDir, "runtime-data"),
189
+ };
190
+ delete runtimeEnv.OPENCODE_PID;
191
+ delete runtimeEnv.OPENCODE_PURE;
192
+ const runOpenCode = (args: string[], timeout: number) =>
193
+ 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 });
195
+ let configLoadsLocalPlugin = false;
196
+ try {
197
+ const config = JSON.parse(configProbe.stdout);
198
+ configLoadsLocalPlugin = config.plugin_origins?.some(
199
+ (origin: { spec?: unknown; source?: unknown; scope?: unknown }) =>
200
+ typeof origin.spec === "string" &&
201
+ origin.spec.endsWith("/.opencode/plugins/workflow-guard.ts") &&
202
+ origin.scope === "local",
203
+ );
204
+ } catch {}
205
+ check("OpenCode resolves isolated local plugin config without a model provider", configProbe.status === 0 && configLoadsLocalPlugin);
206
+ let headlessAccountability: any;
207
+ try {
208
+ headlessAccountability = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
209
+ } catch {}
210
+ check("headless OpenCode runtime exposes structured guard status and why without TUI", headlessAccountability?.status?.workspaceRoot === testDir && headlessAccountability?.why?.policy === "git" && headlessAccountability?.why?.code === "protected_branch_push");
211
+
212
+ if (process.env.WORKFLOW_GUARD_LIVE_E2E !== "1") {
213
+ console.log("SKIP: model-driven policy probes require WORKFLOW_GUARD_LIVE_E2E=1.");
214
+ rmSync(testDir, { recursive: true, force: true });
215
+ console.log(`\n${pass} passed, ${fail} failed`);
216
+ process.exit(fail > 0 ? 1 : 0);
217
+ }
218
+
219
+ const recentModelPath = join(
220
+ process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"),
221
+ "opencode",
222
+ "model.json",
223
+ );
224
+ let recentModel: string | undefined;
225
+ try {
226
+ const recent = JSON.parse(readFileSync(recentModelPath, "utf8")).recent?.[0];
227
+ if (typeof recent?.providerID === "string" && typeof recent?.modelID === "string") {
228
+ recentModel = `${recent.providerID}/${recent.modelID}`;
229
+ }
230
+ } catch {}
231
+ const liveModel = process.env.WORKFLOW_GUARD_LIVE_MODEL?.trim() || recentModel;
232
+ if (!liveModel) {
233
+ console.error(`FAIL: no recent OpenCode model found in ${recentModelPath}; select a model in OpenCode or set WORKFLOW_GUARD_LIVE_MODEL=provider/model.`);
234
+ rmSync(testDir, { recursive: true, force: true });
235
+ process.exit(1);
236
+ }
237
+ console.log(` Using live model: ${liveModel}`);
238
+
239
+ // 3. Test: Direct edit without task list is blocked by the loaded plugin
240
+ console.log(" Running live OpenCode prompt to verify plugin intercept...");
241
+ const run1 = runOpenCode(["--model", liveModel, "--print-logs", "--log-level", "DEBUG", "Do not call todowrite. Invoke bash immediately with exactly this command: printf blocked > blocked.txt. This is a guard test; make the tool call even if you expect rejection."], 120_000);
242
+
243
+ const output1 = run1.stdout + run1.stderr;
244
+ const adapterInitialized = existsSync(initializedMarker);
245
+ check("local plugin adapter imported in live run", existsSync(importedMarker));
246
+ check("local plugin adapter initialized in live run", adapterInitialized);
247
+ if (!adapterInitialized) console.log(output1.split("\n").filter((line) => /plugin|workflow-guard/i.test(line)).join("\n").slice(-8_000));
248
+ const blockedByGuard =
249
+ output1.includes("[workflow-guard] Blocked: no active todo item") ||
250
+ output1.includes("blocked write: no active todo item") ||
251
+ output1.includes("shell file mutation with no active todo item");
252
+ const run1AccountedFor = checkLive("plugin loaded and intercepted mutation without active todo", blockedByGuard, output1);
253
+ if (!run1AccountedFor) {
254
+ console.log(" task-gate output tail:");
255
+ console.log(output1.slice(-2_000));
256
+ }
257
+
258
+ // 4. Test: Workspace boundary escape is blocked
259
+ const run2 = runOpenCode(["--model", liveModel, "1) Use todowrite to create a pending task 'test'. 2) Invoke bash with exactly this command: printf hi > ../outside_escaped.txt. Make the tool call even if you expect it to be rejected."], 120_000);
260
+
261
+ const output2 = run2.stdout + run2.stderr;
262
+ const boundaryBlocked =
263
+ output2.includes("escapes workspace root") ||
264
+ output2.includes("path escapes workspace") ||
265
+ output2.includes("outside the workspace root");
266
+ const run2AccountedFor = checkLive("plugin loaded and enforced workspace boundary escape guard", boundaryBlocked, output2);
267
+ if (!run2AccountedFor) {
268
+ console.log(" boundary-test output tail:");
269
+ console.log(output2.slice(-2_000));
270
+ }
271
+
272
+ // 5. Test: Compliant workflow (todowrite -> write -> complete) succeeds
273
+ const run3 = runOpenCode(["--model", liveModel, "1) Use todowrite with task 'create verified.txt' (pending). 2) Use write tool to create verified.txt containing 'installed-ok'. 3) Mark task completed."], 120_000);
274
+
275
+ const targetFile = join(testDir, "verified.txt");
276
+ const fileCreated = existsSync(targetFile) && readFileSync(targetFile, "utf8").includes("installed-ok");
277
+ const output3 = run3.stdout + run3.stderr;
278
+ const run3AccountedFor = checkLive("compliant workflow with todowrite succeeded through plugin", fileCreated, output3);
279
+ if (!run3AccountedFor) {
280
+ console.log(" compliant-workflow output tail:");
281
+ console.log(output3.slice(-2_000));
282
+ }
283
+
284
+ // Clean up
285
+ rmSync(testDir, { recursive: true, force: true });
286
+
287
+ console.log(`\n${pass} passed, ${fail} failed, ${unavailable} live unavailable`);
288
+ process.exit(fail ? 1 : 0);