staysfixed 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@ 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
+
11
55
  ## [0.9.0] — 2026-08-30
12
56
 
13
57
  Found the same way as 0.8.0 and one better: the published build was installed as a stranger
package/docs/mcp.md CHANGED
@@ -293,3 +293,16 @@ Nobody who wired that up has to change anything. Everything version 1's picture
293
293
  check did is also still reachable from the command line — `staysfixed check
294
294
  --pictures`, `staysfixed approve` — and pictures still require a person to
295
295
  approve them there, for the same reason they always did.
296
+
297
+ ---
298
+
299
+ ## You do not have to fill the path in yourself
300
+
301
+ The blocks above show `/absolute/path/to/your/project` because a page cannot know where you
302
+ are. `staysfixed doctor --json` can: its `wiring.mcp` carries the same block with `cwd`
303
+ already set to this project, ready to paste. Ask for that rather than editing by hand — a
304
+ path filled in wrongly fails silently, which is the worst way for this to go wrong.
305
+
306
+ ```sh
307
+ npx staysfixed doctor --json | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.stringify(JSON.parse(s).wiring.mcp,null,2)))"
308
+ ```
@@ -17,7 +17,7 @@ Three files make that work:
17
17
 
18
18
  | | |
19
19
  | --- | --- |
20
- | `.github/workflows/staysfixed.yml` | The job. Copy it into your own project as it is. |
20
+ | `.github/workflows/staysfixed.yml` | The job. It is printed in full at the bottom of this page, because it is not in the npm package. |
21
21
  | `src/v2/ci.js` | Works out what to compare against, runs the check, writes the report, exits with the code that decides the job. |
22
22
  | This file | What it can and cannot do up there, and why. |
23
23
 
@@ -304,3 +304,125 @@ What is **not** proved, and would only be settled by a real run:
304
304
  - How long a paired run takes on a real product. On a two-command scratch project it was
305
305
  about a second; a real project with a browser in it will be minutes, and the 30-minute
306
306
  timeout in the workflow is a guess.
307
+
308
+ ---
309
+
310
+ ## The workflow itself
311
+
312
+ This file lives in the repository, not in the npm package — so if you installed Stays Fixed
313
+ from npm you cannot copy it off disk, and the line above used to send you looking for it.
314
+ Here it is in full. Save it as `.github/workflows/staysfixed.yml` in your own project.
315
+
316
+ ```yaml
317
+ # Prove that nothing which already worked has changed — on every pull request.
318
+ #
319
+ # WHY A BUILD SERVER IS A GOOD PLACE FOR THIS, and not a compromise. Everything Stays Fixed
320
+ # concludes rests on one claim: the difference was caused by the change and nothing else. A
321
+ # fresh runner has the same fonts every time, the same operating system, nothing else
322
+ # competing for a port, and no half-finished experiment left over from yesterday. It is a
323
+ # better machine for this job than anybody's laptop.
324
+ #
325
+ # WHAT IT COMPARES AGAINST. On a pull request: the commit your branch forked from — put back
326
+ # on this same runner with `git archive` and walked again, minutes apart. That is a full
327
+ # paired run, and it needs nothing stored from a previous job. On a push it works down to the
328
+ # last release, the last tag, or the commit before. Whichever it lands on is named in the job
329
+ # summary, because they are not equally strong.
330
+ #
331
+ # THE ONE LINE YOU MUST NOT DELETE is `fetch-depth: 0`. A shallow checkout answers "what did
332
+ # this branch fork from" with "this commit" — so the check would compare the build against
333
+ # itself, find nothing, and go green. Stays Fixed catches that and refuses, but the run is
334
+ # then worth nothing. Full history is what makes this work.
335
+
336
+ name: Stays Fixed
337
+
338
+ on:
339
+ pull_request:
340
+ push:
341
+ branches: [main, master]
342
+
343
+ # Nothing is published, released or tagged here, and no token is used for anything.
344
+ permissions:
345
+ contents: read
346
+
347
+ # Two runs of the same branch would be two builds on one machine fighting over ports and
348
+ # user data directories. Sequential, never simultaneous, is a rule of the whole tool.
349
+ concurrency:
350
+ group: staysfixed-${{ github.workflow }}-${{ github.ref }}
351
+ cancel-in-progress: true
352
+
353
+ jobs:
354
+ check:
355
+ name: Has anything that worked changed?
356
+ runs-on: ubuntu-latest
357
+ timeout-minutes: 30
358
+
359
+ steps:
360
+ - uses: actions/checkout@v4
361
+ with:
362
+ # Read the paragraph above before changing this.
363
+ fetch-depth: 0
364
+
365
+ - uses: actions/setup-node@v4
366
+ with:
367
+ node-version: '22'
368
+
369
+ - name: Install
370
+ run: npm ci || npm install
371
+
372
+ # Only needed if anything you check is a web page. Delete these three lines for a
373
+ # command line tool or a library — the check will say what it could not look at either way.
374
+ - name: A browser to look at web pages with
375
+ run: npx playwright install chromium
376
+
377
+ - name: Is Stays Fixed set up in this project?
378
+ id: setup
379
+ run: |
380
+ # Two separate looks on purpose. One `ls` over both patterns fails whenever
381
+ # either of them matches nothing, which would report a configured project as
382
+ # unconfigured and quietly skip the whole check.
383
+ if ls staysfixed.config.* >/dev/null 2>&1 || ls .staysfixed/config.* >/dev/null 2>&1; then
384
+ echo "configured=yes" >> "$GITHUB_OUTPUT"
385
+ else
386
+ echo "configured=no" >> "$GITHUB_OUTPUT"
387
+ {
388
+ echo "## Stays Fixed"
389
+ echo
390
+ echo "This project has no Stays Fixed settings file, so **nothing was checked**."
391
+ echo "This job is green because nothing was claimed — not because anything was proved."
392
+ echo
393
+ echo 'Run `npx staysfixed init` in your project to set it up.'
394
+ } >> "$GITHUB_STEP_SUMMARY"
395
+ fi
396
+
397
+ # Works out what to compare against, runs the check, writes the plain-English report
398
+ # into the job summary, and exits with the code that decides this job:
399
+ #
400
+ # 0 nothing that already worked has changed
401
+ # 1 something changed that nobody accounted for
402
+ # 2 the check could not run, or there was nothing to compare against — which is
403
+ # NOT a pass, and is why this step has no `continue-on-error`
404
+ - name: Check
405
+ if: steps.setup.outputs.configured == 'yes'
406
+ run: |
407
+ # In your own project Stays Fixed is a dependency. In the Stays Fixed repository
408
+ # itself it is the source you are looking at. Both paths are tried so this file
409
+ # can be copied straight out of one into the other.
410
+ if [ -f node_modules/staysfixed/src/v2/ci.js ]; then
411
+ node node_modules/staysfixed/src/v2/ci.js
412
+ else
413
+ node src/v2/ci.js
414
+ fi
415
+
416
+ # Every observation both builds produced, the verdict, and what it compared against.
417
+ # Kept even when the job fails — especially then.
418
+ - name: Keep the evidence
419
+ if: always() && steps.setup.outputs.configured == 'yes'
420
+ uses: actions/upload-artifact@v4
421
+ with:
422
+ name: staysfixed-${{ github.run_id }}
423
+ path: |
424
+ .staysfixed/ci
425
+ .staysfixed/v2
426
+ if-no-files-found: ignore
427
+ retention-days: 14
428
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.9.0",
3
+ "version": "0.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/guard/name.js CHANGED
@@ -26,6 +26,9 @@ export const NAME_RULE_EXPLAINER =
26
26
  const MAX_LENGTH = 120;
27
27
 
28
28
  /** Words that start a test name rather than describe the app. */
29
+ /** Words that begin a note to yourself rather than a promise about the product. */
30
+ const PLACEHOLDERS = new Set(['todo', 'fixme', 'wip', 'xxx', 'tbd', 'temp', 'placeholder', 'hack']);
31
+
29
32
  const TEST_SPEAK = new Set([
30
33
  'test',
31
34
  'tests',
@@ -100,7 +103,7 @@ const VERBS = new Set([
100
103
  ]);
101
104
 
102
105
  /**
103
- * @typedef {'empty'|'long'|'path'|'id'|'symbols'|'caps'|'identifier'|'testspeak'|'short'} RefusalKind
106
+ * @typedef {'empty'|'long'|'path'|'id'|'symbols'|'caps'|'identifier'|'testspeak'|'short'|'placeholder'|'not-a-sentence'|'mostly-numbers'|'ticket'} RefusalKind
104
107
  */
105
108
 
106
109
  /**
@@ -194,6 +197,43 @@ function refuse(name) {
194
197
  };
195
198
  }
196
199
 
200
+ // Three words is a shape, not a sentence. `a b c`, `1 2 3`, `TODO fix later` and
201
+ // `AC-101 regression check` all cleared every rule above, and each of them is a guard
202
+ // nobody will understand in six months — which is the entire job of this name.
203
+ if (PLACEHOLDERS.has(first)) {
204
+ return {
205
+ kind: 'placeholder',
206
+ why: `Starting with "${words[0]}" is a note to yourself, not a promise about the product. Say what should still be true, so the failure reads as that sentence.`,
207
+ };
208
+ }
209
+
210
+ // A ticket reference is not a behaviour, and this file already says so about "#" and "::".
211
+ // `AC-101 regression check` cleared every other rule and names nothing a person could act on.
212
+ const ticket = words.find((w) => /^[A-Za-z]{2,}[-_]\d+$/.test(w));
213
+ if (ticket) {
214
+ return {
215
+ kind: 'ticket',
216
+ why: `"${ticket}" is a ticket reference, not a behaviour. Put it in "link" and say here what should still be true.`,
217
+ };
218
+ }
219
+
220
+ const realWords = words.filter((w) => /[A-Za-z]{4,}/.test(w));
221
+ if (realWords.length < 2) {
222
+ return {
223
+ kind: 'not-a-sentence',
224
+ why: 'That does not read as a sentence about the product — there are almost no words in it. Write what should still be true, in the words you would say out loud, like "the sidebar still collapses".',
225
+ };
226
+ }
227
+
228
+ const letters = (text.match(/[A-Za-z]/g) ?? []).length;
229
+ const digits = (text.match(/\d/g) ?? []).length;
230
+ if (digits > letters) {
231
+ return {
232
+ kind: 'mostly-numbers',
233
+ why: 'That is mostly numbers. A guard name is the sentence somebody reads when it fails, so put the ticket number in "link" and say the behaviour here.',
234
+ };
235
+ }
236
+
197
237
  return null;
198
238
  }
199
239
 
package/src/v2/check.js CHANGED
@@ -1916,6 +1916,18 @@ async function gatherJourneys({ root, config, options }) {
1916
1916
 
1917
1917
  const named =
1918
1918
  options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
1919
+ // `recorded` is a word this tool knows and `check --help` offers it — it is simply not
1920
+ // wired into a run yet. The MCP surface says exactly that; the command line fell through to
1921
+ // the branch above, treated the word as a FILE PATH, and answered that a file called
1922
+ // "recorded" was missing. The same question has to get the same answer on both.
1923
+ if (options.journeys === 'recorded') {
1924
+ throw new StaysFixedError(
1925
+ 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked.',
1926
+ {
1927
+ hint: 'Leave --journeys out to use the steps each adapter reads from your source, pass `suite` to walk your own test suite, or pass the path to a journeys file.',
1928
+ },
1929
+ );
1930
+ }
1919
1931
  if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
1920
1932
 
1921
1933
  // The project's own test suite, when somebody asked for it in those words and never
package/src/v2/init.js CHANGED
@@ -1579,7 +1579,14 @@ function whatItCovers(readiness) {
1579
1579
  else parts.push('Right now a check here covers nothing in full.');
1580
1580
  if (waiting.length > 0) parts.push(`${plainList(waiting, true)} ${waiting.length === 1 ? 'is' : 'are'} not covered yet, and the list below says exactly what is in the way and who has to do it.`);
1581
1581
  if (notCovered.length > 0) parts.push(`${plainList(notCovered, true)} ${notCovered.length === 1 ? 'is' : 'are'} not checked at all, so a clean result says nothing whatever about ${notCovered.length === 1 ? 'it' : 'them'}.`);
1582
- if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) parts.push('Nothing is being left out.');
1582
+ // "on this machine", because that is the only thing this sentence knows. It is built from
1583
+ // SURFACES — which kinds of product can be watched here — and says nothing whatever about
1584
+ // how much of this project a run actually walks. Unqualified, it read as a coverage
1585
+ // promise, and `getting-started` tells the agent to repeat it to the person: measured
1586
+ // 2026-08-30 on a project the same run reported as five of six doors never opened.
1587
+ if (partly.length === 0 && notCovered.length === 0 && covered.length > 0) {
1588
+ parts.push('Nothing is being left out on this machine — though how much of the project a run actually walks is a separate question, and every check answers it.');
1589
+ }
1583
1590
 
1584
1591
  // The hole that never closes, named with the language that causes it. "Not covered yet"
1585
1592
  // reads as a job somebody will get to; this one is nobody's job and saying so is the
@@ -1661,7 +1661,14 @@ function problem(message) {
1661
1661
  function text(v) {
1662
1662
  if (typeof v !== 'string') return null;
1663
1663
  const s = v.trim();
1664
- return s === '' ? null : s;
1664
+ if (s === '') return null;
1665
+ // Capped, because every one of these is a string an AGENT chose and several of them are
1666
+ // echoed straight back in the reply and then written into the store for ever. A megabyte
1667
+ // of summary came back as a megabyte of tool result and stayed there. Nothing legitimate
1668
+ // here is long: a reason, a finding id, a surface name. Cutting says so out loud rather
1669
+ // than quietly keeping the first part.
1670
+ const MOST = 4000;
1671
+ return s.length <= MOST ? s : `${s.slice(0, MOST)} … (cut here: this was ${s.length} characters, and nothing this tool asks for is that long)`;
1665
1672
  }
1666
1673
 
1667
1674
  /**
package/src/v2/sealed.js CHANGED
@@ -358,7 +358,13 @@ function readFinding(finding) {
358
358
  const differences = finding.differences ?? [];
359
359
  for (const d of differences) {
360
360
  add(d.path, d.path);
361
- add(d.describe, d.path);
361
+ // NOT `d.describe`. That sentence is the TOOL's, not the product's — it explains what a
362
+ // channel watches and is the same on every finding that channel ever produces. The API
363
+ // shape channel's says "a renamed or dropped field shows up on its own", and `dropped`
364
+ // is a data-loss word, so every API shape change on every project was sealed as losing
365
+ // data, permanently, and no agent could wave any of it through. A seal has to be decided
366
+ // by what the PRODUCT said — its addresses, its values, its journey names — never by the
367
+ // tool's own vocabulary, which no user wrote and nobody can change.
362
368
  add(faceOf(d.reference), d.path);
363
369
  add(faceOf(d.candidate), d.path);
364
370
  add(d.journey, `the ${d.journey} journey`);
package/src/v2/ship.js CHANGED
@@ -211,10 +211,13 @@ export async function onShip(opts = {}) {
211
211
  result.cut = cut.unchanged !== true;
212
212
  result.unchanged = cut.unchanged === true;
213
213
 
214
+ const missed = await whatTheCheckMissed(store);
215
+
214
216
  if (cut.unchanged) {
215
217
  result.lines = [
216
218
  `${product} ${release.describe}`,
217
219
  `That build was already what ${product} calls working, so nothing moved and no waivers were retired. Recording a release twice is safe.`,
220
+ ...(missed ? [missed] : []),
218
221
  ];
219
222
  result.summary = `${product} ${release.what} was already the reference — nothing changed.`;
220
223
  return result;
@@ -228,6 +231,8 @@ export async function onShip(opts = {}) {
228
231
  // ran only once, so part of this reference has no steadiness record behind it.
229
232
  ...(cut.stability.measuredJourneys < cut.stability.journeys ? [cut.stability.note] : []),
230
233
  'Nobody has to approve anything. The next check compares against this.',
234
+ // Said in the same breath as the good news, exactly as every other surface says it.
235
+ ...(missed ? [missed] : []),
231
236
  ];
232
237
  result.summary = cut.summary;
233
238
  return result;
@@ -247,6 +252,33 @@ export async function onShip(opts = {}) {
247
252
 
248
253
  // ---------------------------------------------------------------------------
249
254
  // What just shipped?
255
+ /**
256
+ * What the last check did NOT look at, said here too.
257
+ *
258
+ * `ship` is the one command that decides what "working" MEANS from now on, and it printed no
259
+ * coverage caveat at all — not in the text, not in `--json`. Every other surface says it, in
260
+ * the same breath as the good news, because a green result on a product with doors nobody has
261
+ * ever opened is true and is not what it looks like. The command that turns that result into
262
+ * the standard is the last place that should stay quiet about it.
263
+ *
264
+ * @param {Store} store
265
+ * @returns {Promise<string|null>}
266
+ */
267
+ async function whatTheCheckMissed(store) {
268
+ try {
269
+ const raw = JSON.parse(await fsp.readFile(path.join(store.dir, 'last-check.json'), 'utf8'));
270
+ const coverage = raw?.result?.coverage ?? null;
271
+ if (!coverage) return null;
272
+ const { whatWasNotChecked } = await import('./check.js');
273
+ const said = whatWasNotChecked(coverage);
274
+ return typeof said === 'string' && said.trim() ? said.trim() : null;
275
+ } catch {
276
+ // No record, or unreadable. Saying nothing is right here — inventing a caveat would be
277
+ // its own kind of lie.
278
+ return null;
279
+ }
280
+ }
281
+
250
282
  // ---------------------------------------------------------------------------
251
283
 
252
284
  /**