opencode-workflow-guard 1.8.7 → 1.9.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/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;
@@ -0,0 +1,13 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const testHome = mkdtempSync(join(tmpdir(), "workflow-guard-test-home-"));
6
+ process.env.XDG_STATE_HOME = join(testHome, "state");
7
+ process.env.XDG_DATA_HOME = join(testHome, "data");
8
+
9
+ try {
10
+ await import("./test.mts");
11
+ } finally {
12
+ rmSync(testHome, { recursive: true, force: true });
13
+ }