staysfixed 0.7.2 → 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.
Files changed (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
package/src/v2/doctor.js CHANGED
@@ -36,9 +36,12 @@ import { findConfigFile, rootForConfig } from '../core/paths.js';
36
36
  import { platformTag } from '../drive/find.js';
37
37
  import { isRepo } from '../core/git.js';
38
38
  import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
39
- import { POWERSHELL_PATHS } from './remote.js';
39
+ import { POWERSHELL_PATHS, describeRemote } from './remote.js';
40
+ import { deviceToMake } from './adapters/android.js';
41
+ import { describeWindows } from './adapters/windows.js';
40
42
  import { messageOf, EXIT } from '../core/errors.js';
41
43
  import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
44
+ import { loadPlaywright } from './adapters/web-driver.js';
42
45
 
43
46
  const exec = promisify(execFile);
44
47
 
@@ -90,6 +93,19 @@ export const CHANNELS = [
90
93
  * @property {string} [powershell] The absolute path to powershell.exe that answered, when one did.
91
94
  * Kept because it is the evidence: "there is Windows behind this
92
95
  * host" is a claim, and this is the file that proves it.
96
+ * @property {import('./remote.js').RemoteDescription} [detail]
97
+ * Everything the remote runner could learn about that machine once it was known to answer:
98
+ * what is installed on it, whether anybody is signed in, whether the desktop is locked, and
99
+ * a `missing[]` carrying the exact command for each thing that is not there.
100
+ *
101
+ * WHY THE FOUR FIELDS ABOVE ARE NOT READ OUT OF IT. They come from the cheap probe, which
102
+ * is a plain `echo` down an ssh connection and needs nothing on the far machine at all.
103
+ * `detail` comes from the runner, which needs Node there. Letting the deeper answer
104
+ * overwrite the shallower one would report a perfectly reachable machine as unreachable the
105
+ * day somebody's Node is a version too old, and offer them the ssh config as the fix.
106
+ *
107
+ * Absent when the host never answered, and when `doctor --offline` skipped dialling
108
+ * altogether. Absent is "nobody asked", never "there is nothing there".
93
109
  */
94
110
 
95
111
  /**
@@ -180,7 +196,7 @@ export const CHANNELS = [
180
196
  * should be able to read it once and know what to call, what it will get back,
181
197
  * and what it must not bother asking for here.
182
198
  *
183
- * @param {{cwd?: string, configFile?: string, offline?: boolean}} [opts]
199
+ * @param {{cwd?: string, configFile?: string, offline?: boolean, machines?: boolean}} [opts]
184
200
  * @returns {Promise<Capabilities>}
185
201
  */
186
202
  export async function capabilities(opts = {}) {
@@ -202,7 +218,12 @@ export async function capabilities(opts = {}) {
202
218
 
203
219
  const [tools, hosts, repo, reference, drivers, phones, asked] = await Promise.all([
204
220
  findTools(cwd, browsers),
205
- 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 }),
206
227
  isRepo(root).catch(() => false),
207
228
  findReference(root),
208
229
  whatThisCopyCanDrive(),
@@ -233,7 +254,7 @@ export async function capabilities(opts = {}) {
233
254
  },
234
255
  surfaces,
235
256
  drivers,
236
- covers: whatThisRunActuallyCovers(surfaces),
257
+ covers: whatThisRunActuallyCovers(surfaces, configFile !== null),
237
258
  browsers: {
238
259
  willOpen: browsers.chosen,
239
260
  borrowingYourOwn: browsers.borrowingHis,
@@ -256,7 +277,15 @@ export async function capabilities(opts = {}) {
256
277
  hosts,
257
278
  nextSteps: nextSteps(surfaces, reference, repo),
258
279
  limits: PERMANENT_LIMITS,
259
- wiring: WIRING,
280
+ // The wiring block, with the real folder in it.
281
+ //
282
+ // `WIRING` carries a placeholder, because it is written once as a constant and a constant
283
+ // cannot know where it is being asked from. Handing that placeholder straight back is
284
+ // how `doctor --json` came to answer an agent's "how do I wire this up" with the literal
285
+ // words `/absolute/path/to/your/project` — a value that fails silently if pasted, and
286
+ // one the tool knew the real answer to all along. `init` had always filled it in; this
287
+ // is the same courtesy from the command an agent is told to call first.
288
+ wiring: { ...WIRING, mcp: mcpWiringFor(root) },
260
289
  };
261
290
 
262
291
  return caps;
@@ -328,6 +357,20 @@ const WIRING = {
328
357
  },
329
358
  };
330
359
 
360
+ /**
361
+ * The wiring block for one project, with its folder filled in.
362
+ *
363
+ * @param {string} root
364
+ * @returns {Record<string, unknown>}
365
+ */
366
+ function mcpWiringFor(root) {
367
+ return {
368
+ mcpServers: {
369
+ staysfixed: { command: 'npx', args: ['-y', 'staysfixed', 'mcp'], cwd: root },
370
+ },
371
+ };
372
+ }
373
+
331
374
  /**
332
375
  * The version out of package.json, read without importing the CLI — doctor has
333
376
  * to work when everything else in the project is broken.
@@ -470,22 +513,27 @@ async function findTools(cwd, browsers) {
470
513
  })(),
471
514
 
472
515
  (async () => {
473
- const installed = hasModule(cwd, 'playwright') || hasModule(cwd, 'playwright-core');
474
- // The browsers Playwright downloads live outside any project and survive
475
- // every reinstall, so finding them means half the work is already done and
476
- // telling somebody to download them again would be wrong.
516
+ // Ask the thing that actually opens the page, not the folder it might live in.
517
+ //
518
+ // This used to look for a `playwright` folder under the project being checked. That is
519
+ // the wrong question in two directions at once. The driver now ships WITH this tool, so
520
+ // it is present even when the project has never heard of it; and a project that keeps
521
+ // its packages somewhere unusual has one when the folder walk says it does not.
522
+ //
523
+ // Getting this wrong is how 0.7.2 came to tell every agent that asked that web apps and
524
+ // sites could be checked "here and now", and then answered every single website check
525
+ // with "no web page can be opened". `loadPlaywright` is the one piece of code whose
526
+ // answer is the truth, because it is the code the walk itself runs.
527
+ const state = await loadPlaywright({ projectRoot: cwd });
477
528
  const downloaded = playwrightBrowsersDir();
478
529
  add({
479
530
  id: 'playwright',
480
- name: 'Playwright',
481
- found: installed,
482
- where: downloaded ?? undefined,
483
- why: 'Reads a page’s meaning tree properly, and brings a browser of its own so yours is left alone.',
484
- fix: installed
485
- ? undefined
486
- : downloaded
487
- ? `Its browsers are already downloaded in ${downloaded}, so only the package is missing: npm install --save-dev playwright`
488
- : INSTALL_COMMAND,
531
+ name: 'a browser to read pages with',
532
+ found: state.ok,
533
+ where: state.executable ?? downloaded ?? undefined,
534
+ version: state.version,
535
+ why: 'Reads a page’s meaning tree properly, and opens a browser that is not the one you use, so yours is left alone.',
536
+ fix: state.ok ? undefined : `${state.howToGet ?? INSTALL_COMMAND} — nothing to sign up for and nobody to ask.`,
489
537
  automatic: true,
490
538
  });
491
539
  })(),
@@ -498,7 +546,15 @@ async function findTools(cwd, browsers) {
498
546
  found: app !== null,
499
547
  where: app?.where,
500
548
  why: 'A desktop app is driven straight over its own debugging port, so no browser is needed for it — only the app itself.',
501
- fix: app ? undefined : 'Only needed if the product you are watching is a desktop app. If it is, name the built app in your settings under app.binary.',
549
+ // `electron.binary`, not `app.binary`.
550
+ //
551
+ // Both keys exist and they belong to different halves of the tool: `app.binary` is
552
+ // what version 1's picture engine reads, and `electron.binary` is what the difference
553
+ // engine reads. `doctor` describes the difference engine, so telling somebody to set
554
+ // `app.binary` sent them to write a setting the adapter they are about to run never
555
+ // looks at — and the next run says there is no desktop app, for a reason they have
556
+ // just ruled out.
557
+ fix: app ? undefined : 'Only needed if the product you are watching is a desktop app. If it is, name the built app in your settings under electron.binary.',
502
558
  automatic: false,
503
559
  });
504
560
  })(),
@@ -540,7 +596,17 @@ async function findTools(cwd, browsers) {
540
596
  where: where ?? undefined,
541
597
  version: names[0],
542
598
  why: 'The emulator is the program; a virtual device is the phone it runs. Without one there is nothing to install the app onto.',
543
- fix: names.length > 0 ? undefined : 'sdkmanager "system-images;android-35;google_apis;arm64-v8a" && avdmanager create avd -n staysfixed -k "system-images;android-35;google_apis;arm64-v8a"',
599
+ // Asked of the Android adapter, never written out again here.
600
+ //
601
+ // There were two copies of this command, one in each file, and they had drifted in
602
+ // two ways at once. They named different Android versions, so doctor and the adapter
603
+ // disagreed about which device to make. And both hardcoded `arm64-v8a`, so anybody on
604
+ // an Intel Mac or an x86 Linux box was handed a command naming an image that does not
605
+ // exist for their machine — and the failure talks about the image, which sends them
606
+ // looking for the wrong thing entirely. `deviceToMake` reads this machine's processor
607
+ // and carries the Play-Store warning in the same string, so neither half can be
608
+ // separated from the other again.
609
+ fix: names.length > 0 ? undefined : deviceToMake().both,
544
610
  automatic: true,
545
611
  });
546
612
  })(),
@@ -745,7 +811,7 @@ function findDesktopApp(cwd) {
745
811
  // in a folder that contains one script.
746
812
  const text = withoutComments(readFileSync(configFile, 'utf8'));
747
813
  const named = /["']?binary["']?\s*:\s*["'`]([^"'`]+)["'`]/.exec(text);
748
- if (named) return { where: named[1], how: 'your settings name it under app.binary' };
814
+ if (named) return { where: named[1], how: 'your settings name it under electron.binary' };
749
815
  if (/["']?kind["']?\s*:\s*["'`]electron["'`]/.test(text)) {
750
816
  return { where: configFile, how: 'your settings say this project is a desktop app' };
751
817
  }
@@ -910,6 +976,54 @@ function blocks(need) {
910
976
  return need.why.startsWith('Nothing on this platform');
911
977
  }
912
978
 
979
+ /**
980
+ * One adapter's — or one machine's — `Missing` in the shape the rest of this file reads.
981
+ *
982
+ * The two shapes exist for good reasons and neither is going away: an adapter says what it
983
+ * needs and what that unlocks, and doctor has to say additionally whether a person is
984
+ * required. The translation between them is the only place that decision is made, so both
985
+ * callers make it the same way and `blocks` above keeps reading the sentence it expects.
986
+ *
987
+ * @param {import('./adapters/contract.js').Missing} m
988
+ * @returns {Need}
989
+ */
990
+ function needFromMissing(m) {
991
+ return {
992
+ what: String(m.what),
993
+ why: m.blocking === true ? 'Nothing on this platform can be checked at all without it.' : 'It widens what can be watched here.',
994
+ fix: String(m.howToGet ?? ''),
995
+ // Whether a person is needed is read out of the words, because the adapter
996
+ // contract has no field for it. A licence, an account, a pair of hands or a
997
+ // device is a person; everything else is a command the agent just runs. Being
998
+ // wrong in this direction only ever means telling somebody about a step they
999
+ // did not have to take, which is far cheaper than the other way round.
1000
+ automatic: !/licen[cs]e|apple id|app store|plug|pair of hands|somebody has to|a person|sign in|log in|only a person/i.test(String(m.howToGet ?? '')),
1001
+ unlocks: String(m.unlocks ?? ''),
1002
+ };
1003
+ }
1004
+
1005
+ /**
1006
+ * What one platform reports when the adapter that answers for it did not answer.
1007
+ *
1008
+ * Written as blocking on purpose. It is not a claim that the platform cannot be checked — it
1009
+ * is the honest opposite, that nobody here knows — and between the two ways of being wrong,
1010
+ * saying less is covered than really is costs somebody a second look, while saying more is
1011
+ * covered than really is costs them a green run that means nothing.
1012
+ *
1013
+ * @param {string} name
1014
+ * @param {string} why
1015
+ * @returns {Need}
1016
+ */
1017
+ function couldNotAsk(name, why) {
1018
+ return {
1019
+ what: `an answer from the ${name} adapter about what it needs`,
1020
+ why: 'Nothing on this platform can be checked at all without it.',
1021
+ fix: `The ${name} adapter was asked what it needs here and ${why}, so nothing on this page says whether it would work. Run \`staysfixed doctor\` again; if it keeps happening, run a check aimed at ${name} and read what that says.`,
1022
+ automatic: true,
1023
+ unlocks: `an honest answer about whether your ${name} product can be checked on this machine — right now there is none, in either direction`,
1024
+ };
1025
+ }
1026
+
913
1027
  /**
914
1028
  * The platforms that arrive as an adapter of their own, and know their own requirements.
915
1029
  * The built-in five are described by hand above, because they are older than this
@@ -960,29 +1074,26 @@ async function askTheAdapters(root) {
960
1074
  const adapter = engine.adapters.find((a) => a.name === name);
961
1075
  if (!adapter) return;
962
1076
  try {
963
- /** @type {{missing?: {what?: string, unlocks?: string, howToGet?: string, blocking?: boolean}[]}} */
1077
+ /** @type {{missing?: {what?: string, unlocks?: string, howToGet?: string, blocking?: boolean}[]}|null} */
964
1078
  const detection = await Promise.race([
965
1079
  adapter.detect({ root, config: config[name] ?? {} }),
966
- new Promise((resolve) => setTimeout(() => resolve({ missing: [] }), REACH_MS * 2)),
1080
+ // Null, not an empty answer. "The adapter says it needs nothing" and "the adapter
1081
+ // never answered" reached this line as the same empty list, and an empty list is
1082
+ // what makes a surface READY: an Android adapter that hung for sixteen seconds
1083
+ // produced "Covered against the stored record" on a machine where nothing had been
1084
+ // asked at all. A silence that turns into an all-clear is the exact failure this
1085
+ // whole tool exists to prevent, so silence now has a value of its own.
1086
+ new Promise((resolve) => setTimeout(() => resolve(null), REACH_MS * 2)),
967
1087
  ]);
968
- const needs = (detection?.missing ?? [])
1088
+ out.set(name, detection === null ? [couldNotAsk(name, `it did not answer within ${Math.round((REACH_MS * 2) / 1000)} seconds`)] : (detection.missing ?? [])
969
1089
  .filter((m) => typeof m.what === 'string' && m.what !== '')
970
- .map((m) => /** @type {Need} */ ({
971
- what: String(m.what),
972
- why: m.blocking === true ? 'Nothing on this platform can be checked at all without it.' : 'It widens what can be watched here.',
973
- fix: String(m.howToGet ?? ''),
974
- // Whether a person is needed is read out of the words, because the adapter
975
- // contract has no field for it. A licence, an account, a pair of hands or a
976
- // device is a person; everything else is a command the agent just runs. Being
977
- // wrong in this direction only ever means telling somebody about a step they
978
- // did not have to take, which is far cheaper than the other way round.
979
- automatic: !/licen[cs]e|apple id|app store|plug|pair of hands|somebody has to|a person|sign in|log in/i.test(String(m.howToGet ?? '')),
980
- unlocks: String(m.unlocks ?? ''),
981
- }))
982
- .filter((need) => need.fix !== '');
983
- out.set(name, needs);
984
- } catch {
985
- // An adapter that cannot answer contributes nothing, and the machine survey stands.
1090
+ .map((m) => needFromMissing(/** @type {import('./adapters/contract.js').Missing} */ (m)))
1091
+ .filter((need) => need.fix !== ''));
1092
+ } catch (e) {
1093
+ // Same again for an adapter that fell over. It contributes a hole, never a silence:
1094
+ // the machine survey stands, and this platform says plainly that nothing here knows
1095
+ // whether it would work.
1096
+ out.set(name, [couldNotAsk(name, messageOf(e))]);
986
1097
  }
987
1098
  }),
988
1099
  );
@@ -1069,13 +1180,40 @@ function androidSdkTool(folder, name) {
1069
1180
  * answers is a runner the tool already has, and it must never appear in the
1070
1181
  * result as something to go and set up.
1071
1182
  *
1183
+ * @param {{dial?: boolean}} [opts]
1072
1184
  * @returns {Promise<HostReport[]>}
1073
1185
  */
1074
- export async function reachableHosts() {
1186
+ export async function reachableHosts(opts = {}) {
1075
1187
  if (!onPath('ssh')) return [];
1076
1188
  const names = await sshConfigHosts();
1077
1189
  if (names.length === 0) return [];
1078
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
+
1079
1217
  const dialled = await Promise.all(names.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
1080
1218
  // Anything past the cap is NAMED rather than dropped. A machine quietly left out of
1081
1219
  // this list is the same shape of bug as a folder quietly skipped while reading source:
@@ -1126,6 +1264,9 @@ async function describeHost(name) {
1126
1264
  const ssh = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5'];
1127
1265
  const alive = await ask('ssh', [...ssh, name, `echo ${ALIVE}`], REACH_MS);
1128
1266
  if (!answered(alive)) return readHostProbe(name, alive, null);
1267
+ // Nothing more is asked of a machine that will not even echo. Everything below costs a
1268
+ // second connection, and spending it on a host that did not answer the first one is how
1269
+ // doctor stops being the quick command somebody runs when they are already stuck.
1129
1270
 
1130
1271
  // A shell that can SEE powershell.exe on the filesystem has a real Windows desktop
1131
1272
  // behind it — the cheapest Windows runner there is, and one nobody has to provision.
@@ -1135,7 +1276,64 @@ async function describeHost(name) {
1135
1276
  // it. The one list of places to look lives in remote.js, which is the file that later
1136
1277
  // has to actually run one.
1137
1278
  const look = await ask('ssh', [...ssh, name, `ls -d ${POWERSHELL_PATHS.map((p) => `'${p}'`).join(' ')}`], REACH_MS);
1138
- return readHostProbe(name, alive, look);
1279
+ const report = readHostProbe(name, alive, look);
1280
+
1281
+ // And then the half that was written and never called.
1282
+ //
1283
+ // "Reachable" on its own is not an answer anybody can act on. The requirement this whole
1284
+ // file exists to meet is that a stranger's AI can read what the tool needs and tell the
1285
+ // person, with the exact commands — and a remote machine that comes back as one boolean
1286
+ // and a sentence fails that completely. `describeRemote` answers the rest: what is
1287
+ // installed there, whether anybody is signed in on the Windows desktop, whether it is
1288
+ // locked, and a list of what is missing with the command for each.
1289
+ //
1290
+ // It never throws, but it does open a connection, so it is still wrapped: doctor answering
1291
+ // less is a bad day, and doctor not answering is the command somebody runs when they are
1292
+ // already stuck failing on them.
1293
+ try {
1294
+ const detail = await describeRemote(name, {
1295
+ // What the cheap probe already proved, handed over rather than asked again. Without
1296
+ // this, a machine with no Node on it comes back "could not be reached" and the fix
1297
+ // offered is the ssh config that just worked.
1298
+ answered: true,
1299
+ powershell: report.powershell ?? null,
1300
+ timeoutMs: REACH_MS * 2,
1301
+ windowsTimeoutMs: REACH_MS * 4,
1302
+ });
1303
+ return withRemoteDetail(report, detail);
1304
+ } catch (e) {
1305
+ return { ...report, how: `${report.how}, but nothing more could be learned about it (${messageOf(e)})` };
1306
+ }
1307
+ }
1308
+
1309
+ /**
1310
+ * Fold what the runner learned into what the cheap probe proved.
1311
+ *
1312
+ * Split out and exported so the merge can be tested without a network: the rule that the
1313
+ * shallow answer wins on reachability is the whole point of it, and a rule that only exists
1314
+ * inside a function nothing can call without an ssh key is a rule nobody will notice
1315
+ * breaking.
1316
+ *
1317
+ * @param {HostReport} report What the plain `echo` and `ls` probes established.
1318
+ * @param {import('./remote.js').RemoteDescription} detail
1319
+ * @returns {HostReport}
1320
+ */
1321
+ export function withRemoteDetail(report, detail) {
1322
+ /** @type {HostReport} */
1323
+ const out = { ...report, detail };
1324
+ // Windows, only ever added. The cheap probe asks the filesystem for powershell.exe and is
1325
+ // right whether or not anything else on that machine works; the deep one cannot ask at all
1326
+ // unless the runner started. So a `false` from the deep probe is "could not tell", and
1327
+ // letting it clear a `true` would lose the Windows runner somebody already has.
1328
+ if (detail.windows === true && out.windows !== true) {
1329
+ out.windows = true;
1330
+ if (detail.powershell) out.powershell = detail.powershell;
1331
+ }
1332
+ // `detail.how` already opens with the fact that a shell answers, so it replaces the
1333
+ // shallower sentence rather than being appended to it. Two sentences saying the same thing
1334
+ // in different words is how a reader starts wondering which of them is the real answer.
1335
+ if (!detail.runnerStarted) out.how = detail.how;
1336
+ return out;
1139
1337
  }
1140
1338
 
1141
1339
  /**
@@ -1409,7 +1607,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1409
1607
  {
1410
1608
  what: 'settings naming the built app',
1411
1609
  why: 'Two builds of one desktop app fight over its single-instance lock and its data folder, so the tool has to know exactly which file to open and give each run its own folder.',
1412
- fix: `Run \`staysfixed init\`, or set app.binary to ${desktopApp.where}.`,
1610
+ fix: `Run \`staysfixed init\`, or set electron.binary to ${desktopApp.where}.`,
1413
1611
  automatic: true,
1414
1612
  unlocks: 'Your desktop app gets checked end to end, including every IPC channel the code registers — the doors no screenshot has ever seen.',
1415
1613
  },
@@ -1417,7 +1615,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1417
1615
  });
1418
1616
  if (desktopApp === null) {
1419
1617
  notInThisProject.add('electron');
1420
- impossible.set('electron', 'This project has no desktop app in it. If yours is built somewhere else, name the built app in your settings under app.binary and this becomes available — nothing else is needed, and no browser is needed for it at all.');
1618
+ impossible.set('electron', 'This project has no desktop app in it. If yours is built somewhere else, name the built app in your settings under electron.binary and this becomes available — nothing else is needed, and no browser is needed for it at all.');
1421
1619
  }
1422
1620
 
1423
1621
  // Three separate questions, and folding any two of them together is how a surface gets
@@ -1536,25 +1734,51 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1536
1734
  // is "detect rather than ask" at its sharpest: a runner that already answers must never
1537
1735
  // be presented as something to go and set up.
1538
1736
  const windowsDriver = canDrive('windows');
1737
+ // A Windows desktop nobody has signed into is not a runner. There is nothing on it to read
1738
+ // — no windows, no controls — so calling it "partly covered" would be the exact over-claim
1739
+ // this file exists to prevent. The question can only be asked when the runner started
1740
+ // there, so `undefined` means nobody could tell and the older, more generous answer stands.
1741
+ const windowsSignedOut = windowsHost?.detail?.desktopLoggedIn === false;
1742
+ const windowsUsable = windowsHost !== undefined && windowsDriver && !windowsSignedOut;
1539
1743
  surfaces.push({
1540
1744
  id: 'windows',
1541
1745
  name: 'native Windows apps',
1542
- status: windowsHost && windowsDriver ? 'partial' : 'unavailable',
1746
+ status: windowsUsable ? 'partial' : 'unavailable',
1543
1747
  summary: !windowsHost
1544
1748
  ? 'No Windows desktop is reachable from here. This is usually fine: an Electron product on Windows is watched over the debug port instead, from any machine.'
1545
1749
  : !windowsDriver
1546
1750
  ? `A real Windows desktop is reachable through ${windowsHost.name}, and this copy of Stays Fixed cannot drive one. ${noDriver('windows')}`
1547
- : `A real Windows desktop is already reachable through ${windowsHost.name}, and nothing has to be installed on it — the program that reads the screen is sent down the ssh connection each run and disappears when it closes. Two builds still cannot run at once, because Windows shows one desktop, so runs are one after the other and the comparison is weaker here than anywhere else.`,
1548
- canCheck: windowsHost && windowsDriver ? withoutADriver : [],
1549
- cannotCheck: windowsHost && windowsDriver ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
1751
+ // The adapter's own paragraph, not a second one written here. It knows whether
1752
+ // anybody is signed in and whether the screen is locked, and those two change the
1753
+ // answer completely; a summary kept in this file could only ever guess at them, and
1754
+ // two descriptions of one machine will eventually disagree.
1755
+ : windowsHost.detail
1756
+ ? `${describeWindows(windowsHost.detail)} Nothing has to be installed on it either — the program that reads the screen is sent down the ssh connection each run and disappears when it closes.`
1757
+ : `A real Windows desktop is already reachable through ${windowsHost.name}, and nothing has to be installed on it — the program that reads the screen is sent down the ssh connection each run and disappears when it closes. Two builds still cannot run at once, because Windows shows one desktop, so runs are one after the other and the comparison is weaker here than anywhere else.`,
1758
+ canCheck: windowsUsable ? withoutADriver : [],
1759
+ cannotCheck: windowsUsable ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
1550
1760
  // Asked of the Windows adapter, which knows what it needs — the name of a machine and
1551
1761
  // the built program — rather than kept as a second opinion here. The one thing added
1552
1762
  // is the host name, because doctor found it by dialling and the adapter cannot.
1763
+ //
1764
+ // Plus whatever the machine itself is missing, which only the dial could find out: a
1765
+ // desktop nobody has signed into, a screen that is locked. Those come with the sentence
1766
+ // saying what each one unlocks, so an agent can relay one clear line to a person instead
1767
+ // of inventing instructions.
1553
1768
  needs:
1554
1769
  windowsHost && windowsDriver
1555
- ? (asked.get('windows') ?? []).map((need) => ({ ...need, fix: need.fix.replace(/"the-ssh-host-name"/g, `"${windowsHost.name}"`) }))
1770
+ ? [
1771
+ ...(windowsHost.detail?.missing ?? []).map(needFromMissing),
1772
+ ...(asked.get('windows') ?? []).map((need) => ({ ...need, fix: need.fix.replace(/"the-ssh-host-name"/g, `"${windowsHost.name}"`) })),
1773
+ ]
1556
1774
  : [],
1557
1775
  });
1776
+ if (windowsSignedOut && windowsHost) {
1777
+ impossible.set(
1778
+ 'windows',
1779
+ `${windowsHost.name} is a Windows machine and this copy can drive one, but nobody is signed in on that desktop. Nothing can be read off a desktop nobody has signed into. Sign in on it once and leave the session running — locking the screen afterwards is fine — and this becomes available.`,
1780
+ );
1781
+ }
1558
1782
  if (windowsHost && !windowsDriver) {
1559
1783
  impossible.set('windows', `${noDriver('windows')} Nothing you install on that machine changes it. Update Stays Fixed to a copy that has it.`);
1560
1784
  }
@@ -1584,6 +1808,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1584
1808
  * @property {{name: string, why: string}[]} partly Looked at, but not completely, and why.
1585
1809
  * @property {{name: string, why: string, whoFixes: SurfaceState}[]} notCovered
1586
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.
1587
1814
  */
1588
1815
 
1589
1816
  /**
@@ -1595,9 +1822,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1595
1822
  * covers your website; your iPhone app is not being checked, and here is why.
1596
1823
  *
1597
1824
  * @param {SurfaceReport[]} surfaces
1825
+ * @param {boolean} setUpHere Whether a check can actually run in this folder at all.
1598
1826
  * @returns {Covers}
1599
1827
  */
1600
- function whatThisRunActuallyCovers(surfaces) {
1828
+ function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
1601
1829
  // Three buckets, not two. Folding "partly" into "covered" is exactly the
1602
1830
  // over-claim this function exists to stop: an iPhone app whose screens cannot
1603
1831
  // be read is not a covered iPhone app.
@@ -1621,7 +1849,23 @@ function whatThisRunActuallyCovers(surfaces) {
1621
1849
 
1622
1850
  /** @type {string[]} */
1623
1851
  const parts = [];
1624
- 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
+ }
1625
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.`);
1626
1870
  if (missing.length > 0) {
1627
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'}.`);
@@ -1696,8 +1940,15 @@ function nextSteps(surfaces, reference, repo) {
1696
1940
  steps.push({
1697
1941
  what: 'record a reference',
1698
1942
  why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing.',
1699
- fix: 'staysfixed check --paired',
1700
- 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,
1701
1952
  unlocks: 'Every check after this one has something to compare against, so "nothing changed" starts meaning something.',
1702
1953
  });
1703
1954
  }
@@ -1726,6 +1977,49 @@ function nextSteps(surfaces, reference, repo) {
1726
1977
 
1727
1978
  // ── words ───────────────────────────────────────────────────────────────────
1728
1979
 
1980
+ /**
1981
+ * What one reachable machine actually is, in the plainest words there are.
1982
+ *
1983
+ * Every one of these facts was already being collected and thrown away: `describeRemote`
1984
+ * returns what is installed there, whether anybody is signed in, whether the desktop is
1985
+ * locked and what each missing thing would unlock, and doctor was printing a host name.
1986
+ *
1987
+ * Nothing is invented when the answer is unknown. A machine whose runner would not start has
1988
+ * an empty tools list because nothing could ask, not because nothing is installed, and the
1989
+ * note that says so comes from the same place the facts do.
1990
+ *
1991
+ * @param {HostReport} host
1992
+ * @returns {string[]}
1993
+ */
1994
+ function hostLines(host) {
1995
+ /** @type {string[]} */
1996
+ const lines = [];
1997
+ const detail = host.detail;
1998
+ if (!detail) {
1999
+ lines.push(`${host.name}: ${host.how}. Nothing further was asked of it.`);
2000
+ return lines;
2001
+ }
2002
+ const has = Object.entries(detail.tools).filter(([, where]) => typeof where === 'string' && where !== '').map(([name]) => name);
2003
+ const head = [detail.os, has.length > 0 ? `has ${plainList(has)}` : null].filter(Boolean).join(', ');
2004
+ lines.push(`${host.name}: ${head || detail.how}.`);
2005
+ if (detail.windows) {
2006
+ lines.push(
2007
+ ` Windows behind it${detail.windowsVersion ? ` (${detail.windowsVersion})` : ''}, ` +
2008
+ `${detail.desktopLoggedIn === false ? 'with nobody signed in on the desktop' : detail.desktopLoggedIn === true ? 'signed in' : 'and whether anybody is signed in could not be read'}` +
2009
+ `${detail.desktopLocked === true ? ', screen locked — controls still read correctly, only whole-screen pictures come back black' : ''}.`,
2010
+ );
2011
+ }
2012
+ // Each one with the exact command, because "you need Node there" and "run this" are not the
2013
+ // same message, and only one of them can be relayed to somebody who is not a programmer.
2014
+ for (const missing of detail.missing) {
2015
+ lines.push(` ${missing.blocking === true ? 'needs' : 'would help'}: ${missing.what} — ${missing.howToGet}`);
2016
+ }
2017
+ if (!detail.runnerStarted) {
2018
+ lines.push(' What is installed on it is unknown rather than absent: the program this tool sends down the connection did not start, so nothing could be asked.');
2019
+ }
2020
+ return lines;
2021
+ }
2022
+
1729
2023
  /**
1730
2024
  * The same object, said out loud. Used by the CLI, and quoted verbatim into the
1731
2025
  * MCP answer so an agent and a person are never told different things.
@@ -1799,6 +2093,10 @@ export function describeCapabilities(caps) {
1799
2093
  const runners = caps.hosts.filter((h) => h.reachable);
1800
2094
  if (runners.length > 0) {
1801
2095
  lines.push(`Other machines it can already reach: ${runners.map((h) => h.name + (h.windows ? ' (has a real Windows desktop behind it)' : '')).join(', ')}.`);
2096
+ // And then what each of them actually is. A name and the word "reachable" is not
2097
+ // something anybody can act on — the requirement is that an agent reading this can tell
2098
+ // a person what a machine needs, with the command, and a one-word answer fails that.
2099
+ for (const host of runners) for (const line of hostLines(host)) lines.push(` ${line}`);
1802
2100
  lines.push('');
1803
2101
  }
1804
2102
  // Said out loud, because a machine left undialled is a runner somebody may be
@@ -1833,7 +2131,7 @@ export function describeCapabilities(caps) {
1833
2131
  * @returns {Promise<number>}
1834
2132
  */
1835
2133
  export async function run(ctx) {
1836
- 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') });
1837
2135
 
1838
2136
  if (ctx.bool('json')) {
1839
2137
  // Nothing but the object may reach standard output. Doctor is the first call
@@ -1881,6 +2179,7 @@ export async function run(ctx) {
1881
2179
  const runners = caps.hosts.filter((h) => h.reachable);
1882
2180
  if (runners.length > 0) {
1883
2181
  say(paint.grey(` machines it can already reach: ${runners.map((h) => h.name).join(', ')}`));
2182
+ for (const host of runners) for (const line of hostLines(host)) say(paint.grey(` ${line}`));
1884
2183
  }
1885
2184
  const undialled = caps.hosts.filter((h) => h.how.startsWith('not dialled'));
1886
2185
  if (undialled.length > 0) {
@@ -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