staysfixed 0.9.0 → 0.10.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/CHANGELOG.md CHANGED
@@ -8,6 +8,107 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.10.0] — 2026-08-31
12
+
13
+ The night the four surfaces nobody had pointed it at were pointed at it, and the biggest
14
+ remaining lie in the coverage ledger was closed.
15
+
16
+ ### Fixed — the ledger said things had been checked that had not
17
+
18
+ - **Knocking counted as walking.** A door counted as walked because a step said it knocked on
19
+ it, whatever came back. So a route the source declares and the running build answers 404 to
20
+ was counted as covered — and behind a sign-in wall, where every request is bounced to
21
+ /login, every door in the product counted as walked and the run came back clean. What
22
+ answered was on the record all along. **404, 405, 410 and 501** now leave the door shut and
23
+ say that the source and the build disagree about whether it exists. **A redirect** is walked
24
+ — the bounce is real behaviour — but named as a bounce, and when every door that answered
25
+ did it, the run says so: that is what a sign-in wall looks like from outside. **A 500 is
26
+ still walked**, deliberately: the route exists and it broke, which is what a check is for.
27
+ - **A product that never started was reported as twelve regressions.** A server that will not
28
+ boot produces a difference at every address it used to answer at, and not one of them said
29
+ the server had not started. The verdict leads with the cause now.
30
+ - **A build older than its source passed as "nothing that worked has changed".** This tool
31
+ runs your product and never builds it, so `node dist/server.js` runs yesterday's build
32
+ against today's code and compares it against a reference cut from the same stale output. It
33
+ says so now. Following `npm run start` down into package.json is what made it fire at all.
34
+
35
+ ### Added — the headline feature actually runs
36
+
37
+ - **Guards run on `staysfixed check`**, and over MCP, whenever the settings give an address to
38
+ open. A guard that fails becomes a finding **sealed by name**, which no agent may wave
39
+ through. Where there is no address to open it still says so, and names the one line that
40
+ would change it.
41
+
42
+ ### Fixed — it asked for what it had already been given
43
+
44
+ - **`doctor` asked every adapter with an EMPTY config** unless the settings were JSON — and
45
+ `init` writes JavaScript. So on almost every project it asked for the very thing the
46
+ settings already named, and one sentence managed to say an app was present and missing at
47
+ once. It reads the few values it needs out of the text now, scoped per block, without
48
+ running anybody's code.
49
+ - **`ios.app` was invisible**: the lookup only ever searched for `xcworkspace`, while this
50
+ tool's own advice says to name the built app under `ios.app`.
51
+
52
+ ### Fixed — noise that buried the news
53
+
54
+ - **A bundler renames its output whenever the source changes**, so one edit produced four
55
+ extra findings, all of them chunk filenames, with the real change underneath. Normalised
56
+ inside a bundler's own asset paths only — a standalone content hash still changes and still
57
+ shows.
58
+ - **On a monorepo `init` announced five products and wrote settings for one.** What is
59
+ announced and what is written now have to agree.
60
+
61
+ ### Still wrong, and written down rather than hidden
62
+
63
+ - `init` still names a container folder (`packages/`) as a product on a workspaces monorepo.
64
+ - An Electron check deadlocked once with no output and could not be reproduced.
65
+ - The Windows surface is reachable and signed in but its screen is locked, so nothing can be
66
+ driven there yet.
67
+
68
+ ## [0.9.1] — 2026-08-30
69
+
70
+ Eleven more, from the same hunt that produced 0.9.0 — the findings it had already paid for.
71
+ Every one of these was decided by reading the code rather than by building another product to
72
+ reproduce it; the ones that genuinely need a Next.js app or a monorepo are written down and
73
+ left, because guessing at them would be worse.
74
+
75
+ ### Fixed
76
+
77
+ - **Every API shape change on every project was sealed as LOSING DATA**, permanently, and no
78
+ agent could wave any of it through. The classifier reads the words in a finding — and it
79
+ was reading the difference's *description*, which is the TOOL's sentence, identical on every
80
+ finding that channel makes: "a renamed or dropped field shows up on its own". `dropped` is
81
+ a data-loss word. A seal is decided by what the PRODUCT said now — its addresses, its
82
+ values, its journey names — never by vocabulary no user wrote and nobody can change. A real
83
+ deletion still seals.
84
+ - **`ship` printed no coverage caveat at all**, in text or `--json`. It is the command that
85
+ decides what "working" MEANS from that moment on, which makes it the last place that should
86
+ stay quiet about what the check never walked. It says it now, in the same breath as the
87
+ good news, exactly as every other surface does.
88
+ - **`init` said "Nothing is being left out."** from a survey of which SURFACES this machine
89
+ can drive — on a project the same run reported as five of six doors never opened. And
90
+ `getting-started` tells the agent to repeat that sentence to the person.
91
+ - **The MCP server echoed an agent's own strings back unbounded and kept them for ever.** A
92
+ megabyte of summary came back as a megabyte of tool result. Capped, and the cut says so
93
+ rather than quietly keeping the first part.
94
+ - **`check --journeys recorded` reported a missing FILE called "recorded".** `--help` offers
95
+ the word and the MCP surface answers it honestly — written, not wired into a run yet. The
96
+ same question gets the same answer on both surfaces now.
97
+ - **Guard names that said nothing were accepted**: `a b c`, `1 2 3`, `TODO fix later` and
98
+ `AC-101 regression check` all passed every rule. That name is the sentence somebody reads
99
+ when the guard fails, six months from now. Refused now — and every name that actually says
100
+ something still passes.
101
+ - **Every command-line check wrote two rows** to the log `ship` reads, so any count of how
102
+ many checks had run was doubled.
103
+ - **`--only` printed "everything that worked still works"** about a slice, with five of six
104
+ guards filtered away and one of the five failing.
105
+ - **`ship` blessed a build nothing had ever looked at** — it matched by git commit, so an
106
+ uncommitted edit resolved to an earlier build that had been checked and was clean.
107
+ - `docs/running-it-in-ci.md` told you to copy `.github/workflows/staysfixed.yml` "as it is".
108
+ That file is not in the npm package. It is printed in full in the page now.
109
+ - `docs/mcp.md` still told you to fill the project path in by hand; `doctor --json` has
110
+ filled it in since 0.8.0, and a path filled in wrongly fails silently.
111
+
11
112
  ## [0.9.0] — 2026-08-30
12
113
 
13
114
  Found the same way as 0.8.0 and one better: the published build was installed as a stranger
package/docs/mcp.md CHANGED
@@ -293,3 +293,16 @@ Nobody who wired that up has to change anything. Everything version 1's picture
293
293
  check did is also still reachable from the command line — `staysfixed check
294
294
  --pictures`, `staysfixed approve` — and pictures still require a person to
295
295
  approve them there, for the same reason they always did.
296
+
297
+ ---
298
+
299
+ ## You do not have to fill the path in yourself
300
+
301
+ The blocks above show `/absolute/path/to/your/project` because a page cannot know where you
302
+ are. `staysfixed doctor --json` can: its `wiring.mcp` carries the same block with `cwd`
303
+ already set to this project, ready to paste. Ask for that rather than editing by hand — a
304
+ path filled in wrongly fails silently, which is the worst way for this to go wrong.
305
+
306
+ ```sh
307
+ npx staysfixed doctor --json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.stringify(JSON.parse(s).wiring.mcp,null,2)))"
308
+ ```
@@ -17,7 +17,7 @@ Three files make that work:
17
17
 
18
18
  | | |
19
19
  | --- | --- |
20
- | `.github/workflows/staysfixed.yml` | The job. Copy it into your own project as it is. |
20
+ | `.github/workflows/staysfixed.yml` | The job. It is printed in full at the bottom of this page, because it is not in the npm package. |
21
21
  | `src/v2/ci.js` | Works out what to compare against, runs the check, writes the report, exits with the code that decides the job. |
22
22
  | This file | What it can and cannot do up there, and why. |
23
23
 
@@ -304,3 +304,125 @@ What is **not** proved, and would only be settled by a real run:
304
304
  - How long a paired run takes on a real product. On a two-command scratch project it was
305
305
  about a second; a real project with a browser in it will be minutes, and the 30-minute
306
306
  timeout in the workflow is a guess.
307
+
308
+ ---
309
+
310
+ ## The workflow itself
311
+
312
+ This file lives in the repository, not in the npm package — so if you installed Stays Fixed
313
+ from npm you cannot copy it off disk, and the line above used to send you looking for it.
314
+ Here it is in full. Save it as `.github/workflows/staysfixed.yml` in your own project.
315
+
316
+ ```yaml
317
+ # Prove that nothing which already worked has changed — on every pull request.
318
+ #
319
+ # WHY A BUILD SERVER IS A GOOD PLACE FOR THIS, and not a compromise. Everything Stays Fixed
320
+ # concludes rests on one claim: the difference was caused by the change and nothing else. A
321
+ # fresh runner has the same fonts every time, the same operating system, nothing else
322
+ # competing for a port, and no half-finished experiment left over from yesterday. It is a
323
+ # better machine for this job than anybody's laptop.
324
+ #
325
+ # WHAT IT COMPARES AGAINST. On a pull request: the commit your branch forked from — put back
326
+ # on this same runner with `git archive` and walked again, minutes apart. That is a full
327
+ # paired run, and it needs nothing stored from a previous job. On a push it works down to the
328
+ # last release, the last tag, or the commit before. Whichever it lands on is named in the job
329
+ # summary, because they are not equally strong.
330
+ #
331
+ # THE ONE LINE YOU MUST NOT DELETE is `fetch-depth: 0`. A shallow checkout answers "what did
332
+ # this branch fork from" with "this commit" — so the check would compare the build against
333
+ # itself, find nothing, and go green. Stays Fixed catches that and refuses, but the run is
334
+ # then worth nothing. Full history is what makes this work.
335
+
336
+ name: Stays Fixed
337
+
338
+ on:
339
+ pull_request:
340
+ push:
341
+ branches: [main, master]
342
+
343
+ # Nothing is published, released or tagged here, and no token is used for anything.
344
+ permissions:
345
+ contents: read
346
+
347
+ # Two runs of the same branch would be two builds on one machine fighting over ports and
348
+ # user data directories. Sequential, never simultaneous, is a rule of the whole tool.
349
+ concurrency:
350
+ group: staysfixed-${{ github.workflow }}-${{ github.ref }}
351
+ cancel-in-progress: true
352
+
353
+ jobs:
354
+ check:
355
+ name: Has anything that worked changed?
356
+ runs-on: ubuntu-latest
357
+ timeout-minutes: 30
358
+
359
+ steps:
360
+ - uses: actions/checkout@v4
361
+ with:
362
+ # Read the paragraph above before changing this.
363
+ fetch-depth: 0
364
+
365
+ - uses: actions/setup-node@v4
366
+ with:
367
+ node-version: '22'
368
+
369
+ - name: Install
370
+ run: npm ci || npm install
371
+
372
+ # Only needed if anything you check is a web page. Delete these three lines for a
373
+ # command line tool or a library — the check will say what it could not look at either way.
374
+ - name: A browser to look at web pages with
375
+ run: npx playwright install chromium
376
+
377
+ - name: Is Stays Fixed set up in this project?
378
+ id: setup
379
+ run: |
380
+ # Two separate looks on purpose. One `ls` over both patterns fails whenever
381
+ # either of them matches nothing, which would report a configured project as
382
+ # unconfigured and quietly skip the whole check.
383
+ if ls staysfixed.config.* >/dev/null 2>&1 || ls .staysfixed/config.* >/dev/null 2>&1; then
384
+ echo "configured=yes" >> "$GITHUB_OUTPUT"
385
+ else
386
+ echo "configured=no" >> "$GITHUB_OUTPUT"
387
+ {
388
+ echo "## Stays Fixed"
389
+ echo
390
+ echo "This project has no Stays Fixed settings file, so **nothing was checked**."
391
+ echo "This job is green because nothing was claimed — not because anything was proved."
392
+ echo
393
+ echo 'Run `npx staysfixed init` in your project to set it up.'
394
+ } >> "$GITHUB_STEP_SUMMARY"
395
+ fi
396
+
397
+ # Works out what to compare against, runs the check, writes the plain-English report
398
+ # into the job summary, and exits with the code that decides this job:
399
+ #
400
+ # 0 nothing that already worked has changed
401
+ # 1 something changed that nobody accounted for
402
+ # 2 the check could not run, or there was nothing to compare against — which is
403
+ # NOT a pass, and is why this step has no `continue-on-error`
404
+ - name: Check
405
+ if: steps.setup.outputs.configured == 'yes'
406
+ run: |
407
+ # In your own project Stays Fixed is a dependency. In the Stays Fixed repository
408
+ # itself it is the source you are looking at. Both paths are tried so this file
409
+ # can be copied straight out of one into the other.
410
+ if [ -f node_modules/staysfixed/src/v2/ci.js ]; then
411
+ node node_modules/staysfixed/src/v2/ci.js
412
+ else
413
+ node src/v2/ci.js
414
+ fi
415
+
416
+ # Every observation both builds produced, the verdict, and what it compared against.
417
+ # Kept even when the job fails — especially then.
418
+ - name: Keep the evidence
419
+ if: always() && steps.setup.outputs.configured == 'yes'
420
+ uses: actions/upload-artifact@v4
421
+ with:
422
+ name: staysfixed-${{ github.run_id }}
423
+ path: |
424
+ .staysfixed/ci
425
+ .staysfixed/v2
426
+ if-no-files-found: ignore
427
+ retention-days: 14
428
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers — as a CLI and as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/guard/name.js CHANGED
@@ -26,6 +26,9 @@ export const NAME_RULE_EXPLAINER =
26
26
  const MAX_LENGTH = 120;
27
27
 
28
28
  /** Words that start a test name rather than describe the app. */
29
+ /** Words that begin a note to yourself rather than a promise about the product. */
30
+ const PLACEHOLDERS = new Set(['todo', 'fixme', 'wip', 'xxx', 'tbd', 'temp', 'placeholder', 'hack']);
31
+
29
32
  const TEST_SPEAK = new Set([
30
33
  'test',
31
34
  'tests',
@@ -100,7 +103,7 @@ const VERBS = new Set([
100
103
  ]);
101
104
 
102
105
  /**
103
- * @typedef {'empty'|'long'|'path'|'id'|'symbols'|'caps'|'identifier'|'testspeak'|'short'} RefusalKind
106
+ * @typedef {'empty'|'long'|'path'|'id'|'symbols'|'caps'|'identifier'|'testspeak'|'short'|'placeholder'|'not-a-sentence'|'mostly-numbers'|'ticket'} RefusalKind
104
107
  */
105
108
 
106
109
  /**
@@ -194,6 +197,43 @@ function refuse(name) {
194
197
  };
195
198
  }
196
199
 
200
+ // Three words is a shape, not a sentence. `a b c`, `1 2 3`, `TODO fix later` and
201
+ // `AC-101 regression check` all cleared every rule above, and each of them is a guard
202
+ // nobody will understand in six months — which is the entire job of this name.
203
+ if (PLACEHOLDERS.has(first)) {
204
+ return {
205
+ kind: 'placeholder',
206
+ why: `Starting with "${words[0]}" is a note to yourself, not a promise about the product. Say what should still be true, so the failure reads as that sentence.`,
207
+ };
208
+ }
209
+
210
+ // A ticket reference is not a behaviour, and this file already says so about "#" and "::".
211
+ // `AC-101 regression check` cleared every other rule and names nothing a person could act on.
212
+ const ticket = words.find((w) => /^[A-Za-z]{2,}[-_]\d+$/.test(w));
213
+ if (ticket) {
214
+ return {
215
+ kind: 'ticket',
216
+ why: `"${ticket}" is a ticket reference, not a behaviour. Put it in "link" and say here what should still be true.`,
217
+ };
218
+ }
219
+
220
+ const realWords = words.filter((w) => /[A-Za-z]{4,}/.test(w));
221
+ if (realWords.length < 2) {
222
+ return {
223
+ kind: 'not-a-sentence',
224
+ why: 'That does not read as a sentence about the product — there are almost no words in it. Write what should still be true, in the words you would say out loud, like "the sidebar still collapses".',
225
+ };
226
+ }
227
+
228
+ const letters = (text.match(/[A-Za-z]/g) ?? []).length;
229
+ const digits = (text.match(/\d/g) ?? []).length;
230
+ if (digits > letters) {
231
+ return {
232
+ kind: 'mostly-numbers',
233
+ why: 'That is mostly numbers. A guard name is the sentence somebody reads when it fails, so put the ticket number in "link" and say the behaviour here.',
234
+ };
235
+ }
236
+
197
237
  return null;
198
238
  }
199
239
 
package/src/v2/check.js CHANGED
@@ -326,16 +326,38 @@ export async function check(options = {}) {
326
326
  // the word "guard" exactly zero times. A tool built to catch silent breakage must not do
327
327
  // nothing silently.
328
328
  const named = await guardNames(project.root);
329
- if (named.length > 0 && verdict.coverage) {
330
- verdict.coverage.gaps = [
331
- ...(verdict.coverage.gaps ?? []),
332
- {
333
- what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
334
- why:
335
- `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check. ` +
336
- `\`staysfixed check --guards\` walks them. ${named.map((n) => `"${n}"`).join(', ')}`,
337
- },
338
- ];
329
+ if (named.length > 0) {
330
+ const walked = await walkTheGuards(project.root, options);
331
+ if (walked.ran) {
332
+ // A guard that failed is a bug somebody already had, coming back. It is sealed by
333
+ // name one of the five classes no agent may wave through, whatever the reason.
334
+ for (const bad of walked.failed) {
335
+ verdict.findings = [
336
+ ...(verdict.findings ?? []),
337
+ /** @type {any} */ ({
338
+ id: `guard-${sha256(bad.name).slice(0, 6)}`,
339
+ title: `A bug that was already fixed is back: ${bad.name}`,
340
+ why: bad.message ?? 'The guard written for it does not hold any more.',
341
+ class: 'named guard',
342
+ guard: bad.name,
343
+ differences: [],
344
+ rank: 0,
345
+ count: 1,
346
+ }),
347
+ ];
348
+ }
349
+ verdict.summary = `${walked.said} ${verdict.summary}`;
350
+ if (walked.failed.length > 0) verdict.ok = false;
351
+ } else if (verdict.coverage) {
352
+ verdict.coverage.gaps = [
353
+ ...(verdict.coverage.gaps ?? []),
354
+ {
355
+ what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
356
+ why: `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check: ${walked.why}`,
357
+ unlockedBy: 'Give the settings an address to open — `url` beside `start` in the `web` block, or `electron.binary` — and every check walks them from then on.',
358
+ },
359
+ ];
360
+ }
339
361
  }
340
362
 
341
363
  /** @type {CheckOutcome} */
@@ -471,6 +493,21 @@ async function settle(verdict, store, product, guards) {
471
493
  : `NOTHING WAS ACTUALLY COMPARED. Every journey was walked on the build you have, and not one of them had anything on record from the build you were happy with, so there was nothing to hold them against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`;
472
494
  }
473
495
 
496
+ // THE CAUSE FIRST, when the cause is that the product never answered.
497
+ //
498
+ // A server that will not start produces a difference at every address it used to answer
499
+ // at — the content type gone, the body gone, "answered at all" arriving. Measured
500
+ // 2026-08-31 on a product whose start command throws: twelve findings, not one of them
501
+ // saying the server had not started, and a person reads "12 things behave differently"
502
+ // and goes looking for a regression in code that is fine. The symptoms are real and they
503
+ // belong in the list; they are just not the news.
504
+ const silent = didNotAnswer(verdict);
505
+ if (silent.length > 0) {
506
+ verdict.summary =
507
+ `THE PRODUCT DID NOT ANSWER. ${silent.length} ${silent.length === 1 ? 'way in was' : 'ways in were'} not tried at all — ${silent.slice(0, 3).join('; ')}${silent.length > 3 ? '; and more' : ''}. Most of what follows is that one fact wearing different clothes, not ${silent.length === 1 ? 'a separate change' : 'separate changes'}: fix the start and check again before reading any of it as a regression. ` +
508
+ verdict.summary;
509
+ }
510
+
474
511
  // And what was NOT looked at, in the same breath as the good news, on every run
475
512
  // including the clean ones. A green verdict on a product with three hundred doors
476
513
  // nobody has ever opened is true and it is not what it looks like, and the only place
@@ -659,6 +696,45 @@ function comparedNothing(verdict) {
659
696
  return nothingToCompare >= walked ? 'no stored record' : null;
660
697
  }
661
698
 
699
+ /**
700
+ * Walk the guards, if this project can be opened.
701
+ *
702
+ * Guards are the headline of this whole product — one plain-English rule per bug somebody
703
+ * already had — and the default command never ran them. It said so, which was better than
704
+ * pretending, but saying it is not doing it. Version 1 knows how to drive an app and run
705
+ * them; what was missing was anybody calling it from here.
706
+ *
707
+ * It needs an address it can open. Where the settings only say how to START the product,
708
+ * that is version 2's job and this path cannot do it — so it says so instead, and names the
709
+ * one line that would change it.
710
+ *
711
+ * @param {string} root
712
+ * @param {any} options
713
+ * @returns {Promise<{ran: boolean, why: string, said: string, failed: {name: string, message?: string}[]}>}
714
+ */
715
+ async function walkTheGuards(root, options) {
716
+ try {
717
+ const { loadProject } = await import('../core/config.js');
718
+ const { runCheck } = await import('../run.js');
719
+ const project = await loadProject({ cwd: root, configFile: options?.configFile });
720
+ const run = await runCheck(project, { guardsOnly: true, writeReport: false, quiet: true, signal: options?.signal });
721
+ const guards = run.guards ?? [];
722
+ const failed = guards.filter((g) => g.status === 'failed').map((g) => ({ name: g.name, message: g.message }));
723
+ const held = guards.length - failed.length;
724
+ return {
725
+ ran: true,
726
+ why: '',
727
+ said:
728
+ failed.length === 0
729
+ ? `All ${guards.length} guard${guards.length === 1 ? '' : 's'} still hold.`
730
+ : `${failed.length} of ${guards.length} guards failed — ${failed.length === 1 ? 'a bug' : 'bugs'} that ${failed.length === 1 ? 'was' : 'were'} already fixed ${failed.length === 1 ? 'is' : 'are'} back, and no agent may wave ${failed.length === 1 ? 'it' : 'them'} through. ${held} still hold.`,
731
+ failed,
732
+ };
733
+ } catch (e) {
734
+ return { ran: false, why: messageOf(e), said: '', failed: [] };
735
+ }
736
+ }
737
+
662
738
  /**
663
739
  * The guards this project has, by name.
664
740
  *
@@ -681,6 +757,125 @@ export async function guardNames(root) {
681
757
  }
682
758
  }
683
759
 
760
+ /**
761
+ * Is the thing that ran older than the code it was built from?
762
+ *
763
+ * This tool never builds anything — deliberately, because building somebody's project is not
764
+ * its business. But a project whose start command runs `dist/server.js` and whose source
765
+ * lives in `src/` will happily run YESTERDAY's build against today's source, compare it
766
+ * against a reference cut from the same stale output, and answer "Nothing that worked has
767
+ * changed" — about code it has never once executed. Measured 2026-08-31.
768
+ *
769
+ * It cannot be fixed by building; it can be SAID, which is all a coverage gap has to do.
770
+ *
771
+ * @param {string} root
772
+ * @param {any} config
773
+ * @returns {Promise<CoverageGap|null>}
774
+ */
775
+ async function builtBeforeItsSource(root, config) {
776
+ try {
777
+ let pkg = {};
778
+ try {
779
+ pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
780
+ } catch {
781
+ return null;
782
+ }
783
+ const scripts = /** @type {any} */ (pkg).scripts ?? {};
784
+ // `npm run start` says nothing about where the product lives; the answer is one level
785
+ // down, in the script it runs. Following that indirection is the difference between this
786
+ // check firing and never firing, because `init` writes exactly `npm run start`.
787
+ const through = (/** @type {string} */ line) => {
788
+ const run = /(?:npm run|yarn|pnpm run|pnpm)\s+([\w:-]+)/.exec(line);
789
+ const named = run ? scripts[run[1]] : line.includes('npm start') ? scripts.start : null;
790
+ return `${line} ${typeof named === 'string' ? named : ''}`;
791
+ };
792
+ const starts = [
793
+ ...(config?.process?.commands ?? []).map((/** @type {any} */ c) => String(c?.run ?? '')),
794
+ String(config?.http?.start ?? ''),
795
+ String(config?.web?.start ?? ''),
796
+ ].map(through).join(' ');
797
+ const named = /\b(dist|build|out|lib)\b/.exec(starts);
798
+ const builds = typeof scripts.build === 'string';
799
+ if (!named || !builds) return null;
800
+
801
+ const outDir = path.join(root, named[1]);
802
+ const srcDir = path.join(root, 'src');
803
+ const [built, source] = await Promise.all([newestUnder(outDir), newestUnder(srcDir)]);
804
+ if (built === 0 || source === 0 || source <= built) return null;
805
+
806
+ const behind = Math.round((source - built) / 1000);
807
+ /** @param {number} n @param {string} unit */
808
+ const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'}`;
809
+ const howLong =
810
+ behind > 86400 ? plural(Math.round(behind / 86400), 'day') : behind > 3600 ? plural(Math.round(behind / 3600), 'hour') : plural(Math.max(1, Math.round(behind / 60)), 'minute');
811
+ return {
812
+ what: `What ran is older than the code it was built from — \`${named[1]}/\` is ${howLong} behind \`src/\`.`,
813
+ why: `This tool runs your product, it never builds it. So the build in \`${named[1]}/\` is what was walked, and your newer source was not executed at all. A clean result here says nothing whatever about the code you have just written — and the reference it was compared against was cut from the same stale output.`,
814
+ unlockedBy: 'Run your build before the check — `npm run build && npx staysfixed check` — or put the build into the start command in your settings.',
815
+ };
816
+ } catch {
817
+ return null;
818
+ }
819
+ }
820
+
821
+ /**
822
+ * The newest modification time anywhere under a folder, or 0 if there is nothing there.
823
+ * @param {string} dir
824
+ * @returns {Promise<number>}
825
+ */
826
+ async function newestUnder(dir) {
827
+ let newest = 0;
828
+ /** @param {string} at @param {number} depth */
829
+ const walk = async (at, depth) => {
830
+ if (depth > 6) return;
831
+ let entries = [];
832
+ try {
833
+ entries = await fsp.readdir(at, { withFileTypes: true });
834
+ } catch {
835
+ return;
836
+ }
837
+ for (const entry of entries) {
838
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
839
+ const full = path.join(at, entry.name);
840
+ if (entry.isDirectory()) await walk(full, depth + 1);
841
+ else {
842
+ try {
843
+ const at2 = (await fsp.stat(full)).mtimeMs;
844
+ if (at2 > newest) newest = at2;
845
+ } catch {
846
+ // gone between the listing and the question
847
+ }
848
+ }
849
+ }
850
+ };
851
+ await walk(dir, 0);
852
+ return newest;
853
+ }
854
+
855
+ /**
856
+ * The ways into the product that were never tried, and why.
857
+ *
858
+ * A build that would not start does not produce one finding — it produces one at every
859
+ * address it used to answer at, and the real news is nowhere in the list.
860
+ *
861
+ * @param {CheckOutcome} verdict
862
+ * @returns {string[]}
863
+ */
864
+ function didNotAnswer(verdict) {
865
+ /** @type {Set<string>} */
866
+ const said = new Set();
867
+ for (const finding of verdict.findings ?? []) {
868
+ for (const d of finding.differences ?? []) {
869
+ const path = String(d.path ?? '');
870
+ if (!path.endsWith('answered at all')) continue;
871
+ const name = path.split('.').slice(1, -1).join('.') || path;
872
+ const why = typeof d.candidate === 'string' ? d.candidate : typeof d.describe === 'string' ? d.describe : '';
873
+ said.add(why ? `${name} (${String(why).slice(0, 90)})` : name);
874
+ }
875
+ }
876
+ return [...said];
877
+ }
878
+
684
879
  /**
685
880
  * Copies left behind by runs that never finished.
686
881
  *
@@ -1591,6 +1786,8 @@ async function openProject(options) {
1591
1786
  unlockedBy: `Fix package.json, or put the name you want in your settings file as product: '<name>'. Until then every comparison starts from nothing.`,
1592
1787
  });
1593
1788
  }
1789
+ const stale = await builtBeforeItsSource(root, config);
1790
+ if (stale) gaps.push(stale);
1594
1791
  if (storeTrouble.length > 0) {
1595
1792
  gaps.push({
1596
1793
  what: 'This run was NOT written down, so the next check has nothing from today to compare against.',
@@ -1916,6 +2113,18 @@ async function gatherJourneys({ root, config, options }) {
1916
2113
 
1917
2114
  const named =
1918
2115
  options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
2116
+ // `recorded` is a word this tool knows and `check --help` offers it — it is simply not
2117
+ // wired into a run yet. The MCP surface says exactly that; the command line fell through to
2118
+ // the branch above, treated the word as a FILE PATH, and answered that a file called
2119
+ // "recorded" was missing. The same question has to get the same answer on both.
2120
+ if (options.journeys === 'recorded') {
2121
+ throw new StaysFixedError(
2122
+ 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked.',
2123
+ {
2124
+ hint: 'Leave --journeys out to use the steps each adapter reads from your source, pass `suite` to walk your own test suite, or pass the path to a journeys file.',
2125
+ },
2126
+ );
2127
+ }
1919
2128
  if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
1920
2129
 
1921
2130
  // The project's own test suite, when somebody asked for it in those words and never
@@ -101,6 +101,14 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
101
101
  * @property {string[]} [doors] Door keys the journey's steps name, for steps that
102
102
  * named a door with nothing to tell apart from
103
103
  * another of the same name. See doorKey.
104
+ * @property {{door: string, status: number}[]} [onlyRedirected]
105
+ * Doors that answered with a redirect. Walked — the
106
+ * bounce is real behaviour — but what is behind them
107
+ * was never seen.
108
+ * @property {{door: string, status: number}[]} [knockedShut]
109
+ * Doors a step knocked on where the running build
110
+ * answered that they are not there. Knocking is not
111
+ * walking, and these have proved nothing.
104
112
  * @property {string[]} [doorAddresses] Full door addresses, for steps that were specific
105
113
  * enough to build one. A route step knows its verb,
106
114
  * and GET /x and POST /x are two doors that share a
@@ -401,6 +409,15 @@ export function addressesTouched(observations) {
401
409
  return { paths: [...paths], byChannel };
402
410
  }
403
411
 
412
+ /**
413
+ * Answers that mean the door is not there in the build that ran.
414
+ *
415
+ * A 500 is deliberately NOT here: the route exists and it broke, which is a real difference
416
+ * and exactly what a check is for. These four are the codes that say the thing the source
417
+ * declares was never reachable, so nothing has been proved about it either way.
418
+ */
419
+ const NOTHING_THERE = new Set([404, 405, 410, 501]);
420
+
404
421
  /**
405
422
  * A walk, built from one stored capture and, when it is to hand, the journey behind it.
406
423
  *
@@ -426,8 +443,55 @@ export function walkFromCapture(capture, journey) {
426
443
  // would report POST as walked too, which is the coverage ledger lying in the one direction
427
444
  // it must never lie in.
428
445
  const named = journey.steps.filter((s) => typeof s.door === 'string' && typeof s.kind === 'string');
429
- const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '');
430
- const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '');
446
+
447
+ // KNOCKING IS NOT WALKING.
448
+ //
449
+ // A door was counted as walked because a STEP said it knocked on it — whatever came back.
450
+ // So a route the source declares and the running build answers 404 counted as covered; and
451
+ // behind a login wall, where every request is bounced to /login, every door in the product
452
+ // counted as walked and the run came back clean. That is the coverage ledger lying in the
453
+ // one direction this file says it must never lie in.
454
+ //
455
+ // What answered is on the record: the http adapter writes `api.<door>.status`. A 404, 405,
456
+ // 410 or 501 means the thing the code declares is not there in the build that ran, so
457
+ // nothing was proved about it and it stays shut.
458
+ const answered = new Map();
459
+ for (const ob of capture.observations ?? []) {
460
+ const found = /^api\.(.+)\.status$/.exec(String(ob?.path ?? ''));
461
+ if (found) answered.set(found[1], Number(ob.value));
462
+ }
463
+ /** @type {{door: string, status: number}[]} */
464
+ const shut = [];
465
+ /** @type {{door: string, status: number}[]} */
466
+ const bounced = [];
467
+ /** @param {any} s @returns {boolean} */
468
+ const reallyWalked = (s) => {
469
+ // The door is the ROUTE (`/reports`); the observation is addressed by method and route
470
+ // together (`api.GET /reports.status`), because GET and POST on one path are two doors.
471
+ // Try the composite first and the bare name after, so both shapes of step are covered.
472
+ const keys = [
473
+ typeof s.doorDetail === 'string' && s.doorDetail ? `${s.doorDetail} ${s.door}` : null,
474
+ typeof s.method === 'string' && s.method ? `${s.method} ${s.door}` : null,
475
+ String(s.door),
476
+ ].filter(Boolean);
477
+ const key = keys.find((k) => answered.has(/** @type {string} */ (k)));
478
+ const code = key === undefined ? undefined : answered.get(/** @type {string} */ (key));
479
+ // A redirect is real behaviour and it IS walked — but what you saw is the bounce, not the
480
+ // thing behind it. Behind a login wall every door answers 302 to /login, and the run
481
+ // then reports full coverage of a product it never got into.
482
+ if (typeof code === 'number' && code >= 300 && code < 400) {
483
+ bounced.push({ door: String(key), status: code });
484
+ return true;
485
+ }
486
+ if (typeof code !== 'number' || !NOTHING_THERE.has(code)) return true;
487
+ shut.push({ door: String(key), status: code });
488
+ return false;
489
+ };
490
+
491
+ const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '').filter(reallyWalked);
492
+ const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '').filter(reallyWalked);
493
+ if (shut.length > 0) walk.knockedShut = shut;
494
+ if (bounced.length > 0) walk.onlyRedirected = bounced;
431
495
  if (byName.length > 0) walk.doors = byName.map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
432
496
  if (exact.length > 0) {
433
497
  walk.doorAddresses = exact.map((s) =>
@@ -613,6 +677,27 @@ export function buildLedger(input) {
613
677
  `${cutFunctions} functions that really did run were cut from the coverage lists to keep them readable, so up to that many of the doors counted as never opened were in fact opened. This ledger undercounts, and it undercounts by no more than ${cutFunctions}.`,
614
678
  );
615
679
  }
680
+ // Named, never silently dropped. A route the code declares and the build answers 404 to is
681
+ // not a covered route and it is not an absent one either — it is a disagreement between the
682
+ // source and the thing that ran, and that is worth more than most differences.
683
+ /** @type {Map<string, number>} */
684
+ const shutDoors = new Map();
685
+ for (const { walk } of walks) for (const d of walk.knockedShut ?? []) shutDoors.set(d.door, d.status);
686
+ if (shutDoors.size > 0) {
687
+ const listed = [...shutDoors.entries()].slice(0, 6).map(([door, code]) => `${door} answered ${code}`).join(', ');
688
+ caveats.push(
689
+ `${shutDoors.size} ${shutDoors.size === 1 ? 'door the code declares was' : 'doors the code declares were'} knocked on and answered as not being there (${listed}${shutDoors.size > 6 ? ', and more' : ''}). Knocking is not walking: nothing has been proved about ${shutDoors.size === 1 ? 'it' : 'them'}, and the source and the build that ran disagree about whether ${shutDoors.size === 1 ? 'it exists' : 'they exist'}.`,
690
+ );
691
+ }
692
+ /** @type {Map<string, number>} */
693
+ const bouncedDoors = new Map();
694
+ for (const { walk } of walks) for (const d of walk.onlyRedirected ?? []) bouncedDoors.set(d.door, d.status);
695
+ if (bouncedDoors.size > 0) {
696
+ const all = bouncedDoors.size >= Math.max(1, opened);
697
+ caveats.push(
698
+ `${bouncedDoors.size} ${bouncedDoors.size === 1 ? 'door' : 'doors'} answered with a redirect rather than with ${bouncedDoors.size === 1 ? 'a page' : 'pages'} — ${[...bouncedDoors.entries()].slice(0, 5).map(([door, code]) => `${door} answered ${code}`).join(', ')}${bouncedDoors.size > 5 ? ', and more' : ''}. What was seen is the bounce, not what is behind it.${all ? ' EVERY door that answered did this, which is what a sign-in wall looks like from out here: this run has not been inside the product at all.' : ''}`,
699
+ );
700
+ }
616
701
  if (input.doors.length === 0) {
617
702
  caveats.push('No doors are known at all, so this ledger cannot say what is uncovered — which is not the same as there being nothing uncovered.');
618
703
  }
package/src/v2/doctor.js CHANGED
@@ -900,6 +900,18 @@ async function phoneApps(root, configFile) {
900
900
  return found ? { where: found[1], how: `your settings name it under ${key}` } : null;
901
901
  };
902
902
 
903
+ /**
904
+ * A named key whose value has to look right, so one settings word cannot be mistaken for
905
+ * another block's.
906
+ * @param {string} key
907
+ * @param {(value: string) => boolean} looksRight
908
+ * @returns {FoundApp|null}
909
+ */
910
+ const namedPath = (key, looksRight) => {
911
+ const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(settings);
912
+ return found && looksRight(found[1]) ? { where: found[1], how: `your settings name it under ${key}` } : null;
913
+ };
914
+
903
915
  /**
904
916
  * @param {string[]} folders
905
917
  * @param {(name: string) => boolean} wanted
@@ -933,6 +945,14 @@ async function phoneApps(root, configFile) {
933
945
  : null);
934
946
 
935
947
  const ios =
948
+ // `ios.app` FIRST, because that is the key the adapter reads and the key this very file
949
+ // tells people to write: "name the built .app in your settings under ios.app". It then
950
+ // looked only for `xcworkspace`, so a settings file naming a real built app was invisible
951
+ // and doctor answered "no iPhone app was found in this project, and the settings do not
952
+ // name one" about a project whose settings named one. Measured 2026-08-31 against a real
953
+ // TerminalDeck.app. The value has to end in `.app` so a bare `app:` belonging to some
954
+ // other block can never be mistaken for this one.
955
+ namedPath('app', (v) => v.endsWith('.app')) ??
936
956
  named('xcworkspace') ??
937
957
  built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
938
958
  (there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
@@ -1065,6 +1085,16 @@ async function askTheAdapters(root) {
1065
1085
  // Read as text and parsed only when it is JSON. Doctor never runs a person's code to
1066
1086
  // answer a question about their machine, and a settings file may be JavaScript.
1067
1087
  if (file && file.endsWith('.json')) config = JSON.parse(readFileSync(file, 'utf8'));
1088
+ // But "not JSON" was being treated as "says nothing", and `init` writes JavaScript — so
1089
+ // for almost every project every adapter was asked what it needs while being handed an
1090
+ // EMPTY config. It then asked for the very thing the settings already named: a project
1091
+ // whose settings pointed at a real built TerminalDeck.app was told, in one sentence,
1092
+ // "the app is here. What is missing is a built iPhone app to check". Measured 2026-08-31.
1093
+ //
1094
+ // The few values the adapters need to answer honestly are read out of the TEXT instead,
1095
+ // scoped to their own block so one block's `app` can never be read as another's. Still no
1096
+ // code is run, which was the whole point of the rule.
1097
+ else if (file) config = { ...config, ...settingsFromText(readFileSync(file, 'utf8')) };
1068
1098
  } catch {
1069
1099
  config = {};
1070
1100
  }
@@ -1384,6 +1414,54 @@ export function readHostProbe(name, alive, look) {
1384
1414
  return report;
1385
1415
  }
1386
1416
 
1417
+ /**
1418
+ * The handful of settings an adapter needs to say what it is missing, read out of a
1419
+ * JavaScript settings file WITHOUT running it.
1420
+ *
1421
+ * Scoped per block on purpose: `app` means one thing under `ios` and nothing under `web`, and
1422
+ * a flat search would hand the wrong path to the wrong adapter.
1423
+ *
1424
+ * @param {string} text
1425
+ * @returns {Record<string, any>}
1426
+ */
1427
+ function settingsFromText(text) {
1428
+ const clean = withoutComments(text);
1429
+ /** @type {Record<string, any>} */
1430
+ const out = {};
1431
+ /** @type {Record<string, string[]>} */
1432
+ const wanted = {
1433
+ ios: ['app'],
1434
+ android: ['apk', 'package'],
1435
+ electron: ['binary'],
1436
+ web: ['url', 'start'],
1437
+ http: ['start', 'url'],
1438
+ };
1439
+ for (const [block, keys] of Object.entries(wanted)) {
1440
+ const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
1441
+ if (!at) continue;
1442
+ // The block's own text: from its brace to the matching one, counted rather than guessed,
1443
+ // so a nested object cannot end the block early.
1444
+ let depth = 0;
1445
+ let end = at.index + at[0].length;
1446
+ for (; end < clean.length; end += 1) {
1447
+ if (clean[end] === '{') depth += 1;
1448
+ else if (clean[end] === '}') {
1449
+ if (depth === 0) break;
1450
+ depth -= 1;
1451
+ }
1452
+ }
1453
+ const inside = clean.slice(at.index + at[0].length, end);
1454
+ /** @type {Record<string, string>} */
1455
+ const found = {};
1456
+ for (const key of keys) {
1457
+ const hit = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(inside);
1458
+ if (hit) found[key] = hit[1];
1459
+ }
1460
+ if (Object.keys(found).length > 0) out[block] = found;
1461
+ }
1462
+ return out;
1463
+ }
1464
+
1387
1465
  /**
1388
1466
  * Host names out of ~/.ssh/config. Patterns are skipped: `Host *` is a rule, not
1389
1467
  * a machine, and dialling it would be meaningless.
@@ -1704,7 +1782,14 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1704
1782
  ? 'Cannot run here: no usable iOS runtime was found, so there is no simulator to boot the app on.'
1705
1783
  : iosReady
1706
1784
  ? `Covered on the simulator, against the stored record. It boots ${phones.ios.where} on a simulator of its own and reads what each control on the screen is and does. A real iPhone in your hand cannot be compared side by side and never will be — two builds cannot exist on it at once.`
1707
- : `The simulator is here and the app is here. What is missing is ${plainList(iosWants.map((n) => n.what))}, so a clean result would cover less than it looks like.`,
1785
+ : // "and the app is here" was said because a PATH was found, while the adapter was
1786
+ // still asking for a built app — so one sentence claimed the app was present and
1787
+ // missing at once. Measured 2026-08-31 on a settings file pointing at a
1788
+ // TerminalDeck.app that turned out to be an empty folder left by an old build.
1789
+ // A path is not a bundle, and only the adapter can tell the difference.
1790
+ iosWants.some((n) => /built iPhone app/i.test(String(n.what)))
1791
+ ? `The simulator is here and the settings name an app, but there is no built app bundle at that path — an empty folder from an old build looks exactly like this. What is missing is ${plainList(iosWants.map((n) => n.what))}.`
1792
+ : `The simulator is here and the app is here. What is missing is ${plainList(iosWants.map((n) => n.what))}, so a clean result would cover less than it looks like.`,
1708
1793
  canCheck: iosReady || iosPartly ? [...withoutADriver, 'meaning', 'pixels'] : [],
1709
1794
  cannotCheck: iosReady || iosPartly ? [] : CHANNELS.map((c) => c.id),
1710
1795
  needs:
package/src/v2/init.js CHANGED
@@ -1579,7 +1579,14 @@ function whatItCovers(readiness) {
1579
1579
  else parts.push('Right now a check here covers nothing in full.');
1580
1580
  if (waiting.length > 0) parts.push(`${plainList(waiting, true)} ${waiting.length === 1 ? 'is' : 'are'} not covered yet, and the list below says exactly what is in the way and who has to do it.`);
1581
1581
  if (notCovered.length > 0) parts.push(`${plainList(notCovered, true)} ${notCovered.length === 1 ? 'is' : 'are'} not checked at all, so a clean result says nothing whatever about ${notCovered.length === 1 ? 'it' : 'them'}.`);
1582
- if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) parts.push('Nothing is being left out.');
1582
+ // "on this machine", because that is the only thing this sentence knows. It is built from
1583
+ // SURFACES — which kinds of product can be watched here — and says nothing whatever about
1584
+ // how much of this project a run actually walks. Unqualified, it read as a coverage
1585
+ // promise, and `getting-started` tells the agent to repeat it to the person: measured
1586
+ // 2026-08-30 on a project the same run reported as five of six doors never opened.
1587
+ if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) {
1588
+ parts.push('Nothing is being left out on this machine — though how much of the project a run actually walks is a separate question, and every check answers it.');
1589
+ }
1583
1590
 
1584
1591
  // The hole that never closes, named with the language that causes it. "Not covered yet"
1585
1592
  // reads as a job somebody will get to; this one is nobody's job and saying so is the
@@ -1800,7 +1807,21 @@ export async function run(ctx) {
1800
1807
  }
1801
1808
 
1802
1809
  blank();
1810
+ // What was ANNOUNCED and what was WRITTEN have to agree. On a workspaces monorepo this
1811
+ // said "can be checked here now" about four things — including a folder that is only a
1812
+ // container and holds no product at all — and then wrote one settings file describing one
1813
+ // product. Measured 2026-08-31. Everything above is what was FOUND; this says what is
1814
+ // actually covered, and how to cover the rest.
1815
+ const others = (result.plan?.readiness ?? [])
1816
+ .map((/** @type {any} */ r) => String(r.product ?? ''))
1817
+ .filter((/** @type {string} */ n, /** @type {number} */ i, /** @type {string[]} */ all) => n !== '' && all.indexOf(n) === i);
1803
1818
  if (result.written.length > 0) ok(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}`);
1819
+ if (others.length > 1) {
1820
+ warn(
1821
+ `Those settings describe ONE product. ${others.length} were found here (${others.join(', ')}), and the others are not covered by this file. ` +
1822
+ 'Run `staysfixed init` from inside each of the others to give it its own settings, and check it from there.',
1823
+ );
1824
+ }
1804
1825
  if (result.kept.length > 0) warn(`Left exactly as it was: ${result.kept.map((f) => shortPath(f)).join(', ')}`);
1805
1826
  for (const problem of result.problems) fail(problem);
1806
1827
 
@@ -1661,7 +1661,14 @@ function problem(message) {
1661
1661
  function text(v) {
1662
1662
  if (typeof v !== 'string') return null;
1663
1663
  const s = v.trim();
1664
- return s === '' ? null : s;
1664
+ if (s === '') return null;
1665
+ // Capped, because every one of these is a string an AGENT chose and several of them are
1666
+ // echoed straight back in the reply and then written into the store for ever. A megabyte
1667
+ // of summary came back as a megabyte of tool result and stayed there. Nothing legitimate
1668
+ // here is long: a reason, a finding id, a surface name. Cutting says so out loud rather
1669
+ // than quietly keeping the first part.
1670
+ const MOST = 4000;
1671
+ return s.length <= MOST ? s : `${s.slice(0, MOST)} … (cut here: this was ${s.length} characters, and nothing this tool asks for is that long)`;
1665
1672
  }
1666
1673
 
1667
1674
  /**
@@ -114,6 +114,17 @@ export const DEFAULT_RULES = [
114
114
  pattern: '\\b[0-9a-fA-F]{16,31}\\b',
115
115
  with: '<hex>',
116
116
  },
117
+ {
118
+ id: 'asset.bundled',
119
+ kind: 'replace',
120
+ what: 'The hash in a bundler\'s own asset filename — /_next/static/chunks/main-9f2c1a.js, /assets/index-4b8e21.css, app.7d3f9a1c.js.',
121
+ why:
122
+ 'A bundler renames its output whenever the source changes, so editing one line renames several files. Measured on a Next.js app: one source edit produced four extra findings, all of them chunk filenames, and the change a person actually made was underneath them. The rename is not news — the code change is, and that is reported on its own.',
123
+ wouldHide:
124
+ 'A deliberate change to an asset filename, which nobody makes by hand. Deliberately narrow: the hash is only taken inside a path that a bundler owns, so a content hash anywhere else still changes and still shows — see id.hex, which leaves 32-and-longer hex alone for exactly that reason.',
125
+ pattern: '(/_next/static/[^"\'\\s]*?|/assets/[^"\'\\s]*?|\\b[\\w.-]+)[.-][0-9a-fA-F]{6,}(\\.(?:js|mjs|css|map))',
126
+ with: '$1.<asset>$2',
127
+ },
117
128
  {
118
129
  id: 'id.pid',
119
130
  kind: 'replace',
package/src/v2/sealed.js CHANGED
@@ -358,7 +358,13 @@ function readFinding(finding) {
358
358
  const differences = finding.differences ?? [];
359
359
  for (const d of differences) {
360
360
  add(d.path, d.path);
361
- add(d.describe, d.path);
361
+ // NOT `d.describe`. That sentence is the TOOL's, not the product's — it explains what a
362
+ // channel watches and is the same on every finding that channel ever produces. The API
363
+ // shape channel's says "a renamed or dropped field shows up on its own", and `dropped`
364
+ // is a data-loss word, so every API shape change on every project was sealed as losing
365
+ // data, permanently, and no agent could wave any of it through. A seal has to be decided
366
+ // by what the PRODUCT said — its addresses, its values, its journey names — never by the
367
+ // tool's own vocabulary, which no user wrote and nobody can change.
362
368
  add(faceOf(d.reference), d.path);
363
369
  add(faceOf(d.candidate), d.path);
364
370
  add(d.journey, `the ${d.journey} journey`);
package/src/v2/ship.js CHANGED
@@ -211,10 +211,13 @@ export async function onShip(opts = {}) {
211
211
  result.cut = cut.unchanged !== true;
212
212
  result.unchanged = cut.unchanged === true;
213
213
 
214
+ const missed = await whatTheCheckMissed(store);
215
+
214
216
  if (cut.unchanged) {
215
217
  result.lines = [
216
218
  `${product} ${release.describe}`,
217
219
  `That build was already what ${product} calls working, so nothing moved and no waivers were retired. Recording a release twice is safe.`,
220
+ ...(missed ? [missed] : []),
218
221
  ];
219
222
  result.summary = `${product} ${release.what} was already the reference — nothing changed.`;
220
223
  return result;
@@ -228,6 +231,8 @@ export async function onShip(opts = {}) {
228
231
  // ran only once, so part of this reference has no steadiness record behind it.
229
232
  ...(cut.stability.measuredJourneys < cut.stability.journeys ? [cut.stability.note] : []),
230
233
  'Nobody has to approve anything. The next check compares against this.',
234
+ // Said in the same breath as the good news, exactly as every other surface says it.
235
+ ...(missed ? [missed] : []),
231
236
  ];
232
237
  result.summary = cut.summary;
233
238
  return result;
@@ -247,6 +252,33 @@ export async function onShip(opts = {}) {
247
252
 
248
253
  // ---------------------------------------------------------------------------
249
254
  // What just shipped?
255
+ /**
256
+ * What the last check did NOT look at, said here too.
257
+ *
258
+ * `ship` is the one command that decides what "working" MEANS from now on, and it printed no
259
+ * coverage caveat at all — not in the text, not in `--json`. Every other surface says it, in
260
+ * the same breath as the good news, because a green result on a product with doors nobody has
261
+ * ever opened is true and is not what it looks like. The command that turns that result into
262
+ * the standard is the last place that should stay quiet about it.
263
+ *
264
+ * @param {Store} store
265
+ * @returns {Promise<string|null>}
266
+ */
267
+ async function whatTheCheckMissed(store) {
268
+ try {
269
+ const raw = JSON.parse(await fsp.readFile(path.join(store.dir, 'last-check.json'), 'utf8'));
270
+ const coverage = raw?.result?.coverage ?? null;
271
+ if (!coverage) return null;
272
+ const { whatWasNotChecked } = await import('./check.js');
273
+ const said = whatWasNotChecked(coverage);
274
+ return typeof said === 'string' && said.trim() ? said.trim() : null;
275
+ } catch {
276
+ // No record, or unreadable. Saying nothing is right here — inventing a caveat would be
277
+ // its own kind of lie.
278
+ return null;
279
+ }
280
+ }
281
+
250
282
  // ---------------------------------------------------------------------------
251
283
 
252
284
  /**