opencode-workflow-guard 1.12.0 → 1.13.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/README.md CHANGED
@@ -14,15 +14,18 @@ Workflow Guard is deliberately **not an agent harness**: it can constrain action
14
14
 
15
15
  ### 1. Installation
16
16
 
17
- Install the current published version globally with OpenCode's plugin installer:
17
+ Install the current published version globally:
18
18
 
19
19
  ```bash
20
20
  VERSION=$(npm view opencode-workflow-guard version)
21
- opencode plugin "opencode-workflow-guard@$VERSION" --global --force
21
+ opencode plugin add "opencode-workflow-guard@$VERSION" # OpenCode 2.x
22
+ opencode plugin "opencode-workflow-guard@$VERSION" --global --force # OpenCode 1.x
22
23
  ```
23
24
 
24
25
  *Requires OpenCode >= 1.18.* OpenCode detects the package's server and TUI targets and updates both global configs. Use the same command after a release to upgrade; the explicit version gives OpenCode a fresh package-cache key. Restart OpenCode after installation. See [docs/installation.md](docs/installation.md) for details and links to the OpenCode plugin documentation.
25
26
 
27
+ The plugin supports both generations from one package: **OpenCode 1.x** loads the `server()` entrypoint, and **OpenCode 2.x** loads the `setup()` entrypoint (`@opencode/plugin`). Policies, tools, and configuration behave the same on both; V2 load tests run against a real OpenCode 2 binary in `npm run test:install`.
28
+
26
29
  For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. The companion keeps a static `Workflow Guard` shield in the prompt bar; warning toasts provide block details without changing agent behavior. Do not place the TUI module under the server `plugins/` directory.
27
30
 
28
31
  ![Workflow Guard status badge in the OpenCode TUI](docs/assets/workflow-guard-tui-badge.png)
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## Requirements
8
8
 
9
- - **OpenCode >= 1.18** (requires the `GET /session/:id/todo` endpoint and V1 `PluginModule` loader).
9
+ - **OpenCode >= 1.18** (V1: requires the `GET /session/:id/todo` endpoint and the V1 `PluginModule` loader) or **OpenCode 2.x** (V2: loads through the `@opencode/plugin` API; the current todo list is reconstructed from the session message history because V2 has no todo endpoint).
10
10
  - **Node.js >= 22.18** or **Bun** (for running test scripts directly).
11
11
 
12
12
  ---
@@ -19,10 +19,11 @@ Install the current published version with OpenCode's plugin command:
19
19
 
20
20
  ```bash
21
21
  VERSION=$(npm view opencode-workflow-guard version)
22
- opencode plugin "opencode-workflow-guard@$VERSION" --global --force
22
+ opencode plugin add "opencode-workflow-guard@$VERSION" # OpenCode 2.x
23
+ opencode plugin "opencode-workflow-guard@$VERSION" --global --force # OpenCode 1.x
23
24
  ```
24
25
 
25
- OpenCode documents `opencode plugin <module>` as the command to install a plugin and update its config, `--global` as global installation, and `--force` as replacing an existing plugin version. Workflow Guard exposes both server and TUI targets, so OpenCode detects both and updates their global configuration files. Restart OpenCode after installation because configuration is loaded at startup. See the [OpenCode plugin CLI documentation](https://opencode.ai/docs/cli/#plugin).
26
+ OpenCode 2 documents `opencode plugin add <package>` as the command to install a plugin and add it to the global configuration; OpenCode 1 documents `opencode plugin <module>` with `--global` and `--force`. Workflow Guard exposes both server and TUI targets, so OpenCode detects both and updates their global configuration files (OpenCode 2 uses one global `cli.json` for terminal-client settings). Restart OpenCode after installation because configuration is loaded at startup. See the [OpenCode plugin CLI documentation](https://opencode.ai/docs/cli/#plugin).
26
27
 
27
28
  Use the same two commands to upgrade after a Workflow Guard release. Keep the explicit version in OpenCode's configuration rather than relying on a bare package name or `@latest`: OpenCode caches npm plugins, and an already-populated `@latest` cache key may continue resolving to the version that originally populated it. An explicit new version creates a new package-cache key without manually deleting OpenCode's cache.
28
29
 
package/docs/policies.md CHANGED
@@ -12,7 +12,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
12
12
  - **Pre-edit Gate:** All file-editing tools (`edit`, `write`, `apply_patch`) are blocked until the session has an active task list in OpenCode's native todo system (`todowrite`, persisted at `GET /session/:id/todo`).
13
13
  - **Flexible Task Execution:** Tasks can be completed in any order as work concludes without artificial sequential blockers, maintaining maximum pair-programming DX.
14
14
  - **No Silent Deletion:** Each `todowrite` update replaces the complete list. Active tasks must remain in subsequent updates until they are explicitly marked `completed` or `cancelled`.
15
- - **No Silent Early Exit:** When an owning session becomes `session.idle` with unfinished native todos, the guard asynchronously asks OpenCode to resume the existing run. This enforces the session's existing lifecycle state: Workflow Guard does not choose the next task, reorder work, create work, or delegate it. Ordinary automatic continuations are capped at three consecutive attempts and the budget resets on genuine user input. The independently opt-in `ralphMode` uses `ralphMaxIterations` (1-100, default 10) instead; genuine user input terminates an active Ralph run, and unfinished work at the budget limit reports `budget_exhausted` rather than inferring a policy block. Ralph remains disabled by default under every operation profile. Use OpenCode's native `question` tool when user feedback or a decision is required; question and permission interactions wait inside the active run rather than producing an idle completion.
15
+ - **No Silent Early Exit:** When an owning session becomes `session.idle` with unfinished native todos, the guard asynchronously asks OpenCode to resume the existing run. This enforces the session's existing lifecycle state: Workflow Guard does not choose the next task, reorder work, create work, or delegate it. Ordinary automatic continuations are capped at three consecutive attempts and the budget resets on genuine user input. User-initiated interrupts (Esc) surface as `MessageAbortedError` and suspend automatic continuation for that session until genuine user input arrives, mirroring the ralph `user_stopped` semantics. The independently opt-in `ralphMode` uses `ralphMaxIterations` (1-100, default 10) instead; genuine user input terminates an active Ralph run, and unfinished work at the budget limit reports `budget_exhausted` rather than inferring a policy block. Ralph remains disabled by default under every operation profile. Use OpenCode's native `question` tool when user feedback or a decision is required; question and permission interactions wait inside the active run rather than producing an idle completion.
16
16
  - **Subagent Inheritance & Role Confinement:** Subagents automatically inherit active tasks from their parent session via the `parentID` hierarchy. Subagents spawned with read-only roles (`reviewer`, `planner`, `advisor`, `critic`, `explorer`, `scout`, `evaluator`) are hard-confined: file mutations, lifecycle tools, and mutating shell commands are strictly blocked.
17
17
  - **Handoff Safety:** An idle subagent whose effective todos are inherited from its parent is not auto-continued. Inherited work belongs to the parent and can be handed back normally.
18
18
  - **Durable Planning Context Discovery:** `guard_next_tasks` prefers a repository-root `TODO.md`; when it is absent, it reads conventional `ROADMAP.md`, `PLAN.md`, `TASKS.md`, `BACKLOG.md`, their `docs/` counterparts, and Markdown files under `docs/plans/`. The tool exposes candidate planning context without selecting, prioritizing, or sequencing tasks. These files never replace OpenCode's native runtime todo state.
@@ -60,6 +60,8 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
60
60
  - Direct edits of the same paths through the `edit`/`write`/`apply_patch` tools
61
61
  - Shell commands invoking `opencode auth`, `opencode config`, `opencode permission`, or `opencode run --auto`
62
62
  - Evasion-normalized: quote-concatenation (`open''code.json`), escapes (`open\c\ode`), and glob wildcards (`opencode.jso?`) are stripped before matching.
63
+ - Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering: quoted arguments are command data, not shell syntax (redirect analysis runs on the quote-stripped residue), and the redirect heuristic applies to shell commands only (write/edit content is guarded by the target path check instead).
64
+ - Plan files under `.opencode/plans/` are exempt: opencode's plan mode writes agent-authored plan markdown there, and plans are content, not configuration. Everything else under `.opencode/` (including the directory itself) stays protected.
63
65
  - **Read-only access is allowed** (`cat`, `less`, `grep`, `head`, `tail` on config files) - only modification attempts trigger the guard.
64
66
 
65
67
  ### 7. Feature-Branch Workflow
@@ -75,17 +77,18 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
75
77
  - The boundary has **no override**: `WORKFLOW_GUARD_ALLOW_LIVE=1` covers live-system commands only and never weakens the workspace confinement.
76
78
 
77
79
  ### 9. Script-Laundering Guard
78
- - Content written via `edit`, `write`, or `apply_patch` is scanned for destructive CLI commands and settings tamper payloads.
80
+ - Content written via `edit`, `write`, or `apply_patch` is scanned for destructive CLI commands.
79
81
  - Prevents agents from bypassing shell guards by writing destructive commands to script files (e.g. `write deploy.sh` -> `bash deploy.sh`).
80
82
 
81
83
  ### 10. Evidence-Based Verification
82
84
  - When the agent attempts to mark **every** task completed (finalizing the request), the guard requires passing verification evidence.
83
85
  - Verification (`WORKFLOW_GUARD_VERIFY` env, project `verifyCommand`, or auto-detected test/typecheck runner across ecosystems: `package.json` (`npm test`, `npm run typecheck`, `npm run check`, `npm run build`), `Cargo.toml` (`cargo test`), `go.mod` (`go test ./...`), `pytest.ini`/`pyproject.toml` (`pytest`), or `deno.json` (`deno test`)) executes in an isolated environment with scrubbed credentials, output caps, token-efficient stdout/stderr snipping, and strict timeout protection. Environment configuration takes precedence over project configuration.
86
+ - The verification timeout defaults to 30 seconds and can be raised per project (`verifyTimeoutMs` in `.opencode/workflow-guard.json`) or per environment (`WORKFLOW_GUARD_VERIFY_TIMEOUT_MS`, which wins over project config); non-positive values are ignored and fall back to the default. Raise it for suites that legitimately exceed the default - for example this repository's own unit suite.
84
87
  - Project `postEditValidators` can run faster targeted checks after a direct file edit actually changes the target. Each entry accepts a workspace-relative glob `pattern` (`*`, `**`, and `?` wildcards; brace expansion and character classes are not supported), a non-empty `command`, and an optional positive `timeoutMs`; invalid entries are reported as advisory feedback rather than silently ignored. Matching commands run from the workspace root with the same scrubbed verification environment. Validator failures and timeouts are appended to the edit result as advisory feedback and do not replace the blocking whole-project finalization verification. Multi-file `apply_patch` calls validate every changed target.
85
88
  - 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
89
  - 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
90
  - **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.
91
+ - **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
92
  - If verification fails, finalization is blocked with a structured summary of error markers, stack traces, and failure output.
90
93
  - 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
94
  - The verify command is **disabled** when `WORKFLOW_GUARD_VERIFY` is empty (`""`).
@@ -135,6 +138,7 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
135
138
  ### 20. Merged Branch & Base Freshness Guard
136
139
  - Blocks pushing to branches already merged or associated with closed PRs in GitHub or Azure DevOps.
137
140
  - Blocks creating fresh feature branches when the local base branch is behind the remote, prompting the agent to pull latest changes first.
141
+ - Tag push refspecs are exempt: publishing an existing tag (`git push origin v1.2.0`, `git push origin refs/tags/v1.2.0`) and creating tags (`git tag v1.2.0`, `git tag -a -m ...`) are release operations, not branch mutations - only tag deletions (`git tag -d`, `git push origin :refs/tags/v1.2.0`, `--delete`) stay blocked.
138
142
 
139
143
  ### 21. Documentation Review & Synchronization Guard
140
144
  - Ensures that relevant documentation is updated whenever changes introduce new features, policies, or public tools before PR creation. Configurable via `.opencode/workflow-guard.json` (`requireDocumentation: true`) or `WORKFLOW_GUARD_REQUIRE_DOCS=1`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "Deterministic workflow and safety policy enforcement for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,8 +48,10 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@opencode-ai/plugin": "latest",
51
+ "@opencode/plugin": "latest",
51
52
  "@opentui/solid": "latest",
52
- "jsonc-parser": "latest"
53
+ "jsonc-parser": "latest",
54
+ "@opencode/ai": "latest"
53
55
  },
54
56
  "devDependencies": {
55
57
  "@changesets/cli": "latest",
package/src/lib/audit.ts CHANGED
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { Database, type SqliteDatabase } from "./sqlite.ts";
6
- import type { AuditEntry, PolicyDecision, VerifyResult } from "./types.ts";
6
+ import type { AuditEntry, PolicyDecision, VerifyResult, ReviewResult } from "./types.ts";
7
7
  import { asRecord } from "./utils.ts";
8
8
 
9
9
  const AUDIT_DIR = join(
@@ -14,6 +14,7 @@ const AUDIT_DIR = join(
14
14
  const AUDIT_FILE = join(AUDIT_DIR, "workflow-guard.jsonl");
15
15
  const VERIFY_CACHE_FILE = join(AUDIT_DIR, "last-verify.json");
16
16
  const VERIFY_HISTORY_FILE = join(AUDIT_DIR, "verify-history.jsonl");
17
+ const REVIEW_CACHE_FILE = join(AUDIT_DIR, "last-review.json");
17
18
  const MAX_AUDIT_BYTES = 4 * 1024 * 1024;
18
19
  const RETAIN_AUDIT_BYTES = 2 * 1024 * 1024;
19
20
  const MAX_VERIFY_HISTORY_BYTES = 1024 * 1024;
@@ -62,6 +63,10 @@ export function getVerifyHistoryFilePath(): string {
62
63
  return VERIFY_HISTORY_FILE;
63
64
  }
64
65
 
66
+ export function getReviewCacheFilePath(): string {
67
+ return REVIEW_CACHE_FILE;
68
+ }
69
+
65
70
  function appendBoundedJsonlUnlocked(path: string, value: unknown, maxBytes: number, retainBytes: number): void {
66
71
  appendFileSync(path, JSON.stringify(value) + "\n", { encoding: "utf8", mode: 0o600 });
67
72
  chmodSync(path, 0o600);
@@ -156,6 +161,33 @@ export function loadVerifyCache(): VerifyResult | undefined {
156
161
  return undefined;
157
162
  }
158
163
 
164
+ /**
165
+ * Persists passing secondary review approval to disk so session restarts
166
+ * or multi-agent handoffs retain valid review approval state.
167
+ */
168
+ export function persistReviewCache(reviewData: ReviewResult): void {
169
+ try {
170
+ mkdirSync(AUDIT_DIR, { recursive: true });
171
+ writeFileSync(REVIEW_CACHE_FILE, JSON.stringify(reviewData, null, 2), { encoding: "utf8", mode: 0o600 });
172
+ chmodSync(REVIEW_CACHE_FILE, 0o600);
173
+ } catch {}
174
+ }
175
+
176
+ /**
177
+ * Loads durable review approval from disk if present.
178
+ */
179
+ export function loadReviewCache(): ReviewResult | undefined {
180
+ try {
181
+ if (!existsSync(REVIEW_CACHE_FILE)) return undefined;
182
+ const raw = readFileSync(REVIEW_CACHE_FILE, "utf8");
183
+ const data = JSON.parse(raw);
184
+ if (data && typeof data.reviewer === "string" && typeof data.timestamp === "number" && typeof data.passed === "boolean") {
185
+ return data;
186
+ }
187
+ } catch {}
188
+ return undefined;
189
+ }
190
+
159
191
  /**
160
192
  * Reads recent audit entries from the end of the JSONL audit log with a bounded
161
193
  * buffer window to prevent memory spikes on long-lived installations.
@@ -29,6 +29,7 @@ import {
29
29
  isReviewRequired,
30
30
  recordMutation,
31
31
  recordReviewResult,
32
+ resolveEffectiveWorkspace,
32
33
  runWithRuntimeState,
33
34
  } from "./state.ts";
34
35
  import { getRalphOutcome } from "../policies/continuation.ts";
@@ -46,11 +47,19 @@ import { currentGitBranch, onProtectedBranch } from "../policies/git.ts";
46
47
  import { effectiveTodoOwnerSessionID, effectiveTodos, fetchParentSession, fetchParentSessionID, hasActiveTodo } from "../policies/todo.ts";
47
48
 
48
49
  export function extractReviewFollowups(summary: string): Array<{ severity: "P2" | "P3"; summary: string }> {
49
- return summary.split(/\r?\n/).map((line) => ({
50
- line: line.trim(),
51
- severity: line.match(/(?:^|\s)(P[23])(?:\b|[:])/i)?.[1]?.toUpperCase() as "P2" | "P3" | undefined,
52
- })).filter((finding): finding is { line: string; severity: "P2" | "P3" } => Boolean(finding.line && finding.severity))
53
- .map((finding) => ({ severity: finding.severity, summary: finding.line }));
50
+ return summary.split(/\r?\n/).map((line) => {
51
+ const trimmed = line.trim();
52
+ const match = trimmed.match(/(?:^|\s)(P[23])(?:\b|[:])/i);
53
+ const severity = match?.[1]?.toUpperCase() as "P2" | "P3" | undefined;
54
+ return { line: trimmed, severity };
55
+ }).filter((finding): finding is { line: string; severity: "P2" | "P3" } => {
56
+ if (!finding.line || !finding.severity) return false;
57
+ const rest = finding.line.replace(new RegExp(`^.*?(?:${finding.severity})\\s*[:\\-]?\\s*`, "i"), "").trim();
58
+ if (!rest || /^(?:none|nil|n\/?a|0|zero|no\s+(?:issues|findings|defects|items|blockers)|clean|passed|ok|none\s+identified)\b/i.test(rest)) {
59
+ return false;
60
+ }
61
+ return true;
62
+ }).map((finding) => ({ severity: finding.severity, summary: finding.line }));
54
63
  }
55
64
 
56
65
  export function buildWorkflowGuardSystemGuidance(options: {
@@ -196,9 +205,13 @@ export function createCustomTools(options: {
196
205
  }),
197
206
  } : {}),
198
207
  guard_status: tool({
199
- description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. Proactively call at session start and before completing tasks or creating PRs.", args: {},
200
- execute: async (_args, toolContext) => {
201
- const root = effectiveRoot; const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
208
+ description: "Inspect active guardrails, current branch protection, mutation count, outstanding verification/review requirements, and ralph status. Proactively call at session start and before completing tasks or creating PRs.",
209
+ args: {
210
+ directory: tool.schema.string().optional().describe("Target repository directory to inspect (defaults to active session directory or workspace root)"),
211
+ },
212
+ execute: async (args, toolContext) => {
213
+ const root = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args?.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
214
+ const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResultForWorkspace(root); const lastR = getLastReviewResultForWorkspace(root); const lastMut = getWorkspaceMutationTimestamp(root); const cfg = loadProjectConfig(root);
202
215
  const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
203
216
  const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
204
217
  const reviewEvidenceRecord = lastR ? reviewEvidence(lastR) : undefined;
@@ -238,7 +251,12 @@ export function createCustomTools(options: {
238
251
  guard_why: tool({ description: "Simulate and return the structured policy decision for a specific tool call or command. Proactively use before executing questionable or complex commands to check if they would be blocked by guard policies.", args: { tool: tool.schema.string().describe("Tool name (e.g. bash, edit, write, read, apply_patch)"), input: tool.schema.record(tool.schema.string(), tool.schema.any()).optional().describe("Tool input arguments") }, execute: async (args, toolContext) => JSON.stringify(await runWithRuntimeState(effectiveRoot, client, () => guardToolCallImpl(args.tool, args.input ?? {}, { sessionID: toolContext.sessionID, worktree: toolContext.worktree, directory: toolContext.directory, simulate: true })), null, 2) }),
239
252
  record_review: tool({
240
253
  description: "Record a secondary reviewer agent's approval or critique of the current changes. The summary must reference the 5 core review axes from guard_review_rubric (test integrity, task completeness, cleanliness, security, platform).",
241
- args: { reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"), summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"), passed: tool.schema.boolean().describe("True if change is approved, false if changes requested") },
254
+ args: {
255
+ reviewer: tool.schema.string().describe("Identifier/name of the reviewer subagent"),
256
+ summary: tool.schema.string().describe("Review findings summary across the 5 core review axes"),
257
+ passed: tool.schema.boolean().describe("True if change is approved, false if changes requested"),
258
+ directory: tool.schema.string().optional().describe("Target repository directory being reviewed (defaults to parent session's repository or active directory)"),
259
+ },
242
260
  execute: async (args, toolContext) => {
243
261
  const auditVerdict = (verdict: "approved" | "changes_requested" | "rejected", reason: string) => audit({ ts: new Date().toISOString(), sessionID: toolContext.sessionID, tool: "record_review.verdict", decision: verdict === "rejected" ? "block" : "allow", phase: "event", reason, evidence: { reviewVerdict: verdict } });
244
262
  const parentSessionID = await runWithRuntimeState(effectiveRoot, client, () => fetchParentSessionID(toolContext.sessionID));
@@ -247,8 +265,9 @@ export function createCustomTools(options: {
247
265
  const referenced = axesRefs.filter((axis) => args.summary.toLowerCase().includes(axis));
248
266
  if (referenced.length < 3) { auditVerdict("rejected", "insufficient_rubric_axes"); return `[workflow-guard] Review rejected: summary must reference the review axes (found ${referenced.length}/5). Call guard_review_rubric to get the rubric, evaluate each axis, and include findings per axis in the summary.`; }
249
267
  if (args.passed && /(?:^|\s)(?:\[p[01]\]|p[01]\s*:\s*(?:blocker|defect|vulnerability|error|bug|issue)|p[01]\s+blocker)/i.test(args.summary)) { auditVerdict("rejected", "approval_contains_blocker"); return "[workflow-guard] Review rejected: cannot record approval when P0 or P1 blockers are flagged in findings. Resolve all P0/P1 issues before approving or record review with passed=false."; }
250
- recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID, toolContext.worktree || toolContext.directory);
251
- if (followupStore && !secretIn(args.summary)) for (const finding of extractReviewFollowups(args.summary)) recordReviewFollowup(followupStore, { severity: finding.severity, summary: finding.summary, reviewer: args.reviewer, sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(effectiveRoot) });
268
+ const reviewWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext.sessionID, directory: args.directory, fallback: toolContext.worktree || toolContext.directory || effectiveRoot });
269
+ recordReviewResult(args.reviewer, args.summary, args.passed, parentSessionID, reviewWorkspace);
270
+ if (followupStore && !secretIn(args.summary)) for (const finding of extractReviewFollowups(args.summary)) recordReviewFollowup(followupStore, { severity: finding.severity, summary: finding.summary, reviewer: args.reviewer, sessionID: toolContext.sessionID, commit: getCurrentGitCommitHash(reviewWorkspace) });
252
271
  auditVerdict(args.passed ? "approved" : "changes_requested", args.passed ? "approved" : "changes_requested");
253
272
  return args.passed ? `[workflow-guard] Review recorded as APPROVED by ${args.reviewer}.` : `[workflow-guard] Review recorded as CHANGES REQUESTED by ${args.reviewer}.`;
254
273
  },
@@ -256,13 +275,18 @@ export function createCustomTools(options: {
256
275
  guard_review_followups: tool({ description: "List durable local P2/P3 review follow-ups (technical debt) for this project. Proactively check during planning or before final verification to address open findings.", args: {}, execute: async () => JSON.stringify(followupStore ? listReviewFollowups(followupStore) : [], null, 2) }),
257
276
  guard_review_followup_resolve: tool({ description: "Resolve a durable local review follow-up after the underlying issue has been fixed and verified.", args: { id: tool.schema.string() }, execute: async (args) => followupStore && resolveReviewFollowup(followupStore, args.id) ? `[workflow-guard] Review follow-up ${args.id} resolved.` : `[workflow-guard] Review follow-up ${args.id} was not open or was not found.` }),
258
277
  guard_review_rubric: tool({
259
- description: "Get the secondary-review rubric for the current branch diff across 5 core review axes (test integrity, task completeness, cleanliness, security, platform). The orchestrator should call this before finalizing work or creating a PR, then spawn a reviewer subagent with the rubric as the prompt, which records its verdict via record_review.", args: { base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)") },
260
- execute: async (args) => {
278
+ description: "Get the secondary-review rubric for the current branch diff across 5 core review axes (test integrity, task completeness, cleanliness, security, platform). The orchestrator should call this before finalizing work or creating a PR, then spawn a reviewer subagent with the rubric as the prompt, which records its verdict via record_review.",
279
+ args: {
280
+ base: tool.schema.string().optional().describe("Base ref to diff against (default: origin/main, origin/master, main)"),
281
+ directory: tool.schema.string().optional().describe("Target repository directory (defaults to active session directory or workspace root)"),
282
+ },
283
+ execute: async (args, toolContext) => {
261
284
  const sanitizedBase = typeof args.base === "string" && !args.base.startsWith("-") && !/[\s;'"\0]/.test(args.base) ? args.base : undefined;
262
285
  const bases = sanitizedBase ? [sanitizedBase] : ["origin/main", "origin/master", "main", "master"];
286
+ const rubricWorkspace = await resolveEffectiveWorkspace({ sessionID: toolContext?.sessionID, directory: args?.directory, fallback: toolContext?.worktree || toolContext?.directory || effectiveRoot });
263
287
  let diffText = "";
264
- for (const base of bases) { const res = spawnSync("git", ["diff", "--", `${base}...HEAD`], { cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 }); if (res.status === 0 && res.stdout.trim()) { diffText = res.stdout; break; } }
265
- if (!diffText) { const last = spawnSync("git", ["diff", "--", "HEAD~1"], { cwd: getWorkspaceRoot(), encoding: "utf8", timeout: 10_000 }); diffText = last.status === 0 ? last.stdout : "(no diff available)"; }
288
+ for (const base of bases) { const res = spawnSync("git", ["diff", "--", `${base}...HEAD`], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); if (res.status === 0 && res.stdout.trim()) { diffText = res.stdout; break; } }
289
+ if (!diffText) { const last = spawnSync("git", ["diff", "--", "HEAD~1"], { cwd: rubricWorkspace, encoding: "utf8", timeout: 10_000 }); diffText = last.status === 0 ? last.stdout : "(no diff available)"; }
266
290
  return buildReviewRubric(diffText);
267
291
  },
268
292
  }),
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ToolOutcome } from "./tool-outcomes.ts";
3
3
  import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
4
+ import { isSameGitRepo } from "./project-config.ts";
4
5
 
5
6
  function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
6
7
  return createHash("sha256").update(JSON.stringify({ kind, observedAt, subject, source })).digest("hex");
@@ -40,7 +41,11 @@ export function agentAssertionEvidence(assertion: string, observedAt: number, su
40
41
  }
41
42
 
42
43
  export function evidenceMatchesSubject(evidence: EvidenceRecord, subject: EvidenceSubject): boolean {
43
- return evidence.subject.workspace === subject.workspace &&
44
+ const workspaceMatches =
45
+ evidence.subject.workspace === subject.workspace ||
46
+ (Boolean(evidence.subject.workspace && subject.workspace) &&
47
+ isSameGitRepo(evidence.subject.workspace, subject.workspace));
48
+ return workspaceMatches &&
44
49
  evidence.subject.commitHash === subject.commitHash &&
45
50
  evidence.subject.worktreeFingerprint === subject.worktreeFingerprint &&
46
51
  evidence.subject.sessionID === subject.sessionID;
@@ -1,8 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { loadVerifyCache } from "./audit.ts";
3
+ import { loadReviewCache, loadVerifyCache } from "./audit.ts";
4
4
  import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
5
- import { projectRootKey } from "./project-config.ts";
5
+ import { findGitRoot, isSameGitRepo, projectRootKey } from "./project-config.ts";
6
6
  import {
7
7
  getLastReviewResult,
8
8
  getSdkClient,
@@ -34,6 +34,7 @@ import {
34
34
  getGitStatusSummary,
35
35
  getGitWorktreeFingerprint,
36
36
  runVerify,
37
+ resolveVerifyTimeoutMs,
37
38
  snipVerifyOutput,
38
39
  } from "./verify.ts";
39
40
  import { detectShellMutation, extractPatchPaths, guardShellMutation, isPathOutsideWorkspace } from "../policies/boundary.ts";
@@ -55,6 +56,7 @@ import {
55
56
  onProtectedBranch,
56
57
  parseGitInvocation,
57
58
  pushedProtectedBranchIn,
59
+ tagRefspecIn,
58
60
  } from "../policies/git.ts";
59
61
  import { extractInterpreterPayload, outsideWritePathInPayload, secretPathInPayload, writePathsInPayload } from "../policies/interpreter.ts";
60
62
  import { mcpMutationTool } from "../policies/mcp.ts";
@@ -180,7 +182,7 @@ export async function guardToolCallImpl(
180
182
  const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && isEvidenceFresh(verificationEvidence(verifyResult, sessionID), currentSubject, mutationTimestamp);
181
183
  if (!isFresh) {
182
184
  if (context?.simulate) return block("verification", "verification_required", `Blocked: finalization requires fresh passing verification (${command}).`);
183
- const result = await runVerify(command, currentRoot);
185
+ const result = await runVerify(command, currentRoot, resolveVerifyTimeoutMs(currentRoot));
184
186
  recordVerifyResult(command, result, sessionID, currentRoot);
185
187
  if (!result.passed) {
186
188
  const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
@@ -248,7 +250,6 @@ export async function guardToolCallImpl(
248
250
  }
249
251
  }
250
252
  }
251
- for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
252
253
  if (onProtectedBranch(currentRoot)) {
253
254
  logPolicyBlock(`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`);
254
255
  return block("git", "protected_branch", branchGuardReason());
@@ -329,6 +330,7 @@ export async function guardToolCallImpl(
329
330
  }
330
331
  }
331
332
  for (const invocation of gitInvocations) {
333
+ if (tagRefspecIn(`git ${invocation.rest}`, invocation.repoDir)) continue;
332
334
  if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
333
335
  logPolicyBlock(`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`);
334
336
  return block("git", "protected_branch", branchGuardReason());
@@ -397,6 +399,7 @@ export async function guardToolCallImpl(
397
399
  for (const invocation of gitInvocations) {
398
400
  const pushText = `git ${invocation.rest}`;
399
401
  if (!/\bgit\s+push\b/.test(pushText)) continue;
402
+ if (tagRefspecIn(pushText, invocation.repoDir)) continue;
400
403
  const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
401
404
  if (pushedBranch) {
402
405
  logPolicyBlock(`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`);
@@ -405,6 +408,7 @@ export async function guardToolCallImpl(
405
408
  }
406
409
  for (const invocation of gitInvocations) {
407
410
  if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
411
+ if (tagRefspecIn(`git ${invocation.rest}`, invocation.repoDir)) continue;
408
412
  if (onProtectedBranch(invocation.repoDir)) {
409
413
  logPolicyBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
410
414
  return block("git", "protected_branch", branchGuardReason());
@@ -435,7 +439,13 @@ export async function guardToolCallImpl(
435
439
  if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
436
440
  }
437
441
  if (isReviewRequired(prRoot)) {
438
- const review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
442
+ let review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
443
+ if (!review) {
444
+ const diskCached = loadReviewCache();
445
+ if (diskCached && diskCached.passed && diskCached.workspace && (projectRootKey(diskCached.workspace) === projectRootKey(prRoot) || isSameGitRepo(diskCached.workspace, prRoot))) {
446
+ review = diskCached;
447
+ }
448
+ }
439
449
  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
450
  if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
441
451
  }
@@ -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 {
package/src/lib/types.ts CHANGED
@@ -27,6 +27,7 @@ export interface ProjectConfig {
27
27
  profile?: "interactive" | "autonomous";
28
28
  protectedBranches?: string[];
29
29
  verifyCommand?: string;
30
+ verifyTimeoutMs?: number;
30
31
  postEditValidators?: Array<{
31
32
  pattern: string;
32
33
  command: string;