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 +131 -0
- package/README.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/mcp.md +13 -0
- package/docs/running-it-in-ci.md +123 -1
- package/docs/settings.md +15 -0
- package/package.json +1 -1
- package/src/cli/status.js +45 -1
- package/src/core/config.js +31 -0
- package/src/core/paths.js +15 -0
- package/src/guard/name.js +41 -1
- package/src/guard/run.js +21 -2
- package/src/report/console.js +35 -4
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/http.js +7 -9
- package/src/v2/adapters/process.js +55 -5
- package/src/v2/adapters/web-driver.js +43 -3
- package/src/v2/adapters/web.js +7 -9
- package/src/v2/browsers.js +57 -2
- package/src/v2/check.js +135 -3
- package/src/v2/cli.js +23 -18
- package/src/v2/cluster.js +56 -1
- package/src/v2/doctor.js +68 -9
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +29 -6
- package/src/v2/mcp/tools.js +88 -6
- package/src/v2/reference.js +120 -14
- package/src/v2/sealed.js +7 -1
- package/src/v2/ship.js +58 -0
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
|
|
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 **
|
|
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
|
package/docs/getting-started.md
CHANGED
|
@@ -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
|
|
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
|
+
```
|
package/docs/running-it-in-ci.md
CHANGED
|
@@ -17,7 +17,7 @@ Three files make that work:
|
|
|
17
17
|
|
|
18
18
|
| | |
|
|
19
19
|
| --- | --- |
|
|
20
|
-
| `.github/workflows/staysfixed.yml` | The job.
|
|
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.
|
|
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
|
}
|
package/src/core/config.js
CHANGED
|
@@ -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 (
|
|
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.
|