staysfixed 0.8.0 → 0.9.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.
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
@@ -1796,6 +1916,18 @@ async function gatherJourneys({ root, config, options }) {
1796
1916
 
1797
1917
  const named =
1798
1918
  options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
1919
+ // `recorded` is a word this tool knows and `check --help` offers it — it is simply not
1920
+ // wired into a run yet. The MCP surface says exactly that; the command line fell through to
1921
+ // the branch above, treated the word as a FILE PATH, and answered that a file called
1922
+ // "recorded" was missing. The same question has to get the same answer on both.
1923
+ if (options.journeys === 'recorded') {
1924
+ throw new StaysFixedError(
1925
+ 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked.',
1926
+ {
1927
+ hint: 'Leave --journeys out to use the steps each adapter reads from your source, pass `suite` to walk your own test suite, or pass the path to a journeys file.',
1928
+ },
1929
+ );
1930
+ }
1799
1931
  if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
1800
1932
 
1801
1933
  // The project's own test suite, when somebody asked for it in those words and never
@@ -1996,7 +2128,7 @@ async function readConfig(configFile) {
1996
2128
  * @param {string} product
1997
2129
  * @returns {Promise<BuildFingerprint>}
1998
2130
  */
1999
- async function fingerprintWorkingTree(root, product) {
2131
+ export async function fingerprintWorkingTree(root, product) {
2000
2132
  const sha = await git(root, ['rev-parse', 'HEAD']);
2001
2133
  if (!sha) {
2002
2134
  // 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.
package/src/v2/doctor.js CHANGED
@@ -196,7 +196,7 @@ export const CHANNELS = [
196
196
  * should be able to read it once and know what to call, what it will get back,
197
197
  * and what it must not bother asking for here.
198
198
  *
199
- * @param {{cwd?: string, configFile?: string, offline?: boolean}} [opts]
199
+ * @param {{cwd?: string, configFile?: string, offline?: boolean, machines?: boolean}} [opts]
200
200
  * @returns {Promise<Capabilities>}
201
201
  */
202
202
  export async function capabilities(opts = {}) {
@@ -218,7 +218,12 @@ export async function capabilities(opts = {}) {
218
218
 
219
219
  const [tools, hosts, repo, reference, drivers, phones, asked] = await Promise.all([
220
220
  findTools(cwd, browsers),
221
- offline ? Promise.resolve(/** @type {HostReport[]} */ ([])) : reachableHosts(),
221
+ // Only a product that could actually run somewhere else is a reason to go looking for
222
+ // somewhere else. A website or a command-line tool never needs a Windows desktop, and
223
+ // the hosts list feeds exactly one surface: that one.
224
+ offline
225
+ ? Promise.resolve(/** @type {HostReport[]} */ ([]))
226
+ : reachableHosts({ dial: opts.machines === true || desktopApp !== null }),
222
227
  isRepo(root).catch(() => false),
223
228
  findReference(root),
224
229
  whatThisCopyCanDrive(),
@@ -249,7 +254,7 @@ export async function capabilities(opts = {}) {
249
254
  },
250
255
  surfaces,
251
256
  drivers,
252
- covers: whatThisRunActuallyCovers(surfaces),
257
+ covers: whatThisRunActuallyCovers(surfaces, configFile !== null),
253
258
  browsers: {
254
259
  willOpen: browsers.chosen,
255
260
  borrowingYourOwn: browsers.borrowingHis,
@@ -1175,13 +1180,40 @@ function androidSdkTool(folder, name) {
1175
1180
  * answers is a runner the tool already has, and it must never appear in the
1176
1181
  * result as something to go and set up.
1177
1182
  *
1183
+ * @param {{dial?: boolean}} [opts]
1178
1184
  * @returns {Promise<HostReport[]>}
1179
1185
  */
1180
- export async function reachableHosts() {
1186
+ export async function reachableHosts(opts = {}) {
1181
1187
  if (!onPath('ssh')) return [];
1182
1188
  const names = await sshConfigHosts();
1183
1189
  if (names.length === 0) return [];
1184
1190
 
1191
+ // READING the ssh config is free and tells nobody anything. DIALLING is neither, and it
1192
+ // is not something this tool may do to somebody who has just installed it.
1193
+ //
1194
+ // The first command a stranger runs is `doctor`. On a brand-new scratch project with no
1195
+ // settings file and nothing that could possibly need a second machine, this opened ssh
1196
+ // connections to every host in their `~/.ssh/config` and ran a command on each — measured
1197
+ // on 2026-08-30: ten hosts configured, connections out within seconds of the first run,
1198
+ // nothing said before or after. Those are production servers in a lot of people's configs,
1199
+ // and in a lot of workplaces that alone is a policy breach. `--offline` existed, but a way
1200
+ // out you only learn about afterwards is not consent.
1201
+ //
1202
+ // So it is asked for now rather than assumed, and the machines are still NAMED either way,
1203
+ // because a machine quietly left out of the answer is the same bug as a folder quietly
1204
+ // skipped while reading source: the list looks complete and the runner somebody needed is
1205
+ // simply not in it.
1206
+ if (opts.dial !== true) {
1207
+ return names.map(
1208
+ (name) =>
1209
+ /** @type {HostReport} */ ({
1210
+ name,
1211
+ reachable: false,
1212
+ how: 'named in your ssh config and deliberately NOT dialled. Nothing here needs a second machine, and this tool does not connect to yours unasked. `staysfixed doctor --machines` checks them.',
1213
+ })
1214
+ );
1215
+ }
1216
+
1185
1217
  const dialled = await Promise.all(names.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
1186
1218
  // Anything past the cap is NAMED rather than dropped. A machine quietly left out of
1187
1219
  // this list is the same shape of bug as a folder quietly skipped while reading source:
@@ -1776,6 +1808,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1776
1808
  * @property {{name: string, why: string}[]} partly Looked at, but not completely, and why.
1777
1809
  * @property {{name: string, why: string, whoFixes: SurfaceState}[]} notCovered
1778
1810
  * @property {boolean} everything True only when nothing at all is left out.
1811
+ * @property {boolean} [canRunHere]
1812
+ * False when nothing is set up in this folder, so a check cannot run here at all
1813
+ * whatever this machine could otherwise drive.
1779
1814
  */
1780
1815
 
1781
1816
  /**
@@ -1787,9 +1822,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1787
1822
  * covers your website; your iPhone app is not being checked, and here is why.
1788
1823
  *
1789
1824
  * @param {SurfaceReport[]} surfaces
1825
+ * @param {boolean} setUpHere Whether a check can actually run in this folder at all.
1790
1826
  * @returns {Covers}
1791
1827
  */
1792
- function whatThisRunActuallyCovers(surfaces) {
1828
+ function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
1793
1829
  // Three buckets, not two. Folding "partly" into "covered" is exactly the
1794
1830
  // over-claim this function exists to stop: an iPhone app whose screens cannot
1795
1831
  // be read is not a covered iPhone app.
@@ -1813,7 +1849,23 @@ function whatThisRunActuallyCovers(surfaces) {
1813
1849
 
1814
1850
  /** @type {string[]} */
1815
1851
  const parts = [];
1816
- parts.push(full.length > 0 ? `A check here covers ${plainList(out.covered)} in full.` : 'A check here covers nothing in full.');
1852
+ // What this MACHINE can drive and what a check in THIS FOLDER would cover are two
1853
+ // different questions, and only one of them was being answered. In an empty folder — no
1854
+ // settings, no code — this said "A check here covers command-line tools and libraries and
1855
+ // web apps and sites in full" and doctor exited 0, while `check` in that same folder
1856
+ // refused to run at all: "No Stays Fixed config found here, so there is nothing to check."
1857
+ // Measured 2026-08-30. `doctor --json` is the first call an agent is told to make, which
1858
+ // is the worst place there is for a sentence with "here" in it to mean somewhere else.
1859
+ //
1860
+ // Everything below still says what it said — a reader needs the whole picture either way —
1861
+ // it is just no longer written as though a check could run.
1862
+ if (!setUpHere) {
1863
+ out.canRunHere = false;
1864
+ parts.push('Nothing is set up in this folder, so a check cannot run here at all and would cover nothing. Run `staysfixed init` first.');
1865
+ parts.push(full.length > 0 ? `Once it is set up, this machine could cover ${plainList(out.covered)} in full.` : 'Even set up, this machine could cover nothing in full.');
1866
+ } else {
1867
+ parts.push(full.length > 0 ? `A check here covers ${plainList(out.covered)} in full.` : 'A check here covers nothing in full.');
1868
+ }
1817
1869
  if (some.length > 0) parts.push(`It covers ${plainList(some.map((s) => s.name))} only partly — read the summary for each before treating a clean result as proof.`);
1818
1870
  if (missing.length > 0) {
1819
1871
  parts.push(`It does NOT check ${plainList(missing.map((s) => s.name))} at all, so a clean result says nothing whatever about ${missing.length === 1 ? 'that' : 'those'}.`);
@@ -1888,8 +1940,15 @@ function nextSteps(surfaces, reference, repo) {
1888
1940
  steps.push({
1889
1941
  what: 'record a reference',
1890
1942
  why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing.',
1891
- fix: 'staysfixed check --paired',
1892
- automatic: true,
1943
+ // Both halves of this were wrong, and they were wrong in the direction that matters.
1944
+ // `check --paired` cannot record a reference — run it twice on a fresh project and
1945
+ // both runs answer that there is no build on record, with the reference id still
1946
+ // empty — so anybody following this went round in a circle. And `automatic: true` told
1947
+ // the agent this was its to do, when the one rule underneath this whole product is
1948
+ // that only shipping cuts a reference and no agent may bless its own work. Saying an
1949
+ // agent can do the single thing it must never do is worse than saying nothing.
1950
+ fix: 'staysfixed ship (only shipping records what "working" means — no agent may cut that reference)',
1951
+ automatic: false,
1893
1952
  unlocks: 'Every check after this one has something to compare against, so "nothing changed" starts meaning something.',
1894
1953
  });
1895
1954
  }
@@ -2072,7 +2131,7 @@ export function describeCapabilities(caps) {
2072
2131
  * @returns {Promise<number>}
2073
2132
  */
2074
2133
  export async function run(ctx) {
2075
- const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline') });
2134
+ const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline'), machines: ctx.bool('machines') });
2076
2135
 
2077
2136
  if (ctx.bool('json')) {
2078
2137
  // Nothing but the object may reach standard output. Doctor is the first call
@@ -517,7 +517,11 @@ function buildEscalations(product, record, verdict) {
517
517
  kind: 'no-reference',
518
518
  what: `There is no build of ${product} on record as working yet, so this run had nothing to compare against.`,
519
519
  why: 'Only you can say what "working" means, and you say it by shipping — no agent may cut that reference.',
520
- todo: 'Ship once with the hook in place. From the next change onwards it is automatic and you will not see this again.',
520
+ // The order is said out loud because leaving it out sent people round a circle: `ship`
521
+ // on a build nothing has watched answers "run a check before the next release", and
522
+ // this line answered "you say it by shipping". Both are true and neither says which
523
+ // comes first. A check watches the build; shipping then blesses what was watched.
524
+ todo: 'Run `staysfixed check` once so there is a build to bless, then `staysfixed ship`. From the next change onwards it is automatic and you will not see this again.',
521
525
  });
522
526
  }
523
527
 
package/src/v2/init.js CHANGED
@@ -824,7 +824,10 @@ function sortNeeds(readiness, project, machine) {
824
824
  what: 'one build on record as working',
825
825
  why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing at all.',
826
826
  unlocks: 'every check from then on',
827
- fix: 'staysfixed check --paired (or ship once with `staysfixed ship` at the end of your release)',
827
+ // `check --paired` was named here first, and it does not do this: run it twice on a
828
+ // fresh project and both runs answer "there is no build on record as working". Only
829
+ // shipping cuts a reference, on purpose — no agent may bless its own work.
830
+ fix: 'staysfixed ship (only shipping records what "working" means — run it once at the end of your release)',
828
831
  who: 'the agent',
829
832
  });
830
833
  }
@@ -1148,7 +1151,10 @@ export function configText(project) {
1148
1151
  w(' // "working" means, so the name is how two of them are told apart.');
1149
1152
  if (project.products.length > 1) {
1150
1153
  w(' // This repository makes more than one thing. A check covers whichever of them the');
1151
- w(' // settings below describe; run the others with `staysfixed check --product <name>`.');
1154
+ // `--product` is not an option on `check` and never has been. Naming it here sent people
1155
+ // to a flag the CLI rejects, in the settings file the tool itself wrote for them.
1156
+ w(' // settings below describe. To check one of the others, run `staysfixed check` from');
1157
+ w(' // inside that package, or point at its settings with `--config <file>`.');
1152
1158
  }
1153
1159
  w(` product: ${JSON.stringify(project.name)},`);
1154
1160
  w('');
@@ -1342,7 +1348,12 @@ export function configText(project) {
1342
1348
  } else if (project.pages.length > 0) {
1343
1349
  w(`${webOn}// ${project.pages.length} page address${project.pages.length === 1 ? '' : 'es'} are read out of your folder names automatically — nothing to list here.`);
1344
1350
  w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
1345
- w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ fill: '#email', with: 'a@b.c' }, { click: 'Sign in' }] }],`);
1351
+ // `fill:` and `with:` are not words this tool knows the verb is `type:` and the value
1352
+ // is `text:`. An unknown key used to be skipped in silence, so this example, handed to
1353
+ // every stranger with a sign-in, filled nothing, clicked Sign in on an empty form, and
1354
+ // then photographed the login page for every screen behind the wall while reporting a
1355
+ // clean run. The example that teaches the vocabulary has to be IN the vocabulary.
1356
+ w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ type: '#email', text: 'a@b.c' }, { type: '#password', text: 'secret' }, { click: 'Sign in' }] }],`);
1346
1357
  } else {
1347
1358
  w(`${webOn}// screens: [{ name: 'the front page', url: '/' }],`);
1348
1359
  }
@@ -1568,7 +1579,14 @@ function whatItCovers(readiness) {
1568
1579
  else parts.push('Right now a check here covers nothing in full.');
1569
1580
  if (waiting.length > 0) parts.push(`${plainList(waiting, true)} ${waiting.length === 1 ? 'is' : 'are'} not covered yet, and the list below says exactly what is in the way and who has to do it.`);
1570
1581
  if (notCovered.length > 0) parts.push(`${plainList(notCovered, true)} ${notCovered.length === 1 ? 'is' : 'are'} not checked at all, so a clean result says nothing whatever about ${notCovered.length === 1 ? 'it' : 'them'}.`);
1571
- if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) parts.push('Nothing is being left out.');
1582
+ // "on this machine", because that is the only thing this sentence knows. It is built from
1583
+ // SURFACES — which kinds of product can be watched here — and says nothing whatever about
1584
+ // how much of this project a run actually walks. Unqualified, it read as a coverage
1585
+ // promise, and `getting-started` tells the agent to repeat it to the person: measured
1586
+ // 2026-08-30 on a project the same run reported as five of six doors never opened.
1587
+ if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) {
1588
+ parts.push('Nothing is being left out on this machine — though how much of the project a run actually walks is a separate question, and every check answers it.');
1589
+ }
1572
1590
 
1573
1591
  // The hole that never closes, named with the language that causes it. "Not covered yet"
1574
1592
  // reads as a job somebody will get to; this one is nobody's job and saying so is the
@@ -1600,9 +1618,14 @@ function nextCommands(readiness, project) {
1600
1618
  if (reachable.length > 0) {
1601
1619
  next.push({
1602
1620
  command: 'staysfixed check --paired',
1621
+ // It does NOT record what "working" means, and saying so here sent everybody round a
1622
+ // loop: run it, be told there is no build on record, run it again, be told the same
1623
+ // thing. Only `ship` cuts a reference — that is the rule the whole product rests on,
1624
+ // because an agent that can bless its own work is not a safety net. So this says what
1625
+ // the run actually does, and `ship` below says what only it can do.
1603
1626
  what: reachable.some((r) => r.state === 'ready')
1604
- ? 'The first real run. It records what working looks like, so later runs have something to compare against.'
1605
- : 'The first real run. Nothing here is fully set up yet, so it records what it can reach and says plainly what it left out — which is more useful than waiting.',
1627
+ ? 'The first real run. It walks everything and shows you what it sees. It cannot record what "working" means only shipping does that.'
1628
+ : 'The first real run. Nothing here is fully set up yet, so it walks what it can reach and says plainly what it left out — which is more useful than waiting.',
1606
1629
  });
1607
1630
  }
1608
1631
  if (project.tests.files > 0) {