jules-orchestrator-kit 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/webhook.mjs CHANGED
@@ -1,5 +1,420 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { getStateDir } from "./state.mjs";
6
+ import { resolveRoot } from "./config.mjs";
7
+ import { redactSecrets } from "./security.mjs";
8
+
9
+ export const DEFAULT_CRITICAL_REASONS = [
10
+ "R3_GATE_VIOLATION",
11
+ "AWAITING_USER_FEEDBACK",
12
+ "OODA_REPAIR_EXHAUSTED",
13
+ "SECRET_LEAK_DETECTED",
14
+ "CRITICAL_FAILURE",
15
+ ];
16
+
17
+ export function getDigestFilePath(root = resolveRoot()) {
18
+ return join(getStateDir(root), "escalation-digest.json");
19
+ }
20
+
21
+ export function getInterruptionLedgerPath(root = resolveRoot()) {
22
+ return join(getStateDir(root), "interruption-ledger.json");
23
+ }
24
+
25
+ export function loadEscalationDigest(root = resolveRoot()) {
26
+ const file = getDigestFilePath(root);
27
+ if (!existsSync(file)) return { incidents: [], createdAt: null, updatedAt: null };
28
+ try {
29
+ const raw = readFileSync(file, "utf-8");
30
+ const data = JSON.parse(raw);
31
+ return {
32
+ incidents: Array.isArray(data.incidents) ? data.incidents : [],
33
+ createdAt: data.createdAt || null,
34
+ updatedAt: data.updatedAt || null,
35
+ };
36
+ } catch (_) {
37
+ return { incidents: [], createdAt: null, updatedAt: null };
38
+ }
39
+ }
40
+
41
+ export function saveEscalationDigest(root = resolveRoot(), digestData = {}) {
42
+ const file = getDigestFilePath(root);
43
+ const data = {
44
+ incidents: digestData.incidents || [],
45
+ createdAt: digestData.createdAt || new Date().toISOString(),
46
+ updatedAt: new Date().toISOString(),
47
+ };
48
+ writeFileSync(file, JSON.stringify(data, null, 2), "utf-8");
49
+ return data;
50
+ }
51
+
52
+ export function clearEscalationDigest(root = resolveRoot()) {
53
+ const file = getDigestFilePath(root);
54
+ if (existsSync(file)) {
55
+ try {
56
+ unlinkSync(file);
57
+ } catch (_) {
58
+ writeFileSync(file, JSON.stringify({ incidents: [], createdAt: null, updatedAt: null }), "utf-8");
59
+ }
60
+ }
61
+ return { ok: true, cleared: true };
62
+ }
63
+
64
+ export function bufferEscalationIncident(incident = {}, root = resolveRoot()) {
65
+ const current = loadEscalationDigest(root);
66
+ const rawLogs = incident.logs || incident.error || "";
67
+ const scrubbedLogs = rawLogs ? redactSecrets(String(rawLogs)) : "";
68
+ const entry = {
69
+ id: incident.sessionId || `incident-${Date.now()}`,
70
+ sessionId: incident.sessionId || "unknown-session",
71
+ taskId: incident.taskId || "agent-task",
72
+ branch: incident.branch || "main",
73
+ reason: incident.reason || "NON_CRITICAL_EVENT",
74
+ logs: scrubbedLogs.split("\n").slice(-10).join("\n"),
75
+ timestamp: incident.timestamp || new Date().toISOString(),
76
+ };
77
+ const updatedIncidents = [...current.incidents, entry];
78
+ saveEscalationDigest(root, {
79
+ incidents: updatedIncidents,
80
+ createdAt: current.createdAt || new Date().toISOString(),
81
+ });
82
+ return {
83
+ buffered: true,
84
+ count: updatedIncidents.length,
85
+ incident: entry,
86
+ };
87
+ }
88
+
89
+ export function loadInterruptionLedger(root = resolveRoot()) {
90
+ const file = getInterruptionLedgerPath(root);
91
+ if (!existsSync(file)) return [];
92
+ try {
93
+ const raw = readFileSync(file, "utf-8");
94
+ const arr = JSON.parse(raw);
95
+ return Array.isArray(arr) ? arr : [];
96
+ } catch (_) {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ export function recordInterruption(root = resolveRoot()) {
102
+ const file = getInterruptionLedgerPath(root);
103
+ const now = Date.now();
104
+ const entries = loadInterruptionLedger(root).filter((ts) => now - ts < 3600000); // 1 hour sliding window
105
+ entries.push(now);
106
+ writeFileSync(file, JSON.stringify(entries), "utf-8");
107
+ return entries.length;
108
+ }
109
+
110
+ export function countRecentInterruptions(root = resolveRoot(), windowMs = 3600000) {
111
+ const now = Date.now();
112
+ const entries = loadInterruptionLedger(root);
113
+ return entries.filter((ts) => now - ts < windowMs).length;
114
+ }
115
+
116
+ export function getEscalationDigestStatus(root = resolveRoot(), config = {}) {
117
+ const digest = loadEscalationDigest(root);
118
+ const notifications = config.notifications || {};
119
+ const budgetPerHour = notifications.budgetPerHour ?? 3;
120
+ const recentInterruptions = countRecentInterruptions(root);
121
+
122
+ return {
123
+ pendingCount: digest.incidents.length,
124
+ incidents: digest.incidents,
125
+ createdAt: digest.createdAt,
126
+ updatedAt: digest.updatedAt,
127
+ budgetPerHour,
128
+ recentInterruptions,
129
+ budgetAvailable: Math.max(0, budgetPerHour - recentInterruptions),
130
+ mode: notifications.mode || "immediate",
131
+ threshold: notifications.threshold || 5,
132
+ };
133
+ }
134
+
135
+ export async function flushEscalationDigest(config = {}, options = {}) {
136
+ const root = options.root || resolveRoot();
137
+ const digest = loadEscalationDigest(root);
138
+ if (digest.incidents.length === 0) {
139
+ return { flushed: false, count: 0, reason: "No pending incidents in digest" };
140
+ }
141
+
142
+ const notifications = config.notifications || {};
143
+ const slackUrl = process.env.SLACK_WEBHOOK_URL || notifications.slackWebhookUrl || config.slackWebhookUrl || "";
144
+ const discordUrl = process.env.DISCORD_WEBHOOK_URL || notifications.discordWebhookUrl || config.discordWebhookUrl || "";
145
+ const dryRun = options.dryRun || config.dryRun || false;
146
+
147
+ const count = digest.incidents.length;
148
+ const results = { flushed: true, count, slack: false, discord: false, dryRun };
149
+
150
+ if (dryRun) {
151
+ if (!options.preserve) clearEscalationDigest(root);
152
+ return {
153
+ ...results,
154
+ payload: {
155
+ count,
156
+ incidents: digest.incidents,
157
+ },
158
+ };
159
+ }
160
+
161
+ if (!slackUrl && !discordUrl) {
162
+ return { flushed: false, count, slack: false, discord: false, reason: "No webhook URL configured" };
163
+ }
164
+
165
+ // Format Slack digest
166
+ if (slackUrl) {
167
+ try {
168
+ const summaryText = digest.incidents
169
+ .map((inc) => `• *\`${inc.sessionId}\`* on \`${inc.branch}\` [${inc.reason}]: \`agentctl resume ${inc.sessionId}\``)
170
+ .join("\n");
171
+
172
+ const slackBody = {
173
+ text: `📋 *Jules Escalation Digest* (${count} batched incident${count > 1 ? "s" : ""})`,
174
+ blocks: [
175
+ {
176
+ type: "header",
177
+ text: { type: "plain_text", text: `📋 Jules Escalation Digest (${count} incidents)` },
178
+ },
179
+ {
180
+ type: "section",
181
+ text: { type: "mrkdwn", text: summaryText.slice(0, 2500) },
182
+ },
183
+ {
184
+ type: "context",
185
+ elements: [
186
+ { type: "mrkdwn", text: `Aggregated by Type III Silence Governor · Oldest: ${digest.createdAt || "N/A"}` },
187
+ ],
188
+ },
189
+ ],
190
+ };
191
+
192
+ const res = await fetch(slackUrl, {
193
+ method: "POST",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify(slackBody),
196
+ });
197
+ results.slack = res.ok;
198
+ } catch (_) {
199
+ results.slack = false;
200
+ }
201
+ }
202
+
203
+ // Format Discord digest
204
+ if (discordUrl) {
205
+ try {
206
+ const fields = digest.incidents.slice(0, 10).map((inc) => ({
207
+ name: `Session \`${inc.sessionId}\` [${inc.reason}]`,
208
+ value: `Branch: \`${inc.branch}\`\n\`agentctl resume ${inc.sessionId} --response "<answer>"\``,
209
+ inline: false,
210
+ }));
211
+
212
+ const discordBody = {
213
+ content: `📋 **Jules Escalation Digest** (${count} batched incident${count > 1 ? "s" : ""})`,
214
+ embeds: [
215
+ {
216
+ title: `Escalation Digest (${count} incidents)`,
217
+ color: 3447003,
218
+ fields,
219
+ footer: { text: `Aggregated by Type III Silence Governor · Total: ${count}` },
220
+ },
221
+ ],
222
+ };
223
+
224
+ const res = await fetch(discordUrl, {
225
+ method: "POST",
226
+ headers: { "Content-Type": "application/json" },
227
+ body: JSON.stringify(discordBody),
228
+ });
229
+ results.discord = res.ok;
230
+ } catch (_) {
231
+ results.discord = false;
232
+ }
233
+ }
234
+
235
+ if (results.slack || results.discord) {
236
+ if (!options.preserve) {
237
+ clearEscalationDigest(root);
238
+ }
239
+ }
240
+
241
+ return results;
242
+ }
243
+
244
+ /**
245
+ * Dispatches an asynchronous escalation incident payload to configured Slack and/or Discord webhooks,
246
+ * governed by the Type III Silence Governor and interruption budget.
247
+ * @param {object} incident - Incident details
248
+ * @param {string} incident.sessionId
249
+ * @param {string} [incident.taskId]
250
+ * @param {string} [incident.branch]
251
+ * @param {string} [incident.reason] - AWAITING_USER_FEEDBACK | OODA_REPAIR_EXHAUSTED | R3_GATE_VIOLATION
252
+ * @param {string} [incident.logs] - Last 20 lines of compiler/test output
253
+ * @param {boolean} [incident.critical] - Force immediate alert
254
+ * @param {object} [config]
255
+ * @returns {Promise<object>} Dispatch status
256
+ */
257
+ export async function dispatchEscalation(incident = {}, config = {}) {
258
+ const root = config.root || resolveRoot();
259
+ const notifications = config.notifications || {};
260
+ const mode = (notifications.mode || config.notificationMode || "immediate").toLowerCase();
261
+ const threshold = notifications.threshold || 5;
262
+ const budgetPerHour = notifications.budgetPerHour ?? 3;
263
+ const criticalReasons = notifications.criticalReasons || DEFAULT_CRITICAL_REASONS;
264
+
265
+ const reason = incident.reason || "AWAITING_USER_FEEDBACK";
266
+ const isCritical = incident.critical === true || criticalReasons.includes(reason) || config.forceImmediate === true;
267
+
268
+ // Non-critical governance
269
+ if (!isCritical) {
270
+ if (mode === "silent") {
271
+ bufferEscalationIncident(incident, root);
272
+ return {
273
+ dispatched: false,
274
+ buffered: true,
275
+ silent: true,
276
+ reason: "SILENT_MODE",
277
+ };
278
+ }
279
+
280
+ if (mode === "digest" || mode === "threshold") {
281
+ const bufferRes = bufferEscalationIncident(incident, root);
282
+ if (bufferRes.count >= threshold) {
283
+ return await flushEscalationDigest(config, { root, dryRun: config.dryRun });
284
+ }
285
+ return {
286
+ dispatched: false,
287
+ buffered: true,
288
+ digestCount: bufferRes.count,
289
+ reason: "BUFFERED_IN_DIGEST",
290
+ };
291
+ }
292
+
293
+ // mode === "immediate" with interruption budget
294
+ const recentCount = countRecentInterruptions(root);
295
+ if (recentCount >= budgetPerHour) {
296
+ const bufferRes = bufferEscalationIncident(incident, root);
297
+ return {
298
+ dispatched: false,
299
+ buffered: true,
300
+ digestCount: bufferRes.count,
301
+ reason: "INTERRUPTION_BUDGET_EXCEEDED",
302
+ };
303
+ }
304
+ }
305
+
306
+ // If immediate/critical dispatch:
307
+ if (!isCritical) {
308
+ recordInterruption(root);
309
+ }
310
+
311
+ const slackUrl = process.env.SLACK_WEBHOOK_URL || notifications.slackWebhookUrl || config.slackWebhookUrl || "";
312
+ const discordUrl = process.env.DISCORD_WEBHOOK_URL || notifications.discordWebhookUrl || config.discordWebhookUrl || "";
313
+
314
+ if (!slackUrl && !discordUrl && !config.dryRun) {
315
+ return { dispatched: false, slack: false, discord: false, reason: "No webhook URL configured" };
316
+ }
317
+
318
+ const sessionId = incident.sessionId || "unknown-session";
319
+ const taskId = incident.taskId || "agent-task";
320
+ const branch = incident.branch || "main";
321
+ const rawLogs = incident.logs || incident.error || "No error log attached.";
322
+
323
+ // Normalize last 20 lines of logs with secret scrubbing
324
+ const scrubbedLogs = redactSecrets(String(rawLogs));
325
+ const logLines = scrubbedLogs.split("\n").slice(-20).join("\n");
326
+ const resumeCmd = `agentctl resume ${sessionId} --response "<your-answer>"`;
327
+
328
+ const results = { dispatched: true, slack: false, discord: false };
329
+
330
+ if (config.dryRun) {
331
+ return {
332
+ ...results,
333
+ dryRun: true,
334
+ payload: {
335
+ sessionId,
336
+ taskId,
337
+ reason,
338
+ resumeCmd,
339
+ logLines,
340
+ },
341
+ };
342
+ }
343
+
344
+ // Dispatch to Slack
345
+ if (slackUrl) {
346
+ try {
347
+ const slackBody = {
348
+ text: `🚨 *Jules Agent Escalation* [${reason}]: Session ${sessionId}`,
349
+ blocks: [
350
+ {
351
+ type: "header",
352
+ text: { type: "plain_text", text: `🚨 Jules Escalation: ${reason}` },
353
+ },
354
+ {
355
+ type: "section",
356
+ fields: [
357
+ { type: "mrkdwn", text: `*Session ID:*\n\`${sessionId}\`` },
358
+ { type: "mrkdwn", text: `*Branch:*\n\`${branch}\`` },
359
+ ],
360
+ },
361
+ {
362
+ type: "section",
363
+ text: { type: "mrkdwn", text: `*Recent Log Output:*\n\`\`\`${logLines.slice(0, 1000)}\`\`\`` },
364
+ },
365
+ {
366
+ type: "context",
367
+ elements: [
368
+ { type: "mrkdwn", text: `👉 *To resume session run:* \`${resumeCmd}\`` },
369
+ ],
370
+ },
371
+ ],
372
+ };
373
+
374
+ const res = await fetch(slackUrl, {
375
+ method: "POST",
376
+ headers: { "Content-Type": "application/json" },
377
+ body: JSON.stringify(slackBody),
378
+ });
379
+ results.slack = res.ok;
380
+ } catch (_) {
381
+ results.slack = false;
382
+ }
383
+ }
384
+
385
+ // Dispatch to Discord
386
+ if (discordUrl) {
387
+ try {
388
+ const discordBody = {
389
+ content: `🚨 **Jules Agent Escalation** [${reason}] for session \`${sessionId}\``,
390
+ embeds: [
391
+ {
392
+ title: `Escalation Reason: ${reason}`,
393
+ color: 15158332, // Red
394
+ fields: [
395
+ { name: "Session ID", value: `\`${sessionId}\``, inline: true },
396
+ { name: "Branch", value: `\`${branch}\``, inline: true },
397
+ { name: "Resume Command", value: `\`${resumeCmd}\`` },
398
+ ],
399
+ description: `\`\`\`\n${logLines.slice(0, 1000)}\n\`\`\``,
400
+ },
401
+ ],
402
+ };
403
+
404
+ const res = await fetch(discordUrl, {
405
+ method: "POST",
406
+ headers: { "Content-Type": "application/json" },
407
+ body: JSON.stringify(discordBody),
408
+ });
409
+ results.discord = res.ok;
410
+ } catch (_) {
411
+ results.discord = false;
412
+ }
413
+ }
414
+
415
+ return results;
416
+ }
417
+
3
418
 
4
419
  /**
5
420
  * Verify GitHub webhook HMAC SHA-256 signature against request payload.
@@ -186,122 +601,3 @@ export function createWebhookServer({ _port = 8787, secret = process.env.JULES_W
186
601
  return server;
187
602
  }
188
603
 
189
- /**
190
- * Dispatches an asynchronous escalation incident payload to configured Slack and/or Discord webhooks.
191
- * @param {object} incident - Incident details
192
- * @param {string} incident.sessionId
193
- * @param {string} [incident.taskId]
194
- * @param {string} [incident.branch]
195
- * @param {string} [incident.reason] - AWAITING_USER_FEEDBACK | OODA_REPAIR_EXHAUSTED | R3_GATE_VIOLATION
196
- * @param {string} [incident.logs] - Last 20 lines of compiler/test output
197
- * @param {object} [config]
198
- * @returns {Promise<object>} Dispatch status { dispatched: boolean, slack: boolean, discord: boolean }
199
- */
200
- export async function dispatchEscalation(incident = {}, config = {}) {
201
- const slackUrl = process.env.SLACK_WEBHOOK_URL || config.slackWebhookUrl || "";
202
- const discordUrl = process.env.DISCORD_WEBHOOK_URL || config.discordWebhookUrl || "";
203
-
204
- if (!slackUrl && !discordUrl && !config.dryRun) {
205
- return { dispatched: false, slack: false, discord: false, reason: "No webhook URL configured" };
206
- }
207
-
208
- const sessionId = incident.sessionId || "unknown-session";
209
- const taskId = incident.taskId || "agent-task";
210
- const branch = incident.branch || "main";
211
- const reason = incident.reason || "AWAITING_USER_FEEDBACK";
212
- const rawLogs = incident.logs || incident.error || "No error log attached.";
213
-
214
- // Normalize last 20 lines of logs
215
- const logLines = String(rawLogs).split("\n").slice(-20).join("\n");
216
- const resumeCmd = `agentctl resume ${sessionId} --response "<your-answer>"`;
217
-
218
- const results = { dispatched: true, slack: false, discord: false };
219
-
220
- if (config.dryRun) {
221
- return {
222
- ...results,
223
- dryRun: true,
224
- payload: {
225
- sessionId,
226
- taskId,
227
- reason,
228
- resumeCmd,
229
- logLines,
230
- },
231
- };
232
- }
233
-
234
- // Dispatch to Slack
235
- if (slackUrl) {
236
- try {
237
- const slackBody = {
238
- text: `🚨 *Jules Agent Escalation* [${reason}]: Session ${sessionId}`,
239
- blocks: [
240
- {
241
- type: "header",
242
- text: { type: "plain_text", text: `🚨 Jules Escalation: ${reason}` },
243
- },
244
- {
245
- type: "section",
246
- fields: [
247
- { type: "mrkdwn", text: `*Session ID:*\n\`${sessionId}\`` },
248
- { type: "mrkdwn", text: `*Branch:*\n\`${branch}\`` },
249
- ],
250
- },
251
- {
252
- type: "section",
253
- text: { type: "mrkdwn", text: `*Recent Log Output:*\n\`\`\`${logLines.slice(0, 1000)}\`\`\`` },
254
- },
255
- {
256
- type: "context",
257
- elements: [
258
- { type: "mrkdwn", text: `👉 *To resume session run:* \`${resumeCmd}\`` },
259
- ],
260
- },
261
- ],
262
- };
263
-
264
- const res = await fetch(slackUrl, {
265
- method: "POST",
266
- headers: { "Content-Type": "application/json" },
267
- body: JSON.stringify(slackBody),
268
- });
269
- results.slack = res.ok;
270
- } catch (_) {
271
- results.slack = false;
272
- }
273
- }
274
-
275
- // Dispatch to Discord
276
- if (discordUrl) {
277
- try {
278
- const discordBody = {
279
- content: `🚨 **Jules Agent Escalation** [${reason}] for session \`${sessionId}\``,
280
- embeds: [
281
- {
282
- title: `Escalation Reason: ${reason}`,
283
- color: 15158332, // Red
284
- fields: [
285
- { name: "Session ID", value: `\`${sessionId}\``, inline: true },
286
- { name: "Branch", value: `\`${branch}\``, inline: true },
287
- { name: "Resume Command", value: `\`${resumeCmd}\`` },
288
- ],
289
- description: `\`\`\`\n${logLines.slice(0, 1000)}\n\`\`\``,
290
- },
291
- ],
292
- };
293
-
294
- const res = await fetch(discordUrl, {
295
- method: "POST",
296
- headers: { "Content-Type": "application/json" },
297
- body: JSON.stringify(discordBody),
298
- });
299
- results.discord = res.ok;
300
- } catch (_) {
301
- results.discord = false;
302
- }
303
- }
304
-
305
- return results;
306
- }
307
-
@@ -60,10 +60,15 @@ export function tierOptions() {
60
60
  return order.map((name) => {
61
61
  const p = TIER_PRESETS[name];
62
62
  const worker = p.concurrency === 1 ? "worker" : "workers";
63
+ // The ceiling is shown next to the default so the number the wizard writes
64
+ // reads as a starting point rather than as the plan's limit.
65
+ const slots = p.maxConcurrency > p.concurrency
66
+ ? `${p.concurrency} ${worker} (plan allows ${p.maxConcurrency})`
67
+ : `${p.concurrency} ${worker}`;
63
68
  return {
64
69
  label: TIER_LABELS[name] || name,
65
70
  value: name,
66
- description: `${p.concurrency} ${worker}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
71
+ description: `${slots}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
67
72
  };
68
73
  });
69
74
  }