opencode-workflow-guard 1.19.0 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/state.ts CHANGED
@@ -81,6 +81,10 @@ export function clearTodoSessionState(sessionID: string): void {
81
81
  // instance must never outrank the call's own context or the host fallback.
82
82
  const sessionWorkspaceBound = new Set<string>();
83
83
 
84
+ // Last directory the HOST named per session, so a repeated `session.updated`
85
+ // costs one string comparison instead of a `git rev-parse` child process.
86
+ const sessionHostDirectories = new Map<string, string>();
87
+
84
88
  export function setSessionWorkspace(sessionID: string, workspace: string): void {
85
89
  if (!sessionID || !workspace) return;
86
90
  const gitRoot = findGitRoot(workspace);
@@ -88,6 +92,18 @@ export function setSessionWorkspace(sessionID: string, workspace: string): void
88
92
  sessionWorkspaceBound.add(sessionID);
89
93
  }
90
94
 
95
+ /**
96
+ * Drop a deleted session's workspace binding and host-directory cache entry.
97
+ *
98
+ * Both are keyed by session id, so a deleted session can never read them again.
99
+ * Leaving them would grow both maps for the life of a long-lived server.
100
+ */
101
+ export function forgetSessionWorkspace(sessionID: string): void {
102
+ sessionWorkspaces.delete(sessionID);
103
+ sessionWorkspaceBound.delete(sessionID);
104
+ sessionHostDirectories.delete(sessionID);
105
+ }
106
+
91
107
  export function getSessionWorkspace(sessionID?: string): string | undefined {
92
108
  if (!sessionID) return undefined;
93
109
  return sessionWorkspaces.get(sessionID);
@@ -236,11 +252,74 @@ function auditWorkspaceRebind(sessionID: string, from: string | undefined, to: s
236
252
  tool: "workspace.binding",
237
253
  decision: "allow",
238
254
  phase: "event",
239
- reason: "workspace_rebind_host_worktree",
255
+ reason: source === "session-move" ? "workspace_rebind_session_move" : "workspace_rebind_host_worktree",
240
256
  input: { from, to, source },
241
257
  });
242
258
  }
243
259
 
260
+ /**
261
+ * Rebind a session to the workspace the HOST says it now occupies.
262
+ *
263
+ * `session_move` (and the equivalent host move command) relocates a session
264
+ * between checkouts. Without this the binding stayed on the old checkout, so
265
+ * every later boundary, branch, and PR decision judged the tree the session had
266
+ * left: the move looked like it had no effect. A host move is an explicit
267
+ * boundary statement from the host, in the same class as a per-call worktree,
268
+ * so it may widen an already-bound session. It is journaled for that reason.
269
+ *
270
+ * The hard part is telling a move from a routine update. `session.updated` is a
271
+ * hot event, emitted for title and cost changes, and it names the session's
272
+ * creation directory. A session bound to a LINKED WORKTREE by its own tool-call
273
+ * `workdir` legitimately differs from that directory, so treating every update
274
+ * as a move pulled the session back to the checkout it had deliberately left.
275
+ * Three signals therefore count, and nothing else does:
276
+ *
277
+ * - The host announced the relocation itself (`moved: true`, from
278
+ * `session.moved`).
279
+ * - The directory the host names now differs from the one it named last for
280
+ * this session.
281
+ * - The named repository is a DIFFERENT repository from the bound workspace. A
282
+ * linked worktree is the same repository, so this catches the cross-repository
283
+ * move that a first observation would otherwise miss on OpenCode 1.x, which
284
+ * has no move event.
285
+ *
286
+ * The last directory is remembered so a repeated update costs one string
287
+ * comparison instead of a `git rev-parse` child process. The cache stores a
288
+ * directory that actually resolved to a repository root: an unresolved
289
+ * directory is not cached, so a later event for the same path retries instead
290
+ * of being suppressed forever.
291
+ */
292
+ export function rebindSessionWorkspaceFromHost(
293
+ sessionID: string | undefined,
294
+ directory: string | undefined,
295
+ options: { moved?: boolean } = {},
296
+ ): void {
297
+ if (!sessionID || !directory) return;
298
+ const gitRoot = findGitRoot(directory);
299
+ if (!gitRoot) return;
300
+ const previousHostDirectory = sessionHostDirectories.get(sessionID);
301
+ sessionHostDirectories.set(sessionID, directory);
302
+ const previous = sessionWorkspaces.get(sessionID);
303
+ const moved =
304
+ options.moved === true
305
+ || (previousHostDirectory !== undefined && previousHostDirectory !== directory)
306
+ || (previous !== undefined && gitRoot !== previous && !isSameGitRepo(gitRoot, previous));
307
+ if (!moved) {
308
+ if (sessionWorkspaceBound.has(sessionID)) return;
309
+ // A bootstrap entry that was never bound still needs binding: the host
310
+ // just named the session's real directory.
311
+ sessionWorkspaces.set(sessionID, gitRoot);
312
+ sessionWorkspaceBound.add(sessionID);
313
+ return;
314
+ }
315
+ if (previous === gitRoot) {
316
+ sessionWorkspaceBound.add(sessionID);
317
+ return;
318
+ }
319
+ setSessionWorkspace(sessionID, gitRoot);
320
+ auditWorkspaceRebind(sessionID, previous, gitRoot, "session-move");
321
+ }
322
+
244
323
  /**
245
324
  * Durable record of a refused widening. The refusal itself is the boundary
246
325
  * policy's decision; this entry exists so an operator can tell whether a
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Single source of truth for the builtin tool-description suffixes.
3
+ *
4
+ * Both entrypoints append the same text: the V1 `tool.definition` hook in
5
+ * `workflow-guard.ts` and the V2 `ctx.tool.transform` enrichment in
6
+ * `lib/v2-plugin.ts`. Keeping one copy prevents the two hosts from drifting
7
+ * apart, which would make a model's expectations depend on which host it runs
8
+ * on.
9
+ *
10
+ * Each suffix carries a marker. The marker is the idempotence key: a
11
+ * description already containing it is left untouched, so a second plugin
12
+ * instance (or a re-applied transform) cannot append the text twice.
13
+ */
14
+
15
+ export const TODOWRITE_MARKER = "verification evidence";
16
+ export const TODOWRITE_SUFFIX =
17
+ "\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.";
18
+
19
+ export const EDIT_MARKER = "Workflow Guard requirement";
20
+ export const EDIT_SUFFIX =
21
+ "\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.";
22
+
23
+ export const SUBAGENT_MARKER = "Workflow Guard subagent guidance";
24
+ export const SUBAGENT_SUFFIX =
25
+ "\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
26
+
27
+ /**
28
+ * Append `suffix` unless the description already carries `marker`.
29
+ *
30
+ * Returns `undefined` when the suffix is already present, so a caller can tell
31
+ * "nothing to do" from "wrote an empty string".
32
+ */
33
+ export function appendToolDescriptionSuffix(description: string, suffix: string, marker: string): string | undefined {
34
+ if (description.includes(marker)) return undefined;
35
+ return description + suffix;
36
+ }
package/src/lib/types.ts CHANGED
@@ -19,8 +19,14 @@ export interface TodoSdkClient {
19
19
  get?: (opts: { path: { id: string } }) => Promise<{ data?: { parentID?: unknown; title?: unknown; time?: { idle?: unknown } } }>;
20
20
  promptAsync?: (opts: {
21
21
  path: { id: string };
22
- body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }> };
22
+ body: { messageID?: string; parts: Array<{ type: "text"; text: string; synthetic?: boolean }>; delivery?: "steer" | "queue"; resume?: boolean };
23
23
  }) => Promise<unknown>;
24
+ /**
25
+ * V2 only. The shim forwards a synthetic continuation through here, and
26
+ * the presence of this method is what marks a V2 client in
27
+ * `policies/continuation.ts` for `delivery`/`resume` support.
28
+ */
29
+ synthetic?: (opts: { sessionID: string; text: string; delivery?: "steer" | "queue"; resume?: boolean }) => Promise<unknown>;
24
30
  };
25
31
  tui?: {
26
32
  showToast?: (opts: { body: { title?: string; message: string; variant?: string } }) => Promise<unknown>;
@@ -247,6 +253,13 @@ export interface AuditEntry {
247
253
  export interface GitInvocation {
248
254
  repoDir: string;
249
255
  rest: string;
256
+ /**
257
+ * True when the command text itself names the repository (`-C`,
258
+ * `--git-dir`, `--work-tree`, `GIT_DIR=`). False means `repoDir` is only
259
+ * the invocation's assumed base directory, which is the shell's working
260
+ * directory and not a fact the command string carries.
261
+ */
262
+ explicitRepoDir?: boolean;
250
263
  }
251
264
 
252
265
  export interface ShellMutation {
@@ -10,7 +10,7 @@
10
10
  * - `ctx.command.transform` for the server-side `/guard-status` command
11
11
  * - `ctx.permission.hook("evaluate")` for the pre-execution deny seat
12
12
  * - `ctx.shell.hook("create.before")` for env scrubbing
13
- * - `ctx.mcp.list()` plus `mcp.tools.changed` for the guarded-server inventory
13
+ * - `ctx.mcp.list()` plus `mcp.status.changed` for the guarded-server inventory
14
14
  * - `ctx.event.subscribe()` for tool outcomes, session lifecycle, and claims-vs-evidence
15
15
  *
16
16
  * The V1 SDK client surface (`TodoSdkClient`) is satisfied by an adapter so
@@ -22,12 +22,14 @@ import { Message } from "@opencode/ai/effect";
22
22
  import { z } from "zod";
23
23
  import { join, resolve } from "node:path";
24
24
  import type { TodoSdkClient } from "./types.ts";
25
- import { detectTodoCapability, recordTodoCapability, recordedTodoCapability, scanSessionTodos, toolPartName } from "./v2-todo.ts";
25
+ import { detectTodoCapability, recordTodoCapability, recordedTodoCapability, scanSessionTodos } from "./v2-todo.ts";
26
26
  import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
27
27
  import {
28
28
  setWorkspaceRoot,
29
29
  setSdkClient,
30
30
  getSessionBoundWorkspace,
31
+ rebindSessionWorkspaceFromHost,
32
+ forgetSessionWorkspace,
31
33
  registerSessionWorkspaceFromToolCall,
32
34
  runWithRuntimeState,
33
35
  getSessionMutationCount,
@@ -58,7 +60,7 @@ import { secretIn } from "../policies/secrets.ts";
58
60
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./custom-tools.ts";
59
61
  import { isMutatingSurfaceTool, isReadOnlyRole, SHELL_TOOL_NAMES } from "./guard-dispatcher.ts";
60
62
  import { ToolInvocationLifecycle } from "./tool-lifecycle.ts";
61
- import { ToolOutcomeTracker, type ToolOutcomePart } from "./tool-outcomes.ts";
63
+ import { ToolOutcomeTracker } from "./tool-outcomes.ts";
62
64
  import { EDIT_TOOL_NAMES, fetchParentSession, fetchParentSessionID, effectiveTodos } from "../policies/todo.ts";
63
65
  import { currentGitBranch, isProtectedBranchName } from "../policies/git.ts";
64
66
  import { checkCompletionClaims } from "../policies/completion.ts";
@@ -70,11 +72,21 @@ import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } f
70
72
  import { extractCommands, splitShellSegments } from "./shell.ts";
71
73
  import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "../policies/boundary.ts";
72
74
  import { audit, summarizeInput } from "./audit.ts";
73
- import { asRecord, showBlockToast, isSensitiveEnvKey } from "./utils.ts";
75
+ import { asRecord, isSensitiveEnvKey } from "./utils.ts";
74
76
  import { loadedPluginVersion } from "./version.ts";
75
- import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, recordUserMessage } from "../policies/continuation.ts";
77
+ import { clearContinuationState, continueUnfinishedSession, isGeneratedContinuationMessage, markInterrupted, recordUserMessage } from "../policies/continuation.ts";
76
78
  import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, nextRecoveryRun } from "./checkpoint.ts";
77
- import { buildCompactionContext, evaluateDogfoodGate, guardToolCall } from "../workflow-guard.ts";
79
+ import { buildCompactionContext, guardToolCall } from "./guard-api.ts";
80
+ import { evaluateDogfoodGate } from "./dogfood.ts";
81
+ import {
82
+ appendToolDescriptionSuffix,
83
+ EDIT_MARKER,
84
+ EDIT_SUFFIX,
85
+ SUBAGENT_MARKER,
86
+ SUBAGENT_SUFFIX,
87
+ TODOWRITE_MARKER,
88
+ TODOWRITE_SUFFIX,
89
+ } from "./tool-descriptions.ts";
78
90
  import { registerPluginInstance, releasePluginInstance } from "./plugin-instance.ts";
79
91
  import { claimToolCall, releaseToolCallClaim } from "./call-claims.ts";
80
92
  import { evaluatePermissionSeat } from "./permission-seat.ts";
@@ -92,11 +104,12 @@ type V2Context = Plugin.Context;
92
104
  // capability) so builtin-only sessions are not gated forever. See v2-todo.ts.
93
105
 
94
106
  function createV2SdkClient(ctx: V2Context): TodoSdkClient {
95
- // V2 has no server-side log API or TUI surface, so app.log degrades to
96
- // console logging and showBlockToast becomes a no-op. Block reasons still
97
- // reach the agent through the thrown tool-hook error; keep this adapter
98
- // from logging anything beyond guard decision summaries.
99
- return {
107
+ // V2 has no server-side log API or TUI surface. The client shim supplies
108
+ // only { app, session, lsp }, so there is no `tui.showToast` to call: block
109
+ // paths on V2 do not attempt a toast and rely on the thrown hook error,
110
+ // which is what actually reaches the model. Keep this adapter from logging
111
+ // anything beyond guard decision summaries.
112
+ const client: TodoSdkClient = {
100
113
  app: {
101
114
  log: async ({ body }) => {
102
115
  try {
@@ -110,10 +123,32 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
110
123
  const session = await ctx.session.get({ sessionID: path.id });
111
124
  return { data: { parentID: (session as { parentID?: string }).parentID, title: (session as { title?: string }).title } };
112
125
  },
126
+ // V2 only: forwards a synthetic message with `delivery`/`resume`.
127
+ // Its presence is also what marks this client as V2 for
128
+ // `policies/continuation.ts`, which is why the values travel here
129
+ // instead of through the shared `promptAsync` body type.
130
+ synthetic: async (opts) => {
131
+ await ctx.session.synthetic({
132
+ sessionID: opts.sessionID,
133
+ text: opts.text,
134
+ ...(opts.delivery ? { delivery: opts.delivery } : {}),
135
+ ...(opts.resume === undefined ? {} : { resume: opts.resume }),
136
+ });
137
+ },
113
138
  promptAsync: async (opts) => {
114
- for (const part of opts.body.parts) {
139
+ const body = opts.body as {
140
+ parts: Array<{ type: "text"; text: string; synthetic?: boolean }>;
141
+ delivery?: "steer" | "queue";
142
+ resume?: boolean;
143
+ };
144
+ for (const part of body.parts) {
115
145
  if (part.synthetic) {
116
- await ctx.session.synthetic({ sessionID: opts.path.id, text: part.text });
146
+ await client.session!.synthetic!({
147
+ sessionID: opts.path.id,
148
+ text: part.text,
149
+ ...(body.delivery ? { delivery: body.delivery } : {}),
150
+ ...(body.resume === undefined ? {} : { resume: body.resume }),
151
+ });
117
152
  } else {
118
153
  await ctx.session.prompt({ sessionID: opts.path.id, text: part.text });
119
154
  }
@@ -122,19 +157,9 @@ function createV2SdkClient(ctx: V2Context): TodoSdkClient {
122
157
  },
123
158
  lsp: {},
124
159
  };
160
+ return client;
125
161
  }
126
162
 
127
- // ── Builtin tool description enrichment ───────────────────────────────────────
128
-
129
- const TODOWRITE_SUFFIX =
130
- "\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.";
131
-
132
- const EDIT_SUFFIX =
133
- "\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.";
134
-
135
- const SUBAGENT_SUFFIX =
136
- "\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
137
-
138
163
  // Builtin tool description enrichment. Candidates are matched against the
139
164
  // host's tool registry; `ToolEditor.update` ignores missing IDs, so extra
140
165
  // candidates are harmless no-ops.
@@ -153,9 +178,9 @@ const SUBAGENT_SUFFIX =
153
178
  // - The V1 subagent tool `task` was renamed to `subagent`; both are kept as
154
179
  // candidates for the same reason.
155
180
  const ENRICHMENT_TARGETS: Array<{ candidates: string[]; suffix: string; marker: string }> = [
156
- { candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker: "verification evidence" },
157
- { candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker: "Workflow Guard requirement" },
158
- { candidates: ["task", "subagent"], suffix: SUBAGENT_SUFFIX, marker: "Workflow Guard subagent guidance" },
181
+ { candidates: ["todowrite"], suffix: TODOWRITE_SUFFIX, marker: TODOWRITE_MARKER },
182
+ { candidates: [...EDIT_TOOL_NAMES], suffix: EDIT_SUFFIX, marker: EDIT_MARKER },
183
+ { candidates: ["task", "subagent"], suffix: SUBAGENT_SUFFIX, marker: SUBAGENT_MARKER },
159
184
  ];
160
185
 
161
186
  // ── Setup ─────────────────────────────────────────────────────────────────────
@@ -254,8 +279,8 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
254
279
  for (const id of target.candidates) {
255
280
  editor.update(id, (tool) => {
256
281
  const description = typeof tool.description === "string" ? tool.description : "";
257
- if (description.includes(target.marker)) return;
258
- tool.description = description + target.suffix;
282
+ const next = appendToolDescriptionSuffix(description, target.suffix, target.marker);
283
+ if (next !== undefined) tool.description = next;
259
284
  });
260
285
  }
261
286
  }
@@ -298,7 +323,6 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
298
323
  });
299
324
  if (reason !== undefined) {
300
325
  releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
301
- await showBlockToast(reason);
302
326
  const failureCount = event.sessionID ? toolOutcomes.getFailureCount(event.sessionID) : 0;
303
327
  const circuitBreakerSuffix = failureCount >= 2
304
328
  ? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly, or inspect policy details with guard_status or guard_why.]"
@@ -314,7 +338,6 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
314
338
  const researchReason = liveDocsGatingReason(event.sessionID, event.tool, threshold, getResearchToolPatterns(toolWorktree));
315
339
  if (researchReason !== undefined) {
316
340
  releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
317
- await showBlockToast(researchReason);
318
341
  console.log(`[workflow-guard] warn: ${researchReason}`);
319
342
  audit({
320
343
  ts: new Date().toISOString(),
@@ -381,19 +404,13 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
381
404
  await ctx.tool.hook("execute.after", async (event) => {
382
405
  releaseToolCallClaim(instance.key, event.sessionID, event.id, event.tool);
383
406
  const startedAt = toolLifecycle.finish(event.sessionID, event.id);
384
- const outcome = toolOutcomes.recordFallbackCompleted(event.sessionID, event.id, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
385
- if (outcome) {
386
- audit({
387
- ts: new Date().toISOString(),
388
- sessionID: outcome.sessionID,
389
- callID: outcome.callID,
390
- tool: outcome.tool,
391
- decision: "allow",
392
- phase: "outcome",
393
- durationMs: outcome.durationMs,
394
- reason: outcome.status,
395
- });
396
- }
407
+ recordV2ToolOutcome(toolOutcomes, effectiveRoot, {
408
+ tool: event.tool,
409
+ sessionID: event.sessionID,
410
+ callID: event.id,
411
+ status: event.status,
412
+ error: event.status === "error" ? event.error : undefined,
413
+ }, startedAt);
397
414
  releaseFileClaims(event.sessionID, event.id);
398
415
  if (event.tool === "read") {
399
416
  const observation = toolLifecycle.takeReadObservation(event.sessionID, event.id);
@@ -719,93 +736,127 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
719
736
  };
720
737
  };
721
738
 
739
+ // ── Tool outcome telemetry ────────────────────────────────────────────────────
740
+
741
+ /**
742
+ * Record one tool call's terminal outcome from `execute.after`.
743
+ *
744
+ * The former `session.message.content.updated` subscription never fired: that
745
+ * type is absent from `EventManifest.ServerDefinitions`, so no tool part was
746
+ * ever mapped and the repeated-failure circuit breaker plus the Policy 25
747
+ * research floor both read an always-zero count. `execute.after` does fire,
748
+ * including for a failed call, because the host triggers the hook with
749
+ * `status: "error"` before it rethrows. The error's own message feeds the
750
+ * tracker's signature, so repeat detection keys on what the model actually saw.
751
+ */
752
+ function recordV2ToolOutcome(
753
+ toolOutcomes: ToolOutcomeTracker,
754
+ effectiveRoot: string,
755
+ event: {
756
+ tool: string;
757
+ sessionID?: string;
758
+ callID: string;
759
+ status?: "completed" | "error";
760
+ error?: { message?: string } | string;
761
+ },
762
+ startedAt?: number,
763
+ ): void {
764
+ const status = event.status;
765
+ if (status !== "completed" && status !== "error") {
766
+ // The host's `execute.after` always carries a terminal status. A partial
767
+ // event from a harness or stub leaves it unknown, so record the
768
+ // optimistic completion that this hook used to record unconditionally
769
+ // rather than dropping the call from telemetry entirely. The audit row
770
+ // is kept here so this path does not lose the coverage it had before
771
+ // outcomes moved onto `execute.after`.
772
+ const fallback = toolOutcomes.recordFallbackCompleted(event.sessionID ?? "", event.callID, event.tool, startedAt === undefined ? undefined : Date.now() - startedAt);
773
+ if (fallback) {
774
+ audit({
775
+ ts: new Date().toISOString(),
776
+ sessionID: fallback.sessionID,
777
+ callID: fallback.callID,
778
+ tool: fallback.tool,
779
+ decision: "allow",
780
+ phase: "outcome",
781
+ durationMs: fallback.durationMs,
782
+ reason: fallback.status,
783
+ });
784
+ }
785
+ return;
786
+ }
787
+ const error = typeof event.error === "string" ? event.error : event.error?.message;
788
+ const outcome = toolOutcomes.record({
789
+ type: "tool",
790
+ sessionID: event.sessionID,
791
+ callID: event.callID,
792
+ tool: event.tool,
793
+ state: {
794
+ status,
795
+ error,
796
+ time: startedAt === undefined ? undefined : { start: startedAt, end: Date.now() },
797
+ },
798
+ });
799
+ if (!outcome) return;
800
+ // Policy 25 research floor (V1 parity): failures accrue debt; successful
801
+ // live-source lookups clear it. A failed research attempt never accrues
802
+ // debt, so a flaky lookup cannot deepen the block it was meant to escape.
803
+ if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
804
+ if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
805
+ } else if (outcome.status === "error") {
806
+ recordLiveDocsFailure(outcome.sessionID);
807
+ }
808
+ audit({
809
+ ts: new Date().toISOString(),
810
+ sessionID: outcome.sessionID,
811
+ callID: outcome.callID,
812
+ tool: outcome.tool,
813
+ decision: "allow",
814
+ phase: "outcome",
815
+ durationMs: outcome.durationMs,
816
+ reason: outcome.status,
817
+ });
818
+ if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
819
+ audit({
820
+ ts: new Date().toISOString(),
821
+ sessionID: outcome.sessionID,
822
+ callID: outcome.callID,
823
+ tool: outcome.tool,
824
+ decision: "allow",
825
+ phase: "event",
826
+ reason: "repeated-equivalent-failure:3",
827
+ });
828
+ console.log(`[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
829
+ }
830
+ }
831
+
722
832
  // ── Event handling ────────────────────────────────────────────────────────────
723
833
 
724
- interface V2EventState {
834
+ export interface V2EventState {
725
835
  effectiveRoot: string;
726
836
  client: unknown;
727
837
  toolLifecycle: ToolInvocationLifecycle;
728
838
  toolOutcomes: ToolOutcomeTracker;
729
- /** Re-read the host MCP roster after `mcp.tools.changed`. */
839
+ /** Re-read the host MCP roster after `mcp.status.changed`. */
730
840
  refreshMcpInventory?: () => Promise<void>;
731
841
  }
732
842
 
733
- async function handleV2Event(
843
+ export async function handleV2Event(
734
844
  event: { type?: string; data?: unknown },
735
845
  state: V2EventState,
736
846
  ): Promise<void> {
737
847
  const { effectiveRoot, client, toolLifecycle, toolOutcomes } = state;
738
848
 
739
- if (event.type === "mcp.tools.changed") {
740
- // The host's server roster or one server's tool list changed. Re-read it
849
+ if (event.type === "mcp.status.changed") {
850
+ // A server's connection status settled (connected, failed, needs_auth,
851
+ // closed), which is when its tools become callable. Re-read the roster
741
852
  // so a newly connected live-system server is guarded before its tools
742
- // are callable. Widen-only: this can add tokens, never remove them.
853
+ // can be called. Widen-only: this can add tokens, never remove them.
854
+ // `mcp.resources.changed` is deliberately not a trigger: it reports
855
+ // resources, not tool availability.
743
856
  await state.refreshMcpInventory?.();
744
857
  return;
745
858
  }
746
859
 
747
- if (event.type === "session.message.content.updated") {
748
- const data = event.data as { sessionID?: unknown; content?: unknown } | undefined;
749
- const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
750
- if (!sessionID || !Array.isArray(data?.content)) return;
751
- for (const part of data.content as Array<Record<string, unknown>>) {
752
- if (part?.type !== "tool") continue;
753
- const toolState = part.state as { status?: unknown; error?: { message?: string } | string } | undefined;
754
- const status = toolState?.status;
755
- if (status !== "completed" && status !== "error") continue;
756
- // V2 keeps timing on the tool part itself (part.time), not inside state.
757
- const partTime = part.time as { ran?: unknown; completed?: unknown } | undefined;
758
- const start = typeof partTime?.ran === "number" ? partTime.ran : undefined;
759
- const end = typeof partTime?.completed === "number" ? partTime.completed : undefined;
760
- const mapped: ToolOutcomePart = {
761
- type: "tool",
762
- sessionID,
763
- callID: part.id,
764
- tool: toolPartName(part),
765
- state: {
766
- status,
767
- error: typeof toolState?.error === "string" ? toolState.error : toolState?.error?.message,
768
- time: { start, end },
769
- },
770
- };
771
- const outcome = toolOutcomes.record(mapped);
772
- if (outcome) {
773
- // Policy 25 research floor (V1 parity): failures accrue debt;
774
- // successful live-source lookups clear it. A failed research
775
- // attempt never accrues debt, so a flaky lookup cannot deepen
776
- // the block it was meant to escape.
777
- if (isResearchTool(outcome.tool, getResearchToolPatterns(getSessionBoundWorkspace(outcome.sessionID) || effectiveRoot))) {
778
- if (outcome.status !== "error") recordResearchLookup(outcome.sessionID);
779
- } else if (outcome.status === "error") {
780
- recordLiveDocsFailure(outcome.sessionID);
781
- }
782
- audit({
783
- ts: new Date().toISOString(),
784
- sessionID: outcome.sessionID,
785
- callID: outcome.callID,
786
- tool: outcome.tool,
787
- decision: "allow",
788
- phase: "outcome",
789
- durationMs: outcome.durationMs,
790
- reason: outcome.status,
791
- });
792
- if (outcome.status === "error" && outcome.repeatedFailureCount === 3) {
793
- audit({
794
- ts: new Date().toISOString(),
795
- sessionID: outcome.sessionID,
796
- callID: outcome.callID,
797
- tool: outcome.tool,
798
- decision: "allow",
799
- phase: "event",
800
- reason: "repeated-equivalent-failure:3",
801
- });
802
- console.log(`[workflow-guard] ${outcome.tool} failed equivalently 3 times in this session; change approach or inspect the underlying failure before retrying.`);
803
- }
804
- }
805
- }
806
- return;
807
- }
808
-
809
860
  if (event.type === "session.text.ended") {
810
861
  const data = event.data as { sessionID?: unknown; text?: unknown } | undefined;
811
862
  const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
@@ -841,8 +892,53 @@ async function handleV2Event(
841
892
  return;
842
893
  }
843
894
 
844
- if (event.type === "session.idle") {
845
- const data = event.data as { sessionID?: unknown } | undefined;
895
+ if (event.type === "session.moved") {
896
+ // `session_move` relocates a session between checkouts, and the host
897
+ // announces it. On the pinned 2.2.0 SDK the V2 union carries
898
+ // `session.moved` and no `session.updated`, and the move event itself
899
+ // names the destination: `data.sessionID` plus
900
+ // `data.location.directory`. Without this rebind the session kept
901
+ // judging the tree it left.
902
+ const data = event.data as {
903
+ sessionID?: unknown;
904
+ location?: { directory?: unknown };
905
+ } | undefined;
906
+ const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
907
+ const directory = typeof data?.location?.directory === "string" ? data.location.directory : undefined;
908
+ rebindSessionWorkspaceFromHost(sessionID, directory, { moved: true });
909
+ return;
910
+ }
911
+
912
+ if (event.type === "session.execution.interrupted") {
913
+ // `reason` is one of "user" | "shutdown" | "superseded" | "inactivity".
914
+ // Only "user" is user intent (Esc), so only it suspends automatic
915
+ // continuation. The other three are host-lifecycle events and must
916
+ // leave continuation state alone.
917
+ const data = event.data as { sessionID?: unknown; reason?: unknown } | undefined;
918
+ const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
919
+ if (sessionID && data?.reason === "user") {
920
+ // Continuation state is keyed on the SDK client plus project root,
921
+ // so it must be reached inside the runtime state.
922
+ await runWithRuntimeState(effectiveRoot, client, () => markInterrupted(sessionID));
923
+ }
924
+ return;
925
+ }
926
+
927
+ // Idle is the trigger for automatic continuation. The host keeps two
928
+ // event types for it: `session.status` with `status.type === "idle"`, and
929
+ // the deprecated `session.idle`. Both are in
930
+ // `EventManifest.ServerDefinitions` on 2.0.15, 2.1.3, and 2.2.0, but which
931
+ // one the server actually publishes is not settled: the TUI derives idle
932
+ // from `session.execution.*` instead of consuming `session.status`, and an
933
+ // upstream report says `session.status` is declared but never published.
934
+ // Handling both keeps continuation working whichever one arrives, and adds
935
+ // no new trigger beyond what each host already emits. A host that emits
936
+ // both for one transition still sends a single prompt, because
937
+ // `continueUnfinishedSession` holds a per-session in-flight lock for the
938
+ // whole awaited send. The side effects keep their original order.
939
+ if (event.type === "session.status" || event.type === "session.idle") {
940
+ const data = event.data as { sessionID?: unknown; status?: { type?: unknown } } | undefined;
941
+ if (event.type === "session.status" && data?.status?.type !== "idle") return;
846
942
  const sessionID = typeof data?.sessionID === "string" ? data.sessionID : undefined;
847
943
  if (typeof sessionID === "string") {
848
944
  releaseFileClaims(sessionID);
@@ -870,6 +966,7 @@ async function handleV2Event(
870
966
  clearTodoSessionState(sessionID);
871
967
  toolLifecycle.takeRecoveryRun(sessionID);
872
968
  await runWithRuntimeState(effectiveRoot, client, () => clearContinuationState(sessionID));
969
+ forgetSessionWorkspace(sessionID);
873
970
  }
874
971
  return;
875
972
  }
@@ -889,25 +986,8 @@ async function handleV2Event(
889
986
  });
890
987
  return;
891
988
  }
892
- // `command.executed` exists in the V2 event stream (`data: { name, sessionID,
893
- // arguments, messageID }`) and carries what the V1 audit wanted: the slash
894
- // command name, its arguments, and the session. It is journaled here rather
895
- // than dropped, so a command that mutates workspace state stays attributable.
896
- if (event.type === "command.executed") {
897
- const data = event.data as { sessionID?: unknown; name?: unknown; arguments?: unknown } | undefined;
898
- audit({
899
- ts: new Date().toISOString(),
900
- sessionID: typeof data?.sessionID === "string" ? data.sessionID : undefined,
901
- tool: "command.executed",
902
- decision: "allow",
903
- phase: "event",
904
- reason: typeof data?.name === "string" ? data.name : undefined,
905
- input: summarizeInput({ name: data?.name, arguments: data?.arguments }),
906
- });
907
- return;
908
- }
909
989
  // Note on V1 events without a V2 counterpart:
910
990
  // - `permission.updated`: replaced by `permission.asked`/`permission.replied` above.
911
- // - `session.created`: exists in the V2 union but needed no V1 behavior beyond
912
- // the audit above; session state resets happen on `session.deleted`.
991
+ // - `session.created`: V2 journals no create event; session state resets on
992
+ // `session.deleted`.
913
993
  }