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.
@@ -6,9 +6,11 @@
6
6
  * - `ctx.tool.transform` for custom tools and tool-description enrichment
7
7
  * - `ctx.tool.hook("execute.before"/"execute.after")` for deterministic enforcement
8
8
  * - `ctx.session.hook("context"/"compaction"/"prompt")` for system guidance,
9
- * compaction context, and recovery checkpoints
10
- * - `ctx.permission.hook("evaluate")` for permission journaling
9
+ * read-only tool pruning, compaction context, and recovery checkpoints
10
+ * - `ctx.command.transform` for the server-side `/guard-status` command
11
+ * - `ctx.permission.hook("evaluate")` for the pre-execution deny seat
11
12
  * - `ctx.shell.hook("create.before")` for env scrubbing
13
+ * - `ctx.mcp.list()` plus `mcp.tools.changed` for the guarded-server inventory
12
14
  * - `ctx.event.subscribe()` for tool outcomes, session lifecycle, and claims-vs-evidence
13
15
  *
14
16
  * The V1 SDK client surface (`TodoSdkClient`) is satisfied by an adapter so
@@ -20,7 +22,7 @@ import { Message } from "@opencode/ai";
20
22
  import { z } from "zod";
21
23
  import { join, resolve } from "node:path";
22
24
  import type { TodoSdkClient } from "./types.ts";
23
- import { scanSessionTodos, toolPartName } from "./v2-todo.ts";
25
+ import { detectTodoCapability, recordTodoCapability, recordedTodoCapability, scanSessionTodos, toolPartName } from "./v2-todo.ts";
24
26
  import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
25
27
  import {
26
28
  setWorkspaceRoot,
@@ -31,10 +33,14 @@ import {
31
33
  getSessionMutationCount,
32
34
  sessionVerifyResults,
33
35
  sessionReviews,
36
+ clearTodoSessionState,
34
37
  isRecoveryCheckpointsEnabled,
35
38
  isLearningEnabled,
36
39
  isProjectMemoryEnabled,
37
40
  getProjectConfig,
41
+ isLiveDocsRequired,
42
+ getLiveDocFailureThreshold,
43
+ getResearchToolPatterns,
38
44
  } from "./state.ts";
39
45
  import {
40
46
  openProjectMemory,
@@ -48,12 +54,14 @@ import {
48
54
  } from "./project-memory.ts";
49
55
  import { secretIn } from "../policies/secrets.ts";
50
56
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
51
- import { isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
57
+ import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
52
58
  import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
53
59
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./tool-outcomes.ts";
54
60
  import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
55
61
  import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
56
62
  import { checkCompletionClaims } from "../policies/completion.ts";
63
+ import { checkSweepingClaims } from "../policies/sweeping-claims.ts";
64
+ import { clearLiveDocsFloor, isResearchTool, liveDocsGatingReason, recordLiveDocsFailure, recordResearchLookup } from "../policies/live-docs.ts";
57
65
  import { releaseFileClaims } from "../policies/file-claims.ts";
58
66
  import { beginReadObservation, recordMutationObservation, recordSuccessfulRead, clearReadFingerprints } from "../policies/stale-write.ts";
59
67
  import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "../policies/post-edit-validation.ts";
@@ -65,6 +73,13 @@ import { loadedPluginVersion } from "./version.ts";
65
73
  import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
66
74
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
67
75
  import { buildCompactionContext, guardToolCall } from "../workflow-guard.ts";
76
+ import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
77
+ import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
78
+ import { evaluatePermissionSeat } from "./permission-seat.ts";
79
+ import { pruneToolsForReadOnlyRole } from "./read-only-tools.ts";
80
+ import { registerGuardStatusCommand } from "./guard-command.ts";
81
+ import { createHostWorktreePort } from "./host-worktree.ts";
82
+ import { listHostMcpServerNames, recordHostMcpServers } from "./mcp-inventory.ts";
68
83
 
69
84
  type V2Context = Plugin.Context;
70
85
 
@@ -88,7 +103,7 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
88
103
  },
89
104
  },
90
105
  session: {
91
- todo: async ({ path }) => ({ data: await scanSessionTodos(ctx, path.id) }),
106
+ todo: async ({ path }) => ({ data: await scanSessionTodos(ctx, path.id, recordedTodoCapability()) }),
92
107
  get: async ({ path }) => {
93
108
  const session = await ctx.session.get({ sessionID: path.id });
94
109
  return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
@@ -110,7 +125,7 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
110
125
  // ── Builtin tool description enrichment ───────────────────────────────────────
111
126
 
112
127
  const TODOWRITE_SUFFIX =
113
- "\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.";
128
+ "\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.";
114
129
 
115
130
  const EDIT_SUFFIX =
116
131
  "\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
@@ -122,12 +137,14 @@ const SUBAGENT_SUFFIX =
122
137
  // host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
123
138
  // candidates are harmless no-ops.
124
139
  //
125
- // V2.0.10 builtin surface (docs + binary): read, glob, grep, edit, write,
126
- // patch, shell, webfetch, websearch, question, skill, subagent, execute.
127
- // - There is no builtin `todowrite` (the V1 name). Todowrite tool parts seen
128
- // in V2 sessions come from ACP agents that supply their own tools; those
129
- // are not in the server registry, so this enrichment cannot reach them.
130
- // The candidate is kept for hosts that do register a todowrite tool.
140
+ // Version boundary for the builtin surface:
141
+ // - V2.0.x (docs + binary, up to and including 2.0.15): read, glob, grep,
142
+ // edit, write, patch, shell, webfetch, websearch, question, skill, subagent,
143
+ // execute. There is no builtin `todowrite`; todowrite parts in V2 sessions
144
+ // come from ACP agents that supply their own tools, which are not in the
145
+ // server registry, so this enrichment cannot reach them.
146
+ // - V2.1.x adds a builtin `todowrite` behind the `experimental.todo` config
147
+ // key, so the candidate below reaches a real registry entry there.
131
148
  // - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
132
149
  // candidates for the same reason.
133
150
  const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
@@ -141,7 +158,10 @@ const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker:
141
158
  export const WorkflowGuardV2 = async (ctx: V2Context) => {
142
159
  const hostRoot = ctx.location?.directory || process.cwd();
143
160
  const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
144
- const projectDirectory = (ctx.location as { project?: { directory?: string; canonical?: string } })?.project?.directory;
161
+ // `location.project` is required by the host's `Location.Info` schema
162
+ // (`@opencode/schema/dist/location.d.ts`), so the optional read is only a
163
+ // runtime guard for harnesses and stubs that construct a partial context.
164
+ const projectDirectory = ctx.location.project?.directory;
145
165
  let effectiveRoot = hostIsFilesystemRoot && projectDirectory && resolve(projectDirectory) !== resolve(projectDirectory, "..")
146
166
  ? projectDirectory
147
167
  : hostRoot;
@@ -154,6 +174,20 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
154
174
  }
155
175
 
156
176
  const client = createV2SdkClient(ctx);
177
+ // Load-once guard: OpenCode 2 merges plugin arrays, so the same package
178
+ // named in global and project config loads this module twice. A duplicate
179
+ // stays inert (no hooks, no tools, no second workspace latch) and leaves a
180
+ // warn-level record; see lib/plugin-instance.ts.
181
+ const instance = registerPluginInstance({ workspaceRoot: effectiveRoot });
182
+ if (instance.duplicate) {
183
+ try {
184
+ console.warn(
185
+ `[workflow-guard] warn: duplicate plugin load for ${instance.workspaceRoot} (module ${instance.modulePath}); ` +
186
+ "this instance stays inert. Configure the guard in one location only.",
187
+ );
188
+ } catch {}
189
+ return () => releasePluginInstance(instance.key);
190
+ }
157
191
  setWorkspaceRoot(effectiveRoot);
158
192
  setSdkClient(client);
159
193
  reloadProjectConfig(effectiveRoot);
@@ -185,7 +219,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
185
219
  const toolOutcomes = new ToolOutcomeTracker();
186
220
 
187
221
  // ── Custom tools + builtin description enrichment ──
188
- const customTools = createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client });
222
+ const customTools = createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client, hostWorktree: createHostWorktreePort(ctx), todoCapability: () => recordedTodoCapability(), hostVersion: () => ctx.app?.version });
189
223
  await ctx.tool.transform((editor) => {
190
224
  for (const [name, definition] of Object.entries(customTools)) {
191
225
  editor.add({
@@ -222,8 +256,32 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
222
256
  }
223
257
  });
224
258
 
259
+ // ── /guard-status command ──
260
+ // Reachable from every V2 client without the optional TUI companion. The
261
+ // report is produced by the same guard_status implementation the tool uses,
262
+ // then delivered as a synthetic message so it costs no model turn.
263
+ await registerGuardStatusCommand(ctx, {
264
+ runStatus: async (sessionID) => {
265
+ const boundWorktree = getSessionBoundWorkspace(sessionID);
266
+ const result = await (customTools.guard_status as unknown as {
267
+ execute: (input: unknown, context: unknown) => Promise<unknown>;
268
+ }).execute({}, {
269
+ sessionID,
270
+ ...(boundWorktree ? { worktree: boundWorktree } : {}),
271
+ directory: boundWorktree ?? effectiveRoot,
272
+ });
273
+ return typeof result === "string" ? result : JSON.stringify(result);
274
+ },
275
+ effectiveRoot,
276
+ client,
277
+ });
278
+
225
279
  // ── Deterministic enforcement ──
226
280
  await ctx.tool.hook("execute.before", (event) => {
281
+ // One policy decision per tool call even if a second instance with a
282
+ // different module path (global cache copy plus a source checkout)
283
+ // registered too. The owner evaluates; the other defers.
284
+ if (!claimToolCall(instance.key, event.sessionID, event.id, event.tool)) return;
227
285
  const toolWorktree = getSessionBoundWorkspace(event.sessionID) || effectiveRoot;
228
286
  return runWithRuntimeState(toolWorktree, client, async () => {
229
287
  const args = event.input;
@@ -234,6 +292,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
234
292
  agent: event.agent,
235
293
  });
236
294
  if (reason !== undefined) {
295
+ releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
237
296
  await showBlockToast(reason);
238
297
  const failureCount = event.sessionID ? toolOutcomes.getFailureCount(event.sessionID) : 0;
239
298
  const circuitBreakerSuffix = failureCount >= 2
@@ -241,11 +300,40 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
241
300
  : "";
242
301
  throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
243
302
  }
303
+ // Policy 25: repeated failures with no live lookup since block the
304
+ // guess-and-retry surfaces until the session grounds itself in
305
+ // current documentation (V1 parity). Research tools are never gated.
306
+ if (event.sessionID && isMutatingSurfaceTool(event.tool)) {
307
+ const threshold = getLiveDocFailureThreshold(toolWorktree);
308
+ if (isLiveDocsRequired(toolWorktree)) {
309
+ const researchReason = liveDocsGatingReason(event.sessionID, event.tool, threshold, getResearchToolPatterns(toolWorktree));
310
+ if (researchReason !== undefined) {
311
+ releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
312
+ await showBlockToast(researchReason);
313
+ console.log(`[workflow-guard] warn: ${researchReason}`);
314
+ audit({
315
+ ts: new Date().toISOString(),
316
+ sessionID: event.sessionID,
317
+ callID: event.id,
318
+ tool: event.tool,
319
+ decision: "block",
320
+ phase: "decision",
321
+ reason: "live_docs_research_floor",
322
+ });
323
+ throw new Error(`[workflow-guard] ${researchReason}`);
324
+ }
325
+ }
326
+ }
244
327
  toolLifecycle.start(event.sessionID, event.id);
245
328
  const record = asRecord(args);
246
329
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
247
330
  const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
248
- registerSessionWorkspaceFromToolCall(event.sessionID, { workdir, filePath, toolWorktree });
331
+ await registerSessionWorkspaceFromToolCall(event.sessionID, {
332
+ workdir,
333
+ filePath,
334
+ toolWorktree,
335
+ mutating: EDIT_TOOL_NAMES.has(event.tool) || SHELL_TOOL_NAMES.has(event.tool),
336
+ });
249
337
  if (event.tool === "read") {
250
338
  const target = editTargets(args, toolWorktree)[0];
251
339
  if (target) {
@@ -286,6 +374,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
286
374
  });
287
375
 
288
376
  await ctx.tool.hook("execute.after", async (event) => {
377
+ releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
289
378
  const startedAt = toolLifecycle.finish(event.sessionID, event.id);
290
379
  const outcome = toolOutcomes.recordFallbackCompleted(event.sessionID, event.id, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
291
380
  if (outcome) {
@@ -339,6 +428,25 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
339
428
  // ── Session request hooks ──
340
429
  await ctx.session.hook("context", (event) => {
341
430
  try {
431
+ // A read-only role must not be able to *choose* a mutating tool. The
432
+ // execute.before seat still denies it, but pruning the per-request
433
+ // tool snapshot removes the call before the model plans it. Pruning
434
+ // is a narrowing only: tools supplied outside the host snapshot
435
+ // (ACP agents, nested Code Mode calls) still meet the backstop.
436
+ if (event.agent && isReadOnlyRole(event.agent)) {
437
+ const removed = pruneToolsForReadOnlyRole(event.tools as Record<string, unknown> | undefined);
438
+ if (removed.length > 0) {
439
+ audit({
440
+ ts: new Date().toISOString(),
441
+ sessionID: event.sessionID,
442
+ tool: "session.context",
443
+ decision: "block",
444
+ phase: "decision",
445
+ reason: "read_only_role_tools_pruned",
446
+ input: { agent: event.agent, removed },
447
+ });
448
+ }
449
+ }
342
450
  const existing = Array.isArray(event.system)
343
451
  ? event.system.map((part) => (typeof part === "string" ? part : (part as { text?: string }).text ?? "")).join("\n")
344
452
  : "";
@@ -388,7 +496,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
388
496
  priorityContextBlocks.push(
389
497
  `## Active Tasks${attribution}\n` +
390
498
  lines.join("\n") +
391
- "\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
499
+ "\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.",
392
500
  );
393
501
  }
394
502
 
@@ -451,22 +559,57 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
451
559
  } catch {}
452
560
  });
453
561
 
454
- // ── Permission journaling ──
455
- await ctx.permission.hook("evaluate", (event) => {
562
+ // ── Permission seat: deny before the tool starts ──
563
+ // `evaluate` sees normalized `action`/`resources` and runs before the tool,
564
+ // so it is the earliest hard stop for shell commands, file mutations, and
565
+ // guarded MCP calls. It cannot see raw tool input, so the raw-argument work
566
+ // (todo lifecycle, stale-write, post-edit snapshots) stays in
567
+ // `execute.before`. See lib/permission-seat.ts for the split and the limits.
568
+ //
569
+ // Flow limit, documented by the host: an explicit configured `deny` is
570
+ // resolved before this hook runs, so the hook never sees it. A guard audit
571
+ // therefore cannot explain a configured deny; only host config can. The
572
+ // hook runs for allow/ask, and a denial here is always a tightening.
573
+ await ctx.permission.hook("evaluate", async (event) => {
574
+ const configuredEffect = event.effect;
456
575
  try {
576
+ const seat = await evaluatePermissionSeat(
577
+ {
578
+ sessionID: event.sessionID,
579
+ agent: event.agent,
580
+ action: event.action,
581
+ resources: event.resources,
582
+ effect: configuredEffect,
583
+ },
584
+ { workspaceRoot: effectiveRoot, client },
585
+ );
586
+ if (seat.decision) {
587
+ event.effect = "deny";
588
+ event.message = seat.decision.message;
589
+ }
457
590
  audit({
458
591
  ts: new Date().toISOString(),
459
592
  sessionID: event.sessionID,
460
593
  tool: "permission.ask",
461
- decision: event.effect === "deny" ? "block" : "allow",
594
+ decision: seat.effect === "deny" ? "block" : "allow",
595
+ phase: "decision",
596
+ reason: seat.decision?.code,
597
+ policyDecision: seat.decision,
462
598
  input: {
463
599
  action: event.action,
464
600
  resources: event.resources,
465
601
  agent: event.agent,
466
- effect: event.effect,
602
+ configuredEffect,
603
+ mappedTool: seat.mappedTool,
467
604
  },
468
605
  });
469
- } catch {}
606
+ } catch (error) {
607
+ // Fail closed: a seat that cannot evaluate must not become a pass.
608
+ try {
609
+ event.effect = "deny";
610
+ event.message = `[workflow-guard permission.evaluate] Blocked: the guard could not evaluate this permission request (${(error as Error)?.message ?? "unknown error"}).`;
611
+ } catch {}
612
+ }
470
613
  });
471
614
 
472
615
  // ── Shell env scrubbing ──
@@ -488,6 +631,47 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
488
631
  } catch {}
489
632
  });
490
633
 
634
+ // ── MCP inventory ──
635
+ // Seed the guarded token set from host-declared servers, then refresh it
636
+ // whenever the host reports that a server's tools changed. Widen-only:
637
+ // removal adds nothing, so a renamed or dropped server can never unguard a
638
+ // tool that still names a live system. The static defaults in
639
+ // policies/mcp.ts stay the floor.
640
+ const seedMcpInventory = async () => {
641
+ try {
642
+ const added = recordHostMcpServers(await listHostMcpServerNames(ctx));
643
+ if (added.length > 0) {
644
+ audit({
645
+ ts: new Date().toISOString(),
646
+ tool: "mcp.inventory",
647
+ decision: "allow",
648
+ phase: "event",
649
+ reason: "host_mcp_servers_widened",
650
+ input: { added },
651
+ });
652
+ }
653
+ } catch {}
654
+ };
655
+ await seedMcpInventory();
656
+
657
+ // ── Todo capability probe ──
658
+ // Ask the host's registry whether `todowrite` exists, instead of inferring
659
+ // it from history. On 2.0.x the answer is no, so the mutation gate fails
660
+ // open; on 2.1.x with `experimental.todo: true` the answer is yes, so a
661
+ // session that never planned is gated. Probe once: the registry is fixed
662
+ // for the life of the process.
663
+ try {
664
+ const capability = await detectTodoCapability(ctx);
665
+ recordTodoCapability(capability);
666
+ audit({
667
+ ts: new Date().toISOString(),
668
+ tool: "todo.capability",
669
+ decision: "allow",
670
+ phase: "event",
671
+ reason: `todo_capability_${capability}`,
672
+ });
673
+ } catch {}
674
+
491
675
  // ── Event stream ──
492
676
  const controller = new AbortController();
493
677
  void (async () => {
@@ -500,6 +684,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
500
684
  client,
501
685
  toolLifecycle,
502
686
  toolOutcomes,
687
+ refreshMcpInventory: seedMcpInventory,
503
688
  });
504
689
  } catch {}
505
690
  }
@@ -508,7 +693,10 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
508
693
  }
509
694
  })();
510
695
 
511
- return () => controller.abort();
696
+ return () => {
697
+ controller.abort();
698
+ releasePluginInstance(instance.key);
699
+ };
512
700
  };
513
701
 
514
702
  // ── Event handling ────────────────────────────────────────────────────────────
@@ -518,6 +706,8 @@ interface V2EventState {
518
706
  client: unknown;
519
707
  toolLifecycle: ToolInvocationLifecycle;
520
708
  toolOutcomes: ToolOutcomeTracker;
709
+ /** Re-read the host MCP roster after `mcp.tools.changed`. */
710
+ refreshMcpInventory?: () => Promise<void>;
521
711
  }
522
712
 
523
713
  async function handleV2Event(
@@ -526,6 +716,14 @@ async function handleV2Event(
526
716
  ): Promise<void> {
527
717
  const { effectiveRoot, client, toolLifecycle, toolOutcomes } = state;
528
718
 
719
+ if (event.type === "mcp.tools.changed") {
720
+ // The host's server roster or one server's tool list changed. Re-read it
721
+ // so a newly connected live-system server is guarded before its tools
722
+ // are callable. Widen-only: this can add tokens, never remove them.
723
+ await state.refreshMcpInventory?.();
724
+ return;
725
+ }
726
+
529
727
  if (event.type === "session.message.content.updated") {
530
728
  const data = event.data as { sessionID?: unknown; content?: unknown } | undefined;
531
729
  const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
@@ -552,6 +750,15 @@ async function handleV2Event(
552
750
  };
553
751
  const outcome = toolOutcomes.record(mapped);
554
752
  if (outcome) {
753
+ // Policy 25 research floor (V1 parity): failures accrue debt;
754
+ // successful live-source lookups clear it. A failed research
755
+ // attempt never accrues debt, so a flaky lookup cannot deepen
756
+ // the block it was meant to escape.
757
+ if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
758
+ if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
759
+ } else if (outcome.status === "error") {
760
+ recordLiveDocsFailure(outcome.sessionID);
761
+ }
555
762
  audit({
556
763
  ts: new Date().toISOString(),
557
764
  sessionID: outcome.sessionID,
@@ -596,6 +803,20 @@ async function handleV2Event(
596
803
  input: { claim: check.claim, evidenceState: check.evidenceState },
597
804
  });
598
805
  }
806
+ // Policy 26: journal sweeping repository claims unsupported by read
807
+ // coverage. Observability only - never mutates the response text.
808
+ const sweeping = checkSweepingClaims(text, { sessionID });
809
+ if (sweeping.sweeping && sweeping.reason) {
810
+ console.log(`[workflow-guard] sweeping claim mismatch: ${sweeping.reason}`);
811
+ audit({
812
+ ts: new Date().toISOString(),
813
+ sessionID,
814
+ tool: "experimental.text.complete",
815
+ decision: "allow",
816
+ reason: `Sweeping claim '${sweeping.claim}' with ${sweeping.readFileCount} file(s) read`,
817
+ input: { claim: sweeping.claim, readFileCount: sweeping.readFileCount },
818
+ });
819
+ }
599
820
  } catch {}
600
821
  return;
601
822
  }
@@ -624,7 +845,9 @@ async function handleV2Event(
624
845
  releaseFileClaims(sessionID);
625
846
  clearReadFingerprints(sessionID);
626
847
  toolLifecycle.clearSession(sessionID);
848
+ clearLiveDocsFloor(sessionID);
627
849
  toolOutcomes.clearSession(sessionID);
850
+ clearTodoSessionState(sessionID);
628
851
  toolLifecycle.takeRecoveryRun(sessionID);
629
852
  await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
630
853
  }
@@ -646,8 +869,24 @@ async function handleV2Event(
646
869
  });
647
870
  return;
648
871
  }
872
+ // `command.executed` exists in the V2 event stream (`data: { name, sessionID,
873
+ // arguments, messageID }`) and carries what the V1 audit wanted: the slash
874
+ // command name, its arguments, and the session. It is journaled here rather
875
+ // than dropped, so a command that mutates workspace state stays attributable.
876
+ if (event.type === "command.executed") {
877
+ const data = event.data as { sessionID?: unknown; name?: unknown; arguments?: unknown } | undefined;
878
+ audit({
879
+ ts: new Date().toISOString(),
880
+ sessionID: typeof data?.sessionID === "string" ? data.sessionID : undefined,
881
+ tool: "command.executed",
882
+ decision: "allow",
883
+ phase: "event",
884
+ reason: typeof data?.name === "string" ? data.name : undefined,
885
+ input: summarizeInput({ name: data?.name, arguments: data?.arguments }),
886
+ });
887
+ return;
888
+ }
649
889
  // Note on V1 events without a V2 counterpart:
650
- // - `command.executed`: no V2 event stream equivalent (audit-only in V1; dropped).
651
890
  // - `permission.updated`: replaced by `permission.asked`/`permission.replied` above.
652
891
  // - `session.created`: exists in the V2 union but needed no V1 behavior beyond
653
892
  // the audit above; session state resets happen on `session.deleted`.