opencode-workflow-guard 1.8.7 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-workflow-guard",
3
- "version": "1.8.7",
3
+ "version": "1.9.0",
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, VerifyResult } from "./types.ts";
6
+ import type { AuditEntry, PolicyDecision, VerifyResult } from "./types.ts";
7
7
  import { asRecord } from "./utils.ts";
8
8
 
9
9
  const AUDIT_DIR = join(
@@ -207,17 +207,19 @@ export function logDecision(
207
207
  tool: string,
208
208
  input: unknown,
209
209
  context: { sessionID?: string; callID?: string } | undefined,
210
- reason: string | undefined,
210
+ policyDecision: PolicyDecision,
211
211
  evidence?: AuditEntry["evidence"],
212
212
  ): void {
213
+ const blocked = policyDecision.status !== "allowed";
213
214
  audit({
214
215
  ts: new Date().toISOString(),
215
216
  sessionID: context?.sessionID,
216
217
  callID: context?.callID,
217
218
  tool,
218
- decision: reason ? "block" : "allow",
219
+ decision: blocked ? "block" : "allow",
220
+ policyDecision,
219
221
  phase: "decision",
220
- reason,
222
+ reason: blocked ? policyDecision.message : undefined,
221
223
  input: summarizeInput(input),
222
224
  evidence,
223
225
  });
@@ -12,24 +12,31 @@ import {
12
12
  searchProjectMemory,
13
13
  } from "./project-memory.ts";
14
14
  import { loadLearnerProfile, recordLearningEvidence, selectLearningOpportunity, updateLearnerProfile } from "./learning.ts";
15
+ import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
15
16
  import {
16
17
  getLearningInterventionBudget,
18
+ getOperationProfile,
17
19
  getProjectConfig,
18
- getLastMutationTimestamp,
19
- getLastReviewResult,
20
- getLastVerifyResult,
21
- getMutationCount,
20
+ getRalphMaxIterations,
21
+ getLastReviewResultForWorkspace,
22
+ getLastVerifyResultForWorkspace,
23
+ getWorkspaceMutationCount,
24
+ getWorkspaceMutationTimestamp,
22
25
  getWorkspaceRoot,
23
26
  isDocumentationRequired,
27
+ isRecoveryCheckpointsEnabled,
28
+ isRalphModeEnabled,
24
29
  isReviewRequired,
25
30
  recordMutation,
26
31
  recordReviewResult,
27
32
  runWithRuntimeState,
28
33
  } from "./state.ts";
29
- import { loadProjectConfig } from "./project-config.ts";
30
- import { detectVerifyCommand, getCurrentGitCommitHash } from "./verify.ts";
34
+ import { getRalphOutcome } from "../policies/continuation.ts";
35
+ import { loadProjectConfig, projectRootKey } from "./project-config.ts";
36
+ import { detectVerifyCommand, getCurrentGitCommitHash, getGitWorktreeFingerprint } from "./verify.ts";
31
37
  import { buildReviewRubric } from "./review.ts";
32
38
  import { createGitWorktree, cleanupGitWorktree } from "./worktree.ts";
39
+ import { branchHasDocumentationChange } from "../policies/docs.ts";
33
40
  import { restoreRecoveryCheckpoint } from "./checkpoint.ts";
34
41
  import { audit, getRecentAuditEntries } from "./audit.ts";
35
42
  import { guardToolCallImpl } from "./guard-dispatcher.ts";
@@ -58,7 +65,7 @@ export function createCustomTools(options: {
58
65
  }) {
59
66
  const { effectiveRoot, projectMemoryEnabled, learningEnabled, projectMemory, followupStore, portableMemoryPath, learningInterventions, client } = options;
60
67
  return {
61
- ...(getProjectConfig(effectiveRoot).recoveryCheckpoints === true ? {
68
+ ...(isRecoveryCheckpointsEnabled(effectiveRoot) ? {
62
69
  guard_recovery_restore: tool({
63
70
  description: "Restore this root session's workspace to its pre-run recovery checkpoint. Refuses if the run has not gone idle or if the workspace changed after that boundary.",
64
71
  args: { run: tool.schema.number() },
@@ -145,13 +152,27 @@ export function createCustomTools(options: {
145
152
  } : {}),
146
153
  guard_status: tool({
147
154
  description: "Inspect active guardrails, current branch protection, and verification/review status.", args: {},
148
- execute: async () => {
149
- const root = getWorkspaceRoot(); const branch = currentGitBranch(root) ?? "unknown"; const isProtected = onProtectedBranch(root); const lastV = getLastVerifyResult(); const lastR = getLastReviewResult(); const lastMut = getLastMutationTimestamp(); const cfg = loadProjectConfig(root);
150
- return JSON.stringify({ workspaceRoot: root, branch, onProtectedBranch: isProtected, lastMutationTimestamp: lastMut, mutationCount: getMutationCount(), lastVerify: lastV ? { command: lastV.command, passed: lastV.passed, fresh: lastV.timestamp >= lastMut, commitHash: lastV.commitHash } : null, lastReview: lastR ? { reviewer: lastR.reviewer, passed: lastR.passed, summary: lastR.summary } : null, projectConfig: { protectedBranches: cfg.protectedBranches ?? ["main", "master"], verifyCommand: detectVerifyCommand(root) ?? null, requireReview: isReviewRequired(root), requireDocumentation: isDocumentationRequired(root) } }, null, 2);
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);
157
+ const subject = { workspace: projectRootKey(root), commitHash: getCurrentGitCommitHash(root), worktreeFingerprint: getGitWorktreeFingerprint(root) };
158
+ const verifyEvidence = lastV ? verificationEvidence(lastV) : undefined;
159
+ const reviewEvidenceRecord = lastR ? reviewEvidence(lastR) : undefined;
160
+ const verifyCommand = detectVerifyCommand(root);
161
+ const verifyFresh = Boolean(lastV && verifyEvidence && lastV.passed && lastV.command === verifyCommand && isEvidenceFresh(verifyEvidence, subject, lastMut));
162
+ const reviewRequired = isReviewRequired(root);
163
+ const reviewFresh = Boolean(lastR && reviewEvidenceRecord && lastR.passed && isEvidenceFresh(reviewEvidenceRecord, { ...subject, sessionID: lastR.targetSessionID }, lastMut));
164
+ const documentationRequired = isDocumentationRequired(root);
165
+ const outstandingRequirements = [
166
+ ...(verifyCommand && !verifyFresh ? ["verification"] : []),
167
+ ...(reviewRequired && !reviewFresh ? ["review"] : []),
168
+ ...(documentationRequired && !branchHasDocumentationChange(root) ? ["documentation"] : []),
169
+ ];
170
+ 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);
151
172
  },
152
173
  }),
153
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) }),
154
- guard_why: tool({ description: "Simulate and explain whether a specific tool call or command would be blocked by guardrails.", 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) => { const reason = await guardToolCallImpl(args.tool, args.input ?? {}); return reason ? `BLOCKED: ${reason}` : "ALLOWED: Satisfies all current guardrails."; } }),
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) }),
155
176
  record_review: tool({
156
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.",
157
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") },
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ToolOutcome } from "./tool-outcomes.ts";
3
+ import type { EvidenceRecord, EvidenceSubject, PolicyDecision, ReviewResult, VerifyResult } from "./types.ts";
4
+
5
+ function evidenceId(kind: EvidenceRecord["kind"], observedAt: number, subject: EvidenceRecord["subject"], source: Record<string, unknown>): string {
6
+ return createHash("sha256").update(JSON.stringify({ kind, observedAt, subject, source })).digest("hex");
7
+ }
8
+
9
+ export function verificationEvidence(result: VerifyResult, sessionID?: string): EvidenceRecord {
10
+ const subject = { workspace: result.workspaceRoot, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID };
11
+ const source = { command: result.command, passed: result.passed, durationMs: result.durationMs, gitStatus: result.gitStatus };
12
+ return { id: evidenceId("verification", result.timestamp, subject, source), kind: "verification", confidence: "deterministic_observation", observedAt: result.timestamp, subject, source };
13
+ }
14
+
15
+ export function reviewEvidence(result: ReviewResult): EvidenceRecord {
16
+ const subject = { workspace: result.workspace, commitHash: result.commitHash, worktreeFingerprint: result.worktreeFingerprint, sessionID: result.targetSessionID, actor: result.reviewer };
17
+ const source = { reviewer: result.reviewer, passed: result.passed, summary: result.summary, gitStatus: result.gitStatus };
18
+ return { id: evidenceId("review", result.timestamp, subject, source), kind: "review", confidence: "attestation", observedAt: result.timestamp, subject, source };
19
+ }
20
+
21
+ export function toolOutcomeEvidence(outcome: ToolOutcome, observedAt: number, workspace?: string): EvidenceRecord {
22
+ const subject = { workspace, sessionID: outcome.sessionID, callID: outcome.callID };
23
+ const source = { tool: outcome.tool, status: outcome.status, durationMs: outcome.durationMs, repeatedFailureCount: outcome.repeatedFailureCount };
24
+ return { id: evidenceId("tool_outcome", observedAt, subject, source), kind: "tool_outcome", confidence: "deterministic_observation", observedAt, subject, source };
25
+ }
26
+
27
+ export function policyDecisionEvidence(decision: PolicyDecision, observedAt: number, subject: EvidenceSubject): EvidenceRecord {
28
+ const source = { status: decision.status, code: decision.code, policy: decision.policy };
29
+ return { id: evidenceId("policy_evaluation", observedAt, subject, source), kind: "policy_evaluation", confidence: "deterministic_observation", observedAt, subject, source };
30
+ }
31
+
32
+ export function lifecycleStateEvidence(state: string, observedAt: number, subject: EvidenceSubject, source: Record<string, unknown> = {}): EvidenceRecord {
33
+ const lifecycleSource = { state, ...source };
34
+ return { id: evidenceId("lifecycle_state", observedAt, subject, lifecycleSource), kind: "lifecycle_state", confidence: "derived_state", observedAt, subject, source: lifecycleSource };
35
+ }
36
+
37
+ export function agentAssertionEvidence(assertion: string, observedAt: number, subject: EvidenceSubject): EvidenceRecord {
38
+ const source = { assertion };
39
+ return { id: evidenceId("agent_assertion", observedAt, subject, source), kind: "agent_assertion", confidence: "agent_assertion", observedAt, subject, source };
40
+ }
41
+
42
+ export function evidenceMatchesSubject(evidence: EvidenceRecord, subject: EvidenceSubject): boolean {
43
+ return evidence.subject.workspace === subject.workspace &&
44
+ evidence.subject.commitHash === subject.commitHash &&
45
+ evidence.subject.worktreeFingerprint === subject.worktreeFingerprint &&
46
+ evidence.subject.sessionID === subject.sessionID;
47
+ }
48
+
49
+ export function isEvidenceFresh(evidence: EvidenceRecord, subject: EvidenceSubject, mutationTimestamp: number): boolean {
50
+ // HEAD is legitimately absent in a freshly initialized repository, but the
51
+ // worktree fingerprint remains available there and is required for freshness.
52
+ if (!evidence.subject.worktreeFingerprint || !subject.worktreeFingerprint) return false;
53
+ return evidence.observedAt >= mutationTimestamp && evidenceMatchesSubject(evidence, subject);
54
+ }
@@ -1,6 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { loadVerifyCache } from "./audit.ts";
4
+ import { isEvidenceFresh, reviewEvidence, verificationEvidence } from "./evidence.ts";
5
+ import { projectRootKey } from "./project-config.ts";
4
6
  import {
5
7
  getLastReviewResult,
6
8
  getSdkClient,
@@ -24,6 +26,7 @@ import {
24
26
  unwrapShellCommand,
25
27
  } from "./shell.ts";
26
28
  import { asRecord, extractTargetPath } from "./utils.ts";
29
+ import type { PolicyDecision } from "./types.ts";
27
30
  import { getProjectMemoryIdentity } from "./project-memory.ts";
28
31
  import {
29
32
  detectVerifyCommand,
@@ -37,7 +40,7 @@ import { detectShellMutation, extractPatchPaths, guardShellMutation, isPathOutsi
37
40
  import { branchHasChangelogChange, checkLockfileSync, hasPrCreateInvocation, prBodyHasLiteralLineBreakEscapes, prBodyIncludesChangelog } from "../policies/changelog.ts";
38
41
  import { extractEditContent, liveMutationIn } from "../policies/destructive.ts";
39
42
  import { branchHasDocumentationChange } from "../policies/docs.ts";
40
- import { claimFiles } from "../policies/file-claims.ts";
43
+ import { claimFiles, fileClaimConflictReason } from "../policies/file-claims.ts";
41
44
  import {
42
45
  GIT_BRANCH_CREATE_RE,
43
46
  GIT_WRITE_RE,
@@ -88,7 +91,8 @@ export function isReadOnlyRole(agent?: string): boolean {
88
91
  return READ_ONLY_ROLES.has(lower) || Array.from(READ_ONLY_ROLES).some((r) => lower.includes(r));
89
92
  }
90
93
 
91
- function logBlock(message: string): void {
94
+ function logBlock(message: string, simulate = false): void {
95
+ if (simulate) return;
92
96
  try {
93
97
  void getSdkClient()?.app?.log?.({
94
98
  body: {
@@ -103,22 +107,27 @@ function logBlock(message: string): void {
103
107
  export async function guardToolCallImpl(
104
108
  toolName: string,
105
109
  input: unknown,
106
- context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
107
- ): Promise<string | undefined> {
110
+ context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string; simulate?: boolean },
111
+ ): Promise<PolicyDecision> {
112
+ const logPolicyBlock = (message: string) => logBlock(message, context?.simulate);
108
113
  const currentRoot = getWorkspaceRoot();
114
+ const allow = (): PolicyDecision => ({ status: "allowed", code: "allowed", message: "Allowed by current guardrails." });
115
+ const block = (policy: string, code: string, message: string): PolicyDecision => ({ status: "blocked", policy, code, message });
116
+ const needsApproval = (policy: string, code: string, message: string): PolicyDecision => ({ status: "needs_approval", policy, code, message });
117
+ let remotePrStateUnchecked = false;
109
118
 
110
119
  if (context?.agent && isReadOnlyRole(context.agent)) {
111
120
  if (EDIT_TOOL_NAMES.has(toolName) || toolName.startsWith("guard_worktree_")) {
112
121
  const reason = `Blocked: subagent with read-only role '${context.agent}' cannot perform file mutations or lifecycle changes.`;
113
- logBlock(`[workflow-guard] ${reason}`);
114
- return reason;
122
+ logPolicyBlock(`[workflow-guard] ${reason}`);
123
+ return block("subagent-role", "read_only_role", reason);
115
124
  }
116
125
  }
117
126
 
118
127
  if (toolName === "read" || toolName === "read_file") {
119
128
  const target = extractTargetPath(input) ?? "";
120
129
  if (target && isSecretPath(target)) {
121
- logBlock(`[workflow-guard] blocked read: secret file ${target}`);
130
+ logPolicyBlock(`[workflow-guard] blocked read: secret file ${target}`);
122
131
  if (isEnvFilePath(target)) {
123
132
  let schemaHint = "";
124
133
  try {
@@ -129,11 +138,11 @@ export async function guardToolCallImpl(
129
138
  schemaHint = `\n\nSafe variable schema mask:\n\`\`\`\n${masked.slice(0, 800)}\n\`\`\``;
130
139
  }
131
140
  } catch {}
132
- return `Blocked: reading sensitive credential file '${target}' directly is not permitted. Use environment variables or reference safe templates.${schemaHint}`;
141
+ return block("secrets", "secret_read", `Blocked: reading sensitive credential file '${target}' directly is not permitted. Use environment variables or reference safe templates.${schemaHint}`);
133
142
  }
134
- return `Blocked: reading sensitive credential/secret file '${target}' is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`;
143
+ return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${target}' is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
135
144
  }
136
- return undefined;
145
+ return allow();
137
146
  }
138
147
 
139
148
  if (toolName === "todowrite") {
@@ -144,8 +153,8 @@ export async function guardToolCallImpl(
144
153
  const existingTodos = context?.sessionID ? await fetchSessionTodos(context.sessionID) : undefined;
145
154
  const err = validateTodoLifecycle(newTodos, existingTodos);
146
155
  if (err) {
147
- logBlock(`[workflow-guard] ${err}`);
148
- return err;
156
+ logPolicyBlock(`[workflow-guard] ${err}`);
157
+ return block("todo", "todo_lifecycle", err);
149
158
  }
150
159
  const allDone = newTodos.length > 0 && newTodos.every((t) => {
151
160
  const s = String(t.status ?? "");
@@ -155,8 +164,8 @@ export async function guardToolCallImpl(
155
164
  const conflictCheck = checkMergeConflicts(currentRoot);
156
165
  if (conflictCheck.hasConflicts) {
157
166
  const reason = `Blocked todowrite: ${conflictCheck.reason}`;
158
- logBlock(`[workflow-guard] ${reason}`);
159
- return reason;
167
+ logPolicyBlock(`[workflow-guard] ${reason}`);
168
+ return block("git", "merge_conflict", reason);
160
169
  }
161
170
  const command = detectVerifyCommand(currentRoot);
162
171
  if (command) {
@@ -164,27 +173,26 @@ export async function guardToolCallImpl(
164
173
  let verifyResult = sessionID ? sessionVerifyResults.get(sessionID) : lastVerify;
165
174
  if (!verifyResult) {
166
175
  const diskCached = loadVerifyCache();
167
- if (diskCached && diskCached.passed && diskCached.command === command && diskCached.workspaceRoot === resolve(currentRoot)) verifyResult = diskCached;
176
+ if (diskCached && diskCached.passed && diskCached.command === command && diskCached.workspaceRoot && projectRootKey(diskCached.workspaceRoot) === projectRootKey(currentRoot)) verifyResult = diskCached;
168
177
  }
169
178
  const mutationTimestamp = sessionID ? (sessionMutationTimestamps.get(sessionID) ?? 0) : lastMutationTimestamp;
170
- const currentCommit = getCurrentGitCommitHash(currentRoot);
171
- const currentGitStatus = getGitStatusSummary(currentRoot);
172
- const gitStateMatches = !verifyResult?.commitHash || (verifyResult.commitHash === currentCommit && verifyResult.gitStatus === currentGitStatus);
173
- const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && verifyResult.timestamp >= mutationTimestamp && gitStateMatches;
179
+ const currentSubject = { workspace: projectRootKey(currentRoot), commitHash: getCurrentGitCommitHash(currentRoot), worktreeFingerprint: getGitWorktreeFingerprint(currentRoot), sessionID };
180
+ const isFresh = verifyResult !== undefined && verifyResult.passed && verifyResult.command === command && isEvidenceFresh(verificationEvidence(verifyResult, sessionID), currentSubject, mutationTimestamp);
174
181
  if (!isFresh) {
182
+ if (context?.simulate) return block("verification", "verification_required", `Blocked: finalization requires fresh passing verification (${command}).`);
175
183
  const result = await runVerify(command, currentRoot);
176
184
  recordVerifyResult(command, result, sessionID, currentRoot);
177
185
  if (!result.passed) {
178
186
  const tail = snipVerifyOutput(result.output, result.passed).slice(-500);
179
187
  const reason = `Blocked todowrite: all tasks marked done but verification is failing (${command}). Fix the failure before finishing; output tail: ${tail}`;
180
- logBlock(`[workflow-guard] ${reason}`);
181
- return reason;
188
+ logPolicyBlock(`[workflow-guard] ${reason}`);
189
+ return block("verification", "verification_failed", reason);
182
190
  }
183
191
  }
184
192
  }
185
193
  }
186
194
  }
187
- return undefined;
195
+ return allow();
188
196
  }
189
197
 
190
198
  if (EDIT_TOOL_NAMES.has(toolName)) {
@@ -192,39 +200,39 @@ export async function guardToolCallImpl(
192
200
  const record = asRecord(input);
193
201
  const target = extractTargetPath(input) ?? "";
194
202
  if (target && isProtectedPath(target)) {
195
- logBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
196
- return PROTECTED_PATH_REASON;
203
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: protected path ${target}`);
204
+ return block("tamper", "protected_path", PROTECTED_PATH_REASON);
197
205
  }
198
206
  if (target && isSecretPath(target)) {
199
- logBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
200
- return `Blocked: modifying secret file '${target}' directly is not permitted. Store credentials in environment variables or safe secret stores instead.`;
207
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: secret file path ${target}`);
208
+ return block("secrets", "secret_write", `Blocked: modifying secret file '${target}' directly is not permitted. Store credentials in environment variables or safe secret stores instead.`);
201
209
  }
202
210
  if (toolName === "apply_patch") {
203
211
  const patchText = typeof record?.patchText === "string" ? record.patchText : "";
204
212
  for (const patchPath of extractPatchPaths(patchText)) {
205
213
  if (isProtectedPath(patchPath)) {
206
- logBlock(`[workflow-guard] blocked apply_patch: protected path ${patchPath}`);
207
- return PROTECTED_PATH_REASON;
214
+ logPolicyBlock(`[workflow-guard] blocked apply_patch: protected path ${patchPath}`);
215
+ return block("tamper", "protected_path", PROTECTED_PATH_REASON);
208
216
  }
209
217
  if (isSecretPath(patchPath)) {
210
- logBlock(`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`);
211
- return `Blocked: modifying secret file '${patchPath}' directly is not permitted.`;
218
+ logPolicyBlock(`[workflow-guard] blocked apply_patch: secret file path ${patchPath}`);
219
+ return block("secrets", "secret_write", `Blocked: modifying secret file '${patchPath}' directly is not permitted.`);
212
220
  }
213
221
  if (isPathOutsideWorkspace(patchPath, currentRoot)) {
214
- logBlock(`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`);
215
- return `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`;
222
+ logPolicyBlock(`[workflow-guard] blocked apply_patch: patch target escapes workspace: ${patchPath}`);
223
+ return block("boundary", "workspace_escape", `Blocked: patch targets file '${patchPath}' outside workspace root (${currentRoot}).`);
216
224
  }
217
225
  }
218
226
  }
219
227
  if (target && isPathOutsideWorkspace(target, currentRoot)) {
220
- logBlock(`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`);
221
- return `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`;
228
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: path escapes workspace: ${target}`);
229
+ return block("boundary", "workspace_escape", `Blocked: file path '${target}' escapes workspace root (${currentRoot}). All changes must stay within the workspace.`);
222
230
  }
223
231
  for (const content of extractEditContent(input)) {
224
232
  const secret = secretIn(content);
225
233
  if (secret) {
226
- logBlock(`[workflow-guard] blocked ${toolName}: payload contains ${secret}`);
227
- return `Blocked: payload appears to contain a ${secret}. Secrets must not be committed to the repository. Store them in a secret manager or environment file excluded from git, and reference them by name instead.`;
234
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: payload contains ${secret}`);
235
+ return block("secrets", "secret_payload", `Blocked: payload appears to contain a ${secret}. Secrets must not be committed to the repository. Store them in a secret manager or environment file excluded from git, and reference them by name instead.`);
228
236
  }
229
237
  }
230
238
  if (!allowLive) {
@@ -234,58 +242,61 @@ export async function guardToolCallImpl(
234
242
  if (trimmed.startsWith("#") || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) continue;
235
243
  const what = liveMutationIn(normalizeGitCommands(line));
236
244
  if (what) {
237
- logBlock(`[workflow-guard] blocked ${toolName}: payload contains ${what}`);
238
- return `Blocked: the file you are writing contains a ${what}. Script files are not a way to smuggle destructive commands past the shell guard. Only the user can allow live mutations (WORKFLOW_GUARD_ALLOW_LIVE=1).`;
245
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: payload contains ${what}`);
246
+ return block("destructive", "live_mutation_payload", `Blocked: the file you are writing contains a ${what}. Script files are not a way to smuggle destructive commands past the shell guard. Only the user can allow live mutations (WORKFLOW_GUARD_ALLOW_LIVE=1).`);
239
247
  }
240
248
  }
241
249
  }
242
250
  }
243
- for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return PROTECTED_PATH_REASON;
251
+ for (const content of extractEditContent(input)) if (isSettingsTamper(content)) return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
244
252
  if (onProtectedBranch(currentRoot)) {
245
- logBlock(`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`);
246
- return branchGuardReason();
253
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: on protected branch ${currentGitBranch(currentRoot)}`);
254
+ return block("git", "protected_branch", branchGuardReason());
247
255
  }
248
256
  const todos = await effectiveTodos(context?.sessionID);
249
257
  if (todos !== undefined && !hasActiveTodo(todos)) {
250
- logBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
251
- return "Blocked: no active todo item. First break the request down with the todowrite tool (create items with status 'pending' or 'in_progress'), then work them through, marking each completed via todowrite as you finish it. When every item is completed, create a fresh todo list before starting new work.";
258
+ logPolicyBlock(`[workflow-guard] blocked ${toolName}: no active todo item (session ${context?.sessionID ?? "?"})`);
259
+ return block("todo", "no_active_todo", "Blocked: no active todo item. First break the request down with the todowrite tool (create items with status 'pending' or 'in_progress'), then work them through, marking each completed via todowrite as you finish it. When every item is completed, create a fresh todo list before starting new work.");
252
260
  }
253
261
  if (context?.sessionID) {
254
262
  const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
255
263
  if (reason) {
256
- logBlock(`[workflow-guard] ${reason}`);
257
- return reason;
264
+ logPolicyBlock(`[workflow-guard] ${reason}`);
265
+ return block("todo", "mutation_budget", reason);
258
266
  }
259
267
  }
260
- if (context?.sessionID && context.callID) {
268
+ if (context?.sessionID) {
261
269
  if (toolName === "edit" || toolName === "write") {
262
270
  for (const path of editTargets(input, currentRoot)) {
263
271
  const staleReason = staleWriteReason(path, context.sessionID);
264
272
  if (staleReason) {
265
- logBlock(`[workflow-guard] ${staleReason}`);
266
- return staleReason;
273
+ logPolicyBlock(`[workflow-guard] ${staleReason}`);
274
+ return block("stale-write", "stale_write", staleReason);
267
275
  }
268
276
  }
269
277
  }
270
- const claimReason = claimFiles(editTargets(input, currentRoot), context.sessionID, context.callID);
278
+ const targets = editTargets(input, currentRoot);
279
+ const claimReason = context.simulate
280
+ ? fileClaimConflictReason(targets, context.sessionID)
281
+ : context.callID ? claimFiles(targets, context.sessionID, context.callID) : undefined;
271
282
  if (claimReason) {
272
- logBlock(`[workflow-guard] ${claimReason}`);
273
- return claimReason;
283
+ logPolicyBlock(`[workflow-guard] ${claimReason}`);
284
+ return block("file-claims", "file_claim_conflict", claimReason);
274
285
  }
275
286
  }
276
- recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
277
- return undefined;
287
+ if (!context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
288
+ return allow();
278
289
  }
279
290
 
280
291
  const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
281
292
  if (!allowLive) {
282
293
  const mcpWhat = mcpMutationTool(toolName);
283
294
  if (mcpWhat) {
284
- logBlock(`[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`);
285
- return `Blocked: ${toolName} mutates ${mcpWhat} - a live system. Changes must be made in code unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent.`;
295
+ logPolicyBlock(`[workflow-guard] blocked MCP tool ${toolName} (${mcpWhat} mutation)`);
296
+ return block("mcp", "live_mcp_mutation", `Blocked: ${toolName} mutates ${mcpWhat} - a live system. Changes must be made in code unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent.`);
286
297
  }
287
298
  }
288
- if (!SHELL_TOOL_NAMES.has(toolName)) return undefined;
299
+ if (!SHELL_TOOL_NAMES.has(toolName)) return allow();
289
300
  const shellWorkdir = asRecord(input)?.workdir;
290
301
  const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
291
302
  const shellRoot = !isPathOutsideWorkspace(requestedShellRoot, currentRoot) || getProjectMemoryIdentity(requestedShellRoot) === getProjectMemoryIdentity(currentRoot)
@@ -294,116 +305,117 @@ export async function guardToolCallImpl(
294
305
 
295
306
  for (const raw of extractCommands(input)) {
296
307
  const dynamicSyntax = dynamicShellSyntaxIn(raw);
297
- if (dynamicSyntax) return `Blocked: command contains ${dynamicSyntax} that cannot be safely classified without executing shell expansion.`;
308
+ if (dynamicSyntax) return block("shell-safety", "dynamic_shell_syntax", `Blocked: command contains ${dynamicSyntax} that cannot be safely classified without executing shell expansion.`);
298
309
  const command = normalize(raw);
299
310
  const ttyCheck = checkInteractiveTtyCommand(command);
300
311
  if (ttyCheck.isInteractive) {
301
- logBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
302
- return `Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ${ttyCheck.advice}`;
312
+ logPolicyBlock(`[workflow-guard] blocked interactive TTY command: ${command.slice(0, 100)}`);
313
+ return block("shell-safety", "interactive_tty", `Blocked: '${command.slice(0, 60)}' is an ${ttyCheck.name} and will hang non-interactive agent execution. ${ttyCheck.advice}`);
303
314
  }
304
315
  const packageCheck = checkPackageHygiene(command);
305
316
  if (packageCheck.isViolating && !allowLive) {
306
- logBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
307
- return `Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ${packageCheck.advice}`;
317
+ logPolicyBlock(`[workflow-guard] blocked package supply-chain violation: ${command.slice(0, 100)}`);
318
+ return block("shell-safety", "package_hygiene", `Blocked: '${command.slice(0, 60)}' is a ${packageCheck.name}. ${packageCheck.advice}`);
308
319
  }
309
320
  const normalizedCommand = normalizeGitCommands(command);
310
- if (hasUnsafeGitAlias(command)) return "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.";
321
+ if (hasUnsafeGitAlias(command)) return block("git", "unsafe_git_alias", "Blocked: per-invocation Git aliases cannot be used because they can hide guarded Git operations.");
311
322
  const gitInvocations = splitShellSegments(command).map((segment) => parseGitInvocation(segment.trim())).filter((invocation): invocation is NonNullable<ReturnType<typeof parseGitInvocation>> => invocation !== undefined);
312
323
  const effectiveRoot = gitInvocations[0]?.repoDir ?? currentRoot;
313
324
  for (const invocation of gitInvocations) {
314
325
  const normalizedInvocation = `git ${invocation.rest}`;
315
326
  if (isPathOutsideWorkspace(invocation.repoDir, currentRoot) && (GIT_WRITE_RE.test(normalizedInvocation) || /\bgit\s+push\b/.test(normalizedInvocation))) {
316
- logBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
317
- return `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
327
+ logPolicyBlock(`[workflow-guard] blocked git mutation on repository outside workspace: ${invocation.repoDir}`);
328
+ return block("boundary", "workspace_escape", `Blocked: git command targets repository '${invocation.repoDir}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`);
318
329
  }
319
330
  }
320
331
  for (const invocation of gitInvocations) {
321
332
  if (GIT_WRITE_RE.test(`git ${invocation.rest}`) && onProtectedBranch(invocation.repoDir)) {
322
- logBlock(`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`);
323
- return branchGuardReason();
333
+ logPolicyBlock(`[workflow-guard] blocked git write on protected branch: ${command.slice(0, 120)}`);
334
+ return block("git", "protected_branch", branchGuardReason());
324
335
  }
325
336
  }
326
337
  if (GIT_BRANCH_CREATE_RE.test(normalizedCommand)) {
327
338
  const behindCheck = checkBranchBaseIsUpToDate(effectiveRoot);
328
339
  if (behindCheck.isBehind) {
329
- logBlock(`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`);
330
- return `Blocked: ${behindCheck.reason}`;
340
+ logPolicyBlock(`[workflow-guard] blocked branch creation: base is behind remote ${behindCheck.baseRef}`);
341
+ return block("git", "branch_base_behind", `Blocked: ${behindCheck.reason}`);
331
342
  }
332
343
  }
333
344
  if (isSettingsTamper(command)) {
334
- logBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
335
- return PROTECTED_PATH_REASON;
345
+ logPolicyBlock(`[workflow-guard] blocked settings tamper: ${command.slice(0, 120)}`);
346
+ return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
336
347
  }
337
348
  for (const segment of command.split(/[\n|;&]+/)) {
338
349
  const secretFile = secretFileReadIn(segment.trim());
339
350
  if (secretFile) {
340
- logBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
341
- return `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`;
351
+ logPolicyBlock(`[workflow-guard] blocked shell read of secret file: ${secretFile}`);
352
+ return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretFile}' via shell is not permitted. Reference environment variables by name or inspect safe templates (e.g. .env.example) instead.`);
342
353
  }
343
354
  }
344
355
  for (const payload of extractInterpreterPayload(raw)) {
345
356
  const outsidePath = outsideWritePathInPayload(payload, currentRoot);
346
357
  if (outsidePath) {
347
- logBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
348
- return `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`;
358
+ logPolicyBlock(`[workflow-guard] blocked interpreter payload writing outside workspace: ${outsidePath}`);
359
+ return block("boundary", "workspace_escape", `Blocked: inline interpreter script targets file '${outsidePath}' outside workspace root (${currentRoot}). All changes must stay within the workspace.`);
349
360
  }
350
361
  const writePaths = writePathsInPayload(payload);
351
362
  if (writePaths.length > 0) {
352
- if (context?.agent && isReadOnlyRole(context.agent)) return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`;
353
- for (const path of writePaths) if (isProtectedPath(path)) return PROTECTED_PATH_REASON;
354
- if (onProtectedBranch(currentRoot)) return branchGuardReason();
363
+ if (context?.agent && isReadOnlyRole(context.agent)) return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations.`);
364
+ for (const path of writePaths) if (isProtectedPath(path)) return block("tamper", "protected_path", PROTECTED_PATH_REASON);
365
+ if (onProtectedBranch(currentRoot)) return block("git", "protected_branch", branchGuardReason());
355
366
  const todos = await effectiveTodos(context?.sessionID);
356
- if (todos !== undefined && !hasActiveTodo(todos)) return "Blocked: inline interpreter file mutation with no active todo item.";
357
- recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
367
+ if (todos !== undefined && !hasActiveTodo(todos)) return block("todo", "no_active_todo", "Blocked: inline interpreter file mutation with no active todo item.");
368
+ if (!context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
358
369
  }
359
370
  if (!allowLive) {
360
371
  const liveCheck = liveMutationIn(normalizeGitCommands(normalize(payload)));
361
372
  if (liveCheck) {
362
- logBlock(`[workflow-guard] blocked interpreter payload containing ${liveCheck}`);
363
- return `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`;
373
+ logPolicyBlock(`[workflow-guard] blocked interpreter payload containing ${liveCheck}`);
374
+ return block("destructive", "live_mutation_payload", `Blocked: inline interpreter script contains a ${liveCheck}. Interpreter payloads cannot smuggle live destructive commands past the guard.`);
364
375
  }
365
376
  }
366
- if (isSettingsTamper(payload)) return PROTECTED_PATH_REASON;
377
+ if (isSettingsTamper(payload)) return block("tamper", "settings_tamper", PROTECTED_PATH_REASON);
367
378
  const secretPath = secretPathInPayload(payload);
368
- if (secretPath) return `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`;
379
+ if (secretPath) return block("secrets", "secret_read", `Blocked: reading sensitive credential/secret file '${secretPath}' via inline interpreter script is not permitted.`);
369
380
  }
370
- const shellMutationReason = await guardShellMutation(command, context?.sessionID);
381
+ const shellMutationReason = await guardShellMutation(command, context?.sessionID, !context?.simulate);
371
382
  if (shellMutationReason) {
372
- logBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
373
- return shellMutationReason;
383
+ logPolicyBlock(`[workflow-guard] blocked shell mutation: ${command.slice(0, 120)}`);
384
+ return block("boundary", "shell_mutation", shellMutationReason);
374
385
  }
375
386
  if (!allowLive) {
376
387
  const what = liveMutationIn(normalizedCommand) ?? liveMutationIn(command);
377
388
  if (what) {
378
- logBlock(`[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`);
379
- return `Blocked: ${what} targets a live system. Changes must be made in code (IaC, migrations, source) unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent.`;
389
+ logPolicyBlock(`[workflow-guard] blocked ${what}: ${command.slice(0, 120)}`);
390
+ return block("destructive", "live_mutation", `Blocked: ${what} targets a live system. Changes must be made in code (IaC, migrations, source) unless the user explicitly allows live changes. Only the user can override this, via the WORKFLOW_GUARD_ALLOW_LIVE=1 environment variable set before launching the agent.`);
380
391
  }
381
392
  }
382
393
  if (PUSH_TO_MAIN_RE.test(normalizedCommand)) {
383
- logBlock(`[workflow-guard] blocked push to main/master: ${command}`);
384
- return "Blocked: direct pushes to main/master are not allowed. Create a feature branch and open a PR instead.";
394
+ logPolicyBlock(`[workflow-guard] blocked push to main/master: ${command}`);
395
+ return block("git", "protected_branch_push", "Blocked: direct pushes to main/master are not allowed. Create a feature branch and open a PR instead.");
385
396
  }
386
397
  for (const invocation of gitInvocations) {
387
398
  const pushText = `git ${invocation.rest}`;
388
399
  if (!/\bgit\s+push\b/.test(pushText)) continue;
389
400
  const pushedBranch = pushedProtectedBranchIn(pushText, invocation.repoDir);
390
401
  if (pushedBranch) {
391
- logBlock(`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`);
392
- return `Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. Create a feature branch and open a PR instead.`;
402
+ logPolicyBlock(`[workflow-guard] blocked push to protected branch '${pushedBranch}': ${command}`);
403
+ return block("git", "protected_branch_push", `Blocked: direct pushes to protected branch '${pushedBranch}' are not allowed. Create a feature branch and open a PR instead.`);
393
404
  }
394
405
  }
395
406
  for (const invocation of gitInvocations) {
396
407
  if (!/\bgit\s+push\b/.test(`git ${invocation.rest}`)) continue;
397
408
  if (onProtectedBranch(invocation.repoDir)) {
398
- logBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
399
- return branchGuardReason();
409
+ logPolicyBlock(`[workflow-guard] blocked push from protected branch: ${command}`);
410
+ return block("git", "protected_branch", branchGuardReason());
400
411
  }
401
412
  const branch = currentGitBranch(invocation.repoDir);
402
413
  if (branch) {
403
- const mergedStatus = isBranchAlreadyMergedOrClosed(invocation.repoDir, branch);
414
+ if (context?.simulate) remotePrStateUnchecked = true;
415
+ const mergedStatus = isBranchAlreadyMergedOrClosed(invocation.repoDir, branch, !context?.simulate);
404
416
  if (mergedStatus.merged) {
405
- logBlock(`[workflow-guard] blocked push to merged/closed branch: ${branch}`);
406
- return `Blocked: ${mergedStatus.reason}`;
417
+ logPolicyBlock(`[workflow-guard] blocked push to merged/closed branch: ${branch}`);
418
+ return block("git", "merged_branch", `Blocked: ${mergedStatus.reason}`);
407
419
  }
408
420
  }
409
421
  }
@@ -418,12 +430,13 @@ export async function guardToolCallImpl(
418
430
  if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
419
431
  const branch = currentGitBranch(prRoot);
420
432
  if (branch) {
421
- const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch);
433
+ if (context?.simulate) remotePrStateUnchecked = true;
434
+ const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch, !context?.simulate);
422
435
  if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
423
436
  }
424
437
  if (isReviewRequired(prRoot)) {
425
438
  const review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
426
- const reviewMatchesContext = review?.passed === true && review.workspace === resolve(prRoot) && (!review.targetSessionID || review.targetSessionID === context?.sessionID) && review.commitHash === getCurrentGitCommitHash(prRoot) && review.gitStatus === getGitStatusSummary(prRoot) && review.worktreeFingerprint === getGitWorktreeFingerprint(prRoot);
439
+ 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);
427
440
  if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
428
441
  }
429
442
  if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
@@ -444,8 +457,8 @@ export async function guardToolCallImpl(
444
457
  if (lockCheck.isOutOfSync) preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
445
458
  if (preflightFailures.length > 0) {
446
459
  const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
447
- logBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
448
- return reason;
460
+ logPolicyBlock(`[workflow-guard] blocked ${prTool}: ${preflightFailures.length} preflight requirement(s) failed`);
461
+ return block("pr-preflight", "pr_preflight_failed", reason,);
449
462
  }
450
463
  }
451
464
  const hasGitMutation = gitInvocations.some((invocation) => {
@@ -455,12 +468,12 @@ export async function guardToolCallImpl(
455
468
  if (context?.agent && isReadOnlyRole(context.agent)) {
456
469
  const mutation = detectShellMutation(command);
457
470
  if (mutation) {
458
- logBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
459
- return `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`;
471
+ logPolicyBlock(`[workflow-guard] blocked shell mutation for read-only role ${context.agent}: ${mutation.what}`);
472
+ return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot perform shell file mutations (${mutation.what}).`);
460
473
  }
461
474
  if (hasGitMutation) {
462
- logBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
463
- return `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`;
475
+ logPolicyBlock(`[workflow-guard] blocked git mutation for read-only role ${context.agent}`);
476
+ return block("subagent-role", "read_only_role", `Blocked: subagent with read-only role '${context.agent}' cannot mutate git history or state.`);
464
477
  }
465
478
  }
466
479
  const shellMut = detectShellMutation(command);
@@ -468,12 +481,13 @@ export async function guardToolCallImpl(
468
481
  if (context?.sessionID) {
469
482
  const reason = await subagentMutationBudgetReason(context.sessionID, currentRoot);
470
483
  if (reason) {
471
- logBlock(`[workflow-guard] ${reason}`);
472
- return reason;
484
+ logPolicyBlock(`[workflow-guard] ${reason}`);
485
+ return block("todo", "mutation_budget", reason);
473
486
  }
474
487
  }
475
488
  }
476
- if (hasGitMutation) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
489
+ if (hasGitMutation && !context?.simulate) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
477
490
  }
478
- return undefined;
491
+ if (remotePrStateUnchecked) return needsApproval("git", "remote_state_unchecked", "Remote GitHub/Azure PR state was not queried during simulation; no deterministic local guardrail blocked this call, but final allow/block status requires real enforcement.");
492
+ return allow();
479
493
  }
@@ -1,8 +1,16 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
3
  import type { ProjectConfig } from "./types.ts";
4
4
 
5
- let cachedProjectConfig: ProjectConfig | undefined;
5
+ const projectConfigCache = new Map<string, ProjectConfig>();
6
+
7
+ export function projectRootKey(root: string): string {
8
+ try {
9
+ return realpathSync(root);
10
+ } catch {
11
+ return resolve(root);
12
+ }
13
+ }
6
14
 
7
15
  export function projectConfigCandidates(root: string): string[] {
8
16
  return [
@@ -60,9 +68,9 @@ export function loadProjectConfig(root: string): ProjectConfig {
60
68
  }
61
69
 
62
70
  export function reloadProjectConfig(root: string): void {
63
- cachedProjectConfig = loadProjectConfig(root);
71
+ projectConfigCache.set(projectRootKey(root), loadProjectConfig(root));
64
72
  }
65
73
 
66
- export function getCachedProjectConfig(): ProjectConfig | undefined {
67
- return cachedProjectConfig;
74
+ export function getCachedProjectConfig(root: string): ProjectConfig | undefined {
75
+ return projectConfigCache.get(projectRootKey(root));
68
76
  }
package/src/lib/state.ts CHANGED
@@ -2,7 +2,7 @@ import { realpathSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
- import { getCachedProjectConfig, loadProjectConfig } from "./project-config.ts";
5
+ import { getCachedProjectConfig, loadProjectConfig, projectRootKey } from "./project-config.ts";
6
6
  import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
7
7
  import type {
8
8
  TodoSdkClient,
@@ -70,6 +70,8 @@ export function runWithRuntimeState<T>(root: string, client: unknown, fn: () =>
70
70
 
71
71
  export let lastMutationTimestamp = 0;
72
72
  export let mutationCount = 0;
73
+ const workspaceMutationTimestamps = new Map<string, number>();
74
+ const workspaceMutationCounts = new Map<string, number>();
73
75
  export const sessionMutationTimestamps = new Map<string, number>();
74
76
  export const sessionMutationCounts = new Map<string, number>();
75
77
 
@@ -82,6 +84,9 @@ export const sessionReviews = new Map<string, ReviewResult>();
82
84
  export function recordMutation(sessionID?: string, actorSessionID?: string): void {
83
85
  lastMutationTimestamp = Date.now();
84
86
  mutationCount++;
87
+ const workspace = projectRootKey(runtimeState.getStore()?.workspaceRoot ?? getWorkspaceRoot());
88
+ workspaceMutationTimestamps.set(workspace, lastMutationTimestamp);
89
+ workspaceMutationCounts.set(workspace, (workspaceMutationCounts.get(workspace) ?? 0) + 1);
85
90
  for (const id of new Set([sessionID, actorSessionID].filter((value): value is string => Boolean(value)))) {
86
91
  sessionMutationTimestamps.set(id, lastMutationTimestamp);
87
92
  sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
@@ -108,10 +113,26 @@ export function getLastMutationTimestamp(): number {
108
113
  return lastMutationTimestamp;
109
114
  }
110
115
 
116
+ export function getWorkspaceMutationTimestamp(root: string): number {
117
+ return workspaceMutationTimestamps.get(projectRootKey(root)) ?? 0;
118
+ }
119
+
120
+ export function getWorkspaceMutationCount(root: string): number {
121
+ return workspaceMutationCounts.get(projectRootKey(root)) ?? 0;
122
+ }
123
+
111
124
  export function getLastVerifyResult(): typeof lastVerify {
112
125
  return lastVerify;
113
126
  }
114
127
 
128
+ export function getLastVerifyResultForWorkspace(root: string): typeof lastVerify {
129
+ const workspace = projectRootKey(root);
130
+ const candidates = [lastVerify, ...sessionVerifyResults.values()].filter(
131
+ (result): result is NonNullable<VerifyResult> => result?.workspaceRoot != null && projectRootKey(result.workspaceRoot) === workspace,
132
+ );
133
+ return candidates.reduce<NonNullable<VerifyResult> | undefined>((latest, result) => !latest || result.timestamp > latest.timestamp ? result : latest, undefined);
134
+ }
135
+
115
136
  export function recordVerifyResult(
116
137
  command: string,
117
138
  result: { passed: boolean; output: string; durationMs?: number },
@@ -126,7 +147,8 @@ export function recordVerifyResult(
126
147
  durationMs: result.durationMs,
127
148
  commitHash: getCurrentGitCommitHash(root),
128
149
  gitStatus: getGitStatusSummary(root),
129
- workspaceRoot: resolve(root),
150
+ workspaceRoot: projectRootKey(root),
151
+ worktreeFingerprint: getGitWorktreeFingerprint(root),
130
152
  };
131
153
  if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
132
154
  persistVerifyHistory(lastVerify);
@@ -145,6 +167,8 @@ export function resetVerifyState(): void {
145
167
  lastVerify = undefined;
146
168
  sessionMutationTimestamps.clear();
147
169
  sessionMutationCounts.clear();
170
+ workspaceMutationTimestamps.clear();
171
+ workspaceMutationCounts.clear();
148
172
  sessionVerifyResults.clear();
149
173
  }
150
174
 
@@ -155,7 +179,7 @@ export function recordReviewResult(
155
179
  targetSessionID?: string,
156
180
  workspace?: string,
157
181
  ): void {
158
- const root = resolve(workspace ?? getWorkspaceRoot());
182
+ const root = projectRootKey(workspace ?? getWorkspaceRoot());
159
183
  lastReview = {
160
184
  reviewer,
161
185
  summary: summary.slice(-4000),
@@ -174,6 +198,14 @@ export function getLastReviewResult(): typeof lastReview {
174
198
  return lastReview;
175
199
  }
176
200
 
201
+ export function getLastReviewResultForWorkspace(root: string): typeof lastReview {
202
+ const workspace = projectRootKey(root);
203
+ const candidates = [lastReview, ...sessionReviews.values()].filter(
204
+ (result): result is ReviewResult => result?.workspace != null && projectRootKey(result.workspace) === workspace,
205
+ );
206
+ return candidates.reduce<ReviewResult | undefined>((latest, result) => !latest || result.timestamp > latest.timestamp ? result : latest, undefined);
207
+ }
208
+
177
209
  export function resetReviewState(): void {
178
210
  lastReview = undefined;
179
211
  sessionReviews.clear();
@@ -181,8 +213,8 @@ export function resetReviewState(): void {
181
213
 
182
214
  export function getProjectConfig(root: string): ProjectConfig {
183
215
  const active = runtimeState.getStore();
184
- if (active?.workspaceRoot === root) return active.projectConfig;
185
- return getCachedProjectConfig() ?? loadProjectConfig(root);
216
+ if (active && projectRootKey(active.workspaceRoot) === projectRootKey(root)) return active.projectConfig;
217
+ return getCachedProjectConfig(root) ?? loadProjectConfig(root);
186
218
  }
187
219
 
188
220
  export function isReviewRequired(root: string): boolean {
@@ -197,6 +229,25 @@ export function isDocumentationRequired(root: string): boolean {
197
229
  return cfg.requireDocumentation === true;
198
230
  }
199
231
 
232
+ export function getOperationProfile(root: string): "interactive" | "autonomous" {
233
+ return getProjectConfig(root).profile === "autonomous" ? "autonomous" : "interactive";
234
+ }
235
+
236
+ export function isRecoveryCheckpointsEnabled(root: string): boolean {
237
+ const cfg = getProjectConfig(root);
238
+ return cfg.recoveryCheckpoints ?? getOperationProfile(root) === "autonomous";
239
+ }
240
+
241
+ export function isRalphModeEnabled(root: string): boolean {
242
+ return getProjectConfig(root).ralphMode === true;
243
+ }
244
+
245
+ export function getRalphMaxIterations(root: string): number {
246
+ const configured = getProjectConfig(root).ralphMaxIterations;
247
+ if (typeof configured === "number" && Number.isInteger(configured) && configured > 0 && configured <= 100) return configured;
248
+ return 10;
249
+ }
250
+
200
251
  export function getSubagentMutationBudget(root: string): number {
201
252
  if (process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS) {
202
253
  const parsed = parseInt(process.env.WORKFLOW_GUARD_MAX_SUBAGENT_MUTATIONS, 10);
package/src/lib/types.ts CHANGED
@@ -24,6 +24,7 @@ export interface TodoSdkClient {
24
24
  }
25
25
 
26
26
  export interface ProjectConfig {
27
+ profile?: "interactive" | "autonomous";
27
28
  protectedBranches?: string[];
28
29
  verifyCommand?: string;
29
30
  postEditValidators?: Array<{
@@ -37,10 +38,22 @@ export interface ProjectConfig {
37
38
  projectMemory?: boolean;
38
39
  learning?: boolean;
39
40
  titleSettleWorkaround?: boolean;
41
+ ralphMode?: boolean;
42
+ ralphMaxIterations?: number;
40
43
  maxSubagentMutations?: number;
41
44
  maxLearningInterventions?: number;
42
45
  }
43
46
 
47
+ export type PolicyDecisionStatus = "allowed" | "blocked" | "needs_approval" | "verification_failed";
48
+
49
+ export interface PolicyDecision {
50
+ status: PolicyDecisionStatus;
51
+ code: string;
52
+ policy?: string;
53
+ message: string;
54
+ details?: Record<string, unknown>;
55
+ }
56
+
44
57
  export interface VerifyResult {
45
58
  passed: boolean;
46
59
  command: string;
@@ -50,6 +63,7 @@ export interface VerifyResult {
50
63
  commitHash?: string;
51
64
  gitStatus?: string;
52
65
  workspaceRoot?: string;
66
+ worktreeFingerprint?: string;
53
67
  }
54
68
 
55
69
  export interface ReviewResult {
@@ -64,12 +78,33 @@ export interface ReviewResult {
64
78
  worktreeFingerprint?: string;
65
79
  }
66
80
 
81
+ export type EvidenceConfidence = "deterministic_observation" | "attestation" | "derived_state" | "agent_assertion";
82
+
83
+ export interface EvidenceRecord {
84
+ id: string;
85
+ kind: "verification" | "review" | "tool_outcome" | "policy_evaluation" | "lifecycle_state" | "agent_assertion";
86
+ confidence: EvidenceConfidence;
87
+ observedAt: number;
88
+ subject: {
89
+ workspace?: string;
90
+ commitHash?: string;
91
+ worktreeFingerprint?: string;
92
+ sessionID?: string;
93
+ callID?: string;
94
+ actor?: string;
95
+ };
96
+ source: Record<string, unknown>;
97
+ }
98
+
99
+ export type EvidenceSubject = EvidenceRecord["subject"];
100
+
67
101
  export interface AuditEntry {
68
102
  ts: string;
69
103
  sessionID?: string;
70
104
  callID?: string;
71
105
  tool: string;
72
106
  decision: "allow" | "block";
107
+ policyDecision?: PolicyDecision;
73
108
  phase?: "decision" | "outcome" | "event";
74
109
  durationMs?: number;
75
110
  reason?: string;
@@ -317,6 +317,7 @@ export function detectShellMutation(command: string): ShellMutation | undefined
317
317
  export async function guardShellMutation(
318
318
  command: string,
319
319
  sessionID: string | undefined,
320
+ record = true,
320
321
  ): Promise<string | undefined> {
321
322
  const root = getWorkspaceRoot();
322
323
  let hasMutation = false;
@@ -383,7 +384,7 @@ export async function guardShellMutation(
383
384
  }
384
385
  }
385
386
  }
386
- if (hasMutation) {
387
+ if (hasMutation && record) {
387
388
  recordMutation(await effectiveTodoOwnerSessionID(sessionID), sessionID);
388
389
  }
389
390
  return undefined;
@@ -1,5 +1,8 @@
1
- import { sessionVerifyResults, lastVerify, sessionMutationTimestamps, lastMutationTimestamp } from "../lib/state.ts";
1
+ import { isEvidenceFresh, verificationEvidence } from "../lib/evidence.ts";
2
+ import { projectRootKey } from "../lib/project-config.ts";
3
+ import { getWorkspaceRoot, sessionVerifyResults, lastVerify, sessionMutationTimestamps, lastMutationTimestamp } from "../lib/state.ts";
2
4
  import type { VerifyResult } from "../lib/types.ts";
5
+ import { getCurrentGitCommitHash, getGitWorktreeFingerprint } from "../lib/verify.ts";
3
6
 
4
7
  /**
5
8
  * Claims-vs-evidence check (Policy 24): when the assistant's final text
@@ -60,7 +63,12 @@ export function checkCompletionClaims(
60
63
  } else if (!verifyResult.passed) {
61
64
  evidenceState = "failing";
62
65
  } else if (
63
- verifyResult.timestamp < mutationTimestamp ||
66
+ !isEvidenceFresh(verificationEvidence(verifyResult, context?.sessionID), {
67
+ workspace: projectRootKey(getWorkspaceRoot()),
68
+ commitHash: getCurrentGitCommitHash(getWorkspaceRoot()),
69
+ worktreeFingerprint: getGitWorktreeFingerprint(getWorkspaceRoot()),
70
+ sessionID: context?.sessionID,
71
+ }, mutationTimestamp) ||
64
72
  Date.now() - verifyResult.timestamp > VERIFICATION_FRESH_WINDOW_MS
65
73
  ) {
66
74
  evidenceState = "stale-pass";
@@ -1,17 +1,22 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { getSdkClient } from "../lib/state.ts";
2
+ import { getRalphMaxIterations, getSdkClient, getWorkspaceRoot, isRalphModeEnabled } from "../lib/state.ts";
3
+ import { projectRootKey } from "../lib/project-config.ts";
3
4
  import { effectiveTodosWithOwner, hasActiveTodo } from "./todo.ts";
4
5
 
5
6
  const MAX_CONSECUTIVE_CONTINUATIONS = 3;
7
+ const MAX_GENERATED_MESSAGE_IDS = 100;
6
8
  const TITLE_SETTLE_MS = 250;
7
9
  const TITLE_SETTLE_ATTEMPTS = 8;
8
10
  const DEFAULT_SESSION_TITLE = /^(?:New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
9
11
  type ContinuationState = {
10
12
  counts: Map<string, number>;
13
+ ralphOutcomes: Map<string, RalphOutcome>;
11
14
  generatedMessageIDs: Map<string, Set<string>>;
15
+ lastUserMessageIDs: Map<string, string>;
12
16
  inFlight: Set<string>;
13
17
  };
14
- const states = new WeakMap<object, ContinuationState>();
18
+ export type RalphOutcome = "running" | "completed" | "blocked" | "budget_exhausted" | "user_stopped";
19
+ const states = new WeakMap<object, Map<string, ContinuationState>>();
15
20
 
16
21
  export async function waitForSessionTitle(sessionID: string): Promise<void> {
17
22
  const session = getSdkClient()?.session;
@@ -30,10 +35,16 @@ export async function waitForSessionTitle(sessionID: string): Promise<void> {
30
35
  function getState(): ContinuationState | undefined {
31
36
  const client = getSdkClient();
32
37
  if (!client || (typeof client !== "object" && typeof client !== "function")) return undefined;
33
- let state = states.get(client as object);
38
+ let clientStates = states.get(client as object);
39
+ if (!clientStates) {
40
+ clientStates = new Map();
41
+ states.set(client as object, clientStates);
42
+ }
43
+ const root = projectRootKey(getWorkspaceRoot());
44
+ let state = clientStates.get(root);
34
45
  if (!state) {
35
- state = { counts: new Map(), generatedMessageIDs: new Map(), inFlight: new Set() };
36
- states.set(client as object, state);
46
+ state = { counts: new Map(), ralphOutcomes: new Map(), generatedMessageIDs: new Map(), lastUserMessageIDs: new Map(), inFlight: new Set() };
47
+ clientStates.set(root, state);
37
48
  }
38
49
  return state;
39
50
  }
@@ -43,19 +54,33 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
43
54
  if (!state || state.inFlight.has(sessionID)) return false;
44
55
  state.inFlight.add(sessionID);
45
56
  try {
57
+ const ralph = isRalphModeEnabled(getWorkspaceRoot());
58
+ if (ralph && state.ralphOutcomes.get(sessionID) === "user_stopped") return false;
46
59
  const effective = await effectiveTodosWithOwner(sessionID);
47
- if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos)) return false;
60
+ if (!effective || effective.ownerSessionID !== sessionID || !hasActiveTodo(effective.todos)) {
61
+ if (ralph && state.ralphOutcomes.has(sessionID)) state.ralphOutcomes.set(sessionID, "completed");
62
+ return false;
63
+ }
48
64
  if (settleTitle) await waitForSessionTitle(sessionID);
49
65
 
50
66
  const count = state.counts.get(sessionID) ?? 0;
51
- if (count >= MAX_CONSECUTIVE_CONTINUATIONS) return false;
67
+ const maxContinuations = ralph ? getRalphMaxIterations(getWorkspaceRoot()) : MAX_CONSECUTIVE_CONTINUATIONS;
68
+ if (count >= maxContinuations) {
69
+ if (ralph) state.ralphOutcomes.set(sessionID, "budget_exhausted");
70
+ return false;
71
+ }
52
72
 
53
73
  const session = getSdkClient()?.session;
54
74
  if (typeof session?.promptAsync !== "function") return false;
55
75
 
56
76
  const messageID = `workflow-guard-${randomUUID()}`;
57
77
  state.counts.set(sessionID, count + 1);
78
+ if (ralph) state.ralphOutcomes.set(sessionID, "running");
58
79
  const generatedIDs = state.generatedMessageIDs.get(sessionID) ?? new Set<string>();
80
+ if (generatedIDs.size >= MAX_GENERATED_MESSAGE_IDS) {
81
+ const oldestID = generatedIDs.values().next().value;
82
+ if (oldestID) generatedIDs.delete(oldestID);
83
+ }
59
84
  generatedIDs.add(messageID);
60
85
  state.generatedMessageIDs.set(sessionID, generatedIDs);
61
86
  try {
@@ -86,11 +111,18 @@ export function recordUserMessage(sessionID: string, messageID?: string): void {
86
111
  const state = getState();
87
112
  if (!state) return;
88
113
  const generatedIDs = state.generatedMessageIDs.get(sessionID);
89
- if (messageID && generatedIDs?.delete(messageID)) {
90
- if (generatedIDs.size === 0) state.generatedMessageIDs.delete(sessionID);
91
- return;
92
- }
114
+ if (messageID && generatedIDs?.has(messageID)) return;
115
+ if (messageID && state.lastUserMessageIDs.get(sessionID) === messageID) return;
116
+ if (messageID) state.lastUserMessageIDs.set(sessionID, messageID);
93
117
  state.counts.delete(sessionID);
118
+ if (isRalphModeEnabled(getWorkspaceRoot()) && state.ralphOutcomes.has(sessionID)) {
119
+ if (state.ralphOutcomes.get(sessionID) === "user_stopped") state.ralphOutcomes.delete(sessionID);
120
+ else state.ralphOutcomes.set(sessionID, "user_stopped");
121
+ }
122
+ }
123
+
124
+ export function getRalphOutcome(sessionID: string): RalphOutcome | undefined {
125
+ return getState()?.ralphOutcomes.get(sessionID);
94
126
  }
95
127
 
96
128
  export function isGeneratedContinuationMessage(sessionID: string, messageID?: string): boolean {
@@ -102,6 +134,8 @@ export function clearContinuationState(sessionID: string): void {
102
134
  const state = getState();
103
135
  if (!state) return;
104
136
  state.counts.delete(sessionID);
137
+ state.ralphOutcomes.delete(sessionID);
105
138
  state.generatedMessageIDs.delete(sessionID);
139
+ state.lastUserMessageIDs.delete(sessionID);
106
140
  state.inFlight.delete(sessionID);
107
141
  }
@@ -33,15 +33,22 @@ export function canonicalPath(path: string, seen = new Set<string>()): string {
33
33
 
34
34
  export function claimFiles(paths: string[], sessionID: string, callID: string): string | undefined {
35
35
  const canonical = [...new Set(paths.map((path) => canonicalPath(path)))];
36
+ const conflict = fileClaimConflictReason(canonical, sessionID);
37
+ if (conflict) return conflict;
36
38
  for (const path of canonical) {
37
- const existing = claims.get(path);
38
- if (existing && existing.sessionID !== sessionID) {
39
- return `Blocked: file '${path}' is claimed by another active session ('${existing.sessionID}'). Wait for that edit to finish or use an isolated worktree.`;
39
+ if (!claims.has(path)) {
40
+ claims.set(path, { sessionID, callID });
40
41
  }
41
42
  }
43
+ return undefined;
44
+ }
45
+
46
+ export function fileClaimConflictReason(paths: string[], sessionID: string): string | undefined {
47
+ const canonical = [...new Set(paths.map((path) => canonicalPath(path)))];
42
48
  for (const path of canonical) {
43
- if (!claims.has(path)) {
44
- claims.set(path, { sessionID, callID });
49
+ const existing = claims.get(path);
50
+ if (existing && existing.sessionID !== sessionID) {
51
+ return `Blocked: file '${path}' is claimed by another active session ('${existing.sessionID}'). Wait for that edit to finish or use an isolated worktree.`;
45
52
  }
46
53
  }
47
54
  return undefined;
@@ -195,6 +195,7 @@ export function branchGuardReason(): string {
195
195
  export function isBranchAlreadyMergedOrClosed(
196
196
  root: string,
197
197
  branch: string,
198
+ checkRemotePrs = true,
198
199
  ): { merged: boolean; reason?: string } {
199
200
  if (!branch || PROTECTED_BRANCHES.has(branch)) {
200
201
  return { merged: false };
@@ -220,6 +221,7 @@ export function isBranchAlreadyMergedOrClosed(
220
221
  }
221
222
  }
222
223
  }
224
+ if (!checkRemotePrs) return { merged: false };
223
225
 
224
226
  try {
225
227
  const ghRes = spawnSync(
@@ -31,7 +31,7 @@ function text(props: Record<string, unknown>, children: Child[]) {
31
31
 
32
32
  const BADGE_ACTIVE = "Workflow Guard 🛡️";
33
33
 
34
- type ProjectToggle = "recoveryCheckpoints" | "projectMemory" | "learning" | "titleSettleWorkaround";
34
+ type ProjectToggle = "recoveryCheckpoints" | "projectMemory" | "learning" | "titleSettleWorkaround" | "ralphMode";
35
35
 
36
36
  export function readProjectOption(root: string, option: ProjectToggle): boolean {
37
37
  const path = projectConfigPath(root);
@@ -88,6 +88,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
88
88
  const memory = readProjectOption(root, "projectMemory");
89
89
  const learning = readProjectOption(root, "learning");
90
90
  const titleSettle = readProjectOption(root, "titleSettleWorkaround");
91
+ const ralphMode = readProjectOption(root, "ralphMode");
91
92
  api.ui.dialog.replace(() => api.ui.DialogSelect({
92
93
  title: "Workflow Guard Project Options",
93
94
  current: undefined,
@@ -96,6 +97,7 @@ export const WorkflowGuardTui: TuiPlugin = async (api) => {
96
97
  { title: `Project memory: ${memory ? "On" : "Off"}`, value: "projectMemory", description: "Toggle durable local project memory" },
97
98
  { title: `Learner mode: ${learning ? "On" : "Off"}`, value: "learning", description: "Toggle evidence-based learning tools" },
98
99
  { title: `Title settle workaround: ${titleSettle ? "On" : "Off"}`, value: "titleSettleWorkaround", description: "Delay automatic continuation while OpenCode generates a session title" },
100
+ { title: `Ralph mode: ${ralphMode ? "On" : "Off"}`, value: "ralphMode", description: "Opt into bounded autonomous continuation of already-owned todos" },
99
101
  ],
100
102
  onSelect: (option) => {
101
103
  const key = option.value as ProjectToggle;
@@ -19,7 +19,7 @@ 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
21
  import { createCustomTools } from "./lib/custom-tools.ts";
22
- import type { TodoSdkClient } from "./lib/types.ts";
22
+ import type { PolicyDecision, TodoSdkClient } from "./lib/types.ts";
23
23
  export { isReadOnlyRole } from "./lib/guard-dispatcher.ts";
24
24
  export { extractReviewFollowups } from "./lib/custom-tools.ts";
25
25
 
@@ -30,6 +30,11 @@ export type {
30
30
  VerifyResult,
31
31
  ReviewResult,
32
32
  AuditEntry,
33
+ PolicyDecision,
34
+ PolicyDecisionStatus,
35
+ EvidenceRecord,
36
+ EvidenceConfidence,
37
+ EvidenceSubject,
33
38
  GitInvocation,
34
39
  ShellMutation,
35
40
  LearnerProfile,
@@ -37,6 +42,8 @@ export type {
37
42
  LearningOpportunity,
38
43
  } from "./lib/types.ts";
39
44
  export type { TodoSdkClient };
45
+ export { verificationEvidence, reviewEvidence, toolOutcomeEvidence, policyDecisionEvidence, lifecycleStateEvidence, agentAssertionEvidence, evidenceMatchesSubject, isEvidenceFresh } from "./lib/evidence.ts";
46
+ export { getRalphOutcome } from "./policies/continuation.ts";
40
47
 
41
48
  // ── State & runtime context ──────────────────────────────────────────────────
42
49
  import {
@@ -57,6 +64,8 @@ import {
57
64
  sessionMutationTimestamps,
58
65
  sessionVerifyResults,
59
66
  getProjectConfig,
67
+ getOperationProfile,
68
+ isRecoveryCheckpointsEnabled,
60
69
  isReviewRequired,
61
70
  isDocumentationRequired,
62
71
  getSubagentMutationBudget,
@@ -83,6 +92,8 @@ export {
83
92
  loadProjectConfig,
84
93
  reloadProjectConfig,
85
94
  stripJsonComments,
95
+ getOperationProfile,
96
+ isRecoveryCheckpointsEnabled,
86
97
  isReviewRequired,
87
98
  isDocumentationRequired,
88
99
  getSubagentMutationBudget,
@@ -293,20 +304,28 @@ function logObservation(client: unknown, message: string): void {
293
304
  * Public guard entry point. Audits every decision (block or allow) to a
294
305
  * durable JSONL file before returning.
295
306
  */
296
- export async function guardToolCall(
307
+ export async function guardToolDecision(
297
308
  toolName: string,
298
309
  input: unknown,
299
310
  context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
300
- ): Promise<string | undefined> {
311
+ ): Promise<PolicyDecision> {
301
312
  const customRoot = context?.worktree || context?.directory;
302
313
  const runImpl = () => guardToolCallImpl(toolName, input, context);
303
- const reason = customRoot
314
+ return customRoot
304
315
  ? await runWithRuntimeState(customRoot, getSdkClient(), runImpl)
305
316
  : await runImpl();
317
+ }
318
+
319
+ export async function guardToolCall(
320
+ toolName: string,
321
+ input: unknown,
322
+ context?: { sessionID?: string; callID?: string; worktree?: string; directory?: string; agent?: string },
323
+ ): Promise<string | undefined> {
324
+ const decision = await guardToolDecision(toolName, input, context);
306
325
  const allowLive = process.env.WORKFLOW_GUARD_ALLOW_LIVE === "1";
307
326
  const isMutation = EDIT_TOOL_NAMES.has(toolName);
308
327
  const targetPath = extractRecordTargetPath(input);
309
- logDecision(toolName, input, context, reason, {
328
+ logDecision(toolName, input, context, decision, {
310
329
  mutation: isMutation,
311
330
  targetPath,
312
331
  allowLive,
@@ -319,7 +338,7 @@ export async function guardToolCall(
319
338
  }
320
339
  : undefined,
321
340
  });
322
- return reason;
341
+ return decision.status === "allowed" ? undefined : decision.message;
323
342
  }
324
343
 
325
344
  async function emitBlockFeedback(message: string): Promise<void> {
@@ -597,7 +616,7 @@ export const WorkflowGuard: Plugin = async (ctx) => {
597
616
  },
598
617
 
599
618
  "chat.message": async (input) => {
600
- if (getProjectConfig(effectiveRoot).recoveryCheckpoints !== true) return;
619
+ if (!isRecoveryCheckpointsEnabled(effectiveRoot)) return;
601
620
  if (isGeneratedContinuationMessage(input.sessionID, input.messageID)) return;
602
621
  const parent = await runWithRuntimeState(effectiveRoot, ctx.client, () => fetchParentSession(input.sessionID));
603
622
  if (!parent.ok || parent.parentID) return;