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.
- package/CHANGELOG.md +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
|
@@ -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.
|
package/docs/watching.md
ADDED
|
@@ -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.
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
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 `
|
|
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
|
-
|
|
279
|
-
|
|
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
|
|
package/src/core/config.js
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
130
|
+
// Every command that lands here — status, 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;
|