jules-orchestrator-kit 0.34.0 → 0.35.1
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 +8 -4
- package/bin/agentctl.mjs +240 -0
- package/index.mjs +26 -2
- package/package.json +1 -1
- package/src/config.mjs +15 -0
- package/src/flaky-ledger.mjs +205 -2
- package/src/ops/command-registry.mjs +54 -0
- package/src/web-templates.mjs +40 -0
- package/src/webhook.mjs +415 -119
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
|
-
*
|
|
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 **
|
|
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
|
|
|
@@ -387,7 +389,7 @@ Native stdio server exposing task dispatch, gate verification, and risk auditing
|
|
|
387
389
|
| :--- | :--- | :--- | :--- |
|
|
388
390
|
| `init` | `agentctl init [--interactive] [--tier pro]` | Interactive onboarding wizard & stack oracle inspector generating `.agent/config.yml`. | `0` (Created) |
|
|
389
391
|
| `task create` | `agentctl task create [--title <t>] [--prompt <p>] [--template <id>] [--role <name>] [--tier fast\|complex] [--depends-on <id,...>]` | Interactively authors & scopes falsifiable task envelopes with secret scrubbing, preflight gate checks, specialist role resolution, DAG dependency wiring, and an optional Cost Router tier override. | `0` (Queued), `1` (Unfalsifiable / Secret leak) |
|
|
390
|
-
| `task template` | `agentctl task template [<id>] [--list] [--json]` | Lists and synthesizes specialized web task envelopes (`web-cwv`, `web-wcag`, `web-seo`, `web-playwright`, `web-flaky-heal`). | `0` (Synthesized/Listed) |
|
|
392
|
+
| `task template` | `agentctl task template [<id>] [--list] [--json]` | Lists and synthesizes specialized web task envelopes (`web-cwv`, `web-wcag`, `web-seo`, `web-playwright`, `web-flaky-heal`, `web-i18n`). | `0` (Synthesized/Listed) |
|
|
391
393
|
| `task optimize` | `agentctl task optimize "<prompt>" [--fix] [--web] [--json]` | Linter & optimizer injecting Google Labs 3-phase exploration budgets, critic steering, and web oracles. | `0` (Scored/Fixed) |
|
|
392
394
|
| `test-gen` | `agentctl test-gen --title <t> --spec <s> [--run]` | Scaffolds falsifiable unit tests, verifies **RED** failure state, and locks test in `scope.deny`. | `0` (Scaffolded/Red) |
|
|
393
395
|
| `rollback` | `agentctl rollback [sessionId \| --latest]` | Restores exact commit, uncommitted files, and cleans orphan task worktrees from pre-flight checkpoints. | `0` (Restored), `1` (Error) |
|
|
@@ -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
|
|
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 {
|
|
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
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,
|
package/src/flaky-ledger.mjs
CHANGED
|
@@ -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/web-templates.mjs
CHANGED
|
@@ -193,6 +193,46 @@ export const WEB_TEMPLATES = {
|
|
|
193
193
|
5. **Stability Verification**:
|
|
194
194
|
- Test suite must pass cleanly across ${reps} consecutive runs without a single failure.`;
|
|
195
195
|
}
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
"web-i18n": {
|
|
199
|
+
id: "web-i18n",
|
|
200
|
+
name: "Internationalization (i18n), Locale Routing & Hreflang Integrity",
|
|
201
|
+
description: "Verify multi-language routing, symmetric hreflang alternate links, html lang attributes, and translation fallback safety.",
|
|
202
|
+
defaultVerifyCmd: "npm test",
|
|
203
|
+
category: "Internationalization & SEO",
|
|
204
|
+
criticFocus: [
|
|
205
|
+
"Confirm that hreflang alternate tags are fully bidirectional and symmetric across all locale variants.",
|
|
206
|
+
"Check that the <html> tag dynamically renders the correct ISO 639-1 / BCP 47 lang attribute for the active route.",
|
|
207
|
+
"Verify that localized URL slugs and route parameters handle non-ASCII unicode characters cleanly without encoding errors.",
|
|
208
|
+
"Ensure missing translation keys resolve to configured fallback locales rather than displaying raw placeholder strings or throwing runtime exceptions."
|
|
209
|
+
],
|
|
210
|
+
defaultParams: {
|
|
211
|
+
targetLocales: "en, sv, de, fr",
|
|
212
|
+
defaultLocale: "en",
|
|
213
|
+
targetRoutes: "all public routes"
|
|
214
|
+
},
|
|
215
|
+
generatePrompt: (params = {}) => {
|
|
216
|
+
const locales = params.targetLocales || "en, sv, de, fr";
|
|
217
|
+
const defaultLoc = params.defaultLocale || "en";
|
|
218
|
+
const routes = params.targetRoutes || "all public routes";
|
|
219
|
+
const customGoal = params.goal ? `\n- **Target Focus**: ${params.goal}` : "";
|
|
220
|
+
|
|
221
|
+
return `Implement and verify comprehensive Internationalization (i18n) and locale routing integrity for ${routes}.${customGoal}
|
|
222
|
+
|
|
223
|
+
### i18n & Hreflang Acceptance Criteria:
|
|
224
|
+
1. **Symmetric Hreflang Alternate Links**:
|
|
225
|
+
- Every page across target locales (${locales}) must render bidirectional \`<link rel="alternate" hreflang="..." href="...">\` tags pointing to all counterparts, including self and \`hreflang="x-default"\` (pointing to ${defaultLoc}).
|
|
226
|
+
2. **HTML Root & Metadata Localization**:
|
|
227
|
+
- The root \`<html lang="...">\` attribute must accurately match the active page locale.
|
|
228
|
+
- Page \`<title>\`, \`<meta name="description">\`, and OpenGraph (\`og:locale\` / \`og:locale:alternate\`) tags must be fully localized.
|
|
229
|
+
3. **Locale Routing & Missing Translation Fallback**:
|
|
230
|
+
- Clean URL routing for all supported locales with zero redirect loops or 404 dead-ends.
|
|
231
|
+
- Missing translation keys must gracefully fall back to default locale (\`${defaultLoc}\`) without runtime panics or raw string delimiters.
|
|
232
|
+
4. **Encoding & Number/Date Formats**:
|
|
233
|
+
- UTF-8 clean encoding with zero unescaped unicode artefacts.
|
|
234
|
+
- Localized formatting for dates, currencies, and numbers using standard Intl APIs.`;
|
|
235
|
+
}
|
|
196
236
|
}
|
|
197
237
|
};
|
|
198
238
|
|
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
|
-
|