opencode-workflow-guard 1.12.1 → 1.13.2
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 +5 -2
- package/docs/installation.md +4 -3
- package/docs/policies.md +7 -2
- package/docs/troubleshooting.md +18 -0
- package/package.json +4 -2
- package/src/lib/guard-dispatcher.ts +6 -2
- package/src/lib/types.ts +1 -0
- package/src/lib/v2-plugin.ts +649 -0
- package/src/lib/verify.ts +11 -0
- package/src/policies/boundary.ts +26 -5
- package/src/policies/continuation.ts +20 -1
- package/src/policies/git.ts +42 -1
- package/src/policies/tamper.ts +17 -0
- package/src/workflow-guard-ui.ts +111 -14
- package/src/workflow-guard.ts +26 -5
- package/test/test-e2e.mts +129 -27
- package/test/test.mts +71 -1
package/src/policies/boundary.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
recordMutation,
|
|
9
9
|
} from "../lib/state.ts";
|
|
10
10
|
import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
11
|
-
import { isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
|
|
11
|
+
import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
|
|
12
12
|
import { isSecretPath, secretIn } from "./secrets.ts";
|
|
13
13
|
import { onProtectedBranch, branchGuardReason } from "./git.ts";
|
|
14
14
|
import {
|
|
@@ -129,14 +129,22 @@ export function teeTargetsIn(segment: string): string[] {
|
|
|
129
129
|
|
|
130
130
|
function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
131
131
|
const mutations: ShellMutation[] = [];
|
|
132
|
-
|
|
132
|
+
// The `(?!=)` lookahead after the op rejects comparison operators (`>=`,
|
|
133
|
+
// `==`) so they cannot match as a redirect op with `=` as its target.
|
|
134
|
+
const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?(?!=))\s*["']?([^\s>&|;"']+)/g;
|
|
133
135
|
for (const redirectMatch of segment.matchAll(redirectRe)) {
|
|
134
136
|
if (!redirectMatch[1] || !redirectMatch[2]) continue;
|
|
135
137
|
const op = redirectMatch[1];
|
|
136
138
|
const target = redirectMatch[2];
|
|
137
139
|
// Filter fd duplication (e.g. 2>&1, >&2) where target is purely an fd number
|
|
138
140
|
const isFdDup = op.endsWith("&") && /^\d+$/.test(target);
|
|
139
|
-
|
|
141
|
+
// Filter comparison operands from embedded non-shell syntax (SQL, awk,
|
|
142
|
+
// test expressions): `WHERE count > 5`, `x >= 10`. A bare `>` whose
|
|
143
|
+
// target is purely numeric is overwhelmingly a comparison operand, not
|
|
144
|
+
// a redirect into a numeric filename. `>>` and fd forms (`2>`) keep
|
|
145
|
+
// redirect semantics.
|
|
146
|
+
const isComparisonOperand = op === ">" && /^\d+$/.test(target);
|
|
147
|
+
if (!isFdDup && !isComparisonOperand && !/^\/dev\/(?:null|stdout|stderr|tty|fd\/\d+)$/.test(target)) {
|
|
140
148
|
mutations.push({
|
|
141
149
|
kind: "redirect",
|
|
142
150
|
target,
|
|
@@ -303,8 +311,17 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
|
|
|
303
311
|
return undefined;
|
|
304
312
|
}
|
|
305
313
|
|
|
314
|
+
// Remove single- and double-quoted spans from a shell segment. Used to
|
|
315
|
+
// analyze the residue of collaboration invocations: quoted arguments are
|
|
316
|
+
// command data and can never be shell redirects, while unquoted redirects
|
|
317
|
+
// keep receiving full validation.
|
|
318
|
+
function stripQuotedSpans(segment: string): string {
|
|
319
|
+
return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
|
|
320
|
+
}
|
|
321
|
+
|
|
306
322
|
export function detectShellMutation(command: string): ShellMutation | undefined {
|
|
307
|
-
for (const
|
|
323
|
+
for (const rawSegment of command.split(/[\n|;&]+/)) {
|
|
324
|
+
const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
|
|
308
325
|
const simpleMutations = simpleFilesystemMutations(segment);
|
|
309
326
|
if (simpleMutations.length > 0) return simpleMutations[0];
|
|
310
327
|
const teeTargets = teeTargetsIn(segment);
|
|
@@ -328,7 +345,11 @@ export async function guardShellMutation(
|
|
|
328
345
|
): Promise<string | undefined> {
|
|
329
346
|
const root = getWorkspaceRoot();
|
|
330
347
|
let hasMutation = false;
|
|
331
|
-
for (const
|
|
348
|
+
for (const rawSegment of command.split(/[\n|;&]+/)) {
|
|
349
|
+
// Same residue analysis as detectShellMutation: quoted arguments of
|
|
350
|
+
// gh/glab/az PR/issue commands are command data, not shell redirects,
|
|
351
|
+
// while unquoted redirects still get full validation below.
|
|
352
|
+
const segment = isCollaborationInvocation(rawSegment) ? stripQuotedSpans(rawSegment) : rawSegment;
|
|
332
353
|
const secretSource = secretSourceInFilesystemCommand(segment);
|
|
333
354
|
if (secretSource) {
|
|
334
355
|
return `Blocked: shell command would copy, move, or link sensitive file '${secretSource}' under a non-secret name.`;
|
|
@@ -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
|
}
|
package/src/policies/git.ts
CHANGED
|
@@ -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
|
}
|
package/src/policies/tamper.ts
CHANGED
|
@@ -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 (
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -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,95 @@ 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` creates a layer owned by the calling component and only
|
|
109
|
+
// resolves inside the TUI's provider render tree; plugin setup() runs as a
|
|
110
|
+
// plain async call outside it. Slot renders execute in a reactive scope under
|
|
111
|
+
// the provider, so the layer is registered from an app-slot claim instead.
|
|
112
|
+
// The guard keeps repeated renders from stacking duplicate layers; layers and
|
|
113
|
+
// slot claims are disposed automatically on unload.
|
|
114
|
+
let keymapLayerRegistered = false;
|
|
115
|
+
ctx.ui.slot({
|
|
116
|
+
append: "app",
|
|
117
|
+
render: () => {
|
|
118
|
+
if (!keymapLayerRegistered) {
|
|
119
|
+
keymapLayerRegistered = true;
|
|
120
|
+
ctx.keymap.layer(() => ({
|
|
121
|
+
commands: [{
|
|
122
|
+
id: "workflow-guard.project-options",
|
|
123
|
+
title: "Workflow Guard: Project Options",
|
|
124
|
+
description: "Toggle Workflow Guard project options (recovery checkpoints, project memory, learning, title settle, ralph mode)",
|
|
125
|
+
group: "Workflow Guard",
|
|
126
|
+
palette: true,
|
|
127
|
+
slash: { name: "guard-options" },
|
|
128
|
+
async run() {
|
|
129
|
+
const root = optionsRoot();
|
|
130
|
+
for (;;) {
|
|
131
|
+
const current = new Map(TOGGLE_OPTIONS.map((option) => [option.key, readProjectOption(root, option.key)]));
|
|
132
|
+
const choice = await ctx.ui.dialog.select<ProjectToggle>({
|
|
133
|
+
title: "Workflow Guard Project Options",
|
|
134
|
+
options: TOGGLE_OPTIONS.map((option) => ({
|
|
135
|
+
title: `${option.label}: ${current.get(option.key) ? "On" : "Off"}`,
|
|
136
|
+
value: option.key,
|
|
137
|
+
description: option.description,
|
|
138
|
+
})),
|
|
139
|
+
});
|
|
140
|
+
if (!choice) break;
|
|
141
|
+
try {
|
|
142
|
+
const enabled = !readProjectOption(root, choice);
|
|
143
|
+
const path = writeProjectOption(root, choice, enabled);
|
|
144
|
+
ctx.ui.toast.show({
|
|
145
|
+
variant: "success",
|
|
146
|
+
title: "Workflow Guard",
|
|
147
|
+
message: `Saved ${choice} ${enabled ? "on" : "off"} in ${path}. Restart OpenCode to apply.`,
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
ctx.ui.toast.show({
|
|
151
|
+
variant: "error",
|
|
152
|
+
title: "Workflow Guard",
|
|
153
|
+
message: error instanceof Error ? error.message : String(error),
|
|
154
|
+
});
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}],
|
|
160
|
+
bindings: [],
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
ctx.ui.slot({ append: "home.footer.status", render: () => badge() });
|
|
168
|
+
ctx.ui.slot({ append: "prompt.footer.status", render: () => badge() });
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
// Slot claims and keymap layers are disposed automatically on unload.
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ── V1 TUI plugin ─────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
78
177
|
export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
79
178
|
api.keymap.registerLayer({
|
|
80
179
|
commands: [{
|
|
@@ -85,11 +184,6 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
85
184
|
slashName: "guard-options",
|
|
86
185
|
run() {
|
|
87
186
|
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
187
|
const toggle = (key: ProjectToggle) => {
|
|
94
188
|
const enabled = !readProjectOption(root, key);
|
|
95
189
|
const path = writeProjectOption(root, key, enabled);
|
|
@@ -99,13 +193,12 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
99
193
|
api.ui.dialog.replace(() => api.ui.DialogSelect({
|
|
100
194
|
title: "Workflow Guard Project Options",
|
|
101
195
|
current: undefined,
|
|
102
|
-
options:
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
],
|
|
196
|
+
options: TOGGLE_OPTIONS.map((option) => ({
|
|
197
|
+
title: `${option.label}: ${readProjectOption(root, option.key) ? "On" : "Off"}`,
|
|
198
|
+
value: option.key,
|
|
199
|
+
description: option.description,
|
|
200
|
+
onSelect: () => toggle(option.key),
|
|
201
|
+
})),
|
|
109
202
|
}));
|
|
110
203
|
},
|
|
111
204
|
}],
|
|
@@ -129,7 +222,11 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
|
|
|
129
222
|
});
|
|
130
223
|
};
|
|
131
224
|
|
|
225
|
+
// Default export supports BOTH generations: V1 TUI clients call tui(), V2 reads setup().
|
|
132
226
|
export default {
|
|
133
|
-
|
|
227
|
+
...Plugin.define({
|
|
228
|
+
id: "workflow-guard-ui",
|
|
229
|
+
setup: WorkflowGuardTuiV2,
|
|
230
|
+
}),
|
|
134
231
|
tui: WorkflowGuardTui,
|
|
135
|
-
} satisfies TuiPluginModule;
|
|
232
|
+
} as const satisfies TuiPluginModule;
|
package/src/workflow-guard.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
827
|
+
...Plugin.define({
|
|
828
|
+
id: "workflow-guard",
|
|
829
|
+
setup: WorkflowGuardV2,
|
|
830
|
+
}),
|
|
810
831
|
server: WorkflowGuard,
|
|
811
832
|
} satisfies PluginModule;
|