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.
- package/README.md +28 -5
- package/dist/activityLog.d.ts +19 -3
- package/dist/activityLog.js +63 -65
- package/dist/activityLog.js.map +1 -1
- package/dist/automation.d.ts +23 -94
- package/dist/automation.js +270 -1797
- package/dist/automation.js.map +1 -1
- package/dist/bridge.d.ts +3 -0
- package/dist/bridge.js +122 -3
- package/dist/bridge.js.map +1 -1
- package/dist/claudeDriver.d.ts +47 -0
- package/dist/claudeDriver.js +58 -2
- package/dist/claudeDriver.js.map +1 -1
- package/dist/claudeMdPatch.d.ts +29 -0
- package/dist/claudeMdPatch.js +164 -0
- package/dist/claudeMdPatch.js.map +1 -0
- package/dist/claudeOrchestrator.js +12 -3
- package/dist/claudeOrchestrator.js.map +1 -1
- package/dist/commands/task.d.ts +14 -0
- package/dist/commands/task.js +289 -0
- package/dist/commands/task.js.map +1 -0
- package/dist/commands/tokenEfficiency.d.ts +9 -0
- package/dist/commands/tokenEfficiency.js +211 -0
- package/dist/commands/tokenEfficiency.js.map +1 -0
- package/dist/commands/tools.d.ts +28 -0
- package/dist/commands/tools.js +326 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/dashboard.js +40 -0
- package/dist/dashboard.js.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.js +1 -0
- package/dist/errors.js.map +1 -1
- package/dist/extensionClient.d.ts +5 -12
- package/dist/extensionClient.js +20 -26
- package/dist/extensionClient.js.map +1 -1
- package/dist/fp/activityAnalytics.d.ts +39 -0
- package/dist/fp/activityAnalytics.js +122 -0
- package/dist/fp/activityAnalytics.js.map +1 -0
- package/dist/fp/async.d.ts +48 -0
- package/dist/fp/async.js +60 -0
- package/dist/fp/async.js.map +1 -0
- package/dist/fp/automationInterpreter.d.ts +37 -0
- package/dist/fp/automationInterpreter.js +523 -0
- package/dist/fp/automationInterpreter.js.map +1 -0
- package/dist/fp/automationProgram.d.ts +89 -0
- package/dist/fp/automationProgram.js +29 -0
- package/dist/fp/automationProgram.js.map +1 -0
- package/dist/fp/automationState.d.ts +135 -0
- package/dist/fp/automationState.js +206 -0
- package/dist/fp/automationState.js.map +1 -0
- package/dist/fp/automationUtils.d.ts +27 -0
- package/dist/fp/automationUtils.js +48 -0
- package/dist/fp/automationUtils.js.map +1 -0
- package/dist/fp/brandedTypes.d.ts +32 -0
- package/dist/fp/brandedTypes.js +41 -0
- package/dist/fp/brandedTypes.js.map +1 -0
- package/dist/fp/commandDescription.d.ts +18 -0
- package/dist/fp/commandDescription.js +125 -0
- package/dist/fp/commandDescription.js.map +1 -0
- package/dist/fp/extensionSnapshot.d.ts +10 -0
- package/dist/fp/extensionSnapshot.js +14 -0
- package/dist/fp/extensionSnapshot.js.map +1 -0
- package/dist/fp/index.d.ts +8 -0
- package/dist/fp/index.js +9 -0
- package/dist/fp/index.js.map +1 -0
- package/dist/fp/interpreterContext.d.ts +69 -0
- package/dist/fp/interpreterContext.js +56 -0
- package/dist/fp/interpreterContext.js.map +1 -0
- package/dist/fp/policyParser.d.ts +16 -0
- package/dist/fp/policyParser.js +334 -0
- package/dist/fp/policyParser.js.map +1 -0
- package/dist/fp/result.d.ts +38 -0
- package/dist/fp/result.js +57 -0
- package/dist/fp/result.js.map +1 -0
- package/dist/fp/tokenBucket.d.ts +27 -0
- package/dist/fp/tokenBucket.js +36 -0
- package/dist/fp/tokenBucket.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +103 -57
- package/dist/index.js.map +1 -1
- package/dist/oauth.js +9 -34
- package/dist/oauth.js.map +1 -1
- package/dist/prompts.js +123 -0
- package/dist/prompts.js.map +1 -1
- package/dist/quickTaskPresets.d.ts +64 -0
- package/dist/quickTaskPresets.js +156 -0
- package/dist/quickTaskPresets.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +47 -0
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.js +6 -0
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/activityLog.js +2 -2
- package/dist/tools/activityLog.js.map +1 -1
- package/dist/tools/auditDependencies.js +1 -1
- package/dist/tools/auditDependencies.js.map +1 -1
- package/dist/tools/batchLsp.d.ts +57 -0
- package/dist/tools/batchLsp.js +79 -13
- package/dist/tools/batchLsp.js.map +1 -1
- package/dist/tools/bridgeStatus.js +3 -5
- package/dist/tools/bridgeStatus.js.map +1 -1
- package/dist/tools/explainDiagnostic.d.ts +137 -0
- package/dist/tools/explainDiagnostic.js +230 -0
- package/dist/tools/explainDiagnostic.js.map +1 -0
- package/dist/tools/formatAndSave.d.ts +0 -23
- package/dist/tools/formatAndSave.js +22 -5
- package/dist/tools/formatAndSave.js.map +1 -1
- package/dist/tools/getAnalyticsReport.js +8 -0
- package/dist/tools/getAnalyticsReport.js.map +1 -1
- package/dist/tools/getClaudeTaskStatus.js +2 -2
- package/dist/tools/getClaudeTaskStatus.js.map +1 -1
- package/dist/tools/getDiagnostics.js +17 -3
- package/dist/tools/getDiagnostics.js.map +1 -1
- package/dist/tools/getDiffFromHandoff.d.ts +89 -0
- package/dist/tools/getDiffFromHandoff.js +163 -0
- package/dist/tools/getDiffFromHandoff.js.map +1 -0
- package/dist/tools/github/pr.js +1 -1
- package/dist/tools/github/pr.js.map +1 -1
- package/dist/tools/handoffNote.js +91 -6
- package/dist/tools/handoffNote.js.map +1 -1
- package/dist/tools/httpClient.js +1 -1
- package/dist/tools/httpClient.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +83 -10
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/jumpToFirstError.d.ts +0 -7
- package/dist/tools/jumpToFirstError.js +6 -6
- package/dist/tools/jumpToFirstError.js.map +1 -1
- package/dist/tools/launchQuickTask.d.ts +76 -0
- package/dist/tools/launchQuickTask.js +170 -0
- package/dist/tools/launchQuickTask.js.map +1 -0
- package/dist/tools/listClaudeTasks.js +2 -2
- package/dist/tools/listClaudeTasks.js.map +1 -1
- package/dist/tools/openFile.js +2 -2
- package/dist/tools/openFile.js.map +1 -1
- package/dist/tools/performanceReport.d.ts +133 -0
- package/dist/tools/performanceReport.js +218 -0
- package/dist/tools/performanceReport.js.map +1 -0
- package/dist/tools/previewEdit.d.ts +107 -0
- package/dist/tools/previewEdit.js +270 -0
- package/dist/tools/previewEdit.js.map +1 -0
- package/dist/tools/runClaudeTask.js +7 -7
- package/dist/tools/runClaudeTask.js.map +1 -1
- package/dist/tools/runCommand.js +8 -141
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/runTests.js +16 -3
- package/dist/tools/runTests.js.map +1 -1
- package/dist/tools/searchAndReplace.js +1 -1
- package/dist/tools/searchAndReplace.js.map +1 -1
- package/dist/tools/spawnWorkspace.d.ts +103 -0
- package/dist/tools/spawnWorkspace.js +268 -0
- package/dist/tools/spawnWorkspace.js.map +1 -0
- package/dist/tools/terminal.js +1 -1
- package/dist/tools/terminal.js.map +1 -1
- package/dist/tools/testTraceToSource.d.ts +80 -0
- package/dist/tools/testTraceToSource.js +206 -0
- package/dist/tools/testTraceToSource.js.map +1 -0
- package/dist/tools/transaction.d.ts +243 -0
- package/dist/tools/transaction.js +309 -0
- package/dist/tools/transaction.js.map +1 -0
- package/dist/tools/utils.d.ts +2 -1
- package/dist/tools/utils.js.map +1 -1
- package/dist/tools/watchDiagnostics.js +29 -13
- package/dist/tools/watchDiagnostics.js.map +1 -1
- package/dist/transport.d.ts +7 -0
- package/dist/transport.js +25 -8
- package/dist/transport.js.map +1 -1
- package/package.json +2 -1
- package/templates/managed-agent/code-review-agent.md +50 -0
- package/templates/managed-agent/managed-agent-mcp.json +102 -0
package/dist/automation.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
|
|
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
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
700
|
-
*
|
|
387
|
+
* Per-runner last outcome — used to detect fail→pass transitions for onTestPassAfterFailure.
|
|
388
|
+
* Key: runner name. Value: "pass" | "fail".
|
|
701
389
|
*/
|
|
702
|
-
|
|
390
|
+
lastTestOutcomeByRunner = new Map();
|
|
703
391
|
_lastFiredAt = null;
|
|
704
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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
|
-
*
|
|
829
|
-
*
|
|
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
|
-
|
|
833
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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 (
|
|
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
|
-
//
|
|
896
|
-
this.
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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:
|
|
929
|
-
information:
|
|
930
|
-
hint:
|
|
475
|
+
info: 2,
|
|
476
|
+
information: 2,
|
|
477
|
+
hint: 3,
|
|
931
478
|
};
|
|
932
|
-
const
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
|
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
|
-
//
|
|
1392
|
-
this.
|
|
587
|
+
// Feed interpreter state
|
|
588
|
+
this._automationState = setTestRunnerStatus(this._automationState, runner, current);
|
|
1393
589
|
if (prev === "fail" && current === "pass") {
|
|
1394
|
-
|
|
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
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
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
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
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
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
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
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
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.
|
|
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
|
};
|