@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.
- package/LICENSE +202 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +17 -0
- package/dist/dist-4eKFSpyW.js +474 -0
- package/dist/dist-DtuSmcav.js +95385 -0
- package/dist/index.d.ts +252 -0
- package/dist/index.js +760 -0
- package/dist/io-DUfUn6KG.js +702 -0
- package/dist/src-DVFj9U50.js +1174 -0
- package/package.json +63 -0
|
@@ -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 };
|