staysfixed 0.11.1 → 0.13.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 (57) hide show
  1. package/CHANGELOG.md +108 -2
  2. package/README.md +77 -19
  3. package/docs/design-v2.md +8 -7
  4. package/docs/getting-started.md +5 -3
  5. package/docs/guards.md +18 -0
  6. package/docs/how-v2-works.md +43 -10
  7. package/docs/mcp.md +6 -4
  8. package/docs/settings.md +11 -2
  9. package/package.json +1 -1
  10. package/src/cli/approve.js +4 -1
  11. package/src/cli/flake.js +4 -1
  12. package/src/cli/mark.js +5 -1
  13. package/src/cli/status.js +53 -1
  14. package/src/cli/trace.js +27 -2
  15. package/src/core/config.js +136 -25
  16. package/src/core/stop-tree.js +109 -0
  17. package/src/drive/browser.js +20 -31
  18. package/src/drive/page.js +74 -2
  19. package/src/guard/api.js +14 -9
  20. package/src/types.js +1 -1
  21. package/src/v2/adapters/android.js +220 -11
  22. package/src/v2/adapters/child.js +15 -17
  23. package/src/v2/adapters/contract.js +122 -1
  24. package/src/v2/adapters/extension.js +1988 -0
  25. package/src/v2/adapters/http.js +152 -30
  26. package/src/v2/adapters/ios-driver.js +95 -12
  27. package/src/v2/adapters/ios.js +220 -10
  28. package/src/v2/adapters/isolate.js +169 -14
  29. package/src/v2/adapters/linux-driver.js +1028 -0
  30. package/src/v2/adapters/linux.js +1324 -0
  31. package/src/v2/adapters/macos-driver.js +913 -0
  32. package/src/v2/adapters/macos.js +1374 -0
  33. package/src/v2/adapters/process.js +72 -8
  34. package/src/v2/adapters/source.js +254 -7
  35. package/src/v2/adapters/web.js +69 -19
  36. package/src/v2/browsers.js +145 -25
  37. package/src/v2/cause.js +46 -5
  38. package/src/v2/check.js +465 -47
  39. package/src/v2/cli.js +21 -1
  40. package/src/v2/coverage.js +556 -19
  41. package/src/v2/detect.js +742 -42
  42. package/src/v2/doctor.js +125 -18
  43. package/src/v2/escalate.js +57 -11
  44. package/src/v2/init.js +574 -23
  45. package/src/v2/journeys/answers-probe.js +376 -0
  46. package/src/v2/journeys/from-exports.js +456 -0
  47. package/src/v2/journeys/from-suite.js +9 -1
  48. package/src/v2/journeys/index.js +3 -3
  49. package/src/v2/journeys/record-session.js +839 -0
  50. package/src/v2/journeys/record.js +12 -0
  51. package/src/v2/mcp/tools.js +193 -27
  52. package/src/v2/observation.js +145 -0
  53. package/src/v2/run.js +133 -9
  54. package/src/v2/selfcheck.js +297 -11
  55. package/src/v2/store.js +16 -1
  56. package/src/v2/types.js +1 -1
  57. package/src/v2/watch/events.js +6 -0
package/src/v2/doctor.js CHANGED
@@ -39,6 +39,8 @@ import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
39
39
  import { POWERSHELL_PATHS, describeRemote } from './remote.js';
40
40
  import { deviceToMake } from './adapters/android.js';
41
41
  import { describeWindows } from './adapters/windows.js';
42
+ import { describeLinuxDesktop } from './adapters/linux.js';
43
+ import { describeMacos } from './adapters/macos.js';
42
44
  import { messageOf, EXIT } from '../core/errors.js';
43
45
  import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
44
46
  import { loadPlaywright } from './adapters/web-driver.js';
@@ -906,7 +908,7 @@ function findDesktopApp(cwd) {
906
908
  *
907
909
  * @param {string} root
908
910
  * @param {string|null} settingsText
909
- * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}>}
911
+ * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}>}
910
912
  */
911
913
  async function phoneApps(root, settingsText) {
912
914
  // Comments taken away first, for the same reason `findDesktopApp` does it: a
@@ -923,15 +925,35 @@ async function phoneApps(root, settingsText) {
923
925
  };
924
926
 
925
927
  /**
926
- * A named key whose value has to look right, so one settings word cannot be mistaken for
927
- * another block's.
928
+ * A named key looked for INSIDE one settings block, never across the whole file.
929
+ *
930
+ * `remoteExe` means "the built program on that machine" under `windows:` and exactly the
931
+ * same thing under `linux:`, so a flat search finds one and reports it as the other — and
932
+ * doctor would tell somebody with a GTK app that they have a native Windows program.
933
+ * Written the day the Linux surface landed, 2026-08-31, before it could be true.
934
+ *
935
+ * @param {string} block
928
936
  * @param {string} key
929
- * @param {(value: string) => boolean} looksRight
937
+ * @param {(value: string) => boolean} [looksRight]
930
938
  * @returns {FoundApp|null}
931
939
  */
932
- const namedPath = (key, looksRight) => {
933
- const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(settings);
934
- return found && looksRight(found[1]) ? { where: found[1], how: `your settings name it under ${key}` } : null;
940
+ const inBlock = (block, key, looksRight) => {
941
+ const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(settings);
942
+ if (!at) return null;
943
+ let depth = 0;
944
+ let end = at.index + at[0].length;
945
+ for (; end < settings.length; end += 1) {
946
+ if (settings[end] === '{') depth += 1;
947
+ else if (settings[end] === '}') {
948
+ if (depth === 0) break;
949
+ depth -= 1;
950
+ }
951
+ }
952
+ const inside = settings.slice(at.index + at[0].length, end);
953
+ const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(inside);
954
+ if (!found) return null;
955
+ if (looksRight && !looksRight(found[1])) return null;
956
+ return { where: found[1], how: `your settings name it under ${block}.${key}` };
935
957
  };
936
958
 
937
959
  /**
@@ -974,7 +996,11 @@ async function phoneApps(root, settingsText) {
974
996
  // name one" about a project whose settings named one. Measured 2026-08-31 against a real
975
997
  // TerminalDeck.app. The value has to end in `.app` so a bare `app:` belonging to some
976
998
  // other block can never be mistaken for this one.
977
- namedPath('app', (v) => v.endsWith('.app')) ??
999
+ // Scoped to the `ios` block, not searched across the whole file. `app` means an iPhone
1000
+ // bundle under `ios:` and a Mac bundle under `macos:` — both end in `.app`, so a flat
1001
+ // search finds one and announces the other. Written the day the Mac surface landed,
1002
+ // 2026-08-31, before it could be true.
1003
+ inBlock('ios', 'app', (v) => v.endsWith('.app')) ??
978
1004
  named('xcworkspace') ??
979
1005
  built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
980
1006
  (there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
@@ -983,9 +1009,11 @@ async function phoneApps(root, settingsText) {
983
1009
  ? { where: root, how: 'there is an Xcode project here, but nothing says where the built app is' }
984
1010
  : null);
985
1011
 
986
- const windows = named('remoteExe') ?? namedPath('exe', (v) => /\.exe$/i.test(v));
1012
+ const windows = inBlock('windows', 'remoteExe') ?? inBlock('windows', 'exe', (v) => /\.exe$/i.test(v));
1013
+ const linux = inBlock('linux', 'remoteExe') ?? inBlock('linux', 'exe');
1014
+ const macos = inBlock('macos', 'app', (v) => v.endsWith('.app'));
987
1015
 
988
- return { android, ios, windows };
1016
+ return { android, ios, windows, linux, macos };
989
1017
  }
990
1018
 
991
1019
  /**
@@ -1071,9 +1099,9 @@ function couldNotAsk(name, why) {
1071
1099
  /**
1072
1100
  * The platforms that arrive as an adapter of their own, and know their own requirements.
1073
1101
  * The built-in five are described by hand above, because they are older than this
1074
- * mechanism and their wording is tested; these three answer for themselves.
1102
+ * mechanism and their wording is tested; these four answer for themselves.
1075
1103
  */
1076
- const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
1104
+ const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows', 'linux', 'macos'];
1077
1105
 
1078
1106
  /**
1079
1107
  * Ask each separate adapter what IT is missing, in its own words.
@@ -1189,7 +1217,7 @@ async function whatThisCopyCanDrive() {
1189
1217
  // file that would not load, so a check on this copy is not running at all — and both
1190
1218
  // the command line and the MCP surface say that in their own words already.
1191
1219
  const why = `This copy could not be asked what it can drive: ${messageOf(e)}`;
1192
- for (const surface of ['android', 'ios', 'windows']) out.push({ surface, present: false, why });
1220
+ for (const surface of ['android', 'ios', 'windows', 'linux', 'macos']) out.push({ surface, present: false, why });
1193
1221
  }
1194
1222
  return out;
1195
1223
  }
@@ -1550,6 +1578,7 @@ function settingsFromText(text) {
1550
1578
  // as a result: it could not see the machine the settings named, nor the built program,
1551
1579
  // so it asked for both while both were sitting in the file.
1552
1580
  windows: ['host', 'remoteExe', 'exe'],
1581
+ linux: ['host', 'remoteExe', 'exe'],
1553
1582
  };
1554
1583
  for (const [block, keys] of Object.entries(wanted)) {
1555
1584
  const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
@@ -1679,7 +1708,7 @@ async function findReference(root) {
1679
1708
  * @param {import('./browsers.js').BrowserSurvey} browsers
1680
1709
  * @param {{where: string, how: string}|null} desktopApp
1681
1710
  * @param {DriverReport[]} drivers What this copy of the tool can drive at all.
1682
- * @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}} phones
1711
+ * @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}} phones
1683
1712
  * @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
1684
1713
  * @param {{commands: number, imports: number}} [wires]
1685
1714
  * What this project's own settings wire for the command-line surface. A surface with
@@ -1728,7 +1757,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1728
1757
  name: 'command-line tools and libraries',
1729
1758
  status: wiredForCli ? 'ready' : 'partial',
1730
1759
  summary: wiredForCli
1731
- ? 'Fully covered here. What it printed, what it exited with, what it wrote, what it called out to, and what it exports.'
1760
+ ? 'What it printed, what it exited with, what it wrote, what it called out to, what it exports, and what each exported function answers when it is called with a fixed list of inputs. Not covered: any input outside that list, and any function whose name says it deletes, sends, publishes or charges — those are never called. A clean result covers what was walked; `staysfixed coverage` lists what was not.'
1732
1761
  : configured
1733
1762
  ? 'This machine can cover it in full — what a command printed, what it exited with, what it wrote, what it called out to, what it exports — but these settings wire no command to run and nothing to import, so a check runs none of it and a clean result says nothing about any of it.'
1734
1763
  : 'This machine can cover it in full, but nothing is set up in this folder yet, so a check cannot run here at all.',
@@ -1861,7 +1890,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1861
1890
  : !canDrive('android')
1862
1891
  ? `An Android app is here (${phones.android.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('android')}`
1863
1892
  : androidReady
1864
- ? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether two emulator snapshots restore identically is still unproven, so a paired run is not offered — and the run says which mode it used.`
1893
+ ? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether a snapshot restore repeats was measured on 2026-08-31: one build walked ten times with a restore between every walk, 301 of 309 addresses agreeing in all five pairs, and the eight that moved were the app's own freshly-made identity code — ordinary wobble, which this tool already subtracts. So a paired run is offered. It also needs a copy of the OLD build's package, because an APK is a build output that no checkout of the old commit contains: name it with {"reference": "path/to/the-old.apk"} under "android" in the settings. Without it the run falls back to the stored record and says so.`
1865
1894
  : androidPartly
1866
1895
  ? `Most of your Android app can be checked: every screen another app can reach is opened and read. What is missing is ${plainList(androidMissing)}, and without ${androidMissing.length === 1 ? 'it' : 'them'} nothing is typed, pressed or saved — so a clean result covers the screens and not what the app DOES.`
1867
1896
  : `An Android app is here (${phones.android.how}), and ${plainList(androidMissing)} ${androidMissing.length === 1 ? 'is' : 'are'} still missing. ${androidWants.every((n) => n.automatic) ? `${androidMissing.length === 1 ? 'It installs' : 'They all install'} without anybody clicking anything, so nobody needs to be asked.` : 'Some of it needs a person, and each one says what it is and what it unlocks.'}`,
@@ -2039,6 +2068,84 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
2039
2068
  if (windowsHost && !windowsDriver) {
2040
2069
  impossible.set('windows', `${noDriver('windows')} Nothing you install on that machine changes it. Update Stays Fixed to a copy that has it.`);
2041
2070
  }
2071
+
2072
+ // ── native Mac apps ───────────────────────────────────────────────────────────────────
2073
+ //
2074
+ // No remote option here, unlike Windows and Linux: the Accessibility API only answers
2075
+ // inside a signed-in graphical session on the machine itself, so a Mac app is checked on
2076
+ // the Mac it is on or not at all. And one person has to allow it once — macOS will not let
2077
+ // any program grant itself permission to read another app's window.
2078
+ const macDriver = canDrive('macos');
2079
+ const onAMacHere = process.platform === 'darwin';
2080
+ const macNeeds = asked.get('macos') ?? [];
2081
+ const macAllowed = onAMacHere && macDriver && !macNeeds.some((n) => /permission/i.test(String(n.what)));
2082
+ const macUsable = phones.macos !== null && macAllowed;
2083
+ surfaces.push({
2084
+ id: 'macos',
2085
+ name: 'native Mac apps',
2086
+ status: macUsable ? 'ready' : 'unavailable',
2087
+ summary: phones.macos === null
2088
+ ? 'Nothing to check: this project names no native Mac app in its settings. Most Mac products are Electron, and those are covered over their debug port from any machine.'
2089
+ : !onAMacHere
2090
+ ? 'Cannot run here: a native Mac window can only be read from a Mac.'
2091
+ : !macDriver
2092
+ ? `A Mac app is named in the settings, and this copy of Stays Fixed cannot drive one. ${noDriver('macos')}`
2093
+ // The adapter's own paragraph, not a second one written here. It knows whether this
2094
+ // Mac has been allowed to read another app's window, and that changes the answer
2095
+ // completely.
2096
+ : describeMacos({ darwin: true, allowed: macAllowed }),
2097
+ canCheck: macUsable ? [...withoutADriver, 'meaning', 'pixels'] : [],
2098
+ cannotCheck: macUsable ? [] : CHANNELS.map((c) => c.id),
2099
+ needs: onAMacHere && macDriver && phones.macos !== null ? macNeeds : [],
2100
+ });
2101
+ if (phones.macos === null) {
2102
+ notInThisProject.add('macos');
2103
+ impossible.set('macos', 'This project names no native Mac app in its settings, so there is nothing to open. If yours is built somewhere else, name the built .app under macos.app. Most Mac products are Electron, and those are covered over their debug port from any machine.');
2104
+ } else if (!onAMacHere) {
2105
+ impossible.set('macos', 'A native Mac window can only be read from a Mac. Everything else on this list is unaffected — check the Mac app from a Mac, and let this machine cover the rest.');
2106
+ } else if (!macDriver) {
2107
+ impossible.set('macos', `${noDriver('macos')} Nothing you install on this machine changes it. Update Stays Fixed to a copy that has it.`);
2108
+ }
2109
+
2110
+ // ── native Linux desktop apps ─────────────────────────────────────────────────────────
2111
+ //
2112
+ // The project is asked before the machine, the same as everywhere else: a repository with
2113
+ // no native Linux program named in it does not need a Linux desktop, and telling somebody
2114
+ // to go and find one is work that changes nothing.
2115
+ const linuxDriver = canDrive('linux');
2116
+ const linuxHost = hosts.find((h) => h.reachable && h.windows !== true);
2117
+ const linuxUsable = phones.linux !== null && linuxHost !== undefined && linuxDriver;
2118
+ surfaces.push({
2119
+ id: 'linux',
2120
+ name: 'native Linux desktop apps',
2121
+ status: linuxUsable ? 'partial' : 'unavailable',
2122
+ summary: phones.linux === null
2123
+ ? 'Nothing to check: this project names no native Linux program in its settings. Most Linux desktop products are Electron, and those are covered over their debug port from any machine.'
2124
+ : !linuxDriver
2125
+ ? `A native Linux program is named (${phones.linux.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('linux')}`
2126
+ : !linuxHost
2127
+ ? nobodyWasDialled
2128
+ ? 'No machine was dialled, so whether a Linux desktop can be reached from here is unknown. Name one under `linux: { host: "..." }` in your settings and it is asked every time, or run `staysfixed doctor --machines`.'
2129
+ : 'No Linux desktop is reachable from here. A native Linux window can only be read from the desktop it is running on.'
2130
+ // The adapter's own paragraph, never a second one written here — it knows whether
2131
+ // that machine has a desktop session at all, and a summary kept in this file could
2132
+ // only guess at it.
2133
+ : linuxHost.detail
2134
+ ? describeLinuxDesktop(linuxHost.detail)
2135
+ : `A Linux machine answers through "${linuxHost.name}". A native Linux app is read through the accessibility bus every screen reader already uses, and nothing has to be installed there.`,
2136
+ canCheck: linuxUsable ? withoutADriver : [],
2137
+ cannotCheck: linuxUsable ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
2138
+ needs: linuxUsable ? (asked.get('linux') ?? []) : [],
2139
+ });
2140
+ if (phones.linux === null) {
2141
+ notInThisProject.add('linux');
2142
+ impossible.set(
2143
+ 'linux',
2144
+ 'This project has no native Linux program named in its settings, so there is nothing to open on a Linux desktop. '
2145
+ + 'If yours is built somewhere else, name it under linux.remoteExe (already on that machine) or linux.exe (copied over each run). '
2146
+ + 'Most Linux desktop products are Electron, and those are covered over their debug port from any machine.'
2147
+ );
2148
+ }
2042
2149
  if (!windowsHost) {
2043
2150
  impossible.set(
2044
2151
  'windows',
@@ -2119,9 +2226,9 @@ function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
2119
2226
  if (!setUpHere) {
2120
2227
  out.canRunHere = false;
2121
2228
  parts.push('Nothing is set up in this folder, so a check cannot run here at all and would cover nothing. Run `staysfixed init` first.');
2122
- parts.push(full.length > 0 ? `Once it is set up, this machine could cover ${plainList(out.covered)} in full.` : 'Even set up, this machine could cover nothing in full.');
2229
+ parts.push(full.length > 0 ? `Once it is set up, this machine can drive ${plainList(out.covered)}.` : 'Even set up, this machine can drive none of these.');
2123
2230
  } else {
2124
- parts.push(full.length > 0 ? `A check here covers ${plainList(out.covered)} in full.` : 'A check here covers nothing in full.');
2231
+ parts.push(full.length > 0 ? `A check here can drive ${plainList(out.covered)}. That is what this machine is able to walk, not what any run has walked.` : 'A check here can drive none of these.');
2125
2232
  }
2126
2233
  if (some.length > 0) parts.push(`It covers ${plainList(some.map((s) => s.name))} only partly — read the summary for each before treating a clean result as proof.`);
2127
2234
  if (missing.length > 0) {
@@ -437,7 +437,7 @@ export async function rememberCheck(store, what) {
437
437
  *
438
438
  * @typedef {object} Escalation
439
439
  * @property {string} id
440
- * @property {'sealed'|'budget'|'unpredictable'|'blocked'|'no-reference'} kind
440
+ * @property {'sealed'|'difference'|'budget'|'unpredictable'|'blocked'|'no-reference'} kind
441
441
  * @property {string} what What changed.
442
442
  * @property {string} why Why no agent could wave it through.
443
443
  * @property {string} todo What to do about it.
@@ -500,11 +500,26 @@ export async function escalationsFor(store, product) {
500
500
  * @returns {Escalations}
501
501
  */
502
502
  function buildEscalations(product, record, verdict) {
503
+ // TWO PILES, AND THE ORDER BETWEEN THEM IS THE FIX.
504
+ //
505
+ // `real` is everything that is the product behaving differently, or the check not having
506
+ // happened at all. `steadiness` is the one item that is neither: addresses that used to
507
+ // give the same answer every run and now do not. That is worth a person's attention and it
508
+ // is NOT a difference — nothing in it has a wrong value.
509
+ //
510
+ // WHAT WENT WRONG, 2026-08-31. A run caught one real change correctly and also measured 242
511
+ // addresses as newly unsteady. Only the second reached this block, because an ordinary
512
+ // difference has never been put in here at all — so the only sentence the owner read told
513
+ // him to hold the release over a wobble measurement, and never mentioned the change the
514
+ // tool had actually found. Piling them separately makes that ordering structural instead of
515
+ // accidental, and `crowdedOut` below makes sure a real change is never the thing left out.
503
516
  /** @type {Escalation[]} */
504
- const items = [];
517
+ const real = [];
518
+ /** @type {Escalation[]} */
519
+ const steadiness = [];
505
520
 
506
521
  if (verdict.blocked === true) {
507
- items.push({
522
+ real.push({
508
523
  id: 'blocked',
509
524
  kind: 'blocked',
510
525
  what: `Stays Fixed could not check ${product} at all on this run, so nothing about it has been proved either way.`,
@@ -512,7 +527,7 @@ function buildEscalations(product, record, verdict) {
512
527
  todo: `Something is in the way and it needs clearing — the run said: ${oneLine(verdict.summary, 200)}`,
513
528
  });
514
529
  } else if (!verdict.reference || verdict.reference.id === '') {
515
- items.push({
530
+ real.push({
516
531
  id: 'no-reference',
517
532
  kind: 'no-reference',
518
533
  what: `There is no build of ${product} on record as working yet, so this run had nothing to compare against.`,
@@ -527,7 +542,7 @@ function buildEscalations(product, record, verdict) {
527
542
 
528
543
  for (const f of record.findings) {
529
544
  if (f.unwaivable !== true) continue;
530
- items.push({
545
+ real.push({
531
546
  id: f.id,
532
547
  kind: 'sealed',
533
548
  what: oneLine(f.title, 220),
@@ -542,7 +557,7 @@ function buildEscalations(product, record, verdict) {
542
557
  }
543
558
 
544
559
  if (record.accounting.left === 0 && record.accounting.reported > record.accounting.unwaivable) {
545
- items.push({
560
+ real.push({
546
561
  id: 'budget',
547
562
  kind: 'budget',
548
563
  what: `The agent has used all ${record.accounting.budget} of the differences it is allowed to record as intended on ${product}, and there are still differences left over.`,
@@ -553,7 +568,7 @@ function buildEscalations(product, record, verdict) {
553
568
 
554
569
  if (record.newlyUnstable.length > 0) {
555
570
  const n = record.newlyUnstable.length;
556
- items.push({
571
+ steadiness.push({
557
572
  id: 'unpredictable',
558
573
  kind: 'unpredictable',
559
574
  what: `${n} ${n === 1 ? 'thing in' : 'things in'} ${product} used to give the same answer every single run and now ${n === 1 ? 'does' : 'do'} not: ${record.newlyUnstable.slice(0, 3).join(', ')}${n > 3 ? ', and more' : ''}.`,
@@ -561,15 +576,36 @@ function buildEscalations(product, record, verdict) {
561
576
  todo: 'Have it looked into before shipping. Something in the change made the product unpredictable.',
562
577
  paths: record.newlyUnstable.slice(0, 6),
563
578
  });
579
+ // A REAL CHANGE IS NEVER THE THING LEFT OUT.
580
+ //
581
+ // An ordinary difference is the agent's problem and does not get its own item — that is
582
+ // deliberate, and it stops a handful of items a month becoming a feed nobody reads. But it
583
+ // stops being right the moment the ONLY thing in this block is a wobble measurement,
584
+ // because then the sentence a person reads is an alarm about a non-difference with no
585
+ // mention of the difference the tool did find. So a real change gets one line here, and it
586
+ // goes first, exactly when it would otherwise have been the thing crowded out.
587
+ const crowdedOut = record.findings.filter((f) => f.waivedBy === undefined && f.unwaivable !== true);
588
+ if (real.length === 0 && crowdedOut.length > 0) {
589
+ const worst = crowdedOut[0];
590
+ real.push({
591
+ id: 'differences',
592
+ kind: 'difference',
593
+ what: `${product} behaves differently from the build you were happy with in ${crowdedOut.length} ${crowdedOut.length === 1 ? 'place' : 'places'}: ${oneLine(worst.title, 180)}${crowdedOut.length > 1 ? ', and more' : ''}.`,
594
+ why: 'This is a real change in the product, which is what the check is for — it is named before the steadiness note below so it cannot be read past.',
595
+ todo: 'The agent has to deal with each one: fix it, or record it as intended and say why. Nothing is the new normal until you ship.',
596
+ paths: (worst.paths ?? []).slice(0, 6),
597
+ });
598
+ }
564
599
  }
565
600
 
601
+ const all = [...real, ...steadiness];
566
602
  return {
567
603
  product,
568
604
  at: record.at,
569
- items,
605
+ items: all,
570
606
  waived: record.accounting.waived,
571
607
  expiredWaivers: record.accounting.expiredWaivers,
572
- note: summaryNote(product, items, record),
608
+ note: summaryNote(product, all, record),
573
609
  };
574
610
  }
575
611
 
@@ -595,11 +631,21 @@ function sealedTodo(f) {
595
631
  * @returns {string}
596
632
  */
597
633
  function summaryNote(product, items, record) {
634
+ // NOTHING NEEDING YOUR WORD IS NOT THE SAME AS NOTHING BEING WRONG.
635
+ //
636
+ // This line used to read "nothing on X needs your word" on a run that had found real
637
+ // differences the agent had not dealt with — true about who has to decide, and read by
638
+ // anybody skimming as an all-clear. One clause fixes it, and it is added rather than the
639
+ // sentence replaced, because who has to decide is still the thing this block is about.
640
+ const left = record.accounting.reported;
641
+ const outstanding = left > 0
642
+ ? ` The agent still has ${left} ${left === 1 ? 'difference' : 'differences'} of its own to deal with on this build.`
643
+ : '';
598
644
  if (items.length === 0) {
599
645
  const waived = record.accounting.waived;
600
646
  return waived > 0
601
- ? `Stays Fixed: nothing on ${product} needs your word. ${waived} ${waived === 1 ? 'difference was' : 'differences were'} recorded as intended by the agent and ${waived === 1 ? 'is' : 'are'} waiting on your next ship.`
602
- : `Stays Fixed: nothing on ${product} needs your word.`;
647
+ ? `Stays Fixed: nothing on ${product} needs your word.${outstanding} ${waived} ${waived === 1 ? 'difference was' : 'differences were'} recorded as intended by the agent and ${waived === 1 ? 'is' : 'are'} waiting on your next ship.`
648
+ : `Stays Fixed: nothing on ${product} needs your word.${outstanding}`;
603
649
  }
604
650
  return `Stays Fixed: ${items.length} ${items.length === 1 ? 'thing needs' : 'things need'} your word on ${product}.`;
605
651
  }