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/CHANGELOG.md CHANGED
@@ -6,7 +6,111 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- Nothing yet.
9
+ ### The self-check corpus knows about the five false all-clears found on 2026-08-31
10
+
11
+ Five defects were found and fixed that day, each of which could have blessed a broken build,
12
+ and not one of them would have been caught by the corpus that is supposed to make that
13
+ impossible. There is a case for each now, and every one of them was made to fail against the
14
+ code as it stood before its fix before it was believed.
15
+
16
+ - **A build that crashes on its first line is no longer a build that agrees with itself.** Two
17
+ builds that crash the same way used to agree at every address, and agreement is the silence
18
+ this tool reads as "nothing changed".
19
+ - **Coverage this build took away is never handed back as a pass.** The engine decided that
20
+ correctly and the bookkeeping then assigned the verdict rather than narrowing it.
21
+ - **A standard cut from a tree git does not have is never walked out of git.** The old build
22
+ was exported from the commit, walked, and a real regression was dropped as something it does
23
+ too.
24
+ - **With nothing edited, nothing can be a change.** The build being checked and the build on
25
+ record were one build, and whatever flickered between two runs was reported as a change
26
+ nobody made.
27
+ - **What "working" means moves only when somebody ships.** Re-running a check on the shipped
28
+ build quietly replaced the standard with whatever that run happened to see.
29
+
30
+ ### A guard can hand `evaluate` a function
31
+
32
+ `page.evaluate` took JavaScript as text and nothing else. Passing it a function — the obvious
33
+ thing, and what every other tool in this space accepts — answered `The app refused the request
34
+ "Runtime.evaluate": Invalid parameters`, which is the debug protocol talking about its own wire
35
+ format to somebody who had done nothing wrong. It now takes either, and the two things it
36
+ genuinely cannot run — a built-in, and a function that asks for an argument it has no way to
37
+ be given — say so in a sentence with an example in it.
38
+
39
+ ## [0.12.0] — 2026-08-31
40
+
41
+ Five capabilities that were missing rather than broken. Each was built by a lane working on
42
+ its own, each had to prove itself against a real product before it was believed, and three of
43
+ the five turned up a false all-clear on the way in.
44
+
45
+ ### Record yourself using it once, and it is checked for ever
46
+
47
+ Everything this tool checked before, it worked out by READING — routes out of the source,
48
+ exports out of a package, screens out of a router. That finds what the code says it does. It
49
+ never found the way somebody actually uses the thing. `staysfixed record <a-name>` now opens
50
+ your product, follows what you do, and writes it down.
51
+
52
+ - **A click that moves the page is written down as the CLICK, not the destination.** Writing
53
+ the destination would open that page whether or not the button works, so a broken button
54
+ would have come back clean. That is the whole feature failing quietly, and it is guarded.
55
+ - **A recording is walked twice before it is accepted.** Anything that differs between the two
56
+ walks is not a step, it is noise, and the recording is refused rather than kept. A recording
57
+ that never got through its own steps even once is refused too — both walks failing
58
+ identically would otherwise read as perfectly steady.
59
+ - A project that asks for recorded sessions and has none is reported as not checked, never as
60
+ a pass.
61
+
62
+ ### Phones compare two builds side by side
63
+
64
+ Both phone surfaces refused a paired run because nobody knew whether an emulator or simulator
65
+ comes back the same twice. Nobody had ever measured it. Measured now, one build walked ten
66
+ times with a restore between every walk:
67
+
68
+ - **iOS: 725 of 725 addresses agreed across five pairs.**
69
+ - **Android: 301 of 309.** The eight that moved were the app's own freshly-made identity code
70
+ — ordinary wobble, which this tool already subtracts. A control run with the restore switched
71
+ off produced the same eight and no others, so the restore contributed exactly zero.
72
+
73
+ So paired is on for both, and every sentence that repeated the old guess now carries the
74
+ number instead. The real obstacle turned out to be somewhere else entirely: a paired phone run
75
+ needs a kept copy of the OLD build's APK or `.app`, because a build output is in no checkout of
76
+ the old commit. That is the new `reference` setting — and pointing it at the SAME file as the
77
+ new build is caught and reported as a hole on every journey, rather than comparing one build
78
+ with itself and calling it unchanged.
79
+
80
+ Also fixed on the way: `simctl` lists the newest iPhone first and the adapter took the last
81
+ one, asking for an iPhone 6s on iOS 27 — which Apple refuses with an empty message. The whole
82
+ iPhone surface was dark on a Mac that had everything it needed.
83
+
84
+ ### Three new kinds of product
85
+
86
+ - **Native Mac apps** (Swift or Objective-C, not Electron). Read through the Accessibility API
87
+ by JavaScript handed to macOS's own `osascript` — nothing is installed. Two builds of an
88
+ AppKit app differing by one line came back as exactly that one difference out of 75 things
89
+ read. **Two copies of one Mac app make one of them stop answering, and a silent app looks
90
+ exactly like an app with no controls** — so every read is cross-checked against the window
91
+ server's own list of what is on screen, and runs are one at a time.
92
+ - **Native Linux desktop apps** (GTK or Qt). Read over ssh through the accessibility bus every
93
+ screen reader already uses, with nothing installed on that machine: 190 controls in 305ms,
94
+ the same cost as the Windows surface. A machine with nobody logged in has no accessibility
95
+ bus at all and is told so, rather than reported as an app with no controls.
96
+ - **Browser extensions** (Chromium). The manifest is read as a contract — a permission that
97
+ appears, or a host permission widening from one named site to every site, is a change
98
+ somebody has to agree to, and that half needs no browser. Then the popup and options pages
99
+ are walked, and what a content script does to somebody else's page is measured by opening
100
+ that page with the extension and without it. What the background worker logs and asks the
101
+ network for is a named hole on every run, because both happen before anything can listen.
102
+
103
+ ### False all-clears found while building the above
104
+
105
+ - **The browser launcher passes `--disable-extensions` unconditionally**, and Chrome accepts
106
+ that alongside `--load-extension` without a word and loads nothing. Anything routed through
107
+ it would have walked a browser with no extension in it and reported everything as unchanged.
108
+ - **The settings lookup for a built program searched the whole file, not its own block.** `app`
109
+ means an iPhone bundle under `ios:` and a Mac bundle under `macos:`; `remoteExe` means the
110
+ same thing under `windows:` and `linux:`. Every one of them is scoped now, before the new
111
+ surfaces could make it true.
112
+ - **A `reference` pointing at the same file as the new build** compares a build with itself,
113
+ finds nothing, and says nothing changed. Caught by real path, on every journey.
10
114
 
11
115
  ## [0.11.1] — 2026-08-31
12
116
 
@@ -64,7 +168,9 @@ adapter and tests turned out to hold at least one defect that only running it co
64
168
  record is filed under a fingerprint of the tree that was walked, because the files checked
65
169
  are not the files git has. Paired mode walked the commit and called it the old build, so an
66
170
  address the record holds a real value for was reported as *"is there now and was not
67
- before"*.
171
+ before"*. Asking for a paired run against such a reference is now refused outright, naming
172
+ the reason and what to do instead, rather than quietly giving you something weaker; an
173
+ ordinary check still falls back to the stored record and says so.
68
174
 
69
175
  ### It reported things as checked that were never checked
70
176
 
package/README.md CHANGED
@@ -163,13 +163,17 @@ pretend otherwise.
163
163
  | The coverage ledger — every door counted, the unopened ones named, and the sentence saying so on every reply | **Works.** See [what it did not check](#what-it-did-not-check). |
164
164
  | Aiming a check at one kind of product, and refusing by name rather than checking something else | **Works.** |
165
165
  | Steps taken from your own test suite | **Works.** `--journeys suite` runs each test file twice inside the scratch copy, reports every check by name and why each failure failed, and stops after 90 seconds naming every file it did not reach. It is opt-in: running a stranger's whole suite twice on every check is not a thing to do by default. It catches what nothing else can — remove the penny-rounding from a `total()` and the product's own output does not move by one character, the discovered journeys say "nothing has changed", and the harvest names the check that turned red. |
166
- | Steps taken from a recorded session, or rejected at birth for not repeating twice | **Written, not wired.** The code is in `src/v2/journeys/` with tests around it, and nothing on the check path calls it yet. Ask for `--journeys recorded` and you are told so by name. |
167
- | Android APKs on an emulator | **The adapter is here.** It reads everything the APK declares with nothing installed and no Java, and where there is an emulator it installs one build at a time and walks it. Whether *this* machine can run one is a separate question, and `doctor` asks the adapter itself rather than keeping a second opinion — most of what it wants installs with a command; accepting Google's licence, once, needs a person. Two emulator snapshots restoring byte-identically is unproven, so Android compares against the stored record and says which mode it used. |
168
- | The iOS simulator | **The adapter is here.** It reads what the app bundle declares with nothing running, and where Xcode and a simulator runtime are present it installs one build at a time, boots it and reads what is on the screen. It is new. Paired running costs two `xcodebuild` passes, so it is for before a release rather than for every edit, and like Android it compares against the stored record and says which mode it used. Ask `doctor` what it is actually covering on your machine before trusting a clean run. |
166
+ | Steps taken from a recorded session, or rejected at birth for not repeating twice | **Works.** `staysfixed record <name>` opens your product, follows what you do in it, and writes the session down; `--journeys recorded` walks it on every later check. It is the one source that knows which four screens a person actually opens every morning, which no amount of reading the source can work out. Nothing is kept until it has been walked twice against the same build: anything that differs between those two walks is noise, not a step, and the recording is refused with the reason. Passwords and tokens are taken out on the way to the file. Proved end to end on a two-page site — break the second page and the journeys read out of the code say "nothing that worked has changed", because nothing in the source names that page; the recorded session names the sentence that changed. |
167
+ | Android APKs on an emulator | **The adapter is here.** It reads everything the APK declares with nothing installed and no Java, and where there is an emulator it installs one build at a time and walks it. Whether *this* machine can run one is a separate question, and `doctor` asks the adapter itself rather than keeping a second opinion — most of what it wants installs with a command; accepting Google's licence, once, needs a person. Two restores of one build were measured on 2026-08-31: 301 of 309 addresses agreed across five pairs, and the eight that moved were the app's own identity code, which the wobble subtraction already removes. So a paired run is offered — give it a kept copy of the old build's APK with `reference` under `android`, because a checkout of the old commit holds no build output. |
168
+ | The iOS simulator | **The adapter is here.** It reads what the app bundle declares with nothing running, and where Xcode and a simulator runtime are present it installs one build at a time, boots it and reads what is on the screen. It is new. Paired running costs two `xcodebuild` passes, so it is for before a release rather than for every edit, and two restores of one build were measured on 2026-08-31 on an iOS 27.0 simulator and 725 of 725 addresses agreed across five pairs, so a paired run is offered — it needs a kept copy of the old build's `.app` (`reference` under `ios`), because a checkout of the old commit holds no build output. Ask `doctor` what it is actually covering on your machine before trusting a clean run. |
169
169
  | Native Windows GUI (a real Win32 app, not an Electron one) | **Works,** and it has now been driven end to end: a real Win32 window on a Windows 11 desktop reached over ssh, 10 addresses read out of the UI Automation tree, a reference cut, and the next run compared against it. Nothing was installed on that machine — the program that reads the screen is sent down the connection each run. Windows shows one desktop, so two builds can never run at once: the comparison is genuinely weaker here than anywhere else, and a run says so rather than hiding it. |
170
+ | Browser extensions (Chromium) | **Works.** The manifest is read as a contract — a permission that appears, or a host permission that widens from one named site to every site, is reported as a change somebody has to agree to, and that half needs no browser at all. Then it loads the extension into a throwaway browser, walks its popup and options pages for what the screen means, and measures what its content scripts do to somebody else's page by opening that page with the extension and without it and comparing the two. Nothing reaches the internet. It does not read what the background worker logs or what it asks the network for — both happen in the instant the browser starts it, before anything can listen — and it says so on every run. |
171
+ | Native Mac GUI (a Swift or Objective-C app, not an Electron one) | **Works,** and it has been driven end to end on a real Mac: two builds of an AppKit app differing by one line, 75 addresses read out of the Accessibility API each, and exactly the one changed checkbox reported. Nothing is installed — the program that reads the screen is JavaScript handed to macOS's own `osascript`. One person has to allow it once under System Settings, Privacy & Security, Accessibility; macOS will not let any program grant itself that. One build at a time: two copies of one Mac app make one of them stop answering, which looks exactly like an app with no controls, so every read is cross-checked against the window server's own list. |
172
+ | Native Linux desktop GUI (GTK or Qt, not an Electron one) | **Works,** and it has been driven end to end: a real GTK window on an Ubuntu 24.04 desktop reached over ssh, every control read out of the accessibility bus every screen reader already uses, two runs of one build with zero unstable addresses, then a build with one checkbox ticked and one button disabled reported as exactly two differences and nothing else. Nothing was installed on that machine. A desktop shows one screen, so two builds can never run at once. A machine with nobody logged in has no accessibility bus at all, and is told so rather than reported as an app with no controls. |
170
173
 
171
- All eight surfaces — command-line tools, libraries, servers, websites, Electron,
172
- Android, iOS and native Windows have now actually been run against a real
174
+ All eleven surfaces — command-line tools, libraries, servers, websites, browser
175
+ extensions, Electron, Android, iOS, native Windows, native Linux and native Mac have now
176
+ actually been run against a real
173
177
  product, rather than only having an adapter and tests. That sentence was not true
174
178
  before 2026-08-31, and the four that were unproven each turned out to have at
175
179
  least one defect that only running them could find.
@@ -283,15 +287,59 @@ under instrumentation → recorded real sessions → the agent exploring one nam
283
287
  gap and freezing it into a replayable file → never a person clicking through an
284
288
  app.
285
289
 
286
- What is wired into `staysfixed check` today is the first of those, a journeys file
287
- you point it at, and when you ask for it — the project's own test suite. Each
288
- adapter reads your source and offers the journeys it can walk: routes, commands,
289
- screens, message channels. `--journeys <file>` names steps by hand. `--journeys
290
- suite` runs each test file twice inside the scratch copy, reports every check by
291
- name, and stops after 90 seconds naming every file it did not reach. Recorded
292
- sessions and the flake register are written and tested in `src/v2/journeys/`, and
293
- **nothing on the check path calls them yet**. Saying so is the point: a feature
294
- that exists in the repository and not in the run is not a feature you have.
290
+ Each adapter reads your source and offers the journeys it can walk: routes,
291
+ commands, screens, message channels. `--journeys <file>` names steps by hand.
292
+ `--journeys suite` runs each test file twice inside the scratch copy, reports every
293
+ check by name, and stops after 90 seconds naming every file it did not reach.
294
+ `--journeys recorded` walks the sessions you made with `staysfixed record` the
295
+ one source that knows how a person actually uses the product, because every other
296
+ one worked it out by reading, and reading only ever finds which doors exist, never
297
+ which four somebody opens every morning. A recording is walked twice against the
298
+ same build before it is kept, and refused with the reason if the two walks
299
+ disagree. The flake register is written and tested and **nothing on the check path
300
+ calls it yet**. Saying so is the point: a feature that exists in the repository and
301
+ not in the run is not a feature you have.
302
+
303
+ ### Recording the session you actually perform
304
+
305
+ ```
306
+ staysfixed record the-morning-round
307
+ staysfixed check --journeys recorded
308
+ ```
309
+
310
+ The first command opens your product, follows what you do in it, and writes the
311
+ session into `.staysfixed/journeys/`. Close the window when you are done. Commit
312
+ the file: it is the promise, not the evidence.
313
+
314
+ It is the answer to the one thing reading your source can never do. The code says
315
+ which doors exist; it never says which four you open every morning, in which
316
+ order, with what in the boxes. Tell it once and it is checked for ever. On a
317
+ two-page site whose second page is named nowhere in the source, breaking that page
318
+ reports **nothing** through the journeys read out of the code — the run says
319
+ "nothing that worked has changed" — and the recorded session names the sentence
320
+ that changed.
321
+
322
+ Nothing is kept until it repeats. The session is walked twice against the same
323
+ build, by the same walker a later check uses, before a byte of it reaches the
324
+ disk; if the two walks disagree about what exists, or it could not get through its
325
+ own steps, it is refused and told why. A journey that argues with itself goes red
326
+ on somebody else's laptop for no reason, and a check nobody trusts is worse than
327
+ no check.
328
+
329
+ Two details worth knowing. Passwords, tokens, card numbers and one-time codes are
330
+ taken out on the way to the file, and the count of what was hidden is written into
331
+ it. And a page move that a click caused is **not** written down as a step — a
332
+ journey that clicked a link and then opened that address would open the second
333
+ page whether or not the link still worked, which is exactly the false all-clear
334
+ this tool exists to never give.
335
+
336
+ Two limits, said plainly. `staysfixed record` follows you around a **web** app, in
337
+ a browser of the tool's own; nothing yet follows your hands around a desktop or a
338
+ phone, though `--journeys recorded` walks a recording of whatever surface its file
339
+ names. And a recorded journey is the weakest of the sources — one path somebody
340
+ happened to take, which goes stale when the interface moves — so anything reachable
341
+ from your code or your test suite should come from there instead. Before a run
342
+ walks one, it says by name what in it may not replay.
295
343
 
296
344
  ## Keeping it quiet
297
345
 
@@ -934,11 +982,14 @@ Honestly, so you know before you invest an afternoon.
934
982
  here.** No paired run is possible on a device in your hand: two builds cannot
935
983
  exist on one handset at once. Real iPhones and real Android handsets fall back
936
984
  to comparing against the stored record, and say so out loud on every run. The
937
- emulator and the simulator are the honest answer, and both of them compare
938
- against the stored record too for Android because two emulator snapshots
939
- restoring byte-identically is unproven, and for iOS because a paired run costs
940
- two `xcodebuild` passes and belongs before a release rather than after every
941
- edit. If you ship a phone app, ask `doctor` what it is actually covering before
985
+ emulator and the simulator are the honest answer, and both of them can be
986
+ paired. That was measured on 2026-08-31: iOS agreed at 725 of 725 addresses
987
+ across five pairs, Android at 301 of 309, and the eight Android ones that moved
988
+ were the app's own identity code noise the wobble subtraction already removes.
989
+ What a paired phone run needs from you is a kept copy of the old build's APK or
990
+ `.app`, named with `reference` under `android` or `ios`, because a build output
991
+ is not in any checkout of the old commit. Without it the run falls back to the
992
+ stored record and says so on every journey. If you ship a phone app, ask `doctor` what it is actually covering before
942
993
  you trust a clean result.
943
994
  - **It is not every possible state.** "Deep" means every door the code exposes and
944
995
  every journey it was given. Nothing can enumerate every state, and any tool
@@ -955,6 +1006,13 @@ Honestly, so you know before you invest an afternoon.
955
1006
  It earns the ask: take the penny-rounding out of a `total()` and the product's
956
1007
  own output does not move by one character — the discovered journeys say nothing
957
1008
  has changed, and the harvest names the check that turned red.
1009
+ - **A recorded session is the weakest of the sources, and it says so.** It is one
1010
+ path somebody happened to take, and it goes stale the moment the interface it
1011
+ describes moves — so anything reachable from the code or from your test suite
1012
+ comes from there instead. Before a run walks one it says what in it may not
1013
+ replay, by name. What it buys is the one thing the other sources cannot reach:
1014
+ the way a person actually uses the product, which no amount of reading can work
1015
+ out.
958
1016
  - **No hosted service, no dashboard, no accounts, no teams, nothing paid.** It is
959
1017
  a command and a folder of files in your repository.
960
1018
  - **Pictures still do not travel between operating systems.** Text is drawn
package/docs/design-v2.md CHANGED
@@ -17,12 +17,13 @@
17
17
  > servers, source reading, the web, Electron, Android, iOS and native Windows over ssh. The
18
18
  > MCP surface is seven tools and it is what `staysfixed mcp` serves.
19
19
  >
20
- > **What is written and not wired:** replaying a recorded session. The code is in
21
- > `src/v2/journeys/` with tests around it and nothing on the check path calls it; asking for
22
- > it is refused by name. Harvesting a project's own test suite was in this list and is now
23
- > wired `--journeys suite`, opt-in, held to a 90-second budget, every file it did not reach
24
- > named. Journeys otherwise come from what each adapter reads out of your source, plus a
25
- > journeys file you point it at.
20
+ > **What is written and not wired:** nothing. Replaying a recorded session was the last one
21
+ > and it landed on 2026-08-31: `staysfixed record` opens the product, follows what a person
22
+ > does, walks the session twice against the same build before keeping it, and `--journeys
23
+ > recorded` walks it on every later check. Harvesting a project's own test suite was in this
24
+ > list too and is wired `--journeys suite`, opt-in, held to a 90-second budget, every file
25
+ > it did not reach named. Journeys otherwise come from what each adapter reads out of your
26
+ > source, plus a journeys file you point it at.
26
27
  >
27
28
  > **What is permanent and will not change:** nothing irreversible is ever run — it is watched
28
29
  > at the call and refused at the effect, and the refusal is reported as missing coverage;
@@ -174,7 +175,7 @@ The FlaUI probe over SSH to his office box. Built only if he ever ships a non-El
174
175
 
175
176
  ## Honest limits
176
177
 
177
- WHERE "EVERY PLATFORM, NO COMPROMISE" DOES NOT HOLD. Web, Electron, CLI, libraries and servers get the full paired treatment. Android and the iOS SIMULATOR get it with real work and one unproven assumption each. Real iPhones and real Android handsets do not — no paired run is possible on a device he is holding. Windows native GUI cannot run two builds at once even in principle, because UI Automation reads whatever desktop is in front. And any product whose old build can no longer be compiled — a yanked dependency, a dead toolchain — falls back to comparing against the stored record from the last time it ran. That fallback is genuinely weaker: it reintroduces every cross-day difference the paired design exists to eliminate. It must announce itself in those words on every run, not degrade quietly.
178
+ WHERE "EVERY PLATFORM, NO COMPROMISE" DOES NOT HOLD. Web, Electron, CLI, libraries and servers get the full paired treatment. Android and the iOS SIMULATOR get it too the assumption each was resting on was measured on 2026-08-31 and held: iOS agreed at 725 of 725 addresses across five pairs of restores, Android at 301 of 309, and the eight Android ones that moved were the app's own freshly-made identity code, which the wobble subtraction already removes. What a paired phone run needs instead is a kept copy of the OLD build's APK or .app, because a build output is not in any checkout of the old commit. Real iPhones and real Android handsets do not — no paired run is possible on a device he is holding. Windows native GUI cannot run two builds at once even in principle, because UI Automation reads whatever desktop is in front. And any product whose old build can no longer be compiled — a yanked dependency, a dead toolchain — falls back to comparing against the stored record from the last time it ran. That fallback is genuinely weaker: it reintroduces every cross-day difference the paired design exists to eliminate. It must announce itself in those words on every run, not degrade quietly.
178
179
 
179
180
  WHAT "DEEP" HONESTLY MEANS. Every door the code exposes, and every journey the existing test suite already walks. It is NOT every possible state — nothing can enumerate that, and any tool claiming otherwise is lying. The defensible claim is: it catches breaks reachable from the journeys it has, and the coverage ledger names the doors it has never opened, so the hole is visible instead of pretended away.
180
181
 
@@ -88,9 +88,11 @@ What comes back:
88
88
  finds there — routes, commands, screens, message channels — and `--journeys <file>` names
89
89
  steps by hand. `--journeys suite` adds their own test suite: each test file runs twice inside
90
90
  the scratch copy, every check is reported by name, and it stops after 90 seconds naming every
91
- file it did not reach. Replaying a recorded session is written in `src/v2/journeys/` and is
92
- **not wired into a run**: ask for `--journeys recorded` and you are told so by name. Nothing
93
- quietly substitutes different steps and hands you a clean answer about them.
91
+ file it did not reach. `--journeys recorded` walks the sessions they recorded with
92
+ `staysfixed record` the one source that knows which screens a person actually opens, which
93
+ reading their source cannot work out. Ask for it in a project with no recordings and the run
94
+ stops and says so; nothing quietly substitutes different steps and hands you a clean answer
95
+ about them.
94
96
 
95
97
  ### 3. Take the first reading
96
98
 
package/docs/guards.md CHANGED
@@ -126,6 +126,24 @@ export default {
126
126
  | `app.read(file)` | Read a project file as text. |
127
127
  | `app.project` | The resolved config and paths. |
128
128
 
129
+ ### Running your own JavaScript in the page
130
+
131
+ `page.evaluate` takes it either way round — as a piece of JavaScript written as
132
+ text, or as a function it will call inside the app:
133
+
134
+ ```js
135
+ const title = await app.page.evaluate('document.title');
136
+ const title = await app.page.evaluate(() => document.title);
137
+ ```
138
+
139
+ The function is run with nothing passed to it, so one that asks for an argument
140
+ is refused rather than quietly handed `undefined`. Close over what it needs, or
141
+ write the value into the JavaScript itself:
142
+
143
+ ```js
144
+ await app.page.evaluate(`document.title === ${JSON.stringify(expected)}`);
145
+ ```
146
+
129
147
  ---
130
148
 
131
149
  ## The `expect` style
@@ -126,16 +126,39 @@ Ranked, because this is the real workload question:
126
126
  4. **The agent exploring one named gap** and freezing it into a replayable file.
127
127
  5. Never a person clicking through an app.
128
128
 
129
- `--journeys <source>` picks between them and **one of those five is written and
130
- not wired.** A run walks (1) by default: each adapter reads your source and offers
131
- what it finds there. `--journeys <file>` names steps by hand, and `--journeys
132
- suite` adds (2), the project's own test suite each file run twice inside the
133
- scratch copy, every check reported by name, held to a 90-second budget with every
134
- file it did not reach named. Session replay lives in `src/v2/journeys/` with tests
135
- around it and nothing on the check path calls it; ask for `--journeys recorded` and
136
- you are told that by name rather than handed a clean result about steps something
137
- else chose. Saying so is the point: a feature that exists in the repository and not
138
- in the run is not a feature you have.
129
+ `--journeys <source>` picks between them. A run walks (1) by default: each adapter
130
+ reads your source and offers what it finds there. `--journeys <file>` names steps
131
+ by hand. `--journeys suite` adds (2), the project's own test suite — each file run
132
+ twice inside the scratch copy, every check reported by name, held to a 90-second
133
+ budget with every file it did not reach named. `--journeys recorded` adds (3), the
134
+ sessions somebody made with `staysfixed record` and kept in `.staysfixed/journeys`.
135
+
136
+ **Why (3) is worth having at all.** Everything above it was worked out by READING:
137
+ routes out of the source, exported names out of a package, screens out of a router.
138
+ Reading finds what the code says it does, and it is completely blind to the way a
139
+ person actually uses the thing — the four screens they open every morning, in that
140
+ order, with that data. A recorded session is the only channel that can learn that,
141
+ and once learned it is checked for ever. On a two-page site whose second page is
142
+ named nowhere in the source, breaking that page reports **nothing** through the
143
+ journeys read out of the code and is caught immediately by the recorded one.
144
+
145
+ **Nothing is recorded that has not repeated.** `staysfixed record` walks a fresh
146
+ session twice against the same build before it writes a single byte, using the very
147
+ same walker a later check uses, and refuses it if the two walks disagree about what
148
+ exists or if it could not get through the steps at all. A journey that argues with
149
+ itself would go red on somebody else's laptop for no reason, and version 1 already
150
+ proved where that ends: a flaky check does not get fixed, it gets ignored.
151
+
152
+ One trap is worth knowing about, because it is the shape of a false all-clear. When
153
+ a person clicks a link, the browser moves. Writing both down — click the link, then
154
+ open that address — makes a journey that opens the second page whether or not the
155
+ link still works, so a broken button comes back clean. A page move that arrives
156
+ right after something the person did is therefore dropped: it is the result of the
157
+ act, and the act is the step.
158
+
159
+ Asking for recorded sessions in a project that has none stops the run and says so,
160
+ naming the folder and the command that makes one. It never falls back to the steps
161
+ read out of the source and calls that a pass.
139
162
 
140
163
  ---
141
164
 
@@ -286,6 +309,16 @@ RUN`, and the reason is in the coverage as a hole. This is not a tolerance — n
286
309
  number here decides whether any single difference is real. It decides one thing:
287
310
  whether this run has earned the right to use the word clean.
288
311
 
312
+ **It is asked once per journey, not once per run.** Asking it only of everything
313
+ added together is how a page that told you nothing gets averaged away by nine
314
+ pages that behaved: 179 unsteady addresses out of 2849 is not a storm, while four
315
+ of that run's twelve journeys were unsteady at 69%, 75% and twice 100% of their
316
+ own. `noAnswerJourneys(wobbles)` is the same one comparison asked journey by
317
+ journey, and any journey it names takes the pass away and is listed by name in the
318
+ summary's first sentence. A journey walked twice that came back with no addresses
319
+ at all is in that list too — two empty walks agree about everything, and that is
320
+ not a measurement.
321
+
289
322
  ### A finding
290
323
 
291
324
  A cluster of differences that share a cause, which is what the agent reads:
package/docs/mcp.md CHANGED
@@ -178,10 +178,12 @@ out of your source. `"suite"` walks the project's own test suite as well — eac
178
178
  test file run twice inside the scratch copy, every check reported by name and why
179
179
  each failure failed, stopping after 90 seconds with every file it did not reach
180
180
  named. It is worth asking for when a change could break behaviour the product's own
181
- output would never show. You can also pass a path to a journeys file. `"recorded"`
182
- is written in `src/v2/journeys/` and not yet wired into a run: ask for it and you
183
- are told so by name, rather than given a clean result about steps something quietly
184
- chose instead.
181
+ output would never show. `"recorded"` walks the sessions a person made with
182
+ `staysfixed record` and kept in `.staysfixed/journeys` the one source that knows
183
+ which screens somebody actually opens, which reading the source cannot work out. Ask
184
+ for it in a project with no recordings and the reply comes back blocked, naming the
185
+ folder and the command that makes one, rather than giving a clean result about steps
186
+ something quietly chose instead. You can also pass a path to a journeys file.
185
187
 
186
188
  ---
187
189
 
package/docs/settings.md CHANGED
@@ -406,12 +406,15 @@ anything behind in them.
406
406
  ## `android` — an APK on an emulator
407
407
 
408
408
  Installed on an emulator of its own, walked, then removed. Compared against the
409
- stored record: whether two emulator snapshots come back byte for byte is unproven,
410
- and every run says which mode it used.
409
+ stored record, or paired against the old build if you keep a copy of it. A snapshot
410
+ restore was measured on 2026-08-31 and repeats: 301 of 309 addresses agreed across
411
+ five pairs, and the eight that moved were the app's own freshly-made identity code,
412
+ which the wobble subtraction already removes. Every run says which mode it used.
411
413
 
412
414
  | Option | What it does |
413
415
  | --- | --- |
414
416
  | `apk` | The built package. Left out, it looks in the usual build folders. |
417
+ | `reference` | A kept copy of the OLD build's package, for a paired run. An APK is a build output, so a checkout of the old commit has none. Also accepts `{ apk: '...' }`. Left out, a paired run falls back to the stored record and says so. |
415
418
  | `avd` | Which emulator to use. Left out, it takes the first one that is **not** a Play Store image. |
416
419
  | `serial` | A device already plugged in, or an emulator already running. |
417
420
  | `journeys[]` / `screens[]` | Walks through the app: `{ name, steps: [{ tap: 'Sign in' }] }`. Left out, it opens the app and reads the first screen. |
@@ -459,9 +462,15 @@ Installed on a simulator, opened, and read — the same roles, names and states
459
462
  person hears read out to them. One build at a time. macOS only: nothing on Linux
460
463
  or Windows can run an iOS simulator.
461
464
 
465
+ A paired run is offered: two restores of one build were measured on 2026-08-31 on
466
+ an iOS 27.0 simulator and 725 of 725 addresses agreed across five pairs. It needs a
467
+ kept copy of the old build's `.app`, because a build output is not in any checkout
468
+ of the old commit.
469
+
462
470
  | Option | What it does |
463
471
  | --- | --- |
464
472
  | `app` | The built app bundle for the simulator. Left out, it looks where builds land. |
473
+ | `reference` | A kept copy of the OLD build's app bundle, for a paired run. A `.app` is a build output, so a checkout of the old commit has none. Also accepts `{ app: '...' }`. Left out, a paired run falls back to the stored record and says so. |
465
474
  | `deviceType`, `runtime` | Which simulator and which system: `'iPhone 17'`, `'iOS 26.4'`. Left out it picks a sensible one and says which. |
466
475
  | `device` | An exact simulator that already exists, by name or id. |
467
476
  | `journeys[]` | Walks through the app: `{ name, steps: [{ tap: 'Sign in' }] }`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers \u2014 as a CLI and as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,7 +16,10 @@ import { EXIT } from '../core/errors.js';
16
16
  * @returns {Promise<number>}
17
17
  */
18
18
  export async function run(ctx) {
19
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
19
+ // `opening: false` approving moves a picture the last check already took. It launches
20
+ // nothing. On a project with no screen there is simply never anything waiting, and saying
21
+ // that in one line is the true answer; refusing to load the settings at all was not.
22
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
20
23
  const all = ctx.bool('all');
21
24
  const names = ctx.args.filter((a) => a.trim() !== '');
22
25
 
package/src/cli/flake.js CHANGED
@@ -17,7 +17,10 @@ import { EXIT } from '../core/errors.js';
17
17
  * @returns {Promise<number>}
18
18
  */
19
19
  export async function run(ctx) {
20
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
20
+ // `opening: false` the register is a file on disk and reading it opens nothing. Without
21
+ // this, the one command whose whole job is to name unreliable checks was itself refused on
22
+ // every command-line tool and library this tool sets up. Measured 2026-08-31.
23
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
21
24
  const history = await loadHistory(project.paths.historyFile);
22
25
 
23
26
  const forgive = ctx.str('clear');
package/src/cli/mark.js CHANGED
@@ -13,7 +13,11 @@ import { StaysFixedError, EXIT } from '../core/errors.js';
13
13
  * @returns {Promise<number>}
14
14
  */
15
15
  export async function run(ctx) {
16
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
16
+ // `opening: false` a marker is a commit, a date and a set of fingerprints written to a
17
+ // file. Nothing is opened and nothing is photographed, so a project with no screen can pin
18
+ // a known-good version exactly like any other. It was refused on every one of them until
19
+ // 2026-08-31, which took `trace` down with it: there was nothing to trace back to.
20
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
17
21
 
18
22
  if (ctx.bool('list')) return showAll(project);
19
23
 
package/src/cli/status.js CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { loadProject } from '../core/config.js';
9
9
  import { projectStatus } from '../run.js';
10
10
  import { printStatus } from '../report/console.js';
11
+ import { say, blank, heading, paint } from '../core/log.js';
11
12
  import { EXIT } from '../core/errors.js';
12
13
 
13
14
  /**
@@ -56,8 +57,59 @@ export function versionTwoState(root) {
56
57
  * @returns {Promise<number>}
57
58
  */
58
59
  export async function run(ctx) {
59
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
60
+ // `opening: false` this command reads files and prints them. It says so in its own first
61
+ // line. Loading the settings through the check that insists on something to open made the
62
+ // fastest command in the tool refuse outright on every command-line tool, library and
63
+ // server it had just set up, with a paragraph about an `app` key version 2 never writes.
64
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
60
65
  const status = await projectStatus(project);
61
66
  printStatus(/** @type {any} */ ({ ...status, v2: versionTwoState(project.paths?.root ?? ctx.cwd) }));
67
+ printWhatIsSetUp(project.config);
62
68
  return EXIT.ok;
63
69
  }
70
+
71
+ /**
72
+ * What these settings actually cover, in one short list.
73
+ *
74
+ * The promise of this command is "what is set up here", and on a project with no screen the
75
+ * picture counters answer it with four zeroes — every one of them true, and together they
76
+ * read as "nothing is set up" about a project whose settings name three commands and two
77
+ * folders of source. So the settings are read back in the words they were written in.
78
+ *
79
+ * Everything here is read out of the settings file. Nothing is inferred and nothing is
80
+ * counted that is not there, because a status line that overstates what is covered is the
81
+ * one kind of wrong this tool cannot afford.
82
+ *
83
+ * @param {import('../types.js').ResolvedConfig} config
84
+ * @returns {void}
85
+ */
86
+ function printWhatIsSetUp(config) {
87
+ const any = /** @type {Record<string, any>} */ (/** @type {unknown} */ (config));
88
+ /** @type {string[]} */
89
+ const lines = [];
90
+
91
+ const commands = Array.isArray(any.process?.commands) ? any.process.commands.filter((/** @type {any} */ c) => c && !c.skip) : [];
92
+ const imports = Array.isArray(any.process?.imports) ? any.process.imports : [];
93
+ if (commands.length > 0) lines.push(`${commands.length} ${commands.length === 1 ? 'command' : 'commands'} to run and compare word for word`);
94
+ if (imports.length > 0) lines.push(`${imports.length} ${imports.length === 1 ? 'library entry' : 'library entries'} to import and compare what ${imports.length === 1 ? 'it exports' : 'they export'}`);
95
+ if (any.source && typeof any.source === 'object') {
96
+ const folders = Array.isArray(any.source.folders) ? any.source.folders : [];
97
+ lines.push(folders.length > 0 ? `the code in ${folders.join(', ')}, read without running it` : 'the code, read without running it');
98
+ }
99
+ if (any.http && typeof any.http === 'object') lines.push('a server, booted on a spare port and asked for every route');
100
+ if (any.web && typeof any.web === 'object') {
101
+ const screens = Array.isArray(any.web.screens) ? any.web.screens.length : 0;
102
+ lines.push(screens > 0 ? `a website, and ${screens} ${screens === 1 ? 'screen' : 'screens'} of it` : 'a website');
103
+ }
104
+ if (any.electron && typeof any.electron === 'object') lines.push('a desktop app');
105
+ if (any.android && typeof any.android === 'object') lines.push('an Android app');
106
+ if (any.ios && typeof any.ios === 'object') lines.push('an iPhone app');
107
+ if (any.windows && typeof any.windows === 'object') lines.push('a native Windows app');
108
+
109
+ if (lines.length === 0) return;
110
+ heading('What these settings cover');
111
+ for (const line of lines) say(` ${paint.grey('·')} ${line}`);
112
+ blank();
113
+ say(paint.grey(` Run ${paint.cyan('staysfixed check')} to walk it, and ${paint.cyan('staysfixed coverage')} for what a run did NOT look at.`));
114
+ blank();
115
+ }