opencode-workflow-guard 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/test/test.mts CHANGED
@@ -2,6 +2,7 @@ import { mkdtempSync, writeFileSync, rmSync, symlinkSync, readFileSync, existsSy
2
2
  import { spawn, spawnSync } from "node:child_process";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
5
6
  import { DatabaseSync } from "node:sqlite";
6
7
  import type { PluginModule } from "@opencode-ai/plugin";
7
8
  import { getRalphMaxIterations, runWithRuntimeState } from "../src/lib/state.ts";
@@ -27,6 +28,8 @@ import {
27
28
  getCurrentGitCommitHash,
28
29
  getGitStatusSummary,
29
30
  getGitWorktreeFingerprint,
31
+ getTrackedWorktreeFingerprint,
32
+ resolveWorktreeTimeoutMs,
30
33
  getVerifyCacheFilePath,
31
34
  persistVerifyCache,
32
35
  loadVerifyCache,
@@ -72,6 +75,7 @@ import {
72
75
  checkLockfileSync,
73
76
  branchHasDocumentationChange,
74
77
  isDocumentationRequired,
78
+ loadedPluginVersion,
75
79
  isReviewRequired,
76
80
  getOperationProfile,
77
81
  isRecoveryCheckpointsEnabled,
@@ -676,6 +680,35 @@ check("block write to /etc/passwd", blocked(await call("write", { filePath: "/et
676
680
  check("block write to ~ path", blocked(await call("write", { filePath: "~/.bashrc", content: "x" }, { sessionID: "s-active" })));
677
681
  check("block write to $HOME path", blocked(await call("write", { filePath: "$HOME/.profile", content: "x" }, { sessionID: "s-active" })));
678
682
  check("block write to unresolvable $VAR path", blocked(await call("write", { filePath: "$UNKNOWN_DIR/file.txt", content: "x" }, { sessionID: "s-active" })));
683
+ // Cause attribution: indeterminate $VARIABLE destinations get a message that
684
+ // names the unresolvable reference (the on-device over-block class), while
685
+ // deterministically-outside paths keep the generic escape message.
686
+ const unresolvableWrite = await call("write", { filePath: "$UNKNOWN_DIR/file.txt", content: "x" }, { sessionID: "s-active" });
687
+ check("unresolvable $VAR write names the variable cause", blocked(unresolvableWrite) && String(unresolvableWrite).includes("unresolvable variable reference") && String(unresolvableWrite).includes("$UNKNOWN_DIR"));
688
+ const literalEscapeWrite = await call("write", { filePath: "/etc/passwd", content: "x" }, { sessionID: "s-active" });
689
+ check("literal outside path keeps the generic escape message", blocked(literalEscapeWrite) && String(literalEscapeWrite).includes("escapes workspace root") && !String(literalEscapeWrite).includes("unresolvable variable reference"));
690
+ const homeWrite = await call("write", { filePath: "$HOME/.profile", content: "x" }, { sessionID: "s-active" });
691
+ check("deterministically-expanded $HOME path keeps the generic escape message", blocked(homeWrite) && String(homeWrite).includes("escapes workspace root") && !String(homeWrite).includes("unresolvable variable reference"));
692
+ const unresolvableShell = await call("bash", { command: "mkdir -p $D/sub" }, { sessionID: "s-active" });
693
+ check("shell mutation with unresolvable $VAR names the variable cause", blocked(unresolvableShell) && String(unresolvableShell).includes("unresolvable variable reference") && String(unresolvableShell).includes("$D"));
694
+ const unresolvablePatch = await call("apply_patch", { patchText: "*** Update File: $UNKNOWN_DIR/file.ts\n" }, { sessionID: "s-active" });
695
+ check("apply_patch with unresolvable $VAR names the variable cause", blocked(unresolvablePatch) && String(unresolvablePatch).includes("unresolvable variable reference"));
696
+ const unresolvableMv = await call("bash", { command: "mv $D/foo.txt ./bar.txt" }, { sessionID: "s-active" });
697
+ check("mv source with unresolvable $VAR names the variable cause", blocked(unresolvableMv) && String(unresolvableMv).includes("unresolvable variable reference"));
698
+ const unresolvableGit = await call("bash", { command: "git -C $OTHER_REPO commit -m x" }, { sessionID: "s-active" });
699
+ check("git repoDir with unresolvable $VAR names the variable cause", blocked(unresolvableGit) && String(unresolvableGit).includes("unresolvable variable reference"));
700
+ // Scratch-directory cause attribution: /tmp/opencode is the environment's
701
+ // designated scratch space, but the workspace boundary is workspace-absolute
702
+ // and does not cover it; the message explains that instead of reading as a
703
+ // guard bug (6 of the device audit's boundary blocks were this class).
704
+ const scratchWrite = await call("write", { filePath: "/tmp/opencode/scratch.txt", content: "x" }, { sessionID: "s-active" });
705
+ check("scratch-dir write attributes the environment scratch cause", blocked(scratchWrite) && String(scratchWrite).includes("environment scratch directory") && String(scratchWrite).includes("/tmp/opencode") && !String(scratchWrite).includes("unresolvable variable reference"));
706
+ const scratchRedirect = await call("bash", { command: "echo x > /tmp/opencode/out.md" }, { sessionID: "s-active" });
707
+ check("scratch-dir redirect attributes the environment scratch cause", blocked(scratchRedirect) && String(scratchRedirect).includes("environment scratch directory"));
708
+ const scratchPatch = await call("apply_patch", { patchText: "*** Add File: /tmp/opencode/scratch.ts\n+scratch\n" }, { sessionID: "s-active" });
709
+ check("scratch-dir apply_patch attributes the environment scratch cause", blocked(scratchPatch) && String(scratchPatch).includes("environment scratch directory"));
710
+ const otherOutsideWrite = await call("write", { filePath: "/var/tmp/elsewhere.txt", content: "x" }, { sessionID: "s-active" });
711
+ check("non-scratch outside path keeps the generic escape message", blocked(otherOutsideWrite) && String(otherOutsideWrite).includes("escapes workspace root") && !String(otherOutsideWrite).includes("scratch directory"));
679
712
  check("allow apply_patch within workspace", !(await call("apply_patch", { patchText: "*** Update File: src/app.ts\n" }, { sessionID: "s-active" })));
680
713
  check("block apply_patch escaping workspace", blocked(await call("apply_patch", { patchText: "*** Update File: ../../secret.env\n" }, { sessionID: "s-active" })));
681
714
 
@@ -895,6 +928,169 @@ try {
895
928
  }
896
929
  check("blocked multi-target patch leaves no partial claims", !partialPatchClaim);
897
930
 
931
+ // Stale claim takeover (missed idle release): a claim created BEFORE its
932
+ // owner's last recorded idle timestamp cannot belong to an in-flight tool
933
+ // call, so a leaked claim must not block another session forever. Owners that
934
+ // are still mid-session (no idle timestamp), a client without a session
935
+ // lookup, and a failing lookup all keep the block (fail-closed).
936
+ console.log("- Stale file-claim takeover -");
937
+ const takeoverDir = mkdtempSync(join(tmpdir(), "wg-claim-takeover-"));
938
+ writeFileSync(join(takeoverDir, "stale.ts"), "before");
939
+ writeFileSync(join(takeoverDir, "live.ts"), "before");
940
+ writeFileSync(join(takeoverDir, "refresh.ts"), "before");
941
+ const takeoverIdle = new Map<string, number>();
942
+ const takeoverClient = {
943
+ session: {
944
+ todo: fakeClient.session.todo,
945
+ get: async ({ path }: { path: { id: string } }) => {
946
+ const idle = takeoverIdle.get(path.id);
947
+ return { data: { id: path.id, parentID: fakeParents.get(path.id), ...(typeof idle === "number" ? { time: { idle } } : {}) } };
948
+ },
949
+ },
950
+ };
951
+ const takeoverHooks = await pluginFn({
952
+ directory: takeoverDir,
953
+ client: takeoverClient as any,
954
+ project: {} as any,
955
+ worktree: takeoverDir,
956
+ experimental_workspace: {} as any,
957
+ serverUrl: new URL("http://localhost:4096"),
958
+ $: undefined as any,
959
+ });
960
+ todo("s-stale-owner", item("edit stale file", "in_progress"));
961
+ todo("s-live-owner", item("edit live file", "in_progress"));
962
+ todo("s-claimant", item("take over stale claim", "in_progress"));
963
+ const takeoverBefore = takeoverHooks["tool.execute.before"];
964
+ const takeoverAfter = takeoverHooks["tool.execute.after"];
965
+
966
+ // The owner claims the file, its after-hook is skipped, and it idles LATER.
967
+ await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "stale.ts"), "takeover-read-stale");
968
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "stale-claim" }, { args: { filePath: join(takeoverDir, "stale.ts"), content: "a" } });
969
+ await new Promise((resolve) => setTimeout(resolve, 10));
970
+ takeoverIdle.set("s-stale-owner", Date.now());
971
+ // A live owner, still mid-session, claims the other file.
972
+ await recordRead(takeoverBefore, takeoverAfter, "s-live-owner", join(takeoverDir, "live.ts"), "takeover-read-live");
973
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-live-owner", callID: "live-claim" }, { args: { filePath: join(takeoverDir, "live.ts"), content: "b" } });
974
+
975
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "stale.ts"), "takeover-read-claimant");
976
+ let staleTakeoverAllowed = true;
977
+ try {
978
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "takeover-claim" }, { args: { filePath: join(takeoverDir, "stale.ts"), content: "c" } });
979
+ } catch {
980
+ staleTakeoverAllowed = false;
981
+ }
982
+ check("a claim older than its owner's recorded idle is released for the next claimant", staleTakeoverAllowed);
983
+ const takeoverAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "file-claims" && entry.reason === "stale_claim_takeover");
984
+ check("stale claim takeover is audited with the released path and owner", takeoverAudit?.decision === "allow" && typeof takeoverAudit?.evidence?.targetPath === "string" && takeoverAudit.evidence.targetPath.endsWith("stale.ts") && takeoverAudit.evidence.claimOwnerSessionID === "s-stale-owner");
985
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "takeover-claim", args: {} }, { title: "edit", output: "edited", metadata: {} });
986
+
987
+ // A live owner, still mid-turn (no idle timestamp recorded), keeps its claim.
988
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "live.ts"), "takeover-read-live-claimant");
989
+ let liveClaimBlocked = false;
990
+ try {
991
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "live-blocked" }, { args: { filePath: join(takeoverDir, "live.ts"), content: "c" } });
992
+ } catch (error) {
993
+ liveClaimBlocked = String(error).includes("claimed by another active session");
994
+ }
995
+ check("a live owner without an idle timestamp keeps its claim", liveClaimBlocked);
996
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-live-owner", callID: "live-claim", args: {} }, { title: "edit", output: "edited", metadata: {} });
997
+
998
+ // A same-session re-claim refreshes the stored claim (new callID/timestamp):
999
+ // an owner that idled with a leaked claim and comes back to the same file
1000
+ // mid-turn must not have its fresh claim taken over, and the refreshed
1001
+ // callID must be the one the after-hook releases.
1002
+ await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "refresh.ts"), "takeover-read-refresh");
1003
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-1" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "d" } });
1004
+ await new Promise((resolve) => setTimeout(resolve, 10));
1005
+ takeoverIdle.set("s-stale-owner", Date.now());
1006
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-2" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "e" } });
1007
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "refresh.ts"), "takeover-read-refresh-claimant");
1008
+ let refreshClaimBlocked = false;
1009
+ try {
1010
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-conflict" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "f" } });
1011
+ } catch (error) {
1012
+ refreshClaimBlocked = String(error).includes("claimed by another active session");
1013
+ }
1014
+ check("a same-session re-claim refreshes past the owner's idle so it is not taken over mid-turn", refreshClaimBlocked);
1015
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-stale-owner", callID: "refresh-claim-2", args: {} }, { title: "edit", output: "edited", metadata: {} });
1016
+ let refreshReleasedAllowed = true;
1017
+ try {
1018
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-after-release" }, { args: { filePath: join(takeoverDir, "refresh.ts"), content: "g" } });
1019
+ } catch {
1020
+ refreshReleasedAllowed = false;
1021
+ }
1022
+ check("the refreshed claim releases through its updated callID", refreshReleasedAllowed);
1023
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "refresh-after-release", args: {} }, { title: "edit", output: "edited", metadata: {} });
1024
+
1025
+ // The takeover releases every provably stale claim of the owner, each with
1026
+ // its own audit entry: an owner holding two stale paths loses both in one
1027
+ // pass, and no fresh claim of that owner is touched.
1028
+ writeFileSync(join(takeoverDir, "multi-a.ts"), "before");
1029
+ writeFileSync(join(takeoverDir, "multi-b.ts"), "before");
1030
+ await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "multi-a.ts"), "takeover-read-multi-a");
1031
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "multi-claim-a" }, { args: { filePath: join(takeoverDir, "multi-a.ts"), content: "h" } });
1032
+ await recordRead(takeoverBefore, takeoverAfter, "s-stale-owner", join(takeoverDir, "multi-b.ts"), "takeover-read-multi-b");
1033
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-stale-owner", callID: "multi-claim-b" }, { args: { filePath: join(takeoverDir, "multi-b.ts"), content: "h" } });
1034
+ await new Promise((resolve) => setTimeout(resolve, 10));
1035
+ takeoverIdle.set("s-stale-owner", Date.now());
1036
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "multi-a.ts"), "takeover-read-multi-a-claimant");
1037
+ let multiTakeoverAllowed = true;
1038
+ try {
1039
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-a" }, { args: { filePath: join(takeoverDir, "multi-a.ts"), content: "i" } });
1040
+ } catch {
1041
+ multiTakeoverAllowed = false;
1042
+ }
1043
+ check("one conflict releases every provably stale claim of the owner", multiTakeoverAllowed);
1044
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-a", args: {} }, { title: "edit", output: "edited", metadata: {} });
1045
+ await recordRead(takeoverBefore, takeoverAfter, "s-claimant", join(takeoverDir, "multi-b.ts"), "takeover-read-multi-b-claimant");
1046
+ let multiSecondAllowed = true;
1047
+ try {
1048
+ await takeoverBefore?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-b" }, { args: { filePath: join(takeoverDir, "multi-b.ts"), content: "j" } });
1049
+ } catch {
1050
+ multiSecondAllowed = false;
1051
+ }
1052
+ check("the owner's other stale path is released in the same pass", multiSecondAllowed);
1053
+ await takeoverAfter?.({ tool: "edit", sessionID: "s-claimant", callID: "multi-takeover-b", args: {} }, { title: "edit", output: "edited", metadata: {} });
1054
+ const takeoverAudits = getRecentAuditEntries(30).filter((entry) => entry.reason === "stale_claim_takeover" && typeof entry.evidence?.targetPath === "string" && entry.evidence.targetPath.includes("multi-"));
1055
+ check("each released stale path has its own audited takeover entry", takeoverAudits.length === 2 && takeoverAudits.every((entry) => entry.evidence?.claimOwnerSessionID === "s-stale-owner"));
1056
+
1057
+ // Fail-closed: no session lookup on the client, and a lookup that throws.
1058
+ const noLookupDir = mkdtempSync(join(tmpdir(), "wg-claim-nolookup-"));
1059
+ writeFileSync(join(noLookupDir, "file.ts"), "before");
1060
+ const noLookupHooks = await pluginFn({ directory: noLookupDir, client: {} as any, project: {} as any, worktree: noLookupDir, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
1061
+ const noLookupBefore = noLookupHooks["tool.execute.before"];
1062
+ const noLookupAfter = noLookupHooks["tool.execute.after"];
1063
+ todo("s-nolookup-owner", item("edit file", "in_progress"));
1064
+ await recordRead(noLookupBefore, noLookupAfter, "s-nolookup-owner", join(noLookupDir, "file.ts"), "nolookup-read-owner");
1065
+ await noLookupBefore?.({ tool: "edit", sessionID: "s-nolookup-owner", callID: "nolookup-claim" }, { args: { filePath: join(noLookupDir, "file.ts"), content: "a" } });
1066
+ todo("s-nolookup-claimant", item("edit file", "in_progress"));
1067
+ await recordRead(noLookupBefore, noLookupAfter, "s-nolookup-claimant", join(noLookupDir, "file.ts"), "nolookup-read-claimant");
1068
+ let noLookupBlocked = false;
1069
+ try {
1070
+ await noLookupBefore?.({ tool: "edit", sessionID: "s-nolookup-claimant", callID: "nolookup-claimant" }, { args: { filePath: join(noLookupDir, "file.ts"), content: "b" } });
1071
+ } catch (error) {
1072
+ noLookupBlocked = String(error).includes("claimed by another active session");
1073
+ }
1074
+ check("stale takeover fails closed when the client has no session lookup", noLookupBlocked);
1075
+
1076
+ const throwLookupDir = mkdtempSync(join(tmpdir(), "wg-claim-throwlookup-"));
1077
+ writeFileSync(join(throwLookupDir, "file.ts"), "before");
1078
+ const throwLookupHooks = await pluginFn({ directory: throwLookupDir, client: { session: { get: async () => { throw new Error("lookup failed"); } } } as any, project: {} as any, worktree: throwLookupDir, experimental_workspace: {} as any, serverUrl: new URL("http://localhost:4096"), $: undefined as any });
1079
+ const throwLookupBefore = throwLookupHooks["tool.execute.before"];
1080
+ const throwLookupAfter = throwLookupHooks["tool.execute.after"];
1081
+ todo("s-throwlookup-owner", item("edit file", "in_progress"));
1082
+ await recordRead(throwLookupBefore, throwLookupAfter, "s-throwlookup-owner", join(throwLookupDir, "file.ts"), "throwlookup-read-owner");
1083
+ await throwLookupBefore?.({ tool: "edit", sessionID: "s-throwlookup-owner", callID: "throwlookup-claim" }, { args: { filePath: join(throwLookupDir, "file.ts"), content: "a" } });
1084
+ todo("s-throwlookup-claimant", item("edit file", "in_progress"));
1085
+ await recordRead(throwLookupBefore, throwLookupAfter, "s-throwlookup-claimant", join(throwLookupDir, "file.ts"), "throwlookup-read-claimant");
1086
+ let throwLookupBlocked = false;
1087
+ try {
1088
+ await throwLookupBefore?.({ tool: "edit", sessionID: "s-throwlookup-claimant", callID: "throwlookup-claimant" }, { args: { filePath: join(throwLookupDir, "file.ts"), content: "b" } });
1089
+ } catch (error) {
1090
+ throwLookupBlocked = String(error).includes("claimed by another active session");
1091
+ }
1092
+ check("stale takeover fails closed when the session lookup throws", throwLookupBlocked);
1093
+
898
1094
  // Existing files must be read by the same session before edit/write, and the
899
1095
  // bytes plus filesystem identity must still match when the mutation starts.
900
1096
  const staleDir = mkdtempSync(join(tmpdir(), "wg-stale-write-"));
@@ -980,6 +1176,56 @@ try {
980
1176
  }
981
1177
  check("stale-write protection allows creating a new file without a prior read", newFileAllowed);
982
1178
  await staleAfter?.({ tool: "write", sessionID: "s-stale", callID: "new-file", args: {} }, { title: "write", output: "written", metadata: {} });
1179
+ // A successful mutation seeds the session's observation of the bytes it
1180
+ // authored, so iterating on a just-created/modified file needs no re-read;
1181
+ // a no-op mutation seeds nothing and external changes stay blocked.
1182
+ const mutationSession = "s-stale-mutation";
1183
+ todo(mutationSession, item("iterate on authored file", "in_progress"));
1184
+ const authoredPath = join(staleDir, "authored.ts");
1185
+ await staleBefore?.({ tool: "write", sessionID: mutationSession, callID: "write-authored" }, { args: { filePath: authoredPath, content: "v1" } });
1186
+ writeFileSync(authoredPath, "v1");
1187
+ await staleAfter?.({ tool: "write", sessionID: mutationSession, callID: "write-authored", args: {} }, { title: "write", output: "written", metadata: {} });
1188
+ let authoredEditAllowed = true;
1189
+ try {
1190
+ await staleBefore?.({ tool: "edit", sessionID: mutationSession, callID: "edit-authored-1" }, { args: { filePath: authoredPath, oldString: "v1", newString: "v2" } });
1191
+ } catch {
1192
+ authoredEditAllowed = false;
1193
+ }
1194
+ check("a successful write seeds the session observation so the authored file can be edited without a re-read", authoredEditAllowed);
1195
+ writeFileSync(authoredPath, "v2");
1196
+ await staleAfter?.({ tool: "edit", sessionID: mutationSession, callID: "edit-authored-1", args: {} }, { title: "edit", output: "edited", metadata: {} });
1197
+ let chainedEditAllowed = true;
1198
+ try {
1199
+ await staleBefore?.({ tool: "edit", sessionID: mutationSession, callID: "edit-authored-2" }, { args: { filePath: authoredPath, oldString: "v2", newString: "v3" } });
1200
+ } catch {
1201
+ chainedEditAllowed = false;
1202
+ }
1203
+ check("a successful edit seeds the observation so chained edits need no re-read", chainedEditAllowed);
1204
+ writeFileSync(authoredPath, "changed externally");
1205
+ let externalAfterMutationBlocked = false;
1206
+ try {
1207
+ await staleBefore?.({ tool: "edit", sessionID: mutationSession, callID: "edit-authored-3" }, { args: { filePath: authoredPath, oldString: "v3", newString: "v4" } });
1208
+ } catch (error) {
1209
+ externalAfterMutationBlocked = String(error).includes("changed since this session read it");
1210
+ }
1211
+ check("an external change after a successful mutation is still blocked", externalAfterMutationBlocked);
1212
+ // Negative branch: a mutation that leaves content unchanged must not seed.
1213
+ // apply_patch is not stale-write checked, so this unobserved session can run
1214
+ // it; because the file is left unchanged, the seed gate must skip and a
1215
+ // later edit must still demand a read.
1216
+ const noopSession = "s-stale-noop";
1217
+ todo(noopSession, item("no-op patch", "in_progress"));
1218
+ const noopPath = join(staleDir, "noop.ts");
1219
+ writeFileSync(noopPath, "stable");
1220
+ await staleBefore?.({ tool: "apply_patch", sessionID: noopSession, callID: "noop-patch" }, { args: { patchText: `*** Begin Patch\n*** Update File: ${noopPath}\n@@\n stable\n*** End Patch` } });
1221
+ await staleAfter?.({ tool: "apply_patch", sessionID: noopSession, callID: "noop-patch", args: {} }, { title: "patch", output: "done", metadata: {} });
1222
+ let noopEditBlocked = false;
1223
+ try {
1224
+ await staleBefore?.({ tool: "edit", sessionID: noopSession, callID: "noop-edit" }, { args: { filePath: noopPath, oldString: "stable", newString: "changed" } });
1225
+ } catch (error) {
1226
+ noopEditBlocked = String(error).includes("has not been read");
1227
+ }
1228
+ check("a no-op mutation does not seed an observation for a later edit", noopEditBlocked);
983
1229
  const staleAlternateRoot = mkdtempSync(join(tmpdir(), "wg-stale-root-"));
984
1230
  writeFileSync(join(staleAlternateRoot, "relative.ts"), "observed");
985
1231
  todo("s-stale-root", item("edit relative file", "in_progress"));
@@ -1718,10 +1964,10 @@ check("evidence freshness requires exact subject and post-mutation observation",
1718
1964
  const missingProvenanceEvidence = verificationEvidence({ passed: true, command: "npm test", output: "ok", timestamp: 123, workspaceRoot: "/tmp/ws" }, "s-evidence");
1719
1965
  check("evidence freshness fails closed when Git provenance is unavailable", !isEvidenceFresh(missingProvenanceEvidence, { workspace: "/tmp/ws", sessionID: "s-evidence" }, 0) && !isEvidenceFresh(verificationEvidenceResult, { workspace: "/tmp/ws", sessionID: "s-evidence" }, 0));
1720
1966
  await call("edit", { filePath: join(root, "after-review.ts"), content: "changed" }, { sessionID: "s-active" });
1721
- check("new mutation invalidates prior review approval", getLastReviewResult() === undefined);
1967
+ check("mutations no longer erase review approval state (freshness is re-evaluated against content)", getLastReviewResult() !== undefined);
1722
1968
  recordReviewResult("reviewer-subagent", "Child review passed all axes.", true, "s-reviewed-child");
1723
1969
  recordMutation("s-reviewed-parent", "s-reviewed-child");
1724
- check("actor mutation invalidates child-targeted global review", getLastReviewResult() === undefined);
1970
+ check("actor mutation no longer erases child-targeted review evidence", getLastReviewResult()?.targetSessionID === "s-reviewed-child");
1725
1971
 
1726
1972
  const fingerprintRepo = mkdtempSync(join(tmpdir(), "wg-review-fingerprint-"));
1727
1973
  spawnSync("git", ["init", "-b", "feature/review"], { cwd: fingerprintRepo });
@@ -1791,6 +2037,29 @@ symlinkSync(join(specialRepo, "missing-target-2"), join(specialRepo, "broken-lin
1791
2037
  check("adding an untracked symlink updates the fingerprint", getGitWorktreeFingerprint(specialRepo) !== fpWithSpecials);
1792
2038
  rmSync(specialRepo, { recursive: true, force: true });
1793
2039
  rmSync(linkTargetBase, { recursive: true, force: true });
2040
+ // Review evidence binds to tracked content only: untracked scratch files are
2041
+ // not part of the reviewed diff, so creating or deleting them must keep the
2042
+ // approval fresh, while the full fingerprint still covers untracked contents
2043
+ // for verification evidence.
2044
+ const trackedFpRepo = mkdtempSync(join(tmpdir(), "wg-review-tracked-fp-"));
2045
+ spawnSync("git", ["init", "-b", "main"], { cwd: trackedFpRepo });
2046
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: trackedFpRepo });
2047
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: trackedFpRepo });
2048
+ writeFileSync(join(trackedFpRepo, "tracked.txt"), "tracked\n");
2049
+ spawnSync("git", ["add", "tracked.txt"], { cwd: trackedFpRepo });
2050
+ spawnSync("git", ["commit", "-m", "base"], { cwd: trackedFpRepo });
2051
+ const trackedFpBase = getTrackedWorktreeFingerprint(trackedFpRepo);
2052
+ check("tracked-content fingerprint is available for a git worktree", typeof trackedFpBase === "string" && trackedFpBase.length === 64);
2053
+ writeFileSync(join(trackedFpRepo, ".tmp-review-final.diff"), "scratch\n");
2054
+ check("untracked scratch creation does not change the tracked-content fingerprint", getTrackedWorktreeFingerprint(trackedFpRepo) === trackedFpBase);
2055
+ const fullFpWithScratch = getGitWorktreeFingerprint(trackedFpRepo);
2056
+ writeFileSync(join(trackedFpRepo, ".tmp-review-final.diff"), "scratch v2\n");
2057
+ check("full fingerprint still binds untracked contents for verification evidence", getGitWorktreeFingerprint(trackedFpRepo) !== fullFpWithScratch);
2058
+ rmSync(join(trackedFpRepo, ".tmp-review-final.diff"));
2059
+ check("untracked scratch deletion does not change the tracked-content fingerprint", getTrackedWorktreeFingerprint(trackedFpRepo) === trackedFpBase);
2060
+ writeFileSync(join(trackedFpRepo, "tracked.txt"), "tracked edit\n");
2061
+ check("tracked-content fingerprint changes on a tracked edit", getTrackedWorktreeFingerprint(trackedFpRepo) !== trackedFpBase);
2062
+ rmSync(trackedFpRepo, { recursive: true, force: true });
1794
2063
 
1795
2064
  // 9. Secret-File READ Blocks (Policy 17)
1796
2065
  console.log("- Policy 17: Secret-File READ Blocks -");
@@ -2237,6 +2506,17 @@ try {
2237
2506
  rmSync(whyRemoteRoot, { recursive: true, force: true });
2238
2507
  }
2239
2508
 
2509
+ // record_review binds verdicts to tracked repository content, so its tool
2510
+ // tests need a real git worktree rather than the non-git test root.
2511
+ const reviewToolRepo = join(root, "wg-review-tool-repo");
2512
+ mkdirSync(reviewToolRepo, { recursive: true });
2513
+ spawnSync("git", ["init", "-b", "main"], { cwd: reviewToolRepo });
2514
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: reviewToolRepo });
2515
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: reviewToolRepo });
2516
+ writeFileSync(join(reviewToolRepo, "code.txt"), "reviewed\n");
2517
+ spawnSync("git", ["add", "code.txt"], { cwd: reviewToolRepo });
2518
+ spawnSync("git", ["commit", "-m", "base"], { cwd: reviewToolRepo });
2519
+
2240
2520
  fakeParents.set("s-reviewer-tool", "s-active");
2241
2521
  const unrelatedClient = {
2242
2522
  session: {
@@ -2255,15 +2535,15 @@ await (defaultExport?.server ?? WorkflowGuard)({
2255
2535
  });
2256
2536
  const reviewToolResult = await customPlugin.tool?.record_review?.execute(
2257
2537
  { reviewer: "subagent-1", summary: "Test integrity: real assertions. Task completeness: done. Cleanliness: no stubs. Security: clean. Platform fit: ok.", passed: true },
2258
- { sessionID: "s-reviewer-tool", agent: "reviewer", worktree: root, directory: root } as any,
2538
+ { sessionID: "s-reviewer-tool", agent: "reviewer", worktree: reviewToolRepo, directory: reviewToolRepo } as any,
2259
2539
  );
2260
- check("record_review resolves subagent lineage through its own plugin client", typeof reviewToolResult === "string" && reviewToolResult.includes("APPROVED"));
2540
+ check("record_review resolves subagent lineage through its own plugin client", typeof reviewToolResult === "string" && reviewToolResult.includes("APPROVED") && reviewToolResult.includes(reviewToolRepo));
2261
2541
  setSdkClient(fakeClient);
2262
2542
 
2263
2543
  fakeParents.set("s-reviewer-changes", "s-active");
2264
2544
  const changesReviewResult = await customPlugin.tool?.record_review?.execute(
2265
2545
  { reviewer: "subagent-changes", summary: "Test integrity: gap found. Task completeness: needs work. Cleanliness: ok. Security: ok. Platform: ok.", passed: false },
2266
- { sessionID: "s-reviewer-changes", agent: "reviewer", worktree: root, directory: root } as any,
2546
+ { sessionID: "s-reviewer-changes", agent: "reviewer", worktree: reviewToolRepo, directory: reviewToolRepo } as any,
2267
2547
  );
2268
2548
  const changesReviewAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-reviewer-changes");
2269
2549
  check("record_review audit distinguishes changes-requested verdict without persisting summary", typeof changesReviewResult === "string" && changesReviewResult.includes("CHANGES REQUESTED") && changesReviewAudit?.reason === "changes_requested" && !JSON.stringify(changesReviewAudit).includes("gap found"));
@@ -2275,7 +2555,7 @@ await customPlugin.tool?.record_review?.execute(
2275
2555
  summary: "Test integrity: covered. Task completeness: done. Cleanliness: clean. Security: safe. Platform: compatible.\nP2: first durable issue\nP3: second durable issue",
2276
2556
  passed: true,
2277
2557
  },
2278
- { sessionID: "s-reviewer-followups", agent: "reviewer", worktree: root, directory: root } as any,
2558
+ { sessionID: "s-reviewer-followups", agent: "reviewer", worktree: reviewToolRepo, directory: reviewToolRepo } as any,
2279
2559
  );
2280
2560
  const durableReviewFollowups = JSON.parse(String(await customPlugin.tool?.guard_review_followups?.execute({}, {} as any))) as Array<{ severity?: string; summary?: string }>;
2281
2561
  check("record_review persists multiple P2/P3 findings independently", durableReviewFollowups.some((item) => item.severity === "P2" && item.summary?.includes("first durable issue")) && durableReviewFollowups.some((item) => item.severity === "P3" && item.summary?.includes("second durable issue")));
@@ -2316,11 +2596,80 @@ check(
2316
2596
  const rubricOptionFlag = await customPlugin.tool?.guard_review_rubric?.execute({ base: "--output=injected" }, {} as any);
2317
2597
  check("guard_review_rubric sanitizes option flags in base ref", typeof rubricOptionFlag === "string" && !existsSync(join(root, "injected")));
2318
2598
 
2599
+ // Regression: the rubric must embed the real branch diff. The git argv
2600
+ // previously placed `--` before the revision, so the revision parsed as a
2601
+ // pathspec and every rubric carried an empty diff block.
2602
+ const rubricDiffRepo = join(root, "wg-review-rubric-diff");
2603
+ mkdirSync(rubricDiffRepo, { recursive: true });
2604
+ spawnSync("git", ["init", "-b", "main"], { cwd: rubricDiffRepo });
2605
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: rubricDiffRepo });
2606
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: rubricDiffRepo });
2607
+ writeFileSync(join(rubricDiffRepo, "seed.txt"), "base\n");
2608
+ spawnSync("git", ["add", "seed.txt"], { cwd: rubricDiffRepo });
2609
+ spawnSync("git", ["commit", "-m", "base"], { cwd: rubricDiffRepo });
2610
+ spawnSync("git", ["switch", "-c", "feature/rubric-diff"], { cwd: rubricDiffRepo });
2611
+ writeFileSync(join(rubricDiffRepo, "added.txt"), "export const rubricDiffMarker = 42;\n");
2612
+ spawnSync("git", ["add", "added.txt"], { cwd: rubricDiffRepo });
2613
+ spawnSync("git", ["commit", "-m", "add rubric marker"], { cwd: rubricDiffRepo });
2614
+ const rubricWithDiff = await customPlugin.tool?.guard_review_rubric?.execute(
2615
+ { base: "main", directory: rubricDiffRepo },
2616
+ { sessionID: "s-rubric-diff", worktree: rubricDiffRepo, directory: rubricDiffRepo } as any,
2617
+ );
2618
+ check(
2619
+ "guard_review_rubric embeds the real branch diff content",
2620
+ typeof rubricWithDiff === "string" && rubricWithDiff.includes("rubricDiffMarker") && rubricWithDiff.includes("added.txt"),
2621
+ );
2622
+ rmSync(rubricDiffRepo, { recursive: true, force: true });
2623
+
2624
+ // The default-bases loop is directly asserted: with no origin remote, the
2625
+ // loop falls through origin/main and origin/master to the local main branch
2626
+ // and embeds the real branch diff. The spawn also keeps a trailing `--` after
2627
+ // the revision so the base ref can never parse as a pathspec (LL-001).
2628
+ const rubricNoOrigin = join(root, "wg-rubric-no-origin");
2629
+ mkdirSync(rubricNoOrigin, { recursive: true });
2630
+ spawnSync("git", ["init", "-b", "main"], { cwd: rubricNoOrigin });
2631
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: rubricNoOrigin });
2632
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: rubricNoOrigin });
2633
+ writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "base\n");
2634
+ spawnSync("git", ["add", "fallthrough.txt"], { cwd: rubricNoOrigin });
2635
+ spawnSync("git", ["commit", "-m", "base"], { cwd: rubricNoOrigin });
2636
+ spawnSync("git", ["switch", "-c", "feature/rubric-fallthrough"], { cwd: rubricNoOrigin });
2637
+ writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "branch marker\n");
2638
+ spawnSync("git", ["add", "fallthrough.txt"], { cwd: rubricNoOrigin });
2639
+ spawnSync("git", ["commit", "-qm", "branch change"], { cwd: rubricNoOrigin });
2640
+ // An uncommitted tracked edit discriminates the fallbacks: main...HEAD is
2641
+ // committed-only and must NOT include it, while the HEAD~1 fallback (commit
2642
+ // vs worktree) would — so the assertion proves the base loop won.
2643
+ writeFileSync(join(rubricNoOrigin, "fallthrough.txt"), "branch marker\nuncommitted worktree edit\n");
2644
+ const rubricFallthrough = await customPlugin.tool?.guard_review_rubric?.execute(
2645
+ { directory: rubricNoOrigin },
2646
+ { sessionID: "s-rubric-fallthrough", worktree: rubricNoOrigin, directory: rubricNoOrigin } as any,
2647
+ );
2648
+ check(
2649
+ "rubric default bases fall through to the local main when no origin exists",
2650
+ typeof rubricFallthrough === "string" && rubricFallthrough.includes("diff --git") && rubricFallthrough.includes("fallthrough.txt") && rubricFallthrough.includes("branch marker") && !rubricFallthrough.includes("uncommitted worktree edit"),
2651
+ );
2652
+ rmSync(rubricNoOrigin, { recursive: true, force: true });
2653
+
2654
+ // Reviewer-type agents often lack record_review in their toolset entirely, so
2655
+ // the tool accepts recorders of any session type: the verdict binds to the
2656
+ // recording session's directory and the audit trail captures the recorder
2657
+ // lineage (recorderRole). The decision is documented in the changelog.
2319
2658
  const mainReviewToolResult = await customPlugin.tool?.record_review?.execute(
2320
- { reviewer: "self", summary: "self approval", passed: true },
2321
- { sessionID: "s-active", agent: "main", worktree: root, directory: root } as any,
2659
+ { reviewer: "root-relay", summary: "Test integrity: verified. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
2660
+ { sessionID: "s-active", agent: "main", worktree: reviewToolRepo, directory: reviewToolRepo } as any,
2661
+ );
2662
+ check(
2663
+ "record_review accepts a root-session recorder and binds the verdict to its directory",
2664
+ typeof mainReviewToolResult === "string" && mainReviewToolResult.includes("APPROVED") && mainReviewToolResult.includes(reviewToolRepo),
2322
2665
  );
2323
- check("record_review rejects self-approval from main session", typeof mainReviewToolResult === "string" && mainReviewToolResult.includes("rejected"));
2666
+ const rootRecorderAudit = getRecentAuditEntries(30).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-active");
2667
+ check(
2668
+ "record_review audit includes the binding key and recorder lineage",
2669
+ (rootRecorderAudit?.evidence as { binding?: { workspace?: string; recorderRole?: string } })?.binding?.workspace === reviewToolRepo &&
2670
+ (rootRecorderAudit?.evidence as { binding?: { recorderRole?: string } })?.binding?.recorderRole === "root",
2671
+ );
2672
+ resetReviewState();
2324
2673
 
2325
2674
  // Secondary Review Approval Handoff & Durable Cache Tests
2326
2675
  console.log("- Secondary Review Approval Handoff & Durability -");
@@ -2412,6 +2761,259 @@ check("worktree and main repo recognized as same git repository", isSameGitRepo(
2412
2761
  rmSync(handoffRepo, { recursive: true, force: true });
2413
2762
  resetReviewState();
2414
2763
 
2764
+ // 5. Review binding across sessions and linked worktrees (regression): a
2765
+ // review recorded by a subagent rooted in a LINKED WORKTREE must satisfy the
2766
+ // PR preflight even when the PR is created from a DIFFERENT session, and must
2767
+ // stay valid across innocuous untracked-file cleanup.
2768
+ console.log("- Review Binding Across Sessions & Linked Worktrees -");
2769
+ const flowRepo = join(root, "wg-flow-repo");
2770
+ mkdirSync(flowRepo, { recursive: true });
2771
+ spawnSync("git", ["init", "-b", "main"], { cwd: flowRepo });
2772
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: flowRepo });
2773
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: flowRepo });
2774
+ writeFileSync(join(flowRepo, "seed.txt"), "base\n");
2775
+ spawnSync("git", ["add", "seed.txt"], { cwd: flowRepo });
2776
+ spawnSync("git", ["commit", "-m", "base"], { cwd: flowRepo });
2777
+ mkdirSync(join(flowRepo, ".opencode"), { recursive: true });
2778
+ writeFileSync(join(flowRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
2779
+ const prevWorktreeDirFlow = process.env.WORKFLOW_GUARD_WORKTREE_DIR;
2780
+ process.env.WORKFLOW_GUARD_WORKTREE_DIR = join(root, "wg-flow-worktrees");
2781
+ const flowWorktreeRes = createGitWorktree("fix/flow-branch", "HEAD", flowRepo);
2782
+ check("flow: linked worktree created", flowWorktreeRes.success && typeof flowWorktreeRes.worktreePath === "string");
2783
+ const flowWt = flowWorktreeRes.worktreePath!;
2784
+ writeFileSync(join(flowWt, "seed.txt"), "worktree change\n");
2785
+ spawnSync("git", ["add", "seed.txt"], { cwd: flowWt });
2786
+ spawnSync("git", ["commit", "-qm", "worktree change"], { cwd: flowWt });
2787
+ writeFileSync(join(flowWt, ".tmp-review-final.diff"), "scratch\n");
2788
+
2789
+ const flowPlugin = await WorkflowGuard({
2790
+ directory: root,
2791
+ worktree: root,
2792
+ client: fakeClient as any,
2793
+ project: {} as any,
2794
+ experimental_workspace: {} as any,
2795
+ serverUrl: new URL("http://localhost:4096"),
2796
+ $: undefined as any,
2797
+ });
2798
+ // The orchestrator session moved itself into the linked worktree
2799
+ // (tools.opencode.session_move): its file tools are rooted at the worktree.
2800
+ setSessionWorkspace("s-flow-orch", flowWt);
2801
+ fakeParents.set("s-flow-reviewer", "s-flow-orch");
2802
+ fakeParents.set("s-flow-pr", "s-flow-orch");
2803
+ todo("s-flow-orch", item("drive the flow", "in_progress"));
2804
+ const flowCtx = (sessionID: string) => ({ sessionID, worktree: flowWt, directory: flowWt }) as any;
2805
+
2806
+ const flowReview = await flowPlugin.tool?.record_review?.execute(
2807
+ { reviewer: "secondary-reviewer", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
2808
+ { ...flowCtx("s-flow-reviewer"), agent: "general" },
2809
+ );
2810
+ check("flow: review recorded from a subagent rooted in the linked worktree binds to it", typeof flowReview === "string" && flowReview.includes("APPROVED") && flowReview.includes(flowWt));
2811
+
2812
+ const flowStatus = JSON.parse(String(await flowPlugin.tool?.guard_status?.execute({ directory: flowWt }, flowCtx("s-flow-orch"))));
2813
+ check("flow: guard_status reports fresh lastReview for the linked worktree", flowStatus.lastReview?.fresh === true && !flowStatus.outstandingRequirements.includes("review"));
2814
+
2815
+ const flowPrevPath = process.env.PATH;
2816
+ const flowBin = join(root, "wg-flow-bin");
2817
+ mkdirSync(flowBin, { recursive: true });
2818
+ writeFileSync(join(flowBin, "gh"), "#!/bin/sh\nexit 1\n");
2819
+ writeFileSync(join(flowBin, "az"), "#!/bin/sh\nexit 1\n");
2820
+ chmodSync(join(flowBin, "gh"), 0o755);
2821
+ chmodSync(join(flowBin, "az"), 0o755);
2822
+ process.env.PATH = `${flowBin}:${flowPrevPath ?? ""}`;
2823
+ const flowPrBody = "gh pr create --title 'fix: flow' --body 'Changelog: flow'";
2824
+ try {
2825
+ const flowPrParent = await call("bash", { command: flowPrBody, workdir: flowWt }, flowCtx("s-flow-orch"));
2826
+ check("flow: PR from the reviewer's parent session is allowed", !blocked(flowPrParent));
2827
+ const flowPrOther = await call("bash", { command: flowPrBody, workdir: flowWt }, flowCtx("s-flow-pr"));
2828
+ check("flow: PR from a DIFFERENT session in the same worktree is allowed (content binding, not session identity)", !blocked(flowPrOther));
2829
+ if (blocked(flowPrOther)) console.log(" blocked:", flowPrOther);
2830
+
2831
+ // Untracked scratch cleanup must not invalidate the approval
2832
+ const flowRm = await call("bash", { command: "rm -f .tmp-review-final.diff", workdir: flowWt }, flowCtx("s-flow-orch"));
2833
+ check("flow: untracked scratch deletion is a permitted cleanup", !blocked(flowRm));
2834
+ const flowStatusAfterRm = JSON.parse(String(await flowPlugin.tool?.guard_status?.execute({ directory: flowWt }, flowCtx("s-flow-orch"))));
2835
+ check("flow: review stays fresh after untracked scratch deletion", flowStatusAfterRm.lastReview?.fresh === true && !flowStatusAfterRm.outstandingRequirements.includes("review"));
2836
+ const flowPrAfterRm = await call("bash", { command: flowPrBody, workdir: flowWt }, flowCtx("s-flow-pr"));
2837
+ check("flow: PR still allowed after innocuous untracked-file mutations", !blocked(flowPrAfterRm));
2838
+
2839
+ // Tracked content changes still invalidate (the gate is not weakened)
2840
+ writeFileSync(join(flowWt, "seed.txt"), "post-review tracked edit\n");
2841
+ const flowStatusAfterEdit = JSON.parse(String(await flowPlugin.tool?.guard_status?.execute({ directory: flowWt }, flowCtx("s-flow-orch"))));
2842
+ check("flow: tracked edit after review makes the approval stale", flowStatusAfterEdit.outstandingRequirements.includes("review"));
2843
+ const flowPrAfterEdit = await call("bash", { command: flowPrBody, workdir: flowWt }, flowCtx("s-flow-orch"));
2844
+ check("flow: PR blocked after tracked content changed post-review", blocked(flowPrAfterEdit) && String(flowPrAfterEdit).includes("tracked content changed after review"));
2845
+ spawnSync("git", ["checkout", "--", "seed.txt"], { cwd: flowWt });
2846
+ } finally {
2847
+ process.env.PATH = flowPrevPath;
2848
+ }
2849
+
2850
+ // Reviewer-type agents cannot call record_review themselves (their toolset has
2851
+ // no record_review), so the orchestrator relays the verdict from its own
2852
+ // session; the verdict must still bind to the reviewed worktree.
2853
+ const relayedReview = await flowPlugin.tool?.record_review?.execute(
2854
+ { reviewer: "relayed-reviewer", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
2855
+ { ...flowCtx("s-flow-orch"), agent: "reviewer" },
2856
+ );
2857
+ check("flow: relayed verdict from a root session binds to the reviewed worktree", typeof relayedReview === "string" && relayedReview.includes("APPROVED") && relayedReview.includes(flowWt));
2858
+
2859
+ // A verdict recorded against a non-git directory can never bind to a PR
2860
+ // preflight; it must fail loudly instead of silently not binding.
2861
+ const flowNonGit = join(root, "wg-flow-nongit");
2862
+ mkdirSync(flowNonGit, { recursive: true });
2863
+ const flowBadReview = await flowPlugin.tool?.record_review?.execute(
2864
+ { reviewer: "unbindingable", summary: "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", passed: true },
2865
+ { ...flowCtx("s-flow-reviewer"), worktree: flowNonGit, directory: flowNonGit },
2866
+ );
2867
+ check(
2868
+ "flow: verdict against a non-git directory returns an explicit bind error",
2869
+ typeof flowBadReview === "string" && flowBadReview.includes("rejected") && flowBadReview.includes("cannot be bound") && !flowBadReview.includes("APPROVED"),
2870
+ );
2871
+
2872
+ rmSync(flowNonGit, { recursive: true, force: true });
2873
+ rmSync(flowBin, { recursive: true, force: true });
2874
+ rmSync(flowRepo, { recursive: true, force: true });
2875
+ if (prevWorktreeDirFlow === undefined) delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
2876
+ else process.env.WORKFLOW_GUARD_WORKTREE_DIR = prevWorktreeDirFlow;
2877
+ rmSync(join(root, "wg-flow-worktrees"), { recursive: true, force: true });
2878
+ resetReviewState();
2879
+
2880
+ // Commit drift: a review recorded with its change already staged stays
2881
+ // fingerprint-identical once that change is committed, so the commit-hash
2882
+ // binding is the invalidation signal and the preflight must name it.
2883
+ const driftRepo = join(root, "wg-review-commit-drift");
2884
+ mkdirSync(driftRepo, { recursive: true });
2885
+ spawnSync("git", ["init", "-b", "feature/commit-drift"], { cwd: driftRepo });
2886
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: driftRepo });
2887
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: driftRepo });
2888
+ writeFileSync(join(driftRepo, "code.txt"), "base\n");
2889
+ spawnSync("git", ["add", "code.txt"], { cwd: driftRepo });
2890
+ spawnSync("git", ["commit", "-m", "base"], { cwd: driftRepo });
2891
+ mkdirSync(join(driftRepo, ".opencode"), { recursive: true });
2892
+ writeFileSync(join(driftRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
2893
+ writeFileSync(join(driftRepo, "code.txt"), "reviewed staged edit\n");
2894
+ spawnSync("git", ["add", "code.txt"], { cwd: driftRepo });
2895
+ const driftFingerprintBefore = getTrackedWorktreeFingerprint(driftRepo);
2896
+ recordReviewResult("commit-drift-reviewer", "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.", true, "s-drift", driftRepo);
2897
+ spawnSync("git", ["commit", "-qm", "commit reviewed change"], { cwd: driftRepo });
2898
+ check("committing staged reviewed content keeps the tracked fingerprint", getTrackedWorktreeFingerprint(driftRepo) === driftFingerprintBefore);
2899
+ const driftPr = await call("bash", { command: "gh pr create --title t --body 'Changelog: drift'", workdir: driftRepo }, { sessionID: "s-drift", worktree: driftRepo, directory: driftRepo });
2900
+ check("PR blocked when only the reviewed commit drifted, with a commit-specific cause", blocked(driftPr) && String(driftPr).includes("bound to commit"));
2901
+ rmSync(driftRepo, { recursive: true, force: true });
2902
+ resetReviewState();
2903
+
2904
+ // Strict recorder mode (requireSubagentReview): an opt-in policy that closes
2905
+ // the root-session self-approval path which default content binding keeps
2906
+ // open for read-only reviewer relays. Approvals must be recorded from a
2907
+ // subagent session (lineage, not agent role); changes-requested verdicts
2908
+ // stay relayable.
2909
+ console.log("- Strict Recorder Mode (requireSubagentReview) -");
2910
+ const strictSummary = "Test integrity: covered. Task completeness: complete. Cleanliness: clean. Security: safe. Platform: compatible.";
2911
+ const strictRepo = join(root, "wg-review-strict");
2912
+ mkdirSync(strictRepo, { recursive: true });
2913
+ spawnSync("git", ["init", "-b", "feature/strict-recorder"], { cwd: strictRepo });
2914
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: strictRepo });
2915
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: strictRepo });
2916
+ writeFileSync(join(strictRepo, "code.txt"), "base\n");
2917
+ spawnSync("git", ["add", "code.txt"], { cwd: strictRepo });
2918
+ spawnSync("git", ["commit", "-m", "base"], { cwd: strictRepo });
2919
+ mkdirSync(join(strictRepo, ".opencode"), { recursive: true });
2920
+ writeFileSync(join(strictRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true, requireSubagentReview: true }));
2921
+ const strictPlugin = await WorkflowGuard({
2922
+ directory: root,
2923
+ worktree: root,
2924
+ client: fakeClient as any,
2925
+ project: {} as any,
2926
+ experimental_workspace: {} as any,
2927
+ serverUrl: new URL("http://localhost:4096"),
2928
+ $: undefined as any,
2929
+ });
2930
+ const strictPrProbe = { tool: "bash", input: { command: "gh pr create --title t --body 'Changelog: strict'", workdir: strictRepo } };
2931
+
2932
+ // Root-session self-approval is rejected before any verdict is recorded
2933
+ const strictRootAttempt = await strictPlugin.tool?.record_review?.execute(
2934
+ { reviewer: "self-approved", summary: strictSummary, passed: true },
2935
+ { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
2936
+ );
2937
+ const strictRootAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-strict-root");
2938
+ check("strict mode: root-session approval is rejected with an actionable cause", typeof strictRootAttempt === "string" && strictRootAttempt.includes("rejected") && strictRootAttempt.includes("requireSubagentReview") && !strictRootAttempt.includes("APPROVED"));
2939
+ check("strict mode: rejected root approval is audited as subagent_recorder_required", strictRootAudit?.reason === "subagent_recorder_required");
2940
+ const strictStatusAfterRoot = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: strictRepo }, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
2941
+ check("strict mode: review gate stays unsatisfied after rejected self-approval", strictStatusAfterRoot.outstandingRequirements.includes("review") && strictStatusAfterRoot.lastReview === null && strictStatusAfterRoot.projectConfig?.requireSubagentReview === true);
2942
+ const strictPrAfterRoot = JSON.parse(String(await strictPlugin.tool?.guard_why?.execute(strictPrProbe, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
2943
+ check("strict mode: PR preflight still blocked after rejected self-approval", strictPrAfterRoot.status === "blocked" && strictPrAfterRoot.message.includes("Passing secondary review approval is required"));
2944
+
2945
+ // Changes-requested findings remain relayable from the root session
2946
+ const strictRootNegative = await strictPlugin.tool?.record_review?.execute(
2947
+ { reviewer: "self-flagged", summary: strictSummary, passed: false },
2948
+ { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
2949
+ );
2950
+ check("strict mode: root session may still relay changes-requested verdicts", typeof strictRootNegative === "string" && strictRootNegative.includes("CHANGES REQUESTED"));
2951
+
2952
+ // A subagent session (has a parent) records the approval
2953
+ fakeParents.set("s-strict-reviewer", "s-strict-root");
2954
+ const strictSubagentResult = await strictPlugin.tool?.record_review?.execute(
2955
+ { reviewer: "strict-reviewer", summary: strictSummary, passed: true },
2956
+ { sessionID: "s-strict-reviewer", agent: "general", worktree: strictRepo, directory: strictRepo } as any,
2957
+ );
2958
+ check("strict mode: subagent recorder records the approval", typeof strictSubagentResult === "string" && strictSubagentResult.includes("APPROVED"));
2959
+ const strictStatusAfterSubagent = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: strictRepo }, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
2960
+ check("strict mode: subagent approval satisfies the review gate", !strictStatusAfterSubagent.outstandingRequirements.includes("review") && strictStatusAfterSubagent.lastReview?.fresh === true);
2961
+ const strictPrAfterSubagent = JSON.parse(String(await strictPlugin.tool?.guard_why?.execute(strictPrProbe, { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any)));
2962
+ check("strict mode: PR preflight passes after subagent-recorded approval", strictPrAfterSubagent.status !== "blocked" && (strictPrAfterSubagent.status === "allowed" || strictPrAfterSubagent.code === "remote_state_unchecked"));
2963
+ if (strictPrAfterSubagent.status === "blocked") console.log(" blocked:", strictPrAfterSubagent.message);
2964
+
2965
+ // The knob follows the REVIEWED workspace's config, not the caller's: a root
2966
+ // session based in a non-strict context cannot approve strictRepo's work by
2967
+ // naming it as the reviewed directory from outside strict mode. Note the
2968
+ // reviewed workspace comes from the tool ARG (`directory`), not the caller's
2969
+ // context; the context worktree below is deliberately the non-strict temp root.
2970
+ const strictCrossAttempt = await strictPlugin.tool?.record_review?.execute(
2971
+ { reviewer: "cross-root", summary: strictSummary, passed: true, directory: strictRepo },
2972
+ { sessionID: "s-strict-cross", worktree: root, directory: root } as any,
2973
+ );
2974
+ const strictCrossAudit = getRecentAuditEntries(20).find((entry) => entry.tool === "record_review.verdict" && entry.sessionID === "s-strict-cross");
2975
+ check("strict mode: the knob follows the reviewed workspace, not the caller's repo", typeof strictCrossAttempt === "string" && strictCrossAttempt.includes("rejected") && strictCrossAttempt.includes("requireSubagentReview") && !strictCrossAttempt.includes("APPROVED"));
2976
+ check("strict mode: cross-repo root approval audited as subagent_recorder_required", strictCrossAudit?.reason === "subagent_recorder_required");
2977
+
2978
+ // Env override: =0 disables the knob even when project config enables it
2979
+ process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW = "0";
2980
+ const strictEnvOff = await strictPlugin.tool?.record_review?.execute(
2981
+ { reviewer: "env-off-root", summary: strictSummary, passed: true },
2982
+ { sessionID: "s-strict-root", worktree: strictRepo, directory: strictRepo } as any,
2983
+ );
2984
+ check("strict mode env off: WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=0 overrides the enabled project config", typeof strictEnvOff === "string" && strictEnvOff.includes("APPROVED"));
2985
+ delete process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW;
2986
+
2987
+ rmSync(strictRepo, { recursive: true, force: true });
2988
+ resetReviewState();
2989
+
2990
+ // Environment overrides follow the requireReview pattern; the default keeps
2991
+ // root-session relay recording allowed without the knob.
2992
+ const relaxedRepo = join(root, "wg-review-relaxed");
2993
+ mkdirSync(relaxedRepo, { recursive: true });
2994
+ spawnSync("git", ["init", "-b", "feature/relaxed-recorder"], { cwd: relaxedRepo });
2995
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: relaxedRepo });
2996
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: relaxedRepo });
2997
+ writeFileSync(join(relaxedRepo, "code.txt"), "base\n");
2998
+ spawnSync("git", ["add", "code.txt"], { cwd: relaxedRepo });
2999
+ spawnSync("git", ["commit", "-m", "base"], { cwd: relaxedRepo });
3000
+ process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW = "1";
3001
+ const relaxedEnvAttempt = await strictPlugin.tool?.record_review?.execute(
3002
+ { reviewer: "self-approved", summary: strictSummary, passed: true },
3003
+ { sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any,
3004
+ );
3005
+ check("strict recorder env override: WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW=1 applies without project config", typeof relaxedEnvAttempt === "string" && relaxedEnvAttempt.includes("rejected") && relaxedEnvAttempt.includes("subagent"));
3006
+ const relaxedEnvStatus = JSON.parse(String(await strictPlugin.tool?.guard_status?.execute({ directory: relaxedRepo }, { sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any)));
3007
+ check("strict recorder env override: status echoes the forced requirement", relaxedEnvStatus.projectConfig?.requireSubagentReview === true);
3008
+ delete process.env.WORKFLOW_GUARD_REQUIRE_SUBAGENT_REVIEW;
3009
+ const relaxedDefault = await strictPlugin.tool?.record_review?.execute(
3010
+ { reviewer: "relayed-reviewer", summary: strictSummary, passed: true },
3011
+ { sessionID: "s-relaxed-root", worktree: relaxedRepo, directory: relaxedRepo } as any,
3012
+ );
3013
+ check("strict recorder default: root-session relay recording stays allowed without the knob", typeof relaxedDefault === "string" && relaxedDefault.includes("APPROVED"));
3014
+ rmSync(relaxedRepo, { recursive: true, force: true });
3015
+ resetReviewState();
3016
+
2415
3017
  // Event hook handles permission events
2416
3018
  if (typeof customPlugin.event === "function") {
2417
3019
  await customPlugin.event({
@@ -2743,6 +3345,17 @@ check("checkInteractiveTtyCommand permits top long-form batch flag", !checkInter
2743
3345
  check("checkInteractiveTtyCommand permits hyphenated top-like filename", !checkInteractiveTtyCommand("ls top-level-dir").isInteractive);
2744
3346
  check("top batch mode does not hide another interactive monitor", checkInteractiveTtyCommand("top -b -n 1; htop").isInteractive);
2745
3347
  check("top batch mode does not hide a later interactive top", checkInteractiveTtyCommand("top -b -n 1; top").isInteractive);
3348
+ check("checkInteractiveTtyCommand permits top as an argument value", !checkInteractiveTtyCommand("az keyvault secret show --vault-name jcs-pim-vault-nz --name top").isInteractive);
3349
+ check("checkInteractiveTtyCommand permits an unquoted top argument", !checkInteractiveTtyCommand("echo top").isInteractive);
3350
+ check("checkInteractiveTtyCommand still detects a monitor after a pipe", checkInteractiveTtyCommand("cat file | top").isInteractive);
3351
+ check("checkInteractiveTtyCommand still detects a wrapped monitor", checkInteractiveTtyCommand("sudo top").isInteractive);
3352
+ check("checkInteractiveTtyCommand still detects busybox top", checkInteractiveTtyCommand("busybox top").isInteractive);
3353
+ check("checkInteractiveTtyCommand detects eval-wrapped monitor", checkInteractiveTtyCommand("eval top").isInteractive);
3354
+ check("checkInteractiveTtyCommand detects shell -c monitor", checkInteractiveTtyCommand("bash -c top").isInteractive);
3355
+ check("checkInteractiveTtyCommand detects shell -lc quoted monitor", checkInteractiveTtyCommand("sh -lc 'htop'").isInteractive);
3356
+ check("checkInteractiveTtyCommand permits a quoted separator before a monitor name", !checkInteractiveTtyCommand('echo "run; top"').isInteractive);
3357
+ check("checkInteractiveTtyCommand detects timeout-wrapped monitor", checkInteractiveTtyCommand("timeout 5 top").isInteractive);
3358
+ check("checkInteractiveTtyCommand detects a backgrounded-wrapper monitor", checkInteractiveTtyCommand("sudo -b top").isInteractive);
2746
3359
  check("checkInteractiveTtyCommand detects sudo", checkInteractiveTtyCommand("sudo apt-get update").isInteractive);
2747
3360
  check("checkInteractiveTtyCommand detects git rebase -i", checkInteractiveTtyCommand("git rebase -i HEAD~2").isInteractive);
2748
3361
  check("checkInteractiveTtyCommand detects npm init without -y", checkInteractiveTtyCommand("npm init").isInteractive);
@@ -2757,6 +3370,7 @@ check("shell tool blocks less", blocked(await shell("less package.json")));
2757
3370
  check("shell tool blocks top", blocked(await shell("top")));
2758
3371
  check("shell tool allows top batch mode", !(await shell("top -b -n 1")));
2759
3372
  check("shell tool allows top long-form batch flag", !(await shell("top --batch -n 1")));
3373
+ check("shell tool allows top as an argument value", !(await shell("az keyvault secret show --vault-name jcs-pim-vault-nz --name top")));
2760
3374
  check("shell tool blocks npm init without flag", blocked(await shell("npm init")));
2761
3375
  check("shell tool allows npm init -y", !(await shell("npm init -y")));
2762
3376
 
@@ -2974,6 +3588,21 @@ check(
2974
3588
  !rogueCleanup.success && existsSync(join(roguePath, "keep.txt")),
2975
3589
  );
2976
3590
 
3591
+ // Idempotent cleanup: a registered worktree whose directory vanished (the
3592
+ // prunable admin entry `git worktree list` shows) must be deregistered rather
3593
+ // than refused, while unregistered missing paths stay refused.
3594
+ const prunableCreate = createGitWorktree("feat/prunable-cleanup", "HEAD", worktreeBaseRepo);
3595
+ check("prunable-cleanup setup creates a worktree", prunableCreate.success && typeof prunableCreate.worktreePath === "string");
3596
+ const prunablePath = prunableCreate.worktreePath!;
3597
+ rmSync(prunablePath, { recursive: true, force: true });
3598
+ const prunableListedBefore = (spawnSync("git", ["worktree", "list", "--porcelain"], { cwd: worktreeBaseRepo, encoding: "utf8" }).stdout ?? "").includes(prunablePath);
3599
+ check("a removed-directory worktree is still listed as prunable before cleanup", prunableListedBefore);
3600
+ const prunableCleanup = cleanupGitWorktree(prunablePath, worktreeBaseRepo);
3601
+ const prunableStillListed = (spawnSync("git", ["worktree", "list", "--porcelain"], { cwd: worktreeBaseRepo, encoding: "utf8" }).stdout ?? "").includes(prunablePath);
3602
+ check("cleanupGitWorktree deregisters a registered worktree whose directory is missing", prunableCleanup.success && !prunableStillListed);
3603
+ const missingRogue = cleanupGitWorktree(join(storageBase, "missing-rogue"), worktreeBaseRepo);
3604
+ check("cleanupGitWorktree still refuses an unregistered missing path", !missingRogue.success);
3605
+
2977
3606
  // Snapshot integrity: when the snapshot commit cannot be established (e.g. a
2978
3607
  // failing pre-commit hook), cleanup must abort and preserve the worktree.
2979
3608
  const failingHooks = mkdtempSync(join(tmpdir(), "wg-wt-hooks-"));
@@ -3067,6 +3696,53 @@ delete process.env.GIT_INDEX_FILE;
3067
3696
  delete process.env.GIT_DIR;
3068
3697
  check("getCleanGitEnv strips git context variables", typeof getCleanGitEnv().GIT_INDEX_FILE === "undefined");
3069
3698
 
3699
+ // Partial-failure rollback: git creates the branch ref before the worktree
3700
+ // checkout, so a failure after that point must not leave the branch behind,
3701
+ // must not delete a branch that already existed, must leave a pre-existing
3702
+ // target path intact, and must report the git exit status rather than only
3703
+ // stderr progress text.
3704
+ const storageBaseForRollback = getWorktreeStorageDir(worktreeBaseRepo);
3705
+ mkdirSync(storageBaseForRollback, { recursive: true });
3706
+ const rollbackBranch = "feat/rollback-partial";
3707
+ const rollbackCollision = join(storageBaseForRollback, "feat-rollback-partial");
3708
+ writeFileSync(rollbackCollision, "path collision\n");
3709
+ const rollbackRes = createGitWorktree(rollbackBranch, "HEAD", worktreeBaseRepo);
3710
+ check(
3711
+ "partial worktree add failure reports git exit status, not only stderr progress",
3712
+ rollbackRes.success === false && /exit status/.test(String(rollbackRes.error)),
3713
+ );
3714
+ check(
3715
+ "partial worktree add failure rolls back the created branch",
3716
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${rollbackBranch}`], { cwd: worktreeBaseRepo }).status !== 0,
3717
+ );
3718
+ check("partial worktree add failure leaves a pre-existing target path intact", existsSync(rollbackCollision) && readFileSync(rollbackCollision, "utf8") === "path collision\n");
3719
+ const preservedBranch = "feat/preserve-on-fail";
3720
+ spawnSync("git", ["branch", preservedBranch], { cwd: worktreeBaseRepo });
3721
+ writeFileSync(join(storageBaseForRollback, "feat-preserve-on-fail"), "blocker\n");
3722
+ createGitWorktree(preservedBranch, "HEAD", worktreeBaseRepo);
3723
+ check(
3724
+ "a pre-existing branch is not deleted when worktree add fails",
3725
+ spawnSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${preservedBranch}`], { cwd: worktreeBaseRepo }).status === 0,
3726
+ );
3727
+ // A colliding sanitized path must never let rollback destroy a live
3728
+ // registered worktree (duplicate names, concurrent subagents, feat/x vs
3729
+ // feat:x). The failed add must leave it and its uncommitted work intact.
3730
+ const liveCollision = createGitWorktree("feat/live-collision", "HEAD", worktreeBaseRepo);
3731
+ check("live-collision setup creates a registered worktree", liveCollision.success && typeof liveCollision.worktreePath === "string");
3732
+ const liveCollisionPath = liveCollision.worktreePath!;
3733
+ writeFileSync(join(liveCollisionPath, "uncommitted-work.txt"), "do not lose me\n");
3734
+ const duplicateCreate = createGitWorktree("feat/live-collision", "HEAD", worktreeBaseRepo);
3735
+ const liveSurvived = existsSync(liveCollisionPath) && existsSync(join(liveCollisionPath, "uncommitted-work.txt"));
3736
+ const liveCleanup = cleanupGitWorktree(liveCollisionPath, worktreeBaseRepo);
3737
+ check(
3738
+ "failed duplicate create leaves a live registered worktree and its uncommitted work intact",
3739
+ duplicateCreate.success === false && liveSurvived && liveCleanup.success,
3740
+ );
3741
+ process.env.WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS = "2500";
3742
+ check("resolveWorktreeTimeoutMs honors WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS", resolveWorktreeTimeoutMs() === 2500);
3743
+ delete process.env.WORKFLOW_GUARD_WORKTREE_TIMEOUT_MS;
3744
+ check("resolveWorktreeTimeoutMs falls back to the 15s default", resolveWorktreeTimeoutMs() === 15_000);
3745
+
3070
3746
  rmSync(worktreeBaseRepo, { recursive: true, force: true });
3071
3747
  delete process.env.WORKFLOW_GUARD_WORKTREE_DIR;
3072
3748
  rmSync(worktreeStorage, { recursive: true, force: true });
@@ -3915,6 +4591,80 @@ check("project-memory initialization failure leaves core guard hooks active", ty
3915
4591
  if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME;
3916
4592
  else process.env.XDG_DATA_HOME = prevDataHome;
3917
4593
 
4594
+ // Loaded plugin version visibility: OpenCode resolves configured bare plugin
4595
+ // names through Node's parent-directory lookup from its configuration
4596
+ // location, so the loaded copy can silently differ from a globally
4597
+ // npm-installed one (observed on-device: npm i -g updates ignored while a
4598
+ // home-level pnpm node_modules copy kept loading). The version is therefore
4599
+ // surfaced in guard_status and in the startup app log.
4600
+ console.log("- Git hygiene snapshot -");
4601
+ const hygieneRepo = join(root, "wg-hygiene");
4602
+ mkdirSync(hygieneRepo, { recursive: true });
4603
+ const hgit = (args: string[]): string => {
4604
+ const res = spawnSync("git", args, { cwd: hygieneRepo, encoding: "utf8" });
4605
+ if (res.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${res.stderr}`);
4606
+ return res.stdout;
4607
+ };
4608
+ hgit(["init", "-b", "main"]);
4609
+ hgit(["config", "user.email", "test@test.local"]);
4610
+ hgit(["config", "user.name", "Test Runner"]);
4611
+ writeFileSync(join(hygieneRepo, "base.txt"), "base\n");
4612
+ hgit(["add", "base.txt"]);
4613
+ hgit(["commit", "-m", "base"]);
4614
+ hgit(["switch", "-c", "feature/done"]);
4615
+ writeFileSync(join(hygieneRepo, "done.txt"), "done\n");
4616
+ hgit(["add", "done.txt"]);
4617
+ hgit(["commit", "-m", "done work"]);
4618
+ hgit(["switch", "main"]);
4619
+ hgit(["merge", "--ff-only", "feature/done"]);
4620
+ hgit(["switch", "-c", "feature/open"]);
4621
+ writeFileSync(join(hygieneRepo, "open.txt"), "open\n");
4622
+ hgit(["add", "open.txt"]);
4623
+ hgit(["commit", "-m", "open work"]);
4624
+ // Prunable worktree: register one, then delete its directory (crashed-add
4625
+ // simulation) so the admin entry stays behind.
4626
+ hgit(["worktree", "add", join(root, "wg-hygiene-wt"), "feature/done"]);
4627
+ rmSync(join(root, "wg-hygiene-wt"), { recursive: true, force: true });
4628
+ const hygieneStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneRepo }, { sessionID: "s-hygiene", worktree: hygieneRepo, directory: hygieneRepo } as any)));
4629
+ check("guard_status exposes the git hygiene snapshot", typeof hygieneStatus.gitHygiene === "object" && hygieneStatus.gitHygiene !== null);
4630
+ check("hygiene lists merged local branches excluding the current and unmerged", Array.isArray(hygieneStatus.gitHygiene.mergedLocalBranches) && hygieneStatus.gitHygiene.mergedLocalBranches.includes("feature/done") && !hygieneStatus.gitHygiene.mergedLocalBranches.includes("feature/open") && !hygieneStatus.gitHygiene.mergedLocalBranches.includes("main"));
4631
+ check("hygiene counts prunable worktree admin entries", hygieneStatus.gitHygiene.prunableWorktreeCount === 1);
4632
+ check("hygiene omits base-behind without a remote", hygieneStatus.gitHygiene.baseBehind === undefined);
4633
+ // The list is capped while the count reflects reality.
4634
+ for (let i = 0; i < 12; i++) {
4635
+ hgit(["switch", "main"]);
4636
+ hgit(["switch", "-c", `feature/merged-${i}`]);
4637
+ writeFileSync(join(hygieneRepo, `merged-${i}.txt`), `${i}\n`);
4638
+ hgit(["add", `merged-${i}.txt`]);
4639
+ hgit(["commit", "-m", `merged ${i}`]);
4640
+ hgit(["switch", "main"]);
4641
+ hgit(["merge", "--ff-only", `feature/merged-${i}`]);
4642
+ }
4643
+ // End on a merged, non-base current branch: the current-branch filter must
4644
+ // exclude it from the merged list independently of the base filter.
4645
+ hgit(["switch", "feature/merged-11"]);
4646
+ const cappedStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneRepo }, { sessionID: "s-hygiene-capped", worktree: hygieneRepo, directory: hygieneRepo } as any)));
4647
+ check("hygiene branch list is capped, counts reality, and excludes the merged current branch", cappedStatus.gitHygiene.mergedLocalBranchCount === 12 && cappedStatus.gitHygiene.mergedLocalBranches.length === 10 && !cappedStatus.gitHygiene.mergedLocalBranches.includes("feature/merged-11") && cappedStatus.gitHygiene.mergedLocalBranches.includes("feature/merged-10"));
4648
+ rmSync(hygieneRepo, { recursive: true, force: true });
4649
+ const hygieneNonGit = mkdtempSync(join(tmpdir(), "wg-hygiene-nongit-"));
4650
+ const nonGitStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: hygieneNonGit }, { sessionID: "s-hygiene-nongit", worktree: hygieneNonGit, directory: hygieneNonGit } as any)));
4651
+ check("hygiene snapshot is null outside a git repository", nonGitStatus.gitHygiene === null);
4652
+ rmSync(hygieneNonGit, { recursive: true, force: true });
4653
+
4654
+ console.log("- Loaded plugin version visibility -");
4655
+ const expectedVersion = String((JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version);
4656
+ check("loadedPluginVersion reads the adjacent package manifest", loadedPluginVersion() === expectedVersion);
4657
+ check("loadedPluginVersion fails soft for an unreadable manifest", loadedPluginVersion(pathToFileURL(join(root, "wg-version-none", "src", "workflow-guard.ts")).href) === undefined);
4658
+ const versionStatus = JSON.parse(String(await customPlugin.tool?.guard_status?.execute({ directory: root }, { sessionID: "s-version", worktree: root, directory: root } as any)));
4659
+ check("guard_status exposes the loaded plugin version", versionStatus.pluginVersion === expectedVersion);
4660
+ const versionLogs: string[] = [];
4661
+ await WorkflowGuard({
4662
+ directory: root,
4663
+ worktree: root,
4664
+ client: { app: { log: async (opts: { body: { message: string } }) => { versionLogs.push(opts.body.message); } } } as any,
4665
+ } as any);
4666
+ check("startup app log states the loaded plugin version", versionLogs.some((message) => message.startsWith(`Workflow Guard v${expectedVersion} plugin initialized for ${root}; `) && message.includes("managed config")));
4667
+
3918
4668
  console.log("- live control-plane paths (tier port: config-path facts) -");
3919
4669
  const liveBase = mkdtempSync(join(tmpdir(), "wg-lcp-base-"));
3920
4670
  const livePlane = join(liveBase, ".config", "open" + "code");