opencode-workflow-guard 1.5.1 → 1.7.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.
@@ -0,0 +1,399 @@
1
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
2
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
3
+ return undefined;
4
+ }
5
+ return value as Record<string, unknown>;
6
+ }
7
+
8
+ export function extractCommands(input: unknown): string[] {
9
+ if (typeof input === "string") {
10
+ return [input];
11
+ }
12
+ const record = asRecord(input);
13
+ if (!record) {
14
+ return [];
15
+ }
16
+ const commands: string[] = [];
17
+ if (typeof record.command === "string") commands.push(record.command);
18
+ if (Array.isArray(record.commands)) {
19
+ for (const c of record.commands) {
20
+ if (typeof c === "string") commands.push(c);
21
+ }
22
+ }
23
+ return commands;
24
+ }
25
+
26
+ export function normalize(cmd: string): string {
27
+ return cmd.replace(/\s+/g, " ");
28
+ }
29
+
30
+ export function dynamicShellSyntaxIn(command: string): string | undefined {
31
+ let quote: "'" | '"' | undefined;
32
+ let escaped = false;
33
+ for (let i = 0; i < command.length; i += 1) {
34
+ const char = command[i]!;
35
+ if (escaped) {
36
+ escaped = false;
37
+ continue;
38
+ }
39
+ if (char === "\\" && quote !== "'") {
40
+ escaped = true;
41
+ continue;
42
+ }
43
+ if (char === "'") {
44
+ if (!quote) quote = "'";
45
+ else if (quote === "'") quote = undefined;
46
+ continue;
47
+ }
48
+ if (char === '"') {
49
+ if (!quote) quote = '"';
50
+ else if (quote === '"') quote = undefined;
51
+ continue;
52
+ }
53
+ if (quote === "'") continue;
54
+ if (char === "`" || (char === "$" && command[i + 1] === "(") || (!quote && (char === "<" || char === ">") && command[i + 1] === "(")) {
55
+ return "dynamic command/process substitution";
56
+ }
57
+ if (char === "$" && /^(?:IFS\b|\{IFS(?:\}|[:\[]))/.test(command.slice(i + 1))) return "dynamic IFS expansion";
58
+ if (!quote && (char === "\r" || /[\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/u.test(char))) {
59
+ return "ambiguous shell whitespace";
60
+ }
61
+ }
62
+ if (quote || escaped) return "malformed shell quoting";
63
+ return undefined;
64
+ }
65
+
66
+ export function decodeShellEscapes(text: string): string {
67
+ return text
68
+ // ANSI-C quoting wrapper: $'...' -> ...
69
+ .replace(/\$'([^']*)'/g, "$1")
70
+ // Hex escapes: \xHH
71
+ .replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
72
+ // Unicode escapes: \uHHHH
73
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
74
+ // Octal escapes: \0OO or \OOO (e.g. \162 -> 'r')
75
+ .replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
76
+ }
77
+
78
+ export function shellWords(command: string): string[] {
79
+ const words: string[] = [];
80
+ let word = "";
81
+ let quote: "'" | '"' | undefined;
82
+ let escaped = false;
83
+ let started = false;
84
+ for (const char of command) {
85
+ if (escaped) {
86
+ word += char;
87
+ escaped = false;
88
+ started = true;
89
+ continue;
90
+ }
91
+ if (char === "\\" && quote !== "'") {
92
+ escaped = true;
93
+ started = true;
94
+ continue;
95
+ }
96
+ if (char === "'" || char === '"') {
97
+ if (!quote) quote = char;
98
+ else if (quote === char) quote = undefined;
99
+ else word += char;
100
+ started = true;
101
+ continue;
102
+ }
103
+ if (!quote && /\s/.test(char)) {
104
+ if (started) words.push(word);
105
+ word = "";
106
+ started = false;
107
+ continue;
108
+ }
109
+ word += char;
110
+ started = true;
111
+ }
112
+ if (escaped) word += "\\";
113
+ if (started) words.push(word);
114
+ return words;
115
+ }
116
+
117
+ function envSplitWords(value: string): string[] {
118
+ const words: string[] = [];
119
+ let word = "";
120
+ let quote: "'" | '"' | undefined;
121
+ let started = false;
122
+ const push = () => {
123
+ if (started) words.push(word);
124
+ word = "";
125
+ started = false;
126
+ };
127
+ for (let i = 0; i < value.length; i += 1) {
128
+ const char = value[i]!;
129
+ if (char === "'" || char === '"') {
130
+ if (!quote) quote = char;
131
+ else if (quote === char) quote = undefined;
132
+ else word += char;
133
+ started = true;
134
+ continue;
135
+ }
136
+ if (char === "\\" && i + 1 < value.length) {
137
+ const next = value[++i]!;
138
+ if (quote === "'" && next !== "'" && next !== "\\") {
139
+ word += `\\${next}`;
140
+ started = true;
141
+ continue;
142
+ }
143
+ if (next === "c" && !quote) break;
144
+ if (next === "_" && quote !== "'") {
145
+ if (quote === '"') {
146
+ word += " ";
147
+ started = true;
148
+ } else push();
149
+ continue;
150
+ }
151
+ const escapes: Record<string, string> = { f: "\f", n: "\n", r: "\r", t: "\t", v: "\v", "#": "#", "$": "$", '"': '"', "'": "'", "\\": "\\" };
152
+ word += escapes[next] ?? next;
153
+ started = true;
154
+ continue;
155
+ }
156
+ if (!quote && /[\s\f\v]/.test(char)) {
157
+ push();
158
+ continue;
159
+ }
160
+ if (!quote && char === "#" && !started) break;
161
+ if (char === "$" && quote !== "'" && value[i + 1] === "{") {
162
+ const end = value.indexOf("}", i + 2);
163
+ if (end >= 0) {
164
+ const name = value.slice(i + 2, end);
165
+ word += process.env[name] ?? "";
166
+ started = true;
167
+ i = end;
168
+ continue;
169
+ }
170
+ }
171
+ word += char;
172
+ started = true;
173
+ }
174
+ push();
175
+ return words;
176
+ }
177
+
178
+ export function splitShellSegments(command: string): string[] {
179
+ const segments: string[] = [];
180
+ let current = "";
181
+ let quote: "'" | '"' | "ansi" | undefined;
182
+ let escaped = false;
183
+ for (let i = 0; i < command.length; i += 1) {
184
+ const char = command[i]!;
185
+ if (escaped) {
186
+ current += char;
187
+ escaped = false;
188
+ continue;
189
+ }
190
+ if (!quote && char === "$" && command[i + 1] === "'") {
191
+ current += "$'";
192
+ quote = "ansi";
193
+ i += 1;
194
+ continue;
195
+ }
196
+ if (char === "\\" && quote !== "'") {
197
+ current += char;
198
+ escaped = true;
199
+ continue;
200
+ }
201
+ if (quote === "ansi" && char === "'") {
202
+ quote = undefined;
203
+ current += char;
204
+ continue;
205
+ }
206
+ if (char === "'" || char === '"') {
207
+ if (!quote) quote = char;
208
+ else if (quote === char) quote = undefined;
209
+ current += char;
210
+ continue;
211
+ }
212
+ if (!quote && /[\n|;&]/.test(char)) {
213
+ if (current.trim()) segments.push(current.trim());
214
+ current = "";
215
+ continue;
216
+ }
217
+ current += char;
218
+ }
219
+ if (current.trim()) segments.push(current.trim());
220
+ return segments;
221
+ }
222
+
223
+ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean } {
224
+ let cleanCmd = decodeShellEscapes(command.trim());
225
+ // Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
226
+ while (
227
+ (cleanCmd.startsWith("(") && cleanCmd.endsWith(")")) ||
228
+ (cleanCmd.startsWith("{") && cleanCmd.endsWith("}"))
229
+ ) {
230
+ cleanCmd = cleanCmd.slice(1, -1).trim();
231
+ if (cleanCmd.endsWith(";")) cleanCmd = cleanCmd.slice(0, -1).trim();
232
+ }
233
+
234
+ const words = shellWords(cleanCmd);
235
+ let i = 0;
236
+ let changesCwd = false;
237
+ while (i < words.length) {
238
+ const rawWord = words[i]!;
239
+ const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
240
+ if (!word) {
241
+ i++;
242
+ continue;
243
+ }
244
+
245
+ if (word === "command" || word === "exec" || word === "eval" || word === "nohup") {
246
+ i++;
247
+ continue;
248
+ }
249
+ if (word === "nice") {
250
+ i++;
251
+ if (i < words.length && (words[i] === "-n" || words[i]?.startsWith("-n"))) {
252
+ if (words[i] === "-n") i += 2;
253
+ else i++;
254
+ }
255
+ continue;
256
+ }
257
+ if (word === "timeout") {
258
+ i++;
259
+ while (i < words.length && words[i]!.startsWith("-")) {
260
+ const opt = words[i++]!;
261
+ if ((opt === "-k" || opt === "-s" || opt === "--signal" || opt === "--kill-after") && i < words.length) {
262
+ i++;
263
+ }
264
+ }
265
+ if (i < words.length && /^\d+[smhd]?$/.test(words[i]!)) {
266
+ i++;
267
+ }
268
+ continue;
269
+ }
270
+ if (word === "stdbuf") {
271
+ i++;
272
+ while (i < words.length && words[i]!.startsWith("-")) {
273
+ const opt = words[i++]!;
274
+ if ((opt === "-i" || opt === "-o" || opt === "-e") && i < words.length) {
275
+ i++;
276
+ }
277
+ }
278
+ continue;
279
+ }
280
+ if (word === "time") {
281
+ i++;
282
+ while (i < words.length && words[i]!.startsWith("-")) {
283
+ i++;
284
+ }
285
+ continue;
286
+ }
287
+ if (word === "sudo" || word === "doas") {
288
+ i++;
289
+ const valueOptions = new Set([
290
+ "-u",
291
+ "--user",
292
+ "-g",
293
+ "--group",
294
+ "-h",
295
+ "--host",
296
+ "-p",
297
+ "--prompt",
298
+ "-C",
299
+ "--close-from",
300
+ "-R",
301
+ "--chroot",
302
+ "-D",
303
+ "--chdir",
304
+ ]);
305
+ while (i < words.length && words[i]!.startsWith("-")) {
306
+ const option = words[i++]!;
307
+ if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) changesCwd = true;
308
+ if (valueOptions.has(option) && i < words.length) i++;
309
+ }
310
+ continue;
311
+ }
312
+ if (word === "env") {
313
+ i++;
314
+ const valueOptions = new Set([
315
+ "-u",
316
+ "--unset",
317
+ "--argv0",
318
+ "-C",
319
+ "--chdir",
320
+ "-S",
321
+ "--split-string",
322
+ ]);
323
+ while (i < words.length) {
324
+ const w = words[i]!;
325
+ if (w === "--") {
326
+ i++;
327
+ break;
328
+ }
329
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
330
+ i++;
331
+ continue;
332
+ }
333
+ let splitValue: string | undefined;
334
+ let consumed = 0;
335
+ const clustered = /^-[iv0]*([SC])(.*)$/.exec(w);
336
+ if (clustered?.[1] === "C") {
337
+ changesCwd = true;
338
+ i++;
339
+ if (!clustered[2] && i < words.length) i++;
340
+ continue;
341
+ }
342
+ if (clustered?.[1] === "S") {
343
+ if (clustered[2]) splitValue = clustered[2];
344
+ else if (i + 1 < words.length) {
345
+ splitValue = words[i + 1]!;
346
+ consumed = 1;
347
+ }
348
+ }
349
+ else if (w.startsWith("-S") && w !== "-S") splitValue = w.slice(2);
350
+ else if (w.startsWith("--split-string=")) splitValue = w.slice("--split-string=".length);
351
+ else if ((w === "-S" || w === "--split-string") && i + 1 < words.length) {
352
+ splitValue = words[i + 1]!;
353
+ consumed = 1;
354
+ }
355
+ if (splitValue !== undefined) {
356
+ words.splice(i, consumed + 1, ...envSplitWords(splitValue));
357
+ continue;
358
+ }
359
+ const clusteredValue = /^-[iv0]*([ua])(.*)$/.exec(w);
360
+ if (clusteredValue) {
361
+ i++;
362
+ if (!clusteredValue[2] && i < words.length) i++;
363
+ continue;
364
+ }
365
+ if (w.startsWith("-C") && w !== "-C") {
366
+ changesCwd = true;
367
+ i++;
368
+ continue;
369
+ }
370
+ if (!w.startsWith("-")) break;
371
+ i++;
372
+ if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) changesCwd = true;
373
+ if (valueOptions.has(w) && i < words.length) i++;
374
+ }
375
+ continue;
376
+ }
377
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
378
+ i++;
379
+ continue;
380
+ }
381
+ break;
382
+ }
383
+ return { words: words
384
+ .slice(i)
385
+ .map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
386
+ .filter(Boolean), changesCwd };
387
+ }
388
+
389
+ export function unwrapShellWords(command: string): string[] {
390
+ return unwrapShellWordsImpl(command).words;
391
+ }
392
+
393
+ export function shellWrappersChangeCwd(command: string): boolean {
394
+ return unwrapShellWordsImpl(command).changesCwd;
395
+ }
396
+
397
+ export function unwrapShellCommand(command: string): string {
398
+ return unwrapShellWords(command).join(" ");
399
+ }
package/src/lib/state.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { realpathSync, readFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
1
+ import { realpathSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
- import { persistVerifyCache } from "./audit.ts";
4
+ import { persistVerifyCache, persistVerifyHistory } from "./audit.ts";
5
+ import { getCachedProjectConfig, loadProjectConfig } from "./project-config.ts";
5
6
  import { snipVerifyOutput, getCurrentGitCommitHash, getGitStatusSummary, getGitWorktreeFingerprint } from "./verify.ts";
6
7
  import type {
7
8
  TodoSdkClient,
@@ -78,16 +79,16 @@ export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>()
78
79
  export let lastReview: ReviewResult | undefined;
79
80
  export const sessionReviews = new Map<string, ReviewResult>();
80
81
 
81
- export function recordMutation(sessionID?: string): void {
82
+ export function recordMutation(sessionID?: string, actorSessionID?: string): void {
82
83
  lastMutationTimestamp = Date.now();
83
84
  mutationCount++;
84
- if (sessionID) {
85
- sessionMutationTimestamps.set(sessionID, lastMutationTimestamp);
86
- sessionMutationCounts.set(sessionID, (sessionMutationCounts.get(sessionID) ?? 0) + 1);
87
- sessionVerifyResults.delete(sessionID);
85
+ for (const id of new Set([sessionID, actorSessionID].filter((value): value is string => Boolean(value)))) {
86
+ sessionMutationTimestamps.set(id, lastMutationTimestamp);
87
+ sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
88
+ sessionVerifyResults.delete(id);
89
+ sessionReviews.delete(id);
88
90
  }
89
- if (sessionID) sessionReviews.delete(sessionID);
90
- if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID) {
91
+ if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID || lastReview.targetSessionID === actorSessionID) {
91
92
  lastReview = undefined;
92
93
  }
93
94
  }
@@ -99,6 +100,10 @@ export function getMutationCount(sessionID?: string): number {
99
100
  return mutationCount;
100
101
  }
101
102
 
103
+ export function getSessionMutationCount(sessionID: string): number {
104
+ return sessionMutationCounts.get(sessionID) ?? 0;
105
+ }
106
+
102
107
  export function getLastMutationTimestamp(): number {
103
108
  return lastMutationTimestamp;
104
109
  }
@@ -124,6 +129,7 @@ export function recordVerifyResult(
124
129
  workspaceRoot: resolve(root),
125
130
  };
126
131
  if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
132
+ persistVerifyHistory(lastVerify);
127
133
  if (lastVerify.passed) {
128
134
  persistVerifyCache(lastVerify);
129
135
  }
@@ -173,99 +179,10 @@ export function resetReviewState(): void {
173
179
  sessionReviews.clear();
174
180
  }
175
181
 
176
- export let cachedProjectConfig: ProjectConfig | undefined;
177
-
178
- /**
179
- * Strips single-line comments, block comments, and trailing commas from JSONC
180
- * text without corrupting comment markers inside string literals or URLs.
181
- */
182
- export function stripJsonComments(jsonc: string): string {
183
- let insideString = false;
184
- let stringQuote = "";
185
- let escaped = false;
186
- let output = "";
187
- let i = 0;
188
-
189
- while (i < jsonc.length) {
190
- const char = jsonc[i]!;
191
- const next = jsonc[i + 1];
192
-
193
- if (escaped) {
194
- output += char;
195
- escaped = false;
196
- i++;
197
- continue;
198
- }
199
-
200
- if (char === "\\") {
201
- output += char;
202
- escaped = true;
203
- i++;
204
- continue;
205
- }
206
-
207
- if (char === '"' || char === "'") {
208
- if (!insideString) {
209
- insideString = true;
210
- stringQuote = char;
211
- } else if (stringQuote === char) {
212
- insideString = false;
213
- }
214
- output += char;
215
- i++;
216
- continue;
217
- }
218
-
219
- if (!insideString) {
220
- if (char === "/" && next === "/") {
221
- i += 2;
222
- while (i < jsonc.length && jsonc[i] !== "\n" && jsonc[i] !== "\r") {
223
- i++;
224
- }
225
- continue;
226
- }
227
- if (char === "/" && next === "*") {
228
- i += 2;
229
- while (i < jsonc.length && !(jsonc[i] === "*" && jsonc[i + 1] === "/")) {
230
- i++;
231
- }
232
- i += 2;
233
- continue;
234
- }
235
- }
236
-
237
- output += char;
238
- i++;
239
- }
240
-
241
- return output.replace(/,(\s*[}\]])/g, "$1");
242
- }
243
-
244
- export function loadProjectConfig(root: string): ProjectConfig {
245
- const candidates = [
246
- join(root, ".opencode", "workflow-guard.json"),
247
- join(root, ".opencode", "workflow-guard.jsonc"),
248
- join(root, "workflow-guard.json"),
249
- join(root, "workflow-guard.jsonc"),
250
- ];
251
- for (const candidate of candidates) {
252
- try {
253
- const raw = readFileSync(candidate, "utf8");
254
- const sanitized = stripJsonComments(raw);
255
- return JSON.parse(sanitized);
256
- } catch {}
257
- }
258
- return {};
259
- }
260
-
261
- export function reloadProjectConfig(root: string): void {
262
- cachedProjectConfig = loadProjectConfig(root);
263
- }
264
-
265
182
  export function getProjectConfig(root: string): ProjectConfig {
266
183
  const active = runtimeState.getStore();
267
184
  if (active?.workspaceRoot === root) return active.projectConfig;
268
- return cachedProjectConfig ?? loadProjectConfig(root);
185
+ return getCachedProjectConfig() ?? loadProjectConfig(root);
269
186
  }
270
187
 
271
188
  export function isReviewRequired(root: string): boolean {
@@ -289,11 +206,15 @@ export function getSubagentMutationBudget(root: string): number {
289
206
  if (typeof cfg.maxSubagentMutations === "number" && cfg.maxSubagentMutations > 0) {
290
207
  return cfg.maxSubagentMutations;
291
208
  }
292
- return 50;
209
+ return 100;
293
210
  }
294
211
 
295
212
  export function isLearningEnabled(root: string): boolean {
296
- return process.env.WORKFLOW_GUARD_LEARNING === "1";
213
+ return process.env.WORKFLOW_GUARD_LEARNING === "1" || getProjectConfig(root).learning === true;
214
+ }
215
+
216
+ export function isProjectMemoryEnabled(root: string): boolean {
217
+ return getProjectConfig(root).projectMemory !== false;
297
218
  }
298
219
 
299
220
  export function getLearningInterventionBudget(root: string): number {
@@ -0,0 +1,63 @@
1
+ import type { FileSnapshot } from "../policies/post-edit-validation.ts";
2
+ import type { ReadObservation } from "../policies/stale-write.ts";
3
+
4
+ function invocationKey(sessionID: string, callID: string): string {
5
+ return `${sessionID}\0${callID}`;
6
+ }
7
+
8
+ export class ToolInvocationLifecycle {
9
+ private readonly postEditSnapshots = new Map<string, { root: string; snapshots: FileSnapshot[] }>();
10
+ private readonly startedAt = new Map<string, number>();
11
+ private readonly readObservations = new Map<string, ReadObservation>();
12
+ private readonly recoveryRuns = new Map<string, number>();
13
+
14
+ start(sessionID: string, callID: string): void {
15
+ this.startedAt.set(invocationKey(sessionID, callID), Date.now());
16
+ }
17
+
18
+ finish(sessionID: string, callID: string): number | undefined {
19
+ const key = invocationKey(sessionID, callID);
20
+ const startedAt = this.startedAt.get(key);
21
+ this.startedAt.delete(key);
22
+ return startedAt;
23
+ }
24
+
25
+ setReadObservation(sessionID: string, callID: string, observation: ReadObservation): void {
26
+ this.readObservations.set(invocationKey(sessionID, callID), observation);
27
+ }
28
+
29
+ takeReadObservation(sessionID: string, callID: string): ReadObservation | undefined {
30
+ const key = invocationKey(sessionID, callID);
31
+ const observation = this.readObservations.get(key);
32
+ this.readObservations.delete(key);
33
+ return observation;
34
+ }
35
+
36
+ setPostEditSnapshots(sessionID: string, callID: string, root: string, snapshots: FileSnapshot[]): void {
37
+ this.postEditSnapshots.set(invocationKey(sessionID, callID), { root, snapshots });
38
+ }
39
+
40
+ takePostEditSnapshots(sessionID: string, callID: string): { root: string; snapshots: FileSnapshot[] } | undefined {
41
+ const key = invocationKey(sessionID, callID);
42
+ const snapshots = this.postEditSnapshots.get(key);
43
+ this.postEditSnapshots.delete(key);
44
+ return snapshots;
45
+ }
46
+
47
+ setRecoveryRun(sessionID: string, run: number): void {
48
+ this.recoveryRuns.set(sessionID, run);
49
+ }
50
+
51
+ takeRecoveryRun(sessionID: string): number | undefined {
52
+ const run = this.recoveryRuns.get(sessionID);
53
+ this.recoveryRuns.delete(sessionID);
54
+ return run;
55
+ }
56
+
57
+ clearSession(sessionID: string): void {
58
+ const prefix = `${sessionID}\0`;
59
+ for (const key of this.startedAt.keys()) {
60
+ if (key.startsWith(prefix)) this.startedAt.delete(key);
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export interface ToolOutcomePart {
4
+ type?: unknown;
5
+ sessionID?: unknown;
6
+ callID?: unknown;
7
+ tool?: unknown;
8
+ state?: {
9
+ status?: unknown;
10
+ error?: unknown;
11
+ time?: { start?: unknown; end?: unknown };
12
+ };
13
+ }
14
+
15
+ export interface ToolOutcome {
16
+ sessionID: string;
17
+ callID: string;
18
+ tool: string;
19
+ status: "completed" | "error";
20
+ durationMs?: number;
21
+ repeatedFailureCount?: number;
22
+ }
23
+
24
+ export class ToolOutcomeTracker {
25
+ private readonly terminalCalls = new Map<string, Set<string>>();
26
+ private readonly failures = new Map<string, { signature: string; count: number }>();
27
+ private static readonly maxTerminalCallsPerSession = 1024;
28
+
29
+ record(part: ToolOutcomePart): ToolOutcome | undefined {
30
+ if (part.type !== "tool" || typeof part.sessionID !== "string" || typeof part.callID !== "string" || typeof part.tool !== "string") return undefined;
31
+ const status = part.state?.status;
32
+ if (status !== "completed" && status !== "error") return undefined;
33
+ let terminalCalls = this.terminalCalls.get(part.sessionID);
34
+ if (!terminalCalls) {
35
+ terminalCalls = new Set();
36
+ this.terminalCalls.set(part.sessionID, terminalCalls);
37
+ }
38
+ if (terminalCalls.has(part.callID)) return undefined;
39
+ terminalCalls.add(part.callID);
40
+ if (terminalCalls.size > ToolOutcomeTracker.maxTerminalCallsPerSession) {
41
+ terminalCalls.delete(terminalCalls.values().next().value!);
42
+ }
43
+
44
+ const start = part.state?.time?.start;
45
+ const end = part.state?.time?.end;
46
+ const durationMs = typeof start === "number" && typeof end === "number" ? Math.max(0, end - start) : undefined;
47
+ if (status === "completed") {
48
+ this.failures.delete(part.sessionID);
49
+ return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs };
50
+ }
51
+
52
+ const error = typeof part.state?.error === "string" ? part.state.error : "";
53
+ const signature = createHash("sha256").update(`${part.tool}\0${error}`).digest("hex");
54
+ const previous = this.failures.get(part.sessionID);
55
+ const count = previous?.signature === signature ? previous.count + 1 : 1;
56
+ this.failures.set(part.sessionID, { signature, count });
57
+ return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs, repeatedFailureCount: count };
58
+ }
59
+
60
+ clearSession(sessionID: string): void {
61
+ this.failures.delete(sessionID);
62
+ this.terminalCalls.delete(sessionID);
63
+ }
64
+ }