opencode-workflow-guard 1.5.0 → 1.5.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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/lib/types.ts +4 -0
- package/src/lib/utils.ts +132 -8
- package/src/policies/changelog.ts +114 -15
- package/src/policies/continuation.ts +83 -0
- package/src/policies/planning.ts +50 -0
- package/src/policies/todo.ts +8 -2
- package/src/workflow-guard.ts +67 -6
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ For complete policy specifications and override rules, see [docs/policies.md](do
|
|
|
68
68
|
|
|
69
69
|
| Tool | Purpose |
|
|
70
70
|
|---|---|
|
|
71
|
+
| `guard_next_tasks` | Load `TODO.md`, or fall back to repository roadmap/plan/task Markdown when deciding what to work on next |
|
|
71
72
|
| `guard_status` | Inspect active guardrails, current branch protection, and verification/review status |
|
|
72
73
|
| `guard_why` | Simulate and explain whether a specific tool call or command would be blocked |
|
|
73
74
|
| `guard_audit` | View recent audit log entries recorded in `workflow-guard.jsonl` |
|
package/package.json
CHANGED
package/src/lib/types.ts
CHANGED
|
@@ -7,6 +7,10 @@ export interface TodoSdkClient {
|
|
|
7
7
|
session?: {
|
|
8
8
|
todo?: (opts: { path: { id: string } }) => Promise<{ data?: unknown }>;
|
|
9
9
|
get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown } }>;
|
|
10
|
+
promptAsync?: (opts: {
|
|
11
|
+
path: { id: string };
|
|
12
|
+
body: { messageID?: string; parts: Array<{ type: "text"; text: string }> };
|
|
13
|
+
}) => Promise<unknown>;
|
|
10
14
|
};
|
|
11
15
|
tui?: {
|
|
12
16
|
showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
|
package/src/lib/utils.ts
CHANGED
|
@@ -38,9 +38,7 @@ export function decodeShellEscapes(text: string): string {
|
|
|
38
38
|
// Unicode escapes: \uHHHH
|
|
39
39
|
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
|
|
40
40
|
// Octal escapes: \0OO or \OOO (e.g. \162 -> 'r')
|
|
41
|
-
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
|
|
42
|
-
// Single character escapes: \c -> c (e.g. \r\m -> rm)
|
|
43
|
-
.replace(/\\(.)/g, "$1");
|
|
41
|
+
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
export function shellWords(command: string): string[] {
|
|
@@ -82,22 +80,95 @@ export function shellWords(command: string): string[] {
|
|
|
82
80
|
return words;
|
|
83
81
|
}
|
|
84
82
|
|
|
83
|
+
function envSplitWords(value: string): string[] {
|
|
84
|
+
const words: string[] = [];
|
|
85
|
+
let word = "";
|
|
86
|
+
let quote: "'" | '"' | undefined;
|
|
87
|
+
let started = false;
|
|
88
|
+
const push = () => {
|
|
89
|
+
if (started) words.push(word);
|
|
90
|
+
word = "";
|
|
91
|
+
started = false;
|
|
92
|
+
};
|
|
93
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
94
|
+
const char = value[i]!;
|
|
95
|
+
if (char === "'" || char === '"') {
|
|
96
|
+
if (!quote) quote = char;
|
|
97
|
+
else if (quote === char) quote = undefined;
|
|
98
|
+
else word += char;
|
|
99
|
+
started = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (char === "\\" && i + 1 < value.length) {
|
|
103
|
+
const next = value[++i]!;
|
|
104
|
+
if (quote === "'" && next !== "'" && next !== "\\") {
|
|
105
|
+
word += `\\${next}`;
|
|
106
|
+
started = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (next === "c" && !quote) break;
|
|
110
|
+
if (next === "_" && quote !== "'") {
|
|
111
|
+
if (quote === '"') {
|
|
112
|
+
word += " ";
|
|
113
|
+
started = true;
|
|
114
|
+
} else push();
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const escapes: Record<string, string> = { f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "#": "#", "$": "$", '"': '"', "'": "'", "\\": "\\" };
|
|
118
|
+
word += escapes[next] ?? next;
|
|
119
|
+
started = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!quote && /[\s\f\v]/.test(char)) {
|
|
123
|
+
push();
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!quote && char === "#" && !started) break;
|
|
127
|
+
if (char === "$" && quote !== "'" && value[i + 1] === "{") {
|
|
128
|
+
const end = value.indexOf("}", i + 2);
|
|
129
|
+
if (end >= 0) {
|
|
130
|
+
const name = value.slice(i + 2, end);
|
|
131
|
+
word += process.env[name] ?? "";
|
|
132
|
+
started = true;
|
|
133
|
+
i = end;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
word += char;
|
|
138
|
+
started = true;
|
|
139
|
+
}
|
|
140
|
+
push();
|
|
141
|
+
return words;
|
|
142
|
+
}
|
|
143
|
+
|
|
85
144
|
export function splitShellSegments(command: string): string[] {
|
|
86
145
|
const segments: string[] = [];
|
|
87
146
|
let current = "";
|
|
88
|
-
let quote: "'" | '"' | undefined;
|
|
147
|
+
let quote: "'" | '"' | "ansi" | undefined;
|
|
89
148
|
let escaped = false;
|
|
90
|
-
for (
|
|
149
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
150
|
+
const char = command[i]!;
|
|
91
151
|
if (escaped) {
|
|
92
152
|
current += char;
|
|
93
153
|
escaped = false;
|
|
94
154
|
continue;
|
|
95
155
|
}
|
|
156
|
+
if (!quote && char === "$" && command[i + 1] === "'") {
|
|
157
|
+
current += "$'";
|
|
158
|
+
quote = "ansi";
|
|
159
|
+
i += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
96
162
|
if (char === "\\" && quote !== "'") {
|
|
97
163
|
current += char;
|
|
98
164
|
escaped = true;
|
|
99
165
|
continue;
|
|
100
166
|
}
|
|
167
|
+
if (quote === "ansi" && char === "'") {
|
|
168
|
+
quote = undefined;
|
|
169
|
+
current += char;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
101
172
|
if (char === "'" || char === '"') {
|
|
102
173
|
if (!quote) quote = char;
|
|
103
174
|
else if (quote === char) quote = undefined;
|
|
@@ -115,7 +186,7 @@ export function splitShellSegments(command: string): string[] {
|
|
|
115
186
|
return segments;
|
|
116
187
|
}
|
|
117
188
|
|
|
118
|
-
|
|
189
|
+
function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean } {
|
|
119
190
|
let cleanCmd = decodeShellEscapes(command.trim());
|
|
120
191
|
// Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
|
|
121
192
|
while (
|
|
@@ -128,6 +199,7 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
128
199
|
|
|
129
200
|
const words = shellWords(cleanCmd);
|
|
130
201
|
let i = 0;
|
|
202
|
+
let changesCwd = false;
|
|
131
203
|
while (i < words.length) {
|
|
132
204
|
const rawWord = words[i]!;
|
|
133
205
|
const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
|
|
@@ -198,6 +270,7 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
198
270
|
]);
|
|
199
271
|
while (i < words.length && words[i]!.startsWith("-")) {
|
|
200
272
|
const option = words[i++]!;
|
|
273
|
+
if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) changesCwd = true;
|
|
201
274
|
if (valueOptions.has(option) && i < words.length) i++;
|
|
202
275
|
}
|
|
203
276
|
continue;
|
|
@@ -207,6 +280,7 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
207
280
|
const valueOptions = new Set([
|
|
208
281
|
"-u",
|
|
209
282
|
"--unset",
|
|
283
|
+
"--argv0",
|
|
210
284
|
"-C",
|
|
211
285
|
"--chdir",
|
|
212
286
|
"-S",
|
|
@@ -214,12 +288,54 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
214
288
|
]);
|
|
215
289
|
while (i < words.length) {
|
|
216
290
|
const w = words[i]!;
|
|
291
|
+
if (w === "--") {
|
|
292
|
+
i++;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
217
295
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
|
|
218
296
|
i++;
|
|
219
297
|
continue;
|
|
220
298
|
}
|
|
299
|
+
let splitValue: string | undefined;
|
|
300
|
+
let consumed = 0;
|
|
301
|
+
const clustered = /^-[iv0]*([SC])(.*)$/.exec(w);
|
|
302
|
+
if (clustered?.[1] === "C") {
|
|
303
|
+
changesCwd = true;
|
|
304
|
+
i++;
|
|
305
|
+
if (!clustered[2] && i < words.length) i++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (clustered?.[1] === "S") {
|
|
309
|
+
if (clustered[2]) splitValue = clustered[2];
|
|
310
|
+
else if (i + 1 < words.length) {
|
|
311
|
+
splitValue = words[i + 1]!;
|
|
312
|
+
consumed = 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else if (w.startsWith("-S") && w !== "-S") splitValue = w.slice(2);
|
|
316
|
+
else if (w.startsWith("--split-string=")) splitValue = w.slice("--split-string=".length);
|
|
317
|
+
else if ((w === "-S" || w === "--split-string") && i + 1 < words.length) {
|
|
318
|
+
splitValue = words[i + 1]!;
|
|
319
|
+
consumed = 1;
|
|
320
|
+
}
|
|
321
|
+
if (splitValue !== undefined) {
|
|
322
|
+
words.splice(i, consumed + 1, ...envSplitWords(splitValue));
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const clusteredValue = /^-[iv0]*([ua])(.*)$/.exec(w);
|
|
326
|
+
if (clusteredValue) {
|
|
327
|
+
i++;
|
|
328
|
+
if (!clusteredValue[2] && i < words.length) i++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (w.startsWith("-C") && w !== "-C") {
|
|
332
|
+
changesCwd = true;
|
|
333
|
+
i++;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
221
336
|
if (!w.startsWith("-")) break;
|
|
222
337
|
i++;
|
|
338
|
+
if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) changesCwd = true;
|
|
223
339
|
if (valueOptions.has(w) && i < words.length) i++;
|
|
224
340
|
}
|
|
225
341
|
continue;
|
|
@@ -230,10 +346,18 @@ export function unwrapShellWords(command: string): string[] {
|
|
|
230
346
|
}
|
|
231
347
|
break;
|
|
232
348
|
}
|
|
233
|
-
return words
|
|
349
|
+
return { words: words
|
|
234
350
|
.slice(i)
|
|
235
351
|
.map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
|
|
236
|
-
.filter(Boolean);
|
|
352
|
+
.filter(Boolean), changesCwd };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function unwrapShellWords(command: string): string[] {
|
|
356
|
+
return unwrapShellWordsImpl(command).words;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function shellWrappersChangeCwd(command: string): boolean {
|
|
360
|
+
return unwrapShellWordsImpl(command).changesCwd;
|
|
237
361
|
}
|
|
238
362
|
|
|
239
363
|
export function unwrapShellCommand(command: string): string {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
4
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
5
|
-
import { splitShellSegments, unwrapShellCommand } from "../lib/utils.ts";
|
|
5
|
+
import { splitShellSegments, unwrapShellCommand, unwrapShellWords } from "../lib/utils.ts";
|
|
6
6
|
|
|
7
7
|
export const CHANGELOG_SECTION_RE = /^\s*(?:#{1,6}\s*)?changelog\s*(?::|$)/im;
|
|
8
8
|
|
|
@@ -20,6 +20,84 @@ export function hasPrCreateInvocation(command: string): boolean {
|
|
|
20
20
|
// Accepts either a CHANGELOG file modification or a .changeset/*.md file.
|
|
21
21
|
const CHANGELOG_OR_CHANGESET_RE = /(?:changelog|\.changeset\/[^/]+\.md$)/i;
|
|
22
22
|
|
|
23
|
+
function decodeAnsiCLineBreaks(value: string): string {
|
|
24
|
+
let decoded = "";
|
|
25
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
26
|
+
if (value[i] !== "\\" || i + 1 >= value.length) {
|
|
27
|
+
decoded += value[i];
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const next = value[++i];
|
|
31
|
+
if (next === "n") decoded += "\n";
|
|
32
|
+
else if (next === "r") decoded += "\r";
|
|
33
|
+
else if (next === "\\") decoded += "\\";
|
|
34
|
+
else decoded += `\\${next}`;
|
|
35
|
+
}
|
|
36
|
+
return decoded;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function preserveAnsiCLineBreaks(command: string): string {
|
|
40
|
+
return command.replace(/\$'((?:\\.|[^'])*)'/g, (_match, value: string) => {
|
|
41
|
+
const decoded = decodeAnsiCLineBreaks(value).replace(/'/g, "'\\''");
|
|
42
|
+
return `'${decoded}'`;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function prShellWords(command: string): string[] {
|
|
47
|
+
const words: string[] = [];
|
|
48
|
+
let word = "";
|
|
49
|
+
let quote: "'" | '"' | "ansi" | undefined;
|
|
50
|
+
let started = false;
|
|
51
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
52
|
+
const char = command[i]!;
|
|
53
|
+
if (!quote && char === "$" && command[i + 1] === "'") {
|
|
54
|
+
quote = "ansi";
|
|
55
|
+
started = true;
|
|
56
|
+
i += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (quote === "ansi") {
|
|
60
|
+
if (char === "'") {
|
|
61
|
+
quote = undefined;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (char === "\\" && i + 1 < command.length) {
|
|
65
|
+
let escape = char + command[++i];
|
|
66
|
+
word += decodeAnsiCLineBreaks(escape);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
word += char;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === "'" || char === '"') {
|
|
73
|
+
if (!quote) quote = char;
|
|
74
|
+
else if (quote === char) quote = undefined;
|
|
75
|
+
else word += char;
|
|
76
|
+
started = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (char === "\\" && quote !== "'") {
|
|
80
|
+
const next = command[++i];
|
|
81
|
+
if (next !== "\n") {
|
|
82
|
+
if (quote === '"' && next && !['$', '`', '"', '\\'].includes(next)) word += "\\";
|
|
83
|
+
word += next ?? "\\";
|
|
84
|
+
}
|
|
85
|
+
started = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!quote && /\s/.test(char)) {
|
|
89
|
+
if (started) words.push(word);
|
|
90
|
+
word = "";
|
|
91
|
+
started = false;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
word += char;
|
|
95
|
+
started = true;
|
|
96
|
+
}
|
|
97
|
+
if (started) words.push(word);
|
|
98
|
+
return words;
|
|
99
|
+
}
|
|
100
|
+
|
|
23
101
|
export function branchHasChangelogChange(root: string): boolean {
|
|
24
102
|
try {
|
|
25
103
|
const baseCandidates = ["origin/HEAD", "origin/main", "origin/master"];
|
|
@@ -54,23 +132,44 @@ export function branchHasChangelogChange(root: string): boolean {
|
|
|
54
132
|
}
|
|
55
133
|
}
|
|
56
134
|
|
|
57
|
-
export function prBodyIncludesChangelog(command: string): boolean {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
135
|
+
export function prBodyIncludesChangelog(command: string, invocationRoot: string | null = getWorkspaceRoot()): boolean {
|
|
136
|
+
const parsedWords = prShellWords(command);
|
|
137
|
+
const envIndex = parsedWords.indexOf("env");
|
|
138
|
+
const usesEnvSplitString = envIndex >= 0 && parsedWords.slice(envIndex + 1).some((word) =>
|
|
139
|
+
word === "-S" || word.startsWith("-S") || word === "--split-string" || word.startsWith("--split-string="));
|
|
140
|
+
const commandWords = usesEnvSplitString
|
|
141
|
+
? unwrapShellWords(preserveAnsiCLineBreaks(command))
|
|
142
|
+
: parsedWords;
|
|
143
|
+
const cliIndex = commandWords.findIndex((word) => word === "gh" || word === "az");
|
|
144
|
+
const words = cliIndex >= 0 ? commandWords.slice(cliIndex) : [];
|
|
145
|
+
let body = "";
|
|
146
|
+
let bodyFile: string | undefined;
|
|
147
|
+
const valueOptions = new Set([
|
|
148
|
+
"--assignee", "--base", "--head", "--head-repo", "--label", "--milestone",
|
|
149
|
+
"--project", "--recover", "--reviewer", "--template", "--title",
|
|
150
|
+
"--bypass-policy-reason", "--labels", "--merge-commit-message", "--org",
|
|
151
|
+
"--repository", "--required-reviewer-ids", "--reviewer-ids", "--source-branch",
|
|
152
|
+
"--target-branch", "--work-items", "-a", "-B", "-H", "-l", "-m", "-p", "-r", "-T", "-t",
|
|
153
|
+
]);
|
|
154
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
155
|
+
const word = words[i]!;
|
|
156
|
+
if (["--description", "-d"].includes(word)) {
|
|
157
|
+
const lines: string[] = [];
|
|
158
|
+
while (i + 1 < words.length && !words[i + 1]!.startsWith("-")) lines.push(words[++i]!);
|
|
159
|
+
body = lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
else if (["--body", "-b"].includes(word)) body = words[++i] ?? "";
|
|
162
|
+
else if (/^(?:--body|--description)=/.test(word)) body = word.slice(word.indexOf("=") + 1);
|
|
163
|
+
else if (["--body-file", "--description-file", "-F"].includes(word)) bodyFile = words[++i];
|
|
164
|
+
else if (/^(?:--body-file|--description-file)=/.test(word)) bodyFile = word.slice(word.indexOf("=") + 1);
|
|
165
|
+
else if (valueOptions.has(word)) i += 1;
|
|
64
166
|
}
|
|
65
|
-
|
|
66
|
-
/(?:--body-file|--description-file|-F)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
|
|
67
|
-
);
|
|
68
|
-
const bodyFile =
|
|
69
|
-
bodyFileMatch?.[1] ?? bodyFileMatch?.[2] ?? bodyFileMatch?.[3];
|
|
167
|
+
if (CHANGELOG_SECTION_RE.test(body)) return true;
|
|
70
168
|
if (bodyFile) {
|
|
71
169
|
try {
|
|
170
|
+
if (!isAbsolute(bodyFile) && invocationRoot === null) return false;
|
|
72
171
|
return CHANGELOG_SECTION_RE.test(
|
|
73
|
-
readFileSync(
|
|
172
|
+
readFileSync(isAbsolute(bodyFile) ? bodyFile : resolve(invocationRoot!, bodyFile), "utf8"),
|
|
74
173
|
);
|
|
75
174
|
} catch {
|
|
76
175
|
return false;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getSdkClient } from "../lib/state.ts";
|
|
3
|
+
import { effectiveTodosWithOwner, hasActiveTodo } from "./todo.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_CONSECUTIVE_CONTINUATIONS = 3;
|
|
6
|
+
type ContinuationState = {
|
|
7
|
+
counts: Map<string, number>;
|
|
8
|
+
generatedMessageIDs: Map<string, Set<string>>;
|
|
9
|
+
inFlight: Set<string>;
|
|
10
|
+
};
|
|
11
|
+
const states = new WeakMap<object, ContinuationState>();
|
|
12
|
+
|
|
13
|
+
function getState(): ContinuationState | undefined {
|
|
14
|
+
const client = getSdkClient();
|
|
15
|
+
if (!client || (typeof client !== "object" && typeof client !== "function")) return undefined;
|
|
16
|
+
let state = states.get(client as object);
|
|
17
|
+
if (!state) {
|
|
18
|
+
state = { counts: new Map(), generatedMessageIDs: new Map(), inFlight: new Set() };
|
|
19
|
+
states.set(client as object, state);
|
|
20
|
+
}
|
|
21
|
+
return state;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function continueUnfinishedSession(sessionID: string): Promise<boolean> {
|
|
25
|
+
const state = getState();
|
|
26
|
+
if (!state || state.inFlight.has(sessionID)) return false;
|
|
27
|
+
state.inFlight.add(sessionID);
|
|
28
|
+
try {
|
|
29
|
+
const effective = await effectiveTodosWithOwner(sessionID);
|
|
30
|
+
if (!effective || !hasActiveTodo(effective.todos) || effective.ownerSessionID !== sessionID) return false;
|
|
31
|
+
|
|
32
|
+
const count = state.counts.get(sessionID) ?? 0;
|
|
33
|
+
if (count >= MAX_CONSECUTIVE_CONTINUATIONS) return false;
|
|
34
|
+
|
|
35
|
+
const session = getSdkClient()?.session;
|
|
36
|
+
if (typeof session?.promptAsync !== "function") return false;
|
|
37
|
+
|
|
38
|
+
const messageID = `workflow-guard-${randomUUID()}`;
|
|
39
|
+
state.counts.set(sessionID, count + 1);
|
|
40
|
+
const generatedIDs = state.generatedMessageIDs.get(sessionID) ?? new Set<string>();
|
|
41
|
+
generatedIDs.add(messageID);
|
|
42
|
+
state.generatedMessageIDs.set(sessionID, generatedIDs);
|
|
43
|
+
try {
|
|
44
|
+
await session.promptAsync({
|
|
45
|
+
path: { id: sessionID },
|
|
46
|
+
body: {
|
|
47
|
+
messageID,
|
|
48
|
+
parts: [{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: "Workflow Guard: unfinished todos remain. Continue working through them. If you need user input, use the question tool instead of ending the run.",
|
|
51
|
+
}],
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
state.counts.set(sessionID, count);
|
|
57
|
+
generatedIDs.delete(messageID);
|
|
58
|
+
if (generatedIDs.size === 0) state.generatedMessageIDs.delete(sessionID);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
state.inFlight.delete(sessionID);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function recordUserMessage(sessionID: string, messageID?: string): void {
|
|
67
|
+
const state = getState();
|
|
68
|
+
if (!state) return;
|
|
69
|
+
const generatedIDs = state.generatedMessageIDs.get(sessionID);
|
|
70
|
+
if (messageID && generatedIDs?.delete(messageID)) {
|
|
71
|
+
if (generatedIDs.size === 0) state.generatedMessageIDs.delete(sessionID);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
state.counts.delete(sessionID);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function clearContinuationState(sessionID: string): void {
|
|
78
|
+
const state = getState();
|
|
79
|
+
if (!state) return;
|
|
80
|
+
state.counts.delete(sessionID);
|
|
81
|
+
state.generatedMessageIDs.delete(sessionID);
|
|
82
|
+
state.inFlight.delete(sessionID);
|
|
83
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
const PRIMARY_TODO_NAMES = ["TODO.md", "todo.md"];
|
|
5
|
+
const FALLBACK_NAMES = [
|
|
6
|
+
"ROADMAP.md", "roadmap.md",
|
|
7
|
+
"PLAN.md", "plan.md",
|
|
8
|
+
"TASKS.md", "tasks.md",
|
|
9
|
+
"BACKLOG.md", "backlog.md",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export interface PlanningSource {
|
|
13
|
+
path: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function discoverPlanningSources(root: string): PlanningSource[] {
|
|
18
|
+
let realRoot: string;
|
|
19
|
+
try {
|
|
20
|
+
realRoot = realpathSync(root);
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const isConfined = (path: string): boolean => {
|
|
25
|
+
const rel = relative(realRoot, realpathSync(path));
|
|
26
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
27
|
+
};
|
|
28
|
+
const readSources = (paths: string[]): PlanningSource[] => paths.flatMap((path) => {
|
|
29
|
+
try {
|
|
30
|
+
// Planning discovery is read-only and must not become a symlink escape hatch.
|
|
31
|
+
if (!lstatSync(path).isFile() || !isConfined(path)) return [];
|
|
32
|
+
return [{ path: relative(root, path), content: readFileSync(path, "utf8").slice(0, 20_000) }];
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const primary = readSources(PRIMARY_TODO_NAMES.map((name) => join(root, name)));
|
|
39
|
+
if (primary.length > 0) return primary;
|
|
40
|
+
|
|
41
|
+
const candidates = FALLBACK_NAMES.flatMap((name) => [join(root, name), join(root, "docs", name)]);
|
|
42
|
+
const plansDir = join(root, "docs", "plans");
|
|
43
|
+
try {
|
|
44
|
+
if (!lstatSync(plansDir).isDirectory() || !isConfined(plansDir)) return readSources(candidates);
|
|
45
|
+
for (const entry of readdirSync(plansDir, { withFileTypes: true })) {
|
|
46
|
+
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) candidates.push(join(plansDir, entry.name));
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
return readSources(candidates);
|
|
50
|
+
}
|
package/src/policies/todo.ts
CHANGED
|
@@ -35,6 +35,12 @@ export async function fetchParentSessionID(sessionID: string): Promise<string |
|
|
|
35
35
|
export async function effectiveTodos(
|
|
36
36
|
sessionID: string | undefined,
|
|
37
37
|
): Promise<TodoItem[] | undefined> {
|
|
38
|
+
return (await effectiveTodosWithOwner(sessionID))?.todos;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function effectiveTodosWithOwner(
|
|
42
|
+
sessionID: string | undefined,
|
|
43
|
+
): Promise<{ todos: TodoItem[]; ownerSessionID?: string } | undefined> {
|
|
38
44
|
if (!sessionID) return undefined;
|
|
39
45
|
const seen = new Set<string>();
|
|
40
46
|
let current: string | undefined = sessionID;
|
|
@@ -42,10 +48,10 @@ export async function effectiveTodos(
|
|
|
42
48
|
seen.add(current);
|
|
43
49
|
const todos = await fetchSessionTodos(current);
|
|
44
50
|
if (todos === undefined) return undefined;
|
|
45
|
-
if (todos.length > 0) return todos;
|
|
51
|
+
if (todos.length > 0) return { todos, ownerSessionID: current };
|
|
46
52
|
current = await fetchParentSessionID(current);
|
|
47
53
|
}
|
|
48
|
-
return [];
|
|
54
|
+
return { todos: [] };
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export async function effectiveTodoOwnerSessionID(
|
package/src/workflow-guard.ts
CHANGED
|
@@ -89,7 +89,9 @@ import {
|
|
|
89
89
|
asRecord,
|
|
90
90
|
extractCommands,
|
|
91
91
|
normalize,
|
|
92
|
+
shellWrappersChangeCwd,
|
|
92
93
|
splitShellSegments,
|
|
94
|
+
unwrapShellCommand,
|
|
93
95
|
getCleanEnv,
|
|
94
96
|
showBlockToast,
|
|
95
97
|
SENSITIVE_ENV_KEYS,
|
|
@@ -204,6 +206,13 @@ import {
|
|
|
204
206
|
effectiveTodoOwnerSessionID,
|
|
205
207
|
hasActiveTodo,
|
|
206
208
|
} from "./policies/todo.ts";
|
|
209
|
+
import {
|
|
210
|
+
clearContinuationState,
|
|
211
|
+
continueUnfinishedSession,
|
|
212
|
+
recordUserMessage,
|
|
213
|
+
} from "./policies/continuation.ts";
|
|
214
|
+
import { discoverPlanningSources } from "./policies/planning.ts";
|
|
215
|
+
export { discoverPlanningSources };
|
|
207
216
|
|
|
208
217
|
export { validateTodoLifecycle };
|
|
209
218
|
|
|
@@ -301,6 +310,14 @@ function logBlock(message: string): void {
|
|
|
301
310
|
} catch {}
|
|
302
311
|
}
|
|
303
312
|
|
|
313
|
+
function logObservation(client: unknown, message: string): void {
|
|
314
|
+
try {
|
|
315
|
+
void (client as any)?.app?.log?.({
|
|
316
|
+
body: { service: "workflow-guard", level: "info", message },
|
|
317
|
+
});
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
|
|
304
321
|
const READ_ONLY_ROLES = new Set([
|
|
305
322
|
"reviewer",
|
|
306
323
|
"planner",
|
|
@@ -877,11 +894,19 @@ async function guardToolCallImpl(
|
|
|
877
894
|
}
|
|
878
895
|
|
|
879
896
|
const branchChangelog = branchHasChangelogChange(currentRoot);
|
|
880
|
-
|
|
881
|
-
|
|
897
|
+
let shellCwdKnown = true;
|
|
898
|
+
const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
|
|
899
|
+
for (const segment of splitShellSegments(raw)) {
|
|
900
|
+
const unwrapped = unwrapShellCommand(segment);
|
|
901
|
+
if (/(?:^|\s)(?:(?:builtin|command)\s+)?(?:cd|pushd|popd)(?:\s|$)/.test(unwrapped)) shellCwdKnown = false;
|
|
902
|
+
if (hasPrCreateInvocation(segment)) {
|
|
903
|
+
const wrapperChangesCwd = shellWrappersChangeCwd(segment);
|
|
904
|
+
prSegments.push({ segment, invocationRoot: shellCwdKnown && !wrapperChangesCwd ? currentRoot : null });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
882
907
|
const hasChangelog =
|
|
883
908
|
branchChangelog ||
|
|
884
|
-
prSegments.every((segment) => prBodyIncludesChangelog(segment));
|
|
909
|
+
prSegments.every(({ segment, invocationRoot }) => prBodyIncludesChangelog(segment, invocationRoot));
|
|
885
910
|
if (!hasChangelog) {
|
|
886
911
|
preflightFailures.push(`Changelog is required; update a CHANGELOG file or include a 'Changelog:' section in the PR description (${descFlag}).`);
|
|
887
912
|
}
|
|
@@ -991,8 +1016,13 @@ async function emitBlockFeedback(message: string): Promise<void> {
|
|
|
991
1016
|
|
|
992
1017
|
export const WorkflowGuard: Plugin = async (ctx) => {
|
|
993
1018
|
// Honor worktree if present (e.g. opencode worktrees or devcontainers)
|
|
994
|
-
// so worktree plugins cannot punch through boundary gates.
|
|
995
|
-
|
|
1019
|
+
// so worktree plugins cannot punch through boundary gates. When the host
|
|
1020
|
+
// reports the filesystem root, use the SDK's actual project worktree instead.
|
|
1021
|
+
const hostRoot = ctx.worktree || ctx.directory || process.cwd();
|
|
1022
|
+
const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
|
|
1023
|
+
const effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree
|
|
1024
|
+
? ctx.project.worktree
|
|
1025
|
+
: hostRoot;
|
|
996
1026
|
setWorkspaceRoot(effectiveRoot);
|
|
997
1027
|
setSdkClient(ctx.client);
|
|
998
1028
|
reloadProjectConfig(effectiveRoot);
|
|
@@ -1021,6 +1051,17 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1021
1051
|
|
|
1022
1052
|
return {
|
|
1023
1053
|
tool: {
|
|
1054
|
+
guard_next_tasks: tool({
|
|
1055
|
+
description:
|
|
1056
|
+
"Load durable repository task context when deciding what to work on next. Prefers TODO.md; if absent, discovers conventional roadmap, plan, tasks, backlog, and docs/plans Markdown files.",
|
|
1057
|
+
args: {},
|
|
1058
|
+
execute: async () => {
|
|
1059
|
+
const sources = discoverPlanningSources(effectiveRoot);
|
|
1060
|
+
return sources.length > 0
|
|
1061
|
+
? JSON.stringify({ sources }, null, 2)
|
|
1062
|
+
: JSON.stringify({ sources: [], message: "No TODO, roadmap, plan, tasks, or backlog Markdown files found." });
|
|
1063
|
+
},
|
|
1064
|
+
}),
|
|
1024
1065
|
project_memory_search: tool({
|
|
1025
1066
|
description: "Search current durable knowledge for this project. Returns concise typed records with provenance; superseded records are excluded.",
|
|
1026
1067
|
args: { query: tool.schema.string() },
|
|
@@ -1526,7 +1567,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1526
1567
|
const text = typeof output.text === "string" ? output.text : "";
|
|
1527
1568
|
const check = checkCompletionClaims(text, { sessionID });
|
|
1528
1569
|
if (check.claimsCompletion && check.evidenceState && check.evidenceState !== "fresh-pass") {
|
|
1529
|
-
|
|
1570
|
+
logObservation(ctx.client, `[workflow-guard] completion claim mismatch: ${check.reason}`);
|
|
1530
1571
|
audit({
|
|
1531
1572
|
ts: new Date().toISOString(),
|
|
1532
1573
|
sessionID,
|
|
@@ -1553,6 +1594,26 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
1553
1594
|
event: async ({
|
|
1554
1595
|
event,
|
|
1555
1596
|
}: { event: { type?: string; properties?: unknown } }) => {
|
|
1597
|
+
if (event?.type === "session.idle") {
|
|
1598
|
+
const sessionID = (event.properties as { sessionID?: unknown })?.sessionID;
|
|
1599
|
+
if (typeof sessionID === "string") {
|
|
1600
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => continueUnfinishedSession(sessionID));
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (event?.type === "message.updated") {
|
|
1604
|
+
const info = (event.properties as { info?: { id?: unknown; role?: unknown; sessionID?: unknown } })?.info;
|
|
1605
|
+
if (info?.role === "user" && typeof info.sessionID === "string") {
|
|
1606
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => {
|
|
1607
|
+
recordUserMessage(info.sessionID as string, typeof info.id === "string" ? info.id : undefined);
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (event?.type === "session.deleted") {
|
|
1612
|
+
const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
|
|
1613
|
+
if (typeof sessionID === "string") {
|
|
1614
|
+
await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1556
1617
|
if (
|
|
1557
1618
|
event?.type === "command.executed" ||
|
|
1558
1619
|
event?.type === "permission.updated" ||
|