opencode-workflow-guard 1.12.1 → 1.13.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.
@@ -14,6 +14,7 @@ type ContinuationState = {
14
14
  generatedMessageIDs: Map<string, Set<string>>;
15
15
  lastUserMessageIDs: Map<string, string>;
16
16
  inFlight: Set<string>;
17
+ interrupted: Set<string>;
17
18
  };
18
19
  export type RalphOutcome = "running" | "completed" | "blocked" | "budget_exhausted" | "user_stopped";
19
20
  const states = new WeakMap<object, Map<string, ContinuationState>>();
@@ -43,7 +44,7 @@ function getState(): ContinuationState | undefined {
43
44
  const root = projectRootKey(getWorkspaceRoot());
44
45
  let state = clientStates.get(root);
45
46
  if (!state) {
46
- state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set() };
47
+ state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set(), interrupted: new Set() };
47
48
  clientStates.set(root, state);
48
49
  }
49
50
  return state;
@@ -56,6 +57,9 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
56
57
  try {
57
58
  const ralph = isRalphModeEnabled(getWorkspaceRoot());
58
59
  if (ralph && state.ralphOutcomes.get(sessionID) === "user_stopped") return false;
60
+ // A user-initiated interrupt (Esc) suspends automatic continuation:
61
+ // only genuine user input re-enables it (see recordUserMessage).
62
+ if (state.interrupted.has(sessionID)) return false;
59
63
  const effective = await effectiveTodosWithOwner(sessionID);
60
64
  if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos)) {
61
65
  if (ralph && state.ralphOutcomes.has(sessionID)) state.ralphOutcomes.set(sessionID, "completed");
@@ -122,12 +126,26 @@ export function recordUserMessage(sessionID: string, messageID?: string): void {
122
126
  if (messageID && state.lastUserMessageIDs.get(sessionID) === messageID) return;
123
127
  if (messageID) state.lastUserMessageIDs.set(sessionID, messageID);
124
128
  state.counts.delete(sessionID);
129
+ state.interrupted.delete(sessionID);
125
130
  if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
126
131
  if (state.ralphOutcomes.get(sessionID) === "user_stopped") state.ralphOutcomes.delete(sessionID);
127
132
  else state.ralphOutcomes.set(sessionID, "user_stopped");
128
133
  }
129
134
  }
130
135
 
136
+ // Record a user-initiated interrupt (Esc): the session's run was aborted
137
+ // from the user side, so automatic continuation must stop. Ralph runs are
138
+ // marked user_stopped, mirroring genuine user input. A later genuine user
139
+ // message re-enables continuation (recordUserMessage clears the flag).
140
+ export function markInterrupted(sessionID: string): void {
141
+ const state = getState();
142
+ if (!state) return;
143
+ state.interrupted.add(sessionID);
144
+ if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
145
+ state.ralphOutcomes.set(sessionID, "user_stopped");
146
+ }
147
+ }
148
+
131
149
  export function getRalphOutcome(sessionID: string): RalphOutcome | undefined {
132
150
  return getState()?.ralphOutcomes.get(sessionID);
133
151
  }
@@ -145,4 +163,5 @@ export function clearContinuationState(sessionID: string): void {
145
163
  state.generatedMessageIDs.delete(sessionID);
146
164
  state.lastUserMessageIDs.delete(sessionID);
147
165
  state.inFlight.delete(sessionID);
166
+ state.interrupted.delete(sessionID);
148
167
  }
@@ -123,7 +123,7 @@ export function normalizeGitCommands(command: string): string {
123
123
  }
124
124
 
125
125
  export const GIT_WRITE_RE =
126
- /\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
126
+ /\bgit\s+(add|rm|mv|commit|merge|rebase|cherry-pick|revert|stash\s+pop|apply|am|restore|reset|update-ref|filter-branch)\b|\bgit\s+tag\s+(?!--?list\b|-l\b)(?:[^|;&]*\s)?(?:-d\b|--delete\b)|\bgit\s+checkout\s+(?!-b\b)|\bgit\s+branch\s+(?:[^|;&]*\s)?-[dDM]\b/;
127
127
 
128
128
  export function currentGitBranch(root: string): string | undefined {
129
129
  const result = spawnSync("git", ["branch", "--show-current"], {
@@ -179,6 +179,47 @@ export function pushedProtectedBranchIn(pushCommand: string, root: string): stri
179
179
  return undefined;
180
180
  }
181
181
 
182
+ /**
183
+ * True when a `git push` command pushes a tag refspec: an explicit
184
+ * `refs/tags/<name>` destination, or a short name that resolves to an
185
+ * existing tag in the repository at `root`. Publishing a tag is a release
186
+ * operation, not a branch mutation, so callers exempt it from the
187
+ * protected-branch and merged-branch push rules (#134). Deletion refspecs
188
+ * (`:refs/tags/<name>`, `git push --delete`, `-d`) return false so tag
189
+ * deletions stay blocked.
190
+ */
191
+ export function tagRefspecIn(pushCommand: string, root: string): boolean {
192
+ if (/\s--delete\b|\s-d(?:\b|$)/.test(pushCommand)) return false;
193
+ for (const segment of splitShellSegments(pushCommand)) {
194
+ const parsed = parseGitInvocation(segment);
195
+ if (!parsed || !/\bpush\b/.test(parsed.rest)) continue;
196
+ const tokens = parsed.rest.split(/\s+/).slice(1);
197
+ let remoteSeen = false;
198
+ for (const rawToken of tokens) {
199
+ if (rawToken.startsWith("-")) continue;
200
+ if (!remoteSeen) {
201
+ remoteSeen = true;
202
+ continue;
203
+ }
204
+ const token = rawToken.replace(/^\+/, "");
205
+ if (!token) continue;
206
+ if (token.startsWith(":")) continue; // deletion refspec: empty source
207
+ if (token.includes(":")) {
208
+ if (token.includes(":refs/tags/")) return true; // <src>:refs/tags/<name>
209
+ continue;
210
+ }
211
+ if (token.startsWith("refs/tags/")) return true; // explicit tag refspec
212
+ const probe = spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/tags/${token}`], {
213
+ cwd: root,
214
+ encoding: "utf8",
215
+ timeout: 10_000,
216
+ });
217
+ if (probe.status === 0) return true;
218
+ }
219
+ }
220
+ return false;
221
+ }
222
+
182
223
  export function onProtectedBranch(root: string): boolean {
183
224
  return isProtectedBranchName(currentGitBranch(root) ?? "", root);
184
225
  }
@@ -36,11 +36,23 @@ export function normalizeGlobPathEvasion(text: string): string {
36
36
  .replace(/opencode\.[?*]/gi, ocJson);
37
37
  }
38
38
 
39
+ // Collaboration invocations (hosted-git/PR/issue CLIs) never write local
40
+ // configuration: their arguments may legitimately mention guarded paths.
41
+ const COLLABORATION_INVOCATION_PATTERNS: RegExp[] = [
42
+ /^\s*(?:gh|glab)\s+(?:issue|pr)\b/,
43
+ /^\s*az\s+repos\s+pr\b/,
44
+ ];
45
+
46
+ export function isCollaborationInvocation(segment: string): boolean {
47
+ return COLLABORATION_INVOCATION_PATTERNS.some((re) => re.test(segment));
48
+ }
49
+
39
50
  export function isSettingsTamper(command: string): boolean {
40
51
  const segments = command.split(/[\n|;&]+/).map((s) =>
41
52
  normalizeGlobPathEvasion(normalizeShellEvasion(s)),
42
53
  );
43
54
  return segments.some((segment) =>
55
+ !isCollaborationInvocation(segment) &&
44
56
  SETTINGS_TAMPER_PATTERNS.some((re) => re.test(segment)),
45
57
  );
46
58
  }
@@ -53,6 +65,11 @@ export function isProtectedPath(targetPath: string): boolean {
53
65
  const base = basename(path);
54
66
  const lower = path.toLowerCase();
55
67
  const dotOc = "." + "opencode/";
68
+ // opencode plan mode writes agent plan markdown under the project's
69
+ // .opencode/plans/ directory - plan files are documents, not
70
+ // configuration. The trailing slash keeps the plans directory
71
+ // itself protected.
72
+ if (lower.includes("/" + dotOc + "plans/")) return false;
56
73
  const cfgOc = "/.config/" + "opencode/";
57
74
  const cfgOcJson = "/.config/" + "opencode.json";
58
75
  return (
@@ -1,4 +1,14 @@
1
+ /**
2
+ * Workflow Guard TUI companion plugin for OpenCode V2 (CLI plugin API).
3
+ *
4
+ * - Registers the `/guard-options` palette + slash command to toggle project options.
5
+ * - Renders the Workflow Guard badge in the home and prompt footer status slots.
6
+ *
7
+ * The V1 entrypoint (`WorkflowGuardTui`) remains for OpenCode 1.x TUI clients.
8
+ */
9
+
1
10
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
11
+ import { Plugin } from "@opencode/plugin/tui";
2
12
  import type { JSX } from "@opentui/solid";
3
13
  import { createElement, insert, setProp } from "@opentui/solid";
4
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -75,6 +85,79 @@ export function formatBadge(): { text: string; isBlocked: boolean } {
75
85
  return { text: BADGE_ACTIVE, isBlocked: false };
76
86
  }
77
87
 
88
+ // ── V2 CLI plugin ─────────────────────────────────────────────────────────────
89
+
90
+ type TuiContext = Plugin.Context;
91
+
92
+ const TOGGLE_OPTIONS: Array<{ key: ProjectToggle; label: string; description: string }> = [
93
+ { key: "recoveryCheckpoints", label: "Recovery checkpoints", description: "Toggle durable pre-run Git checkpoints" },
94
+ { key: "projectMemory", label: "Project memory", description: "Toggle durable local project memory" },
95
+ { key: "learning", label: "Learner mode", description: "Toggle evidence-based learning tools" },
96
+ { key: "titleSettleWorkaround", label: "Title settle workaround", description: "Delay automatic continuation while OpenCode generates a session title" },
97
+ { key: "ralphMode", label: "Ralph mode", description: "Opt into bounded autonomous continuation of already-owned todos" },
98
+ ];
99
+
100
+ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
101
+ const badge = () => {
102
+ const formatted = formatBadge();
103
+ return text({ fg: ctx.theme.text.feedback.success.base }, [formatted.text]);
104
+ };
105
+
106
+ const optionsRoot = () => ctx.location?.directory || process.cwd();
107
+
108
+ ctx.keymap.layer(() => ({
109
+ commands: [{
110
+ id: "workflow-guard.project-options",
111
+ title: "Workflow Guard: Project Options",
112
+ description: "Toggle Workflow Guard project options (recovery checkpoints, project memory, learning, title settle, ralph mode)",
113
+ group: "Workflow Guard",
114
+ palette: true,
115
+ slash: { name: "guard-options" },
116
+ async run() {
117
+ const root = optionsRoot();
118
+ for (;;) {
119
+ const current = new Map(TOGGLE_OPTIONS.map((option) => [option.key, readProjectOption(root, option.key)]));
120
+ const choice = await ctx.ui.dialog.select<ProjectToggle>({
121
+ title: "Workflow Guard Project Options",
122
+ options: TOGGLE_OPTIONS.map((option) => ({
123
+ title: `${option.label}: ${current.get(option.key) ? "On" : "Off"}`,
124
+ value: option.key,
125
+ description: option.description,
126
+ })),
127
+ });
128
+ if (!choice) break;
129
+ try {
130
+ const enabled = !readProjectOption(root, choice);
131
+ const path = writeProjectOption(root, choice, enabled);
132
+ ctx.ui.toast.show({
133
+ variant: "success",
134
+ title: "Workflow Guard",
135
+ message: `Saved ${choice} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.`,
136
+ });
137
+ } catch (error) {
138
+ ctx.ui.toast.show({
139
+ variant: "error",
140
+ title: "Workflow Guard",
141
+ message: error instanceof Error ? error.message : String(error),
142
+ });
143
+ break;
144
+ }
145
+ }
146
+ },
147
+ }],
148
+ bindings: [],
149
+ }));
150
+
151
+ ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
152
+ ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
153
+
154
+ return () => {
155
+ // Slot claims and keymap layers are disposed automatically on unload.
156
+ };
157
+ };
158
+
159
+ // ── V1 TUI plugin ─────────────────────────────────────────────────────────────
160
+
78
161
  export const WorkflowGuardTui: TuiPlugin = async (api) => {
79
162
  api.keymap.registerLayer({
80
163
  commands: [{
@@ -85,11 +168,6 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
85
168
  slashName: "guard-options",
86
169
  run() {
87
170
  const root = api.state.path.worktree || api.state.path.directory;
88
- const recovery = readProjectOption(root, "recoveryCheckpoints");
89
- const memory = readProjectOption(root, "projectMemory");
90
- const learning = readProjectOption(root, "learning");
91
- const titleSettle = readProjectOption(root, "titleSettleWorkaround");
92
- const ralphMode = readProjectOption(root, "ralphMode");
93
171
  const toggle = (key: ProjectToggle) => {
94
172
  const enabled = !readProjectOption(root, key);
95
173
  const path = writeProjectOption(root, key, enabled);
@@ -99,13 +177,12 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
99
177
  api.ui.dialog.replace(() => api.ui.DialogSelect({
100
178
  title: "Workflow Guard Project Options",
101
179
  current: undefined,
102
- options: [
103
- { title: `Recovery checkpoints: ${recovery ? "On" : "Off"}`, value: "recoveryCheckpoints", description: "Toggle durable pre-run Git checkpoints", onSelect: () => toggle("recoveryCheckpoints") },
104
- { title: `Project memory: ${memory ? "On" : "Off"}`, value: "projectMemory", description: "Toggle durable local project memory", onSelect: () => toggle("projectMemory") },
105
- { title: `Learner mode: ${learning ? "On" : "Off"}`, value: "learning", description: "Toggle evidence-based learning tools", onSelect: () => toggle("learning") },
106
- { title: `Title settle workaround: ${titleSettle ? "On" : "Off"}`, value: "titleSettleWorkaround", description: "Delay automatic continuation while OpenCode generates a session title", onSelect: () => toggle("titleSettleWorkaround") },
107
- { title: `Ralph mode: ${ralphMode ? "On" : "Off"}`, value: "ralphMode", description: "Opt into bounded autonomous continuation of already-owned todos", onSelect: () => toggle("ralphMode") },
108
- ],
180
+ options: TOGGLE_OPTIONS.map((option) => ({
181
+ title: `${option.label}: ${readProjectOption(root, option.key) ? "On" : "Off"}`,
182
+ value: option.key,
183
+ description: option.description,
184
+ onSelect: () => toggle(option.key),
185
+ })),
109
186
  }));
110
187
  },
111
188
  }],
@@ -129,7 +206,11 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
129
206
  });
130
207
  };
131
208
 
209
+ // Default export supports BOTH generations: V1 TUI clients call tui(), V2 reads setup().
132
210
  export default {
133
- id: "workflow-guard-ui",
211
+ ...Plugin.define({
212
+ id: "workflow-guard-ui",
213
+ setup: WorkflowGuardTuiV2,
214
+ }),
134
215
  tui: WorkflowGuardTui,
135
- } satisfies TuiPluginModule;
216
+ } as const satisfies TuiPluginModule;
@@ -8,7 +8,9 @@
8
8
  * from ./lib/, and re-exports the public helper surface for tests.
9
9
  */
10
10
 
11
- import { type Plugin, type PluginModule } from "@opencode-ai/plugin";
11
+ import { type Plugin as V1Plugin, type PluginModule } from "@opencode-ai/plugin";
12
+ import { Plugin } from "@opencode/plugin";
13
+ import { WorkflowGuardV2 } from "./lib/v2-plugin.ts";
12
14
  import { existsSync, readFileSync } from "node:fs";
13
15
  import { resolve } from "node:path";
14
16
  import { join } from "node:path";
@@ -188,6 +190,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
188
190
  // ── Verification engine ──────────────────────────────────────────────────────
189
191
  import {
190
192
  detectVerifyCommand,
193
+ resolveVerifyTimeoutMs,
191
194
  runVerify,
192
195
  snipVerifyOutput,
193
196
  getCurrentGitCommitHash,
@@ -197,6 +200,7 @@ import {
197
200
 
198
201
  export {
199
202
  detectVerifyCommand,
203
+ resolveVerifyTimeoutMs,
200
204
  runVerify,
201
205
  snipVerifyOutput,
202
206
  getCurrentGitCommitHash,
@@ -239,6 +243,7 @@ import {
239
243
  clearContinuationState,
240
244
  continueUnfinishedSession,
241
245
  isGeneratedContinuationMessage,
246
+ markInterrupted,
242
247
  recordUserMessage,
243
248
  } from "./policies/continuation.ts";
244
249
  import {
@@ -377,7 +382,7 @@ export function buildCompactionContext(operationalState: string, priorityBlocks:
377
382
  return context;
378
383
  }
379
384
 
380
- export const WorkflowGuard: Plugin = async (ctx) => {
385
+ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
381
386
  // Honor worktree if present (e.g. opencode worktrees or devcontainers)
382
387
  // so worktree plugins cannot punch through boundary gates. When the host
383
388
  // reports the filesystem root, use the SDK's actual project worktree instead.
@@ -763,12 +768,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
763
768
  }
764
769
  }
765
770
  if (event?.type === "message.updated") {
766
- const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown } })?.info;
771
+ const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown; error?: { name?: unknown } } })?.info;
767
772
  if (info?.role === "user" && typeof info.sessionID === "string") {
768
773
  await runWithRuntimeState(effectiveRoot, ctx.client, () => {
769
774
  recordUserMessage(info.sessionID as string, typeof info.id === "string" ? info.id : undefined);
770
775
  });
771
776
  }
777
+ // A user-initiated interrupt (Esc) surfaces as an aborted
778
+ // assistant message: stop automatic continuation for the
779
+ // session until genuine user input arrives.
780
+ if (info?.role === "assistant" && typeof info.sessionID === "string" && info.error?.name === "MessageAbortedError") {
781
+ await runWithRuntimeState(effectiveRoot, ctx.client, () => markInterrupted(info.sessionID as string));
782
+ }
783
+ }
784
+ if (event?.type === "session.error") {
785
+ const props = event.properties as { sessionID?: unknown; error?: { name?: unknown } };
786
+ if (typeof props?.sessionID === "string" && props?.error?.name === "MessageAbortedError") {
787
+ await runWithRuntimeState(effectiveRoot, ctx.client, () => markInterrupted(props.sessionID as string));
788
+ }
772
789
  }
773
790
  if (event?.type === "session.deleted") {
774
791
  const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
@@ -804,8 +821,12 @@ export const WorkflowGuard: Plugin = async (ctx) => {
804
821
  };
805
822
  };
806
823
 
807
- // Default export MUST be a V1 PluginModule record.
824
+ // Default export supports BOTH generations: V1 calls server(), V2 reads id+setup().
825
+ // Spread Plugin.define() so the V2 definition type-checks separately from server().
808
826
  export default {
809
- id: "workflow-guard",
827
+ ...Plugin.define({
828
+ id: "workflow-guard",
829
+ setup: WorkflowGuardV2,
830
+ }),
810
831
  server: WorkflowGuard,
811
832
  } satisfies PluginModule;
package/test/test-e2e.mts CHANGED
@@ -7,8 +7,9 @@ import { parse as parseJsonc } from "jsonc-parser";
7
7
  let pass = 0;
8
8
  let fail = 0;
9
9
  let unavailable = 0;
10
- const check = (name: string, cond: unknown): void => {
10
+ const check = (name: string, cond: unknown): boolean => {
11
11
  cond ? (pass++, console.log(" ok " + name)) : (fail++, console.log("FAIL " + name));
12
+ return Boolean(cond);
12
13
  };
13
14
  const providerUnavailable = (output: string): boolean =>
14
15
  /available credits|insufficient credits|rate limit|too many requests|capacity|overloaded/i.test(output);
@@ -137,7 +138,7 @@ check("server and TUI package specs resolve to different modules", installedPack
137
138
  const opencodeCheck = spawnSync("opencode", ["--version"], { encoding: "utf8" });
138
139
  if (opencodeCheck.status !== 0) {
139
140
  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
+ if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
141
142
  console.log(`\n${pass} passed, ${fail} failed`);
142
143
  process.exit(fail > 0 ? 1 : 0);
143
144
  }
@@ -149,20 +150,41 @@ mkdirSync(pluginsDir, { recursive: true });
149
150
 
150
151
  // Pre-seed .opencode with dependencies already installed into testDir
151
152
  // so opencode does not execute a cold network install at runtime startup.
152
- const opencodeVersion = opencodeCheck.stdout.trim();
153
+ const opencodeVersionRaw = opencodeCheck.stdout.trim();
154
+ const opencodeSemver = opencodeVersionRaw.match(/(\d+\.\d+\.\d+)/)?.[1] ?? opencodeVersionRaw;
155
+ const opencodeMajor = Number(opencodeSemver.split(".")[0] ?? 1);
156
+ const isOpenCodeV2 = opencodeMajor >= 2;
153
157
  const lockPath = join(testDir, "package-lock.json");
154
158
  if (existsSync(lockPath) && existsSync(join(testDir, "node_modules"))) {
155
159
  try {
156
160
  const lock = JSON.parse(readFileSync(lockPath, "utf8"));
157
161
  lock.name = ".opencode";
158
- if (lock.packages?.["node_modules/@opencode-ai/plugin"]) {
159
- lock.packages["node_modules/@opencode-ai/plugin"].version = opencodeVersion;
162
+ const installedVersions: Record<string, string> = {};
163
+ for (const key of ["node_modules/@opencode-ai/plugin", "node_modules/@opencode/plugin"]) {
164
+ const version = lock.packages?.[key]?.version;
165
+ if (typeof version === "string" && version) installedVersions[key.replace("node_modules/", "")] = version;
160
166
  }
161
- if (lock.packages?.[""]) {
162
- lock.packages[""].dependencies = { "@opencode-ai/plugin": opencodeVersion };
167
+ if (isOpenCodeV2) {
168
+ // V2 loads .opencode deps with the real installed versions; rewriting
169
+ // them to the CLI version string would produce invalid semver.
170
+ const dependencies: Record<string, string> = {};
171
+ for (const [name, version] of Object.entries(installedVersions)) dependencies[name] = version;
172
+ if (!Object.keys(dependencies).length) dependencies["@opencode/plugin"] = "latest";
173
+ if (lock.packages?.[""]) {
174
+ lock.packages[""].dependencies = dependencies;
175
+ }
176
+ writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
177
+ writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies }, null, 2) + "\n");
178
+ } else {
179
+ if (lock.packages?.["node_modules/@opencode-ai/plugin"]) {
180
+ lock.packages["node_modules/@opencode-ai/plugin"].version = opencodeSemver;
181
+ }
182
+ if (lock.packages?.[""]) {
183
+ lock.packages[""].dependencies = { "@opencode-ai/plugin": opencodeSemver };
184
+ }
185
+ writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
186
+ writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies: { "@opencode-ai/plugin": opencodeSemver } }, null, 2) + "\n");
163
187
  }
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
188
  cpSync(join(testDir, "node_modules"), join(opencodeDir, "node_modules"), { recursive: true });
167
189
  } catch {}
168
190
  }
@@ -179,20 +201,63 @@ const importedMarker = join(testDir, ".workflow-guard-imported");
179
201
  const initializedMarker = join(testDir, ".workflow-guard-initialized");
180
202
  const accountabilityMarker = join(testDir, ".workflow-guard-accountability");
181
203
  writeFileSync(localAdapter, `import { writeFileSync } from "node:fs";
204
+ import { Plugin } from "@opencode/plugin";
182
205
 
183
206
  writeFileSync(${JSON.stringify(importedMarker)}, "imported\\n");
184
207
 
185
- export const WorkflowGuardE2E = async (ctx: any) => {
186
- \tconst { WorkflowGuard } = await import("../workflow-guard-source/workflow-guard.ts");
187
- \tconst hooks = await WorkflowGuard(ctx);
188
- \tconst status = await hooks.tool?.guard_status?.execute({}, { sessionID: "headless-e2e", directory: ctx.directory, worktree: ctx.worktree });
189
- \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 });
208
+ // Load the shipped dual plugin: V1 probes call server(), V2 runs the real
209
+ // setup(ctx) against the real plugin context and probes the REGISTERED tools.
210
+ const loaded = await import("../workflow-guard-source/workflow-guard.ts");
211
+
212
+ const headlessToolContext = { sessionID: "headless-e2e", agent: "build", messageID: "msg_headless", id: "call_headless", progress: async () => {} };
213
+
214
+ const runV2Probes = async (ctx: any) => {
215
+ \tawait loaded.default.setup(ctx);
216
+ \tconst tools = await ctx.tool.list();
217
+ \tconst byId = (id: string) => tools.find((t: any) => t.id === id);
218
+ \tconst todowrite = byId("todowrite");
219
+ \tconst guardStatus = byId("guard_status");
220
+ \tconst guardWhy = byId("guard_why");
221
+ \tconst pick = (r: any) => (typeof r?.content === "string" ? r.content : JSON.stringify(r));
222
+ \tconst statusRes = guardStatus ? await guardStatus.execute({}, headlessToolContext) : undefined;
223
+ \tconst whyRes = guardWhy ? await guardWhy.execute({ tool: "bash", input: { command: "git push origin main" } }, headlessToolContext) : undefined;
224
+ \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({
225
+ \t\ttools: tools.map((t: any) => t.id),
226
+ \t\ttodowriteEnriched: Boolean(todowrite?.description?.includes("Workflow Guard lifecycle")),
227
+ \t\tstatus: JSON.parse(pick(statusRes)),
228
+ \t\twhy: JSON.parse(pick(whyRes)),
229
+ \t}) + "\\n");
230
+ \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
231
+ };
232
+
233
+ const runV1Probes = async (ctx: any) => {
234
+ \tconst hooks = await loaded.default.server(ctx);
235
+ \tconst directory = ctx?.location?.directory ?? ctx?.directory ?? process.cwd();
236
+ \tconst toolCtx = { sessionID: "headless-e2e", directory, worktree: directory };
237
+ \tconst status = await hooks.tool?.guard_status?.execute({}, toolCtx);
238
+ \tconst why = await hooks.tool?.guard_why?.execute({ tool: "bash", input: { command: "git push origin main" } }, toolCtx);
190
239
  \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: JSON.parse(String(why)) }) + "\\n");
191
240
  \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
192
241
  \treturn hooks;
193
242
  };
194
243
 
195
- export default { id: "workflow-guard-e2e", server: WorkflowGuardE2E };
244
+ export default {
245
+ \t...Plugin.define({
246
+ \t\tid: "workflow-guard-e2e",
247
+ \t\tasync setup(ctx: any) {
248
+ \t\t\twriteFileSync(${JSON.stringify(importedMarker)}, "imported\\n");
249
+ \t\t\ttry {
250
+ \t\t\t\tawait runV2Probes(ctx);
251
+ \t\t\t} catch (error) {
252
+ \t\t\t\twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ error: String((error as Error)?.message ?? error), stack: String((error as Error)?.stack ?? "") }) + "\\n");
253
+ \t\t\t\tthrow error;
254
+ \t\t\t}
255
+ \t\t},
256
+ \t}),
257
+ \tserver: async (ctx: any) => {
258
+ \t\treturn await runV1Probes(ctx);
259
+ \t},
260
+ };
196
261
  `);
197
262
  writeFileSync(join(testDir, ".opencode", "opencode.json"), `${JSON.stringify({ plugin: ["./plugins/workflow-guard.ts"] }, null, 2)}\n`);
198
263
  check("local plugin adapter and source copied successfully", existsSync(localAdapter) && existsSync(targetPlugin));
@@ -211,6 +276,13 @@ const runtimeEnv: NodeJS.ProcessEnv = {
211
276
  delete runtimeEnv.OPENCODE_PID;
212
277
  delete runtimeEnv.OPENCODE_PURE;
213
278
  delete runtimeEnv.OPENCODE;
279
+ if (isOpenCodeV2) {
280
+ // V2 runs a shared managed service on a fixed default port. Each run uses
281
+ // its own isolated XDG dirs, so pick a run-unique port to keep concurrent
282
+ // and sequential runs from colliding on service startup.
283
+ const servicePort = 49400 + (process.pid % 1000);
284
+ spawnSync("opencode", ["service", "set", "port", String(servicePort)], { cwd: testDir, encoding: "utf8", env: runtimeEnv, timeout: 30_000 });
285
+ }
214
286
  const runOpenCode = (args: string[], timeout: number) =>
215
287
  spawnSync("opencode", ["run", "--dir", testDir, ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
216
288
  const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
@@ -229,7 +301,11 @@ const configProbe = await new Promise<{ status: number | null; stdout: string; s
229
301
 
230
302
  const checkReady = () => {
231
303
  try {
232
- if (JSON.parse(stdout)?.plugin_origins && existsSync(initializedMarker)) {
304
+ const parsed = JSON.parse(stdout);
305
+ // V1 prints an object with plugin_origins; V2 prints a config document
306
+ // array. Either way, the adapter's setup/server must have run.
307
+ const hasConfig = Boolean(parsed?.plugin_origins) || Array.isArray(parsed);
308
+ if (hasConfig && existsSync(initializedMarker)) {
233
309
  done(0);
234
310
  }
235
311
  } catch {}
@@ -247,26 +323,52 @@ const configProbe = await new Promise<{ status: number | null; stdout: string; s
247
323
 
248
324
  timer = setTimeout(() => done(null), 30_000);
249
325
  });
326
+ if (isOpenCodeV2) {
327
+ // V2's debug config can exit before plugin setup completes; wait briefly
328
+ // for the adapter's initialized marker before evaluating plugin checks.
329
+ for (let i = 0; i < 200 && !existsSync(initializedMarker); i++) {
330
+ await new Promise((resolve) => setTimeout(resolve, 50));
331
+ }
332
+ }
250
333
  let configLoadsLocalPlugin = false;
251
334
  try {
252
- const config = JSON.parse(configProbe.stdout);
253
- configLoadsLocalPlugin = config.plugin_origins?.some(
254
- (origin: { spec?: unknown; source?: unknown; scope?: unknown }) =>
255
- typeof origin.spec === "string" &&
256
- origin.spec.endsWith("/.opencode/plugins/workflow-guard.ts") &&
257
- origin.scope === "local",
258
- );
335
+ if (isOpenCodeV2) {
336
+ // V2 has no plugin_origins in debug config output; the adapter's setup
337
+ // writing the imported marker is the plugin-load proof.
338
+ configLoadsLocalPlugin = existsSync(importedMarker) && existsSync(initializedMarker);
339
+ } else {
340
+ const config = JSON.parse(configProbe.stdout);
341
+ configLoadsLocalPlugin = config.plugin_origins?.some(
342
+ (origin: { spec?: unknown; source?: unknown; scope?: unknown }) =>
343
+ typeof origin.spec === "string" &&
344
+ origin.spec.endsWith("/.opencode/plugins/workflow-guard.ts") &&
345
+ origin.scope === "local",
346
+ );
347
+ }
259
348
  } catch {}
260
- check("OpenCode resolves isolated local plugin config without a model provider", configProbe.status === 0 && configLoadsLocalPlugin);
349
+ const configCheckPassed = Boolean(check("OpenCode resolves isolated local plugin config without a model provider", configProbe.status === 0 && configLoadsLocalPlugin));
350
+ if (!configCheckPassed) {
351
+ console.log(` configProbe stderr: ${configProbe.stderr.slice(0, 2000)}`);
352
+ console.log(` configProbe stdout: ${configProbe.stdout.slice(0, 2000)}`);
353
+ console.log(` importedMarker: ${existsSync(importedMarker)} initializedMarker: ${existsSync(initializedMarker)}`);
354
+ }
261
355
  let headlessAccountability: any;
262
356
  try {
263
357
  headlessAccountability = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
264
358
  } catch {}
265
359
  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");
360
+ if (isOpenCodeV2) {
361
+ // The V2 adapter runs the shipped setup(ctx) for real; verify the
362
+ // registered tool surface. Builtin description enrichment is best-effort:
363
+ // builtins are not visible to ctx.tool.list() during plugin setup, so
364
+ // todowriteEnriched is reported but not asserted.
365
+ check("V2 setup registers custom guard tools", (headlessAccountability?.tools ?? []).includes("guard_status") && (headlessAccountability?.tools ?? []).includes("guard_why"));
366
+ console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
367
+ }
266
368
 
267
369
  if (process.env.WORKFLOW_GUARD_LIVE_E2E !== "1") {
268
370
  console.log("SKIP: model-driven policy probes require WORKFLOW_GUARD_LIVE_E2E=1.");
269
- rmSync(testDir, { recursive: true, force: true });
371
+ if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
270
372
  console.log(`\n${pass} passed, ${fail} failed`);
271
373
  process.exit(fail > 0 ? 1 : 0);
272
374
  }
@@ -286,7 +388,7 @@ try {
286
388
  const liveModel = process.env.WORKFLOW_GUARD_LIVE_MODEL?.trim() || recentModel;
287
389
  if (!liveModel) {
288
390
  console.error(`FAIL: no recent OpenCode model found in ${recentModelPath}; select a model in OpenCode or set WORKFLOW_GUARD_LIVE_MODEL=provider/model.`);
289
- rmSync(testDir, { recursive: true, force: true });
391
+ if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
290
392
  process.exit(1);
291
393
  }
292
394
  console.log(` Using live model: ${liveModel}`);
@@ -337,7 +439,7 @@ if (!run3AccountedFor) {
337
439
  }
338
440
 
339
441
  // Clean up
340
- rmSync(testDir, { recursive: true, force: true });
442
+ if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
341
443
 
342
444
  console.log(`\n${pass} passed, ${fail} failed, ${unavailable} live unavailable`);
343
445
  process.exit(fail ? 1 : 0);