jules-orchestrator-kit 0.32.8 → 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 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 **461 unit tests across 66 suites passing in < 10.0s**, supporting 300+ daily agent sessions per repository. 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.
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, checkDailyBudget, getQueueDir } from "../src/state.mjs";
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 = "0.32.8";
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 v0.32.8 — Universal Agent Orchestrator & Safety Gatekeeper
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 (!command || command === "--help" || command === "-h") {
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
- const budget = checkDailyBudget(root, config.limits.dailyTasks);
324
- console.log(` Daily Budget : ${budget.used} / ${budget.budget} sessions used`);
325
- console.log(`--------------------------------------------------\n`);
326
- process.exit(0);
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 = checkDailyBudget(root, config.limits.dailyTasks);
582
- console.log(` Daily Budget : ${budget.used} / ${budget.budget} used`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jules-orchestrator-kit",
3
- "version": "0.32.8",
3
+ "version": "0.33.0",
4
4
  "description": "Orchestration kit for running Google Jules autonomous agents.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- add(
85
- "agentctl VERSION const",
86
- constMatch?.[1] === version,
87
- constMatch ? `found "${constMatch[1]}", expected "${version}"` : "no `export const VERSION` found"
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
- const stale = [...cli.matchAll(/agentctl v(\d+\.\d+\.\d+)/g)].map((m) => m[1]).filter((v) => v !== version);
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
@@ -142,9 +142,13 @@ export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
142
142
  * explicit root to keep callers (notably tests) off the operator's real ledger.
143
143
  */
144
144
  export function reserveDailyBudget(maxSessions = 300, taskKey = "", root = resolveRoot()) {
145
- appendLedger({ event: "budget_reserved", key: taskKey }, root);
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);
146
150
  const check = checkDailyBudget(root, maxSessions);
147
- return { ok: check.ok, used: check.used, budget: maxSessions };
151
+ return { ok: check.ok, used: check.used, budget: maxSessions, reservationId };
148
152
  }
149
153
 
150
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: "0.29.1", root, ts: new Date().toISOString() }));
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";
@@ -746,6 +747,19 @@ export async function dispatch(task = {}, opts = {}) {
746
747
 
747
748
  const runDispatch = () => provider.dispatch(cleanTask, { root, dryRun: opts.dryRun });
748
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
+
749
763
  try {
750
764
  // A dry run performs no provider call and produces no work, so it must not
751
765
  // consume one of the operator's finite daily task slots. Reserving here also
@@ -753,9 +767,17 @@ export async function dispatch(task = {}, opts = {}) {
753
767
  // ledger and turned the suite red for reasons unrelated to any code change.
754
768
  const session = opts.dryRun
755
769
  ? await runDispatch()
756
- : await withBudget(runDispatch, root, config.limits.dailyTasks);
770
+ : await withBudget(runDispatch, root, budget.limit, { enforce: budget.certain });
757
771
  return classification ? { ...session, _routeTier: classification.tier, _routeReason: classification.reason } : session;
758
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
+ }
759
781
  if (err instanceof ProviderRateLimitError || err instanceof ProviderUnavailableError) {
760
782
  if (err.reservationId && !err.budgetReservationRolledBack) {
761
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 { checkDailyBudget, lockStatus } from "./state.mjs";
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: "0.29.1",
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 = checkDailyBudget(root, config.limits.dailyTasks);
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
- budget: { used: budget.used, limit: budget.budget, remaining: budget.budget - budget.used },
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 hasApiKey = Boolean(process.env.JULES_API_KEY || process.env.GEMINI_API_KEY);
397
- if (hasApiKey) {
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
- summary: "API key environment variable detected",
405
- evidence: [{ label: "keyConfigured", value: true, sensitive: false }],
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
- if (used >= limit) {
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
- export async function withBudget(fn, root = resolveRoot(), limit = 300) {
325
- const reservation = reserveBudget(root, limit);
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);
@@ -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();
@@ -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
- export const TIER_PROFILES = {
25
- free: {
26
- concurrency: 1,
27
- daily_tasks: 30,
28
- stagger_ms: 3000,
29
- diff_kb: 50,
30
- },
31
- pro: {
32
- concurrency: 3,
33
- daily_tasks: 300,
34
- stagger_ms: 1500,
35
- diff_kb: 75,
36
- },
37
- enterprise: {
38
- concurrency: 10,
39
- daily_tasks: 1000,
40
- stagger_ms: 500,
41
- diff_kb: 100,
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
- const limits = TIER_PROFILES[tierName] || TIER_PROFILES.pro;
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 (v0.29.0)
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
- { label: "Pro Tier (Recommended)", value: "pro", description: "3 parallel workers, 300 daily tasks, 75 KB diff limit" },
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