@rulvar/cli 1.0.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.
@@ -0,0 +1,702 @@
1
+ import { ConfigError, FileModelKnowledgeStore, JsonlFileStore, claimExpired, compilePermissionPreset, costReportFromJournal, createEngine, parseModelRef, priceUsdOf, remeasureQueue, resolvePricing, runProfile } from "@rulvar/core";
2
+ import { parseArgs } from "node:util";
3
+ import { join, resolve } from "node:path";
4
+ import { existsSync, statSync } from "node:fs";
5
+ import { pathToFileURL } from "node:url";
6
+ import { createInterface } from "node:readline";
7
+ //#region src/config.ts
8
+ /**
9
+ * CLI configuration convention (shell-owned; the canonical grammar in
10
+ * docs/06 section 10.5 says nothing about engine assembly, and the CLI
11
+ * builds exclusively from the public API per docs/02 section 4):
12
+ *
13
+ * - `rulvar.config.mjs` (or .js) in the working directory default-exports
14
+ * `{ engineOptions?, workflows? }`: adapters, stores, defaults come
15
+ * from the HOST's module, so @rulvar/cli itself depends only on
16
+ * @rulvar/core.
17
+ * - `rulvar run <file>` imports the module at <file>: its default export
18
+ * (or named `workflow`) is the Workflow; optional named exports
19
+ * `engineOptions` and `workflows` merge OVER the config file's.
20
+ * - `rulvar run <name>` and `rulvar resume <runId>` resolve names
21
+ * against the merged workflow registry (config first, file second).
22
+ */
23
+ const CONFIG_BASENAMES = ["rulvar.config.mjs", "rulvar.config.js"];
24
+ /**
25
+ * ESM caches by URL for the process lifetime; a CLI process imports each
26
+ * module once, but long-lived hosts (and the e2e suite) re-read edited
27
+ * configs, so the mtime rides the URL.
28
+ */
29
+ function moduleUrl(path) {
30
+ return `${pathToFileURL(path).href}?mtime=${statSync(path).mtimeMs}`;
31
+ }
32
+ function isWorkflowValue(value) {
33
+ return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.body === "function";
34
+ }
35
+ /** Loads `rulvar.config.mjs`/`.js` from cwd; absent config is fine. */
36
+ async function loadCliConfig(cwd) {
37
+ for (const basename of CONFIG_BASENAMES) {
38
+ const path = resolve(cwd, basename);
39
+ if (!existsSync(path)) continue;
40
+ const mod = await import(moduleUrl(path));
41
+ const config = mod.default;
42
+ if (config !== void 0 && isWorkflowValue(config)) throw new ConfigError(`${basename} default-exports a workflow; it must default-export { engineOptions?, workflows? }`);
43
+ return {
44
+ ...config ?? {},
45
+ ...mod.engineOptions === void 0 ? {} : { engineOptions: mod.engineOptions },
46
+ ...mod.workflows === void 0 ? {} : { workflows: mod.workflows }
47
+ };
48
+ }
49
+ return {};
50
+ }
51
+ /** Imports a workflow module given on the command line. */
52
+ async function loadWorkflowModule(file, cwd) {
53
+ const path = resolve(cwd, file);
54
+ if (!existsSync(path)) throw new ConfigError(`workflow file not found: ${path}`);
55
+ const mod = await import(moduleUrl(path));
56
+ const candidate = mod.workflow ?? mod.default;
57
+ const loaded = {};
58
+ if (candidate !== void 0 && isWorkflowValue(candidate)) loaded.workflow = candidate;
59
+ if (mod.engineOptions !== void 0) loaded.engineOptions = mod.engineOptions;
60
+ if (mod.workflows !== void 0) loaded.workflows = mod.workflows;
61
+ return loaded;
62
+ }
63
+ /** True when the `run` target names a file rather than a registry entry. */
64
+ function looksLikeFile(target) {
65
+ return target.includes("/") || target.includes("\\") || /\.(ts|mts|cts|js|mjs|cjs)$/.test(target);
66
+ }
67
+ //#endregion
68
+ //#region src/engine-assembly.ts
69
+ /**
70
+ * Engine assembly for CLI commands: the host's config supplies adapters
71
+ * and defaults (the CLI depends only on @rulvar/core, docs/02 section
72
+ * 4); --store selects the JsonlFileStore directory (default `.rulvar`),
73
+ * and an explicit stores entry in engineOptions wins over it.
74
+ */
75
+ const DEFAULT_STORE_DIR = ".rulvar";
76
+ function assembleEngine(options) {
77
+ const { config, module } = options;
78
+ let engineOptions = {
79
+ ...config.engineOptions,
80
+ ...module?.engineOptions
81
+ };
82
+ if (options.profile !== void 0) {
83
+ const profile = runProfile(options.profile);
84
+ if (profile === void 0) throw new ConfigError(`unknown run profile '${options.profile}'; shipped: fast, standard, deep, ultra`);
85
+ engineOptions = applyRunProfile(profile, engineOptions);
86
+ }
87
+ const store = engineOptions.stores?.journal ?? new JsonlFileStore({ dir: resolve(options.cwd, options.storePath ?? ".rulvar") });
88
+ const workflows = {
89
+ ...config.workflows,
90
+ ...module?.workflows
91
+ };
92
+ const adapters = engineOptions.adapters ?? [];
93
+ const engine = createEngine({
94
+ adapters,
95
+ ...engineOptions,
96
+ stores: {
97
+ ...engineOptions.stores,
98
+ journal: store
99
+ },
100
+ defaults: {
101
+ ...engineOptions.defaults,
102
+ workflows
103
+ }
104
+ });
105
+ const byId = new Map(adapters.map((adapter) => [adapter.id, adapter]));
106
+ const priceUsd = (servedBy, usage) => {
107
+ const { adapterId, model } = parseModelRef(servedBy);
108
+ const pricing = resolvePricing(servedBy, engineOptions.pricing, byId.get(adapterId)?.caps(model).pricing);
109
+ return pricing === void 0 ? void 0 : priceUsdOf(pricing, usage);
110
+ };
111
+ return {
112
+ engine,
113
+ store,
114
+ workflows,
115
+ priceUsd
116
+ };
117
+ }
118
+ /**
119
+ * Merges a RunProfile UNDER host engineOptions (host wins). Effort hints
120
+ * seed per-role defaults on routing entries that carry none; concurrency
121
+ * and budget defaults fill unset slots; the permission preset applies to
122
+ * the engine-wide chain when the host set none.
123
+ */
124
+ function applyRunProfile(profile, host) {
125
+ const merged = { ...host };
126
+ if (profile.perRunConcurrency !== void 0) merged.concurrency = {
127
+ perRun: profile.perRunConcurrency,
128
+ ...host.concurrency
129
+ };
130
+ if (profile.lifetimeSpawnCap !== void 0 || profile.maxDepth !== void 0) merged.budgetDefaults = {
131
+ ...profile.lifetimeSpawnCap === void 0 ? {} : { lifetimeSpawnCap: profile.lifetimeSpawnCap },
132
+ ...profile.maxDepth === void 0 ? {} : { maxDepth: profile.maxDepth },
133
+ ...host.budgetDefaults
134
+ };
135
+ if (profile.permissionPreset !== void 0 && host.defaults?.permissions === void 0) {
136
+ const compiled = compilePermissionPreset(profile.permissionPreset);
137
+ merged.defaults = {
138
+ ...host.defaults,
139
+ permissions: compiled
140
+ };
141
+ }
142
+ return merged;
143
+ }
144
+ //#endregion
145
+ //#region src/tui.ts
146
+ function money(usd) {
147
+ return `$${usd.toFixed(4)}`;
148
+ }
149
+ /** Renders one event to a line, or undefined for silent event types. */
150
+ function renderEventLine(event) {
151
+ const replayMark = event.replayed === true ? "replay " : "";
152
+ switch (event.type) {
153
+ case "run:start": return `run ${event.runId} ${event.resumed ? "resumed" : "started"} (workflow ${event.workflow})`;
154
+ case "phase:start": return `phase ${event.phase}`;
155
+ case "agent:start": return `${replayMark}agent ${event.agentType || "(anon)"}${event.label === void 0 ? "" : ` [${event.label}]`} ${event.role} on ${event.model}`;
156
+ case "agent:end": return `${replayMark}agent ${event.agentType || "(anon)"} ${event.status} (${money(event.costUsd)}, ${event.usage.inputTokens + event.usage.outputTokens} tok)`;
157
+ case "agent:error": return `agent ${event.agentType || "(anon)"} error: ${event.error.message}${event.willRetry ? " (will retry)" : ""}`;
158
+ case "agent:queued": return `agent ${event.agentType || "(anon)"} queued`;
159
+ case "tool:start": return `${replayMark}tool ${event.toolName}`;
160
+ case "tool:end": return `${replayMark}tool ${event.toolName} ${event.outcome} (${event.durationMs}ms)`;
161
+ case "approval:pending": return `approval pending: tool ${event.toolName} (entry ${event.entryRef})`;
162
+ case "log": return event.level === "debug" ? void 0 : `${event.level}: ${event.msg}`;
163
+ case "run:end": return `run ${event.runId} ${event.status}`;
164
+ default: return;
165
+ }
166
+ }
167
+ /** Attaches the renderer to a handle's event stream; returns a detach. */
168
+ function attachProgress(handle, io) {
169
+ const detachers = [];
170
+ const forward = (event) => {
171
+ const line = renderEventLine(event);
172
+ if (line !== void 0) io.err(line);
173
+ };
174
+ for (const type of [
175
+ "run:start",
176
+ "phase:start",
177
+ "agent:start",
178
+ "agent:end",
179
+ "agent:error",
180
+ "agent:queued",
181
+ "tool:start",
182
+ "tool:end",
183
+ "approval:pending",
184
+ "log",
185
+ "run:end"
186
+ ]) detachers.push(handle.on(type, forward));
187
+ return () => {
188
+ for (const detach of detachers) detach();
189
+ };
190
+ }
191
+ //#endregion
192
+ //#region src/drive.ts
193
+ const APPROVAL_PREFIX = "approval:";
194
+ /** Parses an approval answer; undefined = unusable input. */
195
+ function approvalDecision(answer) {
196
+ const normalized = answer.trim().toLowerCase();
197
+ if ([
198
+ "allow",
199
+ "a",
200
+ "yes",
201
+ "y"
202
+ ].includes(normalized)) return { decision: "allow" };
203
+ if ([
204
+ "deny",
205
+ "d",
206
+ "no",
207
+ "n"
208
+ ].includes(normalized)) return { decision: "deny" };
209
+ }
210
+ /**
211
+ * Prompts for and applies resolutions for every pending suspension.
212
+ * Returns the number applied; 0 means input was exhausted or unusable
213
+ * and the run stays suspended.
214
+ */
215
+ async function resolvePending(handle, pending, io) {
216
+ let applied = 0;
217
+ for (const item of pending) {
218
+ if (item.key.startsWith(APPROVAL_PREFIX)) {
219
+ const answer = await io.prompt(`approve '${item.prompt ?? item.key}'? [allow/deny]`);
220
+ if (answer === void 0) return applied;
221
+ const decision = approvalDecision(answer);
222
+ if (decision === void 0) {
223
+ io.err(`unrecognized answer '${answer.trim()}'; leaving ${item.key} suspended`);
224
+ continue;
225
+ }
226
+ const outcome = await handle.resolveExternal(item.key, decision);
227
+ io.err(`approval ${item.key}: ${decision.decision} (${outcome.applied ? "applied" : outcome.reason})`);
228
+ if (outcome.applied) applied += 1;
229
+ continue;
230
+ }
231
+ const label = item.prompt === void 0 ? item.key : `${item.key} (${item.prompt})`;
232
+ const answer = await io.prompt(`value for external '${label}' as JSON:`);
233
+ if (answer === void 0) return applied;
234
+ let value;
235
+ try {
236
+ value = JSON.parse(answer);
237
+ } catch {
238
+ io.err(`not valid JSON; leaving '${item.key}' suspended`);
239
+ continue;
240
+ }
241
+ const outcome = await handle.resolveExternal(item.key, value);
242
+ io.err(`external '${item.key}': ${outcome.applied ? "applied" : outcome.reason}`);
243
+ if (outcome.applied) applied += 1;
244
+ }
245
+ return applied;
246
+ }
247
+ /**
248
+ * Drives a handle to a terminal outcome, resolving suspensions
249
+ * interactively and resuming until the run settles or input runs dry.
250
+ */
251
+ async function driveRun(options) {
252
+ let handle = options.first;
253
+ for (;;) {
254
+ const detach = attachProgress(handle, options.io);
255
+ const outcome = await handle.result;
256
+ detach();
257
+ if (outcome.status !== "suspended" || outcome.pending.length === 0) return outcome;
258
+ if (await resolvePending(handle, outcome.pending, options.io) === 0) return outcome;
259
+ handle = options.engine.resume(handle.runId, options.workflow, { args: options.args });
260
+ }
261
+ }
262
+ /** Renders the settled outcome; returns the process exit code. */
263
+ function reportOutcome(outcome, io) {
264
+ io.err(`status: ${outcome.status}`);
265
+ if (outcome.value !== void 0) io.out(JSON.stringify(outcome.value, null, 2));
266
+ if (outcome.error !== void 0) io.err(`error: ${outcome.error.message}`);
267
+ if (outcome.dropped.length > 0) io.err(`dropped: ${outcome.dropped.length} item(s)`);
268
+ for (const pending of outcome.pending) io.err(`pending: ${pending.key} (entry ${pending.entryRef})`);
269
+ io.err(`cost: $${outcome.cost.totalUsd.toFixed(4)}`);
270
+ for (const [model, usd] of Object.entries(outcome.cost.byModel)) io.err(` by model ${model}: $${usd.toFixed(4)}`);
271
+ for (const [phase, usd] of Object.entries(outcome.cost.byPhase)) if (phase !== "") io.err(` by phase ${phase}: $${usd.toFixed(4)}`);
272
+ if (outcome.cost.unpriced.length > 0) io.err(`unpriced models: ${outcome.cost.unpriced.map((u) => u.model).join(", ")}`);
273
+ switch (outcome.status) {
274
+ case "ok":
275
+ case "suspended": return 0;
276
+ default: return 1;
277
+ }
278
+ }
279
+ //#endregion
280
+ //#region src/commands.ts
281
+ /**
282
+ * The four M5 commands of the canonical CLI grammar (docs/06, section
283
+ * 10.5; no aliases in v1):
284
+ *
285
+ * rulvar run <file|name> [--args JSON] [--store PATH] [--budget-usd N]
286
+ * rulvar resume <runId> [--store PATH]
287
+ * rulvar runs ls [--store PATH]
288
+ * rulvar inspect <runId> [--store PATH]
289
+ *
290
+ * `plan` and `kb` land with M6+/M10. Every command builds strictly from
291
+ * the public @rulvar/core API (docs/02, section 4).
292
+ */
293
+ function parseRunFlags(argv) {
294
+ const { values, positionals } = parseArgs({
295
+ args: argv,
296
+ allowPositionals: true,
297
+ options: {
298
+ args: { type: "string" },
299
+ store: { type: "string" },
300
+ "budget-usd": { type: "string" },
301
+ profile: { type: "string" }
302
+ }
303
+ });
304
+ const parsed = { positionals };
305
+ if (values.store !== void 0) parsed.store = values.store;
306
+ if (values.profile !== void 0) parsed.profile = values.profile;
307
+ if (values.args !== void 0) parsed.args = values.args;
308
+ if (values["budget-usd"] !== void 0) {
309
+ const budget = Number(values["budget-usd"]);
310
+ if (!Number.isFinite(budget) || budget <= 0) throw new ConfigError(`--budget-usd must be a positive number, got '${values["budget-usd"]}'`);
311
+ parsed.budgetUsd = budget;
312
+ }
313
+ return parsed;
314
+ }
315
+ function parseCommonFlags(argv) {
316
+ const { values, positionals } = parseArgs({
317
+ args: argv,
318
+ allowPositionals: true,
319
+ options: { store: { type: "string" } }
320
+ });
321
+ return {
322
+ positionals,
323
+ ...values.store === void 0 ? {} : { store: values.store }
324
+ };
325
+ }
326
+ async function runCommand(argv, context) {
327
+ const flags = parseRunFlags(argv);
328
+ const target = flags.positionals[0];
329
+ if (target === void 0) throw new ConfigError("usage: rulvar run <file|name> [--args JSON] [--store PATH] [--budget-usd N]");
330
+ const config = await loadCliConfig(context.cwd);
331
+ const module = looksLikeFile(target) ? await loadWorkflowModule(target, context.cwd) : void 0;
332
+ const assembled = assembleEngine({
333
+ config,
334
+ ...module === void 0 ? {} : { module },
335
+ ...flags.store === void 0 ? {} : { storePath: flags.store },
336
+ ...flags.profile === void 0 ? {} : { profile: flags.profile },
337
+ cwd: context.cwd
338
+ });
339
+ const workflow = module?.workflow ?? assembled.workflows[target];
340
+ 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`);
341
+ let args;
342
+ if (flags.args !== void 0) try {
343
+ args = JSON.parse(flags.args);
344
+ } catch {
345
+ throw new ConfigError(`--args is not valid JSON: ${flags.args}`);
346
+ }
347
+ const runOptions = { ...flags.budgetUsd === void 0 ? {} : { budgetUsd: flags.budgetUsd } };
348
+ const first = assembled.engine.run(workflow, args, runOptions);
349
+ context.io.err(`runId: ${first.runId}`);
350
+ return reportOutcome(await driveRun({
351
+ engine: assembled.engine,
352
+ workflow,
353
+ first,
354
+ io: context.io,
355
+ args
356
+ }), context.io);
357
+ }
358
+ async function resumeCommand(argv, context) {
359
+ const flags = parseRunFlags(argv);
360
+ const runId = flags.positionals[0];
361
+ if (runId === void 0) throw new ConfigError("usage: rulvar resume <runId> [--args JSON] [--store PATH]");
362
+ let args;
363
+ if (flags.args !== void 0) try {
364
+ args = JSON.parse(flags.args);
365
+ } catch {
366
+ throw new ConfigError(`--args is not valid JSON: ${flags.args}`);
367
+ }
368
+ const assembled = assembleEngine({
369
+ config: await loadCliConfig(context.cwd),
370
+ ...flags.store === void 0 ? {} : { storePath: flags.store },
371
+ cwd: context.cwd
372
+ });
373
+ const meta = (await assembled.store.listRuns()).find((m) => m.runId === runId);
374
+ if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
375
+ const name = meta.workflowName;
376
+ const workflow = name === void 0 ? void 0 : assembled.workflows[name];
377
+ if (workflow === void 0) throw new ConfigError(`run '${runId}' was started from workflow '${name ?? "(unknown)"}'; register it under that name in rulvar.config.mjs workflows to resume (docs/06, section 10.2: resume requires the in-process workflow value)`);
378
+ const first = assembled.engine.resume(runId, workflow, { args });
379
+ return reportOutcome(await driveRun({
380
+ engine: assembled.engine,
381
+ workflow,
382
+ first,
383
+ io: context.io,
384
+ args
385
+ }), context.io);
386
+ }
387
+ async function runsLsCommand(argv, context) {
388
+ const flags = parseCommonFlags(argv);
389
+ const metas = await assembleEngine({
390
+ config: await loadCliConfig(context.cwd),
391
+ ...flags.store === void 0 ? {} : { storePath: flags.store },
392
+ cwd: context.cwd
393
+ }).store.listRuns();
394
+ if (metas.length === 0) {
395
+ context.io.err("no runs in the store");
396
+ return 0;
397
+ }
398
+ for (const meta of metas) {
399
+ const workflow = meta.workflowName === void 0 ? "" : ` workflow=${meta.workflowName}`;
400
+ const name = meta.name === void 0 ? "" : ` name=${meta.name}`;
401
+ context.io.out(`${meta.runId} ${meta.status} updated=${meta.updatedAt}${workflow}${name}`);
402
+ }
403
+ return 0;
404
+ }
405
+ async function inspectCommand(argv, context) {
406
+ const flags = parseCommonFlags(argv);
407
+ const runId = flags.positionals[0];
408
+ if (runId === void 0) throw new ConfigError("usage: rulvar inspect <runId> [--store PATH]");
409
+ const assembled = assembleEngine({
410
+ config: await loadCliConfig(context.cwd),
411
+ ...flags.store === void 0 ? {} : { storePath: flags.store },
412
+ cwd: context.cwd
413
+ });
414
+ const meta = (await assembled.store.listRuns()).find((m) => m.runId === runId);
415
+ if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
416
+ const entries = await assembled.store.load(runId);
417
+ context.io.out(`run ${meta.runId}: ${meta.status} (updated ${meta.updatedAt})`);
418
+ if (meta.workflowName !== void 0) context.io.out(`workflow: ${meta.workflowName}`);
419
+ const byKind = /* @__PURE__ */ new Map();
420
+ let openSuspensions = 0;
421
+ const resolvedRefs = /* @__PURE__ */ new Set();
422
+ for (const entry of entries) {
423
+ byKind.set(entry.kind, (byKind.get(entry.kind) ?? 0) + 1);
424
+ if (entry.kind === "resolution" && typeof entry.ref === "number") resolvedRefs.add(entry.ref);
425
+ }
426
+ for (const entry of entries) if ((entry.kind === "external" || entry.kind === "approval") && entry.status === "suspended") {
427
+ if (!resolvedRefs.has(entry.seq)) openSuspensions += 1;
428
+ }
429
+ context.io.out(`entries: ${entries.length}`);
430
+ for (const [kind, count] of [...byKind.entries()].sort((a, b) => a[0].localeCompare(b[0]))) context.io.out(` ${kind}: ${count}`);
431
+ context.io.out(`open suspensions: ${openSuspensions}`);
432
+ const cost = costReportFromJournal(entries, assembled.priceUsd);
433
+ context.io.out(`cost: $${cost.totalUsd.toFixed(4)}`);
434
+ for (const [model, usd] of Object.entries(cost.byModel)) context.io.out(` ${model}: $${usd.toFixed(4)}`);
435
+ for (const item of cost.unpriced) context.io.out(` unpriced: ${item.model} (${item.usage.inputTokens + item.usage.outputTokens} tok)`);
436
+ for (const entry of entries) {
437
+ const status = entry.status === void 0 ? "" : ` ${entry.status}`;
438
+ const served = entry.servedBy === void 0 ? "" : ` servedBy=${entry.servedBy}`;
439
+ context.io.out(`#${entry.seq} ${entry.kind}${status}${served}`);
440
+ }
441
+ return 0;
442
+ }
443
+ /**
444
+ * rulvar plan "<goal>" [--dry-run] (docs/06, 10.5; M6-T11): plans a
445
+ * workflow script through @rulvar/planner (loaded dynamically: the CLI's
446
+ * static dependency stays @rulvar/core only, docs/02 dependency rules),
447
+ * prints the accepted script and its advisories, and runs it in the
448
+ * worker sandbox unless --dry-run.
449
+ */
450
+ async function planCommand(argv, context) {
451
+ const parsed = parseArgs({
452
+ args: argv,
453
+ allowPositionals: true,
454
+ options: { "dry-run": { type: "boolean" } }
455
+ });
456
+ const goal = parsed.positionals[0];
457
+ if (goal === void 0 || parsed.positionals.length > 1) throw new ConfigError("usage: rulvar plan \"<goal>\" [--dry-run]");
458
+ let plannerModule;
459
+ try {
460
+ plannerModule = await import("./dist-DtuSmcav.js");
461
+ } catch {
462
+ throw new ConfigError("rulvar plan requires @rulvar/planner (the plan agent, compileScript, and the worker sandbox live there); install it next to the CLI");
463
+ }
464
+ const assembled = assembleEngine({
465
+ config: await loadCliConfig(context.cwd),
466
+ cwd: context.cwd
467
+ });
468
+ const planned = await plannerModule.plan(assembled.engine, goal);
469
+ context.io.err(`plan: accepted with ${String(planned.lint.length)} advisory diagnostic(s)`);
470
+ for (const diagnostic of planned.lint) context.io.err(` ${diagnostic.ruleId}: ${diagnostic.message}`);
471
+ if (parsed.values["dry-run"] === true) {
472
+ context.io.out(planned.source);
473
+ return 0;
474
+ }
475
+ const workflow = planned.workflow;
476
+ const first = assembled.engine.run(workflow, null);
477
+ context.io.err(`runId: ${first.runId}`);
478
+ return reportOutcome(await driveRun({
479
+ engine: assembled.engine,
480
+ workflow,
481
+ first,
482
+ io: context.io,
483
+ args: null
484
+ }), context.io);
485
+ }
486
+ /**
487
+ * rulvar kb list (docs/06, 10.5; docs/05, 4.4; M10-T04): the second
488
+ * consumption path. Claims with full provenance for the humans who
489
+ * author ladders, floors, and profiles; no run and no pin, so model
490
+ * names render VERBATIM here (only in-run cards are nameless). Reads
491
+ * the per-project file store (./rulvar.models.json). The grammar
492
+ * members inbox (phase 3) and sweep (phase 2) fail loudly until their
493
+ * phases ship.
494
+ */
495
+ async function kbCommand(argv, context) {
496
+ const [sub, ...rest] = argv;
497
+ if (sub === "inbox") throw new ConfigError("rulvar kb inbox arrives with ModelKnowledge phase 3 (M12, gated by the measured-value checkpoint; docs/05, section \"Phases and placement\")");
498
+ if (sub === "sweep") return await kbSweepCommand(rest, context);
499
+ if (sub !== "list" || rest.length > 0) throw new ConfigError("usage: rulvar kb <list | inbox | sweep> (no aliases in v1)");
500
+ const snapshot = await new FileModelKnowledgeStore({ path: join(context.cwd, "rulvar.models.json") }).current();
501
+ context.io.out(`knowledge store: rulvar.models.json (version ${String(snapshot.version)}, ${String(snapshot.claims.length)} claim${snapshot.claims.length === 1 ? "" : "s"})`);
502
+ renderKbList(snapshot, context);
503
+ return 0;
504
+ }
505
+ function renderKbList(snapshot, context) {
506
+ const now = (/* @__PURE__ */ new Date()).toISOString();
507
+ for (const claim of snapshot.claims) {
508
+ const effort = claim.subject.effort === void 0 ? "" : ` effort=${claim.subject.effort}`;
509
+ const ttl = claim.status === "active" ? claimExpired(claim, now) ? " TTL EXPIRED" : " TTL holds" : "";
510
+ context.io.out(`${claim.id} [${claim.status}${ttl}] ${claim.subject.model}${effort} :: ${claim.taskClass} ${claim.polarity} (${claim.class}, confidence ${claim.confidence})`);
511
+ context.io.out(` ${claim.statement}`);
512
+ context.io.out(` observed=${claim.observedAt} expires=${claim.expiresAt} author=${claim.author.kind}:${claim.author.id} gate=${claim.author.kind === "human" ? "human (git review)" : "eval-committer"}`);
513
+ const evidence = claim.evidence.map((ref) => ref.kind === "journal" ? `journal ${ref.runId}#${String(ref.entryRef)}` : `eval ${ref.reportId} [${ref.caseIds.join(", ")}]`).join("; ");
514
+ context.io.out(` evidence: ${evidence}`);
515
+ if (claim.metrics !== void 0) context.io.out(` metrics: passRate=${String(claim.metrics.passRate)} n=${String(claim.metrics.n)} grader=${claim.metrics.graderId}`);
516
+ if (claim.supersedes !== void 0) context.io.out(` supersedes: ${claim.supersedes}`);
517
+ if (claim.origin !== void 0) context.io.out(` origin: ${claim.origin.kind} run=${claim.origin.runId}#${String(claim.origin.entryRef)}`);
518
+ }
519
+ }
520
+ /**
521
+ * rulvar kb sweep (M11-T05; docs/05, section "Grounding and decay"):
522
+ * falsification sweeps, run manually, from CI, or from a user cron,
523
+ * NEVER engine-scheduled. The matrix is the config's FIXED pool
524
+ * UNIONED with the store's falsification set: every model carrying an
525
+ * active, unexpired negative claim MUST be included, and the
526
+ * re-measurement queue (expired active eval claims) rides along. With
527
+ * canary probes configured, drift flips stale strictly BEFORE the
528
+ * sweep re-measures.
529
+ */
530
+ async function kbSweepCommand(argv, context) {
531
+ if (argv.length > 0) throw new ConfigError("usage: rulvar kb sweep (configuration lives in rulvar.config.mjs)");
532
+ const config = await loadCliConfig(context.cwd);
533
+ const sweep = config.kbSweep;
534
+ if (sweep === void 0) throw new ConfigError("rulvar kb sweep requires a kbSweep section in rulvar.config.mjs ({ committerId, models, cases }; docs/05, section 'Grounding and decay')");
535
+ let evals;
536
+ try {
537
+ evals = await import("./dist-4eKFSpyW.js");
538
+ } catch {
539
+ 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");
540
+ }
541
+ const store = new FileModelKnowledgeStore({ path: join(context.cwd, "rulvar.models.json") });
542
+ const snapshot = await store.current();
543
+ const observedAt = (/* @__PURE__ */ new Date()).toISOString();
544
+ const memberKey = (member) => `${member.model} :: ${member.effort ?? ""}`;
545
+ const pool = /* @__PURE__ */ new Map();
546
+ for (const member of sweep.models) pool.set(memberKey(member), {
547
+ member,
548
+ origin: "config"
549
+ });
550
+ for (const claim of snapshot.claims) if (claim.status === "active" && claim.polarity === "weakness" && !claimExpired(claim, observedAt)) {
551
+ const member = { ...claim.subject };
552
+ if (!pool.has(memberKey(member))) pool.set(memberKey(member), {
553
+ member,
554
+ origin: "falsification (active negative claim)"
555
+ });
556
+ }
557
+ for (const claim of remeasureQueue(snapshot.claims, observedAt)) {
558
+ const member = { ...claim.subject };
559
+ if (!pool.has(memberKey(member))) pool.set(memberKey(member), {
560
+ member,
561
+ origin: "re-measure (expired eval claim)"
562
+ });
563
+ }
564
+ if (pool.size === 0) {
565
+ context.io.out("kb sweep: the pool is empty (no configured models, no falsification targets)");
566
+ return 0;
567
+ }
568
+ const base = config.engineOptions ?? {};
569
+ const engineFor = sweep.engineFor ?? ((member) => createEngine({
570
+ ...base,
571
+ adapters: base.adapters ?? [],
572
+ defaults: {
573
+ ...base.defaults,
574
+ routing: {
575
+ ...base.defaults?.routing,
576
+ loop: member.model,
577
+ extract: member.model
578
+ }
579
+ }
580
+ }));
581
+ for (const { member, origin } of pool.values()) {
582
+ const effort = member.effort === void 0 ? "" : ` effort=${member.effort}`;
583
+ context.io.out(`pool: ${member.model}${effort} [${origin}]`);
584
+ }
585
+ if (sweep.canary !== void 0) for (const { member } of pool.values()) {
586
+ const engine = await engineFor(member);
587
+ const fingerprint = await evals.canaryFingerprint(engine, sweep.canary);
588
+ const drift = await evals.flipStaleOnCanaryDrift(store, member.model, fingerprint);
589
+ 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`));
590
+ }
591
+ const report = await evals.runSweepMatrix({
592
+ models: [...pool.values()].map((entry) => entry.member),
593
+ cases: sweep.cases
594
+ }, {
595
+ reportId: sweep.reportId ?? `kb-sweep-${observedAt}`,
596
+ committerId: sweep.committerId,
597
+ observedAt,
598
+ engineFor,
599
+ store,
600
+ ...sweep.thresholds === void 0 ? {} : { thresholds: sweep.thresholds }
601
+ });
602
+ 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"}`);
603
+ for (const claim of report.claims) context.io.out(`claim ${claim.id}: ${claim.taskClass} ${claim.polarity}`);
604
+ 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})`);
605
+ return 0;
606
+ }
607
+ //#endregion
608
+ //#region src/cli-main.ts
609
+ /**
610
+ * Command dispatch for the canonical grammar (docs/06, section 10.5):
611
+ * no aliases in v1; unknown commands and flags fail loudly with usage.
612
+ */
613
+ const HELP = `rulvar: durable multi-agent workflows (docs/06, section 10.5)
614
+
615
+ rulvar run <file|name> [--args JSON] [--store PATH] [--budget-usd N]
616
+ rulvar resume <runId> [--store PATH]
617
+ rulvar runs ls [--store PATH]
618
+ rulvar inspect <runId> [--store PATH]
619
+ rulvar plan "<goal>" [--dry-run]
620
+ rulvar kb <list | inbox | sweep>
621
+
622
+ Engine assembly: adapters, defaults, and the workflow registry come from
623
+ rulvar.config.mjs in the working directory (default export
624
+ { engineOptions?, workflows? }) or from the workflow module's named
625
+ exports. --store selects the JsonlFileStore directory (default .rulvar).
626
+ plan asks the planner model (role plan) to write a workflow script,
627
+ lints and self-repairs it, then runs it in the worker sandbox; --dry-run
628
+ prints the accepted script without running. Requires @rulvar/planner
629
+ installed. kb list shows the per-project claim store
630
+ (./rulvar.models.json) with full provenance. kb sweep runs the
631
+ falsification matrix from the kbSweep section of rulvar.config.mjs
632
+ (fixed pool UNIONED with every model carrying an active negative claim
633
+ plus the re-measure queue; optional canary probes flip drifted claims
634
+ stale first; requires @rulvar/evals installed). kb inbox arrives with
635
+ ModelKnowledge phase 3.`;
636
+ async function runCli(argv, options) {
637
+ const [command, ...rest] = argv;
638
+ const context = {
639
+ cwd: options.cwd,
640
+ io: options.io
641
+ };
642
+ try {
643
+ switch (command) {
644
+ case "run": return await runCommand(rest, context);
645
+ case "resume": return await resumeCommand(rest, context);
646
+ case "runs": {
647
+ const [sub, ...subRest] = rest;
648
+ if (sub !== "ls") throw new ConfigError("usage: rulvar runs ls [--store PATH] (no aliases in v1)");
649
+ return await runsLsCommand(subRest, context);
650
+ }
651
+ case "inspect": return await inspectCommand(rest, context);
652
+ case "plan": return await planCommand(rest, context);
653
+ case "kb": return await kbCommand(rest, context);
654
+ case void 0:
655
+ case "help":
656
+ case "--help":
657
+ case "-h":
658
+ options.io.out(HELP);
659
+ return command === void 0 ? 1 : 0;
660
+ default: throw new ConfigError(`unknown command '${command}' (no aliases in v1); see rulvar --help`);
661
+ }
662
+ } catch (thrown) {
663
+ if (thrown instanceof ConfigError) {
664
+ options.io.err(`error: ${thrown.message}`);
665
+ return 1;
666
+ }
667
+ throw thrown;
668
+ }
669
+ }
670
+ //#endregion
671
+ //#region src/io.ts
672
+ /**
673
+ * CLI io seam: every command writes and prompts through this interface
674
+ * so the e2e suite drives the real command paths in-process with
675
+ * scripted stdin and captured output (docs/02, section 8.1: the CLI is
676
+ * a shell strictly on top of the public APIs).
677
+ */
678
+ /** The process-backed io the bin entry uses. */
679
+ function processIo() {
680
+ return {
681
+ out: (line) => process.stdout.write(`${line}\n`),
682
+ err: (line) => process.stderr.write(`${line}\n`),
683
+ isTTY: process.stdout.isTTY === true,
684
+ prompt: (question) => new Promise((resolve) => {
685
+ const rl = createInterface({
686
+ input: process.stdin,
687
+ output: process.stderr
688
+ });
689
+ let settled = false;
690
+ rl.question(`${question} `, (answer) => {
691
+ settled = true;
692
+ rl.close();
693
+ resolve(answer);
694
+ });
695
+ rl.on("close", () => {
696
+ if (!settled) resolve(void 0);
697
+ });
698
+ })
699
+ };
700
+ }
701
+ //#endregion
702
+ export { resumeCommand as a, driveRun as c, renderEventLine as d, DEFAULT_STORE_DIR as f, looksLikeFile as g, loadWorkflowModule as h, inspectCommand as i, reportOutcome as l, loadCliConfig as m, HELP as n, runCommand as o, assembleEngine as p, runCli as r, runsLsCommand as s, processIo as t, attachProgress as u };