@trazum/cli 1.47.0 → 1.48.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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Where a waiver's uses are written down.
3
+ *
4
+ * The core decides what a use means and what a run of them adds up to; this
5
+ * decides where the bytes go — the same split every module here follows, so
6
+ * `@trazum/core` stays browser-safe and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, and never rewritten.** There is deliberately no prune, no
9
+ * compaction and no `--clear`: a record of decisions that the tool can erase
10
+ * is a record nobody can rely on, and the one thing a waiver history is for is
11
+ * being awkward six months later. Deleting the file is a thing a person does
12
+ * with `rm`, on purpose, having seen it.
13
+ *
14
+ * **A write that fails never fails the run.** The gate's job is the exit code.
15
+ * A read-only checkout, a full disk or a directory somebody's CI cannot create
16
+ * must not turn a passing build red on account of bookkeeping — the failure is
17
+ * reported and the gate's own verdict stands.
18
+ *
19
+ * **A line that will not parse is counted and skipped**, exactly as in the
20
+ * store. Losing the whole history because one line is broken would be the
21
+ * worst possible response; pretending the history is complete would be the
22
+ * second worst.
23
+ */
24
+ import type { WaiverUse } from '@trazum/core';
25
+ /** One file, not one per month: a waiver history is small and read whole. */
26
+ export declare const WAIVER_LOG = ".trazum/waivers.jsonl";
27
+ export interface WaiverReadResult {
28
+ uses: WaiverUse[];
29
+ /** 1-based positions of lines that would not parse. Named, never dropped quietly. */
30
+ unreadable: number[];
31
+ /** False when the file does not exist — "nothing recorded" is not "no file". */
32
+ present: boolean;
33
+ }
34
+ export declare function readWaiverLog(root: string): Promise<WaiverReadResult>;
35
+ /**
36
+ * Appends one use, and swallows any failure after reporting it.
37
+ *
38
+ * Returns the error message rather than throwing, so the caller can print it
39
+ * beside the gate's own output without the gate ever depending on the write.
40
+ */
41
+ export declare function appendWaiverUse(root: string, use: WaiverUse): Promise<string | null>;
42
+ //# sourceMappingURL=waiver-log.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waiver-log.d.ts","sourceRoot":"","sources":["../src/waiver-log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAKH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,6EAA6E;AAC7E,eAAO,MAAM,UAAU,0BAA0B,CAAC;AAElD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB,qFAAqF;IACrF,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,gFAAgF;IAChF,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAuB3E;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAS1F"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Where a waiver's uses are written down.
3
+ *
4
+ * The core decides what a use means and what a run of them adds up to; this
5
+ * decides where the bytes go — the same split every module here follows, so
6
+ * `@trazum/core` stays browser-safe and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, and never rewritten.** There is deliberately no prune, no
9
+ * compaction and no `--clear`: a record of decisions that the tool can erase
10
+ * is a record nobody can rely on, and the one thing a waiver history is for is
11
+ * being awkward six months later. Deleting the file is a thing a person does
12
+ * with `rm`, on purpose, having seen it.
13
+ *
14
+ * **A write that fails never fails the run.** The gate's job is the exit code.
15
+ * A read-only checkout, a full disk or a directory somebody's CI cannot create
16
+ * must not turn a passing build red on account of bookkeeping — the failure is
17
+ * reported and the gate's own verdict stands.
18
+ *
19
+ * **A line that will not parse is counted and skipped**, exactly as in the
20
+ * store. Losing the whole history because one line is broken would be the
21
+ * worst possible response; pretending the history is complete would be the
22
+ * second worst.
23
+ */
24
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ import { isWaiverUse } from '@trazum/core';
27
+ /** One file, not one per month: a waiver history is small and read whole. */
28
+ export const WAIVER_LOG = '.trazum/waivers.jsonl';
29
+ export async function readWaiverLog(root) {
30
+ let raw;
31
+ try {
32
+ raw = await readFile(join(root, WAIVER_LOG), 'utf8');
33
+ }
34
+ catch {
35
+ // Absent is the normal state of a repository that has never waived
36
+ // anything, and it is not an error.
37
+ return { uses: [], unreadable: [], present: false };
38
+ }
39
+ const uses = [];
40
+ const unreadable = [];
41
+ raw.split('\n').forEach((line, index) => {
42
+ if (line.trim() === '')
43
+ return;
44
+ try {
45
+ const parsed = JSON.parse(line);
46
+ if (isWaiverUse(parsed))
47
+ uses.push(parsed);
48
+ else
49
+ unreadable.push(index + 1);
50
+ }
51
+ catch {
52
+ unreadable.push(index + 1);
53
+ }
54
+ });
55
+ return { uses, unreadable, present: true };
56
+ }
57
+ /**
58
+ * Appends one use, and swallows any failure after reporting it.
59
+ *
60
+ * Returns the error message rather than throwing, so the caller can print it
61
+ * beside the gate's own output without the gate ever depending on the write.
62
+ */
63
+ export async function appendWaiverUse(root, use) {
64
+ const path = join(root, WAIVER_LOG);
65
+ try {
66
+ await mkdir(join(path, '..'), { recursive: true });
67
+ await writeFile(path, `${JSON.stringify(use)}\n`, { flag: 'a', mode: 0o600 });
68
+ return null;
69
+ }
70
+ catch (error) {
71
+ return error instanceof Error ? error.message : String(error);
72
+ }
73
+ }
74
+ //# sourceMappingURL=waiver-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waiver-log.js","sourceRoot":"","sources":["../src/waiver-log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAG3C,6EAA6E;AAC7E,MAAM,CAAC,MAAM,UAAU,GAAG,uBAAuB,CAAC;AAUlD,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAY;IAC9C,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC;IACvD,CAAC;IAAC,MAAM,CAAC;QACP,mEAAmE;QACnE,oCAAoC;QACpC,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IACtD,CAAC;IAED,MAAM,IAAI,GAAgB,EAAE,CAAC;IAC7B,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;QACtC,IAAI,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE;YAAE,OAAO;QAC/B,IAAI,CAAC;YACH,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACzC,IAAI,WAAW,CAAC,MAAM,CAAC;gBAAE,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;;gBACtC,UAAU,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QAClC,CAAC;QAAC,MAAM,CAAC;YACP,UAAU,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC,CAAC,CAAC;IACH,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AAC7C,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY,EAAE,GAAc;IAChE,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACnD,MAAM,SAAS,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QAC9E,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAChE,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/cli",
3
- "version": "1.47.0",
3
+ "version": "1.48.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.47.0"
40
+ "@trazum/core": "1.48.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -1554,6 +1554,8 @@ ${bold('EXAMPLES')}
1554
1554
  `WAIVED — the ${gate} failure above is on the record and silenced until ${until} (${daysLeft} days left): "${reason}". The bill still counts it; only the exit code is quiet, and the day the waiver expires this gate fails again.`,
1555
1555
  waiveExpired: (gate, until, reason) =>
1556
1556
  `The waiver on ${gate} expired on ${until} and no longer silences anything. It was written for: "${reason}". Renew it with a new date and a current reason, or fix what it was covering — an expired waiver left in place is a finding deleted with extra steps.`,
1557
+ waiveNotRecorded: (path, because) =>
1558
+ ` (This waiver was not written to ${path}: ${because}. The gate's verdict above is unaffected.)`,
1557
1559
  summaryNoComparison: () =>
1558
1560
  'No previous log was given, so nothing here says whether the bill moved — a summary without a comparison states the bill, not its stability.',
1559
1561
  summaryFooter: () =>
@@ -1807,6 +1809,33 @@ ${bold('EXAMPLES')}
1807
1809
  unrecognized: (name) => `${name} is neither a stored report nor a saved plan, so it is in no series above.`,
1808
1810
  footer: () =>
1809
1811
  'A series names shapes, not futures. Twenty points make a trend visible; they do not make next month knowable — where these lines go next is yours to judge.',
1812
+ waiverHeading: () => 'What this repository has been living with',
1813
+ waiverSince: (day, uses) =>
1814
+ `${uses} recorded ${uses === 1 ? 'use' : 'uses'} since ${day}, when recording started.`,
1815
+ waiverNoneRecorded: () =>
1816
+ 'No waiver has silenced a gate since recording started. Nothing here is inferred from the config — a waiver written down and never hit is not a decision anyone is living with.',
1817
+ waiverHabit: (gate, uses, days, firstDay, lastDay) =>
1818
+ `${gate}: ${uses} ${uses === 1 ? 'use' : 'uses'} across ${days} ${days === 1 ? 'day' : 'days'}, ${firstDay} to ${lastDay}`,
1819
+ waiverVerdict: (verdict) =>
1820
+ verdict === 'used-once'
1821
+ ? 'Used once. Nothing to read into it yet.'
1822
+ : verdict === 'recurring'
1823
+ ? 'The same decision, holding. The gate keeps firing and the reason has not moved.'
1824
+ : verdict === 'renewed-without-revisiting'
1825
+ ? 'The expiry has moved and the reason has not. That is the shape a decision takes when nobody is revisiting it — which is sometimes exactly right, and worth saying out loud either way.'
1826
+ : 'The reason changed between uses. Somebody looked again.',
1827
+ waiverReasonNow: (reason) => `Reason: ${reason}`,
1828
+ waiverReasonsChanged: (count) => `${count} different reasons given over that time.`,
1829
+ waiverExpiriesMoved: (from, to, count) =>
1830
+ `Expiry moved ${count} ${count === 1 ? 'time' : 'times'}: ${from} → ${to}.`,
1831
+ waiverNoLongerConfigured: () =>
1832
+ 'Not in the config any more. The decision was reversed; the record keeps it.',
1833
+ waiverNeverUsed: (gates) =>
1834
+ `Waived in the config and never hit by a recorded run: ${gates}. Either the gate stopped failing — good news nobody wrote down — or the waiver names a situation that does not arise. Both are worth deleting.`,
1835
+ waiverUnreadable: (count, path) =>
1836
+ `${count} ${count === 1 ? 'line' : 'lines'} in ${path} could not be read and are not counted above.`,
1837
+ waiverStartsHere: () =>
1838
+ 'Nothing before that day exists. This record began when recording did, and no past was reconstructed from the config as it stands.',
1810
1839
  },
1811
1840
 
1812
1841
  verify: {
package/src/i18n/es.ts CHANGED
@@ -1584,6 +1584,8 @@ ${bold('EJEMPLOS')}
1584
1584
  `WAIVED — el fallo de ${gate} de arriba queda registrado y silenciado hasta ${until} (${daysLeft} días restantes): "${reason}". La factura lo sigue contando; solo el código de salida calla, y el día que caduque el waiver este gate vuelve a fallar.`,
1585
1585
  waiveExpired: (gate, until, reason) =>
1586
1586
  `El waiver de ${gate} caducó el ${until} y ya no silencia nada. Se escribió por: "${reason}". Renuévalo con fecha nueva y razón vigente, o arregla lo que cubría — un waiver caducado dejado ahí es un hallazgo borrado con pasos extra.`,
1587
+ waiveNotRecorded: (path, because) =>
1588
+ ` (Este waiver no se pudo escribir en ${path}: ${because}. El veredicto de la puerta de arriba no cambia.)`,
1587
1589
  summaryNoComparison: () =>
1588
1590
  'No se dio un registro anterior, así que nada de aquí dice si la factura se movió — un resumen sin comparación indica la factura, no su estabilidad.',
1589
1591
  summaryFooter: () =>
@@ -1837,6 +1839,33 @@ ${bold('EJEMPLOS')}
1837
1839
  unrecognized: (name) => `${name} no es ni un informe guardado ni un plan guardado, así que no está en ninguna serie de arriba.`,
1838
1840
  footer: () =>
1839
1841
  'Una serie nombra formas, no futuros. Veinte puntos hacen visible una tendencia; no hacen conocible el mes que viene — adónde van estas líneas después lo juzgas tú.',
1842
+ waiverHeading: () => 'Con lo que este repositorio ha estado conviviendo',
1843
+ waiverSince: (day, uses) =>
1844
+ `${uses} ${uses === 1 ? 'uso registrado' : 'usos registrados'} desde el ${day}, cuando empezó el registro.`,
1845
+ waiverNoneRecorded: () =>
1846
+ 'Ningún waiver ha silenciado una puerta desde que empezó el registro. Nada de aquí se deduce de la configuración — un waiver escrito y nunca usado no es una decisión con la que nadie conviva.',
1847
+ waiverHabit: (gate, uses, days, firstDay, lastDay) =>
1848
+ `${gate}: ${uses} ${uses === 1 ? 'uso' : 'usos'} en ${days} ${days === 1 ? 'día' : 'días'}, del ${firstDay} al ${lastDay}`,
1849
+ waiverVerdict: (verdict) =>
1850
+ verdict === 'used-once'
1851
+ ? 'Usado una vez. Todavía no hay nada que leer en ello.'
1852
+ : verdict === 'recurring'
1853
+ ? 'La misma decisión, sosteniéndose. La puerta sigue saltando y el motivo no ha cambiado.'
1854
+ : verdict === 'renewed-without-revisiting'
1855
+ ? 'La fecha de caducidad se ha movido y el motivo no. Esa es la forma que toma una decisión que nadie está revisando — a veces es exactamente lo correcto, y merece decirse en voz alta igualmente.'
1856
+ : 'El motivo cambió entre usos. Alguien volvió a mirarlo.',
1857
+ waiverReasonNow: (reason) => `Motivo: ${reason}`,
1858
+ waiverReasonsChanged: (count) => `${count} motivos distintos dados en ese tiempo.`,
1859
+ waiverExpiriesMoved: (from, to, count) =>
1860
+ `La caducidad se movió ${count} ${count === 1 ? 'vez' : 'veces'}: ${from} → ${to}.`,
1861
+ waiverNoLongerConfigured: () =>
1862
+ 'Ya no está en la configuración. La decisión se revirtió; el registro la conserva.',
1863
+ waiverNeverUsed: (gates) =>
1864
+ `Con waiver en la configuración y nunca usados por una ejecución registrada: ${gates}. O la puerta dejó de fallar — buenas noticias que nadie anotó — o el waiver nombra una situación que no ocurre. Ambos merecen borrarse.`,
1865
+ waiverUnreadable: (count, path) =>
1866
+ `${count} ${count === 1 ? 'línea' : 'líneas'} de ${path} no se pudieron leer y no cuentan arriba.`,
1867
+ waiverStartsHere: () =>
1868
+ 'Nada anterior a ese día existe. Este registro empezó cuando empezó a registrarse, y no se reconstruyó ningún pasado a partir de la configuración actual.',
1840
1869
  },
1841
1870
 
1842
1871
  verify: {
package/src/i18n/types.ts CHANGED
@@ -877,6 +877,14 @@ export interface CliMessages {
877
877
  */
878
878
  waiveActive(gate: string, reason: string, until: string, daysLeft: string): string;
879
879
  waiveExpired(gate: string, until: string, reason: string): string;
880
+ /**
881
+ * A use that could not be written down.
882
+ *
883
+ * Printed dim and never as an error: the gate's verdict is unaffected, and
884
+ * a read-only checkout must not turn a passing build red on account of
885
+ * bookkeeping.
886
+ */
887
+ waiveNotRecorded(path: string, because: string): string;
880
888
  /**
881
889
  * `--markdown-summary`: the short form for a pull-request body or a weekly
882
890
  * note. A view over the same report, never a different set of figures.
@@ -1254,6 +1262,19 @@ export interface CliMessages {
1254
1262
  undated(name: string): string;
1255
1263
  unrecognized(name: string): string;
1256
1264
  footer(): string;
1265
+ /** The waiver record — what this team has been living with, and for how long. */
1266
+ waiverHeading(): string;
1267
+ waiverSince(day: string, uses: number): string;
1268
+ waiverNoneRecorded(): string;
1269
+ waiverHabit(gate: string, uses: number, days: number, firstDay: string, lastDay: string): string;
1270
+ waiverVerdict(verdict: string): string;
1271
+ waiverReasonNow(reason: string): string;
1272
+ waiverReasonsChanged(count: number): string;
1273
+ waiverExpiriesMoved(from: string, to: string, count: number): string;
1274
+ waiverNoLongerConfigured(): string;
1275
+ waiverNeverUsed(gates: string): string;
1276
+ waiverUnreadable(count: number, path: string): string;
1277
+ waiverStartsHere(): string;
1257
1278
  };
1258
1279
 
1259
1280
  /**
package/src/index.ts CHANGED
@@ -38,6 +38,8 @@ import {
38
38
  detectFromSource,
39
39
  matchLocale,
40
40
  parsePlanDocument,
41
+ waiverDay,
42
+ waiverHistory,
41
43
  proposeInit,
42
44
  MIN_RATE_DAYS,
43
45
  parseConfig,
@@ -126,6 +128,7 @@ import type {
126
128
  } from '@trazum/core';
127
129
  import type {
128
130
  UsageProfileReport,
131
+ WaiverUse,
129
132
  InitDecline,
130
133
  InitJustification,
131
134
  InitObservations,
@@ -168,6 +171,7 @@ import {
168
171
  import type { Revision } from './git.js';
169
172
  import { fetchProviderUsage, findCredential } from './connect.js';
170
173
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
174
+ import { WAIVER_LOG, appendWaiverUse, readWaiverLog } from './waiver-log.js';
171
175
  import { DEFAULT_PORT, buildServer, listen } from './serve.js';
172
176
  import {
173
177
  WATCH_STATE_VERSION,
@@ -3348,7 +3352,12 @@ async function commandConnect(
3348
3352
  * same action planned twice — and no series, however long, becomes a
3349
3353
  * forecast.
3350
3354
  */
3351
- async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
3355
+ async function commandHistory(
3356
+ args: Args,
3357
+ config: TrazumConfig,
3358
+ pricing: PricingCatalogue,
3359
+ t: CliMessages,
3360
+ ): Promise<void> {
3352
3361
  /**
3353
3362
  * `--store` builds the series from measured spend already on disk.
3354
3363
  *
@@ -3452,7 +3461,23 @@ async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessa
3452
3461
  throw new Error(t.history.needsThree(String(history.periods.length)));
3453
3462
  }
3454
3463
 
3455
- const stamped = { ...history, unrecognizedFiles: unrecognized };
3464
+ /**
3465
+ * The waiver record — closing the gap 1.40 named and could not fill.
3466
+ *
3467
+ * 1.40 wanted to say "this finding has been waived three times in a row" and
3468
+ * refused to, because the only material available was the config as it
3469
+ * stands, and a past reconstructed from a present is a guess wearing a
3470
+ * record's clothes. The material exists now: since 1.48 a waiver that
3471
+ * silences a gate writes down that it did, and this reads those lines back.
3472
+ *
3473
+ * Read from the working directory rather than from the reports directory:
3474
+ * the waiver record belongs to the repository whose gates fired, and the
3475
+ * stored reports may have come from anywhere.
3476
+ */
3477
+ const waivers = await readWaiverLog('.');
3478
+ const waiverReport = waiverHistory(waivers.uses, config.waive ?? []);
3479
+
3480
+ const stamped = { ...history, unrecognizedFiles: unrecognized, waivers: waiverReport };
3456
3481
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
3457
3482
  const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
3458
3483
  const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
@@ -3506,6 +3531,58 @@ async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessa
3506
3531
  for (const name of unrecognized) {
3507
3532
  out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
3508
3533
  }
3534
+ /**
3535
+ * The waiver record, printed only once something has been recorded.
3536
+ *
3537
+ * Silent on a repository that has never waived anything, rather than a
3538
+ * heading over "0 uses" — an empty section teaches a reader to skip the
3539
+ * section, and this is the one they should not learn to skip.
3540
+ */
3541
+ if (waivers.present) {
3542
+ out.push('');
3543
+ out.push(md ? `### ${t.history.waiverHeading()}` : t.history.waiverHeading());
3544
+ if (waiverReport.totalUses === 0) {
3545
+ out.push(md ? `- ${t.history.waiverNoneRecorded()}` : ` ${t.history.waiverNoneRecorded()}`);
3546
+ } else {
3547
+ const since = t.history.waiverSince(waiverReport.since ?? '', waiverReport.totalUses);
3548
+ out.push(md ? `- ${since}` : ` ${since}`);
3549
+ out.push(md ? `- _${t.history.waiverStartsHere()}_` : ` ${t.history.waiverStartsHere()}`);
3550
+ for (const habit of waiverReport.habits) {
3551
+ out.push('');
3552
+ const head = t.history.waiverHabit(
3553
+ habit.gate,
3554
+ habit.uses,
3555
+ habit.days,
3556
+ habit.firstDay,
3557
+ habit.lastDay,
3558
+ );
3559
+ out.push(md ? `- **${head}**` : ` ${head}`);
3560
+ const rows = [t.history.waiverVerdict(habit.verdict)];
3561
+ // The reason as it stands *now* — never read backwards onto an
3562
+ // older use, which is the same mistake the record exists to avoid.
3563
+ const latest = habit.reasons[habit.reasons.length - 1];
3564
+ if (latest !== undefined) rows.push(t.history.waiverReasonNow(latest));
3565
+ if (habit.reasons.length > 1) rows.push(t.history.waiverReasonsChanged(habit.reasons.length));
3566
+ const firstExpiry = habit.expiries[0];
3567
+ const lastExpiry = habit.expiries[habit.expiries.length - 1];
3568
+ if (habit.expiries.length > 1 && firstExpiry !== undefined && lastExpiry !== undefined) {
3569
+ rows.push(t.history.waiverExpiriesMoved(firstExpiry, lastExpiry, habit.expiries.length - 1));
3570
+ }
3571
+ if (!habit.stillConfigured) rows.push(t.history.waiverNoLongerConfigured());
3572
+ for (const row of rows) out.push(md ? ` - ${row}` : ` ${row}`);
3573
+ }
3574
+ }
3575
+ if (waiverReport.neverUsed.length > 0) {
3576
+ out.push('');
3577
+ const dead = t.history.waiverNeverUsed(waiverReport.neverUsed.join(', '));
3578
+ out.push(md ? `- ${dead}` : ` ${dead}`);
3579
+ }
3580
+ if (waivers.unreadable.length > 0) {
3581
+ const bad = t.history.waiverUnreadable(waivers.unreadable.length, WAIVER_LOG);
3582
+ out.push(md ? `- ${bad}` : ` ${bad}`);
3583
+ }
3584
+ }
3585
+
3509
3586
  if (fromStore) {
3510
3587
  out.push('');
3511
3588
  const note = t.history.storeNoLabels();
@@ -4248,7 +4325,16 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4248
4325
  * way, because a waived failure that vanished from the output would be a
4249
4326
  * finding deleted with extra steps.
4250
4327
  */
4251
- const waived = (gate: string): boolean => {
4328
+ /**
4329
+ * Uses recorded this run, flushed after the gates have finished.
4330
+ *
4331
+ * Collected rather than written inline because `waived` is synchronous and
4332
+ * called from seven places inside the gate pass. Writing from each of them
4333
+ * would mean seven awaits threaded through the exit-code logic — the one
4334
+ * part of this command where a mistake turns a red build green.
4335
+ */
4336
+ const waiverUses: WaiverUse[] = [];
4337
+ const waived = (gate: string, measuredUsd: number | null = null, limitUsd: number | null = null): boolean => {
4252
4338
  const found = waiverFor(gate);
4253
4339
  if (found === null) return false;
4254
4340
  if (found.expired) {
@@ -4264,6 +4350,29 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4264
4350
  console.error(
4265
4351
  c.yellow(t.profile.waiveActive(gate, found.entry.reason, found.entry.until, String(daysLeft))),
4266
4352
  );
4353
+ /**
4354
+ * Recorded **when it silences something**, never when it is configured.
4355
+ *
4356
+ * A waiver nobody's build has ever hit is not a habit — it is dead config
4357
+ * — and the history reports the two apart. This is also the only honest
4358
+ * way to build the record 1.40 refused to invent: it starts today and
4359
+ * says so, rather than reconstructing a past from the present.
4360
+ *
4361
+ * The reason and the expiry are taken from the config **as it stands at
4362
+ * this moment**, because that is the decision that was actually in force.
4363
+ * Reading today's reason back onto last quarter's use is the same mistake
4364
+ * one layer down.
4365
+ */
4366
+ waiverUses.push({
4367
+ schemaVersion: 1,
4368
+ day: waiverDay(new Date()),
4369
+ gate,
4370
+ reason: found.entry.reason,
4371
+ until: found.entry.until,
4372
+ commit: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? null,
4373
+ measuredUsd,
4374
+ limitUsd,
4375
+ });
4267
4376
  return true;
4268
4377
  };
4269
4378
  const applyGates = (): void => {
@@ -4312,7 +4421,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4312
4421
  }
4313
4422
  if (usd > limit) {
4314
4423
  console.error(c.red(t.profile.labelBudgetFailed(label, formatUsd(usd), formatUsd(limit))));
4315
- if (!waived(`byLabel:${label}`)) process.exitCode = 1;
4424
+ if (!waived(`byLabel:${label}`, usd, limit)) process.exitCode = 1;
4316
4425
  } else {
4317
4426
  console.error(c.dim(t.profile.labelBudgetOk(label, formatUsd(usd), formatUsd(limit))));
4318
4427
  }
@@ -4385,7 +4494,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4385
4494
  * copy says so.
4386
4495
  */
4387
4496
  explainFailure(report.total.totalUsd - maxUsd);
4388
- if (!waived('maxUsd')) process.exitCode = 1;
4497
+ if (!waived('maxUsd', report.total.totalUsd, maxUsd)) process.exitCode = 1;
4389
4498
  } else {
4390
4499
  console.error(c.dim(t.profile.maxUsdOk(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
4391
4500
  explainMargin(report.total.totalUsd, maxUsd);
@@ -4425,7 +4534,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4425
4534
  process.exitCode = 1;
4426
4535
  } else if (againstDelta > maxGrowth) {
4427
4536
  console.error(c.red(t.profile.maxGrowthUsdFailed(formatSignedUsd(againstDelta), formatUsd(maxGrowth))));
4428
- if (!waived('maxGrowthUsd')) process.exitCode = 1;
4537
+ if (!waived('maxGrowthUsd', againstDelta, maxGrowth)) process.exitCode = 1;
4429
4538
  }
4430
4539
  }
4431
4540
  /**
@@ -4443,7 +4552,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4443
4552
  console.error(
4444
4553
  c.red(t.profile.maxCacheLossFailed(formatUsd(gateCache.deltaUsd), formatUsd(maxLoss))),
4445
4554
  );
4446
- if (!waived('maxCacheLossUsd')) process.exitCode = 1;
4555
+ if (!waived('maxCacheLossUsd', gateCache.deltaUsd, maxLoss)) process.exitCode = 1;
4447
4556
  } else if (gateCache.worstCaseDeltaUsd > maxLoss) {
4448
4557
  console.error(
4449
4558
  c.red(
@@ -4454,7 +4563,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4454
4563
  ),
4455
4564
  ),
4456
4565
  );
4457
- if (!waived('maxCacheLossUsd')) process.exitCode = 1;
4566
+ if (!waived('maxCacheLossUsd', gateCache.worstCaseDeltaUsd, maxLoss)) process.exitCode = 1;
4458
4567
  } else {
4459
4568
  console.error(
4460
4569
  c.dim(t.profile.maxCacheLossOk(formatUsd(Math.max(0, gateCache.worstCaseDeltaUsd)), formatUsd(maxLoss))),
@@ -4503,7 +4612,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4503
4612
  c.red(`${t.profile.maxDayFailed(worst.day, formatUsd(worst.usd), formatUsd(maxDay))}${suspect}`),
4504
4613
  );
4505
4614
  explainFailure(worst.usd - maxDay, { namesLargest: true });
4506
- if (!waived('maxDayUsd')) process.exitCode = 1;
4615
+ if (!waived('maxDayUsd', worst.usd, maxDay)) process.exitCode = 1;
4507
4616
  } else {
4508
4617
  console.error(c.dim(t.profile.maxDayOk(worst.day, formatUsd(worst.usd), formatUsd(maxDay))));
4509
4618
  explainMargin(worst.usd, maxDay);
@@ -4545,7 +4654,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4545
4654
  c.red(t.profile.maxSessionFailed(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
4546
4655
  );
4547
4656
  explainFailure(report.sessionSpend.maxUsd - maxSession);
4548
- if (!waived('maxSessionUsd')) process.exitCode = 1;
4657
+ if (!waived('maxSessionUsd', report.sessionSpend.maxUsd, maxSession)) process.exitCode = 1;
4549
4658
  } else {
4550
4659
  console.error(
4551
4660
  c.dim(t.profile.maxSessionOk(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
@@ -4579,6 +4688,26 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4579
4688
  }
4580
4689
  };
4581
4690
 
4691
+ /**
4692
+ * Writes down every waiver that silenced something this run.
4693
+ *
4694
+ * **A failure here never fails the build.** The gate's job is the exit code;
4695
+ * a read-only checkout or a full disk must not turn a passing build red on
4696
+ * account of bookkeeping. The problem is reported and the gate's own verdict
4697
+ * stands — which is also why this runs after `recordGates` rather than
4698
+ * inside it: nothing about the exit code depends on the write.
4699
+ */
4700
+ const recordWaiverUses = async (): Promise<void> => {
4701
+ if (waiverUses.length === 0) return;
4702
+ for (const use of waiverUses) {
4703
+ const failed = await appendWaiverUse('.', use);
4704
+ if (failed !== null) {
4705
+ console.error(c.dim(t.profile.waiveNotRecorded(WAIVER_LOG, failed)));
4706
+ return;
4707
+ }
4708
+ }
4709
+ };
4710
+
4582
4711
  /**
4583
4712
  * The side files the caller asked for. Written on **both** output paths:
4584
4713
  * under --json the human rendering returns early, and the first version of
@@ -4745,6 +4874,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4745
4874
  ),
4746
4875
  );
4747
4876
  recordGates();
4877
+ await recordWaiverUses();
4748
4878
  await writeSideFiles();
4749
4879
  return;
4750
4880
  }
@@ -5899,6 +6029,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
5899
6029
  reportProfileGaps(report, t, n, pricingStale);
5900
6030
 
5901
6031
  recordGates();
6032
+ await recordWaiverUses();
5902
6033
 
5903
6034
  await writeSideFiles();
5904
6035
  }
@@ -7612,7 +7743,7 @@ async function main(): Promise<void> {
7612
7743
  await commandVerify(args, pricing, t);
7613
7744
  break;
7614
7745
  case 'history':
7615
- await commandHistory(args, pricing, t);
7746
+ await commandHistory(args, config, pricing, t);
7616
7747
  break;
7617
7748
  case 'connect':
7618
7749
  await commandConnect(args, pricing, t);
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Where a waiver's uses are written down.
3
+ *
4
+ * The core decides what a use means and what a run of them adds up to; this
5
+ * decides where the bytes go — the same split every module here follows, so
6
+ * `@trazum/core` stays browser-safe and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, and never rewritten.** There is deliberately no prune, no
9
+ * compaction and no `--clear`: a record of decisions that the tool can erase
10
+ * is a record nobody can rely on, and the one thing a waiver history is for is
11
+ * being awkward six months later. Deleting the file is a thing a person does
12
+ * with `rm`, on purpose, having seen it.
13
+ *
14
+ * **A write that fails never fails the run.** The gate's job is the exit code.
15
+ * A read-only checkout, a full disk or a directory somebody's CI cannot create
16
+ * must not turn a passing build red on account of bookkeeping — the failure is
17
+ * reported and the gate's own verdict stands.
18
+ *
19
+ * **A line that will not parse is counted and skipped**, exactly as in the
20
+ * store. Losing the whole history because one line is broken would be the
21
+ * worst possible response; pretending the history is complete would be the
22
+ * second worst.
23
+ */
24
+
25
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
26
+ import { join } from 'node:path';
27
+ import { isWaiverUse } from '@trazum/core';
28
+ import type { WaiverUse } from '@trazum/core';
29
+
30
+ /** One file, not one per month: a waiver history is small and read whole. */
31
+ export const WAIVER_LOG = '.trazum/waivers.jsonl';
32
+
33
+ export interface WaiverReadResult {
34
+ uses: WaiverUse[];
35
+ /** 1-based positions of lines that would not parse. Named, never dropped quietly. */
36
+ unreadable: number[];
37
+ /** False when the file does not exist — "nothing recorded" is not "no file". */
38
+ present: boolean;
39
+ }
40
+
41
+ export async function readWaiverLog(root: string): Promise<WaiverReadResult> {
42
+ let raw: string;
43
+ try {
44
+ raw = await readFile(join(root, WAIVER_LOG), 'utf8');
45
+ } catch {
46
+ // Absent is the normal state of a repository that has never waived
47
+ // anything, and it is not an error.
48
+ return { uses: [], unreadable: [], present: false };
49
+ }
50
+
51
+ const uses: WaiverUse[] = [];
52
+ const unreadable: number[] = [];
53
+ raw.split('\n').forEach((line, index) => {
54
+ if (line.trim() === '') return;
55
+ try {
56
+ const parsed: unknown = JSON.parse(line);
57
+ if (isWaiverUse(parsed)) uses.push(parsed);
58
+ else unreadable.push(index + 1);
59
+ } catch {
60
+ unreadable.push(index + 1);
61
+ }
62
+ });
63
+ return { uses, unreadable, present: true };
64
+ }
65
+
66
+ /**
67
+ * Appends one use, and swallows any failure after reporting it.
68
+ *
69
+ * Returns the error message rather than throwing, so the caller can print it
70
+ * beside the gate's own output without the gate ever depending on the write.
71
+ */
72
+ export async function appendWaiverUse(root: string, use: WaiverUse): Promise<string | null> {
73
+ const path = join(root, WAIVER_LOG);
74
+ try {
75
+ await mkdir(join(path, '..'), { recursive: true });
76
+ await writeFile(path, `${JSON.stringify(use)}\n`, { flag: 'a', mode: 0o600 });
77
+ return null;
78
+ } catch (error) {
79
+ return error instanceof Error ? error.message : String(error);
80
+ }
81
+ }