jules-orchestrator-kit 0.34.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/README.md CHANGED
@@ -98,7 +98,9 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
98
98
 
99
99
  * **⚡ AST Blast-Radius Selective Testing:** Traverses file import dependency graphs to execute only affected downstream test suites, cutting monorepo test latency from minutes to milliseconds.
100
100
 
101
- * **🚨 Asynchronous HITL Escalation Bridge (`agentctl escalate`):** Dispatches Slack & Discord webhook alerts when Jules needs feedback, allowing engineers to unblock agents asynchronously via `agentctl resume <id> --response "<reply>"`.
101
+ * **🔕 Type III Silence Governor & Interruption Budgeting (`v0.35.0`):** `agentctl escalate` manages Slack and Discord webhook alerts with configurable digest modes and hourly interruption budgets, buffering non-critical notifications while guaranteeing zero-latency delivery for critical escalations.
102
+
103
+ * **🩹 Automated Flaky Test Healing Swarm (`v0.35.0`):** `agentctl flaky heal` automatically consumes Wilson-quarantined tests (Exit Code 8) and dispatches specialized anti-flakiness repair tasks to eliminate race conditions, async timing leaks, and resource collisions without weakening test assertions.
102
104
 
103
105
  * **🔒 Zero Runtime Dependencies:** Built exclusively on Node.js 20+ built-ins (`node:fs`, `node:child_process`, `node:crypto`, `node:path`, `node:http`, `node:tty`, `node:test`). Zero third-party npm packages mean zero supply-chain CVE risk.
104
106
 
@@ -120,7 +122,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
120
122
 
121
123
  * **🚀 Zero-Test Bootstrapping (`agentctl bootstrap`):** Synthesizes deterministic syntax-check and smoke-test verification oracles for untested legacy repositories so agents always operate against a falsifiable feedback loop.
122
124
 
123
- * **📈 Proven Scale & Reliability:** Empirically tested with **510 unit tests across 77 suites passing in < 10.0s**. An adversarial red-team suite (`test/adversarial-claims.test.mjs`) continuously attempts to falsify the safety guarantees documented above — including cross-platform probes for the case-insensitive filesystems on macOS and Windows — and a documentation-sync gate (`scripts/doc-sync-check.mjs`) blocks any release whose docs have drifted from the code.
125
+ * **📈 Proven Scale & Reliability:** Empirically tested with **526 unit tests across 79 suites passing in < 10.0s**. An adversarial red-team suite (`test/adversarial-claims.test.mjs`) continuously attempts to falsify the safety guarantees documented above — including cross-platform probes for the case-insensitive filesystems on macOS and Windows — and a documentation-sync gate (`scripts/doc-sync-check.mjs`) blocks any release whose docs have drifted from the code.
124
126
 
125
127
  <br/>
126
128
 
@@ -404,9 +406,11 @@ Native stdio server exposing task dispatch, gate verification, and risk auditing
404
406
  | `lock` | `agentctl lock <acquire\|release\|status>`| Manages VFS mutex locks for multi-agent non-overlapping file ownership. | `0` (Locked/Released), `1` (Conflict) |
405
407
  | `clean` | `agentctl clean` | Prunes stale git worktrees, lockfiles, and temporary ledgers. | `0` (Clean) |
406
408
  | `evidence` | `agentctl evidence <generate\|verify\|show> [--manifest <path>] [--json]` | Generates, verifies, or prints a SHA-256 cryptographic evidence manifest (changed-file hashes + test-file tamper lock) for audit trails. | `0` (Verified/Generated), `1` (Tamper detected / Verification failed) |
409
+ | `escalate` | `agentctl escalate [<sessionId>] [--status] [--flush] [--clear]` | Dispatches or manages webhook escalation incidents across Slack and Discord with Type III Silence Governor and interruption budgeting. | `0` (Dispatched/Buffered) |
410
+ | `flaky` | `agentctl flaky <status\|heal\|reset> [--dispatch] [--dry-run] [--role <name>]` | Manages Wilson-quarantined tests (Exit Code 8) and dispatches automated anti-flakiness healing swarm without assertion weakening. | `0` (Healed/Queued/Listed) |
407
411
  | `mcp` | `agentctl mcp` | Starts stdio Model Context Protocol (MCP) server for tool integration. | `0` / Stdio stream |
408
412
  | `mcp init` | `agentctl mcp init [--target cursor\|vscode\|claude\|all]` | 1-click scaffolding for Cursor (`.cursor/mcp.json`), VS Code tasks (`tasks.json`), and Claude Desktop. | `0` (Scaffolded) |
409
- | `version` | `agentctl version` | Outputs orchestrator kit semantic version (`v0.32.5`). | `0` |
413
+ | `version` | `agentctl version` | Outputs orchestrator kit semantic version. | `0` |
410
414
 
411
415
  <br/>
412
416
 
package/bin/agentctl.mjs CHANGED
@@ -59,6 +59,8 @@ Commands:
59
59
  mcp init Scaffold IDE integration config (cursor | vscode | claude | all)
60
60
  rollback Restore git state & working tree to atomic pre-flight checkpoint
61
61
  resume Resume warm session with human response (--response "<text>")
62
+ escalate Dispatch or manage webhook escalation incidents (--flush, --status, --clear)
63
+ flaky Manage Wilson-quarantined tests and dispatch healing swarm (status | heal | reset)
62
64
  status Display queue and system status summary
63
65
  budget Show the 24h task budget, worker slots and their provenance (reset --yes)
64
66
  scan Scan codebase for TODO/FIXME task candidates
@@ -779,6 +781,244 @@ async function main() {
779
781
  break;
780
782
  }
781
783
 
784
+ case "escalate": {
785
+ const {
786
+ dispatchEscalation,
787
+ flushEscalationDigest,
788
+ getEscalationDigestStatus,
789
+ clearEscalationDigest,
790
+ } = await import("../src/webhook.mjs");
791
+
792
+ const { values, positionals } = parseArgs({
793
+ args: args.slice(1),
794
+ options: {
795
+ reason: { type: "string", short: "r", default: "AWAITING_USER_FEEDBACK" },
796
+ branch: { type: "string", short: "b", default: config.baseBranch || "main" },
797
+ logs: { type: "string", short: "l" },
798
+ "log-file": { type: "string" },
799
+ critical: { type: "boolean" },
800
+ flush: { type: "boolean" },
801
+ status: { type: "boolean" },
802
+ clear: { type: "boolean" },
803
+ "dry-run": { type: "boolean", short: "d" },
804
+ json: { type: "boolean", short: "j" },
805
+ },
806
+ allowPositionals: true,
807
+ });
808
+
809
+ if (values.status) {
810
+ const st = getEscalationDigestStatus(root, config);
811
+ if (values.json) {
812
+ console.log(JSON.stringify({ ok: true, status: st }, null, 2));
813
+ } else {
814
+ console.log(`\n🔇 Type III Silence Governor Status (v${VERSION})`);
815
+ console.log(`--------------------------------------------------`);
816
+ console.log(` Notification Mode : ${st.mode.toUpperCase()}`);
817
+ console.log(` Pending Digest Count : ${st.pendingCount} / ${st.threshold}`);
818
+ console.log(` Interruption Budget : ${st.recentInterruptions} / ${st.budgetPerHour} per hour (Available: ${st.budgetAvailable})`);
819
+ if (st.createdAt) {
820
+ console.log(` Oldest Buffered Item : ${st.createdAt}`);
821
+ }
822
+ if (st.incidents.length > 0) {
823
+ console.log(`\n Buffered Incidents (${st.incidents.length}):`);
824
+ st.incidents.forEach((inc) => {
825
+ console.log(` - [${inc.reason}] Session ${inc.sessionId} (${inc.branch})`);
826
+ });
827
+ }
828
+ console.log(`--------------------------------------------------\n`);
829
+ }
830
+ process.exit(0);
831
+ }
832
+
833
+ if (values.clear) {
834
+ const res = clearEscalationDigest(root);
835
+ if (values.json) {
836
+ console.log(JSON.stringify(res, null, 2));
837
+ } else {
838
+ console.log(`✅ Cleared pending escalation digest buffer.`);
839
+ }
840
+ process.exit(0);
841
+ }
842
+
843
+ if (values.flush) {
844
+ const res = await flushEscalationDigest(config, { root, dryRun: values["dry-run"] });
845
+ if (values.json) {
846
+ console.log(JSON.stringify(res, null, 2));
847
+ } else {
848
+ if (res.flushed) {
849
+ console.log(`\n📢 Flushed Escalation Digest (${res.count} incidents)`);
850
+ console.log(` Slack : ${res.slack ? "✅ Delivered" : "❌ Skipped/Failed"}`);
851
+ console.log(` Discord : ${res.discord ? "✅ Delivered" : "❌ Skipped/Failed"}\n`);
852
+ } else {
853
+ console.log(`ℹ️ Nothing to flush: ${res.reason}`);
854
+ }
855
+ }
856
+ process.exit(0);
857
+ }
858
+
859
+ const sessionId = positionals[0] || values.session;
860
+ if (!sessionId) {
861
+ console.error("Error: Session ID is required for agentctl escalate <sessionId> (or use --flush / --status).");
862
+ process.exit(1);
863
+ }
864
+
865
+ let logContent = values.logs || "";
866
+ if (values["log-file"] && existsSync(values["log-file"])) {
867
+ logContent = readFileSync(values["log-file"], "utf-8");
868
+ }
869
+
870
+ const incident = {
871
+ sessionId,
872
+ branch: values.branch,
873
+ reason: values.reason,
874
+ logs: logContent,
875
+ critical: values.critical,
876
+ };
877
+
878
+ const res = await dispatchEscalation(incident, {
879
+ root,
880
+ config,
881
+ dryRun: values["dry-run"],
882
+ });
883
+
884
+ if (values.json) {
885
+ console.log(JSON.stringify({ ok: true, result: res }, null, 2));
886
+ } else {
887
+ if (res.buffered) {
888
+ console.log(`\n🔇 Incident buffered by Silence Governor (${res.reason})`);
889
+ console.log(` Session ID : ${sessionId}`);
890
+ console.log(` Digest Count : ${res.digestCount || 1}`);
891
+ console.log(` (Use 'agentctl escalate --flush' to deliver immediately)\n`);
892
+ } else if (res.dispatched) {
893
+ console.log(`\n🚨 Incident Escalation Dispatched!`);
894
+ console.log(` Session ID : ${sessionId}`);
895
+ console.log(` Reason : ${values.reason}`);
896
+ console.log(` Slack : ${res.slack ? "✅ Sent" : "N/A"}`);
897
+ console.log(` Discord : ${res.discord ? "✅ Sent" : "N/A"}\n`);
898
+ } else {
899
+ console.log(`⚠️ Escalation not dispatched: ${res.reason}`);
900
+ }
901
+ }
902
+ process.exit(0);
903
+ break;
904
+ }
905
+
906
+ case "flaky": {
907
+ const {
908
+ listQuarantinedTests,
909
+ clearFlakyLedger,
910
+ runFlakyHealingSwarm,
911
+ synthesizeFlakyHealingTask,
912
+ } = await import("../src/flaky-ledger.mjs");
913
+
914
+ const subAction = args[1] || "status";
915
+ const { values, positionals } = parseArgs({
916
+ args: args.slice(2),
917
+ options: {
918
+ dispatch: { type: "boolean" },
919
+ role: { type: "string", short: "r", default: "janitor" },
920
+ "test-cmd": { type: "string", short: "t" },
921
+ "dry-run": { type: "boolean", short: "d" },
922
+ json: { type: "boolean", short: "j" },
923
+ },
924
+ allowPositionals: true,
925
+ });
926
+
927
+ if (subAction === "status" || subAction === "list") {
928
+ const quarantined = listQuarantinedTests(root);
929
+ if (values.json) {
930
+ console.log(JSON.stringify({ ok: true, quarantined, count: quarantined.length }, null, 2));
931
+ } else {
932
+ console.log(`\n🧪 Statistical Flaky Test Quarantine (v${VERSION})`);
933
+ console.log(`--------------------------------------------------`);
934
+ if (quarantined.length === 0) {
935
+ console.log(` ✅ Zero quarantined tests. All suites stable.`);
936
+ } else {
937
+ console.log(` Found ${quarantined.length} test suite(s) quarantined (Exit Code 8):\n`);
938
+ quarantined.forEach((q, idx) => {
939
+ const oscPct = Math.round(q.oscillation * 100);
940
+ console.log(` ${idx + 1}. \`${q.testCmd}\``);
941
+ console.log(` Oscillation: ${oscPct}% (${q.fails} fails / ${q.passes} passes in last ${q.n} runs)`);
942
+ console.log(` Wilson CI : [${q.wilson.lower.toFixed(2)}, ${q.wilson.upper.toFixed(2)}]`);
943
+ console.log(` Last Run : ${q.lastRunTimestamp}\n`);
944
+ });
945
+ console.log(` 👉 To dispatch auto-healing swarm, run: agentctl flaky heal`);
946
+ }
947
+ console.log(`--------------------------------------------------\n`);
948
+ }
949
+ process.exit(0);
950
+ }
951
+
952
+ if (subAction === "heal") {
953
+ const targetCmd = positionals[0] || values["test-cmd"];
954
+ let res;
955
+ if (targetCmd) {
956
+ const taskPlan = synthesizeFlakyHealingTask({ testCmd: targetCmd }, { role: values.role });
957
+ if (values.dispatch && !values["dry-run"]) {
958
+ const { dispatch } = await import("../src/engine.mjs");
959
+ try {
960
+ const session = await dispatch(
961
+ { title: taskPlan.title, prompt: taskPlan.prompt, role: taskPlan.role },
962
+ { root, config }
963
+ );
964
+ taskPlan.session = session;
965
+ taskPlan.dispatched = true;
966
+ } catch (err) {
967
+ taskPlan.dispatchError = err.message;
968
+ taskPlan.dispatched = false;
969
+ }
970
+ } else if (!values["dry-run"]) {
971
+ const { writeFileSync } = await import("node:fs");
972
+ const queueDir = getQueueDir(root);
973
+ const filePath = join(queueDir, `${taskPlan.taskId}.md`);
974
+ writeFileSync(filePath, taskPlan.fullEnvelope, "utf-8");
975
+ taskPlan.taskFile = filePath;
976
+ taskPlan.queued = true;
977
+ }
978
+ res = { count: 1, tasks: [taskPlan], dryRun: values["dry-run"] };
979
+ } else {
980
+ res = await runFlakyHealingSwarm(root, {
981
+ dispatch: values.dispatch,
982
+ dryRun: values["dry-run"],
983
+ role: values.role,
984
+ });
985
+ }
986
+
987
+ if (values.json) {
988
+ console.log(JSON.stringify({ ok: true, ...res }, null, 2));
989
+ } else {
990
+ if (res.count === 0) {
991
+ console.log(`ℹ️ ${res.message || "No quarantined tests to heal."}`);
992
+ } else {
993
+ console.log(`\n🩹 Flaky Test Healing Swarm (${res.count} task${res.count > 1 ? "s" : ""})`);
994
+ console.log(`--------------------------------------------------`);
995
+ res.tasks.forEach((t) => {
996
+ console.log(` • ${t.title}`);
997
+ if (t.taskFile) console.log(` Queued: ${t.taskFile}`);
998
+ if (t.session) console.log(` Dispatched Session: ${t.session.id}`);
999
+ });
1000
+ console.log(`--------------------------------------------------\n`);
1001
+ }
1002
+ }
1003
+ process.exit(0);
1004
+ }
1005
+
1006
+ if (subAction === "reset" || subAction === "clear") {
1007
+ const targetCmd = positionals[0] || values["test-cmd"] || null;
1008
+ const res = clearFlakyLedger(root, targetCmd);
1009
+ if (values.json) {
1010
+ console.log(JSON.stringify(res, null, 2));
1011
+ } else {
1012
+ console.log(`✅ Flaky test ledger reset (${targetCmd ? `command: ${targetCmd}` : "all tests"}).`);
1013
+ }
1014
+ process.exit(0);
1015
+ }
1016
+
1017
+ console.error(`Unknown flaky subaction: '${subAction}'. Supported: agentctl flaky status | heal | reset`);
1018
+ process.exit(1);
1019
+ break;
1020
+ }
1021
+
782
1022
  case "test-gen": {
783
1023
  const { scaffoldTddTest, runTddCycle } = await import("../src/ops/tdd-generator.mjs");
784
1024
  const { values } = parseArgs({
package/index.mjs CHANGED
@@ -103,8 +103,19 @@ export { scorePromptFalsifiability, optimizeTaskPrompt, levenshteinDistance, ext
103
103
  // Atomic Git Checkpoints & Rollback
104
104
  export { createCheckpoint, restoreCheckpoint, listCheckpoints, pruneCheckpoints, CheckpointError } from "./src/ops/checkpoint.mjs";
105
105
 
106
- // Webhook & HITL Escalation Bridge
107
- export { dispatchEscalation, verifySignature, parseWebhookPayload, routeWebhookEvent, createWebhookServer } from "./src/webhook.mjs";
106
+ // Webhook, Silence Governor & HITL Escalation Bridge
107
+ export {
108
+ dispatchEscalation,
109
+ verifySignature,
110
+ parseWebhookPayload,
111
+ routeWebhookEvent,
112
+ createWebhookServer,
113
+ flushEscalationDigest,
114
+ getEscalationDigestStatus,
115
+ clearEscalationDigest,
116
+ bufferEscalationIncident,
117
+ DEFAULT_CRITICAL_REASONS,
118
+ } from "./src/webhook.mjs";
108
119
 
109
120
  // PR Review Evidence Bundler & Dev Server Probe
110
121
  export { synthesizePrDescription, probeDevServer } from "./src/engine.mjs";
@@ -149,3 +160,16 @@ export {
149
160
  export { KIT_VERSION } from "./src/version.mjs";
150
161
  export { VENDOR_TIERS, FALLBACK_TIER } from "./src/config.mjs";
151
162
  export { tierOptions } from "./src/wizard-init.mjs";
163
+
164
+ // Statistical Flaky Ledger & Automated Healing Swarm
165
+ export {
166
+ wilsonScoreInterval,
167
+ computeOscillation,
168
+ recordVerifyRun,
169
+ readVerifyRuns,
170
+ flakyVerdict,
171
+ listQuarantinedTests,
172
+ clearFlakyLedger,
173
+ synthesizeFlakyHealingTask,
174
+ runFlakyHealingSwarm,
175
+ } from "./src/flaky-ledger.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Orchestration kit for running Google Jules autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.mjs CHANGED
@@ -429,6 +429,21 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
429
429
  complex: parsed.router?.complex || "",
430
430
  threshold: Number.isFinite(Number(parsed.router?.threshold)) ? Number(parsed.router.threshold) : 0,
431
431
  },
432
+ notifications: {
433
+ mode: parsed.notifications?.mode || "immediate",
434
+ threshold: Number.isFinite(Number(parsed.notifications?.threshold)) ? Number(parsed.notifications.threshold) : 5,
435
+ windowMs: Number.isFinite(Number(parsed.notifications?.window_ms ?? parsed.notifications?.windowMs))
436
+ ? Number(parsed.notifications.window_ms ?? parsed.notifications.windowMs)
437
+ : 300000,
438
+ budgetPerHour: Number.isFinite(Number(parsed.notifications?.budget_per_hour ?? parsed.notifications?.budgetPerHour))
439
+ ? Number(parsed.notifications.budget_per_hour ?? parsed.notifications.budgetPerHour)
440
+ : 3,
441
+ criticalReasons: Array.isArray(parsed.notifications?.critical_reasons)
442
+ ? parsed.notifications.critical_reasons
443
+ : ["R3_GATE_VIOLATION", "AWAITING_USER_FEEDBACK", "OODA_REPAIR_EXHAUSTED", "SECRET_LEAK_DETECTED", "CRITICAL_FAILURE"],
444
+ slackWebhookUrl: parsed.notifications?.slack_webhook_url || parsed.notifications?.slackWebhookUrl || "",
445
+ discordWebhookUrl: parsed.notifications?.discord_webhook_url || parsed.notifications?.discordWebhookUrl || "",
446
+ },
432
447
  scope: normalizeScope(parsed),
433
448
  limits: {
434
449
  ...DEFAULTS.limits,
@@ -1,6 +1,7 @@
1
- import { readFileSync, existsSync, appendFileSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { getStateDir, ensureDir } from "./state.mjs";
3
+ import { getStateDir, getQueueDir, ensureDir } from "./state.mjs";
4
+ import { resolveRoot } from "./config.mjs";
4
5
 
5
6
  /**
6
7
  * Calculates Wilson score interval for binomial proportion.
@@ -169,3 +170,205 @@ export function flakyVerdict(runs = []) {
169
170
  wilson,
170
171
  };
171
172
  }
173
+
174
+ /**
175
+ * Lists all test suites/commands currently classified as QUARANTINED in .agent/state/flaky.jsonl.
176
+ * @param {string} [root]
177
+ * @returns {Array<object>} Quarantined tests with oscillation & Wilson stats
178
+ */
179
+ export function listQuarantinedTests(root = resolveRoot()) {
180
+ const allRuns = readVerifyRuns(root);
181
+ if (allRuns.length === 0) return [];
182
+
183
+ // Group by testCmd
184
+ const grouped = new Map();
185
+ for (const run of allRuns) {
186
+ const cmd = run.testCmd || "default";
187
+ if (!grouped.has(cmd)) grouped.set(cmd, []);
188
+ grouped.get(cmd).push(run);
189
+ }
190
+
191
+ const quarantined = [];
192
+ for (const [testCmd, runs] of grouped.entries()) {
193
+ const verdict = flakyVerdict(runs);
194
+ if (verdict.verdict === "QUARANTINED") {
195
+ const lastRun = runs[runs.length - 1];
196
+ quarantined.push({
197
+ testCmd,
198
+ verdict: verdict.verdict,
199
+ oscillation: verdict.oscillation,
200
+ fails: verdict.fails,
201
+ passes: verdict.passes,
202
+ n: verdict.n,
203
+ wilson: verdict.wilson,
204
+ lastRunTimestamp: lastRun?.timestamp || new Date().toISOString(),
205
+ lastDurationMs: lastRun?.durationMs || 0,
206
+ fingerprint: lastRun?.fingerprint || null,
207
+ });
208
+ }
209
+ }
210
+
211
+ return quarantined;
212
+ }
213
+
214
+ /**
215
+ * Clears flaky ledger entries for all tests or a specific command.
216
+ * @param {string} [root]
217
+ * @param {string} [testCmd]
218
+ */
219
+ export function clearFlakyLedger(root = resolveRoot(), testCmd = null) {
220
+ const stateDir = getStateDir(root);
221
+ const filePath = join(stateDir, "flaky.jsonl");
222
+ if (!existsSync(filePath)) return { ok: true, cleared: 0 };
223
+
224
+ if (!testCmd) {
225
+ try {
226
+ unlinkSync(filePath);
227
+ } catch (_) {
228
+ writeFileSync(filePath, "", "utf-8");
229
+ }
230
+ return { ok: true, cleared: "all" };
231
+ }
232
+
233
+ const runs = readVerifyRuns(root);
234
+ const retained = runs.filter((r) => r.testCmd !== testCmd);
235
+ const clearedCount = runs.length - retained.length;
236
+ writeFileSync(filePath, retained.map((r) => JSON.stringify(r)).join("\n") + (retained.length ? "\n" : ""), "utf-8");
237
+ return { ok: true, cleared: clearedCount, remaining: retained.length };
238
+ }
239
+
240
+ /**
241
+ * Synthesizes a specialized anti-flakiness prompt envelope for a quarantined test.
242
+ * @param {object|string} quarantinedItem - Quarantined item object or test command string
243
+ * @param {object} [options]
244
+ * @returns {{ taskId: string, title: string, prompt: string, role: string, verifyCmd: string, fullEnvelope: string }}
245
+ */
246
+ export function synthesizeFlakyHealingTask(quarantinedItem, options = {}) {
247
+ const item = typeof quarantinedItem === "string" ? { testCmd: quarantinedItem, oscillation: 0.5, fails: 3, passes: 3, n: 6 } : quarantinedItem;
248
+ const testCmd = item.testCmd || "npm test";
249
+ const oscillationPct = Math.round((item.oscillation || 0.4) * 100);
250
+ const role = options.role || "janitor";
251
+ const slug = String(testCmd).replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").slice(0, 20).toLowerCase();
252
+ const taskId = options.taskId || `flaky-heal-${slug}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
253
+
254
+ // Build verification oracle that verifies deterministic stability over multiple consecutive runs
255
+ const verifyOracle = options.verifyCmd || `${testCmd} && ${testCmd} && ${testCmd}`;
256
+
257
+ const prompt = `# Task: Eliminate Flaky Test Timing Oscillations & Race Conditions
258
+
259
+ ## Context & Statistical Diagnosis
260
+ The test command \`${testCmd}\` has been quarantined by the Wilson-Score Statistical Flaky Guard (Exit Code 8).
261
+ - **Oscillation Rate**: ${oscillationPct}% state transitions between passes and failures
262
+ - **Total Sample Window**: ${item.n || 6} runs (${item.fails || 3} failures, ${item.passes || 3} passes)
263
+ - **Quarantine Verdict**: Statistical non-determinism detected (Wilson CI interior score).
264
+
265
+ ## Root Cause Remediations Required
266
+ 1. **Eliminate Arbitrary Sleep & Polling Race Conditions**:
267
+ - Replace arbitrary timers (\`sleep()\`, \`setTimeout()\`, \`delay()\`) with deterministic condition-based assertions or event-driven completion promises.
268
+ 2. **State & Resource Isolation**:
269
+ - Ensure tests clean up global variables, open socket descriptors, temporary filesystem directories, or database handles in \`afterEach\` / teardown hooks.
270
+ - Prevent port collisions by binding to ephemeral ports (port \`0\`) or namespaced mutexes.
271
+ 3. **Mock Unreliable Boundaries**:
272
+ - Intercept and mock non-deterministic external network calls, system clocks, and asynchronous background timers.
273
+ 4. **STRICT INVARIANT: NO TEST WEAKENING**:
274
+ - You are **strictly forbidden** from deleting failing assertions, commenting out checks, skipping test cases, or increasing broad timeouts indefinitely to force a pass.
275
+ - The test requirements and assertions must remain 100% rigorous; only the underlying non-determinism, race conditions, or unhandled timing flaws must be fixed.
276
+
277
+ ## Verification Gate
278
+ Before opening PR, the test must pass cleanly across consecutive executions without a single oscillation:
279
+ \`\`\`bash
280
+ ${verifyOracle}
281
+ \`\`\``;
282
+
283
+ const title = `Heal Flaky Test: ${testCmd.slice(0, 50)}`;
284
+
285
+ const envelopeMetadata = {
286
+ version: 1,
287
+ id: taskId,
288
+ title,
289
+ role,
290
+ verifyCmd: verifyOracle,
291
+ };
292
+
293
+ const fullEnvelope = `<!-- JULES_TASK_ENVELOPE: ${JSON.stringify(envelopeMetadata)} -->
294
+ # ${title}
295
+ # Task ID: ${taskId}
296
+
297
+ [TASK INSTRUCTIONS]
298
+ ${prompt}
299
+
300
+ [VERIFICATION ORACLE]
301
+ Test/Verification Command: ${verifyOracle}
302
+ `;
303
+
304
+ return {
305
+ taskId,
306
+ title,
307
+ prompt,
308
+ role,
309
+ verifyCmd: verifyOracle,
310
+ fullEnvelope,
311
+ item,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Runs the Flaky Healing Swarm: scans quarantined tests, synthesizes healing envelopes,
317
+ * and either enqueues them in .agent/jules-queue/ or dispatches them directly.
318
+ * @param {string} [root]
319
+ * @param {object} [options]
320
+ * @returns {Promise<object>}
321
+ */
322
+ export async function runFlakyHealingSwarm(root = resolveRoot(), options = {}) {
323
+ const quarantined = listQuarantinedTests(root);
324
+ if (quarantined.length === 0) {
325
+ return { count: 0, tasks: [], message: "No quarantined flaky tests detected in repository." };
326
+ }
327
+
328
+ const tasks = [];
329
+ const queueDir = getQueueDir(root);
330
+
331
+ for (const item of quarantined) {
332
+ const taskPlan = synthesizeFlakyHealingTask(item, options);
333
+ tasks.push(taskPlan);
334
+
335
+ if (options.dryRun) {
336
+ continue;
337
+ }
338
+
339
+ if (options.dispatch) {
340
+ const { dispatch } = await import("./engine.mjs");
341
+ const { loadConfig } = await import("./config.mjs");
342
+ const config = loadConfig(root);
343
+ try {
344
+ const session = await dispatch(
345
+ {
346
+ title: taskPlan.title,
347
+ prompt: taskPlan.prompt,
348
+ role: taskPlan.role,
349
+ },
350
+ { root, config }
351
+ );
352
+ taskPlan.session = session;
353
+ taskPlan.dispatched = true;
354
+ } catch (err) {
355
+ taskPlan.dispatchError = err.message;
356
+ taskPlan.dispatched = false;
357
+ }
358
+ } else {
359
+ const fileName = `${taskPlan.taskId}.md`;
360
+ const filePath = join(queueDir, fileName);
361
+ writeFileSync(filePath, taskPlan.fullEnvelope, "utf-8");
362
+ taskPlan.taskFile = filePath;
363
+ taskPlan.queued = true;
364
+ }
365
+ }
366
+
367
+ return {
368
+ count: tasks.length,
369
+ tasks,
370
+ queued: !options.dispatch && !options.dryRun,
371
+ dispatched: Boolean(options.dispatch && !options.dryRun),
372
+ dryRun: Boolean(options.dryRun),
373
+ };
374
+ }
@@ -222,6 +222,60 @@ export const COMMAND_REGISTRY = [
222
222
  { name: "json", type: "boolean", description: "Output status in JSON format" },
223
223
  ],
224
224
  },
225
+ {
226
+ id: "escalate",
227
+ path: ["escalate"],
228
+ title: "escalate",
229
+ description: "Dispatch or manage webhook escalation incidents with Silence Governor",
230
+ category: "Operate",
231
+ mutates: true,
232
+ risk: "low",
233
+ interactive: "never",
234
+ requiresRepository: true,
235
+ shortcuts: ["esc"],
236
+ examples: [
237
+ 'agentctl escalate sess-123 --reason "AWAITING_USER_FEEDBACK"',
238
+ "agentctl escalate --status",
239
+ "agentctl escalate --flush",
240
+ "agentctl escalate --clear",
241
+ ],
242
+ flags: [
243
+ { name: "reason", type: "string", description: "Escalation reason category" },
244
+ { name: "branch", type: "string", description: "Target git branch" },
245
+ { name: "logs", type: "string", description: "Error logs text" },
246
+ { name: "critical", type: "boolean", description: "Bypass Silence Governor and alert immediately" },
247
+ { name: "flush", type: "boolean", description: "Flush buffered escalation digest" },
248
+ { name: "status", type: "boolean", description: "Inspect digest status and interruption budget" },
249
+ { name: "clear", type: "boolean", description: "Clear pending digest buffer" },
250
+ { name: "dry-run", type: "boolean", description: "Simulate dispatch without sending HTTP requests" },
251
+ { name: "json", type: "boolean", description: "Output JSON structured response" },
252
+ ],
253
+ },
254
+ {
255
+ id: "flaky",
256
+ path: ["flaky"],
257
+ title: "flaky",
258
+ description: "Manage Wilson-quarantined tests and dispatch healing swarm",
259
+ category: "Repair",
260
+ mutates: true,
261
+ risk: "moderate",
262
+ interactive: "never",
263
+ requiresRepository: true,
264
+ shortcuts: ["flk"],
265
+ examples: [
266
+ "agentctl flaky status",
267
+ "agentctl flaky heal",
268
+ 'agentctl flaky heal "npm test"',
269
+ "agentctl flaky reset",
270
+ ],
271
+ flags: [
272
+ { name: "dispatch", type: "boolean", description: "Dispatch healing tasks directly to AI agents" },
273
+ { name: "role", type: "string", description: "Agent persona role (default: janitor)" },
274
+ { name: "test-cmd", type: "string", description: "Target specific test command" },
275
+ { name: "dry-run", type: "boolean", description: "Simulate healing swarm generation without writing" },
276
+ { name: "json", type: "boolean", description: "Output JSON structured response" },
277
+ ],
278
+ },
225
279
  ];
226
280
 
227
281
  /**
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
-