opencode-workflow-guard 1.15.1 → 1.15.3

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.
@@ -15,12 +15,14 @@ import { existsSync, readFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
16
  import { join } from "node:path";
17
17
  import { loadedPluginVersion } from "./lib/version.ts";
18
- import { editTargets, runPostEditValidators, snapshotFile } from "./policies/post-edit-validation.ts";
18
+ import { editTargets, runPostEditValidators, snapshotFile, type FileSnapshot } from "./policies/post-edit-validation.ts";
19
19
  import { claimFiles, releaseFileClaims } from "./policies/file-claims.ts";
20
+ import { extractCommands, splitShellSegments } from "./lib/shell.ts";
21
+ import { expandShellTargetPath, isPathOutsideWorkspace, OPENCODE_SCRATCH_DIR, redirectMutationsIn } from "./policies/boundary.ts";
20
22
  import { beginReadObservation, clearReadFingerprints, recordMutationObservation, recordSuccessfulRead, staleWriteReason } from "./policies/stale-write.ts";
21
23
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
22
24
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
23
- import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
25
+ import { guardToolCallImpl, isReadOnlyRole, SHELL_TOOL_NAMES } from "./lib/guard-dispatcher.ts";
24
26
  import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
25
27
  import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
26
28
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
@@ -84,6 +86,9 @@ import {
84
86
  sessionReviews,
85
87
  setSessionWorkspace,
86
88
  getSessionWorkspace,
89
+ getSessionBoundWorkspace,
90
+ registerSessionWorkspaceFromToolCall,
91
+ resolveSessionBoundWorkspace,
87
92
  resolveEffectiveWorkspace,
88
93
  } from "./lib/state.ts";
89
94
  import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
@@ -116,6 +121,9 @@ export {
116
121
  resetReviewState,
117
122
  setSessionWorkspace,
118
123
  getSessionWorkspace,
124
+ getSessionBoundWorkspace,
125
+ registerSessionWorkspaceFromToolCall,
126
+ resolveSessionBoundWorkspace,
119
127
  resolveEffectiveWorkspace,
120
128
  findGitRoot,
121
129
  isSameGitRepo,
@@ -448,12 +456,16 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
448
456
  tool: createCustomTools({ effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client: ctx.client }),
449
457
 
450
458
  "tool.execute.before": async (input, output) => {
451
- const toolWorktree =
459
+ const hostWorktree =
452
460
  (input as { worktree?: string })?.worktree ||
453
461
  (input as { directory?: string })?.directory ||
454
462
  (output as { worktree?: string })?.worktree ||
455
- (output as { directory?: string })?.directory ||
456
- effectiveRoot;
463
+ (output as { directory?: string })?.directory;
464
+ // Session-bound workspace before the plugin-instance fallback:
465
+ // concurrent loops share this process, and a fallback derived from
466
+ // the last-active instance must not re-anchor another session's
467
+ // policy context.
468
+ const toolWorktree = hostWorktree || getSessionBoundWorkspace(input.sessionID) || effectiveRoot;
457
469
  const toolAgent =
458
470
  (input as { agent?: string })?.agent ||
459
471
  (output as { agent?: string })?.agent;
@@ -480,11 +492,7 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
480
492
  const record = asRecord(args);
481
493
  const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
482
494
  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
- }
495
+ registerSessionWorkspaceFromToolCall(input.sessionID, { workdir, filePath, hostWorktree, toolWorktree });
488
496
  }
489
497
  if (input.tool === "read") {
490
498
  const target = editTargets(args, toolWorktree)[0];
@@ -497,6 +505,32 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
497
505
  const snapshots = editTargets(args, toolWorktree).map(snapshotFile);
498
506
  if (snapshots.length) toolLifecycle.setPostEditSnapshots(input.sessionID, input.callID, toolWorktree, snapshots);
499
507
  }
508
+ if (SHELL_TOOL_NAMES.has(input.tool)) {
509
+ // An allowed shell redirect authors the target's bytes exactly
510
+ // like edit/write (LL-003), so pre-register each redirect
511
+ // target's pre-state and seed the session's freshness
512
+ // observation after the call succeeds. Observation only: shell
513
+ // writes never run the post-edit validators.
514
+ const record = asRecord(args);
515
+ const shellWorkdir = typeof record?.workdir === "string" ? record.workdir : undefined;
516
+ const shellCwd = shellWorkdir ? resolve(toolWorktree, shellWorkdir) : toolWorktree;
517
+ const snapshots: FileSnapshot[] = [];
518
+ for (const command of extractCommands(args)) {
519
+ for (const segment of splitShellSegments(command)) {
520
+ for (const mutation of redirectMutationsIn(segment)) {
521
+ const target = mutation.target ?? "";
522
+ if (!target || target === "/dev/null") continue;
523
+ const expanded = expandShellTargetPath(target);
524
+ if (!expanded) continue;
525
+ const resolvedTarget = resolve(shellCwd, expanded);
526
+ if (resolvedTarget === OPENCODE_SCRATCH_DIR || resolvedTarget.startsWith(OPENCODE_SCRATCH_DIR + "/")) continue;
527
+ if (isPathOutsideWorkspace(resolvedTarget, toolWorktree)) continue;
528
+ snapshots.push(snapshotFile(resolvedTarget));
529
+ }
530
+ }
531
+ }
532
+ if (snapshots.length) toolLifecycle.setShellWriteSnapshots(input.sessionID, input.callID, toolWorktree, snapshots);
533
+ }
500
534
  });
501
535
  },
502
536
 
@@ -520,6 +554,14 @@ export const WorkflowGuard: V1Plugin = async (ctx: Parameters<V1Plugin>[0]) => {
520
554
  const observation = toolLifecycle.takeReadObservation(input.sessionID, input.callID);
521
555
  if (observation) recordSuccessfulRead(observation, input.sessionID);
522
556
  }
557
+ const shellPending = toolLifecycle.takeShellWriteSnapshots(input.sessionID, input.callID);
558
+ if (shellPending) {
559
+ // An allowed shell redirect authored these bytes (LL-003): seed
560
+ // the session's observation so a follow-up edit needs no
561
+ // redundant re-read. The digest gate keeps no-op/failed calls
562
+ // from seeding; validators are intentionally not run here.
563
+ for (const before of shellPending.snapshots) recordMutationObservation(before.path, input.sessionID, before.digest);
564
+ }
523
565
  const pending = toolLifecycle.takePostEditSnapshots(input.sessionID, input.callID);
524
566
  if (!pending) return;
525
567
  await runWithRuntimeState(pending.root, ctx.client, async () => {
package/test/test-e2e.mts CHANGED
@@ -220,12 +220,24 @@ const runV2Probes = async (ctx: any) => {
220
220
  \tconst guardWhy = byId("guard_why");
221
221
  \tconst pick = (r: any) => (typeof r?.content === "string" ? r.content : JSON.stringify(r));
222
222
  \tconst statusRes = guardStatus ? await guardStatus.execute({}, headlessToolContext) : undefined;
223
- \tconst whyRes = guardWhy ? await guardWhy.execute({ tool: "bash", input: { command: "git push origin main" } }, headlessToolContext) : undefined;
223
+ \tconst whyProbes: Record<string, { tool: string; input: Record<string, unknown> }> = {
224
+ \t\tgit: { tool: "bash", input: { command: "git push origin main" } },
225
+ \t\t"secrets-read": { tool: "read", input: { filePath: ".env" } },
226
+ \t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
227
+ \t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
228
+ \t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
229
+ \t};
230
+ \tconst whyResults: Record<string, any> = {};
231
+ \tfor (const [name, probe] of Object.entries(whyProbes)) {
232
+ \t\tconst res = guardWhy ? await guardWhy.execute(probe, headlessToolContext) : undefined;
233
+ \t\twhyResults[name] = res === undefined ? null : JSON.parse(pick(res));
234
+ \t}
224
235
  \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({
225
236
  \t\ttools: tools.map((t: any) => t.id),
226
237
  \t\ttodowriteEnriched: Boolean(todowrite?.description?.includes("Workflow Guard lifecycle")),
227
238
  \t\tstatus: JSON.parse(pick(statusRes)),
228
- \t\twhy: JSON.parse(pick(whyRes)),
239
+ \t\twhy: whyResults.git,
240
+ \t\twhyProbes: whyResults,
229
241
  \t}) + "\\n");
230
242
  \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
231
243
  };
@@ -235,8 +247,19 @@ const runV1Probes = async (ctx: any) => {
235
247
  \tconst directory = ctx?.location?.directory ?? ctx?.directory ?? process.cwd();
236
248
  \tconst toolCtx = { sessionID: "headless-e2e", directory, worktree: directory };
237
249
  \tconst status = await hooks.tool?.guard_status?.execute({}, toolCtx);
238
- \tconst why = await hooks.tool?.guard_why?.execute({ tool: "bash", input: { command: "git push origin main" } }, toolCtx);
239
- \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: JSON.parse(String(why)) }) + "\\n");
250
+ \tconst whyProbes: Record<string, { tool: string; input: Record<string, unknown> }> = {
251
+ \t\tgit: { tool: "bash", input: { command: "git push origin main" } },
252
+ \t\t"secrets-read": { tool: "read", input: { filePath: ".env" } },
253
+ \t\t"secrets-shell": { tool: "bash", input: { command: "cat .env" } },
254
+ \t\t"boundary-write": { tool: "write", input: { filePath: "../outside_escaped.txt", content: "probe" } },
255
+ \t\t"shell-tty": { tool: "bash", input: { command: "vim notes.txt" } },
256
+ \t};
257
+ \tconst whyResults: Record<string, any> = {};
258
+ \tfor (const [name, probe] of Object.entries(whyProbes)) {
259
+ \t\tconst res = await hooks.tool?.guard_why?.execute(probe, toolCtx);
260
+ \t\twhyResults[name] = res === undefined ? null : JSON.parse(String(res));
261
+ \t}
262
+ \twriteFileSync(${JSON.stringify(accountabilityMarker)}, JSON.stringify({ status: JSON.parse(String(status)), why: whyResults.git, whyProbes: whyResults }) + "\\n");
240
263
  \twriteFileSync(${JSON.stringify(initializedMarker)}, "initialized\\n");
241
264
  \treturn hooks;
242
265
  };
@@ -266,6 +289,18 @@ check("local plugin adapter and source copied successfully", existsSync(localAda
266
289
  spawnSync("git", ["init", "-b", "feat/install-verification"], { cwd: testDir });
267
290
  spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: testDir });
268
291
  spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: testDir });
292
+ // Project configuration loads from .opencode/workflow-guard.json[c]; sentinel
293
+ // values prove the real runtime reads project config (guard_status echoes it)
294
+ // instead of silently falling back to defaults. Both fields are behaviorally
295
+ // inert for every harness probe: no PR is created, record_review is not
296
+ // called, and verifyCommand is left unset so the all-done verification gate
297
+ // still finds no configured command in this configless test project.
298
+ writeFileSync(join(testDir, ".opencode", "workflow-guard.json"), JSON.stringify({
299
+ protectedBranches: ["e2e-config-probe-branch"],
300
+ requireReview: false,
301
+ learning: true,
302
+ recoveryCheckpoints: true,
303
+ }, null, 2) + "\n");
269
304
 
270
305
  const runtimeEnv: NodeJS.ProcessEnv = {
271
306
  ...process.env,
@@ -276,6 +311,10 @@ const runtimeEnv: NodeJS.ProcessEnv = {
276
311
  delete runtimeEnv.OPENCODE_PID;
277
312
  delete runtimeEnv.OPENCODE_PURE;
278
313
  delete runtimeEnv.OPENCODE;
314
+ // The learning config key must stay load-bearing: an inherited
315
+ // WORKFLOW_GUARD_LEARNING=1 would register the learning tools without the
316
+ // sentinel config file being read (review P2, tool-surface iteration).
317
+ delete runtimeEnv.WORKFLOW_GUARD_LEARNING;
279
318
  if (isOpenCodeV2) {
280
319
  // V2 runs a shared managed service on a fixed default port. Each run uses
281
320
  // its own isolated XDG dirs, so pick a run-unique port to keep concurrent
@@ -357,12 +396,28 @@ try {
357
396
  headlessAccountability = JSON.parse(readFileSync(accountabilityMarker, "utf8"));
358
397
  } catch {}
359
398
  check("headless OpenCode runtime exposes structured guard status and why without TUI", headlessAccountability?.status?.workspaceRoot === testDir && headlessAccountability?.why?.policy === "git" && headlessAccountability?.why?.code === "protected_branch_push");
399
+ const whyProbe = (name: string): any => headlessAccountability?.whyProbes?.[name];
400
+ check("headless guard_why simulates secret read policy via read tool", whyProbe("secrets-read")?.policy === "secrets" && whyProbe("secrets-read")?.code === "secret_read" && whyProbe("secrets-read")?.status === "blocked");
401
+ check("headless guard_why simulates secret read policy via shell", whyProbe("secrets-shell")?.policy === "secrets" && whyProbe("secrets-shell")?.code === "secret_read" && whyProbe("secrets-shell")?.status === "blocked");
402
+ check("headless guard_why simulates workspace boundary policy via write tool", whyProbe("boundary-write")?.policy === "boundary" && whyProbe("boundary-write")?.code === "workspace_escape" && whyProbe("boundary-write")?.status === "blocked");
403
+ check("headless guard_why simulates interactive tty shell-safety policy", whyProbe("shell-tty")?.policy === "shell-safety" && whyProbe("shell-tty")?.code === "interactive_tty" && whyProbe("shell-tty")?.status === "blocked");
404
+ check("headless guard_status reports project config loaded from .opencode/workflow-guard.json", headlessAccountability?.status?.projectConfig?.protectedBranches?.[0] === "e2e-config-probe-branch" && headlessAccountability?.status?.projectConfig?.requireReview === false);
360
405
  if (isOpenCodeV2) {
361
406
  // The V2 adapter runs the shipped setup(ctx) for real; verify the
362
407
  // registered tool surface. Builtin description enrichment is best-effort:
363
408
  // builtins are not visible to ctx.tool.list() during plugin setup, so
364
409
  // todowriteEnriched is reported but not asserted.
365
410
  check("V2 setup registers custom guard tools", (headlessAccountability?.tools ?? []).includes("guard_status") && (headlessAccountability?.tools ?? []).includes("guard_why"));
411
+ const expectedGuardTools = [
412
+ "guard_status", "guard_why", "guard_audit", "guard_next_tasks", "record_review",
413
+ "guard_review_rubric", "guard_review_followups", "guard_review_followup_resolve",
414
+ "guard_worktree_create", "guard_worktree_cleanup",
415
+ "project_memory_record", "project_memory_search", "project_memory_export", "project_memory_import",
416
+ "learning_checkpoint", "learning_profile", "learning_record", "guard_recovery_restore",
417
+ ];
418
+ const missingGuardTools = expectedGuardTools.filter((id) => !(headlessAccountability?.tools ?? []).includes(id));
419
+ check("V2 setup registers the full config-gated guard tool surface", missingGuardTools.length === 0);
420
+ if (missingGuardTools.length > 0) console.log(" missing guard tools: " + missingGuardTools.join(", "));
366
421
  console.log(` note: builtin todowrite description enriched under V2: ${headlessAccountability?.todowriteEnriched === true ? "yes" : "unknown at setup time"}`);
367
422
  }
368
423
 
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,
@@ -729,6 +731,34 @@ check("tee -- flag separator outside workspace is blocked", blocked(await call("
729
731
  check("multi-target tee with an outside path is blocked", blocked(await call("bash", { command: "echo x | tee in.txt /tmp/wg-outside-tee" }, { sessionID: "s-active" })));
730
732
  check("tee --append within workspace is allowed with todos", !(await call("bash", { command: "echo x | tee --append src/a.ts" }, { sessionID: "s-active" })));
731
733
 
734
+ // Regression (device audit, workspace_escape): symlink aliases of the
735
+ // workspace itself. An absolute target that is lexically outside the root
736
+ // but canonically inside (the alias resolves back into the workspace) must
737
+ // be accepted, while symlinks inside the workspace pointing outside stay
738
+ // blocked. Machine-independent: the alias is built inside the fixture
739
+ // instead of relying on host aliasing such as /home -> /var/home.
740
+ const aliasDir = mkdtempSync(join(tmpdir(), "wg-boundary-alias-"));
741
+ mkdirSync(join(aliasDir, "real"), { recursive: true });
742
+ symlinkSync(aliasDir, join(aliasDir, "alias"), "dir");
743
+ writeFileSync(join(aliasDir, "real", "existing.txt"), "keep\n");
744
+ symlinkSync("/etc", join(aliasDir, "real", "escape"));
745
+ setWorkspaceRoot(join(aliasDir, "real"));
746
+ check("write through symlink alias of the workspace is allowed (new file)", !(await call("write", { filePath: join(aliasDir, "alias", "real", "new-file.txt"), content: "x" }, { sessionID: "s-active" })));
747
+ // The write gate also applies stale-write protection (existing files need a
748
+ // prior session read), so seed the observation by reading through the alias.
749
+ // Plugin setup latches the process root to its directory/worktree
750
+ // (workflow-guard.ts setWorkspaceRoot on setup), so re-latch the alias
751
+ // workspace root before the gated checks.
752
+ const boundaryAliasPlugin = await WorkflowGuard({ directory: root, worktree: root, client: fakeClient as any } as any);
753
+ setWorkspaceRoot(join(aliasDir, "real"));
754
+ await boundaryAliasPlugin["tool.execute.before"]?.({ tool: "read", sessionID: "s-active", callID: "alias-read" }, { args: { filePath: join(aliasDir, "alias", "real", "existing.txt") } } as any);
755
+ await boundaryAliasPlugin["tool.execute.after"]?.({ tool: "read", sessionID: "s-active", callID: "alias-read", args: { filePath: join(aliasDir, "alias", "real", "existing.txt") } } as any, { title: "existing.txt", output: "keep", metadata: {} } as any);
756
+ check("write through symlink alias of the workspace is allowed (existing file)", !(await call("write", { filePath: join(aliasDir, "alias", "real", "existing.txt"), content: "updated" }, { sessionID: "s-active" })));
757
+ check("write through escaping symlink to an outside file stays blocked", blocked(await call("write", { filePath: join(aliasDir, "real", "escape", "passwd"), content: "x" }, { sessionID: "s-active" })));
758
+ check("new file through an escaping symlink stays blocked", blocked(await call("write", { filePath: join(aliasDir, "real", "escape", "wg-escape-new.txt"), content: "x" }, { sessionID: "s-active" })));
759
+ rmSync(aliasDir, { recursive: true, force: true });
760
+ setWorkspaceRoot(root);
761
+
732
762
  console.log("- Compaction focus preservation & TUI toast -");
733
763
  const boundedCompaction = buildCompactionContext("## Operational Guard State\n- critical", ["## Active Tasks\n" + "a".repeat(10_000), "## Project Memory\nshould-not-fit"], 500);
734
764
  check("compaction packer enforces its total context budget", boundedCompaction.length === 500);
@@ -1226,6 +1256,72 @@ try {
1226
1256
  noopEditBlocked = String(error).includes("has not been read");
1227
1257
  }
1228
1258
  check("a no-op mutation does not seed an observation for a later edit", noopEditBlocked);
1259
+ // LL-003 extension: an allowed shell redirect authors the target's bytes
1260
+ // exactly like edit/write, so the follow-up edit/write of the file it wrote
1261
+ // needs no redundant read. Seeding stays byte-change-gated: a /dev/null
1262
+ // redirect seeds nothing and an identical-bytes rewrite seeds nothing.
1263
+ const shellSession = "s-stale-shell";
1264
+ todo(shellSession, item("write scratch file via shell redirect", "in_progress"));
1265
+ const shellAuthoredPath = join(staleDir, "shell-authored.ts");
1266
+ await staleBefore?.({ tool: "bash", sessionID: shellSession, callID: "shell-write" }, { args: { command: "echo x > shell-authored.ts" } });
1267
+ writeFileSync(shellAuthoredPath, "x\n");
1268
+ await staleAfter?.({ tool: "bash", sessionID: shellSession, callID: "shell-write", args: {} }, { title: "bash", output: "x", metadata: {} });
1269
+ let shellAuthoredWriteAllowed = true;
1270
+ try {
1271
+ await staleBefore?.({ tool: "write", sessionID: shellSession, callID: "shell-write-followup" }, { args: { filePath: shellAuthoredPath, content: "next" } });
1272
+ } catch {
1273
+ shellAuthoredWriteAllowed = false;
1274
+ }
1275
+ check("an allowed shell redirect seeds the observation so its authored file can be written without a read", shellAuthoredWriteAllowed);
1276
+ await staleAfter?.({ tool: "write", sessionID: shellSession, callID: "shell-write-followup", args: {} }, { title: "write", output: "written", metadata: {} });
1277
+ const shellUnrelatedSession = "s-stale-shell-unrelated";
1278
+ todo(shellUnrelatedSession, item("edit file untouched by shell", "in_progress"));
1279
+ const shellUnrelatedPath = join(staleDir, "shell-untouched.ts");
1280
+ writeFileSync(shellUnrelatedPath, "observed");
1281
+ await staleBefore?.({ tool: "bash", sessionID: shellUnrelatedSession, callID: "shell-devnull" }, { args: { command: "echo x > /dev/null" } });
1282
+ await staleAfter?.({ tool: "bash", sessionID: shellUnrelatedSession, callID: "shell-devnull", args: {} }, { title: "bash", output: "x", metadata: {} });
1283
+ let shellUnrelatedWriteBlocked = false;
1284
+ try {
1285
+ await staleBefore?.({ tool: "write", sessionID: shellUnrelatedSession, callID: "shell-untouched-write" }, { args: { filePath: shellUnrelatedPath, content: "next" } });
1286
+ } catch (error) {
1287
+ shellUnrelatedWriteBlocked = String(error).includes("has not been read");
1288
+ }
1289
+ check("a shell redirect to /dev/null seeds nothing, so an unobserved file stays blocked", shellUnrelatedWriteBlocked);
1290
+ const shellNoopSession = "s-stale-shell-noop";
1291
+ todo(shellNoopSession, item("no-op shell redirect", "in_progress"));
1292
+ const shellNoopPath = join(staleDir, "shell-noop.ts");
1293
+ writeFileSync(shellNoopPath, "x\n");
1294
+ await staleBefore?.({ tool: "bash", sessionID: shellNoopSession, callID: "shell-noop" }, { args: { command: "echo x > shell-noop.ts" } });
1295
+ writeFileSync(shellNoopPath, "x\n");
1296
+ await staleAfter?.({ tool: "bash", sessionID: shellNoopSession, callID: "shell-noop", args: {} }, { title: "bash", output: "x", metadata: {} });
1297
+ let shellNoopWriteBlocked = false;
1298
+ try {
1299
+ await staleBefore?.({ tool: "write", sessionID: shellNoopSession, callID: "shell-noop-write" }, { args: { filePath: shellNoopPath, content: "changed" } });
1300
+ } catch (error) {
1301
+ shellNoopWriteBlocked = String(error).includes("has not been read");
1302
+ }
1303
+ check("a no-op shell redirect (identical bytes) does not seed an observation", shellNoopWriteBlocked);
1304
+ // Path-spelling alias convergence: an observation seeded through one
1305
+ // absolute spelling of a file must authorize the mutation requested through
1306
+ // a symlink-aliased spelling of the same file (canonicalPath convergence).
1307
+ const staleAliasDir = mkdtempSync(join(tmpdir(), "wg-stale-alias-"));
1308
+ const staleAliasRealDir = join(staleAliasDir, "real");
1309
+ mkdirSync(staleAliasRealDir);
1310
+ symlinkSync(staleAliasRealDir, join(staleAliasDir, "alias"));
1311
+ const aliasRealPath = join(staleAliasRealDir, "aliased.ts");
1312
+ writeFileSync(aliasRealPath, "observed");
1313
+ todo("s-stale-alias", item("edit aliased file", "in_progress"));
1314
+ await staleBefore?.({ tool: "read", sessionID: "s-stale-alias", callID: "alias-read", worktree: staleAliasDir } as any, { args: { filePath: aliasRealPath } });
1315
+ await staleAfter?.({ tool: "read", sessionID: "s-stale-alias", callID: "alias-read", args: { filePath: aliasRealPath } }, { title: "aliased.ts", output: "observed", metadata: {} });
1316
+ let aliasWriteAllowed = true;
1317
+ try {
1318
+ await staleBefore?.({ tool: "write", sessionID: "s-stale-alias", callID: "alias-write", worktree: staleAliasDir } as any, { args: { filePath: join(staleAliasDir, "alias", "aliased.ts"), content: "next" } });
1319
+ } catch {
1320
+ aliasWriteAllowed = false;
1321
+ }
1322
+ check("a read observed through one path spelling authorizes the write through its symlink alias", aliasWriteAllowed);
1323
+ await staleAfter?.({ tool: "write", sessionID: "s-stale-alias", callID: "alias-write", args: {} }, { title: "write", output: "written", metadata: {} });
1324
+ rmSync(staleAliasDir, { recursive: true, force: true });
1229
1325
  const staleAlternateRoot = mkdtempSync(join(tmpdir(), "wg-stale-root-"));
1230
1326
  writeFileSync(join(staleAlternateRoot, "relative.ts"), "observed");
1231
1327
  todo("s-stale-root", item("edit relative file", "in_progress"));
@@ -1735,6 +1831,10 @@ check("dynamic shell syntax detects IFS construction", dynamicShellSyntaxIn("git
1735
1831
  check("dynamic shell syntax detects ambiguous whitespace", dynamicShellSyntaxIn("git\rpush") !== undefined && dynamicShellSyntaxIn("git\u00a0push") !== undefined);
1736
1832
  check("dynamic shell syntax detects malformed quote boundaries", dynamicShellSyntaxIn("echo 'unterminated") !== undefined && dynamicShellSyntaxIn("echo trailing\\") !== undefined);
1737
1833
  check("dynamic shell syntax preserves quoted literals", dynamicShellSyntaxIn("printf '%s' '$(literal) `literal` <(literal) $IFS'") === undefined);
1834
+ const substitutionBlock = await guardToolDecision("bash", { command: "echo $(dangerous)" }, { sessionID: "s-dynamic-syntax" });
1835
+ check("substitution block names the construct and remedy", substitutionBlock.status === "blocked" && substitutionBlock.code === "dynamic_shell_syntax" && substitutionBlock.message.includes("process substitution") && substitutionBlock.message.includes("guard_why"));
1836
+ const quotingBlock = await guardToolDecision("bash", { command: "echo 'unterminated" }, { sessionID: "s-dynamic-syntax" });
1837
+ check("malformed quoting block names the construct and remedy", quotingBlock.status === "blocked" && quotingBlock.code === "dynamic_shell_syntax" && quotingBlock.message.includes("balanced quoting"));
1738
1838
  check(
1739
1839
  "runVerify terminates timed-out verification commands safely",
1740
1840
  !verifyTimeout.passed && verifyTimeout.output.includes("timed out"),
@@ -3152,6 +3252,27 @@ check("git checkout -b origin/main with a stale branch is allowed", !blocked(awa
3152
3252
  check("wrapper git switch -c origin/main keeps the fresh start point exemption", !blocked(await shell("env git switch -c wg-wrapper-fresh origin/main")));
3153
3253
  check("variable start point fails closed on a stale branch", blocked(await shell("git switch -c wg-start-var $WG_REF")));
3154
3254
  check("plain git branch is not treated as guarded branch creation", !blocked(await shell("git branch wg-plain-branch origin/main")));
3255
+ // Redirection tokens are never start points (audit regression: `git switch
3256
+ // -c feat/x 2>&1` classified the trailing `2>` left by `&`-segment splitting
3257
+ // as an explicit start point and failed closed).
3258
+ const redirStale = await shell("git switch -c wg-redir-stale 2>/dev/null");
3259
+ check(
3260
+ "redirect to /dev/null is not a start point on a stale branch (still stale-blocked)",
3261
+ blocked(redirStale) && (redirStale as string).includes("'feat/conflict-branch'") && !(redirStale as string).includes("'2>'"),
3262
+ );
3263
+ spawnSync("git", ["switch", "-c", "wg-redir-base", "origin/main"], { cwd: conflictRepo });
3264
+ check("attached stderr redirect is not a start point on a fresh branch", !blocked(await shell("git switch -c wg-redir-null 2>/dev/null")));
3265
+ check("separated stdout redirect is not a start point on a fresh branch", !blocked(await shell("git checkout -b wg-redir-out > /dev/null")));
3266
+ check("fd duplication 2>&1 left by &-segment splitting is not a start point", !blocked(await shell("git switch -c wg-redir-dup 2>&1")));
3267
+ check("separated fd stderr redirect is not a start point on a fresh branch", !blocked(await shell("git switch -c wg-redir-sep 2> /dev/null")));
3268
+ check("explicit start point before a redirect stays effective", !blocked(await shell("git switch -c wg-redir-after origin/main 2>&1")));
3269
+ spawnSync("git", ["switch", "feat/conflict-branch"], { cwd: conflictRepo });
3270
+ const staleShaRedirect = await shell(`git switch -c wg-stale-redir ${staleStartSha} >/dev/null`);
3271
+ check(
3272
+ "explicit stale start point is still classified behind a redirect",
3273
+ blocked(staleShaRedirect) && (staleShaRedirect as string).includes("start point") && (staleShaRedirect as string).includes(staleStartSha),
3274
+ );
3275
+ check("variable start point behind a redirect still fails closed", blocked(await shell("git switch -c wg-var-redir $WG_REF 2>/dev/null")));
3155
3276
 
3156
3277
  // Lockfile synchronization tests
3157
3278
  const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));
@@ -3169,6 +3290,219 @@ writeFileSync(join(lockRepo, "package-lock.json"), "{\"name\":\"demo\",\"version
3169
3290
  check("checkLockfileSync passes when lockfile is updated", !checkLockfileSync(lockRepo).isOutOfSync);
3170
3291
  rmSync(lockRepo, { recursive: true, force: true });
3171
3292
 
3293
+ // Lockfile synchronization tracks lockfile-relevant manifest sections, not
3294
+ // the mere presence of package.json in the branch diff. A scripts-only edit
3295
+ // (the documented curation act of adding a suite to a test script) regenerates
3296
+ // package-lock.json byte-identical, so demanding a lockfile change for it is
3297
+ // unsatisfiable; dependency/engines/packageManager changes still demand one.
3298
+ const scriptLockRepo = mkdtempSync(join(tmpdir(), "wg-lock-scripts-"));
3299
+ spawnSync("git", ["init", "-b", "main"], { cwd: scriptLockRepo });
3300
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: scriptLockRepo });
3301
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: scriptLockRepo });
3302
+ const scriptManifestBase = { name: "demo", version: "1.0.0", scripts: { test: "node test.mjs" } };
3303
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestBase, null, 2) + "\n");
3304
+ writeFileSync(join(scriptLockRepo, "package-lock.json"), JSON.stringify({ name: "demo", version: "1.0.0", lockfileVersion: 3 }, null, 2) + "\n");
3305
+ // A nested manifest already present at the merge base, so its scripts-only
3306
+ // edit on the branch is a true edit (not a new manifest).
3307
+ mkdirSync(join(scriptLockRepo, "packages", "app"), { recursive: true });
3308
+ const nestedManifestBase = { name: "app", version: "0.0.0", scripts: { build: "node build.mjs" } };
3309
+ writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestBase, null, 2) + "\n");
3310
+ spawnSync("git", ["add", "-A"], { cwd: scriptLockRepo });
3311
+ spawnSync("git", ["commit", "-qm", "init"], { cwd: scriptLockRepo });
3312
+ spawnSync("git", ["switch", "-c", "feat/scripts-only"], { cwd: scriptLockRepo });
3313
+ const scriptManifestEdited = JSON.parse(JSON.stringify(scriptManifestBase)) as typeof scriptManifestBase;
3314
+ scriptManifestEdited.scripts.test = "node test.mjs && node test-ci.mjs";
3315
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify(scriptManifestEdited, null, 2) + "\n");
3316
+ check("checkLockfileSync passes an uncommitted scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3317
+ spawnSync("git", ["commit", "-qam", "curate test suite"], { cwd: scriptLockRepo });
3318
+ check("checkLockfileSync passes a committed scripts-only manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3319
+ // The observed false positive: a scripts-only edit alongside a lockfile that
3320
+ // npm rewrote byte-identical (present in the diff set, yet unchanged in
3321
+ // content — the old check demanded exactly this unsatisfiable combination).
3322
+ writeFileSync(join(scriptLockRepo, "package-lock.json"), readFileSync(join(scriptLockRepo, "package-lock.json"), "utf8"));
3323
+ check("checkLockfileSync passes a byte-identical lockfile rewrite next to a scripts-only edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3324
+ // A nested manifest's scripts-only edit passes too.
3325
+ const nestedManifestEdited = JSON.parse(JSON.stringify(nestedManifestBase)) as typeof nestedManifestBase;
3326
+ nestedManifestEdited.scripts.build = "node build.mjs && node verify.mjs";
3327
+ writeFileSync(join(scriptLockRepo, "packages", "app", "package.json"), JSON.stringify(nestedManifestEdited, null, 2) + "\n");
3328
+ check("checkLockfileSync passes a scripts-only nested manifest edit", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3329
+ // Contract pins: sections the lockfile records still demand the lockfile.
3330
+ const scriptsAfterNesting = scriptManifestEdited.scripts;
3331
+ const relevantManifest = { name: "demo", version: "1.0.0", scripts: scriptsAfterNesting };
3332
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
3333
+ check("checkLockfileSync flags a dependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3334
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, engines: { node: ">=22" } }, null, 2) + "\n");
3335
+ check("checkLockfileSync flags an engines change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3336
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, packageManager: "pnpm@9.0.0" }, null, 2) + "\n");
3337
+ check("checkLockfileSync flags a packageManager change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3338
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, devDependencies: { typescript: "^5.0.0" } }, null, 2) + "\n");
3339
+ check("checkLockfileSync flags a devDependencies change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3340
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, pnpm: { overrides: { "left-pad": "^1.3.0" } } }, null, 2) + "\n");
3341
+ check("checkLockfileSync flags a pnpm-overrides change without a lockfile update", checkLockfileSync(scriptLockRepo).isOutOfSync);
3342
+ // And a relevant change WITH the lockfile updated passes.
3343
+ writeFileSync(join(scriptLockRepo, "package.json"), JSON.stringify({ ...relevantManifest, dependencies: { "left-pad": "^1.3.0" } }, null, 2) + "\n");
3344
+ 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");
3345
+ check("checkLockfileSync passes when the lockfile is updated alongside a relevant change", !checkLockfileSync(scriptLockRepo).isOutOfSync);
3346
+ rmSync(scriptLockRepo, { recursive: true, force: true });
3347
+
3348
+ // Concurrent loops share ONE guard process: workspace resolution must come
3349
+ // from the call's own context or the session's own binding, never from the
3350
+ // last-active plugin instance. Two plugin instances in this process simulate
3351
+ // the two loops; instance B's roots must never re-anchor session A's context.
3352
+ const isoRepoA = mkdtempSync(join(tmpdir(), "wg-iso-a-"));
3353
+ spawnSync("git", ["init", "-b", "main"], { cwd: isoRepoA });
3354
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoA });
3355
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoA });
3356
+ writeFileSync(join(isoRepoA, "seed.txt"), "loop a\n");
3357
+ spawnSync("git", ["add", "-A"], { cwd: isoRepoA });
3358
+ spawnSync("git", ["commit", "-qm", "init a"], { cwd: isoRepoA });
3359
+ spawnSync("git", ["switch", "-c", "feat/iso-a"], { cwd: isoRepoA });
3360
+ const isoRepoB = mkdtempSync(join(tmpdir(), "wg-iso-b-"));
3361
+ spawnSync("git", ["init", "-b", "loop/other-loop"], { cwd: isoRepoB });
3362
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: isoRepoB });
3363
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: isoRepoB });
3364
+ writeFileSync(join(isoRepoB, "seed.txt"), "loop b\n");
3365
+ spawnSync("git", ["add", "-A"], { cwd: isoRepoB });
3366
+ spawnSync("git", ["commit", "-qm", "init b"], { cwd: isoRepoB });
3367
+ 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 });
3368
+ 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 });
3369
+ const isoHookOf = (plugin: unknown) => (plugin as { "tool.execute.before"?: (input: unknown, output: unknown) => Promise<unknown> })["tool.execute.before"];
3370
+ // Session A binds to repoA from its OWN call context (an edit/read target
3371
+ // inside repoA delivered through the call arguments).
3372
+ await isoHookOf(isoPluginA)?.({ sessionID: "s-iso-shared", tool: "read", callID: "c-iso-1" }, { args: { filePath: join(isoRepoA, "seed.txt") } });
3373
+ check("a session binds to its workspace from its own call context", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
3374
+ // The other loop's plugin instance must not re-anchor it from its own root.
3375
+ await isoHookOf(isoPluginB)?.({ sessionID: "s-iso-shared", tool: "bash", callID: "c-iso-2" }, { args: { command: "ls -la" } });
3376
+ check("an instance-derived fallback never re-anchors a bound session", getSessionBoundWorkspace("s-iso-shared") === isoRepoA);
3377
+ const isoStatus = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({}, { sessionID: "s-iso-shared" } as any)));
3378
+ check("guard_status resolves the session's own workspace under plugin-instance drift", isoStatus.workspaceRoot === isoRepoA && isoStatus.branch === "feat/iso-a");
3379
+ const isoStatusExplicit = JSON.parse(String(await isoPluginB.tool?.guard_status?.execute({ directory: isoRepoB }, { sessionID: "s-iso-shared" } as any)));
3380
+ check("guard_status honors an explicit directory over the session binding", isoStatusExplicit.workspaceRoot === isoRepoB && isoStatusExplicit.branch === "loop/other-loop");
3381
+ check("resolveEffectiveWorkspace keeps a bound session off a foreign fallback", (await resolveEffectiveWorkspace({ sessionID: "s-iso-shared", fallback: isoRepoB })) === isoRepoA);
3382
+ check("resolveEffectiveWorkspace keeps fallback semantics for an unbound session", (await resolveEffectiveWorkspace({ sessionID: "s-iso-unbound", fallback: isoRepoB })) === isoRepoB);
3383
+ // Policy decisions follow the call's own context: a boundary write inside the
3384
+ // session's repo passes even while the other loop's root is the fallback.
3385
+ todo("s-iso-shared", item("iso work", "in_progress"));
3386
+ const isoWrite = await guardToolDecision("write", { filePath: join(isoRepoA, "scratch.txt"), content: "safe" }, { sessionID: "s-iso-shared" });
3387
+ check("boundary checks use the session's own workspace, not the other loop's root", isoWrite.status === "allowed");
3388
+ if (isoWrite.status !== "allowed") console.log(" blocked:", isoWrite.message);
3389
+ // Review verdict binding is never guessed: no explicit directory, no session
3390
+ // binding, no per-call worktree — the verdict is rejected, not latched.
3391
+ const unboundReview = await isoPluginA.tool?.record_review?.execute(
3392
+ { reviewer: "no-binding", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
3393
+ { sessionID: "s-iso-unbound-recorder" } as any,
3394
+ );
3395
+ check(
3396
+ "record_review rejects a verdict it cannot bind instead of latching a root",
3397
+ typeof unboundReview === "string" && unboundReview.includes("rejected") && unboundReview.includes("never bound to a guessed root") && !unboundReview.includes("APPROVED"),
3398
+ );
3399
+ const unboundReviewAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-iso-unbound-recorder");
3400
+ check("unbindingable verdict audited as review_binding_unresolved", unboundReviewAudit?.reason === "review_binding_unresolved");
3401
+ // A bound session records through the OTHER loop's plugin instance: the
3402
+ // verdict binds to the session's own workspace, not the instance root.
3403
+ const isoBoundReview = await isoPluginB.tool?.record_review?.execute(
3404
+ { reviewer: "drift-proof", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
3405
+ { sessionID: "s-iso-shared" } as any,
3406
+ );
3407
+ check(
3408
+ "record_review binds to the session's own workspace, not the other loop's instance root",
3409
+ typeof isoBoundReview === "string" && isoBoundReview.includes("APPROVED") && isoBoundReview.includes(isoRepoA) && !isoBoundReview.includes(isoRepoB),
3410
+ );
3411
+ rmSync(isoRepoA, { recursive: true, force: true });
3412
+ rmSync(isoRepoB, { recursive: true, force: true });
3413
+ resetReviewState();
3414
+ setWorkspaceRoot(root);
3415
+
3416
+ // Review bindings are consumed per WORKTREE, not per repository-recency: two
3417
+ // loops sharing one repo must not displace each other's approvals between
3418
+ // record_review and PR creation.
3419
+ const raceRepo = join(root, "wg-race-repo");
3420
+ mkdirSync(raceRepo, { recursive: true });
3421
+ spawnSync("git", ["init", "-b", "main"], { cwd: raceRepo });
3422
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: raceRepo });
3423
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: raceRepo });
3424
+ writeFileSync(join(raceRepo, "seed.txt"), "base\n");
3425
+ spawnSync("git", ["add", "-A"], { cwd: raceRepo });
3426
+ spawnSync("git", ["commit", "-qm", "base"], { cwd: raceRepo });
3427
+ mkdirSync(join(raceRepo, ".opencode"), { recursive: true });
3428
+ writeFileSync(join(raceRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
3429
+ spawnSync("git", ["switch", "-c", "feat/race-a"], { cwd: raceRepo });
3430
+ writeFileSync(join(raceRepo, "a.txt"), "loop a change\n");
3431
+ spawnSync("git", ["add", "-A"], { cwd: raceRepo });
3432
+ spawnSync("git", ["commit", "-qm", "loop a change"], { cwd: raceRepo });
3433
+ const prevWorktreeDirRace = process.env.WORKFLOW_GUARD_WORKTREE_DIR;
3434
+ process.env.WORKFLOW_GUARD_WORKTREE_DIR = join(root, "wg-race-worktrees");
3435
+ const raceWtRes = createGitWorktree("feat/race-b", "HEAD", raceRepo);
3436
+ check("race: sibling worktree created", raceWtRes.success && typeof raceWtRes.worktreePath === "string");
3437
+ const raceWt = raceWtRes.worktreePath!;
3438
+ writeFileSync(join(raceWt, "b.txt"), "loop b change\n");
3439
+ spawnSync("git", ["add", "-A"], { cwd: raceWt });
3440
+ spawnSync("git", ["commit", "-qm", "loop b change"], { cwd: raceWt });
3441
+ const raceSummary = "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.";
3442
+ // Loop A records first; loop B records a NEWER review for the sibling
3443
+ // worktree. Each worktree must keep consuming its OWN approval.
3444
+ resetReviewState();
3445
+ recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
3446
+ recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
3447
+ check("loop A consumes its own worktree review, not the newer sibling review", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
3448
+ check("loop B consumes its own worktree review", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
3449
+ resetReviewState();
3450
+ recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
3451
+ recordReviewResult("reviewer-A", raceSummary, true, "s-race-a", raceRepo);
3452
+ check("loop A keeps its own review when it was recorded first", getLastReviewResultForWorkspace(raceRepo)?.reviewer === "reviewer-A");
3453
+ check("loop B keeps its own review when it was recorded last", getLastReviewResultForWorkspace(raceWt)?.reviewer === "reviewer-B");
3454
+ // Preflight consumption with the remote state stubbed off: loop A's PR must
3455
+ // pass while the sibling loop's newer review exists, and must FAIL with a
3456
+ // worktree-mismatch cause when only the sibling review is present.
3457
+ const racePrevPath = process.env.PATH;
3458
+ const raceBin = join(root, "wg-race-bin");
3459
+ mkdirSync(raceBin, { recursive: true });
3460
+ writeFileSync(join(raceBin, "gh"), "#!/bin/sh\nexit 1\n");
3461
+ writeFileSync(join(raceBin, "az"), "#!/bin/sh\nexit 1\n");
3462
+ chmodSync(join(raceBin, "gh"), 0o755);
3463
+ chmodSync(join(raceBin, "az"), 0o755);
3464
+ process.env.PATH = `${raceBin}:${racePrevPath ?? ""}`;
3465
+ const racePrBody = "gh pr create --title 'fix: race' --body 'Changelog: race'";
3466
+ try {
3467
+ const racePrA = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
3468
+ check("loop A's PR passes preflight while the sibling loop's newer review exists", !blocked(racePrA));
3469
+ if (blocked(racePrA)) console.log(" blocked:", racePrA);
3470
+ const racePrB = await call("bash", { command: racePrBody, workdir: raceWt }, { sessionID: "s-race-b", worktree: raceWt, directory: raceWt });
3471
+ check("loop B's PR passes preflight independently", !blocked(racePrB));
3472
+ if (blocked(racePrB)) console.log(" blocked:", racePrB);
3473
+ // Negative control: only the sibling review present -> the mismatch is
3474
+ // named instead of displacing the other loop's evidence.
3475
+ resetReviewState();
3476
+ recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
3477
+ const racePrOnlyB = await call("bash", { command: racePrBody, workdir: raceRepo }, { sessionID: "s-race-a", worktree: raceRepo, directory: raceRepo });
3478
+ check(
3479
+ "loop A's PR is blocked on the sibling review alone, naming the worktree mismatch",
3480
+ blocked(racePrOnlyB) && String(racePrOnlyB).includes("does not match the current worktree contents"),
3481
+ );
3482
+ // Same-repo fallback still rescues content-identical trees: a review
3483
+ // recorded from a worktree whose tracked content equals this one applies,
3484
+ // even when a NEWER sibling review does not match this content.
3485
+ const raceWtSameContentRes = createGitWorktree("feat/race-same", "feat/race-a", raceRepo);
3486
+ const raceWtSame = raceWtSameContentRes.worktreePath!;
3487
+ resetReviewState();
3488
+ recordReviewResult("reviewer-same", raceSummary, true, "s-race-same", raceRepo);
3489
+ recordReviewResult("reviewer-B", raceSummary, true, "s-race-b", raceWt);
3490
+ check(
3491
+ "a same-repo review whose tracked content matches this worktree is preferred over a newer mismatching one",
3492
+ getLastReviewResultForWorkspace(raceWtSame)?.reviewer === "reviewer-same",
3493
+ );
3494
+ rmSync(raceWtSame, { recursive: true, force: true });
3495
+ } finally {
3496
+ process.env.PATH = racePrevPath;
3497
+ }
3498
+ if (prevWorktreeDirRace === undefined) delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
3499
+ else process.env.WORKFLOW_GUARD_WORKTREE_DIR = prevWorktreeDirRace;
3500
+ rmSync(join(root, "wg-race-worktrees"), { recursive: true, force: true });
3501
+ rmSync(raceBin, { recursive: true, force: true });
3502
+ rmSync(raceRepo, { recursive: true, force: true });
3503
+ resetReviewState();
3504
+ setWorkspaceRoot(root);
3505
+
3172
3506
  // 14. Documentation Review & Synchronization Guard (Policy 21)
3173
3507
  console.log("- Policy 21: Documentation Review & Synchronization Guard -");
3174
3508
  const docRepo = mkdtempSync(join(tmpdir(), "wg-doc-repo-"));