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 +1 -1
- package/package.json +1 -1
- package/src/lib/audit.ts +33 -1
- package/src/lib/custom-tools.ts +39 -15
- package/src/lib/evidence.ts +6 -1
- package/src/lib/guard-dispatcher.ts +9 -3
- package/src/lib/project-config.ts +52 -2
- package/src/lib/state.ts +94 -6
- package/src/policies/continuation.ts +1 -1
- package/src/workflow-guard.ts +34 -3
- package/test/test.mts +99 -0
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
|
|
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
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.
|
package/src/lib/custom-tools.ts
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.",
|
|
200
|
-
|
|
201
|
-
|
|
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: {
|
|
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
|
-
|
|
251
|
-
|
|
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.",
|
|
260
|
-
|
|
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:
|
|
265
|
-
if (!diffText) { const last = spawnSync("git", ["diff", "--", "HEAD~1"], { cwd:
|
|
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
|
}),
|
package/src/lib/evidence.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = `
|
|
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>();
|
package/src/workflow-guard.ts
CHANGED
|
@@ -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
|
-
|
|
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");
|