@rulvar/cli 1.24.1 → 1.26.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/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { r as runCli, t as processIo } from "./io-CV_G44A2.js";
2
+ import { r as runCli, t as processIo } from "./io-C3T5nbNg.js";
3
+ import { sanitizeTerminalText } from "@rulvar/core";
3
4
  import { inspect } from "node:util";
4
5
  //#region src/cli.ts
5
6
  /**
@@ -11,7 +12,8 @@ runCli(process.argv.slice(2), {
11
12
  }).then((code) => {
12
13
  process.exitCode = code;
13
14
  }, (thrown) => {
14
- process.stderr.write(`${inspect(thrown)}\n`);
15
+ const rendered = inspect(thrown).split("\n").map(sanitizeTerminalText).join("\n");
16
+ process.stderr.write(`${rendered}\n`);
15
17
  process.exitCode = 1;
16
18
  });
17
19
  //#endregion
package/dist/index.d.ts CHANGED
@@ -142,7 +142,13 @@ declare function driveRun(options: {
142
142
  io: CliIo; /** Original run arguments: not journaled in v1, the host re-supplies them. */
143
143
  args?: unknown;
144
144
  }): Promise<RunOutcome<unknown>>;
145
- /** Renders the settled outcome; returns the process exit code. */
145
+ /**
146
+ * Renders the settled outcome; returns the process exit code. Error
147
+ * messages, suspension keys, model refs, and phase names originate from
148
+ * providers, tools, and workflow authors, so each is sanitized before
149
+ * it reaches a terminal line, matching the TUI renderer (v1.24.1 review
150
+ * P2-1). Values print as JSON, which escapes control bytes on its own.
151
+ */
146
152
  declare function reportOutcome(outcome: RunOutcome<unknown>, io: CliIo): number;
147
153
  //#endregion
148
154
  //#region src/server.d.ts
@@ -158,12 +164,43 @@ interface CreateServerOptions {
158
164
  */
159
165
  priceUsd?: (servedBy: ModelRef, usage: Usage) => number | undefined;
160
166
  /**
161
- * Opt-in retention (OQ-20 executed at M8-T04): evaluated
167
+ * Opt-in DURABLE retention (OQ-20 executed at M8-T04): evaluated
162
168
  * when a tracked run settles terminally; a true verdict applies
163
169
  * engine.deleteRun (transcript cascade, then the journal) and
164
- * untracks the run. Absent means everything persists indefinitely.
170
+ * untracks the run. This deletes the durable record; to release only
171
+ * process memory, use `memoryRetention` or `maxTrackedRuns`. Absent
172
+ * means nothing is deleted.
165
173
  */
166
174
  retention?: (meta: RunMeta) => boolean;
175
+ /**
176
+ * Opt-in retention of PROCESS MEMORY, decoupled from the durable kind
177
+ * (v1.25.0 scale review P1-2): evaluated when a tracked run settles
178
+ * terminally, after `retention`; a true verdict releases the tracked
179
+ * state (args, outcome, handle, SSE buffer) while the journal and
180
+ * transcripts stay untouched, after which GET status/cost serve from
181
+ * the store exactly as for a run another process owns, and GET events
182
+ * answers with the documented empty stream for a run not live here.
183
+ */
184
+ memoryRetention?: (meta: RunMeta) => boolean;
185
+ /**
186
+ * Cap on SETTLED tracked runs kept in process memory: when a run
187
+ * settles terminally and neither retention released it, the oldest
188
+ * settled tracked runs beyond the cap are released exactly like a
189
+ * `memoryRetention` verdict (durable state untouched). Live runs are
190
+ * never evicted and do not count toward the cap. Absent means no cap.
191
+ */
192
+ maxTrackedRuns?: number;
193
+ /**
194
+ * Upper bound on buffered SSE replay events per tracked run: past the
195
+ * bound the OLDEST buffered events are dropped in chunks (so the
196
+ * retained replay window stays at least seven eighths of the bound)
197
+ * and counted. A replay that no longer reaches back to a client's
198
+ * cursor carries `x-rulvar-events-dropped: <count>` and a leading SSE
199
+ * comment naming the first retained seq; the journal remains the
200
+ * durable record of the run itself. Absent means unbounded (the
201
+ * historical behavior).
202
+ */
203
+ maxBufferedEventsPerRun?: number;
167
204
  }
168
205
  interface RulvarServer {
169
206
  fetch(req: Request): Promise<Response>;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as resumeCommand, c as driveRun, d as renderEventLine, f as DEFAULT_STORE_DIR, g as looksLikeFile, h as loadWorkflowModule, i as inspectCommand, l as reportOutcome, m as loadCliConfig, n as HELP, o as runCommand, p as assembleEngine, r as runCli, s as runsLsCommand, t as processIo, u as attachProgress } from "./io-CV_G44A2.js";
2
- import { ConfigError, InvalidResolutionError, JournalCompatibilityError, LeaseHeldError, Replayer, RulvarError, buildDeriverRegistry, costReportFromJournal, maskSecrets, normalizeEntry, scanJournalCompatibility, validateSchemaSpec } from "@rulvar/core";
1
+ import { a as resumeCommand, c as driveRun, d as renderEventLine, f as DEFAULT_STORE_DIR, g as looksLikeFile, h as loadWorkflowModule, i as inspectCommand, l as reportOutcome, m as loadCliConfig, n as HELP, o as runCommand, p as assembleEngine, r as runCli, s as runsLsCommand, t as processIo, u as attachProgress } from "./io-C3T5nbNg.js";
2
+ import { ConfigError, InvalidResolutionError, JournalCompatibilityError, LeaseHeldError, Replayer, RulvarError, buildDeriverRegistry, costReportFromJournal, maskSecrets, normalizeEntry, readRunMeta, scanJournalCompatibility, validateSchemaSpec } from "@rulvar/core";
3
3
  //#region src/server.ts
4
4
  /**
5
5
  * createServer (M8-T01): the HTTP shell over the public engine API
@@ -77,13 +77,39 @@ function createServer(options) {
77
77
  const { engine, workflows } = options;
78
78
  const journal = engine.stores.journal;
79
79
  const runs = /* @__PURE__ */ new Map();
80
+ /**
81
+ * Buffers one event under the configured bound. Overflow drops the
82
+ * oldest chunk (an eighth of the bound) in one splice, so the
83
+ * amortized cost per event stays O(1) and the retained window never
84
+ * falls below seven eighths of the bound.
85
+ */
86
+ function pushBuffered(run, event) {
87
+ run.buffer.push(event);
88
+ const max = options.maxBufferedEventsPerRun;
89
+ if (max !== void 0 && run.buffer.length > max) {
90
+ const chunk = Math.max(1, Math.floor(max / 8));
91
+ run.buffer.splice(0, chunk);
92
+ run.dropped += chunk;
93
+ }
94
+ }
95
+ /**
96
+ * Releases settled tracked runs beyond maxTrackedRuns, oldest first
97
+ * (Map insertion order), durable state untouched. Live runs never
98
+ * count and are never evicted.
99
+ */
100
+ function enforceTrackedCap() {
101
+ const cap = options.maxTrackedRuns;
102
+ if (cap === void 0) return;
103
+ const settled = [...runs.values()].filter((run) => run.done);
104
+ for (const run of settled.slice(0, Math.max(0, settled.length - cap))) runs.delete(run.runId);
105
+ }
80
106
  /** Pumps one resume segment's events into the buffer and the feeds. */
81
107
  function attach(run, handle) {
82
108
  run.handle = handle;
83
109
  run.outcome = void 0;
84
110
  (async () => {
85
111
  for await (const event of handle.events) {
86
- run.buffer.push(event);
112
+ pushBuffered(run, event);
87
113
  for (const feed of [...run.feeds]) feed(event);
88
114
  }
89
115
  })().catch(() => void 0);
@@ -93,12 +119,18 @@ function createServer(options) {
93
119
  run.done = true;
94
120
  for (const feed of [...run.feeds]) feed(null);
95
121
  run.feeds.clear();
96
- if (options.retention !== void 0) (async () => {
97
- const meta = await metaOf(run.runId);
122
+ (async () => {
123
+ const meta = options.retention === void 0 && options.memoryRetention === void 0 ? void 0 : await metaOf(run.runId);
98
124
  if (meta !== void 0 && options.retention?.(meta) === true) {
99
125
  await engine.deleteRun(run.runId);
100
126
  runs.delete(run.runId);
127
+ return;
128
+ }
129
+ if (meta !== void 0 && options.memoryRetention?.(meta) === true) {
130
+ runs.delete(run.runId);
131
+ return;
101
132
  }
133
+ enforceTrackedCap();
102
134
  })().catch(() => void 0);
103
135
  }
104
136
  }).catch(() => void 0);
@@ -109,6 +141,7 @@ function createServer(options) {
109
141
  workflowName,
110
142
  args,
111
143
  buffer: [],
144
+ dropped: 0,
112
145
  feeds: /* @__PURE__ */ new Set(),
113
146
  handle,
114
147
  done: false,
@@ -119,7 +152,7 @@ function createServer(options) {
119
152
  return run;
120
153
  }
121
154
  async function metaOf(runId) {
122
- return (await journal.listRuns()).find((meta) => meta.runId === runId);
155
+ return readRunMeta(journal, runId);
123
156
  }
124
157
  async function startRun(req) {
125
158
  let body;
@@ -221,19 +254,35 @@ function createServer(options) {
221
254
  const lastEventId = req.headers.get("last-event-id");
222
255
  const encoder = new TextEncoder();
223
256
  let feed;
257
+ let cursor;
258
+ if (lastEventId !== null) {
259
+ const parsed = Number(lastEventId);
260
+ if (Number.isFinite(parsed)) cursor = parsed;
261
+ }
262
+ let startIndex = 0;
263
+ if (cursor !== void 0) {
264
+ let lo = 0;
265
+ let hi = run.buffer.length;
266
+ while (lo < hi) {
267
+ const mid = lo + hi >> 1;
268
+ if (run.buffer[mid].seq <= cursor) lo = mid + 1;
269
+ else hi = mid;
270
+ }
271
+ startIndex = lo;
272
+ }
273
+ const firstRetained = run.buffer.length > 0 ? run.buffer[0].seq : void 0;
274
+ const gap = run.dropped > 0 && (cursor === void 0 || firstRetained === void 0 || cursor < firstRetained);
275
+ const headers = {
276
+ "content-type": "text/event-stream",
277
+ "cache-control": "no-cache",
278
+ ...run.dropped > 0 ? { "x-rulvar-events-dropped": String(run.dropped) } : {}
279
+ };
224
280
  const stream = new ReadableStream({
225
281
  start(controller) {
226
- let startIndex = 0;
227
- if (lastEventId !== null) {
228
- const cursor = Number(lastEventId);
229
- if (Number.isFinite(cursor)) {
230
- for (let i = run.buffer.length - 1; i >= 0; i -= 1) if (run.buffer[i].seq === cursor) {
231
- startIndex = i + 1;
232
- break;
233
- }
234
- }
235
- }
236
- for (const event of run.buffer.slice(startIndex)) controller.enqueue(encoder.encode(sseFrame(event)));
282
+ if (gap) controller.enqueue(encoder.encode(`: replay window starts at seq ${String(firstRetained ?? "none")}; ${run.dropped} earlier events were dropped from the in-memory buffer (the journal is the durable record)
283
+
284
+ `));
285
+ for (let i = startIndex; i < run.buffer.length; i += 1) controller.enqueue(encoder.encode(sseFrame(run.buffer[i])));
237
286
  if (run.done) {
238
287
  controller.close();
239
288
  return;
@@ -257,10 +306,7 @@ function createServer(options) {
257
306
  });
258
307
  return new Response(stream, {
259
308
  status: 200,
260
- headers: {
261
- "content-type": "text/event-stream",
262
- "cache-control": "no-cache"
263
- }
309
+ headers
264
310
  });
265
311
  }
266
312
  /** The tracked path: live (or settled-suspended) in this process. */
@@ -487,14 +533,28 @@ function createWorker(engine, options) {
487
533
  const pollMs = options.pollMs ?? 1e3;
488
534
  const registry = buildDeriverRegistry(options.extraDerivers);
489
535
  const active = /* @__PURE__ */ new Map();
490
- /** Runs this worker must not retry (DEF-6 violations, binding errors). */
491
- const poisoned = /* @__PURE__ */ new Set();
492
536
  /**
493
- * Journal length at our last release of a still-suspended run: nothing
494
- * new to consume until it grows (an offline resolution appends).
537
+ * Runs this worker must not retry (DEF-6 violations, binding errors),
538
+ * keyed to the run's generation (RunMeta.genesis) at poison time: a
539
+ * deleteRun and recreate of the same runId is a NEW run and must not
540
+ * inherit the poison. Entries for runIds that leave the candidate set
541
+ * are dropped each sweep, so an external delete cannot pin
542
+ * process-local state forever (v1.25.0 scale review).
543
+ */
544
+ const poisoned = /* @__PURE__ */ new Map();
545
+ /**
546
+ * Journal length AND generation at our last release of a
547
+ * still-suspended run: nothing new to consume until the journal grows
548
+ * (an offline resolution appends) or the generation changes (the same
549
+ * runId was deleted and recreated; length alone cannot tell the new
550
+ * run from the old unchanged one, the v1.25.0 scale review). Runs
551
+ * whose meta predates the genesis field compare as equal when both
552
+ * sides are undefined, the historical behavior of length alone.
495
553
  */
496
554
  const suspendedAt = /* @__PURE__ */ new Map();
497
555
  let pollTimer;
556
+ /** An interval tick never overlaps a sweep that is still running. */
557
+ let sweeping = false;
498
558
  let stopping = false;
499
559
  function reportError(runId, error) {
500
560
  try {
@@ -523,10 +583,13 @@ function createWorker(engine, options) {
523
583
  const settled = handle.result.then(async (outcome) => {
524
584
  if (outcome.status === "suspended") {
525
585
  const entries = await store.load(runId);
526
- suspendedAt.set(runId, entries.length);
586
+ suspendedAt.set(runId, {
587
+ length: entries.length,
588
+ genesis: meta.genesis
589
+ });
527
590
  } else suspendedAt.delete(runId);
528
591
  }).catch((thrown) => {
529
- if (thrown instanceof ConfigError || thrown instanceof JournalCompatibilityError) poisoned.add(runId);
592
+ if (thrown instanceof ConfigError || thrown instanceof JournalCompatibilityError) poisoned.set(runId, meta.genesis);
530
593
  reportError(runId, thrown);
531
594
  }).finally(async () => {
532
595
  clearInterval(renewTimer);
@@ -562,45 +625,58 @@ function createWorker(engine, options) {
562
625
  }
563
626
  }
564
627
  async function sweep() {
565
- if (stopping) return 0;
566
- let picked = 0;
567
- const metas = await store.listRuns();
568
- for (const meta of metas) {
569
- if (active.size >= concurrency) break;
570
- if (!CANDIDATE_STATUSES.has(meta.status)) {
571
- if (options.retention !== void 0 && !active.has(meta.runId)) await applyRetention(meta).catch((thrown) => {
572
- reportError(meta.runId, thrown);
573
- });
574
- continue;
575
- }
576
- if (active.has(meta.runId) || poisoned.has(meta.runId)) continue;
577
- let lease;
578
- try {
579
- lease = await store.acquire(meta.runId, owner);
580
- } catch (thrown) {
581
- if (thrown instanceof LeaseHeldError) continue;
582
- throw thrown;
583
- }
584
- try {
585
- const entries = (await store.load(meta.runId)).map((raw) => normalizeEntry(raw));
586
- scanJournalCompatibility(meta.runId, entries, registry);
587
- if (meta.status === "suspended" && suspendedAt.get(meta.runId) === entries.length) {
588
- await releaseQuietly(lease);
628
+ if (stopping || sweeping) return 0;
629
+ sweeping = true;
630
+ try {
631
+ let picked = 0;
632
+ const metas = options.retention === void 0 ? await store.listRuns({ statuses: [...CANDIDATE_STATUSES] }) : await store.listRuns();
633
+ const candidateIds = new Set(metas.filter((meta) => CANDIDATE_STATUSES.has(meta.status)).map((meta) => meta.runId));
634
+ for (const runId of [...suspendedAt.keys()]) if (!candidateIds.has(runId)) suspendedAt.delete(runId);
635
+ for (const runId of [...poisoned.keys()]) if (!candidateIds.has(runId)) poisoned.delete(runId);
636
+ for (const meta of metas) {
637
+ if (active.size >= concurrency) break;
638
+ if (!CANDIDATE_STATUSES.has(meta.status)) {
639
+ if (options.retention !== void 0 && !active.has(meta.runId)) await applyRetention(meta).catch((thrown) => {
640
+ reportError(meta.runId, thrown);
641
+ });
589
642
  continue;
590
643
  }
591
- picked += 1;
592
- drive(meta.runId, meta, lease);
593
- } catch (thrown) {
594
- await releaseQuietly(lease);
595
- if (thrown instanceof JournalCompatibilityError || thrown instanceof ConfigError) {
596
- poisoned.add(meta.runId);
644
+ if (active.has(meta.runId)) continue;
645
+ if (poisoned.has(meta.runId)) {
646
+ if (poisoned.get(meta.runId) === meta.genesis) continue;
647
+ poisoned.delete(meta.runId);
648
+ }
649
+ let lease;
650
+ try {
651
+ lease = await store.acquire(meta.runId, owner);
652
+ } catch (thrown) {
653
+ if (thrown instanceof LeaseHeldError) continue;
654
+ throw thrown;
655
+ }
656
+ try {
657
+ const entries = (await store.load(meta.runId)).map((raw) => normalizeEntry(raw));
658
+ scanJournalCompatibility(meta.runId, entries, registry);
659
+ const cached = suspendedAt.get(meta.runId);
660
+ if (meta.status === "suspended" && cached !== void 0 && cached.length === entries.length && cached.genesis === meta.genesis) {
661
+ await releaseQuietly(lease);
662
+ continue;
663
+ }
664
+ picked += 1;
665
+ drive(meta.runId, meta, lease);
666
+ } catch (thrown) {
667
+ await releaseQuietly(lease);
668
+ if (thrown instanceof JournalCompatibilityError || thrown instanceof ConfigError) {
669
+ poisoned.set(meta.runId, meta.genesis);
670
+ reportError(meta.runId, thrown);
671
+ continue;
672
+ }
597
673
  reportError(meta.runId, thrown);
598
- continue;
599
674
  }
600
- reportError(meta.runId, thrown);
601
675
  }
676
+ return picked;
677
+ } finally {
678
+ sweeping = false;
602
679
  }
603
- return picked;
604
680
  }
605
681
  return {
606
682
  start: () => {
@@ -1,4 +1,4 @@
1
- import { ConfigError, FileModelKnowledgeStore, INBOX_PROPOSAL_TTL_DAYS, JsonlFileStore, claimExpired, claimExpiry, compilePermissionPreset, costReportFromJournal, createEngine, hashRunArgs, parseModelRef, priceUsdOf, proposalStatement, remeasureQueue, resolvePricing, runProfile, sanitizeTerminalText } from "@rulvar/core";
1
+ import { ConfigError, FileModelKnowledgeStore, INBOX_PROPOSAL_TTL_DAYS, JsonlFileStore, claimExpired, claimExpiry, compilePermissionPreset, costReportFromJournal, createEngine, hashRunArgs, parseModelRef, priceUsdOf, proposalStatement, readRunMeta, remeasureQueue, resolvePricing, runProfile, sanitizeTerminalText } from "@rulvar/core";
2
2
  import { join, resolve } from "node:path";
3
3
  import { existsSync, statSync } from "node:fs";
4
4
  import { pathToFileURL } from "node:url";
@@ -234,6 +234,13 @@ function attachProgress(handle, io) {
234
234
  }
235
235
  //#endregion
236
236
  //#region src/drive.ts
237
+ /**
238
+ * The run/suspend/resolve/resume loop shared by `rulvar run` and
239
+ * `rulvar resume` (the CLI performs interactive resolution of suspended
240
+ * approvals and external inputs). Prompts read
241
+ * one line per pending suspension; EOF leaves the run suspended with a
242
+ * notice, never an error.
243
+ */
237
244
  const APPROVAL_PREFIX = "approval:";
238
245
  /** Parses an approval answer; undefined = unusable input. */
239
246
  function approvalDecision(answer) {
@@ -259,31 +266,32 @@ function approvalDecision(answer) {
259
266
  async function resolvePending(handle, pending, io) {
260
267
  let applied = 0;
261
268
  for (const item of pending) {
269
+ const keyRef = sanitizeTerminalText(item.key);
262
270
  if (item.key.startsWith(APPROVAL_PREFIX)) {
263
- const answer = await io.prompt(`approve '${item.prompt ?? item.key}'? [allow/deny]`);
271
+ const answer = await io.prompt(`approve '${sanitizeTerminalText(item.prompt ?? item.key)}'? [allow/deny]`);
264
272
  if (answer === void 0) return applied;
265
273
  const decision = approvalDecision(answer);
266
274
  if (decision === void 0) {
267
- io.err(`unrecognized answer '${answer.trim()}'; leaving ${item.key} suspended`);
275
+ io.err(`unrecognized answer '${sanitizeTerminalText(answer.trim())}'; leaving ${keyRef} suspended`);
268
276
  continue;
269
277
  }
270
278
  const outcome = await handle.resolveExternal(item.key, decision);
271
- io.err(`approval ${item.key}: ${decision.decision} (${outcome.applied ? "applied" : outcome.reason})`);
279
+ io.err(`approval ${keyRef}: ${decision.decision} (${outcome.applied ? "applied" : sanitizeTerminalText(outcome.reason)})`);
272
280
  if (outcome.applied) applied += 1;
273
281
  continue;
274
282
  }
275
- const label = item.prompt === void 0 ? item.key : `${item.key} (${item.prompt})`;
283
+ const label = item.prompt === void 0 ? keyRef : `${keyRef} (${sanitizeTerminalText(item.prompt)})`;
276
284
  const answer = await io.prompt(`value for external '${label}' as JSON:`);
277
285
  if (answer === void 0) return applied;
278
286
  let value;
279
287
  try {
280
288
  value = JSON.parse(answer);
281
289
  } catch {
282
- io.err(`not valid JSON; leaving '${item.key}' suspended`);
290
+ io.err(`not valid JSON; leaving '${keyRef}' suspended`);
283
291
  continue;
284
292
  }
285
293
  const outcome = await handle.resolveExternal(item.key, value);
286
- io.err(`external '${item.key}': ${outcome.applied ? "applied" : outcome.reason}`);
294
+ io.err(`external '${keyRef}': ${outcome.applied ? "applied" : sanitizeTerminalText(outcome.reason)}`);
287
295
  if (outcome.applied) applied += 1;
288
296
  }
289
297
  return applied;
@@ -320,29 +328,35 @@ async function reportDryRun(handle, io) {
320
328
  io.err(` hits: ${preview.hits} misses: ${preview.misses} reruns: ${preview.reruns} skipped: ${preview.skipped}`);
321
329
  io.err(preview.orphaned.length === 0 ? " orphaned effect roots: none" : ` orphaned effect roots (entryRefs): ${preview.orphaned.join(", ")}`);
322
330
  if (preview.invalidResolutions.length === 0) io.err(" invalid resolutions: none");
323
- else for (const invalid of preview.invalidResolutions) io.err(` invalid resolution at seq ${invalid.seq}: ${invalid.detail}`);
331
+ else for (const invalid of preview.invalidResolutions) io.err(` invalid resolution at seq ${invalid.seq}: ${sanitizeTerminalText(invalid.detail)}`);
324
332
  if (outcome.error?.code === "journal_miss") {
325
- io.err(` stopped at the first would-be-live call: ${outcome.error.message}`);
333
+ io.err(` stopped at the first would-be-live call: ${sanitizeTerminalText(outcome.error.message)}`);
326
334
  io.err(" a real resume would perform new paid work from this point");
327
335
  return 0;
328
336
  }
329
337
  io.err(` would settle: ${outcome.status}`);
330
- if (outcome.error !== void 0) io.err(` error: ${outcome.error.message}`);
331
- for (const pending of outcome.pending) io.err(` pending: ${pending.key} (entry ${pending.entryRef})`);
338
+ if (outcome.error !== void 0) io.err(` error: ${sanitizeTerminalText(outcome.error.message)}`);
339
+ for (const pending of outcome.pending) io.err(` pending: ${sanitizeTerminalText(pending.key)} (entry ${pending.entryRef})`);
332
340
  if (outcome.value !== void 0) io.out(JSON.stringify(outcome.value, null, 2));
333
341
  return 0;
334
342
  }
335
- /** Renders the settled outcome; returns the process exit code. */
343
+ /**
344
+ * Renders the settled outcome; returns the process exit code. Error
345
+ * messages, suspension keys, model refs, and phase names originate from
346
+ * providers, tools, and workflow authors, so each is sanitized before
347
+ * it reaches a terminal line, matching the TUI renderer (v1.24.1 review
348
+ * P2-1). Values print as JSON, which escapes control bytes on its own.
349
+ */
336
350
  function reportOutcome(outcome, io) {
337
351
  io.err(`status: ${outcome.status}`);
338
352
  if (outcome.value !== void 0) io.out(JSON.stringify(outcome.value, null, 2));
339
- if (outcome.error !== void 0) io.err(`error: ${outcome.error.message}`);
353
+ if (outcome.error !== void 0) io.err(`error: ${sanitizeTerminalText(outcome.error.message)}`);
340
354
  if (outcome.dropped.length > 0) io.err(`dropped: ${outcome.dropped.length} item(s)`);
341
- for (const pending of outcome.pending) io.err(`pending: ${pending.key} (entry ${pending.entryRef})`);
355
+ for (const pending of outcome.pending) io.err(`pending: ${sanitizeTerminalText(pending.key)} (entry ${pending.entryRef})`);
342
356
  io.err(`cost: $${outcome.cost.totalUsd.toFixed(4)}`);
343
- for (const [model, usd] of Object.entries(outcome.cost.byModel)) io.err(` by model ${model}: $${usd.toFixed(4)}`);
344
- for (const [phase, usd] of Object.entries(outcome.cost.byPhase)) if (phase !== "") io.err(` by phase ${phase}: $${usd.toFixed(4)}`);
345
- if (outcome.cost.unpriced.length > 0) io.err(`unpriced models: ${outcome.cost.unpriced.map((u) => u.model).join(", ")}`);
357
+ for (const [model, usd] of Object.entries(outcome.cost.byModel)) io.err(` by model ${sanitizeTerminalText(model)}: $${usd.toFixed(4)}`);
358
+ for (const [phase, usd] of Object.entries(outcome.cost.byPhase)) if (phase !== "") io.err(` by phase ${sanitizeTerminalText(phase)}: $${usd.toFixed(4)}`);
359
+ if (outcome.cost.unpriced.length > 0) io.err(`unpriced models: ${outcome.cost.unpriced.map((u) => sanitizeTerminalText(u.model)).join(", ")}`);
346
360
  switch (outcome.status) {
347
361
  case "ok":
348
362
  case "suspended": return 0;
@@ -655,6 +669,10 @@ async function loadCompanion(loading, specifier, command, missingMessage) {
655
669
  * later. In-process hosts keep the wider engine contract (functions,
656
670
  * BigInt, cycles record presence without a hash); the CLI does not need
657
671
  * it.
672
+ *
673
+ * Diagnostics name the failure class and the way out but never echo the
674
+ * value: workflow args may carry private data, and stderr routinely
675
+ * lands in CI logs (v1.24.1 review P2-1).
658
676
  */
659
677
  function parseArgsJson(raw) {
660
678
  if (raw === void 0) return;
@@ -662,12 +680,12 @@ function parseArgsJson(raw) {
662
680
  try {
663
681
  parsed = JSON.parse(raw);
664
682
  } catch {
665
- throw new ConfigError(`--args is not valid JSON: ${raw}`);
683
+ throw new ConfigError("--args is not valid JSON; check the JSON syntax and shell quoting (the value is withheld from diagnostics: workflow args may carry private data)");
666
684
  }
667
685
  try {
668
686
  hashRunArgs(parsed);
669
687
  } catch {
670
- throw new ConfigError(`--args is not representable as canonical JSON: a numeric value overflows JavaScript's finite range (e.g. 1e400 parses to Infinity). Supply finite JSON so the run's args binding can be hashed and later verified on resume: ${raw}`);
688
+ throw new ConfigError("--args is not representable as canonical JSON: a numeric value overflows JavaScript's finite range (e.g. 1e400 parses to Infinity). Supply finite JSON so the run's args binding can be hashed and later verified on resume (the value is withheld from diagnostics: workflow args may carry private data)");
671
689
  }
672
690
  return parsed;
673
691
  }
@@ -711,20 +729,21 @@ async function runCommand(argv, context) {
711
729
  */
712
730
  function enforceArgsBinding(input) {
713
731
  const { meta, argsGiven, args, allowChange, io } = input;
732
+ const runRef = sanitizeTerminalText(meta.runId);
714
733
  if (meta.argsProvided === void 0) {
715
734
  if (!argsGiven && !allowChange) throw new ConfigError(`run '${meta.runId}' predates the args binding (rulvar < 1.24.0), so the CLI cannot tell whether it was started with --args, and resuming without them silently changes the logical run if any were used at start. Re-supply the original --args, or acknowledge explicitly with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
716
- if (argsGiven) io.err(`warning: run '${meta.runId}' predates the args binding; the supplied --args cannot be verified against the original invocation`);
735
+ if (argsGiven) io.err(`warning: run '${runRef}' predates the args binding; the supplied --args cannot be verified against the original invocation`);
717
736
  return;
718
737
  }
719
738
  if (meta.argsProvided) {
720
739
  if (!argsGiven) {
721
740
  if (!allowChange) throw new ConfigError(`run '${meta.runId}' was started WITH args, but this resume supplies none; the workflow would see undefined and every args-dependent call would become new paid work instead of a replay. Re-supply the original --args, or force the change with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
722
- io.err(`warning: resuming '${meta.runId}' without its genesis args (--allow-args-change)`);
741
+ io.err(`warning: resuming '${runRef}' without its genesis args (--allow-args-change)`);
723
742
  return;
724
743
  }
725
744
  if (meta.argsHash === void 0) {
726
745
  if (!allowChange) throw new ConfigError(`run '${meta.runId}' started WITH args but recorded no verifiable hash (the genesis args were not JCS-serializable), so the CLI cannot confirm the supplied --args match the original; resuming risks silently changing the logical run and re-paying every args-dependent call. Force deliberately with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
727
- io.err(`warning: run '${meta.runId}' recorded args presence but no hash (genesis args not JCS-serializable); the supplied --args cannot be verified (--allow-args-change)`);
746
+ io.err(`warning: run '${runRef}' recorded args presence but no hash (genesis args not JCS-serializable); the supplied --args cannot be verified (--allow-args-change)`);
728
747
  return;
729
748
  }
730
749
  let supplied;
@@ -735,13 +754,13 @@ function enforceArgsBinding(input) {
735
754
  }
736
755
  if (supplied !== meta.argsHash) {
737
756
  if (!allowChange) throw new ConfigError(`--args does not match the args run '${meta.runId}' was started with (recorded hash ${meta.argsHash.slice(0, 12)}, supplied ${supplied?.slice(0, 12) ?? "none"}); changed args silently change the logical run and re-pay every args-dependent call. Force deliberately with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
738
- io.err(`warning: resuming '${meta.runId}' with changed args (--allow-args-change)`);
757
+ io.err(`warning: resuming '${runRef}' with changed args (--allow-args-change)`);
739
758
  }
740
759
  return;
741
760
  }
742
761
  if (argsGiven) {
743
762
  if (!allowChange) throw new ConfigError(`run '${meta.runId}' was started WITHOUT args, but this resume supplies some; added args silently change the logical run. Drop --args, or force the change with --allow-args-change; ${usageOf(GRAMMAR.resume)}`);
744
- io.err(`warning: resuming no-args run '${meta.runId}' with args (--allow-args-change)`);
763
+ io.err(`warning: resuming no-args run '${runRef}' with args (--allow-args-change)`);
745
764
  }
746
765
  }
747
766
  async function resumeCommand(argv, context) {
@@ -758,7 +777,7 @@ async function resumeCommand(argv, context) {
758
777
  ...store === void 0 ? {} : { storePath: store },
759
778
  cwd: context.cwd
760
779
  });
761
- const meta = (await assembled.store.listRuns()).find((m) => m.runId === runId);
780
+ const meta = await readRunMeta(assembled.store, runId);
762
781
  if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
763
782
  enforceArgsBinding({
764
783
  meta,
@@ -810,7 +829,7 @@ async function inspectCommand(argv, context) {
810
829
  ...store === void 0 ? {} : { storePath: store },
811
830
  cwd: context.cwd
812
831
  });
813
- const meta = (await assembled.store.listRuns()).find((m) => m.runId === runId);
832
+ const meta = await readRunMeta(assembled.store, runId);
814
833
  if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
815
834
  const entries = await assembled.store.load(runId);
816
835
  context.io.out(`run ${meta.runId}: ${meta.status} (updated ${meta.updatedAt})`);
@@ -872,7 +891,7 @@ async function planCommand(argv, context) {
872
891
  });
873
892
  const planned = await plannerModule.plan(assembled.engine, goal, planningBudgetUsd === void 0 ? void 0 : { run: { budgetUsd: planningBudgetUsd } });
874
893
  context.io.err(`plan: accepted with ${String(planned.lint.length)} advisory diagnostic(s)`);
875
- for (const diagnostic of planned.lint) context.io.err(` ${diagnostic.ruleId}: ${diagnostic.message}`);
894
+ for (const diagnostic of planned.lint) context.io.err(` ${diagnostic.ruleId}: ${sanitizeTerminalText(diagnostic.message)}`);
876
895
  if (dryRun) {
877
896
  context.io.out(planned.source);
878
897
  return 0;
@@ -1068,7 +1087,7 @@ async function kbGateCommand(argv, context) {
1068
1087
  ...values.store === void 0 ? {} : { storePath: values.store },
1069
1088
  cwd: context.cwd
1070
1089
  });
1071
- const meta = (await assembled.store.listRuns()).find((candidate) => candidate.runId === runId);
1090
+ const meta = await readRunMeta(assembled.store, runId);
1072
1091
  if (meta === void 0) throw new ConfigError(`run '${runId}' not found in the store`);
1073
1092
  if (meta.status === "running") throw new ConfigError(`run '${runId}' is still running; proposals gate from finished runs`);
1074
1093
  if (Date.parse(meta.updatedAt) < Date.now() - INBOX_PROPOSAL_TTL_DAYS * 24 * 60 * 60 * 1e3) throw new ConfigError(`the proposal expired: run '${runId}' finished ${meta.updatedAt}, and inbox entries expire after ${String(INBOX_PROPOSAL_TTL_DAYS)} days`);
@@ -1339,7 +1358,7 @@ async function runCli(argv, options) {
1339
1358
  }
1340
1359
  } catch (thrown) {
1341
1360
  if (thrown instanceof ConfigError) {
1342
- options.io.err(`error: ${thrown.message}`);
1361
+ options.io.err(`error: ${sanitizeTerminalText(thrown.message)}`);
1343
1362
  return 1;
1344
1363
  }
1345
1364
  throw thrown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/cli",
3
- "version": "1.24.1",
3
+ "version": "1.26.0",
4
4
  "description": "Rulvar shell: run/resume/runs/inspect/plan/kb commands, TUI progress, createServer, createWorker, OTel exporter.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,17 +22,17 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@rulvar/core": "1.24.1"
25
+ "@rulvar/core": "1.26.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.20.0",
29
29
  "tsdown": "^0.22.3",
30
30
  "typescript": "~6.0.3",
31
- "@rulvar/store-sqlite": "1.24.1",
32
- "@rulvar/testing": "1.24.1",
33
- "@rulvar/planner": "1.24.1",
34
- "@rulvar/plan": "1.24.1",
35
- "@rulvar/evals": "1.24.1"
31
+ "@rulvar/plan": "1.26.0",
32
+ "@rulvar/testing": "1.26.0",
33
+ "@rulvar/planner": "1.26.0",
34
+ "@rulvar/evals": "1.26.0",
35
+ "@rulvar/store-sqlite": "1.26.0"
36
36
  },
37
37
  "bin": {
38
38
  "rulvar": "./dist/cli.js"