staysfixed 0.3.1 → 0.4.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.
Files changed (46) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
@@ -0,0 +1,511 @@
1
+ /**
2
+ * The four gates, machine-checked.
3
+ *
4
+ * An agent cannot write a reference. Only shipping does that, and only Asad ships. What an agent
5
+ * CAN write is a waiver: a record saying "this difference is one I meant to cause". A waiver is
6
+ * provisional — it silences one exact difference until the reference moves, and then it dies. It
7
+ * never makes anything the new normal.
8
+ *
9
+ * Four things are checked before one is written, in this order, and each refusal says which gate
10
+ * stopped it in a sentence a person could read over the agent's shoulder:
11
+ *
12
+ * 1. SEALED Money, signing in, losing data, a crash, or anything touching a named guard.
13
+ * Unwaivable. No reason is read, because a reason is exactly what an agent
14
+ * under pressure produces best.
15
+ * 2. INTENT There has to be an intent, sealed BEFORE the check ran, still under the
16
+ * reference in force. An explanation written after seeing the damage proves
17
+ * nothing; one written before is falsifiable.
18
+ * 3. COVERAGE The difference has to fall inside what that intent declared. A claim about
19
+ * something the agent never said it was touching is not a claim about a side
20
+ * effect, it is a rationalisation.
21
+ * 4. BUDGET Five between one ship and the next. Past five it is not a change with side
22
+ * effects, it is a rewrite, and a person looks at a rewrite.
23
+ *
24
+ * WHY A REFUSAL IS A RETURN VALUE AND NOT AN EXCEPTION. Being told no is a normal, expected
25
+ * answer here — it is the tool working. It comes back as data with the gate named, so the caller
26
+ * can hand the agent the sentence unchanged and the summary can count refusals as easily as it
27
+ * counts waivers.
28
+ *
29
+ * WHY THE BUDGET IS COUNTED AGAINST THE REFERENCE AND NOT THE INTENT. Otherwise an agent that
30
+ * has spent its five buys five more by sealing a fresh intent and calling the same work a
31
+ * different change. Between one ship and the next, all of it is one change.
32
+ *
33
+ * WHY A WAIVER IS PINNED TO THE VALUES AND NOT THE ADDRESS. "I meant the total to read 9.99
34
+ * instead of 10.00" must not go on quietly covering the same total the day it becomes 0. The
35
+ * fingerprint takes in every value that differs, so a DIFFERENT break at the same address is a
36
+ * different difference and is reported.
37
+ */
38
+
39
+ import fsp from 'node:fs/promises';
40
+ import path from 'node:path';
41
+ import crypto from 'node:crypto';
42
+
43
+ import { safeName } from '../core/paths.js';
44
+ import { StaysFixedError } from '../core/errors.js';
45
+ import { classify, sayRefusal } from './sealed.js';
46
+ import {
47
+ intentCovers,
48
+ readIntent,
49
+ readIntentById,
50
+ referenceStamp,
51
+ readJsonFile,
52
+ writeJsonAtomic,
53
+ shortDigest,
54
+ } from './intent.js';
55
+
56
+ /**
57
+ * @typedef {import('./types.js').Store} Store
58
+ * @typedef {import('./types.js').Finding} Finding
59
+ * @typedef {import('./types.js').FindingClass} FindingClass
60
+ * @typedef {import('./intent.js').Intent} Intent
61
+ * @typedef {import('./intent.js').IntentCoverage} IntentCoverage
62
+ * @typedef {import('./sealed.js').SealedVerdict} SealedVerdict
63
+ */
64
+
65
+ /**
66
+ * Five waivers between one ship and the next.
67
+ *
68
+ * The number is deliberately small enough to be annoying. An agent that needs a sixth has not
69
+ * had an unlucky day; it has misunderstood its task, and the right thing to happen next is that
70
+ * a person reads what it did.
71
+ */
72
+ export const WAIVER_BUDGET = 5;
73
+
74
+ /** How many differences of one finding go into its fingerprint. Clusters can hold hundreds. */
75
+ const FINGERPRINT_DIFFERENCES = 40;
76
+
77
+ /** How many dead waivers are kept per product, so a summary can still say what expired. */
78
+ const KEEP_EXPIRED = 50;
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // The shapes
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * A recorded "I meant to do that".
86
+ *
87
+ * Every field here answers a question somebody will ask later: who, when, why, against which
88
+ * intent, and under which reference. A waiver nobody can interrogate is a rubber stamp with
89
+ * extra steps.
90
+ *
91
+ * @typedef {object} Waiver
92
+ * @property {string} id
93
+ * @property {string} product
94
+ * @property {string} fingerprint Pins the exact difference, values included.
95
+ * @property {string} finding The finding's own id, when it had one.
96
+ * @property {string} summary The finding's title, kept so this reads without a check.
97
+ * @property {string[]} paths The addresses involved, trimmed.
98
+ * @property {FindingClass} class What the engine called it. Always ordinary — see gate 1.
99
+ * @property {string} why The agent's reason, in its own words.
100
+ * @property {string} intentId
101
+ * @property {string} intentSummary Copied, so a pruned intent does not orphan the waiver.
102
+ * @property {string} ordering What was known about when the intent was sealed.
103
+ * @property {IntentCoverage} coverage How well it matched what was declared, and how sure.
104
+ * @property {string} at ISO. Written here, never supplied.
105
+ * @property {string} [by]
106
+ * @property {string} reference The stamp it was written under. It dies when this moves.
107
+ * @property {string} [retiredAt] Set when something retired it early — a build shipped, or a
108
+ * person struck it out. A retired waiver is dead whatever its
109
+ * reference says. Kept rather than deleted, because "three
110
+ * waivers expired when you shipped" is a sentence somebody may
111
+ * reasonably want to check.
112
+ * @property {string} [retiredBy]
113
+ * @property {string} [retiredWhy]
114
+ */
115
+
116
+ /**
117
+ * @typedef {object} WaiverGranted
118
+ * @property {true} ok
119
+ * @property {Waiver} waiver
120
+ * @property {boolean} already True when this exact difference was already waived, which
121
+ * costs no further slot.
122
+ * @property {number} spent
123
+ * @property {number} left
124
+ * @property {number} budget
125
+ * @property {string} say The whole answer, in plain English, ready to hand back.
126
+ */
127
+
128
+ /**
129
+ * @typedef {object} WaiverRefused
130
+ * @property {false} ok
131
+ * @property {'sealed'|'intent'|'coverage'|'budget'|'incomplete'} gate
132
+ * Which gate stopped it. `incomplete` is not one of the four — it means the call itself was
133
+ * missing something the gates need, and it says what.
134
+ * @property {string} say
135
+ * @property {SealedVerdict} [sealed]
136
+ * @property {IntentCoverage} [coverage]
137
+ * @property {number} [spent]
138
+ * @property {number} [budget]
139
+ */
140
+
141
+ /** @typedef {WaiverGranted|WaiverRefused} WaiverDecision */
142
+
143
+ /**
144
+ * What the caller has to say about the check this difference came out of.
145
+ *
146
+ * `at` is what makes gate 2 real. Without it there is no way to tell whether the intent was
147
+ * sealed before or after the agent saw what broke, and that ordering is the whole point of
148
+ * sealing one — so a call that leaves it out is refused rather than waved through on trust.
149
+ *
150
+ * @typedef {object} CheckStamp
151
+ * @property {string} at ISO. When the check that produced this finding ran.
152
+ * @property {string} [runId]
153
+ */
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Writing one
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Try to record a difference as intended.
161
+ *
162
+ * @param {Store} store
163
+ * @param {{product: string, finding: Finding, why: string, intentId?: string, check?: CheckStamp, guards?: string[], by?: string}} what
164
+ * @returns {Promise<WaiverDecision>}
165
+ */
166
+ export async function waive(store, what) {
167
+ const product = typeof what?.product === 'string' ? what.product.trim() : '';
168
+ const finding = what?.finding;
169
+ const why = typeof what?.why === 'string' ? what.why.trim() : '';
170
+ if (!product) throw new StaysFixedError('A waiver has to say which product it is about.');
171
+ if (!finding || typeof finding !== 'object') throw new StaysFixedError('A waiver has to be about a finding the tool reported.');
172
+
173
+ if (why === '') {
174
+ return refuse(
175
+ 'incomplete',
176
+ 'Say why this difference is what you meant, in one plain sentence. A waiver with no reason is worth nothing to whoever reads it later, and somebody will read it.'
177
+ );
178
+ }
179
+
180
+ // ---- GATE 1. The sealed classes. Nothing gets through this, ever, and it is checked before
181
+ // anything else so that no amount of good paperwork can get a look-in first.
182
+ const sealed = classify(finding, { guards: what.guards ?? [] });
183
+ if (sealed) {
184
+ return { ok: false, gate: 'sealed', say: sayRefusal(sealed, finding), sealed };
185
+ }
186
+
187
+ const fingerprint = fingerprintFinding(finding);
188
+ const stamp = await referenceStamp(store, product);
189
+ const waivers = await allWaivers(store, product);
190
+
191
+ // Already recorded. The gates were all passed the day it was written, the reference has not
192
+ // moved since, and re-affirming it must not cost another slot — an agent that runs a check
193
+ // twice would otherwise spend its budget on the same difference.
194
+ const standing = waivers.find((w) => w.fingerprint === fingerprint && isLive(w, stamp));
195
+ if (standing) {
196
+ const spent = waivers.filter((w) => isLive(w, stamp)).length;
197
+ return {
198
+ ok: true,
199
+ waiver: standing,
200
+ already: true,
201
+ spent,
202
+ left: Math.max(0, WAIVER_BUDGET - spent),
203
+ budget: WAIVER_BUDGET,
204
+ say: `Already recorded as intended, and it did not cost you another waiver.\n ${trim(finding.title ?? '', 200)}\n Your reason on the day: ${standing.why}`,
205
+ };
206
+ }
207
+
208
+ // ---- GATE 2. There has to be an intent, it has to predate the check, and it has to belong to
209
+ // the world as it is now.
210
+ const intent = what.intentId ? await readIntentById(store, product, what.intentId) : await readIntent(store, product);
211
+ if (!intent) {
212
+ return refuse(
213
+ 'intent',
214
+ what.intentId
215
+ ? `Refused. There is no sealed intent called "${what.intentId}" for ${product}. You can only waive against an intent this tool actually holds.`
216
+ : 'Refused. You did not seal an intent before this run, so there is nothing to check your claim against. Seal one that names what you are changing, run the check again, and waive from that. Sealing one now, after seeing what broke, would prove nothing.'
217
+ );
218
+ }
219
+ if (intent.product !== product) {
220
+ return refuse('intent', `Refused. That intent was sealed for ${intent.product}, and this difference is in ${product}. An intent covers one product.`);
221
+ }
222
+
223
+ const check = what.check;
224
+ if (!check || typeof check.at !== 'string' || Number.isNaN(Date.parse(check.at))) {
225
+ return refuse(
226
+ 'incomplete',
227
+ 'Refused. Nothing here says when the check ran, so there is no way to tell whether you sealed your intent before or after you saw what broke, and that ordering is the whole point of sealing one. Pass the time of the check that produced this finding.'
228
+ );
229
+ }
230
+ if (Date.parse(intent.sealedAt) > Date.parse(check.at)) {
231
+ return refuse(
232
+ 'intent',
233
+ [
234
+ `Refused. That intent (${intent.id}) was sealed AFTER the check ran.`,
235
+ 'An intent only means something when it is written before you see what broke. Run the check again so the claim is tested against an intent that already existed, and waive from that run.',
236
+ ].join('\n')
237
+ );
238
+ }
239
+ if (intent.reference !== stamp) {
240
+ return refuse(
241
+ 'intent',
242
+ [
243
+ 'Refused. That intent was sealed against a different reference, and the reference has moved since: a build shipped.',
244
+ 'What counts as working is now something else, so an intent written about the old one cannot cover anything. Seal a fresh intent, run the check again, and waive from there.',
245
+ ].join('\n')
246
+ );
247
+ }
248
+
249
+ // ---- GATE 3. It has to fall inside what was declared. This is the substance of the whole
250
+ // system: the gate exists to stop a difference FAR from the declared work being waved through,
251
+ // and a weak match is exactly the shape a rationalisation takes.
252
+ const coverage = intentCovers(intent, finding);
253
+ if (!coverage.covers) {
254
+ return {
255
+ ok: false,
256
+ gate: 'coverage',
257
+ coverage,
258
+ say: [
259
+ 'Refused. This is outside what you sealed.',
260
+ ` ${trim(finding.title ?? '', 200)}`,
261
+ '',
262
+ `You said you were changing: ${intent.summary}`,
263
+ `You said it would affect: ${intent.files.join(', ')}.`,
264
+ `Why this does not match: ${coverage.why}`,
265
+ '',
266
+ 'A difference outside what you declared is the definition of a side effect, which is the exact thing you are not allowed to wave through. If you genuinely meant to change this too, that is a different change: seal a new intent that names it, run the check again, and waive from there.',
267
+ ].join('\n'),
268
+ };
269
+ }
270
+
271
+ // ---- GATE 4. Five between one ship and the next.
272
+ const spent = waivers.filter((w) => isLive(w, stamp)).length;
273
+ if (spent >= WAIVER_BUDGET) {
274
+ return {
275
+ ok: false,
276
+ gate: 'budget',
277
+ spent,
278
+ budget: WAIVER_BUDGET,
279
+ say: [
280
+ `Refused. That would be waiver number ${spent + 1} since the last build shipped, and the limit is ${WAIVER_BUDGET}.`,
281
+ '',
282
+ 'Past five, this is not a change with side effects, it is a rewrite, and a person looks at a rewrite. Sealing another intent will not give you more. Stop waiving, fix what you can, and report the rest plainly.',
283
+ ].join('\n'),
284
+ };
285
+ }
286
+
287
+ /** @type {Waiver} */
288
+ const waiver = {
289
+ id: `waiver-${crypto.randomBytes(5).toString('hex')}`,
290
+ product,
291
+ fingerprint,
292
+ finding: typeof finding.id === 'string' ? finding.id : '',
293
+ summary: trim(finding.title ?? '(a difference with no title)', 200),
294
+ paths: (finding.paths ?? (finding.differences ?? []).map((d) => d.path)).slice(0, 8),
295
+ class: finding.class ?? 'ordinary',
296
+ why,
297
+ intentId: intent.id,
298
+ intentSummary: intent.summary,
299
+ ordering: intent.ordering,
300
+ coverage,
301
+ at: new Date().toISOString(),
302
+ reference: stamp,
303
+ };
304
+ if (what.by) waiver.by = what.by;
305
+
306
+ waivers.push(waiver);
307
+ await writeJsonAtomic(waiversFile(store, product), prune(waivers, stamp));
308
+
309
+ const left = WAIVER_BUDGET - (spent + 1);
310
+ return {
311
+ ok: true,
312
+ waiver,
313
+ already: false,
314
+ spent: spent + 1,
315
+ left,
316
+ budget: WAIVER_BUDGET,
317
+ say: [
318
+ `Recorded as intended: ${waiver.summary}`,
319
+ `Your reason, kept: ${why}`,
320
+ `Matched against what you sealed: ${coverage.why} (${coverage.confidence} match)`,
321
+ '',
322
+ `${left} of your ${WAIVER_BUDGET} waivers left before the next ship. This one is pinned to the exact values that differ and to the reference in force now: if either moves, it stops covering anything.`,
323
+ 'This is not approval. Nothing becomes the new normal until a build ships. Say in what you report back that you waived this, and why.',
324
+ ].join('\n'),
325
+ };
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Reading them back
330
+ // ---------------------------------------------------------------------------
331
+
332
+ /**
333
+ * The waivers that still apply: written under the reference that is in force now.
334
+ *
335
+ * @param {Store} store
336
+ * @param {string} product
337
+ * @returns {Promise<Waiver[]>}
338
+ */
339
+ export async function activeWaivers(store, product) {
340
+ const stamp = await referenceStamp(store, product);
341
+ return (await allWaivers(store, product)).filter((w) => isLive(w, stamp));
342
+ }
343
+
344
+ /**
345
+ * Every waiver kept for a product, live and dead, oldest first.
346
+ *
347
+ * @param {Store} store
348
+ * @param {string} product
349
+ * @returns {Promise<Waiver[]>}
350
+ */
351
+ export async function allWaivers(store, product) {
352
+ const raw = await readJsonFile(waiversFile(store, product), []);
353
+ if (!Array.isArray(raw)) return [];
354
+ return raw.filter((w) => w && typeof w === 'object' && typeof w.fingerprint === 'string' && typeof w.id === 'string');
355
+ }
356
+
357
+ /**
358
+ * Which waiver, if any, covers this exact difference.
359
+ *
360
+ * Pure and synchronous on purpose: a check has hundreds of findings and one list of waivers, so
361
+ * the list is read from disk once by `activeWaivers` and matched against every finding here
362
+ * without touching the disk again.
363
+ *
364
+ * @param {Waiver[]} waivers From `activeWaivers`. Passing every waiver ever written would let
365
+ * an expired one go on covering something, so it takes the live list.
366
+ * @param {Finding} finding
367
+ * @returns {Waiver|null}
368
+ */
369
+ export function waiverFor(waivers, finding) {
370
+ const fingerprint = fingerprintFinding(finding);
371
+ return waivers.find((w) => w.fingerprint === fingerprint) ?? null;
372
+ }
373
+
374
+ /**
375
+ * What the closing summary needs: how many were waived, how many are left, what expired, and one
376
+ * sentence saying so.
377
+ *
378
+ * Waivers must be visible, not quiet. This is the function that makes them so, and a summary
379
+ * that does not use it is hiding something an agent decided on its own.
380
+ *
381
+ * @param {Store} store
382
+ * @param {string} product
383
+ * @returns {Promise<{budget: number, spent: number, left: number, active: Waiver[], expired: number, reference: string, line: string}>}
384
+ */
385
+ export async function countWaivers(store, product) {
386
+ const stamp = await referenceStamp(store, product);
387
+ const all = await allWaivers(store, product);
388
+ const active = all.filter((w) => isLive(w, stamp));
389
+ const expired = all.length - active.length;
390
+ const left = Math.max(0, WAIVER_BUDGET - active.length);
391
+
392
+ const line =
393
+ active.length === 0
394
+ ? `Nothing was waived${expired > 0 ? `, and ${expired} older waiver${expired === 1 ? '' : 's'} died when the reference last moved` : ''}.`
395
+ : `${active.length} difference${active.length === 1 ? ' was' : 's were'} recorded as intended, not approved: ${active
396
+ .map((w) => trim(w.summary, 90))
397
+ .join('; ')}. ${left} of the ${WAIVER_BUDGET} allowed before a person has to look ${left === 1 ? 'is' : 'are'} left.`;
398
+
399
+ return { budget: WAIVER_BUDGET, spent: active.length, left, active, expired, reference: stamp, line };
400
+ }
401
+
402
+ /**
403
+ * Forget a product's waivers. Housekeeping, and the way a test starts clean.
404
+ *
405
+ * @param {Store} store
406
+ * @param {string} product
407
+ * @returns {Promise<void>}
408
+ */
409
+ export async function forgetWaivers(store, product) {
410
+ await fsp.rm(waiversFile(store, product), { force: true });
411
+ }
412
+
413
+ /**
414
+ * What a waiver is pinned to.
415
+ *
416
+ * Every value that differs goes in, so the waiver covers this break and not the address. A
417
+ * cluster that grew a difference produces a different fingerprint and the waiver stops applying,
418
+ * which errs towards a person looking at something they have already seen rather than towards a
419
+ * new break hiding behind an old excuse. That is the right way round.
420
+ *
421
+ * @param {Finding} finding
422
+ * @returns {string}
423
+ */
424
+ export function fingerprintFinding(finding) {
425
+ const differences = (finding.differences ?? [])
426
+ .slice(0, FINGERPRINT_DIFFERENCES)
427
+ .map((d) => [d.path, d.kind, face(d.reference), face(d.candidate)]);
428
+ // A finding with no differences attached, which some callers pass, still has to be pinnable,
429
+ // so the sample and the paths stand in for them.
430
+ const fallback =
431
+ differences.length > 0
432
+ ? []
433
+ : [finding.sample?.path ?? '', finding.sample?.kind ?? '', face(finding.sample?.reference), face(finding.sample?.candidate)];
434
+ return shortDigest([finding.title ?? '', [...(finding.paths ?? [])].sort(), differences, fallback]);
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Small things
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /**
442
+ * @param {Store} store
443
+ * @param {string} product
444
+ * @returns {string}
445
+ */
446
+ export function waiversFile(store, product) {
447
+ return path.join(store.dir, 'waivers', `${safeName(product)}.json`);
448
+ }
449
+
450
+ /**
451
+ * Keep every live waiver and the most recent dead ones. The dead ones are worth something, since
452
+ * "this was waived once and then the reference moved" is a real sentence in a summary, but they
453
+ * are not worth keeping forever.
454
+ *
455
+ * @param {Waiver[]} waivers
456
+ * @param {string} stamp
457
+ * @returns {Waiver[]}
458
+ */
459
+ function prune(waivers, stamp) {
460
+ const live = waivers.filter((w) => isLive(w, stamp));
461
+ const dead = waivers.filter((w) => !isLive(w, stamp)).slice(-KEEP_EXPIRED);
462
+ return [...dead, ...live];
463
+ }
464
+
465
+ /**
466
+ * Does this waiver still cover anything?
467
+ *
468
+ * Two conditions, and both are needed. A waiver retired by hand or by a ship is dead whatever it
469
+ * says. A waiver written against a reference that has since moved is dead even if nothing got
470
+ * round to retiring it. Belt and braces, because the cost of getting this wrong is a regression
471
+ * nobody is ever shown.
472
+ *
473
+ * @param {Waiver} waiver
474
+ * @param {string} stamp
475
+ * @returns {boolean}
476
+ */
477
+ function isLive(waiver, stamp) {
478
+ return !waiver.retiredAt && waiver.reference === stamp;
479
+ }
480
+
481
+ /**
482
+ * @param {'sealed'|'intent'|'coverage'|'budget'|'incomplete'} gate
483
+ * @param {string} say
484
+ * @returns {WaiverRefused}
485
+ */
486
+ function refuse(gate, say) {
487
+ return { ok: false, gate, say };
488
+ }
489
+
490
+ /**
491
+ * @param {unknown} value
492
+ * @returns {string}
493
+ */
494
+ function face(value) {
495
+ if (value === undefined) return '(absent)';
496
+ try {
497
+ return JSON.stringify(value) ?? 'null';
498
+ } catch {
499
+ return String(value);
500
+ }
501
+ }
502
+
503
+ /**
504
+ * @param {string} text
505
+ * @param {number} max
506
+ * @returns {string}
507
+ */
508
+ function trim(text, max) {
509
+ const one = String(text).replace(/\s+/g, ' ').trim();
510
+ return one.length > max ? `${one.slice(0, max - 1)}…` : one;
511
+ }