staysfixed 0.3.0 → 0.4.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 (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,1571 @@
1
+ /**
2
+ * The Stays Fixed v2 tool set, as an agent sees it.
3
+ *
4
+ * Everything here is written for a reader who is not human: a coding agent that
5
+ * has just changed a product and needs to know, in as few tokens as possible,
6
+ * what moved that it did not mean to move. Three rules shape the whole file.
7
+ *
8
+ * 1. SAY ONLY WHAT IS NOT ACCOUNTED FOR. Unchanged paths never reach the
9
+ * agent's context. But the silence has to be legible, so every reply says
10
+ * what the run actually covered and what it could not. "Nothing changed"
11
+ * and "nothing ran" read identically otherwise, and one of those is a
12
+ * broken tool reporting success.
13
+ *
14
+ * 2. NOTHING HEAVY IS PUSHED. Every value, picture and piece of evidence is
15
+ * fetched on request through `staysfixed_explain`, never volunteered. Each
16
+ * reply ends by naming what was withheld and the exact call that fetches it.
17
+ *
18
+ * 3. THE AGENT CANNOT BLESS ITS OWN WORK. It can record that a difference was
19
+ * intended, and only through four gates it cannot argue with: five classes
20
+ * are sealed off entirely, the claim has to match an intent sealed BEFORE
21
+ * the run, five waivers between one ship and the next, and every waiver
22
+ * dies when the reference moves. There is no door here marked "approve".
23
+ *
24
+ * This file is the surface, the bookkeeping and the gates. The difference engine
25
+ * proper lives in `src/v2/check.js` and `src/v2/doctor.js`; the engine is found
26
+ * at run time rather than imported, so the server still starts, still lists its
27
+ * tools and still answers `staysfixed_capabilities` when a piece of it is not
28
+ * built yet. Capabilities is the call an agent makes to find out what is
29
+ * missing, so it must never be the thing that is missing.
30
+ */
31
+
32
+ import fs from 'node:fs';
33
+ import fsp from 'node:fs/promises';
34
+ import path from 'node:path';
35
+
36
+ import { isExpected, messageOf } from '../../core/errors.js';
37
+ import { findConfigFile, rootForConfig } from '../../core/paths.js';
38
+ import { openStore } from '../store.js';
39
+ import { SEALED_CLASSES, classify } from '../sealed.js';
40
+ import { sealIntent } from '../intent.js';
41
+ import { waive } from '../waiver.js';
42
+ import {
43
+ WAIVER_BUDGET,
44
+ decide,
45
+ escalationBlock,
46
+ escalationsFor,
47
+ productFor,
48
+ readCheckRecord,
49
+ readDecisions,
50
+ } from '../escalate.js';
51
+
52
+ /**
53
+ * What a check hands back, and one finding inside it.
54
+ *
55
+ * `CheckOutcome` is a Verdict plus `blocked`: "the check could not be completed" is
56
+ * neither a pass nor a failure, and reporting it as either is the exact thing this
57
+ * tool exists to stop. It is declared at the engine's front door in src/v2/check.js.
58
+ *
59
+ * Taken from src/v2/types.js, which is the contract every part of version 2 is built
60
+ * against. This surface used to read them off the command line module, which does not
61
+ * define them - so the two would have been free to drift, and the drift would have
62
+ * shown up as an agent being told about a field that was never filled in.
63
+ *
64
+ * @typedef {import('../check.js').CheckOutcome} CheckResult
65
+ * @typedef {import('../types.js').Finding} RawFinding
66
+ */
67
+
68
+ /**
69
+ * A finding with the two things the surface needs and the engine does not
70
+ * provide: something to call it by, and something a waiver can be pinned to.
71
+ *
72
+ * @typedef {RawFinding & {id: string, fingerprint: string}} Finding
73
+ */
74
+
75
+ /**
76
+ * What every tool call is handed.
77
+ *
78
+ * @typedef {object} ToolContext
79
+ * @property {string} root
80
+ * @property {string} cwd
81
+ * @property {string} version
82
+ * @property {string} protocolVersion
83
+ */
84
+
85
+ /** @typedef {{type: 'text', text: string}|{type: 'image', data: string, mimeType: string}} ContentItem */
86
+ /** @typedef {{content: ContentItem[], structuredContent?: Record<string, unknown>, isError?: boolean}} ToolResult */
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Constants that are policy, not preference
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * The five classes an agent may never wave through, said as a name and a sentence.
94
+ *
95
+ * This file used to keep its own copy of the list and its own regular expressions for
96
+ * matching it, which is precisely the shape of bug the whole tool exists to catch: two
97
+ * statements of what may never be waived, agreeing today, one of them quietly edited in six
98
+ * months. There is one list now, in src/v2/sealed.js, and one function that decides which
99
+ * class a difference is in. This only reads them.
100
+ *
101
+ * @type {Record<string, string>}
102
+ */
103
+ const SEALED_SAYS = Object.fromEntries(SEALED_CLASSES.map((c) => [c.name, `${c.says} - ${c.because}`]));
104
+
105
+ /** How many findings ride in a default reply before the rest are counted instead. */
106
+ const DEFAULT_LIMIT = 10;
107
+
108
+ /** How many paths of one finding are listed before they are summarised. */
109
+ const MAX_PATHS = 8;
110
+
111
+ /** Pictures are channel seven and evidence only: this many, and only when asked. */
112
+ const MAX_IMAGES = 2;
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Finding the difference engine
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Where the engine's callable parts live, and what they are called.
120
+ *
121
+ * The surface and the engine are built in parallel, and a hard `import` of a
122
+ * file that is not written yet takes the whole server down - including the one
123
+ * call whose job is to explain that it is missing. So the specifier is built
124
+ * from a variable: a literal would be resolved at type-check time and fail there
125
+ * instead.
126
+ *
127
+ * If a name moves, one edit to this table wires it up again, and
128
+ * `staysfixed_capabilities` prints exactly what was looked for and not found, so
129
+ * nobody has to guess.
130
+ */
131
+ const ENGINE_FILES = ['check.js', 'doctor.js'];
132
+
133
+ /**
134
+ * Part name to the export names accepted for it, best first.
135
+ *
136
+ * `proveCause` in src/v2/cause.js is deliberately NOT listed. It takes an
137
+ * engine-internal finding and a loaded project, neither of which this surface
138
+ * has, and calling it with the wrong shapes would fail in a way that reads like
139
+ * a bug in the product being checked. Refusing is the honest answer until a
140
+ * facade with a plain argument object exists.
141
+ */
142
+ const ENGINE_PARTS = {
143
+ check: ['check'],
144
+ prove: ['prove'],
145
+ explain: ['explain'],
146
+ capabilities: ['capabilities'],
147
+ describe: ['describeCapabilities'],
148
+ // What a run did NOT look at, in one sentence. Taken from the engine rather than
149
+ // written again here, because two differently-worded statements of the same coverage
150
+ // is how one of them quietly starts being wrong — and this is the sentence that stands
151
+ // between a clean result and somebody believing more of it than it says.
152
+ notChecked: ['whatWasNotChecked'],
153
+ };
154
+
155
+ /**
156
+ * @typedef {object} Engine
157
+ * @property {Record<string, (arg: any) => any>} parts
158
+ * @property {string[]} loaded
159
+ * @property {string[]} missing
160
+ */
161
+
162
+ /** @type {Engine|null} */
163
+ let engineCache = null;
164
+
165
+ /**
166
+ * Load whatever of the engine exists, once per process.
167
+ *
168
+ * Exported because `selfcheck.js` drives the same engine and must find it the
169
+ * same way. If those two ever looked in different places, the corpus would be
170
+ * proving something other than what an agent actually runs.
171
+ *
172
+ * @param {boolean} [refresh] During the build the engine appears mid-session.
173
+ * @returns {Promise<Engine>}
174
+ */
175
+ export async function loadEngine(refresh = false) {
176
+ if (engineCache && !refresh) return engineCache;
177
+
178
+ /** @type {Record<string, any>} */
179
+ const bag = {};
180
+ /** @type {string[]} */
181
+ const loaded = [];
182
+
183
+ for (const rel of ENGINE_FILES) {
184
+ const url = new URL(`../${rel}`, import.meta.url);
185
+ if (!fs.existsSync(url)) continue;
186
+ try {
187
+ // Built from a variable, not a literal - see the note on ENGINE_FILES.
188
+ const href = url.href;
189
+ const mod = await import(href);
190
+ for (const [key, value] of Object.entries(mod)) {
191
+ if (!(key in bag)) bag[key] = value;
192
+ }
193
+ loaded.push(rel);
194
+ } catch {
195
+ // A module that throws while loading is that lane's problem, not a reason
196
+ // for this server to die. It shows up as a missing part below.
197
+ }
198
+ }
199
+
200
+ /** @type {Engine} */
201
+ const engine = { parts: {}, loaded, missing: [] };
202
+ for (const [part, names] of Object.entries(ENGINE_PARTS)) {
203
+ const hit = names.find((n) => typeof bag[n] === 'function');
204
+ if (hit) engine.parts[part] = bag[hit];
205
+ else engine.missing.push(part);
206
+ }
207
+
208
+ engineCache = engine;
209
+ return engine;
210
+ }
211
+
212
+ /**
213
+ * What an agent gets when it asks for something only the engine can do and the
214
+ * engine is not there. Written as instructions to whoever is integrating,
215
+ * because that is the only person who will ever read it.
216
+ *
217
+ * @param {Engine} engine
218
+ * @param {string} part
219
+ * @param {string} needs The exact signature the missing function must have.
220
+ * @returns {ToolResult}
221
+ */
222
+ function engineMissing(engine, part, needs) {
223
+ const names = (/** @type {Record<string, string[]>} */ (ENGINE_PARTS)[part] ?? []).join(' or ');
224
+ return problem(
225
+ [
226
+ `The difference engine cannot do "${part}" in this copy of Stays Fixed.`,
227
+ '',
228
+ `It looked for a function called ${names}, exported from ${ENGINE_FILES.map((f) => `src/v2/${f}`).join(' or ')}.`,
229
+ engine.loaded.length ? `It loaded ${engine.loaded.map((f) => `src/v2/${f}`).join(' and ')}, and neither exports that.` : 'Neither of those files exists yet.',
230
+ '',
231
+ `It needs: ${needs}`,
232
+ '',
233
+ 'Everything else still works. Call staysfixed_capabilities for what this copy can do.',
234
+ ].join('\n')
235
+ );
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Where this tool keeps its own bookkeeping
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * The project root: the folder with the settings file in it, else the folder
244
+ * with the repository or the package, else where we were started.
245
+ *
246
+ * @param {string} from
247
+ * @returns {string}
248
+ */
249
+ export function findRoot(from) {
250
+ const config = findConfigFile(from);
251
+ if (config) return rootForConfig(config);
252
+ let dir = path.resolve(from);
253
+ for (;;) {
254
+ if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'package.json'))) return dir;
255
+ const parent = path.dirname(dir);
256
+ if (parent === dir) return path.resolve(from);
257
+ dir = parent;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * The sealed intents, the waivers and the record of the last check all live in the store's
263
+ * own folder. This file reads and writes none of them: intents belong to src/v2/intent.js,
264
+ * waivers to src/v2/waiver.js, the record and the escalations to src/v2/escalate.js.
265
+ *
266
+ * That separation is the point. The engine decides what is DIFFERENT; the decision layer
267
+ * decides what an agent is ALLOWED to say about it; this surface only asks, and repeats the
268
+ * answer word for word. An engine bug must not be able to widen a gate, and neither must a
269
+ * rewording here — which is exactly what happened while this file held its own copy of the
270
+ * sealed classes and its own idea of what an intent covered.
271
+ *
272
+ * @param {ToolContext} ctx
273
+ * @returns {import('../types.js').Store}
274
+ */
275
+ function storeFor(ctx) {
276
+ return openStore({ root: ctx.root });
277
+ }
278
+
279
+ /**
280
+ * A sealed statement of what the agent meant to change, written before the run.
281
+ * @typedef {import('../intent.js').Intent} Intent
282
+ */
283
+
284
+ /**
285
+ * The record of the last check, written by the engine and read here, so that explain, prove
286
+ * and waive can all be handed an id and "there is no finding called that" is a real answer
287
+ * rather than a shrug.
288
+ * @typedef {import('../escalate.js').CheckRecord} CheckIndex
289
+ */
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // The list
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * The `tools/list` payload. Static, and it never touches disk: an agent listing
297
+ * tools in a project that is not set up must still see
298
+ * `staysfixed_capabilities`, which is the tool that explains why nothing else
299
+ * will work yet.
300
+ *
301
+ * @returns {{name: string, description: string, inputSchema: Record<string, any>}[]}
302
+ */
303
+ export function toolDefinitions() {
304
+ return [
305
+ {
306
+ name: 'staysfixed_capabilities',
307
+ description:
308
+ 'CALL THIS FIRST, once per session. What Stays Fixed can check on this machine right now, what it cannot and why, what is missing that would unlock more, which other machines it can already reach, and the exact shape of every reply you will get back. It runs nothing and changes nothing. After this call you should not need to read any documentation about this tool.',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ detail: { type: 'string', enum: ['brief', 'full'], description: "'brief' is the short answer and the default. 'full' adds every surface, the machine, the result shapes and the wiring." },
313
+ format: { type: 'string', enum: ['text', 'json'], description: "'json' gives the same answer as one machine-readable object and no prose." },
314
+ offline: { type: 'boolean', description: 'Do not dial any other machine. Faster, and it will report no remote runners.' },
315
+ refresh: { type: 'boolean', description: 'Look for the engine again. Only useful while Stays Fixed itself is being built.' },
316
+ },
317
+ additionalProperties: false,
318
+ },
319
+ },
320
+ {
321
+ name: 'staysfixed_intent',
322
+ description:
323
+ 'Seal what you MEANT to change, BEFORE you run a check. One plain sentence, the files or areas you expect to affect, and the differences you expect to see. This is what makes a later "that one was me" claim checkable instead of a story: you cannot waive a difference outside what you sealed, and you cannot seal an intent after seeing what broke. Call it once per change, right before or right after you edit.',
324
+ inputSchema: {
325
+ type: 'object',
326
+ properties: {
327
+ summary: { type: 'string', description: 'What you set out to change, in one plain sentence a non-technical person would understand.' },
328
+ touches: {
329
+ type: 'array',
330
+ items: { type: 'string' },
331
+ description: 'Files, folders or named areas you expect this to affect, e.g. ["src/checkout/total.js", "the basket page"]. A difference outside this list cannot be waived.',
332
+ },
333
+ expect: { type: 'array', items: { type: 'string' }, description: 'Differences you expect this change to produce, in your own words. Optional, and it makes the check sharper.' },
334
+ },
335
+ required: ['summary', 'touches'],
336
+ additionalProperties: false,
337
+ },
338
+ },
339
+ {
340
+ name: 'staysfixed_check',
341
+ description:
342
+ 'Run it. Puts the build you just changed through the same steps as the build that was last shipped, twice, and reports only the differences that are left after the product\'s own wobble is subtracted. Covers what the screen says a control does, what calls go out, what files are written, what errors appear, what the program prints, and what the code exposes. You get back ONLY what you did not account for, ranked with the differences furthest from your edit at the top, because those are side effects. Everything unchanged is silent. Seal an intent first.',
343
+ inputSchema: {
344
+ type: 'object',
345
+ properties: {
346
+ only: { type: 'array', items: { type: 'string' }, description: 'Check only these journeys, by name. Leave it out to check everything.' },
347
+ against: { type: 'string', description: 'Compare against this marker, tag or commit instead of the newest reference.' },
348
+ paired: {
349
+ type: 'boolean',
350
+ description: 'Boot the old build live and walk it from the start instead of trusting the stored record. Slower and much stronger. Use it before a release, and on the first run of a product with no stored record.',
351
+ },
352
+ journeys: { type: 'string', description: "Where the steps come from: 'suite', 'code', 'recorded', or a path to a journeys file." },
353
+ surface: {
354
+ type: 'string',
355
+ enum: ['auto', 'cli', 'library', 'server', 'web', 'electron', 'android', 'ios'],
356
+ description:
357
+ "What kind of product to aim at. Default 'auto', which uses the settings. 'web' opens the page in a browser of the tool's own — never yours — and reads what the screen says each control is and does. 'electron' opens the desktop app with its own scratch data folder and drives it over its own debugging port. 'android' installs the APK on a virtual device; 'ios' boots the built app on a simulator. Aim it at something this copy or this machine cannot drive and it refuses by name rather than checking something else and reporting that.",
358
+ },
359
+ at: {
360
+ type: 'string',
361
+ description:
362
+ "Where that product is: a URL for 'web' (http://localhost:3000), the path to the built app for 'electron', the APK for 'android', the built .app for 'ios'. Leave it out to use whatever the settings name.",
363
+ },
364
+ limit: { type: 'number', description: `How many findings to return in full. Default ${DEFAULT_LIMIT}; the rest are counted and named.` },
365
+ offset: { type: 'number', description: 'Skip this many findings. Pages through the last run without running anything again.' },
366
+ format: { type: 'string', enum: ['text', 'json'], description: "'json' returns the whole result as machine-readable JSON and no prose." },
367
+ },
368
+ additionalProperties: false,
369
+ },
370
+ },
371
+ {
372
+ name: 'staysfixed_explain',
373
+ description:
374
+ 'One finding, in depth: every address that moved, both values in full, what class it is in, how far it sits from your edit, and the evidence. This is where the heavy material lives - it is never pushed into a check reply, so ask for it on the two or three findings you actually intend to act on.',
375
+ inputSchema: {
376
+ type: 'object',
377
+ properties: {
378
+ finding: { type: 'string', description: 'The finding id from staysfixed_check.' },
379
+ include: {
380
+ type: 'array',
381
+ items: { type: 'string', enum: ['values', 'paths', 'evidence', 'pixels'] },
382
+ description: "What to include. Default is values and paths. 'pixels' returns a picture and costs a lot of context - ask only when a picture would settle it.",
383
+ },
384
+ },
385
+ required: ['finding'],
386
+ additionalProperties: false,
387
+ },
388
+ },
389
+ {
390
+ name: 'staysfixed_prove',
391
+ description:
392
+ 'Test a causal claim by undoing a change and running again. You believe your edit to a particular file caused a finding: this puts that file back to the reference, re-runs, and tells you whether the difference went away. If it survives the revert, your edit did not cause it and you were about to fix the wrong thing. Nothing is left reverted.',
393
+ inputSchema: {
394
+ type: 'object',
395
+ properties: {
396
+ finding: { type: 'string', description: 'The finding id you are trying to explain.' },
397
+ revert: { type: 'array', items: { type: 'string' }, description: 'The files you think caused it. They are put back to the reference for one run.' },
398
+ },
399
+ required: ['finding', 'revert'],
400
+ additionalProperties: false,
401
+ },
402
+ },
403
+ {
404
+ name: 'staysfixed_waive',
405
+ description:
406
+ 'Record that a difference was intended. This is NOT approval and it makes nothing the new normal - only shipping does that. Four rules are enforced and cannot be argued with: differences touching money, signing in, losing data, a crash, or a named guard can never be waived; the difference has to fall inside what you sealed with staysfixed_intent before the run; five between one ship and the next; and every waiver dies the moment the reference moves. If a waiver is refused, that is the answer - fix the code instead.',
407
+ inputSchema: {
408
+ type: 'object',
409
+ properties: {
410
+ finding: { type: 'string', description: 'The finding id from staysfixed_check.' },
411
+ because: { type: 'string', description: 'Why this difference is what you meant, in one plain sentence. A person reads this later.' },
412
+ },
413
+ required: ['finding', 'because'],
414
+ additionalProperties: false,
415
+ },
416
+ },
417
+ {
418
+ name: 'staysfixed_coverage',
419
+ description:
420
+ 'What was NOT checked. The ways in that no journey has ever opened, the surfaces this machine cannot reach at all, anything refused because doing it twice would not have been reversible, and the things this tool can never see on any machine. Read it before you tell anyone a change is safe: a clean check only covers what was walked, and this is the list of what was not.',
421
+ inputSchema: {
422
+ type: 'object',
423
+ properties: { format: { type: 'string', enum: ['text', 'json'] } },
424
+ additionalProperties: false,
425
+ },
426
+ },
427
+ ];
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // The call
432
+ // ---------------------------------------------------------------------------
433
+
434
+ /**
435
+ * Run one tool.
436
+ *
437
+ * A tool that fails is a RESULT with `isError: true`, never a JSON-RPC error -
438
+ * the agent is meant to read the failure and act on it, and a protocol-level
439
+ * error is swallowed by its client before it ever sees the words.
440
+ *
441
+ * @param {string} name
442
+ * @param {any} args
443
+ * @param {ToolContext} ctx
444
+ * @returns {Promise<ToolResult>}
445
+ */
446
+ export async function callTool(name, args, ctx) {
447
+ /** @type {Record<string, any>} */
448
+ const input = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
449
+
450
+ try {
451
+ switch (name) {
452
+ case 'staysfixed_capabilities':
453
+ return await toolCapabilities(ctx, input);
454
+ case 'staysfixed_intent':
455
+ return await toolIntent(ctx, input);
456
+ case 'staysfixed_check':
457
+ return await toolCheck(ctx, input);
458
+ case 'staysfixed_explain':
459
+ return await toolExplain(ctx, input);
460
+ case 'staysfixed_prove':
461
+ return await toolProve(ctx, input);
462
+ case 'staysfixed_waive':
463
+ return await toolWaive(ctx, input);
464
+ case 'staysfixed_coverage':
465
+ return await toolCoverage(ctx, input);
466
+ default:
467
+ return problem(`There is no Stays Fixed tool called "${name}". The tools are: ${toolDefinitions().map((t) => t.name).join(', ')}.`);
468
+ }
469
+ } catch (e) {
470
+ return problem(explainError(e));
471
+ }
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // capabilities
476
+ // ---------------------------------------------------------------------------
477
+
478
+ /**
479
+ * The self-describing call.
480
+ *
481
+ * The machine survey belongs to `doctor.js` and is quoted from there rather than
482
+ * repeated here - an agent and a person being told different things about the
483
+ * same machine is exactly the bug this tool exists to catch, and it would be a
484
+ * poor joke to ship it in the tool itself. What this adds is the part doctor
485
+ * does not know: the loop, the shape of the replies, and the rules on waiving.
486
+ *
487
+ * @param {ToolContext} ctx
488
+ * @param {Record<string, any>} input
489
+ * @returns {Promise<ToolResult>}
490
+ */
491
+ async function toolCapabilities(ctx, input) {
492
+ const engine = await loadEngine(input.refresh === true);
493
+ const full = input.detail === 'full';
494
+
495
+ /** @type {any} */
496
+ let caps = null;
497
+ /** @type {string|null} */
498
+ let capsError = null;
499
+ if (engine.parts.capabilities) {
500
+ try {
501
+ caps = await engine.parts.capabilities({ cwd: ctx.root, offline: input.offline === true });
502
+ } catch (e) {
503
+ capsError = messageOf(e);
504
+ }
505
+ }
506
+
507
+ const decisions = await readDecisions(storeFor(ctx), await productFor(ctx.root));
508
+ const spent = decisions.spent;
509
+
510
+ if (input.format === 'json') {
511
+ const payload = {
512
+ tool: 'staysfixed',
513
+ version: ctx.version,
514
+ loop: LOOP_STEPS,
515
+ resultShapes: RESULT_SHAPES,
516
+ waiving: { budget: WAIVER_BUDGET, spent, sealedClasses: SEALED_SAYS, expiresWhen: 'the reference moves - when a build is shipped' },
517
+ engine: { loaded: engine.loaded, missing: engine.missing },
518
+ machine: caps,
519
+ machineError: capsError,
520
+ // Pulled up out of `machine` on purpose. These two are the answers an agent
521
+ // acts on — what a clean run would actually mean, and which browser gets
522
+ // opened — and burying them inside the machine survey is how they get skipped.
523
+ covers: caps?.covers ?? null,
524
+ browsers: caps?.browsers ?? null,
525
+ aiming: AIMING,
526
+ selfCheck: 'staysfixed check --selfcheck',
527
+ };
528
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], structuredContent: payload };
529
+ }
530
+
531
+ /** @type {string[]} */
532
+ const out = [];
533
+ // No version number on this line: `describeCapabilities` opens with one, and
534
+ // two version numbers on two lines is the sort of small contradiction that
535
+ // makes a reader stop trusting everything under it.
536
+ out.push('Stays Fixed proves that nothing which already worked has changed.');
537
+ out.push('');
538
+
539
+ if (caps && engine.parts.describe) {
540
+ // Word for word what `staysfixed doctor` prints. Same words to the agent and
541
+ // to the person, always.
542
+ for (const line of engine.parts.describe(caps)) out.push(String(line));
543
+ } else if (capsError) {
544
+ out.push(`This copy could not survey the machine: ${capsError}`);
545
+ } else {
546
+ out.push('This copy cannot survey the machine - src/v2/doctor.js is not exporting capabilities(). Nothing below tells you what this machine can actually drive.');
547
+ }
548
+
549
+ if (engine.missing.length) {
550
+ out.push('');
551
+ out.push(`NOT BUILT IN THIS COPY: ${engine.missing.join(', ')}. Those calls refuse rather than pretend. Everything else works.`);
552
+ }
553
+
554
+ out.push('');
555
+ out.push('AIMING A CHECK AT ONE KIND OF PRODUCT');
556
+ for (const line of AIMING) out.push(`- ${line}`);
557
+
558
+ if (caps?.browsers) {
559
+ out.push('');
560
+ out.push('WHAT IT DOES TO THIS MACHINE WHILE IT RUNS');
561
+ out.push(`- ${caps.browsers.note}`);
562
+ for (const promise of caps.browsers.neverTouches ?? []) out.push(`- ${promise}`);
563
+ out.push(`- ${caps.browsers.leftovers}`);
564
+ }
565
+
566
+ out.push('');
567
+ out.push('THE LOOP');
568
+ for (const step of LOOP_STEPS) out.push(`- ${step}`);
569
+
570
+ out.push('');
571
+ out.push('WHAT YOU MAY DECIDE, AND WHAT YOU MAY NOT');
572
+ out.push(`- You cannot write a reference. Only shipping does that, and only a person ships.`);
573
+ out.push(`- You can record that a difference was intended: ${WAIVER_BUDGET} between one ship and the next, ${spent} already spent, and every one of them dies when the reference moves.`);
574
+ out.push('- These can never be waived by you, whatever the reason:');
575
+ for (const [name, why] of Object.entries(SEALED_SAYS)) out.push(` ${name} - ${why}`);
576
+
577
+ if (full) {
578
+ out.push('');
579
+ out.push('WHAT COMES BACK');
580
+ for (const line of RESULT_SHAPES) out.push(`- ${line}`);
581
+ if (caps?.wiring?.mcp) {
582
+ out.push('');
583
+ out.push('HOW THIS IS WIRED UP (paste this into an editor that does not have it yet)');
584
+ out.push(JSON.stringify(caps.wiring.mcp, null, 2));
585
+ }
586
+ out.push('');
587
+ out.push('PROVING THE TOOL ITSELF STILL WORKS');
588
+ out.push('- `staysfixed check --selfcheck` builds a set of deliberately broken products and fails loudly if any of them get past the engine. A tool reporting "nothing changed" looks exactly like a tool that is broken; that is what tells the two apart. Run it before you trust a clean result from a copy you have not used before.');
589
+ } else {
590
+ out.push('');
591
+ out.push('Ask again with { "detail": "full" } for the result shapes, the block that wires this into an editor, and how to prove the tool itself still catches things.');
592
+ }
593
+
594
+ return { content: [{ type: 'text', text: out.join('\n') }] };
595
+ }
596
+
597
+ /**
598
+ * How to point a check at one kind of product. Said once, here, because it is the
599
+ * part of the surface that changed when web and desktop apps arrived and an agent
600
+ * that has to guess at it will guess wrong.
601
+ */
602
+ const AIMING = [
603
+ "staysfixed_check { surface: 'web', at: 'http://localhost:3000' } — opens the page in a browser of the tool's own, never yours, and reads what the screen says each control is and does. Start your dev server yourself first; the tool will not guess at a command that might build over what you have running.",
604
+ "staysfixed_check { surface: 'electron', at: '/path/to/YourApp.app' } — opens the built desktop app with its own scratch data folder and its own debugging port, so it can never fight your own copy of it over a lock, a data folder or a relay slot.",
605
+ "staysfixed_check { surface: 'android', at: '/path/to/app-release.apk' } — installs the APK on a virtual device of its own, walks it, and reads what each control on the screen is and does. A real handset cannot be compared against a second build and never will be; the emulator is the honest answer.",
606
+ "staysfixed_check { surface: 'ios', at: '/path/to/YourApp.app' } — boots the built app on a simulator of its own. Real iPhones are out of reach for the same reason.",
607
+ "staysfixed_check { surface: 'cli' } or { surface: 'server' } — the same engine on a command-line tool or an HTTP server.",
608
+ 'Aiming at something this copy has no adapter for, or something this project does not contain, is REFUSED by name. It never falls back to checking whatever else was lying around and reporting that as your answer.',
609
+ 'Leave both out and it uses the settings. If you aim it and the result does not confirm it went there, the reply says so at the top and nothing below it is about what you asked for.',
610
+ 'Both builds are opened one after the other, never at once. Two copies of one app fight over ports, single-instance locks and data folders, and a tool that caused that would be causing the bug it exists to catch.',
611
+ ];
612
+
613
+ /** The loop, said once, in the one place an agent reads it. */
614
+ const LOOP_STEPS = [
615
+ 'staysfixed_intent - seal what you meant to change, before you run anything.',
616
+ 'staysfixed_check - run it. You get back only what you did not account for.',
617
+ 'staysfixed_explain - the two or three findings you intend to act on, in depth.',
618
+ 'staysfixed_prove - undo the change you suspect and re-run, to test whether it really caused a finding.',
619
+ 'staysfixed_waive - record that a difference was intended. Four rules apply and a refusal is final.',
620
+ 'Fix what you caused, then call staysfixed_check again. Repeat until nothing unaccounted-for comes back.',
621
+ ];
622
+
623
+ /** What every reply looks like, so an agent can act on one without being taught. */
624
+ const RESULT_SHAPES = [
625
+ 'staysfixed_check: a headline, then a ranked list. Each finding has an id, one plain sentence, the addresses that moved, and a sample of one value before and after. Furthest from your edit sorts to the top, because that is what a side effect looks like.',
626
+ 'An address reads channel first: results, effects, complaints, meaning, contract, counters, pixels. That first word tells you what kind of thing moved.',
627
+ 'A finding marked SEALED touches money, signing in, losing data, a crash, or a named guard. You cannot waive it. Fix it, or tell a person.',
628
+ '"Newly unpredictable" is reported separately from the findings: those are addresses that were the same every run before your change and disagree with themselves now. Nothing looks broken, which is exactly why that class of bug survives for months. A run with any of these is not a pass.',
629
+ 'A run in stored-record mode says so in those words. It is genuinely weaker than a paired run and the reply never hides it.',
630
+ 'staysfixed_explain: one finding with every address and both values in full. Pass include: ["pixels"] only when a picture would settle it.',
631
+ 'staysfixed_prove: a verdict - the difference went away when you reverted, or it survived and your edit did not cause it.',
632
+ 'staysfixed_coverage: ways in never opened, surfaces out of reach, anything refused for being irreversible. A refusal is missing coverage, never a pass.',
633
+ ];
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // intent
637
+ // ---------------------------------------------------------------------------
638
+
639
+ /**
640
+ * @param {ToolContext} ctx
641
+ * @param {Record<string, any>} input
642
+ * @returns {Promise<ToolResult>}
643
+ */
644
+ async function toolIntent(ctx, input) {
645
+ const summary = text(input.summary);
646
+ const touches = stringList(input.touches) ?? [];
647
+ const expect = stringList(input.expect) ?? [];
648
+
649
+ if (!summary) return problem('Say what you meant to change, in one plain sentence: { "summary": "...", "touches": ["..."] }.');
650
+ if (touches.length === 0) {
651
+ return problem(
652
+ 'Name at least one file, folder or area you expect this change to affect. That is the whole point of sealing an intent: a difference outside what you named cannot later be waived, so an empty list would leave you able to waive nothing at all.'
653
+ );
654
+ }
655
+
656
+ const store = storeFor(ctx);
657
+ const product = await productFor(ctx.root);
658
+
659
+ // src/v2/intent.js does the sealing, and it does more than write a file down: it
660
+ // fingerprints the working tree at this moment, so whether the intent was written before
661
+ // the edits or after them stops being a promise and becomes something anybody can check.
662
+ const intent = await sealIntent(store, { product, summary, touches, expect, by: 'an agent, over MCP' });
663
+
664
+ // Sealing a new intent does NOT hand out a fresh five. The budget is counted
665
+ // against the reference, precisely so an agent that has spent its waivers
666
+ // cannot buy five more by re-declaring what it meant to do. Between one ship
667
+ // and the next, all of it is one change.
668
+ const spent = (await readDecisions(store, product)).spent;
669
+
670
+ return {
671
+ content: [
672
+ {
673
+ type: 'text',
674
+ text: [
675
+ `Sealed as ${intent.id} at ${intent.sealedAt}${intent.tree.head ? `, on commit ${intent.tree.head.slice(0, 7)}` : ''}.`,
676
+ `You said: ${summary}`,
677
+ `Expecting to affect: ${intent.files.join(', ')}.`,
678
+ expect.length ? `Expecting to see: ${expect.join('; ')}.` : '',
679
+ intent.ordering,
680
+ '',
681
+ `You may waive at most ${WAIVER_BUDGET} differences before a person has to look, and ${spent} of those are already spent since the last time a build shipped. Sealing another intent does not give you more, and you can only waive a difference that falls inside what you just named.`,
682
+ 'Now run staysfixed_check.',
683
+ ]
684
+ .filter(Boolean)
685
+ .join('\n'),
686
+ },
687
+ ],
688
+ };
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // check
693
+ // ---------------------------------------------------------------------------
694
+
695
+ /**
696
+ * @param {ToolContext} ctx
697
+ * @param {Record<string, any>} input
698
+ * @returns {Promise<ToolResult>}
699
+ */
700
+ async function toolCheck(ctx, input) {
701
+ const engine = await loadEngine();
702
+ const run = engine.parts.check;
703
+ if (!run) {
704
+ return engineMissing(engine, 'check', 'check({cwd, configFile, against, paired, journeys, only}) returning a CheckResult - the shape at the top of src/v2/cli.js.');
705
+ }
706
+
707
+ const store = storeFor(ctx);
708
+ const limit = positive(input.limit) ?? DEFAULT_LIMIT;
709
+ const offset = positive(input.offset) ?? 0;
710
+
711
+ const surface = text(input.surface);
712
+ const at = text(input.at);
713
+ const aimed = (surface !== null && surface !== 'auto') || at !== null;
714
+
715
+ // Paging really does page. An agent asking for the next ten findings is asking to read
716
+ // more of an answer it already has, and running the whole product again to give it to
717
+ // them would be minutes of work for a result that could also come back DIFFERENT - which
718
+ // is the one thing a page two must never be.
719
+ const product = await productFor(ctx.root);
720
+ const paging = offset > 0 ? await readCheckRecord(store) : null;
721
+
722
+ /** @type {CheckResult} */
723
+ const result = paging ? paging.result : await run({
724
+ cwd: ctx.root,
725
+ configFile: undefined,
726
+ against: text(input.against) ?? undefined,
727
+ paired: input.paired === true,
728
+ journeys: text(input.journeys) ?? undefined,
729
+ only: stringList(input.only) ?? [],
730
+ surface: surface && surface !== 'auto' ? surface : undefined,
731
+ at: at ?? undefined,
732
+ });
733
+
734
+ // A run that was AIMED at something has to confirm it went there. An engine
735
+ // that quietly ignores an unknown option would hand back a perfectly clean
736
+ // result about something else entirely, and the agent would read it as proof
737
+ // about the thing it named. So the confirmation is required, not assumed.
738
+ const missedTheTarget = aimed && !paging ? aimingNote(surface, at, /** @type {any} */ (result).target) : null;
739
+
740
+ // Waivers were applied by the engine, in src/v2/escalate.js, before this ever saw the
741
+ // verdict - and the record it wrote holds EVERY finding, waived ones included, so this
742
+ // surface can still explain and prove one. What is applied here is only the fallback for
743
+ // an engine too old to have done it, and it calls exactly the same function rather than
744
+ // keeping a second opinion about what a waiver means.
745
+ const record = paging ?? (await readCheckRecord(store));
746
+ const fresh = record && (paging !== null || record.result?.runId === result?.runId);
747
+
748
+ /** @type {Finding[]} */
749
+ const all = fresh && record ? record.findings : decide(Array.isArray(result?.findings) ? result.findings : [], await readDecisions(store, product)).all;
750
+ const accounting = fresh && record ? record.accounting : (/** @type {any} */ (result).accounted ?? null);
751
+
752
+ const waived = all.filter((f) => typeof (/** @type {any} */ (f).waivedBy) === 'string');
753
+ const unaccounted = all.filter((f) => typeof (/** @type {any} */ (f).waivedBy) !== 'string');
754
+ const expired = accounting?.expiredWaivers ?? 0;
755
+
756
+ // The engine reports a newly unpredictable address as a whole wobble entry. The
757
+ // agent only needs the address, so that is all that is carried forward - the values
758
+ // behind it are fetched with staysfixed_explain like everything else heavy.
759
+ const newlyUnstable = (Array.isArray(result?.newlyUnstable) ? result.newlyUnstable : []).map((e) =>
760
+ typeof e === 'string' ? e : e.path
761
+ );
762
+
763
+ const page = unaccounted.slice(offset, offset + limit);
764
+ const clean = unaccounted.length === 0 && newlyUnstable.length === 0 && result?.blocked !== true;
765
+
766
+ // What this run did not look at, in the engine's own words. It rides in every reply,
767
+ // clean ones included: a green result on a product with three hundred unopened doors is
768
+ // true, and it is not what it looks like, and an agent that reads only the headline is
769
+ // about to tell somebody their change is safe.
770
+ const notChecked = coverageSentence(engine, result);
771
+ const doors = result?.coverage?.doorsKnown ?? 0;
772
+ const unopened = Math.max(0, doors - (result?.coverage?.doorsWalked ?? 0));
773
+
774
+ // On a clean run, and only then, the machine's own statement of what a clean run here
775
+ // would MEAN. This is the sentence the design asks for in so many words: "this covers
776
+ // your website; your iPhone app is not being checked, and here is why". An agent about
777
+ // to tell somebody a change is safe needs it, and a run with findings in it does not —
778
+ // that agent has work to do and no reason to over-read anything.
779
+ const covers = clean ? await whatAGreenRunMeansHere(engine, ctx) : null;
780
+
781
+ if (input.format === 'json') {
782
+ const payload = {
783
+ ok: clean,
784
+ verdict: result?.blocked ? 'blocked' : clean ? 'nothing unaccounted for' : 'differences found',
785
+ mode: result?.mode ?? null,
786
+ note: result?.summary ?? null,
787
+ noiseRemoved: result?.differencesNoise ?? null,
788
+ newlyUnstable,
789
+ coverage: result?.coverage ?? null,
790
+ // Never only inside `coverage`. A field an agent has to know to go and look at is a
791
+ // field that gets skipped, and this is the one that must not be.
792
+ notChecked,
793
+ doorsNeverOpened: unopened,
794
+ covers,
795
+ unaccounted: unaccounted.length,
796
+ // Never a bare number. An agent has to be able to see that fifty things were waived
797
+ // rather than merely that nothing was reported, or "silent" and "switched off" look
798
+ // the same from here.
799
+ accountedFor: {
800
+ waived: waived.length,
801
+ expiredWaivers: expired,
802
+ unwaivable: unaccounted.filter((f) => classify(f) !== null).length,
803
+ budget: accounting?.budget ?? WAIVER_BUDGET,
804
+ waiversLeft: accounting?.left ?? null,
805
+ note: accounting?.note ?? null,
806
+ },
807
+ findings: page,
808
+ aimedAt: aimed ? { surface: surface ?? 'auto', at: at ?? null, confirmed: missedTheTarget === null } : null,
809
+ aimingWarning: missedTheTarget,
810
+ };
811
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], structuredContent: payload, isError: !clean };
812
+ }
813
+
814
+ const intent = (await readDecisions(store, product)).intent;
815
+ const body = renderCheck({
816
+ result,
817
+ unaccounted,
818
+ page,
819
+ offset,
820
+ limit,
821
+ waived: waived.length,
822
+ expired,
823
+ waiversLeft: accounting?.left ?? null,
824
+ newlyUnstable,
825
+ intent,
826
+ clean,
827
+ missedTheTarget,
828
+ notChecked,
829
+ covers,
830
+ });
831
+
832
+ // The handful of things a person has to rule on, written for the person rather than for
833
+ // the agent, and handed over as a block to be pasted whole. This is the entire delivery
834
+ // mechanism: he reads one closing summary at the end of a working stretch, so anything
835
+ // that is not inside it did not reach him. There is no report to open and no dashboard.
836
+ const escalations = await escalationsFor(store, result?.product ?? product);
837
+ const tail = escalations.items.length
838
+ ? [
839
+ '',
840
+ '',
841
+ 'PUT THIS IN YOUR CLOSING SUMMARY, WORD FOR WORD. It is written for the person you are working for, not for you, and it is the only part of this run they will ever see:',
842
+ '',
843
+ escalationBlock(escalations),
844
+ ].join('\n')
845
+ : '';
846
+
847
+ // A difference is reported as an error result on purpose. Protocol-wise the
848
+ // call succeeded, but `isError` is the flag every client puts in front of the
849
+ // agent, and an agent that skims past a real regression is exactly the failure
850
+ // this whole tool exists to prevent.
851
+ return { content: [{ type: 'text', text: body + tail }], isError: !clean };
852
+ }
853
+
854
+ /**
855
+ * @param {object} a
856
+ * @param {CheckResult} a.result
857
+ * @param {Finding[]} a.unaccounted
858
+ * @param {Finding[]} a.page
859
+ * @param {number} a.offset
860
+ * @param {number} a.limit
861
+ * @param {number} a.waived
862
+ * @param {number} a.expired
863
+ * @param {number|null} a.waiversLeft
864
+ * @param {string[]} a.newlyUnstable
865
+ * @param {Intent|null} a.intent
866
+ * @param {boolean} a.clean
867
+ * @param {string|null} a.missedTheTarget
868
+ * @param {string} a.notChecked
869
+ * @param {string|null} a.covers
870
+ * @returns {string}
871
+ */
872
+ function renderCheck({ result, unaccounted, page, offset, limit, waived, expired, waiversLeft, newlyUnstable, intent, clean, missedTheTarget, notChecked, covers }) {
873
+ /** @type {string[]} */
874
+ const out = [];
875
+
876
+ if (result?.blocked === true) {
877
+ out.push('BLOCKED - the check could not be completed, so this is not a pass and not a failure. It is no answer at all.');
878
+ if (result.summary) out.push(result.summary);
879
+ out.push('Fix what is in the way and run it again. staysfixed_capabilities says what this machine can and cannot do.');
880
+ return out.join('\n');
881
+ }
882
+
883
+ if (clean) {
884
+ out.push('NOTHING UNACCOUNTED FOR. Everything that worked before still works, as far as this run could see.');
885
+ } else if (unaccounted.length) {
886
+ const sealed = unaccounted.filter((f) => classify(f) !== null).length;
887
+ out.push(`${unaccounted.length} ${unaccounted.length === 1 ? 'DIFFERENCE' : 'DIFFERENCES'} YOU DID NOT ACCOUNT FOR${sealed ? `, ${sealed} of them sealed and not yours to waive` : ''}.`);
888
+ } else {
889
+ out.push('NOTHING CHANGED, BUT THIS IS NOT A CLEAN RUN - see the newly unpredictable addresses below.');
890
+ }
891
+
892
+ // The silence has to be legible. "Nothing changed" and "nothing ran" read the
893
+ // same otherwise, and one of those is a broken tool reporting success.
894
+ /** @type {string[]} */
895
+ const arithmetic = [];
896
+ if (result?.coverage) arithmetic.push(`${result.coverage.journeys} ${result.coverage.journeys === 1 ? 'way in was' : 'ways in were'} walked`);
897
+ if (typeof result?.differencesNoise === 'number' && result.differencesNoise > 0) arithmetic.push(`${result.differencesNoise} differences subtracted as this product's own wobble`);
898
+ if (waived) arithmetic.push(`${waived} already recorded as intended and not shown again`);
899
+ if (arithmetic.length) out.push(arithmetic.join(', ') + '.');
900
+
901
+ // Immediately under the headline, never at the bottom. On a clean run this is the only
902
+ // line that stops "nothing unaccounted for" being read as "your product is fine".
903
+ out.push(notChecked);
904
+ if (covers) out.push(covers);
905
+
906
+ if (missedTheTarget) out.push(missedTheTarget);
907
+
908
+ if (result?.mode === 'stored-record') {
909
+ out.push(
910
+ 'Compared against the STORED RECORD, not against the old build booted live. That is genuinely weaker: it lets back in every difference that comes from the machine and the day rather than from your change. Pass paired: true for the strong comparison.'
911
+ );
912
+ }
913
+ if (result?.summary) out.push(result.summary);
914
+
915
+ if (newlyUnstable.length) {
916
+ out.push('');
917
+ out.push(`NEWLY UNPREDICTABLE - ${newlyUnstable.length} ${newlyUnstable.length === 1 ? 'address was' : 'addresses were'} the same every run before your change and disagree with themselves now:`);
918
+ for (const p of newlyUnstable.slice(0, MAX_PATHS)) out.push(`- ${p}`);
919
+ if (newlyUnstable.length > MAX_PATHS) out.push(`- and ${newlyUnstable.length - MAX_PATHS} more.`);
920
+ out.push('Nothing here looks broken, which is exactly why this kind of bug survives for months. It cannot be waived: it is not a difference, it is a loss of determinism.');
921
+ }
922
+
923
+ if (page.length) {
924
+ out.push('');
925
+ out.push(offset > 0 ? `Findings ${offset + 1} to ${offset + page.length}:` : 'Worst first - furthest from your edit is at the top, because that is what a side effect looks like:');
926
+ for (const f of page) out.push(...renderFinding(f));
927
+ }
928
+
929
+ const shown = offset + page.length;
930
+ if (shown < unaccounted.length) {
931
+ out.push('');
932
+ out.push(`${unaccounted.length - shown} more not shown. Ask with staysfixed_check { "offset": ${shown}, "limit": ${limit} } - it pages through the last run and does not run anything again.`);
933
+ }
934
+
935
+ if (expired > 0) {
936
+ out.push('');
937
+ out.push(
938
+ `${expired} ${expired === 1 ? 'waiver has' : 'waivers have'} expired because the reference moved - a build shipped since ${expired === 1 ? 'it was' : 'they were'} written. ${expired === 1 ? 'It covers' : 'They cover'} nothing any more, and anything ${expired === 1 ? 'it' : 'they'} used to cover is either in the list above or is now simply how the product works.`
939
+ );
940
+ }
941
+
942
+ // Counted out loud on every run that has spent any of them. A waiver applied in silence
943
+ // is how a rubber stamp starts, and the whole point of the budget is that an agent can
944
+ // see itself running out.
945
+ if (waived > 0 || (typeof waiversLeft === 'number' && waiversLeft < WAIVER_BUDGET)) {
946
+ out.push('');
947
+ out.push(
948
+ `${waived} ${waived === 1 ? 'difference is' : 'differences are'} being held as intended by you, and ${typeof waiversLeft === 'number' ? `${waiversLeft} of your ${WAIVER_BUDGET} waivers ${waiversLeft === 1 ? 'is' : 'are'} left` : 'the budget could not be read'} before a person has to look. None of it is the new normal until a build ships.`
949
+ );
950
+ }
951
+
952
+ if (unaccounted.length) {
953
+ out.push('');
954
+ out.push('Everything above is trimmed hard. staysfixed_explain gives you one finding in full; staysfixed_prove tells you whether your edit really caused it. Ask for those on the two or three you intend to act on, not on all of them.');
955
+ if (!intent) out.push('You have not sealed an intent for this change, so nothing here can be waived. Call staysfixed_intent before the next run.');
956
+ }
957
+
958
+ return out.join('\n');
959
+ }
960
+
961
+ /**
962
+ * What a clean run on THIS machine actually means, in the machine survey's own words.
963
+ *
964
+ * A product with a website and an iPhone app, checked on a machine that can only open the
965
+ * website, produces a perfectly clean result that says nothing whatever about the phone.
966
+ * Nothing inside the run can know that — the run only knows what it walked. This is the
967
+ * other half, and it is the difference between "your change is safe" and "your change is
968
+ * safe as far as your website goes".
969
+ *
970
+ * @param {Engine} engine
971
+ * @param {ToolContext} ctx
972
+ * @returns {Promise<string|null>}
973
+ */
974
+ async function whatAGreenRunMeansHere(engine, ctx) {
975
+ const survey = engine.parts.capabilities;
976
+ if (typeof survey !== 'function') return null;
977
+ try {
978
+ // Offline: no other machine is dialled for this. A check is not the moment to spend
979
+ // eight seconds finding out whether somebody's server is awake.
980
+ const caps = await survey({ cwd: ctx.root, offline: true });
981
+ const said = caps?.covers?.short;
982
+ return typeof said === 'string' && said.trim() !== '' ? `WHAT A CLEAN RESULT HERE MEANS: ${said}` : null;
983
+ } catch {
984
+ return null;
985
+ }
986
+ }
987
+
988
+ /**
989
+ * What this run did not look at, in one sentence, guaranteed.
990
+ *
991
+ * The words come from the engine, so an agent and a person are never told two different
992
+ * things about the same run. The fallback exists because this sentence may never be
993
+ * absent: a reply that silently omits it on the one run where it mattered is exactly the
994
+ * failure the whole coverage ledger is built to prevent. There is deliberately no path
995
+ * through here that returns an empty string.
996
+ *
997
+ * @param {Engine} engine
998
+ * @param {CheckResult} result
999
+ * @returns {string}
1000
+ */
1001
+ function coverageSentence(engine, result) {
1002
+ const say = engine.parts.notChecked;
1003
+ if (typeof say === 'function') {
1004
+ try {
1005
+ const said = say(result?.coverage);
1006
+ if (typeof said === 'string' && said.trim() !== '') return said;
1007
+ } catch {
1008
+ // An engine that threw while describing its own coverage has told us the most
1009
+ // important thing it could: do not trust the silence. Fall through and say so.
1010
+ }
1011
+ }
1012
+
1013
+ const coverage = result?.coverage;
1014
+ if (!coverage) {
1015
+ return 'NOT EVERYTHING WAS CHECKED, and this run did not say how much — so how thorough it was is unknown. Treat a clean result with suspicion, and call staysfixed_coverage before you tell anybody a change is safe.';
1016
+ }
1017
+ const unopened = Math.max(0, (coverage.doorsKnown ?? 0) - (coverage.doorsWalked ?? 0));
1018
+ const gaps = (coverage.gaps ?? []).filter((g) => typeof g.doors !== 'number').length;
1019
+ if (unopened === 0 && gaps === 0) {
1020
+ return `Everything this run knows how to walk was walked — ${coverage.paths} addresses across ${coverage.journeys} journeys. That is not every possible state of your product; nothing can enumerate that.`;
1021
+ }
1022
+ return `NOT EVERYTHING WAS CHECKED: ${unopened} ways into this product have never been walked through, and ${gaps} other things were not looked at. A clean result only covers what was walked — staysfixed_coverage has the list.`;
1023
+ }
1024
+
1025
+ /**
1026
+ * Did the run go where it was aimed?
1027
+ *
1028
+ * An engine that does not understand `surface` or `at` will not fail — it will
1029
+ * ignore them and check whatever it was going to check anyway, and hand back a
1030
+ * clean result. That result is true, and it is about the wrong thing, and it is
1031
+ * the most dangerous shape a reply can have. So a run that was aimed must come
1032
+ * back saying where it went, and when it does not, the reply says so before it
1033
+ * says anything else.
1034
+ *
1035
+ * Exported only so a test can hold the exact refusal wording to account. It is
1036
+ * the sentence that stands between an agent and a clean result about the wrong
1037
+ * product, and a sentence that important should not be provable only by running
1038
+ * a whole check.
1039
+ *
1040
+ * @param {string|null} surface
1041
+ * @param {string|null} at
1042
+ * @param {unknown} confirmation `target` off the result: what the engine says it aimed at.
1043
+ * @returns {string|null} the warning, or null when the run went where it was told
1044
+ */
1045
+ export function aimingNote(surface, at, confirmation) {
1046
+ const said = /** @type {{surface?: string, at?: string}} */ (confirmation && typeof confirmation === 'object' ? confirmation : {});
1047
+ const wanted = surface && surface !== 'auto' ? surface : null;
1048
+ const okSurface = !wanted || said.surface === wanted;
1049
+ const okAt = !at || said.at === at;
1050
+ if (okSurface && okAt) return null;
1051
+
1052
+ const asked = [wanted ? `the ${wanted} target` : null, at ? `"${at}"` : null].filter(Boolean).join(' at ');
1053
+ return (
1054
+ `THIS RUN DID NOT CONFIRM IT WENT WHERE YOU AIMED IT. You asked for ${asked}, and the result does not say it went there` +
1055
+ `${said.surface || said.at ? ` — it says it checked ${said.surface ?? 'something else'}${said.at ? ` at "${said.at}"` : ''}` : ' — it says nothing about a target at all'}. ` +
1056
+ 'Treat everything below as saying NOTHING about what you aimed at. Call staysfixed_capabilities to see whether that kind of product can be checked on this machine at all.'
1057
+ );
1058
+ }
1059
+
1060
+ /**
1061
+ * One finding, in as few lines as carry the meaning.
1062
+ * @param {Finding} f
1063
+ * @returns {string[]}
1064
+ */
1065
+ function renderFinding(f) {
1066
+ /** @type {string[]} */
1067
+ const out = [];
1068
+ const flags = [];
1069
+ const sealed = classify(f);
1070
+ if (sealed) flags.push(`SEALED: ${sealed.class}`);
1071
+ if (typeof f.count === 'number' && f.count > 1) flags.push(`${f.count} addresses`);
1072
+ out.push(`- [${f.id}] ${trim(f.title, 200)}${flags.length ? ` (${flags.join(', ')})` : ''}`);
1073
+
1074
+ // Only list the addresses when they add something. A single address the
1075
+ // sentence already names is a line of pure repetition, and the agent pays for
1076
+ // every one of those.
1077
+ const paths = f.paths ?? [];
1078
+ const worthListing = paths.filter((p) => !f.title.includes(p));
1079
+ if (worthListing.length) out.push(` ${worthListing.slice(0, 3).join(', ')}${worthListing.length > 3 ? `, and ${worthListing.length - 3} more` : ''}`);
1080
+ if (f.sample) out.push(` ${f.sample.path}: was ${valueOf(f.sample.reference)}, now ${valueOf(f.sample.candidate)}`);
1081
+ if (sealed) out.push(` You cannot waive this: ${sealed.says}. Fix it, or tell a person. ${sealed.strength === 'likely' ? 'Read it before you assume it is a false alarm - and if it is one, that is a person\'s call, not yours.' : ''}`.trimEnd());
1082
+ return out;
1083
+ }
1084
+
1085
+ // ---------------------------------------------------------------------------
1086
+ // explain
1087
+ // ---------------------------------------------------------------------------
1088
+
1089
+ /**
1090
+ * @param {ToolContext} ctx
1091
+ * @param {Record<string, any>} input
1092
+ * @returns {Promise<ToolResult>}
1093
+ */
1094
+ async function toolExplain(ctx, input) {
1095
+ const id = text(input.finding);
1096
+ if (!id) return problem('Say which finding to explain, e.g. { "finding": "f-a1b2c3" }. The ids come from staysfixed_check.');
1097
+
1098
+ const last = await readCheckRecord(storeFor(ctx));
1099
+ if (!last) return problem('No check has run in this copy yet, so there is nothing to explain. Run staysfixed_check first.');
1100
+ const f = last.findings.find((x) => x.id === id);
1101
+ if (!f) {
1102
+ const ids = last.findings.slice(0, 12).map((x) => x.id);
1103
+ return problem(`The last check has no finding called "${id}".${ids.length ? ` It found: ${ids.join(', ')}.` : ' It found nothing at all.'}`);
1104
+ }
1105
+
1106
+ const include = stringList(input.include) ?? ['values', 'paths'];
1107
+ const engine = await loadEngine();
1108
+
1109
+ /** @type {any} */
1110
+ let deep = null;
1111
+ if (engine.parts.explain) {
1112
+ try {
1113
+ deep = await engine.parts.explain({ cwd: ctx.root, finding: id, include });
1114
+ } catch (e) {
1115
+ deep = { error: messageOf(e) };
1116
+ }
1117
+ }
1118
+
1119
+ /** @type {string[]} */
1120
+ const out = [];
1121
+ const sealed = classify(f);
1122
+ out.push(f.title + (sealed ? ` (SEALED: ${sealed} - not yours to waive)` : ''));
1123
+ if (typeof f.distance === 'number') {
1124
+ out.push(f.distance === 0 ? 'This sits inside the code you changed.' : `This sits ${f.distance} away from the code you changed, which is why it is ranked where it is. The further away, the more it looks like a side effect.`);
1125
+ }
1126
+
1127
+ const paths = f.paths ?? [];
1128
+ if (include.includes('paths') && paths.length) {
1129
+ out.push('');
1130
+ out.push(`EVERY ADDRESS THAT MOVED (${paths.length})`);
1131
+ for (const p of paths.slice(0, 60)) out.push(` ${p}`);
1132
+ if (paths.length > 60) out.push(` and ${paths.length - 60} more.`);
1133
+ }
1134
+
1135
+ if (include.includes('values')) {
1136
+ out.push('');
1137
+ if (f.sample) {
1138
+ out.push(`BEFORE - ${f.sample.path}`);
1139
+ out.push(indent(stringy(f.sample.reference)));
1140
+ out.push(`AFTER - ${f.sample.path}`);
1141
+ out.push(indent(stringy(f.sample.candidate)));
1142
+ if (paths.length > 1) out.push(`That is one of ${paths.length} addresses in this finding. The engine keeps one sample per finding rather than every value, which is what stops a broken stylesheet costing you a whole context window.`);
1143
+ } else {
1144
+ out.push('The engine kept no sample value for this finding, so there is nothing to show side by side.');
1145
+ }
1146
+ }
1147
+
1148
+ /** @type {ContentItem[]} */
1149
+ const content = [];
1150
+
1151
+ if (include.includes('evidence')) {
1152
+ out.push('');
1153
+ if (f.evidence) {
1154
+ out.push(`EVIDENCE: ${f.evidence}`);
1155
+ const body = await readText(path.resolve(ctx.root, f.evidence));
1156
+ if (body !== null) out.push(indent(trimLines(body, 40)));
1157
+ } else {
1158
+ out.push('No evidence file was kept for this finding.');
1159
+ }
1160
+ } else if (f.evidence) {
1161
+ out.push('');
1162
+ out.push('Evidence was kept and not sent. Ask with include: ["evidence"].');
1163
+ }
1164
+
1165
+ if (deep?.error) {
1166
+ out.push('');
1167
+ out.push(`The engine could not go deeper on this one: ${deep.error}`);
1168
+ } else if (deep && typeof deep.text === 'string') {
1169
+ out.push('');
1170
+ out.push(deep.text);
1171
+ }
1172
+
1173
+ content.push({ type: 'text', text: out.join('\n') });
1174
+
1175
+ // Pixels are channel seven and expensive. They ride along only when asked for
1176
+ // by name, never because a finding happened to have a picture attached.
1177
+ const pictures = picturesFrom(f, deep);
1178
+ if (include.includes('pixels')) {
1179
+ for (const p of pictures.slice(0, MAX_IMAGES)) {
1180
+ const png = await readMaybe(path.resolve(ctx.root, p));
1181
+ if (!png) continue;
1182
+ content.push({ type: 'image', data: png.toString('base64'), mimeType: 'image/png' });
1183
+ }
1184
+ } else if (pictures.length) {
1185
+ content.push({ type: 'text', text: `${pictures.length} picture(s) were kept as evidence and not sent. Ask with include: ["pixels"] if a picture would settle it.` });
1186
+ }
1187
+
1188
+ return { content };
1189
+ }
1190
+
1191
+ /**
1192
+ * @param {Finding} f
1193
+ * @param {any} deep
1194
+ * @returns {string[]}
1195
+ */
1196
+ function picturesFrom(f, deep) {
1197
+ /** @type {string[]} */
1198
+ const out = [];
1199
+ if (typeof f.evidence === 'string' && /\.png$/i.test(f.evidence)) out.push(f.evidence);
1200
+ if (Array.isArray(deep?.pictures)) for (const p of deep.pictures) if (typeof p === 'string') out.push(p);
1201
+ return out;
1202
+ }
1203
+
1204
+ // ---------------------------------------------------------------------------
1205
+ // prove
1206
+ // ---------------------------------------------------------------------------
1207
+
1208
+ /**
1209
+ * @param {ToolContext} ctx
1210
+ * @param {Record<string, any>} input
1211
+ * @returns {Promise<ToolResult>}
1212
+ */
1213
+ async function toolProve(ctx, input) {
1214
+ const engine = await loadEngine();
1215
+ const run = engine.parts.prove;
1216
+ if (!run) {
1217
+ return engineMissing(
1218
+ engine,
1219
+ 'prove',
1220
+ 'prove({cwd, configFile, finding, revert}) returning {gone: boolean, detail?: string}. src/v2/cause.js already has proveCause(), but it takes an engine-internal finding and a loaded project, which this surface does not have - a small facade in src/v2/check.js is all that is needed.'
1221
+ );
1222
+ }
1223
+
1224
+ const id = text(input.finding);
1225
+ const revert = stringList(input.revert);
1226
+ if (!id) return problem('Say which finding you are trying to explain, e.g. { "finding": "f-a1b2c3", "revert": ["src/total.js"] }.');
1227
+ if (!revert) return problem('Name what to put back to the reference for one run, e.g. { "revert": ["src/checkout/total.js"] }. Without that there is no claim to test.');
1228
+
1229
+ const last = await readCheckRecord(storeFor(ctx));
1230
+ const f = last?.findings.find((x) => x.id === id);
1231
+ if (!f) return problem(`The last check has no finding called "${id}". Run staysfixed_check first, then prove one of the ids it gives you.`);
1232
+
1233
+ /** @type {any} */
1234
+ const result = (await run({ cwd: ctx.root, finding: id, revert })) ?? {};
1235
+ const gone = result.gone === true;
1236
+
1237
+ /** @type {string[]} */
1238
+ const out = [];
1239
+ if (gone) {
1240
+ out.push(`PROVEN: your change caused it. With ${revert.join(', ')} put back, this matched the reference again.`);
1241
+ out.push(` ${trim(f.title, 200)}`);
1242
+ out.push('So it is yours to fix - or to record as intended, if that is genuinely what you meant and it is not sealed.');
1243
+ } else {
1244
+ out.push(`NOT PROVEN: it survived the revert. With ${revert.join(', ')} put back, this was still different.`);
1245
+ out.push(` ${trim(f.title, 200)}`);
1246
+ out.push('Your edit did not cause this, so fixing that file will not help. Something else did, or it was already broken before you started.');
1247
+ }
1248
+ if (result.detail) out.push('', trim(String(result.detail), 600));
1249
+ out.push('', 'The working tree has been put back exactly as it was.');
1250
+
1251
+ return { content: [{ type: 'text', text: out.join('\n') }] };
1252
+ }
1253
+
1254
+ // ---------------------------------------------------------------------------
1255
+ // waive - the four gates
1256
+ // ---------------------------------------------------------------------------
1257
+
1258
+ /**
1259
+ * The only door an agent has, and it is a narrow one.
1260
+ *
1261
+ * A refusal here is the tool working, not the tool being difficult. The failure
1262
+ * it guards against is specific: an agent under pressure to finish declares the
1263
+ * real regression intended, and the reason it writes reads perfectly plausible.
1264
+ * Every gate below exists to make that particular sentence impossible to write
1265
+ * rather than merely discouraged.
1266
+ *
1267
+ * @param {ToolContext} ctx
1268
+ * @param {Record<string, any>} input
1269
+ * @returns {Promise<ToolResult>}
1270
+ */
1271
+ async function toolWaive(ctx, input) {
1272
+ const id = text(input.finding);
1273
+ const because = text(input.because);
1274
+ if (!id) return problem('Say which finding, e.g. { "finding": "f-a1b2c3", "because": "..." }.');
1275
+ if (!because) return problem('Say why this difference is what you meant, in one plain sentence. A waiver with no reason is worth nothing to whoever reads it later.');
1276
+
1277
+ const store = storeFor(ctx);
1278
+ const last = await readCheckRecord(store);
1279
+ if (!last) return problem('No check has run in this copy yet, so there is no difference to waive. Run staysfixed_check first.');
1280
+ const f = last.findings.find((x) => x.id === id);
1281
+ if (!f) return problem(`The last check has no finding called "${id}". You can only waive something this tool actually reported.`);
1282
+
1283
+ // Every gate lives in src/v2/waiver.js, and it runs all four itself rather than trusting
1284
+ // this file to have run them first. A safety property that depends on being CALLED
1285
+ // correctly is not a safety property, and this surface used to hold its own slightly
1286
+ // different copy of all four — which is how a gate quietly stops meaning anything.
1287
+ //
1288
+ // The check stamp is what makes gate two real: an intent sealed after this check ran
1289
+ // cannot be used to justify anything in it, and only the record knows when it ran.
1290
+ const decision = await waive(store, {
1291
+ product: last.product ?? (await productFor(ctx.root)),
1292
+ finding: f,
1293
+ why: because,
1294
+ check: { at: last.at, runId: last.result?.runId },
1295
+ by: 'an agent, over MCP',
1296
+ });
1297
+
1298
+ // A refusal is the tool working, not the tool being difficult, and the wording is the
1299
+ // feature: an agent told "refused" tries again in different words, an agent told which
1300
+ // gate stopped it and what it could legitimately do instead goes and does that.
1301
+ if (!decision.ok) return problem(decision.say);
1302
+
1303
+ return { content: [{ type: 'text', text: decision.say }] };
1304
+ }
1305
+
1306
+ /**
1307
+ * Whether a finding falls inside what the agent declared is decided by `intentCovers` in
1308
+ * src/v2/intent.js, and it used to be decided here as well, by a second and slightly
1309
+ * different set of rules. Two answers to one question is how a gate quietly stops meaning
1310
+ * anything: the strict one refuses, somebody notices, and the loose one becomes the one
1311
+ * that gets called. There is one now, and it grades its own confidence rather than
1312
+ * returning a bare yes.
1313
+ */
1314
+
1315
+ // ---------------------------------------------------------------------------
1316
+ // coverage
1317
+ // ---------------------------------------------------------------------------
1318
+
1319
+ /**
1320
+ * What was not checked.
1321
+ *
1322
+ * Assembled from two places on purpose. The last run knows which ways in it
1323
+ * never opened; the machine survey knows which surfaces cannot be reached from
1324
+ * here at all. A report missing either half would let somebody read "everything
1325
+ * walked" and believe the product was covered when the phone was never touched.
1326
+ *
1327
+ * @param {ToolContext} ctx
1328
+ * @param {Record<string, any>} input
1329
+ * @returns {Promise<ToolResult>}
1330
+ */
1331
+ async function toolCoverage(ctx, input) {
1332
+ const engine = await loadEngine();
1333
+ const last = await readCheckRecord(storeFor(ctx));
1334
+
1335
+ /** @type {any} */
1336
+ let caps = null;
1337
+ if (engine.parts.capabilities) {
1338
+ try {
1339
+ caps = await engine.parts.capabilities({ cwd: ctx.root, offline: true });
1340
+ } catch {
1341
+ // The machine survey is one half of the answer, not the whole of it. Losing
1342
+ // it must not lose the half that came from the run.
1343
+ caps = null;
1344
+ }
1345
+ }
1346
+
1347
+ const coverage = last?.result?.coverage ?? null;
1348
+ const unreachable = (caps?.surfaces ?? []).filter((/** @type {any} */ s) => s.status === 'unavailable');
1349
+ const partial = (caps?.surfaces ?? []).filter((/** @type {any} */ s) => s.status === 'partial');
1350
+
1351
+ if (input.format === 'json') {
1352
+ const payload = {
1353
+ lastCheckAt: last?.at ?? null,
1354
+ covers: caps?.covers ?? null,
1355
+ walked: coverage?.journeys ?? null,
1356
+ doorsKnown: coverage?.doorsKnown ?? null,
1357
+ doorsWalked: coverage?.doorsWalked ?? null,
1358
+ doorsNeverOpened: coverage ? Math.max(0, (coverage.doorsKnown ?? 0) - (coverage.doorsWalked ?? 0)) : null,
1359
+ // What this COPY of the tool can drive, which is not the same question as what this
1360
+ // machine could run. A Mac with Xcode on it can run an iPhone app; that says nothing
1361
+ // about whether there is an adapter here that knows how to open one.
1362
+ cannotBeDriven: (caps?.drivers ?? []).filter((/** @type {any} */ d) => !d.present).map((/** @type {any} */ d) => ({ surface: d.surface, why: d.why })),
1363
+ unopened: (coverage?.gaps ?? []).filter((/** @type {{doors?: number}} */ g) => typeof g.doors !== 'number').map((/** @type {{what: string}} */ g) => g.what),
1364
+ surfacesOutOfReach: unreachable.map((/** @type {any} */ s) => ({ name: s.name, why: s.summary, needs: s.needs })),
1365
+ surfacesPartial: partial.map((/** @type {any} */ s) => ({ name: s.name, why: s.summary })),
1366
+ neverVisible: caps?.limits ?? null,
1367
+ };
1368
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }], structuredContent: payload };
1369
+ }
1370
+
1371
+ /** @type {string[]} */
1372
+ const out = [];
1373
+ out.push('WHAT WAS NOT CHECKED');
1374
+ out.push('');
1375
+ // The machine's honest statement goes first, because it is the half that
1376
+ // survives even when no check has ever run here.
1377
+ if (caps?.covers?.short) {
1378
+ out.push(caps.covers.short);
1379
+ out.push('');
1380
+ }
1381
+
1382
+ if (!last) {
1383
+ out.push('No check has run in this copy yet, so nothing at all has been covered.');
1384
+ } else if (!coverage) {
1385
+ out.push('The last run did not report what it covered, so how deep it went is unknown. That is itself the answer: treat its clean result with suspicion.');
1386
+ } else {
1387
+ // A gap IS something that was not looked at, said in plain English by whoever could
1388
+ // not look at it. Printing the sentence keeps the reason attached to the hole. The
1389
+ // doors gap is left out here because it is already counted, in its own words, just
1390
+ // above — and a number a reader can catch out twice is a number they stop believing.
1391
+ const unopened = (coverage.gaps ?? [])
1392
+ .filter((/** @type {{doors?: number}} */ g) => typeof g.doors !== 'number')
1393
+ .map((/** @type {{what: string}} */ g) => g.what);
1394
+ out.push(`The last run walked ${coverage.journeys} ${coverage.journeys === 1 ? 'way in' : 'ways in'}.`);
1395
+ const doorsKnown = coverage.doorsKnown ?? 0;
1396
+ const never = Math.max(0, doorsKnown - (coverage.doorsWalked ?? 0));
1397
+ if (never > 0) {
1398
+ out.push(
1399
+ doorsKnown === 1
1400
+ ? 'The one door the code opens — a route, an exported function, an IPC channel — has never been walked through by any journey. It is known to EXIST and is not known to WORK, and no check has ever said anything about it.'
1401
+ : `${never} of the ${doorsKnown} doors the code opens — routes, exported functions, IPC channels — ${never === 1 ? 'has' : 'have'} never been walked through by any journey. They are known to EXIST and are not known to WORK, and no check has ever said anything about them.`
1402
+ );
1403
+ }
1404
+ if (unopened.length === 0 && never === 0) {
1405
+ out.push('It opened every way in that it knows about. That is not the same as every possible state - nothing can enumerate that - but there is no known door it has never been through.');
1406
+ } else if (unopened.length > 0) {
1407
+ out.push(`${unopened.length} other ${unopened.length === 1 ? 'thing was' : 'things were'} not looked at, so nothing in any check says anything about ${unopened.length === 1 ? 'it' : 'them'}:`);
1408
+ for (const d of unopened.slice(0, 30)) out.push(`- ${trim(String(d), 160)}`);
1409
+ if (unopened.length > 30) out.push(`- and ${unopened.length - 30} more.`);
1410
+ }
1411
+ }
1412
+
1413
+ const noAdapter = (caps?.drivers ?? []).filter((/** @type {any} */ d) => !d.present);
1414
+ if (noAdapter.length) {
1415
+ out.push('');
1416
+ out.push('This copy of Stays Fixed has no adapter for these, so nothing aimed at them would be walked at all:');
1417
+ for (const d of noAdapter) out.push(`- ${d.surface}: ${d.why}`);
1418
+ }
1419
+
1420
+ if (unreachable.length) {
1421
+ out.push('');
1422
+ out.push('Cannot be reached from this machine at all, so nothing there has been checked by anything:');
1423
+ for (const s of unreachable) {
1424
+ out.push(`- ${s.name}: ${s.summary}`);
1425
+ for (const need of s.needs ?? []) out.push(` it would take: ${need.fix ?? need.what}`);
1426
+ }
1427
+ }
1428
+ if (partial.length) {
1429
+ out.push('');
1430
+ out.push(`Reachable, but not everything on them can be watched: ${partial.map((/** @type {any} */ s) => s.name).join(', ')}. staysfixed_capabilities says what each limit is.`);
1431
+ }
1432
+
1433
+ if (Array.isArray(caps?.limits) && caps.limits.length) {
1434
+ out.push('');
1435
+ out.push('And these it will never see, on any machine, by design:');
1436
+ for (const limit of caps.limits) out.push(`- ${limit}`);
1437
+ }
1438
+
1439
+ out.push('');
1440
+ out.push('What this tool can honestly claim: it catches breaks reachable from the journeys it has. Everything above is the hole, kept visible on purpose rather than pretended away.');
1441
+
1442
+ return { content: [{ type: 'text', text: out.join('\n') }] };
1443
+ }
1444
+
1445
+ // ---------------------------------------------------------------------------
1446
+ // Small helpers
1447
+ // ---------------------------------------------------------------------------
1448
+
1449
+ /**
1450
+ * @param {unknown} e
1451
+ * @returns {string}
1452
+ */
1453
+ function explainError(e) {
1454
+ const message = messageOf(e);
1455
+ if (isExpected(e)) {
1456
+ const hint = /** @type {{hint?: string}} */ (e).hint;
1457
+ return hint ? `${message}\n${hint}` : message;
1458
+ }
1459
+ return `Stays Fixed could not finish that: ${message}`;
1460
+ }
1461
+
1462
+ /**
1463
+ * @param {string} message
1464
+ * @returns {ToolResult}
1465
+ */
1466
+ function problem(message) {
1467
+ return { content: [{ type: 'text', text: message }], isError: true };
1468
+ }
1469
+
1470
+ /**
1471
+ * @param {unknown} v
1472
+ * @returns {string|null}
1473
+ */
1474
+ function text(v) {
1475
+ if (typeof v !== 'string') return null;
1476
+ const s = v.trim();
1477
+ return s === '' ? null : s;
1478
+ }
1479
+
1480
+ /**
1481
+ * @param {unknown} v
1482
+ * @returns {string[]|undefined}
1483
+ */
1484
+ function stringList(v) {
1485
+ if (!Array.isArray(v)) return undefined;
1486
+ const out = v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => String(x).trim());
1487
+ return out.length ? out : undefined;
1488
+ }
1489
+
1490
+ /**
1491
+ * @param {unknown} v
1492
+ * @returns {number|null}
1493
+ */
1494
+ function positive(v) {
1495
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : null;
1496
+ }
1497
+
1498
+ /**
1499
+ * @param {unknown} v
1500
+ * @returns {string}
1501
+ */
1502
+ function stringy(v) {
1503
+ if (v === null || v === undefined) return '(nothing)';
1504
+ if (typeof v === 'string') return v;
1505
+ try {
1506
+ return JSON.stringify(v);
1507
+ } catch {
1508
+ return String(v);
1509
+ }
1510
+ }
1511
+
1512
+ /**
1513
+ * @param {unknown} v
1514
+ * @returns {string}
1515
+ */
1516
+ function valueOf(v) {
1517
+ const one = stringy(v).replace(/\s+/g, ' ').trim();
1518
+ if (one === '(nothing)') return one;
1519
+ return one.length > 90 ? `"${one.slice(0, 89)}..."` : `"${one}"`;
1520
+ }
1521
+
1522
+ /**
1523
+ * @param {string} s
1524
+ * @param {number} max
1525
+ */
1526
+ function trim(s, max) {
1527
+ const one = String(s).replace(/\s+/g, ' ').trim();
1528
+ return one.length > max ? one.slice(0, max - 1) + '...' : one;
1529
+ }
1530
+
1531
+ /**
1532
+ * @param {string} s
1533
+ * @param {number} lines
1534
+ */
1535
+ function trimLines(s, lines) {
1536
+ const all = String(s).split('\n');
1537
+ return all.length <= lines ? s : all.slice(0, lines).join('\n') + `\n... and ${all.length - lines} more lines.`;
1538
+ }
1539
+
1540
+ /** @param {unknown} s */
1541
+ function indent(s) {
1542
+ return String(s)
1543
+ .split('\n')
1544
+ .slice(0, 40)
1545
+ .map((line) => ` ${line}`)
1546
+ .join('\n');
1547
+ }
1548
+
1549
+ /**
1550
+ * @param {string} file
1551
+ * @returns {Promise<Buffer|null>}
1552
+ */
1553
+ async function readMaybe(file) {
1554
+ try {
1555
+ return await fsp.readFile(file);
1556
+ } catch {
1557
+ return null;
1558
+ }
1559
+ }
1560
+
1561
+ /**
1562
+ * @param {string} file
1563
+ * @returns {Promise<string|null>}
1564
+ */
1565
+ async function readText(file) {
1566
+ try {
1567
+ return await fsp.readFile(file, 'utf8');
1568
+ } catch {
1569
+ return null;
1570
+ }
1571
+ }