staysfixed 0.10.0 → 0.11.1

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 (42) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +17 -5
  3. package/docs/getting-started.md +10 -0
  4. package/docs/how-v2-works.md +5 -2
  5. package/package.json +2 -2
  6. package/src/guard/api.js +208 -25
  7. package/src/guard/run.js +154 -20
  8. package/src/report/console.js +235 -17
  9. package/src/report/html.js +75 -19
  10. package/src/types.js +5 -0
  11. package/src/v2/adapters/android-driver.js +62 -12
  12. package/src/v2/adapters/contract.js +18 -4
  13. package/src/v2/adapters/electron.js +96 -14
  14. package/src/v2/adapters/http.js +264 -23
  15. package/src/v2/adapters/ios-driver.js +22 -4
  16. package/src/v2/adapters/ios.js +5 -2
  17. package/src/v2/adapters/isolate.js +78 -5
  18. package/src/v2/adapters/process.js +350 -92
  19. package/src/v2/adapters/web-driver.js +23 -1
  20. package/src/v2/adapters/web.js +42 -3
  21. package/src/v2/adapters/windows.js +32 -15
  22. package/src/v2/browsers.js +32 -1
  23. package/src/v2/check.js +319 -9
  24. package/src/v2/cli.js +345 -3
  25. package/src/v2/cluster.js +112 -4
  26. package/src/v2/coverage.js +208 -8
  27. package/src/v2/detect.js +182 -9
  28. package/src/v2/doctor.js +214 -39
  29. package/src/v2/init.js +97 -11
  30. package/src/v2/mcp/server.js +4 -1
  31. package/src/v2/mcp/tools.js +291 -24
  32. package/src/v2/observation.js +57 -5
  33. package/src/v2/reference.js +133 -14
  34. package/src/v2/refusal.js +389 -0
  35. package/src/v2/remote.js +24 -3
  36. package/src/v2/run.js +306 -16
  37. package/src/v2/sealed.js +14 -2
  38. package/src/v2/ship.js +286 -22
  39. package/src/v2/store.js +101 -2
  40. package/src/v2/types.js +5 -0
  41. package/src/v2/waiver.js +9 -2
  42. package/src/watch/panel.js +12 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,157 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.11.1] — 2026-08-31
12
+
13
+ Two defects that a Mac could never have shown, both caught by CI on Linux minutes after
14
+ 0.11.0 went out. Both were verified as broken and then as fixed on a real Linux machine
15
+ rather than by reading the code.
16
+
17
+ - **A command a guard had been abandoned mid-flight kept running.** Killing the shell is not
18
+ killing what the shell started: a command runs through a shell, so the signal reaches the
19
+ shell and the program it started carries on with a new parent. On macOS the shell usually
20
+ takes its child with it and this was invisible; on Linux it does not, and a command the run
21
+ had given up on finished its work 800 milliseconds later and wrote its file. It now runs in
22
+ its own process group and the group is what gets signalled. (`exec` does not document
23
+ `detached`; `spawn` does, so this moved to `spawn` rather than resting on behaviour that
24
+ happens to work.)
25
+ - **The ordinary way of closing a browser left its throwaway profile behind.** One `rm` is a
26
+ snapshot, and a browser is not one process: the parent exiting says nothing about its
27
+ renderers, which are still writing into the profile while they are reaped. The last-resort
28
+ path had learned this; the polite path — the one every ordinary run uses — swallowed the
29
+ failure with a bare catch. It passes on macOS and on an idle Linux box and fails on a loaded
30
+ one, which is exactly how a race behaves.
31
+ - **`doctor` asked the machine before it asked the project.** For iPhone and Windows apps the
32
+ platform test came first, so on Linux a project containing no iPhone app was told its
33
+ non-existent app could not be reached from this machine — a machine reason given for a
34
+ project fact, which is the exact conflation that sends somebody to install thirty gigabytes
35
+ of Xcode for nothing. The project is asked first now, on every surface and every platform.
36
+
37
+ ## [0.11.0] — 2026-08-31
38
+
39
+ Fifty-odd defects, found by running the thing rather than reading it. Two rounds of seven
40
+ lanes, each lane required to reproduce its defect on a throwaway product before touching
41
+ anything, and each fix held by a test that fails without it.
42
+
43
+ **All eight surfaces have now actually been driven against a real product** — command-line
44
+ tools, libraries, servers, websites, Electron, Android, iOS and, last of all, native Windows.
45
+ That sentence was not true before this release. Each of the four that had only ever had an
46
+ adapter and tests turned out to hold at least one defect that only running it could find.
47
+
48
+ ### The three that could have blessed a broken build
49
+
50
+ - **A refusal was stored as an ordinary value.** When a journey cannot run — the product
51
+ throws on its first line, a command dies before printing anything — what got written down
52
+ was a fact about the crash. Two builds that crash the same way agree at every address, and
53
+ agreement is what this tool reads as "nothing changed". So a product that did nothing at
54
+ all came back *"Nothing that worked has changed. 7 addresses checked"*, `ship` blessed it,
55
+ and the day somebody fixed the product, four findings arrived that nobody had caused. A
56
+ refusal is now a different kind of thing from a value: never compared to one, never
57
+ compared to another refusal as though both were answers, and refused at `ship` in
58
+ `shouldCut`, where every caller passes through.
59
+ - **`settle()` assigned the verdict instead of narrowing it**, throwing away every not-a-pass
60
+ the engine had already decided — a run where the product never answered, and a run drowning
61
+ in wobble, both came back through that line as a pass. Accounting may take a pass away; it
62
+ may never hand one back.
63
+ - **A reference cut from a tree with uncommitted changes was booted from its commit.** The
64
+ record is filed under a fingerprint of the tree that was walked, because the files checked
65
+ are not the files git has. Paired mode walked the commit and called it the old build, so an
66
+ address the record holds a real value for was reported as *"is there now and was not
67
+ before"*.
68
+
69
+ ### It reported things as checked that were never checked
70
+
71
+ - **A root-level server went unread the moment the project also had a `src/` folder.** A
72
+ deleted route and a 200 turning into a 500 both came back "Nothing that worked has changed".
73
+ - **`ship` blessed a run in which every journey refused**, and **the command-line surface was
74
+ hard-coded ready** and never looked at the project.
75
+ - **`init` said a library "can be checked here now" and is "covered in full"** about an
76
+ `index.js` that does not exist.
77
+ - **`init` asked the machine what it could do before working out the settings**, so on a fresh
78
+ project every question that is answered by reading the settings was answered against none.
79
+ A plain Node command-line tool was told it needed "a command to run" by the same run that
80
+ had just written `node cli.js --help` into its settings.
81
+
82
+ ### It stated things it had never looked for
83
+
84
+ - **"No Windows desktop is reachable from here"** on a project whose settings named the
85
+ machine — with that machine reachable, signed in and unlocked the whole time. Nothing had
86
+ been dialled. Naming a machine in your own settings is the ask; the rest of your ssh config
87
+ is still left alone, and when nothing was dialled the sentence now says so.
88
+ - **The live panel called every failed guard "broken again"**, with the story of the original
89
+ bug printed underneath as though it were proof. A guard that ran out of time did not answer
90
+ the question, and one that asserted nothing never asked it.
91
+ - The HTML report said *"All 3 bugs that were fixed are still fixed"* over three guards that
92
+ were skipped, and *"Everything that worked still works"* over a run that checked nothing at
93
+ all.
94
+
95
+ ### Nothing hangs, and nothing waits in silence
96
+
97
+ - **Every wait in the phone, desktop and Windows adapters now has a limit, and every limit
98
+ says what it was waiting for.** The cause was never a missing timeout: waits were settled by
99
+ a child's `close` event, which does not mean the program ended — it means nobody anywhere is
100
+ still holding its pipes, and one orphaned grandchild refuses that for ever. A verdict
101
+ printed, and then nothing, for ever. That is what the unexplained Electron deadlock was.
102
+ - **A mistyped limit removed the limit.** `Number("10s")` is `NaN`, and every comparison
103
+ against `NaN` is false, so a step with a typo in its settings looped without end.
104
+ - **A stock Vite app cost three minutes and told you nothing.** Vite ignores the port and host
105
+ it is handed and binds the name `localhost`, which on a Mac is the IPv6 loopback — so the
106
+ site came up on `[::1]` and the check knocked on `127.0.0.1` about 450 times a side. Boot
107
+ waits now knock on both, and a command that ignores its port, or exits, is named in seconds:
108
+ three minutes became ten seconds.
109
+ - **A person at a terminal is told what the run is waiting for.** The running commentary
110
+ reached the live panel and nothing else, so a long wait was a blank screen — which is
111
+ indistinguishable from the tool being broken.
112
+ - **Reclaiming a scratch folder now stops what is still running inside it.** Four servers from
113
+ the previous day were still up on this machine, out of folders that had already been deleted.
114
+
115
+ ### The measurement no longer measures itself
116
+
117
+ - **With nothing edited, a check compared the build against the same build** — and called
118
+ whatever flickered between two runs minutes apart a change nobody made. On a stock Next.js
119
+ app with a link between two pages, four runs in five reported a difference, and one claimed
120
+ the change "made something non-deterministic". Two runs of one build are a wobble
121
+ measurement, which is this tool's own word for it, and it now says so.
122
+ - **The record of what "working" means moved on its own.** The store keeps every capture a
123
+ build ever produced and the reader took the newest, so anybody checking out the old commit
124
+ and running a check quietly replaced the standard. Only `ship` decides what working means.
125
+ - **A request cancelled by our own teardown was reported as the product complaining.** Next.js
126
+ starts a prefetch behind every internal link; closing the page aborts it, and the browser
127
+ reports that exactly like a real failure. It is recorded as a hole now — louder than a
128
+ complaint, never quieter.
129
+
130
+ ### The report no longer disagrees with itself
131
+
132
+ - **A renamed field was described only as the field that appeared**, never the one that
133
+ vanished — the half that breaks every caller, on the channel that exists to catch it.
134
+ - **Two byte-identical runs produced different `coverage.gaps`**, and `coverage.doorsWalked`
135
+ counted doors as walked in the same report whose only line about them said "was not tried".
136
+ - **`ship` said "nothing is being compared against anything yet"** with a reference in force.
137
+ - **`ship` read the product name only out of a JSON settings file** — and every settings file
138
+ `init` writes is JavaScript. So it blessed under the package name while `check` recorded
139
+ under the settings name, and the two never met: a project could ship and check for ever
140
+ without once comparing anything.
141
+
142
+ ### The person and the agent get the same truth
143
+
144
+ - **Five commands a person did not have:** `coverage`, `explain`, `prove`, `waive` and
145
+ `intent`. All are commands now, `check` prints the finding ids so there is something to hand
146
+ them, and a test asserts the two readers get byte-identical facts.
147
+ - **An intent sealed at the command line was recorded as "an agent, over MCP".**
148
+ - **Six places sent a person to something only an agent can call** — a tool name, a JSON
149
+ field, an `include:` block — and `check --help` still said intent and waive live only on the
150
+ MCP server.
151
+ - **`staysfixed_waive` refused the only file in the product**, because an agent holds absolute
152
+ paths and the seal expected relative ones; **`staysfixed_coverage` forced the machine survey
153
+ offline** and reported the forced answer as a fact about the world.
154
+
155
+ ### What a person is handed when something goes wrong
156
+
157
+ - An unwritable `TMPDIR` handed over a raw `ENOENT ... mkdtemp`; a check run inside a nested
158
+ project silently checked the parent's product; `flake` said "No check here has ever changed
159
+ its mind" about a register whose own JSON showed a guard flipping, and gave an untrue
160
+ reason; a monorepo container folder was announced as a product.
161
+
11
162
  ## [0.10.0] — 2026-08-31
12
163
 
13
164
  The night the four surfaces nobody had pointed it at were pointed at it, and the biggest
package/README.md CHANGED
@@ -166,7 +166,13 @@ pretend otherwise.
166
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
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
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. |
169
- | Native Windows GUI (a real Win32 app, not an Electron one) | **The probe is here**, driven over ssh to any machine that reaches a Windows desktop a WSL shell on one counts, and nothing is installed on it. Windows shows one desktop, so two builds can never run at once: the comparison is genuinely weaker here than anywhere else. |
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
+
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
173
+ product, rather than only having an adapter and tests. That sentence was not true
174
+ before 2026-08-31, and the four that were unproven each turned out to have at
175
+ least one defect that only running them could find.
170
176
 
171
177
  `staysfixed check` is the front door for both. Version 1's flags still mean
172
178
  exactly what they meant yesterday — `--pictures`, `--guards` and `--only` reach
@@ -357,7 +363,10 @@ everything, because it did not: it checked every way in *this tool knows about*.
357
363
  staysfixed check --json # coverage.doorsKnown, coverage.doorsWalked, coverage.gaps
358
364
  ```
359
365
 
360
- Over MCP it is `staysfixed_coverage`. Every `staysfixed_check` reply says it in
366
+ At a terminal it is `staysfixed coverage`; over MCP it is `staysfixed_coverage`.
367
+ Both roads reach the same code — that stopped being true for a while, and a person
368
+ got a strictly worse answer about their own product than an agent did, which is
369
+ now a test. Every `staysfixed_check` reply says it in
361
370
  words directly under the headline, and the JSON form carries `notChecked` and
362
371
  `doorsNeverOpened` as fields of their own rather than only as prose — a number an
363
372
  agent has to go looking for is a number it skips.
@@ -634,9 +643,12 @@ Full wiring for every client: [docs/mcp.md](docs/mcp.md).
634
643
  Every version ships knowing, in machine-readable form and in plain English: what
635
644
  it can check on this machine right now and what it cannot; what is missing that
636
645
  would unlock more, and whether the tool can install it itself or a person has to;
637
- which other machines it can already reach, **found by dialling them** rather than
638
- by asking you; and the shape of its own results, so an agent can act on them
639
- without being taught. That is `staysfixed doctor --json`, and it is
646
+ which other machines your ssh config names, and for the ones your own settings
647
+ name, plus any you ask about with `--machines` whether they answer, what they
648
+ run and what they are short of; and the shape of its own results, so an agent can
649
+ act on them without being taught. It does **not** dial your machines unasked: the
650
+ first command a stranger runs must not open connections to their production
651
+ servers, so naming a machine in your settings is what asks about it. That is `staysfixed doctor --json`, and it is
640
652
  `staysfixed_capabilities` over MCP.
641
653
 
642
654
  ---
@@ -297,6 +297,16 @@ Say these once, when someone asks how much it covers. They are permanent, they a
297
297
  | `staysfixed check --against <ref>` | Compare against a tag, commit or marker. |
298
298
  | `staysfixed check --selfcheck` | Prove the engine still catches deliberate breakage. |
299
299
  | `staysfixed ship` | The build that went out is now what "working" means. |
300
+ | `staysfixed coverage` | What the last check did NOT look at. Read it before calling anything safe. |
301
+ | `staysfixed intent "<what you meant to change>"` | Seal what you meant to change, before you check. |
302
+ | `staysfixed explain <id>` | One finding from the last check, in full. |
303
+ | `staysfixed prove <id>` | Undo your own edit and re-measure, to see whether it really caused the finding. |
304
+ | `staysfixed waive <id> --why "<reason>"` | Record that a difference was intended. It is not approval. |
305
+
306
+ Those last five existed only over MCP until 2026-08-31, which meant a person at a terminal
307
+ got a strictly worse answer about their own product than an agent did. Every one of them is
308
+ now a command as well, and both roads reach the same code. `check` prints the finding ids, so
309
+ there is something to hand them.
300
310
 
301
311
  If you have to write a settings block by hand — something `init` could not know, a second
302
312
  product, a journey through a screen — every option is in
@@ -366,8 +366,11 @@ It carries:
366
366
  the seven channels are reachable for each
367
367
  - what is missing, why it matters, and the exact command that would fix it —
368
368
  marked with whether the tool can do it itself or a person has to
369
- - which other machines it can already reach, **detected by dialling them**, so a
370
- working SSH host is never presented as something to go and set up
369
+ - which other machines your ssh config names, and whether the ones it is allowed
370
+ to dial answer the ones your own settings name, plus any you ask about with
371
+ `--machines` — so a working SSH host is never presented as something to go and
372
+ set up. Nothing is dialled unasked: the first command a stranger runs must not
373
+ open connections to their production servers
371
374
  - the shape of its own results, so an agent can act on them without being taught
372
375
  - what it will never be able to see, on any machine
373
376
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.10.0",
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 as a CLI and as an MCP server.",
3
+ "version": "0.11.1",
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",
7
7
  "author": "Asad Iqbal",
package/src/guard/api.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * off IS the guard's own words, in the guard's own order.
16
16
  */
17
17
 
18
- import { exec } from 'node:child_process';
18
+ import { spawn } from 'node:child_process';
19
19
  import fsp from 'node:fs/promises';
20
20
  import path from 'node:path';
21
21
  import { StaysFixedError } from '../core/errors.js';
@@ -31,6 +31,26 @@ export class ExpectationFailed extends Error {
31
31
  }
32
32
  }
33
33
 
34
+ /**
35
+ * The run has given up on this guard, and the guard is still going.
36
+ *
37
+ * Every door in the object a guard is handed throws this once the attempt that opened it has
38
+ * been abandoned. Measured on 2026-08-31: a guard with `timeoutMs: 200`, ticking every 25
39
+ * milliseconds, had written 7 lines to a file by the time the run reported it and 26 half a
40
+ * second later — the timeout is a `Promise.race`, and losing a race does not stop the loser.
41
+ * It went on clicking, reading and holding a page that the guard after it was already using.
42
+ *
43
+ * Nobody prints this. By the time it is thrown the verdict for this guard is already written,
44
+ * and the point of it is only to unwind a body the run has stopped listening to.
45
+ */
46
+ export class GuardAbandoned extends Error {
47
+ /** @param {string} message */
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = 'GuardAbandoned';
51
+ }
52
+ }
53
+
34
54
  const DEFAULT_RUN_TIMEOUT = 60_000;
35
55
 
36
56
  /** Commands can print a lot; 10MB before we cut them off. */
@@ -57,6 +77,11 @@ const ACTION = 'did';
57
77
  * @property {(step: import('../types.js').CheckStep) => void} [onStep]
58
78
  * Called as each claim and each action starts, and again as it settles.
59
79
  * The two calls carry the same `key`.
80
+ * @property {AbortSignal} [signal]
81
+ * Raised when the run has given up on this guard — its clock ran out, or its
82
+ * attempt has simply already returned. Every door here refuses from then on, so
83
+ * the body stops at its very next step instead of driving the app behind a run
84
+ * that has moved on.
60
85
  */
61
86
 
62
87
  /**
@@ -70,8 +95,67 @@ const ACTION = 'did';
70
95
  export function makeGuardApi(page, project, opts = {}) {
71
96
  const root = project.paths.root;
72
97
  const onStep = typeof opts.onStep === 'function' ? opts.onStep : null;
98
+ const givenUp = opts.signal;
73
99
  let counted = 0;
74
100
 
101
+ /**
102
+ * Refuse to do anything more once the run has given up on this guard.
103
+ *
104
+ * This is what makes a timeout mean something. Before it, a guard that ran out of time
105
+ * kept its whole body running: measured on 2026-08-31, 7 file writes by the time the run
106
+ * reported it and 26 half a second later, every one of them free to click the page the
107
+ * next guard had just started on. Refusing at the door is the only stopping there is —
108
+ * JavaScript cannot take a promise back — but it is enough, because a guard reaches the
109
+ * app through this object, and it reaches it constantly.
110
+ *
111
+ * @returns {void}
112
+ */
113
+ function stopHere() {
114
+ if (!givenUp?.aborted) return;
115
+ throw refusal();
116
+ }
117
+
118
+ /** @returns {GuardAbandoned} */
119
+ function refusal() {
120
+ return new GuardAbandoned(
121
+ 'The run had already given up on this guard and moved on, so this step was refused rather than run.',
122
+ );
123
+ }
124
+
125
+ /**
126
+ * The page with one extra rule: once the run has given up on this guard, it refuses.
127
+ *
128
+ * `app.page` is documented as "the whole page", so a guard that drives the app itself
129
+ * never passes through the five methods below — and those were exactly the guards left
130
+ * holding a page after their own timeout. The wrapper belongs to this attempt and dies
131
+ * with it; the real handle is untouched, and every other part of the tool keeps using it.
132
+ *
133
+ * @param {import('../types.js').PageApi} handle
134
+ * @returns {import('../types.js').PageApi}
135
+ */
136
+ function refusable(handle) {
137
+ if (!givenUp) return handle;
138
+ /** @type {Record<string, unknown>} */
139
+ const doors = {};
140
+ for (const key of Object.keys(handle)) {
141
+ const value = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (handle))[key];
142
+ if (typeof value !== 'function') {
143
+ doors[key] = value;
144
+ continue;
145
+ }
146
+ // Refused in the same shape the door answers in. `goto` is awaited and `consoleErrors`
147
+ // is read, and a refusal that arrives as a thrown error where a value was expected —
148
+ // or as a rejected promise where none was — is a second surprise on top of the first.
149
+ const answersWithAPromise = value.constructor?.name === 'AsyncFunction';
150
+ doors[key] = (/** @type {unknown[]} */ ...args) => {
151
+ if (!givenUp.aborted) return /** @type {(...a: unknown[]) => unknown} */ (value).apply(handle, args);
152
+ if (answersWithAPromise) return Promise.reject(refusal());
153
+ throw refusal();
154
+ };
155
+ }
156
+ return /** @type {import('../types.js').PageApi} */ (/** @type {unknown} */ (doors));
157
+ }
158
+
75
159
  /**
76
160
  * Hand one step out, and never let that matter.
77
161
  *
@@ -111,7 +195,7 @@ export function makeGuardApi(page, project, opts = {}) {
111
195
  }
112
196
 
113
197
  return {
114
- page,
198
+ page: refusable(page),
115
199
  project,
116
200
 
117
201
  /**
@@ -119,6 +203,7 @@ export function makeGuardApi(page, project, opts = {}) {
119
203
  * @returns {Promise<void>}
120
204
  */
121
205
  async open(to) {
206
+ stopHere();
122
207
  const settle = announce(ACTION, `opened ${short(to)}`);
123
208
  try {
124
209
  await page.goto(to);
@@ -134,6 +219,7 @@ export function makeGuardApi(page, project, opts = {}) {
134
219
  * @returns {Promise<void>}
135
220
  */
136
221
  async click(selector) {
222
+ stopHere();
137
223
  const settle = announce(ACTION, `clicked ${short(selector)}`);
138
224
  try {
139
225
  await page.click(selector);
@@ -150,6 +236,7 @@ export function makeGuardApi(page, project, opts = {}) {
150
236
  * @returns {Promise<void>}
151
237
  */
152
238
  async expect(claim, check) {
239
+ stopHere();
153
240
  if (typeof claim !== 'string' || claim.trim() === '') {
154
241
  throw new StaysFixedError('An expectation needs a sentence in front of it.', {
155
242
  hint: 'Write it the way you would say it: expect("the sidebar is hidden", () => ...). That sentence is what a person reads when the guard fails.',
@@ -202,34 +289,129 @@ export function makeGuardApi(page, project, opts = {}) {
202
289
  * @returns {Promise<{code: number, stdout: string, stderr: string}>}
203
290
  */
204
291
  async run(cmd, runOpts = {}) {
292
+ // Before the command is started, not after. A guard the run has given up on must not
293
+ // be able to leave a process of its own behind it.
294
+ stopHere();
205
295
  const cwd = runOpts.cwd ? path.resolve(root, runOpts.cwd) : root;
206
296
  const timeoutMs = runOpts.timeoutMs ?? DEFAULT_RUN_TIMEOUT;
207
297
  const settle = announce(ACTION, `ran ${short(cmd)}`);
208
298
 
209
299
  /** @type {Promise<{code: number, stdout: string, stderr: string}>} */
210
- const finished = new Promise((resolve) => {
211
- exec(
212
- cmd,
213
- { cwd, timeout: timeoutMs, maxBuffer: MAX_OUTPUT, encoding: 'utf8' },
214
- (error, stdout, stderr) => {
215
- const out = String(stdout ?? '');
216
- let err = String(stderr ?? '');
217
- let code = 0;
218
-
219
- if (error) {
220
- const e = /** @type {any} */ (error);
221
- if (e.killed || e.signal) {
222
- // 124 is what `timeout(1)` uses, so a guard can spot it.
223
- code = 124;
224
- err += `\n(the command was stopped after ${humanTime(timeoutMs)})`;
225
- } else {
226
- code = typeof e.code === 'number' ? e.code : 1;
227
- }
228
- }
229
-
230
- resolve({ code, stdout: out, stderr: err });
231
- },
232
- );
300
+ const finished = new Promise((resolve, reject) => {
301
+ // `spawn` with `shell: true` rather than `exec`, for one reason: `detached`.
302
+ //
303
+ // KILLING THE SHELL IS NOT KILLING WHAT THE SHELL STARTED. A command runs through a
304
+ // shell, so a signal reaches the shell and the program it started carries on with a
305
+ // new parent. On a Mac the shell usually takes its child with it and this was
306
+ // invisible; on Linux it does not, and a command the run had given up on finished its
307
+ // work 800ms later, wrote its file, and proved it — caught by CI on 2026-08-31
308
+ // against a green Mac suite. `detached` puts the shell and everything it starts in
309
+ // one process group, and the group is what gets signalled.
310
+ //
311
+ // `exec` cannot do this: `detached` is not one of its documented options. It happens
312
+ // to be passed through today, and a tool built on not lying should not rest on that.
313
+ const child = spawn(cmd, {
314
+ cwd,
315
+ shell: true,
316
+ detached: process.platform !== 'win32',
317
+ windowsHide: true,
318
+ });
319
+
320
+ let out = '';
321
+ let err = '';
322
+ let tooMuch = false;
323
+ /** @type {'ran'|'gave up'|'ran out of time'} */
324
+ let how = 'ran';
325
+ let done = false;
326
+
327
+ /** Stop the shell AND everything it started. */
328
+ const stopEverything = () => {
329
+ if (!child.pid) return;
330
+ try {
331
+ if (process.platform === 'win32') child.kill('SIGKILL');
332
+ else process.kill(-child.pid, 'SIGKILL');
333
+ } catch {
334
+ // Already gone, which is the good case.
335
+ }
336
+ };
337
+
338
+ child.stdout?.setEncoding('utf8');
339
+ child.stderr?.setEncoding('utf8');
340
+ child.stdout?.on('data', (/** @type {string} */ chunk) => {
341
+ if (out.length + chunk.length > MAX_OUTPUT) {
342
+ tooMuch = true;
343
+ out = (out + chunk).slice(0, MAX_OUTPUT);
344
+ stopEverything();
345
+ return;
346
+ }
347
+ out += chunk;
348
+ });
349
+ child.stderr?.on('data', (/** @type {string} */ chunk) => {
350
+ if (err.length + chunk.length > MAX_OUTPUT) {
351
+ tooMuch = true;
352
+ err = (err + chunk).slice(0, MAX_OUTPUT);
353
+ stopEverything();
354
+ return;
355
+ }
356
+ err += chunk;
357
+ });
358
+
359
+ const ranOut = setTimeout(() => {
360
+ how = 'ran out of time';
361
+ stopEverything();
362
+ }, timeoutMs);
363
+ if (typeof ranOut.unref === 'function') ranOut.unref();
364
+
365
+ const gaveUp = () => {
366
+ how = 'gave up';
367
+ stopEverything();
368
+ };
369
+ givenUp?.addEventListener('abort', gaveUp, { once: true });
370
+ if (givenUp?.aborted) gaveUp();
371
+
372
+ /** @param {number} code */
373
+ const finish = (code) => {
374
+ if (done) return;
375
+ done = true;
376
+ clearTimeout(ranOut);
377
+ givenUp?.removeEventListener('abort', gaveUp);
378
+
379
+ // Stopped because the run gave up on this guard, not because the command ran long.
380
+ // Saying "stopped after 60 seconds" here would be a made-up reason.
381
+ if (how === 'gave up') {
382
+ reject(
383
+ new GuardAbandoned(
384
+ 'The run gave up on this guard while this command was still going, so the command was stopped.',
385
+ ),
386
+ );
387
+ return;
388
+ }
389
+ if (how === 'ran out of time') {
390
+ // 124 is what `timeout(1)` uses, so a guard can spot it.
391
+ resolve({ code: 124, stdout: out, stderr: `${err}\n(the command was stopped after ${humanTime(timeoutMs)})` });
392
+ return;
393
+ }
394
+ if (tooMuch) {
395
+ resolve({ code: 124, stdout: out, stderr: `${err}\n(the command was stopped after printing more than this tool will keep)` });
396
+ return;
397
+ }
398
+ resolve({ code, stdout: out, stderr: err });
399
+ };
400
+
401
+ child.on('error', (/** @type {any} */ e) => {
402
+ if (done) return;
403
+ done = true;
404
+ clearTimeout(ranOut);
405
+ givenUp?.removeEventListener('abort', gaveUp);
406
+ if (how === 'gave up') {
407
+ reject(new GuardAbandoned('The run gave up on this guard while this command was still going, so the command was stopped.'));
408
+ return;
409
+ }
410
+ resolve({ code: 1, stdout: out, stderr: `${err}\n${String(e?.message ?? e)}` });
411
+ });
412
+ // `close`, not `exit`: exit fires when the shell ends, and everything it printed has
413
+ // to have been read before the answer is handed back.
414
+ child.on('close', (/** @type {number|null} */ code) => finish(typeof code === 'number' ? code : 1));
233
415
  });
234
416
 
235
417
  const outcome = await finished;
@@ -247,6 +429,7 @@ export function makeGuardApi(page, project, opts = {}) {
247
429
  * @returns {Promise<string>}
248
430
  */
249
431
  async read(file) {
432
+ stopHere();
250
433
  const full = path.resolve(root, file);
251
434
  const relative = path.relative(root, full);
252
435
  // A guard belongs to one project; reading outside it makes the guard depend