staysfixed 0.10.0 → 0.11.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.
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,
@@ -875,21 +899,13 @@ function findDesktopApp(cwd) {
875
899
  * design turns on never doing that.
876
900
  *
877
901
  * @param {string} root
878
- * @param {string|null} configFile
902
+ * @param {string|null} settingsText
879
903
  * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null}>}
880
904
  */
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
- }
905
+ async function phoneApps(root, settingsText) {
906
+ // Comments taken away first, for the same reason `findDesktopApp` does it: a
907
+ // commented-out `apk:` line is an example, not an Android app.
908
+ const settings = settingsText ? withoutComments(settingsText) : '';
893
909
 
894
910
  /**
895
911
  * @param {string} key
@@ -1065,9 +1081,12 @@ const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
1065
1081
  * take the rest of the answer with it.
1066
1082
  *
1067
1083
  * @param {string} root
1084
+ * @param {string|null} [settingsText] The settings as text — from disk, or from what
1085
+ * `init` is about to write.
1086
+ * @param {boolean} [settingsAreJson]
1068
1087
  * @returns {Promise<Map<string, Need[]>>}
1069
1088
  */
1070
- async function askTheAdapters(root) {
1089
+ async function askTheAdapters(root, settingsText = null, settingsAreJson = false) {
1071
1090
  /** @type {Map<string, Need[]>} */
1072
1091
  const out = new Map();
1073
1092
  /** @type {{adapters: {name: string, detect: (p: any) => Promise<any>}[]}} */
@@ -1081,10 +1100,9 @@ async function askTheAdapters(root) {
1081
1100
  /** @type {Record<string, any>} */
1082
1101
  let config = {};
1083
1102
  try {
1084
- const file = findConfigFile(root);
1085
1103
  // Read as text and parsed only when it is JSON. Doctor never runs a person's code to
1086
1104
  // 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'));
1105
+ if (settingsText && settingsAreJson) config = JSON.parse(settingsText);
1088
1106
  // But "not JSON" was being treated as "says nothing", and `init` writes JavaScript — so
1089
1107
  // for almost every project every adapter was asked what it needs while being handed an
1090
1108
  // EMPTY config. It then asked for the very thing the settings already named: a project
@@ -1094,7 +1112,7 @@ async function askTheAdapters(root) {
1094
1112
  // The few values the adapters need to answer honestly are read out of the TEXT instead,
1095
1113
  // scoped to their own block so one block's `app` can never be read as another's. Still no
1096
1114
  // code is run, which was the whole point of the rule.
1097
- else if (file) config = { ...config, ...settingsFromText(readFileSync(file, 'utf8')) };
1115
+ else if (settingsText) config = { ...config, ...settingsFromText(settingsText) };
1098
1116
  } catch {
1099
1117
  config = {};
1100
1118
  }
@@ -1210,7 +1228,7 @@ function androidSdkTool(folder, name) {
1210
1228
  * answers is a runner the tool already has, and it must never appear in the
1211
1229
  * result as something to go and set up.
1212
1230
  *
1213
- * @param {{dial?: boolean}} [opts]
1231
+ * @param {{dial?: boolean, only?: string[]}} [opts]
1214
1232
  * @returns {Promise<HostReport[]>}
1215
1233
  */
1216
1234
  export async function reachableHosts(opts = {}) {
@@ -1233,6 +1251,24 @@ export async function reachableHosts(opts = {}) {
1233
1251
  // because a machine quietly left out of the answer is the same bug as a folder quietly
1234
1252
  // skipped while reading source: the list looks complete and the runner somebody needed is
1235
1253
  // simply not in it.
1254
+ // The settings named these, so they are dialled even when nothing else is. Everything
1255
+ // else in the ssh config stays untouched and is still listed by name.
1256
+ const named = (opts.only ?? []).filter((name) => names.includes(name));
1257
+ if (opts.dial !== true && named.length > 0) {
1258
+ const dialledByName = await Promise.all(named.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
1259
+ const rest = names
1260
+ .filter((name) => !named.includes(name))
1261
+ .map(
1262
+ (name) =>
1263
+ /** @type {HostReport} */ ({
1264
+ name,
1265
+ reachable: false,
1266
+ 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.',
1267
+ })
1268
+ );
1269
+ return [...dialledByName, ...rest];
1270
+ }
1271
+
1236
1272
  if (opts.dial !== true) {
1237
1273
  return names.map(
1238
1274
  (name) =>
@@ -1414,6 +1450,73 @@ export function readHostProbe(name, alive, look) {
1414
1450
  return report;
1415
1451
  }
1416
1452
 
1453
+ /**
1454
+ * Every machine the settings name by ssh host. These are the ones doctor may dial without
1455
+ * being asked twice, because naming a machine in your own settings IS the ask.
1456
+ *
1457
+ * @param {string|null} text
1458
+ * @returns {string[]}
1459
+ */
1460
+ export function hostsNamedInSettings(text) {
1461
+ if (!text) return [];
1462
+ const clean = withoutComments(text);
1463
+ const out = new Set();
1464
+ for (const hit of clean.matchAll(/["']?host["']?\s*:\s*["'`]([^"'`]+)["'`]/g)) out.add(hit[1]);
1465
+ return [...out];
1466
+ }
1467
+
1468
+ /**
1469
+ * The settings as text, or null when there are none to read yet.
1470
+ *
1471
+ * Null and empty are different answers here. "There is no settings file" is what makes a
1472
+ * surface unready; "the settings say nothing about this surface" is a different sentence.
1473
+ *
1474
+ * @param {string|null} file
1475
+ * @returns {string|null}
1476
+ */
1477
+ function readTextOrNull(file) {
1478
+ if (!file) return null;
1479
+ try {
1480
+ return readFileSync(file, 'utf8');
1481
+ } catch {
1482
+ return null;
1483
+ }
1484
+ }
1485
+
1486
+ /**
1487
+ * How much of this project is actually wired for the command-line surface.
1488
+ *
1489
+ * The `cli` surface was hard-coded READY with "Fully covered here" — it never looked at the
1490
+ * project at all. So on a settings file whose `process` block wires no commands and nothing
1491
+ * to import, doctor said command-line tools were fully covered, and a check then answered
1492
+ * "Nothing that worked has changed" having run not one command. Measured 2026-08-31.
1493
+ *
1494
+ * Counted out of the text, like everything else here, because the settings may be JavaScript
1495
+ * and doctor never runs a person's code to answer a question about their machine.
1496
+ *
1497
+ * @param {string} text
1498
+ * @returns {{commands: number, imports: number}}
1499
+ */
1500
+ function whatTheProcessBlockWires(text) {
1501
+ const clean = withoutComments(text);
1502
+ const at = /["']?process["']?\s*:\s*\{/.exec(clean);
1503
+ if (!at) return { commands: 0, imports: 0 };
1504
+ let depth = 0;
1505
+ let end = at.index + at[0].length;
1506
+ for (; end < clean.length; end += 1) {
1507
+ if (clean[end] === '{') depth += 1;
1508
+ else if (clean[end] === '}') {
1509
+ if (depth === 0) break;
1510
+ depth -= 1;
1511
+ }
1512
+ }
1513
+ const inside = clean.slice(at.index + at[0].length, end);
1514
+ return {
1515
+ commands: (inside.match(/["']?run["']?\s*:/g) ?? []).length,
1516
+ imports: (inside.match(/["']?module["']?\s*:/g) ?? []).length,
1517
+ };
1518
+ }
1519
+
1417
1520
  /**
1418
1521
  * The handful of settings an adapter needs to say what it is missing, read out of a
1419
1522
  * JavaScript settings file WITHOUT running it.
@@ -1435,6 +1538,10 @@ function settingsFromText(text) {
1435
1538
  electron: ['binary'],
1436
1539
  web: ['url', 'start'],
1437
1540
  http: ['start', 'url'],
1541
+ // Left out until 2026-08-31, and the Windows adapter was handed an empty settings object
1542
+ // as a result: it could not see the machine the settings named, nor the built program,
1543
+ // so it asked for both while both were sitting in the file.
1544
+ windows: ['host', 'remoteExe', 'exe'],
1438
1545
  };
1439
1546
  for (const [block, keys] of Object.entries(wanted)) {
1440
1547
  const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
@@ -1566,9 +1673,12 @@ async function findReference(root) {
1566
1673
  * @param {DriverReport[]} drivers What this copy of the tool can drive at all.
1567
1674
  * @param {{android: FoundApp|null, ios: FoundApp|null}} phones
1568
1675
  * @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
1676
+ * @param {{commands: number, imports: number}} [wires]
1677
+ * What this project's own settings wire for the command-line surface. A surface with
1678
+ * nothing wired covers nothing here, whatever this machine could do.
1569
1679
  * @returns {SurfaceReport[]}
1570
1680
  */
1571
- function describeSurfaces(tools, hosts, configured, browsers, desktopApp, drivers, phones, asked) {
1681
+ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, drivers, phones, asked, wires = { commands: 0, imports: 0 }) {
1572
1682
  /** @param {string} surface */
1573
1683
  const canDrive = (surface) => drivers.find((d) => d.surface === surface)?.present !== false;
1574
1684
  /** @param {string} surface */
@@ -1578,6 +1688,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1578
1688
  const browser = browsers.chosen !== null;
1579
1689
  const ownBrowser = browsers.chosen !== null && !browsers.chosen.everyday;
1580
1690
  const windowsHost = hosts.find((h) => h.reachable && h.windows === true);
1691
+ // Nothing was dialled at all: every host carries the sentence that says so. That is a
1692
+ // different answer from "they were dialled and none of them runs Windows".
1693
+ const nobodyWasDialled = hosts.length > 0 && hosts.every((h) => /NOT dialled|not dialled/.test(String(h.how ?? '')));
1581
1694
 
1582
1695
  /** Every channel that needs no driver at all — a child process is enough. */
1583
1696
  const withoutADriver = ['effects', 'complaints', 'results', 'contract', 'counters'];
@@ -1597,14 +1710,31 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1597
1710
  */
1598
1711
  const notInThisProject = new Set();
1599
1712
 
1713
+ // READY is about this PROJECT, not about this machine. Hard-coded ready meant doctor said
1714
+ // command-line tools were "fully covered here" on a settings file that wires no commands
1715
+ // and nothing to import — and a check then answered "Nothing that worked has changed"
1716
+ // having run not one command.
1717
+ const wiredForCli = configured && wires.commands + wires.imports > 0;
1600
1718
  surfaces.push({
1601
1719
  id: 'cli',
1602
1720
  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.',
1721
+ status: wiredForCli ? 'ready' : 'partial',
1722
+ summary: wiredForCli
1723
+ ? 'Fully covered here. What it printed, what it exited with, what it wrote, what it called out to, and what it exports.'
1724
+ : configured
1725
+ ? '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.'
1726
+ : '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
1727
  canCheck: withoutADriver,
1606
1728
  cannotCheck: ['meaning', 'pixels'],
1607
- needs: [],
1729
+ needs: wiredForCli
1730
+ ? []
1731
+ : [{
1732
+ what: 'a command to run, or something to import',
1733
+ why: 'Nothing here is walked otherwise, and a run that walks nothing still finishes and still says nothing changed.',
1734
+ fix: 'Add `process: { commands: [{ name: "help", run: "node bin/cli.js --help" }] }` to your settings, or `imports: [{ name: "the package entry", module: "index.js" }]`.',
1735
+ automatic: true,
1736
+ unlocks: 'Everything a command does — what it printed, what it exited with, what it wrote to disk, what it reached for.',
1737
+ }],
1608
1738
  });
1609
1739
 
1610
1740
  surfaces.push({
@@ -1830,7 +1960,12 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1830
1960
  name: 'native Windows apps',
1831
1961
  status: windowsUsable ? 'partial' : 'unavailable',
1832
1962
  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.'
1963
+ ? nobodyWasDialled
1964
+ // Not "no Windows desktop is reachable" — nothing was dialled, so that is not known.
1965
+ // The two were the same sentence until 2026-08-31, and it stated as a fact about the
1966
+ // world an answer that came from a decision not to look.
1967
+ ? '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.'
1968
+ : '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
1969
  : !windowsDriver
1835
1970
  ? `A real Windows desktop is reachable through ${windowsHost.name}, and this copy of Stays Fixed cannot drive one. ${noDriver('windows')}`
1836
1971
  // The adapter's own paragraph, not a second one written here. It knows whether
@@ -1970,7 +2105,10 @@ function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
1970
2105
  if (noSuchProduct.length) {
1971
2106
  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
2107
  }
1973
- if (noSuchMachine.length) parts.push(`${plainList(noSuchMachine, true)} cannot be done here at all, and the reason for each is in notCovered.`);
2108
+ // "the reason for each is listed above", not "is in notCovered": this lands in
2109
+ // covers.short, which `staysfixed doctor` and `staysfixed coverage` both print at a
2110
+ // person. A JSON field name is not somewhere a person can go and look.
2111
+ if (noSuchMachine.length) parts.push(`${plainList(noSuchMachine, true)} cannot be done here at all, and the reason for each is listed above.`);
1974
2112
  }
1975
2113
  if (out.everything) parts.push('Nothing is being left out on this machine.');
1976
2114
  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',`);
@@ -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