staysfixed 0.6.2 → 0.7.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.
@@ -0,0 +1,306 @@
1
+ # Running it where merges happen
2
+
3
+ *A check that only runs on the author's laptop catches what the author was already looking
4
+ for. The same check on every pull request catches what nobody was looking for — which is
5
+ the whole class of thing this tool exists to find.*
6
+
7
+ ---
8
+
9
+ ## What it does
10
+
11
+ On every pull request it runs your product twice, boots the commit your branch **forked
12
+ from** on the same machine, walks that too, and reports only what behaves differently. If
13
+ something changed that nobody accounted for, the job fails and the pull request cannot be
14
+ merged on a green tick that was never earned.
15
+
16
+ Three files make that work:
17
+
18
+ | | |
19
+ | --- | --- |
20
+ | `.github/workflows/staysfixed.yml` | The job. Copy it into your own project as it is. |
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
+ | This file | What it can and cannot do up there, and why. |
23
+
24
+ ---
25
+
26
+ ## A build server is a *better* machine for this, not a worse one
27
+
28
+ Everything Stays Fixed concludes rests on one claim: **the difference was caused by the
29
+ change and nothing else.** That claim is only as good as the machine underneath it.
30
+
31
+ A laptop has your fonts, your other work, your ports in use, your half-finished experiment
32
+ from yesterday afternoon, and a browser that updated itself last Tuesday. A fresh runner has
33
+ the same fonts every time, the same operating system, nothing else competing for memory, and
34
+ no history. For a difference machine that is worth a great deal.
35
+
36
+ The tool's own rule — **sequential, never simultaneous** — is easier to keep here too. The
37
+ workflow cancels a superseded run of the same branch rather than letting two builds fight
38
+ over one port.
39
+
40
+ ---
41
+
42
+ ## The one line you must not delete
43
+
44
+ ```yaml
45
+ - uses: actions/checkout@v4
46
+ with:
47
+ fetch-depth: 0
48
+ ```
49
+
50
+ `actions/checkout` fetches **one commit** by default. Ask a repository with one commit in it
51
+ what your branch forked from and it says: this commit. So the check would compare your build
52
+ against *itself*, find nothing, and go green — the strongest-looking pass the tool can print,
53
+ meaning absolutely nothing.
54
+
55
+ Stays Fixed catches that case by name and refuses it, so it fails honestly rather than
56
+ passing dishonestly. But a refusal is not a check. Full history is what makes this work, it
57
+ costs a few seconds, and it is the single most important line in the workflow.
58
+
59
+ On GitLab the same thing is `GIT_DEPTH: 0`.
60
+
61
+ ---
62
+
63
+ ## How the reference is chosen
64
+
65
+ There is no stored record on a fresh runner — no folder from yesterday, nothing from the last
66
+ time anybody ran anything. So the reference has to be rebuilt out of what a build server does
67
+ have, which is git. That turns out to be enough, because a check can be aimed at a commit and
68
+ `git archive` puts that commit back on the machine without touching your working tree or
69
+ your `.git`.
70
+
71
+ `referenceForCI()` works down this list and stops at the first one available. **It always says
72
+ which rung it landed on, in the job summary, because they are not equally strong.**
73
+
74
+ | Mode | How it is found | Worth | When it applies |
75
+ | --- | --- | --- | --- |
76
+ | **named** | Somebody passed `--against v1.2.0` | strong | Whenever you say so |
77
+ | **merge-base** | `git merge-base` between the base branch and yours | strong | Pull requests. The right answer, and the default |
78
+ | **released** | The commit your project's own reference points at — what somebody said *ship* to | strong | Pushes to a main branch |
79
+ | **last-tag** | The most recent tag in this history | fair | No pull request, no reference recorded |
80
+ | **previous-commit** | What the branch was immediately before this push | fair | Nothing else available |
81
+ | **stored-record** | Observations committed in `.staysfixed`, or restored from a cache | weak | No commit could be booted at all |
82
+ | **none** | Nothing | nothing | Reported as a failure, never as a pass |
83
+
84
+ **Why merge-base is the right one for a pull request.** It compares what *your branch* did.
85
+ Comparing against the last release instead would drag in everything else that landed on the
86
+ base branch while your pull request was open, and hand you a list of differences your branch
87
+ is not responsible for.
88
+
89
+ Every mode that names a commit runs **paired**: the old build is put back on the runner and
90
+ walked live, minutes after the new one, on the same machine. That is the strongest answer the
91
+ tool has, and CI is the one place where paying for it costs nobody any waiting.
92
+
93
+ Any candidate that turns out to be the commit under test is thrown away, whichever rung it
94
+ came from. Comparing something against itself proves nothing and reads like a perfect pass.
95
+
96
+ ---
97
+
98
+ ## What comes out
99
+
100
+ The job summary carries a plain-English table, then what changed, then what it did **not**
101
+ look at — that last part on a clean run as well as a dirty one, because a gap only mentioned
102
+ when something fails is a gap nobody ever sees.
103
+
104
+ The exit code decides the job:
105
+
106
+ | Code | Means |
107
+ | --- | --- |
108
+ | **0** | Nothing that already worked has changed. |
109
+ | **1** | Something changed that nobody accounted for. |
110
+ | **2** | The check could not run, or there was nothing to compare against. |
111
+
112
+ **Two is not a pass.** A run that proved nothing exiting zero is the exact failure this whole
113
+ tool exists to prevent, and it would be an easy and invisible one to ship. So the workflow has
114
+ no `continue-on-error` on the check step, and it should stay that way.
115
+
116
+ ---
117
+
118
+ ## What it cannot do up there, and why
119
+
120
+ **It never approves anything.** A build server cannot say what "working" means — only a
121
+ person shipping can. CI reports; it does not bless. Nothing in `ci.js` cuts a reference,
122
+ writes a waiver, or records a build in a way that would let one be cut later, and nothing in
123
+ it ever should.
124
+
125
+ **It never writes your project's record.** Observations from a pull request job were taken on
126
+ a machine nobody will see again, off a branch nobody has merged. Letting them become "what
127
+ the old build did" would move the standard sideways every time a runner changed. So the check
128
+ runs with remembering switched off, and the report says so at the bottom.
129
+
130
+ **Pictures are tied to the machine that took them.** This is the honest caveat and it is
131
+ worth reading twice. Version 2 puts pixels last on purpose — they are evidence for a finding
132
+ another channel already made, never the accusation. That design decision is what lets this
133
+ run in CI at all. A picture taken on your Mac and a picture taken on an Ubuntu runner differ
134
+ in font rasterisation, in sub-pixel antialiasing, in emoji, in scrollbar width, and in a
135
+ dozen other ways that have nothing whatever to do with your code. Compare those two directly
136
+ and every screen is "different", every run is red, and within a week somebody switches the
137
+ whole thing off.
138
+
139
+ Two things keep that from happening here. Both builds are walked **on the same runner**, so
140
+ their pictures are taken by the same machine and the comparison is fair. And the first six
141
+ channels — what the interface says a control does, what calls went out, what it complained
142
+ about, what it gave back, what the source declares, and the coarse counts — do not care what
143
+ machine they ran on at all. Those are what a CI run is really made of.
144
+
145
+ The consequence, said plainly: **never compare a picture taken on a laptop against a picture
146
+ taken on a runner.** That is the `stored-record` mode, and it is marked weak for exactly this
147
+ reason. If the stored record was taken on a machine like this one the report says so; if it
148
+ was taken somewhere else the report says *that*, in those words, at the top.
149
+
150
+ **There are no pictures in the artifact.** The engine writes its evidence images into a
151
+ scratch folder and clears that folder when the run ends, so nothing is left to collect by the
152
+ time the job packs up. The artifact holds every observation both builds produced as JSONL,
153
+ the verdict, and what it compared against — which is the real evidence and is enough to work
154
+ out what happened after the runner is gone. An empty folder called `evidence` would be worse
155
+ than saying this.
156
+
157
+ **Anything irreversible is refused, not run twice.** Money, sign-in, a message going out, a
158
+ migration that destroys data — observed at the call boundary, never at the effect, and
159
+ reported as missing coverage. That is the same everywhere; it is only worth repeating here
160
+ because a build server is exactly where somebody would be tempted to point this at a staging
161
+ system with real credentials in it. Do not.
162
+
163
+ ---
164
+
165
+ ## Making a stored record worth something (optional)
166
+
167
+ The `stored-record` mode is weak mostly because the record usually came from somebody's
168
+ laptop. There is one arrangement that fixes that, and it is the one thing CI can do here that
169
+ a laptop cannot do as cleanly: **take the record on a runner, so every later run compares
170
+ against observations from an identical machine.**
171
+
172
+ Add a second job that runs on pushes to your main branch, passes `--remember`, and caches the
173
+ result:
174
+
175
+ ```yaml
176
+ record:
177
+ if: github.event_name == 'push'
178
+ runs-on: ubuntu-latest
179
+ steps:
180
+ - uses: actions/checkout@v4
181
+ with: { fetch-depth: 0 }
182
+ - uses: actions/setup-node@v4
183
+ with: { node-version: '22' }
184
+ - run: npm ci || npm install
185
+ - run: node node_modules/staysfixed/src/v2/ci.js --remember
186
+ - uses: actions/cache/save@v4
187
+ with:
188
+ path: .staysfixed/v2
189
+ key: staysfixed-${{ github.sha }}
190
+ ```
191
+
192
+ and restore it in the check job with `actions/cache/restore@v4` before the check step.
193
+
194
+ `--remember` is a deliberate flag and not a default, because getting it the wrong way round
195
+ would turn every red pull request into the new definition of working. **Only ever pass it on
196
+ a branch that has been merged.**
197
+
198
+ This is worth setting up only if you cannot get a full history — with `fetch-depth: 0` the
199
+ paired merge-base run is stronger than any stored record, and needs no cache at all.
200
+
201
+ ---
202
+
203
+ ## Not on GitHub?
204
+
205
+ `ci.js` reads GitLab and CircleCI too, and falls back to plain git when it recognises
206
+ neither. There is nothing GitHub-specific in the check itself — only the job summary page,
207
+ which GitLab and CircleCI do not have, so on those the report goes to the job log and to
208
+ `.staysfixed/ci/summary.md` in the artifact.
209
+
210
+ **GitLab.** `CI_MERGE_REQUEST_DIFF_BASE_SHA` is the fork point already worked out, so GitLab
211
+ is the one server that gives the strongest mode away for free:
212
+
213
+ ```yaml
214
+ staysfixed:
215
+ image: node:22
216
+ variables:
217
+ GIT_DEPTH: 0 # read the section above before removing this
218
+ script:
219
+ - npm ci
220
+ - node node_modules/staysfixed/src/v2/ci.js
221
+ artifacts:
222
+ when: always
223
+ paths: [.staysfixed/ci, .staysfixed/v2]
224
+ ```
225
+
226
+ **CircleCI** tells a job that a pull request exists and refuses to say which branch it is
227
+ aimed at, so the base is worked out from git — `origin/main` and `origin/master` are tried.
228
+ Pass `--against` if your main branch is called something else.
229
+
230
+ **Anywhere else, or by hand.** It is one command and nothing about it needs a build server:
231
+
232
+ ```sh
233
+ git fetch --unshallow 2>/dev/null || true # full history, however you get it
234
+ npm ci
235
+ node node_modules/staysfixed/src/v2/ci.js --against "$(git merge-base origin/main HEAD)"
236
+ echo "exit code: $?" # 0 nothing changed · 1 something did · 2 no answer at all
237
+ ```
238
+
239
+ The report is printed to the log and written to `.staysfixed/ci/summary.md`. Keep that folder
240
+ and `.staysfixed/v2` if you want to look at what happened afterwards.
241
+
242
+ ---
243
+
244
+ ## Calling it from code
245
+
246
+ ```js
247
+ // A file path, not a package name. Stays Fixed does not publish a subpath yet, so
248
+ // `import ... from 'staysfixed/src/v2/ci.js'` will NOT resolve — this form will.
249
+ import { detectCI, referenceForCI, reportForCI, runCI } from './node_modules/staysfixed/src/v2/ci.js';
250
+
251
+ const where = detectCI(); // provider, commit, branch, pull request
252
+ const reference = await referenceForCI(); // mode, commit, how strong, what was ruled out
253
+ const { exitCode, report } = await runCI(); // all of it, plus the job summary
254
+ ```
255
+
256
+ The whole surface: `detectCI`, `referenceForCI`, `reportForCI`, `writeJobSummary`,
257
+ `saveEvidence`, `runCI`, `main`.
258
+
259
+ `referenceForCI()` hands back everything it *considered*, not just what it chose — each mode,
260
+ whether it was available, why not, and the concrete thing that would unlock it. An agent
261
+ reading a weak run can find out what to do about it without being told.
262
+
263
+ There is no `staysfixed ci` command yet. The command table lives in another file; when it
264
+ gains one it will be `runCI` and nothing else, and this workflow will not have to change.
265
+
266
+ ---
267
+
268
+ ## What is not proven yet
269
+
270
+ Written down plainly, because a claim about CI made from a laptop is a claim nobody checked.
271
+
272
+ **No real GitHub Actions job has ever run this.** It could not be run from where it was
273
+ written. Everything below was proved locally by faking the environment variables and by
274
+ running the whole thing against real git repositories on a Mac.
275
+
276
+ What *was* proved:
277
+
278
+ - All four environments detected from faked variables — GitHub (pull request and push),
279
+ GitLab, CircleCI, and neither. The GitHub event file is read for the branch tip, and a
280
+ pull request is still recognised when that file is missing.
281
+ - **merge-base found correctly** on a real clone with a real branch forked from `origin/main`.
282
+ - **A depth-1 clone caught and refused.** This was a genuine bug found in the writing: a
283
+ shallow clone answers the fork-point question with HEAD, and the run would have gone green
284
+ having compared a build against itself. Every mode now throws away a reference that is the
285
+ commit under test.
286
+ - The whole chain end to end on a scratch project: reference chosen, old build put back with
287
+ `git archive`, both builds walked, a broken exit code found and sealed as a crash, exit **1**.
288
+ The same project with the change reverted: exit **0**. A repository with nothing to compare
289
+ against: exit **2**.
290
+ - The project's git history byte-identical before and after a run.
291
+ - The workflow's YAML parses, and both branches of its setup step were run in `bash -eo
292
+ pipefail` against a configured and an unconfigured project. That step had a real bug too —
293
+ one `ls` over both patterns fails whenever either matches nothing, which would have skipped
294
+ the check on a properly configured project.
295
+
296
+ What is **not** proved, and would only be settled by a real run:
297
+
298
+ - Ubuntu. Every local run was on macOS. Nothing in `ci.js` is platform-specific, but that is
299
+ an argument, not a measurement.
300
+ - The job summary actually rendering. The markdown is written to the file GitHub names; how
301
+ it looks on the page is unseen.
302
+ - `actions/upload-artifact` picking up `.staysfixed/ci` and `.staysfixed/v2`.
303
+ - Whether a Chromium install is enough for the web adapter on a runner with no display.
304
+ - How long a paired run takes on a real product. On a two-command scratch project it was
305
+ about a second; a real project with a browser in it will be minutes, and the 30-minute
306
+ timeout in the workflow is a guess.
@@ -0,0 +1,190 @@
1
+ # Watching it work
2
+
3
+ A check is normally something you start and then look away from. `--watch` opens
4
+ a panel and draws the run as it happens — and puts that panel hard against the
5
+ app it is checking, so the two read as one window: your app, and its side panel
6
+ telling you what it is finding.
7
+
8
+ ```
9
+ staysfixed check --watch
10
+ staysfixed walk --watch
11
+ ```
12
+
13
+ Watching it is most of how anybody comes to trust it. Without `--watch`, a desktop
14
+ app under check is moved off the screen rather than opened in front of you.
15
+
16
+ ---
17
+
18
+ ## What the panel shows
19
+
20
+ This is a product being proven unchanged, so that is what the panel draws:
21
+
22
+ - **Every journey, in a list**, with the surface each one walks — a page, a
23
+ command, a screen, a message channel — moving from waiting, to running, to what
24
+ it turned out to be.
25
+ - **Which build it is being measured against**, and how that build was chosen: the
26
+ last one you shipped, a marker you named, or the stored record from the last time
27
+ the old build ran.
28
+ - **Addresses ticking up as they are watched.** That number is the size of the
29
+ answer: 601 addresses is a different claim from 15,000.
30
+ - **How much wobble was measured and subtracted.** This is the number that explains
31
+ why the tool is quiet, and no other tool of this kind has one — it is the
32
+ difference between "nothing changed" and "nothing was looked at".
33
+ - **The findings that survived**, worst first, and separately anything **newly
34
+ unpredictable**.
35
+ - **What was NOT checked**, in the same window as the good news.
36
+ - **The one or two things only a person may decide**, if there are any.
37
+
38
+ The terminal still prints everything it always printed. The panel is a second view
39
+ of one run, not a replacement for the first.
40
+
41
+ `--pictures --watch` and `--guards --watch` open version 1's panel instead — the
42
+ screens, the thumbnails and the approved-versus-now comparison — because that is
43
+ the run they describe.
44
+
45
+ ---
46
+
47
+ ## It attaches itself to your app
48
+
49
+ When the run opens your app, the panel pushes that window to one edge of the
50
+ screen and puts itself flush against it, filling the space left over. Nothing is
51
+ resized: your app keeps the size it asked for, the panel keeps the width you
52
+ asked for, and the panel keeps its width and its edge, and if the two together are wider than your display the app is pushed off the far edge rather than resized — resizing the thing being photographed would change every picture.
53
+
54
+ By default the app goes to the **right** edge with the panel down its left.
55
+ `--watch-side left` is the mirror image.
56
+
57
+ If there is not enough room for both — an app nearly as wide as the display —
58
+ the panel still opens at its full width rather than shrinking to something you
59
+ cannot read. Move one of them, or ask for a narrower panel.
60
+
61
+ ## You can move it, and it stays moved
62
+
63
+ It is an ordinary window. Grab its edge and drag it wherever you like. Once you
64
+ have moved it, the panel stops placing itself: it will not jump back, and it will
65
+ not be pushed anywhere else for the rest of the run. Where you put it is where it
66
+ stays.
67
+
68
+ If you would rather it never moved anything in the first place, use `--no-snap`
69
+ and both windows open exactly where they would have anyway.
70
+
71
+ ---
72
+
73
+ ## The flags
74
+
75
+ | Flag | What it does |
76
+ | --- | --- |
77
+ | `--watch` | Open the panel. |
78
+ | `--watch-side left` | Push the app to the left edge and put the panel on its right. The default is `right`. |
79
+ | `--watch-width 520` | How wide the panel is, in pixels. The default is 480; anything under 240 or over 900 is brought back to the nearest of the two. |
80
+ | `--no-snap` | Leave both windows exactly where they are. Nothing is moved. |
81
+ | `--no-keep-open` | Close the panel the moment the run ends. By default it stays up so you can look at what changed. |
82
+ | `--watch-front` | Bring the panel to the front. See below for why that is not the default. |
83
+ | `--profile` | Nothing to do with the panel: prints where the seconds went after the run. |
84
+
85
+ Both `staysfixed check` and `staysfixed walk` take all of them.
86
+
87
+ `--watch` and `--json` ask for opposite things — a window to look at, and output
88
+ for a script to read. Ask for both and the tool says so in one line and carries
89
+ on without the panel, rather than quietly picking one.
90
+
91
+ You can set any of them once, in `staysfixed.config.js`, instead of typing them
92
+ every time:
93
+
94
+ ```js
95
+ export default {
96
+ app: { kind: 'web', url: 'http://localhost:5173' },
97
+ watch: { enabled: true, side: 'left', width: 520 },
98
+ screens: [/* ... */],
99
+ };
100
+ ```
101
+
102
+ The command line wins over the settings file, with one deliberate exception:
103
+ `--watch` can only ever turn the panel **on**. Not typing it is not the same as
104
+ saying no to it, so it never switches off a panel your settings file asked for.
105
+
106
+ ---
107
+
108
+ ## It comes up once, and then never takes your screen again
109
+
110
+ The rule is not "stay hidden". An app the tool opens is *allowed* to appear, and
111
+ should — that first appearance is how you see what is happening. **From the moment
112
+ you pick something else, anything the tool launched loses the argument
113
+ permanently.**
114
+
115
+ That is enforced rather than asked for, because there is nothing to ask. An
116
+ Electron app calls `focus()` from its own main process while it starts, a
117
+ simulator activates when it boots, a browser activates when a window opens — none
118
+ of it goes through this tool. The only thing that works is watching which
119
+ application you are using and putting it back in front the moment something the
120
+ tool started pushes in. It learns which application is yours by watching what you
121
+ choose, never by being told, and it says nothing at all unless it actually had to
122
+ act.
123
+
124
+ The panel itself opens *behind* whatever you are using and keeps drawing there.
125
+ Bring it forward when you want to look — click it in your dock or task switcher —
126
+ or start the run with `--watch-front` if you would rather it came forward on its
127
+ own.
128
+
129
+ ---
130
+
131
+ ## What it needs
132
+
133
+ A Chrome-family browser on the machine: Chrome, Chromium, Edge or Brave. The
134
+ panel is a plain HTML page in an ordinary browser window, with no server, no port
135
+ you have to remember and nothing loaded from the internet.
136
+
137
+ If there is no such browser, or it will not start, you get one line saying the
138
+ panel could not open and **the run carries on exactly as it would have without
139
+ it**. A panel that failed to appear has never changed a verdict and never will.
140
+
141
+ `staysfixed doctor` tells you what browsers it can find here.
142
+
143
+ ---
144
+
145
+ ## It cannot affect the pictures
146
+
147
+ This matters more than the feature does, so it is worth being explicit.
148
+
149
+ The panel is a **separate browser window of its own**, with a throwaway profile.
150
+ It is not injected into your app, it shares no page and no rendering settings
151
+ with the app being photographed, and it never sends anything into it. It only
152
+ reads: the run publishes small events as it goes — this screen started, here is
153
+ its thumbnail, this one changed by four thousand pixels — and the panel draws
154
+ them.
155
+
156
+ Snapping moves the app's **window**. It never touches the app's page. What gets
157
+ photographed is the viewport the capture sets for itself, which is fixed by your
158
+ settings and has nothing to do with where the window happens to be sitting on
159
+ your desk — so a run watched and a run unwatched reach the same verdict, and the
160
+ pictures are byte for byte the same either way.
161
+
162
+ The thumbnails in the panel are shrunk copies made after a picture has already
163
+ been taken and compared. Nothing that appears in the panel is ever the thing that
164
+ gets compared.
165
+
166
+ ---
167
+
168
+ ## Where the time went
169
+
170
+ `--profile` is the other half of this work and needs no window:
171
+
172
+ ```
173
+ $ staysfixed check --profile
174
+
175
+ ...
176
+
177
+ Where the time went
178
+ taking the pictures until two agree 11.5s 36%
179
+ running the steps 9.5s 30%
180
+ waiting for fonts and images 7.7s 24%
181
+ comparing against the approved pictures 2.7s 9%
182
+ opening the app 2.4s 8%
183
+ running the guards 900ms 3%
184
+ everything else 184ms 1%
185
+ in total 31.9s
186
+ each screen 2.9s on average, across 11 screens
187
+ ```
188
+
189
+ It is a pointer at the slow part, not a benchmark — the numbers are rounded to
190
+ what a person would say out loud. Run it twice before believing any one figure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.6.2",
3
+ "version": "0.7.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",
@@ -35,6 +35,7 @@
35
35
  "bin",
36
36
  "src",
37
37
  "examples",
38
+ "docs",
38
39
  "README.md",
39
40
  "LICENSE",
40
41
  "CHANGELOG.md"
@@ -54,8 +55,7 @@
54
55
  },
55
56
  "dependencies": {
56
57
  "pixelmatch": "^7.2.0",
57
- "pngjs": "^7.0.0",
58
- "playwright": "^1.62.1"
58
+ "pngjs": "^7.0.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^24.0.0",
package/src/cli/index.js CHANGED
@@ -11,6 +11,7 @@ import { StaysFixedError, EXIT } from '../core/errors.js';
11
11
  import { setLogLevel } from '../core/log.js';
12
12
  import { V2_COMMANDS } from '../v2/cli.js';
13
13
  import { SHIP_COMMANDS } from '../v2/ship.js';
14
+ import { INIT_COMMANDS } from '../v2/init.js';
14
15
 
15
16
  /** @type {{version?: string}} */
16
17
  const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
@@ -219,25 +220,35 @@ const COMMANDS = {
219
220
  },
220
221
  mcp: {
221
222
  summary: 'Run as an MCP server so a coding agent can check its own work.',
222
- usage: 'staysfixed mcp',
223
+ usage: 'staysfixed mcp [--v1]',
223
224
  describe:
224
- 'Speaks the Model Context Protocol on standard input and output, so Claude Code,\nCodex, Gemini or Cursor can check the screens right after editing your code.\nApproving still belongs to you: an agent cannot approve its own work.',
225
- examples: ['staysfixed mcp'],
226
- spec: {},
225
+ 'Speaks the Model Context Protocol on standard input and output, so Claude Code,\nCodex, Gemini or Cursor can check your product right after editing it. It serves\nthe difference engine: ask what can be checked here, seal what you meant to\nchange, check, explain one finding, prove a cause, and record one as intended.\nWhat "working" means is never an agent\'s to move it is cut by shipping, by you.\n\n--v1 serves the older picture-checking tool set instead, unchanged, for anybody\nwho wired that up and is not ready to move.',
226
+ options: [['--v1', 'Serve the version 1 picture tools instead of the difference engine.']],
227
+ examples: ['staysfixed mcp', 'staysfixed mcp --v1'],
228
+ spec: { booleans: ['v1'] },
227
229
  },
228
230
  };
229
231
 
230
232
  /*
231
- * Version 2 takes over `check` and `doctor`.
233
+ * Version 2 takes over `check`, `doctor` and `init`, and adds `ship`.
232
234
  *
233
235
  * It is a takeover rather than a second set of names because the answer to "did I
234
236
  * break anything" should be one command, not two — and because everything version 1
235
237
  * did is still reachable from it: `--pictures`, `--guards` and `--watch` behave
236
238
  * exactly as they always have. Anyone who installed this yesterday types the same
237
239
  * thing tomorrow.
240
+ *
241
+ * `init` was the last one left out, and leaving it out was not a decision — it was
242
+ * an omission with a cost. docs/getting-started.md is written entirely around what
243
+ * version 2's `init --json` returns: `plan.project`, `plan.readiness`,
244
+ * `plan.needs.person`, `plan.journeys`, `plan.covers.short`. Version 1's init
245
+ * returns none of that and tells whoever ran it to go and approve pictures. So an
246
+ * agent following this project's own installation page got an answer with none of
247
+ * the fields the page told it to read.
238
248
  */
239
249
  Object.assign(COMMANDS, V2_COMMANDS);
240
250
  Object.assign(COMMANDS, SHIP_COMMANDS);
251
+ Object.assign(COMMANDS, INIT_COMMANDS);
241
252
 
242
253
  /**
243
254
  * @param {string[]} argv
@@ -273,10 +284,26 @@ export async function main(argv) {
273
284
 
274
285
  // An MCP server talks JSON-RPC on stdout. One stray friendly line would break
275
286
  // the conversation, so the logger is silenced before the server ever starts.
287
+ //
288
+ // `mcp` serves VERSION 2 — the difference engine — because that is what every
289
+ // document about this tool describes, what `staysfixed_capabilities` explains, and
290
+ // the only surface where an agent can check without being able to approve. This
291
+ // line pointed at version 1's picture tools for a day, which meant an agent that
292
+ // followed the README's own wiring block got a set of tools none of the
293
+ // documentation mentions, and never reached the engine at all. Version 1's server
294
+ // is still here behind `--v1` so nobody who wired it up is stranded.
276
295
  if (command === 'mcp') {
277
296
  setLogLevel({ quiet: true, verbose: false });
278
- const { serveMcp } = await import('../mcp/server.js');
279
- await serveMcp({ cwd, configFile, version: VERSION });
297
+ if (parsed.flags.v1 === true) {
298
+ const { serveMcp } = await import('../mcp/server.js');
299
+ await serveMcp({ cwd, configFile, version: VERSION });
300
+ return EXIT.ok;
301
+ }
302
+ const { serveMcp } = await import('../v2/mcp/server.js');
303
+ const { rootForConfig } = await import('../core/paths.js');
304
+ // A `--config` pointing somewhere else names the project, so it decides the root.
305
+ // Dropping the flag silently would have the server answer about the wrong folder.
306
+ await serveMcp({ cwd, root: configFile ? rootForConfig(path.resolve(cwd, configFile)) : undefined, version: VERSION });
280
307
  return EXIT.ok;
281
308
  }
282
309
 
@@ -127,8 +127,18 @@ export function resolveConfig(raw, file = '(inline)') {
127
127
  const c = /** @type {import('../types.js').StaysFixedConfig} */ (raw);
128
128
 
129
129
  if (!c.app || typeof c.app !== 'object') {
130
- throw new StaysFixedError('The config has no `app` Stays Fixed does not know what to open.', {
131
- hint: "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`.",
130
+ // Every command that lands herestatus, walk, approve, mark, trace, flake, and
131
+ // `check --pictures` works by OPENING something and photographing it. A settings
132
+ // file with no `app` in it is the normal, correct shape for a command-line tool, a
133
+ // library or a server: there is nothing to open, and telling somebody to go and add a
134
+ // web address they do not have sends them off inventing one. So the message says which
135
+ // half of the tool needs it, and names the half that does not.
136
+ const anything = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (c));
137
+ const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => anything[k] && typeof anything[k] === 'object');
138
+ throw new StaysFixedError('These settings do not name anything to open, and this command works by opening your product and photographing it.', {
139
+ hint: notVisual.length
140
+ ? `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: '...' }\`.`
141
+ : "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`. If your product has no screen at all, `staysfixed check` covers it without one.",
132
142
  });
133
143
  }
134
144
  const kind = c.app.kind;