claude-ide-bridge 2.34.0 → 2.42.2

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.
Files changed (170) hide show
  1. package/README.md +28 -5
  2. package/dist/activityLog.d.ts +19 -3
  3. package/dist/activityLog.js +63 -65
  4. package/dist/activityLog.js.map +1 -1
  5. package/dist/automation.d.ts +23 -94
  6. package/dist/automation.js +270 -1797
  7. package/dist/automation.js.map +1 -1
  8. package/dist/bridge.d.ts +3 -0
  9. package/dist/bridge.js +122 -3
  10. package/dist/bridge.js.map +1 -1
  11. package/dist/claudeDriver.d.ts +47 -0
  12. package/dist/claudeDriver.js +58 -2
  13. package/dist/claudeDriver.js.map +1 -1
  14. package/dist/claudeMdPatch.d.ts +29 -0
  15. package/dist/claudeMdPatch.js +164 -0
  16. package/dist/claudeMdPatch.js.map +1 -0
  17. package/dist/claudeOrchestrator.js +12 -3
  18. package/dist/claudeOrchestrator.js.map +1 -1
  19. package/dist/commands/task.d.ts +14 -0
  20. package/dist/commands/task.js +289 -0
  21. package/dist/commands/task.js.map +1 -0
  22. package/dist/commands/tokenEfficiency.d.ts +9 -0
  23. package/dist/commands/tokenEfficiency.js +211 -0
  24. package/dist/commands/tokenEfficiency.js.map +1 -0
  25. package/dist/commands/tools.d.ts +28 -0
  26. package/dist/commands/tools.js +326 -0
  27. package/dist/commands/tools.js.map +1 -0
  28. package/dist/dashboard.js +40 -0
  29. package/dist/dashboard.js.map +1 -1
  30. package/dist/errors.d.ts +1 -0
  31. package/dist/errors.js +1 -0
  32. package/dist/errors.js.map +1 -1
  33. package/dist/extensionClient.d.ts +5 -12
  34. package/dist/extensionClient.js +20 -26
  35. package/dist/extensionClient.js.map +1 -1
  36. package/dist/fp/activityAnalytics.d.ts +39 -0
  37. package/dist/fp/activityAnalytics.js +122 -0
  38. package/dist/fp/activityAnalytics.js.map +1 -0
  39. package/dist/fp/async.d.ts +48 -0
  40. package/dist/fp/async.js +60 -0
  41. package/dist/fp/async.js.map +1 -0
  42. package/dist/fp/automationInterpreter.d.ts +37 -0
  43. package/dist/fp/automationInterpreter.js +523 -0
  44. package/dist/fp/automationInterpreter.js.map +1 -0
  45. package/dist/fp/automationProgram.d.ts +89 -0
  46. package/dist/fp/automationProgram.js +29 -0
  47. package/dist/fp/automationProgram.js.map +1 -0
  48. package/dist/fp/automationState.d.ts +135 -0
  49. package/dist/fp/automationState.js +206 -0
  50. package/dist/fp/automationState.js.map +1 -0
  51. package/dist/fp/automationUtils.d.ts +27 -0
  52. package/dist/fp/automationUtils.js +48 -0
  53. package/dist/fp/automationUtils.js.map +1 -0
  54. package/dist/fp/brandedTypes.d.ts +32 -0
  55. package/dist/fp/brandedTypes.js +41 -0
  56. package/dist/fp/brandedTypes.js.map +1 -0
  57. package/dist/fp/commandDescription.d.ts +18 -0
  58. package/dist/fp/commandDescription.js +125 -0
  59. package/dist/fp/commandDescription.js.map +1 -0
  60. package/dist/fp/extensionSnapshot.d.ts +10 -0
  61. package/dist/fp/extensionSnapshot.js +14 -0
  62. package/dist/fp/extensionSnapshot.js.map +1 -0
  63. package/dist/fp/index.d.ts +8 -0
  64. package/dist/fp/index.js +9 -0
  65. package/dist/fp/index.js.map +1 -0
  66. package/dist/fp/interpreterContext.d.ts +69 -0
  67. package/dist/fp/interpreterContext.js +56 -0
  68. package/dist/fp/interpreterContext.js.map +1 -0
  69. package/dist/fp/policyParser.d.ts +16 -0
  70. package/dist/fp/policyParser.js +334 -0
  71. package/dist/fp/policyParser.js.map +1 -0
  72. package/dist/fp/result.d.ts +38 -0
  73. package/dist/fp/result.js +57 -0
  74. package/dist/fp/result.js.map +1 -0
  75. package/dist/fp/tokenBucket.d.ts +27 -0
  76. package/dist/fp/tokenBucket.js +36 -0
  77. package/dist/fp/tokenBucket.js.map +1 -0
  78. package/dist/index.d.ts +1 -1
  79. package/dist/index.js +103 -57
  80. package/dist/index.js.map +1 -1
  81. package/dist/oauth.js +9 -34
  82. package/dist/oauth.js.map +1 -1
  83. package/dist/prompts.js +123 -0
  84. package/dist/prompts.js.map +1 -1
  85. package/dist/quickTaskPresets.d.ts +64 -0
  86. package/dist/quickTaskPresets.js +156 -0
  87. package/dist/quickTaskPresets.js.map +1 -0
  88. package/dist/server.d.ts +9 -0
  89. package/dist/server.js +47 -0
  90. package/dist/server.js.map +1 -1
  91. package/dist/streamableHttp.js +6 -0
  92. package/dist/streamableHttp.js.map +1 -1
  93. package/dist/tools/activityLog.js +2 -2
  94. package/dist/tools/activityLog.js.map +1 -1
  95. package/dist/tools/auditDependencies.js +1 -1
  96. package/dist/tools/auditDependencies.js.map +1 -1
  97. package/dist/tools/batchLsp.d.ts +57 -0
  98. package/dist/tools/batchLsp.js +79 -13
  99. package/dist/tools/batchLsp.js.map +1 -1
  100. package/dist/tools/bridgeStatus.js +3 -5
  101. package/dist/tools/bridgeStatus.js.map +1 -1
  102. package/dist/tools/explainDiagnostic.d.ts +137 -0
  103. package/dist/tools/explainDiagnostic.js +230 -0
  104. package/dist/tools/explainDiagnostic.js.map +1 -0
  105. package/dist/tools/formatAndSave.d.ts +0 -23
  106. package/dist/tools/formatAndSave.js +22 -5
  107. package/dist/tools/formatAndSave.js.map +1 -1
  108. package/dist/tools/getAnalyticsReport.js +8 -0
  109. package/dist/tools/getAnalyticsReport.js.map +1 -1
  110. package/dist/tools/getClaudeTaskStatus.js +2 -2
  111. package/dist/tools/getClaudeTaskStatus.js.map +1 -1
  112. package/dist/tools/getDiagnostics.js +17 -3
  113. package/dist/tools/getDiagnostics.js.map +1 -1
  114. package/dist/tools/getDiffFromHandoff.d.ts +89 -0
  115. package/dist/tools/getDiffFromHandoff.js +163 -0
  116. package/dist/tools/getDiffFromHandoff.js.map +1 -0
  117. package/dist/tools/github/pr.js +1 -1
  118. package/dist/tools/github/pr.js.map +1 -1
  119. package/dist/tools/handoffNote.js +91 -6
  120. package/dist/tools/handoffNote.js.map +1 -1
  121. package/dist/tools/httpClient.js +1 -1
  122. package/dist/tools/httpClient.js.map +1 -1
  123. package/dist/tools/index.d.ts +1 -1
  124. package/dist/tools/index.js +83 -10
  125. package/dist/tools/index.js.map +1 -1
  126. package/dist/tools/jumpToFirstError.d.ts +0 -7
  127. package/dist/tools/jumpToFirstError.js +6 -6
  128. package/dist/tools/jumpToFirstError.js.map +1 -1
  129. package/dist/tools/launchQuickTask.d.ts +76 -0
  130. package/dist/tools/launchQuickTask.js +170 -0
  131. package/dist/tools/launchQuickTask.js.map +1 -0
  132. package/dist/tools/listClaudeTasks.js +2 -2
  133. package/dist/tools/listClaudeTasks.js.map +1 -1
  134. package/dist/tools/openFile.js +2 -2
  135. package/dist/tools/openFile.js.map +1 -1
  136. package/dist/tools/performanceReport.d.ts +133 -0
  137. package/dist/tools/performanceReport.js +218 -0
  138. package/dist/tools/performanceReport.js.map +1 -0
  139. package/dist/tools/previewEdit.d.ts +107 -0
  140. package/dist/tools/previewEdit.js +270 -0
  141. package/dist/tools/previewEdit.js.map +1 -0
  142. package/dist/tools/runClaudeTask.js +7 -7
  143. package/dist/tools/runClaudeTask.js.map +1 -1
  144. package/dist/tools/runCommand.js +8 -141
  145. package/dist/tools/runCommand.js.map +1 -1
  146. package/dist/tools/runTests.js +16 -3
  147. package/dist/tools/runTests.js.map +1 -1
  148. package/dist/tools/searchAndReplace.js +1 -1
  149. package/dist/tools/searchAndReplace.js.map +1 -1
  150. package/dist/tools/spawnWorkspace.d.ts +103 -0
  151. package/dist/tools/spawnWorkspace.js +268 -0
  152. package/dist/tools/spawnWorkspace.js.map +1 -0
  153. package/dist/tools/terminal.js +1 -1
  154. package/dist/tools/terminal.js.map +1 -1
  155. package/dist/tools/testTraceToSource.d.ts +80 -0
  156. package/dist/tools/testTraceToSource.js +206 -0
  157. package/dist/tools/testTraceToSource.js.map +1 -0
  158. package/dist/tools/transaction.d.ts +243 -0
  159. package/dist/tools/transaction.js +309 -0
  160. package/dist/tools/transaction.js.map +1 -0
  161. package/dist/tools/utils.d.ts +2 -1
  162. package/dist/tools/utils.js.map +1 -1
  163. package/dist/tools/watchDiagnostics.js +29 -13
  164. package/dist/tools/watchDiagnostics.js.map +1 -1
  165. package/dist/transport.d.ts +7 -0
  166. package/dist/transport.js +25 -8
  167. package/dist/transport.js.map +1 -1
  168. package/package.json +2 -1
  169. package/templates/managed-agent/code-review-agent.md +50 -0
  170. package/templates/managed-agent/managed-agent-mcp.json +102 -0
@@ -1,56 +1,12 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { minimatch } from "minimatch";
5
- import { getPrompt } from "./prompts.js";
6
- /** Maximum length (chars) of a single diagnostic message before truncation */
7
- const MAX_DIAGNOSTIC_MSG_CHARS = 500;
8
- const MAX_DIAGNOSTICS_IN_PROMPT = 20;
9
- /**
10
- * Wrap an untrusted user-controlled value in delimiters that include a
11
- * per-trigger nonce so a crafted value cannot forge a closing delimiter.
12
- * The nonce is stripped from the value itself before insertion.
13
- */
14
- function untrustedBlock(label, value, nonce) {
15
- if (!/^[A-Z][A-Z0-9 ]*$/.test(label)) {
16
- throw new Error(`untrustedBlock: label must be uppercase ASCII, got: ${JSON.stringify(label)}`);
17
- }
18
- const safe = value.replace(new RegExp(nonce, "g"), "");
19
- return `\n--- BEGIN ${label} [${nonce}] (untrusted) ---\n${safe}\n--- END ${label} [${nonce}] ---\n`;
20
- }
21
- /** Maximum length (chars) of a file path inserted into prompts */
22
- const MAX_FILE_PATH_CHARS = 500;
23
- /**
24
- * Build a trusted metadata prefix that is prepended to every automation hook
25
- * prompt BEFORE any untrustedBlock() substitutions. This allows Claude to
26
- * identify which hook triggered the task and correlate it with IDE context.
27
- */
28
- function buildHookMetadata(hookName, file) {
29
- // Strip control characters from the file path before embedding in the trusted
30
- // metadata prefix — prevents a crafted file name containing \n from injecting
31
- // additional lines into the structured header block.
32
- const safeFile = file
33
- ? file.slice(0, MAX_FILE_PATH_CHARS).replace(/[\x00-\x1F\x7F]/g, "")
34
- : "N/A";
35
- return `@@ HOOK: ${hookName} | file: ${safeFile} | ts: ${new Date().toISOString()} @@\n`;
36
- }
4
+ import { executeAutomationPolicy } from "./fp/automationInterpreter.js";
5
+ import { EMPTY_AUTOMATION_STATE, setLatestDiagnostics, setTestRunnerStatus, tasksInLastHour, } from "./fp/automationState.js";
6
+ import { VsCodeBackend } from "./fp/interpreterContext.js";
7
+ import { parsePolicy } from "./fp/policyParser.js";
37
8
  /** Maximum length (chars) of an automation policy prompt template (matches runClaudeTask cap) */
38
9
  const MAX_POLICY_PROMPT_CHARS = 32_768;
39
- /**
40
- * Truncate a final prompt to MAX_POLICY_PROMPT_CHARS at the last newline before
41
- * the limit and append a truncation notice. Called after all placeholder
42
- * substitutions and buildHookMetadata() prepends so the cap applies to the
43
- * fully-assembled string, not just the raw template.
44
- */
45
- function truncatePrompt(prompt) {
46
- if (prompt.length <= MAX_POLICY_PROMPT_CHARS)
47
- return prompt;
48
- const cutoff = prompt.lastIndexOf("\n", MAX_POLICY_PROMPT_CHARS);
49
- const end = cutoff > 0 ? cutoff : MAX_POLICY_PROMPT_CHARS;
50
- return `${prompt.slice(0, end)}\n[... truncated to fit 32KB limit ...]`;
51
- }
52
- /** Prune lastTrigger entries older than this to prevent unbounded Map growth */
53
- const LAST_TRIGGER_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
54
10
  /** Default system prompt for automation subprocesses when none is set in policy. */
55
11
  const DEFAULT_AUTOMATION_SYSTEM_PROMPT = "You are a concise automation assistant. " +
56
12
  "Respond in \u22645 lines. No preamble. No markdown headers. " +
@@ -171,7 +127,66 @@ export function loadPolicy(filePath) {
171
127
  throw new Error(`"defaultEffort" must be one of "low", "medium", "high", "max"`);
172
128
  }
173
129
  }
174
- // Validate onDiagnosticsError
130
+ const HOOK_SUBJECT_KEY = {
131
+ onFileSave: "file",
132
+ onFileChanged: "file",
133
+ onGitCommit: "message",
134
+ onGitPush: "branch",
135
+ onGitPull: "branch",
136
+ onBranchCheckout: "branch",
137
+ onPullRequest: "title",
138
+ onTestPassAfterFailure: "runner",
139
+ onTaskCreated: "prompt",
140
+ onTaskSuccess: "output",
141
+ onPermissionDenied: "tool",
142
+ onDiagnosticsCleared: "file",
143
+ onCwdChanged: "cwd",
144
+ onPreCompact: "session",
145
+ onPostCompact: "session",
146
+ onTestRun: "runner",
147
+ onDebugSessionStart: "sessionName",
148
+ onDebugSessionEnd: "sessionName",
149
+ };
150
+ void HOOK_SUBJECT_KEY; // referenced by callers; kept for documentation
151
+ // Standard hooks: required cooldownMs, no extra fields
152
+ const STANDARD_HOOK_KEYS = [
153
+ "onTestPassAfterFailure",
154
+ "onGitCommit",
155
+ "onGitPush",
156
+ "onGitPull",
157
+ "onBranchCheckout",
158
+ "onPullRequest",
159
+ "onTaskCreated",
160
+ "onPermissionDenied",
161
+ "onDiagnosticsCleared",
162
+ "onTaskSuccess",
163
+ "onDebugSessionStart",
164
+ "onDebugSessionEnd",
165
+ "onCwdChanged",
166
+ "onPreCompact",
167
+ "onPostCompact",
168
+ ];
169
+ for (const key of STANDARD_HOOK_KEYS) {
170
+ const cfg = policy[key];
171
+ if (cfg === undefined)
172
+ continue;
173
+ if (typeof cfg !== "object" || cfg === null) {
174
+ throw new Error(`"${key}" must be an object`);
175
+ }
176
+ const rec = cfg;
177
+ if (typeof rec.enabled !== "boolean") {
178
+ throw new Error(`"${key}.enabled" must be a boolean`);
179
+ }
180
+ validatePromptSource(key, rec);
181
+ expectType(rec.cooldownMs, "number", `${key}.cooldownMs`);
182
+ if (!Number.isFinite(rec.cooldownMs)) {
183
+ throw new Error(`"${key}.cooldownMs" must be a finite number`);
184
+ }
185
+ rec.cooldownMs = Math.max(rec.cooldownMs, MIN_COOLDOWN_MS);
186
+ }
187
+ // ── Per-hook extras (after generic fold) ─────────────────────────────────
188
+ // Validate onDiagnosticsError (extra: minSeverity required, diagnosticTypes,
189
+ // dedupeByContent, dedupeContentCooldownMs)
175
190
  if (policy.onDiagnosticsError !== undefined) {
176
191
  const d = policy.onDiagnosticsError;
177
192
  if (typeof d !== "object" || d === null) {
@@ -188,9 +203,7 @@ export function loadPolicy(filePath) {
188
203
  if (!Number.isFinite(d.cooldownMs)) {
189
204
  throw new Error(`"onDiagnosticsError.cooldownMs" must be a finite number`);
190
205
  }
191
- if (d.cooldownMs < MIN_COOLDOWN_MS) {
192
- d.cooldownMs = MIN_COOLDOWN_MS;
193
- }
206
+ d.cooldownMs = Math.max(d.cooldownMs, MIN_COOLDOWN_MS);
194
207
  if (d.diagnosticTypes !== undefined) {
195
208
  if (!Array.isArray(d.diagnosticTypes) ||
196
209
  d.diagnosticTypes.length === 0 ||
@@ -207,12 +220,10 @@ export function loadPolicy(filePath) {
207
220
  !Number.isFinite(d.dedupeContentCooldownMs)) {
208
221
  throw new Error(`"onDiagnosticsError.dedupeContentCooldownMs" must be a number`);
209
222
  }
210
- if (d.dedupeContentCooldownMs < MIN_COOLDOWN_MS) {
211
- d.dedupeContentCooldownMs = MIN_COOLDOWN_MS;
212
- }
223
+ d.dedupeContentCooldownMs = Math.max(d.dedupeContentCooldownMs, MIN_COOLDOWN_MS);
213
224
  }
214
225
  }
215
- // Validate onFileSave
226
+ // Validate onFileSave (extra: patterns required)
216
227
  if (policy.onFileSave !== undefined) {
217
228
  const s = policy.onFileSave;
218
229
  if (typeof s !== "object" || s === null) {
@@ -231,11 +242,9 @@ export function loadPolicy(filePath) {
231
242
  if (!Number.isFinite(s.cooldownMs)) {
232
243
  throw new Error(`"onFileSave.cooldownMs" must be a finite number`);
233
244
  }
234
- if (s.cooldownMs < MIN_COOLDOWN_MS) {
235
- s.cooldownMs = MIN_COOLDOWN_MS;
236
- }
245
+ s.cooldownMs = Math.max(s.cooldownMs, MIN_COOLDOWN_MS);
237
246
  }
238
- // Validate onFileChanged
247
+ // Validate onFileChanged (extra: patterns required)
239
248
  if (policy.onFileChanged !== undefined) {
240
249
  const fc = policy.onFileChanged;
241
250
  if (typeof fc !== "object" || fc === null) {
@@ -254,65 +263,9 @@ export function loadPolicy(filePath) {
254
263
  if (!Number.isFinite(fc.cooldownMs)) {
255
264
  throw new Error(`"onFileChanged.cooldownMs" must be a finite number`);
256
265
  }
257
- if (fc.cooldownMs < MIN_COOLDOWN_MS) {
258
- fc.cooldownMs = MIN_COOLDOWN_MS;
259
- }
260
- }
261
- // Validate onCwdChanged
262
- if (policy.onCwdChanged !== undefined) {
263
- const cw = policy.onCwdChanged;
264
- if (typeof cw !== "object" || cw === null) {
265
- throw new Error(`"onCwdChanged" must be an object`);
266
- }
267
- if (typeof cw.enabled !== "boolean") {
268
- throw new Error(`"onCwdChanged.enabled" must be a boolean`);
269
- }
270
- validatePromptSource("onCwdChanged", cw);
271
- expectType(cw.cooldownMs, "number", "onCwdChanged.cooldownMs");
272
- if (!Number.isFinite(cw.cooldownMs)) {
273
- throw new Error(`"onCwdChanged.cooldownMs" must be a finite number`);
274
- }
275
- if (cw.cooldownMs < MIN_COOLDOWN_MS) {
276
- cw.cooldownMs = MIN_COOLDOWN_MS;
277
- }
278
- }
279
- // Validate onPreCompact
280
- if (policy.onPreCompact !== undefined) {
281
- const p = policy.onPreCompact;
282
- if (typeof p !== "object" || p === null) {
283
- throw new Error(`"onPreCompact" must be an object`);
284
- }
285
- if (typeof p.enabled !== "boolean") {
286
- throw new Error(`"onPreCompact.enabled" must be a boolean`);
287
- }
288
- validatePromptSource("onPreCompact", p);
289
- expectType(p.cooldownMs, "number", "onPreCompact.cooldownMs");
290
- if (!Number.isFinite(p.cooldownMs)) {
291
- throw new Error(`"onPreCompact.cooldownMs" must be a finite number`);
292
- }
293
- if (p.cooldownMs < MIN_COOLDOWN_MS) {
294
- p.cooldownMs = MIN_COOLDOWN_MS;
295
- }
296
- }
297
- // Validate onPostCompact
298
- if (policy.onPostCompact !== undefined) {
299
- const p = policy.onPostCompact;
300
- if (typeof p !== "object" || p === null) {
301
- throw new Error(`"onPostCompact" must be an object`);
302
- }
303
- if (typeof p.enabled !== "boolean") {
304
- throw new Error(`"onPostCompact.enabled" must be a boolean`);
305
- }
306
- validatePromptSource("onPostCompact", p);
307
- expectType(p.cooldownMs, "number", "onPostCompact.cooldownMs");
308
- if (!Number.isFinite(p.cooldownMs)) {
309
- throw new Error(`"onPostCompact.cooldownMs" must be a finite number`);
310
- }
311
- if (p.cooldownMs < MIN_COOLDOWN_MS) {
312
- p.cooldownMs = MIN_COOLDOWN_MS;
313
- }
266
+ fc.cooldownMs = Math.max(fc.cooldownMs, MIN_COOLDOWN_MS);
314
267
  }
315
- // Validate onInstructionsLoaded
268
+ // Validate onInstructionsLoaded (special: cooldownMs optional, min 5000)
316
269
  if (policy.onInstructionsLoaded !== undefined) {
317
270
  const il = policy.onInstructionsLoaded;
318
271
  if (typeof il !== "object" || il === null) {
@@ -328,7 +281,7 @@ export function loadPolicy(filePath) {
328
281
  }
329
282
  validatePromptSource("onInstructionsLoaded", il);
330
283
  }
331
- // Validate onTestRun
284
+ // Validate onTestRun (extra: onFailureOnly required, minDuration optional)
332
285
  if (policy.onTestRun !== undefined) {
333
286
  const tr = policy.onTestRun;
334
287
  if (typeof tr !== "object" || tr === null) {
@@ -345,9 +298,7 @@ export function loadPolicy(filePath) {
345
298
  if (!Number.isFinite(tr.cooldownMs)) {
346
299
  throw new Error(`"onTestRun.cooldownMs" must be a finite number`);
347
300
  }
348
- if (tr.cooldownMs < MIN_COOLDOWN_MS) {
349
- tr.cooldownMs = MIN_COOLDOWN_MS;
350
- }
301
+ tr.cooldownMs = Math.max(tr.cooldownMs, MIN_COOLDOWN_MS);
351
302
  if (tr.minDuration !== undefined) {
352
303
  if (typeof tr.minDuration !== "number" ||
353
304
  !Number.isFinite(tr.minDuration) ||
@@ -356,222 +307,6 @@ export function loadPolicy(filePath) {
356
307
  }
357
308
  }
358
309
  }
359
- // Validate onTestPassAfterFailure
360
- if (policy.onTestPassAfterFailure !== undefined) {
361
- const tpaf = policy.onTestPassAfterFailure;
362
- if (typeof tpaf !== "object" || tpaf === null) {
363
- throw new Error(`"onTestPassAfterFailure" must be an object`);
364
- }
365
- if (typeof tpaf.enabled !== "boolean") {
366
- throw new Error(`"onTestPassAfterFailure.enabled" must be a boolean`);
367
- }
368
- validatePromptSource("onTestPassAfterFailure", tpaf);
369
- expectType(tpaf.cooldownMs, "number", "onTestPassAfterFailure.cooldownMs");
370
- if (!Number.isFinite(tpaf.cooldownMs)) {
371
- throw new Error(`"onTestPassAfterFailure.cooldownMs" must be a finite number`);
372
- }
373
- if (tpaf.cooldownMs < MIN_COOLDOWN_MS) {
374
- tpaf.cooldownMs = MIN_COOLDOWN_MS;
375
- }
376
- }
377
- // Validate onGitCommit
378
- if (policy.onGitCommit !== undefined) {
379
- const gc = policy.onGitCommit;
380
- if (typeof gc !== "object" || gc === null) {
381
- throw new Error(`"onGitCommit" must be an object`);
382
- }
383
- if (typeof gc.enabled !== "boolean") {
384
- throw new Error(`"onGitCommit.enabled" must be a boolean`);
385
- }
386
- validatePromptSource("onGitCommit", gc);
387
- expectType(gc.cooldownMs, "number", "onGitCommit.cooldownMs");
388
- if (!Number.isFinite(gc.cooldownMs)) {
389
- throw new Error(`"onGitCommit.cooldownMs" must be a finite number`);
390
- }
391
- if (gc.cooldownMs < MIN_COOLDOWN_MS) {
392
- gc.cooldownMs = MIN_COOLDOWN_MS;
393
- }
394
- }
395
- // Validate onGitPush
396
- if (policy.onGitPush !== undefined) {
397
- const gp = policy.onGitPush;
398
- if (typeof gp !== "object" || gp === null) {
399
- throw new Error(`"onGitPush" must be an object`);
400
- }
401
- if (typeof gp.enabled !== "boolean") {
402
- throw new Error(`"onGitPush.enabled" must be a boolean`);
403
- }
404
- validatePromptSource("onGitPush", gp);
405
- expectType(gp.cooldownMs, "number", "onGitPush.cooldownMs");
406
- if (!Number.isFinite(gp.cooldownMs)) {
407
- throw new Error(`"onGitPush.cooldownMs" must be a finite number`);
408
- }
409
- if (gp.cooldownMs < MIN_COOLDOWN_MS) {
410
- gp.cooldownMs = MIN_COOLDOWN_MS;
411
- }
412
- }
413
- // Validate onGitPull
414
- if (policy.onGitPull !== undefined) {
415
- const gpl = policy.onGitPull;
416
- if (typeof gpl !== "object" || gpl === null) {
417
- throw new Error(`"onGitPull" must be an object`);
418
- }
419
- if (typeof gpl.enabled !== "boolean") {
420
- throw new Error(`"onGitPull.enabled" must be a boolean`);
421
- }
422
- validatePromptSource("onGitPull", gpl);
423
- expectType(gpl.cooldownMs, "number", "onGitPull.cooldownMs");
424
- if (!Number.isFinite(gpl.cooldownMs)) {
425
- throw new Error(`"onGitPull.cooldownMs" must be a finite number`);
426
- }
427
- if (gpl.cooldownMs < MIN_COOLDOWN_MS) {
428
- gpl.cooldownMs = MIN_COOLDOWN_MS;
429
- }
430
- }
431
- // Validate onBranchCheckout
432
- if (policy.onBranchCheckout !== undefined) {
433
- const bc = policy.onBranchCheckout;
434
- if (typeof bc !== "object" || bc === null) {
435
- throw new Error(`"onBranchCheckout" must be an object`);
436
- }
437
- if (typeof bc.enabled !== "boolean") {
438
- throw new Error(`"onBranchCheckout.enabled" must be a boolean`);
439
- }
440
- validatePromptSource("onBranchCheckout", bc);
441
- expectType(bc.cooldownMs, "number", "onBranchCheckout.cooldownMs");
442
- if (!Number.isFinite(bc.cooldownMs)) {
443
- throw new Error(`"onBranchCheckout.cooldownMs" must be a finite number`);
444
- }
445
- if (bc.cooldownMs < MIN_COOLDOWN_MS) {
446
- bc.cooldownMs = MIN_COOLDOWN_MS;
447
- }
448
- }
449
- // Validate onPullRequest
450
- if (policy.onPullRequest !== undefined) {
451
- const pr = policy.onPullRequest;
452
- if (typeof pr !== "object" || pr === null) {
453
- throw new Error(`"onPullRequest" must be an object`);
454
- }
455
- if (typeof pr.enabled !== "boolean") {
456
- throw new Error(`"onPullRequest.enabled" must be a boolean`);
457
- }
458
- validatePromptSource("onPullRequest", pr);
459
- expectType(pr.cooldownMs, "number", "onPullRequest.cooldownMs");
460
- if (!Number.isFinite(pr.cooldownMs)) {
461
- throw new Error(`"onPullRequest.cooldownMs" must be a finite number`);
462
- }
463
- if (pr.cooldownMs < MIN_COOLDOWN_MS) {
464
- pr.cooldownMs = MIN_COOLDOWN_MS;
465
- }
466
- }
467
- // Validate onTaskCreated
468
- if (policy.onTaskCreated !== undefined) {
469
- const tc = policy.onTaskCreated;
470
- if (typeof tc !== "object" || tc === null) {
471
- throw new Error(`"onTaskCreated" must be an object`);
472
- }
473
- if (typeof tc.enabled !== "boolean") {
474
- throw new Error(`"onTaskCreated.enabled" must be a boolean`);
475
- }
476
- validatePromptSource("onTaskCreated", tc);
477
- expectType(tc.cooldownMs, "number", "onTaskCreated.cooldownMs");
478
- if (!Number.isFinite(tc.cooldownMs)) {
479
- throw new Error(`"onTaskCreated.cooldownMs" must be a finite number`);
480
- }
481
- if (tc.cooldownMs < MIN_COOLDOWN_MS) {
482
- tc.cooldownMs = MIN_COOLDOWN_MS;
483
- }
484
- }
485
- // Validate onPermissionDenied
486
- if (policy.onPermissionDenied !== undefined) {
487
- const pd = policy.onPermissionDenied;
488
- if (typeof pd !== "object" || pd === null) {
489
- throw new Error(`"onPermissionDenied" must be an object`);
490
- }
491
- if (typeof pd.enabled !== "boolean") {
492
- throw new Error(`"onPermissionDenied.enabled" must be a boolean`);
493
- }
494
- validatePromptSource("onPermissionDenied", pd);
495
- expectType(pd.cooldownMs, "number", "onPermissionDenied.cooldownMs");
496
- if (!Number.isFinite(pd.cooldownMs)) {
497
- throw new Error(`"onPermissionDenied.cooldownMs" must be a finite number`);
498
- }
499
- if (pd.cooldownMs < MIN_COOLDOWN_MS) {
500
- pd.cooldownMs = MIN_COOLDOWN_MS;
501
- }
502
- }
503
- // Validate onDiagnosticsCleared
504
- if (policy.onDiagnosticsCleared !== undefined) {
505
- const dc = policy.onDiagnosticsCleared;
506
- if (typeof dc !== "object" || dc === null) {
507
- throw new Error(`"onDiagnosticsCleared" must be an object`);
508
- }
509
- if (typeof dc.enabled !== "boolean") {
510
- throw new Error(`"onDiagnosticsCleared.enabled" must be a boolean`);
511
- }
512
- validatePromptSource("onDiagnosticsCleared", dc);
513
- expectType(dc.cooldownMs, "number", "onDiagnosticsCleared.cooldownMs");
514
- if (!Number.isFinite(dc.cooldownMs)) {
515
- throw new Error(`"onDiagnosticsCleared.cooldownMs" must be a finite number`);
516
- }
517
- if (dc.cooldownMs < MIN_COOLDOWN_MS) {
518
- dc.cooldownMs = MIN_COOLDOWN_MS;
519
- }
520
- }
521
- // Validate onTaskSuccess
522
- if (policy.onTaskSuccess !== undefined) {
523
- const ts = policy.onTaskSuccess;
524
- if (typeof ts !== "object" || ts === null) {
525
- throw new Error(`"onTaskSuccess" must be an object`);
526
- }
527
- if (typeof ts.enabled !== "boolean") {
528
- throw new Error(`"onTaskSuccess.enabled" must be a boolean`);
529
- }
530
- validatePromptSource("onTaskSuccess", ts);
531
- expectType(ts.cooldownMs, "number", "onTaskSuccess.cooldownMs");
532
- if (!Number.isFinite(ts.cooldownMs)) {
533
- throw new Error(`"onTaskSuccess.cooldownMs" must be a finite number`);
534
- }
535
- if (ts.cooldownMs < MIN_COOLDOWN_MS) {
536
- ts.cooldownMs = MIN_COOLDOWN_MS;
537
- }
538
- }
539
- // Validate onDebugSessionStart
540
- if (policy.onDebugSessionStart !== undefined) {
541
- const dss = policy.onDebugSessionStart;
542
- if (typeof dss !== "object" || dss === null) {
543
- throw new Error(`"onDebugSessionStart" must be an object`);
544
- }
545
- if (typeof dss.enabled !== "boolean") {
546
- throw new Error(`"onDebugSessionStart.enabled" must be a boolean`);
547
- }
548
- validatePromptSource("onDebugSessionStart", dss);
549
- expectType(dss.cooldownMs, "number", "onDebugSessionStart.cooldownMs");
550
- if (!Number.isFinite(dss.cooldownMs)) {
551
- throw new Error(`"onDebugSessionStart.cooldownMs" must be a finite number`);
552
- }
553
- if (dss.cooldownMs < MIN_COOLDOWN_MS) {
554
- dss.cooldownMs = MIN_COOLDOWN_MS;
555
- }
556
- }
557
- // Validate onDebugSessionEnd
558
- if (policy.onDebugSessionEnd !== undefined) {
559
- const dse = policy.onDebugSessionEnd;
560
- if (typeof dse !== "object" || dse === null) {
561
- throw new Error(`"onDebugSessionEnd" must be an object`);
562
- }
563
- if (typeof dse.enabled !== "boolean") {
564
- throw new Error(`"onDebugSessionEnd.enabled" must be a boolean`);
565
- }
566
- validatePromptSource("onDebugSessionEnd", dse);
567
- expectType(dse.cooldownMs, "number", "onDebugSessionEnd.cooldownMs");
568
- if (!Number.isFinite(dse.cooldownMs)) {
569
- throw new Error(`"onDebugSessionEnd.cooldownMs" must be a finite number`);
570
- }
571
- if (dse.cooldownMs < MIN_COOLDOWN_MS) {
572
- dse.cooldownMs = MIN_COOLDOWN_MS;
573
- }
574
- }
575
310
  return policy;
576
311
  }
577
312
  /**
@@ -634,254 +369,88 @@ export function checkCcHookWiring() {
634
369
  // ── AutomationHooks ───────────────────────────────────────────────────────────
635
370
  export class AutomationHooks {
636
371
  policy;
637
- orchestrator;
638
372
  log;
639
- extensionClient;
640
- workspace;
641
- /** Last trigger time per "trigger key" (e.g. "diagnostics:/path/to/file"). */
642
- lastTrigger = new Map();
643
- /**
644
- * Active task IDs per file for the diagnostics handler.
645
- * Kept separate from activeSaveTasks so a running save task does not suppress
646
- * the diagnostics trigger (and vice-versa) for the same file.
647
- */
648
- activeDiagnosticsTasks = new Map();
649
- /** Active task IDs per file for the file-saved handler. */
650
- activeSaveTasks = new Map();
651
- /** Active task IDs per file for the file-changed handler. */
652
- activeFileChangedTasks = new Map();
653
- /** Active task ID for the test-run handler (workspace-global). */
654
- activeTestRunTaskId = null;
655
- /** Active task ID for the test-pass-after-failure handler (workspace-global). */
656
- activeTestPassAfterFailureTaskId = null;
373
+ /** Compiled AST for the functional interpreter. Null if parse failed. */
374
+ _programAST = null;
375
+ /** Backend instance for the functional interpreter. */
376
+ _interpreterBackend = null;
657
377
  /**
658
- * Per-runner last outcome used to detect fail→pass transitions.
659
- * Key: runner name (e.g. "vitest", "jest"). Value: "pass" | "fail".
660
- * Stored separately per runner so a vitest pass doesn't incorrectly trigger
661
- * when a jest run was the one that previously failed.
378
+ * Pure-value state holding cooldown timestamps, diagnostic error counts, and
379
+ * test outcomes. Mutations go through the pure-function helpers from
380
+ * `src/fp/automationState.ts`; the class re-assigns `_automationState` on
381
+ * each "write" to maintain immutability semantics at the value level.
662
382
  */
663
- lastTestOutcomeByRunner = new Map();
664
- /** Active task ID for the git-commit handler (workspace-global). */
665
- activeGitCommitTaskId = null;
666
- /** Active task ID for the git-push handler (workspace-global). */
667
- activeGitPushTaskId = null;
668
- /** Active task ID for the git-pull handler (workspace-global). */
669
- activeGitPullTaskId = null;
670
- /** Active task ID for the branch-checkout handler (workspace-global). */
671
- activeBranchCheckoutTaskId = null;
672
- /** Active task ID for the pull-request handler (workspace-global). */
673
- activePullRequestTaskId = null;
674
- /** Active task ID for the task-created handler (workspace-global). */
675
- activeTaskCreatedTaskId = null;
676
- /** Active task ID for the permission-denied handler (workspace-global). */
677
- activePermissionDeniedTaskId = null;
678
- /** Active task IDs per file for the diagnostics-cleared handler. */
679
- activeDiagnosticsClearedTasks = new Map();
383
+ _automationState = EMPTY_AUTOMATION_STATE;
680
384
  /** Tracks previous error count per normalized file path for zero-transition detection. */
681
385
  prevDiagnosticErrors = new Map();
682
- /** Latest diagnostics by file — used by _evaluateWhen() for conditional hooks. */
683
- latestDiagnosticsByFile = new Map();
684
- /** Last test runner outcome per runner name — used by _evaluateWhen(). */
685
- lastTestRunnerStatusByRunner = new Map();
686
- /** Active task ID for the task-success handler (workspace-global). */
687
- activeTaskSuccessTaskId = null;
688
- /** Active task ID for the debug-session-end handler (workspace-global). */
689
- activeDebugSessionEndTaskId = null;
690
- /** Active task ID for the debug-session-start handler (workspace-global). */
691
- activeDebugSessionStartTaskId = null;
692
- /** Active task ID for the post-compact handler (workspace-global). */
693
- activePostCompactTaskId = null;
694
- /** Active task ID for the pre-compact handler (workspace-global). */
695
- activePreCompactTaskId = null;
696
- /** Active task ID for the instructions-loaded handler (workspace-global). */
697
- activeInstructionsLoadedTaskId = null;
698
386
  /**
699
- * Rolling window of task enqueue timestamps for maxTasksPerHour enforcement.
700
- * Entries older than 60 minutes are pruned on each enqueue.
387
+ * Per-runner last outcome used to detect fail→pass transitions for onTestPassAfterFailure.
388
+ * Key: runner name. Value: "pass" | "fail".
701
389
  */
702
- taskTimestamps = [];
390
+ lastTestOutcomeByRunner = new Map();
703
391
  _lastFiredAt = null;
704
- constructor(policy, orchestrator, log, extensionClient, workspace) {
392
+ /** Last interpreter run promise — allows tests to await completion. */
393
+ _lastRunPromise = Promise.resolve();
394
+ constructor(policy, orchestrator, log, _extensionClient, _workspace) {
705
395
  this.policy = policy;
706
- this.orchestrator = orchestrator;
707
396
  this.log = log;
708
- this.extensionClient = extensionClient;
709
- this.workspace = workspace;
710
- }
711
- /**
712
- * Central enqueue for all automation-triggered tasks.
713
- * Applies defaultModel (Haiku by default) and enforces maxTasksPerHour.
714
- * Throws with the same "Task queue is full" message on rate-limit breach so
715
- * callers can handle it identically.
716
- */
717
- _enqueueAutomationTask(opts) {
718
- const maxPerHour = this.policy.maxTasksPerHour ?? 20;
719
- if (maxPerHour > 0) {
720
- const now = Date.now();
721
- const cutoff = now - 60 * 60 * 1_000;
722
- // Prune old timestamps
723
- let i = 0;
724
- while (i < this.taskTimestamps.length &&
725
- (this.taskTimestamps[i] ?? 0) < cutoff)
726
- i++;
727
- if (i > 0)
728
- this.taskTimestamps.splice(0, i);
729
- if (this.taskTimestamps.length >= maxPerHour) {
730
- throw new Error(`Automation rate limit reached (max ${maxPerHour} tasks/hour)`);
397
+ // Phase 4: always initialise interpreter (primary path)
398
+ {
399
+ const parseResult = parsePolicy(policy);
400
+ if (parseResult.ok) {
401
+ this._programAST = parseResult.value;
402
+ this._interpreterBackend = new VsCodeBackend(orchestrator, {
403
+ info: this.log.bind(this),
404
+ });
405
+ }
406
+ else {
407
+ this.log(`[automation] interpreter parse failed: ${parseResult.message}`);
731
408
  }
732
409
  }
733
- const model = opts.hookCfg?.model ??
734
- this.policy.defaultModel ??
735
- "claude-haiku-4-5-20251001";
736
- const effort = opts.hookCfg?.effort ?? this.policy.defaultEffort ?? "low";
737
- const systemPrompt = this.policy.automationSystemPrompt ?? DEFAULT_AUTOMATION_SYSTEM_PROMPT;
738
- const taskId = this.orchestrator.enqueue({
739
- prompt: opts.prompt,
740
- sessionId: "",
741
- isAutomationTask: true,
742
- triggerSource: opts.triggerSource,
743
- model,
744
- effort,
745
- systemPrompt,
746
- });
747
- // Push timestamp only after successful enqueue so tasksThisHour never
748
- // diverges from the actual task row count (F3 phantom-increment fix).
749
- if (maxPerHour > 0) {
750
- this.taskTimestamps.push(Date.now());
751
- }
752
- this._lastFiredAt = new Date().toISOString();
753
- // Schedule retry watcher if retryCount > 0.
754
- const retryCount = opts.hookCfg?.retryCount ?? 0;
755
- const retryAttempt = opts._retryAttempt ?? 0;
756
- if (retryCount > 0) {
757
- const retryDelayMs = Math.max(5_000, opts.hookCfg?.retryDelayMs ?? 30_000);
758
- this._watchForRetry(taskId, opts, retryAttempt, retryCount, retryDelayMs);
759
- }
760
- return taskId;
761
410
  }
762
- /**
763
- * Poll a task until it reaches a terminal state. If the task ends with
764
- * status "error" and retries remain, re-enqueue after `retryDelayMs`.
765
- */
766
- _watchForRetry(taskId, opts, retryAttempt, retryCount, retryDelayMs) {
767
- const interval = setInterval(() => {
768
- const task = this.orchestrator.getTask(taskId);
769
- if (!task) {
770
- clearInterval(interval);
771
- return;
411
+ async _runInterpreter(eventType, eventData) {
412
+ if (!this._programAST || !this._interpreterBackend)
413
+ return;
414
+ const ctx = {
415
+ state: this._automationState,
416
+ now: Date.now(),
417
+ eventType,
418
+ eventData,
419
+ backend: this._interpreterBackend,
420
+ log: this.log.bind(this),
421
+ };
422
+ const result = await executeAutomationPolicy(this._programAST, ctx);
423
+ if (result.ok) {
424
+ this._automationState = result.value.updatedState;
425
+ if (result.value.taskIds.length > 0) {
426
+ this.log(`[interpreter] ${eventType}: enqueued ${result.value.taskIds.length} task(s)`);
772
427
  }
773
- if (task.status === "pending" || task.status === "running")
774
- return;
775
- clearInterval(interval);
776
- if (task.status !== "error")
777
- return; // cancelled/done no retry
778
- const nextAttempt = retryAttempt + 1;
779
- if (nextAttempt > retryCount) {
780
- this.log(`[automation] ${opts.triggerSource}: max retries (${retryCount}) reached, dropping`);
781
- return;
428
+ for (const s of result.value.skipped) {
429
+ this.log(`[interpreter] ${eventType}: skipped ${s.hook} — ${s.reason}`);
430
+ }
431
+ for (const e of result.value.errors) {
432
+ this.log(`[interpreter] ${eventType}: error in ${e.hook} — ${e.message}`);
782
433
  }
783
- this.log(`[automation] ${opts.triggerSource}: retry ${nextAttempt}/${retryCount} in ${retryDelayMs}ms`);
784
- setTimeout(() => {
785
- try {
786
- this._enqueueAutomationTask({
787
- ...opts,
788
- _retryAttempt: nextAttempt,
789
- });
790
- }
791
- catch (e) {
792
- this.log(`[automation] ${opts.triggerSource}: retry enqueue failed: ${e}`);
793
- }
794
- }, retryDelayMs);
795
- }, 2_000);
796
- }
797
- /**
798
- * Resolve a named prompt, substituting any `{{placeholder}}` tokens in
799
- * `promptArgs` values with sanitized event data before calling `getPrompt()`.
800
- *
801
- * Returns the resolved user-message text, or `null` if the prompt is unknown
802
- * or has missing required arguments.
803
- */
804
- _resolveNamedPrompt(name, args, eventData) {
805
- // Substitute event placeholders into promptArgs values.
806
- // Sanitize: strip control characters and cap length to prevent injection
807
- // via crafted file paths or branch names embedded in args.
808
- const resolvedArgs = {};
809
- for (const [k, v] of Object.entries(args)) {
810
- resolvedArgs[k] = v.replace(/\{\{(\w+)\}\}/g, (_match, placeholder) => {
811
- const raw = eventData[placeholder] ?? "";
812
- return raw
813
- .replace(/[\x00-\x1F\x7F]/g, "")
814
- .slice(0, MAX_FILE_PATH_CHARS);
815
- });
816
434
  }
817
- const result = getPrompt(name, resolvedArgs);
818
- if (!result) {
819
- this.log(`[automation] promptName "${name}" could not be resolved — unknown prompt or missing required args`);
820
- return null;
435
+ else {
436
+ this.log(`[interpreter] ${eventType}: interpreter error — ${result.message}`);
821
437
  }
822
- return result.messages
823
- .filter((m) => m.role === "user")
824
- .map((m) => m.content.text)
825
- .join("\n\n");
826
438
  }
827
439
  /**
828
- * Evaluate the optional `when` condition block on a hook.
829
- * Called after _matchesCondition() succeeds and before cooldown checks.
830
- * Returns true if all specified conditions pass (or no `when` block present).
440
+ * Returns a Promise that resolves once all in-flight interpreter runs finish.
441
+ * Useful in tests to await async side-effects before asserting on task counts.
831
442
  */
832
- _evaluateWhen(cfg, file) {
833
- const when = cfg.when;
834
- if (!when)
835
- return true;
836
- if (when.minDiagnosticCount !== undefined ||
837
- when.diagnosticsMinSeverity !== undefined) {
838
- const diags = (file ? this.latestDiagnosticsByFile.get(file) : undefined) ?? [];
839
- if (when.minDiagnosticCount !== undefined &&
840
- diags.length < when.minDiagnosticCount) {
841
- return false;
842
- }
843
- if (when.diagnosticsMinSeverity !== undefined) {
844
- const targetRank = when.diagnosticsMinSeverity === "error" ? 2 : 1;
845
- const severityRank = {
846
- error: 2,
847
- warning: 1,
848
- info: 0,
849
- information: 0,
850
- hint: 0,
851
- };
852
- const hasMatchingSeverity = diags.some((d) => (severityRank[d.severity] ?? 0) >= targetRank);
853
- if (!hasMatchingSeverity)
854
- return false;
855
- }
856
- }
857
- if (when.testRunnerLastStatus !== undefined) {
858
- // Check any runner's last status (wildcard: first match wins)
859
- const statuses = Array.from(this.lastTestRunnerStatusByRunner.values());
860
- if (statuses.length === 0)
861
- return false;
862
- if (when.testRunnerLastStatus !== "any") {
863
- const hasMatch = statuses.some((s) => s === when.testRunnerLastStatus);
864
- if (!hasMatch)
865
- return false;
866
- }
867
- // "any" passes as long as at least one runner has reported a status
868
- }
869
- return true;
443
+ async flush() {
444
+ await this._lastRunPromise;
870
445
  }
871
- _matchesCondition(cfg, primaryValue) {
872
- if (!cfg.condition)
873
- return true;
874
- const pattern = cfg.condition;
875
- // Support !-prefixed negation: "!**/*.test.ts" means "fire when NOT matching"
876
- if (pattern.startsWith("!")) {
877
- return !minimatch(primaryValue, pattern.slice(1), { dot: true });
878
- }
879
- return minimatch(primaryValue, pattern, { dot: true });
446
+ /** Tear down the instance: nulls interpreter references. */
447
+ destroy() {
448
+ this._programAST = null;
449
+ this._interpreterBackend = null;
880
450
  }
881
451
  handleDiagnosticsChanged(file, diagnostics) {
882
- // Normalize path before any processing
883
452
  const normalizedFile = path.resolve(file);
884
- // Track error count for zero-transition detection (needed regardless of which hooks are enabled)
453
+ // Track error count for zero-transition detection (onDiagnosticsCleared)
885
454
  const severityRankForClear = {
886
455
  error: 2,
887
456
  warning: 1,
@@ -892,126 +461,62 @@ export class AutomationHooks {
892
461
  const currentErrorCount = diagnostics.filter((d) => (severityRankForClear[d.severity] ?? 0) >= 1).length;
893
462
  const prevErrorCount = this.prevDiagnosticErrors.get(normalizedFile) ?? 0;
894
463
  this.prevDiagnosticErrors.set(normalizedFile, currentErrorCount);
895
- // Keep latest diagnostics for _evaluateWhen() condition checks
896
- this.latestDiagnosticsByFile.set(normalizedFile, diagnostics);
897
- // Fire onDiagnosticsCleared if transitioning from non-zero → zero
898
- if (prevErrorCount > 0 && currentErrorCount === 0) {
899
- this.handleDiagnosticsCleared(normalizedFile);
900
- }
901
- const cfg = this.policy.onDiagnosticsError;
902
- if (!cfg?.enabled)
903
- return;
904
- // Condition filter
905
- if (!this._matchesCondition(cfg, normalizedFile))
906
- return;
907
- if (!this._evaluateWhen(cfg, normalizedFile))
908
- return;
909
- // Skip onDiagnosticsError if there are no errors to report
910
- if (currentErrorCount === 0)
911
- return;
912
- // Loop guard: skip if a task for this file is still pending/running
913
- const existingId = this.activeDiagnosticsTasks.get(normalizedFile);
914
- if (existingId) {
915
- const existing = this.orchestrator.getTask(existingId);
916
- if (existing &&
917
- (existing.status === "pending" || existing.status === "running")) {
918
- this.log(`[automation] skipping diagnostics trigger for ${normalizedFile} — task ${existingId.slice(0, 8)} still active`);
919
- return;
920
- }
921
- // Prune stale entry for completed tasks
922
- this.activeDiagnosticsTasks.delete(normalizedFile);
923
- }
924
- // Severity filter
925
- const severityRank = {
926
- error: 2,
464
+ // FIFO cap to bound memory
465
+ if (this.prevDiagnosticErrors.size > 5_000) {
466
+ const oldest = this.prevDiagnosticErrors.keys().next().value;
467
+ if (oldest !== undefined)
468
+ this.prevDiagnosticErrors.delete(oldest);
469
+ }
470
+ // Feed interpreter state using severity numbers where lower = more severe
471
+ // (error=0, warning=1, info/hint=2+) matching automationState.ts / evaluateWhen convention.
472
+ const severityToNum = {
473
+ error: 0,
927
474
  warning: 1,
928
- info: 0,
929
- information: 0,
930
- hint: 0,
475
+ info: 2,
476
+ information: 2,
477
+ hint: 3,
931
478
  };
932
- const minRank = severityRank[cfg.minSeverity] ?? 0;
933
- let matching = diagnostics.filter((d) => (severityRank[d.severity] ?? 0) >= minRank);
934
- if (matching.length === 0)
935
- return;
936
- // Optional diagnosticTypes filter: only fire for specific sources/codes
937
- if (cfg.diagnosticTypes && cfg.diagnosticTypes.length > 0) {
938
- const types = cfg.diagnosticTypes.map((t) => t.toLowerCase());
939
- matching = matching.filter((d) => (d.source && types.includes(d.source.toLowerCase())) ||
940
- (d.code !== undefined &&
941
- types.includes(String(d.code).toLowerCase())));
942
- if (matching.length === 0)
943
- return;
944
- }
945
- // Cooldown check. When dedupeByContent is enabled, extend the key with a
946
- // diagnostic-content signature so identical LSP re-emissions collide but
947
- // genuinely different errors on the same file still trigger.
948
- let key = `diagnostics:${normalizedFile}`;
949
- let effectiveCooldownMs = cfg.cooldownMs;
950
- if (cfg.dedupeByContent) {
951
- const sig = diagnosticSignature(matching);
952
- key = `diagnostics:${normalizedFile}:${sig}`;
953
- effectiveCooldownMs = cfg.dedupeContentCooldownMs ?? 900_000;
954
- }
955
- const now = Date.now();
956
- const last = this.lastTrigger.get(key) ?? 0;
957
- if (now - last < effectiveCooldownMs) {
958
- const remaining = effectiveCooldownMs - (now - last);
959
- if (cfg.dedupeByContent) {
960
- this.log(`[automation] dedupe suppressed onDiagnosticsError for ${normalizedFile} (${remaining}ms remaining, sig=${key.slice(-12)})`);
961
- }
962
- else {
963
- this.log(`[automation] cooldown active for ${normalizedFile} (${remaining}ms remaining)`);
964
- }
965
- return;
966
- }
967
- // Note: lastTrigger is set AFTER successful enqueue (below) so a failed
968
- // enqueue does not impose a spurious cooldown on the next trigger attempt.
969
- this._pruneLastTrigger(now);
970
- // Truncate file path and each diagnostic message to prevent prompt injection
971
- // via crafted file names or linter output embedding instruction-like content.
972
- // The diagnosticsText is placed between explicit delimiters in the prompt to
973
- // architecturally separate trusted policy instructions from untrusted data.
974
- const safeFilePath = normalizedFile.slice(0, MAX_FILE_PATH_CHARS);
975
- let prompt;
976
- if (cfg.promptName) {
977
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { file: safeFilePath });
978
- if (resolved === null)
979
- return;
980
- prompt = resolved;
479
+ const maxSeverityNum = diagnostics.reduce((min, d) => {
480
+ const rank = severityToNum[d.severity] ?? 3;
481
+ return rank < min ? rank : min;
482
+ }, 4); // 4 = no diagnostics / below hint
483
+ this._automationState = setLatestDiagnostics(this._automationState, normalizedFile, maxSeverityNum, diagnostics.length);
484
+ // Build diagnostics text for {{diagnostics}} placeholder (capped at 20)
485
+ const diagsForPrompt = diagnostics.slice(0, 20);
486
+ const overflow = diagnostics.length - diagsForPrompt.length;
487
+ const diagnosticsText = diagsForPrompt
488
+ .map((d) => `[${d.severity}] ${d.message}${d.source ? ` (${d.source})` : ""}`)
489
+ .join("\n") + (overflow > 0 ? `\n… and ${overflow} more` : "");
490
+ // Collect source/code strings for diagnosticTypes filtering
491
+ const diagnosticSources = diagnostics
492
+ .flatMap((d) => [
493
+ d.source?.toLowerCase() ?? "",
494
+ String(d.code ?? "").toLowerCase(),
495
+ ])
496
+ .filter(Boolean)
497
+ .join(",");
498
+ const diagnosticSig = diagnosticSignature(diagnostics);
499
+ // Fire onDiagnosticsCleared if transitioning from non-zero → zero; chain
500
+ // the interpreter runs so flush() awaits both and state is updated correctly.
501
+ if (prevErrorCount > 0 && currentErrorCount === 0) {
502
+ this._lastRunPromise = this._runInterpreter("onDiagnosticsCleared", {
503
+ file: normalizedFile,
504
+ }).then(() => this._runInterpreter("onDiagnosticsError", {
505
+ file: normalizedFile,
506
+ diagnostics: diagnosticsText,
507
+ diagnosticSources,
508
+ diagnosticSig,
509
+ count: String(diagnostics.length),
510
+ }));
981
511
  }
982
512
  else {
983
- const displayMatching = matching.slice(0, MAX_DIAGNOSTICS_IN_PROMPT);
984
- const omittedCount = matching.length - displayMatching.length;
985
- const diagnosticsText = displayMatching
986
- .map((d) => `[${d.severity}] ${d.message.slice(0, MAX_DIAGNOSTIC_MSG_CHARS)}`)
987
- .join("\n") +
988
- (omittedCount > 0 ? `\n… and ${omittedCount} more` : "");
989
- const nonce = crypto.randomBytes(6).toString("hex");
990
- prompt =
991
- (cfg.prompt ?? "")
992
- .replace(/\{\{file\}\}/g, untrustedBlock("FILE PATH", safeFilePath, nonce))
993
- .replace(/\{\{diagnostics\}\}/g, untrustedBlock("DIAGNOSTIC DATA", diagnosticsText, nonce)) ?? "";
994
- }
995
- prompt = truncatePrompt(buildHookMetadata("onDiagnosticsError", normalizedFile) + prompt);
996
- try {
997
- const taskId = this._enqueueAutomationTask({
998
- prompt,
999
- triggerSource: "onDiagnosticsError",
1000
- hookCfg: cfg,
513
+ this._lastRunPromise = this._runInterpreter("onDiagnosticsError", {
514
+ file: normalizedFile,
515
+ diagnostics: diagnosticsText,
516
+ diagnosticSources,
517
+ diagnosticSig,
518
+ count: String(diagnostics.length),
1001
519
  });
1002
- this.lastTrigger.set(key, now);
1003
- this.activeDiagnosticsTasks.set(normalizedFile, taskId);
1004
- this.log(`[automation] triggered diagnostics task ${taskId.slice(0, 8)} for ${normalizedFile}`);
1005
- }
1006
- catch (err) {
1007
- this.log(`[automation] failed to enqueue diagnostics task for ${normalizedFile}: ${err instanceof Error ? err.message : String(err)}`);
1008
- }
1009
- }
1010
- /** Prune lastTrigger entries older than LAST_TRIGGER_MAX_AGE_MS to prevent unbounded growth. */
1011
- _pruneLastTrigger(now) {
1012
- for (const [k, t] of this.lastTrigger) {
1013
- if (now - t > LAST_TRIGGER_MAX_AGE_MS)
1014
- this.lastTrigger.delete(k);
1015
520
  }
1016
521
  }
1017
522
  /**
@@ -1019,50 +524,9 @@ export class AutomationHooks {
1019
524
  * Fires when CC's working directory changes — useful for re-snapshotting workspace context.
1020
525
  */
1021
526
  handleCwdChanged(newCwd) {
1022
- const cfg = this.policy.onCwdChanged;
1023
- if (!cfg?.enabled)
1024
- return;
1025
- const safeCwdForCondition = newCwd.slice(0, MAX_FILE_PATH_CHARS);
1026
- if (!this._matchesCondition(cfg, safeCwdForCondition))
1027
- return;
1028
- // Cap path before using as map key to prevent unbounded map growth from
1029
- // an extension sending unique paths rapidly.
1030
- const safeCwd = newCwd.slice(0, MAX_FILE_PATH_CHARS);
1031
- // Cooldown check — keyed on the capped cwd so switching between two known
1032
- // directories doesn't bypass the global rate but each dir has its own window
1033
- const key = `cwdChanged:${safeCwd}`;
1034
- const now = Date.now();
1035
- const last = this.lastTrigger.get(key) ?? 0;
1036
- if (now - last < cfg.cooldownMs) {
1037
- this.log(`[automation] cooldown active for cwd-changed ${newCwd} (${cfg.cooldownMs - (now - last)}ms remaining)`);
1038
- return;
1039
- }
1040
- this._pruneLastTrigger(now);
1041
- let prompt;
1042
- if (cfg.promptName) {
1043
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { cwd: safeCwd });
1044
- if (resolved === null)
1045
- return;
1046
- prompt = resolved;
1047
- }
1048
- else {
1049
- const nonce = crypto.randomBytes(6).toString("hex");
1050
- prompt =
1051
- (cfg.prompt ?? "").replace(/\{\{cwd\}\}/g, untrustedBlock("CWD", safeCwd, nonce)) ?? "";
1052
- }
1053
- prompt = truncatePrompt(buildHookMetadata("onCwdChanged") + prompt);
1054
- try {
1055
- const taskId = this._enqueueAutomationTask({
1056
- prompt,
1057
- triggerSource: "onCwdChanged",
1058
- hookCfg: cfg,
1059
- });
1060
- this.lastTrigger.set(key, now);
1061
- this.log(`[automation] triggered cwd-changed task ${taskId.slice(0, 8)} for ${newCwd}`);
1062
- }
1063
- catch (err) {
1064
- this.log(`[automation] failed to enqueue cwd-changed task for ${newCwd}: ${err instanceof Error ? err.message : String(err)}`);
1065
- }
527
+ this._lastRunPromise = this._runInterpreter("onCwdChanged", {
528
+ cwd: newCwd,
529
+ });
1066
530
  }
1067
531
  /**
1068
532
  * Called when Claude Code fires a PreCompact hook.
@@ -1070,237 +534,29 @@ export class AutomationHooks {
1070
534
  * write a handoff note before Claude loses context.
1071
535
  */
1072
536
  handlePreCompact() {
1073
- const cfg = this.policy.onPreCompact;
1074
- if (!cfg?.enabled)
1075
- return;
1076
- if (this.activePreCompactTaskId) {
1077
- const existing = this.orchestrator.getTask(this.activePreCompactTaskId);
1078
- if (existing &&
1079
- (existing.status === "pending" || existing.status === "running")) {
1080
- this.log(`[automation] skipping pre-compact trigger — task ${this.activePreCompactTaskId.slice(0, 8)} still active`);
1081
- return;
1082
- }
1083
- this.activePreCompactTaskId = null;
1084
- }
1085
- const key = "pre-compact";
1086
- const now = Date.now();
1087
- const last = this.lastTrigger.get(key) ?? 0;
1088
- if (now - last < cfg.cooldownMs) {
1089
- this.log(`[automation] cooldown active for PreCompact (${cfg.cooldownMs - (now - last)}ms remaining)`);
1090
- return;
1091
- }
1092
- this._pruneLastTrigger(now);
1093
- let preCompactPrompt;
1094
- if (cfg.promptName) {
1095
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {});
1096
- if (resolved === null)
1097
- return;
1098
- preCompactPrompt = resolved;
1099
- }
1100
- else {
1101
- preCompactPrompt = cfg.prompt ?? "";
1102
- }
1103
- preCompactPrompt = truncatePrompt(buildHookMetadata("onPreCompact") + preCompactPrompt);
1104
- try {
1105
- const taskId = this._enqueueAutomationTask({
1106
- prompt: preCompactPrompt,
1107
- triggerSource: "onPreCompact",
1108
- hookCfg: cfg,
1109
- });
1110
- this.lastTrigger.set(key, now);
1111
- this.activePreCompactTaskId = taskId;
1112
- this.log(`[automation] triggered PreCompact task ${taskId.slice(0, 8)}`);
1113
- }
1114
- catch (err) {
1115
- this.log(`[automation] failed to enqueue PreCompact task: ${err instanceof Error ? err.message : String(err)}`);
1116
- }
537
+ this._lastRunPromise = this._runInterpreter("onPreCompact", {});
1117
538
  }
1118
539
  /**
1119
540
  * Called when Claude Code fires a PostCompact hook (Claude Code 2.1.76+).
1120
541
  * Re-enqueues the configured prompt so Claude can re-snapshot IDE state after losing context.
1121
542
  */
1122
543
  handlePostCompact() {
1123
- const cfg = this.policy.onPostCompact;
1124
- if (!cfg?.enabled)
1125
- return;
1126
- if (this.activePostCompactTaskId) {
1127
- const existing = this.orchestrator.getTask(this.activePostCompactTaskId);
1128
- if (existing &&
1129
- (existing.status === "pending" || existing.status === "running")) {
1130
- this.log(`[automation] skipping post-compact trigger — task ${this.activePostCompactTaskId.slice(0, 8)} still active`);
1131
- return;
1132
- }
1133
- this.activePostCompactTaskId = null;
1134
- }
1135
- const key = "post-compact";
1136
- const now = Date.now();
1137
- const last = this.lastTrigger.get(key) ?? 0;
1138
- if (now - last < cfg.cooldownMs) {
1139
- this.log(`[automation] cooldown active for PostCompact (${cfg.cooldownMs - (now - last)}ms remaining)`);
1140
- return;
1141
- }
1142
- this._pruneLastTrigger(now);
1143
- let postCompactPrompt;
1144
- if (cfg.promptName) {
1145
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {});
1146
- if (resolved === null)
1147
- return;
1148
- postCompactPrompt = resolved;
1149
- }
1150
- else {
1151
- postCompactPrompt = cfg.prompt ?? "";
1152
- }
1153
- postCompactPrompt = truncatePrompt(buildHookMetadata("onPostCompact") + postCompactPrompt);
1154
- try {
1155
- const taskId = this._enqueueAutomationTask({
1156
- prompt: postCompactPrompt,
1157
- triggerSource: "onPostCompact",
1158
- hookCfg: cfg,
1159
- });
1160
- // Set lastTrigger AFTER successful enqueue so a failed enqueue does not
1161
- // impose a spurious cooldown on the next trigger attempt.
1162
- this.lastTrigger.set(key, now);
1163
- this.activePostCompactTaskId = taskId;
1164
- this.log(`[automation] triggered PostCompact task ${taskId.slice(0, 8)}`);
1165
- }
1166
- catch (err) {
1167
- this.log(`[automation] failed to enqueue PostCompact task: ${err instanceof Error ? err.message : String(err)}`);
1168
- }
544
+ this._lastRunPromise = this._runInterpreter("onPostCompact", {});
1169
545
  }
1170
546
  /**
1171
547
  * Called when Claude Code fires an InstructionsLoaded hook (Claude Code 2.1.76+).
1172
548
  * Fires once per session; injects bridge status / tool capability summary at start.
1173
549
  */
1174
550
  handleInstructionsLoaded() {
1175
- const cfg = this.policy.onInstructionsLoaded;
1176
- if (!cfg?.enabled)
1177
- return;
1178
- if (this.activeInstructionsLoadedTaskId) {
1179
- const existing = this.orchestrator.getTask(this.activeInstructionsLoadedTaskId);
1180
- if (existing &&
1181
- (existing.status === "pending" || existing.status === "running")) {
1182
- this.log(`[automation] skipping instructions-loaded trigger — task ${this.activeInstructionsLoadedTaskId.slice(0, 8)} still active`);
1183
- return;
1184
- }
1185
- this.activeInstructionsLoadedTaskId = null;
1186
- }
1187
- // Cross-hook cascade guard: each automation subprocess fires the
1188
- // InstructionsLoaded CC hook when it starts, which would spawn a second
1189
- // onInstructionsLoaded task without this check. Suppress if any
1190
- // automation task is currently pending or running.
1191
- const anyAutomationActive = this.orchestrator
1192
- .list()
1193
- .some((t) => t.isAutomationTask &&
1194
- (t.status === "pending" || t.status === "running"));
1195
- if (anyAutomationActive) {
1196
- this.log("[automation] skipping instructions-loaded trigger — another automation task is active (cascade guard)");
1197
- return;
1198
- }
1199
- const cooldownMs = cfg.cooldownMs ?? 60_000;
1200
- const key = "instructions-loaded";
1201
- const now = Date.now();
1202
- const last = this.lastTrigger.get(key) ?? 0;
1203
- if (now - last < cooldownMs) {
1204
- this.log(`[automation] cooldown active for InstructionsLoaded (${cooldownMs - (now - last)}ms remaining)`);
1205
- return;
1206
- }
1207
- this._pruneLastTrigger(now);
1208
- let instrPrompt;
1209
- if (cfg.promptName) {
1210
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {});
1211
- if (resolved === null)
1212
- return;
1213
- instrPrompt = resolved;
1214
- }
1215
- else {
1216
- instrPrompt = cfg.prompt ?? "";
1217
- }
1218
- instrPrompt = truncatePrompt(buildHookMetadata("onInstructionsLoaded") + instrPrompt);
1219
- try {
1220
- const taskId = this._enqueueAutomationTask({
1221
- prompt: instrPrompt,
1222
- triggerSource: "onInstructionsLoaded",
1223
- hookCfg: cfg,
1224
- });
1225
- this.lastTrigger.set(key, now);
1226
- this.activeInstructionsLoadedTaskId = taskId;
1227
- this.log(`[automation] triggered InstructionsLoaded task ${taskId.slice(0, 8)}`);
1228
- }
1229
- catch (err) {
1230
- this.log(`[automation] failed to enqueue InstructionsLoaded task: ${err instanceof Error ? err.message : String(err)}`);
1231
- }
551
+ this._lastRunPromise = this._runInterpreter("onInstructionsLoaded", {});
1232
552
  }
1233
553
  handleFileSaved(_id, type, file) {
1234
- const cfg = this.policy.onFileSave;
1235
- if (!cfg?.enabled)
1236
- return;
1237
554
  if (type !== "save")
1238
555
  return;
1239
- // Normalize path to prevent loop-guard bypass via equivalent paths
1240
556
  const normalizedFile = path.resolve(file);
1241
- // Condition filter
1242
- if (!this._matchesCondition(cfg, normalizedFile))
1243
- return;
1244
- if (!this._evaluateWhen(cfg, normalizedFile))
1245
- return;
1246
- // Pattern matching — also try workspace-relative path so patterns like
1247
- // "src/**/*.ts" work when VS Code sends absolute paths.
1248
- const relFile = this.workspace && path.isAbsolute(normalizedFile)
1249
- ? path.relative(this.workspace, normalizedFile)
1250
- : normalizedFile;
1251
- const matched = cfg.patterns.some((pattern) => minimatch(normalizedFile, pattern, { dot: true }) ||
1252
- (relFile !== normalizedFile &&
1253
- minimatch(relFile, pattern, { dot: true })));
1254
- if (!matched)
1255
- return;
1256
- // Loop guard
1257
- const existingId = this.activeSaveTasks.get(normalizedFile);
1258
- if (existingId) {
1259
- const existing = this.orchestrator.getTask(existingId);
1260
- if (existing &&
1261
- (existing.status === "pending" || existing.status === "running")) {
1262
- this.log(`[automation] skipping save trigger for ${normalizedFile} — task ${existingId.slice(0, 8)} still active`);
1263
- return;
1264
- }
1265
- // Prune stale entry for completed tasks
1266
- this.activeSaveTasks.delete(normalizedFile);
1267
- }
1268
- // Cooldown check
1269
- const key = `save:${normalizedFile}`;
1270
- const now = Date.now();
1271
- const last = this.lastTrigger.get(key) ?? 0;
1272
- if (now - last < cfg.cooldownMs) {
1273
- this.log(`[automation] cooldown active for save ${normalizedFile} (${cfg.cooldownMs - (now - last)}ms remaining)`);
1274
- return;
1275
- }
1276
- this._pruneLastTrigger(now);
1277
- const safeFilePath = normalizedFile.slice(0, MAX_FILE_PATH_CHARS);
1278
- let prompt;
1279
- if (cfg.promptName) {
1280
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { file: safeFilePath });
1281
- if (resolved === null)
1282
- return;
1283
- prompt = resolved;
1284
- }
1285
- else {
1286
- const nonce = crypto.randomBytes(6).toString("hex");
1287
- prompt =
1288
- (cfg.prompt ?? "").replace(/\{\{file\}\}/g, untrustedBlock("FILE PATH", safeFilePath, nonce)) ?? "";
1289
- }
1290
- prompt = truncatePrompt(buildHookMetadata("onFileSave", normalizedFile) + prompt);
1291
- try {
1292
- const taskId = this._enqueueAutomationTask({
1293
- prompt,
1294
- triggerSource: "onFileSave",
1295
- hookCfg: cfg,
1296
- });
1297
- this.lastTrigger.set(key, now);
1298
- this.activeSaveTasks.set(normalizedFile, taskId);
1299
- this.log(`[automation] triggered onFileSave task ${taskId.slice(0, 8)} for ${normalizedFile}`);
1300
- }
1301
- catch (err) {
1302
- this.log(`[automation] failed to enqueue save task for ${normalizedFile}: ${err instanceof Error ? err.message : String(err)}`);
1303
- }
557
+ this._lastRunPromise = this._runInterpreter("onFileSave", {
558
+ file: normalizedFile,
559
+ });
1304
560
  }
1305
561
  /**
1306
562
  * Called when the VS Code extension reports a file-changed event (type === "change").
@@ -1308,72 +564,12 @@ export class AutomationHooks {
1308
564
  * Useful for triggering tasks on unsaved edits (e.g. lint-as-you-type workflows).
1309
565
  */
1310
566
  handleFileChanged(_id, type, file) {
1311
- const cfg = this.policy.onFileChanged;
1312
- if (!cfg?.enabled)
1313
- return;
1314
567
  if (type !== "change")
1315
568
  return;
1316
569
  const normalizedFile = path.resolve(file);
1317
- // Condition filter
1318
- if (!this._matchesCondition(cfg, normalizedFile))
1319
- return;
1320
- // Pattern matching — also try workspace-relative path so patterns like
1321
- // "src/**/*.ts" work when VS Code sends absolute paths.
1322
- const relFile = this.workspace && path.isAbsolute(normalizedFile)
1323
- ? path.relative(this.workspace, normalizedFile)
1324
- : normalizedFile;
1325
- const matched = cfg.patterns.some((pattern) => minimatch(normalizedFile, pattern, { dot: true }) ||
1326
- (relFile !== normalizedFile &&
1327
- minimatch(relFile, pattern, { dot: true })));
1328
- if (!matched)
1329
- return;
1330
- // Loop guard
1331
- const existingId = this.activeFileChangedTasks.get(normalizedFile);
1332
- if (existingId) {
1333
- const existing = this.orchestrator.getTask(existingId);
1334
- if (existing &&
1335
- (existing.status === "pending" || existing.status === "running")) {
1336
- this.log(`[automation] skipping file-changed trigger for ${normalizedFile} — task ${existingId.slice(0, 8)} still active`);
1337
- return;
1338
- }
1339
- this.activeFileChangedTasks.delete(normalizedFile);
1340
- }
1341
- // Cooldown check
1342
- const key = `fileChanged:${normalizedFile}`;
1343
- const now = Date.now();
1344
- const last = this.lastTrigger.get(key) ?? 0;
1345
- if (now - last < cfg.cooldownMs) {
1346
- this.log(`[automation] cooldown active for file-changed ${normalizedFile} (${cfg.cooldownMs - (now - last)}ms remaining)`);
1347
- return;
1348
- }
1349
- this._pruneLastTrigger(now);
1350
- const safeFilePath = normalizedFile.slice(0, MAX_FILE_PATH_CHARS);
1351
- let prompt;
1352
- if (cfg.promptName) {
1353
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { file: safeFilePath });
1354
- if (resolved === null)
1355
- return;
1356
- prompt = resolved;
1357
- }
1358
- else {
1359
- const nonce = crypto.randomBytes(6).toString("hex");
1360
- prompt =
1361
- (cfg.prompt ?? "").replace(/\{\{file\}\}/g, untrustedBlock("FILE PATH", safeFilePath, nonce)) ?? "";
1362
- }
1363
- prompt = truncatePrompt(buildHookMetadata("onFileChanged", normalizedFile) + prompt);
1364
- try {
1365
- const taskId = this._enqueueAutomationTask({
1366
- prompt,
1367
- triggerSource: "onFileChanged",
1368
- hookCfg: cfg,
1369
- });
1370
- this.lastTrigger.set(key, now);
1371
- this.activeFileChangedTasks.set(normalizedFile, taskId);
1372
- this.log(`[automation] triggered file-changed task ${taskId.slice(0, 8)} for ${normalizedFile}`);
1373
- }
1374
- catch (err) {
1375
- this.log(`[automation] failed to enqueue file-changed task for ${normalizedFile}: ${err instanceof Error ? err.message : String(err)}`);
1376
- }
570
+ this._lastRunPromise = this._runInterpreter("onFileChanged", {
571
+ file: normalizedFile,
572
+ });
1377
573
  }
1378
574
  /**
1379
575
  * Called after every runTests tool invocation completes.
@@ -1381,167 +577,39 @@ export class AutomationHooks {
1381
577
  */
1382
578
  handleTestRun(result) {
1383
579
  const failureCount = result.summary.failed + result.summary.errored;
580
+ const current = failureCount === 0 ? "pass" : "fail";
1384
581
  // Update per-runner outcome state unconditionally so onTestPassAfterFailure
1385
582
  // can detect fail→pass transitions even when onTestRun is disabled/absent.
1386
- const testStatus = failureCount === 0 ? "passed" : "failed";
583
+ const passAfterFailRunners = [];
1387
584
  for (const runner of result.runners) {
1388
585
  const prev = this.lastTestOutcomeByRunner.get(runner);
1389
- const current = failureCount === 0 ? "pass" : "fail";
1390
586
  this.lastTestOutcomeByRunner.set(runner, current);
1391
- // Update lastTestRunnerStatusByRunner for _evaluateWhen() condition checks
1392
- this.lastTestRunnerStatusByRunner.set(runner, testStatus);
587
+ // Feed interpreter state
588
+ this._automationState = setTestRunnerStatus(this._automationState, runner, current);
1393
589
  if (prev === "fail" && current === "pass") {
1394
- this._handleTestPassAfterFailure(result, runner);
1395
- }
1396
- }
1397
- const cfg = this.policy.onTestRun;
1398
- if (!cfg?.enabled)
1399
- return;
1400
- // Honour onFailureOnly: skip trigger when all tests pass
1401
- if (cfg.onFailureOnly && failureCount === 0)
1402
- return;
1403
- // Skip if test run was shorter than the configured minimum duration
1404
- // Only skip when durationMs is known and below the threshold.
1405
- // If durationMs is absent (runner didn't report timing), let the hook fire —
1406
- // silently suppressing based on missing data would be surprising behaviour.
1407
- if (cfg.minDuration !== undefined &&
1408
- result.summary.durationMs !== undefined &&
1409
- result.summary.durationMs < cfg.minDuration) {
1410
- this.log(`[automation] skipping test-run trigger — duration ${result.summary.durationMs}ms < minDuration ${cfg.minDuration}ms`);
1411
- return;
1412
- }
1413
- // Evaluate optional when condition
1414
- if (!this._evaluateWhen(cfg))
1415
- return;
1416
- // Loop guard: skip if a task is still pending/running
1417
- if (this.activeTestRunTaskId) {
1418
- const existing = this.orchestrator.getTask(this.activeTestRunTaskId);
1419
- if (existing &&
1420
- (existing.status === "pending" || existing.status === "running")) {
1421
- this.log(`[automation] skipping test-run trigger — task ${this.activeTestRunTaskId.slice(0, 8)} still active`);
1422
- return;
590
+ passAfterFailRunners.push(runner);
1423
591
  }
1424
- this.activeTestRunTaskId = null;
1425
- }
1426
- // Cooldown check (workspace-global key)
1427
- const key = "testRun:global";
1428
- const now = Date.now();
1429
- const last = this.lastTrigger.get(key) ?? 0;
1430
- if (now - last < cfg.cooldownMs) {
1431
- this.log(`[automation] cooldown active for test-run (${cfg.cooldownMs - (now - last)}ms remaining)`);
1432
- return;
1433
- }
1434
- this._pruneLastTrigger(now);
1435
- const runnerStr = result.runners.join(", ") || "unknown";
1436
- const failureLines = result.failures
1437
- .slice(0, 10)
1438
- .map((f) => {
1439
- const loc = f.file ? ` (${f.file.slice(0, MAX_FILE_PATH_CHARS)})` : "";
1440
- const msg = f.message
1441
- ? `: ${f.message.slice(0, MAX_DIAGNOSTIC_MSG_CHARS)}`
1442
- : "";
1443
- return `- ${f.name}${loc}${msg}`;
1444
- })
1445
- .join("\n");
1446
- const failuresText = result.failures.length > 10
1447
- ? `${failureLines}\n… and ${result.failures.length - 10} more`
1448
- : failureLines;
1449
- let prompt;
1450
- if (cfg.promptName) {
1451
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {
1452
- runner: runnerStr,
1453
- failed: String(failureCount),
1454
- passed: String(result.summary.passed),
1455
- total: String(result.summary.total),
1456
- });
1457
- if (resolved === null)
1458
- return;
1459
- prompt = resolved;
1460
592
  }
1461
- else {
1462
- const nonce = crypto.randomBytes(6).toString("hex");
1463
- prompt =
1464
- (cfg.prompt ?? "")
1465
- .replace(/\{\{runner\}\}/g, untrustedBlock("TEST RUNNER", runnerStr, nonce))
1466
- .replace(/\{\{failed\}\}/g, String(failureCount))
1467
- .replace(/\{\{passed\}\}/g, String(result.summary.passed))
1468
- .replace(/\{\{total\}\}/g, String(result.summary.total))
1469
- .replace(/\{\{failures\}\}/g, untrustedBlock("TEST FAILURES", failuresText, nonce)) ?? "";
1470
- }
1471
- prompt = truncatePrompt(buildHookMetadata("onTestRun") + prompt);
1472
- try {
1473
- const taskId = this._enqueueAutomationTask({
1474
- prompt,
1475
- triggerSource: "onTestRun",
1476
- hookCfg: cfg,
1477
- });
1478
- this.lastTrigger.set(key, now);
1479
- this.activeTestRunTaskId = taskId;
1480
- this.log(`[automation] triggered test-run task ${taskId.slice(0, 8)} (${failureCount} failure(s))`);
1481
- }
1482
- catch (err) {
1483
- this.log(`[automation] failed to enqueue test-run task: ${err instanceof Error ? err.message : String(err)}`);
1484
- }
1485
- }
1486
- /**
1487
- * Internal — fires onTestPassAfterFailure when a specific runner transitions
1488
- * from failing → passing. Called from handleTestRun after outcome state update.
1489
- */
1490
- _handleTestPassAfterFailure(result, runner) {
1491
- const cfg = this.policy.onTestPassAfterFailure;
1492
- if (!cfg?.enabled)
1493
- return;
1494
- // Loop guard: skip if a task is still pending/running
1495
- if (this.activeTestPassAfterFailureTaskId) {
1496
- const existing = this.orchestrator.getTask(this.activeTestPassAfterFailureTaskId);
1497
- if (existing &&
1498
- (existing.status === "pending" || existing.status === "running")) {
1499
- this.log(`[automation] skipping test-pass-after-failure — task ${this.activeTestPassAfterFailureTaskId.slice(0, 8)} still active`);
1500
- return;
593
+ const testRunEventData = {
594
+ runner: result.runners.join(", ") || "",
595
+ failed: String(failureCount),
596
+ passed: String(result.summary.passed),
597
+ total: String(result.summary.total),
598
+ failures: JSON.stringify(result.failures.slice(0, 100)),
599
+ durationMs: String(result.summary.durationMs ?? ""),
600
+ };
601
+ // Chain interpreter runs so flush() awaits all of them and state is updated
602
+ // sequentially. If any runner had a fail→pass transition, run that first so
603
+ // its cooldown state is visible to subsequent runs within the same flush.
604
+ if (passAfterFailRunners.length > 0) {
605
+ let chain = Promise.resolve();
606
+ for (const runner of passAfterFailRunners) {
607
+ chain = chain.then(() => this._runInterpreter("onTestPassAfterFailure", { runner }));
1501
608
  }
1502
- this.activeTestPassAfterFailureTaskId = null;
1503
- }
1504
- // Cooldown check (workspace-global key)
1505
- const key = "testPassAfterFailure:global";
1506
- const now = Date.now();
1507
- const last = this.lastTrigger.get(key) ?? 0;
1508
- if (now - last < cfg.cooldownMs) {
1509
- this.log(`[automation] cooldown active for test-pass-after-failure (${cfg.cooldownMs - (now - last)}ms remaining)`);
1510
- return;
1511
- }
1512
- this._pruneLastTrigger(now);
1513
- let prompt;
1514
- if (cfg.promptName) {
1515
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {
1516
- runner,
1517
- passed: String(result.summary.passed),
1518
- total: String(result.summary.total),
1519
- });
1520
- if (resolved === null)
1521
- return;
1522
- prompt = resolved;
609
+ this._lastRunPromise = chain.then(() => this._runInterpreter("onTestRun", testRunEventData));
1523
610
  }
1524
611
  else {
1525
- const nonce = crypto.randomBytes(6).toString("hex");
1526
- prompt =
1527
- (cfg.prompt ?? "")
1528
- .replace(/\{\{runner\}\}/g, untrustedBlock("TEST RUNNER", runner, nonce))
1529
- .replace(/\{\{passed\}\}/g, String(result.summary.passed))
1530
- .replace(/\{\{total\}\}/g, String(result.summary.total)) ?? "";
1531
- }
1532
- prompt = truncatePrompt(buildHookMetadata("onTestPassAfterFailure") + prompt);
1533
- try {
1534
- const taskId = this._enqueueAutomationTask({
1535
- prompt,
1536
- triggerSource: "onTestPassAfterFailure",
1537
- hookCfg: cfg,
1538
- });
1539
- this.lastTrigger.set(key, now);
1540
- this.activeTestPassAfterFailureTaskId = taskId;
1541
- this.log(`[automation] triggered test-pass-after-failure task ${taskId.slice(0, 8)} (runner: ${runner})`);
1542
- }
1543
- catch (err) {
1544
- this.log(`[automation] failed to enqueue test-pass-after-failure task: ${err instanceof Error ? err.message : String(err)}`);
612
+ this._lastRunPromise = this._runInterpreter("onTestRun", testRunEventData);
1545
613
  }
1546
614
  }
1547
615
  /**
@@ -1549,704 +617,109 @@ export class AutomationHooks {
1549
617
  * Fires the onGitCommit automation hook if configured.
1550
618
  */
1551
619
  async handleGitCommit(result) {
1552
- const cfg = this.policy.onGitCommit;
1553
- if (!cfg?.enabled)
1554
- return;
1555
- if (!this._matchesCondition(cfg, result.branch))
1556
- return;
1557
- // Loop guard: skip if a task is still pending/running
1558
- if (this.activeGitCommitTaskId) {
1559
- const existing = this.orchestrator.getTask(this.activeGitCommitTaskId);
1560
- if (existing &&
1561
- (existing.status === "pending" || existing.status === "running")) {
1562
- this.log(`[automation] skipping git-commit trigger — task ${this.activeGitCommitTaskId.slice(0, 8)} still active`);
1563
- return;
1564
- }
1565
- this.activeGitCommitTaskId = null;
1566
- }
1567
- // Cooldown check (workspace-global)
1568
- const key = "gitCommit:global";
1569
- const now = Date.now();
1570
- const last = this.lastTrigger.get(key) ?? 0;
1571
- if (now - last < cfg.cooldownMs) {
1572
- this.log(`[automation] cooldown active for git-commit (${cfg.cooldownMs - (now - last)}ms remaining)`);
1573
- return;
1574
- }
1575
- this._pruneLastTrigger(now);
1576
- const safeHash = result.hash.slice(0, 64);
1577
- const safeBranchCommit = result.branch.slice(0, MAX_FILE_PATH_CHARS);
1578
- const safeMessage = result.message.slice(0, MAX_DIAGNOSTIC_MSG_CHARS);
1579
- const fileList = result.files
1580
- .slice(0, 20)
1581
- .map((f) => `- ${f.slice(0, MAX_FILE_PATH_CHARS)}`)
1582
- .join("\n");
1583
- const filesText = result.files.length > 20
1584
- ? `${fileList}\n… and ${result.files.length - 20} more`
1585
- : fileList;
1586
- // B1: Fetch changeImpact if extensionClient is connected and files exist
1587
- // Uses getDiagnostics as a lightweight proxy: summarizes error/warning count
1588
- // across changed files as a blast-radius indicator.
1589
- let changeImpact;
1590
- if (this.extensionClient?.isConnected() && result.files.length > 0) {
1591
- try {
1592
- const diagResult = await Promise.race([
1593
- this.extensionClient.getDiagnostics(),
1594
- new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
1595
- ]);
1596
- if (diagResult) {
1597
- const diagArr = Array.isArray(diagResult) ? diagResult : [];
1598
- const errorCount = diagArr.filter((d) => d.severity === "error" || d.severity === "warning").length;
1599
- changeImpact = `${result.count} file(s) changed; ${errorCount} diagnostic(s) in workspace`;
1600
- }
1601
- }
1602
- catch {
1603
- // best-effort — changeImpact remains undefined
1604
- }
1605
- }
1606
- let prompt;
1607
- if (cfg.promptName) {
1608
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {
1609
- hash: safeHash,
1610
- branch: safeBranchCommit,
1611
- message: safeMessage,
1612
- count: String(result.count),
1613
- });
1614
- if (resolved === null)
1615
- return;
1616
- prompt = resolved;
1617
- }
1618
- else {
1619
- const nonce = crypto.randomBytes(6).toString("hex");
1620
- prompt =
1621
- (cfg.prompt ?? "")
1622
- .replace(/\{\{hash\}\}/g, untrustedBlock("COMMIT HASH", safeHash, nonce))
1623
- .replace(/\{\{branch\}\}/g, untrustedBlock("BRANCH", safeBranchCommit, nonce))
1624
- .replace(/\{\{message\}\}/g, untrustedBlock("COMMIT MESSAGE", safeMessage, nonce))
1625
- .replace(/\{\{count\}\}/g, untrustedBlock("COMMIT COUNT", String(result.count), nonce))
1626
- .replace(/\{\{files\}\}/g, untrustedBlock("COMMITTED FILES", filesText, nonce))
1627
- .replace(/\{\{changeImpact\}\}/g, changeImpact
1628
- ? untrustedBlock("CHANGE IMPACT", changeImpact, nonce)
1629
- : "(change impact unavailable)") ?? "";
1630
- }
1631
- prompt = truncatePrompt(buildHookMetadata("onGitCommit") + prompt);
1632
- try {
1633
- const taskId = this._enqueueAutomationTask({
1634
- prompt,
1635
- triggerSource: "onGitCommit",
1636
- hookCfg: cfg,
1637
- });
1638
- this.lastTrigger.set(key, now);
1639
- this.activeGitCommitTaskId = taskId;
1640
- this.log(`[automation] triggered git-commit task ${taskId.slice(0, 8)} (hash: ${result.hash}, ${result.count} file(s))`);
1641
- }
1642
- catch (err) {
1643
- this.log(`[automation] failed to enqueue git-commit task: ${err instanceof Error ? err.message : String(err)}`);
1644
- }
620
+ this._lastRunPromise = this._runInterpreter("onGitCommit", {
621
+ hash: result.hash,
622
+ branch: result.branch,
623
+ message: result.message,
624
+ count: String(result.count),
625
+ files: result.files.join(", "),
626
+ });
1645
627
  }
1646
628
  /**
1647
629
  * Called after a successful gitPush tool call.
1648
630
  * Fires the onGitPush automation hook if configured.
1649
631
  */
1650
632
  handleGitPush(result) {
1651
- const cfg = this.policy.onGitPush;
1652
- if (!cfg?.enabled)
1653
- return;
1654
- if (!this._matchesCondition(cfg, result.branch))
1655
- return;
1656
- // Loop guard: skip if a task is still pending/running
1657
- if (this.activeGitPushTaskId) {
1658
- const existing = this.orchestrator.getTask(this.activeGitPushTaskId);
1659
- if (existing &&
1660
- (existing.status === "pending" || existing.status === "running")) {
1661
- this.log(`[automation] skipping git-push trigger — task ${this.activeGitPushTaskId.slice(0, 8)} still active`);
1662
- return;
1663
- }
1664
- this.activeGitPushTaskId = null;
1665
- }
1666
- // Cooldown check (workspace-global)
1667
- const key = "gitPush:global";
1668
- const now = Date.now();
1669
- const last = this.lastTrigger.get(key) ?? 0;
1670
- if (now - last < cfg.cooldownMs) {
1671
- this.log(`[automation] cooldown active for git-push (${cfg.cooldownMs - (now - last)}ms remaining)`);
1672
- return;
1673
- }
1674
- this._pruneLastTrigger(now);
1675
- const safeRemote = result.remote.slice(0, MAX_FILE_PATH_CHARS);
1676
- const safeBranch = result.branch.slice(0, MAX_FILE_PATH_CHARS);
1677
- const safeHash = result.hash.slice(0, 64);
1678
- let prompt;
1679
- if (cfg.promptName) {
1680
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { remote: safeRemote, branch: safeBranch, hash: safeHash });
1681
- if (resolved === null)
1682
- return;
1683
- prompt = resolved;
1684
- }
1685
- else {
1686
- const nonce = crypto.randomBytes(6).toString("hex");
1687
- prompt =
1688
- (cfg.prompt ?? "")
1689
- .replace(/\{\{remote\}\}/g, untrustedBlock("REMOTE", safeRemote, nonce))
1690
- .replace(/\{\{branch\}\}/g, untrustedBlock("BRANCH", safeBranch, nonce))
1691
- .replace(/\{\{hash\}\}/g, untrustedBlock("COMMIT HASH", safeHash, nonce)) ?? "";
1692
- }
1693
- prompt = truncatePrompt(buildHookMetadata("onGitPush") + prompt);
1694
- try {
1695
- const taskId = this._enqueueAutomationTask({
1696
- prompt,
1697
- triggerSource: "onGitPush",
1698
- hookCfg: cfg,
1699
- });
1700
- this.lastTrigger.set(key, now);
1701
- this.activeGitPushTaskId = taskId;
1702
- this.log(`[automation] triggered git-push task ${taskId.slice(0, 8)} (${result.remote}/${result.branch} @ ${result.hash})`);
1703
- }
1704
- catch (err) {
1705
- this.log(`[automation] failed to enqueue git-push task: ${err instanceof Error ? err.message : String(err)}`);
1706
- }
633
+ this._lastRunPromise = this._runInterpreter("onGitPush", {
634
+ branch: result.branch,
635
+ remote: result.remote,
636
+ hash: result.hash,
637
+ });
1707
638
  }
1708
639
  /**
1709
640
  * Fires the onGitPull automation hook if configured.
1710
641
  */
1711
642
  handleGitPull(result) {
1712
- const cfg = this.policy.onGitPull;
1713
- if (!cfg?.enabled)
1714
- return;
1715
- if (!this._matchesCondition(cfg, result.branch))
1716
- return;
1717
- // Loop guard: skip if a task is still pending/running
1718
- if (this.activeGitPullTaskId) {
1719
- const existing = this.orchestrator.getTask(this.activeGitPullTaskId);
1720
- if (existing &&
1721
- (existing.status === "pending" || existing.status === "running")) {
1722
- this.log(`[automation] skipping git-pull trigger — task ${this.activeGitPullTaskId.slice(0, 8)} still active`);
1723
- return;
1724
- }
1725
- this.activeGitPullTaskId = null;
1726
- }
1727
- // Cooldown check (workspace-global)
1728
- const key = "gitPull:global";
1729
- const now = Date.now();
1730
- const last = this.lastTrigger.get(key) ?? 0;
1731
- if (now - last < cfg.cooldownMs) {
1732
- this.log(`[automation] cooldown active for git-pull (${cfg.cooldownMs - (now - last)}ms remaining)`);
1733
- return;
1734
- }
1735
- this._pruneLastTrigger(now);
1736
- const safeRemote = result.remote.slice(0, MAX_FILE_PATH_CHARS);
1737
- const safeBranch = result.branch.slice(0, MAX_FILE_PATH_CHARS);
1738
- let prompt;
1739
- if (cfg.promptName) {
1740
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { remote: safeRemote, branch: safeBranch });
1741
- if (resolved === null)
1742
- return;
1743
- prompt = resolved;
1744
- }
1745
- else {
1746
- const nonce = crypto.randomBytes(6).toString("hex");
1747
- prompt =
1748
- (cfg.prompt ?? "")
1749
- .replace(/\{\{remote\}\}/g, untrustedBlock("REMOTE", safeRemote, nonce))
1750
- .replace(/\{\{branch\}\}/g, untrustedBlock("BRANCH", safeBranch, nonce)) ?? "";
1751
- }
1752
- prompt = truncatePrompt(buildHookMetadata("onGitPull") + prompt);
1753
- try {
1754
- const taskId = this._enqueueAutomationTask({
1755
- prompt,
1756
- triggerSource: "onGitPull",
1757
- hookCfg: cfg,
1758
- });
1759
- this.lastTrigger.set(key, now);
1760
- this.activeGitPullTaskId = taskId;
1761
- this.log(`[automation] triggered git-pull task ${taskId.slice(0, 8)} (${result.remote}/${result.branch}, alreadyUpToDate=${result.alreadyUpToDate})`);
1762
- }
1763
- catch (err) {
1764
- this.log(`[automation] failed to enqueue git-pull task: ${err instanceof Error ? err.message : String(err)}`);
1765
- }
643
+ this._lastRunPromise = this._runInterpreter("onGitPull", {
644
+ branch: result.branch,
645
+ remote: result.remote,
646
+ });
1766
647
  }
1767
648
  /**
1768
649
  * Called after a successful gitCheckout tool call.
1769
650
  * Fires the onBranchCheckout automation hook if configured.
1770
651
  */
1771
652
  handleBranchCheckout(result) {
1772
- const cfg = this.policy.onBranchCheckout;
1773
- if (!cfg?.enabled)
1774
- return;
1775
- if (!this._matchesCondition(cfg, result.branch))
1776
- return;
1777
- // Loop guard: skip if a task is still pending/running
1778
- if (this.activeBranchCheckoutTaskId) {
1779
- const existing = this.orchestrator.getTask(this.activeBranchCheckoutTaskId);
1780
- if (existing &&
1781
- (existing.status === "pending" || existing.status === "running")) {
1782
- this.log(`[automation] skipping branch-checkout trigger — task ${this.activeBranchCheckoutTaskId.slice(0, 8)} still active`);
1783
- return;
1784
- }
1785
- this.activeBranchCheckoutTaskId = null;
1786
- }
1787
- // Cooldown check (workspace-global)
1788
- const key = "branchCheckout:global";
1789
- const now = Date.now();
1790
- const last = this.lastTrigger.get(key) ?? 0;
1791
- if (now - last < cfg.cooldownMs) {
1792
- this.log(`[automation] cooldown active for branch-checkout (${cfg.cooldownMs - (now - last)}ms remaining)`);
1793
- return;
1794
- }
1795
- this._pruneLastTrigger(now);
1796
- const safeBranch = result.branch.slice(0, MAX_FILE_PATH_CHARS);
1797
- const safePreviousBranch = (result.previousBranch ?? "(detached HEAD)").slice(0, MAX_FILE_PATH_CHARS);
1798
- let prompt;
1799
- if (cfg.promptName) {
1800
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {
1801
- branch: safeBranch,
1802
- previousBranch: safePreviousBranch,
1803
- created: String(result.created),
1804
- });
1805
- if (resolved === null)
1806
- return;
1807
- prompt = resolved;
1808
- }
1809
- else {
1810
- const nonce = crypto.randomBytes(6).toString("hex");
1811
- prompt =
1812
- (cfg.prompt ?? "")
1813
- .replace(/\{\{branch\}\}/g, untrustedBlock("BRANCH", safeBranch, nonce))
1814
- .replace(/\{\{previousBranch\}\}/g, untrustedBlock("PREVIOUS BRANCH", safePreviousBranch, nonce))
1815
- .replace(/\{\{created\}\}/g, String(result.created)) ?? "";
1816
- }
1817
- prompt = truncatePrompt(buildHookMetadata("onBranchCheckout") + prompt);
1818
- try {
1819
- const taskId = this._enqueueAutomationTask({
1820
- prompt,
1821
- triggerSource: "onBranchCheckout",
1822
- hookCfg: cfg,
1823
- });
1824
- this.lastTrigger.set(key, now);
1825
- this.activeBranchCheckoutTaskId = taskId;
1826
- this.log(`[automation] triggered branch-checkout task ${taskId.slice(0, 8)} (${result.created ? "created" : "switched to"} ${result.branch})`);
1827
- }
1828
- catch (err) {
1829
- this.log(`[automation] failed to enqueue branch-checkout task: ${err instanceof Error ? err.message : String(err)}`);
1830
- }
653
+ this._lastRunPromise = this._runInterpreter("onBranchCheckout", {
654
+ branch: result.branch,
655
+ previousBranch: result.previousBranch ?? "(detached HEAD)",
656
+ created: String(result.created),
657
+ });
1831
658
  }
1832
659
  /**
1833
660
  * Fires the onPullRequest automation hook if configured.
1834
661
  */
1835
662
  handlePullRequest(result) {
1836
- const cfg = this.policy.onPullRequest;
1837
- if (!cfg?.enabled)
1838
- return;
1839
- if (!this._matchesCondition(cfg, result.branch))
1840
- return;
1841
- // Loop guard: skip if a task is still pending/running
1842
- if (this.activePullRequestTaskId) {
1843
- const existing = this.orchestrator.getTask(this.activePullRequestTaskId);
1844
- if (existing &&
1845
- (existing.status === "pending" || existing.status === "running")) {
1846
- this.log(`[automation] skipping pull-request trigger — task ${this.activePullRequestTaskId.slice(0, 8)} still active`);
1847
- return;
1848
- }
1849
- this.activePullRequestTaskId = null;
1850
- }
1851
- const key = "pullRequest:global";
1852
- const now = Date.now();
1853
- const last = this.lastTrigger.get(key) ?? 0;
1854
- if (now - last < cfg.cooldownMs) {
1855
- this.log(`[automation] skipping pull-request trigger — cooldown active (${cfg.cooldownMs - (now - last)}ms remaining)`);
1856
- return;
1857
- }
1858
- this._pruneLastTrigger(now);
1859
- const safeUrl = result.url.slice(0, MAX_FILE_PATH_CHARS);
1860
- const safeTitle = result.title.slice(0, MAX_DIAGNOSTIC_MSG_CHARS);
1861
- const safeBranch = result.branch.slice(0, MAX_FILE_PATH_CHARS);
1862
- const safeNumber = result.number !== null ? String(result.number) : "(unknown)";
1863
- let prompt;
1864
- if (cfg.promptName) {
1865
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {
1866
- url: safeUrl,
1867
- title: safeTitle,
1868
- branch: safeBranch,
1869
- number: safeNumber,
1870
- });
1871
- if (resolved === null)
1872
- return;
1873
- prompt = resolved;
1874
- }
1875
- else {
1876
- const nonce = crypto.randomBytes(6).toString("hex");
1877
- prompt =
1878
- (cfg.prompt ?? "")
1879
- .replace(/\{\{url\}\}/g, untrustedBlock("PR URL", safeUrl, nonce))
1880
- .replace(/\{\{number\}\}/g, safeNumber)
1881
- .replace(/\{\{title\}\}/g, untrustedBlock("PR TITLE", safeTitle, nonce))
1882
- .replace(/\{\{branch\}\}/g, untrustedBlock("BRANCH", safeBranch, nonce)) ?? "";
1883
- }
1884
- prompt = truncatePrompt(buildHookMetadata("onPullRequest") + prompt);
1885
- try {
1886
- const taskId = this._enqueueAutomationTask({
1887
- prompt,
1888
- triggerSource: "onPullRequest",
1889
- hookCfg: cfg,
1890
- });
1891
- this.lastTrigger.set(key, now);
1892
- this.activePullRequestTaskId = taskId;
1893
- this.log(`[automation] triggered pull-request task ${taskId.slice(0, 8)} (PR #${result.number ?? "?"}: ${result.title})`);
1894
- }
1895
- catch (err) {
1896
- this.log(`[automation] failed to enqueue pull-request task: ${err instanceof Error ? err.message : String(err)}`);
1897
- }
663
+ this._lastRunPromise = this._runInterpreter("onPullRequest", {
664
+ title: result.title,
665
+ url: result.url,
666
+ branch: result.branch,
667
+ number: result.number != null ? String(result.number) : "",
668
+ });
1898
669
  }
1899
670
  handleTaskCreated(result) {
1900
- const cfg = this.policy.onTaskCreated;
1901
- if (!cfg?.enabled)
1902
- return;
1903
- if (!this._matchesCondition(cfg, result.taskId))
1904
- return;
1905
- if (this.activeTaskCreatedTaskId) {
1906
- const existing = this.orchestrator.getTask(this.activeTaskCreatedTaskId);
1907
- if (existing &&
1908
- (existing.status === "pending" || existing.status === "running")) {
1909
- this.log(`[automation] skipping task-created trigger — task ${this.activeTaskCreatedTaskId.slice(0, 8)} still active`);
1910
- return;
1911
- }
1912
- this.activeTaskCreatedTaskId = null;
1913
- }
1914
- const key = "taskCreated:global";
1915
- const now = Date.now();
1916
- const last = this.lastTrigger.get(key) ?? 0;
1917
- if (now - last < cfg.cooldownMs) {
1918
- this.log(`[automation] skipping task-created trigger — cooldown active (${cfg.cooldownMs - (now - last)}ms remaining)`);
1919
- return;
1920
- }
1921
- this._pruneLastTrigger(now);
1922
- const safeTaskId = result.taskId.slice(0, MAX_FILE_PATH_CHARS);
1923
- const safePrompt = result.prompt.slice(0, MAX_DIAGNOSTIC_MSG_CHARS);
1924
- let prompt;
1925
- if (cfg.promptName) {
1926
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { taskId: safeTaskId, prompt: safePrompt });
1927
- if (resolved === null)
1928
- return;
1929
- prompt = resolved;
1930
- }
1931
- else {
1932
- const nonce = crypto.randomBytes(6).toString("hex");
1933
- prompt =
1934
- (cfg.prompt ?? "")
1935
- .replace(/\{\{taskId\}\}/g, untrustedBlock("TASK ID", safeTaskId, nonce))
1936
- .replace(/\{\{prompt\}\}/g, untrustedBlock("TASK PROMPT", safePrompt, nonce)) ?? "";
1937
- }
1938
- prompt = truncatePrompt(buildHookMetadata("onTaskCreated") + prompt);
1939
- try {
1940
- const taskId = this._enqueueAutomationTask({
1941
- prompt,
1942
- triggerSource: "onTaskCreated",
1943
- hookCfg: cfg,
1944
- });
1945
- this.lastTrigger.set(key, now);
1946
- this.activeTaskCreatedTaskId = taskId;
1947
- this.log(`[automation] triggered task-created task ${taskId.slice(0, 8)} (spawned task: ${result.taskId.slice(0, 8)})`);
1948
- }
1949
- catch (err) {
1950
- this.log(`[automation] failed to enqueue task-created task: ${err instanceof Error ? err.message : String(err)}`);
1951
- }
671
+ this._lastRunPromise = this._runInterpreter("onTaskCreated", {
672
+ taskId: result.taskId,
673
+ prompt: result.prompt,
674
+ });
1952
675
  }
1953
676
  handlePermissionDenied(result) {
1954
- const cfg = this.policy.onPermissionDenied;
1955
- if (!cfg?.enabled)
1956
- return;
1957
- if (!this._matchesCondition(cfg, result.tool))
1958
- return;
1959
- if (this.activePermissionDeniedTaskId) {
1960
- const existing = this.orchestrator.getTask(this.activePermissionDeniedTaskId);
1961
- if (existing &&
1962
- (existing.status === "pending" || existing.status === "running")) {
1963
- this.log(`[automation] skipping permission-denied trigger — task ${this.activePermissionDeniedTaskId.slice(0, 8)} still active`);
1964
- return;
1965
- }
1966
- this.activePermissionDeniedTaskId = null;
1967
- }
1968
- const key = "permissionDenied:global";
1969
- const now = Date.now();
1970
- const last = this.lastTrigger.get(key) ?? 0;
1971
- if (now - last < cfg.cooldownMs) {
1972
- this.log(`[automation] skipping permission-denied trigger — cooldown active (${cfg.cooldownMs - (now - last)}ms remaining)`);
1973
- return;
1974
- }
1975
- this._pruneLastTrigger(now);
1976
- const safeTool = result.tool.slice(0, MAX_FILE_PATH_CHARS);
1977
- const safeReason = result.reason.slice(0, MAX_DIAGNOSTIC_MSG_CHARS);
1978
- let prompt;
1979
- if (cfg.promptName) {
1980
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { tool: safeTool, reason: safeReason });
1981
- if (resolved === null)
1982
- return;
1983
- prompt = resolved;
1984
- }
1985
- else {
1986
- const nonce = crypto.randomBytes(6).toString("hex");
1987
- prompt =
1988
- (cfg.prompt ?? "")
1989
- .replace(/\{\{tool\}\}/g, untrustedBlock("TOOL NAME", safeTool, nonce))
1990
- .replace(/\{\{reason\}\}/g, untrustedBlock("DENIAL REASON", safeReason, nonce)) ?? "";
1991
- }
1992
- prompt = truncatePrompt(buildHookMetadata("onPermissionDenied") + prompt);
1993
- try {
1994
- const taskId = this._enqueueAutomationTask({
1995
- prompt,
1996
- triggerSource: "onPermissionDenied",
1997
- hookCfg: cfg,
1998
- });
1999
- this.lastTrigger.set(key, now);
2000
- this.activePermissionDeniedTaskId = taskId;
2001
- this.log(`[automation] triggered permission-denied task ${taskId.slice(0, 8)} (tool: ${result.tool})`);
2002
- }
2003
- catch (err) {
2004
- this.log(`[automation] failed to enqueue permission-denied task: ${err instanceof Error ? err.message : String(err)}`);
2005
- }
677
+ this._lastRunPromise = this._runInterpreter("onPermissionDenied", {
678
+ tool: result.tool,
679
+ reason: result.reason,
680
+ });
2006
681
  }
2007
682
  /**
2008
683
  * Fires the onDiagnosticsCleared hook when a file transitions from non-zero to zero errors.
2009
684
  * Called internally by handleDiagnosticsChanged.
2010
685
  */
2011
686
  handleDiagnosticsCleared(normalizedFile) {
2012
- const cfg = this.policy.onDiagnosticsCleared;
2013
- if (!cfg?.enabled)
2014
- return;
2015
- if (!this._matchesCondition(cfg, normalizedFile))
2016
- return;
2017
- // Loop guard: skip if a task for this file is still pending/running
2018
- const existingId = this.activeDiagnosticsClearedTasks.get(normalizedFile);
2019
- if (existingId) {
2020
- const existing = this.orchestrator.getTask(existingId);
2021
- if (existing &&
2022
- (existing.status === "pending" || existing.status === "running")) {
2023
- this.log(`[automation] skipping diagnostics-cleared trigger for ${normalizedFile} — task ${existingId.slice(0, 8)} still active`);
2024
- return;
2025
- }
2026
- this.activeDiagnosticsClearedTasks.delete(normalizedFile);
2027
- }
2028
- const key = `diagnosticsCleared:${normalizedFile}`;
2029
- const now = Date.now();
2030
- const last = this.lastTrigger.get(key) ?? 0;
2031
- if (now - last < cfg.cooldownMs) {
2032
- this.log(`[automation] cooldown active for diagnostics-cleared ${normalizedFile} (${cfg.cooldownMs - (now - last)}ms remaining)`);
2033
- return;
2034
- }
2035
- this._pruneLastTrigger(now);
2036
- const safeFilePath = normalizedFile.slice(0, MAX_FILE_PATH_CHARS);
2037
- let prompt;
2038
- if (cfg.promptName) {
2039
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { file: safeFilePath });
2040
- if (resolved === null)
2041
- return;
2042
- prompt = resolved;
2043
- }
2044
- else {
2045
- const nonce = crypto.randomBytes(6).toString("hex");
2046
- prompt =
2047
- (cfg.prompt ?? "").replace(/\{\{file\}\}/g, untrustedBlock("FILE PATH", safeFilePath, nonce)) ?? "";
2048
- }
2049
- prompt = truncatePrompt(buildHookMetadata("onDiagnosticsCleared", normalizedFile) + prompt);
2050
- try {
2051
- const taskId = this._enqueueAutomationTask({
2052
- prompt,
2053
- triggerSource: "onDiagnosticsCleared",
2054
- hookCfg: cfg,
2055
- });
2056
- this.lastTrigger.set(key, now);
2057
- this.activeDiagnosticsClearedTasks.set(normalizedFile, taskId);
2058
- this.log(`[automation] triggered diagnostics-cleared task ${taskId.slice(0, 8)} for ${normalizedFile}`);
2059
- }
2060
- catch (err) {
2061
- this.log(`[automation] failed to enqueue diagnostics-cleared task for ${normalizedFile}: ${err instanceof Error ? err.message : String(err)}`);
2062
- }
687
+ this._lastRunPromise = this._runInterpreter("onDiagnosticsCleared", {
688
+ file: normalizedFile,
689
+ diagnosticSig: "",
690
+ });
2063
691
  }
2064
692
  /**
2065
693
  * Fires the onTaskSuccess hook when a Claude orchestrator task completes with status "done".
2066
694
  * Call from bridge.ts when a task transitions to done.
2067
695
  */
2068
696
  handleTaskSuccess(result) {
2069
- const cfg = this.policy.onTaskSuccess;
2070
- if (!cfg?.enabled)
2071
- return;
2072
- if (!this._matchesCondition(cfg, result.taskId))
2073
- return;
2074
- // Loop guard: skip if a prior task-success task is still active
2075
- if (this.activeTaskSuccessTaskId) {
2076
- const existing = this.orchestrator.getTask(this.activeTaskSuccessTaskId);
2077
- if (existing &&
2078
- (existing.status === "pending" || existing.status === "running")) {
2079
- this.log(`[automation] skipping task-success trigger — task ${this.activeTaskSuccessTaskId.slice(0, 8)} still active`);
2080
- return;
2081
- }
2082
- this.activeTaskSuccessTaskId = null;
2083
- }
2084
- const key = "taskSuccess:global";
2085
- const now = Date.now();
2086
- const last = this.lastTrigger.get(key) ?? 0;
2087
- if (now - last < cfg.cooldownMs) {
2088
- this.log(`[automation] skipping task-success trigger — cooldown active (${cfg.cooldownMs - (now - last)}ms remaining)`);
2089
- return;
2090
- }
2091
- this._pruneLastTrigger(now);
2092
- const safeTaskId = result.taskId.slice(0, MAX_FILE_PATH_CHARS);
2093
- const safeOutput = result.output.slice(0, 500);
2094
- let prompt;
2095
- if (cfg.promptName) {
2096
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, { taskId: safeTaskId, output: safeOutput });
2097
- if (resolved === null)
2098
- return;
2099
- prompt = resolved;
2100
- }
2101
- else {
2102
- const nonce = crypto.randomBytes(6).toString("hex");
2103
- prompt =
2104
- (cfg.prompt ?? "")
2105
- .replace(/\{\{taskId\}\}/g, untrustedBlock("TASK ID", safeTaskId, nonce))
2106
- .replace(/\{\{output\}\}/g, untrustedBlock("TASK OUTPUT", safeOutput, nonce)) ?? "";
2107
- }
2108
- prompt = truncatePrompt(buildHookMetadata("onTaskSuccess") + prompt);
2109
- try {
2110
- const taskId = this._enqueueAutomationTask({
2111
- prompt,
2112
- triggerSource: "onTaskSuccess",
2113
- hookCfg: cfg,
2114
- });
2115
- this.lastTrigger.set(key, now);
2116
- this.activeTaskSuccessTaskId = taskId;
2117
- this.log(`[automation] triggered task-success task ${taskId.slice(0, 8)} (completed task: ${result.taskId.slice(0, 8)})`);
2118
- }
2119
- catch (err) {
2120
- this.log(`[automation] failed to enqueue task-success task: ${err instanceof Error ? err.message : String(err)}`);
2121
- }
697
+ this._lastRunPromise = this._runInterpreter("onTaskSuccess", {
698
+ taskId: result.taskId,
699
+ output: result.output,
700
+ });
2122
701
  }
2123
702
  /**
2124
703
  * Called when a VS Code debug session ends (hasActiveSession transitions true→false).
2125
704
  * Fires the onDebugSessionEnd automation hook if configured.
2126
705
  */
2127
706
  async handleDebugSessionEnd(result) {
2128
- const cfg = this.policy.onDebugSessionEnd;
2129
- if (!cfg?.enabled)
2130
- return;
2131
- // Loop guard: skip if a task is still pending/running
2132
- if (this.activeDebugSessionEndTaskId) {
2133
- const existing = this.orchestrator.getTask(this.activeDebugSessionEndTaskId);
2134
- if (existing &&
2135
- (existing.status === "pending" || existing.status === "running")) {
2136
- this.log(`[automation] skipping debug-session-end trigger — task ${this.activeDebugSessionEndTaskId.slice(0, 8)} still active`);
2137
- return;
2138
- }
2139
- this.activeDebugSessionEndTaskId = null;
2140
- }
2141
- // Cooldown check (workspace-global)
2142
- const key = "debugSessionEnd:global";
2143
- const now = Date.now();
2144
- const last = this.lastTrigger.get(key) ?? 0;
2145
- if (now - last < cfg.cooldownMs) {
2146
- this.log(`[automation] cooldown active for debug-session-end (${cfg.cooldownMs - (now - last)}ms remaining)`);
2147
- return;
2148
- }
2149
- this._pruneLastTrigger(now);
2150
- const safeSessionName = result.sessionName.slice(0, MAX_FILE_PATH_CHARS);
2151
- const safeSessionType = result.sessionType.slice(0, MAX_FILE_PATH_CHARS);
2152
- let prompt;
2153
- if (cfg.promptName) {
2154
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {
2155
- sessionName: safeSessionName,
2156
- sessionType: safeSessionType,
2157
- });
2158
- if (resolved === null)
2159
- return;
2160
- prompt = resolved;
2161
- }
2162
- else {
2163
- const nonce = crypto.randomBytes(6).toString("hex");
2164
- prompt =
2165
- (cfg.prompt ?? "")
2166
- .replace(/\{\{sessionName\}\}/g, untrustedBlock("SESSION NAME", safeSessionName, nonce))
2167
- .replace(/\{\{sessionType\}\}/g, untrustedBlock("SESSION TYPE", safeSessionType, nonce)) ?? "";
2168
- }
2169
- prompt = truncatePrompt(buildHookMetadata("onDebugSessionEnd") + prompt);
2170
- try {
2171
- const taskId = this._enqueueAutomationTask({
2172
- prompt,
2173
- triggerSource: "onDebugSessionEnd",
2174
- hookCfg: cfg,
2175
- });
2176
- this.lastTrigger.set(key, now);
2177
- this.activeDebugSessionEndTaskId = taskId;
2178
- this.log(`[automation] triggered debug-session-end task ${taskId.slice(0, 8)} (session: ${result.sessionName}, type: ${result.sessionType})`);
2179
- }
2180
- catch (err) {
2181
- this.log(`[automation] failed to enqueue debug-session-end task: ${err instanceof Error ? err.message : String(err)}`);
2182
- }
707
+ this._lastRunPromise = this._runInterpreter("onDebugSessionEnd", {
708
+ sessionName: result.sessionName,
709
+ sessionType: result.sessionType,
710
+ });
2183
711
  }
2184
712
  /**
2185
713
  * Called when a VS Code debug session starts (hasActiveSession transitions false→true).
2186
714
  * Fires the onDebugSessionStart automation hook if configured.
2187
715
  */
2188
716
  async handleDebugSessionStart(result) {
2189
- const cfg = this.policy.onDebugSessionStart;
2190
- if (!cfg?.enabled)
2191
- return;
2192
- // Loop guard: skip if a task is still pending/running
2193
- if (this.activeDebugSessionStartTaskId) {
2194
- const existing = this.orchestrator.getTask(this.activeDebugSessionStartTaskId);
2195
- if (existing &&
2196
- (existing.status === "pending" || existing.status === "running")) {
2197
- this.log(`[automation] skipping debug-session-start trigger — task ${this.activeDebugSessionStartTaskId.slice(0, 8)} still active`);
2198
- return;
2199
- }
2200
- this.activeDebugSessionStartTaskId = null;
2201
- }
2202
- // Cooldown check (workspace-global)
2203
- const key = "debugSessionStart:global";
2204
- const now = Date.now();
2205
- const last = this.lastTrigger.get(key) ?? 0;
2206
- if (now - last < cfg.cooldownMs) {
2207
- this.log(`[automation] cooldown active for debug-session-start (${cfg.cooldownMs - (now - last)}ms remaining)`);
2208
- return;
2209
- }
2210
- this._pruneLastTrigger(now);
2211
- const safeSessionName = result.sessionName.slice(0, MAX_FILE_PATH_CHARS);
2212
- const safeSessionType = result.sessionType.slice(0, MAX_FILE_PATH_CHARS);
2213
- const safeActiveFile = result.activeFile.slice(0, MAX_FILE_PATH_CHARS);
2214
- const breakpointCount = String(result.breakpointCount);
2215
- let prompt;
2216
- if (cfg.promptName) {
2217
- const resolved = this._resolveNamedPrompt(cfg.promptName, cfg.promptArgs ?? {}, {
2218
- sessionName: safeSessionName,
2219
- sessionType: safeSessionType,
2220
- activeFile: safeActiveFile,
2221
- breakpointCount,
2222
- });
2223
- if (resolved === null)
2224
- return;
2225
- prompt = resolved;
2226
- }
2227
- else {
2228
- const nonce = crypto.randomBytes(6).toString("hex");
2229
- prompt =
2230
- (cfg.prompt ?? "")
2231
- .replace(/\{\{sessionName\}\}/g, untrustedBlock("SESSION NAME", safeSessionName, nonce))
2232
- .replace(/\{\{sessionType\}\}/g, untrustedBlock("SESSION TYPE", safeSessionType, nonce))
2233
- .replace(/\{\{activeFile\}\}/g, untrustedBlock("ACTIVE FILE", safeActiveFile, nonce))
2234
- .replace(/\{\{breakpointCount\}\}/g, breakpointCount) ?? "";
2235
- }
2236
- prompt = truncatePrompt(buildHookMetadata("onDebugSessionStart") + prompt);
2237
- try {
2238
- const taskId = this._enqueueAutomationTask({
2239
- prompt,
2240
- triggerSource: "onDebugSessionStart",
2241
- hookCfg: cfg,
2242
- });
2243
- this.lastTrigger.set(key, now);
2244
- this.activeDebugSessionStartTaskId = taskId;
2245
- this.log(`[automation] triggered debug-session-start task ${taskId.slice(0, 8)} (session: ${result.sessionName}, type: ${result.sessionType}, breakpoints: ${result.breakpointCount})`);
2246
- }
2247
- catch (err) {
2248
- this.log(`[automation] failed to enqueue debug-session-start task: ${err instanceof Error ? err.message : String(err)}`);
2249
- }
717
+ this._lastRunPromise = this._runInterpreter("onDebugSessionStart", {
718
+ sessionName: result.sessionName,
719
+ sessionType: result.sessionType,
720
+ breakpointCount: String(result.breakpointCount),
721
+ activeFile: result.activeFile,
722
+ });
2250
723
  }
2251
724
  /** Summary of automation policy for getBridgeStatus. */
2252
725
  getStatus() {
@@ -2380,7 +853,7 @@ export class AutomationHooks {
2380
853
  unwiredEnabledHooks,
2381
854
  defaultModel: p.defaultModel ?? "claude-haiku-4-5-20251001",
2382
855
  maxTasksPerHour: p.maxTasksPerHour ?? 20,
2383
- tasksThisHour: this.taskTimestamps.filter((t) => t >= Date.now() - 60 * 60 * 1_000).length,
856
+ tasksThisHour: tasksInLastHour(this._automationState, Date.now()),
2384
857
  defaultEffort: p.defaultEffort ?? "low",
2385
858
  automationSystemPrompt: (p.automationSystemPrompt ?? DEFAULT_AUTOMATION_SYSTEM_PROMPT).slice(0, 80),
2386
859
  };