@trazum/cli 1.37.0 → 1.38.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/cli",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
@@ -37,7 +37,7 @@
37
37
  "prepublishOnly": "npm run build && npm test"
38
38
  },
39
39
  "dependencies": {
40
- "@trazum/core": "1.37.0"
40
+ "@trazum/core": "1.38.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -39,6 +39,7 @@ ${bold('USAGE')}
39
39
  trazum eval <file> --cases <file> [options]
40
40
  trazum eval <file> --cases <file> --export promptfoo -o suite.json
41
41
  trazum route <log.jsonl> --prompt-file <file> --cases <file> --yes
42
+ trazum plan <log.jsonl|dir> [options]
42
43
  trazum diff <before> <after> [options]
43
44
  trazum diff --all <dir> <dir> [options]
44
45
  trazum rank <dir> [options]
@@ -299,6 +300,23 @@ ${bold('OPTIONS FOR profile')}
299
300
  "label" (which workload), "session" (which conversation — grouped by, never
300
301
  printed), "stop_reason"/"finish_reason" (answers cut off at max_tokens).
301
302
 
303
+ ${bold('OPTIONS FOR plan')}
304
+ --min-usd <n> Leave out actions worth less than n dollars. How
305
+ many were left out is stated, never silent.
306
+ -o, --out <file> Save the plan as dated JSON — the file
307
+ "trazum verify" will later hold it to.
308
+ --markdown-out <file> Also write the plan as Markdown, for a CI job
309
+ summary or a pull request comment.
310
+ --pricing <file> Local price overlay, as everywhere else.
311
+ --json The plan as data.
312
+
313
+ Reads a usage log and turns the report's findings into a ranked plan: route
314
+ this, batch that, fix the truncation pair, look at the cache. The money is
315
+ composed correctly — route and batch on the same slice arrive combined,
316
+ never summed — and every action names what the log cannot confirm, because
317
+ a plan that hides its assumptions is advice pretending to be arithmetic.
318
+ Projected savings and money already spent are separate totals throughout.
319
+
302
320
  ${bold('OPTIONS FOR route')}
303
321
  --prompt-file <file> The prompt those calls send. Not --prompt, which
304
322
  names a marked prompt inside a source file.
@@ -1345,6 +1363,55 @@ ${bold('EXAMPLES')}
1345
1363
  `${label} on ${model}: ${single} of ${sessions} conversations ended after their first turn and spent ${usd} writing a cache that nothing in this log ever read. Within the conversation, across conversations — no read anywhere, so those writes bought nothing. Caching a one-shot call is pure write premium; stop marking these calls with cache_control.`,
1346
1364
  },
1347
1365
 
1366
+ plan: {
1367
+ noTarget: () =>
1368
+ 'Point this at a usage log or a directory of them: trazum plan usage.jsonl. It turns the report into a ranked plan — what to do first, what each action is worth, and what the log cannot confirm about it.',
1369
+ nothingPriced: () =>
1370
+ 'This log priced nothing — no call in it matched a model in the catalogue. A plan over zero dollars would be advice about nothing; check the log with "trazum profile" first.',
1371
+ heading: (actions, total) => `The plan: ${actions} actions against a ${total} bill`,
1372
+ totals: (projected, staked) =>
1373
+ `${projected} projected savings, on assumptions listed below. ${staked} already spent on problems this plan names — measured, not projected.`,
1374
+ noClock: () =>
1375
+ 'No timestamps in this log, so every figure is per this log, not per any period.',
1376
+ projected: (usd) => `${usd} projected`,
1377
+ staked: (usd) => `${usd} already spent`,
1378
+ action: (kind, label, model) => {
1379
+ const verb =
1380
+ kind === 'route'
1381
+ ? 'Route'
1382
+ : kind === 'batch'
1383
+ ? 'Batch'
1384
+ : kind === 'route+batch'
1385
+ ? 'Route and batch'
1386
+ : kind === 'fix-truncation'
1387
+ ? 'Fix the truncation retries on'
1388
+ : 'Look at the cache on';
1389
+ return `${verb} ${label} (${model})`;
1390
+ },
1391
+ routeTo: (model) => `to ${model} — combined with batching where both apply, never summed`,
1392
+ assume: (assumption) => {
1393
+ switch (assumption.kind) {
1394
+ case 'model-capability':
1395
+ return `assumes ${assumption.model} can do this work — the log prices the move, it cannot judge the answers`;
1396
+ case 'batch-window':
1397
+ return 'assumes these calls can wait for a batch window';
1398
+ case 'retry-pattern-real':
1399
+ return 'assumes the retry pattern is real — the log sees shapes, not content';
1400
+ case 'max-tokens-fits':
1401
+ return 'assumes a max_tokens the answers fit in removes the pair';
1402
+ case 'traffic-pattern-holds':
1403
+ return 'assumes the traffic pattern holds — a cache that lost money on this log may pay on different traffic';
1404
+ }
1405
+ },
1406
+ check: (command) => `check it: ${command}`,
1407
+ filtered: (count, minUsd, worth) =>
1408
+ `${count} actions under ${minUsd}, worth ${worth} together, left out by --min-usd — left out of this document entirely, not disproved.`,
1409
+ footer: () =>
1410
+ 'Ranked by money, projected or already spent alike. The assumptions are yours to answer: this plan is arithmetic over the log, not knowledge of your product.',
1411
+ wrote: (path) =>
1412
+ `Plan written to ${path}, dated. Keep it: a prediction nobody wrote down is a prediction nobody can be held to.`,
1413
+ },
1414
+
1348
1415
  route: {
1349
1416
  noTarget: () =>
1350
1417
  'Point this at a usage log and a prompt: trazum route usage.jsonl --prompt-file prompts/support.txt --cases cases.txt --yes. It finds the slice worth the most, then measures whether the cheaper model still does the job. The flag is --prompt-file and not --prompt, because --prompt names a marked prompt inside a source file everywhere else in this tool.',
package/src/i18n/es.ts CHANGED
@@ -26,6 +26,7 @@ ${bold('USO')}
26
26
  trazum eval <fichero> --cases <fichero> [opciones]
27
27
  trazum eval <fichero> --cases <fichero> --export promptfoo -o suite.json
28
28
  trazum route <log.jsonl> --prompt-file <fichero> --cases <fichero> --yes
29
+ trazum plan <log.jsonl|dir> [opciones]
29
30
  trazum diff <antes> <después> [opciones]
30
31
  trazum diff --all <dir> <dir> [opciones]
31
32
  trazum rank <dir> [opciones]
@@ -304,6 +305,25 @@ ${bold('OPCIONES DE profile')}
304
305
  nunca se imprime), "stop_reason"/"finish_reason" (respuestas cortadas en
305
306
  max_tokens).
306
307
 
308
+ ${bold('OPCIONES DE plan')}
309
+ --min-usd <n> Deja fuera las acciones que valen menos de n
310
+ dólares. Cuántas quedaron fuera se dice, nunca
311
+ en silencio.
312
+ -o, --out <fichero> Guarda el plan como JSON fechado — el fichero al
313
+ que "trazum verify" lo atará después.
314
+ --markdown-out <fichero> Escribe además el plan como Markdown, para un
315
+ resumen de CI o un comentario de pull request.
316
+ --pricing <fichero> Tarifas locales superpuestas, como en el resto.
317
+ --json El plan como datos.
318
+
319
+ Lee un registro de uso y convierte los hallazgos del informe en un plan
320
+ ordenado: enruta esto, agrupa aquello, arregla el par de truncados, mira la
321
+ caché. El dinero está compuesto correctamente — ruta y batch sobre la misma
322
+ porción llegan combinados, nunca sumados — y cada acción nombra lo que el
323
+ registro no puede confirmar, porque un plan que esconde sus supuestos es un
324
+ consejo haciéndose pasar por aritmética. El ahorro proyectado y el dinero ya
325
+ gastado son totales separados en todas partes.
326
+
307
327
  ${bold('OPCIONES DE route')}
308
328
  --prompt-file <fichero> El prompt que mandan esas llamadas. No --prompt,
309
329
  que nombra un prompt marcado dentro de un fuente.
@@ -1363,6 +1383,55 @@ ${bold('EJEMPLOS')}
1363
1383
  `${label} en ${model}: ${single} de ${sessions} conversaciones terminaron tras su primer turno y gastaron ${usd} escribiendo una caché que nada en este registro leyó jamás. Dentro de la conversación, entre conversaciones — ninguna lectura en ningún sitio, así que esas escrituras no compraron nada. Cachear una llamada de un solo uso es puro sobreprecio de escritura; deja de marcar estas llamadas con cache_control.`,
1364
1384
  },
1365
1385
 
1386
+ plan: {
1387
+ noTarget: () =>
1388
+ 'Apunta esto a un registro de uso o a un directorio de ellos: trazum plan usage.jsonl. Convierte el informe en un plan ordenado — qué hacer primero, cuánto vale cada acción y qué no puede confirmar el registro sobre ella.',
1389
+ nothingPriced: () =>
1390
+ 'Este registro no tasó nada — ninguna llamada coincide con un modelo del catálogo. Un plan sobre cero dólares sería un consejo sobre nada; revisa el registro primero con "trazum profile".',
1391
+ heading: (actions, total) => `El plan: ${actions} acciones contra una factura de ${total}`,
1392
+ totals: (projected, staked) =>
1393
+ `${projected} de ahorro proyectado, sobre los supuestos listados abajo. ${staked} ya gastados en problemas que este plan nombra — medido, no proyectado.`,
1394
+ noClock: () =>
1395
+ 'Este registro no tiene marcas de tiempo, así que cada cifra es por este registro, no por ningún período.',
1396
+ projected: (usd) => `${usd} proyectados`,
1397
+ staked: (usd) => `${usd} ya gastados`,
1398
+ action: (kind, label, model) => {
1399
+ const verb =
1400
+ kind === 'route'
1401
+ ? 'Enruta'
1402
+ : kind === 'batch'
1403
+ ? 'Agrupa en batch'
1404
+ : kind === 'route+batch'
1405
+ ? 'Enruta y agrupa'
1406
+ : kind === 'fix-truncation'
1407
+ ? 'Arregla los reintentos por truncado de'
1408
+ : 'Mira la caché de';
1409
+ return `${verb} ${label} (${model})`;
1410
+ },
1411
+ routeTo: (model) => `a ${model} — combinado con el batch donde aplican los dos, nunca sumado`,
1412
+ assume: (assumption) => {
1413
+ switch (assumption.kind) {
1414
+ case 'model-capability':
1415
+ return `supone que ${assumption.model} puede hacer este trabajo — el registro tasa el cambio, no puede juzgar las respuestas`;
1416
+ case 'batch-window':
1417
+ return 'supone que estas llamadas pueden esperar una ventana de batch';
1418
+ case 'retry-pattern-real':
1419
+ return 'supone que el patrón de reintentos es real — el registro ve formas, no contenido';
1420
+ case 'max-tokens-fits':
1421
+ return 'supone que un max_tokens en el que quepan las respuestas elimina el par';
1422
+ case 'traffic-pattern-holds':
1423
+ return 'supone que el patrón de tráfico se mantiene — una caché que perdió dinero en este registro puede rendir con otro tráfico';
1424
+ }
1425
+ },
1426
+ check: (command) => `compruébalo: ${command}`,
1427
+ filtered: (count, minUsd, worth) =>
1428
+ `${count} acciones por debajo de ${minUsd}, que juntas valen ${worth}, quedaron fuera por --min-usd — fuera de este documento por completo, no refutadas.`,
1429
+ footer: () =>
1430
+ 'Ordenado por dinero, proyectado o ya gastado por igual. Los supuestos los respondes tú: este plan es aritmética sobre el registro, no conocimiento de tu producto.',
1431
+ wrote: (path) =>
1432
+ `Plan escrito en ${path}, con fecha. Guárdalo: una predicción que nadie apuntó es una predicción que no se le puede exigir a nadie.`,
1433
+ },
1434
+
1366
1435
  route: {
1367
1436
  noTarget: () =>
1368
1437
  'Apunta esto a un registro de uso y a un prompt: trazum route usage.jsonl --prompt-file prompts/soporte.txt --cases casos.txt --yes. Busca la porción que más vale y mide si el modelo más barato sigue haciendo el trabajo. El flag es --prompt-file y no --prompt, porque en el resto de la herramienta --prompt nombra un prompt marcado dentro de un fichero fuente.',
package/src/i18n/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { EvalVerdict, Locale, RuleLevel } from '@trazum/core';
1
+ import type { EvalVerdict, Locale, PlanActionKind, PlanAssumption, RuleLevel } from '@trazum/core';
2
2
 
3
3
  /**
4
4
  * The CLI's own message catalogue.
@@ -1038,6 +1038,40 @@ export interface CliMessages {
1038
1038
  singleTurnCeiling(label: string, model: string, single: string, sessions: string, usd: string): string;
1039
1039
  singleTurnConfirmed(label: string, model: string, single: string, sessions: string, usd: string): string;
1040
1040
  };
1041
+ /**
1042
+ * `trazum plan <log>` — not a list of findings, a ranked plan of what to do.
1043
+ *
1044
+ * The report names what it found; the plan ranks what to do about it, with
1045
+ * the money composed correctly (route and batch on one slice combined,
1046
+ * never summed) and every action carrying what the log cannot confirm.
1047
+ * Projected savings and measured stakes are separate columns throughout:
1048
+ * "what you would save" and "what you already paid" merged into one number
1049
+ * is a number that is neither.
1050
+ */
1051
+ plan: {
1052
+ noTarget(): string;
1053
+ /** The log priced nothing — a plan over zero calls would be advice about nothing. */
1054
+ nothingPriced(): string;
1055
+ heading(actions: string, total: string): string;
1056
+ totals(projected: string, staked: string): string;
1057
+ /** No timestamps: the figures are per this log, not per any period. */
1058
+ noClock(): string;
1059
+ projected(usd: string): string;
1060
+ staked(usd: string): string;
1061
+ /** One action line: what to do, to which workload, on which model. */
1062
+ action(kind: PlanActionKind, label: string, model: string): string;
1063
+ routeTo(model: string): string;
1064
+ /** One assumption the log cannot confirm, localized from its typed form. */
1065
+ assume(assumption: PlanAssumption): string;
1066
+ /** The command that can check the assumption, when one exists. */
1067
+ check(command: string): string;
1068
+ /** Actions below --min-usd, counted out loud rather than dropped silently. */
1069
+ filtered(count: string, minUsd: string, worth: string): string;
1070
+ footer(): string;
1071
+ /** The plan saved as dated JSON — what 1.39's verify will hold it to. */
1072
+ wrote(path: string): string;
1073
+ };
1074
+
1041
1075
  /**
1042
1076
  * `trazum route` — the loop the levers section could only point at.
1043
1077
  *
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  cacheableMinimum,
12
12
  analyzeCachePrefix,
13
13
  billLevers,
14
+ buildPlan,
14
15
  cacheEconomics,
15
16
  cacheHitRate,
16
17
  contextPressure,
@@ -172,6 +173,7 @@ interface Args {
172
173
  const VALUE_FLAGS = new Set([
173
174
  'against',
174
175
  'from-log',
176
+ 'min-usd',
175
177
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
176
178
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
177
179
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -312,6 +314,13 @@ function levelFlag(args: Args, config: TrazumConfig, t: CliMessages): RuleLevel
312
314
  * model id. It beats the default because reading the code is better than
313
315
  * assuming, and loses to config because being told is better than reading.
314
316
  */
317
+ /**
318
+ * The file names a usage log answers to, shared by every command that reads a
319
+ * directory of them. One list, because two commands disagreeing on what counts
320
+ * as a log would be the same directory billing differently by verb.
321
+ */
322
+ const LOG_EXTENSIONS = ['.jsonl', '.ndjson', '.log', '.json'];
323
+
315
324
  /**
316
325
  * One usage log, gzip included, shared by every command that reads one.
317
326
  *
@@ -465,6 +474,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
465
474
  check: ['max-tokens', 'level', 'exact-tokens', 'markdown-out', 'baseline'],
466
475
  baseline: ['model', 'calls', 'output-tokens', 'cache-hit-rate', 'batch', 'exact-tokens', 'out', 'o'],
467
476
  profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary', 'by-source'],
477
+ plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
468
478
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
469
479
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
470
480
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2204,6 +2214,140 @@ function isoDate(): string {
2204
2214
  * metered API calls somebody was actually billed for — the bill exists wherever
2205
2215
  * Trazum happens to be running, so the host has no bearing on it.
2206
2216
  */
2217
+ /**
2218
+ * `trazum plan <log>` — not a list of findings, a ranked plan of what to do.
2219
+ *
2220
+ * The composition (route and batch on one slice never summed) happens in
2221
+ * core's `buildPlan`; this command owns the I/O and the rendering. The plan
2222
+ * saves as a dated JSON file on request, which is what makes verifying it
2223
+ * against a later log possible at all — a prediction nobody wrote down is a
2224
+ * prediction nobody can be held to.
2225
+ */
2226
+ async function commandPlan(
2227
+ args: Args,
2228
+ pricing: PricingCatalogue,
2229
+ t: CliMessages,
2230
+ ): Promise<void> {
2231
+ const path = args.positional[0];
2232
+ if (path === undefined) throw new Error(t.plan.noTarget());
2233
+
2234
+ const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
2235
+ const READABLE = [...LOG_EXTENSIONS, ...GZ];
2236
+ const target = await stat(path).catch(() => null);
2237
+ let files: string[] = [path];
2238
+ if (target?.isDirectory()) {
2239
+ const entries = await readdir(path, { withFileTypes: true });
2240
+ files = entries
2241
+ .filter((entry) => entry.isFile() && READABLE.some((ext) => entry.name.endsWith(ext)))
2242
+ .map((entry) => join(path, entry.name))
2243
+ .sort((a, b) => a.localeCompare(b));
2244
+ if (files.length === 0) throw new Error(t.profile.noLogsInDirectory(path, READABLE.join(', ')));
2245
+ }
2246
+ const texts = await Promise.all(files.map((file) => readUsageLog(file, t)));
2247
+ const raw = texts.map((text) => (text.endsWith('\n') ? text : `${text}\n`)).join('');
2248
+
2249
+ const report = profileUsage(raw, { catalogue: pricing });
2250
+ if (report.total.calls === 0) throw new Error(t.plan.nothingPriced());
2251
+ const levers = billLevers(report, { catalogue: pricing });
2252
+ const plan = buildPlan(report, levers, pricing.lastReviewed);
2253
+
2254
+ const minUsd = typeof args.flags.get('min-usd') === 'string' ? numberFlag(args, 'min-usd', 0, t) : 0;
2255
+ const actions = plan.actions.filter((a) => (a.savingUsd ?? a.stakeUsd ?? 0) >= minUsd);
2256
+ const filtered = plan.actions.length - actions.length;
2257
+ const droppedUsd = plan.actions
2258
+ .filter((a) => (a.savingUsd ?? a.stakeUsd ?? 0) < minUsd)
2259
+ .reduce((sum, a) => sum + (a.savingUsd ?? a.stakeUsd ?? 0), 0);
2260
+
2261
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2262
+ /**
2263
+ * The document's totals cover the actions the document holds — a filtered
2264
+ * plan whose totals still counted the filtered actions would be a file
2265
+ * that contradicts itself, and 1.39's verify would hold it to money it
2266
+ * cannot see. What --min-usd dropped is stated with its worth, never
2267
+ * silently.
2268
+ */
2269
+ const stamped = {
2270
+ ...plan,
2271
+ actions,
2272
+ projectedSavingUsd: actions.reduce((sum, a) => sum + (a.savingUsd ?? 0), 0),
2273
+ measuredStakeUsd: actions.reduce((sum, a) => sum + (a.stakeUsd ?? 0), 0),
2274
+ createdAt: new Date().toISOString(),
2275
+ };
2276
+
2277
+ const outPath = stringFlag(args, 'out');
2278
+ if (outPath !== undefined) {
2279
+ await writeFile(outPath, `${JSON.stringify(stamped, null, 2)}\n`);
2280
+ }
2281
+
2282
+ await writeMarkdown(args, () => {
2283
+ const lines: string[] = [];
2284
+ lines.push(`## ${t.plan.heading(n(actions.length), formatUsd(plan.totalUsd))}`);
2285
+ lines.push('');
2286
+ lines.push(t.plan.totals(formatUsd(stamped.projectedSavingUsd), formatUsd(stamped.measuredStakeUsd)));
2287
+ if (plan.span === null) {
2288
+ lines.push('');
2289
+ lines.push(`_${t.plan.noClock()}_`);
2290
+ }
2291
+ for (const action of actions) {
2292
+ const name = action.label === UNLABELLED ? t.profile.unlabelled() : action.label;
2293
+ const money =
2294
+ action.savingUsd !== null
2295
+ ? t.plan.projected(formatUsd(action.savingUsd))
2296
+ : t.plan.staked(formatUsd(action.stakeUsd ?? 0));
2297
+ lines.push('');
2298
+ lines.push(`### ${t.plan.action(action.kind, name, action.model)} — ${money}`);
2299
+ if (action.detail.routeTo !== undefined) lines.push(`- ${t.plan.routeTo(action.detail.routeTo.displayName)}`);
2300
+ for (const assumption of action.assumes) lines.push(`- ${t.plan.assume(assumption)}`);
2301
+ if (action.check !== null) lines.push(`- ${t.plan.check(action.check)}`);
2302
+ }
2303
+ if (filtered > 0) {
2304
+ lines.push('');
2305
+ lines.push(`_${t.plan.filtered(n(filtered), formatUsd(minUsd), formatUsd(droppedUsd))}_`);
2306
+ }
2307
+ lines.push('');
2308
+ lines.push(`_${t.plan.footer()}_`);
2309
+ return lines.join('\n');
2310
+ });
2311
+
2312
+ if (boolFlag(args, 'json')) {
2313
+ console.log(JSON.stringify(stamped, null, 2));
2314
+ return;
2315
+ }
2316
+
2317
+ console.log(c.bold(t.plan.heading(n(actions.length), formatUsd(plan.totalUsd))));
2318
+ console.log(
2319
+ ` ${wrap(t.plan.totals(formatUsd(stamped.projectedSavingUsd), formatUsd(stamped.measuredStakeUsd)), 74, ' ')}`,
2320
+ );
2321
+ if (plan.span === null) {
2322
+ console.log(` ${c.dim(wrap(t.plan.noClock(), 74, ' '))}`);
2323
+ }
2324
+ for (const action of actions) {
2325
+ const name = action.label === UNLABELLED ? t.profile.unlabelled() : action.label;
2326
+ const money =
2327
+ action.savingUsd !== null
2328
+ ? t.plan.projected(formatUsd(action.savingUsd))
2329
+ : t.plan.staked(formatUsd(action.stakeUsd ?? 0));
2330
+ console.log();
2331
+ console.log(` ${c.green('→')} ${c.bold(t.plan.action(action.kind, name, action.model))} ${money}`);
2332
+ if (action.detail.routeTo !== undefined) {
2333
+ console.log(` ${c.dim(t.plan.routeTo(action.detail.routeTo.displayName))}`);
2334
+ }
2335
+ for (const assumption of action.assumes) {
2336
+ console.log(` ${c.yellow('?')} ${c.dim(wrap(t.plan.assume(assumption), 72, ' '))}`);
2337
+ }
2338
+ if (action.check !== null) {
2339
+ console.log(` ${c.dim(wrap(t.plan.check(action.check), 72, ' '))}`);
2340
+ }
2341
+ }
2342
+ if (filtered > 0) {
2343
+ console.log();
2344
+ console.log(` ${c.dim(wrap(t.plan.filtered(n(filtered), formatUsd(minUsd), formatUsd(droppedUsd)), 74, ' '))}`);
2345
+ }
2346
+ console.log();
2347
+ console.log(` ${c.dim(wrap(t.plan.footer(), 74, ' '))}`);
2348
+ if (outPath !== undefined) console.log(c.dim(wrap(t.plan.wrote(outPath), 74, '')));
2349
+ }
2350
+
2207
2351
  async function commandProfile(args: Args, config: TrazumConfig, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
2208
2352
  const path = args.positional[0];
2209
2353
  if (path === undefined) {
@@ -2227,7 +2371,6 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2227
2371
  * directory holding nothing readable is an error naming what it looked for,
2228
2372
  * not an empty report.
2229
2373
  */
2230
- const LOG_EXTENSIONS = ['.jsonl', '.ndjson', '.log', '.json'];
2231
2374
  /**
2232
2375
  * The same names, gzipped — which is what a rotated log actually looks like
2233
2376
  * a day after it rotates.
@@ -6015,6 +6158,9 @@ async function main(): Promise<void> {
6015
6158
  case 'profile':
6016
6159
  await commandProfile(args, config, pricing, t);
6017
6160
  break;
6161
+ case 'plan':
6162
+ await commandPlan(args, pricing, t);
6163
+ break;
6018
6164
  case 'route':
6019
6165
  await commandRoute(args, pricing, t);
6020
6166
  break;