jules-orchestrator-kit 0.35.1 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -122,7 +122,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
122
122
 
123
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.
124
124
 
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.
125
+ * **📈 Proven Scale & Reliability:** Empirically tested with **529 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.
126
126
 
127
127
  <br/>
128
128
 
package/bin/agentctl.mjs CHANGED
@@ -889,6 +889,12 @@ async function main() {
889
889
  console.log(` Session ID : ${sessionId}`);
890
890
  console.log(` Digest Count : ${res.digestCount || 1}`);
891
891
  console.log(` (Use 'agentctl escalate --flush' to deliver immediately)\n`);
892
+ } else if (res.dryRun) {
893
+ // Nothing left the machine, so do not claim it did.
894
+ console.log(`\n🧪 Dry run — this incident would be sent immediately:`);
895
+ console.log(` Session ID : ${sessionId}`);
896
+ console.log(` Reason : ${values.reason}`);
897
+ console.log(` (No request sent, and your hourly interruption budget is untouched.)\n`);
892
898
  } else if (res.dispatched) {
893
899
  console.log(`\n🚨 Incident Escalation Dispatched!`);
894
900
  console.log(` Session ID : ${sessionId}`);
package/index.mjs CHANGED
@@ -114,7 +114,11 @@ export {
114
114
  getEscalationDigestStatus,
115
115
  clearEscalationDigest,
116
116
  bufferEscalationIncident,
117
+ loadEscalationDigest,
118
+ recordInterruption,
119
+ countRecentInterruptions,
117
120
  DEFAULT_CRITICAL_REASONS,
121
+ DIGEST_BATCH_LIMIT,
118
122
  } from "./src/webhook.mjs";
119
123
 
120
124
  // PR Review Evidence Bundler & Dev Server Probe
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.35.1",
3
+ "version": "0.35.2",
4
4
  "description": "Orchestration kit for running Google Jules autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/config.mjs CHANGED
@@ -349,6 +349,18 @@ export const VENDOR_TIERS = ["free", "pro", "ultra"];
349
349
  /** The tier used when a config names one that does not exist. */
350
350
  export const FALLBACK_TIER = "ultra";
351
351
 
352
+ /**
353
+ * Escalation reasons that bypass the Silence Governor and alert immediately.
354
+ *
355
+ * Kept here rather than in webhook.mjs because `loadConfig` needs it as the
356
+ * default for `notifications.critical_reasons`, and webhook.mjs already imports
357
+ * from this module — the reverse direction would be a cycle. Two hand-copied
358
+ * lists were how v0.35.0 ended up with a governor that governed nothing.
359
+ *
360
+ * See webhook.mjs for why the list is this short.
361
+ */
362
+ export const DEFAULT_CRITICAL_REASONS = ["R3_GATE_VIOLATION", "SECRET_LEAK_DETECTED", "CRITICAL_FAILURE"];
363
+
352
364
  /**
353
365
  * Loads and validates configuration from .agent/config.yml or .agent/jules.yml.
354
366
  */
@@ -440,7 +452,7 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
440
452
  : 3,
441
453
  criticalReasons: Array.isArray(parsed.notifications?.critical_reasons)
442
454
  ? parsed.notifications.critical_reasons
443
- : ["R3_GATE_VIOLATION", "AWAITING_USER_FEEDBACK", "OODA_REPAIR_EXHAUSTED", "SECRET_LEAK_DETECTED", "CRITICAL_FAILURE"],
455
+ : [...DEFAULT_CRITICAL_REASONS],
444
456
  slackWebhookUrl: parsed.notifications?.slack_webhook_url || parsed.notifications?.slackWebhookUrl || "",
445
457
  discordWebhookUrl: parsed.notifications?.discord_webhook_url || parsed.notifications?.discordWebhookUrl || "",
446
458
  },
package/src/webhook.mjs CHANGED
@@ -3,16 +3,41 @@ import { createServer } from "node:http";
3
3
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { getStateDir } from "./state.mjs";
6
- import { resolveRoot } from "./config.mjs";
6
+ import { resolveRoot, DEFAULT_CRITICAL_REASONS } from "./config.mjs";
7
7
  import { redactSecrets } from "./security.mjs";
8
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
- ];
9
+ /**
10
+ * Reasons that bypass the governor and page the operator the moment they occur.
11
+ * Defined in config.mjs (loadConfig needs the same list); re-exported here
12
+ * because this is the module that acts on it.
13
+ *
14
+ * The list is deliberately short. Anything named here is exempt from batching,
15
+ * so a reason belongs on it only when a delayed alert would let damage widen:
16
+ * a leaked credential keeps being valid, a violated gate keeps merging. Those
17
+ * are safety events, and the cost of waking someone beats the cost of waiting.
18
+ *
19
+ * `AWAITING_USER_FEEDBACK` is deliberately NOT here, even though it is the most
20
+ * urgent-*feeling* reason. It is the one a blocked agent raises, so on a swarm
21
+ * of fifteen workers it is also the most frequent by a wide margin — and it is
22
+ * the exact case the governor exists to batch. Listing it (as v0.35.0 did) made
23
+ * every default-configured escalation critical and left the governor governing
24
+ * nothing. Same for `OODA_REPAIR_EXHAUSTED`: the task has already stopped, so
25
+ * nothing worsens while it sits in a digest.
26
+ *
27
+ * An operator who wants the old behaviour sets `notifications.critical_reasons`
28
+ * in `.agent/config.yml` — this is a default, not a policy.
29
+ */
30
+ export { DEFAULT_CRITICAL_REASONS };
31
+
32
+ /**
33
+ * How many incidents one flush may carry.
34
+ *
35
+ * Slack truncates the summary block and Discord accepts a bounded field list,
36
+ * so a flush of fifty would render ten and drop forty. The digest promises the
37
+ * opposite — that a buffered incident is never lost — so a flush sends at most
38
+ * this many and leaves the remainder buffered for the next one.
39
+ */
40
+ export const DIGEST_BATCH_LIMIT = 10;
16
41
 
17
42
  export function getDigestFilePath(root = resolveRoot()) {
18
43
  return join(getStateDir(root), "escalation-digest.json");
@@ -144,16 +169,21 @@ export async function flushEscalationDigest(config = {}, options = {}) {
144
169
  const discordUrl = process.env.DISCORD_WEBHOOK_URL || notifications.discordWebhookUrl || config.discordWebhookUrl || "";
145
170
  const dryRun = options.dryRun || config.dryRun || false;
146
171
 
147
- const count = digest.incidents.length;
148
- const results = { flushed: true, count, slack: false, discord: false, dryRun };
172
+ // One flush carries a bounded batch; whatever does not fit stays buffered.
173
+ const batch = digest.incidents.slice(0, DIGEST_BATCH_LIMIT);
174
+ const remainder = digest.incidents.slice(DIGEST_BATCH_LIMIT);
175
+ const count = batch.length;
176
+ const results = { flushed: true, count, pending: remainder.length, slack: false, discord: false, dryRun };
149
177
 
150
178
  if (dryRun) {
151
- if (!options.preserve) clearEscalationDigest(root);
179
+ // A dry run shows what *would* be sent. It must leave the buffer exactly as
180
+ // it found it — clearing here (as v0.35.0 did) discarded real incidents in
181
+ // exchange for a preview.
152
182
  return {
153
183
  ...results,
154
184
  payload: {
155
185
  count,
156
- incidents: digest.incidents,
186
+ incidents: batch,
157
187
  },
158
188
  };
159
189
  }
@@ -165,7 +195,7 @@ export async function flushEscalationDigest(config = {}, options = {}) {
165
195
  // Format Slack digest
166
196
  if (slackUrl) {
167
197
  try {
168
- const summaryText = digest.incidents
198
+ const summaryText = batch
169
199
  .map((inc) => `• *\`${inc.sessionId}\`* on \`${inc.branch}\` [${inc.reason}]: \`agentctl resume ${inc.sessionId}\``)
170
200
  .join("\n");
171
201
 
@@ -183,7 +213,12 @@ export async function flushEscalationDigest(config = {}, options = {}) {
183
213
  {
184
214
  type: "context",
185
215
  elements: [
186
- { type: "mrkdwn", text: `Aggregated by Type III Silence Governor · Oldest: ${digest.createdAt || "N/A"}` },
216
+ {
217
+ type: "mrkdwn",
218
+ text:
219
+ `Aggregated by Type III Silence Governor · Oldest: ${digest.createdAt || "N/A"}` +
220
+ (remainder.length ? ` · ${remainder.length} still buffered` : ""),
221
+ },
187
222
  ],
188
223
  },
189
224
  ],
@@ -203,7 +238,7 @@ export async function flushEscalationDigest(config = {}, options = {}) {
203
238
  // Format Discord digest
204
239
  if (discordUrl) {
205
240
  try {
206
- const fields = digest.incidents.slice(0, 10).map((inc) => ({
241
+ const fields = batch.map((inc) => ({
207
242
  name: `Session \`${inc.sessionId}\` [${inc.reason}]`,
208
243
  value: `Branch: \`${inc.branch}\`\n\`agentctl resume ${inc.sessionId} --response "<answer>"\``,
209
244
  inline: false,
@@ -216,7 +251,11 @@ export async function flushEscalationDigest(config = {}, options = {}) {
216
251
  title: `Escalation Digest (${count} incidents)`,
217
252
  color: 3447003,
218
253
  fields,
219
- footer: { text: `Aggregated by Type III Silence Governor · Total: ${count}` },
254
+ footer: {
255
+ text:
256
+ `Aggregated by Type III Silence Governor · Total: ${count}` +
257
+ (remainder.length ? ` · ${remainder.length} still buffered` : ""),
258
+ },
220
259
  },
221
260
  ],
222
261
  };
@@ -232,8 +271,13 @@ export async function flushEscalationDigest(config = {}, options = {}) {
232
271
  }
233
272
  }
234
273
 
235
- if (results.slack || results.discord) {
236
- if (!options.preserve) {
274
+ // Only what actually reached a webhook is dropped. A failed delivery leaves
275
+ // the whole buffer intact so the next flush retries it rather than silently
276
+ // eating the incidents.
277
+ if ((results.slack || results.discord) && !options.preserve) {
278
+ if (remainder.length) {
279
+ saveEscalationDigest(root, { incidents: remainder, createdAt: digest.createdAt });
280
+ } else {
237
281
  clearEscalationDigest(root);
238
282
  }
239
283
  }
@@ -303,11 +347,6 @@ export async function dispatchEscalation(incident = {}, config = {}) {
303
347
  }
304
348
  }
305
349
 
306
- // If immediate/critical dispatch:
307
- if (!isCritical) {
308
- recordInterruption(root);
309
- }
310
-
311
350
  const slackUrl = process.env.SLACK_WEBHOOK_URL || notifications.slackWebhookUrl || config.slackWebhookUrl || "";
312
351
  const discordUrl = process.env.DISCORD_WEBHOOK_URL || notifications.discordWebhookUrl || config.discordWebhookUrl || "";
313
352
 
@@ -341,6 +380,14 @@ export async function dispatchEscalation(incident = {}, config = {}) {
341
380
  };
342
381
  }
343
382
 
383
+ // The budget counts interruptions, not intentions. Recording it any earlier
384
+ // charged the operator's hourly allowance for a `--dry-run` preview or for a
385
+ // repo with no webhook configured — an alert nobody ever received. Same
386
+ // mistake the daily task budget made before cd26d6e; same fix.
387
+ if (!isCritical) {
388
+ recordInterruption(root);
389
+ }
390
+
344
391
  // Dispatch to Slack
345
392
  if (slackUrl) {
346
393
  try {