jules-orchestrator-kit 0.72.2 → 0.73.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.
Files changed (80) hide show
  1. package/.agent/prompts/{Overseer.md → Auditor.md} +3 -3
  2. package/.agent/prompts/{Alchemist.md → Database.md} +1 -1
  3. package/.agent/prompts/Debugger.md +25 -0
  4. package/.agent/prompts/{Scribe.md → Docs.md} +8 -5
  5. package/.agent/prompts/{Spectator.md → E2E.md} +9 -6
  6. package/.agent/prompts/{Janitor.md → Hygiene.md} +2 -2
  7. package/.agent/prompts/{Bolt.md → Performance.md} +1 -1
  8. package/.agent/prompts/Resilience.md +20 -0
  9. package/.agent/prompts/Security.md +21 -0
  10. package/.agent/prompts/Testing.md +30 -0
  11. package/.agent/prompts/Types.md +19 -0
  12. package/.agent/rules/jules-protocol.md +4 -3
  13. package/AGENTS.md +78 -96
  14. package/CHANGELOG.md +193 -0
  15. package/JULES_RULES_TEMPLATE.md +83 -96
  16. package/LICENSE +1 -1
  17. package/README.md +77 -439
  18. package/ROADMAP_V1.md +22 -132
  19. package/bin/agentctl.mjs +443 -144
  20. package/bin/init.js +6 -3
  21. package/index.mjs +9 -6
  22. package/package.json +1 -1
  23. package/scripts/asset-integrity-check.mjs +1 -1
  24. package/scripts/doc-sync-check.mjs +47 -0
  25. package/scripts/generate-command-reference.mjs +39 -0
  26. package/scripts/jules-dispatch.mjs +12 -113
  27. package/scripts/jules-merge-swarm.mjs +8 -196
  28. package/scripts/jules-patch.mjs +7 -8
  29. package/scripts/jules-queue-runner.mjs +6 -8
  30. package/scripts/jules-scan-todos.mjs +10 -38
  31. package/scripts/jules-self-audit.mjs +8 -139
  32. package/scripts/jules-status.mjs +32 -38
  33. package/scripts/jules-webhook-receiver.mjs +1 -1
  34. package/src/assertions.mjs +5 -50
  35. package/src/bidi-guard.mjs +36 -0
  36. package/src/budget.mjs +3 -14
  37. package/src/config.mjs +2 -6
  38. package/src/dashboard.mjs +7 -9
  39. package/src/dispatch.mjs +212 -0
  40. package/src/engine.mjs +40 -16
  41. package/src/evidence.mjs +10 -41
  42. package/src/execution-envelope.mjs +13 -1
  43. package/src/flaky-ledger.mjs +1 -1
  44. package/src/fs-atomic.mjs +72 -0
  45. package/src/git.mjs +298 -27
  46. package/src/mcp.mjs +296 -7
  47. package/src/memory.mjs +0 -0
  48. package/src/merge-swarm.mjs +202 -0
  49. package/src/ops/cli-intent.mjs +1 -0
  50. package/src/ops/command-registry.mjs +796 -70
  51. package/src/ops/doctor-registry.mjs +134 -47
  52. package/src/ops/handover.mjs +3 -27
  53. package/src/ops/pr-harvest.mjs +1 -1
  54. package/src/prompt-guard.mjs +33 -3
  55. package/src/provider.mjs +51 -7
  56. package/src/remediation.mjs +2 -2
  57. package/src/role-resolver.mjs +113 -3
  58. package/src/router.mjs +19 -11
  59. package/src/runtime-env.mjs +67 -0
  60. package/src/scaffold.mjs +3 -1
  61. package/src/scope-guard.mjs +249 -0
  62. package/src/secret-scanner.mjs +530 -0
  63. package/src/security.mjs +81 -2972
  64. package/src/self-audit.mjs +140 -0
  65. package/src/session-ops.mjs +29 -0
  66. package/src/stability.mjs +8 -1
  67. package/src/stack-detector.mjs +5 -2
  68. package/src/state.mjs +45 -0
  69. package/src/swarm.mjs +76 -0
  70. package/src/task-optimizer.mjs +34 -7
  71. package/src/telemetry.mjs +23 -0
  72. package/src/test-tamper-guard.mjs +2173 -0
  73. package/src/todo-scanner.mjs +129 -0
  74. package/src/web-templates.mjs +3 -3
  75. package/src/webhook.mjs +10 -3
  76. package/src/wizard-init.mjs +12 -20
  77. package/src/wizard-oracle.mjs +4 -3
  78. package/src/wizard-task.mjs +37 -9
  79. package/.agent/prompts/Sentinel.md +0 -18
  80. package/scripts/utils.mjs +0 -241
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { execFileSync, spawnSync } from "node:child_process";
5
- import { loadConfig } from "../config.mjs";
5
+ import { loadConfig, detectStack } from "../config.mjs";
6
6
  import { probeProvider, detectAvailableProviders, probeProviderLiveness } from "../provider-readiness.mjs";
7
7
  import { resolveConcurrency } from "../budget.mjs";
8
8
 
@@ -121,7 +121,11 @@ export async function runDoctorChecks(options = {}) {
121
121
  // runtime.git
122
122
  let gitPassed = false;
123
123
  try {
124
- const gitOut = execFileSync("git", ["--version"], { encoding: "utf-8", timeout: 5000 });
124
+ const gitOut = execFileSync("git", ["--version"], {
125
+ encoding: "utf-8",
126
+ timeout: 5000,
127
+ stdio: ["ignore", "pipe", "ignore"],
128
+ });
125
129
  gitPassed = true;
126
130
  addResult({
127
131
  id: "runtime.git",
@@ -167,18 +171,46 @@ export async function runDoctorChecks(options = {}) {
167
171
  let isRepo = false;
168
172
  let headSha = "";
169
173
  try {
170
- headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf-8" }).trim();
171
- isRepo = true;
172
- addResult({
173
- id: "repo.root",
174
- category: "Repository",
175
- title: "Repository Root Verification",
176
- status: "pass",
177
- severity: "info",
178
- summary: `Valid repository at ${root} (HEAD: ${headSha.slice(0, 8)})`,
179
- evidence: [{ label: "headSha", value: headSha, sensitive: false }],
180
- });
181
- } catch {
174
+ const insideWorkTree = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
175
+ cwd: root,
176
+ encoding: "utf-8",
177
+ stdio: ["ignore", "pipe", "ignore"],
178
+ }).trim();
179
+ if (insideWorkTree === "true") {
180
+ isRepo = true;
181
+ try {
182
+ headSha = execFileSync("git", ["rev-parse", "--verify", "--quiet", "HEAD"], {
183
+ cwd: root,
184
+ encoding: "utf-8",
185
+ stdio: ["ignore", "pipe", "ignore"],
186
+ }).trim();
187
+ } catch (_) {}
188
+
189
+ if (headSha) {
190
+ addResult({
191
+ id: "repo.root",
192
+ category: "Repository",
193
+ title: "Repository Root Verification",
194
+ status: "pass",
195
+ severity: "info",
196
+ summary: `Valid repository at ${root} (HEAD: ${headSha.slice(0, 8)})`,
197
+ evidence: [{ label: "headSha", value: headSha, sensitive: false }],
198
+ });
199
+ } else {
200
+ addResult({
201
+ id: "repo.root",
202
+ category: "Repository",
203
+ title: "Repository Root Verification",
204
+ status: "pass",
205
+ severity: "info",
206
+ summary: `Valid repository at ${root} (unborn HEAD, 0 commits)`,
207
+ evidence: [{ label: "headSha", value: "", sensitive: false }],
208
+ });
209
+ }
210
+ }
211
+ } catch (_) {}
212
+
213
+ if (!isRepo) {
182
214
  addResult({
183
215
  id: "repo.root",
184
216
  category: "Repository",
@@ -191,7 +223,11 @@ export async function runDoctorChecks(options = {}) {
191
223
 
192
224
  if (isRepo) {
193
225
  try {
194
- const statusOut = execFileSync("git", ["status", "--porcelain"], { cwd: root, encoding: "utf-8" }).trim();
226
+ const statusOut = execFileSync("git", ["status", "--porcelain"], {
227
+ cwd: root,
228
+ encoding: "utf-8",
229
+ stdio: ["ignore", "pipe", "ignore"],
230
+ }).trim();
195
231
  const modifiedCount = statusOut ? statusOut.split("\n").length : 0;
196
232
  if (modifiedCount === 0) {
197
233
  addResult({
@@ -300,49 +336,96 @@ export async function runDoctorChecks(options = {}) {
300
336
  }
301
337
 
302
338
  // 4. Verification Oracle Checks
339
+ // Judge the command the gate will actually run — `verify.test` from the
340
+ // config, falling back to the npm `test` script — rather than package.json
341
+ // alone. package.json-only logic told a fresh Python/Rust/Go or zero-test
342
+ // repo "missing test script" (a warning, exit 0) while config carried the
343
+ // real command, or nothing at all; and a green `doctor` right before an
344
+ // `agentctl gate`/`agentctl task create` that both hard-fail with "No
345
+ // Verification Oracle" sent newcomers into a contradiction. When verification
346
+ // is required and there is no oracle, that is a genuine health failure and is
347
+ // reported red so `doctor`'s exit code matches what the gate will do.
303
348
  const pkgPath = join(root, "package.json");
349
+ let pkgTestScript = "";
350
+ let pkgValid = false;
304
351
  if (existsSync(pkgPath)) {
305
352
  try {
306
353
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
307
- const testScript = pkg.scripts && pkg.scripts.test;
308
- if (testScript) {
309
- addResult({
310
- id: "oracle.test",
311
- category: "Verification",
312
- title: "Test Oracle Configuration",
313
- status: "pass",
314
- severity: "info",
315
- summary: `Test oracle script found: "npm test" -> "${testScript}"`,
316
- evidence: [{ label: "testScript", value: testScript, sensitive: false }],
317
- });
318
- } else {
319
- addResult({
320
- id: "oracle.test",
321
- category: "Verification",
322
- title: "Test Oracle Configuration",
323
- status: "warn",
324
- severity: "medium",
325
- summary: "package.json missing test script entry",
326
- });
327
- }
354
+ pkgValid = true;
355
+ pkgTestScript = (pkg.scripts && pkg.scripts.test) || "";
328
356
  } catch {
329
- addResult({
330
- id: "oracle.test",
331
- category: "Verification",
332
- title: "Test Oracle Configuration",
333
- status: "warn",
334
- severity: "medium",
335
- summary: "Malformed package.json file",
336
- });
357
+ pkgValid = false;
337
358
  }
338
- } else {
359
+ }
360
+ let cfg = null;
361
+ try {
362
+ cfg = loadConfig(root);
363
+ } catch (_) {
364
+ // A config the loader rejects is already reported by config.present; fall
365
+ // through with null and treat verification as required (the default).
366
+ }
367
+ const configTest = (cfg?.verify?.test || "").trim();
368
+ // verify.required defaults to true; an operator who sets it false is saying
369
+ // "scope/secret gating only, on purpose" — which is a warning, not a failure.
370
+ const verificationRequired = cfg?.verify?.required !== false;
371
+ // The gate auto-detects a per-stack command even before init writes config
372
+ // (e.g. `python3 -m pytest` for a .py repo), so detection counts as an oracle
373
+ // too — otherwise a detected but not-yet-onboarded stack would report red.
374
+ const detectedTest = detectStack(root)?.testCmd || "";
375
+ const effectiveTest = configTest || pkgTestScript || detectedTest;
376
+
377
+ if (effectiveTest) {
378
+ const source = configTest
379
+ ? "verify.test in config"
380
+ : pkgTestScript
381
+ ? "package.json test script"
382
+ : "auto-detected from repository stack";
339
383
  addResult({
340
384
  id: "oracle.test",
341
385
  category: "Verification",
342
386
  title: "Test Oracle Configuration",
343
387
  status: "pass",
344
388
  severity: "info",
345
- summary: "Non-Node workspace verified",
389
+ summary: `Verification oracle: "${effectiveTest}" (${source})`,
390
+ evidence: [{ label: "testCommand", value: effectiveTest, sensitive: false }],
391
+ });
392
+ } else if (existsSync(pkgPath) && !pkgValid) {
393
+ addResult({
394
+ id: "oracle.test",
395
+ category: "Verification",
396
+ title: "Test Oracle Configuration",
397
+ status: "warn",
398
+ severity: "medium",
399
+ summary: "Malformed package.json file",
400
+ });
401
+ } else if (!verificationRequired) {
402
+ addResult({
403
+ id: "oracle.test",
404
+ category: "Verification",
405
+ title: "Test Oracle Configuration",
406
+ status: "warn",
407
+ severity: "medium",
408
+ summary: "No verification oracle — verify.required: false (scope/secret gating only)",
409
+ });
410
+ } else {
411
+ addResult({
412
+ id: "oracle.test",
413
+ category: "Verification",
414
+ title: "Test Oracle Configuration",
415
+ status: "fail",
416
+ severity: "high",
417
+ summary:
418
+ "No verification command is configured, so the gate rejects every change with a No Verification Oracle finding",
419
+ fixes: [
420
+ {
421
+ id: "oracle.bootstrap",
422
+ title: "Generate a verification oracle",
423
+ summary: "Run `agentctl bootstrap` to create one for this stack, or set verify.test in .agent/config.yml",
424
+ risk: "low",
425
+ automatic: true,
426
+ requiresProbe: false,
427
+ },
428
+ ],
346
429
  });
347
430
  }
348
431
 
@@ -514,7 +597,11 @@ export async function runDoctorChecks(options = {}) {
514
597
  if (existsSync(envFile)) {
515
598
  let tracked = false;
516
599
  try {
517
- const res = spawnSync("git", ["ls-files", "--error-unmatch", ".env"], { cwd: root, encoding: "utf-8" });
600
+ const res = spawnSync("git", ["ls-files", "--error-unmatch", ".env"], {
601
+ cwd: root,
602
+ encoding: "utf-8",
603
+ stdio: ["ignore", "pipe", "ignore"],
604
+ });
518
605
  tracked = res.status === 0;
519
606
  } catch (_) {}
520
607
 
@@ -5,15 +5,9 @@ import {
5
5
  readdirSync,
6
6
  statSync,
7
7
  unlinkSync,
8
- renameSync,
9
- openSync,
10
- writeSync,
11
- fsyncSync,
12
- closeSync,
13
8
  } from "node:fs";
14
9
  import { join, resolve, relative, isAbsolute, basename } from "node:path";
15
- import { randomUUID } from "node:crypto";
16
- import { redactSecrets } from "../security.mjs";
10
+ import { redactSecrets, safeAtomicWrite } from "../security.mjs";
17
11
  import { resolveRoot } from "../config.mjs";
18
12
 
19
13
  export class HandoverError extends Error {
@@ -77,25 +71,6 @@ function sanitizeText(value) {
77
71
  return redactSecrets(String(value).trim());
78
72
  }
79
73
 
80
- function writeFileAtomically(filePath, content) {
81
- const tmpPath = `${filePath}.${randomUUID()}.tmp`;
82
- let fd;
83
- try {
84
- fd = openSync(tmpPath, "wx", 0o600);
85
- writeSync(fd, content, "utf-8");
86
- fsyncSync(fd);
87
- closeSync(fd);
88
- fd = undefined;
89
- renameSync(tmpPath, filePath);
90
- } catch (err) {
91
- if (fd !== undefined) {
92
- try { closeSync(fd); } catch (_) {}
93
- }
94
- try { unlinkSync(tmpPath); } catch (_) {}
95
- throw err;
96
- }
97
- }
98
-
99
74
  /**
100
75
  * Creates and persists a Baton Pass handover manifest in .agent/handovers/YYYY-MM-DD-[sessionId].md.
101
76
  *
@@ -192,7 +167,8 @@ export function createHandover(root = resolveRoot(), data = {}, options = {}) {
192
167
  bodySections.push("");
193
168
  const fullContent = frontmatterLines.join("\n") + bodySections.join("\n");
194
169
 
195
- writeFileAtomically(filePath, fullContent);
170
+ // Handover transcripts can quote session content; keep the private 0600 mode.
171
+ safeAtomicWrite(filePath, fullContent, { mode: 0o600 });
196
172
 
197
173
  const retention = typeof options.maxRetention === "number" ? options.maxRetention : 20;
198
174
  if (retention > 0) {
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { classifyRiskTier, RISK_TIERS } from "../risk.mjs";
3
- import { checkSafetyGate } from "../../scripts/jules-merge-swarm.mjs";
3
+ import { checkSafetyGate } from "../merge-swarm.mjs";
4
4
  import { normalizePath, loadConfig } from "../config.mjs";
5
5
 
6
6
  /**
@@ -6,7 +6,7 @@
6
6
  * neutralizes LLM control role markers and prompt injection patterns, and wraps inputs in strict tags.
7
7
  */
8
8
 
9
- const ZERO_WIDTH_AND_BIDI_REGEX = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g;
9
+ const ZERO_WIDTH_AND_BIDI_REGEX = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]|[\u{E0000}-\u{E007F}]/gu;
10
10
  const ANSI_ESCAPE_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
11
11
 
12
12
  const CONTROL_TAGS_REGEX = /<\|im_start\|>|<\|im_end\|>|<\|system\|>|<\|assistant\|>|<\|user\|>|<\|endoftext\|>|\[INST\]|\[\/INST\]|<<SYS>>|<\/SYS>>/gi;
@@ -27,29 +27,59 @@ const INJECTION_PATTERNS = [
27
27
  * to prevent false-positive safety classifier tripwires in Google VM environments.
28
28
  */
29
29
  export const STERILE_VOCABULARY_MAP = [
30
+ { pattern: /\bkill\s+-9\b/gi, replacement: "terminate with SIGTERM" },
31
+ { pattern: /\bSIGKILL\b/gi, replacement: "SIGTERM" },
30
32
  { pattern: /\bkill\s+(?:the\s+)?processes\b/gi, replacement: "terminate the processes" },
31
33
  { pattern: /\bkill\s+(?:the\s+)?process\b/gi, replacement: "terminate the process" },
32
34
  { pattern: /\bkill\s+(?:the\s+)?pid\b/gi, replacement: "terminate the PID" },
35
+ { pattern: /\breap\s+(?:the\s+)?(?:zombie\s+)?processes\b/gi, replacement: "collect child processes" },
36
+ { pattern: /\breap\s+(?:the\s+)?(?:zombie\s+)?process\b/gi, replacement: "collect child process" },
37
+ { pattern: /\breap\s+(?:the\s+)?zombies\b/gi, replacement: "collect child processes" },
33
38
  { pattern: /\bamputate\s+(?:dead\s+|unused\s+)?code\b/gi, replacement: "prune unused code" },
34
39
  { pattern: /\bamputate\b/gi, replacement: "prune" },
35
40
  { pattern: /\bsabotage\s+(?:the\s+)?tests?\b/gi, replacement: "mutate test logic" },
36
41
  { pattern: /\bsabotage\b/gi, replacement: "mutate" },
37
42
  { pattern: /\bdestroy\s+(?:the\s+)?(state|cache|file|data|directory)\b/gi, replacement: "purge the $1" },
38
43
  { pattern: /\bwipe\s+(?:the\s+)?(state|cache|directory|disk|table)\b/gi, replacement: "clear the $1" },
44
+ { pattern: /\bexploit_payload\b/gi, replacement: "test_payload" },
39
45
  ];
40
46
 
41
47
  /**
42
- * Sanitizes aggressive phrases into clinical equivalents.
48
+ * Sanitizes aggressive phrases into clinical equivalents while preserving
49
+ * code blocks (```...```) and inline backticks (`...`) verbatim.
43
50
  *
44
51
  * @param {string} text - Prompt text
45
52
  * @returns {string} Sanitized prompt with clinical vocabulary
46
53
  */
47
54
  export function sanitizePromptVocabulary(text) {
48
55
  if (!text || typeof text !== "string") return text || "";
49
- let sanitized = text;
56
+
57
+ const codeSpans = [];
58
+ const placeholderPrefix = "@@VERBATIM_CODE_SPAN_";
59
+
60
+ // Protect fenced code blocks
61
+ let protectedText = text.replace(/```[\s\S]*?```/g, (match) => {
62
+ const idx = codeSpans.length;
63
+ codeSpans.push(match);
64
+ return `${placeholderPrefix}${idx}@@`;
65
+ });
66
+
67
+ // Protect inline code spans
68
+ protectedText = protectedText.replace(/`[^`\n]+`/g, (match) => {
69
+ const idx = codeSpans.length;
70
+ codeSpans.push(match);
71
+ return `${placeholderPrefix}${idx}@@`;
72
+ });
73
+
74
+ let sanitized = protectedText;
50
75
  for (const { pattern, replacement } of STERILE_VOCABULARY_MAP) {
51
76
  sanitized = sanitized.replace(pattern, replacement);
52
77
  }
78
+
79
+ for (let i = 0; i < codeSpans.length; i++) {
80
+ sanitized = sanitized.replace(`${placeholderPrefix}${i}@@`, codeSpans[i]);
81
+ }
82
+
53
83
  return sanitized;
54
84
  }
55
85
 
package/src/provider.mjs CHANGED
@@ -185,19 +185,36 @@ const NAMED_PRESETS = {
185
185
  * Multi-token manager with round-robin rotation, 429 quarantine/cooldown, and failover.
186
186
  */
187
187
  export class TokenPool {
188
- constructor(tokens = []) {
188
+ constructor(tokens = [], options = {}) {
189
189
  this.tokens = Array.from(new Set(tokens.filter(Boolean)));
190
190
  this.currentIndex = 0;
191
191
  this.cooldowns = new Map(); // token -> cooldownExpiryTimestamp
192
192
  this.usage24h = new Map(); // token -> count
193
+ this.limits = new Map(Object.entries(options.limits || {}));
194
+ }
195
+
196
+ getLimit(token, index) {
197
+ if (this.limits.has(token)) return this.limits.get(token);
198
+ return index === 0 ? 100 : 15;
193
199
  }
194
200
 
195
201
  static fromEnv(config = {}) {
196
- const rawList = (process.env.JULES_API_KEYS || process.env.JULES_API_KEY_SECONDARY || "")
202
+ const rawList = (
203
+ process.env.JULES_API_KEYS ||
204
+ process.env.JULES_API_KEY_SECONDARY ||
205
+ process.env.JULES_SECONDARY_TOKENS ||
206
+ process.env.AGENT_SECONDARY_TOKENS ||
207
+ ""
208
+ )
197
209
  .split(",")
198
210
  .map((t) => t.trim())
199
211
  .filter(Boolean);
200
- const primary = (process.env.JULES_API_KEY || "").trim();
212
+ const primary = (
213
+ process.env.JULES_API_KEY ||
214
+ process.env.JULES_MAIN_TOKEN ||
215
+ process.env.AGENT_MAIN_TOKEN ||
216
+ ""
217
+ ).trim();
201
218
  const configKeys = Array.isArray(config.julesApiKeys) ? config.julesApiKeys : [];
202
219
  const combined = Array.from(new Set([primary, ...rawList, ...configKeys].filter(Boolean)));
203
220
  return new TokenPool(combined);
@@ -211,15 +228,38 @@ export class TokenPool {
211
228
  if (this.tokens.length === 0) return "";
212
229
  const now = Date.now();
213
230
 
214
- // Find first non-cooldown token starting from currentIndex
231
+ // Collect available (non-cooldown) candidates
232
+ const available = [];
215
233
  for (let i = 0; i < this.tokens.length; i++) {
216
234
  const idx = (this.currentIndex + i) % this.tokens.length;
217
235
  const token = this.tokens[idx];
218
236
  const cooldownUntil = this.cooldowns.get(token) || 0;
219
237
  if (now >= cooldownUntil) {
220
- this.currentIndex = (idx + 1) % this.tokens.length;
221
- return token;
238
+ const usage = this.usage24h.get(token) || 0;
239
+ const limit = this.getLimit(token, idx);
240
+ const utilization = limit > 0 ? usage / limit : 1;
241
+ available.push({ token, idx, usage, limit, utilization });
242
+ }
243
+ }
244
+
245
+ if (available.length > 0) {
246
+ // If any usage has been recorded, pick the one with lowest utilization
247
+ const hasUsage = available.some((a) => a.usage > 0);
248
+ if (hasUsage) {
249
+ let best = available[0];
250
+ for (const candidate of available) {
251
+ if (candidate.utilization < best.utilization) {
252
+ best = candidate;
253
+ }
254
+ }
255
+ this.currentIndex = (best.idx + 1) % this.tokens.length;
256
+ return best.token;
222
257
  }
258
+
259
+ // If no usage recorded yet, preserve exact round-robin behavior
260
+ const chosen = available[0];
261
+ this.currentIndex = (chosen.idx + 1) % this.tokens.length;
262
+ return chosen.token;
223
263
  }
224
264
 
225
265
  // If all are in cooldown, pick the one that expires earliest
@@ -253,6 +293,8 @@ export class TokenPool {
253
293
  const cooldownUntil = this.cooldowns.get(token) || 0;
254
294
  const inCooldown = now < cooldownUntil;
255
295
  const maskedToken = token.length <= 8 ? "****" : `${token.slice(0, 4)}...${token.slice(-4)}`;
296
+ const limit = this.getLimit(token, index);
297
+ const usage = this.usage24h.get(token) || 0;
256
298
  return {
257
299
  id: `key-${index + 1}`,
258
300
  index,
@@ -260,7 +302,9 @@ export class TokenPool {
260
302
  maskedToken,
261
303
  inCooldown,
262
304
  cooldownRemainingMs: inCooldown ? cooldownUntil - now : 0,
263
- usage: this.usage24h.get(token) || 0,
305
+ usage,
306
+ limit,
307
+ utilization: limit > 0 ? usage / limit : 1,
264
308
  };
265
309
  });
266
310
  }
@@ -285,7 +285,7 @@ export function createWhackAMoleDetector(opts = {}) {
285
285
  const oscillatingTests = [...cycleTests];
286
286
 
287
287
  const testSummary = oscillatingTests.length > 0 ? oscillatingTests.join(" <-> ") : "tests";
288
- const promptDirective = `[WHACK_A_MOLE_WARNING] You are trapped in a local optimization cycle where fixing one test breaks another (<UNTRUSTED>${testSummary}</UNTRUSTED>). Do not add more conditional edge-case band-aids. Revert recent patches and refactor the core logic cleanly.`;
288
+ const promptDirective = `[Test Oscillation Circuit Breaker Activated] Switching verification strategy: fixing one test repeatedly breaks another (<UNTRUSTED>${testSummary}</UNTRUSTED>). Do not add more conditional edge-case patches. Revert recent patches and refactor the core logic cleanly.`;
289
289
 
290
290
  return {
291
291
  whackAMole: true,
@@ -293,7 +293,7 @@ export function createWhackAMoleDetector(opts = {}) {
293
293
  oscillatingTests,
294
294
  occurrences,
295
295
  promptDirective,
296
- reason: `Whack-a-Mole Test Oscillation Detected: Test failure signature repeated across repair turns (${testSummary}).`,
296
+ reason: `Test Oscillation Circuit Breaker Activated: Test failure signature repeated across repair turns (${testSummary}).`,
297
297
  };
298
298
  }
299
299
 
@@ -19,6 +19,89 @@ import { loadConfig } from "./config.mjs";
19
19
  */
20
20
  export const ROLE_PROMPT_TOKENS = ["VERIFY_TEST", "VERIFY_LINT", "VERIFY_BUILD", "DIFF_KB", "BASE_BRANCH"];
21
21
 
22
+ /**
23
+ * Legacy and convenience aliases for the standard engineering roles.
24
+ *
25
+ * Strictly one-directional: every key is a non-canonical name and every
26
+ * value is a canonical role. Provides full backward-compatibility for prior
27
+ * command flags (--role bolt, etc.) without letting a canonical role ever
28
+ * resolve away from its own prompt file.
29
+ */
30
+ export const ROLE_ALIASES = Object.freeze({
31
+ // Legacy RPG/fantasy names mapped to canonical engineering roles
32
+ overseer: "auditor",
33
+ bolt: "performance",
34
+ sentinel: "security",
35
+ janitor: "hygiene",
36
+ spectator: "e2e",
37
+ scribe: "docs",
38
+ alchemist: "database",
39
+ bulwark: "resilience",
40
+ typist: "types",
41
+ hunter: "debugger",
42
+
43
+ // Common operator shortcuts
44
+ perf: "performance",
45
+ sec: "security",
46
+ db: "database",
47
+ cleanup: "hygiene",
48
+ accessibility: "a11y",
49
+ documentation: "docs",
50
+ visual: "e2e",
51
+ playwright: "e2e",
52
+ schema: "database",
53
+ migration: "database",
54
+ reliability: "resilience",
55
+ typecheck: "types",
56
+ "type-safety": "types",
57
+ debug: "debugger",
58
+ "defect-fix": "debugger",
59
+ lead: "auditor",
60
+ coordinator: "auditor",
61
+ test: "testing",
62
+ "unit-test": "testing",
63
+ "integration-test": "testing",
64
+ qa: "testing",
65
+ tester: "testing",
66
+ });
67
+
68
+ export const CANONICAL_ROLES = Object.freeze([
69
+ "auditor",
70
+ "performance",
71
+ "security",
72
+ "hygiene",
73
+ "a11y",
74
+ "docs",
75
+ "e2e",
76
+ "database",
77
+ "resilience",
78
+ "types",
79
+ "debugger",
80
+ "testing",
81
+ ]);
82
+
83
+ /**
84
+ * Pre-consolidation prompt filenames, canonical role -> legacy file stem.
85
+ *
86
+ * Deliberately NOT part of ROLE_ALIASES: the alias table answers "what did
87
+ * the operator mean", while this map answers "what might an older checkout
88
+ * still have on disk". Keeping the two separate is what lets ROLE_ALIASES
89
+ * stay strictly one-directional without stranding repos scaffolded before
90
+ * the legacy duplicates were deleted.
91
+ */
92
+ const LEGACY_PROMPT_FILENAMES = Object.freeze({
93
+ auditor: "overseer",
94
+ performance: "bolt",
95
+ security: "sentinel",
96
+ hygiene: "janitor",
97
+ e2e: "spectator",
98
+ docs: "scribe",
99
+ database: "alchemist",
100
+ resilience: "bulwark",
101
+ types: "typist",
102
+ debugger: "hunter",
103
+ });
104
+
22
105
  /**
23
106
  * Substitutes `{{TOKEN}}` placeholders in a role prompt from resolved config.
24
107
  *
@@ -51,14 +134,41 @@ export function hydrateRolePrompt(content = "", config = {}) {
51
134
  export function resolveRolePrompt(root = process.cwd(), roleName = "", opts = {}) {
52
135
  if (!roleName || typeof roleName !== "string") return null;
53
136
  const cleanName = roleName.trim().toLowerCase();
137
+ const directAlias = ROLE_ALIASES[cleanName];
138
+ const canonical = directAlias && CANONICAL_ROLES.includes(directAlias)
139
+ ? directAlias
140
+ : (CANONICAL_ROLES.includes(cleanName) ? cleanName : null);
141
+
142
+ const candidates = [];
143
+ if (canonical) {
144
+ candidates.push(canonical);
145
+ }
146
+ if (!candidates.includes(cleanName)) {
147
+ candidates.push(cleanName);
148
+ }
149
+ // Secondary fallback: a checkout scaffolded before consolidation may still
150
+ // hold only the legacy filename on disk (e.g. Bolt.md). The canonical file
151
+ // always wins when both exist; this branch just keeps old trees working.
152
+ if (canonical && LEGACY_PROMPT_FILENAMES[canonical] && !candidates.includes(LEGACY_PROMPT_FILENAMES[canonical])) {
153
+ candidates.push(LEGACY_PROMPT_FILENAMES[canonical]);
154
+ }
155
+ // Include direct alias if not yet present
156
+ if (directAlias && !candidates.includes(directAlias)) {
157
+ candidates.push(directAlias);
158
+ }
159
+
54
160
  const promptsDir = join(root, ".agent", "prompts");
55
161
  if (!existsSync(promptsDir)) return null;
56
162
 
57
163
  try {
58
164
  const files = readdirSync(promptsDir);
59
- const matched = files.find(
60
- (f) => f.toLowerCase() === `${cleanName}.md` || f.toLowerCase() === cleanName
61
- );
165
+ let matched = null;
166
+ for (const cand of candidates) {
167
+ matched = files.find(
168
+ (f) => f.toLowerCase() === `${cand}.md` || f.toLowerCase() === cand
169
+ );
170
+ if (matched) break;
171
+ }
62
172
  if (matched) {
63
173
  const fullPath = join(promptsDir, matched);
64
174
  const raw = readFileSync(fullPath, "utf-8").trim();