@rulvar/cli 1.16.1 → 1.17.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,8 +1,8 @@
1
1
  import { ConfigError, FileModelKnowledgeStore, INBOX_PROPOSAL_TTL_DAYS, JsonlFileStore, claimExpired, claimExpiry, compilePermissionPreset, costReportFromJournal, createEngine, parseModelRef, priceUsdOf, proposalStatement, remeasureQueue, resolvePricing, runProfile } from "@rulvar/core";
2
- import { parseArgs } from "node:util";
3
2
  import { join, resolve } from "node:path";
4
3
  import { existsSync, statSync } from "node:fs";
5
4
  import { pathToFileURL } from "node:url";
5
+ import { parseArgs } from "node:util";
6
6
  import { createInterface } from "node:readline";
7
7
  //#region src/config.ts
8
8
  /**
@@ -302,6 +302,254 @@ function reportOutcome(outcome, io) {
302
302
  }
303
303
  }
304
304
  //#endregion
305
+ //#region src/grammar.ts
306
+ /**
307
+ * The canonical CLI grammar as data (v1.16.2 review P3-1): one
308
+ * structure generates the help block, every per-command usage line,
309
+ * and the docs contract test, so the three surfaces can never drift
310
+ * apart again. Parsing goes through parseCommand, which converts
311
+ * node:util parseArgs failures into the fail-loud ConfigError grammar
312
+ * the CLI documents (v1.16.2 review P2-1): nothing is ignored; unknown
313
+ * flags, duplicate value flags, mutually exclusive pairs, and wrong
314
+ * positional arity all fail naming the canonical usage.
315
+ */
316
+ const ARGS = {
317
+ name: "args",
318
+ placeholder: "JSON"
319
+ };
320
+ const STORE = {
321
+ name: "store",
322
+ placeholder: "PATH"
323
+ };
324
+ /**
325
+ * Every command of the canonical grammar (no aliases in v1). The help
326
+ * block, the per-command usage errors, and the docs grammar block in
327
+ * docs/guide/cli.md all derive from this table; the docs contract test
328
+ * compares them literally.
329
+ */
330
+ const GRAMMAR = {
331
+ run: {
332
+ command: "run",
333
+ positionals: ["<file|name>"],
334
+ flags: [
335
+ ARGS,
336
+ STORE,
337
+ {
338
+ name: "budget-usd",
339
+ placeholder: "N"
340
+ },
341
+ {
342
+ name: "profile",
343
+ placeholder: "NAME"
344
+ }
345
+ ]
346
+ },
347
+ resume: {
348
+ command: "resume",
349
+ positionals: ["<runId>"],
350
+ flags: [ARGS, STORE]
351
+ },
352
+ "runs ls": {
353
+ command: "runs ls",
354
+ positionals: [],
355
+ flags: [STORE],
356
+ note: "(no aliases in v1)"
357
+ },
358
+ inspect: {
359
+ command: "inspect",
360
+ positionals: ["<runId>"],
361
+ flags: [STORE]
362
+ },
363
+ plan: {
364
+ command: "plan",
365
+ positionals: ["\"<goal>\""],
366
+ flags: [
367
+ {
368
+ name: "planning-budget-usd",
369
+ placeholder: "N"
370
+ },
371
+ {
372
+ name: "budget-usd",
373
+ placeholder: "N"
374
+ },
375
+ { name: "allow-unbounded" },
376
+ { name: "dry-run" }
377
+ ]
378
+ },
379
+ "kb list": {
380
+ command: "kb list",
381
+ positionals: [],
382
+ flags: []
383
+ },
384
+ "kb inbox": {
385
+ command: "kb inbox",
386
+ positionals: [],
387
+ flags: [STORE]
388
+ },
389
+ "kb gate": {
390
+ command: "kb gate",
391
+ positionals: ["<runId>", "<entryRef>"],
392
+ flags: [
393
+ {
394
+ name: "approver",
395
+ placeholder: "NAME",
396
+ required: true
397
+ },
398
+ {
399
+ name: "ruled-out",
400
+ placeholder: "a,b,c",
401
+ required: true
402
+ },
403
+ {
404
+ name: "contrast-run",
405
+ placeholder: "runId#seq",
406
+ exclusiveGroup: "contrast"
407
+ },
408
+ {
409
+ name: "contrast-eval",
410
+ placeholder: "reportId:caseId[,caseId...]",
411
+ exclusiveGroup: "contrast"
412
+ },
413
+ {
414
+ name: "confidence",
415
+ placeholder: "high|medium|low"
416
+ },
417
+ STORE
418
+ ]
419
+ },
420
+ "kb sweep": {
421
+ command: "kb sweep",
422
+ positionals: [],
423
+ flags: [],
424
+ note: "(configuration lives in rulvar.config.mjs)"
425
+ }
426
+ };
427
+ /** The kb dispatch line: subcommands carry their own grammar entries. */
428
+ const KB_FAMILY_USAGE = "usage: rulvar kb <list | inbox | gate | sweep> (no aliases in v1)";
429
+ function renderFlags(flags) {
430
+ const tokens = [];
431
+ const renderedGroups = /* @__PURE__ */ new Set();
432
+ for (const flag of flags) {
433
+ if (flag.exclusiveGroup !== void 0) {
434
+ if (renderedGroups.has(flag.exclusiveGroup)) continue;
435
+ renderedGroups.add(flag.exclusiveGroup);
436
+ const members = flags.filter((candidate) => candidate.exclusiveGroup === flag.exclusiveGroup).map((member) => `--${member.name} ${member.placeholder ?? ""}`.trim());
437
+ tokens.push(`[${members.join(" | ")}]`);
438
+ continue;
439
+ }
440
+ const body = flag.placeholder === void 0 ? `--${flag.name}` : `--${flag.name} ${flag.placeholder}`;
441
+ tokens.push(flag.required === true ? body : `[${body}]`);
442
+ }
443
+ return tokens;
444
+ }
445
+ /** The full invocation shape, e.g. `rulvar run <file|name> [--args JSON] ...`. */
446
+ function invocationOf(grammar) {
447
+ const parts = [
448
+ `rulvar ${grammar.command}`,
449
+ ...grammar.positionals,
450
+ ...renderFlags(grammar.flags)
451
+ ];
452
+ if (grammar.note !== void 0) parts.push(grammar.note);
453
+ return parts.join(" ");
454
+ }
455
+ /** The canonical usage error line for a command. */
456
+ function usageOf(grammar) {
457
+ return `usage: ${invocationOf(grammar)}`;
458
+ }
459
+ /**
460
+ * The command lines of the help block: every top-level command plus the
461
+ * kb family line, aligned on the flag column. The docs grammar block is
462
+ * the same list verbatim (docs contract test).
463
+ */
464
+ function helpCommandLines() {
465
+ const top = [
466
+ GRAMMAR.run,
467
+ GRAMMAR.resume,
468
+ GRAMMAR["runs ls"],
469
+ GRAMMAR.inspect,
470
+ GRAMMAR.plan
471
+ ];
472
+ const heads = top.map((grammar) => [
473
+ "rulvar",
474
+ grammar.command,
475
+ ...grammar.positionals
476
+ ].join(" "));
477
+ const kbHead = "rulvar kb <list | inbox | gate | sweep>";
478
+ const width = Math.max(...heads.map((head) => head.length), 39);
479
+ const lines = top.map((grammar, index) => {
480
+ const tail = renderFlags(grammar.flags).join(" ");
481
+ return tail === "" ? heads[index] : `${heads[index].padEnd(width)} ${tail}`;
482
+ });
483
+ lines.push(kbHead);
484
+ return lines;
485
+ }
486
+ function isParseArgsError(error) {
487
+ if (!(error instanceof Error)) return false;
488
+ const code = error.code;
489
+ return typeof code === "string" && code.startsWith("ERR_PARSE_ARGS");
490
+ }
491
+ /**
492
+ * Parses argv against one grammar entry. Everything the grammar does
493
+ * not name is an error: unknown options (raised by parseArgs), value
494
+ * flags given twice, both members of an exclusive group, and any
495
+ * positional beyond the exact arity. Every rejection names the
496
+ * canonical usage and happens before configs, stores, or adapters load.
497
+ */
498
+ function parseCommand(grammar, argv) {
499
+ const options = {};
500
+ for (const flag of grammar.flags) options[flag.name] = {
501
+ type: flag.placeholder === void 0 ? "boolean" : "string",
502
+ multiple: true
503
+ };
504
+ let raw;
505
+ try {
506
+ raw = parseArgs({
507
+ args: argv,
508
+ allowPositionals: true,
509
+ options
510
+ });
511
+ } catch (error) {
512
+ if (isParseArgsError(error)) {
513
+ const summary = error.message.split(".")[0];
514
+ throw new ConfigError(`${summary}; ${usageOf(grammar)}`);
515
+ }
516
+ throw error;
517
+ }
518
+ if (raw.positionals.length < grammar.positionals.length) throw new ConfigError(usageOf(grammar));
519
+ if (raw.positionals.length > grammar.positionals.length) {
520
+ const extra = raw.positionals[grammar.positionals.length];
521
+ throw new ConfigError(`unexpected extra argument '${extra}'; ${usageOf(grammar)}`);
522
+ }
523
+ const values = {};
524
+ const groupMembers = /* @__PURE__ */ new Map();
525
+ for (const flag of grammar.flags) {
526
+ const entry = raw.values[flag.name];
527
+ if (entry === void 0) continue;
528
+ if (flag.placeholder !== void 0 && entry.length > 1) throw new ConfigError(`--${flag.name} may appear at most once; ${usageOf(grammar)}`);
529
+ values[flag.name] = flag.placeholder === void 0 ? true : entry[entry.length - 1];
530
+ if (flag.exclusiveGroup !== void 0) {
531
+ const members = groupMembers.get(flag.exclusiveGroup) ?? [];
532
+ members.push(`--${flag.name}`);
533
+ groupMembers.set(flag.exclusiveGroup, members);
534
+ }
535
+ }
536
+ for (const members of groupMembers.values()) if (members.length > 1) throw new ConfigError(`${members.join(" and ")} are mutually exclusive; ${usageOf(grammar)}`);
537
+ return {
538
+ positionals: raw.positionals,
539
+ values
540
+ };
541
+ }
542
+ /**
543
+ * Validates a `--...-usd` flag value: a finite dollar amount strictly
544
+ * above zero (0, NaN, Infinity, negatives, and non-numbers all fail),
545
+ * checked at parse time, before any provider work.
546
+ */
547
+ function parseBudgetValue(flagName, value) {
548
+ const budget = Number(value);
549
+ if (!Number.isFinite(budget) || budget <= 0) throw new ConfigError(`--${flagName} must be a positive number, got '${value}'`);
550
+ return budget;
551
+ }
552
+ //#endregion
305
553
  //#region src/commands.ts
306
554
  /**
307
555
  * The four M5 commands of the canonical CLI grammar (no aliases in v1):
@@ -314,61 +562,60 @@ function reportOutcome(outcome, io) {
314
562
  * `plan` and `kb` land with M6+/M10. Every command builds strictly from
315
563
  * the public @rulvar/core API.
316
564
  */
317
- function parseRunFlags(argv) {
318
- const { values, positionals } = parseArgs({
319
- args: argv,
320
- allowPositionals: true,
321
- options: {
322
- args: { type: "string" },
323
- store: { type: "string" },
324
- "budget-usd": { type: "string" },
325
- profile: { type: "string" }
326
- }
327
- });
328
- const parsed = { positionals };
329
- if (values.store !== void 0) parsed.store = values.store;
330
- if (values.profile !== void 0) parsed.profile = values.profile;
331
- if (values.args !== void 0) parsed.args = values.args;
332
- if (values["budget-usd"] !== void 0) {
333
- const budget = Number(values["budget-usd"]);
334
- if (!Number.isFinite(budget) || budget <= 0) throw new ConfigError(`--budget-usd must be a positive number, got '${values["budget-usd"]}'`);
335
- parsed.budgetUsd = budget;
336
- }
337
- return parsed;
338
- }
339
- function parseCommonFlags(argv) {
340
- const { values, positionals } = parseArgs({
341
- args: argv,
342
- allowPositionals: true,
343
- options: { store: { type: "string" } }
344
- });
345
- return {
346
- positionals,
347
- ...values.store === void 0 ? {} : { store: values.store }
348
- };
565
+ /**
566
+ * True exactly when a companion dynamic import failed because THAT
567
+ * package is not installed (the v1.16.1 review P1): the Node code must
568
+ * be ERR_MODULE_NOT_FOUND and the quoted missing specifier must be the
569
+ * requested companion itself. A transitive miss inside a found
570
+ * companion (same code, different quoted specifier) or any throw during
571
+ * module evaluation is that package's own defect, never install advice.
572
+ */
573
+ function isCompanionMissing(error, specifier) {
574
+ return error instanceof Error && error.code === "ERR_MODULE_NOT_FOUND" && error.message.includes(`'${specifier}'`);
575
+ }
576
+ /**
577
+ * Awaits a command-local companion import. Call sites keep the literal
578
+ * `import('@rulvar/...')` so the specifier stays analyzable and the
579
+ * tsdown external rule preserves it in dist. Missing package: the
580
+ * friendly ConfigError install hint. Anything else: the original error
581
+ * survives as `cause` under a command-prefixed message.
582
+ */
583
+ async function loadCompanion(loading, specifier, command, missingMessage) {
584
+ try {
585
+ return await loading;
586
+ } catch (error) {
587
+ if (isCompanionMissing(error, specifier)) throw new ConfigError(missingMessage);
588
+ throw new Error(`${command}: ${specifier} is installed but failed to load; the cause below is a defect in the installed package or its dependencies, not a missing install`, { cause: error });
589
+ }
590
+ }
591
+ /** Parses --args JSON into workflow arguments; undefined when absent. */
592
+ function parseArgsJson(raw) {
593
+ if (raw === void 0) return;
594
+ try {
595
+ return JSON.parse(raw);
596
+ } catch {
597
+ throw new ConfigError(`--args is not valid JSON: ${raw}`);
598
+ }
349
599
  }
350
600
  async function runCommand(argv, context) {
351
- const flags = parseRunFlags(argv);
352
- const target = flags.positionals[0];
353
- if (target === void 0) throw new ConfigError("usage: rulvar run <file|name> [--args JSON] [--store PATH] [--budget-usd N]");
601
+ const parsed = parseCommand(GRAMMAR.run, argv);
602
+ const target = parsed.positionals[0];
603
+ const store = parsed.values.store;
604
+ const profile = parsed.values.profile;
605
+ const budgetUsd = parsed.values["budget-usd"] === void 0 ? void 0 : parseBudgetValue("budget-usd", parsed.values["budget-usd"]);
606
+ const args = parseArgsJson(parsed.values.args);
354
607
  const config = await loadCliConfig(context.cwd);
355
608
  const module = looksLikeFile(target) ? await loadWorkflowModule(target, context.cwd) : void 0;
356
609
  const assembled = assembleEngine({
357
610
  config,
358
611
  ...module === void 0 ? {} : { module },
359
- ...flags.store === void 0 ? {} : { storePath: flags.store },
360
- ...flags.profile === void 0 ? {} : { profile: flags.profile },
612
+ ...store === void 0 ? {} : { storePath: store },
613
+ ...profile === void 0 ? {} : { profile },
361
614
  cwd: context.cwd
362
615
  });
363
616
  const workflow = module?.workflow ?? assembled.workflows[target];
364
617
  if (workflow === void 0) throw new ConfigError(looksLikeFile(target) ? `${target} exports no workflow (default export or named 'workflow')` : `no workflow named '${target}' in the registry; register it in rulvar.config.mjs`);
365
- let args;
366
- if (flags.args !== void 0) try {
367
- args = JSON.parse(flags.args);
368
- } catch {
369
- throw new ConfigError(`--args is not valid JSON: ${flags.args}`);
370
- }
371
- const runOptions = { ...flags.budgetUsd === void 0 ? {} : { budgetUsd: flags.budgetUsd } };
618
+ const runOptions = { ...budgetUsd === void 0 ? {} : { budgetUsd } };
372
619
  const first = assembled.engine.run(workflow, args, runOptions);
373
620
  context.io.err(`runId: ${first.runId}`);
374
621
  return reportOutcome(await driveRun({
@@ -380,18 +627,13 @@ async function runCommand(argv, context) {
380
627
  }), context.io);
381
628
  }
382
629
  async function resumeCommand(argv, context) {
383
- const flags = parseRunFlags(argv);
384
- const runId = flags.positionals[0];
385
- if (runId === void 0) throw new ConfigError("usage: rulvar resume <runId> [--args JSON] [--store PATH]");
386
- let args;
387
- if (flags.args !== void 0) try {
388
- args = JSON.parse(flags.args);
389
- } catch {
390
- throw new ConfigError(`--args is not valid JSON: ${flags.args}`);
391
- }
630
+ const parsed = parseCommand(GRAMMAR.resume, argv);
631
+ const runId = parsed.positionals[0];
632
+ const args = parseArgsJson(parsed.values.args);
633
+ const store = parsed.values.store;
392
634
  const assembled = assembleEngine({
393
635
  config: await loadCliConfig(context.cwd),
394
- ...flags.store === void 0 ? {} : { storePath: flags.store },
636
+ ...store === void 0 ? {} : { storePath: store },
395
637
  cwd: context.cwd
396
638
  });
397
639
  const meta = (await assembled.store.listRuns()).find((m) => m.runId === runId);
@@ -409,10 +651,10 @@ async function resumeCommand(argv, context) {
409
651
  }), context.io);
410
652
  }
411
653
  async function runsLsCommand(argv, context) {
412
- const flags = parseCommonFlags(argv);
654
+ const store = parseCommand(GRAMMAR["runs ls"], argv).values.store;
413
655
  const metas = await assembleEngine({
414
656
  config: await loadCliConfig(context.cwd),
415
- ...flags.store === void 0 ? {} : { storePath: flags.store },
657
+ ...store === void 0 ? {} : { storePath: store },
416
658
  cwd: context.cwd
417
659
  }).store.listRuns();
418
660
  if (metas.length === 0) {
@@ -427,12 +669,12 @@ async function runsLsCommand(argv, context) {
427
669
  return 0;
428
670
  }
429
671
  async function inspectCommand(argv, context) {
430
- const flags = parseCommonFlags(argv);
431
- const runId = flags.positionals[0];
432
- if (runId === void 0) throw new ConfigError("usage: rulvar inspect <runId> [--store PATH]");
672
+ const parsed = parseCommand(GRAMMAR.inspect, argv);
673
+ const runId = parsed.positionals[0];
674
+ const store = parsed.values.store;
433
675
  const assembled = assembleEngine({
434
676
  config: await loadCliConfig(context.cwd),
435
- ...flags.store === void 0 ? {} : { storePath: flags.store },
677
+ ...store === void 0 ? {} : { storePath: store },
436
678
  cwd: context.cwd
437
679
  });
438
680
  const meta = (await assembled.store.listRuns()).find((m) => m.runId === runId);
@@ -465,39 +707,44 @@ async function inspectCommand(argv, context) {
465
707
  return 0;
466
708
  }
467
709
  /**
468
- * rulvar plan "<goal>" [--dry-run] (M6-T11): plans a
469
- * workflow script through @rulvar/planner (loaded dynamically: the CLI's
470
- * static dependency stays @rulvar/core only),
471
- * prints the accepted script and its advisories, and runs it in the
472
- * worker sandbox unless --dry-run.
710
+ * rulvar plan (M6-T11; grammar in grammar.ts): plans a workflow script
711
+ * through @rulvar/planner (loaded dynamically: the CLI's static
712
+ * dependency stays @rulvar/core only), prints the accepted script and
713
+ * its advisories, and runs it in the worker sandbox unless --dry-run.
714
+ *
715
+ * Both stages are paid runs with their OWN immutable ceilings (the
716
+ * v1.16.2 review P1-1): --planning-budget-usd caps the planning run
717
+ * (PlanOptions.run.budgetUsd, frozen at the planning journal's
718
+ * genesis), --budget-usd caps the execution run (RunOptions.budgetUsd,
719
+ * consistent with rulvar run). A machine-written workflow never runs
720
+ * unbounded silently: missing ceilings fail loudly unless
721
+ * --allow-unbounded waives them explicitly, and an execution ceiling
722
+ * beside --dry-run is a contradiction, not an ignorable leftover.
473
723
  */
474
724
  async function planCommand(argv, context) {
475
- const parsed = parseArgs({
476
- args: argv,
477
- allowPositionals: true,
478
- options: { "dry-run": { type: "boolean" } }
479
- });
725
+ const parsed = parseCommand(GRAMMAR.plan, argv);
480
726
  const goal = parsed.positionals[0];
481
- if (goal === void 0 || parsed.positionals.length > 1) throw new ConfigError("usage: rulvar plan \"<goal>\" [--dry-run]");
482
- let plannerModule;
483
- try {
484
- plannerModule = await import("./dist-D9g28MUP.js");
485
- } catch {
486
- throw new ConfigError("rulvar plan requires @rulvar/planner (the plan agent, compileScript, and the worker sandbox live there); install it next to the CLI");
487
- }
727
+ const dryRun = parsed.values["dry-run"] === true;
728
+ const allowUnbounded = parsed.values["allow-unbounded"] === true;
729
+ const planningBudgetUsd = parsed.values["planning-budget-usd"] === void 0 ? void 0 : parseBudgetValue("planning-budget-usd", parsed.values["planning-budget-usd"]);
730
+ const executionBudgetUsd = parsed.values["budget-usd"] === void 0 ? void 0 : parseBudgetValue("budget-usd", parsed.values["budget-usd"]);
731
+ if (dryRun && executionBudgetUsd !== void 0) throw new ConfigError("--dry-run never executes the planned workflow, so --budget-usd (the execution ceiling) has nothing to bound; drop one of the two");
732
+ if (!allowUnbounded && planningBudgetUsd === void 0) throw new ConfigError("rulvar plan runs the planner model as a paid run; set --planning-budget-usd N (its immutable ceiling) or waive it explicitly with --allow-unbounded");
733
+ if (!allowUnbounded && !dryRun && executionBudgetUsd === void 0) throw new ConfigError("executing the planned workflow is a second paid run; set --budget-usd N (its immutable ceiling) or waive it explicitly with --allow-unbounded");
734
+ const plannerModule = await loadCompanion(import("@rulvar/planner"), "@rulvar/planner", "rulvar plan", "rulvar plan requires @rulvar/planner (the plan agent, compileScript, and the worker sandbox live there); install it next to the CLI");
488
735
  const assembled = assembleEngine({
489
736
  config: await loadCliConfig(context.cwd),
490
737
  cwd: context.cwd
491
738
  });
492
- const planned = await plannerModule.plan(assembled.engine, goal);
739
+ const planned = await plannerModule.plan(assembled.engine, goal, planningBudgetUsd === void 0 ? void 0 : { run: { budgetUsd: planningBudgetUsd } });
493
740
  context.io.err(`plan: accepted with ${String(planned.lint.length)} advisory diagnostic(s)`);
494
741
  for (const diagnostic of planned.lint) context.io.err(` ${diagnostic.ruleId}: ${diagnostic.message}`);
495
- if (parsed.values["dry-run"] === true) {
742
+ if (dryRun) {
496
743
  context.io.out(planned.source);
497
744
  return 0;
498
745
  }
499
746
  const workflow = planned.workflow;
500
- const first = assembled.engine.run(workflow, null);
747
+ const first = assembled.engine.run(workflow, null, executionBudgetUsd === void 0 ? {} : { budgetUsd: executionBudgetUsd });
501
748
  context.io.err(`runId: ${first.runId}`);
502
749
  return reportOutcome(await driveRun({
503
750
  engine: assembled.engine,
@@ -519,7 +766,8 @@ async function kbCommand(argv, context) {
519
766
  if (sub === "inbox") return await kbInboxCommand(rest, context);
520
767
  if (sub === "gate") return await kbGateCommand(rest, context);
521
768
  if (sub === "sweep") return await kbSweepCommand(rest, context);
522
- if (sub !== "list" || rest.length > 0) throw new ConfigError("usage: rulvar kb <list | inbox | gate | sweep> (no aliases in v1)");
769
+ if (sub !== "list") throw new ConfigError(KB_FAMILY_USAGE);
770
+ parseCommand(GRAMMAR["kb list"], rest);
523
771
  const snapshot = await new FileModelKnowledgeStore({ path: join(context.cwd, "rulvar.models.json") }).current();
524
772
  context.io.out(`knowledge store: rulvar.models.json (version ${String(snapshot.version)}, ${String(snapshot.claims.length)} claim${snapshot.claims.length === 1 ? "" : "s"})`);
525
773
  renderKbList(snapshot, context);
@@ -553,14 +801,8 @@ function renderKbList(snapshot, context) {
553
801
  * concrete model names render here VERBATIM, exactly like kb list.
554
802
  */
555
803
  async function kbInboxCommand(argv, context) {
556
- const flags = parseCommonFlags(argv);
557
- if (flags.positionals.length > 0) throw new ConfigError("usage: rulvar kb inbox [--store PATH]");
558
- let plan;
559
- try {
560
- plan = await import("./dist-A5zLp_kr.js");
561
- } catch {
562
- throw new ConfigError("rulvar kb inbox requires @rulvar/plan (the RunLedger fold behind the LedgerExport seam)");
563
- }
804
+ const flags = { store: parseCommand(GRAMMAR["kb inbox"], argv).values.store };
805
+ const plan = await loadCompanion(import("@rulvar/plan"), "@rulvar/plan", "rulvar kb inbox", "rulvar kb inbox requires @rulvar/plan (the RunLedger fold behind the LedgerExport seam)");
564
806
  const assembled = assembleEngine({
565
807
  config: await loadCliConfig(context.cwd),
566
808
  ...flags.store === void 0 ? {} : { storePath: flags.store },
@@ -646,22 +888,11 @@ const RULED_OUT_VOCABULARY = [
646
888
  * per-project file store, whose git review is the authenticating gate.
647
889
  */
648
890
  async function kbGateCommand(argv, context) {
649
- const { values, positionals } = parseArgs({
650
- args: argv,
651
- allowPositionals: true,
652
- options: {
653
- store: { type: "string" },
654
- approver: { type: "string" },
655
- "ruled-out": { type: "string" },
656
- "contrast-run": { type: "string" },
657
- "contrast-eval": { type: "string" },
658
- confidence: { type: "string" }
659
- }
660
- });
661
- const usage = "usage: rulvar kb gate <runId> <entryRef> --approver NAME --ruled-out a,b,c [--contrast-run runId#seq | --contrast-eval reportId:caseId[,caseId...]] [--confidence high|medium|low] [--store PATH]";
662
- const runId = positionals[0];
663
- const entryRefRaw = positionals[1];
664
- if (runId === void 0 || entryRefRaw === void 0 || positionals.length > 2) throw new ConfigError(usage);
891
+ const parsed = parseCommand(GRAMMAR["kb gate"], argv);
892
+ const values = parsed.values;
893
+ const usage = usageOf(GRAMMAR["kb gate"]);
894
+ const runId = parsed.positionals[0];
895
+ const entryRefRaw = parsed.positionals[1];
665
896
  const entryRef = Number(entryRefRaw);
666
897
  if (!Number.isInteger(entryRef) || entryRef < 1) throw new ConfigError(`entryRef must be a positive integer entry seq, got '${entryRefRaw}'`);
667
898
  const approver = values.approver;
@@ -670,7 +901,6 @@ async function kbGateCommand(argv, context) {
670
901
  if (ruledOutRaw === void 0 || ruledOutRaw === "") throw new ConfigError(`--ruled-out is required: the attribution attestation lists the alternative causes you ruled out (${RULED_OUT_VOCABULARY.join(", ")}). ${usage}`);
671
902
  const ruledOut = ruledOutRaw.split(",").map((entry) => entry.trim());
672
903
  for (const entry of ruledOut) if (!RULED_OUT_VOCABULARY.includes(entry)) throw new ConfigError(`--ruled-out '${entry}' is not in the attestation vocabulary (${RULED_OUT_VOCABULARY.join(", ")})`);
673
- if (values["contrast-run"] !== void 0 && values["contrast-eval"] !== void 0) throw new ConfigError("--contrast-run and --contrast-eval are mutually exclusive");
674
904
  let contrastEvidence;
675
905
  if (values["contrast-run"] !== void 0) {
676
906
  const [contrastRun, seqRaw, ...tail] = values["contrast-run"].split("#");
@@ -698,12 +928,7 @@ async function kbGateCommand(argv, context) {
698
928
  "medium",
699
929
  "low"
700
930
  ].includes(confidence)) throw new ConfigError(`--confidence must be high, medium or low, got '${String(values.confidence)}'`);
701
- let plan;
702
- try {
703
- plan = await import("./dist-A5zLp_kr.js");
704
- } catch {
705
- throw new ConfigError("rulvar kb gate requires @rulvar/plan (the RunLedger fold behind the LedgerExport seam)");
706
- }
931
+ const plan = await loadCompanion(import("@rulvar/plan"), "@rulvar/plan", "rulvar kb gate", "rulvar kb gate requires @rulvar/plan (the RunLedger fold behind the LedgerExport seam)");
707
932
  const assembled = assembleEngine({
708
933
  config: await loadCliConfig(context.cwd),
709
934
  ...values.store === void 0 ? {} : { storePath: values.store },
@@ -793,16 +1018,22 @@ async function kbGateCommand(argv, context) {
793
1018
  * sweep re-measures.
794
1019
  */
795
1020
  async function kbSweepCommand(argv, context) {
796
- if (argv.length > 0) throw new ConfigError("usage: rulvar kb sweep (configuration lives in rulvar.config.mjs)");
1021
+ parseCommand(GRAMMAR["kb sweep"], argv);
797
1022
  const config = await loadCliConfig(context.cwd);
798
1023
  const sweep = config.kbSweep;
799
1024
  if (sweep === void 0) throw new ConfigError("rulvar kb sweep requires a kbSweep section in rulvar.config.mjs ({ committerId, models, cases })");
800
- let evals;
801
- try {
802
- evals = await import("./dist-CdhvWszV.js");
803
- } catch {
804
- throw new ConfigError("rulvar kb sweep requires @rulvar/evals (matrix sweeps, the eval-committer identity, and the canary live there); install it next to the CLI");
1025
+ const budgets = sweep.budgets;
1026
+ if (budgets === void 0 && sweep.allowUnbounded !== true) throw new ConfigError("rulvar kb sweep runs paid target, judge, and canary runs; set kbSweep.budgets ({ targetUsd, judgeUsd, canaryUsd, maxTotalUsd }) so every run carries an immutable ceiling and the whole sweep stays under maxTotalUsd, or waive the ceilings explicitly with kbSweep.allowUnbounded: true");
1027
+ if (budgets !== void 0) for (const field of [
1028
+ "targetUsd",
1029
+ "judgeUsd",
1030
+ "canaryUsd",
1031
+ "maxTotalUsd"
1032
+ ]) {
1033
+ const value = budgets[field];
1034
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new ConfigError(`kbSweep.budgets.${field} must be a positive finite number, got ${String(value)}`);
805
1035
  }
1036
+ const evals = await loadCompanion(import("@rulvar/evals"), "@rulvar/evals", "rulvar kb sweep", "rulvar kb sweep requires @rulvar/evals (matrix sweeps, the eval-committer identity, and the canary live there); install it next to the CLI");
806
1037
  const store = new FileModelKnowledgeStore({ path: join(context.cwd, "rulvar.models.json") });
807
1038
  const snapshot = await store.current();
808
1039
  const observedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -843,15 +1074,39 @@ async function kbSweepCommand(argv, context) {
843
1074
  }
844
1075
  }
845
1076
  }));
1077
+ const usd = (amount) => `$${String(Math.round(amount * 1e6) / 1e6)}`;
1078
+ let envelope;
1079
+ if (budgets !== void 0) {
1080
+ envelope = new evals.SpendEnvelope(budgets.maxTotalUsd);
1081
+ const canaryRuns = (sweep.canary?.prompts.length ?? 0) * pool.size;
1082
+ const targetRuns = sweep.cases.length * pool.size;
1083
+ context.io.out(`sweep budget: ${usd(budgets.maxTotalUsd)} maxTotalUsd hard ceiling; authorizes up to ${usd(canaryRuns * budgets.canaryUsd + targetRuns * budgets.targetUsd)} for ${String(canaryRuns)} canary + ${String(targetRuns)} target run(s) before judges (each judge run up to ${usd(budgets.judgeUsd)} draws from the same envelope at grade time)`);
1084
+ } else context.io.err("kb sweep: running UNBOUNDED (kbSweep.allowUnbounded); no target, judge, or canary run carries a ceiling");
846
1085
  for (const { member, origin } of pool.values()) {
847
1086
  const effort = member.effort === void 0 ? "" : ` effort=${member.effort}`;
848
1087
  context.io.out(`pool: ${member.model}${effort} [${origin}]`);
849
1088
  }
850
1089
  if (sweep.canary !== void 0) for (const { member } of pool.values()) {
851
1090
  const engine = await engineFor(member);
852
- const fingerprint = await evals.canaryFingerprint(engine, sweep.canary);
853
- const drift = await evals.flipStaleOnCanaryDrift(store, member.model, fingerprint);
854
- context.io.out(`canary ${member.model}: ${fingerprint.slice(0, 12)}...` + (drift.flipped.length === 0 ? " no drift" : ` DRIFT, ${String(drift.flipped.length)} claim(s) flipped stale`));
1091
+ let canary;
1092
+ try {
1093
+ canary = await evals.runCanary(engine, sweep.canary, { ...budgets === void 0 ? {} : {
1094
+ budgetUsd: budgets.canaryUsd,
1095
+ envelope
1096
+ } });
1097
+ } catch (error) {
1098
+ if (error instanceof Error && error.name === "SweepBudgetError") {
1099
+ context.io.out(`canary ${member.model}: envelope exhausted, skipped`);
1100
+ continue;
1101
+ }
1102
+ throw error;
1103
+ }
1104
+ if (!canary.allOk) {
1105
+ context.io.out(`canary ${member.model}: ${canary.fingerprint.slice(0, 12)}... incomplete (a probe did not settle ok); NOT flipping claims`);
1106
+ continue;
1107
+ }
1108
+ const drift = await evals.flipStaleOnCanaryDrift(store, member.model, canary.fingerprint);
1109
+ context.io.out(`canary ${member.model}: ${canary.fingerprint.slice(0, 12)}...` + (drift.flipped.length === 0 ? " no drift" : ` DRIFT, ${String(drift.flipped.length)} claim(s) flipped stale`));
855
1110
  }
856
1111
  const report = await evals.runSweepMatrix({
857
1112
  models: [...pool.values()].map((entry) => entry.member),
@@ -862,11 +1117,26 @@ async function kbSweepCommand(argv, context) {
862
1117
  observedAt,
863
1118
  engineFor,
864
1119
  store,
865
- ...sweep.thresholds === void 0 ? {} : { thresholds: sweep.thresholds }
1120
+ ...sweep.thresholds === void 0 ? {} : { thresholds: sweep.thresholds },
1121
+ ...budgets === void 0 ? {} : {
1122
+ suite: {
1123
+ budgetUsd: budgets.targetUsd,
1124
+ judgeBudgetUsd: budgets.judgeUsd
1125
+ },
1126
+ envelope
1127
+ }
866
1128
  });
867
- for (const cell of report.cells) context.io.out(`cell ${cell.model} :: ${cell.taskClass}: passRate ${cell.passRate.toFixed(2)} over ${String(cell.n)} case${cell.n === 1 ? "" : "s"}`);
1129
+ for (const cell of report.cells) {
1130
+ if (cell.envelopeExhausted === true) {
1131
+ context.io.out(`cell ${cell.model} :: ${cell.taskClass}: envelope exhausted, not measured (no claim)`);
1132
+ continue;
1133
+ }
1134
+ const exhausted = cell.exhaustedRuns === void 0 ? "" : ` (${String(cell.exhaustedRuns)} run(s) hit their own ceiling; no claim)`;
1135
+ context.io.out(`cell ${cell.model} :: ${cell.taskClass}: passRate ${cell.passRate.toFixed(2)} over ${String(cell.n)} case${cell.n === 1 ? "" : "s"}${exhausted}`);
1136
+ }
868
1137
  for (const claim of report.claims) context.io.out(`claim ${claim.id}: ${claim.taskClass} ${claim.polarity}`);
869
1138
  context.io.out(report.committedVersion === void 0 ? "no claims crossed a threshold; nothing committed" : `committed ${String(report.claims.length)} claim(s) as store version ${String(report.committedVersion)} (report ${report.reportId})`);
1139
+ if (envelope !== void 0) context.io.out(`sweep budget: authorized ${usd(envelope.authorizedUsd)} of ${usd(envelope.maxTotalUsd)}`);
870
1140
  return 0;
871
1141
  }
872
1142
  //#endregion
@@ -877,12 +1147,7 @@ async function kbSweepCommand(argv, context) {
877
1147
  */
878
1148
  const HELP = `rulvar: durable multi-agent workflows (https://docs.rulvar.com)
879
1149
 
880
- rulvar run <file|name> [--args JSON] [--store PATH] [--budget-usd N]
881
- rulvar resume <runId> [--store PATH]
882
- rulvar runs ls [--store PATH]
883
- rulvar inspect <runId> [--store PATH]
884
- rulvar plan "<goal>" [--dry-run]
885
- rulvar kb <list | inbox | gate | sweep>
1150
+ ${helpCommandLines().map((line) => ` ${line}`).join("\n")}
886
1151
 
887
1152
  Engine assembly: adapters, defaults, and the workflow registry come from
888
1153
  rulvar.config.mjs in the working directory (default export
@@ -890,13 +1155,18 @@ rulvar.config.mjs in the working directory (default export
890
1155
  exports. --store selects the JsonlFileStore directory (default .rulvar).
891
1156
  plan asks the planner model (role plan) to write a workflow script,
892
1157
  lints and self-repairs it, then runs it in the worker sandbox; --dry-run
893
- prints the accepted script without running. Requires @rulvar/planner
894
- installed. kb list shows the per-project claim store
1158
+ prints the accepted script without running. Both stages are paid runs
1159
+ with their own immutable ceilings: --planning-budget-usd caps the
1160
+ planning run, --budget-usd caps the execution run, and a missing
1161
+ ceiling fails loudly unless --allow-unbounded waives it explicitly.
1162
+ Requires @rulvar/planner installed. kb list shows the per-project claim store
895
1163
  (./rulvar.models.json) with full provenance. kb sweep runs the
896
1164
  falsification matrix from the kbSweep section of rulvar.config.mjs
897
1165
  (fixed pool UNIONED with every model carrying an active negative claim
898
1166
  plus the re-measure queue; optional canary probes flip drifted claims
899
- stale first; requires @rulvar/evals installed). kb inbox aggregates
1167
+ stale first; kbSweep.budgets sets per-run ceilings plus the maxTotalUsd
1168
+ envelope, required unless allowUnbounded waives it; requires
1169
+ @rulvar/evals installed). kb inbox aggregates
900
1170
  kb_propose proposals from finished runs (14 day TTL); kb gate turns one
901
1171
  inbox proposal into a committed claim behind a human attestation
902
1172
  (--approver and --ruled-out are mandatory). inbox and gate require
@@ -913,7 +1183,7 @@ async function runCli(argv, options) {
913
1183
  case "resume": return await resumeCommand(rest, context);
914
1184
  case "runs": {
915
1185
  const [sub, ...subRest] = rest;
916
- if (sub !== "ls") throw new ConfigError("usage: rulvar runs ls [--store PATH] (no aliases in v1)");
1186
+ if (sub !== "ls") throw new ConfigError(usageOf(GRAMMAR["runs ls"]));
917
1187
  return await runsLsCommand(subRest, context);
918
1188
  }
919
1189
  case "inspect": return await inspectCommand(rest, context);