staysfixed 0.7.2 → 0.8.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 (59) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/README.md +191 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  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 +549 -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/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +285 -45
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
@@ -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}`);