staysfixed 0.1.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/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/staysfixed.js +18 -0
- package/examples/guards/the-sidebar-still-collapses.js +91 -0
- package/examples/staysfixed.config.electron.js +172 -0
- package/examples/staysfixed.config.web.js +277 -0
- package/package.json +61 -0
- package/src/cli/approve.js +126 -0
- package/src/cli/check.js +73 -0
- package/src/cli/doctor.js +379 -0
- package/src/cli/flake.js +61 -0
- package/src/cli/index.js +519 -0
- package/src/cli/init.js +564 -0
- package/src/cli/mark.js +69 -0
- package/src/cli/status.js +19 -0
- package/src/cli/trace.js +73 -0
- package/src/cli/walk.js +57 -0
- package/src/core/config.js +226 -0
- package/src/core/errors.js +48 -0
- package/src/core/git.js +90 -0
- package/src/core/hash.js +32 -0
- package/src/core/history.js +173 -0
- package/src/core/log.js +144 -0
- package/src/core/paths.js +135 -0
- package/src/drive/browser.js +540 -0
- package/src/drive/cdp.js +382 -0
- package/src/drive/electron.js +326 -0
- package/src/drive/find.js +331 -0
- package/src/drive/launch.js +263 -0
- package/src/drive/page.js +1042 -0
- package/src/freeze/clock.js +213 -0
- package/src/freeze/fonts.js +243 -0
- package/src/freeze/index.js +234 -0
- package/src/freeze/mask.js +187 -0
- package/src/freeze/motion.js +206 -0
- package/src/freeze/network.js +455 -0
- package/src/freeze/random.js +87 -0
- package/src/freeze/settle.js +178 -0
- package/src/guard/api.js +197 -0
- package/src/guard/load.js +324 -0
- package/src/guard/name.js +327 -0
- package/src/guard/run.js +224 -0
- package/src/index.js +61 -0
- package/src/marker/mark.js +260 -0
- package/src/marker/trace.js +293 -0
- package/src/mcp/server.js +377 -0
- package/src/mcp/tools.js +978 -0
- package/src/picture/capture.js +276 -0
- package/src/picture/compare.js +103 -0
- package/src/picture/run.js +284 -0
- package/src/picture/store.js +208 -0
- package/src/report/console.js +540 -0
- package/src/report/html.js +579 -0
- package/src/run.js +614 -0
- package/src/types.js +471 -0
- package/src/walk/run.js +541 -0
package/src/mcp/tools.js
ADDED
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Stays Fixed 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 edited some files and needs to know, in as few tokens as possible,
|
|
6
|
+
* whether it broke something that used to work. So the text output leads with the
|
|
7
|
+
* verdict and then says only what is NOT passing. A wall of green lines costs the
|
|
8
|
+
* agent money and tells it nothing.
|
|
9
|
+
*
|
|
10
|
+
* The one rule that shapes this whole file: an agent must not approve its own
|
|
11
|
+
* pictures. `staysfixed_approve` is not merely refused when the project has not
|
|
12
|
+
* opted in — it is not offered at all, so the agent never sees a door to push on.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fsp from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { runCheck, captureOne } from '../run.js';
|
|
19
|
+
import { listApproved, approveFromResult, approvedHashes } from '../picture/store.js';
|
|
20
|
+
import { loadGuards } from '../guard/load.js';
|
|
21
|
+
import { loadHistory, condemned } from '../core/history.js';
|
|
22
|
+
import { safeName, approvedPicture, resultPicture } from '../core/paths.js';
|
|
23
|
+
import { sha256File } from '../core/hash.js';
|
|
24
|
+
import { gitInfo, commitsBetween, filesBetween, commitExists } from '../core/git.js';
|
|
25
|
+
import { platformTag } from '../drive/find.js';
|
|
26
|
+
import { isExpected, messageOf } from '../core/errors.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What every tool call is handed. `reload` re-reads the config from disk, so an
|
|
30
|
+
* agent that just edited staysfixed.config.js sees its own edit on the next call
|
|
31
|
+
* instead of being told a stale story by a server that started an hour ago.
|
|
32
|
+
* @typedef {object} ToolContext
|
|
33
|
+
* @property {import('../types.js').Project} project
|
|
34
|
+
* @property {() => Promise<import('../types.js').Project>} reload
|
|
35
|
+
* @property {string} version
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** @typedef {{type: 'text', text: string}|{type: 'image', data: string, mimeType: string}} ContentItem */
|
|
39
|
+
/** @typedef {{content: ContentItem[], isError?: boolean}} ToolResult */
|
|
40
|
+
|
|
41
|
+
/** How many diff pictures we are willing to push into an agent's context at once. */
|
|
42
|
+
const MAX_IMAGES = 4;
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// The list
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The `tools/list` payload, shaped by what this project has opted into.
|
|
50
|
+
*
|
|
51
|
+
* @param {import('../types.js').ResolvedConfig} config
|
|
52
|
+
* @returns {{name: string, description: string, inputSchema: Record<string, any>}[]}
|
|
53
|
+
*/
|
|
54
|
+
export function toolDefinitions(config) {
|
|
55
|
+
const allowApprove = config.mcp?.allowApprove === true;
|
|
56
|
+
const allowMark = config.mcp?.allowMark === true;
|
|
57
|
+
|
|
58
|
+
const approvalNote = allowApprove
|
|
59
|
+
? ' This project has opted in to letting you approve a new picture yourself. Do it only when you are certain the new look is what the person asked for, and say why.'
|
|
60
|
+
: ' You cannot approve a new picture — only a person can, by running `staysfixed approve <screen>` in their terminal. That is deliberate. If a picture changed on purpose, say so and let them approve it; if it changed by accident, fix your code and check again.';
|
|
61
|
+
|
|
62
|
+
/** @type {{name: string, description: string, inputSchema: Record<string, any>}[]} */
|
|
63
|
+
const tools = [
|
|
64
|
+
{
|
|
65
|
+
name: 'staysfixed_check',
|
|
66
|
+
description:
|
|
67
|
+
'Prove that what already worked still works. Call this after you finish editing and BEFORE you tell anyone you are done. It opens the real app, photographs every screen this project watches, compares each against the approved picture, and runs every guard (one check per bug that was already fixed once). You get a short verdict, a line for anything that is not passing, and the diff image of each changed screen so you can see what moved.' +
|
|
68
|
+
approvalNote,
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
only: {
|
|
73
|
+
type: 'array',
|
|
74
|
+
items: { type: 'string' },
|
|
75
|
+
description: 'Check only these screens and guards, by name. Leave it out to check everything.',
|
|
76
|
+
},
|
|
77
|
+
guardsOnly: {
|
|
78
|
+
type: 'boolean',
|
|
79
|
+
description: 'Skip the pictures and run only the guards. Much faster; use it when your edit could not change how anything looks.',
|
|
80
|
+
},
|
|
81
|
+
picturesOnly: {
|
|
82
|
+
type: 'boolean',
|
|
83
|
+
description: 'Skip the guards and only compare pictures.',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'staysfixed_capture',
|
|
91
|
+
description:
|
|
92
|
+
'Photograph one screen of the real app right now and hand you the picture, without comparing it to anything. Use it to see what your change actually looks like, or to look at a screen before you touch it. It approves nothing and changes nothing.',
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
screen: { type: 'string', description: 'The screen name. Call staysfixed_screens if you do not know it.' },
|
|
97
|
+
},
|
|
98
|
+
required: ['screen'],
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'staysfixed_screens',
|
|
104
|
+
description:
|
|
105
|
+
'List the screens and the guards this project watches, each with its plain-language description. Cheap — it does not open the app. Call this first, so you know what is protected before you change anything.',
|
|
106
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'staysfixed_status',
|
|
110
|
+
description:
|
|
111
|
+
'A quick read on the project: how many approved pictures and guards exist, the known-good markers, how the last check went, and any check that has been condemned for flaking. Does not open the app.',
|
|
112
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'staysfixed_trace',
|
|
116
|
+
description:
|
|
117
|
+
'Find out which change broke a screen. Compares how the screen looks now against the known-good markers and reports the last marker where it still looked right, the first one where it did not, and the commits in between. Use it when staysfixed_check says something changed and you did not expect it to.',
|
|
118
|
+
inputSchema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
screen: { type: 'string', description: 'One screen name. Leave it out to trace every screen that has moved.' },
|
|
122
|
+
},
|
|
123
|
+
additionalProperties: false,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
// Deliberately absent unless the project opted in. An agent that can approve its
|
|
129
|
+
// own screenshots has no safety net at all: it would edit the code, notice the
|
|
130
|
+
// picture moved, bless the new picture, and report success. The whole tool is
|
|
131
|
+
// built around a human standing at this one door.
|
|
132
|
+
if (allowApprove) {
|
|
133
|
+
tools.push({
|
|
134
|
+
name: 'staysfixed_approve',
|
|
135
|
+
description:
|
|
136
|
+
'Accept the new picture of a screen as the correct one from now on. This project has turned this on for agents; it is off by default, because approving is normally a human decision. Only use it when the change was asked for and you can say plainly why the new look is right.',
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
screen: { type: 'string', description: 'The screen whose new picture becomes the approved one.' },
|
|
141
|
+
reason: { type: 'string', description: 'Why the new look is correct, in one plain sentence. It is written to the approval log.' },
|
|
142
|
+
},
|
|
143
|
+
required: ['screen', 'reason'],
|
|
144
|
+
additionalProperties: false,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (allowMark) {
|
|
150
|
+
tools.push({
|
|
151
|
+
name: 'staysfixed_mark',
|
|
152
|
+
description:
|
|
153
|
+
'Pin this moment as known-good, so a future regression can be traced back to it. Everything is checked first and the marker is refused if anything is not passing. Use it at a release, or just before you start something risky.',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
properties: {
|
|
157
|
+
label: { type: 'string', description: "A name for this point, e.g. 'v0.15.0' or 'before-the-store-work'." },
|
|
158
|
+
note: { type: 'string', description: 'Optional one-line note about what this point is.' },
|
|
159
|
+
},
|
|
160
|
+
required: ['label'],
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return tools;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// The call
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run one tool.
|
|
175
|
+
*
|
|
176
|
+
* A tool that fails is a RESULT with `isError: true`, never a JSON-RPC error —
|
|
177
|
+
* the agent is supposed to read the failure and act on it, and a protocol-level
|
|
178
|
+
* error would be swallowed by its client before it ever saw the words.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} name
|
|
181
|
+
* @param {any} args
|
|
182
|
+
* @param {ToolContext} ctx
|
|
183
|
+
* @returns {Promise<ToolResult>}
|
|
184
|
+
*/
|
|
185
|
+
export async function callTool(name, args, ctx) {
|
|
186
|
+
/** @type {Record<string, any>} */
|
|
187
|
+
const input = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
// Re-read the config every call. An agent editing staysfixed.config.js and then
|
|
191
|
+
// calling a tool should see its own edit, not a snapshot from server start.
|
|
192
|
+
const project = await ctx.reload();
|
|
193
|
+
|
|
194
|
+
switch (name) {
|
|
195
|
+
case 'staysfixed_check':
|
|
196
|
+
return await toolCheck(project, input);
|
|
197
|
+
case 'staysfixed_capture':
|
|
198
|
+
return await toolCapture(project, input);
|
|
199
|
+
case 'staysfixed_screens':
|
|
200
|
+
return await toolScreens(project);
|
|
201
|
+
case 'staysfixed_status':
|
|
202
|
+
return await toolStatus(project);
|
|
203
|
+
case 'staysfixed_trace':
|
|
204
|
+
return await toolTrace(project, input);
|
|
205
|
+
case 'staysfixed_approve':
|
|
206
|
+
return await toolApprove(project, input, ctx);
|
|
207
|
+
case 'staysfixed_mark':
|
|
208
|
+
return await toolMark(project, input, ctx);
|
|
209
|
+
default:
|
|
210
|
+
return problem(`There is no Stays Fixed tool called "${name}".`);
|
|
211
|
+
}
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return problem(explain(e));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// check
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {import('../types.js').Project} project
|
|
223
|
+
* @param {Record<string, any>} input
|
|
224
|
+
* @returns {Promise<ToolResult>}
|
|
225
|
+
*/
|
|
226
|
+
async function toolCheck(project, input) {
|
|
227
|
+
const only = stringList(input.only);
|
|
228
|
+
const guardsOnly = input.guardsOnly === true;
|
|
229
|
+
const picturesOnly = input.picturesOnly === true;
|
|
230
|
+
|
|
231
|
+
if (guardsOnly && picturesOnly) {
|
|
232
|
+
return problem('You asked for guards only and pictures only at the same time. Pick one, or neither to check everything.');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** @type {any} */
|
|
236
|
+
const runOpts = {};
|
|
237
|
+
if (only) runOpts.only = only;
|
|
238
|
+
if (guardsOnly) runOpts.guardsOnly = true;
|
|
239
|
+
if (picturesOnly) runOpts.picturesOnly = true;
|
|
240
|
+
|
|
241
|
+
// `runCheck` opens the real app and closes it again on its own way out, success
|
|
242
|
+
// or failure. That matters more here than in the CLI: the CLI process dies after
|
|
243
|
+
// one run, while this server lives for the whole coding session, so a single
|
|
244
|
+
// leaked Electron process would sit there for hours and a leak per call would
|
|
245
|
+
// fill the machine.
|
|
246
|
+
/** @type {import('../types.js').RunSummary} */
|
|
247
|
+
const summary = await runCheck(project, runOpts);
|
|
248
|
+
|
|
249
|
+
if (summary.pictures.length === 0 && summary.guards.length === 0) {
|
|
250
|
+
const known = project.config.screens.map((s) => s.name);
|
|
251
|
+
return problem(
|
|
252
|
+
only
|
|
253
|
+
? `Nothing matched ${only.map(quote).join(', ')}.` +
|
|
254
|
+
(known.length ? ` This project watches: ${known.join(', ')}.` : '') +
|
|
255
|
+
' Call staysfixed_screens to see the screens and guards with descriptions.'
|
|
256
|
+
: 'This project has no screens and no guards yet, so there is nothing to check. Someone needs to add a screen to the config first.'
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** @type {ContentItem[]} */
|
|
261
|
+
const content = [{ type: 'text', text: renderCheck(summary, project) }];
|
|
262
|
+
|
|
263
|
+
for (const item of await diffImages(summary)) content.push(item);
|
|
264
|
+
|
|
265
|
+
// A regression is reported as an error result on purpose. Protocol-wise the call
|
|
266
|
+
// succeeded, but `isError` is the flag every client puts in front of the agent,
|
|
267
|
+
// and an agent that skims past "SOMETHING MOVED" is exactly the failure this
|
|
268
|
+
// whole tool exists to prevent.
|
|
269
|
+
return { content, isError: !summary.ok };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {import('../types.js').RunSummary} summary
|
|
274
|
+
* @param {import('../types.js').Project} project
|
|
275
|
+
* @returns {string}
|
|
276
|
+
*/
|
|
277
|
+
function renderCheck(summary, project) {
|
|
278
|
+
const t = summary.totals;
|
|
279
|
+
/** @type {string[]} */
|
|
280
|
+
const out = [];
|
|
281
|
+
|
|
282
|
+
if (summary.ok) {
|
|
283
|
+
out.push(`ALL GOOD — nothing that worked before has moved. ${count(t.passed, 'check')} passed.`);
|
|
284
|
+
} else {
|
|
285
|
+
/** @type {string[]} */
|
|
286
|
+
const bad = [];
|
|
287
|
+
if (t.changed) bad.push(`${count(t.changed, 'screen')} changed`);
|
|
288
|
+
if (t.failed) bad.push(`${count(t.failed, 'check')} failed`);
|
|
289
|
+
if (t.new) bad.push(`${count(t.new, 'screen')} never approved`);
|
|
290
|
+
if (t.missing) bad.push(`${count(t.missing, 'picture')} missing`);
|
|
291
|
+
out.push(`SOMETHING MOVED — ${bad.join(', ')}. ${t.passed} passed.`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const changed = summary.pictures.filter((p) => p.status === 'changed');
|
|
295
|
+
if (changed.length) {
|
|
296
|
+
out.push('');
|
|
297
|
+
out.push('Screens that look different now:');
|
|
298
|
+
for (const p of changed) out.push(`- ${p.name}${describeOf(p)} — ${changeLine(p)}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const brokePictures = summary.pictures.filter((p) => p.status === 'failed');
|
|
302
|
+
if (brokePictures.length) {
|
|
303
|
+
out.push('');
|
|
304
|
+
out.push('Screens that could not be photographed at all:');
|
|
305
|
+
for (const p of brokePictures) {
|
|
306
|
+
out.push(`- ${p.name} — ${p.message ?? 'it failed and said nothing useful.'}`);
|
|
307
|
+
for (const e of (p.consoleErrors ?? []).slice(0, 2)) out.push(` the app logged: ${trim(e, 160)}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const fresh = summary.pictures.filter((p) => p.status === 'new' || p.status === 'missing');
|
|
312
|
+
if (fresh.length) {
|
|
313
|
+
out.push('');
|
|
314
|
+
out.push('Screens with no approved picture yet (nobody has said what they should look like):');
|
|
315
|
+
for (const p of fresh) out.push(`- ${p.name}${describeOf(p)}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const brokeGuards = summary.guards.filter((g) => g.status !== 'passed' && g.status !== 'skipped');
|
|
319
|
+
if (brokeGuards.length) {
|
|
320
|
+
out.push('');
|
|
321
|
+
out.push('Guards that broke (each one is a bug that was already fixed once — it is back):');
|
|
322
|
+
for (const g of brokeGuards) {
|
|
323
|
+
out.push(`- ${g.name} — ${g.failedAt ? `expected ${quote(g.failedAt)}, and it was not true` : g.message ?? 'it failed.'}`);
|
|
324
|
+
if (g.failedAt && g.message) out.push(` ${trim(g.message, 200)}`);
|
|
325
|
+
if (g.because) out.push(` why this guard exists: ${trim(g.because, 200)}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (summary.condemned && summary.condemned.length) {
|
|
330
|
+
out.push('');
|
|
331
|
+
out.push(
|
|
332
|
+
`These checks change their mind without the code changing, so their verdict cannot be trusted: ${summary.condemned.join(', ')}. Tell the person; they need fixing or deleting.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (changed.length && project.config.mcp?.allowApprove !== true) {
|
|
337
|
+
out.push('');
|
|
338
|
+
out.push(
|
|
339
|
+
'A changed picture is not automatically a bug. If the new look is what was asked for, say so and ask the person to run `staysfixed approve <screen>` — approving is theirs to do, not yours. If it was not asked for, fix it and check again.'
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return out.join('\n');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Diff pictures for the screens that moved, biggest change first, capped so a
|
|
348
|
+
* broken stylesheet cannot blow an agent's whole context on twenty screenshots.
|
|
349
|
+
* @param {import('../types.js').RunSummary} summary
|
|
350
|
+
* @returns {Promise<ContentItem[]>}
|
|
351
|
+
*/
|
|
352
|
+
async function diffImages(summary) {
|
|
353
|
+
const changed = summary.pictures
|
|
354
|
+
.filter((p) => p.status === 'changed' && p.diffPath)
|
|
355
|
+
.sort((a, b) => (b.diffRatio ?? 0) - (a.diffRatio ?? 0));
|
|
356
|
+
|
|
357
|
+
/** @type {ContentItem[]} */
|
|
358
|
+
const out = [];
|
|
359
|
+
for (const p of changed.slice(0, MAX_IMAGES)) {
|
|
360
|
+
const png = await readMaybe(/** @type {string} */ (p.diffPath));
|
|
361
|
+
if (!png) continue;
|
|
362
|
+
out.push({ type: 'text', text: `Difference in "${p.name}" — pink is what moved:` });
|
|
363
|
+
out.push({ type: 'image', data: png.toString('base64'), mimeType: 'image/png' });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const left = changed.length - Math.min(changed.length, MAX_IMAGES);
|
|
367
|
+
if (left > 0) {
|
|
368
|
+
out.push({
|
|
369
|
+
type: 'text',
|
|
370
|
+
text: `${count(left, 'more changed screen')} not pictured here. Call staysfixed_check again with only: [${changed
|
|
371
|
+
.slice(MAX_IMAGES)
|
|
372
|
+
.map((p) => `"${p.name}"`)
|
|
373
|
+
.join(', ')}] to see them.`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* @param {import('../types.js').PictureResult} p
|
|
381
|
+
* @returns {string}
|
|
382
|
+
*/
|
|
383
|
+
function changeLine(p) {
|
|
384
|
+
if (p.size && p.approvedSize && (p.size.width !== p.approvedSize.width || p.size.height !== p.approvedSize.height)) {
|
|
385
|
+
return `it is a different size now: ${p.approvedSize.width}x${p.approvedSize.height} before, ${p.size.width}x${p.size.height} now.`;
|
|
386
|
+
}
|
|
387
|
+
const share = p.diffRatio !== undefined ? formatShare(p.diffRatio) : null;
|
|
388
|
+
const pixels = p.diffPixels !== undefined ? `${p.diffPixels.toLocaleString('en-US')} pixels` : null;
|
|
389
|
+
if (share && pixels) return `${share} of the picture differs (${pixels}).`;
|
|
390
|
+
return p.message ?? 'it does not match the approved picture.';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// capture
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* @param {import('../types.js').Project} project
|
|
399
|
+
* @param {Record<string, any>} input
|
|
400
|
+
* @returns {Promise<ToolResult>}
|
|
401
|
+
*/
|
|
402
|
+
async function toolCapture(project, input) {
|
|
403
|
+
const screen = text(input.screen);
|
|
404
|
+
if (!screen) return problem('Tell me which screen to photograph, e.g. { "screen": "sessions-empty" }.');
|
|
405
|
+
|
|
406
|
+
const known = project.config.screens.map((s) => s.name);
|
|
407
|
+
if (!known.includes(screen)) {
|
|
408
|
+
return problem(
|
|
409
|
+
`This project has no screen called ${quote(screen)}.` +
|
|
410
|
+
(known.length ? ` It watches: ${known.join(', ')}.` : ' It has no screens at all yet.') +
|
|
411
|
+
' Call staysfixed_screens for the descriptions.'
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Same contract as toolCheck: captureOne closes the app it opened, whatever happens.
|
|
416
|
+
/** @type {{png: Buffer, result: import('../types.js').PictureResult}} */
|
|
417
|
+
const shot = await captureOne(project, screen, /** @type {any} */ ({}));
|
|
418
|
+
|
|
419
|
+
const size = shot.result.size;
|
|
420
|
+
const where = size ? ` (${size.width}x${size.height} pixels)` : '';
|
|
421
|
+
/** @type {ContentItem[]} */
|
|
422
|
+
const content = [
|
|
423
|
+
{
|
|
424
|
+
type: 'text',
|
|
425
|
+
text: `Here is ${quote(screen)} as it looks right now${where}. Nothing was compared and nothing was approved.`,
|
|
426
|
+
},
|
|
427
|
+
{ type: 'image', data: shot.png.toString('base64'), mimeType: 'image/png' },
|
|
428
|
+
];
|
|
429
|
+
return { content };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// screens
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* @param {import('../types.js').Project} project
|
|
438
|
+
* @returns {Promise<ToolResult>}
|
|
439
|
+
*/
|
|
440
|
+
async function toolScreens(project) {
|
|
441
|
+
const approved = new Set(await listApproved(project.paths));
|
|
442
|
+
const guards = await loadGuards(project);
|
|
443
|
+
const screens = project.config.screens;
|
|
444
|
+
|
|
445
|
+
/** @type {string[]} */
|
|
446
|
+
const out = [];
|
|
447
|
+
out.push(`This project watches ${count(screens.length, 'screen')} and ${count(guards.length, 'guard')}.`);
|
|
448
|
+
|
|
449
|
+
if (screens.length) {
|
|
450
|
+
out.push('');
|
|
451
|
+
out.push('Screens (photographed and compared against an approved picture):');
|
|
452
|
+
for (const s of screens) {
|
|
453
|
+
const flags = [];
|
|
454
|
+
if (s.skip) flags.push('turned off for now');
|
|
455
|
+
else if (!approved.has(s.name)) flags.push('no approved picture yet');
|
|
456
|
+
out.push(`- ${s.name}${s.describe ? ` — ${s.describe}` : ''}${flags.length ? ` [${flags.join('; ')}]` : ''}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (guards.length) {
|
|
461
|
+
out.push('');
|
|
462
|
+
out.push('Guards (each is a bug that was already fixed; it fails the day the bug comes back):');
|
|
463
|
+
for (const g of guards) {
|
|
464
|
+
out.push(`- ${g.name}${g.skip ? ' [turned off for now]' : ''}`);
|
|
465
|
+
if (g.because) out.push(` ${trim(g.because, 220)}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (!screens.length && !guards.length) {
|
|
470
|
+
out.push('');
|
|
471
|
+
out.push('Nothing is protected yet. Adding a screen to the config is the first step.');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return { content: [{ type: 'text', text: out.join('\n') }] };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// status
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* @param {import('../types.js').Project} project
|
|
483
|
+
* @returns {Promise<ToolResult>}
|
|
484
|
+
*/
|
|
485
|
+
async function toolStatus(project) {
|
|
486
|
+
const approved = await listApproved(project.paths);
|
|
487
|
+
const guards = await loadGuards(project).catch(() => []);
|
|
488
|
+
const markers = await readMarkers(project.paths);
|
|
489
|
+
const history = await loadHistory(project.paths.historyFile);
|
|
490
|
+
const stuck = condemned(history);
|
|
491
|
+
const last = await readLastRun(project.paths);
|
|
492
|
+
|
|
493
|
+
/** @type {string[]} */
|
|
494
|
+
const out = [];
|
|
495
|
+
out.push(`${count(approved.length, 'approved picture')}, ${count(guards.length, 'guard')}, ${count(markers.length, 'known-good marker')}.`);
|
|
496
|
+
|
|
497
|
+
if (last) {
|
|
498
|
+
const when = last.startedAt ? ` on ${day(last.startedAt)}` : '';
|
|
499
|
+
const at = last.git?.shortSha ? `, at commit ${last.git.shortSha}` : '';
|
|
500
|
+
const t = last.totals;
|
|
501
|
+
const detail = t ? ` (${t.passed} passed, ${t.changed} changed, ${t.failed} failed)` : '';
|
|
502
|
+
out.push(`Last check${when}${at}: ${last.ok ? 'everything passed' : 'something moved'}${detail}.`);
|
|
503
|
+
} else {
|
|
504
|
+
out.push('Nothing has been checked yet in this copy of the project.');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (markers.length) {
|
|
508
|
+
const newest = markers.slice(-3).reverse();
|
|
509
|
+
out.push('Newest markers: ' + newest.map((m) => `${m.label}${m.at ? ` (${day(m.at)})` : ''}`).join(', ') + '.');
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (stuck.length) {
|
|
513
|
+
out.push(
|
|
514
|
+
`Condemned — these change their mind without the code changing, so ignore their verdict until a person fixes or deletes them: ${stuck
|
|
515
|
+
.map((e) => e.name)
|
|
516
|
+
.join(', ')}.`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const missing = project.config.screens.filter((s) => !s.skip && !approved.includes(s.name)).map((s) => s.name);
|
|
521
|
+
if (missing.length) out.push(`Screens still waiting for a first approved picture: ${missing.join(', ')}.`);
|
|
522
|
+
|
|
523
|
+
return { content: [{ type: 'text', text: out.join('\n') }] };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// trace
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @param {import('../types.js').Project} project
|
|
532
|
+
* @param {Record<string, any>} input
|
|
533
|
+
* @returns {Promise<ToolResult>}
|
|
534
|
+
*/
|
|
535
|
+
async function toolTrace(project, input) {
|
|
536
|
+
const wanted = text(input.screen);
|
|
537
|
+
const markers = await readMarkers(project.paths);
|
|
538
|
+
|
|
539
|
+
if (markers.length === 0) {
|
|
540
|
+
return {
|
|
541
|
+
content: [
|
|
542
|
+
{
|
|
543
|
+
type: 'text',
|
|
544
|
+
text: 'There are no known-good markers yet, so there is no history to trace against. Someone needs to run `staysfixed mark <label>` at a moment when everything passes; after that, a regression can be pinned to the commits between two markers.',
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const names = wanted ? [wanted] : Array.from(new Set(markers.flatMap((m) => Object.keys(m.pictures ?? {}))));
|
|
551
|
+
if (names.length === 0) return problem('The markers do not record any screens, so there is nothing to trace.');
|
|
552
|
+
|
|
553
|
+
/** @type {import('../types.js').TraceFinding[]} */
|
|
554
|
+
const findings = [];
|
|
555
|
+
for (const name of names) findings.push(await traceOne(project, name, markers));
|
|
556
|
+
|
|
557
|
+
const moved = findings.filter((f) => f.verdict === 'changed');
|
|
558
|
+
const report = wanted ? findings : moved;
|
|
559
|
+
|
|
560
|
+
/** @type {string[]} */
|
|
561
|
+
const out = [];
|
|
562
|
+
if (report.length === 0) {
|
|
563
|
+
out.push(`Every screen still looks the way it did at the newest marker. Nothing to trace across ${count(markers.length, 'marker')}.`);
|
|
564
|
+
} else {
|
|
565
|
+
out.push(
|
|
566
|
+
wanted
|
|
567
|
+
? `Traced ${quote(wanted)} across ${count(markers.length, 'marker')}.`
|
|
568
|
+
: `${count(moved.length, 'screen')} no longer look${moved.length === 1 ? 's' : ''} the way it did at the newest marker.`
|
|
569
|
+
);
|
|
570
|
+
for (const f of report) {
|
|
571
|
+
out.push('');
|
|
572
|
+
out.push(...renderFinding(f));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return { content: [{ type: 'text', text: out.join('\n') }] };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* @param {import('../types.js').TraceFinding} f
|
|
581
|
+
* @returns {string[]}
|
|
582
|
+
*/
|
|
583
|
+
function renderFinding(f) {
|
|
584
|
+
/** @type {string[]} */
|
|
585
|
+
const out = [];
|
|
586
|
+
if (f.verdict === 'unchanged') {
|
|
587
|
+
out.push(`${quote(f.name)} is unchanged${f.lastGood ? ` — it still matches marker "${f.lastGood.label}"` : ''}.`);
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
if (f.verdict === 'unknown') {
|
|
591
|
+
out.push(`${quote(f.name)}: ${f.message ?? 'no history for this screen.'}`);
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
out.push(`${quote(f.name)} changed.`);
|
|
596
|
+
if (f.lastGood && f.firstBad) {
|
|
597
|
+
out.push(
|
|
598
|
+
` It last looked right at marker "${f.lastGood.label}" (${day(f.lastGood.at)}${markerSha(f.lastGood)}) and was already different at "${f.firstBad.label}" (${day(
|
|
599
|
+
f.firstBad.at
|
|
600
|
+
)}${markerSha(f.firstBad)}).`
|
|
601
|
+
);
|
|
602
|
+
} else if (f.message) {
|
|
603
|
+
out.push(` ${f.message}`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const commits = f.commits ?? [];
|
|
607
|
+
if (commits.length) {
|
|
608
|
+
out.push(` ${count(commits.length, 'commit')} sit between those two markers — the break is in one of them:`);
|
|
609
|
+
for (const c of commits.slice(0, 12)) out.push(` ${c.shortSha} ${trim(c.subject, 80)} (${c.author}, ${c.date})`);
|
|
610
|
+
if (commits.length > 12) out.push(` ...and ${commits.length - 12} more.`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const files = f.files ?? [];
|
|
614
|
+
if (files.length) {
|
|
615
|
+
out.push(` Files touched in that window: ${files.slice(0, 10).join(', ')}${files.length > 10 ? `, and ${files.length - 10} more` : ''}.`);
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Work out where a screen stopped looking the way it used to.
|
|
622
|
+
*
|
|
623
|
+
* "How it looks now" is the picture from the last run when there is one, and the
|
|
624
|
+
* approved picture otherwise — so this answers the question straight after a
|
|
625
|
+
* failing check, which is when anybody actually asks it.
|
|
626
|
+
*
|
|
627
|
+
* @param {import('../types.js').Project} project
|
|
628
|
+
* @param {string} name
|
|
629
|
+
* @param {import('../types.js').Marker[]} markers oldest first
|
|
630
|
+
* @returns {Promise<import('../types.js').TraceFinding>}
|
|
631
|
+
*/
|
|
632
|
+
async function traceOne(project, name, markers) {
|
|
633
|
+
const now = await currentLook(project.paths, name);
|
|
634
|
+
if (!now) {
|
|
635
|
+
return { name, verdict: 'unknown', message: 'There is no picture of this screen on disk, so there is nothing to compare against history.' };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const seen = markers.filter((m) => m.pictures && typeof m.pictures[name] === 'string');
|
|
639
|
+
if (seen.length === 0) {
|
|
640
|
+
return { name, verdict: 'unknown', message: 'No marker has ever recorded this screen, so its history starts today.' };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const newest = seen[seen.length - 1];
|
|
644
|
+
if (newest.pictures[name] === now) return { name, verdict: 'unchanged', lastGood: newest };
|
|
645
|
+
|
|
646
|
+
let goodIndex = -1;
|
|
647
|
+
for (let i = seen.length - 1; i >= 0; i -= 1) {
|
|
648
|
+
if (seen[i].pictures[name] === now) {
|
|
649
|
+
goodIndex = i;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (goodIndex === -1) {
|
|
655
|
+
return {
|
|
656
|
+
name,
|
|
657
|
+
verdict: 'changed',
|
|
658
|
+
firstBad: seen[0],
|
|
659
|
+
message: `It does not match any of the ${seen.length} markers that recorded it, so it has never looked like this at a known-good point.`,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const lastGood = seen[goodIndex];
|
|
664
|
+
const firstBad = seen[goodIndex + 1];
|
|
665
|
+
|
|
666
|
+
/** @type {import('../types.js').TraceFinding} */
|
|
667
|
+
const finding = { name, verdict: 'changed', lastGood, firstBad };
|
|
668
|
+
|
|
669
|
+
const from = lastGood.git?.sha;
|
|
670
|
+
const to = firstBad.git?.sha;
|
|
671
|
+
const root = project.paths.root;
|
|
672
|
+
if (from && to && (await commitExists(root, from)) && (await commitExists(root, to))) {
|
|
673
|
+
finding.commits = await commitsBetween(root, from, to);
|
|
674
|
+
finding.files = await filesBetween(root, from, to);
|
|
675
|
+
}
|
|
676
|
+
return finding;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
681
|
+
* @param {string} name
|
|
682
|
+
* @returns {Promise<string|null>}
|
|
683
|
+
*/
|
|
684
|
+
async function currentLook(paths, name) {
|
|
685
|
+
const fresh = await sha256File(resultPicture(paths, name).png);
|
|
686
|
+
if (fresh) return fresh;
|
|
687
|
+
return sha256File(approvedPicture(paths, name).png);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* @param {import('../types.js').Marker} m
|
|
692
|
+
*/
|
|
693
|
+
function markerSha(m) {
|
|
694
|
+
return m.git?.shortSha ? `, commit ${m.git.shortSha}` : '';
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// approve (only reachable when the project opted in)
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* @param {import('../types.js').Project} project
|
|
703
|
+
* @param {Record<string, any>} input
|
|
704
|
+
* @param {ToolContext} ctx
|
|
705
|
+
* @returns {Promise<ToolResult>}
|
|
706
|
+
*/
|
|
707
|
+
async function toolApprove(project, input, ctx) {
|
|
708
|
+
// Belt and braces: the tool is not even listed when this is off, but a client
|
|
709
|
+
// can still send any name it likes, and this is the one door worth bolting twice.
|
|
710
|
+
if (project.config.mcp?.allowApprove !== true) {
|
|
711
|
+
return problem(
|
|
712
|
+
'Approving a picture is a human decision in this project, so this tool is not available to you. Tell the person what changed and why, and let them run `staysfixed approve <screen>`.'
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const screen = text(input.screen);
|
|
717
|
+
const reason = text(input.reason);
|
|
718
|
+
if (!screen) return problem('Say which screen to approve, e.g. { "screen": "sessions-empty", "reason": "..." }.');
|
|
719
|
+
if (!reason) return problem('Say why the new picture is correct. An approval with no reason is worth nothing to the person reading it later.');
|
|
720
|
+
|
|
721
|
+
const git = await gitInfo(project.paths.root);
|
|
722
|
+
const meta = await approveFromResult(project.paths, screen, { git, tool: `staysfixed ${ctx.version} (agent)` });
|
|
723
|
+
|
|
724
|
+
// A trail, because this is the one place an agent overrules the safety net.
|
|
725
|
+
await appendApprovalLog(project.paths, {
|
|
726
|
+
at: meta.approvedAt,
|
|
727
|
+
screen,
|
|
728
|
+
reason,
|
|
729
|
+
by: 'agent, over MCP',
|
|
730
|
+
tool: meta.tool,
|
|
731
|
+
gitSha: git.shortSha,
|
|
732
|
+
sha256: meta.sha256,
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
content: [
|
|
737
|
+
{
|
|
738
|
+
type: 'text',
|
|
739
|
+
text: `Approved: ${quote(screen)} now looks the way it should. Reason recorded: ${reason}\nThe new picture is committed to the project, so mention this in what you report back — a person should know a picture was re-approved by an agent.`,
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
// mark (only reachable when the project opted in)
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* @param {import('../types.js').Project} project
|
|
751
|
+
* @param {Record<string, any>} input
|
|
752
|
+
* @param {ToolContext} ctx
|
|
753
|
+
* @returns {Promise<ToolResult>}
|
|
754
|
+
*/
|
|
755
|
+
async function toolMark(project, input, ctx) {
|
|
756
|
+
if (project.config.mcp?.allowMark !== true) {
|
|
757
|
+
return problem('Writing a known-good marker is a human decision in this project. Ask the person to run `staysfixed mark <label>`.');
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const label = text(input.label);
|
|
761
|
+
const note = text(input.note);
|
|
762
|
+
if (!label) return problem("Give the marker a name, e.g. { \"label\": \"v0.15.0\" }.");
|
|
763
|
+
|
|
764
|
+
// A marker is a promise that everything worked here. Checking first is the only
|
|
765
|
+
// way that promise means anything later, when someone traces a bug back to it.
|
|
766
|
+
/** @type {import('../types.js').RunSummary} */
|
|
767
|
+
const summary = await runCheck(project, /** @type {any} */ ({}));
|
|
768
|
+
if (!summary.ok) {
|
|
769
|
+
return problem(
|
|
770
|
+
`Not marking ${quote(label)} — this is not a known-good point yet.\n\n${renderCheck(summary, project)}`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const git = await gitInfo(project.paths.root);
|
|
775
|
+
/** @type {Record<string, import('../types.js').CheckStatus>} */
|
|
776
|
+
const guards = {};
|
|
777
|
+
for (const g of summary.guards) guards[g.name] = g.status;
|
|
778
|
+
|
|
779
|
+
/** @type {import('../types.js').Marker} */
|
|
780
|
+
const marker = {
|
|
781
|
+
label,
|
|
782
|
+
at: new Date().toISOString(),
|
|
783
|
+
git,
|
|
784
|
+
pictures: await approvedHashes(project.paths),
|
|
785
|
+
guards,
|
|
786
|
+
tool: `staysfixed ${ctx.version}`,
|
|
787
|
+
platform: platformTag(),
|
|
788
|
+
};
|
|
789
|
+
if (note) marker.note = note;
|
|
790
|
+
|
|
791
|
+
await fsp.mkdir(project.paths.markers, { recursive: true });
|
|
792
|
+
await fsp.writeFile(path.join(project.paths.markers, `${safeName(label)}.json`), JSON.stringify(marker, null, 2) + '\n');
|
|
793
|
+
|
|
794
|
+
// A marker on a dirty tree points at a commit that never contained this code, so
|
|
795
|
+
// a later trace would blame the wrong change. Worth saying out loud.
|
|
796
|
+
const dirtyNote = git.dirty
|
|
797
|
+
? ' Note: there are uncommitted changes, so this marker points at a commit that does not contain them. Committing first makes it far more useful.'
|
|
798
|
+
: '';
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
content: [
|
|
802
|
+
{
|
|
803
|
+
type: 'text',
|
|
804
|
+
text: `Marked ${quote(label)} as known-good: ${count(Object.keys(marker.pictures).length, 'picture')} and ${count(
|
|
805
|
+
Object.keys(guards).length,
|
|
806
|
+
'guard'
|
|
807
|
+
)} recorded${git.shortSha ? ` at commit ${git.shortSha}` : ''}. If something breaks later, staysfixed_trace can now point at the commits after this point.${dirtyNote}`,
|
|
808
|
+
},
|
|
809
|
+
],
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// Disk helpers
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Read every marker in the folder, forgiving anything unreadable — a trace is
|
|
819
|
+
* still useful when one marker file has been hand-edited into nonsense.
|
|
820
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
821
|
+
* @returns {Promise<import('../types.js').Marker[]>} oldest first
|
|
822
|
+
*/
|
|
823
|
+
async function readMarkers(paths) {
|
|
824
|
+
/** @type {import('../types.js').Marker[]} */
|
|
825
|
+
const out = [];
|
|
826
|
+
/** @type {string[]} */
|
|
827
|
+
let files;
|
|
828
|
+
try {
|
|
829
|
+
files = await fsp.readdir(paths.markers);
|
|
830
|
+
} catch {
|
|
831
|
+
return out;
|
|
832
|
+
}
|
|
833
|
+
for (const file of files) {
|
|
834
|
+
if (!file.endsWith('.json')) continue;
|
|
835
|
+
try {
|
|
836
|
+
const raw = JSON.parse(await fsp.readFile(path.join(paths.markers, file), 'utf8'));
|
|
837
|
+
if (raw && typeof raw === 'object' && typeof raw.label === 'string') {
|
|
838
|
+
const m = /** @type {import('../types.js').Marker} */ (raw);
|
|
839
|
+
if (!m.pictures || typeof m.pictures !== 'object') m.pictures = {};
|
|
840
|
+
out.push(m);
|
|
841
|
+
}
|
|
842
|
+
} catch {
|
|
843
|
+
// Unreadable marker: skipped on purpose, never fatal.
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
out.sort((a, b) => String(a.at ?? '').localeCompare(String(b.at ?? '')));
|
|
847
|
+
return out;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
852
|
+
* @returns {Promise<import('../types.js').RunSummary|null>}
|
|
853
|
+
*/
|
|
854
|
+
async function readLastRun(paths) {
|
|
855
|
+
for (const file of [path.join(paths.dir, 'last-run.json'), path.join(paths.results, 'last-run.json')]) {
|
|
856
|
+
try {
|
|
857
|
+
const raw = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
858
|
+
if (raw && typeof raw === 'object') return /** @type {import('../types.js').RunSummary} */ (raw);
|
|
859
|
+
} catch {
|
|
860
|
+
// Not there, or not readable. Either way there is simply no last run to show.
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* @param {import('../types.js').ProjectPaths} paths
|
|
868
|
+
* @param {Record<string, unknown>} entry
|
|
869
|
+
*/
|
|
870
|
+
async function appendApprovalLog(paths, entry) {
|
|
871
|
+
try {
|
|
872
|
+
await fsp.mkdir(paths.dir, { recursive: true });
|
|
873
|
+
await fsp.appendFile(path.join(paths.dir, 'approvals.log'), JSON.stringify(entry) + '\n');
|
|
874
|
+
} catch {
|
|
875
|
+
// The approval itself succeeded; failing to write the note about it must not
|
|
876
|
+
// turn into an error the agent reports as a failed approval.
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* @param {string} file
|
|
882
|
+
* @returns {Promise<Buffer|null>}
|
|
883
|
+
*/
|
|
884
|
+
async function readMaybe(file) {
|
|
885
|
+
try {
|
|
886
|
+
return await fsp.readFile(file);
|
|
887
|
+
} catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// Small helpers
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* @param {unknown} e
|
|
898
|
+
* @returns {string}
|
|
899
|
+
*/
|
|
900
|
+
function explain(e) {
|
|
901
|
+
const message = messageOf(e);
|
|
902
|
+
if (isExpected(e)) {
|
|
903
|
+
const hint = /** @type {import('../core/errors.js').StaysFixedError} */ (e).hint;
|
|
904
|
+
return hint ? `${message}\n${hint}` : message;
|
|
905
|
+
}
|
|
906
|
+
return `Stays Fixed could not finish that: ${message}`;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* @param {string} message
|
|
911
|
+
* @returns {ToolResult}
|
|
912
|
+
*/
|
|
913
|
+
function problem(message) {
|
|
914
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* @param {unknown} v
|
|
919
|
+
* @returns {string|null}
|
|
920
|
+
*/
|
|
921
|
+
function text(v) {
|
|
922
|
+
if (typeof v !== 'string') return null;
|
|
923
|
+
const s = v.trim();
|
|
924
|
+
return s === '' ? null : s;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* @param {unknown} v
|
|
929
|
+
* @returns {string[]|undefined}
|
|
930
|
+
*/
|
|
931
|
+
function stringList(v) {
|
|
932
|
+
if (!Array.isArray(v)) return undefined;
|
|
933
|
+
const out = v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => String(x).trim());
|
|
934
|
+
return out.length ? out : undefined;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* @param {number} n
|
|
939
|
+
* @param {string} word
|
|
940
|
+
*/
|
|
941
|
+
function count(n, word) {
|
|
942
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** @param {string} s */
|
|
946
|
+
function quote(s) {
|
|
947
|
+
return `"${s}"`;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* @param {string} s
|
|
952
|
+
* @param {number} max
|
|
953
|
+
*/
|
|
954
|
+
function trim(s, max) {
|
|
955
|
+
const one = String(s).replace(/\s+/g, ' ').trim();
|
|
956
|
+
return one.length > max ? one.slice(0, max - 1) + '…' : one;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** @param {number} ratio */
|
|
960
|
+
function formatShare(ratio) {
|
|
961
|
+
const pct = ratio * 100;
|
|
962
|
+
if (pct >= 1) return `${pct.toFixed(1)}%`;
|
|
963
|
+
if (pct >= 0.01) return `${pct.toFixed(2)}%`;
|
|
964
|
+
return 'less than 0.01%';
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** @param {string} iso */
|
|
968
|
+
function day(iso) {
|
|
969
|
+
const s = String(iso ?? '');
|
|
970
|
+
return s.length >= 10 ? s.slice(0, 10) : s || 'an unknown date';
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* @param {{describe?: string}} p
|
|
975
|
+
*/
|
|
976
|
+
function describeOf(p) {
|
|
977
|
+
return p.describe ? ` (${p.describe})` : '';
|
|
978
|
+
}
|