staysfixed 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,137 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.9.1] — 2026-08-30
12
+
13
+ Eleven more, from the same hunt that produced 0.9.0 — the findings it had already paid for.
14
+ Every one of these was decided by reading the code rather than by building another product to
15
+ reproduce it; the ones that genuinely need a Next.js app or a monorepo are written down and
16
+ left, because guessing at them would be worse.
17
+
18
+ ### Fixed
19
+
20
+ - **Every API shape change on every project was sealed as LOSING DATA**, permanently, and no
21
+ agent could wave any of it through. The classifier reads the words in a finding — and it
22
+ was reading the difference's *description*, which is the TOOL's sentence, identical on every
23
+ finding that channel makes: "a renamed or dropped field shows up on its own". `dropped` is
24
+ a data-loss word. A seal is decided by what the PRODUCT said now — its addresses, its
25
+ values, its journey names — never by vocabulary no user wrote and nobody can change. A real
26
+ deletion still seals.
27
+ - **`ship` printed no coverage caveat at all**, in text or `--json`. It is the command that
28
+ decides what "working" MEANS from that moment on, which makes it the last place that should
29
+ stay quiet about what the check never walked. It says it now, in the same breath as the
30
+ good news, exactly as every other surface does.
31
+ - **`init` said "Nothing is being left out."** from a survey of which SURFACES this machine
32
+ can drive — on a project the same run reported as five of six doors never opened. And
33
+ `getting-started` tells the agent to repeat that sentence to the person.
34
+ - **The MCP server echoed an agent's own strings back unbounded and kept them for ever.** A
35
+ megabyte of summary came back as a megabyte of tool result. Capped, and the cut says so
36
+ rather than quietly keeping the first part.
37
+ - **`check --journeys recorded` reported a missing FILE called "recorded".** `--help` offers
38
+ the word and the MCP surface answers it honestly — written, not wired into a run yet. The
39
+ same question gets the same answer on both surfaces now.
40
+ - **Guard names that said nothing were accepted**: `a b c`, `1 2 3`, `TODO fix later` and
41
+ `AC-101 regression check` all passed every rule. That name is the sentence somebody reads
42
+ when the guard fails, six months from now. Refused now — and every name that actually says
43
+ something still passes.
44
+ - **Every command-line check wrote two rows** to the log `ship` reads, so any count of how
45
+ many checks had run was doubled.
46
+ - **`--only` printed "everything that worked still works"** about a slice, with five of six
47
+ guards filtered away and one of the five failing.
48
+ - **`ship` blessed a build nothing had ever looked at** — it matched by git commit, so an
49
+ uncommitted edit resolved to an earlier build that had been checked and was clean.
50
+ - `docs/running-it-in-ci.md` told you to copy `.github/workflows/staysfixed.yml` "as it is".
51
+ That file is not in the npm package. It is printed in full in the page now.
52
+ - `docs/mcp.md` still told you to fill the project path in by hand; `doctor --json` has
53
+ filled it in since 0.8.0, and a path filled in wrongly fails silently.
54
+
55
+ ## [0.9.0] — 2026-08-30
56
+
57
+ Found the same way as 0.8.0 and one better: the published build was installed as a stranger
58
+ installs it and used on real throwaway products — a café API, a static site, a library, a
59
+ server that spawns its own child — until it said something untrue. And this time the repo's
60
+ own CI was read, which had been **red for ten releases** while every summary said the tests
61
+ passed. It was red for two real reasons, both below.
62
+
63
+ **The tool was breaking the products it was sent to protect**, and on some shapes it never
64
+ came back at all. Those two are why this release exists.
65
+
66
+ ### Fixed — it broke the product, or itself
67
+
68
+ - **Refusing a connection killed the program that made it.** The refusal was delivered by
69
+ emitting `'error'` on the socket; at that instant nothing is listening, and in Node an
70
+ `'error'` with no listener is a thrown exception. `http.get` on Node 22 — the floor this
71
+ package declares — and a bare `net.connect` on every version died with exit 1, and the run
72
+ then reported the user's product as broken. The refusal is real now rather than simulated,
73
+ so the operating system produces it through Node's own plumbing.
74
+ - **`check` printed its whole answer and never exited.** A start command runs through a
75
+ shell, so the server is a grandchild; killing the shell left it alive holding the output
76
+ pipe, so the event loop never emptied. It also orphaned the server. The shell is started as
77
+ its own process group now and the whole group is signalled.
78
+ - **Two agents shipping at once lost what "working" means.** Six ships, all reporting
79
+ success, four records, and the "already the reference" path never firing. The whole cut is
80
+ one at a time per product now.
81
+ - **Every killed run left a whole copy of the project in the temporary folder, for ever** —
82
+ 777 MB of them on an ordinary machine. Abandoned copies are reclaimed; a copy a live run
83
+ owns is never touched.
84
+
85
+ ### Fixed — it said things that were not true
86
+
87
+ - **The agent was told "everything that worked before still works"** about a run that
88
+ compared nothing at all, while the terminal correctly called it no answer. The engine's
89
+ verdict is the floor for the machine surface now, so this class cannot come back one reason
90
+ at a time.
91
+ - **A sealed money change reached the agent labelled `"ordinary"`** — the human text said
92
+ sealed, `waive` refused it, and the JSON said it was waivable.
93
+ - **The seal that exists because somebody was burned before could never fire.** Guard names
94
+ were never passed to the decision, so that class was empty on every run this tool has ever
95
+ done. And a check on a project with a guard in `.staysfixed/guards` printed the word
96
+ "guard" zero times: not run, not counted, not mentioned.
97
+ - **A guard that asks nothing was reported as holding.** An empty `run()` came back as
98
+ "still holds", and would have said so every day for ever.
99
+ - **The library journey never imported anything.** `init` writes `module: "index.js"`; the
100
+ probe treated that as a package name, failed identically on both builds, and the check said
101
+ "Nothing that worked has changed" for ever.
102
+ - **`ship` blessed a build nothing had looked at.** It matched by git commit, so an
103
+ uncommitted edit resolved to an earlier build that had been checked and was clean.
104
+ - **`doctor` said a check "here" covers things in a folder where a check cannot run at all.**
105
+ - **`--only` printed "everything that worked still works"** about a slice.
106
+ - **`init` and `doctor` both offered `check --paired` as the way to record a reference.** It
107
+ cannot; only shipping cuts one, on purpose. `doctor` also marked it as something the agent
108
+ could do — the single thing an agent must never do.
109
+ - **`status` said nothing had happened** one command after a check and a ship.
110
+ - **It called the person's own browser "a separate application from the browser you use"**,
111
+ which is the one case where that sentence matters and the one case it was false.
112
+
113
+ ### Fixed — it could not see, or would not run
114
+
115
+ - **Full Chrome was invisible on Linux and Windows.** Playwright and Puppeteer both unpack
116
+ into `chrome-linux64` and `chrome-win64`; only macOS uses the names this looked for. On
117
+ Linux, the very command this tool tells people to run left a browser it could not find.
118
+ - **`status`, `walk`, `flake`, `approve`, `mark` and `trace` told a website it had no
119
+ screen** — they only knew version 1's `app:`, and `init` writes version 2's `web:`.
120
+ - **The sign-in example `init` writes used two words the tool does not know**, and a step made
121
+ only of unknown words was skipped in silence — so the form was never filled, every page
122
+ behind the login photographed the login page, and the run came back clean.
123
+ - **A browser's throwaway profile outlived an interrupted run.**
124
+
125
+ ### Fixed — it was noisy or unhelpful
126
+
127
+ - **Renaming one heading came back as five findings**, none of them saying "renamed". A thing
128
+ addressed by its own words is now recognised when it is renamed, and its children travel
129
+ with it.
130
+ - **`doctor` connected to every machine in your ssh config, unasked, on the first run.** Nine
131
+ connections on a brand-new scratch project. It is asked for now, and the machines are still
132
+ listed either way.
133
+ - **Every command-line check wrote two rows** to the log `ship` reads.
134
+ - **The `.gitignore` lines `init` writes matched nothing version 2 writes** — 151 untracked
135
+ files and 1.9 MB of run evidence in `git status` after nine checks.
136
+ - `check --json` now carries `notChecked` and `doorsNeverOpened`, which the README had
137
+ promised and only the MCP reply had.
138
+ - `init` no longer names `staysfixed check --product <name>`, which is not an option.
139
+ - The docs said the self-check builds seventeen products; it builds twenty.
140
+ - `process.alsoWatch` is documented.
141
+
11
142
  ## [0.8.0] — 2026-08-30
12
143
 
13
144
  The night this was pointed at itself. Everything below was found the same way:
package/README.md CHANGED
@@ -808,7 +808,7 @@ A tool that reports "nothing changed" looks exactly like a tool that is broken,
808
808
  and there is no way to tell the two apart from the outside. So:
809
809
 
810
810
  **It has to prove it still catches things.** `staysfixed check --selfcheck`
811
- builds seventeen tiny products — each a real repository with a working commit and
811
+ builds twenty tiny products — each a real repository with a working commit and
812
812
  an uncommitted change on top, which is the shape an agent actually points this
813
813
  tool at — and requires the engine to behave on every one.
814
814
 
@@ -843,7 +843,7 @@ a false accusation people learn to ignore. Measured on 2026-08-30: eleven of
843
843
  eleven, three times running, with the project's own suite running in parallel and
844
844
  the machine's load average between 227 and 334; then twelve of twelve after the
845
845
  second sweep of silences, three times running, with the suite in parallel again
846
- and the load average between 208 and 343; and finally **seventeen of seventeen**,
846
+ and the load average between 208 and 343; and finally **twenty of twenty**,
847
847
  with the suite in parallel, at the end of the same day.
848
848
 
849
849
  **The unstable app.** `fixtures/unstable-app` is a page built to be impossible to
@@ -229,7 +229,7 @@ watch it notice. There is a built-in version of the same idea:
229
229
  staysfixed check --selfcheck
230
230
  ```
231
231
 
232
- which builds seventeen deliberately broken products and proves the engine still behaves on
232
+ which builds twenty deliberately broken products and proves the engine still behaves on
233
233
  every one — twelve breaks it must catch, four clean pairs it must stay silent about, and one
234
234
  product so unsteady that the only correct answer is that the run says nothing at all. A case that
235
235
  misbehaves is built again and run again before that counts: fail twice and it is a real
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/docs/settings.md CHANGED
@@ -61,6 +61,21 @@ twice, and subtracted. And there is nothing to approve: the build you say
61
61
 
62
62
  ---
63
63
 
64
+
65
+ ### `process.alsoWatch`
66
+
67
+ A list of extra folders a command is allowed to touch, so that what it writes there is
68
+ watched rather than counted as a surprise. The tool's own run output names this option, and
69
+ until 2026-08-30 it appeared nowhere here — in a page whose promise is every option, per kind
70
+ of product.
71
+
72
+ ```js
73
+ process: {
74
+ commands: [{ name: 'build', run: 'npm run build' }],
75
+ alsoWatch: ['dist', '../shared/generated'],
76
+ }
77
+ ```
78
+
64
79
  ## `product`
65
80
 
66
81
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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/cli/status.js CHANGED
@@ -2,11 +2,55 @@
2
2
  * `staysfixed status` — reads what is on disk and says it. Launches nothing.
3
3
  */
4
4
 
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
5
8
  import { loadProject } from '../core/config.js';
6
9
  import { projectStatus } from '../run.js';
7
10
  import { printStatus } from '../report/console.js';
8
11
  import { EXIT } from '../core/errors.js';
9
12
 
13
+ /**
14
+ * What version 2 has recorded here, if anything.
15
+ *
16
+ * `status` only ever counted version 1's things — approved pictures, screens, guards,
17
+ * markers — so on a project that had just been checked and shipped it said "Nothing has been
18
+ * checked here yet. Start with: staysfixed check". Measured on 2026-08-30, one command after
19
+ * a run that walked 36 addresses and a ship that cut the reference. The command whose whole
20
+ * promise is to say instantly what is going on here was the one saying nothing had happened.
21
+ *
22
+ * @param {string} root
23
+ * @returns {{at: string, verdict: string, reference: string|null, findings: number}|null}
24
+ */
25
+ export function versionTwoState(root) {
26
+ try {
27
+ const file = path.join(root, '.staysfixed', 'v2', 'last-check.json');
28
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
29
+ if (!raw || typeof raw.at !== 'string') return null;
30
+ // The reference is read from where it is KEPT, not from the last check's memory of it.
31
+ // A check writes what it compared against at the time; ship cuts a reference after
32
+ // that, so on the ordinary first-run order — check, then ship — the check's field still
33
+ // says "no-reference-yet" while a reference plainly exists.
34
+ let reference = null;
35
+ try {
36
+ const cuts = JSON.parse(fs.readFileSync(path.join(root, '.staysfixed', 'v2', 'reference-log.json'), 'utf8'));
37
+ const newest = Array.isArray(cuts) && cuts.length ? cuts[cuts.length - 1] : null;
38
+ if (newest && typeof newest.id === 'string') reference = newest.id;
39
+ } catch {
40
+ if (typeof raw.reference === 'string' && raw.reference !== 'no-reference-yet') reference = raw.reference;
41
+ }
42
+ return {
43
+ at: raw.at,
44
+ verdict: typeof raw.verdict === 'string' ? raw.verdict : 'ran',
45
+ reference,
46
+ findings: Array.isArray(raw.findings) ? raw.findings.length : 0,
47
+ };
48
+ } catch {
49
+ // Nothing recorded, or nothing readable. Either way there is nothing to add.
50
+ return null;
51
+ }
52
+ }
53
+
10
54
  /**
11
55
  * @param {import('./index.js').CliContext} ctx
12
56
  * @returns {Promise<number>}
@@ -14,6 +58,6 @@ import { EXIT } from '../core/errors.js';
14
58
  export async function run(ctx) {
15
59
  const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
16
60
  const status = await projectStatus(project);
17
- printStatus(/** @type {any} */ (status));
61
+ printStatus(/** @type {any} */ ({ ...status, v2: versionTwoState(project.paths?.root ?? ctx.cwd) }));
18
62
  return EXIT.ok;
19
63
  }
@@ -146,6 +146,27 @@ export function resolveConfig(raw, file = '(inline)') {
146
146
  }
147
147
  const c = /** @type {import('../types.js').StaysFixedConfig} */ (raw);
148
148
 
149
+ // Version 2's settings describe a website under `web:` and a desktop app under
150
+ // `electron:`. These commands are version 1's and only ever knew about `app:` — so on the
151
+ // settings file `staysfixed init` writes for a website, `status`, `walk` and `flake` all
152
+ // answered "these settings do not name anything to open", and then listed `process,
153
+ // source` as the shape of the project. `init` had said, one command earlier, "The website
154
+ // can be checked here now ... watched by opening it in a throwaway browser". Both about
155
+ // the same file, seconds apart, and `status` is the command whose whole promise is to say
156
+ // instantly what is set up here.
157
+ //
158
+ // Where the address is actually knowable, take it and let the command work. Booting is
159
+ // version 2's job and these commands cannot do it, so `web.start` alone is not enough —
160
+ // that case falls through to the message below, which now says so honestly.
161
+ if ((!c.app || typeof c.app !== 'object')) {
162
+ const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
163
+ if (v2.web && typeof v2.web === 'object' && typeof v2.web.url === 'string' && v2.web.url) {
164
+ c.app = { kind: 'web', url: v2.web.url };
165
+ } else if (v2.electron && typeof v2.electron === 'object' && typeof v2.electron.binary === 'string' && v2.electron.binary) {
166
+ c.app = { kind: 'electron', binary: v2.electron.binary };
167
+ }
168
+ }
169
+
149
170
  if (!c.app || typeof c.app !== 'object') {
150
171
  // Every command that lands here — status, walk, approve, mark, trace, flake, and
151
172
  // `check --pictures` — works by OPENING something and photographing it. A settings
@@ -155,6 +176,16 @@ export function resolveConfig(raw, file = '(inline)') {
155
176
  // half of the tool needs it, and names the half that does not.
156
177
  const anything = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (c));
157
178
  const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => anything[k] && typeof anything[k] === 'object');
179
+ // A project that DOES have a screen, described the version 2 way, must never be told it
180
+ // has none. It is told the true thing instead: this half of the tool photographs an
181
+ // address you can point it at, and version 2 finds the address by booting the product,
182
+ // which is why `check` covers it and these do not.
183
+ const started = anything.web && typeof anything.web === 'object' && typeof (/** @type {any} */ (anything.web).start) === 'string';
184
+ if (started) {
185
+ throw new StaysFixedError('This project has a website, but these settings start it rather than name an address, and this command photographs an address.', {
186
+ hint: "`staysfixed check` covers it exactly as it is — it boots `web.start` and finds the address itself. These picture commands need one they can point at, so add `url: 'http://localhost:3000'` beside `start` in the `web` block if you want them too.",
187
+ });
188
+ }
158
189
  throw new StaysFixedError('These settings do not name anything to open, and this command works by opening your product and photographing it.', {
159
190
  hint: notVisual.length
160
191
  ? `That is the right shape for what this project is — ${notVisual.join(', ')} settings need nothing to open. Run \`staysfixed check\`, which covers it without a picture. If there IS a screen here too, add \`app: { kind: 'web', url: 'http://localhost:3000' }\` or \`app: { kind: 'electron', binary: '...' }\`.`
package/src/core/paths.js CHANGED
@@ -139,4 +139,19 @@ export const GITIGNORE_LINES = [
139
139
  // Where one person dragged the watch panel on one screen. Nobody else's business,
140
140
  // and it would otherwise turn up in their commits.
141
141
  '.staysfixed/watch-window.json',
142
+ // Version 2's evidence, which none of the lines above match — they were all written for
143
+ // version 1's folders. Measured 2026-08-30 on a Next.js project: 151 untracked files and
144
+ // 1.9 MB of run evidence sitting in `git status` after nine checks, and every release then
145
+ // warning that it was made from a dirty tree. `builds/` is the bulk of it: one folder per
146
+ // build, holding what each run observed.
147
+ '.staysfixed/v2/builds/',
148
+ '.staysfixed/v2/last-check.json',
149
+ // The lock a run holds while it changes the record. Never anybody's to commit.
150
+ '.staysfixed/**/*.lock',
142
151
  ];
152
+
153
+ /*
154
+ * Deliberately NOT ignored: `references.json` and `reference-log.json`. Those are the record
155
+ * of what this product calls working and which release said so — the one thing here a team
156
+ * genuinely shares, and the thing a new clone needs in order to compare against anything.
157
+ */
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/guard/run.js CHANGED
@@ -28,6 +28,7 @@ const FRESH_KEY = 'fresh';
28
28
  /**
29
29
  * @typedef {import('../types.js').GuardResult & {
30
30
  * retriedToPass?: boolean,
31
+ * assertedNothing?: boolean,
31
32
  * checks?: import('../types.js').CheckStep[],
32
33
  * }} GuardRunResult
33
34
  */
@@ -136,10 +137,22 @@ export async function runGuards(project, app, guards, opts = {}) {
136
137
  if (opts.signal?.aborted) break;
137
138
  }
138
139
 
140
+ // A guard that asserted NOTHING has not held. It cannot hold, and it cannot fail — it is
141
+ // a name over an empty room. Measured on 2026-08-30: a guard called "the checkout total
142
+ // is never charged twice", whose `run()` was an empty function, came back as
143
+ // "ok ... still holds". That is a false all-clear wearing the friendliest face this tool
144
+ // has, and it would go on saying it every day for ever. The whole promise here is one
145
+ // plain-English rule per bug somebody already had; a rule that checks nothing is worse
146
+ // than no rule, because somebody believes it.
147
+ // Its OWN questions, not the runner's. Every guard gets a "fresh start" step from this
148
+ // file whether it asks anything or not, so counting the whole list would always find one.
149
+ const asked = checks.filter((c) => c.key !== FRESH_KEY && !String(c.key ?? '').endsWith(`-${FRESH_KEY}`));
150
+ const assertedNothing = outcome.ok && asked.length === 0;
151
+
139
152
  /** @type {GuardRunResult} */
140
153
  const result = {
141
154
  name: guard.name,
142
- status: outcome.ok ? 'passed' : 'failed',
155
+ status: outcome.ok && !assertedNothing ? 'passed' : 'failed',
143
156
  file: guard.file,
144
157
  because: guard.because,
145
158
  durationMs: Date.now() - startedAt,
@@ -147,7 +160,13 @@ export async function runGuards(project, app, guards, opts = {}) {
147
160
  };
148
161
  if (checks.length > 0) result.checks = checks;
149
162
 
150
- if (outcome.ok) {
163
+ if (assertedNothing) {
164
+ result.assertedNothing = true;
165
+ result.message =
166
+ `This guard checked nothing. Its \`run()\` finished without asking a single question, so it cannot fail ` +
167
+ `and it is not protecting anything — it would report "still holds" every day for ever. ` +
168
+ `Give it at least one \`expect(...)\`. ${guard.because ? `What it is meant to protect: ${guard.because}` : ''}`.trim();
169
+ } else if (outcome.ok) {
151
170
  // Passing only on the second go is not passing. The flake register picks
152
171
  // this up and condemns the guard, because a guard nobody trusts is worse
153
172
  // than no guard: people learn to re-run it until it goes green.