jules-orchestrator-kit 0.32.8 → 0.34.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.
@@ -1,7 +1,9 @@
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
+ import { loadConfig } from "../config.mjs";
6
+ import { resolveConcurrency } from "../budget.mjs";
5
7
 
6
8
  /**
7
9
  * @typedef {"pass" | "warn" | "fail" | "skip" | "unknown"} DiagnosticStatus
@@ -261,6 +263,41 @@ export async function runDoctorChecks(options = {}) {
261
263
  });
262
264
  }
263
265
 
266
+ // 3b. Worker slots against the plan's ceiling.
267
+ //
268
+ // A warning, never a failure: the kit cannot see the account, only the
269
+ // config. Pooled accounts legitimately exceed a single plan's ceiling, and
270
+ // the provider refuses what it will not allow regardless of what we think.
271
+ try {
272
+ const cfg = loadConfig(root);
273
+ const slots = resolveConcurrency(cfg);
274
+ addResult({
275
+ id: "limits.concurrency",
276
+ category: "Config",
277
+ title: "Worker Slots vs Plan Ceiling",
278
+ status: slots.overCeiling ? "warn" : "pass",
279
+ severity: slots.overCeiling ? "medium" : "info",
280
+ summary: `${slots.concurrency} concurrent worker(s) — ${slots.note}`,
281
+ evidence: [
282
+ { label: "concurrency", value: slots.concurrency, sensitive: false },
283
+ { label: "planCeiling", value: slots.ceiling, sensitive: false },
284
+ { label: "source", value: slots.source, sensitive: false },
285
+ ],
286
+ remediation: slots.overCeiling
287
+ ? [
288
+ {
289
+ summary: `Set limits.concurrency to ${slots.ceiling} or lower in .agent/config.yml, unless this account pools several plans`,
290
+ risk: "low",
291
+ automatic: false,
292
+ requiresProbe: false,
293
+ },
294
+ ]
295
+ : [],
296
+ });
297
+ } catch (_) {
298
+ // A config the loader rejects is already reported by config.present.
299
+ }
300
+
264
301
  // 4. Verification Oracle Checks
265
302
  const pkgPath = join(root, "package.json");
266
303
  if (existsSync(pkgPath)) {
@@ -393,16 +430,21 @@ export async function runDoctorChecks(options = {}) {
393
430
  }
394
431
 
395
432
  // 7. Jules Provider Key Check
396
- const hasApiKey = Boolean(process.env.JULES_API_KEY || process.env.GEMINI_API_KEY);
397
- if (hasApiKey) {
433
+ const keyVar = process.env.JULES_API_KEY ? "JULES_API_KEY" : process.env.GEMINI_API_KEY ? "GEMINI_API_KEY" : "";
434
+ if (keyVar) {
398
435
  addResult({
399
436
  id: "provider.key",
400
437
  category: "Jules",
401
438
  title: "Jules Provider API Key",
402
439
  status: "pass",
403
440
  severity: "info",
404
- summary: "API key environment variable detected",
405
- evidence: [{ label: "keyConfigured", value: true, sensitive: false }],
441
+ // Naming the variable, not the value: an operator who wonders which key a
442
+ // dispatch will use should not have to echo a secret to find out.
443
+ summary: `API key supplied via ${keyVar} (environment only — never written to config or sent anywhere but the provider)`,
444
+ evidence: [
445
+ { label: "keyConfigured", value: true, sensitive: false },
446
+ { label: "keySource", value: keyVar, sensitive: false },
447
+ ],
406
448
  });
407
449
  } else {
408
450
  addResult({
@@ -412,10 +454,50 @@ export async function runDoctorChecks(options = {}) {
412
454
  status: "warn",
413
455
  severity: "high",
414
456
  summary: "Neither JULES_API_KEY nor GEMINI_API_KEY environment variable is set",
457
+ remediation: [
458
+ {
459
+ summary: "Export JULES_API_KEY in your shell profile, or place it in a git-ignored .env",
460
+ risk: "low",
461
+ automatic: false,
462
+ requiresProbe: false,
463
+ },
464
+ ],
415
465
  evidence: [{ label: "keyConfigured", value: false, sensitive: false }],
416
466
  });
417
467
  }
418
468
 
469
+ // 7b. A .env holding the key must not be tracked by git.
470
+ const envFile = join(root, ".env");
471
+ if (existsSync(envFile)) {
472
+ let tracked = false;
473
+ try {
474
+ const res = spawnSync("git", ["ls-files", "--error-unmatch", ".env"], { cwd: root, encoding: "utf-8" });
475
+ tracked = res.status === 0;
476
+ } catch (_) {}
477
+
478
+ addResult({
479
+ id: "provider.key.dotenv",
480
+ category: "Jules",
481
+ title: "Local .env secrecy",
482
+ status: tracked ? "fail" : "pass",
483
+ severity: tracked ? "critical" : "info",
484
+ summary: tracked
485
+ ? ".env is tracked by git — an API key committed here is disclosed to everyone with repository access"
486
+ : ".env is present and untracked",
487
+ evidence: [{ label: "gitTracked", value: tracked, sensitive: false }],
488
+ remediation: tracked
489
+ ? [
490
+ {
491
+ summary: "Run: git rm --cached .env && echo '.env' >> .gitignore, then rotate the key",
492
+ risk: "low",
493
+ automatic: false,
494
+ requiresProbe: false,
495
+ },
496
+ ]
497
+ : [],
498
+ });
499
+ }
500
+
419
501
  // Summarize count by status
420
502
  const summary = { pass: 0, warn: 0, fail: 0, skip: 0, unknown: 0 };
421
503
  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
@@ -55,6 +55,129 @@ export function getDailyLedgerPath(rootOrOpts = resolveRoot()) {
55
55
  return join(getStateDir(root), `ledger-${dateStr}.jsonl`);
56
56
  }
57
57
 
58
+ /**
59
+ * How far back a task still counts against the allowance.
60
+ *
61
+ * The provider's daily quota resets on a rolling 24-hour window, not at
62
+ * midnight. Counting per calendar day — which the ledger's `ledger-<date>`
63
+ * rotation invites — is wrong in both directions: a batch dispatched at 23:00
64
+ * stops being counted at 00:01 while the provider still refuses on it, and
65
+ * yesterday's last hours vanish from a count that should still include them.
66
+ *
67
+ * The files stay day-scoped, because rotation is a storage concern. Counting
68
+ * is not: it spans whatever files the window touches and filters on entry
69
+ * timestamps.
70
+ */
71
+ export const ROLLING_WINDOW_MS = 24 * 60 * 60 * 1000;
72
+
73
+ const DAY_MS = 24 * 60 * 60 * 1000;
74
+
75
+ /**
76
+ * Ledger files that can hold entries inside the rolling window, oldest first.
77
+ *
78
+ * One day wider than the window itself: an entry timestamped 23:59 UTC sits in
79
+ * that day's file, and a window opening moments later still has to see it.
80
+ *
81
+ * @param {string} [root]
82
+ * @param {number} [now] - Epoch ms; injectable so tests need not wait a day.
83
+ * @param {number} [windowMs]
84
+ * @returns {string[]}
85
+ */
86
+ export function getLedgerPathsInWindow(root = resolveRoot(), now = Date.now(), windowMs = ROLLING_WINDOW_MS) {
87
+ const stateDir = getStateDir(root);
88
+ const spanDays = Math.ceil(windowMs / DAY_MS) + 1;
89
+ const paths = [];
90
+ for (let i = spanDays - 1; i >= 0; i--) {
91
+ const dateStr = new Date(now - i * DAY_MS).toISOString().split("T")[0];
92
+ const filePath = join(stateDir, `ledger-${dateStr}.jsonl`);
93
+ if (!paths.includes(filePath) && existsSync(filePath)) paths.push(filePath);
94
+ }
95
+ return paths;
96
+ }
97
+
98
+ /**
99
+ * Replay the budget events in the rolling window and report what is still spent.
100
+ *
101
+ * Reservations carrying a `reservationId` are matched by name. Legacy id-less
102
+ * ones — written by older kit versions — can only be matched by position, so a
103
+ * release without a `releasedTimestamp` consumes the oldest still-open
104
+ * anonymous reservation. `releaseOpenReservations` records that timestamp
105
+ * precisely so the pairing survives the window boundary: without it, an
106
+ * anonymous release could outlive the reservation it cancelled and start
107
+ * subtracting from a later one instead.
108
+ *
109
+ * Entries whose timestamp will not parse are counted. A budget event the kit
110
+ * cannot place in time is safer treated as spent than as free.
111
+ *
112
+ * @param {string} [root]
113
+ * @param {object} [opts]
114
+ * @param {number} [opts.now]
115
+ * @param {number} [opts.windowMs]
116
+ * @returns {{ used: number, open: { reservationId: string|null, timestamp: string, committed: boolean }[], windowStart: string }}
117
+ */
118
+ export function scanBudgetWindow(root = resolveRoot(), opts = {}) {
119
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
120
+ const windowMs = Number.isFinite(opts.windowMs) ? opts.windowMs : ROLLING_WINDOW_MS;
121
+ const cutoff = now - windowMs;
122
+
123
+ /** @type {Map<string, { reservationId: string, timestamp: string, committed: boolean, inWindow: boolean }>} */
124
+ const byId = new Map();
125
+ /** @type {{ reservationId: null, timestamp: string, committed: boolean, inWindow: boolean }[]} */
126
+ const anonymous = [];
127
+
128
+ const inWindow = (timestamp) => {
129
+ const ts = Date.parse(timestamp || "");
130
+ return Number.isFinite(ts) ? ts >= cutoff : true;
131
+ };
132
+
133
+ for (const filePath of getLedgerPathsInWindow(root, now, windowMs)) {
134
+ let raw = "";
135
+ try {
136
+ raw = readFileSync(filePath, "utf-8");
137
+ } catch (_) {
138
+ continue;
139
+ }
140
+ for (const line of raw.split("\n")) {
141
+ if (!line) continue;
142
+ let entry;
143
+ try {
144
+ entry = JSON.parse(line);
145
+ } catch (_) {
146
+ continue;
147
+ }
148
+ if (!entry || !entry.event) continue;
149
+ const timestamp = entry.timestamp || "";
150
+
151
+ if (entry.event === "budget_reserved") {
152
+ const record = { timestamp, committed: false, inWindow: inWindow(timestamp) };
153
+ if (entry.reservationId) byId.set(entry.reservationId, { reservationId: entry.reservationId, ...record });
154
+ else anonymous.push({ reservationId: null, ...record });
155
+ } else if (entry.event === "budget_committed") {
156
+ // A commit does not free the slot — the task really ran — it only marks
157
+ // the reservation as having reached the provider.
158
+ const record = entry.reservationId ? byId.get(entry.reservationId) : null;
159
+ if (record) record.committed = true;
160
+ } else if (entry.event === "budget_rolled_back" || entry.event === "budget_released") {
161
+ if (entry.reservationId) {
162
+ byId.delete(entry.reservationId);
163
+ } else if (entry.releasedTimestamp) {
164
+ const idx = anonymous.findIndex((r) => r.timestamp === entry.releasedTimestamp);
165
+ if (idx !== -1) anonymous.splice(idx, 1);
166
+ } else {
167
+ anonymous.shift();
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ const open = [...byId.values(), ...anonymous].filter((r) => r.inWindow);
174
+ return {
175
+ used: open.length,
176
+ open: open.map(({ reservationId, timestamp, committed }) => ({ reservationId, timestamp, committed })),
177
+ windowStart: new Date(cutoff).toISOString(),
178
+ };
179
+ }
180
+
58
181
  export class MutexTimeoutError extends Error {
59
182
  constructor(message = "Failed to acquire VFS mutex lock within timeout") {
60
183
  super(message);
@@ -187,43 +310,24 @@ export function readLedger(filePath) {
187
310
  }
188
311
  }
189
312
 
190
- export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300) {
313
+ /**
314
+ * Tasks still counted against the allowance, over the rolling 24-hour window.
315
+ *
316
+ * The name is kept for compatibility; "daily" here means the provider's day,
317
+ * which is the last 24 hours rather than the calendar one.
318
+ */
319
+ export function checkDailyBudget(arg1 = resolveRoot(), arg2 = 300, opts = {}) {
191
320
  let root = typeof arg1 === "string" ? arg1 : resolveRoot();
192
321
  let limit = typeof arg1 === "number" ? arg1 : typeof arg2 === "number" ? arg2 : 300;
193
322
 
194
- const filePath = getDailyLedgerPath(root);
195
- if (!existsSync(filePath)) {
196
- return { ok: true, used: 0, budget: limit, remaining: limit };
197
- }
198
323
  try {
199
- const content = readFileSync(filePath, "utf-8");
200
- const lines = content.split("\n").filter(Boolean);
201
- let count = 0;
202
- const activeIds = new Set();
203
- for (const line of lines) {
204
- try {
205
- const entry = JSON.parse(line);
206
- if (entry && entry.event === "budget_reserved") {
207
- if (entry.reservationId) {
208
- activeIds.add(entry.reservationId);
209
- } else {
210
- count++;
211
- }
212
- } else if (entry && (entry.event === "budget_rolled_back" || entry.event === "budget_released")) {
213
- if (entry.reservationId) {
214
- activeIds.delete(entry.reservationId);
215
- } else {
216
- count = Math.max(0, count - 1);
217
- }
218
- }
219
- } catch (_) {}
220
- }
221
- const used = count + activeIds.size;
324
+ const scan = scanBudgetWindow(root, opts);
222
325
  return {
223
- ok: used < limit,
224
- used,
326
+ ok: scan.used < limit,
327
+ used: scan.used,
225
328
  budget: limit,
226
- remaining: Math.max(0, limit - used),
329
+ remaining: Math.max(0, limit - scan.used),
330
+ windowStart: scan.windowStart,
227
331
  };
228
332
  } catch (_) {
229
333
  return { ok: true, used: 0, budget: limit, remaining: limit };
@@ -244,48 +348,43 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
244
348
  const mutexDir = join(stateDir, ".budget.mutex");
245
349
 
246
350
  return withVfsMutex(mutexDir, () => {
247
- const dateStr = new Date().toISOString().split("T")[0];
351
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
352
+ const dateStr = new Date(now).toISOString().split("T")[0];
248
353
  const filePath = join(stateDir, `ledger-${dateStr}.jsonl`);
249
354
 
250
- let count = 0;
251
- const activeIds = new Set();
355
+ // The count spans the rolling window; the hash chain does not. A chain is
356
+ // per file, so the new entry links to today's last hash even when the
357
+ // reservations it is counted against were written yesterday.
358
+ const used = scanBudgetWindow(root, { ...opts, now }).used;
252
359
  let prevHash = "0".repeat(64);
253
360
 
254
361
  if (existsSync(filePath)) {
255
362
  try {
256
363
  const raw = readFileSync(filePath, "utf-8");
257
364
  const lines = raw.split("\n").filter(Boolean);
258
- for (const line of lines) {
365
+ for (let i = lines.length - 1; i >= 0; i--) {
259
366
  try {
260
- const entry = JSON.parse(line);
261
- if (entry && entry.event === "budget_reserved") {
262
- if (entry.reservationId) {
263
- activeIds.add(entry.reservationId);
264
- } else {
265
- count++;
266
- }
267
- } else if (entry && (entry.event === "budget_rolled_back" || entry.event === "budget_released")) {
268
- if (entry.reservationId) {
269
- activeIds.delete(entry.reservationId);
270
- } else {
271
- count = Math.max(0, count - 1);
272
- }
273
- }
367
+ const entry = JSON.parse(lines[i]);
274
368
  if (entry && entry.hash) {
275
369
  prevHash = entry.hash;
370
+ break;
276
371
  }
277
372
  } catch (_) {}
278
373
  }
279
374
  } catch (_) {}
280
375
  }
281
376
 
282
- const used = count + activeIds.size;
283
- if (used >= limit) {
377
+ // `enforce: false` marks a limit the kit only guessed (a tier preset rather
378
+ // than a stated or provider-demonstrated figure). Blocking on a guess would
379
+ // refuse work the provider would happily have accepted, so an uncertain
380
+ // ceiling records the overrun and lets the call through to find out.
381
+ const overLimit = used >= limit;
382
+ if (overLimit && opts.enforce !== false) {
284
383
  throw new BudgetError(`Daily budget exhausted (${used}/${limit} tasks executed)`);
285
384
  }
286
385
 
287
- const timestamp = new Date().toISOString();
288
- const reservationId = `res-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
386
+ const timestamp = new Date(now).toISOString();
387
+ const reservationId = `res-${now}-${Math.random().toString(36).substring(2, 8)}`;
289
388
  const rawPayload = { timestamp, event: "budget_reserved", reservationId, budget: limit, prevHash };
290
389
  const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
291
390
  const payload = { ...rawPayload, hash };
@@ -303,12 +402,13 @@ export function reserveBudgetAtomic(stateDirOrRoot = resolveRoot(), limit = 300,
303
402
  reservationId,
304
403
  remaining: Math.max(0, limit - (used + 1)),
305
404
  used: used + 1,
405
+ softLimitExceeded: overLimit,
306
406
  };
307
407
  }, opts);
308
408
  }
309
409
 
310
- export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300) {
311
- return reserveBudgetAtomic(rootOrOpts, limit);
410
+ export function reserveBudget(rootOrOpts = resolveRoot(), limit = 300, opts = {}) {
411
+ return reserveBudgetAtomic(rootOrOpts, limit, opts);
312
412
  }
313
413
 
314
414
  export function commitBudgetReservation(rootOrOpts = resolveRoot(), reservationId = "") {
@@ -321,8 +421,13 @@ export function rollbackBudgetReservation(rootOrOpts = resolveRoot(), reservatio
321
421
  return appendLedger({ event: "budget_rolled_back", reservationId }, root);
322
422
  }
323
423
 
324
- export async function withBudget(fn, root = resolveRoot(), limit = 300) {
325
- const reservation = reserveBudget(root, limit);
424
+ /**
425
+ * @param {object} [opts]
426
+ * @param {boolean} [opts.enforce=true] - Pass false when `limit` is an estimate
427
+ * rather than a known allowance; the overrun is then recorded, not blocked.
428
+ */
429
+ export async function withBudget(fn, root = resolveRoot(), limit = 300, opts = {}) {
430
+ const reservation = reserveBudget(root, limit, opts);
326
431
  try {
327
432
  const result = await fn();
328
433
  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,57 @@ 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
+ // The ceiling is shown next to the default so the number the wizard writes
64
+ // reads as a starting point rather than as the plan's limit.
65
+ const slots = p.maxConcurrency > p.concurrency
66
+ ? `${p.concurrency} ${worker} (plan allows ${p.maxConcurrency})`
67
+ : `${p.concurrency} ${worker}`;
68
+ return {
69
+ label: TIER_LABELS[name] || name,
70
+ value: name,
71
+ description: `${slots}, ~${p.dailyTasks} daily tasks, ${p.diffKb} KB diff limit`,
72
+ };
73
+ });
74
+ }
75
+
45
76
  export const BUILTIN_PRESETS = [
46
77
  {
47
78
  id: "nightly-security-audit",
@@ -86,7 +117,9 @@ export const BUILTIN_PRESETS = [
86
117
  export function planInit(root = process.cwd(), options = {}) {
87
118
  const oracle = detectStackOracles(root);
88
119
  const tierName = options.tier || "pro";
89
- const limits = TIER_PROFILES[tierName] || TIER_PROFILES.pro;
120
+ // An unrecognised name resolves the same way loadConfig() resolves it, so the
121
+ // scaffolded limits always match what the runtime will later enforce.
122
+ const limits = TIER_PROFILES[tierName] || TIER_PROFILES[FALLBACK_TIER];
90
123
 
91
124
  // Preserve existing config if present
92
125
  let existingConfig = {};
@@ -106,7 +139,7 @@ export function planInit(root = process.cwd(), options = {}) {
106
139
 
107
140
  const selectedPresets = options.presets || existingConfig.presets || ["nightly-security-audit", "flaky-test-quarantine"];
108
141
 
109
- const configYaml = `# Google Jules Orchestrator Kit Config (v0.29.0)
142
+ const configYaml = `# Google Jules Orchestrator Kit Config (v${KIT_VERSION})
110
143
  version: 1
111
144
  provider: ${existingConfig.provider || "jules"}
112
145
  tier: ${tierName}
@@ -203,12 +236,8 @@ export async function runInitWizard(root = process.cwd(), options = {}) {
203
236
  sp.stop(`Detected Stack: ${oracle.stack}`);
204
237
 
205
238
  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",
239
+ tierOptions(),
240
+ "Which plan does your Jules account use? (limits are adjustable later)",
212
241
  options
213
242
  );
214
243