opencode-workflow-guard 1.11.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 (`""`).
@@ -108,11 +108,13 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
108
108
 
109
109
  ### 15. Compaction State & Policy Context Preservation
110
110
  - Integrates with OpenCode's `experimental.session.compacting` hook to inject bounded active policy state into `output.context` before context summarization:
111
+ - Caps injected continuity context at 8,000 characters, preserving operational guard state first and filling the remaining budget with active tasks, review follow-ups, and fresh local project memory.
111
112
  - Active `todowrite` tasks with status badges and subagent hierarchy attribution.
112
113
  - Active Git branch name and protected branch status.
113
114
  - Test verification status (passed/failed, test command, and commit hash).
114
115
  - Secondary review verdicts (reviewer name and approval status).
115
116
  - Uncommitted mutation counts.
117
+ - Compacted project-memory context points back to `project_memory_search` for deeper historical retrieval instead of attempting to embed the full durable store.
116
118
  - Preserves policy-relevant context across session compactions without assigning, prioritizing, or sequencing tasks for the model.
117
119
 
118
120
  ### 16. TUI Visual Feedback & Static Status Badge
@@ -155,8 +157,13 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
155
157
  - Mismatches (failing verification, stale evidence due to mutations after the verify run, or no evidence at all) are journaled to the audit trail and logged at info level. This is **observability, not gating**: the response is never blocked, but a confident wrap-up cannot silently contradict a failing or absent verification state.
156
158
  - Uses the `experimental.text.complete` hook; claim detection is a conservative phrase heuristic to avoid false positives on casual wording.
157
159
 
158
- ### Tool Description Honesty
159
- - Via the `tool.definition` hook, the guard enriches the `todowrite` tool's description with its replacement-list lifecycle and finalization gates (including verification evidence required after the last mutation), so the model is not surprised by preventable lifecycle blocks. Other tools are untouched, and the enrichment is idempotent.
160
+ ### Tool Description Honesty & Cross-Model Adherence
161
+ - Via the `tool.definition` hook, the guard enriches tool descriptions to keep them honest and proactive across all model families (OpenAI, Gemini, Claude, and open-weights models):
162
+ - `todowrite`: Explains its replacement-list lifecycle, finalization verification gate, and directs models to `guard_next_tasks` for roadmap discovery and `guard_status` for gate checks.
163
+ - Mutating tools (`edit`, `write`, `apply_patch`): States preconditions (active todo, feature branch, prior read).
164
+ - `task`: Guides orchestrator agents to isolate parallel mutations with `guard_worktree_create` and conduct secondary reviews via `guard_review_rubric` and `record_review`.
165
+ - Via the `experimental.chat.system.transform` hook, the guard provides concise operational guidelines in the system prompt detailing available guard tools (`guard_next_tasks`, `guard_status`, `guard_review_rubric`, `guard_worktree_create`, `project_memory_*`, `learning_*`, `guard_why`), heavily encouraging open models and Gemini/Claude to proactively use workflow discipline features throughout development.
166
+ - Companion tools feature proactive action triggers ("Proactively call...", "Recommended at session start..."), and `guard_status` returns actionable `recommendedActions` directing models to the exact next step.
160
167
 
161
168
  ---
162
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.11.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,64 @@ 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 }));
63
+ }
64
+
65
+ export function buildWorkflowGuardSystemGuidance(options: {
66
+ projectMemoryEnabled?: boolean;
67
+ learningEnabled?: boolean;
68
+ recoveryCheckpointsEnabled?: boolean;
69
+ isReadOnly?: boolean;
70
+ }): string {
71
+ const sections: string[] = [
72
+ "## Workflow Guard & Operational Tools",
73
+ "Workflow Guard enforces safe engineering discipline and provides proactive workflow tools:",
74
+ "- Task Planning: Proactively call `guard_next_tasks` at session start or when planning work to discover repository roadmap, plan, and TODO items. Check `guard_status` to inspect branch protection, mutation count, and outstanding verification/review gates.",
75
+ ];
76
+
77
+ if (!options.isReadOnly) {
78
+ sections.push(
79
+ "- Review & Verification: Before completing tasks or creating PRs, run tests to produce fresh verification evidence, retrieve the review rubric via `guard_review_rubric` (5 core axes: test integrity, task completeness, cleanliness, security, platform), and spawn a reviewer subagent via `task` that records its verdict with `record_review`.",
80
+ "- Worktree Isolation: When orchestrating parallel subagent tasks with `task`, call `guard_worktree_create` to prevent concurrent file conflicts, and `guard_worktree_cleanup` when finished.",
81
+ );
82
+ }
83
+
84
+ sections.push(
85
+ "- Technical Debt: Inspect open P2/P3 review follow-ups with `guard_review_followups` during planning; resolve fixed items with `guard_review_followup_resolve`.",
86
+ "- Policy Simulation: If uncertain whether a command or file edit is permitted, simulate it with `guard_why`.",
87
+ );
88
+
89
+ if (options.projectMemoryEnabled) {
90
+ sections.push(
91
+ "- Project Memory: Proactively call `project_memory_search` before architectural changes or complex implementations. Record durable lessons, architectural decisions, and repo constraints with `project_memory_record`.",
92
+ );
93
+ }
94
+
95
+ if (options.learningEnabled) {
96
+ sections.push(
97
+ "- Socratic Learning: When facing non-trivial architectural trade-offs, design choices, or debugging breakthroughs, call `learning_checkpoint`. If selected (intervene: true), ask one concise question via `question` before proceeding, and capture observed understanding with `learning_record`.",
98
+ );
99
+ }
100
+
101
+ if (options.recoveryCheckpointsEnabled) {
102
+ sections.push(
103
+ "- Recovery: If workspace state needs rollback to the pre-run checkpoint, use `guard_recovery_restore`.",
104
+ );
105
+ }
106
+
107
+ return sections.join("\n");
54
108
  }
55
109
 
56
110
  export function createCustomTools(options: {
@@ -80,7 +134,7 @@ export function createCustomTools(options: {
80
134
  }),
81
135
  } : {}),
82
136
  guard_next_tasks: tool({
83
- description: "Load durable repository task context when deciding what to work on next. Prefers TODO.md; if absent, discovers conventional roadmap, plan, tasks, backlog, and docs/plans Markdown files.",
137
+ description: "Load durable repository task context when deciding what to work on next or planning upcoming steps. Proactively call at session start or during planning to discover roadmap, plan, tasks, backlog, and TODO.md files.",
84
138
  args: {},
85
139
  execute: async () => {
86
140
  const sources = discoverPlanningSources(effectiveRoot);
@@ -89,12 +143,12 @@ export function createCustomTools(options: {
89
143
  }),
90
144
  ...(projectMemoryEnabled ? {
91
145
  project_memory_search: tool({
92
- description: "Search current durable knowledge for this project. Returns concise typed records with provenance; superseded records are excluded.",
146
+ description: "Search current durable knowledge for this project, including past architectural decisions, constraints, and conventions. Proactively call before starting complex architectural changes, refactors, or new feature implementations.",
93
147
  args: { query: tool.schema.string() },
94
148
  execute: async (args) => projectMemory ? JSON.stringify(args.query.length <= 500 ? searchProjectMemory(projectMemory, args.query, 8) : [], null, 2) : "[workflow-guard] Project memory unavailable; core guard enforcement remains active.",
95
149
  }),
96
150
  project_memory_record: tool({
97
- description: "Record a durable project fact, decision, constraint, or lesson when it will matter in future sessions. Do not record transient tool output, secrets, or speculative hypotheses.",
151
+ description: "Record a durable project fact, decision, constraint, or lesson when it will matter in future sessions. Proactively call after making important architectural choices, discovering tricky repo constraints, or establishing new patterns.",
98
152
  args: { kind: tool.schema.string().describe("fact, decision, constraint, or lesson"), content: tool.schema.string(), paths: tool.schema.array(tool.schema.string()), supersedes: tool.schema.string().optional() },
99
153
  execute: async (args, toolContext) => {
100
154
  if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
@@ -105,7 +159,7 @@ export function createCustomTools(options: {
105
159
  },
106
160
  }),
107
161
  project_memory_export: tool({
108
- description: "Explicitly promote selected durable project-memory records to the human-readable repo-local .opencode/memory/project-memory.jsonl file. Nothing is exported automatically.",
162
+ description: "Explicitly promote selected durable project-memory records to the human-readable repo-local .opencode/memory/project-memory.jsonl file. Call when durable knowledge should be committed and shared with the team.",
109
163
  args: { ids: tool.schema.array(tool.schema.string()) },
110
164
  execute: async (args) => {
111
165
  if (!projectMemory) return "[workflow-guard] Project memory unavailable; core guard enforcement remains active.";
@@ -114,12 +168,12 @@ export function createCustomTools(options: {
114
168
  return `[workflow-guard] Exported ${count} project-memory record(s) to .opencode/memory/project-memory.jsonl.`;
115
169
  },
116
170
  }),
117
- project_memory_import: tool({ description: "Import the fixed repo-local .opencode/memory/project-memory.jsonl file into this project's local working-memory index.", args: {}, execute: async () => projectMemory ? `[workflow-guard] Imported ${importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined)} new project-memory record(s).` : "[workflow-guard] Project memory unavailable; core guard enforcement remains active." }),
171
+ project_memory_import: tool({ description: "Import the repo-local .opencode/memory/project-memory.jsonl file into this project's local working-memory index. Call to synchronize local project memory with shared repository records.", args: {}, execute: async () => projectMemory ? `[workflow-guard] Imported ${importProjectKnowledge(projectMemory, portableMemoryPath, (content) => secretIn(content) !== undefined)} new project-memory record(s).` : "[workflow-guard] Project memory unavailable; core guard enforcement remains active." }),
118
172
  } : {}),
119
173
  ...(learningEnabled ? {
120
- learning_profile: tool({ description: "Inspect the local evidence-based learner profile so teaching can build on demonstrated knowledge without assuming unobserved concepts are gaps.", args: {}, execute: async () => JSON.stringify(loadLearnerProfile(), null, 2) }),
174
+ learning_profile: tool({ description: "Inspect the local evidence-based learner profile so explanations and teaching can build on demonstrated knowledge without assuming unobserved concepts are gaps.", args: {}, execute: async () => JSON.stringify(loadLearnerProfile(), null, 2) }),
121
175
  learning_checkpoint: tool({
122
- description: "At a high-value design decision, debugging moment, or important new concept, rank candidate Socratic learning opportunities. If one is selected, ask one concise question before continuing the work; do not turn routine syntax into a lesson.",
176
+ description: "At a high-value design decision, debugging moment, or important new concept, rank candidate Socratic learning opportunities. Proactively call when encountering non-trivial architectural trade-offs, design choices, or tricky bugs. If selected (intervene: true), ask one concise question via question before continuing; do not turn routine syntax into a lesson.",
123
177
  args: { opportunities: tool.schema.array(tool.schema.object({ type: tool.schema.string().describe("design, debugging, or new-concept"), concept: tool.schema.string().describe("Transferable engineering concept"), relevance: tool.schema.number().describe("Current-task relevance from 0 to 1"), consequence: tool.schema.number().describe("Decision consequence from 0 to 1") })) },
124
178
  execute: async (args, toolContext) => {
125
179
  const valid = args.opportunities.slice(0, 20).filter((candidate): candidate is typeof candidate & { type: "design" | "debugging" | "new-concept" } => (candidate.type === "design" || candidate.type === "debugging" || candidate.type === "new-concept") && candidate.concept.length > 0 && candidate.concept.length <= 100 && candidate.relevance >= 0 && candidate.relevance <= 1 && candidate.consequence >= 0 && candidate.consequence <= 1);
@@ -131,7 +185,7 @@ export function createCustomTools(options: {
131
185
  },
132
186
  }),
133
187
  learning_record: tool({
134
- description: "Record concise evidence from a real Socratic interaction after observing the learner's reasoning. Record what was demonstrated, not a grade or an inferred deficit.",
188
+ description: "Record concise evidence from a real Socratic interaction after observing the learner's reasoning and response to a learning checkpoint question. Record what was demonstrated, not a grade or an inferred deficit.",
135
189
  args: { concept: tool.schema.string(), kind: tool.schema.string().describe("exposed, developing, demonstrated, independent, critique, or needs-reinforcement"), summary: tool.schema.string().describe("Short factual description of observed reasoning") },
136
190
  execute: async (args, toolContext) => {
137
191
  const validKinds = new Set(["exposed", "developing", "demonstrated", "independent", "critique", "needs-reinforcement"]);
@@ -151,9 +205,13 @@ export function createCustomTools(options: {
151
205
  }),
152
206
  } : {}),
153
207
  guard_status: tool({
154
- description: "Inspect active guardrails, current branch protection, and verification/review status.", args: {},
155
- execute: async (_args, toolContext) => {
156
- 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);
157
215
  const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
158
216
  const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
159
217
  const reviewEvidenceRecord = lastR ? reviewEvidence(lastR) : undefined;
@@ -167,15 +225,38 @@ export function createCustomTools(options: {
167
225
  ...(reviewRequired && !reviewFresh ? ["review"] : []),
168
226
  ...(documentationRequired && !branchHasDocumentationChange(root) ? ["documentation"] : []),
169
227
  ];
228
+ const recommendedActions: string[] = [];
229
+ if (outstandingRequirements.includes("verification")) {
230
+ recommendedActions.push(verifyCommand ? `Execute test verification command '${verifyCommand}' to produce fresh verification evidence.` : "Run project tests to establish fresh verification evidence.");
231
+ }
232
+ if (outstandingRequirements.includes("review")) {
233
+ recommendedActions.push("Call guard_review_rubric to retrieve the 5-axis review rubric, then spawn a secondary reviewer subagent via task to evaluate changes and record verdict with record_review.");
234
+ }
235
+ if (outstandingRequirements.includes("documentation")) {
236
+ recommendedActions.push("Update documentation files (e.g. README.md, docs/) to satisfy the documentation gate before opening a PR.");
237
+ }
238
+ let openFollowupsCount = 0;
239
+ try { openFollowupsCount = followupStore ? listReviewFollowups(followupStore, "open", 10).length : 0; } catch {}
240
+ if (openFollowupsCount > 0) {
241
+ recommendedActions.push(`Inspect ${openFollowupsCount} open P2/P3 review follow-up(s) with guard_review_followups and resolve with guard_review_followup_resolve once addressed.`);
242
+ }
243
+ if (recommendedActions.length === 0) {
244
+ recommendedActions.push("All operational requirements are satisfied for task finalization and PR creation.");
245
+ }
170
246
  const ralphOutcome = runWithRuntimeState(root, client, () => getRalphOutcome(toolContext.sessionID));
171
- return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) } }, null, 2);
247
+ return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, outstandingRequirements, recommendedActions, lastMutationTimestamp: lastMut, mutationCount: getWorkspaceMutationCount(root), lastVerify: lastV && verifyEvidence ? { command: lastV.command, passed: lastV.passed, fresh: verifyFresh, evidenceId: verifyEvidence.id, commitHash: lastV.commitHash } : null, lastReview: lastR && reviewEvidenceRecord ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary, fresh: reviewFresh, evidenceId: reviewEvidenceRecord.id } : null, ralph: { enabled: isRalphModeEnabled(root), maxIterations: getRalphMaxIterations(root), outcome: ralphOutcome ?? null }, projectConfig: { profile: getOperationProfile(root), protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: verifyCommand ?? null, requireReview: reviewRequired, requireDocumentation: documentationRequired, recoveryCheckpoints: isRecoveryCheckpointsEnabled(root) } }, null, 2);
172
248
  },
173
249
  }),
174
- guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
175
- guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command.", 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) }),
250
+ guard_audit: tool({ description: "View recent audit entries recorded by opencode-workflow-guard. Use to diagnose policy decisions, blocked commands, or outcome telemetry.", args: { limit: tool.schema.number().optional().describe("Maximum entries to return (default 10)") }, execute: async (args) => JSON.stringify(getRecentAuditEntries(typeof args?.limit === "number" ? Math.min(args.limit, 50) : 10), null, 2) }),
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) }),
176
252
  record_review: tool({
177
- 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.",
178
- 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") },
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).",
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
+ },
179
260
  execute: async (args, toolContext) => {
180
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 } });
181
262
  const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
@@ -184,27 +265,33 @@ export function createCustomTools(options: {
184
265
  const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
185
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.`; }
186
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."; }
187
- recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID, toolContext.worktree || toolContext.directory);
188
- 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) });
189
271
  auditVerdict(args.passed ? "approved" : "changes_requested", args.passed ? "approved" : "changes_requested");
190
272
  return args.passed ? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.` : `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
191
273
  },
192
274
  }),
193
- guard_review_followups: tool({ description: "List durable local P2/P3 review follow-ups for this project. Open findings are technical debt that should be addressed rather than indefinitely deferred.", args: {}, execute: async () => JSON.stringify(followupStore ? listReviewFollowups(followupStore) : [], null, 2) }),
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) }),
194
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.` }),
195
277
  guard_review_rubric: tool({
196
- description: "Get the secondary-review rubric for the current branch diff. The orchestrator calls this, spawns a reviewer subagent with the rubric as the prompt, then the reviewer records its verdict via record_review.", args: { base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)") },
197
- 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) => {
198
284
  const sanitizedBase = typeof args.base === "string" && !args.base.startsWith("-") && !/[\s;'"\0]/.test(args.base) ? args.base : undefined;
199
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 });
200
287
  let diffText = "";
201
- 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; } }
202
- 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)"; }
203
290
  return buildReviewRubric(diffText);
204
291
  },
205
292
  }),
206
293
  guard_worktree_create: tool({
207
- description: "Create an isolated git worktree directory for concurrent subagent execution.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
294
+ description: "Create an isolated git worktree directory for concurrent subagent execution. Proactively call when launching parallel tasks/subagents to prevent file collision and maintain branch isolation.", args: { branch: tool.schema.string().describe("Branch name for the isolated worktree (e.g. 'feat/subagent-task')"), baseBranch: tool.schema.string().optional().describe("Base branch to branch off of (defaults to HEAD)") },
208
295
  execute: async (args, toolContext) => {
209
296
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
210
297
  const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
@@ -212,7 +299,7 @@ export function createCustomTools(options: {
212
299
  },
213
300
  }),
214
301
  guard_worktree_cleanup: tool({
215
- description: "Commit a final snapshot and remove an isolated git worktree directory.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
302
+ description: "Commit a final snapshot and remove an isolated git worktree directory. Call to clean up worktrees created with guard_worktree_create after subagent tasks complete.", args: { worktreePath: tool.schema.string().describe("Path of the worktree directory to clean up") },
216
303
  execute: async (args, toolContext) => {
217
304
  const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
218
305
  const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
@@ -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>();
@@ -97,7 +97,7 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
97
97
  messageID,
98
98
  parts: [{
99
99
  type: "text",
100
- text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run.`,
100
+ text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run. Check guard_status for outstanding verification/review requirements, and guard_review_rubric before final completion.`,
101
101
  synthetic: true,
102
102
  }],
103
103
  },
@@ -18,10 +18,10 @@ import { beginReadObservation, clearReadFingerprints, recordSuccessfulRead, stal
18
18
  import { ToolInvocationLifecycle } from "./lib/tool-lifecycle.ts";
19
19
  import { ToolOutcomeTracker, type ToolOutcomePart } from "./lib/tool-outcomes.ts";
20
20
  import { guardToolCallImpl, isReadOnlyRole } from "./lib/guard-dispatcher.ts";
21
- import { createCustomTools } from "./lib/custom-tools.ts";
21
+ import { createCustomTools, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
22
22
  import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
23
23
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
24
- export { extractReviewFollowups } from "./lib/custom-tools.ts";
24
+ export { extractReviewFollowups, buildWorkflowGuardSystemGuidance } from "./lib/custom-tools.ts";
25
25
 
26
26
  // ── Types ────────────────────────────────────────────────────────────────────
27
27
  export type {
@@ -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 {
@@ -345,15 +359,40 @@ async function emitBlockFeedback(message: string): Promise<void> {
345
359
  await showBlockToast(message);
346
360
  }
347
361
 
362
+ export function buildCompactionContext(operationalState: string, priorityBlocks: string[], maxChars = 8_000): string {
363
+ if (!Number.isFinite(maxChars) || maxChars < 0 || !Number.isInteger(maxChars)) {
364
+ throw new RangeError("maxChars must be a non-negative finite integer");
365
+ }
366
+ const truncate = (value: string, limit: number): string => {
367
+ let end = Math.min(value.length, limit);
368
+ if (end > 0 && end < value.length && /[\uD800-\uDBFF]/.test(value[end - 1])) end -= 1;
369
+ return value.slice(0, end);
370
+ };
371
+ let context = truncate(operationalState, maxChars);
372
+ for (const block of priorityBlocks) {
373
+ const remaining = maxChars - context.length - 2;
374
+ if (remaining <= 0) break;
375
+ context += `\n\n${truncate(block, remaining)}`;
376
+ }
377
+ return context;
378
+ }
379
+
348
380
  export const WorkflowGuard: Plugin = async (ctx) => {
349
381
  // Honor worktree if present (e.g. opencode worktrees or devcontainers)
350
382
  // so worktree plugins cannot punch through boundary gates. When the host
351
383
  // reports the filesystem root, use the SDK's actual project worktree instead.
352
384
  const hostRoot = ctx.worktree || ctx.directory || process.cwd();
353
385
  const hostIsFilesystemRoot = resolve(hostRoot) === resolve(hostRoot, "..");
354
- const effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree
386
+ let effectiveRoot = hostIsFilesystemRoot && ctx.project?.worktree && resolve(ctx.project.worktree) !== resolve(ctx.project.worktree, "..")
355
387
  ? ctx.project.worktree
356
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
+ }
357
396
  setWorkspaceRoot(effectiveRoot);
358
397
  setSdkClient(ctx.client);
359
398
  reloadProjectConfig(effectiveRoot);
@@ -416,11 +455,21 @@ export const WorkflowGuard: Plugin = async (ctx) => {
416
455
  await emitBlockFeedback(reason);
417
456
  const failureCount = input.sessionID ? toolOutcomes.getFailureCount(input.sessionID) : 0;
418
457
  const circuitBreakerSuffix = failureCount >= 2
419
- ? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly.]"
458
+ ? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly, or inspect policy details with guard_status or guard_why.]"
420
459
  : "";
421
460
  throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
422
461
  }
423
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
+ }
424
473
  if (input.tool === "read") {
425
474
  const target = editTargets(args, toolWorktree)[0];
426
475
  if (target) {
@@ -484,7 +533,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
484
533
  : getLastReviewResult();
485
534
  const mutationCountVal = sessionID ? sessionMutationCount : getMutationCount();
486
535
 
487
- const contextBlocks: string[] = [];
536
+ const priorityContextBlocks: string[] = [];
488
537
 
489
538
  if (active && active.length > 0) {
490
539
  const lines = active.slice(0, 20).map(
@@ -497,7 +546,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
497
546
  : sessionID
498
547
  ? ` (Session: ${sessionID})`
499
548
  : "";
500
- contextBlocks.push(
549
+ priorityContextBlocks.push(
501
550
  `## Active Tasks${attribution}\n` +
502
551
  lines.join("\n") +
503
552
  "\nComplete tasks efficiently - mark finished items as completed and address remaining ones.",
@@ -523,9 +572,9 @@ export const WorkflowGuard: Plugin = async (ctx) => {
523
572
  try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
524
573
  if (openFollowups.length > 0) {
525
574
  stateLines.push(`- Open Review Follow-ups: ${openFollowups.length} local P2/P3 item(s)`);
526
- contextBlocks.push(`## Review Follow-ups\n${openFollowups.map((item) => `- [${item.severity}:${item.id.slice(0, 8)}] ${item.summary.slice(0, 300)}`).join("\n")}\nTreat these as durable technical debt: address relevant items when practical and resolve them explicitly after verification.`);
575
+ priorityContextBlocks.push(`## Review Follow-ups\n${openFollowups.map((item) => `- [${item.severity}:${item.id.slice(0, 8)}] ${item.summary.slice(0, 300)}`).join("\n")}\nTreat these as durable technical debt: address relevant items when practical and resolve them explicitly after verification.`);
527
576
  }
528
- contextBlocks.push(stateLines.join("\n"));
577
+ const operationalState = stateLines.join("\n");
529
578
  let projectKnowledge: ReturnType<typeof getRecentProjectMemory> = [];
530
579
  try {
531
580
  const candidates = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
@@ -535,11 +584,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
535
584
  } catch {}
536
585
  if (projectKnowledge.length > 0) {
537
586
  const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
538
- contextBlocks.push(`## Project Memory\n${lines.join("\n")}\nTreat these as historical project knowledge; verify against current repository state when relevant files have changed.`);
587
+ priorityContextBlocks.push(`## Project Memory\nUse project_memory_search when deeper historical context is needed. Treat these as historical project knowledge; verify against current repository state when relevant files have changed.\n${lines.join("\n")}`);
539
588
  }
540
589
 
541
590
  if (Array.isArray(output?.context)) {
542
- output.context.push(contextBlocks.join("\n\n"));
591
+ output.context.push(buildCompactionContext(operationalState, priorityContextBlocks));
543
592
  }
544
593
  } catch {}
545
594
  },
@@ -611,13 +660,14 @@ export const WorkflowGuard: Plugin = async (ctx) => {
611
660
  // Keep tool descriptions honest: todowrite's description reflects lifecycle
612
661
  // and finalization gates so the model is not surprised by preventable blocks.
613
662
  // Similarly, mutating tools reflect their active-todo, feature-branch, and prior-read requirements.
663
+ // Subagent orchestration tools reflect worktree isolation and review expectations.
614
664
  "tool.definition": async (input, output) => {
615
665
  if (input.toolID === "todowrite") {
616
666
  const description = typeof output.description === "string" ? output.description : "";
617
667
  if (description.includes("verification evidence")) return;
618
668
  output.description =
619
669
  description +
620
- "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
670
+ "\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply. Proactively call guard_next_tasks when planning work to discover existing roadmap/TODOs, and guard_status to check outstanding requirements.";
621
671
  return;
622
672
  }
623
673
  if (EDIT_TOOL_NAMES.has(input.toolID)) {
@@ -628,6 +678,30 @@ export const WorkflowGuard: Plugin = async (ctx) => {
628
678
  "\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
629
679
  return;
630
680
  }
681
+ if (input.toolID === "task") {
682
+ const description = typeof output.description === "string" ? output.description : "";
683
+ if (description.includes("Workflow Guard subagent guidance")) return;
684
+ output.description =
685
+ description +
686
+ "\n\nWorkflow Guard subagent guidance: When spawning parallel worker subagents, use guard_worktree_create to isolate file mutations in separate worktrees. When conducting code reviews before task completion or PR creation, fetch the rubric with guard_review_rubric and provide it to a reviewer subagent to evaluate and record via record_review.";
687
+ return;
688
+ }
689
+ },
690
+
691
+ "experimental.chat.system.transform": async (input, output) => {
692
+ try {
693
+ if (!Array.isArray(output?.system)) return;
694
+ const existing = output.system.join("\n");
695
+ if (existing.includes("## Workflow Guard & Operational Tools")) return;
696
+ const isReadOnly = (input as { agent?: string })?.agent ? isReadOnlyRole((input as { agent?: string }).agent!) : false;
697
+ const guidance = buildWorkflowGuardSystemGuidance({
698
+ projectMemoryEnabled,
699
+ learningEnabled,
700
+ recoveryCheckpointsEnabled: isRecoveryCheckpointsEnabled(effectiveRoot),
701
+ isReadOnly,
702
+ });
703
+ output.system.push(guidance);
704
+ } catch {}
631
705
  },
632
706
 
633
707
  "chat.message": async (input) => {
package/test/test.mts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  setWorkspaceRoot,
13
13
  setSdkClient,
14
14
  WorkflowGuard,
15
+ buildCompactionContext,
15
16
  detectVerifyCommand,
16
17
  runVerify,
17
18
  getCleanEnv,
@@ -35,6 +36,14 @@ import {
35
36
  getRecentAuditEntries,
36
37
  getRecentVerifyHistory,
37
38
  getVerifyHistoryFilePath,
39
+ getReviewCacheFilePath,
40
+ loadReviewCache,
41
+ persistReviewCache,
42
+ setSessionWorkspace,
43
+ getSessionWorkspace,
44
+ resolveEffectiveWorkspace,
45
+ getLastReviewResultForWorkspace,
46
+ isSameGitRepo,
38
47
  managedConfigDiagnostic,
39
48
  summarizeInput,
40
49
  extractReviewFollowups,
@@ -565,6 +574,13 @@ check("multi-target tee with an outside path is blocked", blocked(await call("ba
565
574
  check("tee --append within workspace is allowed with todos", !(await call("bash", { command: "echo x | tee --append src/a.ts" }, { sessionID: "s-active" })));
566
575
 
567
576
  console.log("- Compaction focus preservation & TUI toast -");
577
+ const boundedCompaction = buildCompactionContext("## Operational Guard State\n- critical", ["## Active Tasks\n" + "a".repeat(10_000), "## Project Memory\nshould-not-fit"], 500);
578
+ check("compaction packer enforces its total context budget", boundedCompaction.length === 500);
579
+ check("compaction packer preserves mandatory operational state before optional context", boundedCompaction.startsWith("## Operational Guard State\n- critical") && !boundedCompaction.includes("should-not-fit"));
580
+ check("compaction packer preserves the largest valid Unicode prefix", buildCompactionContext("critical \u{1F680}state", [], 10) === "critical ");
581
+ let invalidCompactionBudgetRejected = false;
582
+ try { buildCompactionContext("critical", [], Number.NaN); } catch (error) { invalidCompactionBudgetRejected = error instanceof RangeError; }
583
+ check("compaction packer rejects invalid public budgets", invalidCompactionBudgetRejected);
568
584
  let toasts: unknown[] = [];
569
585
  const toastClient = {
570
586
  session: fakeClient.session,
@@ -588,6 +604,8 @@ if (typeof compactFn === "function") {
588
604
  await compactFn({ sessionID: "s-active" } as any, compactOutput as any);
589
605
  }
590
606
  check("compaction hook injects active tasks into output.context", compactOutput.context.length > 0 && (compactOutput.context[0]?.includes("Active Tasks") ?? false));
607
+ check("compaction hook keeps injected continuity context bounded", compactOutput.context.every((context) => context.length <= 8000));
608
+ check("compaction hook preserves operational guard state within its budget", compactOutput.context.some((context) => context.includes("## Operational Guard State") && context.includes("Git Branch:")));
591
609
 
592
610
  // Blocked tool call emits one in-app warning via tui.showToast.
593
611
  toasts = [];
@@ -1899,6 +1917,10 @@ check("guard_status exposes subject-bound evidence references", parsedStatus.las
1899
1917
  check("guard_status exposes effective operation profile without TUI state", parsedStatus.projectConfig.profile === "interactive" && typeof parsedStatus.projectConfig.recoveryCheckpoints === "boolean");
1900
1918
  check("guard_status exposes disabled-by-default Ralph accountability state", parsedStatus.ralph?.enabled === false && parsedStatus.ralph?.maxIterations === 10 && parsedStatus.ralph?.outcome === null);
1901
1919
  check("guard_status exposes bounded outstanding requirements", Array.isArray(parsedStatus.outstandingRequirements) && parsedStatus.outstandingRequirements.every((requirement: unknown) => ["verification", "review", "documentation"].includes(String(requirement))));
1920
+ check("guard_status exposes actionable recommended actions", Array.isArray(parsedStatus.recommendedActions) && parsedStatus.recommendedActions.length > 0);
1921
+ check("guard_next_tasks description includes proactive trigger", Boolean(customPlugin.tool?.guard_next_tasks?.description.includes("Proactively call")));
1922
+ check("project_memory_search description includes proactive trigger", Boolean(customPlugin.tool?.project_memory_search?.description?.includes("Proactively call")));
1923
+ check("guard_review_rubric description includes orchestrator trigger", Boolean(customPlugin.tool?.guard_review_rubric?.description?.includes("orchestrator should call")));
1902
1924
 
1903
1925
  const requirementsRoot = mkdtempSync(join(tmpdir(), "wg-status-requirements-"));
1904
1926
  mkdirSync(join(requirementsRoot, ".opencode"), { recursive: true });
@@ -2086,6 +2108,96 @@ const mainReviewToolResult = await customPlugin.tool?.record_review?.execute(
2086
2108
  );
2087
2109
  check("record_review rejects self-approval from main session", typeof mainReviewToolResult === "string" && mainReviewToolResult.includes("rejected"));
2088
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
+
2089
2201
  // Event hook handles permission events
2090
2202
  if (typeof customPlugin.event === "function") {
2091
2203
  await customPlugin.event({
@@ -2769,6 +2881,7 @@ writeFileSync(join(checkpointHookDir, "tracked.txt"), "agent result\n");
2769
2881
  await checkpointHooks.event?.({ event: { type: "session.idle", properties: { sessionID: "checkpoint-root" } } } as any);
2770
2882
  const generatedCheckpointMessage = checkpointHookPrompts.find((entry) => entry.sessionID === "checkpoint-root")?.messageID;
2771
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_"));
2772
2885
  await checkpointHooks["chat.message"]?.({ sessionID: "checkpoint-root", messageID: generatedCheckpointMessage } as any, {} as any);
2773
2886
  check("synthetic continuation does not replace recovery checkpoint", listRecoveryCheckpoints(checkpointHookDir, "checkpoint-root").length === 1);
2774
2887
  checkpointHookParents.set("checkpoint-child", "checkpoint-root");
@@ -3208,6 +3321,26 @@ const editIdemDef = { description: editDef.description, parameters: {} };
3208
3321
  await defPlugin["tool.definition"]?.({ toolID: "edit" } as any, editIdemDef);
3209
3322
  check("tool.definition edit enrichment is idempotent", editIdemDef.description === editDef.description);
3210
3323
 
3324
+ const taskDef = { description: "Launch subagents.", parameters: {} };
3325
+ await defPlugin["tool.definition"]?.({ toolID: "task" } as any, taskDef);
3326
+ check("tool.definition enriches task tool with subagent and review guidance", taskDef.description.includes("Workflow Guard subagent guidance") && taskDef.description.includes("guard_worktree_create") && taskDef.description.includes("guard_review_rubric"));
3327
+ const taskIdemDef = { description: taskDef.description, parameters: {} };
3328
+ await defPlugin["tool.definition"]?.({ toolID: "task" } as any, taskIdemDef);
3329
+ check("tool.definition task enrichment is idempotent", taskIdemDef.description === taskDef.description);
3330
+ check("tool.definition todowrite enriches with planning and status guidance", todoDef.description.includes("guard_next_tasks") && todoDef.description.includes("guard_status"));
3331
+
3332
+ // experimental.chat.system.transform provides cross-model workflow guidance
3333
+ const systemOutput = { system: ["Base system prompt"] };
3334
+ await defPlugin["experimental.chat.system.transform"]?.({ model: { id: "gemini-flash" } as any }, systemOutput);
3335
+ check("experimental.chat.system.transform adds workflow guard guidance", systemOutput.system.length === 2 && systemOutput.system[1].includes("## Workflow Guard & Operational Tools") && systemOutput.system[1].includes("guard_next_tasks") && systemOutput.system[1].includes("guard_review_rubric"));
3336
+ const systemOutputIdem = { system: [...systemOutput.system] };
3337
+ await defPlugin["experimental.chat.system.transform"]?.({ model: { id: "gemini-flash" } as any }, systemOutputIdem);
3338
+ check("experimental.chat.system.transform is idempotent", systemOutputIdem.system.length === 2);
3339
+
3340
+ const readOnlySystemOutput = { system: ["Base system prompt"] };
3341
+ await defPlugin["experimental.chat.system.transform"]?.({ agent: "reviewer", model: { id: "claude-sonnet" } as any } as any, readOnlySystemOutput);
3342
+ check("experimental.chat.system.transform respects read-only roles", !readOnlySystemOutput.system[1].includes("spawn a reviewer subagent"));
3343
+
3211
3344
  // Socratic learning engine: evidence is explicit and interventions favor
3212
3345
  // relevant gaps without repeatedly interrupting demonstrated knowledge.
3213
3346
  const learner = createLearnerProfile();
@@ -3287,6 +3420,7 @@ const learningPlugin = await WorkflowGuard({ directory: root, worktree: root, cl
3287
3420
  check("learning mode registers profile tool when explicitly enabled", !!learningPlugin.tool?.learning_profile);
3288
3421
  check("learning mode registers adaptive checkpoint tool", !!learningPlugin.tool?.learning_checkpoint);
3289
3422
  check("learning mode registers evidence recorder", !!learningPlugin.tool?.learning_record);
3423
+ check("learning_checkpoint description includes proactive trigger", Boolean(learningPlugin.tool?.learning_checkpoint?.description?.includes("Proactively call")));
3290
3424
  const checkpointResult = JSON.parse(await (learningPlugin.tool as any).learning_checkpoint.execute({
3291
3425
  opportunities: [{ type: "design", concept: "application-state", relevance: 1, consequence: 0.9 }],
3292
3426
  }, { sessionID: "s-learning-tools" }));