staysfixed 0.7.2 → 0.9.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
package/src/v2/ci.js CHANGED
@@ -415,7 +415,12 @@ export async function referenceForCI(opts = {}) {
415
415
  }
416
416
 
417
417
  // ---- released ----------------------------------------------------------
418
- const released = await releasedCommit(cwd, opts.product);
418
+ // Anything the store could not read travels with the answer and is said out loud, whether
419
+ // the answer was found or not. A run that says "this project has nothing on record as
420
+ // working" while a build folder sat there unreadable is stating a fact it does not have.
421
+ const releasedLook = await releasedCommit(cwd, opts.product);
422
+ const released = releasedLook.found;
423
+ const storeTrouble = releasedLook.trouble;
419
424
  if (released) {
420
425
  const found = await resolveCommit(cwd, released.sha);
421
426
  const sha = notThisBuild(found);
@@ -443,11 +448,12 @@ export async function referenceForCI(opts = {}) {
443
448
  'The fork point of this branch could not be worked out, so this was compared against the last shipped build instead. Anything else that was merged since that release will show up here as well, even though this branch did not do it.';
444
449
  result.unlockedBy = deepen;
445
450
  }
451
+ if (storeTrouble) result.caveat = result.caveat ? `${result.caveat} ${storeTrouble}` : storeTrouble;
446
452
  return result;
447
453
  }
448
- else missed('released', `The project's reference points at ${short(released.sha)}, and that commit is not in this checkout.`, shallow ? deepen : 'Make sure the commit that was shipped is still in this repository.');
454
+ else missed('released', withTrouble(`The project's reference points at ${short(released.sha)}, and that commit is not in this checkout.`, storeTrouble), shallow ? deepen : 'Make sure the commit that was shipped is still in this repository.');
449
455
  } else {
450
- missed('released', 'This project has no build on record as working. Only its owner can set one, by shipping.');
456
+ missed('released', withTrouble('This project has no build on record as working. Only its owner can set one, by shipping.', storeTrouble));
451
457
  }
452
458
 
453
459
  // ---- last tag ----------------------------------------------------------
@@ -491,7 +497,8 @@ export async function referenceForCI(opts = {}) {
491
497
  missed('previous-commit', 'There is no earlier commit in this checkout.', shallow ? deepen : undefined);
492
498
 
493
499
  // ---- stored record -----------------------------------------------------
494
- const stored = await storedRecord(cwd, opts.product);
500
+ const storedLook = await storedRecord(cwd, opts.product);
501
+ const stored = storedLook.found;
495
502
  if (stored) {
496
503
  considered.push({ mode: 'stored-record', available: true, why: `There are stored observations for ${stored.buildId}.` });
497
504
  const same = stored.machine === 'same';
@@ -513,9 +520,14 @@ export async function referenceForCI(opts = {}) {
513
520
  machine: stored.machine,
514
521
  };
515
522
  }
516
- missed('stored-record', 'There are no stored observations in this checkout either.', 'Commit the .staysfixed folder, or restore it from a cache written by a job on your main branch.');
523
+ missed('stored-record', withTrouble('There are no stored observations in this checkout either.', storedLook.trouble), 'Commit the .staysfixed folder, or restore it from a cache written by a job on your main branch.');
517
524
 
518
525
  // ---- nothing -----------------------------------------------------------
526
+ // The last line of this file is the one that says a whole checkout holds nothing to
527
+ // compare against, and it is read as final. It must not be said over the top of a store
528
+ // that was sitting right there with records nobody could open, so whatever the two looks
529
+ // in the store could not see is repeated here where a person will actually meet it.
530
+ const blindSpots = [storeTrouble, storedLook.trouble].filter((t) => t !== '');
519
531
  return {
520
532
  mode: 'none',
521
533
  against: null,
@@ -523,13 +535,27 @@ export async function referenceForCI(opts = {}) {
523
535
  strength: 'none',
524
536
  how: 'Nothing was found to compare against.',
525
537
  why: 'No named commit, no pull request base, no reference, no tag, no earlier commit and no stored record. There is nothing in this checkout that says what this product used to do.',
526
- caveat: 'This run proves nothing about your product either way. It is not a pass.',
527
- unlockedBy: deepen,
538
+ caveat:
539
+ blindSpots.length === 0
540
+ ? 'This run proves nothing about your product either way. It is not a pass.'
541
+ : `This run proves nothing about your product either way. It is not a pass. AND IT COULD NOT SEE EVERYTHING THERE IS: ${[...new Set(blindSpots)].join(' ')}`,
542
+ unlockedBy: blindSpots.length === 0 ? deepen : `${deepen} And repair or delete the build records named above, so the next run can read them.`,
528
543
  considered,
529
544
  shallow,
530
545
  };
531
546
  }
532
547
 
548
+ /**
549
+ * Add what could not be seen to a sentence about what was not found.
550
+ *
551
+ * @param {string} said
552
+ * @param {string} trouble
553
+ * @returns {string}
554
+ */
555
+ function withTrouble(said, trouble) {
556
+ return trouble ? `${said} ${trouble}` : said;
557
+ }
558
+
533
559
  /**
534
560
  * The commit this branch forked from.
535
561
  *
@@ -585,6 +611,19 @@ async function findMergeBase(cwd, ci) {
585
611
  return null;
586
612
  }
587
613
 
614
+ /**
615
+ * What a look in the store found, and what it could not see while looking.
616
+ *
617
+ * `trouble` is empty almost always. It carries a sentence when a build folder had to be
618
+ * skipped, or when the store would not open at all — and that sentence has to travel,
619
+ * because both of those turn "this project has nothing on record as working" into a claim
620
+ * the code is in no position to make. The build being asked for may well be one of the ones
621
+ * that could not be read.
622
+ *
623
+ * @template T
624
+ * @typedef {{found: T|null, trouble: string}} LookedInTheStore
625
+ */
626
+
588
627
  /**
589
628
  * The commit this project's own reference points at — the build somebody shipped.
590
629
  *
@@ -592,23 +631,35 @@ async function findMergeBase(cwd, ci) {
592
631
  *
593
632
  * @param {string} cwd
594
633
  * @param {string} [product]
595
- * @returns {Promise<{sha: string, note: string}|null>}
634
+ * @returns {Promise<LookedInTheStore<{sha: string, note: string}>>}
596
635
  */
597
636
  async function releasedCommit(cwd, product) {
637
+ /** @type {string[]} */
638
+ const skipped = [];
598
639
  try {
599
640
  const store = openStore({ root: cwd });
600
641
  const name = product ?? (await productName(cwd));
601
- if (!name) return null;
642
+ if (!name) return { found: null, trouble: '' };
602
643
  const pointer = await referencePointer(store, name);
603
- if (!pointer) return null;
604
- const builds = await listBuilds(store, { product: name });
644
+ if (!pointer) return { found: null, trouble: '' };
645
+ // Until 2026-08-30 nothing was passed here, so a build folder that could not be read was
646
+ // simply not in the list — and "not in the list" is how this file spells "never existed".
647
+ // The build the reference pointer names is exactly the one most likely to have been
648
+ // written to most recently, and so exactly the one most likely to be the damaged folder.
649
+ const builds = await listBuilds(store, { product: name, onProblem: (m) => skipped.push(m) });
605
650
  const hit = builds.find((b) => b.fingerprint.id === pointer.buildId);
606
651
  const sha = hit?.fingerprint.gitSha ?? shaFromBuildId(pointer.buildId);
607
- if (!sha) return null;
608
- return { sha, note: hit?.fingerprint.version ? `version ${hit.fingerprint.version}` : '' };
609
- } catch {
610
- // A store that will not open is a reason to try the next mode, never a reason to fail.
611
- return null;
652
+ if (!sha) return { found: null, trouble: troubleFrom(skipped, '') };
653
+ return {
654
+ found: { sha, note: hit?.fingerprint.version ? `version ${hit.fingerprint.version}` : '' },
655
+ // Named even on the way to a good answer. A reference found without the record that
656
+ // was supposed to describe it is a weaker answer than one found with it.
657
+ trouble: hit ? troubleFrom(skipped, '') : troubleFrom(skipped, `The record of build ${pointer.buildId} itself was not among them.`),
658
+ };
659
+ } catch (e) {
660
+ // A store that will not open is a reason to try the next mode, never a reason to fail —
661
+ // but it is never a reason to then say this project has nothing on record either.
662
+ return { found: null, trouble: troubleFrom(skipped, `The store here could not be read at all: ${messageOf(e)}`) };
612
663
  }
613
664
  }
614
665
 
@@ -617,26 +668,47 @@ async function releasedCommit(cwd, product) {
617
668
  *
618
669
  * @param {string} cwd
619
670
  * @param {string} [product]
620
- * @returns {Promise<{buildId: string, machine: 'same'|'different'|'unknown'}|null>}
671
+ * @returns {Promise<LookedInTheStore<{buildId: string, machine: 'same'|'different'|'unknown'}>>}
621
672
  */
622
673
  async function storedRecord(cwd, product) {
674
+ /** @type {string[]} */
675
+ const skipped = [];
623
676
  try {
624
677
  const store = openStore({ root: cwd });
625
678
  const name = product ?? (await productName(cwd));
626
- if (!name) return null;
679
+ if (!name) return { found: null, trouble: '' };
627
680
  const pointer = await referencePointer(store, name);
628
- const builds = await listBuilds(store, { product: name });
629
- if (builds.length === 0) return null;
681
+ const builds = await listBuilds(store, { product: name, onProblem: (m) => skipped.push(m) });
682
+ if (builds.length === 0) return { found: null, trouble: troubleFrom(skipped, '') };
630
683
  const hit = (pointer && builds.find((b) => b.fingerprint.id === pointer.buildId)) || builds[0];
631
684
  const here = `${process.platform}-${process.arch}`;
632
685
  /** @type {'same'|'different'|'unknown'} */
633
686
  const machine = !hit.fingerprint.platform ? 'unknown' : hit.fingerprint.platform === here ? 'same' : 'different';
634
- return { buildId: hit.fingerprint.id, machine };
635
- } catch {
636
- return null;
687
+ return { found: { buildId: hit.fingerprint.id, machine }, trouble: troubleFrom(skipped, '') };
688
+ } catch (e) {
689
+ return { found: null, trouble: troubleFrom(skipped, `The store here could not be read at all: ${messageOf(e)}`) };
637
690
  }
638
691
  }
639
692
 
693
+ /**
694
+ * One sentence about what could not be seen, or nothing at all when everything could.
695
+ *
696
+ * @param {string[]} skipped
697
+ * @param {string} extra
698
+ * @returns {string}
699
+ */
700
+ function troubleFrom(skipped, extra) {
701
+ /** @type {string[]} */
702
+ const parts = [];
703
+ if (skipped.length > 0) {
704
+ parts.push(
705
+ `${skipped.length} stored build ${skipped.length === 1 ? 'record was' : 'records were'} skipped because ${skipped.length === 1 ? 'it' : 'they'} could not be read, so what is on record here may be more than this run could see: ${skipped.join(' ')}`,
706
+ );
707
+ }
708
+ if (extra) parts.push(extra);
709
+ return parts.join(' ');
710
+ }
711
+
640
712
  // ---------------------------------------------------------------------------
641
713
  // The report
642
714
  // ---------------------------------------------------------------------------
@@ -816,23 +888,31 @@ function findingLines(f) {
816
888
  /**
817
889
  * Append the report to the job summary, when the build server has one.
818
890
  *
819
- * Returns the file it wrote to, or null when there is nowhere to write. Null is a normal
820
- * answer on GitLab and CircleCI, neither of which has a summary page.
891
+ * TWO ANSWERS THAT USED TO BE ONE NULL. "This server has no summary page" is the normal
892
+ * state on GitLab and CircleCI and needs saying to nobody. "This server HAS one and the
893
+ * report did not reach it" is a report that has vanished — the person opens the job, sees
894
+ * the tab they expect, finds nothing under it, and concludes the check did not run. Both
895
+ * came back as null, so nothing anywhere could tell them apart or mention the second.
896
+ *
897
+ * The exit code is untouched either way. Losing the page must never cost the half that
898
+ * actually stops the merge.
821
899
  *
822
900
  * @param {CIReport} report
823
901
  * @param {CIEnvironment} [env]
824
- * @returns {Promise<string|null>}
902
+ * @returns {Promise<{file: string|null, why: string}>} `why` is empty unless there was a
903
+ * page to write to and writing failed.
825
904
  */
826
905
  export async function writeJobSummary(report, env) {
827
906
  const where = (env ?? detectCI()).summaryFile;
828
- if (!where) return null;
907
+ if (!where) return { file: null, why: '' };
829
908
  try {
830
909
  await fsp.appendFile(where, `${report.markdown}\n`);
831
- return where;
832
- } catch {
833
- // A summary page is a nicety. Losing it must never cost the exit code, which is the
834
- // half that actually stops the merge.
835
- return null;
910
+ return { file: where, why: '' };
911
+ } catch (e) {
912
+ return {
913
+ file: null,
914
+ why: `The report could not be written to this job's summary page (${where}): ${messageOf(e)}. The answer below is the whole of it.`,
915
+ };
836
916
  }
837
917
  }
838
918
 
@@ -931,12 +1011,27 @@ export async function runCI(opts = {}) {
931
1011
 
932
1012
  /** @type {string|null} */
933
1013
  let evidence = null;
1014
+ /** @type {string[]} */
1015
+ const lost = [];
934
1016
  try {
935
1017
  evidence = await saveEvidence({ cwd, dir: opts.evidenceDir, verdict, reference, report, env: where });
936
- } catch {
937
- // Losing the attachment must never change the answer.
1018
+ } catch (e) {
1019
+ // Losing the attachment must never change the answer — and it must never be lost in
1020
+ // silence either, because the report above says in so many words that the rest of the
1021
+ // gaps are "in the evidence attached to this run". A run that swallowed this printed
1022
+ // that sentence pointing at a folder that was never written.
1023
+ lost.push(`THE EVIDENCE FOR THIS RUN WAS NOT SAVED: ${messageOf(e)} Anything the report says is in the attached evidence is not there, so what you can read above is all there is.`);
1024
+ }
1025
+ const summaryPage = await writeJobSummary(report, where);
1026
+ if (summaryPage.why) lost.push(summaryPage.why);
1027
+
1028
+ // Onto the report itself, both shapes of it, rather than only into a log line. The
1029
+ // markdown is what a person opens later and the text is what the job log keeps.
1030
+ if (lost.length > 0) {
1031
+ const said = lost.join(' ');
1032
+ report.markdown = `${report.markdown}\n\n> **${said}**\n`;
1033
+ report.text = `${report.text}\n${said}\n`;
938
1034
  }
939
- await writeJobSummary(report, where);
940
1035
 
941
1036
  if (opts.quiet !== true) process.stdout.write(`${report.text}\n`);
942
1037
  return { exitCode: report.exitCode, report, reference, verdict, evidence };
package/src/v2/cli.js CHANGED
@@ -35,7 +35,9 @@ import { escalationBlock, escalationsFor, productFor, writeEscalations } from '.
35
35
  // to mean two different things depending on which check you ran. src/cli/index.js imports
36
36
  // this file in turn; that circle is safe because nothing here touches it while either
37
37
  // module is still being evaluated.
38
- import { watchFlags } from '../cli/index.js';
38
+ import { watchFlags } from '../cli/watch-flags.js';
39
+ import { INIT_COMMANDS } from './init.js';
40
+ import { whatWasNotChecked } from './check.js';
39
41
 
40
42
  /**
41
43
  * What comes back from a check. Everything that did not change never appears
@@ -102,35 +104,42 @@ const V1_OPTIONS = [
102
104
  ['--pictures', 'The version 1 picture check, unchanged.'],
103
105
  ['--guards', 'The version 1 guards, unchanged.'],
104
106
  ['--only <name>', 'Just this journey, screen or guard. Repeat it for several.'],
107
+ ['--record', 'The version 1 run that records network fixtures.'],
108
+ ['--report / --no-report', 'Write the version 1 HTML report. Version 1 checks only.'],
109
+ ['--profile', 'Print where the time went. Version 1 checks only.'],
105
110
  ];
106
111
 
107
112
  /**
108
- * The commands version 2 replaces, in exactly the shape `src/cli/index.js`
109
- * already uses for its own. Merging this over the existing table is the whole
110
- * of the wiring:
111
- *
112
- * import { V2_COMMANDS } from '../v2/cli.js';
113
- * Object.assign(COMMANDS, V2_COMMANDS);
114
- *
115
- * @type {Record<string, {summary: string, usage: string, describe: string, options: [string,string][], examples: string[], spec: {booleans?: string[], strings?: string[], arrays?: string[]}, load: () => Promise<{run: (ctx: any) => Promise<number>}>}>}
113
+ * Flags only version 1's check reads, by the name the parser knows them under.
114
+ *
115
+ * They are accepted on every `check` because the two halves share one spec — which is right,
116
+ * since `--pictures --profile` has to work. What was wrong is that typing one WITHOUT
117
+ * `--pictures` or `--guards` did nothing at all and said nothing at all: `staysfixed check
118
+ * --profile` ran a perfectly ordinary difference-engine check, printed no profile, and gave
119
+ * no hint that the flag had been ignored. A flag that is accepted and does nothing is the
120
+ * same lie as a flag that does not exist, and a slower one to find.
121
+ */
122
+ const V1_ONLY_FLAGS = [
123
+ ['profile', '--profile'],
124
+ ['report', '--report'],
125
+ ['record', '--record'],
126
+ ];
127
+
128
+ /**
129
+ * The commands version 2 contributes. `src/cli/index.js` merges these over version 1's,
130
+ * so `check` and `doctor` become the difference engine while everything version 1 did
131
+ * stays reachable behind `--pictures`, `--guards` and `--watch`.
116
132
  */
117
133
  export const V2_COMMANDS = {
118
- // `staysfixed ship` comes from src/v2/ship.js. It is merged in here rather than into the
119
- // version 1 command table so that wiring version 2 into the front door stays the one
120
- // import it has always been.
121
- //
122
- // `staysfixed init` from src/v2/init.js is deliberately NOT merged in yet, and this is
123
- // the same call as the MCP server: version 2's init is a BREAKING change. Version 1's
124
- // init writes settings for any folder; version 2's reads the project first and writes
125
- // nothing when it cannot tell what the project is, which is better and is not what the
126
- // three tests in test/cli.test.js describe. Somebody installed this last week and has
127
- // `staysfixed init` in a setup script. Adding one line here —
134
+ // Version 2's init replaces version 1's.
128
135
  //
129
- // import { INIT_COMMANDS } from './init.js'; ...INIT_COMMANDS,
130
- //
131
- // switches it over, and those three tests have to be rewritten in the same change to
132
- // say what the new one does. That is a decision, not an oversight, and it belongs in a
133
- // change of its own rather than arriving as a side effect of wiring up the phones.
136
+ // It was held back on the grounds that somebody might have `staysfixed init` in a
137
+ // setup script and would get a different file. True, and not a reason: the old one
138
+ // writes settings for photographing screens, which is not what this tool does any
139
+ // more, so leaving it in place hands a new project the wrong shape and calls it done.
140
+ // The three tests in test/cli.test.js were rewritten in the same change to say what
141
+ // the new one actually does.
142
+ ...INIT_COMMANDS,
134
143
  ...SHIP_COMMANDS,
135
144
 
136
145
  check: {
@@ -159,16 +168,17 @@ export const V2_COMMANDS = {
159
168
 
160
169
  doctor: {
161
170
  summary: 'What this tool can and cannot check on this machine, and what would unlock more.',
162
- usage: 'staysfixed doctor [--json] [--offline] [--fix]',
171
+ usage: 'staysfixed doctor [--json] [--machines] [--offline] [--fix]',
163
172
  describe:
164
- 'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.',
173
+ 'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.\n\nIt does not connect to the machines in your ssh config unless this project has a\ndesktop app that might need one, or you pass --machines. They are still listed.',
165
174
  options: [
166
175
  ['--json', 'The whole answer as one JSON object. For agents.'],
176
+ ['--machines', 'Also dial the machines in your ssh config, to find a runner for a desktop app.'],
167
177
  ['--offline', 'Do not dial any other machine. Faster, and reports no runners.'],
168
178
  ['--fix', 'Repair the small local things that can be repaired safely.'],
169
179
  ],
170
180
  examples: ['staysfixed doctor', 'staysfixed doctor --json'],
171
- spec: { booleans: ['json', 'offline', 'fix'] },
181
+ spec: { booleans: ['json', 'offline', 'fix', 'machines'] },
172
182
  load: async () => ({ run: doctorRun }),
173
183
  },
174
184
  };
@@ -203,6 +213,16 @@ export async function run(ctx) {
203
213
  return await v1.run(ctx);
204
214
  }
205
215
 
216
+ // Say so when a flag was accepted and will do nothing.
217
+ //
218
+ // This goes to standard error, so it cannot corrupt --json, and it is a warning rather
219
+ // than a refusal: the person asked for a real check and they should still get one.
220
+ for (const [flag, written] of V1_ONLY_FLAGS) {
221
+ if (ctx.flags[flag] !== undefined) {
222
+ warn(`${written} only applies to the version 1 check. This run is the difference engine, so it was ignored — add --pictures or --guards if that is what you wanted.`);
223
+ }
224
+ }
225
+
206
226
  // --json means the answer belongs to a machine. Every line meant for a person
207
227
  // is switched off before anything else runs, rather than trusted not to
208
228
  // print: one stray sentence on standard output and the JSON will not parse.
@@ -225,23 +245,26 @@ export async function run(ctx) {
225
245
  // checks on the command line and then ships is told their build was "never
226
246
  // checked", and the safeguard fires on the honest case instead of the careless
227
247
  // one. The agent surface records its own; this is the command line's half.
228
- try {
229
- const { recordCheck } = await import('./reference.js');
230
- const { openStore } = await import('./store.js');
231
- await recordCheck(openStore({ root: ctx.cwd ?? process.cwd() }), {
232
- buildId: verdict.candidate?.id,
233
- product: verdict.product,
234
- ok: verdict.ok,
235
- blocked: /** @type {any} */ (verdict).blocked === true,
236
- findings: verdict.findings.length,
237
- by: 'staysfixed check',
238
- });
239
- } catch {
240
- // Never let bookkeeping cost somebody the result they came for.
241
- }
248
+ // NOT recorded again here. The engine writes this line itself, for every surface, inside
249
+ // `rememberCheck` and with more in it than this ever had: what was waived, what was
250
+ // sealed, how much went unaccounted for. This block was added on the belief that only the
251
+ // agent surface recorded, so every command-line check wrote TWO near-identical rows two
252
+ // milliseconds apart. Measured 2026-08-30: eight rows for four checks, and `ship` reads
253
+ // this log to decide whether the last check was clean.
242
254
 
243
255
  if (asJson) {
244
- process.stdout.write(JSON.stringify(verdict) + '\n');
256
+ // The README promises these as fields of their own — "a number an agent can read"
257
+ // rather than a sentence it has to parse — and only the MCP reply had them. So a human
258
+ // asking for JSON on the command line got a strictly worse answer about what was NOT
259
+ // checked than an agent asking over MCP, about the very same run.
260
+ const coverage = verdict.coverage ?? null;
261
+ process.stdout.write(
262
+ JSON.stringify({
263
+ ...verdict,
264
+ notChecked: whatWasNotChecked(coverage),
265
+ doorsNeverOpened: Math.max(0, (coverage?.doorsKnown ?? 0) - (coverage?.doorsWalked ?? 0)),
266
+ }) + '\n',
267
+ );
245
268
  } else {
246
269
  report(verdict);
247
270
  }