staysfixed 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/v2/doctor.js CHANGED
@@ -196,7 +196,7 @@ export const CHANNELS = [
196
196
  * should be able to read it once and know what to call, what it will get back,
197
197
  * and what it must not bother asking for here.
198
198
  *
199
- * @param {{cwd?: string, configFile?: string, offline?: boolean}} [opts]
199
+ * @param {{cwd?: string, configFile?: string, offline?: boolean, machines?: boolean}} [opts]
200
200
  * @returns {Promise<Capabilities>}
201
201
  */
202
202
  export async function capabilities(opts = {}) {
@@ -218,7 +218,12 @@ export async function capabilities(opts = {}) {
218
218
 
219
219
  const [tools, hosts, repo, reference, drivers, phones, asked] = await Promise.all([
220
220
  findTools(cwd, browsers),
221
- offline ? Promise.resolve(/** @type {HostReport[]} */ ([])) : reachableHosts(),
221
+ // Only a product that could actually run somewhere else is a reason to go looking for
222
+ // somewhere else. A website or a command-line tool never needs a Windows desktop, and
223
+ // the hosts list feeds exactly one surface: that one.
224
+ offline
225
+ ? Promise.resolve(/** @type {HostReport[]} */ ([]))
226
+ : reachableHosts({ dial: opts.machines === true || desktopApp !== null }),
222
227
  isRepo(root).catch(() => false),
223
228
  findReference(root),
224
229
  whatThisCopyCanDrive(),
@@ -249,7 +254,7 @@ export async function capabilities(opts = {}) {
249
254
  },
250
255
  surfaces,
251
256
  drivers,
252
- covers: whatThisRunActuallyCovers(surfaces),
257
+ covers: whatThisRunActuallyCovers(surfaces, configFile !== null),
253
258
  browsers: {
254
259
  willOpen: browsers.chosen,
255
260
  borrowingYourOwn: browsers.borrowingHis,
@@ -1175,13 +1180,40 @@ function androidSdkTool(folder, name) {
1175
1180
  * answers is a runner the tool already has, and it must never appear in the
1176
1181
  * result as something to go and set up.
1177
1182
  *
1183
+ * @param {{dial?: boolean}} [opts]
1178
1184
  * @returns {Promise<HostReport[]>}
1179
1185
  */
1180
- export async function reachableHosts() {
1186
+ export async function reachableHosts(opts = {}) {
1181
1187
  if (!onPath('ssh')) return [];
1182
1188
  const names = await sshConfigHosts();
1183
1189
  if (names.length === 0) return [];
1184
1190
 
1191
+ // READING the ssh config is free and tells nobody anything. DIALLING is neither, and it
1192
+ // is not something this tool may do to somebody who has just installed it.
1193
+ //
1194
+ // The first command a stranger runs is `doctor`. On a brand-new scratch project with no
1195
+ // settings file and nothing that could possibly need a second machine, this opened ssh
1196
+ // connections to every host in their `~/.ssh/config` and ran a command on each — measured
1197
+ // on 2026-08-30: ten hosts configured, connections out within seconds of the first run,
1198
+ // nothing said before or after. Those are production servers in a lot of people's configs,
1199
+ // and in a lot of workplaces that alone is a policy breach. `--offline` existed, but a way
1200
+ // out you only learn about afterwards is not consent.
1201
+ //
1202
+ // So it is asked for now rather than assumed, and the machines are still NAMED either way,
1203
+ // because a machine quietly left out of the answer is the same bug as a folder quietly
1204
+ // skipped while reading source: the list looks complete and the runner somebody needed is
1205
+ // simply not in it.
1206
+ if (opts.dial !== true) {
1207
+ return names.map(
1208
+ (name) =>
1209
+ /** @type {HostReport} */ ({
1210
+ name,
1211
+ reachable: false,
1212
+ how: 'named in your ssh config and deliberately NOT dialled. Nothing here needs a second machine, and this tool does not connect to yours unasked. `staysfixed doctor --machines` checks them.',
1213
+ })
1214
+ );
1215
+ }
1216
+
1185
1217
  const dialled = await Promise.all(names.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
1186
1218
  // Anything past the cap is NAMED rather than dropped. A machine quietly left out of
1187
1219
  // this list is the same shape of bug as a folder quietly skipped while reading source:
@@ -1776,6 +1808,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1776
1808
  * @property {{name: string, why: string}[]} partly Looked at, but not completely, and why.
1777
1809
  * @property {{name: string, why: string, whoFixes: SurfaceState}[]} notCovered
1778
1810
  * @property {boolean} everything True only when nothing at all is left out.
1811
+ * @property {boolean} [canRunHere]
1812
+ * False when nothing is set up in this folder, so a check cannot run here at all
1813
+ * whatever this machine could otherwise drive.
1779
1814
  */
1780
1815
 
1781
1816
  /**
@@ -1787,9 +1822,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1787
1822
  * covers your website; your iPhone app is not being checked, and here is why.
1788
1823
  *
1789
1824
  * @param {SurfaceReport[]} surfaces
1825
+ * @param {boolean} setUpHere Whether a check can actually run in this folder at all.
1790
1826
  * @returns {Covers}
1791
1827
  */
1792
- function whatThisRunActuallyCovers(surfaces) {
1828
+ function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
1793
1829
  // Three buckets, not two. Folding "partly" into "covered" is exactly the
1794
1830
  // over-claim this function exists to stop: an iPhone app whose screens cannot
1795
1831
  // be read is not a covered iPhone app.
@@ -1813,7 +1849,23 @@ function whatThisRunActuallyCovers(surfaces) {
1813
1849
 
1814
1850
  /** @type {string[]} */
1815
1851
  const parts = [];
1816
- parts.push(full.length > 0 ? `A check here covers ${plainList(out.covered)} in full.` : 'A check here covers nothing in full.');
1852
+ // What this MACHINE can drive and what a check in THIS FOLDER would cover are two
1853
+ // different questions, and only one of them was being answered. In an empty folder — no
1854
+ // settings, no code — this said "A check here covers command-line tools and libraries and
1855
+ // web apps and sites in full" and doctor exited 0, while `check` in that same folder
1856
+ // refused to run at all: "No Stays Fixed config found here, so there is nothing to check."
1857
+ // Measured 2026-08-30. `doctor --json` is the first call an agent is told to make, which
1858
+ // is the worst place there is for a sentence with "here" in it to mean somewhere else.
1859
+ //
1860
+ // Everything below still says what it said — a reader needs the whole picture either way —
1861
+ // it is just no longer written as though a check could run.
1862
+ if (!setUpHere) {
1863
+ out.canRunHere = false;
1864
+ parts.push('Nothing is set up in this folder, so a check cannot run here at all and would cover nothing. Run `staysfixed init` first.');
1865
+ parts.push(full.length > 0 ? `Once it is set up, this machine could cover ${plainList(out.covered)} in full.` : 'Even set up, this machine could cover nothing in full.');
1866
+ } else {
1867
+ parts.push(full.length > 0 ? `A check here covers ${plainList(out.covered)} in full.` : 'A check here covers nothing in full.');
1868
+ }
1817
1869
  if (some.length > 0) parts.push(`It covers ${plainList(some.map((s) => s.name))} only partly — read the summary for each before treating a clean result as proof.`);
1818
1870
  if (missing.length > 0) {
1819
1871
  parts.push(`It does NOT check ${plainList(missing.map((s) => s.name))} at all, so a clean result says nothing whatever about ${missing.length === 1 ? 'that' : 'those'}.`);
@@ -1888,8 +1940,15 @@ function nextSteps(surfaces, reference, repo) {
1888
1940
  steps.push({
1889
1941
  what: 'record a reference',
1890
1942
  why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing.',
1891
- fix: 'staysfixed check --paired',
1892
- automatic: true,
1943
+ // Both halves of this were wrong, and they were wrong in the direction that matters.
1944
+ // `check --paired` cannot record a reference — run it twice on a fresh project and
1945
+ // both runs answer that there is no build on record, with the reference id still
1946
+ // empty — so anybody following this went round in a circle. And `automatic: true` told
1947
+ // the agent this was its to do, when the one rule underneath this whole product is
1948
+ // that only shipping cuts a reference and no agent may bless its own work. Saying an
1949
+ // agent can do the single thing it must never do is worse than saying nothing.
1950
+ fix: 'staysfixed ship (only shipping records what "working" means — no agent may cut that reference)',
1951
+ automatic: false,
1893
1952
  unlocks: 'Every check after this one has something to compare against, so "nothing changed" starts meaning something.',
1894
1953
  });
1895
1954
  }
@@ -2072,7 +2131,7 @@ export function describeCapabilities(caps) {
2072
2131
  * @returns {Promise<number>}
2073
2132
  */
2074
2133
  export async function run(ctx) {
2075
- const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline') });
2134
+ const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline'), machines: ctx.bool('machines') });
2076
2135
 
2077
2136
  if (ctx.bool('json')) {
2078
2137
  // Nothing but the object may reach standard output. Doctor is the first call
@@ -517,7 +517,11 @@ function buildEscalations(product, record, verdict) {
517
517
  kind: 'no-reference',
518
518
  what: `There is no build of ${product} on record as working yet, so this run had nothing to compare against.`,
519
519
  why: 'Only you can say what "working" means, and you say it by shipping — no agent may cut that reference.',
520
- todo: 'Ship once with the hook in place. From the next change onwards it is automatic and you will not see this again.',
520
+ // The order is said out loud because leaving it out sent people round a circle: `ship`
521
+ // on a build nothing has watched answers "run a check before the next release", and
522
+ // this line answered "you say it by shipping". Both are true and neither says which
523
+ // comes first. A check watches the build; shipping then blesses what was watched.
524
+ todo: 'Run `staysfixed check` once so there is a build to bless, then `staysfixed ship`. From the next change onwards it is automatic and you will not see this again.',
521
525
  });
522
526
  }
523
527
 
package/src/v2/init.js CHANGED
@@ -824,7 +824,10 @@ function sortNeeds(readiness, project, machine) {
824
824
  what: 'one build on record as working',
825
825
  why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing at all.',
826
826
  unlocks: 'every check from then on',
827
- fix: 'staysfixed check --paired (or ship once with `staysfixed ship` at the end of your release)',
827
+ // `check --paired` was named here first, and it does not do this: run it twice on a
828
+ // fresh project and both runs answer "there is no build on record as working". Only
829
+ // shipping cuts a reference, on purpose — no agent may bless its own work.
830
+ fix: 'staysfixed ship (only shipping records what "working" means — run it once at the end of your release)',
828
831
  who: 'the agent',
829
832
  });
830
833
  }
@@ -1148,7 +1151,10 @@ export function configText(project) {
1148
1151
  w(' // "working" means, so the name is how two of them are told apart.');
1149
1152
  if (project.products.length > 1) {
1150
1153
  w(' // This repository makes more than one thing. A check covers whichever of them the');
1151
- w(' // settings below describe; run the others with `staysfixed check --product <name>`.');
1154
+ // `--product` is not an option on `check` and never has been. Naming it here sent people
1155
+ // to a flag the CLI rejects, in the settings file the tool itself wrote for them.
1156
+ w(' // settings below describe. To check one of the others, run `staysfixed check` from');
1157
+ w(' // inside that package, or point at its settings with `--config <file>`.');
1152
1158
  }
1153
1159
  w(` product: ${JSON.stringify(project.name)},`);
1154
1160
  w('');
@@ -1342,7 +1348,12 @@ export function configText(project) {
1342
1348
  } else if (project.pages.length > 0) {
1343
1349
  w(`${webOn}// ${project.pages.length} page address${project.pages.length === 1 ? '' : 'es'} are read out of your folder names automatically — nothing to list here.`);
1344
1350
  w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
1345
- w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ fill: '#email', with: 'a@b.c' }, { click: 'Sign in' }] }],`);
1351
+ // `fill:` and `with:` are not words this tool knows the verb is `type:` and the value
1352
+ // is `text:`. An unknown key used to be skipped in silence, so this example, handed to
1353
+ // every stranger with a sign-in, filled nothing, clicked Sign in on an empty form, and
1354
+ // then photographed the login page for every screen behind the wall while reporting a
1355
+ // clean run. The example that teaches the vocabulary has to be IN the vocabulary.
1356
+ w(`${webOn}// screens: [{ name: 'signing in', url: '/login', steps: [{ type: '#email', text: 'a@b.c' }, { type: '#password', text: 'secret' }, { click: 'Sign in' }] }],`);
1346
1357
  } else {
1347
1358
  w(`${webOn}// screens: [{ name: 'the front page', url: '/' }],`);
1348
1359
  }
@@ -1600,9 +1611,14 @@ function nextCommands(readiness, project) {
1600
1611
  if (reachable.length > 0) {
1601
1612
  next.push({
1602
1613
  command: 'staysfixed check --paired',
1614
+ // It does NOT record what "working" means, and saying so here sent everybody round a
1615
+ // loop: run it, be told there is no build on record, run it again, be told the same
1616
+ // thing. Only `ship` cuts a reference — that is the rule the whole product rests on,
1617
+ // because an agent that can bless its own work is not a safety net. So this says what
1618
+ // the run actually does, and `ship` below says what only it can do.
1603
1619
  what: reachable.some((r) => r.state === 'ready')
1604
- ? 'The first real run. It records what working looks like, so later runs have something to compare against.'
1605
- : 'The first real run. Nothing here is fully set up yet, so it records what it can reach and says plainly what it left out — which is more useful than waiting.',
1620
+ ? 'The first real run. It walks everything and shows you what it sees. It cannot record what "working" means only shipping does that.'
1621
+ : 'The first real run. Nothing here is fully set up yet, so it walks what it can reach and says plainly what it left out — which is more useful than waiting.',
1606
1622
  });
1607
1623
  }
1608
1624
  if (project.tests.files > 0) {
@@ -823,7 +823,12 @@ async function toolCheck(ctx, input) {
823
823
  );
824
824
 
825
825
  const page = unaccounted.slice(offset, offset + limit);
826
- const clean = unaccounted.length === 0 && newlyUnstable.length === 0 && result?.blocked !== true;
826
+ // A run that compared NOTHING is not a clean run, and this is the surface where saying so
827
+ // matters most. The engine had already worked it out and set `ok: false`; this line only
828
+ // ever counted differences, so a project with nothing on record produced zero differences,
829
+ // counted as clean, and the agent was told everything still works.
830
+ const comparedNothing = typeof result?.comparedNothing === 'string' && result.comparedNothing.length > 0;
831
+ const clean = cleanForAgent(result, unaccounted.length, newlyUnstable.length);
827
832
 
828
833
  // What this run did not look at, in the engine's own words. It rides in every reply,
829
834
  // clean ones included: a green result on a product with three hundred unopened doors is
@@ -843,7 +848,16 @@ async function toolCheck(ctx, input) {
843
848
  if (input.format === 'json') {
844
849
  const payload = {
845
850
  ok: clean,
846
- verdict: result?.blocked ? 'blocked' : clean ? 'nothing unaccounted for' : 'differences found',
851
+ // "differences found" would be the wrong word for a run that found none because it
852
+ // compared none. There are three outcomes here, not two, and the third is the one
853
+ // that must never be mistaken for either.
854
+ verdict: result?.blocked
855
+ ? 'blocked'
856
+ : comparedNothing
857
+ ? 'nothing was compared'
858
+ : clean
859
+ ? 'nothing unaccounted for'
860
+ : 'differences found',
847
861
  mode: result?.mode ?? null,
848
862
  note: result?.summary ?? null,
849
863
  noiseRemoved: result?.differencesNoise ?? null,
@@ -866,7 +880,14 @@ async function toolCheck(ctx, input) {
866
880
  waiversLeft: accounting?.left ?? null,
867
881
  note: accounting?.note ?? null,
868
882
  },
869
- findings: page,
883
+ // The class an agent reads has to be the class that DECIDES things, not the engine's
884
+ // first guess. A 20% markup on a price came back as `class: "ordinary"` here while the
885
+ // human text on the same run said "1 of them sealed and not yours to waive" and
886
+ // `staysfixed_waive` refused it outright because it touches money. An agent reading
887
+ // "ordinary" would reasonably believe it may wave a price change through, and would
888
+ // tell somebody so. `waivable` is spelled out beside it so nothing has to be inferred
889
+ // from a word at all.
890
+ findings: page.map(findingForAgent),
870
891
  aimedAt: aimed ? { surface: surface ?? 'auto', at: at ?? null, confirmed: missedTheTarget === null } : null,
871
892
  aimingWarning: missedTheTarget,
872
893
  };
@@ -913,6 +934,53 @@ async function toolCheck(ctx, input) {
913
934
  return { content: [{ type: 'text', text: body + tail }], isError: !clean };
914
935
  }
915
936
 
937
+ /**
938
+ * Is this a clean run, as far as the machine asking is concerned?
939
+ *
940
+ * The last line is the one that matters and it is the one that was missing. This surface
941
+ * used to work "clean" out from the difference count ALONE, and a product with nothing on
942
+ * record produces no differences — so zero differences counted as a pass, and an agent was
943
+ * told "everything that worked before still works" about a run that compared nothing at all.
944
+ * The engine had already decided; nobody asked it.
945
+ *
946
+ * So the engine's own verdict is the floor. Whatever else is true, this can never answer
947
+ * clean about a run the engine called not-ok. Counting reasons here will always be a list
948
+ * somebody forgets to add to; deferring to the decision already made cannot be.
949
+ *
950
+ * @param {any} result What `check` returned.
951
+ * @param {number} unaccounted Differences nobody has accounted for.
952
+ * @param {number} newlyUnstable Addresses that were steady and are not any more.
953
+ * @returns {boolean}
954
+ */
955
+ export function cleanForAgent(result, unaccounted, newlyUnstable) {
956
+ if (result && result.ok === false) return false;
957
+ if (result && result.blocked === true) return false;
958
+ if (result && typeof result.comparedNothing === 'string' && result.comparedNothing.length > 0) return false;
959
+ return unaccounted === 0 && newlyUnstable === 0;
960
+ }
961
+
962
+ /**
963
+ * One finding, shaped for the machine that reads it.
964
+ *
965
+ * Split out and exported because it is a DECISION, not formatting, and a decision only a
966
+ * running MCP server can reach is one nobody notices breaking. The class an agent reads has
967
+ * to be the class that decides things: a 20% markup on a price came back over MCP as
968
+ * `class: "ordinary"` while the human text on the same run said "1 of them sealed and not
969
+ * yours to waive" and `staysfixed_waive` refused it because it touches money. An agent
970
+ * reading "ordinary" would reasonably believe it may wave a price change through, and would
971
+ * say so to a person. `waivable` is spelled out beside it so nothing has to be inferred
972
+ * from a word at all.
973
+ *
974
+ * @param {any} f
975
+ * @returns {any}
976
+ */
977
+ export function findingForAgent(f) {
978
+ const sealed = classify(f);
979
+ return sealed
980
+ ? { ...f, class: sealed.class, sealed: true, waivable: false, sealedBecause: sealed.why, sealedBy: sealed.matched }
981
+ : { ...f, sealed: false, waivable: true };
982
+ }
983
+
916
984
  /**
917
985
  * @param {object} a
918
986
  * @param {CheckResult} a.result
@@ -931,7 +999,7 @@ async function toolCheck(ctx, input) {
931
999
  * @param {string|null} a.covers
932
1000
  * @returns {string}
933
1001
  */
934
- function renderCheck({ result, unaccounted, page, offset, limit, waived, expired, waiversLeft, newlyUnstable, intent, clean, missedTheTarget, notChecked, covers }) {
1002
+ export function renderCheck({ result, unaccounted, page, offset, limit, waived, expired, waiversLeft, newlyUnstable, intent, clean, missedTheTarget, notChecked, covers }) {
935
1003
  /** @type {string[]} */
936
1004
  const out = [];
937
1005
 
@@ -942,7 +1010,14 @@ function renderCheck({ result, unaccounted, page, offset, limit, waived, expired
942
1010
  return out.join('\n');
943
1011
  }
944
1012
 
945
- if (clean) {
1013
+ if (result?.comparedNothing) {
1014
+ out.push(
1015
+ result.comparedNothing === 'no reference'
1016
+ ? 'NOTHING WAS ACTUALLY COMPARED. There is no build of this product on record as working, so this run had nothing whatever to hold today\'s behaviour against. This is not a pass and not a failure - it is no answer.'
1017
+ : 'NOTHING WAS ACTUALLY COMPARED. Every journey was walked, and not one of them had anything on record from the build you were happy with. This is not a pass and not a failure - it is no answer.',
1018
+ );
1019
+ out.push('Do not report this as a clean run. Only shipping records what "working" means, and no agent may cut that reference.');
1020
+ } else if (clean) {
946
1021
  out.push('NOTHING UNACCOUNTED FOR. Everything that worked before still works, as far as this run could see.');
947
1022
  } else if (unaccounted.length) {
948
1023
  const sealed = unaccounted.filter((f) => classify(f) !== null).length;
@@ -233,6 +233,82 @@ async function readJson(file, fallback) {
233
233
  }
234
234
  }
235
235
 
236
+ /**
237
+ * Read a JSON file, change it, and write it back with nobody else doing the same thing at
238
+ * the same time.
239
+ *
240
+ * `writeJsonAtomic` makes each individual write whole — nobody ever reads half a file. It
241
+ * does nothing at all about two processes READING the same file, each appending to what they
242
+ * read, and each writing their own version over the other's. Measured on 2026-08-30: six
243
+ * `staysfixed ship` commands started at once on one project, all six reported success, four
244
+ * of them each believed they were cutting the very first reference — and four records
245
+ * survived out of six. This is an MCP server. Two agents shipping at once is not an exotic
246
+ * case, it is the design.
247
+ *
248
+ * The lock is a directory, because creating one either succeeds or fails and never half
249
+ * happens, on every platform this runs on. A lock far older than any write could take is
250
+ * rubbish left behind by a killed process and is taken. Waiting for ever is worse than the
251
+ * bug, so after a long wait it is taken anyway — losing a record is bad, and a release that
252
+ * hangs is worse.
253
+ *
254
+ * @template T
255
+ * @param {string} file
256
+ * @param {(current: T) => T | Promise<T>} change
257
+ * @param {T} fallback
258
+ * @returns {Promise<T>}
259
+ */
260
+ async function updateJsonAtomic(file, change, fallback) {
261
+ return await withLock(`${file}.lock`, async () => {
262
+ const next = await change(await readJson(file, fallback));
263
+ await writeJsonAtomic(file, next);
264
+ return next;
265
+ });
266
+ }
267
+
268
+ /**
269
+ * Do something with nobody else doing it at the same time, across processes.
270
+ *
271
+ * A directory is the lock, because creating one either succeeds or fails and never half
272
+ * happens, on every platform this runs on. A lock far older than the work could take is
273
+ * rubbish left behind by a killed process and is taken. Waiting for ever is worse than the
274
+ * bug it prevents, so after a long wait it is taken anyway: losing a record is bad, and a
275
+ * release that never returns is worse.
276
+ *
277
+ * @template T
278
+ * @param {string} lock
279
+ * @param {() => Promise<T>} work
280
+ * @returns {Promise<T>}
281
+ */
282
+ async function withLock(lock, work) {
283
+ const STALE_MS = 30_000;
284
+ const GIVE_UP_MS = 15_000;
285
+ await fsp.mkdir(path.dirname(lock), { recursive: true });
286
+ const startedAt = Date.now();
287
+ for (;;) {
288
+ try {
289
+ await fsp.mkdir(lock);
290
+ break;
291
+ } catch {
292
+ let age = 0;
293
+ try {
294
+ age = Date.now() - (await fsp.stat(lock)).mtimeMs;
295
+ } catch {
296
+ continue; // It went away between the failure and the question. Try again.
297
+ }
298
+ if (age > STALE_MS || Date.now() - startedAt > GIVE_UP_MS) {
299
+ await fsp.rm(lock, { recursive: true, force: true }).catch(() => {});
300
+ continue;
301
+ }
302
+ await new Promise((done) => setTimeout(done, 15 + Math.floor(Math.random() * 35)));
303
+ }
304
+ }
305
+ try {
306
+ return await work();
307
+ } finally {
308
+ await fsp.rm(lock, { recursive: true, force: true }).catch(() => {});
309
+ }
310
+ }
311
+
236
312
  /**
237
313
  * A sortable, file-safe id for one cut.
238
314
  * @param {Date} [now]
@@ -868,6 +944,32 @@ export async function cutReference(store, opts) {
868
944
 
869
945
  await ensureStore(store);
870
946
 
947
+ // ONE AT A TIME, PER PRODUCT. Everything below reads the current reference, decides
948
+ // whether this build is already it, moves the pointer, retires waivers and writes the log
949
+ // — and until 2026-08-30 nothing stopped two of them doing all of that at once.
950
+ //
951
+ // Measured: six `staysfixed ship` commands started together on one project. All six
952
+ // reported success. FOUR of them each said "Nothing was being compared against before
953
+ // this", because all four had read an empty reference and none had seen the others. Four
954
+ // records survived out of six, and the "already the reference, change nothing" path — the
955
+ // one that stops a release script running twice from writing history twice — never fired
956
+ // once. This is an MCP server: two agents shipping at once is the design, not an exotic
957
+ // case, and the file they were racing on is the one that defines what "working" means.
958
+ return await withLock(path.join(store.dir, `cut.${safeName(product)}.lock`), async () =>
959
+ cutReferenceHoldingTheLock(store, opts, product, buildId),
960
+ );
961
+ }
962
+
963
+ /**
964
+ * The cut itself, with the lock already held.
965
+ *
966
+ * @param {Store} store
967
+ * @param {any} opts
968
+ * @param {string} product
969
+ * @param {string} buildId
970
+ * @returns {Promise<ReferenceCut>}
971
+ */
972
+ async function cutReferenceHoldingTheLock(store, opts, product, buildId) {
871
973
  const decision = await shouldCut(store, product, opts.build);
872
974
  if (!decision.ok && opts.force !== true) {
873
975
  throw new StaysFixedError(decision.refusal ?? decision.why, {
@@ -977,20 +1079,24 @@ function summarise(cut, name, decision) {
977
1079
  */
978
1080
  async function appendToLog(store, cut) {
979
1081
  const file = fileIn(store, 'reference-log.json');
980
- /** @type {ReferenceCut[]} */
981
- const log = await readJson(file, /** @type {ReferenceCut[]} */ ([]));
982
- const all = [...(Array.isArray(log) ? log : []), cut];
983
-
984
- if (all.length > MAX_LOG_ENTRIES) {
985
- const overflow = all.slice(0, all.length - MAX_LOG_ENTRIES);
986
- const archiveFile = fileIn(store, 'reference-log-archive.json');
987
- /** @type {ReferenceCut[]} */
988
- const archive = await readJson(archiveFile, /** @type {ReferenceCut[]} */ ([]));
989
- await writeJsonAtomic(archiveFile, [...(Array.isArray(archive) ? archive : []), ...overflow]);
990
- await writeJsonAtomic(file, all.slice(-MAX_LOG_ENTRIES));
991
- return;
992
- }
993
- await writeJsonAtomic(file, all);
1082
+ const archiveFile = fileIn(store, 'reference-log-archive.json');
1083
+ await updateJsonAtomic(
1084
+ file,
1085
+ async (log) => {
1086
+ const all = [...(Array.isArray(log) ? log : []), cut];
1087
+ if (all.length > MAX_LOG_ENTRIES) {
1088
+ const overflow = all.slice(0, all.length - MAX_LOG_ENTRIES);
1089
+ await updateJsonAtomic(
1090
+ archiveFile,
1091
+ (archive) => [...(Array.isArray(archive) ? archive : []), ...overflow],
1092
+ /** @type {ReferenceCut[]} */ ([]),
1093
+ );
1094
+ return all.slice(-MAX_LOG_ENTRIES);
1095
+ }
1096
+ return all;
1097
+ },
1098
+ /** @type {ReferenceCut[]} */ ([]),
1099
+ );
994
1100
  }
995
1101
 
996
1102
  /**
package/src/v2/ship.js CHANGED
@@ -417,6 +417,32 @@ async function resolveBuild(store, product, release, told, onProblem) {
417
417
  return typeof told === 'string' ? { id: told, product } : told;
418
418
  }
419
419
 
420
+ // WHAT IS ACTUALLY HERE, before what the commit says is here.
421
+ //
422
+ // Matching on the commit alone blesses the wrong thing the moment the tree is dirty:
423
+ // several builds share one commit, this took the first of them, and that is usually an
424
+ // EARLIER build — one that was checked and came back clean. So editing a file and running
425
+ // `staysfixed ship` answered "was already the reference — nothing changed" and exited 0,
426
+ // about a tree nothing had ever looked at. Measured 2026-08-30 by deleting a button and
427
+ // shipping without a check.
428
+ //
429
+ // A dirty tree therefore has to match EXACTLY, on the fingerprint of what is on disk right
430
+ // now. If nothing on record is that tree, then this tree has not been checked, and the
431
+ // honest answer is the one the tool already knows how to give: no record of this build, so
432
+ // the reference does not move.
433
+ try {
434
+ const { fingerprintWorkingTree } = await import('./check.js');
435
+ const here = await fingerprintWorkingTree(store.root, product);
436
+ if (here?.id) {
437
+ const exact = builds.find((b) => b.fingerprint.id === here.id);
438
+ if (exact) return exact.fingerprint;
439
+ if (here.dirty) return null;
440
+ }
441
+ } catch {
442
+ // No git, or the tree could not be read. The joins below are all that is left, and they
443
+ // are better than refusing to record a release at all.
444
+ }
445
+
420
446
  const sha = release.gitSha;
421
447
  if (sha) {
422
448
  const sameCommit = builds.filter((b) => b.fingerprint.gitSha === sha);