opencode-workflow-guard 1.5.0 → 1.6.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,
@@ -124,6 +125,7 @@ export function recordVerifyResult(
124
125
  workspaceRoot: resolve(root),
125
126
  };
126
127
  if (sessionID && lastVerify) sessionVerifyResults.set(sessionID, lastVerify);
128
+ persistVerifyHistory(lastVerify);
127
129
  if (lastVerify.passed) {
128
130
  persistVerifyCache(lastVerify);
129
131
  }
@@ -173,99 +175,10 @@ export function resetReviewState(): void {
173
175
  sessionReviews.clear();
174
176
  }
175
177
 
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
178
  export function getProjectConfig(root: string): ProjectConfig {
266
179
  const active = runtimeState.getStore();
267
180
  if (active?.workspaceRoot === root) return active.projectConfig;
268
- return cachedProjectConfig ?? loadProjectConfig(root);
181
+ return getCachedProjectConfig() ?? loadProjectConfig(root);
269
182
  }
270
183
 
271
184
  export function isReviewRequired(root: string): boolean {
@@ -289,11 +202,15 @@ export function getSubagentMutationBudget(root: string): number {
289
202
  if (typeof cfg.maxSubagentMutations === "number" && cfg.maxSubagentMutations > 0) {
290
203
  return cfg.maxSubagentMutations;
291
204
  }
292
- return 50;
205
+ return 100;
293
206
  }
294
207
 
295
208
  export function isLearningEnabled(root: string): boolean {
296
- return process.env.WORKFLOW_GUARD_LEARNING === "1";
209
+ return process.env.WORKFLOW_GUARD_LEARNING === "1" || getProjectConfig(root).learning === true;
210
+ }
211
+
212
+ export function isProjectMemoryEnabled(root: string): boolean {
213
+ return getProjectConfig(root).projectMemory !== false;
297
214
  }
298
215
 
299
216
  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
+ }