@trazum/cli 1.48.0 → 1.50.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/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +45 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +45 -1
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +34 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +161 -24
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +56 -1
- package/src/i18n/es.ts +56 -1
- package/src/i18n/types.ts +35 -0
- package/src/index.ts +199 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.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.
|
|
40
|
+
"@trazum/core": "1.50.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^26.2.0",
|
package/src/i18n/en.ts
CHANGED
|
@@ -54,6 +54,7 @@ ${bold('USAGE')}
|
|
|
54
54
|
trazum blame <file> [options]
|
|
55
55
|
trazum prune <file> --cases <file> --yes
|
|
56
56
|
trazum where [file]
|
|
57
|
+
trazum conform <file|-> [--contract <name>]
|
|
57
58
|
trazum models
|
|
58
59
|
trazum rules
|
|
59
60
|
|
|
@@ -73,6 +74,20 @@ ${bold('OPTIONS FOR init')}
|
|
|
73
74
|
measured figure and leaves the limit to you — a generated config full of
|
|
74
75
|
guessed numbers is one nobody trusts.
|
|
75
76
|
|
|
77
|
+
${bold('OPTIONS FOR conform')}
|
|
78
|
+
--contract <name> Check against a named contract instead of
|
|
79
|
+
detecting one: usage-log, profile, plan,
|
|
80
|
+
verification, history, connected, cost-answer.
|
|
81
|
+
--json The report as data.
|
|
82
|
+
|
|
83
|
+
Answers two questions and keeps them apart. Does this document conform —
|
|
84
|
+
required fields, present and the right type, exits 1 when not. And what can a
|
|
85
|
+
valid document of this shape not answer, with the field that would unlock
|
|
86
|
+
each.
|
|
87
|
+
|
|
88
|
+
The second never gates. Choosing not to log sessions is a decision, not a
|
|
89
|
+
defect. See docs/format.md.
|
|
90
|
+
|
|
76
91
|
${bold('OPTIONS FOR prune')}
|
|
77
92
|
--cases <file> One input per line, or a JSON array. Required.
|
|
78
93
|
--yes Actually spend the calls. Without it the estimate is
|
|
@@ -904,6 +919,23 @@ ${bold('EXAMPLES')}
|
|
|
904
919
|
`${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
|
|
905
920
|
},
|
|
906
921
|
|
|
922
|
+
conform: {
|
|
923
|
+
noTarget: () =>
|
|
924
|
+
'Pass a file to check — a usage log, or any document Trazum emits. Use "-" to read from stdin.',
|
|
925
|
+
badContract: (given, known) => `"${given}" is not a contract. Known contracts: ${known}.`,
|
|
926
|
+
unrecognised: (path) => `${path} does not match any contract Trazum knows.`,
|
|
927
|
+
heading: (path, contract) => `${path} reads as a ${contract} document`,
|
|
928
|
+
headingLog: (path, contract, records) =>
|
|
929
|
+
`${path} reads as a ${contract}: ${records} ${records === 1 ? 'record' : 'records'}`,
|
|
930
|
+
conforms: () => 'It conforms. Every required field is present and the right type.',
|
|
931
|
+
problem: (at, kind, detail) => `${at}: ${detail} (${kind})`,
|
|
932
|
+
moreProblems: (count) => `…and ${count} more. Fix these first; they are often the same mistake.`,
|
|
933
|
+
unavailableHeading: () => 'What this cannot answer, and what would unlock it',
|
|
934
|
+
unavailable: (finding, because, unlockedBy) => `${finding} — ${because}. Add ${unlockedBy}.`,
|
|
935
|
+
unavailableNeverGates: () =>
|
|
936
|
+
'None of those failed anything. Choosing not to record a field is a decision, not a defect, and a gate that failed on it would be this tool telling you what to log.',
|
|
937
|
+
},
|
|
938
|
+
|
|
907
939
|
where: {
|
|
908
940
|
hostHeading: () => 'Running inside',
|
|
909
941
|
subscription: (host) =>
|
|
@@ -1666,7 +1698,9 @@ ${bold('EXAMPLES')}
|
|
|
1666
1698
|
nothingMeasured: (dir) =>
|
|
1667
1699
|
`Nothing is measured yet (the store at ${dir} is empty), so the budget half of every answer will say so. The cost half still answers from the catalogue: offline is a mode, not a failure.`,
|
|
1668
1700
|
noBudget: () =>
|
|
1669
|
-
'No spend.
|
|
1701
|
+
'No spend.monthlyUsd is configured, so "is there budget left" has no subject and every answer says so rather than guessing one. spend.maxUsd is deliberately not read here: it gates whatever period a log covers, and reading it as a monthly limit is how two surfaces of this tool come to disagree.',
|
|
1702
|
+
partialCoverage: (measuredDays, elapsedDays, period) =>
|
|
1703
|
+
`Only ${measuredDays} of the ${elapsedDays} elapsed days of ${period} carry any measurement, so the consumed figure is a floor on the period rather than the period. Pull the missing days with trazum connect before treating what is left as headroom.`,
|
|
1670
1704
|
badPort: (value) => `"${value}" is not a port. Give a whole number from 0 to 65535, or use --socket.`,
|
|
1671
1705
|
},
|
|
1672
1706
|
|
|
@@ -1742,6 +1776,27 @@ ${bold('EXAMPLES')}
|
|
|
1742
1776
|
span === null
|
|
1743
1777
|
? `Nothing was older than ${days} days. ${kept} measurements kept, and the append log compacted.`
|
|
1744
1778
|
: `Deleted ${count} measurements older than ${days} days, covering ${span} and ${usd} of measured spend. ${kept} kept, and the append log compacted to what the store already resolved to.`,
|
|
1779
|
+
budgetHeading: (period) => `Budget for ${period}`,
|
|
1780
|
+
budgetStanding: (consumed, limit, share, measuredDays, periodDays) =>
|
|
1781
|
+
`${consumed} of ${limit} (${share}), measured over ${measuredDays} of the month's ${periodDays} days.`,
|
|
1782
|
+
budgetShape: (shape, elapsedPct, coverage) =>
|
|
1783
|
+
shape === 'ahead'
|
|
1784
|
+
? `The money is going faster than the calendar: ${elapsedPct}% of the month has elapsed.`
|
|
1785
|
+
: shape === 'behind'
|
|
1786
|
+
? `The money is going slower than the calendar: ${elapsedPct}% of the month has elapsed.`
|
|
1787
|
+
: shape === 'on-pace'
|
|
1788
|
+
? `Tracking the calendar: ${elapsedPct}% of the month has elapsed.`
|
|
1789
|
+
: coverage === 'partial'
|
|
1790
|
+
? 'Whether that is fast or slow for the month cannot be told from a floor: the unmeasured days spent something, and only an overrun would be unarguable.'
|
|
1791
|
+
: 'There is nothing to compare the spend against yet.',
|
|
1792
|
+
budgetNeverForecast: () =>
|
|
1793
|
+
'That is a shape, not a forecast. Where this goes next depends on what you do next, and no arithmetic here knows that.',
|
|
1794
|
+
budgetNothingMeasured: (elapsedDays) =>
|
|
1795
|
+
`Nothing has been measured this month, across ${elapsedDays} elapsed ${elapsedDays === 1 ? 'day' : 'days'}. That is not a budget under control — it is a budget nobody is watching. Run trazum connect to pull what the provider has.`,
|
|
1796
|
+
budgetPartial: (measuredDays, elapsedDays, days) =>
|
|
1797
|
+
`Only ${measuredDays} of ${elapsedDays} elapsed days carry any measurement, so the figure below is a floor on the month rather than the month. Missing: ${days}.`,
|
|
1798
|
+
budgetScopesUnmeasured: (count) =>
|
|
1799
|
+
`${count} budgeted ${count === 1 ? 'scope' : 'scopes'} (per label or per service) cannot be answered from the store: a store record carries a provider and a model, not a workload label. Gate those with trazum profile against a per-call log.`,
|
|
1745
1800
|
},
|
|
1746
1801
|
|
|
1747
1802
|
connect: {
|
package/src/i18n/es.ts
CHANGED
|
@@ -41,6 +41,7 @@ ${bold('USO')}
|
|
|
41
41
|
trazum blame <fichero> [opciones]
|
|
42
42
|
trazum prune <fichero> --cases <fichero> --yes
|
|
43
43
|
trazum where [fichero]
|
|
44
|
+
trazum conform <fichero|-> [--contract <nombre>]
|
|
44
45
|
trazum models
|
|
45
46
|
trazum rules
|
|
46
47
|
|
|
@@ -60,6 +61,20 @@ ${bold('OPCIONES DE init')}
|
|
|
60
61
|
cifra medida y te deja el límite a ti — una configuración generada llena de
|
|
61
62
|
números adivinados no se la cree nadie.
|
|
62
63
|
|
|
64
|
+
${bold('OPCIONES DE conform')}
|
|
65
|
+
--contract <nombre> Comprueba contra un contrato concreto en vez de
|
|
66
|
+
detectarlo: usage-log, profile, plan,
|
|
67
|
+
verification, history, connected, cost-answer.
|
|
68
|
+
--json El informe como datos.
|
|
69
|
+
|
|
70
|
+
Responde dos preguntas y las mantiene separadas. ¿Cumple este documento —
|
|
71
|
+
campos obligatorios, presentes y del tipo correcto; sale con 1 si no. Y qué no
|
|
72
|
+
puede responder un documento válido de esta forma, con el campo que lo
|
|
73
|
+
desbloquearía.
|
|
74
|
+
|
|
75
|
+
La segunda nunca hace fallar nada. Decidir no registrar sesiones es una
|
|
76
|
+
decisión, no un defecto. Ver docs/format.md.
|
|
77
|
+
|
|
63
78
|
${bold('OPCIONES DE prune')}
|
|
64
79
|
--cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
|
|
65
80
|
--yes Gasta las llamadas de verdad. Sin él se imprime la
|
|
@@ -937,6 +952,23 @@ ${bold('EJEMPLOS')}
|
|
|
937
952
|
`${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
|
|
938
953
|
},
|
|
939
954
|
|
|
955
|
+
conform: {
|
|
956
|
+
noTarget: () =>
|
|
957
|
+
'Pasa un archivo para comprobar — un registro de consumo, o cualquier documento que emita Trazum. Usa "-" para leer de la entrada estándar.',
|
|
958
|
+
badContract: (given, known) => `"${given}" no es un contrato. Contratos conocidos: ${known}.`,
|
|
959
|
+
unrecognised: (path) => `${path} no encaja con ningún contrato que Trazum conozca.`,
|
|
960
|
+
heading: (path, contract) => `${path} se lee como un documento ${contract}`,
|
|
961
|
+
headingLog: (path, contract, records) =>
|
|
962
|
+
`${path} se lee como ${contract}: ${records} ${records === 1 ? 'registro' : 'registros'}`,
|
|
963
|
+
conforms: () => 'Cumple. Todos los campos obligatorios están y son del tipo correcto.',
|
|
964
|
+
problem: (at, kind, detail) => `${at}: ${detail} (${kind})`,
|
|
965
|
+
moreProblems: (count) => `…y ${count} más. Arregla estos primero; suelen ser el mismo error.`,
|
|
966
|
+
unavailableHeading: () => 'Lo que esto no puede responder, y qué lo desbloquearía',
|
|
967
|
+
unavailable: (finding, because, unlockedBy) => `${finding} — ${because}. Añade ${unlockedBy}.`,
|
|
968
|
+
unavailableNeverGates: () =>
|
|
969
|
+
'Nada de eso ha hecho fallar nada. Decidir no registrar un campo es una decisión, no un defecto, y una puerta que fallara por ello sería esta herramienta diciéndote qué registrar.',
|
|
970
|
+
},
|
|
971
|
+
|
|
940
972
|
where: {
|
|
941
973
|
hostHeading: () => 'Ejecutándose dentro de',
|
|
942
974
|
subscription: (host) =>
|
|
@@ -1696,7 +1728,9 @@ ${bold('EJEMPLOS')}
|
|
|
1696
1728
|
nothingMeasured: (dir) =>
|
|
1697
1729
|
`Todavía no hay nada medido (el almacén de ${dir} está vacío), así que la mitad de presupuesto de cada respuesta lo dirá. La mitad del coste sigue respondiendo desde el catálogo: sin conexión es un modo, no un fallo.`,
|
|
1698
1730
|
noBudget: () =>
|
|
1699
|
-
'No hay spend.
|
|
1731
|
+
'No hay spend.monthlyUsd configurado, así que "queda presupuesto" no tiene sujeto y cada respuesta lo dice en vez de inventarse uno. spend.maxUsd no se lee aquí a propósito: ese controla el periodo que cubra un registro, y leerlo como límite mensual es justo como dos superficies de esta herramienta acaban en desacuerdo.',
|
|
1732
|
+
partialCoverage: (measuredDays, elapsedDays, period) =>
|
|
1733
|
+
`Solo ${measuredDays} de los ${elapsedDays} días transcurridos de ${period} tienen alguna medición, así que la cifra consumida es un suelo del periodo y no el periodo. Descarga los días que faltan con trazum connect antes de tratar lo que queda como margen.`,
|
|
1700
1734
|
badPort: (value) => `"${value}" no es un puerto. Da un número entero de 0 a 65535, o usa --socket.`,
|
|
1701
1735
|
},
|
|
1702
1736
|
|
|
@@ -1772,6 +1806,27 @@ ${bold('EJEMPLOS')}
|
|
|
1772
1806
|
span === null
|
|
1773
1807
|
? `Nada era más antiguo que ${days} días. ${kept} mediciones conservadas, y el log compactado.`
|
|
1774
1808
|
: `Borradas ${count} mediciones de más de ${days} días, que cubren ${span} y ${usd} de gasto medido. ${kept} conservadas, y el log compactado a lo que el almacén ya resolvía.`,
|
|
1809
|
+
budgetHeading: (period) => `Presupuesto de ${period}`,
|
|
1810
|
+
budgetStanding: (consumed, limit, share, measuredDays, periodDays) =>
|
|
1811
|
+
`${consumed} de ${limit} (${share}), medido sobre ${measuredDays} de los ${periodDays} días del mes.`,
|
|
1812
|
+
budgetShape: (shape, elapsedPct, coverage) =>
|
|
1813
|
+
shape === 'ahead'
|
|
1814
|
+
? `El dinero va más rápido que el calendario: ha transcurrido el ${elapsedPct}% del mes.`
|
|
1815
|
+
: shape === 'behind'
|
|
1816
|
+
? `El dinero va más lento que el calendario: ha transcurrido el ${elapsedPct}% del mes.`
|
|
1817
|
+
: shape === 'on-pace'
|
|
1818
|
+
? `Al ritmo del calendario: ha transcurrido el ${elapsedPct}% del mes.`
|
|
1819
|
+
: coverage === 'partial'
|
|
1820
|
+
? 'Si eso es rápido o lento para el mes no se puede saber desde un suelo: los días sin medir gastaron algo, y solo un exceso sería incontestable.'
|
|
1821
|
+
: 'Todavía no hay nada con lo que comparar el gasto.',
|
|
1822
|
+
budgetNeverForecast: () =>
|
|
1823
|
+
'Eso es una forma, no un pronóstico. A dónde va esto después depende de lo que hagas después, y ninguna aritmética de aquí lo sabe.',
|
|
1824
|
+
budgetNothingMeasured: (elapsedDays) =>
|
|
1825
|
+
`No se ha medido nada este mes, en ${elapsedDays} ${elapsedDays === 1 ? 'día transcurrido' : 'días transcurridos'}. Eso no es un presupuesto bajo control — es un presupuesto que nadie está mirando. Ejecuta trazum connect para descargar lo que tenga el proveedor.`,
|
|
1826
|
+
budgetPartial: (measuredDays, elapsedDays, days) =>
|
|
1827
|
+
`Solo ${measuredDays} de ${elapsedDays} días transcurridos tienen alguna medición, así que la cifra de abajo es un suelo del mes y no el mes. Faltan: ${days}.`,
|
|
1828
|
+
budgetScopesUnmeasured: (count) =>
|
|
1829
|
+
`${count} ${count === 1 ? 'ámbito presupuestado' : 'ámbitos presupuestados'} (por etiqueta o por servicio) no se pueden responder desde el almacén: un registro del almacén lleva un proveedor y un modelo, no una etiqueta de flujo. Contrólalos con trazum profile contra un registro por llamada.`,
|
|
1775
1830
|
},
|
|
1776
1831
|
|
|
1777
1832
|
connect: {
|
package/src/i18n/types.ts
CHANGED
|
@@ -246,6 +246,26 @@ export interface CliMessages {
|
|
|
246
246
|
existingUnparseable(path: string): string;
|
|
247
247
|
};
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The conformance check.
|
|
251
|
+
*
|
|
252
|
+
* Two halves, and the copy keeps them apart everywhere: problems gate, gaps
|
|
253
|
+
* do not. Choosing not to log sessions is a decision, not a defect.
|
|
254
|
+
*/
|
|
255
|
+
conform: {
|
|
256
|
+
noTarget(): string;
|
|
257
|
+
badContract(given: string, known: string): string;
|
|
258
|
+
unrecognised(path: string): string;
|
|
259
|
+
heading(path: string, contract: string): string;
|
|
260
|
+
headingLog(path: string, contract: string, records: number): string;
|
|
261
|
+
conforms(): string;
|
|
262
|
+
problem(at: string, kind: string, detail: string): string;
|
|
263
|
+
moreProblems(count: number): string;
|
|
264
|
+
unavailableHeading(): string;
|
|
265
|
+
unavailable(finding: string, because: string, unlockedBy: string): string;
|
|
266
|
+
unavailableNeverGates(): string;
|
|
267
|
+
};
|
|
268
|
+
|
|
249
269
|
where: {
|
|
250
270
|
hostHeading(): string;
|
|
251
271
|
subscription(host: string): string;
|
|
@@ -1151,6 +1171,12 @@ export interface CliMessages {
|
|
|
1151
1171
|
measuredFrom(usd: string): string;
|
|
1152
1172
|
nothingMeasured(dir: string): string;
|
|
1153
1173
|
noBudget(): string;
|
|
1174
|
+
/**
|
|
1175
|
+
* The period is only partly measured — said out loud, because a position
|
|
1176
|
+
* standing on three days out of thirty must not read as a comfortable
|
|
1177
|
+
* ninety per cent remaining.
|
|
1178
|
+
*/
|
|
1179
|
+
partialCoverage(measuredDays: number, elapsedDays: number, period: string): string;
|
|
1154
1180
|
badPort(value: string): string;
|
|
1155
1181
|
};
|
|
1156
1182
|
|
|
@@ -1205,6 +1231,15 @@ export interface CliMessages {
|
|
|
1205
1231
|
pruneNeedsPolicy(): string;
|
|
1206
1232
|
pruneDryRun(count: string, days: string, span: string | null, usd: string): string;
|
|
1207
1233
|
pruned(count: string, days: string, span: string | null, usd: string, kept: string): string;
|
|
1234
|
+
/** The live budget — the one number `serve` and the MCP guard also read. */
|
|
1235
|
+
budgetHeading(period: string): string;
|
|
1236
|
+
budgetStanding(consumed: string, limit: string, share: string, measuredDays: string, periodDays: string): string;
|
|
1237
|
+
/** The shape of the burn, named. Never a date — see `budgetNeverForecast`. */
|
|
1238
|
+
budgetShape(shape: string, elapsedPct: number, coverage: string): string;
|
|
1239
|
+
budgetNeverForecast(): string;
|
|
1240
|
+
budgetNothingMeasured(elapsedDays: number): string;
|
|
1241
|
+
budgetPartial(measuredDays: number, elapsedDays: number, days: string): string;
|
|
1242
|
+
budgetScopesUnmeasured(count: number): string;
|
|
1208
1243
|
};
|
|
1209
1244
|
|
|
1210
1245
|
/**
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
computeSavings,
|
|
36
36
|
countTokensAnthropic,
|
|
37
37
|
DEFAULT_USAGE,
|
|
38
|
+
budgetPositions,
|
|
39
|
+
conform,
|
|
38
40
|
detectFromSource,
|
|
39
41
|
matchLocale,
|
|
40
42
|
parsePlanDocument,
|
|
@@ -127,6 +129,8 @@ import type {
|
|
|
127
129
|
UsageProfile,
|
|
128
130
|
} from '@trazum/core';
|
|
129
131
|
import type {
|
|
132
|
+
BudgetReport,
|
|
133
|
+
ContractName,
|
|
130
134
|
UsageProfileReport,
|
|
131
135
|
WaiverUse,
|
|
132
136
|
InitDecline,
|
|
@@ -226,6 +230,7 @@ interface Args {
|
|
|
226
230
|
|
|
227
231
|
const VALUE_FLAGS = new Set([
|
|
228
232
|
'against',
|
|
233
|
+
'contract',
|
|
229
234
|
'from-log',
|
|
230
235
|
'min-usd',
|
|
231
236
|
'payload',
|
|
@@ -548,6 +553,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
548
553
|
models: [],
|
|
549
554
|
rank: ['level', 'model', 'calls', 'output-tokens', 'batch', 'disable', 'prompt', 'markdown-out'],
|
|
550
555
|
init: ['dry-run', 'yes', 'json', 'pricing', 'pricing-live'],
|
|
556
|
+
conform: ['contract', 'json'],
|
|
551
557
|
where: [],
|
|
552
558
|
rules: [],
|
|
553
559
|
blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
|
|
@@ -1470,6 +1476,20 @@ const INIT_LOG_CANDIDATES = [
|
|
|
1470
1476
|
* nobody has vouched for.
|
|
1471
1477
|
*/
|
|
1472
1478
|
|
|
1479
|
+
/** Problems listed before the rest are counted. A wall of them helps nobody. */
|
|
1480
|
+
const MAX_CONFORM_PROBLEMS = 20;
|
|
1481
|
+
|
|
1482
|
+
/** The contracts `--contract` accepts, so a typo is refused with the list. */
|
|
1483
|
+
const CONTRACT_NAMES = [
|
|
1484
|
+
'usage-log',
|
|
1485
|
+
'profile',
|
|
1486
|
+
'plan',
|
|
1487
|
+
'verification',
|
|
1488
|
+
'history',
|
|
1489
|
+
'connected',
|
|
1490
|
+
'cost-answer',
|
|
1491
|
+
];
|
|
1492
|
+
|
|
1473
1493
|
/** How many source files, and how large each may be. Both reported when they bite. */
|
|
1474
1494
|
const INIT_MAX_SOURCE_FILES = 400;
|
|
1475
1495
|
const INIT_MAX_SOURCE_BYTES = 256 * 1024;
|
|
@@ -1848,6 +1868,87 @@ async function commandInit(
|
|
|
1848
1868
|
console.log();
|
|
1849
1869
|
}
|
|
1850
1870
|
|
|
1871
|
+
/**
|
|
1872
|
+
* `trazum conform <file>` — does this document conform, and what will it not
|
|
1873
|
+
* be able to answer?
|
|
1874
|
+
*
|
|
1875
|
+
* The command that makes the five contracts something to build against rather
|
|
1876
|
+
* than something to read about. An emitter — a logging wrapper somebody wrote
|
|
1877
|
+
* this afternoon, a connector for a provider this repository has never heard
|
|
1878
|
+
* of, a dashboard writing profile documents of its own — points this at what
|
|
1879
|
+
* it produced and finds out before shipping.
|
|
1880
|
+
*
|
|
1881
|
+
* **The second half is the useful half.** "Valid" is a yes or no. "Here is
|
|
1882
|
+
* what a valid document of this shape cannot tell you, and the field that
|
|
1883
|
+
* would unlock each" is the answer somebody acts on: a usage log with no
|
|
1884
|
+
* `session` is perfectly conformant and simply has no conversation growth in
|
|
1885
|
+
* it, and an emitter that only ever hears "valid" ships it and never finds out
|
|
1886
|
+
* why half the report is empty.
|
|
1887
|
+
*
|
|
1888
|
+
* Exits 1 on a problem, so it gates. It never exits 1 on an *unavailable
|
|
1889
|
+
* finding*: choosing not to log sessions is a decision, not a defect, and a
|
|
1890
|
+
* gate that failed on it would be this tool telling somebody what to record.
|
|
1891
|
+
*/
|
|
1892
|
+
async function commandConform(args: Args, t: CliMessages): Promise<void> {
|
|
1893
|
+
const target = args.positional[0];
|
|
1894
|
+
if (target === undefined) throw new Error(t.conform.noTarget());
|
|
1895
|
+
|
|
1896
|
+
const named = stringFlag(args, 'contract');
|
|
1897
|
+
if (named !== undefined && !CONTRACT_NAMES.includes(named)) {
|
|
1898
|
+
throw new Error(t.conform.badContract(named, CONTRACT_NAMES.join(', ')));
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const text = target === '-' ? await readInput('-', t) : await readUsageLog(target, t);
|
|
1902
|
+
const report = conform(text, named === undefined ? {} : { contract: named as ContractName });
|
|
1903
|
+
|
|
1904
|
+
if (boolFlag(args, 'json')) {
|
|
1905
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1906
|
+
if (!report.conforms) process.exitCode = 1;
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
console.log();
|
|
1911
|
+
if (report.contract === null) {
|
|
1912
|
+
console.log(c.red(t.conform.unrecognised(target)));
|
|
1913
|
+
console.log(` ${c.dim(wrap(report.because ?? '', 74, ' '))}`);
|
|
1914
|
+
console.log();
|
|
1915
|
+
process.exitCode = 1;
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
console.log(
|
|
1920
|
+
c.bold(
|
|
1921
|
+
report.records === null
|
|
1922
|
+
? t.conform.heading(target, report.contract)
|
|
1923
|
+
: t.conform.headingLog(target, report.contract, report.records),
|
|
1924
|
+
),
|
|
1925
|
+
);
|
|
1926
|
+
|
|
1927
|
+
if (report.problems.length === 0) {
|
|
1928
|
+
console.log(` ${c.green(t.conform.conforms())}`);
|
|
1929
|
+
} else {
|
|
1930
|
+
for (const problem of report.problems.slice(0, MAX_CONFORM_PROBLEMS)) {
|
|
1931
|
+
console.log(` ${c.red(t.conform.problem(problem.at, problem.kind, problem.detail))}`);
|
|
1932
|
+
}
|
|
1933
|
+
if (report.problems.length > MAX_CONFORM_PROBLEMS) {
|
|
1934
|
+
console.log(` ${c.dim(t.conform.moreProblems(report.problems.length - MAX_CONFORM_PROBLEMS))}`);
|
|
1935
|
+
}
|
|
1936
|
+
process.exitCode = 1;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (report.unavailable.length > 0) {
|
|
1940
|
+
console.log();
|
|
1941
|
+
console.log(c.bold(t.conform.unavailableHeading()));
|
|
1942
|
+
for (const gap of report.unavailable) {
|
|
1943
|
+
console.log(` ${c.dim(wrap(t.conform.unavailable(gap.finding, gap.because, gap.unlockedBy), 74, ' '))}`);
|
|
1944
|
+
}
|
|
1945
|
+
// Said out loud, because the exit code says it silently and somebody
|
|
1946
|
+
// reading a red-and-yellow screen will assume both halves gated.
|
|
1947
|
+
console.log(` ${c.dim(wrap(t.conform.unavailableNeverGates(), 74, ' '))}`);
|
|
1948
|
+
}
|
|
1949
|
+
console.log();
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1851
1952
|
function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
|
|
1852
1953
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
1853
1954
|
const col = t.models.columns;
|
|
@@ -2747,41 +2848,39 @@ async function commandServe(
|
|
|
2747
2848
|
t: CliMessages,
|
|
2748
2849
|
): Promise<void> {
|
|
2749
2850
|
const root = process.cwd();
|
|
2750
|
-
const limitUsd = config.spend?.maxUsd;
|
|
2751
2851
|
|
|
2752
2852
|
const { resolved } = await readStore(root);
|
|
2753
|
-
|
|
2853
|
+
|
|
2754
2854
|
/**
|
|
2755
|
-
* The
|
|
2855
|
+
* The live budget, from `budgetPositions` — the same number `store` prints
|
|
2856
|
+
* and the same one the MCP guard consults.
|
|
2756
2857
|
*
|
|
2757
|
-
*
|
|
2758
|
-
*
|
|
2759
|
-
*
|
|
2858
|
+
* **This used to read `spend.maxUsd` against the whole store**, which is a
|
|
2859
|
+
* per-log gate compared against however much history the store happened to
|
|
2860
|
+
* hold. A year of records against a monthly limit reported as a budget
|
|
2861
|
+
* position, with a straight face and no way for a caller to tell. Same
|
|
2862
|
+
* units, different denominators, and the two surfaces disagreed by exactly
|
|
2863
|
+
* as much history as the machine had. `spend.monthlyUsd` is the key for a
|
|
2864
|
+
* calendar month and nothing infers one key from the other: a repository
|
|
2865
|
+
* with a per-log gate and no monthly budget has no monthly position, and
|
|
2866
|
+
* this says so rather than picking a number that is the right shape.
|
|
2760
2867
|
*/
|
|
2761
|
-
const
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
}
|
|
2766
|
-
: null;
|
|
2767
|
-
const report = bucketedProfile(
|
|
2768
|
-
{
|
|
2769
|
-
provider: 'store',
|
|
2770
|
-
granularity: 'bucketed',
|
|
2771
|
-
buckets: bucketsFromRecords(resolved.records),
|
|
2772
|
-
window,
|
|
2773
|
-
gaps: [],
|
|
2774
|
-
unavailable: [],
|
|
2775
|
-
},
|
|
2776
|
-
{ catalogue: pricing },
|
|
2777
|
-
);
|
|
2868
|
+
const budget = budgetPositions(resolved.records, config.spend, { catalogue: pricing });
|
|
2869
|
+
const standing = budget.positions[0] ?? null;
|
|
2870
|
+
const limitUsd = config.spend?.monthlyUsd;
|
|
2871
|
+
const measured = standing !== null && standing.coverage !== 'none';
|
|
2778
2872
|
|
|
2779
2873
|
const server = buildServer({
|
|
2780
2874
|
catalogue: pricing,
|
|
2781
2875
|
position: () => ({
|
|
2782
|
-
|
|
2876
|
+
// Nothing measured inside the period is `undefined`, never zero: the
|
|
2877
|
+
// endpoint's `cannot-tell` exists for exactly this, and a $0 consumed
|
|
2878
|
+
// would be the healthiest-looking budget a dead store can produce.
|
|
2879
|
+
consumedUsd: measured ? standing.consumedUsd : undefined,
|
|
2783
2880
|
limitUsd,
|
|
2784
|
-
|
|
2881
|
+
// The period, not the store's span. A caller judging staleness needs to
|
|
2882
|
+
// know which month the figure is about.
|
|
2883
|
+
window: standing === null ? null : { fromMs: standing.period.fromMs, toMs: standing.period.toMs },
|
|
2785
2884
|
}),
|
|
2786
2885
|
});
|
|
2787
2886
|
|
|
@@ -2796,8 +2895,13 @@ async function commandServe(
|
|
|
2796
2895
|
console.log(c.bold(t.serve.listening(where)));
|
|
2797
2896
|
console.log(` ${c.dim(wrap(t.serve.loopbackOnly(), 74, ' '))}`);
|
|
2798
2897
|
console.log(
|
|
2799
|
-
` ${c.dim(wrap(measured ? t.serve.measuredFrom(formatUsd(
|
|
2898
|
+
` ${c.dim(wrap(measured ? t.serve.measuredFrom(formatUsd(standing.consumedUsd)) : t.serve.nothingMeasured(STORE_DIR), 74, ' '))}`,
|
|
2800
2899
|
);
|
|
2900
|
+
if (standing !== null && standing.coverage === 'partial') {
|
|
2901
|
+
console.log(
|
|
2902
|
+
` ${c.yellow(wrap(t.serve.partialCoverage(standing.measuredDays, standing.elapsedDays, standing.period.id), 74, ' '))}`,
|
|
2903
|
+
);
|
|
2904
|
+
}
|
|
2801
2905
|
if (limitUsd === undefined) {
|
|
2802
2906
|
console.log(` ${c.dim(wrap(t.serve.noBudget(), 74, ' '))}`);
|
|
2803
2907
|
}
|
|
@@ -3164,6 +3268,72 @@ async function commandStore(
|
|
|
3164
3268
|
console.log(
|
|
3165
3269
|
` ${c.dim(wrap(keepDays === undefined ? t.store.noRetention() : t.store.retention(String(keepDays)), 74, ' '))}`,
|
|
3166
3270
|
);
|
|
3271
|
+
|
|
3272
|
+
/**
|
|
3273
|
+
* The live budget, printed here because this is where the measurement lives.
|
|
3274
|
+
*
|
|
3275
|
+
* The same call `serve` makes and the same call the MCP guard makes, so the
|
|
3276
|
+
* three cannot disagree about how much of the month is gone — which is the
|
|
3277
|
+
* whole point of the number existing in one place.
|
|
3278
|
+
*/
|
|
3279
|
+
renderBudget(budgetPositions(resolved.records, config.spend, { catalogue: pricing }), t, n);
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
/**
|
|
3283
|
+
* One budget standing, rendered.
|
|
3284
|
+
*
|
|
3285
|
+
* Coverage before the money, deliberately. A reader who sees "$61 of $100"
|
|
3286
|
+
* first has already formed a view by the time they reach "over three of
|
|
3287
|
+
* nineteen elapsed days", and the second sentence has to undo the first.
|
|
3288
|
+
*/
|
|
3289
|
+
function renderBudget(report: BudgetReport, t: CliMessages, n: (value: number) => string): void {
|
|
3290
|
+
const standing = report.positions[0];
|
|
3291
|
+
if (standing === undefined) {
|
|
3292
|
+
if (report.unmeasuredScopes.length > 0) {
|
|
3293
|
+
console.log();
|
|
3294
|
+
console.log(
|
|
3295
|
+
` ${c.dim(wrap(t.store.budgetScopesUnmeasured(report.unmeasuredScopes.length), 74, ' '))}`,
|
|
3296
|
+
);
|
|
3297
|
+
}
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
console.log();
|
|
3302
|
+
console.log(c.bold(t.store.budgetHeading(standing.period.id)));
|
|
3303
|
+
|
|
3304
|
+
if (standing.coverage === 'none') {
|
|
3305
|
+
// Nothing measured is never rendered as nothing spent. A dead store and a
|
|
3306
|
+
// quiet month produce the same zero, and only one of them is good news.
|
|
3307
|
+
console.log(` ${c.red(wrap(t.store.budgetNothingMeasured(standing.elapsedDays), 74, ' '))}`);
|
|
3308
|
+
return;
|
|
3309
|
+
}
|
|
3310
|
+
if (standing.coverage === 'partial') {
|
|
3311
|
+
console.log(
|
|
3312
|
+
` ${c.yellow(wrap(t.store.budgetPartial(standing.measuredDays, standing.elapsedDays, standing.unmeasuredDays.join(', ')), 74, ' '))}`,
|
|
3313
|
+
);
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
const share = standing.burn.consumedShare;
|
|
3317
|
+
console.log(
|
|
3318
|
+
` ${t.store.budgetStanding(
|
|
3319
|
+
formatUsd(standing.consumedUsd),
|
|
3320
|
+
formatUsd(standing.limitUsd),
|
|
3321
|
+
share === null ? '—' : `${Math.round(share * 100)}%`,
|
|
3322
|
+
n(standing.measuredDays),
|
|
3323
|
+
n(standing.period.days),
|
|
3324
|
+
)}`,
|
|
3325
|
+
);
|
|
3326
|
+
const line = t.store.budgetShape(
|
|
3327
|
+
standing.burn.shape,
|
|
3328
|
+
Math.round(standing.burn.elapsedShare * 100),
|
|
3329
|
+
standing.coverage,
|
|
3330
|
+
);
|
|
3331
|
+
console.log(` ${standing.verdict === 'over' ? c.red(line) : c.dim(wrap(line, 74, ' '))}`);
|
|
3332
|
+
// Only where there is a shape to disclaim. "That is a shape, not a forecast"
|
|
3333
|
+
// under "nothing to compare against" is a disclaimer about nothing.
|
|
3334
|
+
if (standing.burn.shape !== 'cannot-tell') {
|
|
3335
|
+
console.log(` ${c.dim(wrap(t.store.budgetNeverForecast(), 74, ' '))}`);
|
|
3336
|
+
}
|
|
3167
3337
|
}
|
|
3168
3338
|
|
|
3169
3339
|
/**
|
|
@@ -7772,6 +7942,9 @@ async function main(): Promise<void> {
|
|
|
7772
7942
|
case 'models':
|
|
7773
7943
|
commandModels(t, pricing);
|
|
7774
7944
|
break;
|
|
7945
|
+
case 'conform':
|
|
7946
|
+
await commandConform(args, t);
|
|
7947
|
+
break;
|
|
7775
7948
|
case 'init':
|
|
7776
7949
|
await commandInit(args, config, pricing, t);
|
|
7777
7950
|
break;
|