opencode-workflow-guard 1.15.5 → 1.17.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.
@@ -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--;
@@ -22,7 +22,7 @@ import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, re
22
22
  import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
23
23
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
24
24
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
25
- import { guardToolCallImpl, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
25
+ import { guardToolCallImpl, isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
26
26
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
27
27
  import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
28
28
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
@@ -68,6 +68,7 @@ import {
68
68
  resetVerifyState,
69
69
  sessionMutationTimestamps,
70
70
  sessionVerifyResults,
71
+ clearTodoSessionState,
71
72
  getProjectConfig,
72
73
  getLiveControlPlaneRoots,
73
74
  getOperationProfile,
@@ -75,6 +76,9 @@ import {
75
76
  isReviewRequired,
76
77
  isSubagentReviewRequired,
77
78
  isDocumentationRequired,
79
+ isLiveDocsRequired,
80
+ getLiveDocFailureThreshold,
81
+ getResearchToolPatterns,
78
82
  getSubagentMutationBudget,
79
83
  isLearningEnabled,
80
84
  isProjectMemoryEnabled,
@@ -88,6 +92,7 @@ import {
88
92
  getSessionWorkspace,
89
93
  getSessionBoundWorkspace,
90
94
  registerSessionWorkspaceFromToolCall,
95
+ resolveInheritedWorkspace,
91
96
  resolveSessionBoundWorkspace,
92
97
  resolveEffectiveWorkspace,
93
98
  } from "./lib/state.ts";
@@ -112,6 +117,9 @@ export {
112
117
  isReviewRequired,
113
118
  isSubagentReviewRequired,
114
119
  isDocumentationRequired,
120
+ isLiveDocsRequired,
121
+ getLiveDocFailureThreshold,
122
+ getResearchToolPatterns,
115
123
  getSubagentMutationBudget,
116
124
  isLearningEnabled,
117
125
  getLearningInterventionBudget,
@@ -123,6 +131,7 @@ export {
123
131
  getSessionWorkspace,
124
132
  getSessionBoundWorkspace,
125
133
  registerSessionWorkspaceFromToolCall,
134
+ resolveInheritedWorkspace,
126
135
  resolveSessionBoundWorkspace,
127
136
  resolveEffectiveWorkspace,
128
137
  findGitRoot,
@@ -200,7 +209,7 @@ export function managedConfigDiagnostic(platform = process.platform, env: NodeJS
200
209
  : "managed config location is unknown on this platform; plugin provenance is not verified by the OpenCode V1 API";
201
210
  }
202
211
 
203
- export { loadedPluginVersion } from "./lib/version.ts";
212
+ export { loadedPluginVersion, qualifiedHostVersion } from "./lib/version.ts";
204
213
 
205
214
  // ── Verification engine ──────────────────────────────────────────────────────
206
215
  import {
@@ -327,6 +336,20 @@ import { checkCompletionClaims } from "./policies/completion.ts";
327
336
 
328
337
  export { checkCompletionClaims };
329
338
 
339
+ import { checkSweepingClaims } from "./policies/sweeping-claims.ts";
340
+
341
+ export { checkSweepingClaims };
342
+
343
+ import {
344
+ clearLiveDocsFloor,
345
+ isResearchTool,
346
+ liveDocsGatingReason,
347
+ recordLiveDocsFailure,
348
+ recordResearchLookup,
349
+ } from "./policies/live-docs.ts";
350
+
351
+ export { isResearchTool, liveDocsGatingReason };
352
+
330
353
  export { sendDesktopNotification } from "./policies/shell-safety.ts";
331
354
  export { checkInteractiveTtyCommand, checkPackageHygiene, escapeAppleScriptString };
332
355
 
@@ -338,6 +361,14 @@ function logObservation(client: unknown, message: string): void {
338
361
  } catch {}
339
362
  }
340
363
 
364
+ function logWarn(client: unknown, message: string): void {
365
+ try {
366
+ void (client as TodoSdkClient | undefined)?.app?.log?.({
367
+ body: { service: "workflow-guard", level: "warn", message },
368
+ });
369
+ } catch {}
370
+ }
371
+
341
372
  /**
342
373
  * Public guard entry point. Audits every decision (block or allow) to a
343
374
  * durable JSONL file before returning.
@@ -487,12 +518,43 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
487
518
  : "";
488
519
  throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
489
520
  }
521
+ // Policy 25: after repeated failures with no live lookup since,
522
+ // block the guess-and-retry surfaces (mutations and shell) until
523
+ // the session grounds itself in current documentation. Research
524
+ // tools are never gated; an allowed research call records the
525
+ // lookup after it succeeds.
526
+ if (input.sessionID && isMutatingSurfaceTool(input.tool)) {
527
+ const threshold = getLiveDocFailureThreshold(toolWorktree);
528
+ if (isLiveDocsRequired(toolWorktree)) {
529
+ const researchReason = liveDocsGatingReason(input.sessionID, input.tool, threshold, getResearchToolPatterns(toolWorktree));
530
+ if (researchReason !== undefined) {
531
+ await emitBlockFeedback(researchReason);
532
+ logWarn(ctx.client, `[workflow-guard] ${researchReason}`);
533
+ audit({
534
+ ts: new Date().toISOString(),
535
+ sessionID: input.sessionID,
536
+ callID: input.callID,
537
+ tool: input.tool,
538
+ decision: "block",
539
+ phase: "decision",
540
+ reason: "live_docs_research_floor",
541
+ });
542
+ throw new Error(`[workflow-guard] ${researchReason}`);
543
+ }
544
+ }
545
+ }
490
546
  toolLifecycle.start(input.sessionID, input.callID);
491
547
  if (input.sessionID) {
492
548
  const record = asRecord(args);
493
549
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
494
550
  const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
495
- registerSessionWorkspaceFromToolCall(input.sessionID, { workdir, filePath, hostWorktree, toolWorktree });
551
+ await registerSessionWorkspaceFromToolCall(input.sessionID, {
552
+ workdir,
553
+ filePath,
554
+ hostWorktree,
555
+ toolWorktree,
556
+ mutating: EDIT_TOOL_NAMES.has(input.tool) || SHELL_TOOL_NAMES.has(input.tool),
557
+ });
496
558
  }
497
559
  if (input.tool === "read") {
498
560
  const target = editTargets(args, toolWorktree)[0];
@@ -613,7 +675,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
613
675
  priorityContextBlocks.push(
614
676
  `## Active Tasks${attribution}\n` +
615
677
  lines.join("\n") +
616
- "\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
678
+ "\nComplete tasks efficiently and address remaining ones. Mark a task completed only after its work is actually done: the guard blocks a completion with no recorded file mutation in that task's window. Append `[read-only]` to a genuinely read-only task (review, investigation), and use `cancelled` for work that was dropped.",
617
679
  );
618
680
  }
619
681
 
@@ -718,6 +780,20 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
718
780
  input: { claim: check.claim, evidenceState: check.evidenceState },
719
781
  });
720
782
  }
783
+ // Policy 26: journal sweeping repository claims unsupported by
784
+ // read coverage. Observability only - never mutates output.text.
785
+ const sweeping = checkSweepingClaims(text, { sessionID });
786
+ if (sweeping.sweeping && sweeping.reason) {
787
+ logObservation(ctx.client, `[workflow-guard] sweeping claim mismatch: ${sweeping.reason}`);
788
+ audit({
789
+ ts: new Date().toISOString(),
790
+ sessionID,
791
+ tool: "experimental.text.complete",
792
+ decision: "allow",
793
+ reason: `Sweeping claim '${sweeping.claim}' with ${sweeping.readFileCount} file(s) read`,
794
+ input: { claim: sweeping.claim, readFileCount: sweeping.readFileCount },
795
+ });
796
+ }
721
797
  } catch {}
722
798
  },
723
799
 
@@ -731,7 +807,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
731
807
  if (description.includes("verification evidence")) return;
732
808
  output.description =
733
809
  description +
734
- "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
810
+ "\n\nWorkflow Guard lifecycle: use todowrite to plan before working - it is the only view of progress the operator has, and every file mutation is gated on it. Each call replaces the complete task list: preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled, and do not omit active tasks when adding new work. A task may be marked completed only after its own work is recorded: the guard blocks a completion that has no file mutation in that task's active window, and one mutation cannot vouch for several completions. If a task is genuinely read-only (review, investigation, reading), append the marker `[read-only]` to its text; every such use is journaled. Use `cancelled` for work that was deliberately dropped. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
735
811
  return;
736
812
  }
737
813
  if (EDIT_TOOL_NAMES.has(input.toolID)) {
@@ -784,6 +860,17 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
784
860
  if (event?.type === "message.part.updated") {
785
861
  const outcome = toolOutcomes.record((event.properties as { part?: ToolOutcomePart })?.part ?? {});
786
862
  if (outcome) {
863
+ // Policy 25 research floor: a failed call accrues debt; a
864
+ // successful live-source lookup clears it. Unrelated
865
+ // successes intentionally do not clear it. A research
866
+ // attempt never accrues debt even when it fails: otherwise
867
+ // a flaky network would deepen the block the agent is
868
+ // already trying to escape, and the floor would deadlock.
869
+ if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
870
+ if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
871
+ } else if (outcome.status === "error") {
872
+ recordLiveDocsFailure(outcome.sessionID);
873
+ }
787
874
  audit({
788
875
  ts: new Date().toISOString(),
789
876
  sessionID: outcome.sessionID,
@@ -810,7 +897,10 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
810
897
  }
811
898
  if (event?.type === "session.deleted") {
812
899
  const sessionID = (event.properties as { info?: { id?: unknown } })?.info?.id;
813
- if (typeof sessionID === "string") toolOutcomes.clearSession(sessionID);
900
+ if (typeof sessionID === "string") {
901
+ toolOutcomes.clearSession(sessionID);
902
+ clearTodoSessionState(sessionID);
903
+ }
814
904
  }
815
905
  if (event?.type === "session.idle") {
816
906
  const sessionID = (event.properties as { sessionID?: unknown })?.sessionID;
@@ -853,6 +943,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
853
943
  clearReadFingerprints(sessionID);
854
944
  toolLifecycle.clearSession(sessionID);
855
945
  toolLifecycle.takeRecoveryRun(sessionID);
946
+ clearLiveDocsFloor(sessionID);
856
947
  await runWithRuntimeState(effectiveRoot, ctx.client, () => clearContinuationState(sessionID));
857
948
  }
858
949
  }
package/test/test-e2e.mts CHANGED
@@ -11,8 +11,13 @@ const check = (name: string, cond: unknown): boolean => {
11
11
  cond ? (pass++, console.log(" ok " + name)) : (fail++, console.log("FAIL " + name));
12
12
  return Boolean(cond);
13
13
  };
14
+ // A live probe can only produce a guard verdict if the model actually ran.
15
+ // These are host/provider errors that mean it did not: the prompt never
16
+ // reached a model, so scoring the probe as a guard failure would be a false
17
+ // accusation. Match the host's literal error shapes, not the bare phrase, so
18
+ // model prose cannot mask a real guard regression.
14
19
  const providerUnavailable = (output: string): boolean =>
15
- /available credits|insufficient credits|rate limit|too many requests|capacity|overloaded/i.test(output);
20
+ /available credits|insufficient credits|rate limit|too many requests|capacity|overloaded|Model unavailable:/i.test(output);
16
21
  const checkLive = (name: string, cond: unknown, output: string): boolean => {
17
22
  if (!cond && providerUnavailable(output)) {
18
23
  unavailable++;
@@ -45,11 +50,11 @@ const packageEntry = join(testDir, "node_modules", "opencode-workflow-guard", "s
45
50
  const installedPackageDir = join(testDir, "node_modules", "opencode-workflow-guard");
46
51
  const installedPackageJson = JSON.parse(readFileSync(join(testDir, "node_modules", "opencode-workflow-guard", "package.json"), "utf8"));
47
52
  check(
48
- "npm package keeps direct dependencies on latest",
53
+ "npm package pins every direct dependency to an exact version",
49
54
  Object.keys(installedPackageJson.dependencies ?? {}).length > 0 &&
50
- Object.values(installedPackageJson.dependencies).every((spec) => spec === "latest") &&
55
+ Object.values(installedPackageJson.dependencies).every((spec) => /^\d+\.\d+\.\d+$/.test(String(spec))) &&
51
56
  Object.keys(installedPackageJson.devDependencies ?? {}).length > 0 &&
52
- Object.values(installedPackageJson.devDependencies).every((spec) => spec === "latest"),
57
+ Object.values(installedPackageJson.devDependencies).every((spec) => /^\d+\.\d+\.\d+$/.test(String(spec))),
53
58
  );
54
59
  check(
55
60
  "npm tarball installs modular plugin entrypoint",
@@ -226,6 +231,7 @@ const runV2Probes = async (ctx: any) => {
226
231
  \t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
227
232
  \t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
228
233
  \t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
234
+ \t\t"todo-completion": { tool: "todowrite", input: { todos: [{ content: "unstarted e2e work", status: "completed", priority: "medium" }] } },
229
235
  \t};
230
236
  \tconst whyResults: Record<string, any> = {};
231
237
  \tfor (const [name, probe] of Object.entries(whyProbes)) {
@@ -253,6 +259,7 @@ const runV1Probes = async (ctx: any) => {
253
259
  \t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
254
260
  \t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
255
261
  \t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
262
+ \t\t"todo-completion": { tool: "todowrite", input: { todos: [{ content: "unstarted e2e work", status: "completed", priority: "medium" }] } },
256
263
  \t};
257
264
  \tconst whyResults: Record<string, any> = {};
258
265
  \tfor (const [name, probe] of Object.entries(whyProbes)) {
@@ -322,8 +329,11 @@ if (isOpenCodeV2) {
322
329
  const servicePort = 49400 + (process.pid % 1000);
323
330
  spawnSync("opencode", ["service", "set", "port", String(servicePort)], { cwd: testDir, encoding: "utf8", env: runtimeEnv, timeout: 30_000 });
324
331
  }
332
+ // `run` takes its directory from the process cwd; 2.0.15 rejects `--dir` as an
333
+ // unknown flag, which made every live probe report a flag error instead of a
334
+ // guard verdict.
325
335
  const runOpenCode = (args: string[], timeout: number) =>
326
- spawnSync("opencode", ["run", "--dir", testDir, ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
336
+ spawnSync("opencode", ["run", ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
327
337
  const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
328
338
  const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: runtimeEnv });
329
339
  let stdout = "";
@@ -401,6 +411,9 @@ check("headless guard_why simulates secret read policy via read tool", whyProbe(
401
411
  check("headless guard_why simulates secret read policy via shell", whyProbe("secrets-shell")?.policy === "secrets" && whyProbe("secrets-shell")?.code === "secret_read" && whyProbe("secrets-shell")?.status === "blocked");
402
412
  check("headless guard_why simulates workspace boundary policy via write tool", whyProbe("boundary-write")?.policy === "boundary" && whyProbe("boundary-write")?.code === "workspace_escape" && whyProbe("boundary-write")?.status === "blocked");
403
413
  check("headless guard_why simulates interactive tty shell-safety policy", whyProbe("shell-tty")?.policy === "shell-safety" && whyProbe("shell-tty")?.code === "interactive_tty" && whyProbe("shell-tty")?.status === "blocked");
414
+ // The completion gate is host-version independent: claiming work the session
415
+ // never did is blocked through the real dispatcher, on both adapter paths.
416
+ check("headless guard_why simulates the todo completion evidence gate", whyProbe("todo-completion")?.policy === "todo" && whyProbe("todo-completion")?.code === "todo_completion_unverified" && whyProbe("todo-completion")?.status === "blocked");
404
417
  check("headless guard_status reports project config loaded from .opencode/workflow-guard.json", headlessAccountability?.status?.projectConfig?.protectedBranches?.[0] === "e2e-config-probe-branch" && headlessAccountability?.status?.projectConfig?.requireReview === false);
405
418
  if (isOpenCodeV2) {
406
419
  // The V2 adapter runs the shipped setup(ctx) for real; verify the
@@ -421,6 +434,78 @@ if (isOpenCodeV2) {
421
434
  console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
422
435
  }
423
436
 
437
+ // The todo capability probe is only meaningful against a host that can gate
438
+ // the tool. Probe the flag rather than the version: a host that does not know
439
+ // `experimental.todo` keeps the capability unavailable, which is the branch
440
+ // the main pass already covers.
441
+ const hostMinor = Number(opencodeSemver.split(".")[1] ?? 0);
442
+ if (isOpenCodeV2 && (opencodeMajor > 2 || (opencodeMajor === 2 && hostMinor >= 1))) {
443
+ const todoConfig = join(testDir, ".opencode", "opencode.json");
444
+ const previousConfig = readFileSync(todoConfig, "utf8");
445
+ const previousAccountability = readFileSync(accountabilityMarker, "utf8");
446
+ rmSync(initializedMarker, { force: true });
447
+ // A second pass needs its own XDG roots and service port. Reusing the first
448
+ // pass's state dirs reconnects to its already-running service, which serves
449
+ // the config it started with and never re-runs plugin setup.
450
+ const todoEnv: NodeJS.ProcessEnv = {
451
+ ...runtimeEnv,
452
+ XDG_CONFIG_HOME: join(testDir, "runtime-config-todo"),
453
+ XDG_STATE_HOME: join(testDir, "runtime-state-todo"),
454
+ XDG_DATA_HOME: join(testDir, "runtime-data-todo"),
455
+ };
456
+ const todoPort = 49400 + ((process.pid + 500) % 1000);
457
+ spawnSync("opencode", ["service", "set", "port", String(todoPort)], { cwd: testDir, encoding: "utf8", env: todoEnv, timeout: 30_000 });
458
+ try {
459
+ const parsed = JSON.parse(previousConfig);
460
+ writeFileSync(todoConfig, `${JSON.stringify({ ...parsed, experimental: { ...(parsed.experimental ?? {}), todo: true } }, null, 2)}\n`);
461
+ const todoProbe = await new Promise<{ status: number | null }>((resolve) => {
462
+ const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: todoEnv });
463
+ let timer: NodeJS.Timeout | undefined;
464
+ const poll = setInterval(() => {
465
+ if (existsSync(initializedMarker)) {
466
+ clearInterval(poll);
467
+ if (timer) clearTimeout(timer);
468
+ try { child.kill("SIGTERM"); } catch {}
469
+ resolve({ status: 0 });
470
+ }
471
+ }, 50);
472
+ timer = setTimeout(() => {
473
+ clearInterval(poll);
474
+ try { child.kill("SIGTERM"); } catch {}
475
+ resolve({ status: null });
476
+ }, 30_000);
477
+ child.on("error", () => {
478
+ clearInterval(poll);
479
+ if (timer) clearTimeout(timer);
480
+ resolve({ status: null });
481
+ });
482
+ });
483
+ let flagged: any;
484
+ try {
485
+ flagged = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
486
+ } catch {}
487
+ // Flag on: the host registry exposes todowrite, so the guard reports the
488
+ // capability available and the gate stops failing open.
489
+ check(
490
+ "V2 todo capability reports available when experimental.todo is enabled",
491
+ todoProbe.status === 0 &&
492
+ (flagged?.tools ?? []).includes("todowrite") &&
493
+ flagged?.status?.todoCapability === "available",
494
+ );
495
+ // The main pass ran with the flag off, so the capability there must be
496
+ // unavailable. Together the two passes prove the probe reads the
497
+ // registry instead of inferring from history.
498
+ check(
499
+ "V2 todo capability reports unavailable when experimental.todo is disabled",
500
+ (headlessAccountability?.tools ?? []).includes("todowrite") === false &&
501
+ headlessAccountability?.status?.todoCapability === "unavailable",
502
+ );
503
+ } finally {
504
+ writeFileSync(todoConfig, previousConfig);
505
+ writeFileSync(accountabilityMarker, previousAccountability);
506
+ }
507
+ }
508
+
424
509
  if (process.env.WORKFLOW_GUARD_LIVE_E2E !== "1") {
425
510
  console.log("SKIP: model-driven policy probes require WORKFLOW_GUARD_LIVE_E2E=1.");
426
511
  if (!process.env.WG_E2E_KEEP) rmSync(testDir, { recursive: true, force: true });
@@ -450,7 +535,7 @@ console.log(` Using live model: ${liveModel}`);
450
535
 
451
536
  // 3. Test: Direct edit without task list is blocked by the loaded plugin
452
537
  console.log(" Running live OpenCode prompt to verify plugin intercept...");
453
- const run1 = runOpenCode(["--model", liveModel, "--print-logs", "--log-level", "DEBUG", "Do not call todowrite. Invoke bash immediately with exactly this command: printf blocked > blocked.txt. This is a guard test; make the tool call even if you expect rejection."], 120_000);
538
+ const run1 = runOpenCode(["--model", liveModel, "--print-logs", "--log-level", "debug", "Do not call todowrite. Invoke bash immediately with exactly this command: printf blocked > blocked.txt. This is a guard test; make the tool call even if you expect rejection."], 120_000);
454
539
 
455
540
  const output1 = run1.stdout + run1.stderr;
456
541
  const adapterInitialized = existsSync(initializedMarker);