staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
package/src/v2/types.js CHANGED
@@ -152,7 +152,6 @@
152
152
  * @property {string|null} [branch]
153
153
  * @property {boolean} [dirty] Working tree had uncommitted changes.
154
154
  * @property {string} [artifact] Path to the built thing. NOT stored here — see store.js.
155
- * @property {string} [artifactSha256]
156
155
  * @property {string} [builtAt] ISO timestamp.
157
156
  * @property {string} [platform] e.g. 'darwin-arm64'. Comparing across platforms warns.
158
157
  * @property {string} [tool] Stays Fixed version that captured it.
@@ -183,7 +182,12 @@
183
182
  * @property {Coverage} [coverage] What this capture did NOT manage to look at.
184
183
  * @property {boolean} [complete] False when the file was read back torn — see store.js.
185
184
  * @property {string} [note]
186
- * @property {string} [rules] Id of the normalisation rule set applied, if any.
185
+ * @property {string} [rules] Fingerprint of what the normalisation rules DO, if any
186
+ * were applied. Scope is stamped separately — see rulesScope.
187
+ * @property {Record<string, string[]>} [rulesScope]
188
+ * Where each scoped rule applied, by rule id. Absent on
189
+ * captures written before this was stamped, which is a real
190
+ * state and says "cannot be compared" rather than "nothing".
187
191
  */
188
192
 
189
193
  // ---------------------------------------------------------------------------
@@ -438,6 +442,12 @@ export {};
438
442
  * '$.items.3.name'. Used by sort, round and drop.
439
443
  * @property {boolean} [off] Shipped, documented, and not switched on.
440
444
  * @property {string} [whyOff] Why it is not on by default.
445
+ * @property {boolean} [machine] This rule's pattern is a fact about THIS machine — where
446
+ * the project is checked out, where home is, where the temp
447
+ * folder went today — rather than a decision about what to
448
+ * tidy. Two machines running the same rule write the same
449
+ * placeholder, so the rule is the same rule and its pattern
450
+ * must not reach the fingerprint. See rulesFingerprint.
441
451
  */
442
452
 
443
453
  /**
package/src/v2/waiver.js CHANGED
@@ -36,7 +36,6 @@
36
36
  * different difference and is reported.
37
37
  */
38
38
 
39
- import fsp from 'node:fs/promises';
40
39
  import path from 'node:path';
41
40
  import crypto from 'node:crypto';
42
41
 
@@ -48,9 +47,10 @@ import {
48
47
  readIntent,
49
48
  readIntentById,
50
49
  referenceStamp,
50
+ fingerprintTree,
51
+ treeMovedSince,
51
52
  readJsonFile,
52
53
  writeJsonAtomic,
53
- shortDigest,
54
54
  } from './intent.js';
55
55
 
56
56
  /**
@@ -71,10 +71,15 @@ import {
71
71
  */
72
72
  export const WAIVER_BUDGET = 5;
73
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. */
74
+ /**
75
+ * How many dead waivers are kept per product, so a summary can still say what expired.
76
+ *
77
+ * LIVE ONES ARE NEVER PRUNED, which is what matters: the budget, the gates and whether a
78
+ * difference is covered are all worked out from those alone and none of them can be affected
79
+ * by this number. What it can affect is the sentence "three waivers expired when you
80
+ * shipped", which counts the dead ones still on disk — so after fifty of them, spread over
81
+ * ten ships or more, that number is the recent history rather than the whole of it.
82
+ */
78
83
  const KEEP_EXPIRED = 50;
79
84
 
80
85
  // ---------------------------------------------------------------------------
@@ -94,12 +99,22 @@ const KEEP_EXPIRED = 50;
94
99
  * @property {string} fingerprint Pins the exact difference, values included.
95
100
  * @property {string} finding The finding's own id, when it had one.
96
101
  * @property {string} summary The finding's title, kept so this reads without a check.
97
- * @property {string[]} paths The addresses involved, trimmed.
102
+ * @property {string[]} paths The first few addresses involved, for somebody reading
103
+ * this back later. Nothing is decided from them: what the
104
+ * waiver actually covers is `fingerprint`, which takes in
105
+ * every difference with no ceiling at all.
98
106
  * @property {FindingClass} class What the engine called it. Always ordinary — see gate 1.
99
107
  * @property {string} why The agent's reason, in its own words.
100
108
  * @property {string} intentId
101
109
  * @property {string} intentSummary Copied, so a pruned intent does not orphan the waiver.
102
110
  * @property {string} ordering What was known about when the intent was sealed.
111
+ * @property {{moved: boolean, knowable: boolean, say: string}} codeSince
112
+ * Whether the code moved between sealing that intent and
113
+ * writing this. Not a gate — a moved tree is what an
114
+ * intent sealed BEFORE the work is supposed to look like.
115
+ * It is recorded because the alternative is that nobody
116
+ * reading this waiver a month later can tell whether the
117
+ * intent describes the build that was actually checked.
103
118
  * @property {IntentCoverage} coverage How well it matched what was declared, and how sure.
104
119
  * @property {string} at ISO. Written here, never supplied.
105
120
  * @property {string} [by]
@@ -284,6 +299,12 @@ export async function waive(store, what) {
284
299
  };
285
300
  }
286
301
 
302
+ // The last of the three things intent.js says its tree fingerprint makes checkable. The
303
+ // other two are gates above; this one cannot be, because both answers are legitimate — an
304
+ // intent sealed before the work SHOULD see a moved tree, and one sealed after it should
305
+ // not. So it is written down rather than judged, and a person reading the waiver decides.
306
+ const codeSince = treeMovedSince(intent, await fingerprintTree(store.root));
307
+
287
308
  /** @type {Waiver} */
288
309
  const waiver = {
289
310
  id: `waiver-${crypto.randomBytes(5).toString('hex')}`,
@@ -297,6 +318,7 @@ export async function waive(store, what) {
297
318
  intentId: intent.id,
298
319
  intentSummary: intent.summary,
299
320
  ordering: intent.ordering,
321
+ codeSince,
300
322
  coverage,
301
323
  at: new Date().toISOString(),
302
324
  reference: stamp,
@@ -318,6 +340,7 @@ export async function waive(store, what) {
318
340
  `Recorded as intended: ${waiver.summary}`,
319
341
  `Your reason, kept: ${why}`,
320
342
  `Matched against what you sealed: ${coverage.why} (${coverage.confidence} match)`,
343
+ codeSince.say,
321
344
  '',
322
345
  `${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
346
  '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.',
@@ -371,45 +394,6 @@ export function waiverFor(waivers, finding) {
371
394
  return waivers.find((w) => w.fingerprint === fingerprint) ?? null;
372
395
  }
373
396
 
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
397
  /**
414
398
  * What a waiver is pinned to.
415
399
  *
@@ -418,20 +402,46 @@ export async function forgetWaivers(store, product) {
418
402
  * which errs towards a person looking at something they have already seen rather than towards a
419
403
  * new break hiding behind an old excuse. That is the right way round.
420
404
  *
405
+ * EVERY difference, and there is deliberately no ceiling on that. Until 2026-08-30 this took
406
+ * the first forty and stopped, and the sentence above was simply false: a waiver written about
407
+ * a three-hundred-address finding went on covering it after a value past the fortieth turned
408
+ * into something else. The addresses were all still there, the title still read the same, the
409
+ * cluster was still one finding — so the pin matched, the difference was filed as intended, and
410
+ * nobody was ever shown the one row that had actually broken. That is the whole failure this
411
+ * file exists to prevent, arriving through the file itself.
412
+ *
413
+ * The cost of having no ceiling is a hash over text the caller is already holding in memory,
414
+ * which is nothing next to being wrong. The tuples are sorted first so that two runs which
415
+ * found the same differences in a different order still pin to the same thing; the number of
416
+ * them goes in as well, so a cluster that merely GREW cannot match a waiver written about the
417
+ * smaller one.
418
+ *
421
419
  * @param {Finding} finding
422
420
  * @returns {string}
423
421
  */
424
422
  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)]);
423
+ const differences = finding.differences ?? [];
424
+ const hash = crypto.createHash('sha256');
425
+ /** @param {unknown} part */
426
+ const eat = (part) => {
427
+ // JSON escapes every newline inside a value, so a newline is a separator nothing in the
428
+ // text can forge — two different findings cannot run together into one identical digest.
429
+ hash.update(`${JSON.stringify(part) ?? 'null'}\n`);
430
+ };
431
+
432
+ eat(finding.title ?? '');
433
+ eat([...(finding.paths ?? [])].sort());
434
+ eat(differences.length);
435
+ const rows = differences.map((d) => JSON.stringify([d.path, d.kind, face(d.reference), face(d.candidate)]));
436
+ rows.sort();
437
+ for (const row of rows) hash.update(`${row}\n`);
438
+
428
439
  // A finding with no differences attached, which some callers pass, still has to be pinnable,
429
440
  // 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]);
441
+ if (differences.length === 0) {
442
+ eat([finding.sample?.path ?? '', finding.sample?.kind ?? '', face(finding.sample?.reference), face(finding.sample?.candidate)]);
443
+ }
444
+ return hash.digest('hex').slice(0, 16);
435
445
  }
436
446
 
437
447
  // ---------------------------------------------------------------------------
@@ -134,6 +134,12 @@ export const SOURCE_WORDS = Object.freeze({
134
134
  * @property {number} [total] How many journeys there are.
135
135
  * @property {number} [count] Whatever this event is counting.
136
136
  * @property {number} [watched] Addresses watched so far, across every journey.
137
+ * @property {number} [steady] Only on 'wobble'. Addresses answered the same way
138
+ * twice, as the engine measured them.
139
+ * @property {boolean} [measured] Only on 'wobble'. False when no wobble was taken.
140
+ * @property {string[]} [findingIds] Only on 'check:done', and only when the whole
141
+ * verdict was to hand: every finding that survived,
142
+ * so a window can drop one that has been waived.
137
143
  * @property {number} [durationMs]
138
144
  * @property {PanelReference} [reference] Only on 'reference'.
139
145
  * @property {PanelWobble} [wobble] Only on 'wobble'.
@@ -235,6 +241,9 @@ export const SOURCE_WORDS = Object.freeze({
235
241
  * @property {string} summary
236
242
  * @property {number} findings
237
243
  * @property {number} sealed How many need a person.
244
+ * @property {number} newlyUnstable Addresses that were steady before this change and
245
+ * are not now. A run can be `ok: false` on these
246
+ * alone, with no findings at all.
238
247
  * @property {number} differencesReal
239
248
  * @property {number} differencesNoise
240
249
  * @property {number} durationMs
@@ -291,220 +300,30 @@ export const SOURCE_WORDS = Object.freeze({
291
300
  */
292
301
 
293
302
  // ---------------------------------------------------------------------------
294
- // Saying the new things
303
+ // WHY THERE IS NO say*() FAMILY HERE ANY MORE (removed 2026-08-30)
295
304
  // ---------------------------------------------------------------------------
296
-
297
- /**
298
- * Put an event on a stream that may not be there.
299
- *
300
- * Watching is a convenience everywhere in this tool: a check with nobody watching is the
301
- * normal case, and this keeps that from being an `if` at every call site.
302
- *
303
- * @param {EventSink|undefined|null} events
304
- * @param {PanelEvent} event
305
- * @returns {void}
306
- */
307
- export function say(events, event) {
308
- if (!events || typeof events.emit !== 'function') return;
309
- try {
310
- events.emit(event);
311
- } catch {
312
- // A stream that cannot take an event is not a reason for a check to stop. It is the
313
- // window's problem, and the window is optional.
314
- }
315
- }
316
-
317
- /**
318
- * How many milliseconds in, according to the stream itself when it can say.
319
- * @param {EventSink|undefined|null} events
320
- * @returns {number}
321
- */
322
- function now(events) {
323
- try {
324
- return typeof events?.elapsed === 'function' ? events.elapsed() : 0;
325
- } catch {
326
- return 0;
327
- }
328
- }
329
-
330
- /**
331
- * The plan, said out loud, for a window that opened after the check started.
332
- *
333
- * @param {EventSink|undefined|null} events
334
- * @param {PanelPlanShape} plan
335
- * @returns {void}
336
- */
337
- export function sayPlan(events, plan) {
338
- say(events, { type: 'plan', at: now(events), plan });
339
- }
340
-
341
- /**
342
- * Which build this is being measured against — and, when it is the weaker kind, that it is.
343
- *
344
- * @param {EventSink|undefined|null} events
345
- * @param {PanelReference} reference
346
- * @returns {void}
347
- */
348
- export function sayReference(events, reference) {
349
- say(events, {
350
- type: 'reference',
351
- at: now(events),
352
- reference,
353
- message: reference.weak
354
- ? `${reference.warning || 'This is a weaker check than usual.'} ${reference.how}`.trim()
355
- : `Measured against ${reference.name}. ${reference.how}`.trim(),
356
- });
357
- }
358
-
359
- /**
360
- * A journey starting, on a named surface.
361
- *
362
- * The surface is the new part. One repository builds a website, a desktop app and a phone app,
363
- * and a window that only says "walking checkout" leaves a person guessing which of the three
364
- * they are watching.
365
- *
366
- * @param {EventSink|undefined|null} events
367
- * @param {object} what
368
- * @param {string} what.journey
369
- * @param {Surface} [what.surface]
370
- * @param {string} [what.describe]
371
- * @param {string} [what.source]
372
- * @param {string} [what.run] 'a', 'b' or 'single'.
373
- * @param {number} [what.index]
374
- * @param {number} [what.total]
375
- * @returns {void}
376
- */
377
- export function sayJourneyStart(events, what) {
378
- // "Website: buying one item with a saved card." The surface leads, because on a repository
379
- // that builds five products the surface is the thing a person is trying to work out.
380
- const where = what.surface ? `${surfaceWord(what.surface)}: ` : '';
381
- const doing = what.describe || `walking ${what.journey}`;
382
- say(events, {
383
- type: 'journey:start',
384
- at: now(events),
385
- journey: what.journey,
386
- describe: what.describe,
387
- surface: what.surface,
388
- surfaceWord: what.surface ? surfaceWord(what.surface) : undefined,
389
- source: what.source,
390
- run: what.run,
391
- index: what.index,
392
- total: what.total,
393
- message: `${where}${doing}.`,
394
- });
395
- }
396
-
397
- /**
398
- * The address count rising while a journey is still walking.
399
- *
400
- * This is the number that makes the window worth having open: proof that something is
401
- * happening, on a run where nothing is going to be wrong and there will be nothing to show.
402
- *
403
- * @param {EventSink|undefined|null} events
404
- * @param {string} journey
405
- * @param {number} count Addresses this journey has watched so far.
406
- * @returns {void}
407
- */
408
- export function sayAddresses(events, journey, count) {
409
- say(events, { type: 'journey:addresses', at: now(events), journey, count });
410
- }
411
-
412
- /**
413
- * A journey finished.
414
- * @param {EventSink|undefined|null} events
415
- * @param {object} what
416
- * @param {string} what.journey
417
- * @param {number} what.count Addresses watched.
418
- * @param {number} [what.unstable] Of those, how many would not sit still.
419
- * @param {number} [what.durationMs]
420
- * @param {Surface} [what.surface]
421
- * @param {string} [what.message]
422
- * @returns {void}
423
- */
424
- export function sayJourneyDone(events, what) {
425
- const unstable = Number(what.unstable ?? 0);
426
- say(events, {
427
- type: 'journey:done',
428
- at: now(events),
429
- journey: what.journey,
430
- surface: what.surface,
431
- surfaceWord: what.surface ? surfaceWord(what.surface) : undefined,
432
- count: what.count,
433
- durationMs: what.durationMs,
434
- message:
435
- what.message ||
436
- `${plural(what.count, 'address', 'addresses')} watched` +
437
- (unstable > 0 ? `, ${unstable} of which this build cannot answer the same way twice.` : '.'),
438
- });
439
- }
440
-
441
- /**
442
- * The wobble, measured.
443
- *
444
- * Nobody else's tool has this number, so it does not get buried. Everything that would not sit
445
- * still between two runs of the SAME build was not caused by the change, and is subtracted
446
- * arithmetically rather than allowed for by a tolerance somebody guessed.
447
- *
448
- * @param {EventSink|undefined|null} events
449
- * @param {PanelWobble} wobble
450
- * @returns {void}
451
- */
452
- export function sayWobble(events, wobble) {
453
- const note = wobble.note || wobbleSentence(wobble);
454
- say(events, { type: 'wobble', at: now(events), wobble: { ...wobble, note }, count: wobble.unstable, message: note });
455
- }
456
-
457
- /**
458
- * One finding, the moment it is formed.
459
- *
460
- * The finding goes on the stream whole. Cutting it down is the mapper's job and only the
461
- * mapper's job — two places trimming the same shape is how a window ends up drawing a
462
- * finding that has already had its findings taken out of it.
463
- *
464
- * @param {EventSink|undefined|null} events
465
- * @param {Finding} finding
466
- * @returns {void}
467
- */
468
- export function sayFinding(events, finding) {
469
- say(events, { type: 'finding', at: now(events), finding, message: finding?.title });
470
- }
471
-
472
- /**
473
- * The coverage, folded — which is mostly the list of what was NOT looked at.
474
- * @param {EventSink|undefined|null} events
475
- * @param {Coverage} coverage
476
- * @returns {void}
477
- */
478
- export function sayCoverage(events, coverage) {
479
- const gaps = Array.isArray(coverage?.gaps) ? coverage.gaps.length : 0;
480
- say(events, {
481
- type: 'coverage',
482
- at: now(events),
483
- coverage,
484
- count: gaps,
485
- message: coverageSentence(trimCoverage(coverage)),
486
- });
487
- }
488
-
489
- /**
490
- * A plain note, for anything that does not have a shape of its own.
491
- * @param {EventSink|undefined|null} events
492
- * @param {string} message
493
- * @returns {void}
494
- */
495
- export function sayNote(events, message) {
496
- say(events, { type: 'note', at: now(events), message });
497
- }
498
-
499
- /**
500
- * The end.
501
- * @param {EventSink|undefined|null} events
502
- * @param {Verdict} verdict
503
- * @returns {void}
504
- */
505
- export function sayCheckDone(events, verdict) {
506
- say(events, { type: 'check:done', at: now(events), verdict, durationMs: verdict?.durationMs, message: verdict?.summary });
507
- }
305
+ //
306
+ // This file used to export a second way of talking to a window: sayPlan, sayReference,
307
+ // sayJourneyStart, sayAddresses, sayJourneyDone, sayWobble, sayFinding, sayCoverage,
308
+ // sayNote and sayCheckDone, each one wrapping an emit. Nothing anywhere ever called a
309
+ // single one of them. The engine emits its own plain CheckEvents and `makeMapper` below
310
+ // translates them, so the tool carried two vocabularies for one window and only one of
311
+ // them was wired.
312
+ //
313
+ // They were deleted rather than adopted, and the reason is the paragraph at the top of
314
+ // watch/index.js: the ENGINE works everything out and the PANEL only draws. Every say*()
315
+ // would have had run.js reach for the window's vocabulary at the moment it is meant to be
316
+ // running a difference machine — and it would only have covered the one stream that
317
+ // remembered to call them, where the mapper covers ANY stream, v1's events included. Dead
318
+ // code in a tool whose job is telling the truth about a product is its own small lie: it
319
+ // reads like a supported road and is a road nobody has ever driven down.
320
+ //
321
+ // What was NOT deleted is the vocabulary the window really needs. CLASS_WORDS and
322
+ // SURFACE_NOTES are now embedded in the page by panel.js, exactly the way SURFACE_WORDS
323
+ // and SOURCE_WORDS already were. CLASS_WORDS had to be: the panel was keeping a second
324
+ // hand-written copy of the same map, and the two had already drifted — the panel said "a
325
+ // bug already reported once" where this file says "a bug you already reported", and the
326
+ // panel had no word for 'ordinary' at all.
508
327
 
509
328
  // ---------------------------------------------------------------------------
510
329
  // Trimming — what crosses into the window, and what stays out
@@ -661,6 +480,16 @@ export function trimVerdict(verdict) {
661
480
  typeof verdict?.sealed === 'number'
662
481
  ? Number(verdict.sealed)
663
482
  : list.filter((/** @type {any} */ f) => f?.sealed || isSealedClass(f?.class)).length;
483
+ // Addresses that used to be steady and are not any more. A run can have NO findings and
484
+ // still not be a pass because of these, and without the number the window cannot tell that
485
+ // apart from a run that compared nothing — the two look identical from `ok: false` and a
486
+ // finding count of nought, and they need opposite sentences.
487
+ const newlyUnstable =
488
+ typeof verdict?.newlyUnstable === 'number'
489
+ ? verdict.newlyUnstable
490
+ : Array.isArray(verdict?.newlyUnstable)
491
+ ? verdict.newlyUnstable.length
492
+ : 0;
664
493
  return {
665
494
  ok: Boolean(verdict?.ok),
666
495
  mode: verdict?.mode ?? 'stored-record',
@@ -668,6 +497,7 @@ export function trimVerdict(verdict) {
668
497
  summary: String(verdict?.summary ?? ''),
669
498
  findings: counted,
670
499
  sealed,
500
+ newlyUnstable,
671
501
  differencesReal: Number(verdict?.differencesReal ?? 0) || 0,
672
502
  differencesNoise: Number(verdict?.differencesNoise ?? 0) || 0,
673
503
  durationMs: Number(verdict?.durationMs ?? 0) || 0,
@@ -913,10 +743,17 @@ export function makeMapper(plan = {}) {
913
743
 
914
744
  case 'wobble': {
915
745
  announcedWobble = true;
746
+ // What the ENGINE measured, wherever it said it. `steady` used to be guessed here as
747
+ // "everything watched so far, minus the unstable ones", and those are two different
748
+ // populations: `watched` counts what the adapters wrote down, while the wobble is
749
+ // measured over addresses. The guess therefore claimed addresses had answered the
750
+ // same way twice that the build had never been asked at. It is kept only as a
751
+ // fallback, for a stream that says nothing about steadiness at all.
752
+ const said = numberOr(event.steady);
916
753
  const wobble = /** @type {PanelWobble|undefined} */ (event.wobble) ?? {
917
- measured: true,
754
+ measured: event.measured !== false,
918
755
  unstable: Number(event.count) || 0,
919
- steady: Math.max(0, watched - (Number(event.count) || 0)),
756
+ steady: said ?? Math.max(0, watched - (Number(event.count) || 0)),
920
757
  newlyUnstable: 0,
921
758
  };
922
759
  return [{ type: 'wobble', at, message: message ?? wobbleSentence(wobble), wobble, count: wobble.unstable }];
@@ -993,6 +830,14 @@ export function makeMapper(plan = {}) {
993
830
  type: 'check:done',
994
831
  at,
995
832
  verdict: trimVerdict(verdict),
833
+ // WHICH findings, not just how many. A finding only ever ARRIVES at a window; there
834
+ // was no way to take one away again, and findings are taken away — the engine's
835
+ // verdict carries every difference it found, and the gates in check.js then remove
836
+ // the ones an agent has recorded as intended and hand back the settled list. Both
837
+ // verdicts reach the window, in that order, so a waived finding stayed drawn beside
838
+ // a terminal that had already stopped reporting it. Naming the survivors is what
839
+ // lets the window agree: anything drawn that is not on this list is gone.
840
+ findingIds: findings.map((/** @type {any} */ f) => String(f?.id ?? '')).filter(Boolean),
996
841
  durationMs: verdict.durationMs,
997
842
  message: message ?? verdict.summary,
998
843
  });
@@ -131,19 +131,29 @@ export async function bringForward(name) {
131
131
  * - When one of ours is in front and you have chosen nothing yet, it is left alone. That
132
132
  * first appearance is the point: it is how you see what is happening.
133
133
  *
134
- * @param {{claims?: string[], everyMs?: number, graceMs?: number}} [opts]
134
+ * `look` and `putBack` exist so this loop can be exercised without a screen. They default to
135
+ * the two functions above and nothing in the tool passes them; a test does, because the
136
+ * bookkeeping — what counts as yours, when the screen is given back, how many times it
137
+ * happened — is the part that has to be right, and it is unreachable behind two calls to
138
+ * `osascript` that answer differently on every machine and not at all on most of them.
139
+ *
140
+ * @param {{claims?: string[], everyMs?: number, graceMs?: number, look?: () => Promise<string|null>, putBack?: (name: string) => Promise<boolean>}} [opts]
135
141
  * @returns {ScreenGuard}
136
142
  */
137
143
  export function guardTheScreen(opts = {}) {
138
144
  const everyMs = opts.everyMs ?? LOOK_EVERY_MS;
139
145
  const graceMs = opts.graceMs ?? GRACE_MS;
146
+ const whoIsInFront = opts.look ?? frontmostApp;
147
+ const putBack = opts.putBack ?? bringForward;
140
148
 
141
149
  /** @type {Set<string>} everything the tool opened */
142
150
  const ours = new Set(opts.claims ?? []);
143
151
  /** @type {string|null} the last application the person chose for themselves */
144
152
  let yours = null;
145
153
  let handedBack = 0;
146
- let stopped = process.platform !== 'darwin';
154
+ // Nothing to guard where there is no window server — unless a caller supplied its own way
155
+ // of looking, which means it is being driven deliberately rather than left to the machine.
156
+ let stopped = process.platform !== 'darwin' && !opts.look;
147
157
  /** @type {ReturnType<typeof setTimeout>|null} */
148
158
  let timer = null;
149
159
  const startedAt = Date.now();
@@ -166,14 +176,14 @@ export function guardTheScreen(opts = {}) {
166
176
 
167
177
  const look = async () => {
168
178
  if (stopped) return;
169
- const front = await frontmostApp();
179
+ const front = await whoIsInFront();
170
180
  if (front) {
171
181
  if (!isOurs(front)) {
172
182
  // The person chose this. It is now what "yours" means.
173
183
  yours = front;
174
184
  } else if (yours && Date.now() - startedAt > graceMs) {
175
185
  // Something of ours is in front, and there is somewhere to put you back.
176
- const ok = await bringForward(yours);
186
+ const ok = await putBack(yours);
177
187
  if (ok) {
178
188
  handedBack += 1;
179
189
  detail(`the screen was taken by ${front}; gave it back to ${yours}`);