jules-orchestrator-kit 0.32.7 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/agentctl.mjs +122 -10
- package/index.mjs +15 -0
- package/package.json +1 -1
- package/scripts/doc-sync-check.mjs +69 -8
- package/scripts/utils.mjs +14 -4
- package/src/budget.mjs +318 -0
- package/src/config.mjs +39 -0
- package/src/dashboard.mjs +2 -1
- package/src/engine.mjs +38 -7
- package/src/mcp.mjs +15 -4
- package/src/ops/command-registry.mjs +24 -1
- package/src/ops/doctor-registry.mjs +50 -5
- package/src/ops/next-step.mjs +111 -0
- package/src/state.mjs +16 -5
- package/src/version.mjs +27 -0
- package/src/wizard-init.mjs +52 -28
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ Autonomous coding agents can write software at 100× human speed—but unconstra
|
|
|
120
120
|
|
|
121
121
|
* **🚀 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
122
|
|
|
123
|
-
* **📈 Proven Scale & Reliability:** Empirically tested with **
|
|
123
|
+
* **📈 Proven Scale & Reliability:** Empirically tested with **498 unit tests across 75 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
124
|
|
|
125
125
|
<br/>
|
|
126
126
|
|
package/bin/agentctl.mjs
CHANGED
|
@@ -5,18 +5,34 @@ import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { loadConfig, resolveRoot, detectStack, bootstrapZeroTestRepo } from "../src/config.mjs";
|
|
7
7
|
import { gate, dispatch, run, isTaskFile } from "../src/engine.mjs";
|
|
8
|
-
import { acquireLock, releaseLock, lockStatus,
|
|
8
|
+
import { acquireLock, releaseLock, lockStatus, getQueueDir } from "../src/state.mjs";
|
|
9
9
|
import { worktreePrune } from "../src/git.mjs";
|
|
10
10
|
import { reapOrphanedIntents, reapStaleMutexDirs } from "../src/journal.mjs";
|
|
11
|
+
import { KIT_VERSION } from "../src/version.mjs";
|
|
12
|
+
import { budgetStatus, listOpenReservations, releaseOpenReservations } from "../src/budget.mjs";
|
|
11
13
|
|
|
12
14
|
const args = process.argv.slice(2);
|
|
13
15
|
const command = args[0];
|
|
14
16
|
|
|
15
|
-
export const VERSION =
|
|
17
|
+
export const VERSION = KIT_VERSION;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render the budget line so the number is never mistaken for the vendor's own
|
|
21
|
+
* counter. The ledger counts what *this checkout* dispatched; sessions started
|
|
22
|
+
* from the web UI or another machine spend the same quota unseen, so a bare
|
|
23
|
+
* "N / M used" invites the reader to trust a figure that cannot be complete.
|
|
24
|
+
* @param {{ used: number, limit: number, source: string, certain: boolean }} b
|
|
25
|
+
*/
|
|
26
|
+
export function formatBudgetLine(b) {
|
|
27
|
+
const scope = `${b.used} / ${b.limit} used (this repo)`;
|
|
28
|
+
if (b.source === "learned") return `${scope} — provider refused further work today`;
|
|
29
|
+
if (b.certain) return `${scope} — limit from ${b.source === "env" ? "JULES_DAILY_BUDGET" : "config"}`;
|
|
30
|
+
return `${scope} — limit estimated from tier "${b.tier || "?"}", not enforced`;
|
|
31
|
+
}
|
|
16
32
|
|
|
17
33
|
export function printHelp() {
|
|
18
34
|
console.log(`
|
|
19
|
-
🚀 agentctl
|
|
35
|
+
🚀 agentctl v${VERSION} — Universal Agent Orchestrator & Safety Gatekeeper
|
|
20
36
|
|
|
21
37
|
Usage: agentctl <command> [options]
|
|
22
38
|
|
|
@@ -41,6 +57,7 @@ Commands:
|
|
|
41
57
|
rollback Restore git state & working tree to atomic pre-flight checkpoint
|
|
42
58
|
resume Resume warm session with human response (--response "<text>")
|
|
43
59
|
status Display queue and system status summary
|
|
60
|
+
budget Show today's task budget and its provenance (reset --yes to reconcile)
|
|
44
61
|
scan Scan codebase for TODO/FIXME task candidates
|
|
45
62
|
hydrate [prompt] Prepend active system learnings and baton-pass state to a prompt
|
|
46
63
|
harvest Harvest failure traces and record/quarantine resolution rules
|
|
@@ -64,11 +81,33 @@ Options:
|
|
|
64
81
|
|
|
65
82
|
|
|
66
83
|
async function main() {
|
|
67
|
-
if (
|
|
84
|
+
if (command === "--help" || command === "-h") {
|
|
68
85
|
printHelp();
|
|
69
86
|
process.exit(0);
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
// Bare `agentctl` answers "what do I do next" rather than dumping thirty
|
|
90
|
+
// commands. The help text is a reference for people who already know the
|
|
91
|
+
// tool; a newcomer cannot tell which entry is step one, and guessing wrong
|
|
92
|
+
// costs them a confusing failure instead of a hint.
|
|
93
|
+
if (!command) {
|
|
94
|
+
const { resolveNextStep, renderNextStep } = await import("../src/ops/next-step.mjs");
|
|
95
|
+
const cwd = process.cwd();
|
|
96
|
+
let budgetLine = "";
|
|
97
|
+
let where = cwd;
|
|
98
|
+
try {
|
|
99
|
+
const nextRoot = resolveRoot();
|
|
100
|
+
where = nextRoot;
|
|
101
|
+
budgetLine = formatBudgetLine(budgetStatus(loadConfig(nextRoot), nextRoot));
|
|
102
|
+
} catch (_) {
|
|
103
|
+
// Outside a repository there is no root to load a config from; the
|
|
104
|
+
// next step below is `git init`, which does not need one.
|
|
105
|
+
}
|
|
106
|
+
const next = resolveNextStep(where);
|
|
107
|
+
console.log(renderNextStep({ version: VERSION, root: where, next, budgetLine: next.blocking ? "" : budgetLine }));
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
72
111
|
if (command === "version" || command === "--version" || command === "-v") {
|
|
73
112
|
console.log(`agentctl v${VERSION}`);
|
|
74
113
|
process.exit(0);
|
|
@@ -282,6 +321,49 @@ async function main() {
|
|
|
282
321
|
break;
|
|
283
322
|
}
|
|
284
323
|
|
|
324
|
+
case "budget": {
|
|
325
|
+
const action = args[1];
|
|
326
|
+
const b = budgetStatus(config, root);
|
|
327
|
+
|
|
328
|
+
if (action === "reset") {
|
|
329
|
+
// The count is local-only, so an operator who knows their real usage
|
|
330
|
+
// must be able to correct it. Appending `budget_released` keeps the
|
|
331
|
+
// hash chain intact — the ledger is corrected forwards, never edited.
|
|
332
|
+
const dryRun = args.includes("--dry-run");
|
|
333
|
+
const confirmed = args.includes("--yes") || args.includes("-y");
|
|
334
|
+
if (!dryRun && !confirmed) {
|
|
335
|
+
const open = listOpenReservations(root);
|
|
336
|
+
console.log(`Would release ${open.length} open reservation(s) from today's ledger.`);
|
|
337
|
+
console.log("This rewrites nothing — it appends `budget_released` entries.");
|
|
338
|
+
console.log("Re-run with --yes to confirm, or --dry-run for detail.");
|
|
339
|
+
process.exit(0);
|
|
340
|
+
}
|
|
341
|
+
const res = releaseOpenReservations({ root, dryRun, reason: "operator-reconcile" });
|
|
342
|
+
const verb = dryRun ? "Would release" : "Released";
|
|
343
|
+
console.log(`${verb} ${res.released} reservation(s) (${res.committed} committed, ${res.uncommitted} never closed).`);
|
|
344
|
+
if (!dryRun) {
|
|
345
|
+
const after = budgetStatus(loadConfig(root), root);
|
|
346
|
+
console.log(`Daily Budget : ${formatBudgetLine(after)}`);
|
|
347
|
+
}
|
|
348
|
+
process.exit(0);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (args.includes("--json")) {
|
|
352
|
+
console.log(JSON.stringify({ ok: true, budget: { ...b, scope: "this-repository" } }, null, 2));
|
|
353
|
+
process.exit(0);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.log(`Daily Budget : ${formatBudgetLine(b)}`);
|
|
357
|
+
console.log(` ${b.note}`);
|
|
358
|
+
console.log(` Open reservations today: ${listOpenReservations(root).length}`);
|
|
359
|
+
console.log("");
|
|
360
|
+
console.log("The ledger counts this checkout only — sessions started from the Jules");
|
|
361
|
+
console.log("web UI or another machine spend the same quota without appearing here.");
|
|
362
|
+
console.log("Use `agentctl budget reset --yes` to reconcile a count you know is wrong.");
|
|
363
|
+
process.exit(0);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
|
|
285
367
|
case "lock": {
|
|
286
368
|
const action = args[1];
|
|
287
369
|
if (action === "acquire") {
|
|
@@ -313,6 +395,24 @@ async function main() {
|
|
|
313
395
|
}
|
|
314
396
|
|
|
315
397
|
case "doctor": {
|
|
398
|
+
const { values } = parseArgs({
|
|
399
|
+
args: args.slice(1),
|
|
400
|
+
options: { json: { type: "boolean", short: "j" } },
|
|
401
|
+
allowPositionals: true,
|
|
402
|
+
strict: false,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// runDoctorChecks() and its seven checks existed but nothing rendered
|
|
406
|
+
// them: this command printed a hand-written summary, so findings like a
|
|
407
|
+
// git-tracked .env were computed, tested, and never shown to anyone.
|
|
408
|
+
const { runDoctorChecks } = await import("../src/ops/doctor-registry.mjs");
|
|
409
|
+
const report = await runDoctorChecks({ root });
|
|
410
|
+
|
|
411
|
+
if (values.json) {
|
|
412
|
+
console.log(JSON.stringify(report, null, 2));
|
|
413
|
+
process.exit(report.summary.fail > 0 ? 1 : 0);
|
|
414
|
+
}
|
|
415
|
+
|
|
316
416
|
console.log(`\n🔍 agentctl System Diagnostics (v${VERSION})`);
|
|
317
417
|
console.log(`--------------------------------------------------`);
|
|
318
418
|
console.log(` Project Root : ${root}`);
|
|
@@ -320,10 +420,22 @@ async function main() {
|
|
|
320
420
|
console.log(` Detected Stack : ${detectStack(root).stack}`);
|
|
321
421
|
console.log(` Test Command : ${config.verify.test || "(None)"}`);
|
|
322
422
|
console.log(` Build Command : ${config.verify.build || "(None)"}`);
|
|
323
|
-
|
|
324
|
-
console.log(
|
|
325
|
-
|
|
326
|
-
|
|
423
|
+
console.log(` Daily Budget : ${formatBudgetLine(budgetStatus(config, root))}`);
|
|
424
|
+
console.log(`--------------------------------------------------`);
|
|
425
|
+
|
|
426
|
+
const icon = { pass: "✅", warn: "⚠️ ", fail: "❌", skip: "⏭️ ", unknown: "❔" };
|
|
427
|
+
for (const r of report.results) {
|
|
428
|
+
console.log(` ${icon[r.status] || "•"} ${r.title}`);
|
|
429
|
+
if (r.status !== "pass") {
|
|
430
|
+
console.log(` ${r.summary}`);
|
|
431
|
+
for (const fix of r.remediation || []) console.log(` → ${fix.summary}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const { pass, warn, fail } = report.summary;
|
|
436
|
+
console.log(`--------------------------------------------------`);
|
|
437
|
+
console.log(` ${pass} passed, ${warn} warning(s), ${fail} failure(s)\n`);
|
|
438
|
+
process.exit(fail > 0 ? 1 : 0);
|
|
327
439
|
break;
|
|
328
440
|
}
|
|
329
441
|
|
|
@@ -578,8 +690,8 @@ async function main() {
|
|
|
578
690
|
console.log(` Project Root : ${root}`);
|
|
579
691
|
console.log(` Pending Tasks : ${files.length}`);
|
|
580
692
|
console.log(` Active VFS Locks : ${lockStatus(root).length}`);
|
|
581
|
-
const budget =
|
|
582
|
-
console.log(` Daily Budget : ${budget
|
|
693
|
+
const budget = budgetStatus(config, root);
|
|
694
|
+
console.log(` Daily Budget : ${formatBudgetLine(budget)}`);
|
|
583
695
|
console.log(`--------------------------------------------------\n`);
|
|
584
696
|
process.exit(0);
|
|
585
697
|
break;
|
package/index.mjs
CHANGED
|
@@ -130,3 +130,18 @@ export {
|
|
|
130
130
|
generateEvidenceMarkdown,
|
|
131
131
|
computeEvidenceHash,
|
|
132
132
|
} from "./src/evidence.mjs";
|
|
133
|
+
|
|
134
|
+
export {
|
|
135
|
+
resolveDailyLimit,
|
|
136
|
+
budgetStatus,
|
|
137
|
+
readObservedCeiling,
|
|
138
|
+
readActiveCeiling,
|
|
139
|
+
recordObservedCeiling,
|
|
140
|
+
isDailyQuotaRejection,
|
|
141
|
+
listOpenReservations,
|
|
142
|
+
releaseOpenReservations,
|
|
143
|
+
CEILING_FILE,
|
|
144
|
+
} from "./src/budget.mjs";
|
|
145
|
+
export { KIT_VERSION } from "./src/version.mjs";
|
|
146
|
+
export { VENDOR_TIERS, FALLBACK_TIER } from "./src/config.mjs";
|
|
147
|
+
export { tierOptions } from "./src/wizard-init.mjs";
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* Exit codes: 0 = in sync, 1 = drift detected.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
19
|
+
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { execSync } from "node:child_process";
|
|
22
22
|
import { resolveRoot } from "../src/config.mjs";
|
|
@@ -60,6 +60,33 @@ function readIfExists(path) {
|
|
|
60
60
|
return existsSync(path) ? readFileSync(path, "utf-8") : null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Version numbers that appear in shipped sources but do not describe this kit.
|
|
65
|
+
* Kept as an explicit, justified list rather than a looser pattern, so a real
|
|
66
|
+
* drift can never slip through by resembling one of these.
|
|
67
|
+
*/
|
|
68
|
+
const FOREIGN_VERSIONS = [
|
|
69
|
+
{ file: "src/ops/doctor-registry.mjs", value: "20.0.0", why: "minimum supported Node.js runtime" },
|
|
70
|
+
{ file: "src/ops/ide-scaffold.mjs", value: "2.0.0", why: "VS Code tasks.json schema version" },
|
|
71
|
+
{ file: "src/version.mjs", value: "0.0.0", why: "documented fallback when package.json is absent" },
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
function isForeignVersion(relPath, value) {
|
|
75
|
+
return FOREIGN_VERSIONS.some((f) => f.file === relPath && f.value === value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every .mjs under `dir`, recursively. */
|
|
79
|
+
function listSourceFiles(dir, acc = []) {
|
|
80
|
+
if (!existsSync(dir)) return acc;
|
|
81
|
+
for (const entry of readdirSync(dir)) {
|
|
82
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
83
|
+
const full = join(dir, entry);
|
|
84
|
+
if (statSync(full).isDirectory()) listSourceFiles(full, acc);
|
|
85
|
+
else if (entry.endsWith(".mjs")) acc.push(full);
|
|
86
|
+
}
|
|
87
|
+
return acc;
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
/**
|
|
64
91
|
* Verifies documentation is in sync with package.json and the real test counts.
|
|
65
92
|
* @param {string} [root]
|
|
@@ -80,18 +107,52 @@ export function checkDocSync(root = process.cwd(), opts = {}) {
|
|
|
80
107
|
if (cli === null) {
|
|
81
108
|
add("agentctl present", false, "bin/agentctl.mjs not found");
|
|
82
109
|
} else {
|
|
110
|
+
// Either the version is derived from the manifest — which cannot drift —
|
|
111
|
+
// or it is a literal, which must match. The derived form is preferred;
|
|
112
|
+
// this check exists to stop a literal creeping back in unnoticed.
|
|
113
|
+
const derived = /export const VERSION\s*=\s*KIT_VERSION/.test(cli);
|
|
83
114
|
const constMatch = cli.match(/export const VERSION\s*=\s*"([^"]+)"/);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
115
|
+
if (derived) {
|
|
116
|
+
const versionModule = readIfExists(join(root, "src", "version.mjs"));
|
|
117
|
+
const readsManifest = Boolean(versionModule && /package\.json/.test(versionModule));
|
|
118
|
+
add(
|
|
119
|
+
"agentctl VERSION const",
|
|
120
|
+
readsManifest,
|
|
121
|
+
readsManifest ? "derived from package.json via src/version.mjs" : "VERSION derives from KIT_VERSION but src/version.mjs does not read package.json"
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
add(
|
|
125
|
+
"agentctl VERSION const",
|
|
126
|
+
constMatch?.[1] === version,
|
|
127
|
+
constMatch ? `found "${constMatch[1]}", expected "${version}"` : "no `export const VERSION` found"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
89
130
|
|
|
90
|
-
|
|
131
|
+
// Scan everything shipped, not just the CLI: the MCP server, dashboard and
|
|
132
|
+
// init wizard each hardcoded a version and sat three minor releases behind
|
|
133
|
+
// while this gate only ever looked at bin/agentctl.mjs and stayed green.
|
|
134
|
+
const shipped = [
|
|
135
|
+
...listSourceFiles(join(root, "src")),
|
|
136
|
+
join(root, "bin", "agentctl.mjs"),
|
|
137
|
+
];
|
|
138
|
+
const stale = [];
|
|
139
|
+
for (const file of shipped) {
|
|
140
|
+
const text = readIfExists(file);
|
|
141
|
+
if (text === null) continue;
|
|
142
|
+
const rel = file.replace(root + "/", "");
|
|
143
|
+
for (const m of text.matchAll(/\bv?(\d+\.\d+\.\d+)\b/g)) {
|
|
144
|
+
if (m[1] === version) continue;
|
|
145
|
+
// Only flag figures presented as *this kit's* version.
|
|
146
|
+
const context = text.slice(Math.max(0, m.index - 60), m.index).toLowerCase();
|
|
147
|
+
if (!/version|agentctl|orchestrator kit|kit config/.test(context)) continue;
|
|
148
|
+
if (isForeignVersion(rel, m[1], context)) continue;
|
|
149
|
+
stale.push(`${rel}: ${m[1]}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
91
152
|
add(
|
|
92
153
|
"agentctl banner/version strings",
|
|
93
154
|
stale.length === 0,
|
|
94
|
-
stale.length ? `stale version string(s): ${[...new Set(stale)].join(", ")}` : `all reference v${version}`
|
|
155
|
+
stale.length ? `stale version string(s): ${[...new Set(stale)].join(", ")}` : `all shipped sources reference v${version}`
|
|
95
156
|
);
|
|
96
157
|
}
|
|
97
158
|
|
package/scripts/utils.mjs
CHANGED
|
@@ -135,10 +135,20 @@ export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
|
|
|
135
135
|
return res;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
/**
|
|
139
|
+
* @param {number} [maxSessions=300]
|
|
140
|
+
* @param {string} [taskKey=""]
|
|
141
|
+
* @param {string} [root] - Ledger root. Defaults to the git toplevel. Pass an
|
|
142
|
+
* explicit root to keep callers (notably tests) off the operator's real ledger.
|
|
143
|
+
*/
|
|
144
|
+
export function reserveDailyBudget(maxSessions = 300, taskKey = "", root = resolveRoot()) {
|
|
145
|
+
// The id is what makes the reservation releasable. Written without one, a
|
|
146
|
+
// reservation counted against the day and no rollback, commit or reconcile
|
|
147
|
+
// could ever name it again — it stayed charged until the ledger rotated.
|
|
148
|
+
const reservationId = `res-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
|
149
|
+
appendLedger({ event: "budget_reserved", reservationId, key: taskKey }, root);
|
|
150
|
+
const check = checkDailyBudget(root, maxSessions);
|
|
151
|
+
return { ok: check.ok, used: check.used, budget: maxSessions, reservationId };
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
export function verifyLedgerIntegrity(filePath) {
|
package/src/budget.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resolveRoot } from "./config.mjs";
|
|
4
|
+
import { getStateDir, getDailyLedgerPath, ensureDir, appendLedger, checkDailyBudget } from "./state.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the observed quota ceiling lives, outside the ledger so it survives
|
|
8
|
+
* rotation.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately scoped to the day it was observed. The local ledger only counts
|
|
11
|
+
* tasks dispatched from *this* checkout, while the account's quota is also
|
|
12
|
+
* spent from the web UI and other machines, so the local count at the moment of
|
|
13
|
+
* a refusal is a lower bound on the real allowance — not the allowance. Treated
|
|
14
|
+
* as permanent it would hard-block the operator below their own quota, which is
|
|
15
|
+
* the exact failure this whole mechanism exists to prevent.
|
|
16
|
+
*
|
|
17
|
+
* What it does mean is precise and useful: *today* the provider has started
|
|
18
|
+
* refusing, so stop asking until tomorrow.
|
|
19
|
+
*/
|
|
20
|
+
export const CEILING_FILE = "budget-ceiling.json";
|
|
21
|
+
|
|
22
|
+
/** Local calendar day, matching the ledger's own rotation key. */
|
|
23
|
+
function today() {
|
|
24
|
+
return new Date().toISOString().split("T")[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Signals that a provider rejection means "you are out of quota for today"
|
|
29
|
+
* rather than "you are going too fast right now". A 429 alone cannot tell the
|
|
30
|
+
* two apart, and learning a ceiling from a per-minute throttle would pin the
|
|
31
|
+
* daily limit to whatever burst happened to trip it.
|
|
32
|
+
*/
|
|
33
|
+
const DAILY_QUOTA_HINT = /resource[_\s-]?exhausted|daily|per[\s-]?day|quota/i;
|
|
34
|
+
const TRANSIENT_HINT = /per[\s-]?minute|per[\s-]?second|too many requests|slow down|retry[\s-]?after/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether a provider error is evidence of a daily quota ceiling.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately conservative: an unrecognised rejection teaches nothing. A
|
|
40
|
+
* missed lesson costs one wasted API call, whereas a ceiling learned from a
|
|
41
|
+
* burst throttle would be recorded as certain and would then hard-block the
|
|
42
|
+
* operator well below their real allowance.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} err
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
export function isDailyQuotaRejection(err) {
|
|
48
|
+
if (!err || typeof err !== "object") return false;
|
|
49
|
+
const status = /** @type {any} */ (err).status;
|
|
50
|
+
if (status !== 429 && status !== 403) return false;
|
|
51
|
+
const text = `${/** @type {any} */ (err).message || ""} ${/** @type {any} */ (err).body || ""}`;
|
|
52
|
+
if (TRANSIENT_HINT.test(text)) return false;
|
|
53
|
+
return DAILY_QUOTA_HINT.test(text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeAtomic(filePath, content) {
|
|
57
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
58
|
+
const fd = openSync(tmpPath, "w");
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(fd, content, "utf-8");
|
|
61
|
+
fsyncSync(fd);
|
|
62
|
+
} finally {
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
}
|
|
65
|
+
renameSync(tmpPath, filePath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the stored ceiling, whenever it was observed.
|
|
70
|
+
* @param {string} [root]
|
|
71
|
+
* @returns {{ ceiling: number, day: string, observedAt: string, source: string, stale: boolean } | null}
|
|
72
|
+
*/
|
|
73
|
+
export function readObservedCeiling(root = resolveRoot()) {
|
|
74
|
+
const filePath = join(getStateDir(root), CEILING_FILE);
|
|
75
|
+
if (!existsSync(filePath)) return null;
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
78
|
+
if (!parsed || typeof parsed.ceiling !== "number" || !Number.isFinite(parsed.ceiling)) return null;
|
|
79
|
+
if (parsed.ceiling < 0) return null;
|
|
80
|
+
const day = typeof parsed.day === "string" ? parsed.day : String(parsed.observedAt || "").slice(0, 10);
|
|
81
|
+
return {
|
|
82
|
+
ceiling: Math.floor(parsed.ceiling),
|
|
83
|
+
day,
|
|
84
|
+
observedAt: typeof parsed.observedAt === "string" ? parsed.observedAt : "",
|
|
85
|
+
source: typeof parsed.source === "string" ? parsed.source : "provider-rejection",
|
|
86
|
+
stale: day !== today(),
|
|
87
|
+
};
|
|
88
|
+
} catch (_) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The ceiling only if it still applies — i.e. observed today.
|
|
95
|
+
* @param {string} [root]
|
|
96
|
+
*/
|
|
97
|
+
export function readActiveCeiling(root = resolveRoot()) {
|
|
98
|
+
const rec = readObservedCeiling(root);
|
|
99
|
+
return rec && !rec.stale ? rec : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Record that the provider refused further work after `usedAtRejection` tasks
|
|
104
|
+
* were dispatched locally today.
|
|
105
|
+
*
|
|
106
|
+
* Zero is a legitimate value: it means the quota was already spent elsewhere
|
|
107
|
+
* (the web UI, another machine) before this checkout dispatched anything.
|
|
108
|
+
*
|
|
109
|
+
* @param {number} usedAtRejection - Tasks reserved locally when the refusal came.
|
|
110
|
+
* @param {string} [root]
|
|
111
|
+
* @param {object} [meta]
|
|
112
|
+
* @returns {{ ceiling: number, day: string, observedAt: string, source: string } | null}
|
|
113
|
+
*/
|
|
114
|
+
export function recordObservedCeiling(usedAtRejection, root = resolveRoot(), meta = {}) {
|
|
115
|
+
const ceiling = Math.floor(Number(usedAtRejection));
|
|
116
|
+
if (!Number.isFinite(ceiling) || ceiling < 0) return null;
|
|
117
|
+
|
|
118
|
+
const stateDir = getStateDir(root);
|
|
119
|
+
ensureDir(stateDir);
|
|
120
|
+
const record = {
|
|
121
|
+
ceiling,
|
|
122
|
+
day: today(),
|
|
123
|
+
observedAt: new Date().toISOString(),
|
|
124
|
+
source: meta.source || "provider-rejection",
|
|
125
|
+
};
|
|
126
|
+
writeAtomic(join(stateDir, CEILING_FILE), JSON.stringify(record, null, 2) + "\n");
|
|
127
|
+
|
|
128
|
+
// Mirrored into the hash-chained ledger so the change is auditable; the JSON
|
|
129
|
+
// file above is only a cheap index that survives ledger rotation.
|
|
130
|
+
try {
|
|
131
|
+
appendLedger({ event: "budget_ceiling_observed", ceiling, source: record.source }, root);
|
|
132
|
+
} catch (_) {}
|
|
133
|
+
|
|
134
|
+
return record;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Resolve today's effective task limit *and how much we trust it*.
|
|
139
|
+
*
|
|
140
|
+
* Precedence, most to least authoritative:
|
|
141
|
+
* 1. `limits.daily_tasks` written explicitly in .agent/config.yml, or
|
|
142
|
+
* JULES_DAILY_BUDGET — the operator stating their own plan.
|
|
143
|
+
* 2. A ceiling the provider demonstrated by refusing work.
|
|
144
|
+
* 3. The tier preset — a guess, and marked as one.
|
|
145
|
+
*
|
|
146
|
+
* `certain` is what callers must branch on: an uncertain limit may warn but
|
|
147
|
+
* must not hard-block, because refusing a request the provider would have
|
|
148
|
+
* accepted breaks the tool for anyone whose plan we guessed wrong.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} config - A config from loadConfig().
|
|
151
|
+
* @param {string} [root]
|
|
152
|
+
* @returns {{ limit: number, source: "config"|"env"|"learned"|"tier"|"default", certain: boolean, note: string }}
|
|
153
|
+
*/
|
|
154
|
+
export function resolveDailyLimit(config, root = resolveRoot()) {
|
|
155
|
+
const provenance = config?.provenance?.dailyTasks || "default";
|
|
156
|
+
const configured = Number(config?.limits?.dailyTasks);
|
|
157
|
+
|
|
158
|
+
// `>= 0`, not `> 0`: a limit of zero is a deliberate "dispatch nothing", and
|
|
159
|
+
// treating it as absent would silently fall through to a permissive estimate.
|
|
160
|
+
if ((provenance === "config" || provenance === "env") && Number.isFinite(configured) && configured >= 0) {
|
|
161
|
+
return {
|
|
162
|
+
limit: configured,
|
|
163
|
+
source: provenance,
|
|
164
|
+
certain: true,
|
|
165
|
+
note: provenance === "env" ? "set via JULES_DAILY_BUDGET" : "set in .agent/config.yml",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Only today's observation may enforce. Yesterday's refusal says nothing
|
|
170
|
+
// about today's remaining quota, and carrying it forward would keep the
|
|
171
|
+
// operator locked out after the quota reset.
|
|
172
|
+
const learned = readActiveCeiling(root);
|
|
173
|
+
if (learned) {
|
|
174
|
+
return {
|
|
175
|
+
limit: learned.ceiling,
|
|
176
|
+
source: "learned",
|
|
177
|
+
certain: true,
|
|
178
|
+
note: `the provider refused further work today after ${learned.ceiling} local task(s); resets tomorrow`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fallback = Number.isFinite(configured) && configured > 0 ? configured : 300;
|
|
183
|
+
return {
|
|
184
|
+
limit: fallback,
|
|
185
|
+
source: provenance === "tier" ? "tier" : "default",
|
|
186
|
+
certain: false,
|
|
187
|
+
note: `estimated from tier "${config?.tier || "unknown"}" — set limits.daily_tasks to make this exact`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* List reservations that today's ledger still counts as spent.
|
|
193
|
+
*
|
|
194
|
+
* A reservation is open until a `budget_rolled_back` or `budget_released` entry
|
|
195
|
+
* names it. `budget_committed` deliberately does not close one — a committed
|
|
196
|
+
* dispatch really did consume quota — so the open set is "everything reserved
|
|
197
|
+
* today that was not given back".
|
|
198
|
+
*
|
|
199
|
+
* Anonymous reservations (no `reservationId`, as older kit versions and
|
|
200
|
+
* scripts/utils.mjs wrote them) are counted too, as records with
|
|
201
|
+
* `reservationId: null`. They must be, or a reconcile would silently leave them
|
|
202
|
+
* charged against the day: `checkDailyBudget` counts them, and with no id there
|
|
203
|
+
* is nothing a targeted release could name. They are matched by count, the same
|
|
204
|
+
* way the counter itself treats them.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} [root]
|
|
207
|
+
* @returns {{ reservationId: string|null, timestamp: string, committed: boolean }[]}
|
|
208
|
+
*/
|
|
209
|
+
export function listOpenReservations(root = resolveRoot()) {
|
|
210
|
+
const filePath = getDailyLedgerPath(root);
|
|
211
|
+
if (!existsSync(filePath)) return [];
|
|
212
|
+
|
|
213
|
+
/** @type {Map<string, { reservationId: string, timestamp: string, committed: boolean }>} */
|
|
214
|
+
const open = new Map();
|
|
215
|
+
/** @type {{ reservationId: null, timestamp: string, committed: boolean }[]} */
|
|
216
|
+
const anonymous = [];
|
|
217
|
+
let raw = "";
|
|
218
|
+
try {
|
|
219
|
+
raw = readFileSync(filePath, "utf-8");
|
|
220
|
+
} catch (_) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const line of raw.split("\n").filter(Boolean)) {
|
|
225
|
+
let entry;
|
|
226
|
+
try {
|
|
227
|
+
entry = JSON.parse(line);
|
|
228
|
+
} catch (_) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (!entry || !entry.event) continue;
|
|
232
|
+
|
|
233
|
+
if (entry.event === "budget_reserved") {
|
|
234
|
+
if (entry.reservationId) {
|
|
235
|
+
open.set(entry.reservationId, {
|
|
236
|
+
reservationId: entry.reservationId,
|
|
237
|
+
timestamp: entry.timestamp || "",
|
|
238
|
+
committed: false,
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
anonymous.push({ reservationId: null, timestamp: entry.timestamp || "", committed: false });
|
|
242
|
+
}
|
|
243
|
+
} else if (entry.event === "budget_committed") {
|
|
244
|
+
const rec = entry.reservationId ? open.get(entry.reservationId) : null;
|
|
245
|
+
if (rec) rec.committed = true;
|
|
246
|
+
} else if (entry.event === "budget_rolled_back" || entry.event === "budget_released") {
|
|
247
|
+
if (entry.reservationId) open.delete(entry.reservationId);
|
|
248
|
+
else anonymous.pop();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return [...open.values(), ...anonymous];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Give today's open reservations back, by appending `budget_released` entries.
|
|
257
|
+
*
|
|
258
|
+
* The ledger is append-only and hash-chained, so a miscounted day is corrected
|
|
259
|
+
* forwards — never by editing or deleting the file, which would break the chain
|
|
260
|
+
* and destroy the audit trail the ledger exists to provide.
|
|
261
|
+
*
|
|
262
|
+
* This is an operator override, not an inference. The kit cannot tell a
|
|
263
|
+
* reservation that reached the provider from one whose process died first, so
|
|
264
|
+
* only the operator knows whether the local count still reflects reality.
|
|
265
|
+
*
|
|
266
|
+
* @param {object} [opts]
|
|
267
|
+
* @param {string} [opts.root]
|
|
268
|
+
* @param {string} [opts.reason] - Recorded on every released entry.
|
|
269
|
+
* @param {boolean} [opts.dryRun] - Report what would be released, write nothing.
|
|
270
|
+
* @returns {{ released: number, committed: number, uncommitted: number, ids: string[], dryRun: boolean }}
|
|
271
|
+
*/
|
|
272
|
+
export function releaseOpenReservations(opts = {}) {
|
|
273
|
+
const root = opts.root || resolveRoot();
|
|
274
|
+
const openRecords = listOpenReservations(root);
|
|
275
|
+
const committed = openRecords.filter((r) => r.committed).length;
|
|
276
|
+
|
|
277
|
+
const result = {
|
|
278
|
+
released: openRecords.length,
|
|
279
|
+
committed,
|
|
280
|
+
uncommitted: openRecords.length - committed,
|
|
281
|
+
anonymous: openRecords.filter((r) => !r.reservationId).length,
|
|
282
|
+
ids: openRecords.map((r) => r.reservationId).filter(Boolean),
|
|
283
|
+
dryRun: Boolean(opts.dryRun),
|
|
284
|
+
};
|
|
285
|
+
if (opts.dryRun || openRecords.length === 0) return result;
|
|
286
|
+
|
|
287
|
+
for (const rec of openRecords) {
|
|
288
|
+
const entry = { event: "budget_released", reason: opts.reason || "operator-reconcile" };
|
|
289
|
+
// An anonymous reservation is released by an equally anonymous entry: the
|
|
290
|
+
// counter decrements those by count, so naming an id here would leave the
|
|
291
|
+
// original charged and subtract from someone else's total instead.
|
|
292
|
+
if (rec.reservationId) entry.reservationId = rec.reservationId;
|
|
293
|
+
appendLedger(entry, root);
|
|
294
|
+
}
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Human-readable budget status for the CLI, dashboard and MCP surface.
|
|
300
|
+
* @param {object} config
|
|
301
|
+
* @param {string} [root]
|
|
302
|
+
*/
|
|
303
|
+
export function budgetStatus(config, root = resolveRoot()) {
|
|
304
|
+
const resolved = resolveDailyLimit(config, root);
|
|
305
|
+
const check = checkDailyBudget(root, resolved.limit);
|
|
306
|
+
return {
|
|
307
|
+
used: check.used,
|
|
308
|
+
limit: resolved.limit,
|
|
309
|
+
remaining: check.remaining,
|
|
310
|
+
tier: config?.tier || "unknown",
|
|
311
|
+
source: resolved.source,
|
|
312
|
+
certain: resolved.certain,
|
|
313
|
+
note: resolved.note,
|
|
314
|
+
// Only a limit we actually know may stop a dispatch.
|
|
315
|
+
enforced: resolved.certain,
|
|
316
|
+
exhausted: check.used >= resolved.limit,
|
|
317
|
+
};
|
|
318
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -270,6 +270,17 @@ export function resolveVerify(root = process.cwd(), userVerify = {}) {
|
|
|
270
270
|
};
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The single source of truth for tier defaults. `src/wizard-init.mjs` projects
|
|
275
|
+
* this into snake_case rather than keeping a second table: the two tables
|
|
276
|
+
* previously disagreed (wizard wrote free=30 while the runtime assumed 15), so
|
|
277
|
+
* a freshly initialised repo was budgeted against numbers no other code used.
|
|
278
|
+
*
|
|
279
|
+
* `dailyTasks` here is a *hint*, not a fact. Provider quotas are set by the
|
|
280
|
+
* vendor and change without notice, so these numbers are only ever a starting
|
|
281
|
+
* guess — see `isTierGuess()` and the learned-ceiling logic in src/state.mjs.
|
|
282
|
+
* An explicit `limits.daily_tasks` in .agent/config.yml always wins.
|
|
283
|
+
*/
|
|
273
284
|
export const TIER_PRESETS = {
|
|
274
285
|
free: {
|
|
275
286
|
dailyTasks: 15,
|
|
@@ -292,8 +303,24 @@ export const TIER_PRESETS = {
|
|
|
292
303
|
staggerMs: 1000,
|
|
293
304
|
diffKb: 75,
|
|
294
305
|
},
|
|
306
|
+
// Not a vendor plan: a self-hosted/pooled profile for operators who front
|
|
307
|
+
// several accounts. Defined here so `tier: enterprise` resolves to what the
|
|
308
|
+
// wizard writes instead of silently collapsing onto the ultra preset.
|
|
309
|
+
enterprise: {
|
|
310
|
+
dailyTasks: 1000,
|
|
311
|
+
repairAttempts: 3,
|
|
312
|
+
concurrency: 10,
|
|
313
|
+
staggerMs: 500,
|
|
314
|
+
diffKb: 100,
|
|
315
|
+
},
|
|
295
316
|
};
|
|
296
317
|
|
|
318
|
+
/** Tier names that correspond to real vendor plans, in ascending order. */
|
|
319
|
+
export const VENDOR_TIERS = ["free", "pro", "ultra"];
|
|
320
|
+
|
|
321
|
+
/** The tier used when a config names one that does not exist. */
|
|
322
|
+
export const FALLBACK_TIER = "ultra";
|
|
323
|
+
|
|
297
324
|
/**
|
|
298
325
|
* Loads and validates configuration from .agent/config.yml or .agent/jules.yml.
|
|
299
326
|
*/
|
|
@@ -382,6 +409,18 @@ export function loadConfig(root = resolveRoot(), explicitPath = null) {
|
|
|
382
409
|
...(envDailyTasks !== null && !isNaN(envDailyTasks) ? { dailyTasks: envDailyTasks } : {}),
|
|
383
410
|
...(envDiffKb !== null && !isNaN(envDiffKb) ? { diffKb: envDiffKb } : {}),
|
|
384
411
|
},
|
|
412
|
+
// Where each contested limit actually came from. The merge above flattens
|
|
413
|
+
// config, env and tier into one number, after which no caller can tell a
|
|
414
|
+
// figure the operator stated from one the kit guessed — and the budget gate
|
|
415
|
+
// must not hard-block on a guess. See resolveDailyLimit() in src/budget.mjs.
|
|
416
|
+
provenance: {
|
|
417
|
+
dailyTasks:
|
|
418
|
+
envDailyTasks !== null && !isNaN(envDailyTasks)
|
|
419
|
+
? "env"
|
|
420
|
+
: normalizedLimits.dailyTasks !== undefined
|
|
421
|
+
? "config"
|
|
422
|
+
: "tier",
|
|
423
|
+
},
|
|
385
424
|
isolation: parsed.isolation || DEFAULTS.isolation,
|
|
386
425
|
runner: parsed.runner || DEFAULTS.runner,
|
|
387
426
|
branchPrefix: parsed.branch_prefix || parsed.branchPrefix || DEFAULTS.branchPrefix,
|
package/src/dashboard.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { join, dirname } from "node:path";
|
|
|
10
10
|
import { readTelemetry, verifyTelemetryIntegrity } from "./telemetry.mjs";
|
|
11
11
|
import { readVerifyRuns, flakyVerdict } from "./flaky-ledger.mjs";
|
|
12
12
|
import { lockStatus } from "./state.mjs";
|
|
13
|
+
import { KIT_VERSION } from "./version.mjs";
|
|
13
14
|
|
|
14
15
|
const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version;
|
|
15
16
|
|
|
@@ -92,7 +93,7 @@ export function createDashboardServer({ root = process.cwd(), port: _port = 4100
|
|
|
92
93
|
|
|
93
94
|
if (url.pathname === "/api/status") {
|
|
94
95
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
95
|
-
return res.end(JSON.stringify({ ok: true, version:
|
|
96
|
+
return res.end(JSON.stringify({ ok: true, version: KIT_VERSION, root, ts: new Date().toISOString() }));
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
if (url.pathname === "/api/telemetry") {
|
package/src/engine.mjs
CHANGED
|
@@ -3,7 +3,8 @@ import { checkScope, scanDiff, redactSecrets } from "./security.mjs";
|
|
|
3
3
|
import { changedFiles, diffBytes, diffText, showFromOrigin, runCmd } from "./git.mjs";
|
|
4
4
|
import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
|
|
5
5
|
import { resolveRoutedProvider } from "./router.mjs";
|
|
6
|
-
import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked } from "./state.mjs";
|
|
6
|
+
import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReservation, isConcurrencyGroupLocked, checkDailyBudget } from "./state.mjs";
|
|
7
|
+
import { resolveDailyLimit, recordObservedCeiling, isDailyQuotaRejection } from "./budget.mjs";
|
|
7
8
|
import { sanitizeUntrustedData, buildAgentEnvelope } from "./prompt-guard.mjs";
|
|
8
9
|
import { recordVerifyRun, readVerifyRuns, flakyVerdict } from "./flaky-ledger.mjs";
|
|
9
10
|
import fs, { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
|
|
@@ -695,7 +696,12 @@ export async function dispatch(task = {}, opts = {}) {
|
|
|
695
696
|
}
|
|
696
697
|
const root = opts.config?._root || opts.root || process.cwd();
|
|
697
698
|
const config = opts.config || loadConfig(root);
|
|
698
|
-
|
|
699
|
+
// opts.provider mirrors gate()'s existing injection point, so a caller can
|
|
700
|
+
// exercise dispatch without reaching a live provider.
|
|
701
|
+
const resolved = resolveRoutedProvider(task, config);
|
|
702
|
+
const provider = opts.provider || resolved.provider;
|
|
703
|
+
const routed = opts.provider ? false : resolved.routed;
|
|
704
|
+
const classification = opts.provider ? null : resolved.classification;
|
|
699
705
|
if (routed && classification) {
|
|
700
706
|
appendTelemetry(root, "router_decision", {
|
|
701
707
|
tier: classification.tier,
|
|
@@ -739,14 +745,39 @@ export async function dispatch(task = {}, opts = {}) {
|
|
|
739
745
|
|
|
740
746
|
const cleanTask = { ...task, prompt: envelopedPrompt };
|
|
741
747
|
|
|
748
|
+
const runDispatch = () => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun });
|
|
749
|
+
|
|
750
|
+
// An estimated ceiling may warn but must not block: refusing a dispatch the
|
|
751
|
+
// provider would have accepted is a worse failure than an over-count.
|
|
752
|
+
const budget = resolveDailyLimit(config, root);
|
|
753
|
+
if (!opts.dryRun && !budget.certain) {
|
|
754
|
+
const check = checkDailyBudget(root, budget.limit);
|
|
755
|
+
if (check.used >= budget.limit) {
|
|
756
|
+
console.warn(
|
|
757
|
+
`[BUDGET_ESTIMATE] ${check.used}/${budget.limit} tasks used — ${budget.note}. ` +
|
|
758
|
+
`Proceeding: the real allowance is unknown, so this is not enforced.`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
742
763
|
try {
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
764
|
+
// A dry run performs no provider call and produces no work, so it must not
|
|
765
|
+
// consume one of the operator's finite daily task slots. Reserving here also
|
|
766
|
+
// made every `npm test` burn real budget, which eventually exhausted the
|
|
767
|
+
// ledger and turned the suite red for reasons unrelated to any code change.
|
|
768
|
+
const session = opts.dryRun
|
|
769
|
+
? await runDispatch()
|
|
770
|
+
: await withBudget(runDispatch, root, budget.limit, { enforce: budget.certain });
|
|
748
771
|
return classification ? { ...session, _routeTier: classification.tier, _routeReason: classification.reason } : session;
|
|
749
772
|
} catch (err) {
|
|
773
|
+
// A refusal for daily quota is the only authoritative statement of the
|
|
774
|
+
// account's real allowance, so record it: from here on the gate enforces a
|
|
775
|
+
// number the provider demonstrated instead of one a tier preset guessed.
|
|
776
|
+
if (isDailyQuotaRejection(err)) {
|
|
777
|
+
try {
|
|
778
|
+
recordObservedCeiling(checkDailyBudget(root, budget.limit).used, root, { source: "provider-rejection" });
|
|
779
|
+
} catch (_) {}
|
|
780
|
+
}
|
|
750
781
|
if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
|
|
751
782
|
if (err.reservationId && !err.budgetReservationRolledBack) {
|
|
752
783
|
rollbackBudgetReservation(root, err.reservationId);
|
package/src/mcp.mjs
CHANGED
|
@@ -2,14 +2,16 @@ import { Transform } from "node:stream";
|
|
|
2
2
|
import { loadConfig, resolveRoot, detectStack } from "./config.mjs";
|
|
3
3
|
import { gate, dispatch } from "./engine.mjs";
|
|
4
4
|
import { classifyRiskTier } from "./risk.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { lockStatus } from "./state.mjs";
|
|
6
|
+
import { budgetStatus } from "./budget.mjs";
|
|
6
7
|
import { reapOrphanedIntents, reapStaleMutexDirs } from "./journal.mjs";
|
|
7
8
|
import { readTelemetry } from "./telemetry.mjs";
|
|
8
9
|
import { ProgressBus } from "./mcp-progress.mjs";
|
|
10
|
+
import { KIT_VERSION } from "./version.mjs";
|
|
9
11
|
|
|
10
12
|
export const MCP_SERVER_INFO = {
|
|
11
13
|
name: "jules-orchestrator-kit",
|
|
12
|
-
version:
|
|
14
|
+
version: KIT_VERSION,
|
|
13
15
|
};
|
|
14
16
|
|
|
15
17
|
export const MAX_MCP_FRAME_SIZE = 4 * 1024 * 1024; // 4 MB memory safety ceiling
|
|
@@ -321,13 +323,22 @@ export async function handleMcpRequest(request, opts = {}) {
|
|
|
321
323
|
|
|
322
324
|
if (toolName === "get_jules_status") {
|
|
323
325
|
const stackInfo = detectStack(root);
|
|
324
|
-
const budget =
|
|
326
|
+
const budget = budgetStatus(config, root);
|
|
325
327
|
const locks = lockStatus(root);
|
|
326
328
|
const status = {
|
|
327
329
|
version: MCP_SERVER_INFO.version,
|
|
328
330
|
root,
|
|
329
331
|
stack: stackInfo.stack,
|
|
330
|
-
|
|
332
|
+
// `source`/`enforced` travel with the number so an agent reading this
|
|
333
|
+
// can tell a measured limit from an estimated one.
|
|
334
|
+
budget: {
|
|
335
|
+
used: budget.used,
|
|
336
|
+
limit: budget.limit,
|
|
337
|
+
remaining: budget.remaining,
|
|
338
|
+
source: budget.source,
|
|
339
|
+
enforced: budget.enforced,
|
|
340
|
+
scope: "this-repository",
|
|
341
|
+
},
|
|
331
342
|
activeLocksCount: locks.length,
|
|
332
343
|
locks,
|
|
333
344
|
};
|
|
@@ -155,7 +155,7 @@ export const COMMAND_REGISTRY = [
|
|
|
155
155
|
"agentctl init --tier pro --yes",
|
|
156
156
|
],
|
|
157
157
|
flags: [
|
|
158
|
-
{ name: "tier", type: "string", description: "Target configuration tier (free, pro, enterprise)" },
|
|
158
|
+
{ name: "tier", type: "string", description: "Target configuration tier (free, pro, ultra, enterprise)" },
|
|
159
159
|
{ name: "interactive", type: "boolean", description: "Launch interactive onboarding wizard" },
|
|
160
160
|
{ name: "yes", type: "boolean", description: "Accept auto-detected Stack Oracle defaults" },
|
|
161
161
|
],
|
|
@@ -180,6 +180,29 @@ export const COMMAND_REGISTRY = [
|
|
|
180
180
|
{ name: "host", type: "string", description: "Bind host address (default 127.0.0.1)" },
|
|
181
181
|
],
|
|
182
182
|
},
|
|
183
|
+
{
|
|
184
|
+
id: "budget",
|
|
185
|
+
path: ["budget"],
|
|
186
|
+
title: "budget",
|
|
187
|
+
description: "Show today's task budget, where its limit came from, and reconcile a wrong count",
|
|
188
|
+
category: "Inspect",
|
|
189
|
+
mutates: false,
|
|
190
|
+
risk: "low",
|
|
191
|
+
interactive: "never",
|
|
192
|
+
requiresRepository: true,
|
|
193
|
+
shortcuts: ["b"],
|
|
194
|
+
examples: [
|
|
195
|
+
"agentctl budget",
|
|
196
|
+
"agentctl budget --json",
|
|
197
|
+
"agentctl budget reset --dry-run",
|
|
198
|
+
"agentctl budget reset --yes",
|
|
199
|
+
],
|
|
200
|
+
flags: [
|
|
201
|
+
{ name: "json", type: "boolean", description: "Output structured JSON budget snapshot" },
|
|
202
|
+
{ name: "dry-run", type: "boolean", description: "Report what reset would release, write nothing" },
|
|
203
|
+
{ name: "yes", type: "boolean", description: "Confirm releasing today's open reservations" },
|
|
204
|
+
],
|
|
205
|
+
},
|
|
183
206
|
{
|
|
184
207
|
id: "status",
|
|
185
208
|
path: ["status"],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* @typedef {"pass" | "warn" | "fail" | "skip" | "unknown"} DiagnosticStatus
|
|
@@ -393,16 +393,21 @@ export async function runDoctorChecks(options = {}) {
|
|
|
393
393
|
}
|
|
394
394
|
|
|
395
395
|
// 7. Jules Provider Key Check
|
|
396
|
-
const
|
|
397
|
-
if (
|
|
396
|
+
const keyVar = process.env.JULES_API_KEY ? "JULES_API_KEY" : process.env.GEMINI_API_KEY ? "GEMINI_API_KEY" : "";
|
|
397
|
+
if (keyVar) {
|
|
398
398
|
addResult({
|
|
399
399
|
id: "provider.key",
|
|
400
400
|
category: "Jules",
|
|
401
401
|
title: "Jules Provider API Key",
|
|
402
402
|
status: "pass",
|
|
403
403
|
severity: "info",
|
|
404
|
-
|
|
405
|
-
|
|
404
|
+
// Naming the variable, not the value: an operator who wonders which key a
|
|
405
|
+
// dispatch will use should not have to echo a secret to find out.
|
|
406
|
+
summary: `API key supplied via ${keyVar} (environment only — never written to config or sent anywhere but the provider)`,
|
|
407
|
+
evidence: [
|
|
408
|
+
{ label: "keyConfigured", value: true, sensitive: false },
|
|
409
|
+
{ label: "keySource", value: keyVar, sensitive: false },
|
|
410
|
+
],
|
|
406
411
|
});
|
|
407
412
|
} else {
|
|
408
413
|
addResult({
|
|
@@ -412,10 +417,50 @@ export async function runDoctorChecks(options = {}) {
|
|
|
412
417
|
status: "warn",
|
|
413
418
|
severity: "high",
|
|
414
419
|
summary: "Neither JULES_API_KEY nor GEMINI_API_KEY environment variable is set",
|
|
420
|
+
remediation: [
|
|
421
|
+
{
|
|
422
|
+
summary: "Export JULES_API_KEY in your shell profile, or place it in a git-ignored .env",
|
|
423
|
+
risk: "low",
|
|
424
|
+
automatic: false,
|
|
425
|
+
requiresProbe: false,
|
|
426
|
+
},
|
|
427
|
+
],
|
|
415
428
|
evidence: [{ label: "keyConfigured", value: false, sensitive: false }],
|
|
416
429
|
});
|
|
417
430
|
}
|
|
418
431
|
|
|
432
|
+
// 7b. A .env holding the key must not be tracked by git.
|
|
433
|
+
const envFile = join(root, ".env");
|
|
434
|
+
if (existsSync(envFile)) {
|
|
435
|
+
let tracked = false;
|
|
436
|
+
try {
|
|
437
|
+
const res = spawnSync("git", ["ls-files", "--error-unmatch", ".env"], { cwd: root, encoding: "utf-8" });
|
|
438
|
+
tracked = res.status === 0;
|
|
439
|
+
} catch (_) {}
|
|
440
|
+
|
|
441
|
+
addResult({
|
|
442
|
+
id: "provider.key.dotenv",
|
|
443
|
+
category: "Jules",
|
|
444
|
+
title: "Local .env secrecy",
|
|
445
|
+
status: tracked ? "fail" : "pass",
|
|
446
|
+
severity: tracked ? "critical" : "info",
|
|
447
|
+
summary: tracked
|
|
448
|
+
? ".env is tracked by git — an API key committed here is disclosed to everyone with repository access"
|
|
449
|
+
: ".env is present and untracked",
|
|
450
|
+
evidence: [{ label: "gitTracked", value: tracked, sensitive: false }],
|
|
451
|
+
remediation: tracked
|
|
452
|
+
? [
|
|
453
|
+
{
|
|
454
|
+
summary: "Run: git rm --cached .env && echo '.env' >> .gitignore, then rotate the key",
|
|
455
|
+
risk: "low",
|
|
456
|
+
automatic: false,
|
|
457
|
+
requiresProbe: false,
|
|
458
|
+
},
|
|
459
|
+
]
|
|
460
|
+
: [],
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
419
464
|
// Summarize count by status
|
|
420
465
|
const summary = { pass: 0, warn: 0, fail: 0, skip: 0, unknown: 0 };
|
|
421
466
|
for (const r of results) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Work out the one thing the operator should do next.
|
|
7
|
+
*
|
|
8
|
+
* `agentctl --help` lists thirty commands with no indication of which is step
|
|
9
|
+
* one. That list is a reference for people who already know the tool; someone
|
|
10
|
+
* meeting it for the first time cannot tell `hydrate` from `harvest` from
|
|
11
|
+
* `dispatch`, and the cost of guessing wrong is a confusing failure rather than
|
|
12
|
+
* a hint. This walks the same preconditions the commands themselves enforce and
|
|
13
|
+
* names the single next action.
|
|
14
|
+
*
|
|
15
|
+
* Ordered by dependency: nothing later is worth suggesting while something
|
|
16
|
+
* earlier is unmet.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} root
|
|
19
|
+
* @param {object} [env=process.env]
|
|
20
|
+
* @returns {{ id: string, headline: string, detail: string, command: string, blocking: boolean }}
|
|
21
|
+
*/
|
|
22
|
+
export function resolveNextStep(root, env = process.env) {
|
|
23
|
+
const inGitRepo = (() => {
|
|
24
|
+
try {
|
|
25
|
+
return spawnSync("git", ["rev-parse", "--git-dir"], { cwd: root, stdio: "ignore" }).status === 0;
|
|
26
|
+
} catch (_) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
if (!inGitRepo) {
|
|
32
|
+
return {
|
|
33
|
+
id: "git",
|
|
34
|
+
headline: "This directory is not a git repository",
|
|
35
|
+
detail:
|
|
36
|
+
"Every safety guarantee here is expressed in terms of diffs, branches and a base to compare against, so the kit has nothing to reason about without git.",
|
|
37
|
+
command: "git init",
|
|
38
|
+
blocking: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const configured = existsSync(join(root, ".agent", "config.yml")) || existsSync(join(root, ".agent", "jules.yml"));
|
|
43
|
+
if (!configured) {
|
|
44
|
+
return {
|
|
45
|
+
id: "init",
|
|
46
|
+
headline: "No .agent/ configuration yet",
|
|
47
|
+
detail:
|
|
48
|
+
"The wizard detects your stack, proposes a verification command, and writes the scope rules the gate enforces.",
|
|
49
|
+
command: "agentctl init",
|
|
50
|
+
blocking: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!env.JULES_API_KEY && !env.GEMINI_API_KEY) {
|
|
55
|
+
return {
|
|
56
|
+
id: "key",
|
|
57
|
+
headline: "No provider API key in the environment",
|
|
58
|
+
detail:
|
|
59
|
+
"The key is read from the environment only — never written to config and never sent anywhere but the provider. Until it is set you can still run `agentctl gate` and `--dry-run` dispatches locally.",
|
|
60
|
+
command: "export JULES_API_KEY=...",
|
|
61
|
+
blocking: false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const queueDir = join(root, ".agent", "queue");
|
|
66
|
+
const queued = existsSync(queueDir) ? readdirSync(queueDir).filter((f) => f.endsWith(".json") || f.endsWith(".yml")).length : 0;
|
|
67
|
+
if (queued > 0) {
|
|
68
|
+
return {
|
|
69
|
+
id: "queue",
|
|
70
|
+
headline: `${queued} task(s) waiting in the queue`,
|
|
71
|
+
detail: "Run them through the gate and dispatch pipeline.",
|
|
72
|
+
command: "agentctl queue",
|
|
73
|
+
blocking: false,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
id: "ready",
|
|
79
|
+
headline: "Set up and ready to dispatch",
|
|
80
|
+
detail: "Add --dry-run first to see the envelope without spending a task.",
|
|
81
|
+
command: 'agentctl dispatch -p "your task"',
|
|
82
|
+
blocking: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render the bare-invocation greeting: state, next step, and where the full
|
|
88
|
+
* command list lives for those who want it.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} ctx
|
|
91
|
+
* @param {string} ctx.version
|
|
92
|
+
* @param {string} ctx.root
|
|
93
|
+
* @param {{ headline: string, detail: string, command: string }} ctx.next
|
|
94
|
+
* @param {string} [ctx.budgetLine]
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function renderNextStep({ version, root, next, budgetLine }) {
|
|
98
|
+
const lines = [
|
|
99
|
+
``,
|
|
100
|
+
`🚀 agentctl v${version}`,
|
|
101
|
+
` ${root}`,
|
|
102
|
+
``,
|
|
103
|
+
` ${next.headline}`,
|
|
104
|
+
` ${next.detail}`,
|
|
105
|
+
``,
|
|
106
|
+
` Next: ${next.command}`,
|
|
107
|
+
];
|
|
108
|
+
if (budgetLine) lines.push(``, ` Budget: ${budgetLine}`);
|
|
109
|
+
lines.push(``, ` All commands: agentctl --help`, ``);
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
package/src/state.mjs
CHANGED
|
@@ -280,7 +280,12 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
const used = count + activeIds.size;
|
|
283
|
-
|
|
283
|
+
// `enforce: false` marks a limit the kit only guessed (a tier preset rather
|
|
284
|
+
// than a stated or provider-demonstrated figure). Blocking on a guess would
|
|
285
|
+
// refuse work the provider would happily have accepted, so an uncertain
|
|
286
|
+
// ceiling records the overrun and lets the call through to find out.
|
|
287
|
+
const overLimit = used >= limit;
|
|
288
|
+
if (overLimit && opts.enforce !== false) {
|
|
284
289
|
throw new BudgetError(`Daily budget exhausted (${used}/${limit} tasks executed)`);
|
|
285
290
|
}
|
|
286
291
|
|
|
@@ -303,12 +308,13 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
|
|
|
303
308
|
reservationId,
|
|
304
309
|
remaining: Math.max(0, limit - (used + 1)),
|
|
305
310
|
used: used + 1,
|
|
311
|
+
softLimitExceeded: overLimit,
|
|
306
312
|
};
|
|
307
313
|
}, opts);
|
|
308
314
|
}
|
|
309
315
|
|
|
310
|
-
export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300) {
|
|
311
|
-
return reserveBudgetAtomic(rootOrOpts, limit);
|
|
316
|
+
export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300, opts = {}) {
|
|
317
|
+
return reserveBudgetAtomic(rootOrOpts, limit, opts);
|
|
312
318
|
}
|
|
313
319
|
|
|
314
320
|
export function commitBudgetReservation(rootOrOpts = resolveRoot(), reservationId = "") {
|
|
@@ -321,8 +327,13 @@ export function rollbackBudgetReservation(rootOrOpts = resolveRoot(), reservatio
|
|
|
321
327
|
return appendLedger({ event: "budget_rolled_back", reservationId }, root);
|
|
322
328
|
}
|
|
323
329
|
|
|
324
|
-
|
|
325
|
-
|
|
330
|
+
/**
|
|
331
|
+
* @param {object} [opts]
|
|
332
|
+
* @param {boolean} [opts.enforce=true] - Pass false when `limit` is an estimate
|
|
333
|
+
* rather than a known allowance; the overrun is then recorded, not blocked.
|
|
334
|
+
*/
|
|
335
|
+
export async function withBudget(fn, root = resolveRoot(), limit = 300, opts = {}) {
|
|
336
|
+
const reservation = reserveBudget(root, limit, opts);
|
|
326
337
|
try {
|
|
327
338
|
const result = await fn();
|
|
328
339
|
commitBudgetReservation(root, reservation.reservationId);
|
package/src/version.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The kit version, read once from package.json.
|
|
6
|
+
*
|
|
7
|
+
* Every module that needed to name the version used to hardcode it, and they
|
|
8
|
+
* drifted: the CLI banner said 0.32.8 while the MCP server, the dashboard and
|
|
9
|
+
* the config the wizard scaffolded all still claimed 0.29.x. Reading the
|
|
10
|
+
* manifest is the only way the number cannot go stale.
|
|
11
|
+
*
|
|
12
|
+
* fileURLToPath keeps this correct on Windows, where a file:// URL pathname
|
|
13
|
+
* starts with a drive-letter slash that fs cannot open.
|
|
14
|
+
*/
|
|
15
|
+
function readKitVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
18
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
19
|
+
return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
|
|
20
|
+
} catch (_) {
|
|
21
|
+
// A consumer may vendor src/ without the manifest; a placeholder is better
|
|
22
|
+
// than an import-time crash in a library whose whole job is running gates.
|
|
23
|
+
return "0.0.0";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const KIT_VERSION = readKitVersion();
|
package/src/wizard-init.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, openSync, fsyncSync, closeSync, renameSync, mkdirSync, readdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { parseYaml } from "./config.mjs";
|
|
3
|
+
import { parseYaml, TIER_PRESETS, VENDOR_TIERS, FALLBACK_TIER } from "./config.mjs";
|
|
4
4
|
import { detectStackOracles, runVerificationProbe } from "./wizard-oracle.mjs";
|
|
5
5
|
import { select, multiSelect, input, confirm, spinner, isTTY } from "./tui.mjs";
|
|
6
|
+
import { KIT_VERSION } from "./version.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Write a file atomically using a temporary file and atomic rename.
|
|
@@ -21,27 +22,52 @@ function writeAtomic(filePath, content) {
|
|
|
21
22
|
renameSync(tmpPath, filePath);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Snake_case projection of {@link TIER_PRESETS} for the YAML the wizard writes.
|
|
27
|
+
* Derived rather than declared: as a second literal table it drifted out of sync
|
|
28
|
+
* with the runtime and scaffolded configs with limits nothing else agreed on.
|
|
29
|
+
*/
|
|
30
|
+
export const TIER_PROFILES = Object.fromEntries(
|
|
31
|
+
Object.entries(TIER_PRESETS).map(([name, p]) => [
|
|
32
|
+
name,
|
|
33
|
+
{
|
|
34
|
+
concurrency: p.concurrency,
|
|
35
|
+
daily_tasks: p.dailyTasks,
|
|
36
|
+
stagger_ms: p.staggerMs,
|
|
37
|
+
diff_kb: p.diffKb,
|
|
38
|
+
},
|
|
39
|
+
])
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const TIER_LABELS = {
|
|
43
|
+
free: "Free",
|
|
44
|
+
pro: "Pro",
|
|
45
|
+
ultra: "Ultra",
|
|
46
|
+
enterprise: "Custom / self-hosted pool",
|
|
43
47
|
};
|
|
44
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Build the tier menu from {@link TIER_PRESETS} so the prompt text cannot drift
|
|
51
|
+
* from the limits actually written. The hardcoded descriptions it replaces
|
|
52
|
+
* advertised numbers no tier had, and omitted `ultra` entirely.
|
|
53
|
+
*
|
|
54
|
+
* No option is marked "recommended": picking a plan the account does not have
|
|
55
|
+
* is precisely how the budget ends up guarding the wrong ceiling.
|
|
56
|
+
* @returns {Array<{ label: string, value: string, description: string }>}
|
|
57
|
+
*/
|
|
58
|
+
export function tierOptions() {
|
|
59
|
+
const order = [...VENDOR_TIERS, ...Object.keys(TIER_PRESETS).filter((t) => !VENDOR_TIERS.includes(t))];
|
|
60
|
+
return order.map((name) => {
|
|
61
|
+
const p = TIER_PRESETS[name];
|
|
62
|
+
const worker = p.concurrency === 1 ? "worker" : "workers";
|
|
63
|
+
return {
|
|
64
|
+
label: TIER_LABELS[name] || name,
|
|
65
|
+
value: name,
|
|
66
|
+
description: `${p.concurrency} ${worker}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
export const BUILTIN_PRESETS = [
|
|
46
72
|
{
|
|
47
73
|
id: "nightly-security-audit",
|
|
@@ -86,7 +112,9 @@ export const BUILTIN_PRESETS = [
|
|
|
86
112
|
export function planInit(root = process.cwd(), options = {}) {
|
|
87
113
|
const oracle = detectStackOracles(root);
|
|
88
114
|
const tierName = options.tier || "pro";
|
|
89
|
-
|
|
115
|
+
// An unrecognised name resolves the same way loadConfig() resolves it, so the
|
|
116
|
+
// scaffolded limits always match what the runtime will later enforce.
|
|
117
|
+
const limits = TIER_PROFILES[tierName] || TIER_PROFILES[FALLBACK_TIER];
|
|
90
118
|
|
|
91
119
|
// Preserve existing config if present
|
|
92
120
|
let existingConfig = {};
|
|
@@ -106,7 +134,7 @@ export function planInit(root = process.cwd(), options = {}) {
|
|
|
106
134
|
|
|
107
135
|
const selectedPresets = options.presets || existingConfig.presets || ["nightly-security-audit", "flaky-test-quarantine"];
|
|
108
136
|
|
|
109
|
-
const configYaml = `# Google Jules Orchestrator Kit Config (
|
|
137
|
+
const configYaml = `# Google Jules Orchestrator Kit Config (v${KIT_VERSION})
|
|
110
138
|
version: 1
|
|
111
139
|
provider: ${existingConfig.provider || "jules"}
|
|
112
140
|
tier: ${tierName}
|
|
@@ -203,12 +231,8 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
|
|
|
203
231
|
sp.stop(`Detected Stack: ${oracle.stack}`);
|
|
204
232
|
|
|
205
233
|
selectedTier = await select(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
{ label: "Free / Individual Tier", value: "free", description: "1 worker, 30 daily tasks, 50 KB diff limit" },
|
|
209
|
-
{ label: "Custom Enterprise", value: "enterprise", description: "10 workers, 1000 daily tasks, 100 KB diff limit" },
|
|
210
|
-
],
|
|
211
|
-
"Select Jules Orchestrator Usage Tier",
|
|
234
|
+
tierOptions(),
|
|
235
|
+
"Which plan does your Jules account use? (limits are adjustable later)",
|
|
212
236
|
options
|
|
213
237
|
);
|
|
214
238
|
|