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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 (const char of command) {
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
- export function unwrapShellWords(command: string): string[] {
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 bodyMatch = command.match(
59
- /(?:--body|--description|-d|-b)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|([^\s|;&]+))/,
60
- );
61
- const body = bodyMatch?.[1] ?? bodyMatch?.[2] ?? bodyMatch?.[3] ?? "";
62
- if (CHANGELOG_SECTION_RE.test(body)) {
63
- return true;
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
- const bodyFileMatch = command.match(
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(resolve(getWorkspaceRoot(), bodyFile), "utf8"),
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
+ }
@@ -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(
@@ -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
- const prSegments = splitShellSegments(raw)
881
- .filter((segment) => hasPrCreateInvocation(segment));
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
- const effectiveRoot = ctx.worktree || ctx.directory || process.cwd();
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
- logBlock(`[workflow-guard] completion claim mismatch: ${check.reason}`);
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" ||