staysfixed 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -180,10 +180,48 @@ export function watcherScript(opts) {
180
180
  " write('reached out', { host: host || 'somewhere it did not name', port: port ?? null });",
181
181
  " // Refused, not allowed through. Whatever this was going to do out there, it does not",
182
182
  " // do it twice, and the run is reported as having a hole rather than as having passed.",
183
- " const error = new Error('Stays Fixed refused a connection to ' + (host || 'an unnamed host') + ': nothing irreversible is allowed out during a check.');",
184
- " error.code = 'ECONNREFUSED';",
185
- " process.nextTick(() => this.emit('error', error));",
186
- " return this;",
183
+ " //",
184
+ " // HOW the refusal arrives matters as much as that it happens. Emitting 'error' on the",
185
+ " // socket ourselves reads correctly and kills the product: at that moment nothing is",
186
+ " // listening on the socket yet, and in Node an 'error' event with no listener is a",
187
+ " // thrown exception. `http.get` and `https.get` on Node 22 - the floor this package",
188
+ " // declares in its own engines field - and a bare `net.connect` on EVERY version all",
189
+ " // died that way, exit 1, and the run then reported the product as broken. A tool",
190
+ " // blaming a product for something the tool itself did is the exact failure this whole",
191
+ " // package exists to prevent. Measured 2026-08-30 against the published 0.8.0 watcher;",
192
+ " // its own CI had been red on this for four releases and nobody read it.",
193
+ " //",
194
+ " // So the refusal is made real rather than simulated: the socket is pointed at a port on",
195
+ " // this machine that nothing can be listening on, and the operating system produces the",
196
+ " // refusal through Node's own plumbing - by which time every listener the runtime wires",
197
+ " // up is in place. The product gets an ordinary ECONNREFUSED, which is exactly what it",
198
+ " // would get if the host were unreachable, and cannot tell the difference.",
199
+ " const named = host || 'an unnamed host';",
200
+ " const explain = 'Stays Fixed refused a connection to ' + named + ': nothing irreversible is allowed out during a check.';",
201
+ " const refusal = () => Object.assign(new Error(explain), { code: 'ECONNREFUSED', refusedBy: 'staysfixed' });",
202
+ " // Said in the error the product actually catches, without swallowing it: prepending a",
203
+ " // listener rewrites the message and still leaves every other handler to run as it would.",
204
+ " this.prependListener('error', (e) => {",
205
+ " if (e && e.code === 'ECONNREFUSED') { e.message = explain; e.refusedBy = 'staysfixed'; }",
206
+ " });",
207
+ " // Belt and braces. If something really is listening down there, the connection is cut",
208
+ " // before one byte can cross it: a boundary that fails open is not a boundary.",
209
+ " this.prependOnceListener('connect', () => { this.destroy(refusal()); });",
210
+ " // And a machine where that port is silently dropped rather than refused would hang",
211
+ " // here instead of failing, which is worse than the bug this replaced: a check that",
212
+ " // never finishes tells you nothing at all. A refusal is owed promptly, so if the",
213
+ " // operating system has not produced one shortly, produce it. Safe to do now, and only",
214
+ " // now, because the listener above means this can never be an unhandled error.",
215
+ " const soon = setTimeout(() => { if (!this.destroyed) this.destroy(refusal()); }, 250);",
216
+ " if (typeof soon.unref === 'function') soon.unref();",
217
+ " this.once('close', () => clearTimeout(soon));",
218
+ " try {",
219
+ " return connect.call(this, { port: 1, host: '127.0.0.1' });",
220
+ " } catch {",
221
+ " // Even the refusal failed. Still never throw into the product.",
222
+ " process.nextTick(() => { if (!this.destroyed) this.destroy(refusal()); });",
223
+ " return this;",
224
+ " }",
187
225
  " };",
188
226
  "} catch { /* no net module, nothing to refuse */ }",
189
227
  "",
@@ -1090,7 +1128,19 @@ export const processAdapter = defineAdapter({
1090
1128
  */
1091
1129
  export function importProbeCommand(moduleId) {
1092
1130
  const probe = [
1093
- "const m = await import(process.argv[1].startsWith('.') || process.argv[1].includes('/') ? new URL(process.argv[1], 'file://' + process.cwd() + '/').href : process.argv[1]);",
1131
+ // A FILE unless it is really a package. The old rule was "starts with a dot, or has a
1132
+ // slash in it" — and `index.js` has neither, so Node was asked for a PACKAGE called
1133
+ // "index.js" and answered ERR_MODULE_NOT_FOUND. `staysfixed init` writes exactly
1134
+ // `{ module: "index.js" }` for an ordinary package entry, so on those projects this
1135
+ // journey failed on every run, failed the SAME way on both builds, produced no
1136
+ // difference, and the check said "Nothing that worked has changed" for ever. Measured
1137
+ // 2026-08-30. So: if a file of that name is really there, it is a file.
1138
+ "const id = process.argv[1];",
1139
+ "const { existsSync } = await import('node:fs');",
1140
+ "const { fileURLToPath } = await import('node:url');",
1141
+ "const asFile = new URL(id, 'file://' + process.cwd() + '/').href;",
1142
+ "const onDisk = (() => { try { return existsSync(fileURLToPath(asFile)); } catch { return false; } })();",
1143
+ "const m = await import(id.startsWith('.') || id.startsWith('/') || id.includes('/') || onDisk ? asFile : id);",
1094
1144
  "const out = {};",
1095
1145
  "for (const key of Object.keys(m).sort()) {",
1096
1146
  " const v = m[key];",
@@ -174,6 +174,8 @@ export async function loadPlaywright(opts = {}) {
174
174
  // So: ask it. Only when there is no browser on the machine at all is this a real "no".
175
175
  /** @type {string|undefined} */
176
176
  let borrowedFrom;
177
+ /** True when the browser found is the one the PERSON uses, not a separate one. */
178
+ let borrowedTheirOwn = false;
177
179
  if (!there) {
178
180
  try {
179
181
  const { surveyBrowsers } = await import('../browsers.js');
@@ -181,6 +183,7 @@ export async function loadPlaywright(opts = {}) {
181
183
  if (survey.chosen?.binary && (await exists(survey.chosen.binary))) {
182
184
  executable = survey.chosen.binary;
183
185
  borrowedFrom = survey.chosen.name;
186
+ borrowedTheirOwn = survey.borrowingHis === true || survey.chosen.everyday === true;
184
187
  there = true;
185
188
  }
186
189
  } catch {
@@ -206,12 +209,35 @@ export async function loadPlaywright(opts = {}) {
206
209
  chromium: mod.chromium,
207
210
  version,
208
211
  executable,
209
- why: borrowedFrom
210
- ? `The browser driver ${version ?? ''} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim()
211
- : `The browser driver ${version ?? ''} is here and its Chromium is downloaded, so pages can be opened.`.trim(),
212
+ why: browserNote(version, borrowedFrom, borrowedTheirOwn),
212
213
  };
213
214
  }
214
215
 
216
+ /**
217
+ * What to say about the browser a check will open.
218
+ *
219
+ * "a separate application from the browser you use" used to be said whatever was found — and
220
+ * the one case where that sentence matters is the case where it is false. With no downloaded
221
+ * test browser anywhere, the survey falls back to the person's OWN browser, and this then
222
+ * told them the opposite of what was about to happen. A reassurance is only worth anything
223
+ * if it is withheld when it is not true.
224
+ *
225
+ * Exported so the wording is a test rather than a thing somebody has to notice.
226
+ *
227
+ * @param {string|undefined} version The driver version, if it said one.
228
+ * @param {string|undefined} borrowedFrom The browser found, if one had to be borrowed.
229
+ * @param {boolean} borrowedTheirOwn True when that browser is the person's own.
230
+ * @returns {string}
231
+ */
232
+ export function browserNote(version, borrowedFrom, borrowedTheirOwn) {
233
+ const v = version ?? '';
234
+ if (!borrowedFrom) return `The browser driver ${v} is here and its Chromium is downloaded, so pages can be opened.`.trim();
235
+ if (borrowedTheirOwn) {
236
+ return `The browser driver ${v} is here and the only browser on this machine is the one you use yourself (${borrowedFrom}). It will be opened invisibly with a throwaway profile, so your own settings, cookies and tabs are never touched — but it is your browser, not a separate one. \`npx playwright install chromium\` gives checks one of their own.`.trim();
237
+ }
238
+ return `The browser driver ${v} is here and it will open ${borrowedFrom}, which is a separate application from the browser you use, so pages can be opened.`.trim();
239
+ }
240
+
215
241
  /**
216
242
  * @param {string} file
217
243
  * @returns {Promise<boolean>}
@@ -1489,6 +1515,20 @@ export async function runStep(page, step, opts = {}) {
1489
1515
  break;
1490
1516
  }
1491
1517
  }
1518
+ // A step that did nothing at all is almost always a word this tool does not know, and
1519
+ // saying nothing about it is the worst outcome available: the journey walks on, the sign-in
1520
+ // never happens, every page behind the login wall photographs the login page, and the run
1521
+ // comes back clean. `staysfixed init` itself shipped `{ fill: '#email', with: 'a@b.c' }`
1522
+ // as its sign-in example, and neither word is in the vocabulary.
1523
+ if (did.length === 0) {
1524
+ const known = new Set([...ACTION_ORDER, 'text', 'timeoutMs', 'name', 'note', 'act', 'checkpoint', 'describe']);
1525
+ const unknown = Object.keys(step).filter((k) => !known.has(k));
1526
+ if (unknown.length > 0) {
1527
+ throw new Error(
1528
+ `This step does nothing: ${unknown.map((k) => `\`${k}\``).join(', ')} ${unknown.length === 1 ? 'is not a word' : 'are not words'} this tool knows, so the step was skipped and whatever it was meant to do did not happen. The steps it understands are: ${ACTION_ORDER.join(', ')} — with \`text\` beside \`type\`. To type into a field: { type: '#email', text: 'a@b.c' }.`,
1529
+ );
1530
+ }
1531
+ }
1492
1532
  return did;
1493
1533
  }
1494
1534
 
@@ -40,7 +40,6 @@ import crypto from 'node:crypto';
40
40
  import fs from 'node:fs';
41
41
  import fsp from 'node:fs/promises';
42
42
  import path from 'node:path';
43
- import { spawn } from 'node:child_process';
44
43
 
45
44
  import {
46
45
  countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
@@ -50,6 +49,7 @@ import { copyForScratch, frozenEnvironment } from './process.js';
50
49
  import { freePort, looksDestructive, waitForServer } from './http.js';
51
50
  import { applyFreeze, prepareForShutter } from '../../freeze/index.js';
52
51
  import { settle } from '../../freeze/settle.js';
52
+ import { spawnServer, stopServer } from './child.js';
53
53
  import {
54
54
  actOf, countRoles, flattenAria, inkOf, loadPlaywright, openWindow, parseAria, runStep, short,
55
55
  watchTheWire, whereItIs, withLimit,
@@ -537,7 +537,7 @@ export const webAdapter = defineAdapter({
537
537
  if (!verdict.safe) notes.push(verdict.why);
538
538
  else {
539
539
  const done = await new Promise((resolve) => {
540
- const child = spawn(String(config.restore), { shell: true, cwd: work, env, stdio: 'ignore' });
540
+ const child = spawnServer(String(config.restore), { cwd: work, env, stdio: 'ignore' });
541
541
  child.on('error', () => resolve(false));
542
542
  child.on('close', (code) => resolve(code === 0));
543
543
  });
@@ -549,7 +549,7 @@ export const webAdapter = defineAdapter({
549
549
  const said = [];
550
550
  /** @type {string|null} */
551
551
  let exited = null;
552
- const child = spawn(String(config.start), { shell: true, cwd: work, env, stdio: ['ignore', 'pipe', 'pipe'] });
552
+ const child = spawnServer(String(config.start), { cwd: work, env });
553
553
  child.stdout?.on('data', (c) => said.push(c));
554
554
  child.stderr?.on('data', (c) => said.push(c));
555
555
  child.on('close', (code, signal) => {
@@ -558,14 +558,14 @@ export const webAdapter = defineAdapter({
558
558
 
559
559
  const up = await waitForServer(port, { timeoutMs: config.startTimeoutMs ?? 90000, crashed: () => exited });
560
560
  if (!up.up) {
561
- child.kill('SIGTERM');
561
+ await stopServer(child);
562
562
  return {
563
563
  build,
564
564
  root: work,
565
565
  ready: false,
566
566
  why: `${up.why} What it printed while trying: ${trimForStorage(Buffer.concat(said).toString('utf8'), 1500).text || '(nothing)'}`,
567
567
  dispose: async () => {
568
- child.kill('SIGKILL');
568
+ await stopServer(child);
569
569
  await fsp.rm(base, { recursive: true, force: true });
570
570
  },
571
571
  };
@@ -584,9 +584,7 @@ export const webAdapter = defineAdapter({
584
584
  running.delete(build.id);
585
585
  if (!held) return;
586
586
  // Only ever the process we started ourselves.
587
- held.child?.kill('SIGTERM');
588
- await new Promise((r) => setTimeout(r, 400));
589
- if (held.child && held.child.exitCode === null) held.child.kill('SIGKILL');
587
+ await stopServer(held.child);
590
588
  await fsp.rm(base, { recursive: true, force: true });
591
589
  },
592
590
  };
@@ -779,7 +777,7 @@ export const webAdapter = defineAdapter({
779
777
  },
780
778
 
781
779
  async teardown() {
782
- for (const [, held] of running) held.child?.kill('SIGTERM');
780
+ for (const [, held] of running) await stopServer(held.child);
783
781
  running.clear();
784
782
  },
785
783
  });
@@ -270,6 +270,16 @@ function testingBrowsers() {
270
270
  take('chromium', path.join(inner, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'));
271
271
  take('chrome-for-testing', path.join(inner, 'chrome-linux', 'chrome'));
272
272
  take('chrome-for-testing', path.join(inner, 'chrome-win', 'chrome.exe'));
273
+ // The 64 matters, and leaving it off made this tool blind to full Chrome on every
274
+ // machine that is not a Mac. Playwright unpacks Linux into `chrome-linux64` and
275
+ // Windows into `chrome-win64`; Puppeteer does the same. Only macOS uses the names
276
+ // above, which is why it was never noticed here. On Linux this meant `npx playwright
277
+ // install chromium` - the command THIS FILE tells people to run - left a browser the
278
+ // survey could not see, and checks quietly fell back to the headless shell, or said
279
+ // there was no browser at all when the shell was not there too. Measured on a real
280
+ // Linux box on 2026-08-30: three Chromes present, none of them found.
281
+ take('chrome-for-testing', path.join(inner, 'chrome-linux64', 'chrome'));
282
+ take('chrome-for-testing', path.join(inner, 'chrome-win64', 'chrome.exe'));
273
283
  take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell'), true);
274
284
  take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-x64', 'chrome-headless-shell'), true);
275
285
  take('headless-shell', path.join(inner, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), true);
@@ -534,11 +544,56 @@ function killNow(pid, home) {
534
544
  } catch {
535
545
  // Already gone. That is the outcome we wanted.
536
546
  }
547
+ // SIGKILL is a request to the kernel, not something that has already happened, and a
548
+ // browser is not one process. While the parent is being reaped its children are still
549
+ // writing into the profile, so deleting the folder in the same breath loses the race:
550
+ // the sweep starts, a file appears behind it, the directory is not empty, and the
551
+ // profile outlives the run - which is the one thing `nothing it opened outlives the
552
+ // run` promises. It passed on macOS and on an idle Linux box and failed on a loaded CI
553
+ // runner for four releases, which is exactly how a race behaves.
554
+ for (let i = 0; i < 40 && stillThere(pid); i++) waitSync(10);
537
555
  }
556
+ // Then remove it, and more than once. One rmSync is a snapshot; a file recreated a
557
+ // millisecond after the sweep began turns it into ENOTEMPTY and a leftover folder.
558
+ for (let attempt = 0; attempt < 5; attempt += 1) {
559
+ try {
560
+ fs.rmSync(home, { recursive: true, force: true });
561
+ return;
562
+ } catch {
563
+ waitSync(20);
564
+ }
565
+ }
566
+ // A profile left in the temporary folder is untidy, not harmful — and it is exactly what
567
+ // `staysfixed browsers --clean` is for.
568
+ }
569
+
570
+ /**
571
+ * Is that process still running? Signal 0 asks without sending anything.
572
+ * @param {number} pid
573
+ * @returns {boolean}
574
+ */
575
+ function stillThere(pid) {
576
+ try {
577
+ process.kill(pid, 0);
578
+ return true;
579
+ } catch {
580
+ return false;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * A real sleep, in the one place nothing may be awaited: this runs inside
586
+ * `process.on('exit')`, where the event loop is already over and a promise never
587
+ * resolves. `Atomics.wait` is the only thing that actually pauses here.
588
+ *
589
+ * @param {number} ms
590
+ */
591
+ function waitSync(ms) {
538
592
  try {
539
- fs.rmSync(home, { recursive: true, force: true });
593
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
540
594
  } catch {
541
- // A profile left in the temporary folder is untidy, not harmful.
595
+ // No SharedArrayBuffer here. Falling straight through is still better than throwing
596
+ // out of a cleanup handler.
542
597
  }
543
598
  }
544
599
 
package/src/v2/check.js CHANGED
@@ -95,7 +95,7 @@ const exec = promisify(execFile);
95
95
  * quietly checked something else and found nothing", and those two read identically
96
96
  * without this. It is only ever set when the run really did reach that surface.
97
97
  *
98
- * @typedef {Verdict & {blocked?: boolean, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
98
+ * @typedef {Verdict & {blocked?: boolean, comparedNothing?: 'no reference'|'no stored record'|null, accounted?: import('./escalate.js').Accounting, target?: {surface: string, at: string|null}}} CheckOutcome
99
99
  */
100
100
 
101
101
  /**
@@ -315,8 +315,31 @@ export async function check(options = {}) {
315
315
  // "nothing was walked" on a run that walked plenty.
316
316
  await countTheDoors(verdict, project);
317
317
 
318
+ // THE GUARDS. `settle` takes the names so that a difference touching a bug somebody
319
+ // already had once is sealed and no agent may wave it through — and both call sites used
320
+ // to hand it nothing, so that class was empty on every run this tool has ever done. The
321
+ // one seal that exists because a person was burned before could never fire.
322
+ //
323
+ // Running them is version 1's job and needs its browser driver, which this path does not
324
+ // have. That is a real hole and it is now SAID rather than left silent: measured on
325
+ // 2026-08-30, a check on a project with a guard sitting in `.staysfixed/guards` printed
326
+ // the word "guard" exactly zero times. A tool built to catch silent breakage must not do
327
+ // nothing silently.
328
+ const named = await guardNames(project.root);
329
+ if (named.length > 0 && verdict.coverage) {
330
+ verdict.coverage.gaps = [
331
+ ...(verdict.coverage.gaps ?? []),
332
+ {
333
+ what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
334
+ why:
335
+ `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check. ` +
336
+ `\`staysfixed check --guards\` walks them. ${named.map((n) => `"${n}"`).join(', ')}`,
337
+ },
338
+ ];
339
+ }
340
+
318
341
  /** @type {CheckOutcome} */
319
- const outcome = await settle(verdict, project.store, project.product);
342
+ const outcome = await settle(verdict, project.store, project.product, named);
320
343
  // Only a run that really did reach the surface it was aimed at may say so. The
321
344
  // confirmation is what lets a caller tell "it went there and found nothing" from
322
345
  // "it checked something else and found nothing", and those are not the same answer.
@@ -432,6 +455,14 @@ async function settle(verdict, store, product, guards) {
432
455
  // nothing came back different — and it is the exact sentence that would let a real
433
456
  // regression through. It is not a pass. It is no answer at all.
434
457
  const nothing = comparedNothing(verdict);
458
+ // Carried as a FIELD, not only folded into a sentence. Every other surface has to be
459
+ // able to ask this question without reading English: the MCP server rendered its own
460
+ // headline from the difference count alone, never looked at `ok`, and answered a
461
+ // machine "NOTHING UNACCOUNTED FOR. Everything that worked before still works" with
462
+ // `ok: true` and `isError: false` on a project that had nothing on record at all —
463
+ // while the terminal, on the same run, correctly said it was no answer. The agent is
464
+ // the reader that matters most here, and it was the one being told the untrue thing.
465
+ verdict.comparedNothing = nothing;
435
466
  if (nothing) {
436
467
  verdict.ok = false;
437
468
  verdict.summary =
@@ -628,6 +659,92 @@ function comparedNothing(verdict) {
628
659
  return nothingToCompare >= walked ? 'no stored record' : null;
629
660
  }
630
661
 
662
+ /**
663
+ * The guards this project has, by name.
664
+ *
665
+ * Names only: sealing a difference by the guard it touches needs the name, and nothing on
666
+ * this path can run one. `loadGuards` wants only the folder, and it is the same reader
667
+ * version 1 uses, so a guard version 1 accepts is a guard this counts.
668
+ *
669
+ * @param {string} root
670
+ * @returns {Promise<string[]>}
671
+ */
672
+ export async function guardNames(root) {
673
+ try {
674
+ const { loadGuards } = await import('../guard/load.js');
675
+ const guards = await loadGuards(/** @type {any} */ ({ paths: { guards: path.join(root, '.staysfixed', 'guards') } }));
676
+ return guards.map((g) => String(g?.name ?? '')).filter(Boolean);
677
+ } catch {
678
+ // A guards folder that will not load must never stop a check running. Version 1 says the
679
+ // same about the same folder, and a check that refuses is worse than one without a seal.
680
+ return [];
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Copies left behind by runs that never finished.
686
+ *
687
+ * A check copies the whole project into a scratch folder, and a run that is killed — Ctrl-C,
688
+ * a laptop closing, a CI job cancelled — never gets to delete it. Nothing else ever did
689
+ * either: measured on 2026-08-30, an ordinary machine had 777 MB of `staysfixed-check-*`
690
+ * sitting in the temporary folder, one copy of it 485 MB, and a later run added to the pile
691
+ * rather than clearing it. A tool that quietly fills somebody's disk is not one they keep.
692
+ *
693
+ * The rule is the one the browser sweep already uses: never touch something in use. A folder
694
+ * whose owner is still running is left completely alone, and one with no owner recorded is
695
+ * only taken once it is far older than any real run could be. A dead process id that has
696
+ * since been reused reads as "still running" and the folder survives, which is the safe way
697
+ * round to be wrong.
698
+ *
699
+ * @returns {Promise<void>}
700
+ */
701
+ export async function sweepAbandonedScratch() {
702
+ const AN_HOUR = 60 * 60 * 1000;
703
+ const MOST_PER_RUN = 20;
704
+ let names = [];
705
+ try {
706
+ names = await fsp.readdir(os.tmpdir());
707
+ } catch {
708
+ return;
709
+ }
710
+ let taken = 0;
711
+ for (const name of names) {
712
+ if (taken >= MOST_PER_RUN) break;
713
+ if (!name.startsWith('staysfixed-check-')) continue;
714
+ const dir = path.join(os.tmpdir(), name);
715
+ let abandoned = false;
716
+ try {
717
+ const owner = JSON.parse(await fsp.readFile(path.join(dir, 'owner.json'), 'utf8'));
718
+ abandoned = typeof owner?.pid !== 'number' || !processAlive(owner.pid);
719
+ } catch {
720
+ // No owner recorded: either an older copy or one that died before it could say. Age is
721
+ // all there is to go on, and an hour is well past the longest run this tool makes.
722
+ try {
723
+ abandoned = Date.now() - (await fsp.stat(dir)).mtimeMs > AN_HOUR;
724
+ } catch {
725
+ abandoned = false;
726
+ }
727
+ }
728
+ if (!abandoned) continue;
729
+ await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
730
+ taken += 1;
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Is that process still running? Signal 0 asks without sending anything.
736
+ * @param {number} pid
737
+ * @returns {boolean}
738
+ */
739
+ function processAlive(pid) {
740
+ try {
741
+ process.kill(pid, 0);
742
+ return true;
743
+ } catch {
744
+ return false;
745
+ }
746
+ }
747
+
631
748
  /**
632
749
  * One gap's sentence, trimmed to something that fits inside another sentence.
633
750
  * @param {string} what
@@ -1382,9 +1499,12 @@ async function openProject(options) {
1382
1499
  storeTrouble.push(`The folder Stays Fixed keeps its records in could not be made: ${messageOf(e)}`);
1383
1500
  }
1384
1501
 
1502
+ await sweepAbandonedScratch();
1385
1503
  const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
1386
1504
  const evidenceDir = path.join(scratch, 'evidence');
1387
1505
  await fsp.mkdir(evidenceDir, { recursive: true });
1506
+ // Who this belongs to, so a later run can tell an abandoned copy from one in use.
1507
+ await fsp.writeFile(path.join(scratch, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() })).catch(() => {});
1388
1508
 
1389
1509
  // Working out what there is to walk comes FIRST, before anything is asked of git. Somebody
1390
1510
  // standing in a folder they have not set up yet should be told to run `init`, not told
@@ -1996,7 +2116,7 @@ async function readConfig(configFile) {
1996
2116
  * @param {string} product
1997
2117
  * @returns {Promise<BuildFingerprint>}
1998
2118
  */
1999
- async function fingerprintWorkingTree(root, product) {
2119
+ export async function fingerprintWorkingTree(root, product) {
2000
2120
  const sha = await git(root, ['rev-parse', 'HEAD']);
2001
2121
  if (!sha) {
2002
2122
  // REFUSING IS THE ONLY HONEST ANSWER HERE, and the alternative is the worst bug this
package/src/v2/cli.js CHANGED
@@ -37,6 +37,7 @@ import { escalationBlock, escalationsFor, productFor, writeEscalations } from '.
37
37
  // module is still being evaluated.
38
38
  import { watchFlags } from '../cli/watch-flags.js';
39
39
  import { INIT_COMMANDS } from './init.js';
40
+ import { whatWasNotChecked } from './check.js';
40
41
 
41
42
  /**
42
43
  * What comes back from a check. Everything that did not change never appears
@@ -167,16 +168,17 @@ export const V2_COMMANDS = {
167
168
 
168
169
  doctor: {
169
170
  summary: 'What this tool can and cannot check on this machine, and what would unlock more.',
170
- usage: 'staysfixed doctor [--json] [--offline] [--fix]',
171
+ usage: 'staysfixed doctor [--json] [--machines] [--offline] [--fix]',
171
172
  describe:
172
- 'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.',
173
+ 'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.\n\nIt does not connect to the machines in your ssh config unless this project has a\ndesktop app that might need one, or you pass --machines. They are still listed.',
173
174
  options: [
174
175
  ['--json', 'The whole answer as one JSON object. For agents.'],
176
+ ['--machines', 'Also dial the machines in your ssh config, to find a runner for a desktop app.'],
175
177
  ['--offline', 'Do not dial any other machine. Faster, and reports no runners.'],
176
178
  ['--fix', 'Repair the small local things that can be repaired safely.'],
177
179
  ],
178
180
  examples: ['staysfixed doctor', 'staysfixed doctor --json'],
179
- spec: { booleans: ['json', 'offline', 'fix'] },
181
+ spec: { booleans: ['json', 'offline', 'fix', 'machines'] },
180
182
  load: async () => ({ run: doctorRun }),
181
183
  },
182
184
  };
@@ -243,23 +245,26 @@ export async function run(ctx) {
243
245
  // checks on the command line and then ships is told their build was "never
244
246
  // checked", and the safeguard fires on the honest case instead of the careless
245
247
  // one. The agent surface records its own; this is the command line's half.
246
- try {
247
- const { recordCheck } = await import('./reference.js');
248
- const { openStore } = await import('./store.js');
249
- await recordCheck(openStore({ root: ctx.cwd ?? process.cwd() }), {
250
- buildId: verdict.candidate?.id,
251
- product: verdict.product,
252
- ok: verdict.ok,
253
- blocked: /** @type {any} */ (verdict).blocked === true,
254
- findings: verdict.findings.length,
255
- by: 'staysfixed check',
256
- });
257
- } catch {
258
- // Never let bookkeeping cost somebody the result they came for.
259
- }
248
+ // NOT recorded again here. The engine writes this line itself, for every surface, inside
249
+ // `rememberCheck` and with more in it than this ever had: what was waived, what was
250
+ // sealed, how much went unaccounted for. This block was added on the belief that only the
251
+ // agent surface recorded, so every command-line check wrote TWO near-identical rows two
252
+ // milliseconds apart. Measured 2026-08-30: eight rows for four checks, and `ship` reads
253
+ // this log to decide whether the last check was clean.
260
254
 
261
255
  if (asJson) {
262
- process.stdout.write(JSON.stringify(verdict) + '\n');
256
+ // The README promises these as fields of their own — "a number an agent can read"
257
+ // rather than a sentence it has to parse — and only the MCP reply had them. So a human
258
+ // asking for JSON on the command line got a strictly worse answer about what was NOT
259
+ // checked than an agent asking over MCP, about the very same run.
260
+ const coverage = verdict.coverage ?? null;
261
+ process.stdout.write(
262
+ JSON.stringify({
263
+ ...verdict,
264
+ notChecked: whatWasNotChecked(coverage),
265
+ doorsNeverOpened: Math.max(0, (coverage?.doorsKnown ?? 0) - (coverage?.doorsWalked ?? 0)),
266
+ }) + '\n',
267
+ );
263
268
  } else {
264
269
  report(verdict);
265
270
  }
package/src/v2/cluster.js CHANGED
@@ -186,20 +186,75 @@ export function findRenames(differences) {
186
186
 
187
187
  /** @type {Map<Difference, {from: string, to: string}>} */
188
188
  const found = new Map();
189
+ /** @type {{was: string, now: string, from: string, to: string}[]} */
190
+ const moves = [];
189
191
  for (const place of places.values()) {
190
192
  if (place.gone.length !== 1 || place.came.length !== 1) continue;
191
193
  const gone = place.gone[0];
192
194
  const came = place.came[0];
193
- if (!sameValue(gone.reference, came.candidate)) continue;
194
195
  const from = leafOf(gone.path);
195
196
  const to = leafOf(came.path);
196
197
  if (from === to) continue;
198
+ // "The values must match" is right for a thing addressed by its position, and wrong for
199
+ // one addressed by its own words. A heading lives at `heading:Nine Bakers` and its value
200
+ // reads `a heading called "Nine Bakers"` — rename it and BOTH move, so the values never
201
+ // match and the rename was never spotted. Measured 2026-08-30: renaming one heading on a
202
+ // page came back as five separate findings, one thing vanishing and a different thing
203
+ // appearing, with nothing anywhere saying "renamed". So a value that changed in exactly
204
+ // the same way the name did counts as the same value. Anything else is still two edits
205
+ // that happened to land side by side, which is what this test exists to keep out.
206
+ if (!sameValue(gone.reference, came.candidate) && !movedWithItsName(gone.reference, came.candidate, from, to)) continue;
197
207
  found.set(gone, { from, to });
198
208
  found.set(came, { from, to });
209
+ moves.push({ was: gone.path, now: came.path, from, to });
210
+ }
211
+
212
+ // A rename takes its children with it. Everything under the old address goes away and the
213
+ // same things arrive under the new one — true, and not a second piece of news. Renaming one
214
+ // heading on a page reported the heading AND the two halves of its own `level`, so one edit
215
+ // a person would describe in four words arrived as four findings. Anything that moved with
216
+ // it, unchanged, belongs to the rename that moved it.
217
+ for (const move of moves) {
218
+ for (const gone of differences) {
219
+ if (gone.kind !== 'vanished' || found.has(gone)) continue;
220
+ if (!gone.path.startsWith(`${move.was}.`)) continue;
221
+ const wanted = move.now + gone.path.slice(move.was.length);
222
+ const came = differences.find(
223
+ (d) => d.kind === 'appeared' && !found.has(d) && d.path === wanted && d.channel === gone.channel && sameValue(gone.reference, d.candidate),
224
+ );
225
+ if (!came) continue;
226
+ found.set(gone, { from: move.from, to: move.to });
227
+ found.set(came, { from: move.from, to: move.to });
228
+ }
199
229
  }
200
230
  return found;
201
231
  }
202
232
 
233
+ /**
234
+ * Did the value change in exactly the way the name did?
235
+ *
236
+ * Only for two addresses naming the same KIND of thing — `heading:X` and `heading:Y`, never
237
+ * `heading:X` and `button:Y` — because the part before the colon is what the thing IS, and a
238
+ * heading becoming a button is not a rename.
239
+ *
240
+ * @param {unknown} before
241
+ * @param {unknown} after
242
+ * @param {string} from
243
+ * @param {string} to
244
+ * @returns {boolean}
245
+ */
246
+ function movedWithItsName(before, after, from, to) {
247
+ if (typeof before !== 'string' || typeof after !== 'string') return false;
248
+ const wasNamed = /^([^:]+):(.+)$/.exec(from);
249
+ const nowNamed = /^([^:]+):(.+)$/.exec(to);
250
+ if (!wasNamed || !nowNamed) return false;
251
+ if (wasNamed[1] !== nowNamed[1]) return false;
252
+ const was = wasNamed[2];
253
+ const now = nowNamed[2];
254
+ if (!was || was === now) return false;
255
+ return before.split(was).join(now) === after;
256
+ }
257
+
203
258
  /**
204
259
  * The grouping key: channel, shape of change, what the address ends in, and the
205
260
  * move from one value to another.