opencode-workflow-guard 1.15.5 → 1.16.0

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.
@@ -2,12 +2,27 @@
2
2
  * V2 todo reconstruction shared by the plugin entrypoint and its V1 SDK
3
3
  * adapter.
4
4
  *
5
- * OpenCode V2 exposes no todo endpoint, and its builtin tool registry has no
6
- * `todowrite` (the V1 name). Todo state therefore only exists when an ACP agent
7
- * supplies its own `todowrite` tool. This module reads that state from session
8
- * message history and distinguishes "definitively empty" from "cannot
9
- * determine", which the policy layer relies on to decide whether to enforce the
10
- * active-todo gate.
5
+ * Version boundary, because the two generations need different treatment:
6
+ *
7
+ * - **OpenCode 2.0.x** exposes no todo endpoint and its builtin tool registry
8
+ * has no `todowrite` (the V1 name). Todo state only exists when an ACP agent
9
+ * supplies its own `todowrite` tool, so the list is reconstructed from
10
+ * session message history.
11
+ * - **OpenCode 2.1.x** reintroduces a builtin `todowrite` behind the
12
+ * `experimental.todo` config key. When that tool is registered, a session
13
+ * with no todo part has never planned, which must gate mutations rather than
14
+ * fail open.
15
+ *
16
+ * Capability is therefore probed, not inferred from history: `detectTodoCapability`
17
+ * asks the host's tool registry (`ctx.tool.list()`) whether `todowrite` is
18
+ * registered. History tells you whether a session planned; the registry tells
19
+ * you whether planning was possible. Only the second question decides whether
20
+ * an empty list means "not yet planned" or "cannot plan".
21
+ *
22
+ * This module distinguishes "definitively empty" from "cannot determine", which
23
+ * the policy layer relies on to decide whether to enforce the active-todo gate.
24
+ * Todos are never evidence: a `todowrite` call replaces the whole list, so a
25
+ * model can mark everything completed without doing the work.
11
26
  */
12
27
 
13
28
  import type { TodoItem } from "./types.ts";
@@ -19,6 +34,50 @@ export interface TodoScanContext {
19
34
  };
20
35
  }
21
36
 
37
+ /** Minimal tool-registry surface needed to probe for `todowrite`. */
38
+ export interface TodoCapabilityContext {
39
+ tool?: {
40
+ list?: () => Promise<unknown>;
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Whether this host can plan at all.
46
+ *
47
+ * - `available`: the registry reports `todowrite`, so an empty list means the
48
+ * session never planned and the gate must enforce.
49
+ * - `unavailable`: the registry answered and has no `todowrite`, so the gate
50
+ * must fail open or builtin-only sessions deadlock.
51
+ * - `unknown`: the registry could not be read. Treat as unavailable for
52
+ * enforcement, but say so out loud (the caller logs once per session).
53
+ */
54
+ export type TodoCapability = "available" | "unavailable" | "unknown";
55
+
56
+ /**
57
+ * Probe the host's tool registry for `todowrite`.
58
+ *
59
+ * Tool ids on the host are effective names, so a namespaced or prefixed
60
+ * registration (`todowrite`, `todo.write`) still counts. A registry that throws
61
+ * or is absent reports `unknown` rather than `unavailable`, because "we could
62
+ * not ask" must never be reported as "the host said no".
63
+ */
64
+ export async function detectTodoCapability(ctx: TodoCapabilityContext | undefined): Promise<TodoCapability> {
65
+ const list = ctx?.tool?.list;
66
+ if (typeof list !== "function") return "unknown";
67
+ try {
68
+ const entries = await list.call(ctx!.tool);
69
+ if (!Array.isArray(entries)) return "unknown";
70
+ return entries.some((entry) => {
71
+ const name = (entry as { name?: unknown; id?: unknown } | undefined)?.name ?? (entry as { id?: unknown } | undefined)?.id;
72
+ return typeof name === "string" && /^todowrite$/i.test(name);
73
+ })
74
+ ? "available"
75
+ : "unavailable";
76
+ } catch {
77
+ return "unknown";
78
+ }
79
+ }
80
+
22
81
  /**
23
82
  * V2 tool parts carry the tool name in `tool` (the V1 field was `name`); ACP
24
83
  * agents may still report either shape, so accept both.
@@ -29,6 +88,66 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
29
88
  return undefined;
30
89
  }
31
90
 
91
+ /**
92
+ * Proven capability per plugin instance, for reporting and for the fail-open
93
+ * warning. Held on `globalThis` via `Symbol.for` because OpenCode can load the
94
+ * same package from two module paths (a cache copy plus a source checkout), and
95
+ * the capability answer must agree between them.
96
+ */
97
+ const TODO_CAPABILITY = Symbol.for("opencode-workflow-guard.todo-capability.v1");
98
+ const capabilityRegistry = globalThis as unknown as Record<symbol, TodoCapability | undefined>;
99
+
100
+ /** Last probed capability. `unknown` before setup, which fails open. */
101
+ export function recordedTodoCapability(): TodoCapability {
102
+ return capabilityRegistry[TODO_CAPABILITY] ?? "unknown";
103
+ }
104
+
105
+ /**
106
+ * Record the probed capability. Returns the previous value so the caller can
107
+ * tell whether this is the first probe for the process.
108
+ */
109
+ export function recordTodoCapability(capability: TodoCapability): TodoCapability {
110
+ const previous = recordedTodoCapability();
111
+ capabilityRegistry[TODO_CAPABILITY] = capability;
112
+ return previous;
113
+ }
114
+
115
+ export function resetTodoCapabilityForTesting(): void {
116
+ delete capabilityRegistry[TODO_CAPABILITY];
117
+ }
118
+
119
+ /**
120
+ * Sessions already warned about the fail-open path. Bounded: the warning is
121
+ * once per session, and a process that outlives thousands of sessions must not
122
+ * leak a set for every one of them.
123
+ */
124
+ const WARNED_SESSIONS = Symbol.for("opencode-workflow-guard.todo-warned.v2");
125
+ const warnedRegistry = globalThis as unknown as Record<symbol, Set<string> | undefined>;
126
+ const warnedSessions: Set<string> =
127
+ warnedRegistry[WARNED_SESSIONS] ?? (warnedRegistry[WARNED_SESSIONS] = new Set());
128
+ const MAX_WARNED_SESSIONS = 512;
129
+
130
+ /**
131
+ * Report the active-todo gate failing open, once per session.
132
+ *
133
+ * Silent fail-open is how a rails policy becomes decorative: the gate stops
134
+ * enforcing and nothing says so. Returns true when this call was the first for
135
+ * the session, so the caller can journal it exactly once.
136
+ */
137
+ export function reportTodoGateFailOpen(sessionID: string | undefined, capability: TodoCapability): boolean {
138
+ const key = sessionID ?? "<no-session>";
139
+ if (warnedSessions.has(key)) return false;
140
+ if (warnedSessions.size >= MAX_WARNED_SESSIONS) warnedSessions.clear();
141
+ warnedSessions.add(key);
142
+ try {
143
+ console.warn(
144
+ `[workflow-guard] warn: active-todo gate is not enforcing for session ${key}: ` +
145
+ `todoCapability=${capability}. Mutations are allowed without an active todo.`,
146
+ );
147
+ } catch {}
148
+ return true;
149
+ }
150
+
32
151
  /**
33
152
  * Reconstruct one session's current todo list from its message history. The
34
153
  * newest applied `todowrite` tool call's input is the current state.
@@ -37,7 +156,13 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
37
156
  * - `TodoItem[]` when a `todowrite` part defines the list, including `[]` when
38
157
  * the newest applied call emptied it;
39
158
  * - `undefined` when todo state is unknown: the history cannot be read, or the
40
- * session contains no `todowrite` part at all (no todo capability).
159
+ * session contains no `todowrite` part and the capability is absent.
160
+ *
161
+ * `capability` decides what an empty history means. When the host reports
162
+ * `available`, a session with no `todowrite` part has never planned, so the
163
+ * result is `[]` and the mutation gate enforces. When it is `unavailable` or
164
+ * `unknown`, an empty history stays `undefined` so builtin-only sessions fail
165
+ * open instead of deadlocking on a tool they cannot call.
41
166
  *
42
167
  * Callers must treat `undefined` as "cannot determine" (fail open) and `[]` as
43
168
  * "definitively no active todos" (enforce). Collapsing the two is what
@@ -46,6 +171,7 @@ export function toolPartName(part: { tool?: unknown; name?: unknown }): string |
46
171
  export async function scanSessionTodos(
47
172
  ctx: TodoScanContext,
48
173
  sessionID: string,
174
+ capability: TodoCapability = "unknown",
49
175
  ): Promise<TodoItem[] | undefined> {
50
176
  try {
51
177
  const messages = (await ctx.session.context({ sessionID })) as unknown as Array<{ content?: unknown }>;
@@ -66,6 +192,10 @@ export async function scanSessionTodos(
66
192
  if (Array.isArray(todos)) return todos as TodoItem[];
67
193
  }
68
194
  }
195
+ // No applied `todowrite` part. With a registered tool that means the
196
+ // session never planned, which must gate. Without one, the session
197
+ // cannot plan at all, so the gate has nothing to enforce.
198
+ if (capability === "available") return [];
69
199
  // A `todowrite` part exists but none is applied yet (e.g. still
70
200
  // streaming): the list is in flux, report it as empty rather than
71
201
  // unknown so the gate stays closed until the write settles.
@@ -19,3 +19,25 @@ export function loadedPluginVersion(moduleUrl: string = import.meta.url): string
19
19
  return undefined;
20
20
  }
21
21
  }
22
+
23
+ /**
24
+ * The OpenCode release this build is qualified against, read from the pinned
25
+ * `@opencode/plugin` dependency in the same manifest.
26
+ *
27
+ * Pinning the host API stops an unreviewed major from floating in, but a
28
+ * published guard can still be installed into a host that is newer or older
29
+ * than the one it was verified on. Reporting the qualified pair next to the
30
+ * loaded host version makes that mismatch visible before it becomes a silent
31
+ * no-op, which is the failure mode the migration guide names.
32
+ */
33
+ export function qualifiedHostVersion(moduleUrl: string = import.meta.url): string | undefined {
34
+ try {
35
+ const manifest = JSON.parse(readFileSync(fileURLToPath(new URL("../../package.json", moduleUrl)), "utf8")) as {
36
+ dependencies?: Record<string, unknown>;
37
+ };
38
+ const pinned = manifest.dependencies?.["@opencode/plugin"];
39
+ return typeof pinned === "string" && /^\d+\.\d+\.\d+/.test(pinned) ? pinned : undefined;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
@@ -197,6 +197,40 @@ export function createGitWorktree(
197
197
  }
198
198
  }
199
199
 
200
+ /**
201
+ * Validate a worktree directory the HOST created before the guard accepts it.
202
+ *
203
+ * The host is a widening authority only in the sense that it may choose the
204
+ * directory (its configured `worktree.directory`). It is never an authority
205
+ * that the directory is safe: the guard re-checks the filesystem and the git
206
+ * registration itself, and refuses the primary working tree. A host adapter
207
+ * that returns a bogus path therefore fails closed.
208
+ */
209
+ export function validateHostWorktreeDirectory(
210
+ directory: string,
211
+ root = getWorkspaceRoot(),
212
+ ): { ok: boolean; error?: string } {
213
+ if (!directory || typeof directory !== "string") {
214
+ return { ok: false, error: "Host worktree create returned no directory." };
215
+ }
216
+ const resolved = resolve(directory);
217
+ if (!existsSync(resolved)) {
218
+ return { ok: false, error: `Host declared worktree '${directory}' does not exist.` };
219
+ }
220
+ const registered = registeredWorktreePaths(root);
221
+ if (!registered) {
222
+ return { ok: false, error: "Failed to list registered worktrees - cannot validate the host worktree." };
223
+ }
224
+ const resolvedRegistered = registered.map((path) => resolve(path));
225
+ if (!resolvedRegistered.includes(resolved)) {
226
+ return { ok: false, error: `Host declared worktree '${directory}' is not a registered worktree of this repository.` };
227
+ }
228
+ if (resolvedRegistered[0] === resolved) {
229
+ return { ok: false, error: "Host declared the primary working tree as a worktree - refusing." };
230
+ }
231
+ return { ok: true };
232
+ }
233
+
200
234
  function registeredWorktreePaths(root: string): string[] | undefined {
201
235
  const res = spawnSync("git", ["worktree", "list", "--porcelain"], {
202
236
  cwd: root,
@@ -215,23 +249,37 @@ function registeredWorktreePaths(root: string): string[] | undefined {
215
249
  export function cleanupGitWorktree(
216
250
  worktreePath: string,
217
251
  root = getWorkspaceRoot(),
252
+ options?: {
253
+ /**
254
+ * Directories the HOST declares as its worktrees for this project
255
+ * (`ctx.worktree.list`). This is the only way cleanup may reach a
256
+ * worktree outside the guard's own storage directory: the host is the
257
+ * authority that the directory belongs to its inventory, and the
258
+ * registration and primary-tree checks below still apply. Omitting the
259
+ * list keeps the original storage-only behaviour.
260
+ */
261
+ hostWorktrees?: readonly string[];
262
+ },
218
263
  ): { success: boolean; error?: string } {
219
264
  try {
220
265
  const resolved = resolve(worktreePath);
221
266
  const storageBase = resolve(getWorktreeStorageDir(root));
267
+ const hostOwned = Boolean(options?.hostWorktrees?.some((path) => resolve(path) === resolved));
268
+ const withinStorage = resolved === storageBase || resolved.startsWith(storageBase + sep);
269
+ if (!withinStorage && !hostOwned) {
270
+ return {
271
+ success: false,
272
+ error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}) and is not a host-declared worktree.`,
273
+ };
274
+ }
222
275
  if (!existsSync(worktreePath)) {
223
276
  // A registered worktree whose directory is gone (manual removal, a
224
277
  // crashed add, external cleanup) still holds a stale admin entry that
225
278
  // keeps it in `git worktree list` as prunable, and must still be
226
- // cleanable. Anything outside storage or unregistered is refused.
227
- // The primary working tree is never at risk: prune only removes
228
- // admin dirs under .git/worktrees/<id>, which the main tree lacks.
229
- if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
230
- return {
231
- success: false,
232
- error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
233
- };
234
- }
279
+ // cleanable. Anything outside storage (unless the host declares it)
280
+ // or unregistered is refused. The primary working tree is never at
281
+ // risk: prune only removes admin dirs under .git/worktrees/<id>,
282
+ // which the main tree lacks.
235
283
  const registeredMissing = registeredWorktreePaths(root);
236
284
  if (!registeredMissing) {
237
285
  return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
@@ -252,15 +300,10 @@ export function cleanupGitWorktree(
252
300
  }
253
301
 
254
302
  // Ownership validation: the path must live under the configured worktree
255
- // storage directory AND be a registered worktree of this repository. This
256
- // refuses arbitrary directories, other repos' worktrees, and the primary
257
- // working tree - cleanup must never be an unrestricted delete primitive.
258
- if (resolved !== storageBase && !resolved.startsWith(storageBase + sep)) {
259
- return {
260
- success: false,
261
- error: `Refusing to clean up '${worktreePath}': path is outside the worktree storage directory (${storageBase}).`,
262
- };
263
- }
303
+ // storage directory (or be declared by the host) AND be a registered
304
+ // worktree of this repository. This refuses arbitrary directories, other
305
+ // repos' worktrees, and the primary working tree - cleanup must never be
306
+ // an unrestricted delete primitive.
264
307
  const registered = registeredWorktreePaths(root);
265
308
  if (!registered) {
266
309
  return { success: false, error: "Failed to list registered worktrees - aborting cleanup." };
@@ -3,7 +3,13 @@ export const MCP_MUTATION_VERB_RE =
3
3
  export const MCP_READ_ONLY_RE =
4
4
  /(?:^|_)(?:get|list|search|show|query|describe|find|read|status|diff|log)(?:_|$)/;
5
5
 
6
- export const GUARDED_MCP_SERVERS = new Set([
6
+ /**
7
+ * Static guarded live-system server tokens.
8
+ *
9
+ * These stay in force regardless of host inventory, because tool names are
10
+ * model-supplied text and the MCP policy is a live-system rail.
11
+ */
12
+ export const GUARDED_MCP_SERVERS: ReadonlySet<string> = new Set([
7
13
  "github",
8
14
  "gh",
9
15
  "azure",
@@ -13,12 +19,51 @@ export const GUARDED_MCP_SERVERS = new Set([
13
19
  "azuredevops",
14
20
  ]);
15
21
 
22
+ /**
23
+ * Tokens learned from the HOST's MCP inventory (`ctx.mcp.list()`), offered by
24
+ * `lib/mcp-inventory.ts` when a server connects or its tools change.
25
+ *
26
+ * Widen-only. A server the operator removed contributes nothing new, but the
27
+ * static defaults above are never narrowed, so removing a server cannot
28
+ * unguard a tool that still names it. Empty on hosts without the API, which
29
+ * leaves behaviour identical to the static list.
30
+ *
31
+ * Held on `globalThis` via `Symbol.for` because OpenCode can load the same
32
+ * package from two distinct module paths (a cache copy plus a source
33
+ * checkout), and each copy would otherwise get its own set.
34
+ */
35
+ const HOST_GUARDED_MCP_TOKENS = Symbol.for("opencode-workflow-guard.mcp-tokens.v1");
36
+ const tokenRegistry = globalThis as unknown as Record<symbol, Set<string> | undefined>;
37
+ const hostGuardedMcpTokens: Set<string> =
38
+ tokenRegistry[HOST_GUARDED_MCP_TOKENS] ?? (tokenRegistry[HOST_GUARDED_MCP_TOKENS] = new Set());
39
+
40
+ /** Register host-declared server tokens. Returns the tokens that were new. */
41
+ export function addGuardedMcpTokens(tokens: Iterable<string>): string[] {
42
+ const added: string[] = [];
43
+ for (const token of tokens) {
44
+ const normalized = String(token).toLowerCase().trim();
45
+ if (!normalized || GUARDED_MCP_SERVERS.has(normalized) || hostGuardedMcpTokens.has(normalized)) continue;
46
+ hostGuardedMcpTokens.add(normalized);
47
+ added.push(normalized);
48
+ }
49
+ return added;
50
+ }
51
+
52
+ export function resetGuardedMcpTokensForTesting(): void {
53
+ hostGuardedMcpTokens.clear();
54
+ }
55
+
56
+ /** Effective guarded token set: static defaults plus host-declared widening. */
57
+ export function guardedMcpServers(): ReadonlySet<string> {
58
+ return new Set([...GUARDED_MCP_SERVERS, ...hostGuardedMcpTokens]);
59
+ }
60
+
16
61
  export function mcpMutationTool(toolName: string): string | undefined {
17
62
  if (!MCP_MUTATION_VERB_RE.test(toolName) || MCP_READ_ONLY_RE.test(toolName)) {
18
63
  return undefined;
19
64
  }
20
65
  const tokens = toolName.toLowerCase().split(/[^a-z0-9]+/);
21
- if (tokens.some((t) => GUARDED_MCP_SERVERS.has(t))) {
66
+ if (tokens.some((t) => GUARDED_MCP_SERVERS.has(t) || hostGuardedMcpTokens.has(t))) {
22
67
  return "GitHub/Azure DevOps";
23
68
  }
24
69
  return undefined;
@@ -11,7 +11,7 @@ export const PROTECTED_PATH_REASON =
11
11
  "these manually in configuration or the UI.";
12
12
 
13
13
  const GT = String.fromCharCode(62);
14
- const V_LIST = ["sed\\s+-i", "tee", "mv", "cp", "rm", "chmod", "chown", "ln", "install", "truncate", "dd"].join("|");
14
+ const V_LIST = ["sed\\s+-i", "tee", "mv", "cp", "rm", "chmod", "chown", "ln", "install", "truncate", "dd", "patch", "perl\\s+-[^\\s]*i"].join("|");
15
15
  const C_LIST = ["open" + "code\\.jsonc?", "\\.config\\/open" + "code", "\\.open" + "code\\/"].join("|");
16
16
  const U_LIST = ["\\.config\\/open" + "code\\/(?:plugins|ui)\\/"].join("|");
17
17
 
@@ -1,5 +1,5 @@
1
1
  import type { TodoItem } from "../lib/types.ts";
2
- import { getSessionMutationCount, getSdkClient, getSubagentMutationBudget } from "../lib/state.ts";
2
+ import { getSessionMutationCount, getSdkClient, getSubagentMutationBudget, todoTaskStarts } from "../lib/state.ts";
3
3
 
4
4
  export const EDIT_TOOL_NAMES = new Set(["edit", "write", "patch", "apply_patch"]);
5
5
  export const ACTIVE_TODO_STATUSES = new Set(["pending", "in_progress"]);
@@ -94,6 +94,221 @@ export function hasActiveTodo(todos: TodoItem[]): boolean {
94
94
  return todos.some((todo) => ACTIVE_TODO_STATUSES.has(String(todo.status ?? "")));
95
95
  }
96
96
 
97
+ /**
98
+ * Explicit read-only escape. A task whose text carries this marker completes
99
+ * without a mutation in its window. Every use is journaled, so an agent that
100
+ * marks real work read-only leaves a visible trail instead of passing silently.
101
+ */
102
+ export const READ_ONLY_TODO_MARKER = /\[read[-_ ]?only\]/i;
103
+
104
+ export function isReadOnlyTodo(todo: TodoItem): boolean {
105
+ return READ_ONLY_TODO_MARKER.test(String(todo.content ?? ""));
106
+ }
107
+
108
+ /**
109
+ * Task identity: trimmed content with the read-only marker removed.
110
+ *
111
+ * The marker is an annotation about a task, not part of which task it is. An
112
+ * agent that planned a task and only then discovers it is read-only appends the
113
+ * marker; that must not read as deleting one task and inventing another, or the
114
+ * documented remedy would be blocked by the no-silent-deletion rule. It also
115
+ * keeps the task's window, so annotating instead of re-planning loses nothing.
116
+ */
117
+ export function todoTaskKey(todo: TodoItem): string {
118
+ return String(todo.content ?? "")
119
+ .replace(READ_ONLY_TODO_MARKER, "")
120
+ .replace(/\s+/g, " ")
121
+ .trim();
122
+ }
123
+
124
+ /** Raw task text, for messages the model has to match against its own list. */
125
+ function todoTaskLabel(todo: TodoItem): string {
126
+ return String(todo.content ?? "").trim();
127
+ }
128
+
129
+ /**
130
+ * Record when each active task entered its current active window, in the
131
+ * session's mutation count. Completion evidence is judged against this.
132
+ *
133
+ * Call this only for a `todowrite` the guard allowed: a blocked write never
134
+ * becomes the task list, so it must not move a task's start.
135
+ */
136
+ export function recordTodoTaskStarts(
137
+ starts: Map<string, number>,
138
+ previousTodos: TodoItem[] | undefined,
139
+ nextTodos: TodoItem[],
140
+ mutationCount: number,
141
+ ): void {
142
+ const previouslyActive = new Set<string>();
143
+ for (const todo of previousTodos ?? []) {
144
+ if (ACTIVE_TODO_STATUSES.has(String(todo.status ?? ""))) previouslyActive.add(todoTaskKey(todo));
145
+ }
146
+ const live = new Set<string>();
147
+ for (const todo of nextTodos) {
148
+ const key = todoTaskKey(todo);
149
+ if (!key) continue;
150
+ if (ACTIVE_TODO_STATUSES.has(String(todo.status ?? ""))) {
151
+ live.add(key);
152
+ // A task keeps its original window across rewrites and status
153
+ // changes within the active set. A task returning to active after
154
+ // finishing starts a fresh window.
155
+ if (!previouslyActive.has(key) || !starts.has(key)) starts.set(key, mutationCount);
156
+ }
157
+ }
158
+ // Finished or dropped tasks close their window; a later task with the
159
+ // same text starts clean instead of inheriting stale evidence.
160
+ for (const key of [...starts.keys()]) {
161
+ if (!live.has(key)) starts.delete(key);
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Completion evidence, enforced: a task may not move to `completed` unless the
167
+ * session mutated at least once inside that task's active window. One mutation
168
+ * cannot vouch for several completions, so N completions need N mutations
169
+ * counted from the earliest of them.
170
+ *
171
+ * Read-only tasks are the documented escape: they carry `[read-only]` in their
172
+ * text and complete freely. Todos are still not authority on finalization; the
173
+ * verification gate remains the authority there.
174
+ *
175
+ * Tasks are matched to their prior occurrence by count, not by last-write-wins,
176
+ * so duplicate content cannot hide a new completion behind an already-completed
177
+ * item of the same text.
178
+ *
179
+ * Returns the tasks that still lack evidence. An empty list means the write may
180
+ * proceed.
181
+ */
182
+ export function unverifiedTodoCompletions(
183
+ previousTodos: TodoItem[] | undefined,
184
+ nextTodos: TodoItem[],
185
+ starts: Map<string, number>,
186
+ mutationCount: number,
187
+ ): string[] {
188
+ // Each prior occurrence can absorb at most one new item of the same key. A
189
+ // prior `completed` occurrence absorbs a new `completed` item silently; any
190
+ // other prior occurrence leaves the new item as a fresh claim.
191
+ const absorbable = new Map<string, number>();
192
+ const absorbCompleted = new Map<string, number>();
193
+ for (const todo of previousTodos ?? []) {
194
+ const key = todoTaskKey(todo);
195
+ if (!key) continue;
196
+ absorbable.set(key, (absorbable.get(key) ?? 0) + 1);
197
+ if (String(todo.status ?? "") === "completed") {
198
+ absorbCompleted.set(key, (absorbCompleted.get(key) ?? 0) + 1);
199
+ }
200
+ }
201
+
202
+ const claimed: Array<{ task: string; start: number }> = [];
203
+ for (const todo of nextTodos) {
204
+ const key = todoTaskKey(todo);
205
+ if (!key) continue;
206
+ if (String(todo.status ?? "") !== "completed") continue;
207
+ const completedBefore = absorbCompleted.get(key) ?? 0;
208
+ if (completedBefore > 0) {
209
+ // An unchanged completed item, not a new claim.
210
+ absorbCompleted.set(key, completedBefore - 1);
211
+ const remaining = absorbable.get(key) ?? 0;
212
+ if (remaining > 0) absorbable.set(key, remaining - 1);
213
+ continue;
214
+ }
215
+ const remaining = absorbable.get(key) ?? 0;
216
+ if (remaining > 0) absorbable.set(key, remaining - 1);
217
+ if (isReadOnlyTodo(todo)) continue;
218
+ claimed.push({ task: todoTaskLabel(todo), start: starts.get(key) ?? 0 });
219
+ }
220
+
221
+ if (claimed.length === 0) return [];
222
+ // Each completion needs its own distinct mutation inside its own window.
223
+ // Checking only the earliest start would let mutations spent before a later
224
+ // task began vouch for it. Sorted by window start, a claim can only be
225
+ // satisfied if the window from that point still holds at least one mutation
226
+ // per remaining claim; the first failure marks an infeasible suffix, since
227
+ // every task after it has an equal or narrower window.
228
+ claimed.sort((a, b) => a.start - b.start);
229
+ for (let index = 0; index < claimed.length; index++) {
230
+ const remainingClaims = claimed.length - index;
231
+ if (mutationCount - claimed[index]!.start < remainingClaims) {
232
+ return claimed.slice(index).map((claim) => claim.task);
233
+ }
234
+ }
235
+ return [];
236
+ }
237
+
238
+ export function readOnlyTodoCompletions(
239
+ previousTodos: TodoItem[] | undefined,
240
+ nextTodos: TodoItem[],
241
+ ): string[] {
242
+ // Count prior occurrences by key and how many were already completed. An
243
+ // item carrying the marker is journaled when it is a new completion, which
244
+ // includes the common case of annotating an already-planned task.
245
+ const completedBefore = new Map<string, number>();
246
+ for (const todo of previousTodos ?? []) {
247
+ const key = todoTaskKey(todo);
248
+ if (!key) continue;
249
+ if (String(todo.status ?? "") === "completed") completedBefore.set(key, (completedBefore.get(key) ?? 0) + 1);
250
+ }
251
+ const readOnly: string[] = [];
252
+ for (const todo of nextTodos) {
253
+ if (String(todo.status ?? "") !== "completed" || !isReadOnlyTodo(todo)) continue;
254
+ const key = todoTaskKey(todo);
255
+ if (!key) continue;
256
+ const unchanged = completedBefore.get(key) ?? 0;
257
+ if (unchanged > 0) {
258
+ completedBefore.set(key, unchanged - 1);
259
+ continue;
260
+ }
261
+ readOnly.push(todoTaskLabel(todo));
262
+ }
263
+ return readOnly;
264
+ }
265
+
266
+ export function todoCompletionBlockReason(blocked: string[]): string {
267
+ const tasks = blocked.map((task) => `'${task}'`).join(", ");
268
+ return (
269
+ `Blocked todowrite: ${blocked.length === 1 ? "task" : "tasks"} ${tasks} marked completed without enough recorded work in the task window. ` +
270
+ "Complete the task's work first, then mark it completed. " +
271
+ "If the task was genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text and mark it completed. " +
272
+ "Use `cancelled` for work that was deliberately dropped."
273
+ );
274
+ }
275
+
276
+ /**
277
+ * Read-only view of a session's task evidence, for `guard_status`. Reports what
278
+ * the completion gate would decide now, so the requirement is visible before it
279
+ * blocks anything.
280
+ */
281
+ export async function todoEvidenceReport(sessionID: string | undefined): Promise<{
282
+ active: number;
283
+ completed: number;
284
+ tasksWithoutRecordedWork: string[];
285
+ readOnlyTasks: string[];
286
+ }> {
287
+ if (!sessionID) return { active: 0, completed: 0, tasksWithoutRecordedWork: [], readOnlyTasks: [] };
288
+ const todos = await effectiveTodos(sessionID);
289
+ if (!todos) return { active: 0, completed: 0, tasksWithoutRecordedWork: [], readOnlyTasks: [] };
290
+ const starts = todoTaskStarts(sessionID);
291
+ const count = getSessionMutationCount(sessionID);
292
+ const active = todos.filter((todo) => ACTIVE_TODO_STATUSES.has(String(todo.status ?? "")));
293
+ const tasksWithoutRecordedWork: string[] = [];
294
+ const readOnlyTasks: string[] = [];
295
+ for (const todo of active) {
296
+ const label = todoTaskLabel(todo);
297
+ if (!label) continue;
298
+ if (isReadOnlyTodo(todo)) {
299
+ readOnlyTasks.push(label);
300
+ continue;
301
+ }
302
+ if (count <= (starts.get(todoTaskKey(todo)) ?? 0)) tasksWithoutRecordedWork.push(label);
303
+ }
304
+ return {
305
+ active: active.length,
306
+ completed: todos.filter((todo) => String(todo.status ?? "") === "completed").length,
307
+ tasksWithoutRecordedWork,
308
+ readOnlyTasks,
309
+ };
310
+ }
311
+
97
312
  export function validateTodoLifecycle(
98
313
  newTodos: TodoItem[],
99
314
  existingTodos: TodoItem[] | undefined,
@@ -115,26 +330,26 @@ export function validateTodoLifecycle(
115
330
  const isReviewObligation = (content: string) => reviewObligation.test(content) && !(remediationAction.test(content) && reviewReference.test(content) && remediationSubject.test(content));
116
331
  const newContentCounts = new Map<string, number>();
117
332
  for (const todo of newTodos) {
118
- const content = String(todo.content ?? "").trim();
333
+ const content = todoTaskKey(todo);
119
334
  newContentCounts.set(content, (newContentCounts.get(content) ?? 0) + 1);
120
335
  }
121
336
  const retainedReviewCounts = new Map<string, number>();
122
337
  for (const todo of activeNew) {
123
- const content = String(todo.content ?? "").trim();
338
+ const content = todoTaskKey(todo);
124
339
  retainedReviewCounts.set(content, (retainedReviewCounts.get(content) ?? 0) + 1);
125
340
  }
126
341
  let retainedReviewObligations = 0;
127
342
  for (const todo of activeExisting) {
128
- const content = String(todo.content ?? "").trim();
343
+ const content = todoTaskKey(todo);
129
344
  const remaining = retainedReviewCounts.get(content) ?? 0;
130
345
  if (remaining > 0 && isReviewObligation(content)) {
131
346
  retainedReviewCounts.set(content, remaining - 1);
132
347
  retainedReviewObligations++;
133
348
  }
134
349
  }
135
- let replacementReviewObligations = activeNew.filter((t) => isReviewObligation(String(t.content ?? ""))).length - retainedReviewObligations;
350
+ let replacementReviewObligations = activeNew.filter((t) => isReviewObligation(todoTaskKey(t))).length - retainedReviewObligations;
136
351
  const missing = activeExisting.find((todo) => {
137
- const content = String(todo.content ?? "").trim();
352
+ const content = todoTaskKey(todo);
138
353
  const remaining = newContentCounts.get(content) ?? 0;
139
354
  if (remaining === 0 && isReviewObligation(content) && replacementReviewObligations > 0) {
140
355
  replacementReviewObligations--;