opencode-workflow-guard 1.12.0 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/policies.md CHANGED
@@ -85,7 +85,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
85
85
  - With project `recoveryCheckpoints: true`, or the `autonomous` operation profile unless explicitly overridden, genuine root-session user runs capture a private Git checkpoint before agent work starts. Checkpoints preserve tracked and untracked state without using the user's stash list; subagents and generated continuation prompts do not replace them. At `session.idle`, the run's resulting workspace fingerprint is recorded before automatic continuation. `guard_recovery_restore` is provenance-bound to the same root session and refuses restoration after any intervening workspace change. Operation profiles only set optional workflow defaults and cannot weaken core safety policies.
86
86
  - Automatic continuation remains synthetic. By default, an eligible root session with unfinished owned todos briefly waits for OpenCode's native asynchronous title generation before that continuation is sent; set `titleSettleWorkaround: false` to opt out. The bounded wait never rewrites titles or provider/model parameters.
87
87
  - **Git State & Snapshot Binding:** Verification evidence records the current git commit hash (`git rev-parse HEAD`) and working tree status. If unverified edits or index modifications occur after verification, fresh verification is enforced.
88
- - **Durable Disk Cache:** Passing verification results are cached to `~/.local/state/opencode/workflow-guard/last-verify.json`, allowing session restarts and multi-agent handoffs to retain valid verification evidence without redundant test runs. Durable evidence is **workspace-bound**: a cached result is only accepted for the workspace that produced it, so a passing run in one project can never satisfy finalization in another.
88
+ - **Durable Disk Cache:** Passing verification results (`~/.local/state/opencode/workflow-guard/last-verify.json`) and passing secondary review approvals (`~/.local/state/opencode/workflow-guard/last-review.json`) are cached to disk, allowing session restarts and multi-agent handoffs to retain valid verification and review evidence without redundant test runs. Durable evidence is **workspace-bound**: a cached result is only accepted for the workspace that produced it, so a passing run in one project can never satisfy finalization in another.
89
89
  - If verification fails, finalization is blocked with a structured summary of error markers, stack traces, and failure output.
90
90
  - OpenCode's current `lsp.client.diagnostics` event identifies the server and path but does not expose the diagnostics themselves, so the guard deliberately does not claim an LSP-clean finalization gate until the SDK provides a supported diagnostics source.
91
91
  - The verify command is **disabled** when `WORKFLOW_GUARD_VERIFY` is empty (`""`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/lib/audit.ts CHANGED
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { Database, type SqliteDatabase } from "./sqlite.ts";
6
- import type { AuditEntry, PolicyDecision, VerifyResult } from "./types.ts";
6
+ import type { AuditEntry, PolicyDecision, VerifyResult, ReviewResult } from "./types.ts";
7
7
  import { asRecord } from "./utils.ts";
8
8
 
9
9
  const AUDIT_DIR = join(
@@ -14,6 +14,7 @@ const AUDIT_DIR = join(
14
14
  const AUDIT_FILE = join(AUDIT_DIR, "workflow-guard.jsonl");
15
15
  const VERIFY_CACHE_FILE = join(AUDIT_DIR, "last-verify.json");
16
16
  const VERIFY_HISTORY_FILE = join(AUDIT_DIR, "verify-history.jsonl");
17
+ const REVIEW_CACHE_FILE = join(AUDIT_DIR, "last-review.json");
17
18
  const MAX_AUDIT_BYTES = 4 * 1024 * 1024;
18
19
  const RETAIN_AUDIT_BYTES = 2 * 1024 * 1024;
19
20
  const MAX_VERIFY_HISTORY_BYTES = 1024 * 1024;
@@ -62,6 +63,10 @@ export function getVerifyHistoryFilePath(): string {
62
63
  return VERIFY_HISTORY_FILE;
63
64
  }
64
65
 
66
+ export function getReviewCacheFilePath(): string {
67
+ return REVIEW_CACHE_FILE;
68
+ }
69
+
65
70
  function appendBoundedJsonlUnlocked(path: string, value: unknown, maxBytes: number, retainBytes: number): void {
66
71
  appendFileSync(path, JSON.stringify(value) + "\n", { encoding: "utf8", mode: 0o600 });
67
72
  chmodSync(path, 0o600);
@@ -156,6 +161,33 @@ export function loadVerifyCache(): VerifyResult | undefined {
156
161
  return undefined;
157
162
  }
158
163
 
164
+ /**
165
+ * Persists passing secondary review approval to disk so session restarts
166
+ * or multi-agent handoffs retain valid review approval state.
167
+ */
168
+ export function persistReviewCache(reviewData: ReviewResult): void {
169
+ try {
170
+ mkdirSync(AUDIT_DIR, { recursive: true });
171
+ writeFileSync(REVIEW_CACHE_FILE, JSON.stringify(reviewData, null, 2), { encoding: "utf8", mode: 0o600 });
172
+ chmodSync(REVIEW_CACHE_FILE, 0o600);
173
+ } catch {}
174
+ }
175
+
176
+ /**
177
+ * Loads durable review approval from disk if present.
178
+ */
179
+ export function loadReviewCache(): ReviewResult | undefined {
180
+ try {
181
+ if (!existsSync(REVIEW_CACHE_FILE)) return undefined;
182
+ const raw = readFileSync(REVIEW_CACHE_FILE, "utf8");
183
+ const data = JSON.parse(raw);
184
+ if (data && typeof data.reviewer === "string" && typeof data.timestamp === "number" && typeof data.passed === "boolean") {
185
+ return data;
186
+ }
187
+ } catch {}
188
+ return undefined;
189
+ }
190
+
159
191
  /**
160
192
  * Reads recent audit entries from the end of the JSONL audit log with a bounded
161
193
  * buffer window to prevent memory spikes on long-lived installations.
@@ -29,6 +29,7 @@ import {
29
29
  isReviewRequired,
30
30
  recordMutation,
31
31
  recordReviewResult,
32
+ resolveEffectiveWorkspace,
32
33
  runWithRuntimeState,
33
34
  } from "./state.ts";
34
35
  import { getRalphOutcome } from "../policies/continuation.ts";
@@ -46,11 +47,19 @@ import { currentGitBranch, onProtectedBranch } from "../policies/git.ts";
46
47
  import { effectiveTodoOwnerSessionID, effectiveTodos, fetchParentSession, fetchParentSessionID, hasActiveTodo } from "../policies/todo.ts";
47
48
 
48
49
  export function extractReviewFollowups(summary: string): Array<{ severity: "P2" | "P3"; summary: string }> {
49
- return summary.split(/\r?\n/).map((line) => ({
50
- line: line.trim(),
51
- severity: line.match(/(?:^|\s)(P[23])(?:\b|[:])/i)?.[1]?.toUpperCase() as "P2" | "P3" | undefined,
52
- })).filter((finding): finding is { line: string; severity: "P2" | "P3" } => Boolean(finding.line && finding.severity))
53
- .map((finding) => ({ severity: finding.severity, summary: finding.line }));
50
+ return summary.split(/\r?\n/).map((line) => {
51
+ const trimmed = line.trim();
52
+ const match = trimmed.match(/(?:^|\s)(P[23])(?:\b|[:])/i);
53
+ const severity = match?.[1]?.toUpperCase() as "P2" | "P3" | undefined;
54
+ return { line: trimmed, severity };
55
+ }).filter((finding): finding is { line: string; severity: "P2" | "P3" } => {
56
+ if (!finding.line || !finding.severity) return false;
57
+ const rest = finding.line.replace(new RegExp(`^.*?(?:${finding.severity})\\s*[:\\-]?\\s*`, "i"), "").trim();
58
+ if (!rest || /^(?:none|nil|n\/?a|0|zero|no\s+(?:issues|findings|defects|items|blockers)|clean|passed|ok|none\s+identified)\b/i.test(rest)) {
59
+ return false;
60
+ }
61
+ return true;
62
+ }).map((finding) => ({ severity: finding.severity, summary: finding.line }));
54
63
  }
55
64
 
56
65
  export function buildWorkflowGuardSystemGuidance(options: {
@@ -196,9 +205,13 @@ export function createCustomTools(options: {
196
205
  }),
197
206
  } : {}),
198
207
  guard_status: tool({
199
- description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. Proactively call at session start and before completing tasks or creating PRs.", args: {},
200
- execute: async (_args, toolContext) => {
201
- const root = effectiveRoot; const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
208
+ description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. Proactively call at session start and before completing tasks or creating PRs.",
209
+ args: {
210
+ directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to active session directory or workspace root)"),
211
+ },
212
+ execute: async (args, toolContext) => {
213
+ const root = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args?.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
214
+ const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
202
215
  const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
203
216
  const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
204
217
  const reviewEvidenceRecord = lastR ? reviewEvidence(lastR) : undefined;
@@ -238,7 +251,12 @@ export function createCustomTools(options: {
238
251
  guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
239
252
  record_review: tool({
240
253
  description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric (test integrity, task completeness, cleanliness, security, platform).",
241
- args: { reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"), summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"), passed: tool.schema.boolean().describe("True if change is approved, false if changes requested") },
254
+ args: {
255
+ reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"),
256
+ summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"),
257
+ passed: tool.schema.boolean().describe("True if change is approved, false if changes requested"),
258
+ directory: tool.schema.string().optional().describe("Target repository directory being reviewed (defaults to parent session's repository or active directory)"),
259
+ },
242
260
  execute: async (args, toolContext) => {
243
261
  const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict } });
244
262
  const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
@@ -247,8 +265,9 @@ export function createCustomTools(options: {
247
265
  const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
248
266
  if (referenced.length < 3) { auditVerdict("rejected", "insufficient_rubric_axes"); return `[workflow-guard] Review rejected: summary must reference the review axes (found ${referenced.length}/5). Call guard_review_rubric to get the rubric, evaluate each axis, and include findings per axis in the summary.`; }
249
267
  if (args.passed && /(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) { auditVerdict("rejected", "approval_contains_blocker"); return "[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. Resolve all P0/P1 issues before approving or record review with passed=false."; }
250
- recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID, toolContext.worktree || toolContext.directory);
251
- if (followupStore && !secretIn(args.summary)) for (const finding of extractReviewFollowups(args.summary)) recordReviewFollowup(followupStore, { severity: finding.severity, summary: finding.summary, reviewer: args.reviewer, sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(effectiveRoot) });
268
+ const reviewWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
269
+ recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID, reviewWorkspace);
270
+ if (followupStore && !secretIn(args.summary)) for (const finding of extractReviewFollowups(args.summary)) recordReviewFollowup(followupStore, { severity: finding.severity, summary: finding.summary, reviewer: args.reviewer, sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(reviewWorkspace) });
252
271
  auditVerdict(args.passed ? "approved" : "changes_requested", args.passed ? "approved" : "changes_requested");
253
272
  return args.passed ? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.` : `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
254
273
  },
@@ -256,13 +275,18 @@ export function createCustomTools(options: {
256
275
  guard_review_followups: tool({ description: "List durable local P2/P3 review follow-ups (technical debt) for this project. Proactively check during planning or before final verification to address open findings.", args: {}, execute: async () => JSON.stringify(followupStore ? listReviewFollowups(followupStore) : [], null, 2) }),
257
276
  guard_review_followup_resolve: tool({ description: "Resolve a durable local review follow-up after the underlying issue has been fixed and verified.", args: { id: tool.schema.string() }, execute: async (args) => followupStore && resolveReviewFollowup(followupStore, args.id) ? `[workflow-guard] Review follow-up ${args.id} resolved.` : `[workflow-guard] Review follow-up ${args.id} was not open or was not found.` }),
258
277
  guard_review_rubric: tool({
259
- description: "Get the secondary-review rubric for the current branch diff across 5 core review axes (test integrity, task completeness, cleanliness, security, platform). The orchestrator should call this before finalizing work or creating a PR, then spawn a reviewer subagent with the rubric as the prompt, which records its verdict via record_review.", args: { base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)") },
260
- execute: async (args) => {
278
+ description: "Get the secondary-review rubric for the current branch diff across 5 core review axes (test integrity, task completeness, cleanliness, security, platform). The orchestrator should call this before finalizing work or creating a PR, then spawn a reviewer subagent with the rubric as the prompt, which records its verdict via record_review.",
279
+ args: {
280
+ base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)"),
281
+ directory: tool.schema.string().optional().describe("Target repository directory (defaults to active session directory or workspace root)"),
282
+ },
283
+ execute: async (args, toolContext) => {
261
284
  const sanitizedBase = typeof args.base === "string" && !args.base.startsWith("-") && !/[\s;'"\0]/.test(args.base) ? args.base : undefined;
262
285
  const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
286
+ const rubricWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, directory: args?.directory, fallback: toolContext?.worktree || toolContext?.directory || effectiveRoot });
263
287
  let diffText = "";
264
- for (const base of bases) { const res = spawnSync("git", ["diff", "--", `${base}...HEAD`], { cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 }); if (res.status === 0 && res.stdout.trim()) { diffText = res.stdout; break; } }
265
- if (!diffText) { const last = spawnSync("git", ["diff", "--", "HEAD~1"], { cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 }); diffText = last.status === 0 ? last.stdout : "(no diff available)"; }
288
+ for (const base of bases) { const res = spawnSync("git", ["diff", "--", `${base}...HEAD`], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); if (res.status === 0 && res.stdout.trim()) { diffText = res.stdout; break; } }
289
+ if (!diffText) { const last = spawnSync("git", ["diff", "--", "HEAD~1"], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); diffText = last.status === 0 ? last.stdout : "(no diff available)"; }
266
290
  return buildReviewRubric(diffText);
267
291
  },
268
292
  }),
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ToolOutcome } from "./tool-outcomes.ts";
3
3
  import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
4
+ import { isSameGitRepo } from "./project-config.ts";
4
5
 
5
6
  function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
6
7
  return createHash("sha256").update(JSON.stringify({ kind, observedAt, subject, source })).digest("hex");
@@ -40,7 +41,11 @@ export function agentAssertionEvidence(assertion: string, observedAt: number, su
40
41
  }
41
42
 
42
43
  export function evidenceMatchesSubject(evidence: EvidenceRecord, subject: EvidenceSubject): boolean {
43
- return evidence.subject.workspace === subject.workspace &&
44
+ const workspaceMatches =
45
+ evidence.subject.workspace === subject.workspace ||
46
+ (Boolean(evidence.subject.workspace && subject.workspace) &&
47
+ isSameGitRepo(evidence.subject.workspace, subject.workspace));
48
+ return workspaceMatches &&
44
49
  evidence.subject.commitHash === subject.commitHash &&
45
50
  evidence.subject.worktreeFingerprint === subject.worktreeFingerprint &&
46
51
  evidence.subject.sessionID === subject.sessionID;
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { loadVerifyCache } from "./audit.ts";
3
+ import { loadReviewCache, loadVerifyCache } from "./audit.ts";
4
4
  import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
5
- import { projectRootKey } from "./project-config.ts";
5
+ import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
6
6
  import {
7
7
  getLastReviewResult,
8
8
  getSdkClient,
@@ -435,7 +435,13 @@ export async function guardToolCallImpl(
435
435
  if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
436
436
  }
437
437
  if (isReviewRequired(prRoot)) {
438
- const review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
438
+ let review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
439
+ if (!review) {
440
+ const diskCached = loadReviewCache();
441
+ if (diskCached && diskCached.passed && diskCached.workspace && (projectRootKey(diskCached.workspace) === projectRootKey(prRoot) || isSameGitRepo(diskCached.workspace, prRoot))) {
442
+ review = diskCached;
443
+ }
444
+ }
439
445
  const reviewMatchesContext = review?.passed === true && isEvidenceFresh(reviewEvidence(review), { workspace: projectRootKey(prRoot), commitHash: getCurrentGitCommitHash(prRoot), worktreeFingerprint: getGitWorktreeFingerprint(prRoot), sessionID: review.targetSessionID }, 0) && (!review.targetSessionID || review.targetSessionID === context?.sessionID);
440
446
  if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
441
447
  }
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync, realpathSync } from "node:fs";
2
- import { join, resolve } from "node:path";
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, resolve, dirname } from "node:path";
3
+ import { spawnSync } from "node:child_process";
3
4
  import type { ProjectConfig } from "./types.ts";
4
5
 
5
6
  const projectConfigCache = new Map<string, ProjectConfig>();
@@ -12,6 +13,55 @@ export function projectRootKey(root: string): string {
12
13
  }
13
14
  }
14
15
 
16
+ export function findGitRoot(startPath?: string): string | undefined {
17
+ if (!startPath || typeof startPath !== "string") return undefined;
18
+ try {
19
+ const resolved = resolve(startPath);
20
+ let dir = resolved;
21
+ try {
22
+ const stat = statSync(resolved);
23
+ dir = stat.isDirectory() ? resolved : dirname(resolved);
24
+ } catch {
25
+ dir = dirname(resolved);
26
+ }
27
+ const res = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
28
+ encoding: "utf8",
29
+ timeout: 5_000,
30
+ });
31
+ if (res.status === 0 && res.stdout.trim()) {
32
+ return projectRootKey(res.stdout.trim());
33
+ }
34
+ } catch {}
35
+ return undefined;
36
+ }
37
+
38
+ export function getGitCommonDir(root: string): string | undefined {
39
+ try {
40
+ const res = spawnSync("git", ["-C", root, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
41
+ encoding: "utf8",
42
+ timeout: 5_000,
43
+ });
44
+ if (res.status === 0 && res.stdout.trim()) {
45
+ return projectRootKey(res.stdout.trim());
46
+ }
47
+ } catch {}
48
+ return undefined;
49
+ }
50
+
51
+ export function isSameGitRepo(pathA?: string, pathB?: string): boolean {
52
+ if (!pathA || !pathB) return false;
53
+ const keyA = projectRootKey(pathA);
54
+ const keyB = projectRootKey(pathB);
55
+ if (keyA === keyB) return true;
56
+ const commonA = getGitCommonDir(keyA);
57
+ const commonB = getGitCommonDir(keyB);
58
+ if (commonA && commonB && commonA === commonB) return true;
59
+ const rootA = findGitRoot(keyA);
60
+ const rootB = findGitRoot(keyB);
61
+ if (rootA && rootB && rootA === rootB) return true;
62
+ return false;
63
+ }
64
+
15
65
  export function projectConfigCandidates(root: string): string[] {
16
66
  return [
17
67
  join(root, ".opencode", "workflow-guard.json"),
package/src/lib/state.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { realpathSync } from "node:fs";
1
+ import { realpathSync, existsSync, unlinkSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
- import { persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
- import { getCachedProjectConfig, loadProjectConfig, projectRootKey } from "./project-config.ts";
4
+ import { getReviewCacheFilePath, loadReviewCache, persistReviewCache, persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
+ import { findGitRoot, getCachedProjectConfig, isSameGitRepo, loadProjectConfig, projectRootKey } from "./project-config.ts";
6
6
  import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
7
7
  import type {
8
8
  TodoSdkClient,
@@ -74,6 +74,18 @@ const workspaceMutationTimestamps = new Map<string, number>();
74
74
  const workspaceMutationCounts = new Map<string, number>();
75
75
  export const sessionMutationTimestamps = new Map<string, number>();
76
76
  export const sessionMutationCounts = new Map<string, number>();
77
+ export const sessionWorkspaces = new Map<string, string>();
78
+
79
+ export function setSessionWorkspace(sessionID: string, workspace: string): void {
80
+ if (!sessionID || !workspace) return;
81
+ const gitRoot = findGitRoot(workspace);
82
+ sessionWorkspaces.set(sessionID, gitRoot ?? projectRootKey(workspace));
83
+ }
84
+
85
+ export function getSessionWorkspace(sessionID?: string): string | undefined {
86
+ if (!sessionID) return undefined;
87
+ return sessionWorkspaces.get(sessionID);
88
+ }
77
89
 
78
90
  export let lastVerify: VerifyResult | undefined;
79
91
  export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>();
@@ -95,6 +107,10 @@ export function recordMutation(sessionID?: string, actorSessionID?: string): voi
95
107
  }
96
108
  if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID || lastReview.targetSessionID === actorSessionID) {
97
109
  lastReview = undefined;
110
+ try {
111
+ const p = getReviewCacheFilePath();
112
+ if (existsSync(p)) unlinkSync(p);
113
+ } catch {}
98
114
  }
99
115
  }
100
116
 
@@ -192,23 +208,95 @@ export function recordReviewResult(
192
208
  worktreeFingerprint: getGitWorktreeFingerprint(root),
193
209
  };
194
210
  if (targetSessionID) sessionReviews.set(targetSessionID, lastReview);
211
+ if (passed) {
212
+ persistReviewCache(lastReview);
213
+ }
195
214
  }
196
215
 
197
216
  export function getLastReviewResult(): typeof lastReview {
198
- return lastReview;
217
+ if (lastReview) return lastReview;
218
+ const diskCached = loadReviewCache();
219
+ if (diskCached) {
220
+ lastReview = diskCached;
221
+ return lastReview;
222
+ }
223
+ return undefined;
199
224
  }
200
225
 
201
226
  export function getLastReviewResultForWorkspace(root: string): typeof lastReview {
202
227
  const workspace = projectRootKey(root);
203
228
  const candidates = [lastReview, ...sessionReviews.values()].filter(
204
- (result): result is ReviewResult => result?.workspace != null && projectRootKey(result.workspace) === workspace,
229
+ (result): result is ReviewResult => result?.workspace != null && (projectRootKey(result.workspace) === workspace || isSameGitRepo(result.workspace, root)),
205
230
  );
206
- return candidates.reduce<ReviewResult | undefined>((latest, result) => !latest || result.timestamp > latest.timestamp ? result : latest, undefined);
231
+ const latest = candidates.reduce<ReviewResult | undefined>((best, result) => !best || result.timestamp > best.timestamp ? result : best, undefined);
232
+ if (latest) return latest;
233
+ const diskCached = loadReviewCache();
234
+ if (diskCached?.workspace && (projectRootKey(diskCached.workspace) === workspace || isSameGitRepo(diskCached.workspace, root))) {
235
+ return diskCached;
236
+ }
237
+ return undefined;
207
238
  }
208
239
 
209
240
  export function resetReviewState(): void {
210
241
  lastReview = undefined;
211
242
  sessionReviews.clear();
243
+ try {
244
+ const p = getReviewCacheFilePath();
245
+ if (existsSync(p)) unlinkSync(p);
246
+ } catch {}
247
+ }
248
+
249
+ export async function resolveEffectiveWorkspace(options: {
250
+ sessionID?: string;
251
+ directory?: string;
252
+ fallback?: string;
253
+ }): Promise<string> {
254
+ if (options.directory) {
255
+ const gitRoot = findGitRoot(options.directory);
256
+ return gitRoot ?? projectRootKey(options.directory);
257
+ }
258
+ const fallbackRoot = options.fallback ? projectRootKey(options.fallback) : undefined;
259
+ const isFallbackFsRoot = fallbackRoot ? resolve(fallbackRoot) === resolve(fallbackRoot, "..") : true;
260
+
261
+ if (fallbackRoot && !isFallbackFsRoot) {
262
+ if (options.sessionID) {
263
+ const sessionWs = sessionWorkspaces.get(options.sessionID);
264
+ if (sessionWs && isSameGitRepo(sessionWs, fallbackRoot)) {
265
+ return sessionWs;
266
+ }
267
+ }
268
+ return fallbackRoot;
269
+ }
270
+
271
+ if (options.sessionID) {
272
+ const sessionWs = sessionWorkspaces.get(options.sessionID);
273
+ if (sessionWs) return sessionWs;
274
+ try {
275
+ const client = getSdkClient();
276
+ const session = client?.session;
277
+ const get = session?.get;
278
+ if (typeof get === "function") {
279
+ const result = await get.call(session, { path: { id: options.sessionID } });
280
+ const parent = (result as { data?: { parentID?: unknown } } | undefined)?.data?.parentID;
281
+ if (typeof parent === "string" && parent && sessionWorkspaces.has(parent)) {
282
+ const parentWs = sessionWorkspaces.get(parent)!;
283
+ sessionWorkspaces.set(options.sessionID, parentWs);
284
+ return parentWs;
285
+ }
286
+ }
287
+ } catch {}
288
+ }
289
+ const activeRoot = getWorkspaceRoot();
290
+ if (activeRoot && resolve(activeRoot) !== resolve(activeRoot, "..")) {
291
+ const gitRoot = findGitRoot(activeRoot);
292
+ if (gitRoot) return gitRoot;
293
+ return activeRoot;
294
+ }
295
+ try {
296
+ const cwdGitRoot = findGitRoot(process.cwd());
297
+ if (cwdGitRoot) return cwdGitRoot;
298
+ } catch {}
299
+ return fallbackRoot || activeRoot || getWorkspaceRoot();
212
300
  }
213
301
 
214
302
  export function getProjectConfig(root: string): ProjectConfig {
@@ -73,7 +73,7 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
73
73
  const session = getSdkClient()?.session;
74
74
  if (typeof session?.promptAsync !== "function") return false;
75
75
 
76
- const messageID = `workflow-guard-${randomUUID()}`;
76
+ const messageID = `msg_wg_${randomUUID().replace(/-/g, "")}`;
77
77
  state.counts.set(sessionID, count + 1);
78
78
  if (ralph) state.ralphOutcomes.set(sessionID, "running");
79
79
  const generatedIDs = state.generatedMessageIDs.get(sessionID) ?? new Set<string>();
@@ -74,10 +74,14 @@ import {
74
74
  getLearningInterventionBudget,
75
75
  recordReviewResult,
76
76
  getLastReviewResult,
77
+ getLastReviewResultForWorkspace,
77
78
  resetReviewState,
78
79
  sessionReviews,
80
+ setSessionWorkspace,
81
+ getSessionWorkspace,
82
+ resolveEffectiveWorkspace,
79
83
  } from "./lib/state.ts";
80
- import { loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
84
+ import { findGitRoot, isSameGitRepo, loadProjectConfig, reloadProjectConfig, stripJsonComments } from "./lib/project-config.ts";
81
85
 
82
86
  export {
83
87
  setWorkspaceRoot,
@@ -101,11 +105,18 @@ export {
101
105
  getLearningInterventionBudget,
102
106
  recordReviewResult,
103
107
  getLastReviewResult,
108
+ getLastReviewResultForWorkspace,
104
109
  resetReviewState,
110
+ setSessionWorkspace,
111
+ getSessionWorkspace,
112
+ resolveEffectiveWorkspace,
113
+ findGitRoot,
114
+ isSameGitRepo,
105
115
  };
106
116
 
107
117
  // ── Shared shell/env utilities ───────────────────────────────────────────────
108
118
  import {
119
+ asRecord,
109
120
  extractRecordTargetPath,
110
121
  getCleanEnv,
111
122
  showBlockToast,
@@ -149,14 +160,17 @@ import { ensureProjectMemoryExcluded, getProjectMemoryIdentity, getRecentProject
149
160
  import {
150
161
  getAuditFilePath,
151
162
  getVerifyCacheFilePath,
163
+ getReviewCacheFilePath,
152
164
  persistVerifyCache,
153
165
  loadVerifyCache,
166
+ persistReviewCache,
167
+ loadReviewCache,
154
168
  audit,
155
169
  logDecision,
156
170
  summarizeInput,
157
171
  } from "./lib/audit.ts";
158
172
 
159
- export { getAuditFilePath, getVerifyCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
173
+ export { getAuditFilePath, getVerifyCacheFilePath, getReviewCacheFilePath, getVerifyHistoryFilePath, persistVerifyCache, loadVerifyCache, persistReviewCache, loadReviewCache, getRecentAuditEntries, getRecentVerifyHistory } from "./lib/audit.ts";
160
174
  export { summarizeInput };
161
175
 
162
176
  export function managedConfigDiagnostic(platform = process.platform, env: NodeJS.ProcessEnv = process.env): string {
@@ -369,9 +383,16 @@ export const WorkflowGuard: Plugin = async (ctx) => {
369
383
  // reports the filesystem root, use the SDK's actual project worktree instead.
370
384
  const hostRoot = ctx.worktree || ctx.directory || process.cwd();
371
385
  const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
372
- const effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree
386
+ let effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree && resolve(ctx.project.worktree) !== resolve(ctx.project.worktree, "..")
373
387
  ? ctx.project.worktree
374
388
  : hostRoot;
389
+ if (resolve(effectiveRoot) === resolve(effectiveRoot, "..")) {
390
+ const nonRootCandidate = [ctx.directory, process.cwd()].find((p) => typeof p === "string" && resolve(p) !== resolve(p, ".."));
391
+ if (nonRootCandidate) {
392
+ const gitRoot = findGitRoot(nonRootCandidate);
393
+ effectiveRoot = gitRoot ?? nonRootCandidate;
394
+ }
395
+ }
375
396
  setWorkspaceRoot(effectiveRoot);
376
397
  setSdkClient(ctx.client);
377
398
  reloadProjectConfig(effectiveRoot);
@@ -439,6 +460,16 @@ export const WorkflowGuard: Plugin = async (ctx) => {
439
460
  throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
440
461
  }
441
462
  toolLifecycle.start(input.sessionID, input.callID);
463
+ if (input.sessionID) {
464
+ const record = asRecord(args);
465
+ const workdir = typeof record?.workdir === "string" ? record.workdir : undefined;
466
+ const filePath = typeof record?.filePath === "string" ? record.filePath : (typeof record?.path === "string" ? record.path : undefined);
467
+ const candidatePath = workdir ?? filePath ?? toolWorktree;
468
+ if (candidatePath) {
469
+ const gitRoot = findGitRoot(candidatePath);
470
+ if (gitRoot) setSessionWorkspace(input.sessionID, gitRoot);
471
+ }
472
+ }
442
473
  if (input.tool === "read") {
443
474
  const target = editTargets(args, toolWorktree)[0];
444
475
  if (target) {
package/test/test.mts CHANGED
@@ -36,6 +36,14 @@ import {
36
36
  getRecentAuditEntries,
37
37
  getRecentVerifyHistory,
38
38
  getVerifyHistoryFilePath,
39
+ getReviewCacheFilePath,
40
+ loadReviewCache,
41
+ persistReviewCache,
42
+ setSessionWorkspace,
43
+ getSessionWorkspace,
44
+ resolveEffectiveWorkspace,
45
+ getLastReviewResultForWorkspace,
46
+ isSameGitRepo,
39
47
  managedConfigDiagnostic,
40
48
  summarizeInput,
41
49
  extractReviewFollowups,
@@ -2100,6 +2108,96 @@ const mainReviewToolResult = await customPlugin.tool?.record_review?.execute(
2100
2108
  );
2101
2109
  check("record_review rejects self-approval from main session", typeof mainReviewToolResult === "string" && mainReviewToolResult.includes("rejected"));
2102
2110
 
2111
+ // Secondary Review Approval Handoff & Durable Cache Tests
2112
+ console.log("- Secondary Review Approval Handoff & Durability -");
2113
+
2114
+ // 1. Negative review statements do not create bogus technical debt items
2115
+ const negativeFollowups = extractReviewFollowups(
2116
+ "Test integrity: ok. Task completeness: ok. Cleanliness: ok. Security: ok. Platform: ok.\n" +
2117
+ "P2: None\n" +
2118
+ "P3: 0\n" +
2119
+ "P2: N/A\n" +
2120
+ "P3: No issues identified\n" +
2121
+ "P2: 0 defects\n" +
2122
+ "P3: ok\n" +
2123
+ "P2: clean\n" +
2124
+ "P2: memory leak in worker pool"
2125
+ );
2126
+ check("extractReviewFollowups filters out negative statements and retains real issues", negativeFollowups.length === 1 && negativeFollowups[0]?.summary.includes("memory leak in worker pool"));
2127
+
2128
+ // 2. Secondary review approval handoff from subagent to parent session
2129
+ const handoffRepo = join(root, "wg-handoff-repo");
2130
+ mkdirSync(handoffRepo, { recursive: true });
2131
+ spawnSync("git", ["init", "-b", "feature/handoff"], { cwd: handoffRepo });
2132
+ spawnSync("git", ["config", "user.email", "test@test.local"], { cwd: handoffRepo });
2133
+ spawnSync("git", ["config", "user.name", "Test Runner"], { cwd: handoffRepo });
2134
+ writeFileSync(join(handoffRepo, "code.txt"), "initial\n");
2135
+ spawnSync("git", ["add", "code.txt"], { cwd: handoffRepo });
2136
+ spawnSync("git", ["commit", "-m", "initial"], { cwd: handoffRepo });
2137
+ mkdirSync(join(handoffRepo, ".opencode"), { recursive: true });
2138
+ writeFileSync(join(handoffRepo, ".opencode", "workflow-guard.json"), JSON.stringify({ requireReview: true }));
2139
+
2140
+ // Simulate host root being the filesystem root ("/") while session operates in handoffRepo
2141
+ const handoffPlugin = await WorkflowGuard({
2142
+ directory: root,
2143
+ worktree: "/",
2144
+ client: fakeClient as any,
2145
+ project: {} as any,
2146
+ experimental_workspace: {} as any,
2147
+ serverUrl: new URL("http://localhost:4096"),
2148
+ $: undefined as any,
2149
+ });
2150
+
2151
+ // Parent session sets active workspace in handoffRepo
2152
+ setSessionWorkspace("s-handoff-parent", handoffRepo);
2153
+ fakeParents.set("s-handoff-reviewer", "s-handoff-parent");
2154
+
2155
+ // Before review: guard_status on parent reports review is unsatisfied
2156
+ const statusBeforeReview = JSON.parse(String(await handoffPlugin.tool?.guard_status?.execute({}, { sessionID: "s-handoff-parent", worktree: "/", directory: root } as any)));
2157
+ check("before review: guard_status reports review in outstandingRequirements", statusBeforeReview.outstandingRequirements.includes("review") && (statusBeforeReview.lastReview === null || statusBeforeReview.lastReview.fresh === false));
2158
+
2159
+ // Before review: PR creation is blocked by missing review
2160
+ const prBeforeReview = JSON.parse(String(await handoffPlugin.tool?.guard_why?.execute({ tool: "bash", input: { command: "gh pr create --title test --body 'Summary\n\nRelease notes'", workdir: handoffRepo } }, { sessionID: "s-handoff-parent", worktree: "/", directory: root } as any)));
2161
+ check("before review: PR creation blocked by review requirement", prBeforeReview.status === "blocked" && prBeforeReview.message.includes("Passing secondary review approval is required"));
2162
+
2163
+ // Subagent calls record_review with passed: true (without explicit directory argument)
2164
+ const subagentReviewResult = await handoffPlugin.tool?.record_review?.execute(
2165
+ {
2166
+ reviewer: "secondary-reviewer",
2167
+ summary: "Test integrity: all tests pass. Task completeness: complete. Cleanliness: clean diff. Security: zero secrets. Platform: standard.",
2168
+ passed: true,
2169
+ },
2170
+ { sessionID: "s-handoff-reviewer", agent: "reviewer", worktree: "/", directory: root } as any,
2171
+ );
2172
+ check("subagent records review approval successfully", typeof subagentReviewResult === "string" && subagentReviewResult.includes("APPROVED"));
2173
+
2174
+ // Parent checks guard_status: handoff succeeds!
2175
+ const statusAfterReview = JSON.parse(String(await handoffPlugin.tool?.guard_status?.execute({}, { sessionID: "s-handoff-parent", worktree: "/", directory: root } as any)));
2176
+ check("secondary review handoff: lastReview.fresh is true in parent session", statusAfterReview.lastReview?.fresh === true && statusAfterReview.lastReview?.passed === true);
2177
+ check("secondary review handoff: review removed from outstandingRequirements", !statusAfterReview.outstandingRequirements.includes("review"));
2178
+
2179
+ // Parent checks PR creation via guard_why: handoff allows PR creation!
2180
+ const prAfterReview = JSON.parse(String(await handoffPlugin.tool?.guard_why?.execute({ tool: "bash", input: { command: "gh pr create --title test --body 'Summary\n\nRelease notes'", workdir: handoffRepo } }, { sessionID: "s-handoff-parent", worktree: "/", directory: root } as any)));
2181
+ check("secondary review handoff: PR creation allowed after review approval", prAfterReview.status !== "blocked" && (prAfterReview.status === "allowed" || prAfterReview.code === "remote_state_unchecked"));
2182
+
2183
+ // 3. Durable disk cache persistence & recovery
2184
+ check("durable review cache file created on disk", existsSync(getReviewCacheFilePath()));
2185
+ const loadedDiskReview = loadReviewCache();
2186
+ check("loadReviewCache returns valid passing review", loadedDiskReview?.passed === true && loadedDiskReview?.reviewer === "secondary-reviewer");
2187
+
2188
+ // Simulate new session / restart: clear in-memory sessionReviews and lastReview
2189
+ const memoryClearedStatus = JSON.parse(String(await handoffPlugin.tool?.guard_status?.execute({ directory: handoffRepo }, { sessionID: "s-fresh-session", worktree: "/", directory: root } as any)));
2190
+ check("fresh session recovers durable review cache from disk", memoryClearedStatus.lastReview?.fresh === true && !memoryClearedStatus.outstandingRequirements.includes("review"));
2191
+
2192
+ // 4. Worktree compatibility: review recorded in linked worktree is recognized by main repo
2193
+ todo("s-handoff-parent", item("worktree test", "in_progress"));
2194
+ const handoffWorktreeRes = (await handoffPlugin.tool?.guard_worktree_create?.execute({ branch: "feat/handoff-worktree" }, { sessionID: "s-handoff-parent", worktree: handoffRepo, directory: handoffRepo } as any)) as string;
2195
+ check("worktree created for handoff test", typeof handoffWorktreeRes === "string" && handoffWorktreeRes.includes("Worktree created"));
2196
+ check("worktree and main repo recognized as same git repository", isSameGitRepo(join(getWorktreeStorageDir(handoffRepo), "feat-handoff-worktree"), handoffRepo));
2197
+
2198
+ rmSync(handoffRepo, { recursive: true, force: true });
2199
+ resetReviewState();
2200
+
2103
2201
  // Event hook handles permission events
2104
2202
  if (typeof customPlugin.event === "function") {
2105
2203
  await customPlugin.event({
@@ -2783,6 +2881,7 @@ writeFileSync(join(checkpointHookDir, "tracked.txt"), "agent result\n");
2783
2881
  await checkpointHooks.event?.({ event: { type: "session.idle", properties: { sessionID: "checkpoint-root" } } } as any);
2784
2882
  const generatedCheckpointMessage = checkpointHookPrompts.find((entry) => entry.sessionID === "checkpoint-root")?.messageID;
2785
2883
  check("idle finalizes recovery checkpoint before continuation", Boolean(listRecoveryCheckpoints(checkpointHookDir, "checkpoint-root")[0]?.endFingerprint && generatedCheckpointMessage));
2884
+ check("synthetic continuation messageID conforms to OpenCode msg_ prefix schema", typeof generatedCheckpointMessage === "string" && generatedCheckpointMessage.startsWith("msg_"));
2786
2885
  await checkpointHooks["chat.message"]?.({ sessionID: "checkpoint-root", messageID: generatedCheckpointMessage } as any, {} as any);
2787
2886
  check("synthetic continuation does not replace recovery checkpoint", listRecoveryCheckpoints(checkpointHookDir, "checkpoint-root").length === 1);
2788
2887
  checkpointHookParents.set("checkpoint-child", "checkpoint-root");