staysfixed 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +17 -5
  3. package/docs/getting-started.md +10 -0
  4. package/docs/how-v2-works.md +5 -2
  5. package/package.json +2 -2
  6. package/src/guard/api.js +208 -25
  7. package/src/guard/run.js +154 -20
  8. package/src/report/console.js +235 -17
  9. package/src/report/html.js +75 -19
  10. package/src/types.js +5 -0
  11. package/src/v2/adapters/android-driver.js +62 -12
  12. package/src/v2/adapters/contract.js +18 -4
  13. package/src/v2/adapters/electron.js +96 -14
  14. package/src/v2/adapters/http.js +264 -23
  15. package/src/v2/adapters/ios-driver.js +22 -4
  16. package/src/v2/adapters/ios.js +5 -2
  17. package/src/v2/adapters/isolate.js +78 -5
  18. package/src/v2/adapters/process.js +350 -92
  19. package/src/v2/adapters/web-driver.js +23 -1
  20. package/src/v2/adapters/web.js +42 -3
  21. package/src/v2/adapters/windows.js +32 -15
  22. package/src/v2/browsers.js +32 -1
  23. package/src/v2/check.js +319 -9
  24. package/src/v2/cli.js +345 -3
  25. package/src/v2/cluster.js +112 -4
  26. package/src/v2/coverage.js +208 -8
  27. package/src/v2/detect.js +182 -9
  28. package/src/v2/doctor.js +214 -39
  29. package/src/v2/init.js +97 -11
  30. package/src/v2/mcp/server.js +4 -1
  31. package/src/v2/mcp/tools.js +291 -24
  32. package/src/v2/observation.js +57 -5
  33. package/src/v2/reference.js +133 -14
  34. package/src/v2/refusal.js +389 -0
  35. package/src/v2/remote.js +24 -3
  36. package/src/v2/run.js +306 -16
  37. package/src/v2/sealed.js +14 -2
  38. package/src/v2/ship.js +286 -22
  39. package/src/v2/store.js +101 -2
  40. package/src/v2/types.js +5 -0
  41. package/src/v2/waiver.js +9 -2
  42. package/src/watch/panel.js +12 -1
package/src/v2/doctor.js CHANGED
@@ -196,7 +196,8 @@ 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, machines?: boolean}} [opts]
199
+ * @param {{cwd?: string, configFile?: string, offline?: boolean, machines?: boolean,
200
+ * settingsText?: string}} [opts]
200
201
  * @returns {Promise<Capabilities>}
201
202
  */
202
203
  export async function capabilities(opts = {}) {
@@ -210,6 +211,28 @@ export async function capabilities(opts = {}) {
210
211
  // sitting two folders up, and told the agent to go and build one that was already there.
211
212
  const root = configFile ? rootForConfig(configFile) : cwd;
212
213
 
214
+ // The settings, as text, from whichever of the two places they live in.
215
+ //
216
+ // `init` is the second place. It works out what it is about to write and only then asks
217
+ // this function what the machine can do — so on a fresh project every question below was
218
+ // answered against NO settings at all, and the answers went straight into the readiness
219
+ // it printed. A plain Node command-line tool was told, by the same run that had just
220
+ // wired `node cli.js --help` into its settings, that it still needed "a command to run".
221
+ // Being sent to set up something the tool has already set up is how somebody decides this
222
+ // page is not worth reading. Measured 2026-08-31.
223
+ const settingsText = opts.settingsText ?? readTextOrNull(configFile);
224
+ // A machine NAMED in the settings is a machine the project has asked for. Doctor does not
225
+ // dial anybody's ssh config unasked — that rule stands and is right — but "you have not
226
+ // asked me to look" and "your own settings name this machine" are different situations,
227
+ // and treating them the same produced a flat untruth: with `windows: { host: "imza-pc" }`
228
+ // sitting in the settings, doctor answered "No Windows desktop is reachable from here"
229
+ // having never dialled anything. Measured 2026-08-31 against a Windows 11 machine that was
230
+ // reachable, signed in and unlocked the whole time. Only the named machines are dialled;
231
+ // the rest of the ssh config is still left alone.
232
+ const askedFor = hostsNamedInSettings(settingsText);
233
+ const settingsAreJson = opts.settingsText ? false : configFile !== null && configFile.endsWith('.json');
234
+ const hasSettings = settingsText !== null;
235
+
213
236
  // The browser survey comes first because three different answers below depend
214
237
  // on it, and asking this machine the same question three times would be both
215
238
  // slow and a way for the three answers to disagree.
@@ -223,15 +246,16 @@ export async function capabilities(opts = {}) {
223
246
  // the hosts list feeds exactly one surface: that one.
224
247
  offline
225
248
  ? Promise.resolve(/** @type {HostReport[]} */ ([]))
226
- : reachableHosts({ dial: opts.machines === true || desktopApp !== null }),
249
+ : reachableHosts({ dial: opts.machines === true || desktopApp !== null, only: askedFor }),
227
250
  isRepo(root).catch(() => false),
228
251
  findReference(root),
229
252
  whatThisCopyCanDrive(),
230
- phoneApps(root, configFile),
231
- askTheAdapters(root),
253
+ phoneApps(root, settingsText),
254
+ askTheAdapters(root, settingsText, settingsAreJson),
232
255
  ]);
233
256
 
234
- const surfaces = describeSurfaces(tools, hosts, configFile !== null, browsers, desktopApp, drivers, phones, asked);
257
+ const wires = settingsText ? whatTheProcessBlockWires(settingsText) : { commands: 0, imports: 0 };
258
+ const surfaces = describeSurfaces(tools, hosts, hasSettings, browsers, desktopApp, drivers, phones, asked, wires);
235
259
 
236
260
  /** @type {Capabilities} */
237
261
  const caps = {
@@ -254,7 +278,7 @@ export async function capabilities(opts = {}) {
254
278
  },
255
279
  surfaces,
256
280
  drivers,
257
- covers: whatThisRunActuallyCovers(surfaces, configFile !== null),
281
+ covers: whatThisRunActuallyCovers(surfaces, hasSettings),
258
282
  browsers: {
259
283
  willOpen: browsers.chosen,
260
284
  borrowingYourOwn: browsers.borrowingHis,
@@ -874,22 +898,20 @@ function findDesktopApp(cwd) {
874
898
  * install thirty gigabytes of Xcode is asking for work that changes nothing, and the whole
875
899
  * design turns on never doing that.
876
900
  *
901
+ * Windows is answered here too, for the same reason and by the same rule: a repository with
902
+ * no Windows program in it does not need a Windows machine, and saying "no Windows desktop
903
+ * can be reached from here" about one sends somebody looking for a machine they will never
904
+ * use. Only the settings can answer it — a native Windows build is not something this file
905
+ * can go and find in a folder.
906
+ *
877
907
  * @param {string} root
878
- * @param {string|null} configFile
879
- * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null}>}
908
+ * @param {string|null} settingsText
909
+ * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}>}
880
910
  */
881
- async function phoneApps(root, configFile) {
882
- /** @type {string} */
883
- let settings = '';
884
- if (configFile) {
885
- try {
886
- // Comments taken away first, for the same reason `findDesktopApp` does it: a
887
- // commented-out `apk:` line is an example, not an Android app.
888
- settings = withoutComments(readFileSync(configFile, 'utf8'));
889
- } catch {
890
- settings = '';
891
- }
892
- }
911
+ async function phoneApps(root, settingsText) {
912
+ // Comments taken away first, for the same reason `findDesktopApp` does it: a
913
+ // commented-out `apk:` line is an example, not an Android app.
914
+ const settings = settingsText ? withoutComments(settingsText) : '';
893
915
 
894
916
  /**
895
917
  * @param {string} key
@@ -961,7 +983,9 @@ async function phoneApps(root, configFile) {
961
983
  ? { where: root, how: 'there is an Xcode project here, but nothing says where the built app is' }
962
984
  : null);
963
985
 
964
- return { android, ios };
986
+ const windows = named('remoteExe') ?? namedPath('exe', (v) => /\.exe$/i.test(v));
987
+
988
+ return { android, ios, windows };
965
989
  }
966
990
 
967
991
  /**
@@ -1065,9 +1089,12 @@ const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
1065
1089
  * take the rest of the answer with it.
1066
1090
  *
1067
1091
  * @param {string} root
1092
+ * @param {string|null} [settingsText] The settings as text — from disk, or from what
1093
+ * `init` is about to write.
1094
+ * @param {boolean} [settingsAreJson]
1068
1095
  * @returns {Promise<Map<string, Need[]>>}
1069
1096
  */
1070
- async function askTheAdapters(root) {
1097
+ async function askTheAdapters(root, settingsText = null, settingsAreJson = false) {
1071
1098
  /** @type {Map<string, Need[]>} */
1072
1099
  const out = new Map();
1073
1100
  /** @type {{adapters: {name: string, detect: (p: any) => Promise<any>}[]}} */
@@ -1081,10 +1108,9 @@ async function askTheAdapters(root) {
1081
1108
  /** @type {Record<string, any>} */
1082
1109
  let config = {};
1083
1110
  try {
1084
- const file = findConfigFile(root);
1085
1111
  // Read as text and parsed only when it is JSON. Doctor never runs a person's code to
1086
1112
  // answer a question about their machine, and a settings file may be JavaScript.
1087
- if (file && file.endsWith('.json')) config = JSON.parse(readFileSync(file, 'utf8'));
1113
+ if (settingsText && settingsAreJson) config = JSON.parse(settingsText);
1088
1114
  // But "not JSON" was being treated as "says nothing", and `init` writes JavaScript — so
1089
1115
  // for almost every project every adapter was asked what it needs while being handed an
1090
1116
  // EMPTY config. It then asked for the very thing the settings already named: a project
@@ -1094,7 +1120,7 @@ async function askTheAdapters(root) {
1094
1120
  // The few values the adapters need to answer honestly are read out of the TEXT instead,
1095
1121
  // scoped to their own block so one block's `app` can never be read as another's. Still no
1096
1122
  // code is run, which was the whole point of the rule.
1097
- else if (file) config = { ...config, ...settingsFromText(readFileSync(file, 'utf8')) };
1123
+ else if (settingsText) config = { ...config, ...settingsFromText(settingsText) };
1098
1124
  } catch {
1099
1125
  config = {};
1100
1126
  }
@@ -1210,7 +1236,7 @@ function androidSdkTool(folder, name) {
1210
1236
  * answers is a runner the tool already has, and it must never appear in the
1211
1237
  * result as something to go and set up.
1212
1238
  *
1213
- * @param {{dial?: boolean}} [opts]
1239
+ * @param {{dial?: boolean, only?: string[]}} [opts]
1214
1240
  * @returns {Promise<HostReport[]>}
1215
1241
  */
1216
1242
  export async function reachableHosts(opts = {}) {
@@ -1233,6 +1259,24 @@ export async function reachableHosts(opts = {}) {
1233
1259
  // because a machine quietly left out of the answer is the same bug as a folder quietly
1234
1260
  // skipped while reading source: the list looks complete and the runner somebody needed is
1235
1261
  // simply not in it.
1262
+ // The settings named these, so they are dialled even when nothing else is. Everything
1263
+ // else in the ssh config stays untouched and is still listed by name.
1264
+ const named = (opts.only ?? []).filter((name) => names.includes(name));
1265
+ if (opts.dial !== true && named.length > 0) {
1266
+ const dialledByName = await Promise.all(named.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
1267
+ const rest = names
1268
+ .filter((name) => !named.includes(name))
1269
+ .map(
1270
+ (name) =>
1271
+ /** @type {HostReport} */ ({
1272
+ name,
1273
+ reachable: false,
1274
+ how: 'named in your ssh config and deliberately NOT dialled. Your settings do not mention it and nothing here needs it. `staysfixed doctor --machines` checks them all.',
1275
+ })
1276
+ );
1277
+ return [...dialledByName, ...rest];
1278
+ }
1279
+
1236
1280
  if (opts.dial !== true) {
1237
1281
  return names.map(
1238
1282
  (name) =>
@@ -1414,6 +1458,73 @@ export function readHostProbe(name, alive, look) {
1414
1458
  return report;
1415
1459
  }
1416
1460
 
1461
+ /**
1462
+ * Every machine the settings name by ssh host. These are the ones doctor may dial without
1463
+ * being asked twice, because naming a machine in your own settings IS the ask.
1464
+ *
1465
+ * @param {string|null} text
1466
+ * @returns {string[]}
1467
+ */
1468
+ export function hostsNamedInSettings(text) {
1469
+ if (!text) return [];
1470
+ const clean = withoutComments(text);
1471
+ const out = new Set();
1472
+ for (const hit of clean.matchAll(/["']?host["']?\s*:\s*["'`]([^"'`]+)["'`]/g)) out.add(hit[1]);
1473
+ return [...out];
1474
+ }
1475
+
1476
+ /**
1477
+ * The settings as text, or null when there are none to read yet.
1478
+ *
1479
+ * Null and empty are different answers here. "There is no settings file" is what makes a
1480
+ * surface unready; "the settings say nothing about this surface" is a different sentence.
1481
+ *
1482
+ * @param {string|null} file
1483
+ * @returns {string|null}
1484
+ */
1485
+ function readTextOrNull(file) {
1486
+ if (!file) return null;
1487
+ try {
1488
+ return readFileSync(file, 'utf8');
1489
+ } catch {
1490
+ return null;
1491
+ }
1492
+ }
1493
+
1494
+ /**
1495
+ * How much of this project is actually wired for the command-line surface.
1496
+ *
1497
+ * The `cli` surface was hard-coded READY with "Fully covered here" — it never looked at the
1498
+ * project at all. So on a settings file whose `process` block wires no commands and nothing
1499
+ * to import, doctor said command-line tools were fully covered, and a check then answered
1500
+ * "Nothing that worked has changed" having run not one command. Measured 2026-08-31.
1501
+ *
1502
+ * Counted out of the text, like everything else here, because the settings may be JavaScript
1503
+ * and doctor never runs a person's code to answer a question about their machine.
1504
+ *
1505
+ * @param {string} text
1506
+ * @returns {{commands: number, imports: number}}
1507
+ */
1508
+ function whatTheProcessBlockWires(text) {
1509
+ const clean = withoutComments(text);
1510
+ const at = /["']?process["']?\s*:\s*\{/.exec(clean);
1511
+ if (!at) return { commands: 0, imports: 0 };
1512
+ let depth = 0;
1513
+ let end = at.index + at[0].length;
1514
+ for (; end < clean.length; end += 1) {
1515
+ if (clean[end] === '{') depth += 1;
1516
+ else if (clean[end] === '}') {
1517
+ if (depth === 0) break;
1518
+ depth -= 1;
1519
+ }
1520
+ }
1521
+ const inside = clean.slice(at.index + at[0].length, end);
1522
+ return {
1523
+ commands: (inside.match(/["']?run["']?\s*:/g) ?? []).length,
1524
+ imports: (inside.match(/["']?module["']?\s*:/g) ?? []).length,
1525
+ };
1526
+ }
1527
+
1417
1528
  /**
1418
1529
  * The handful of settings an adapter needs to say what it is missing, read out of a
1419
1530
  * JavaScript settings file WITHOUT running it.
@@ -1435,6 +1546,10 @@ function settingsFromText(text) {
1435
1546
  electron: ['binary'],
1436
1547
  web: ['url', 'start'],
1437
1548
  http: ['start', 'url'],
1549
+ // Left out until 2026-08-31, and the Windows adapter was handed an empty settings object
1550
+ // as a result: it could not see the machine the settings named, nor the built program,
1551
+ // so it asked for both while both were sitting in the file.
1552
+ windows: ['host', 'remoteExe', 'exe'],
1438
1553
  };
1439
1554
  for (const [block, keys] of Object.entries(wanted)) {
1440
1555
  const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
@@ -1564,11 +1679,14 @@ async function findReference(root) {
1564
1679
  * @param {import('./browsers.js').BrowserSurvey} browsers
1565
1680
  * @param {{where: string, how: string}|null} desktopApp
1566
1681
  * @param {DriverReport[]} drivers What this copy of the tool can drive at all.
1567
- * @param {{android: FoundApp|null, ios: FoundApp|null}} phones
1682
+ * @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}} phones
1568
1683
  * @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
1684
+ * @param {{commands: number, imports: number}} [wires]
1685
+ * What this project's own settings wire for the command-line surface. A surface with
1686
+ * nothing wired covers nothing here, whatever this machine could do.
1569
1687
  * @returns {SurfaceReport[]}
1570
1688
  */
1571
- function describeSurfaces(tools, hosts, configured, browsers, desktopApp, drivers, phones, asked) {
1689
+ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, drivers, phones, asked, wires = { commands: 0, imports: 0 }) {
1572
1690
  /** @param {string} surface */
1573
1691
  const canDrive = (surface) => drivers.find((d) => d.surface === surface)?.present !== false;
1574
1692
  /** @param {string} surface */
@@ -1578,6 +1696,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1578
1696
  const browser = browsers.chosen !== null;
1579
1697
  const ownBrowser = browsers.chosen !== null && !browsers.chosen.everyday;
1580
1698
  const windowsHost = hosts.find((h) => h.reachable && h.windows === true);
1699
+ // Nothing was dialled at all: every host carries the sentence that says so. That is a
1700
+ // different answer from "they were dialled and none of them runs Windows".
1701
+ const nobodyWasDialled = hosts.length > 0 && hosts.every((h) => /NOT dialled|not dialled/.test(String(h.how ?? '')));
1581
1702
 
1582
1703
  /** Every channel that needs no driver at all — a child process is enough. */
1583
1704
  const withoutADriver = ['effects', 'complaints', 'results', 'contract', 'counters'];
@@ -1597,14 +1718,31 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1597
1718
  */
1598
1719
  const notInThisProject = new Set();
1599
1720
 
1721
+ // READY is about this PROJECT, not about this machine. Hard-coded ready meant doctor said
1722
+ // command-line tools were "fully covered here" on a settings file that wires no commands
1723
+ // and nothing to import — and a check then answered "Nothing that worked has changed"
1724
+ // having run not one command.
1725
+ const wiredForCli = configured && wires.commands + wires.imports > 0;
1600
1726
  surfaces.push({
1601
1727
  id: 'cli',
1602
1728
  name: 'command-line tools and libraries',
1603
- status: 'ready',
1604
- summary: 'Fully covered here. What it printed, what it exited with, what it wrote, what it called out to, and what it exports.',
1729
+ status: wiredForCli ? 'ready' : 'partial',
1730
+ summary: wiredForCli
1731
+ ? 'Fully covered here. What it printed, what it exited with, what it wrote, what it called out to, and what it exports.'
1732
+ : configured
1733
+ ? 'This machine can cover it in full — what a command printed, what it exited with, what it wrote, what it called out to, what it exports — but these settings wire no command to run and nothing to import, so a check runs none of it and a clean result says nothing about any of it.'
1734
+ : 'This machine can cover it in full, but nothing is set up in this folder yet, so a check cannot run here at all.',
1605
1735
  canCheck: withoutADriver,
1606
1736
  cannotCheck: ['meaning', 'pixels'],
1607
- needs: [],
1737
+ needs: wiredForCli
1738
+ ? []
1739
+ : [{
1740
+ what: 'a command to run, or something to import',
1741
+ why: 'Nothing here is walked otherwise, and a run that walks nothing still finishes and still says nothing changed.',
1742
+ fix: 'Add `process: { commands: [{ name: "help", run: "node bin/cli.js --help" }] }` to your settings, or `imports: [{ name: "the package entry", module: "index.js" }]`.',
1743
+ automatic: true,
1744
+ unlocks: 'Everything a command does — what it printed, what it exited with, what it wrote to disk, what it reached for.',
1745
+ }],
1608
1746
  });
1609
1747
 
1610
1748
  surfaces.push({
@@ -1754,7 +1892,20 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1754
1892
  const iosBlocked = iosWants.some(blocks);
1755
1893
  const iosReady = iosMachine && canDrive('ios') && phones.ios !== null && iosWants.length === 0;
1756
1894
  const iosPartly = iosMachine && canDrive('ios') && phones.ios !== null && !iosReady && !iosBlocked;
1757
- if (!onAMac) {
1895
+ // THE PROJECT IS ASKED BEFORE THE MACHINE, and the order is the whole point. "There is no
1896
+ // iPhone app in this repository" and "this machine cannot run one" are different sentences
1897
+ // with different things to do about them, and a machine reason given for a project that has
1898
+ // no iPhone app in it sends somebody to install thirty gigabytes of Xcode for nothing. On a
1899
+ // Mac this was already right; on Linux the platform test came first, so every project on
1900
+ // every Linux machine was told its non-existent iPhone app was out of reach. Caught by CI
1901
+ // on 2026-08-31 — the Mac suite was green and said nothing about it.
1902
+ if (phones.ios === null) {
1903
+ notInThisProject.add('ios');
1904
+ impossible.set(
1905
+ 'ios',
1906
+ 'This project has no iPhone app in it, so there is nothing for a simulator to run. If yours is built somewhere else, name the built .app in your settings under ios.app.'
1907
+ );
1908
+ } else if (!onAMac) {
1758
1909
  impossible.set('ios', 'An iPhone build can only be run on a Mac. Everything else on this list is unaffected — check the iPhone app from a Mac, and let this machine cover the rest.');
1759
1910
  } else if (phones.ios === null) {
1760
1911
  notInThisProject.add('ios');
@@ -1772,10 +1923,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1772
1923
  id: 'ios',
1773
1924
  name: 'iPhone apps, on the simulator',
1774
1925
  status: iosReady ? 'ready' : iosPartly ? 'partial' : 'unavailable',
1775
- summary: !onAMac
1776
- ? 'Cannot run here: iOS needs a Mac.'
1777
- : phones.ios === null
1778
- ? 'Nothing to check: no iPhone app was found in this project, and the settings do not name one.'
1926
+ summary: phones.ios === null
1927
+ ? 'Nothing to check: no iPhone app was found in this project, and the settings do not name one.'
1928
+ : !onAMac
1929
+ ? 'Cannot run here: iOS needs a Mac.'
1779
1930
  : !canDrive('ios')
1780
1931
  ? `An iPhone app is here (${phones.ios.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('ios')}`
1781
1932
  : !iosMachine
@@ -1819,6 +1970,20 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1819
1970
  // is "detect rather than ask" at its sharpest: a runner that already answers must never
1820
1971
  // be presented as something to go and set up.
1821
1972
  const windowsDriver = canDrive('windows');
1973
+ // Same rule as iPhone and Android: the project is asked before the machine. A repository
1974
+ // with no native Windows program in it does not need a Windows machine, and "no Windows
1975
+ // desktop is reachable from here" about one is a machine reason given for a project fact.
1976
+ // Caught by CI on 2026-08-31, where a Linux runner reported native Windows apps as out of
1977
+ // reach for a project that contains none.
1978
+ if (phones.windows === null) {
1979
+ notInThisProject.add('windows');
1980
+ impossible.set(
1981
+ 'windows',
1982
+ 'This project has no native Windows program named in its settings, so there is nothing to open on a Windows desktop. '
1983
+ + 'If yours is built somewhere else, name it under windows.remoteExe (already on that machine) or windows.exe (copied over each run). '
1984
+ + 'Most Windows products are Electron, and those are covered over their debug port from any machine.'
1985
+ );
1986
+ }
1822
1987
  // A Windows desktop nobody has signed into is not a runner. There is nothing on it to read
1823
1988
  // — no windows, no controls — so calling it "partly covered" would be the exact over-claim
1824
1989
  // this file exists to prevent. The question can only be asked when the runner started
@@ -1829,8 +1994,15 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1829
1994
  id: 'windows',
1830
1995
  name: 'native Windows apps',
1831
1996
  status: windowsUsable ? 'partial' : 'unavailable',
1832
- summary: !windowsHost
1833
- ? '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.'
1997
+ summary: phones.windows === null
1998
+ ? 'Nothing to check: this project names no native Windows program in its settings. Most Windows products are Electron, and those are covered over their debug port from any machine.'
1999
+ : !windowsHost
2000
+ ? nobodyWasDialled
2001
+ // Not "no Windows desktop is reachable" — nothing was dialled, so that is not known.
2002
+ // The two were the same sentence until 2026-08-31, and it stated as a fact about the
2003
+ // world an answer that came from a decision not to look.
2004
+ ? 'No machine was dialled, so whether a Windows desktop can be reached from here is unknown. This is usually fine: an Electron product on Windows is watched over the debug port instead, from any machine. `staysfixed doctor --machines` asks the machines in your ssh config, and naming one under `windows: { host: "..." }` in your settings asks that one every time.'
2005
+ : '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.'
1834
2006
  : !windowsDriver
1835
2007
  ? `A real Windows desktop is reachable through ${windowsHost.name}, and this copy of Stays Fixed cannot drive one. ${noDriver('windows')}`
1836
2008
  // The adapter's own paragraph, not a second one written here. It knows whether
@@ -1970,7 +2142,10 @@ function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
1970
2142
  if (noSuchProduct.length) {
1971
2143
  parts.push(`There is nothing of ${noSuchProduct.length === 1 ? 'that kind' : 'those kinds'} in this repository — ${plainList(noSuchProduct)} — so there is nothing here to check, and that is not a limit of this machine.`);
1972
2144
  }
1973
- if (noSuchMachine.length) parts.push(`${plainList(noSuchMachine, true)} cannot be done here at all, and the reason for each is in notCovered.`);
2145
+ // "the reason for each is listed above", not "is in notCovered": this lands in
2146
+ // covers.short, which `staysfixed doctor` and `staysfixed coverage` both print at a
2147
+ // person. A JSON field name is not somewhere a person can go and look.
2148
+ if (noSuchMachine.length) parts.push(`${plainList(noSuchMachine, true)} cannot be done here at all, and the reason for each is listed above.`);
1974
2149
  }
1975
2150
  if (out.everything) parts.push('Nothing is being left out on this machine.');
1976
2151
  out.short = parts.join(' ');
package/src/v2/init.js CHANGED
@@ -163,12 +163,24 @@ export async function plan(options = {}) {
163
163
  const root = existing ? rootForConfig(existing) : cwd;
164
164
 
165
165
  const project = await detectProject({ root, readCode: options.readCode });
166
- const machine = await readMachine({ cwd: root, offline: options.offline });
166
+
167
+ // The settings are worked out BEFORE the machine is asked, and handed over. Doctor reads
168
+ // the settings to answer half its questions, and on a fresh project there is no settings
169
+ // file yet — so it used to answer every one of them against nothing, and init printed the
170
+ // result as this project's readiness. A plain Node command-line tool was told it needed
171
+ // "a command to run" by the same run that had already written `node cli.js --help` into
172
+ // its settings. Settings that already exist are read from disk as before; this only hands
173
+ // over the ones that are about to be written.
174
+ const config = await planConfig(root, project, existing);
175
+ const machine = await readMachine({
176
+ cwd: root,
177
+ offline: options.offline,
178
+ settingsText: config.exists ? undefined : config.text,
179
+ });
167
180
 
168
181
  const readiness = readinessFor(project, machine);
169
182
  const journeys = proposeJourneys(project);
170
183
  const needs = sortNeeds(readiness, project, machine);
171
- const config = await planConfig(root, project, existing);
172
184
  const covers = whatItCovers(readiness);
173
185
 
174
186
  return {
@@ -250,13 +262,13 @@ export async function init(options = {}) {
250
262
  * survey throws, and the honest degradation is "nothing is known about this machine", which
251
263
  * makes every surface a person's problem rather than silently a ready one.
252
264
  *
253
- * @param {{cwd: string, offline?: boolean}} opts
265
+ * @param {{cwd: string, offline?: boolean, settingsText?: string}} opts
254
266
  * @returns {Promise<Capabilities|null>}
255
267
  */
256
268
  async function readMachine(opts) {
257
269
  try {
258
270
  const { capabilities } = await import('./doctor.js');
259
- return await capabilities({ cwd: opts.cwd, offline: opts.offline });
271
+ return await capabilities({ cwd: opts.cwd, offline: opts.offline, settingsText: opts.settingsText });
260
272
  } catch {
261
273
  return null;
262
274
  }
@@ -385,6 +397,31 @@ function insteadFor(product, project) {
385
397
  }
386
398
  }
387
399
 
400
+ /**
401
+ * Is the file a package says other code should import actually sitting there?
402
+ *
403
+ * Asked before this command tells anybody a library "can be checked here now". The answer
404
+ * has to allow for the shorthands package.json is allowed to use — `"main": "index"` and
405
+ * `"main": "./lib"` are both perfectly ordinary and both name something real — so the same
406
+ * endings and the same folder entry point Node itself would try are tried here. Erring on
407
+ * the side of "it is there" is the safe direction for THIS question: a way in that exists
408
+ * and is not recognised would put a job on somebody's list that they cannot do anything
409
+ * about, and a way in that is missing is caught the moment a check actually runs.
410
+ *
411
+ * Exported so a test can ask about one path without building a whole project.
412
+ *
413
+ * @param {string} root The project's own folder.
414
+ * @param {string} where Which folder inside it this product lives in. '.' for the root.
415
+ * @param {string} module Exactly what package.json said, e.g. './index.js'.
416
+ * @returns {boolean}
417
+ */
418
+ export function isThereOnDisk(root, where, module) {
419
+ const base = path.resolve(root, where === '' ? '.' : where, module);
420
+ const endings = ['', '.js', '.mjs', '.cjs', '.json', '.node', '.ts'];
421
+ if (endings.some((end) => existsSync(base + end))) return true;
422
+ return ['index.js', 'index.mjs', 'index.cjs', 'index.json'].some((name) => existsSync(path.join(base, name)));
423
+ }
424
+
388
425
  /**
389
426
  * What this particular product is short of, from what was actually found on disk.
390
427
  *
@@ -554,6 +591,36 @@ function productNeeds(product, project) {
554
591
  });
555
592
  }
556
593
 
594
+ // A way in that package.json promises and the folder has not got.
595
+ //
596
+ // package.json is a DECLARATION, not a fact. `"exports": {".": "./index.js"}` in a
597
+ // repository with no index.js in it reads, to everything upstream of here, as a perfectly
598
+ // good library with a perfectly good entry point — so nothing was outstanding, the product
599
+ // came back "ready", and this command told somebody "the library other code imports can be
600
+ // checked here now" and "right now a check here covers it in full", about a file that was
601
+ // not there. Measured 2026-08-31 on a package whose entry had never been built. An import
602
+ // that cannot resolve walks nothing, so "in full" covered nothing at all — which is the one
603
+ // shape of answer this tool exists to make impossible.
604
+ const missingWaysIn = (Array.isArray(suggest.imports) ? suggest.imports : [])
605
+ .map((one) => String(one?.module ?? ''))
606
+ .filter((module) => module !== '' && (module.startsWith('.') || path.isAbsolute(module)))
607
+ .filter((module) => !isThereOnDisk(project.root, product.where, module));
608
+ if (missingWaysIn.length > 0) {
609
+ const one = missingWaysIn.length === 1;
610
+ const build = project.scripts.build;
611
+ needs.push({
612
+ what: `${plainList(missingWaysIn)} — the ${one ? 'file' : 'files'} other code is told to import, ${one ? 'which is' : 'which are'} not there`,
613
+ why: `package.json points other projects at ${one ? 'that file' : 'those files'}, and nothing is at that path. There is nothing to import, so a check would compare none of what this library exports — and a clean result would be a clean result about nothing.`,
614
+ unlocks: 'every name this library exports, and what those exports actually do',
615
+ fix: build
616
+ ? `Run \`${build}\` — that is what writes ${one ? 'it' : 'them'} — then \`staysfixed init --force\`. If the entry in package.json is simply pointing at the wrong path, correct it there instead.`
617
+ : `Either create ${plainList(missingWaysIn)}, or correct the "exports" (or "main") entry in package.json so it names the file that really is the way in.`,
618
+ who: build ? 'the agent' : 'a person',
619
+ product: product.name,
620
+ topic: 'commands',
621
+ });
622
+ }
623
+
557
624
  // A command-line program that has to be built before it can be run. This is what a product
558
625
  // nothing in package.json names looks like on a fresh clone: the source is there, the
559
626
  // program is real, and the file that would be run does not exist yet.
@@ -938,13 +1005,18 @@ export function proposeJourneys(project) {
938
1005
  }
939
1006
  if (product.kind === 'library' && Array.isArray(suggest.imports)) {
940
1007
  for (const entry of suggest.imports) {
1008
+ const module = String(entry.module);
941
1009
  out.push({
942
1010
  name: String(entry.name),
943
- what: `import ${String(entry.module)} and compare what it exports`,
1011
+ what: `import ${module} and compare what it exports`,
944
1012
  from: 'package.json',
945
1013
  surface: 'library',
946
1014
  automatic: false,
947
- ready: true,
1015
+ // Only if the file is really there. package.json naming an entry does not put one
1016
+ // on the disk, and this line printed with no caveat beside it — "import ./index.js
1017
+ // and compare what it exports" — about a file that did not exist. A journey listed
1018
+ // as ready is a promise that a check will walk it.
1019
+ ready: isThereOnDisk(project.root, product.where, module),
948
1020
  });
949
1021
  }
950
1022
  }
@@ -1300,9 +1372,11 @@ export function configText(project) {
1300
1372
  w(' // ───────────────────────────────────────────────────────────────────────');
1301
1373
  w(web ? ' web: {' : ' // web: {');
1302
1374
  const webOn = web ? ' ' : ' // ';
1303
- w(`${webOn}// The command that starts it, listening on the PORT it is given. Much better than`);
1304
- w(`${webOn}// an address: one address can only serve one build, so with an address alone both`);
1305
- w(`${webOn}// halves of the comparison read the same running copy and prove nothing.`);
1375
+ w(`${webOn}// The command that starts it, listening on the PORT it is given and on 127.0.0.1.`);
1376
+ w(`${webOn}// Much better than an address: one address can only serve one build, so with an`);
1377
+ w(`${webOn}// address alone both halves of the comparison read the same running copy and prove`);
1378
+ w(`${webOn}// nothing. A command that ignores the port it was handed is named within a second`);
1379
+ w(`${webOn}// or two, by name, rather than after a minute and a half of waiting.`);
1306
1380
  const webStart = web?.suggest?.start;
1307
1381
  const flatSite = !webStart && Array.isArray(web?.suggest?.screens) && web.suggest.screens.length > 0;
1308
1382
  if (webStart) {
@@ -1319,7 +1393,11 @@ export function configText(project) {
1319
1393
  w(`${webOn}// package the first time it runs, and that is a decision rather than a default.`);
1320
1394
  w(`${webOn}// start: 'npx --yes serve -l $PORT .',`);
1321
1395
  } else {
1322
- w(`${webOn}// start: 'npm run dev',`);
1396
+ w(`${webOn}// It has to listen on the PORT it is given AND on 127.0.0.1, and both halves`);
1397
+ w(`${webOn}// matter: measured on 2026-08-31, Vite ignores the PORT and HOST it is handed`);
1398
+ w(`${webOn}// in the environment and binds the name "localhost", which on a Mac is the IPv6`);
1399
+ w(`${webOn}// loopback — so the site comes up somewhere these settings never said.`);
1400
+ w(`${webOn}// start: 'npm run dev -- --port $PORT --strictPort --host 127.0.0.1',`);
1323
1401
  }
1324
1402
  w(`${webOn}// Or, if it is already running somewhere and you accept the weaker answer:`);
1325
1403
  w(`${webOn}// url: 'http://localhost:3000',`);
@@ -1815,7 +1893,15 @@ export async function run(ctx) {
1815
1893
  const others = (result.plan?.readiness ?? [])
1816
1894
  .map((/** @type {any} */ r) => String(r.product ?? ''))
1817
1895
  .filter((/** @type {string} */ n, /** @type {number} */ i, /** @type {string[]} */ all) => n !== '' && all.indexOf(n) === i);
1818
- if (result.written.length > 0) ok(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}`);
1896
+ if (result.written.length > 0) {
1897
+ ok(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}`);
1898
+ // Worth one line, because of what happens if it is not done. These files are part of the
1899
+ // build now: until they are committed the working tree is not what git has, so the first
1900
+ // reference gets cut from a tree that has no commit of its own — and a later check cannot
1901
+ // put that build back on the machine to walk it live. It falls back to the stored record,
1902
+ // says so, and is weaker for it. One `git add` avoids the whole thing.
1903
+ say('Commit them before you ship. Settings that are not committed leave the first reference tied to a build git does not have, and a later check can then only compare against the record rather than running the old build live.');
1904
+ }
1819
1905
  if (others.length > 1) {
1820
1906
  warn(
1821
1907
  `Those settings describe ONE product. ${others.length} were found here (${others.join(', ')}), and the others are not covered by this file. ` +
@@ -219,7 +219,10 @@ export async function serveMcp(opts = {}) {
219
219
  }
220
220
  await enqueue(async () => {
221
221
  try {
222
- const result = await callTool(name, args, { root, cwd, version, protocolVersion });
222
+ // `audience: 'agent'` is the default in tools.js and is written out anyway: it is
223
+ // the field that decides whose name goes on a sealed intent and on a waiver, and
224
+ // a record of who declared something must never rest on a default being right.
225
+ const result = await callTool(name, args, { root, cwd, version, protocolVersion, audience: 'agent' });
223
226
  reply(id, result);
224
227
  } catch (e) {
225
228
  // A tool that blows up is still a RESULT, not a protocol error: the