staysfixed 0.3.1 → 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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
|
@@ -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
|
+
}
|