@trazum/cli 1.29.0 → 1.31.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/src/index.ts CHANGED
@@ -20,7 +20,11 @@ import {
20
20
  countTokensAnthropic,
21
21
  DEFAULT_USAGE,
22
22
  detectFromSource,
23
+ coverageDrift,
23
24
  driversBetween,
25
+ explainGateFailure,
26
+ gateMargin,
27
+ GATE_MARGIN_TIGHT,
24
28
  estimateTokens,
25
29
  evaluate,
26
30
  extractPrompts,
@@ -2249,6 +2253,19 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2249
2253
  * profiles yesterday's log has a daily budget without Trazum ever
2250
2254
  * guessing what a day is.
2251
2255
  */
2256
+ /**
2257
+ * The gate verdicts, kept so the markdown summary can carry them.
2258
+ *
2259
+ * Collected by wrapping `console.error` for the duration of `applyGates`
2260
+ * rather than by threading a return value through every gate. That is the
2261
+ * unusual choice here and it is deliberate: a gate added later reaches the
2262
+ * summary without anyone remembering to register it, and the alternative —
2263
+ * one push per verdict at a dozen call sites — is a list that goes stale
2264
+ * silently. Colour is stripped, because a summary is markdown and an
2265
+ * escape sequence in it is noise a reader has to look past.
2266
+ */
2267
+ const gateVerdicts: string[] = [];
2268
+ let gateFailed = false;
2252
2269
  const applyGates = (): void => {
2253
2270
  /**
2254
2271
  * Before any verdict: whether the gated figure is the whole bill. A gate
@@ -2306,6 +2323,52 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2306
2323
  console.error(c.dim(t.profile.labelBudgetWindowed()));
2307
2324
  }
2308
2325
 
2326
+ /**
2327
+ * Why a gate failed and how much room a pass had — written once, called by
2328
+ * every gate, because four hand-rolled copies of the same three sentences
2329
+ * is four chances for one of them to soften.
2330
+ */
2331
+ const explainFailure = (overUsd: number, { namesLargest = false } = {}): void => {
2332
+ const why = explainGateFailure(report, levers, overUsd);
2333
+ // The day gate already names its own day's biggest label; repeating the
2334
+ // whole bill's biggest slice under it reads as the same sentence twice.
2335
+ if (why.largest !== null && !namesLargest) {
2336
+ const name = why.largest.label === UNLABELLED ? t.profile.unlabelled() : why.largest.label;
2337
+ console.error(
2338
+ c.dim(wrap(t.profile.gateLargest(name, why.largest.model, formatUsd(why.largest.usd), pct(why.largest.share)), 74, ' ')),
2339
+ );
2340
+ }
2341
+ if (why.lever !== null) {
2342
+ const leverName = why.lever.label === UNLABELLED ? t.profile.unlabelled() : why.lever.label;
2343
+ // The action, not the slice's current model: a slice with only a batch
2344
+ // price has no destination, and naming the model it already runs on as
2345
+ // somewhere to move it would be plainly false.
2346
+ const route = why.lever.route;
2347
+ const action =
2348
+ route !== null && why.lever.batch !== null
2349
+ ? t.profile.gateLeverBoth(route.candidate.displayName)
2350
+ : route !== null
2351
+ ? t.profile.gateLeverRoute(route.candidate.displayName)
2352
+ : t.profile.gateLeverBatch();
2353
+ console.error(
2354
+ c.dim(
2355
+ wrap(
2356
+ t.profile.gateLever(leverName, action, formatUsd(why.lever.combinedUsd), formatUsd(why.overageUsd), why.coversIt),
2357
+ 74,
2358
+ ' ',
2359
+ ),
2360
+ ),
2361
+ );
2362
+ }
2363
+ };
2364
+ /** How much room a pass had, said only when tight, threshold in the copy. */
2365
+ const explainMargin = (judgedUsd: number, limitUsd: number): void => {
2366
+ const margin = gateMargin(judgedUsd, limitUsd);
2367
+ if (margin !== null && margin < GATE_MARGIN_TIGHT) {
2368
+ console.error(c.yellow(wrap(t.profile.gateMarginTight(pct(margin), formatUsd(limitUsd - judgedUsd)), 74, ' ')));
2369
+ }
2370
+ };
2371
+
2309
2372
  if (typeof args.flags.get('max-usd') === 'string' || config.spend?.maxUsd !== undefined) {
2310
2373
  const maxUsd =
2311
2374
  typeof args.flags.get('max-usd') === 'string'
@@ -2313,14 +2376,51 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2313
2376
  : config.spend!.maxUsd!;
2314
2377
  if (report.total.totalUsd > maxUsd) {
2315
2378
  console.error(c.red(t.profile.maxUsdFailed(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
2379
+ /**
2380
+ * What to change, next to the fact that something must. A red build in
2381
+ * CI is the one place nobody opens the full report, so the failure
2382
+ * carries its own next step: which slice holds the money, and the one
2383
+ * lever the report already priced. Nothing here is a recommendation —
2384
+ * whether that model can do the work is the reader's to judge, and the
2385
+ * copy says so.
2386
+ */
2387
+ explainFailure(report.total.totalUsd - maxUsd);
2316
2388
  process.exitCode = 1;
2317
2389
  } else {
2318
2390
  console.error(c.dim(t.profile.maxUsdOk(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
2391
+ explainMargin(report.total.totalUsd, maxUsd);
2319
2392
  }
2320
2393
  }
2321
2394
  if (typeof args.flags.get('max-growth-usd') === 'string' && againstDelta !== null) {
2322
2395
  const maxGrowth = numberFlag(args, 'max-growth-usd', 0, t);
2323
- if (againstDelta > maxGrowth) {
2396
+ /**
2397
+ * A comparison that went blind fails before it is judged.
2398
+ *
2399
+ * The dollars can hold flat while the current log stopped recording a
2400
+ * field the previous one carried — and every finding that needed the
2401
+ * field is now silent for a reason that has nothing to do with spend.
2402
+ * A gate passing there would be certifying a comparison it could not
2403
+ * make: "not measured" is not "did not grow", the same refusal
2404
+ * --max-day-usd makes on a clockless log and --max-session-usd on a
2405
+ * sessionless one. Only a collapse fails; a field that appeared means
2406
+ * this side can see more, which is never a reason to refuse.
2407
+ */
2408
+ const blinded = previous !== null
2409
+ ? coverageDrift(previous.fieldCoverage, report.fieldCoverage).filter((d) => d.delta < 0)
2410
+ : [];
2411
+ const worst = blinded[0];
2412
+ if (worst !== undefined) {
2413
+ console.error(
2414
+ c.red(
2415
+ t.profile.maxGrowthCoverageLost(
2416
+ blinded.map((d) => t.profile.coverageField(d.field)).join(', '),
2417
+ pct(worst.was),
2418
+ pct(worst.now),
2419
+ ),
2420
+ ),
2421
+ );
2422
+ process.exitCode = 1;
2423
+ } else if (againstDelta > maxGrowth) {
2324
2424
  console.error(c.red(t.profile.maxGrowthUsdFailed(formatSignedUsd(againstDelta), formatUsd(maxGrowth))));
2325
2425
  process.exitCode = 1;
2326
2426
  }
@@ -2399,9 +2499,11 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2399
2499
  console.error(
2400
2500
  c.red(`${t.profile.maxDayFailed(worst.day, formatUsd(worst.usd), formatUsd(maxDay))}${suspect}`),
2401
2501
  );
2502
+ explainFailure(worst.usd - maxDay, { namesLargest: true });
2402
2503
  process.exitCode = 1;
2403
2504
  } else {
2404
2505
  console.error(c.dim(t.profile.maxDayOk(worst.day, formatUsd(worst.usd), formatUsd(maxDay))));
2506
+ explainMargin(worst.usd, maxDay);
2405
2507
  /**
2406
2508
  * Calls with no clock are in the bill above and in no day below, so
2407
2509
  * the worst day is a floor by exactly that much. Said only on a
@@ -2439,15 +2541,41 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2439
2541
  console.error(
2440
2542
  c.red(t.profile.maxSessionFailed(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
2441
2543
  );
2544
+ explainFailure(report.sessionSpend.maxUsd - maxSession);
2442
2545
  process.exitCode = 1;
2443
2546
  } else {
2444
2547
  console.error(
2445
2548
  c.dim(t.profile.maxSessionOk(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
2446
2549
  );
2550
+ explainMargin(report.sessionSpend.maxUsd, maxSession);
2447
2551
  }
2448
2552
  }
2449
2553
  };
2450
2554
 
2555
+ /**
2556
+ * Run the gates, keeping what they said. Exit codes and stderr behave
2557
+ * exactly as before — this only also remembers, so `--markdown-out` can put
2558
+ * the verdict where the person reading CI will actually see it.
2559
+ */
2560
+ const recordGates = (): void => {
2561
+ const original = console.error;
2562
+ console.error = (...parts: unknown[]): void => {
2563
+ const text = parts.map((part) => String(part)).join(' ');
2564
+ // Colour stripped and the terminal's wrap collapsed: markdown re-wraps
2565
+ // to its own width, and the escape sequences and hanging indents that
2566
+ // make a terminal readable are noise a summary reader looks past.
2567
+ // eslint-disable-next-line no-control-regex
2568
+ gateVerdicts.push(text.replace(/\u001b\[[0-9;]*m/g, '').replace(/\s+/g, ' ').trim());
2569
+ original(...(parts as []));
2570
+ };
2571
+ try {
2572
+ applyGates();
2573
+ } finally {
2574
+ console.error = original;
2575
+ gateFailed = process.exitCode === 1;
2576
+ }
2577
+ };
2578
+
2451
2579
  /**
2452
2580
  * The side files the caller asked for. Written on **both** output paths:
2453
2581
  * under --json the human rendering returns early, and the first version of
@@ -2483,6 +2611,9 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2483
2611
  ? { window: { since: stringFlag(args, 'since') ?? '—', until: stringFlag(args, 'until') ?? '—' } }
2484
2612
  : {}),
2485
2613
  ...(pricingStale !== null ? { stalePricing: pricingStale } : {}),
2614
+ // The verdict, where the person reading CI will see it. recordGates()
2615
+ // runs before the side files for exactly this.
2616
+ ...(gateVerdicts.length > 0 ? { gates: { failed: gateFailed, lines: gateVerdicts } } : {}),
2486
2617
  // The repricing, when --what-if was given: computed once above and
2487
2618
  // handed over, so the summary in a pull request cannot disagree
2488
2619
  // with the terminal about what a move would cost.
@@ -2608,8 +2739,8 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
2608
2739
  2,
2609
2740
  ),
2610
2741
  );
2742
+ recordGates();
2611
2743
  await writeSideFiles();
2612
- applyGates();
2613
2744
  return;
2614
2745
  }
2615
2746
 
@@ -3643,6 +3774,46 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
3643
3774
  console.log(` ${d.delta > 0 ? c.yellow(line) : c.dim(line)}`);
3644
3775
  }
3645
3776
  }
3777
+
3778
+ /**
3779
+ * What the comparison stopped being able to see.
3780
+ *
3781
+ * Every figure above is dollars, and dollars cannot tell a finding that
3782
+ * was fixed from a finding whose field the log stopped recording — both
3783
+ * are silence. This is the only section that can, so it is loud: a
3784
+ * collapse in coverage invalidates whichever findings depended on it,
3785
+ * and reading the drop as good news is the specific mistake it exists
3786
+ * to prevent.
3787
+ */
3788
+ const drifts = coverageDrift(previous.fieldCoverage, report.fieldCoverage);
3789
+ if (drifts.length > 0) {
3790
+ console.log();
3791
+ for (const drift of drifts) {
3792
+ const line = t.profile.coverageDrift(
3793
+ t.profile.coverageField(drift.field),
3794
+ pct(drift.was),
3795
+ pct(drift.now),
3796
+ );
3797
+ console.log(
3798
+ drift.delta < 0
3799
+ ? ` ${c.yellow('!')} ${c.bold(wrap(line, 74, ' '))}`
3800
+ : ` ${c.dim(wrap(line, 74, ' '))}`,
3801
+ );
3802
+ /**
3803
+ * Which findings went with it, named. "Some findings are silent" is
3804
+ * not something a reader can act on; knowing that conversation
3805
+ * growth and the cache-TTL fit are now silence rather than absence
3806
+ * tells them exactly which sections of this report to distrust.
3807
+ */
3808
+ if (drift.delta < 0) {
3809
+ const silenced = t.profile.coverageSilenced(drift.field);
3810
+ if (silenced !== '') console.log(` ${c.dim(wrap(silenced, 72, ' '))}`);
3811
+ }
3812
+ }
3813
+ if (drifts.some((d) => d.delta < 0)) {
3814
+ console.log(` ${c.dim(wrap(t.profile.coverageDriftWhy(), 74, ' '))}`);
3815
+ }
3816
+ }
3646
3817
  }
3647
3818
  }
3648
3819
 
@@ -3700,9 +3871,9 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
3700
3871
 
3701
3872
  reportProfileGaps(report, t, n, pricingStale);
3702
3873
 
3703
- await writeSideFiles();
3874
+ recordGates();
3704
3875
 
3705
- applyGates();
3876
+ await writeSideFiles();
3706
3877
  }
3707
3878
 
3708
3879
  /**
package/src/markdown.ts CHANGED
@@ -723,6 +723,16 @@ export interface ProfileMarkdownInput {
723
723
  levers: BillLevers;
724
724
  cache: CacheEconomics;
725
725
  t: CliMessages;
726
+ /**
727
+ * The gate verdicts, when any gate was armed.
728
+ *
729
+ * They reached the terminal on stderr and stopped there, so a CI run
730
+ * summary carried the whole report and not the one sentence explaining why
731
+ * the build was red — the reader had to open the raw log to find it. These
732
+ * arrive already rendered by the caller, which owns the thresholds and the
733
+ * copy; this is a rendering and must not decide anything a gate decides.
734
+ */
735
+ gates?: { failed: boolean; lines: string[] };
726
736
  /**
727
737
  * The `--since`/`--until` values as the user typed them, when a window was
728
738
  * applied. Passed through rather than re-derived from `timeWindow`'s epoch
@@ -783,7 +793,7 @@ export interface ProfileMarkdownInput {
783
793
  * reading CI instead of machines.
784
794
  */
785
795
  export function renderProfileMarkdown(input: ProfileMarkdownInput): string {
786
- const { report, levers, cache, t, window, stalePricing, against, whatIf, pressure = [] } = input;
796
+ const { report, levers, cache, t, window, stalePricing, against, whatIf, pressure = [], gates } = input;
787
797
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
788
798
  const pct = (share: number): string => `${(share * 100).toFixed(1)}%`;
789
799
  const shares = sharesOf(report.total);
@@ -793,6 +803,24 @@ export function renderProfileMarkdown(input: ProfileMarkdownInput): string {
793
803
  const lines: string[] = [];
794
804
  lines.push(`### ${t.profile.heading()}`);
795
805
  lines.push('');
806
+ /**
807
+ * The verdict first, when a gate was armed.
808
+ *
809
+ * A run summary that carried the whole report and not the sentence
810
+ * explaining why the build is red made the reader open the raw log to find
811
+ * it — which is the same failure the explanation itself exists to fix, one
812
+ * surface further out. A failure is quoted so it survives being skimmed; a
813
+ * pass is stated plainly and does not shout.
814
+ */
815
+ if (gates !== undefined && gates.lines.length > 0) {
816
+ // One mark, on the verdict. The lines under it explain that verdict and
817
+ // are not themselves failures — marking each would turn one red build
818
+ // into a wall of crosses and make the actual verdict harder to find.
819
+ const [verdict, ...rest] = gates.lines;
820
+ lines.push(gates.failed ? `> ❌ **${mdText(verdict!)}**` : `_${mdText(verdict!)}_`);
821
+ for (const line of rest) lines.push(gates.failed ? `> ${mdText(line)}` : `_${mdText(line)}_`);
822
+ lines.push('');
823
+ }
796
824
  lines.push(`**${t.profile.spent(t.profile.calls(report.total.calls), formatUsd(report.total.totalUsd))}**`);
797
825
  lines.push('');
798
826
  // The span, under the same rule as the terminal: stated, never extrapolated,