opencode-workflow-guard 1.15.1 → 1.15.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/docs/policies.md CHANGED
@@ -31,7 +31,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
31
31
  - The branch diff modifies a `CHANGELOG` file, OR
32
32
  - The branch diff adds/modifies a `.changeset/*.md` fragment file (Changesets workflow), OR
33
33
  - The PR description (`--body`, `--description`, or `--body-file`/`-F`) contains a release-information section headed `Changelog`, `Changes`, `Release notes`, or `Summary`. Multiline Bash ANSI-C `\n`/`\r` quoting is supported for inline descriptions; inline descriptions that would render Markdown line breaks as literal `\n`/`\r` text are blocked with guidance to use real newlines or a description file.
34
- - **Lockfile Synchronization Gate:** Whenever package manifests (`package.json`, `Cargo.toml`, `go.mod`) are modified, PR creation is blocked until their corresponding lockfiles (`package-lock.json`/`bun.lock`/`pnpm-lock.yaml`/`yarn.lock`, `Cargo.lock`, `go.sum`) are updated.
34
+ - **Lockfile Synchronization Gate:** Whenever package manifests (`package.json`, `Cargo.toml`, `go.mod`) are modified, PR creation is blocked until their corresponding lockfiles (`package-lock.json`/`bun.lock`/`pnpm-lock.yaml`/`yarn.lock`, `Cargo.lock`, `go.sum`) are updated. For `package.json` the demand is section-aware: it fires when a section the lockfile records changed (`dependencies`, `devDependencies`, `optionalDependencies`, `peerDependencies`, `peerDependenciesMeta`, `engines`, `packageManager`, `overrides`, `resolutions`, `workspaces`, `pnpm`, or the project `name`/`version`). Scripts- or metadata-only manifest edits regenerate the lockfile byte-identical and pass without one. `Cargo.toml` and `go.mod` changes conservatively keep demanding their lockfiles.
35
35
  - When multiple PR prerequisites fail at once, the guard reports all detected preflight failures together so they can be resolved before retrying PR creation.
36
36
 
37
37
  ### 4. Destructive CLI Operations Guard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.15.1",
3
+ "version": "1.15.2",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
2
  import { spawnSync } from "node:child_process";
3
+ import { resolve } from "node:path";
3
4
  import type { AuditEntry, LearningEvidenceKind } from "./types.ts";
4
5
  import type { ProjectMemoryStore } from "./project-memory.ts";
5
6
  import {
@@ -31,10 +32,11 @@ import {
31
32
  recordMutation,
32
33
  recordReviewResult,
33
34
  resolveEffectiveWorkspace,
35
+ resolveSessionBoundWorkspace,
34
36
  runWithRuntimeState,
35
37
  } from "./state.ts";
36
38
  import { getRalphOutcome } from "../policies/continuation.ts";
37
- import { loadProjectConfig, projectRootKey } from "./project-config.ts";
39
+ import { findGitRoot, loadProjectConfig, projectRootKey } from "./project-config.ts";
38
40
  import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint, getTrackedWorktreeFingerprint } from "./verify.ts";
39
41
  import { buildReviewRubric } from "./review.ts";
40
42
  import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
@@ -121,6 +123,27 @@ export function createCustomTools(options: {
121
123
  client: unknown;
122
124
  }) {
123
125
  const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
126
+ // Resolve the workspace for a tool call from the CALL's own context: an
127
+ // explicit argument first, then the host-provided per-call worktree or
128
+ // directory (even when unusable — the call declared its target), then the
129
+ // session's bound workspace. The plugin-instance default (`effectiveRoot`)
130
+ // is only the last resort — concurrent loops share one guard process, so
131
+ // an instance-derived root must never re-anchor another session's tool
132
+ // execution.
133
+ const resolveCallWorkspace = async (
134
+ toolContext: { sessionID?: string; worktree?: string; directory?: string } | undefined,
135
+ explicitDirectory?: string,
136
+ ): Promise<string> => {
137
+ if (explicitDirectory) {
138
+ return findGitRoot(explicitDirectory) ?? projectRootKey(explicitDirectory);
139
+ }
140
+ const hostContext = toolContext?.worktree || toolContext?.directory;
141
+ const hostIsFilesystemRoot = hostContext ? resolve(hostContext) === resolve(hostContext, "..") : true;
142
+ if (hostContext && !hostIsFilesystemRoot) {
143
+ return findGitRoot(hostContext) ?? projectRootKey(hostContext);
144
+ }
145
+ return resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, fallback: effectiveRoot });
146
+ };
124
147
  return {
125
148
  ...(isRecoveryCheckpointsEnabled(effectiveRoot) ? {
126
149
  guard_recovery_restore: tool({
@@ -131,7 +154,7 @@ export function createCustomTools(options: {
131
154
  const parent = await fetchParentSession(toolContext.sessionID);
132
155
  if (!parent.ok) return "[workflow-guard] Recovery rejected: could not confirm this is a root session.";
133
156
  if (parent.parentID) return "[workflow-guard] Recovery rejected: only root sessions can restore their checkpoints.";
134
- const result = restoreRecoveryCheckpoint(effectiveRoot, toolContext.sessionID, args.run);
157
+ const result = restoreRecoveryCheckpoint(await resolveCallWorkspace(toolContext), toolContext.sessionID, args.run);
135
158
  return result.ok ? `[workflow-guard] Restored recovery checkpoint for run ${args.run}.` : `[workflow-guard] Recovery rejected: ${result.error}`;
136
159
  },
137
160
  }),
@@ -139,8 +162,8 @@ export function createCustomTools(options: {
139
162
  guard_next_tasks: tool({
140
163
  description: "Load durable repository task context when deciding what to work on next or planning upcoming steps. Proactively call at session start or during planning to discover roadmap, plan, tasks, backlog, and TODO.md files.",
141
164
  args: {},
142
- execute: async () => {
143
- const sources = discoverPlanningSources(effectiveRoot);
165
+ execute: async (_args, toolContext) => {
166
+ const sources = discoverPlanningSources(await resolveCallWorkspace(toolContext));
144
167
  return sources.length > 0 ? JSON.stringify({ sources }, null, 2) : JSON.stringify({ sources: [], message: "No TODO, roadmap, plan, tasks, or backlog Markdown files found." });
145
168
  },
146
169
  }),
@@ -158,7 +181,7 @@ export function createCustomTools(options: {
158
181
  if (!(["fact", "decision", "constraint", "lesson"] as string[]).includes(args.kind)) return "[workflow-guard] Project memory rejected: invalid kind.";
159
182
  const detectedSecret = secretIn(args.content);
160
183
  if (detectedSecret) return `[workflow-guard] Project memory rejected: possible secret detected (${detectedSecret}).`;
161
- return JSON.stringify(recordProjectMemory(projectMemory, { kind: args.kind as "fact" | "decision" | "constraint" | "lesson", content: args.content, source: "agent", sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(effectiveRoot), paths: args.paths, supersedes: args.supersedes || undefined }));
184
+ return JSON.stringify(recordProjectMemory(projectMemory, { kind: args.kind as "fact" | "decision" | "constraint" | "lesson", content: args.content, source: "agent", sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(await resolveCallWorkspace(toolContext)), paths: args.paths, supersedes: args.supersedes || undefined }));
162
185
  },
163
186
  }),
164
187
  project_memory_export: tool({
@@ -181,7 +204,7 @@ export function createCustomTools(options: {
181
204
  execute: async (args, toolContext) => {
182
205
  const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } => (candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") && candidate.concept.length > 0 && candidate.concept.length <= 100 && candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1);
183
206
  const used = learningInterventions.get(toolContext.sessionID) ?? 0;
184
- const selected = selectLearningOpportunity(loadLearnerProfile(), valid, { interventionsThisSession: used, maxInterventionsPerSession: getLearningInterventionBudget(effectiveRoot) });
207
+ const selected = selectLearningOpportunity(loadLearnerProfile(), valid, { interventionsThisSession: used, maxInterventionsPerSession: getLearningInterventionBudget(await resolveCallWorkspace(toolContext)) });
185
208
  if (!selected) return JSON.stringify({ intervene: false, reason: "No opportunity selected or session learning budget reached." });
186
209
  learningInterventions.set(toolContext.sessionID, used + 1);
187
210
  return JSON.stringify({ intervene: true, opportunity: selected, guidance: "Ask one question, use the answer as task context, briefly reconcile if useful, then continue building." });
@@ -194,10 +217,11 @@ export function createCustomTools(options: {
194
217
  const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
195
218
  if (!validKinds.has(args.kind)) return "[workflow-guard] Learning evidence rejected: invalid evidence kind.";
196
219
  if (args.concept.length < 1 || args.concept.length > 100 || args.summary.length < 1 || args.summary.length > 1000) return "[workflow-guard] Learning evidence rejected: concept must be 1-100 characters and summary 1-1000 characters.";
220
+ const learningWorkspace = await resolveCallWorkspace(toolContext);
197
221
  try {
198
222
  updateLearnerProfile((profile) => {
199
223
  if (!profile.concepts[args.concept] && Object.keys(profile.concepts).length >= 500) throw new Error("concept-limit");
200
- recordLearningEvidence(profile, { concept: args.concept, kind: args.kind as LearningEvidenceKind, summary: args.summary, timestamp: Date.now(), sessionID: toolContext.sessionID, project: effectiveRoot });
224
+ recordLearningEvidence(profile, { concept: args.concept, kind: args.kind as LearningEvidenceKind, summary: args.summary, timestamp: Date.now(), sessionID: toolContext.sessionID, project: learningWorkspace });
201
225
  });
202
226
  } catch (error) {
203
227
  if ((error as Error).message === "concept-limit") return "[workflow-guard] Learning evidence rejected: learner profile concept limit reached.";
@@ -208,12 +232,12 @@ export function createCustomTools(options: {
208
232
  }),
209
233
  } : {}),
210
234
  guard_status: tool({
211
- description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. The result includes pluginVersion, the version of the loaded guard package, so a stale installation is visible, and a read-only gitHygiene snapshot (local branches already merged into the mainline, prunable worktree admin entries, base-behind distance). Proactively call at session start and before completing tasks or creating PRs.",
235
+ description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. The result includes pluginVersion, the version of the loaded guard package, so a stale installation is visible, and a read-only gitHygiene snapshot (local branches already merged into the mainline, prunable worktree admin entries, base-behind distance). Proactively call at session start and before completing tasks or creating PRs. Concurrent loops share one guard process, so pass 'directory' explicitly when inspecting a root other than this session's own workspace.",
212
236
  args: {
213
- directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to active session directory or workspace root)"),
237
+ directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to this session's own workspace binding, never another concurrent session's root)"),
214
238
  },
215
239
  execute: async (args, toolContext) => {
216
- const root = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args?.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
240
+ const root = await resolveCallWorkspace(toolContext, args?.directory);
217
241
  const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
218
242
  const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
219
243
  const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
@@ -256,14 +280,14 @@ export function createCustomTools(options: {
256
280
  },
257
281
  }),
258
282
  guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
259
- guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
283
+ guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(await resolveCallWorkspace(toolContext), client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
260
284
  record_review: tool({
261
285
  description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric (test integrity, task completeness, cleanliness, security, platform). Projects with requireSubagentReview enabled only accept APPROVALS recorded from a subagent session (one with a parent session), never from the root session itself.",
262
286
  args: {
263
287
  reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"),
264
288
  summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"),
265
289
  passed: tool.schema.boolean().describe("True if change is approved, false if changes requested"),
266
- directory: tool.schema.string().optional().describe("Target repository directory being reviewed (defaults to parent session's repository or active directory)"),
290
+ directory: tool.schema.string().optional().describe("Target repository directory being reviewed. Pass it explicitly whenever the recorder session has no workspace binding: concurrent loops share one guard process, and a verdict is never bound to a guessed root."),
267
291
  },
268
292
  execute: async (args, toolContext) => {
269
293
  const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string, binding?: NonNullable<AuditEntry["evidence"]>["binding"]) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict, ...(binding ? { binding } : {}) } });
@@ -277,7 +301,19 @@ export function createCustomTools(options: {
277
301
  // hash + tracked worktree fingerprint); the recorder's lineage is
278
302
  // captured in the audit trail. The review-before-PR requirement
279
303
  // itself is unchanged.
280
- const reviewWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
304
+ // Binding is never guessed: an explicit directory, the session's
305
+ // own workspace binding (direct or inherited from a bound
306
+ // parent), or a host-provided per-call worktree. When none of
307
+ // those exist the verdict is rejected instead of silently bound
308
+ // to a latched root — concurrent loops share one guard process,
309
+ // and a latched root can belong to another loop's repository.
310
+ const hostProvidesWorkspace = Boolean(toolContext?.worktree);
311
+ const sessionBinding = await resolveSessionBoundWorkspace(toolContext?.sessionID);
312
+ if (!args.directory && !sessionBinding && !hostProvidesWorkspace) {
313
+ auditVerdict("rejected", "review_binding_unresolved", { recorderSessionID: toolContext?.sessionID });
314
+ return "[workflow-guard] Review rejected: the reviewed workspace could not be resolved from this call (no explicit 'directory', no session workspace binding, no per-call worktree). Concurrent loops share one guard process, so a verdict is never bound to a guessed root. Call record_review with 'directory' set to the reviewed repository worktree.";
315
+ }
316
+ const reviewWorkspace = await resolveCallWorkspace(toolContext, args.directory);
281
317
  const bindingFingerprint = getTrackedWorktreeFingerprint(reviewWorkspace);
282
318
  if (!bindingFingerprint) {
283
319
  auditVerdict("rejected", "review_workspace_unbindingable", { workspace: reviewWorkspace, recorderSessionID: toolContext.sessionID });
@@ -325,7 +361,7 @@ export function createCustomTools(options: {
325
361
  execute: async (args, toolContext) => {
326
362
  const sanitizedBase = typeof args.base === "string" && !args.base.startsWith("-") && !/[\s;'"\0]/.test(args.base) ? args.base : undefined;
327
363
  const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
328
- const rubricWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, directory: args?.directory, fallback: toolContext?.worktree || toolContext?.directory || effectiveRoot });
364
+ const rubricWorkspace = await resolveCallWorkspace(toolContext, args?.directory);
329
365
  let diffText = "";
330
366
  // Revision before the trailing `--` (LL-001) so the base ref is
331
367
  // always parsed as a revision, never as a pathspec.
@@ -338,16 +374,20 @@ export function createCustomTools(options: {
338
374
  description: "Create an isolated git worktree directory for concurrent subagent execution. Proactively call when launching parallel tasks/subagents to prevent file collision and maintain branch isolation.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
339
375
  execute: async (args, toolContext) => {
340
376
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
341
- const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
342
- recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID); return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
377
+ const toolRoot = await resolveCallWorkspace(toolContext); const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
378
+ const mutationRoot = await resolveCallWorkspace(toolContext);
379
+ await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
380
+ return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
343
381
  },
344
382
  }),
345
383
  guard_worktree_cleanup: tool({
346
384
  description: "Commit a final snapshot and remove an isolated git worktree directory. Call to clean up worktrees created with guard_worktree_create after subagent tasks complete.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
347
385
  execute: async (args, toolContext) => {
348
386
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
349
- const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
350
- recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID); return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
387
+ const toolRoot = await resolveCallWorkspace(toolContext); const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
388
+ const mutationRoot = await resolveCallWorkspace(toolContext);
389
+ await runWithRuntimeState(mutationRoot, client, async () => recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID));
390
+ return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
351
391
  },
352
392
  }),
353
393
  };
@@ -7,6 +7,7 @@ import {
7
7
  getLastReviewResultForWorkspace,
8
8
  getLiveControlPlaneRoots,
9
9
  getSdkClient,
10
+ getSessionBoundWorkspace,
10
11
  getWorkspaceRoot,
11
12
  isDocumentationRequired,
12
13
  isReviewRequired,
@@ -114,7 +115,15 @@ export async function guardToolCallImpl(
114
115
  context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string; simulate?: boolean },
115
116
  ): Promise<PolicyDecision> {
116
117
  const logPolicyBlock = (message: string) => logBlock(message, context?.simulate);
117
- const currentRoot = getWorkspaceRoot();
118
+ // The workspace for THIS decision comes from the call's own context (the
119
+ // hook's per-call worktree/directory) or, failing that, the session's own
120
+ // bound workspace — never from one process-global slot: concurrent loops
121
+ // share this process, and the last-active instance must not re-anchor
122
+ // another session's policy evaluation.
123
+ const currentRoot = context?.worktree
124
+ || context?.directory
125
+ || getSessionBoundWorkspace(context?.sessionID)
126
+ || getWorkspaceRoot();
118
127
  const allow = (): PolicyDecision => ({ status: "allowed", code: "allowed", message: "Allowed by current guardrails." });
119
128
  const block = (policy: string, code: string, message: string): PolicyDecision => ({ status: "blocked", policy, code, message });
120
129
  const needsApproval = (policy: string, code: string, message: string): PolicyDecision => ({ status: "needs_approval", policy, code, message });
package/src/lib/state.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { realpathSync, existsSync, unlinkSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { existsSync, unlinkSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
5
  import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
@@ -13,36 +13,21 @@ import type {
13
13
  } from "./types.ts";
14
14
 
15
15
  export let workspaceRoot = process.cwd();
16
- export let workspaceRootReal = workspaceRoot;
17
16
  interface RuntimeState {
18
17
  workspaceRoot: string;
19
- workspaceRootReal: string;
20
18
  sdkClient: TodoSdkClient | undefined;
21
19
  projectConfig: ProjectConfig;
22
20
  }
23
21
  const runtimeState = new AsyncLocalStorage<RuntimeState>();
24
22
 
25
- try {
26
- workspaceRootReal = realpathSync(workspaceRoot);
27
- } catch {}
28
-
29
23
  export function setWorkspaceRoot(root: string): void {
30
24
  workspaceRoot = root;
31
- try {
32
- workspaceRootReal = realpathSync(root);
33
- } catch {
34
- workspaceRootReal = root;
35
- }
36
25
  }
37
26
 
38
27
  export function getWorkspaceRoot(): string {
39
28
  return runtimeState.getStore()?.workspaceRoot ?? workspaceRoot;
40
29
  }
41
30
 
42
- export function getWorkspaceRootReal(): string {
43
- return runtimeState.getStore()?.workspaceRootReal ?? workspaceRootReal;
44
- }
45
-
46
31
  export let sdkClient: TodoSdkClient | undefined;
47
32
 
48
33
  export function setSdkClient(client: unknown): void {
@@ -54,14 +39,9 @@ export function getSdkClient(): TodoSdkClient | undefined {
54
39
  }
55
40
 
56
41
  export function runWithRuntimeState<T>(root: string, client: unknown, fn: () => T): T {
57
- let realRoot = root;
58
- try {
59
- realRoot = realpathSync(root);
60
- } catch {}
61
42
  return runtimeState.run(
62
43
  {
63
44
  workspaceRoot: root,
64
- workspaceRootReal: realRoot,
65
45
  sdkClient: client as TodoSdkClient | undefined,
66
46
  projectConfig: loadProjectConfig(root),
67
47
  },
@@ -76,11 +56,18 @@ const workspaceMutationCounts = new Map<string, number>();
76
56
  export const sessionMutationTimestamps = new Map<string, number>();
77
57
  export const sessionMutationCounts = new Map<string, number>();
78
58
  export const sessionWorkspaces = new Map<string, string>();
59
+ // Sessions whose workspace was registered from their OWN context (tool-call
60
+ // arguments or a host-provided per-call worktree). A session registered only
61
+ // from the plugin-instance default is deliberately left unmarked: concurrent
62
+ // loops share this process, and a value derived from the last-active plugin
63
+ // instance must never outrank the call's own context or the host fallback.
64
+ const sessionWorkspaceBound = new Set<string>();
79
65
 
80
66
  export function setSessionWorkspace(sessionID: string, workspace: string): void {
81
67
  if (!sessionID || !workspace) return;
82
68
  const gitRoot = findGitRoot(workspace);
83
69
  sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(workspace));
70
+ sessionWorkspaceBound.add(sessionID);
84
71
  }
85
72
 
86
73
  export function getSessionWorkspace(sessionID?: string): string | undefined {
@@ -88,6 +75,75 @@ export function getSessionWorkspace(sessionID?: string): string | undefined {
88
75
  return sessionWorkspaces.get(sessionID);
89
76
  }
90
77
 
78
+ /**
79
+ * The session's workspace only when it was registered from the session's own
80
+ * context (tool-call arguments or a host-provided per-call worktree), never a
81
+ * plugin-instance-default bootstrap. Policy decisions that bind evidence or
82
+ * confine boundaries must consult this variant: two concurrent loops share one
83
+ * guard process, and an unbound bootstrap entry may have been derived from
84
+ * another session's plugin instance.
85
+ */
86
+ export function getSessionBoundWorkspace(sessionID?: string): string | undefined {
87
+ if (!sessionID) return undefined;
88
+ return sessionWorkspaceBound.has(sessionID) ? sessionWorkspaces.get(sessionID) : undefined;
89
+ }
90
+
91
+ /**
92
+ * Register a session's workspace from a tool call's own context. Candidates in
93
+ * priority order: tool-argument paths (workdir/filePath — the call's own
94
+ * target), then a host-provided per-call worktree/directory. A session that is
95
+ * already registered is never re-anchored from the plugin-instance fallback:
96
+ * under concurrent loops that fallback can belong to a different session's
97
+ * workspace, which is exactly how workspaces cross-contaminate. Only a
98
+ * session with no registration at all is bootstrapped from the fallback, and
99
+ * such a bootstrap stays unbound (never used to bind review evidence).
100
+ */
101
+ export function registerSessionWorkspaceFromToolCall(
102
+ sessionID: string | undefined,
103
+ call: { workdir?: string; filePath?: string; hostWorktree?: string; toolWorktree: string },
104
+ ): void {
105
+ if (!sessionID) return;
106
+ const rawCandidate = call.workdir ?? call.filePath ?? call.hostWorktree;
107
+ if (rawCandidate) {
108
+ const candidatePath = isAbsolute(rawCandidate) ? rawCandidate : resolve(call.toolWorktree, rawCandidate);
109
+ const gitRoot = findGitRoot(candidatePath);
110
+ if (gitRoot) {
111
+ sessionWorkspaces.set(sessionID, gitRoot);
112
+ sessionWorkspaceBound.add(sessionID);
113
+ }
114
+ return;
115
+ }
116
+ if (sessionWorkspaces.has(sessionID)) return;
117
+ const gitRoot = findGitRoot(call.toolWorktree);
118
+ sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(call.toolWorktree));
119
+ }
120
+
121
+ /**
122
+ * The session's workspace resolved from its own bindings only: a directly
123
+ * registered bound workspace, else the nearest bound parent workspace
124
+ * (subagent sessions inherit their parent's binding for relay flows). Returns
125
+ * undefined when neither exists — callers that must not guess (review verdict
126
+ * binding) treat that as "resolve nothing, ask for an explicit directory".
127
+ */
128
+ export async function resolveSessionBoundWorkspace(sessionID?: string): Promise<string | undefined> {
129
+ if (!sessionID) return undefined;
130
+ const direct = getSessionBoundWorkspace(sessionID);
131
+ if (direct) return direct;
132
+ try {
133
+ const client = getSdkClient();
134
+ const session = client?.session;
135
+ const get = session?.get;
136
+ if (typeof get === "function") {
137
+ const result = await get.call(session, { path: { id: sessionID } });
138
+ const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
139
+ if (typeof parent === "string" && parent && sessionWorkspaceBound.has(parent)) {
140
+ return sessionWorkspaces.get(parent);
141
+ }
142
+ }
143
+ } catch {}
144
+ return undefined;
145
+ }
146
+
91
147
  export let lastVerify: VerifyResult | undefined;
92
148
  export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>();
93
149
 
@@ -228,10 +284,28 @@ export function getLastReviewResult(): typeof lastReview {
228
284
 
229
285
  export function getLastReviewResultForWorkspace(root: string): typeof lastReview {
230
286
  const workspace = projectRootKey(root);
231
- const candidates = [lastReview, ...sessionReviews.values()].filter(
232
- (result): result is ReviewResult => result?.workspace != null && (projectRootKey(result.workspace) === workspace || isSameGitRepo(result.workspace, root)),
287
+ const all = [lastReview, ...sessionReviews.values()].filter(
288
+ (result): result is ReviewResult & { workspace: string } => result?.workspace != null,
233
289
  );
234
- const latest = candidates.reduce<ReviewResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
290
+ // Exact worktree binding wins: concurrent loops sharing one repository
291
+ // (different worktrees) must consume their own approvals independently —
292
+ // a newer sibling-worktree review must never displace this worktree's
293
+ // between record_review and PR creation.
294
+ const exact = all.filter((result) => projectRootKey(result.workspace) === workspace);
295
+ let preferred = exact;
296
+ if (preferred.length === 0) {
297
+ const sameRepo = all.filter((result) => isSameGitRepo(result.workspace, root));
298
+ if (sameRepo.length > 0) {
299
+ // Among same-repo candidates, prefer the review whose tracked
300
+ // content matches THIS worktree (the PR preflight consumes the
301
+ // approval bound to the tree it would publish); recency only
302
+ // breaks ties.
303
+ const prFingerprint = getTrackedWorktreeFingerprint(root);
304
+ const contentMatched = prFingerprint ? sameRepo.filter((result) => result.worktreeFingerprint === prFingerprint) : [];
305
+ preferred = contentMatched.length > 0 ? contentMatched : sameRepo;
306
+ }
307
+ }
308
+ const latest = preferred.reduce<ReviewResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
235
309
  if (latest) return latest;
236
310
  const diskCached = loadReviewCache();
237
311
  if (diskCached?.workspace && (projectRootKey(diskCached.workspace) === workspace || isSameGitRepo(diskCached.workspace, root))) {
@@ -254,41 +328,36 @@ export async function resolveEffectiveWorkspace(options: {
254
328
  directory?: string;
255
329
  fallback?: string;
256
330
  }): Promise<string> {
331
+ // Intended precedence. Custom tools (resolveCallWorkspace) consult the
332
+ // explicit argument and the host per-call context BEFORE calling this, so
333
+ // inside this function the remaining order is: bound session workspace >
334
+ // host fallback > unbound bootstrap hint > global latch/cwd. A future
335
+ // direct caller passing a per-call `fallback` should apply the same
336
+ // host-context-first step that resolveCallWorkspace does.
257
337
  if (options.directory) {
258
338
  const gitRoot = findGitRoot(options.directory);
259
339
  return gitRoot ?? projectRootKey(options.directory);
260
340
  }
341
+ // 1. The session's own binding (direct, or inherited from a bound parent
342
+ // for relay flows) wins over every ambient fallback. Concurrent loops
343
+ // share one guard process; a fallback derived from the last-active
344
+ // plugin instance must never re-anchor another session's context.
345
+ const bound = await resolveSessionBoundWorkspace(options.sessionID);
346
+ if (bound) return bound;
261
347
  const fallbackRoot = options.fallback ? projectRootKey(options.fallback) : undefined;
262
348
  const isFallbackFsRoot = fallbackRoot ? resolve(fallbackRoot) === resolve(fallbackRoot, "..") : true;
263
349
 
350
+ // 2. The call's own host-provided per-call worktree/directory.
264
351
  if (fallbackRoot && !isFallbackFsRoot) {
265
- if (options.sessionID) {
266
- const sessionWs = sessionWorkspaces.get(options.sessionID);
267
- if (sessionWs && isSameGitRepo(sessionWs, fallbackRoot)) {
268
- return sessionWs;
269
- }
270
- }
271
352
  return fallbackRoot;
272
353
  }
273
354
 
274
- if (options.sessionID) {
275
- const sessionWs = sessionWorkspaces.get(options.sessionID);
276
- if (sessionWs) return sessionWs;
277
- try {
278
- const client = getSdkClient();
279
- const session = client?.session;
280
- const get = session?.get;
281
- if (typeof get === "function") {
282
- const result = await get.call(session, { path: { id: options.sessionID } });
283
- const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
284
- if (typeof parent === "string" && parent && sessionWorkspaces.has(parent)) {
285
- const parentWs = sessionWorkspaces.get(parent)!;
286
- sessionWorkspaces.set(options.sessionID, parentWs);
287
- return parentWs;
288
- }
289
- }
290
- } catch {}
291
- }
355
+ // 3. Unbound bootstrap hint: the session was registered only from the
356
+ // plugin-instance default, so it carries no per-session authority and
357
+ // loses to any real fallback (step 2). It is still better than the raw
358
+ // global latch when the fallback is absent or the filesystem root.
359
+ const hintRoot = options.sessionID ? sessionWorkspaces.get(options.sessionID) : undefined;
360
+ if (hintRoot) return hintRoot;
292
361
  const activeRoot = getWorkspaceRoot();
293
362
  if (activeRoot && resolve(activeRoot) !== resolve(activeRoot, "..")) {
294
363
  const gitRoot = findGitRoot(activeRoot);
@@ -25,8 +25,8 @@ import { findGitRoot, reloadProjectConfig } from "./project-config.ts";
25
25
  import {
26
26
  setWorkspaceRoot,
27
27
  setSdkClient,
28
- getSessionWorkspace,
29
- setSessionWorkspace,
28
+ getSessionBoundWorkspace,
29
+ registerSessionWorkspaceFromToolCall,
30
30
  runWithRuntimeState,
31
31
  getSessionMutationCount,
32
32
  sessionVerifyResults,
@@ -193,10 +193,14 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
193
193
  // wrap it so the V2 registry receives a real StandardSchemaV1.
194
194
  input: z.object(definition.args as unknown as z.ZodRawShape),
195
195
  execute: async (rawInput: unknown, toolContext: { sessionID: string; id: string }) => {
196
+ // Only a session-bound workspace counts as the tool's own
197
+ // context; an unbound entry is a plugin-instance bootstrap
198
+ // and must not be presented to the tools as one.
199
+ const boundWorktree = getSessionBoundWorkspace(toolContext.sessionID);
196
200
  const result = await (definition.execute as (input: unknown, context: unknown) => Promise<unknown>)(rawInput, {
197
201
  sessionID: toolContext.sessionID,
198
- worktree: getSessionWorkspace(toolContext.sessionID) ?? effectiveRoot,
199
- directory: effectiveRoot,
202
+ ...(boundWorktree ? { worktree: boundWorktree } : {}),
203
+ directory: boundWorktree ?? effectiveRoot,
200
204
  });
201
205
  const text = typeof result === "string" ? result : (result as { output?: string } | undefined)?.output;
202
206
  return {
@@ -218,7 +222,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
218
222
 
219
223
  // ── Deterministic enforcement ──
220
224
  await ctx.tool.hook("execute.before", (event) => {
221
- const toolWorktree = getSessionWorkspace(event.sessionID) || effectiveRoot;
225
+ const toolWorktree = getSessionBoundWorkspace(event.sessionID) || effectiveRoot;
222
226
  return runWithRuntimeState(toolWorktree, client, async () => {
223
227
  const args = event.input;
224
228
  const reason = await guardToolCall(event.tool, args, {
@@ -239,11 +243,7 @@ export const WorkflowGuardV2 = async (ctx: V2Context) => {
239
243
  const record = asRecord(args);
240
244
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
241
245
  const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
242
- const candidatePath = workdir ?? filePath ?? toolWorktree;
243
- if (candidatePath) {
244
- const gitRoot = findGitRoot(candidatePath);
245
- if (gitRoot) setSessionWorkspace(event.sessionID, gitRoot);
246
- }
246
+ registerSessionWorkspaceFromToolCall(event.sessionID, { workdir, filePath, toolWorktree });
247
247
  if (event.tool === "read") {
248
248
  const target = editTargets(args, toolWorktree)[0];
249
249
  if (target) {
@@ -4,7 +4,6 @@ import { dirname, join, resolve } from "node:path";
4
4
  import type { ShellMutation } from "../lib/types.ts";
5
5
  import {
6
6
  getWorkspaceRoot,
7
- getWorkspaceRootReal,
8
7
  recordMutation,
9
8
  } from "../lib/state.ts";
10
9
  import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
@@ -89,7 +88,15 @@ export function isPathOutsideWorkspace(targetPath: string, root: string): boolea
89
88
  if (resolved !== root && !resolved.startsWith(normalizedRoot)) {
90
89
  return true;
91
90
  }
92
- const realRootVal = getWorkspaceRootReal();
91
+ // The boundary is resolved against the workspace THIS call declares (the
92
+ // `root` argument), never against a process-global latched root: concurrent
93
+ // loops share one guard process, and a latched root must not redefine
94
+ // another session's boundary. Symlink-awareness is preserved (ancestor
95
+ // walk below) by canonicalizing the declared root itself.
96
+ let realRootVal = root;
97
+ try {
98
+ realRootVal = realpathSync(root);
99
+ } catch {}
93
100
  try {
94
101
  const real = realpathSync(resolved);
95
102
  const realRoot = realRootVal.endsWith("/") ? realRootVal : realRootVal + "/";
@@ -193,16 +193,114 @@ export function prBodyHasLiteralLineBreakEscapes(command: string): boolean {
193
193
  return false;
194
194
  }
195
195
 
196
+ /**
197
+ * Top-level package.json sections whose change requires a matching lockfile
198
+ * update: the dependency maps (including peerDependenciesMeta) plus every
199
+ * other section a supported package manager records in its lockfile
200
+ * (engines/packageManager, overrides/resolutions, the pnpm section with
201
+ * pnpm.overrides and pnpm.patchedDependencies, the workspace graph, and the
202
+ * project name/version stored in the lockfile root). A scripts-only or
203
+ * metadata-only manifest edit regenerates the lockfile byte-identical —
204
+ * demanding a lockfile change for it is unsatisfiable and must not block PR
205
+ * creation.
206
+ */
207
+ const LOCKFILE_RELEVANT_MANIFEST_KEYS = new Set([
208
+ "name",
209
+ "version",
210
+ "dependencies",
211
+ "devDependencies",
212
+ "optionalDependencies",
213
+ "peerDependencies",
214
+ "peerDependenciesMeta",
215
+ "engines",
216
+ "packageManager",
217
+ "overrides",
218
+ "resolutions",
219
+ "workspaces",
220
+ // pnpm-specific top-level section: pnpm.overrides and
221
+ // pnpm.patchedDependencies are recorded in pnpm-lock.yaml.
222
+ "pnpm",
223
+ ]);
224
+
225
+ /** Order-insensitive JSON comparison: dependency maps are semantically
226
+ * unordered, so reordering keys alone is not a lockfile-relevant change. */
227
+ function canonicalJsonValue(value: unknown): string {
228
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "undefined";
229
+ if (Array.isArray(value)) return `[${value.map(canonicalJsonValue).join(",")}]`;
230
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
231
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJsonValue(item)}`).join(",")}}`;
232
+ }
233
+
234
+ function manifestTouchesLockfileRelevantKeys(oldSource: string | undefined, newSource: string | undefined): boolean {
235
+ try {
236
+ const parse = (source: string | undefined): Record<string, unknown> | undefined => {
237
+ if (source === undefined) return undefined;
238
+ const parsed = JSON.parse(source) as unknown;
239
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not a manifest object");
240
+ return parsed as Record<string, unknown>;
241
+ };
242
+ const oldManifest = parse(oldSource);
243
+ const newManifest = parse(newSource);
244
+ const keys = new Set([...Object.keys(oldManifest ?? {}), ...Object.keys(newManifest ?? {})]);
245
+ for (const key of keys) {
246
+ if (!LOCKFILE_RELEVANT_MANIFEST_KEYS.has(key)) continue;
247
+ if (canonicalJsonValue(oldManifest?.[key]) !== canonicalJsonValue(newManifest?.[key])) return true;
248
+ }
249
+ return false;
250
+ } catch {
251
+ return true; // unreadable manifest: fail closed and keep demanding the lockfile
252
+ }
253
+ }
254
+
255
+ function manifestSourceAt(root: string, revision: string, manifestPath: string): string | undefined {
256
+ const res = spawnSync("git", ["show", `${revision}:${manifestPath}`], { cwd: root, encoding: "utf8", timeout: 10_000 });
257
+ if (res.status !== 0) return undefined;
258
+ return res.stdout;
259
+ }
260
+
261
+ function workingTreeManifestSource(root: string, manifestPath: string): string | undefined {
262
+ try {
263
+ return readFileSync(resolve(root, manifestPath), "utf8");
264
+ } catch {
265
+ return undefined; // deleted from the working tree
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Whether a manifest change demands a lockfile update. package.json is
271
+ * compared section-by-section (scripts-only edits pass); Cargo.toml and
272
+ * go.mod have no dependency-section parser here, so any change to them
273
+ * conservatively demands the lockfile (unchanged behavior).
274
+ */
275
+ function manifestChangeRequiresLockfile(root: string, mergeBaseCommit: string | undefined, manifestPath: string): boolean {
276
+ if (!/(?:^|\/)package\.json$/.test(manifestPath)) return true;
277
+ let oldSource: string | undefined;
278
+ if (mergeBaseCommit) {
279
+ // Absent at the merge base means the manifest was introduced on this
280
+ // branch (or is untracked): the pre-branch state had no manifest, so
281
+ // every lockfile-relevant key it has counts as new.
282
+ oldSource = manifestSourceAt(root, mergeBaseCommit, manifestPath);
283
+ } else {
284
+ oldSource = manifestSourceAt(root, "HEAD", manifestPath);
285
+ }
286
+ const newSource = workingTreeManifestSource(root, manifestPath);
287
+ if (oldSource === undefined && newSource === undefined) return true;
288
+ return manifestTouchesLockfileRelevantKeys(oldSource, newSource);
289
+ }
290
+
196
291
  export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifest?: string; lockfile?: string; reason?: string } {
197
292
  try {
198
293
  let modifiedFiles: string[] = [];
294
+ let mergeBaseCommit: string | undefined;
199
295
  const baseCandidates = ["origin/HEAD", "origin/main", "origin/master", "main", "master"];
200
296
  for (const base of baseCandidates) {
201
297
  const mergeBase = spawnSync("git", ["merge-base", "HEAD", base], { cwd: root, encoding: "utf8", timeout: 10_000 });
202
298
  if (mergeBase.status !== 0 || !mergeBase.stdout.trim()) continue;
203
- const diff = spawnSync("git", ["diff", "--name-only", `${mergeBase.stdout.trim()}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
299
+ const baseCommit = mergeBase.stdout.trim();
300
+ const diff = spawnSync("git", ["diff", "--name-only", `${baseCommit}...HEAD`], { cwd: root, encoding: "utf8", timeout: 10_000 });
204
301
  if (diff.status === 0 && diff.stdout.trim()) {
205
302
  modifiedFiles = diff.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
303
+ mergeBaseCommit = baseCommit;
206
304
  break;
207
305
  }
208
306
  }
@@ -236,16 +334,19 @@ export function checkLockfileSync(root: string): { isOutOfSync: boolean; manifes
236
334
  ];
237
335
 
238
336
  for (const item of manifestToLockfiles) {
239
- const hasManifest = modifiedFiles.some((f) => item.manifest.test(f));
240
- if (hasManifest) {
241
- const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
242
- if (!hasLockfile) {
243
- return {
244
- isOutOfSync: true,
245
- manifest: item.name,
246
- reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
247
- };
248
- }
337
+ const manifestPaths = modifiedFiles.filter((f) => item.manifest.test(f));
338
+ if (manifestPaths.length === 0) continue;
339
+ const hasLockfile = modifiedFiles.some((f) => item.lockfiles.test(f));
340
+ if (hasLockfile) continue;
341
+ // Only a lockfile-relevant manifest change demands the lockfile: a
342
+ // scripts-only edit regenerates it byte-identical, so demanding one
343
+ // is unsatisfiable.
344
+ if (manifestPaths.some((manifestPath) => manifestChangeRequiresLockfile(root, mergeBaseCommit, manifestPath))) {
345
+ return {
346
+ isOutOfSync: true,
347
+ manifest: item.name,
348
+ reason: `Package manifest '${item.name}' was modified without updating its corresponding lockfile. Run the package manager install/lock command before opening a PR.`,
349
+ };
249
350
  }
250
351
  }
251
352
  } catch {}
@@ -84,6 +84,9 @@ import {
84
84
  sessionReviews,
85
85
  setSessionWorkspace,
86
86
  getSessionWorkspace,
87
+ getSessionBoundWorkspace,
88
+ registerSessionWorkspaceFromToolCall,
89
+ resolveSessionBoundWorkspace,
87
90
  resolveEffectiveWorkspace,
88
91
  } from "./lib/state.ts";
89
92
  import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
@@ -116,6 +119,9 @@ export {
116
119
  resetReviewState,
117
120
  setSessionWorkspace,
118
121
  getSessionWorkspace,
122
+ getSessionBoundWorkspace,
123
+ registerSessionWorkspaceFromToolCall,
124
+ resolveSessionBoundWorkspace,
119
125
  resolveEffectiveWorkspace,
120
126
  findGitRoot,
121
127
  isSameGitRepo,
@@ -448,12 +454,16 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
448
454
  tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client: ctx.client }),
449
455
 
450
456
  "tool.execute.before": async (input, output) => {
451
- const toolWorktree =
457
+ const hostWorktree =
452
458
  (input as { worktree?: string })?.worktree ||
453
459
  (input as { directory?: string })?.directory ||
454
460
  (output as { worktree?: string })?.worktree ||
455
- (output as { directory?: string })?.directory ||
456
- effectiveRoot;
461
+ (output as { directory?: string })?.directory;
462
+ // Session-bound workspace before the plugin-instance fallback:
463
+ // concurrent loops share this process, and a fallback derived from
464
+ // the last-active instance must not re-anchor another session's
465
+ // policy context.
466
+ const toolWorktree = hostWorktree || getSessionBoundWorkspace(input.sessionID) || effectiveRoot;
457
467
  const toolAgent =
458
468
  (input as { agent?: string })?.agent ||
459
469
  (output as { agent?: string })?.agent;
@@ -480,11 +490,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
480
490
  const record = asRecord(args);
481
491
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
482
492
  const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
483
- const candidatePath = workdir ?? filePath ?? toolWorktree;
484
- if (candidatePath) {
485
- const gitRoot = findGitRoot(candidatePath);
486
- if (gitRoot) setSessionWorkspace(input.sessionID, gitRoot);
487
- }
493
+ registerSessionWorkspaceFromToolCall(input.sessionID, { workdir, filePath, hostWorktree, toolWorktree });
488
494
  }
489
495
  if (input.tool === "read") {
490
496
  const target = editTargets(args, toolWorktree)[0];
package/test/test.mts CHANGED
@@ -45,6 +45,8 @@ import {
45
45
  persistReviewCache,
46
46
  setSessionWorkspace,
47
47
  getSessionWorkspace,
48
+ getSessionBoundWorkspace,
49
+ registerSessionWorkspaceFromToolCall,
48
50
  resolveEffectiveWorkspace,
49
51
  getLastReviewResultForWorkspace,
50
52
  isSameGitRepo,
@@ -3169,6 +3171,219 @@ writeFileSync(join(lockRepo, "package-lock.json"), "{\"name\":\"demo\",\"version
3169
3171
  check("checkLockfileSync passes when lockfile is updated", !checkLockfileSync(lockRepo).isOutOfSync);
3170
3172
  rmSync(lockRepo, { recursive: true, force: true });
3171
3173
 
3174
+ // Lockfile synchronization tracks lockfile-relevant manifest sections, not
3175
+ // the mere presence of package.json in the branch diff. A scripts-only edit
3176
+ // (the documented curation act of adding a suite to a test script) regenerates
3177
+ // package-lock.json byte-identical, so demanding a lockfile change for it is
3178
+ // unsatisfiable; dependency/engines/packageManager changes still demand one.
3179
+ const scriptLockRepo = mkdtempSync(join(tmpdir(), "wg-lock-scripts-"));
3180
+ spawnSync("git", ["init", "-b", "main"], { cwd: scriptLockRepo });
3181
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: scriptLockRepo });
3182
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: scriptLockRepo });
3183
+ const scriptManifestBase = { name: "demo", version: "1.0.0", scripts: { test: "node test.mjs" } };
3184
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestBase, null, 2) + "\n");
3185
+ writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3 }, null, 2) + "\n");
3186
+ // A nested manifest already present at the merge base, so its scripts-only
3187
+ // edit on the branch is a true edit (not a new manifest).
3188
+ mkdirSync(join(scriptLockRepo, "packages", "app"), { recursive: true });
3189
+ const nestedManifestBase = { name: "app", version: "0.0.0", scripts: { build: "node build.mjs" } };
3190
+ writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestBase, null, 2) + "\n");
3191
+ spawnSync("git", ["add", "-A"], { cwd: scriptLockRepo });
3192
+ spawnSync("git", ["commit", "-qm", "init"], { cwd: scriptLockRepo });
3193
+ spawnSync("git", ["switch", "-c", "feat/scripts-only"], { cwd: scriptLockRepo });
3194
+ const scriptManifestEdited = JSON.parse(JSON.stringify(scriptManifestBase)) as typeof scriptManifestBase;
3195
+ scriptManifestEdited.scripts.test = "node test.mjs && node test-ci.mjs";
3196
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestEdited, null, 2) + "\n");
3197
+ check("checkLockfileSync passes an uncommitted scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3198
+ spawnSync("git", ["commit", "-qam", "curate test suite"], { cwd: scriptLockRepo });
3199
+ check("checkLockfileSync passes a committed scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3200
+ // The observed false positive: a scripts-only edit alongside a lockfile that
3201
+ // npm rewrote byte-identical (present in the diff set, yet unchanged in
3202
+ // content — the old check demanded exactly this unsatisfiable combination).
3203
+ writeFileSync(join(scriptLockRepo, "package-lock.json"), readFileSync(join(scriptLockRepo, "package-lock.json"), "utf8"));
3204
+ check("checkLockfileSync passes a byte-identical lockfile rewrite next to a scripts-only edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3205
+ // A nested manifest's scripts-only edit passes too.
3206
+ const nestedManifestEdited = JSON.parse(JSON.stringify(nestedManifestBase)) as typeof nestedManifestBase;
3207
+ nestedManifestEdited.scripts.build = "node build.mjs && node verify.mjs";
3208
+ writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestEdited, null, 2) + "\n");
3209
+ check("checkLockfileSync passes a scripts-only nested manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3210
+ // Contract pins: sections the lockfile records still demand the lockfile.
3211
+ const scriptsAfterNesting = scriptManifestEdited.scripts;
3212
+ const relevantManifest = { name: "demo", version: "1.0.0", scripts: scriptsAfterNesting };
3213
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
3214
+ check("checkLockfileSync flags a dependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3215
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, engines: { node: ">=22" } }, null, 2) + "\n");
3216
+ check("checkLockfileSync flags an engines change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3217
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, packageManager: "pnpm@9.0.0" }, null, 2) + "\n");
3218
+ check("checkLockfileSync flags a packageManager change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3219
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, devDependencies: { typescript: "^5.0.0" } }, null, 2) + "\n");
3220
+ check("checkLockfileSync flags a devDependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3221
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, pnpm: { overrides: { "left-pad": "^1.3.0" } } }, null, 2) + "\n");
3222
+ check("checkLockfileSync flags a pnpm-overrides change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3223
+ // And a relevant change WITH the lockfile updated passes.
3224
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
3225
+ writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
3226
+ check("checkLockfileSync passes when the lockfile is updated alongside a relevant change", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3227
+ rmSync(scriptLockRepo, { recursive: true, force: true });
3228
+
3229
+ // Concurrent loops share ONE guard process: workspace resolution must come
3230
+ // from the call's own context or the session's own binding, never from the
3231
+ // last-active plugin instance. Two plugin instances in this process simulate
3232
+ // the two loops; instance B's roots must never re-anchor session A's context.
3233
+ const isoRepoA = mkdtempSync(join(tmpdir(), "wg-iso-a-"));
3234
+ spawnSync("git", ["init", "-b", "main"], { cwd: isoRepoA });
3235
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoA });
3236
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoA });
3237
+ writeFileSync(join(isoRepoA, "seed.txt"), "loop a\n");
3238
+ spawnSync("git", ["add", "-A"], { cwd: isoRepoA });
3239
+ spawnSync("git", ["commit", "-qm", "init a"], { cwd: isoRepoA });
3240
+ spawnSync("git", ["switch", "-c", "feat/iso-a"], { cwd: isoRepoA });
3241
+ const isoRepoB = mkdtempSync(join(tmpdir(), "wg-iso-b-"));
3242
+ spawnSync("git", ["init", "-b", "loop/other-loop"], { cwd: isoRepoB });
3243
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoB });
3244
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoB });
3245
+ writeFileSync(join(isoRepoB, "seed.txt"), "loop b\n");
3246
+ spawnSync("git", ["add", "-A"], { cwd: isoRepoB });
3247
+ spawnSync("git", ["commit", "-qm", "init b"], { cwd: isoRepoB });
3248
+ const isoPluginA = await WorkflowGuard({ directory: isoRepoA, worktree: isoRepoA, client: fakeClient as any, project: {} as any, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
3249
+ const isoPluginB = await WorkflowGuard({ directory: isoRepoB, worktree: isoRepoB, client: fakeClient as any, project: {} as any, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
3250
+ const isoHookOf = (plugin: unknown) => (plugin as { "tool.execute.before"?: (input: unknown, output: unknown) => Promise<unknown> })["tool.execute.before"];
3251
+ // Session A binds to repoA from its OWN call context (an edit/read target
3252
+ // inside repoA delivered through the call arguments).
3253
+ await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-shared", tool: "read", callID: "c-iso-1" }, { args: { filePath: join(isoRepoA, "seed.txt") } });
3254
+ check("a session binds to its workspace from its own call context", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
3255
+ // The other loop's plugin instance must not re-anchor it from its own root.
3256
+ await isoHookOf(isoPluginB)?.({ sessionID: "s-iso-shared", tool: "bash", callID: "c-iso-2" }, { args: { command: "ls -la" } });
3257
+ check("an instance-derived fallback never re-anchors a bound session", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
3258
+ const isoStatus = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({}, { sessionID: "s-iso-shared" } as any)));
3259
+ check("guard_status resolves the session's own workspace under plugin-instance drift", isoStatus.workspaceRoot === isoRepoA && isoStatus.branch === "feat/iso-a");
3260
+ const isoStatusExplicit = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({ directory: isoRepoB }, { sessionID: "s-iso-shared" } as any)));
3261
+ check("guard_status honors an explicit directory over the session binding", isoStatusExplicit.workspaceRoot === isoRepoB && isoStatusExplicit.branch === "loop/other-loop");
3262
+ check("resolveEffectiveWorkspace keeps a bound session off a foreign fallback", (await resolveEffectiveWorkspace({ sessionID: "s-iso-shared", fallback: isoRepoB })) === isoRepoA);
3263
+ check("resolveEffectiveWorkspace keeps fallback semantics for an unbound session", (await resolveEffectiveWorkspace({ sessionID: "s-iso-unbound", fallback: isoRepoB })) === isoRepoB);
3264
+ // Policy decisions follow the call's own context: a boundary write inside the
3265
+ // session's repo passes even while the other loop's root is the fallback.
3266
+ todo("s-iso-shared", item("iso work", "in_progress"));
3267
+ const isoWrite = await guardToolDecision("write", { filePath: join(isoRepoA, "scratch.txt"), content: "safe" }, { sessionID: "s-iso-shared" });
3268
+ check("boundary checks use the session's own workspace, not the other loop's root", isoWrite.status === "allowed");
3269
+ if (isoWrite.status !== "allowed") console.log(" blocked:", isoWrite.message);
3270
+ // Review verdict binding is never guessed: no explicit directory, no session
3271
+ // binding, no per-call worktree — the verdict is rejected, not latched.
3272
+ const unboundReview = await isoPluginA.tool?.record_review?.execute(
3273
+ { reviewer: "no-binding", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
3274
+ { sessionID: "s-iso-unbound-recorder" } as any,
3275
+ );
3276
+ check(
3277
+ "record_review rejects a verdict it cannot bind instead of latching a root",
3278
+ typeof unboundReview === "string" && unboundReview.includes("rejected") && unboundReview.includes("never bound to a guessed root") && !unboundReview.includes("APPROVED"),
3279
+ );
3280
+ const unboundReviewAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-iso-unbound-recorder");
3281
+ check("unbindingable verdict audited as review_binding_unresolved", unboundReviewAudit?.reason === "review_binding_unresolved");
3282
+ // A bound session records through the OTHER loop's plugin instance: the
3283
+ // verdict binds to the session's own workspace, not the instance root.
3284
+ const isoBoundReview = await isoPluginB.tool?.record_review?.execute(
3285
+ { reviewer: "drift-proof", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
3286
+ { sessionID: "s-iso-shared" } as any,
3287
+ );
3288
+ check(
3289
+ "record_review binds to the session's own workspace, not the other loop's instance root",
3290
+ typeof isoBoundReview === "string" && isoBoundReview.includes("APPROVED") && isoBoundReview.includes(isoRepoA) && !isoBoundReview.includes(isoRepoB),
3291
+ );
3292
+ rmSync(isoRepoA, { recursive: true, force: true });
3293
+ rmSync(isoRepoB, { recursive: true, force: true });
3294
+ resetReviewState();
3295
+ setWorkspaceRoot(root);
3296
+
3297
+ // Review bindings are consumed per WORKTREE, not per repository-recency: two
3298
+ // loops sharing one repo must not displace each other's approvals between
3299
+ // record_review and PR creation.
3300
+ const raceRepo = join(root, "wg-race-repo");
3301
+ mkdirSync(raceRepo, { recursive: true });
3302
+ spawnSync("git", ["init", "-b", "main"], { cwd: raceRepo });
3303
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: raceRepo });
3304
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: raceRepo });
3305
+ writeFileSync(join(raceRepo, "seed.txt"), "base\n");
3306
+ spawnSync("git", ["add", "-A"], { cwd: raceRepo });
3307
+ spawnSync("git", ["commit", "-qm", "base"], { cwd: raceRepo });
3308
+ mkdirSync(join(raceRepo, ".opencode"), { recursive: true });
3309
+ writeFileSync(join(raceRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
3310
+ spawnSync("git", ["switch", "-c", "feat/race-a"], { cwd: raceRepo });
3311
+ writeFileSync(join(raceRepo, "a.txt"), "loop a change\n");
3312
+ spawnSync("git", ["add", "-A"], { cwd: raceRepo });
3313
+ spawnSync("git", ["commit", "-qm", "loop a change"], { cwd: raceRepo });
3314
+ const prevWorktreeDirRace = process.env.WORKFLOW_GUARD_WORKTREE_DIR;
3315
+ process.env.WORKFLOW_GUARD_WORKTREE_DIR = join(root, "wg-race-worktrees");
3316
+ const raceWtRes = createGitWorktree("feat/race-b", "HEAD", raceRepo);
3317
+ check("race: sibling worktree created", raceWtRes.success && typeof raceWtRes.worktreePath === "string");
3318
+ const raceWt = raceWtRes.worktreePath!;
3319
+ writeFileSync(join(raceWt, "b.txt"), "loop b change\n");
3320
+ spawnSync("git", ["add", "-A"], { cwd: raceWt });
3321
+ spawnSync("git", ["commit", "-qm", "loop b change"], { cwd: raceWt });
3322
+ const raceSummary = "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.";
3323
+ // Loop A records first; loop B records a NEWER review for the sibling
3324
+ // worktree. Each worktree must keep consuming its OWN approval.
3325
+ resetReviewState();
3326
+ recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
3327
+ recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
3328
+ check("loop A consumes its own worktree review, not the newer sibling review", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
3329
+ check("loop B consumes its own worktree review", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
3330
+ resetReviewState();
3331
+ recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
3332
+ recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
3333
+ check("loop A keeps its own review when it was recorded first", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
3334
+ check("loop B keeps its own review when it was recorded last", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
3335
+ // Preflight consumption with the remote state stubbed off: loop A's PR must
3336
+ // pass while the sibling loop's newer review exists, and must FAIL with a
3337
+ // worktree-mismatch cause when only the sibling review is present.
3338
+ const racePrevPath = process.env.PATH;
3339
+ const raceBin = join(root, "wg-race-bin");
3340
+ mkdirSync(raceBin, { recursive: true });
3341
+ writeFileSync(join(raceBin, "gh"), "#!/bin/sh\nexit 1\n");
3342
+ writeFileSync(join(raceBin, "az"), "#!/bin/sh\nexit 1\n");
3343
+ chmodSync(join(raceBin, "gh"), 0o755);
3344
+ chmodSync(join(raceBin, "az"), 0o755);
3345
+ process.env.PATH = `${raceBin}:${racePrevPath ?? ""}`;
3346
+ const racePrBody = "gh pr create --title 'fix: race' --body 'Changelog: race'";
3347
+ try {
3348
+ const racePrA = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
3349
+ check("loop A's PR passes preflight while the sibling loop's newer review exists", !blocked(racePrA));
3350
+ if (blocked(racePrA)) console.log(" blocked:", racePrA);
3351
+ const racePrB = await call("bash", { command: racePrBody, workdir: raceWt }, { sessionID: "s-race-b", worktree: raceWt, directory: raceWt });
3352
+ check("loop B's PR passes preflight independently", !blocked(racePrB));
3353
+ if (blocked(racePrB)) console.log(" blocked:", racePrB);
3354
+ // Negative control: only the sibling review present -> the mismatch is
3355
+ // named instead of displacing the other loop's evidence.
3356
+ resetReviewState();
3357
+ recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
3358
+ const racePrOnlyB = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
3359
+ check(
3360
+ "loop A's PR is blocked on the sibling review alone, naming the worktree mismatch",
3361
+ blocked(racePrOnlyB) && String(racePrOnlyB).includes("does not match the current worktree contents"),
3362
+ );
3363
+ // Same-repo fallback still rescues content-identical trees: a review
3364
+ // recorded from a worktree whose tracked content equals this one applies,
3365
+ // even when a NEWER sibling review does not match this content.
3366
+ const raceWtSameContentRes = createGitWorktree("feat/race-same", "feat/race-a", raceRepo);
3367
+ const raceWtSame = raceWtSameContentRes.worktreePath!;
3368
+ resetReviewState();
3369
+ recordReviewResult("reviewer-same", raceSummary, true, "s-race-same", raceRepo);
3370
+ recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
3371
+ check(
3372
+ "a same-repo review whose tracked content matches this worktree is preferred over a newer mismatching one",
3373
+ getLastReviewResultForWorkspace(raceWtSame)?.reviewer === "reviewer-same",
3374
+ );
3375
+ rmSync(raceWtSame, { recursive: true, force: true });
3376
+ } finally {
3377
+ process.env.PATH = racePrevPath;
3378
+ }
3379
+ if (prevWorktreeDirRace === undefined) delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
3380
+ else process.env.WORKFLOW_GUARD_WORKTREE_DIR = prevWorktreeDirRace;
3381
+ rmSync(join(root, "wg-race-worktrees"), { recursive: true, force: true });
3382
+ rmSync(raceBin, { recursive: true, force: true });
3383
+ rmSync(raceRepo, { recursive: true, force: true });
3384
+ resetReviewState();
3385
+ setWorkspaceRoot(root);
3386
+
3172
3387
  // 14. Documentation Review & Synchronization Guard (Policy 21)
3173
3388
  console.log("- Policy 21: Documentation Review & Synchronization Guard -");
3174
3389
  const docRepo = mkdtempSync(join(tmpdir(), "wg-doc-repo-"));