opencode-workflow-guard 1.8.6 → 1.8.7

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/README.md CHANGED
@@ -23,7 +23,7 @@ opencode plugin "opencode-workflow-guard@$VERSION" --global --force
23
23
 
24
24
  *Requires OpenCode >= 1.18.* OpenCode detects the package's server and TUI targets and updates both global configs. Use the same command after a release to upgrade; the explicit version gives OpenCode a fresh package-cache key. Restart OpenCode after installation. See [docs/installation.md](docs/installation.md) for details and links to the OpenCode plugin documentation.
25
25
 
26
- For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. The companion shows a `Workflow Guard` shield in the prompt bar and reflects blocked state without changing agent behavior. Do not place the TUI module under the server `plugins/` directory.
26
+ For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. The companion keeps a static `Workflow Guard` shield in the prompt bar; warning toasts provide block details without changing agent behavior. Do not place the TUI module under the server `plugins/` directory.
27
27
 
28
28
  ![Workflow Guard status badge in the OpenCode TUI](docs/assets/workflow-guard-tui-badge.png)
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.8.6",
3
+ "version": "1.8.7",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,7 @@ import {
24
24
  isReviewRequired,
25
25
  recordMutation,
26
26
  recordReviewResult,
27
+ runWithRuntimeState,
27
28
  } from "./state.ts";
28
29
  import { loadProjectConfig } from "./project-config.ts";
29
30
  import { detectVerifyCommand, getCurrentGitCommitHash } from "./verify.ts";
@@ -53,8 +54,9 @@ export function createCustomTools(options: {
53
54
  followupStore: ProjectMemoryStore | undefined;
54
55
  portableMemoryPath: string;
55
56
  learningInterventions: Map<string, number>;
57
+ client: unknown;
56
58
  }) {
57
- const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions } = options;
59
+ const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
58
60
  return {
59
61
  ...(getProjectConfig(effectiveRoot).recoveryCheckpoints === true ? {
60
62
  guard_recovery_restore: tool({
@@ -155,7 +157,7 @@ export function createCustomTools(options: {
155
157
  args: { reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"), summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"), passed: tool.schema.boolean().describe("True if change is approved, false if changes requested") },
156
158
  execute: async (args, toolContext) => {
157
159
  const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict } });
158
- const parentSessionID = await fetchParentSessionID(toolContext.sessionID);
160
+ const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
159
161
  if (!parentSessionID) { auditVerdict("rejected", "missing_parent_session"); return "[workflow-guard] Review rejected: record_review must be called from a secondary/subagent session."; }
160
162
  const axesRefs = ["test integrity", "task completeness", "cleanliness", "security", "platform"];
161
163
  const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
@@ -1,5 +1,4 @@
1
1
  import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
2
- import type { BaseRenderable } from "@opentui/core";
3
2
  import type { JSX } from "@opentui/solid";
4
3
  import { createElement, insert, setProp } from "@opentui/solid";
5
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -31,9 +30,6 @@ function text(props: Record<string, unknown>, children: Child[]) {
31
30
  }
32
31
 
33
32
  const BADGE_ACTIVE = "Workflow Guard 🛡️";
34
- const BLOCKED_BADGE_DURATION_MS = 5_000;
35
- let lastBlockedReason: string | undefined;
36
- const sessionBlockedReasons = new Map<string, string>();
37
33
 
38
34
  type ProjectToggle = "recoveryCheckpoints" | "projectMemory" | "learning" | "titleSettleWorkaround";
39
35
 
@@ -74,29 +70,11 @@ export function writeRecoveryCheckpointsOption(root: string, enabled: boolean):
74
70
  return writeProjectOption(root, "recoveryCheckpoints", enabled);
75
71
  }
76
72
 
77
- export function setLastBlockedReasonForTesting(reason: string | undefined): void {
78
- lastBlockedReason = reason;
79
- if (reason === undefined) sessionBlockedReasons.clear();
80
- }
81
-
82
- export function getLastBlockedReason(): string | undefined {
83
- return lastBlockedReason;
84
- }
85
-
86
- export function formatBadge(sessionID?: string): { text: string; isBlocked: boolean } {
87
- const reason = sessionID ? sessionBlockedReasons.get(sessionID) : lastBlockedReason;
88
- if (!reason) {
89
- return { text: BADGE_ACTIVE, isBlocked: false };
90
- }
91
- const cleanReason = reason.replace(/^\[workflow-guard\]\s*/i, "").replace(/^Blocked(?::|\s+)\s*/i, "");
92
- const shortReason = cleanReason.length > 30 ? cleanReason.slice(0, 27) + "..." : cleanReason;
93
- return { text: `[Workflow Guard: Blocked: ${shortReason}]`, isBlocked: true };
73
+ export function formatBadge(): { text: string; isBlocked: boolean } {
74
+ return { text: BADGE_ACTIVE, isBlocked: false };
94
75
  }
95
76
 
96
77
  export const WorkflowGuardTui: TuiPlugin = async (api) => {
97
- const badgeNodes = new Map<string, BaseRenderable>();
98
- const blockedBadgeTimers = new Map<string, ReturnType<typeof setTimeout>>();
99
-
100
78
  api.keymap.registerLayer({
101
79
  commands: [{
102
80
  name: "workflow-guard.project-options",
@@ -132,67 +110,18 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
132
110
  bindings: [],
133
111
  });
134
112
 
135
- function scheduleBadgeReset(sessionID?: string): void {
136
- const key = sessionID ?? "home";
137
- const existing = blockedBadgeTimers.get(key);
138
- if (existing) clearTimeout(existing);
139
- blockedBadgeTimers.set(key, setTimeout(() => {
140
- blockedBadgeTimers.delete(key);
141
- if (sessionID) sessionBlockedReasons.delete(sessionID);
142
- else lastBlockedReason = undefined;
143
- const node = badgeNodes.get(key);
144
- if (node) {
145
- setProp(node, "content", BADGE_ACTIVE);
146
- setProp(node, "fg", api.theme.current.success);
147
- }
148
- }, BLOCKED_BADGE_DURATION_MS));
149
- }
150
-
151
- try {
152
- api.event.on("tui.toast.show", (e) => {
153
- const title = e.properties.title;
154
- const msg = e.properties.message;
155
- if (title === "Workflow Guard Blocked" && typeof msg === "string") {
156
- const route = api.route.current;
157
- const sessionID =
158
- route.name === "session" && typeof route.params?.sessionID === "string"
159
- ? route.params.sessionID
160
- : undefined;
161
- if (sessionID) sessionBlockedReasons.set(sessionID, msg);
162
- else lastBlockedReason = msg;
163
- const key = sessionID ?? "home";
164
- const node = badgeNodes.get(key);
165
- if (node) {
166
- const badge = formatBadge(sessionID);
167
- setProp(node, "content", badge.text);
168
- setProp(node, "fg", badge.isBlocked ? api.theme.current.warning : api.theme.current.success);
169
- }
170
- scheduleBadgeReset(sessionID);
171
- }
172
- });
173
- } catch {}
174
-
175
113
  api.slots.register({
176
114
  order: 1,
177
115
  slots: {
178
116
  home_prompt_right() {
179
117
  const theme = api.theme.current;
180
118
  const badge = formatBadge();
181
- const node = text({ fg: badge.isBlocked ? theme.warning : theme.success }, [badge.text]);
182
- badgeNodes.set("home", node as unknown as BaseRenderable);
183
- return node;
119
+ return text({ fg: theme.success }, [badge.text]);
184
120
  },
185
121
  session_prompt_right() {
186
122
  const theme = api.theme.current;
187
- const route = api.route.current;
188
- const sessionID =
189
- route.name === "session" && typeof route.params?.sessionID === "string"
190
- ? route.params.sessionID
191
- : undefined;
192
- const badge = formatBadge(sessionID);
193
- const node = text({ fg: badge.isBlocked ? theme.warning : theme.success }, [badge.text]);
194
- if (sessionID) badgeNodes.set(sessionID, node as unknown as BaseRenderable);
195
- return node;
123
+ const badge = formatBadge();
124
+ return text({ fg: theme.success }, [badge.text]);
196
125
  },
197
126
  },
198
127
  });
@@ -371,7 +371,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
371
371
  const toolOutcomes = new ToolOutcomeTracker();
372
372
 
373
373
  return {
374
- tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions }),
374
+ tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client: ctx.client }),
375
375
 
376
376
  "tool.execute.before": async (input, output) => {
377
377
  const toolWorktree =