@trazum/cli 1.50.8 → 1.50.10

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.50.8",
3
+ "version": "1.50.10",
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.50.8"
40
+ "@trazum/core": "1.50.10"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -60,6 +60,8 @@ ${bold('USAGE')}
60
60
  trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
61
61
  trazum experiment <log> --a <label> --b <label> --min-outcomes <n>
62
62
  trazum quality <log> --label <name> --at <iso> [--gate]
63
+ trazum semantic <prompt> [--yes]
64
+ trazum owners <log>
63
65
  trazum ladder <log>
64
66
  trazum feedback
65
67
  trazum --version
@@ -147,6 +149,17 @@ ${bold('OPTIONS FOR quality')}
147
149
  whenever the model mix, the call volume or the outcome coverage moved across
148
150
  the boundary — the prompt is not the only variable and it says so.
149
151
 
152
+ ${bold('OPTIONS FOR semantic')}
153
+ --yes Required to send anything. Without it the command
154
+ prints what the call would cost and stops.
155
+ --model <id> Which model to ask. Priced from the catalogue.
156
+
157
+ Finds what a dictionary cannot: the same rule taught twice in different
158
+ words, an instruction restated far away, a policy a later clause contradicts.
159
+ Every quoted passage is checked character for character against the prompt,
160
+ pairs the rules engine already catches are dropped, and every token figure is
161
+ counted rather than believed. Optional, and always will be.
162
+
150
163
  ${bold('OPTIONS FOR prune')}
151
164
  --cases <file> One input per line, or a JSON array. Required.
152
165
  --yes Actually spend the calls. Without it the estimate is
@@ -626,6 +639,12 @@ ${bold('CONFIG FILE')}
626
639
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — money budgets for
627
640
  "trazum profile", in dollars. A budgeted label with no calls in
628
641
  the log is reported as not measured, never as a pass
642
+ owners { "patterns": { "payments": ["billing-*"] },
643
+ "shared": { "search": { "payments": 0.6, "support": 0.4 } },
644
+ "budgets": { "payments": 400 } } — whose budget each workload
645
+ lands on. Spend matching no owner is its own line and is NEVER
646
+ divided between the others; a shared split must sum to 1, and the
647
+ rule travels with the report so the argument is about the rule
629
648
  ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
630
649
  "escalateOn": ["escalated"] } } — cheap model first, escalate a
631
650
  recorded failure to a dearer one. Both fields required.
@@ -992,6 +1011,65 @@ ${bold('EXAMPLES')}
992
1011
  `${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
993
1012
  },
994
1013
 
1014
+ owners: {
1015
+ heading: () => 'Whose money',
1016
+ noOwners: () =>
1017
+ 'No owners configured. Add "owners" to trazum.config.json to attribute spend, for example {"patterns": {"payments": ["billing-*"]}, "budgets": {"payments": 400}}.',
1018
+ columns: { owner: 'owner', spend: 'spend', budget: 'budget', calls: 'calls' },
1019
+ verdict: (kind) =>
1020
+ kind === 'over' ? 'over' : kind === 'within' ? 'within' : kind === 'not-measured' ? 'not measured' : '\u2014',
1021
+ unallocated: (usd, share, labels) =>
1022
+ `Unallocated: ${usd} (${share} of the bill), from ${labels}.`,
1023
+ neverSpread: () =>
1024
+ 'It is not divided between the owners above, and it never will be. Spreading unattributed spend proportionally is the most common lie in cost reporting: it makes every line add up and every team\u2019s number wrong by an amount nobody can see \u2014 hardest on whoever instruments best, because their known spend is largest and they absorb the biggest share of somebody else\u2019s mystery. Claim it with a pattern.',
1025
+ nothingUnallocated: () => 'Every workload in this log has an owner.',
1026
+ sharedHeading: () => 'Shared, by a rule somebody wrote',
1027
+ sharedRule: (label, rule) => `${label}: ${rule}`,
1028
+ problemsHeading: () => 'This ownership config will not do what it looks like it does',
1029
+ problem: (kind, detail) =>
1030
+ kind === 'split-does-not-sum'
1031
+ ? `${detail} \u2014 a split that does not sum to 1 loses money or invents it, silently. That workload is left whole in the unallocated line until this is fixed.`
1032
+ : kind === 'split-names-unknown-owner'
1033
+ ? `${detail} names an owner that "owners.patterns" does not declare.`
1034
+ : kind === 'split-has-one-owner'
1035
+ ? `${detail} is "shared" with one owner \u2014 that is a pattern written the long way, and reading it as a share invites a second owner to be added without the first being adjusted.`
1036
+ : kind === 'negative-share'
1037
+ ? `${detail} has a negative share.`
1038
+ : `${detail} has a budget but no patterns, so nothing can ever land on it.`,
1039
+ notMeasured: (owner) =>
1040
+ `${owner} has a budget and no measured calls. That is NOT under budget \u2014 a team whose logs never arrived passes every budget it has, forever, and a green tick beside their name says the opposite of the truth.`,
1041
+ },
1042
+
1043
+ semantic: {
1044
+ heading: (path) => `Semantic pass on ${path}`,
1045
+ willCost: (usd, input, output, model) =>
1046
+ `This will send the prompt to ${model}: about ${input} tokens in and ${output} out, roughly ${usd}. Estimated, not measured \u2014 a tool that spends your money to tell you how to spend less should be the first thing audited by its own arithmetic. Pass --yes to run it.`,
1047
+ needsYes: () => 'Nothing was sent. Add --yes once you have read the price above.',
1048
+ finding: (kind, because) =>
1049
+ `${kind === 'contradiction' ? 'Contradiction' : kind === 'restated-instruction' ? 'Restated' : 'Same thing, different words'}: ${because}`,
1050
+ span: (line, text) => `line ${line}: ${text}`,
1051
+ ceiling: (tokens) =>
1052
+ `At most ${tokens} tokens \u2014 a ceiling, not a saving. Merging these two means writing one passage that does the work of both, and nobody knows yet how long that is.`,
1053
+ noCeiling: () =>
1054
+ 'No tokens attached. A contradiction is worth fixing because the prompt is wrong, not because it is long, and putting a figure on it would sell the wrong reason.',
1055
+ nothingFound: () => 'Nothing survived checking. That is a real answer.',
1056
+ rejected: (count) => `${count} proposals did not survive checking against the prompt.`,
1057
+ rejectedLine: (reason, span) =>
1058
+ reason === 'span-not-found'
1059
+ ? `paraphrased its own evidence: ${span}`
1060
+ : reason === 'already-detected'
1061
+ ? `already found without a model: ${span}`
1062
+ : reason === 'contradiction-of-a-copy'
1063
+ ? `called a near-copy a contradiction: ${span}`
1064
+ : reason === 'spans-identical' || reason === 'spans-overlap'
1065
+ ? `quoted the same passage twice: ${span}`
1066
+ : `covers ground an accepted finding covers: ${span}`,
1067
+ disposes: () =>
1068
+ 'The model proposes and the deterministic layer disposes: every quoted passage is checked character for character against the prompt, pairs the rules engine already catches are dropped, and every token figure is counted here rather than believed.',
1069
+ optIn: () =>
1070
+ 'This pass is optional and always will be. Trazum works with no key, no network and no model \u2014 that has been true since 0.1.0 and this does not change it.',
1071
+ },
1072
+
995
1073
  quality: {
996
1074
  heading: (label) => `Quality across the change: ${label}`,
997
1075
  needsLabel: () => 'Name the workload with --label: this compares one label before and after a change, and a mixture of workloads would average a regression away.',
package/src/i18n/es.ts CHANGED
@@ -47,6 +47,8 @@ ${bold('USO')}
47
47
  trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
48
48
  trazum experiment <log> --a <label> --b <label> --min-outcomes <n>
49
49
  trazum quality <log> --label <name> --at <iso> [--gate]
50
+ trazum semantic <prompt> [--yes]
51
+ trazum owners <log>
50
52
  trazum ladder <log>
51
53
  trazum feedback
52
54
  trazum --version
@@ -137,6 +139,17 @@ ${bold('OPCIONES DE quality')}
137
139
  siempre que la mezcla de modelos, el volumen o la cobertura se movieran a
138
140
  través de la frontera — el prompt no es la única variable y lo dice.
139
141
 
142
+ ${bold('OPCIONES DE semantic')}
143
+ --yes Obligatorio para enviar nada. Sin él, el comando
144
+ imprime lo que costaría la llamada y para.
145
+ --model <id> Qué modelo preguntar. Se tarifa del catálogo.
146
+
147
+ Encuentra lo que un diccionario no puede: la misma regla enseñada dos veces
148
+ con otras palabras, una instrucción repetida lejos, una política que una
149
+ cláusula posterior contradice. Cada pasaje citado se comprueba carácter a
150
+ carácter contra el prompt, los pares que el motor de reglas ya caza se
151
+ descartan, y toda cifra de tokens se cuenta en vez de creérsela.
152
+
140
153
  ${bold('OPCIONES DE prune')}
141
154
  --cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
142
155
  --yes Gasta las llamadas de verdad. Sin él se imprime la
@@ -646,6 +659,12 @@ ${bold('FICHERO DE CONFIGURACIÓN')}
646
659
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — presupuestos en
647
660
  dólares para "trazum profile". Una etiqueta con presupuesto y sin
648
661
  llamadas se informa como no medida, nunca como aprobada
662
+ owners { "patterns": { "payments": ["billing-*"] },
663
+ "shared": { "search": { "payments": 0.6, "support": 0.4 } },
664
+ "budgets": { "payments": 400 } } — de quién es el presupuesto de
665
+ cada carga. El gasto sin propietario es su propia línea y NUNCA se
666
+ reparte entre los demás; un reparto compartido debe sumar 1, y la
667
+ regla viaja con el informe para que se discuta la regla
649
668
  ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
650
669
  "escalateOn": ["escalated"] } } — modelo barato primero, escalar
651
670
  un fallo registrado a uno más caro. Los dos campos obligatorios.
@@ -1028,6 +1047,65 @@ ${bold('EJEMPLOS')}
1028
1047
  `${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
1029
1048
  },
1030
1049
 
1050
+ owners: {
1051
+ heading: () => 'De qui\u00e9n es el dinero',
1052
+ noOwners: () =>
1053
+ 'No hay propietarios configurados. A\u00f1ade "owners" a trazum.config.json para atribuir gasto, por ejemplo {"patterns": {"payments": ["billing-*"]}, "budgets": {"payments": 400}}.',
1054
+ columns: { owner: 'propietario', spend: 'gasto', budget: 'presupuesto', calls: 'llamadas' },
1055
+ verdict: (kind) =>
1056
+ kind === 'over' ? 'pasado' : kind === 'within' ? 'dentro' : kind === 'not-measured' ? 'sin medir' : '\u2014',
1057
+ unallocated: (usd, share, labels) =>
1058
+ `Sin asignar: ${usd} (${share} de la factura), de ${labels}.`,
1059
+ neverSpread: () =>
1060
+ 'No se reparte entre los propietarios de arriba, y nunca se har\u00e1. Repartir el gasto sin atribuir de forma proporcional es la mentira m\u00e1s com\u00fan en informes de coste: hace que todas las l\u00edneas cuadren y que el n\u00famero de cada equipo est\u00e9 mal en una cantidad que nadie puede ver \u2014 y castiga m\u00e1s a quien mejor instrumenta, porque su gasto conocido es el mayor y absorbe la mayor parte del misterio ajeno. Recl\u00e1malo con un patr\u00f3n.',
1061
+ nothingUnallocated: () => 'Todas las cargas de este log tienen propietario.',
1062
+ sharedHeading: () => 'Compartido, por una regla que escribi\u00f3 alguien',
1063
+ sharedRule: (label, rule) => `${label}: ${rule}`,
1064
+ problemsHeading: () => 'Esta configuraci\u00f3n de propiedad no va a hacer lo que parece',
1065
+ problem: (kind, detail) =>
1066
+ kind === 'split-does-not-sum'
1067
+ ? `${detail} \u2014 un reparto que no suma 1 pierde dinero o lo inventa, en silencio. Esa carga se deja entera en la l\u00ednea sin asignar hasta que se arregle.`
1068
+ : kind === 'split-names-unknown-owner'
1069
+ ? `${detail} nombra un propietario que "owners.patterns" no declara.`
1070
+ : kind === 'split-has-one-owner'
1071
+ ? `${detail} est\u00e1 "compartido" con un solo propietario \u2014 eso es un patr\u00f3n escrito largo, y leerlo como reparto invita a a\u00f1adir un segundo sin ajustar el primero.`
1072
+ : kind === 'negative-share'
1073
+ ? `${detail} tiene un reparto negativo.`
1074
+ : `${detail} tiene presupuesto pero no patrones, as\u00ed que nunca podr\u00e1 caerle nada.`,
1075
+ notMeasured: (owner) =>
1076
+ `${owner} tiene presupuesto y ninguna llamada medida. Eso NO es estar dentro del presupuesto \u2014 un equipo cuyos logs nunca llegaron pasa todos sus presupuestos, siempre, y un tic verde junto a su nombre dice lo contrario de la verdad.`,
1077
+ },
1078
+
1079
+ semantic: {
1080
+ heading: (path) => `Pase sem\u00e1ntico sobre ${path}`,
1081
+ willCost: (usd, input, output, model) =>
1082
+ `Esto enviar\u00e1 el prompt a ${model}: unos ${input} tokens de entrada y ${output} de salida, aproximadamente ${usd}. Estimado, no medido \u2014 una herramienta que gasta tu dinero para decirte c\u00f3mo gastar menos deber\u00eda ser lo primero que audite su propia aritm\u00e9tica. Pasa --yes para ejecutarlo.`,
1083
+ needsYes: () => 'No se envi\u00f3 nada. A\u00f1ade --yes cuando hayas le\u00eddo el precio de arriba.',
1084
+ finding: (kind, because) =>
1085
+ `${kind === 'contradiction' ? 'Contradicci\u00f3n' : kind === 'restated-instruction' ? 'Repetido' : 'Lo mismo con otras palabras'}: ${because}`,
1086
+ span: (line, text) => `l\u00ednea ${line}: ${text}`,
1087
+ ceiling: (tokens) =>
1088
+ `Como mucho ${tokens} tokens \u2014 un techo, no un ahorro. Fundir los dos significa escribir un pasaje que haga el trabajo de ambos, y nadie sabe todav\u00eda cu\u00e1nto ocupa.`,
1089
+ noCeiling: () =>
1090
+ 'Sin tokens asociados. Una contradicci\u00f3n merece arreglarse porque el prompt est\u00e1 mal, no porque sea largo, y ponerle una cifra vender\u00eda el motivo equivocado.',
1091
+ nothingFound: () => 'Nada sobrevivi\u00f3 a la comprobaci\u00f3n. Esa es una respuesta real.',
1092
+ rejected: (count) => `${count} propuestas no sobrevivieron a la comprobaci\u00f3n contra el prompt.`,
1093
+ rejectedLine: (reason, span) =>
1094
+ reason === 'span-not-found'
1095
+ ? `parafrase\u00f3 su propia evidencia: ${span}`
1096
+ : reason === 'already-detected'
1097
+ ? `ya se encuentra sin modelo: ${span}`
1098
+ : reason === 'contradiction-of-a-copy'
1099
+ ? `llam\u00f3 contradicci\u00f3n a una casi copia: ${span}`
1100
+ : reason === 'spans-identical' || reason === 'spans-overlap'
1101
+ ? `cit\u00f3 el mismo pasaje dos veces: ${span}`
1102
+ : `cubre terreno que ya cubre un hallazgo aceptado: ${span}`,
1103
+ disposes: () =>
1104
+ 'El modelo propone y la capa determinista dispone: cada pasaje citado se comprueba car\u00e1cter a car\u00e1cter contra el prompt, los pares que el motor de reglas ya caza se descartan, y toda cifra de tokens se cuenta aqu\u00ed en vez de cre\u00e9rsela.',
1105
+ optIn: () =>
1106
+ 'Este pase es opcional y siempre lo ser\u00e1. Trazum funciona sin clave, sin red y sin modelo \u2014 eso es cierto desde 0.1.0 y esto no lo cambia.',
1107
+ },
1108
+
1031
1109
  quality: {
1032
1110
  heading: (label) => `Calidad a trav\u00e9s del cambio: ${label}`,
1033
1111
  needsLabel: () => 'Nombra la carga con --label: esto compara una etiqueta antes y despu\u00e9s de un cambio, y una mezcla de cargas promediar\u00eda una regresi\u00f3n hasta hacerla desaparecer.',
package/src/i18n/types.ts CHANGED
@@ -268,6 +268,36 @@ export interface CliMessages {
268
268
  * The ladder. Every line here exists to stop somebody reading "we route to
269
269
  * the cheap model first" as a saving without the number that decides it.
270
270
  */
271
+ owners: {
272
+ heading(): string;
273
+ noOwners(): string;
274
+ columns: { owner: string; spend: string; budget: string; calls: string };
275
+ verdict(kind: string): string;
276
+ unallocated(usd: string, share: string, labels: string): string;
277
+ neverSpread(): string;
278
+ nothingUnallocated(): string;
279
+ sharedHeading(): string;
280
+ sharedRule(label: string, rule: string): string;
281
+ problemsHeading(): string;
282
+ problem(kind: string, detail: string): string;
283
+ notMeasured(owner: string): string;
284
+ };
285
+
286
+ semantic: {
287
+ heading(path: string): string;
288
+ willCost(usd: string, input: string, output: string, model: string): string;
289
+ needsYes(): string;
290
+ finding(kind: string, because: string): string;
291
+ span(line: string, text: string): string;
292
+ ceiling(tokens: string): string;
293
+ noCeiling(): string;
294
+ nothingFound(): string;
295
+ rejected(count: string): string;
296
+ rejectedLine(reason: string, span: string): string;
297
+ disposes(): string;
298
+ optIn(): string;
299
+ };
300
+
271
301
  quality: {
272
302
  heading(label: string): string;
273
303
  needsLabel(): string;
package/src/index.ts CHANGED
@@ -40,8 +40,12 @@ import {
40
40
  budgetPositions,
41
41
  conform,
42
42
  BREAK_EVEN_BAND,
43
+ allocate,
43
44
  runExperiment,
44
45
  qualityGate,
46
+ semanticPassCost,
47
+ verifySemanticProposals,
48
+ SEMANTIC_SYSTEM_PROMPT,
45
49
  ladderPosition,
46
50
  validateLadder,
47
51
  outcomeReport,
@@ -143,6 +147,7 @@ import type {
143
147
  ContractName,
144
148
  ExperimentArm,
145
149
  GateSide,
150
+ SemanticProposal,
146
151
  FailurePolicy,
147
152
  GatewayStanding,
148
153
  UsageProfileReport,
@@ -584,6 +589,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
584
589
  ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
585
590
  experiment: ['a', 'b', 'min-outcomes', 'pricing', 'pricing-live'],
586
591
  quality: ['label', 'at', 'gate', 'pricing', 'pricing-live'],
592
+ semantic: ['yes', 'model', 'pricing', 'pricing-live'],
593
+ owners: ['pricing', 'pricing-live', 'since', 'until'],
587
594
  where: [],
588
595
  rules: [],
589
596
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -2609,6 +2616,266 @@ async function commandQuality(
2609
2616
  console.log();
2610
2617
  }
2611
2618
 
2619
+ /**
2620
+ * `trazum semantic <prompt> [--yes]` — the findings a dictionary cannot see.
2621
+ *
2622
+ * The rules engine has deferred these since 0.1.0 for one honest reason: a
2623
+ * dictionary cannot see meaning, and a model that hallucinates a finding is
2624
+ * worse than a rule that misses one.
2625
+ *
2626
+ * **The price is printed before anything is sent, and `--yes` is required.** A
2627
+ * tool that spends somebody's money to tell them how to spend less has to be
2628
+ * the first thing audited by its own arithmetic, and it has to ask.
2629
+ */
2630
+ async function commandSemantic(
2631
+ args: Args,
2632
+ config: TrazumConfig,
2633
+ pricing: PricingCatalogue,
2634
+ t: CliMessages,
2635
+ ): Promise<void> {
2636
+ const prompt = await readInput(args.positional[0], t);
2637
+ const modelId = stringFlag(args, 'model') ?? config.usage?.model ?? DEFAULT_USAGE.model;
2638
+ const model = pricing.byId.get(modelId) ?? getModel(DEFAULT_USAGE.model);
2639
+ const rates = { inputPerMTok: model.inputPerMTok, outputPerMTok: model.outputPerMTok };
2640
+ const cost = semanticPassCost(prompt, rates);
2641
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2642
+
2643
+ console.log();
2644
+ console.log(c.bold(t.semantic.heading(args.positional[0] ?? '-')));
2645
+ console.log();
2646
+ console.log(
2647
+ ` ${wrap(
2648
+ t.semantic.willCost(formatUsd(cost.usd), n(cost.inputTokens), n(cost.outputTokens), model.displayName),
2649
+ 74,
2650
+ ' ',
2651
+ )}`,
2652
+ );
2653
+
2654
+ if (!boolFlag(args, 'yes')) {
2655
+ // Nothing has been sent at this point, and nothing will be. The price
2656
+ // above is the whole output of a run without --yes.
2657
+ console.log();
2658
+ console.log(` ${c.dim(t.semantic.needsYes())}`);
2659
+ console.log();
2660
+ return;
2661
+ }
2662
+
2663
+ const provider = providerFromEnv();
2664
+ if (!provider) throw new Error(t.errors.llmNotConfigured());
2665
+
2666
+ const answer = await provider.complete({ system: SEMANTIC_SYSTEM_PROMPT, user: prompt });
2667
+ let proposals: SemanticProposal[] = [];
2668
+ try {
2669
+ const parsed: unknown = JSON.parse(
2670
+ /^(?:```|~~~)[a-zA-Z]*\n([\s\S]*?)\n?(?:```|~~~)$/.exec(answer.trim())?.[1] ?? answer.trim(),
2671
+ );
2672
+ /**
2673
+ * A response that is not the shape asked for is **no proposals**, never a
2674
+ * crash and never a partial read. The model was told exactly what to
2675
+ * return; anything else is a response this layer cannot check, and an
2676
+ * unchecked finding is the one thing this whole module exists to prevent.
2677
+ */
2678
+ if (Array.isArray(parsed)) {
2679
+ proposals = parsed.filter(
2680
+ (entry): entry is SemanticProposal =>
2681
+ typeof entry === 'object' &&
2682
+ entry !== null &&
2683
+ Array.isArray((entry as SemanticProposal).spans) &&
2684
+ (entry as SemanticProposal).spans.length === 2 &&
2685
+ (entry as SemanticProposal).spans.every((span) => typeof span === 'string'),
2686
+ );
2687
+ }
2688
+ } catch {
2689
+ proposals = [];
2690
+ }
2691
+
2692
+ const result = verifySemanticProposals(prompt, proposals);
2693
+ const lineOf = (offset: number): number => prompt.slice(0, offset).split('\n').length;
2694
+
2695
+ console.log();
2696
+ if (result.findings.length === 0) {
2697
+ console.log(` ${c.dim(t.semantic.nothingFound())}`);
2698
+ }
2699
+ for (const finding of result.findings) {
2700
+ console.log(` ${c.bold(t.semantic.finding(finding.kind, finding.because))}`);
2701
+ finding.spans.forEach((span, index) => {
2702
+ const shown = span.length > 90 ? `${span.slice(0, 87)}…` : span;
2703
+ console.log(` ${c.dim(t.semantic.span(String(lineOf(finding.offsets[index] ?? 0)), shown))}`);
2704
+ });
2705
+ console.log(
2706
+ ` ${c.dim(
2707
+ wrap(
2708
+ finding.ceilingTokens > 0 ? t.semantic.ceiling(n(finding.ceilingTokens)) : t.semantic.noCeiling(),
2709
+ 70,
2710
+ ' ',
2711
+ ),
2712
+ )}`,
2713
+ );
2714
+ console.log();
2715
+ }
2716
+
2717
+ /**
2718
+ * What did **not** survive, counted and reasoned.
2719
+ *
2720
+ * A pass that showed only its accepted findings would hide its own hit
2721
+ * rate, and the hit rate is the most useful thing a reader can know about
2722
+ * whether to run it again.
2723
+ */
2724
+ if (result.rejected.length > 0) {
2725
+ console.log(` ${c.dim(t.semantic.rejected(n(result.rejected.length)))}`);
2726
+ for (const { proposal, reason } of result.rejected.slice(0, 5)) {
2727
+ const span = proposal.spans[0];
2728
+ const shown = span.length > 50 ? `${span.slice(0, 47)}…` : span;
2729
+ console.log(` ${c.dim(t.semantic.rejectedLine(reason, shown))}`);
2730
+ }
2731
+ console.log();
2732
+ }
2733
+
2734
+ console.log(` ${c.dim(wrap(t.semantic.disposes(), 74, ' '))}`);
2735
+ console.log(` ${c.dim(wrap(t.semantic.optIn(), 74, ' '))}`);
2736
+ console.log();
2737
+ }
2738
+
2739
+ /**
2740
+ * `trazum owners <log>` — whose budget each workload lands on.
2741
+ *
2742
+ * The fleet answered *which service* in 1.37. This answers *whose money*,
2743
+ * which is the question that decides whether anything on the list gets done: a
2744
+ * report saying "the bill is $40,000 and here is $9,000 of savings" is read by
2745
+ * four people who each assume it is one of the other three's problem.
2746
+ *
2747
+ * **The unallocated is its own line and is never spread.** See `owners.ts` for
2748
+ * why that is worth breaking a module over.
2749
+ */
2750
+ async function commandOwners(
2751
+ args: Args,
2752
+ config: TrazumConfig,
2753
+ pricing: PricingCatalogue,
2754
+ t: CliMessages,
2755
+ ): Promise<void> {
2756
+ const path = args.positional[0];
2757
+ if (path === undefined) throw new Error(t.errors.missingInputFile());
2758
+
2759
+ console.log();
2760
+ console.log(c.bold(t.owners.heading()));
2761
+ if (config.owners === undefined) {
2762
+ console.log(` ${c.dim(wrap(t.owners.noOwners(), 74, ' '))}`);
2763
+ console.log();
2764
+ return;
2765
+ }
2766
+
2767
+ const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
2768
+ const result = allocate(
2769
+ report.byLabel.map((entry) => ({
2770
+ label: entry.label,
2771
+ usd: entry.breakdown.totalUsd,
2772
+ calls: entry.breakdown.calls,
2773
+ })),
2774
+ config.owners,
2775
+ );
2776
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2777
+ const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
2778
+
2779
+ /**
2780
+ * Problems first, before any figure.
2781
+ *
2782
+ * A split that does not sum to one sends a whole workload to unallocated,
2783
+ * and a reader who saw the table before the explanation would go looking for
2784
+ * a bug in their logs.
2785
+ */
2786
+ if (result.problems.length > 0) {
2787
+ console.log();
2788
+ console.log(` ${c.red('✗')} ${c.bold(t.owners.problemsHeading())}`);
2789
+ for (const problem of result.problems) {
2790
+ const detail =
2791
+ problem.kind === 'budget-for-unknown-owner'
2792
+ ? problem.owner
2793
+ : problem.kind === 'split-does-not-sum'
2794
+ ? `"${problem.label}" sums to ${problem.total}`
2795
+ : problem.kind === 'negative-share' || problem.kind === 'split-names-unknown-owner'
2796
+ ? `"${problem.label}" → ${problem.owner}`
2797
+ : `"${problem.label}"`;
2798
+ console.log(` ${wrap(t.owners.problem(problem.kind, detail), 70, ' ')}`);
2799
+ }
2800
+ process.exitCode = 1;
2801
+ }
2802
+
2803
+ console.log();
2804
+ const col = t.owners.columns;
2805
+ const rows = result.owners.map((line) => ({
2806
+ owner: line.owner,
2807
+ spend: formatUsd(line.usd),
2808
+ budget: line.budgetUsd === null ? '—' : formatUsd(line.budgetUsd),
2809
+ calls: n(Math.round(line.calls)),
2810
+ verdict: t.owners.verdict(line.verdict),
2811
+ kind: line.verdict,
2812
+ }));
2813
+ const w = {
2814
+ owner: Math.max(...rows.map((r) => r.owner.length), col.owner.length),
2815
+ spend: Math.max(...rows.map((r) => r.spend.length), col.spend.length),
2816
+ budget: Math.max(...rows.map((r) => r.budget.length), col.budget.length),
2817
+ calls: Math.max(...rows.map((r) => r.calls.length), col.calls.length),
2818
+ };
2819
+ console.log(
2820
+ c.dim(
2821
+ ` ${col.owner.padEnd(w.owner)} ${col.spend.padStart(w.spend)} ` +
2822
+ `${col.budget.padStart(w.budget)} ${col.calls.padStart(w.calls)}`,
2823
+ ),
2824
+ );
2825
+ for (const row of rows) {
2826
+ const tint = row.kind === 'over' ? c.red : row.kind === 'not-measured' ? c.yellow : c.dim;
2827
+ console.log(
2828
+ ` ${row.owner.padEnd(w.owner)} ${row.spend.padStart(w.spend)} ` +
2829
+ `${row.budget.padStart(w.budget)} ${row.calls.padStart(w.calls)} ${tint(row.verdict)}`,
2830
+ );
2831
+ }
2832
+
2833
+ // The 1.37 refusal, applied to people, said in full for each owner it hits.
2834
+ for (const line of result.owners) {
2835
+ if (line.verdict === 'not-measured') {
2836
+ console.log();
2837
+ console.log(` ${c.yellow('!')} ${wrap(t.owners.notMeasured(line.owner), 74, ' ')}`);
2838
+ }
2839
+ }
2840
+
2841
+ console.log();
2842
+ if (result.unallocated.usd > 0) {
2843
+ console.log(
2844
+ ` ${c.yellow('!')} ${wrap(
2845
+ t.owners.unallocated(
2846
+ formatUsd(result.unallocated.usd),
2847
+ report.total.totalUsd > 0 ? pct(result.unallocated.usd / report.total.totalUsd) : '—',
2848
+ result.unallocated.labels.slice(0, 6).join(', '),
2849
+ ),
2850
+ 74,
2851
+ ' ',
2852
+ )}`,
2853
+ );
2854
+ console.log(` ${c.dim(wrap(t.owners.neverSpread(), 72, ' '))}`);
2855
+ } else {
2856
+ console.log(` ${c.dim(t.owners.nothingUnallocated())}`);
2857
+ }
2858
+
2859
+ /**
2860
+ * The shared rules, printed with the report.
2861
+ *
2862
+ * The whole design: the argument then happens about the rule — "why is
2863
+ * search 60/40?" — rather than about the number, which is an argument nobody
2864
+ * can win because nobody can see where the number came from.
2865
+ */
2866
+ if (result.sharedApplied.length > 0) {
2867
+ console.log();
2868
+ console.log(` ${c.bold(t.owners.sharedHeading())}`);
2869
+ for (const { label, split } of result.sharedApplied) {
2870
+ const rule = Object.entries(split)
2871
+ .map(([owner, share]) => `${owner} ${pct(share)}`)
2872
+ .join(', ');
2873
+ console.log(` ${c.dim(t.owners.sharedRule(label, rule))}`);
2874
+ }
2875
+ }
2876
+ console.log();
2877
+ }
2878
+
2612
2879
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
2613
2880
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2614
2881
  const col = t.models.columns;
@@ -8809,6 +9076,12 @@ async function main(): Promise<void> {
8809
9076
  case 'models':
8810
9077
  commandModels(t, pricing);
8811
9078
  break;
9079
+ case 'owners':
9080
+ await commandOwners(args, config, pricing, t);
9081
+ break;
9082
+ case 'semantic':
9083
+ await commandSemantic(args, config, pricing, t);
9084
+ break;
8812
9085
  case 'quality':
8813
9086
  await commandQuality(args, config, pricing, t);
8814
9087
  break;