opencode-workflow-guard 1.15.0 → 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/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,
@@ -1600,22 +1602,19 @@ const fakeV2TuiCtx = {
1600
1602
  ui: { slot: (claim: { append: string; render: () => unknown }) => { v2SlotClaims.push(claim); } },
1601
1603
  };
1602
1604
  await WorkflowGuardTuiV2(fakeV2TuiCtx as any);
1603
- check("v2 tui companion registers home and prompt status slots", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 2);
1605
+ check("v2 tui companion registers only the prompt footer status slot", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 1 && v2SlotClaims.some((claim) => claim.append === "prompt.footer.status"));
1604
1606
  const diagLogPath = join(process.env.XDG_STATE_HOME!, "opencode", "workflow-guard-ui.log");
1605
1607
  const diagLogText = () => readFileSync(diagLogPath, "utf8");
1606
1608
  check("v2 tui companion logs module load and setup with app version", diagLogText().includes("module load:") && diagLogText().includes("setup: app=9.9.9-test"));
1607
- const v2HomeRender = v2SlotClaims.find((claim) => claim.append === "home.footer.status")!.render;
1609
+ const v2StatusRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
1608
1610
  // In a plain Node process there is no OpenTUI renderer, so badge() throws the
1609
1611
  // same "No renderer found" the long-running-session bug produced in the TUI.
1610
- check("v2 slot render returns null instead of throwing when the render context is unavailable", v2HomeRender() === null);
1611
- check("v2 slot failure log records probes and stack", diagLogText().includes("render home.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
1612
- for (let i = 0; i < 6; i++) v2HomeRender();
1613
- const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render home.footer.status FAILED (")).length;
1612
+ check("v2 slot render returns null instead of throwing when the render context is unavailable", v2StatusRender() === null);
1613
+ check("v2 slot failure log records probes and stack", diagLogText().includes("render prompt.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
1614
+ for (let i = 0; i < 6; i++) v2StatusRender();
1615
+ const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render prompt.footer.status FAILED (")).length;
1614
1616
  check("v2 slot failure logging is bounded to 3 per success epoch", v2FailuresLogged === 3);
1615
1617
  check("v2 slot failure suppression notice is logged once", diagLogText().split("still failing after 3 logged failures").length === 2);
1616
- const v2PromptRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
1617
- check("v2 prompt status slot also degrades to null on render failure", v2PromptRender() === null);
1618
-
1619
1618
 
1620
1619
  // ── Adversarial tests & hardened invariants ──
1621
1620
  console.log("- Adversarial tests & hardened invariants -");
@@ -3123,6 +3122,39 @@ spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: conf
3123
3122
  const baseUpToDateCheck = checkBranchBaseIsUpToDate(conflictRepo);
3124
3123
  check("checkBranchBaseIsUpToDate passes when equal to remote", !baseUpToDateCheck.isBehind);
3125
3124
 
3125
+ // Policy 20: branch creation start-point awareness. Advance the simulated
3126
+ // remote past the current branch so HEAD is stale, then verify that an
3127
+ // explicit fresh start point clears the gate while startless and
3128
+ // indeterminate creations still fail (closed).
3129
+ spawnSync("git", ["switch", "-c", "wg-origin-advance"], { cwd: conflictRepo });
3130
+ writeFileSync(join(conflictRepo, "file.txt"), "remote advance content\n");
3131
+ spawnSync("git", ["commit", "-am", "remote advance"], { cwd: conflictRepo });
3132
+ spawnSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: conflictRepo });
3133
+ spawnSync("git", ["switch", "feat/conflict-branch"], { cwd: conflictRepo });
3134
+ const staleHeadCheck = checkBranchBaseIsUpToDate(conflictRepo);
3135
+ check(
3136
+ "checkBranchBaseIsUpToDate attributes the stale current branch and working remedies",
3137
+ staleHeadCheck.isBehind &&
3138
+ staleHeadCheck.baseRef === "origin/main" &&
3139
+ (staleHeadCheck.reason ?? "").includes("'feat/conflict-branch'") &&
3140
+ (staleHeadCheck.reason ?? "").includes("git rebase origin/main"),
3141
+ );
3142
+ const freshStartCheck = checkBranchBaseIsUpToDate(conflictRepo, ["origin/main"]);
3143
+ check("checkBranchBaseIsUpToDate accepts an explicit start point containing the remote default", !freshStartCheck.isBehind);
3144
+ const staleStartSha = spawnSync("git", ["rev-parse", "HEAD"], { cwd: conflictRepo, encoding: "utf8" }).stdout!.trim();
3145
+ const staleStartCheck = checkBranchBaseIsUpToDate(conflictRepo, [staleStartSha]);
3146
+ check("checkBranchBaseIsUpToDate blocks an explicit stale start point", staleStartCheck.isBehind && (staleStartCheck.reason ?? "").includes("start point"));
3147
+ const unresolvableCheck = checkBranchBaseIsUpToDate(conflictRepo, ["$WG_START_REF"]);
3148
+ check("checkBranchBaseIsUpToDate fails closed on an unresolvable start point", unresolvableCheck.isBehind && (unresolvableCheck.reason ?? "").includes("could not be resolved"));
3149
+ const mixedStartCheck = checkBranchBaseIsUpToDate(conflictRepo, ["origin/main", undefined]);
3150
+ check("checkBranchBaseIsUpToDate keeps the head check when a creation lacks a start point", mixedStartCheck.isBehind && (mixedStartCheck.reason ?? "").includes("'feat/conflict-branch'"));
3151
+ check("git switch -c without a start point is blocked on a stale branch", blocked(await shell("git switch -c wg-start-stale")));
3152
+ check("git switch -c origin/main with a stale branch is allowed", !blocked(await shell("git switch -c wg-start-fresh origin/main")));
3153
+ check("git checkout -b origin/main with a stale branch is allowed", !blocked(await shell("git checkout -b wg-checkout-fresh origin/main")));
3154
+ 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")));
3155
+ check("variable start point fails closed on a stale branch", blocked(await shell("git switch -c wg-start-var $WG_REF")));
3156
+ check("plain git branch is not treated as guarded branch creation", !blocked(await shell("git branch wg-plain-branch origin/main")));
3157
+
3126
3158
  // Lockfile synchronization tests
3127
3159
  const lockRepo = mkdtempSync(join(tmpdir(), "wg-lock-repo-"));
3128
3160
  spawnSync("git", ["init", "-b", "main"], { cwd: lockRepo });
@@ -3139,6 +3171,219 @@ writeFileSync(join(lockRepo, "package-lock.json"), "{\"name\":\"demo\",\"version
3139
3171
  check("checkLockfileSync passes when lockfile is updated", !checkLockfileSync(lockRepo).isOutOfSync);
3140
3172
  rmSync(lockRepo, { recursive: true, force: true });
3141
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
+
3142
3387
  // 14. Documentation Review & Synchronization Guard (Policy 21)
3143
3388
  console.log("- Policy 21: Documentation Review & Synchronization Guard -");
3144
3389
  const docRepo = mkdtempSync(join(tmpdir(), "wg-doc-repo-"));