staysfixed 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Stays Fixed v2 over the Model Context Protocol, hand-rolled.
3
+ *
4
+ * ────────────────────────────────────────────────────────────────────────────
5
+ * STDOUT IS THE PROTOCOL. Nothing but JSON-RPC messages may EVER be written
6
+ * to it — one compact JSON object per line, newline-terminated. A single
7
+ * stray `console.log` anywhere in the process, in this tool or in a project's
8
+ * own code that we imported, corrupts the stream and the client's parser dies
9
+ * with an error that points nowhere near the real cause. Every human-readable
10
+ * word goes to stderr. `serveMcp` enforces this by swapping
11
+ * `process.stdout.write` for a diverter and keeping the real one to itself;
12
+ * do not undo that.
13
+ * ────────────────────────────────────────────────────────────────────────────
14
+ *
15
+ * What changed from v1's server: almost nothing, on purpose. The transport was
16
+ * the one part of v1 that was already right — a hundred lines, no dependency,
17
+ * and it has never been the thing that broke. What changed is what it says on
18
+ * connect, and that it hands every tool the protocol revision it negotiated so
19
+ * a tool can decide whether the client will understand structured output.
20
+ *
21
+ * The server also starts, lists its tools and answers `staysfixed_capabilities`
22
+ * when the difference engine is half-built or the project is not set up at all.
23
+ * That is deliberate: capabilities is the call an agent makes to find out what
24
+ * is wrong, so it is the one call that must never be the thing that is wrong.
25
+ */
26
+
27
+ import { StringDecoder } from 'node:string_decoder';
28
+ import path from 'node:path';
29
+
30
+ import { isExpected, messageOf } from '../../core/errors.js';
31
+ import { toolDefinitions, callTool, findRoot } from './tools.js';
32
+
33
+ /** Protocol revisions this server understands. Newest first. */
34
+ const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
35
+ const LATEST_PROTOCOL = SUPPORTED_PROTOCOLS[0];
36
+
37
+ /** JSON-RPC 2.0 error codes, plus the ones MCP leans on. */
38
+ const RPC = {
39
+ parseError: -32700,
40
+ invalidRequest: -32600,
41
+ methodNotFound: -32601,
42
+ invalidParams: -32602,
43
+ internalError: -32603,
44
+ };
45
+
46
+ /**
47
+ * A line this long without a newline is a stream that has gone wrong, not a
48
+ * message. Incoming requests are small; only our replies ever carry evidence.
49
+ */
50
+ const MAX_LINE_BYTES = 64 * 1024 * 1024;
51
+
52
+ /** How long we wait for a check that is mid-flight when the client hangs up. */
53
+ const SHUTDOWN_GRACE_MS = 10_000;
54
+
55
+ /**
56
+ * What the server tells the agent the moment it connects. This is the shortest
57
+ * useful description of the loop, and it names the first call, because an agent
58
+ * that starts with `staysfixed_check` on an unconfigured project learns nothing
59
+ * and spends a minute finding that out.
60
+ */
61
+ const INSTRUCTIONS = [
62
+ 'Stays Fixed proves that nothing which already worked has changed.',
63
+ '',
64
+ 'The loop: seal what you MEANT to change with staysfixed_intent, do the work, then call',
65
+ 'staysfixed_check. You get back only the differences you did not intend — everything unchanged is',
66
+ 'skipped and never reaches you, and the count of what was skipped is in the reply so the silence is',
67
+ 'legible. Read the findings, fix the ones you caused, and check again.',
68
+ '',
69
+ 'Call staysfixed_capabilities FIRST, once per session. It tells you what can be checked on this',
70
+ 'machine right now, what cannot and why, what is missing that would unlock more, and the exact shape',
71
+ 'of every reply. You should not need to read any documentation after that call.',
72
+ '',
73
+ 'You cannot decide that a difference is acceptable. You can only record that one was intended, with',
74
+ 'staysfixed_waive, and five classes are sealed off from you entirely: money, signing in, losing data,',
75
+ 'a crash, and anything a named guard covers. Those go to a person.',
76
+ ].join('\n');
77
+
78
+ /**
79
+ * Serve Stays Fixed v2 on stdin/stdout until the client goes away.
80
+ *
81
+ * @param {{cwd?: string, root?: string, version?: string}} [opts]
82
+ * @returns {Promise<void>}
83
+ */
84
+ export async function serveMcp(opts = {}) {
85
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
86
+ // An editor usually starts an MCP server in the folder it was opened in, which
87
+ // is often a subfolder of the project. Walking up to the real root here means
88
+ // an agent never has to know or say where the project starts.
89
+ const root = opts.root ? path.resolve(opts.root) : findRoot(cwd);
90
+ const version = opts.version ?? '0.0.0';
91
+
92
+ const writeToClient = process.stdout.write.bind(process.stdout);
93
+ const realStdoutWrite = process.stdout.write;
94
+ /** @type {any} */ (process.stdout).write = divertToStderr;
95
+
96
+ /** The revision we settled on with this client. Tools ask before shaping output. */
97
+ let protocolVersion = LATEST_PROTOCOL;
98
+
99
+ /**
100
+ * One JSON-RPC message out. The ONLY function allowed to touch real stdout.
101
+ * @param {Record<string, any>} message
102
+ */
103
+ function send(message) {
104
+ try {
105
+ writeToClient(JSON.stringify(message) + '\n');
106
+ } catch (e) {
107
+ // The client hung up mid-answer. There is nowhere left to report it but here.
108
+ log(`could not write a reply: ${messageOf(e)}`);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @param {string|number} id
114
+ * @param {any} result
115
+ */
116
+ function reply(id, result) {
117
+ send({ jsonrpc: '2.0', id, result });
118
+ }
119
+
120
+ /**
121
+ * @param {string|number|null} id
122
+ * @param {number} code
123
+ * @param {string} message
124
+ */
125
+ function replyError(id, code, message) {
126
+ send({ jsonrpc: '2.0', id, error: { code, message } });
127
+ }
128
+
129
+ /** @param {string} line */
130
+ function log(line) {
131
+ process.stderr.write(`[staysfixed] ${line}\n`);
132
+ }
133
+
134
+ // Tool calls run one at a time. Two at once would mean two copies of the app
135
+ // fighting over ports, locks and data dirs — which is the exact failure this
136
+ // whole design rejects in its third principle. The queue is not optional.
137
+ /** @type {Promise<void>} */
138
+ let queue = Promise.resolve();
139
+ /** @type {Set<Promise<void>>} */
140
+ const inFlight = new Set();
141
+
142
+ /**
143
+ * @param {() => Promise<void>} job
144
+ * @returns {Promise<void>}
145
+ */
146
+ function enqueue(job) {
147
+ const run = queue.then(job);
148
+ const settled = run.then(
149
+ () => {},
150
+ () => {}
151
+ );
152
+ queue = settled;
153
+ inFlight.add(settled);
154
+ settled.then(() => inFlight.delete(settled));
155
+ return run;
156
+ }
157
+
158
+ /** @param {any} msg */
159
+ async function handle(msg) {
160
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
161
+ replyError(null, RPC.invalidRequest, 'Each line must be one JSON-RPC request object. Batched arrays are not supported.');
162
+ return;
163
+ }
164
+
165
+ const method = typeof msg.method === 'string' ? msg.method : null;
166
+ // No `id` member means a notification, and a notification never gets an
167
+ // answer — replying to one is the classic way to wedge a strict client.
168
+ const isNotification = !('id' in msg);
169
+ const id = /** @type {string|number} */ (msg.id);
170
+
171
+ if (!method) {
172
+ if (!isNotification) replyError(id, RPC.invalidRequest, 'That message has no method name.');
173
+ return;
174
+ }
175
+
176
+ if (isNotification) {
177
+ if (method === 'notifications/cancelled') log('client cancelled a request');
178
+ return;
179
+ }
180
+
181
+ switch (method) {
182
+ case 'initialize': {
183
+ const asked = msg.params?.protocolVersion;
184
+ const client = msg.params?.clientInfo?.name;
185
+ // Speak the client's revision when we know it; otherwise answer with ours
186
+ // and let it decide whether it can live with that.
187
+ protocolVersion = typeof asked === 'string' && SUPPORTED_PROTOCOLS.includes(asked) ? asked : LATEST_PROTOCOL;
188
+ reply(id, {
189
+ protocolVersion,
190
+ capabilities: { tools: {} },
191
+ serverInfo: { name: 'staysfixed', version },
192
+ instructions: INSTRUCTIONS,
193
+ });
194
+ log(`connected${client ? ` to ${client}` : ''} — speaking ${protocolVersion}`);
195
+ return;
196
+ }
197
+
198
+ case 'ping':
199
+ reply(id, {});
200
+ return;
201
+
202
+ case 'tools/list':
203
+ // Static, and it never touches disk. An agent listing tools on a project
204
+ // that is not set up yet must still see `staysfixed_capabilities`, which
205
+ // is the tool that explains why nothing else will work.
206
+ reply(id, { tools: toolDefinitions() });
207
+ return;
208
+
209
+ case 'tools/call': {
210
+ const name = msg.params?.name;
211
+ if (typeof name !== 'string' || name === '') {
212
+ replyError(id, RPC.invalidParams, 'A tools/call needs the name of the tool to run.');
213
+ return;
214
+ }
215
+ const args = msg.params?.arguments ?? {};
216
+ if (Array.isArray(args)) {
217
+ replyError(id, RPC.invalidParams, 'Tool arguments must be an object, not a list.');
218
+ return;
219
+ }
220
+ await enqueue(async () => {
221
+ try {
222
+ const result = await callTool(name, args, { root, cwd, version, protocolVersion });
223
+ reply(id, result);
224
+ } catch (e) {
225
+ // A tool that blows up is still a RESULT, not a protocol error: the
226
+ // agent is meant to read what went wrong and act on it, and a
227
+ // protocol error is swallowed by its client before it sees the words.
228
+ reply(id, {
229
+ content: [{ type: 'text', text: isExpected(e) ? messageOf(e) : `Stays Fixed could not finish that: ${messageOf(e)}` }],
230
+ isError: true,
231
+ });
232
+ }
233
+ });
234
+ return;
235
+ }
236
+
237
+ default:
238
+ replyError(id, RPC.methodNotFound, `This server does not handle "${method}".`);
239
+ }
240
+ }
241
+
242
+ // ── the stdio loop ────────────────────────────────────────────────────────
243
+
244
+ const decoder = new StringDecoder('utf8');
245
+ let buffer = '';
246
+ let overlong = false;
247
+
248
+ /** @type {() => void} */
249
+ let finish = () => {};
250
+ /** @type {Promise<void>} */
251
+ const done = new Promise((resolve) => {
252
+ finish = resolve;
253
+ });
254
+
255
+ let closing = false;
256
+
257
+ /** @param {string} why */
258
+ async function shutdown(why) {
259
+ if (closing) return;
260
+ closing = true;
261
+ log(`shutting down (${why})`);
262
+ // Wait for whatever is mid-flight so anything it opened gets closed properly.
263
+ // Past the grace period we stop waiting; a hung app must not hold the editor.
264
+ if (inFlight.size > 0) {
265
+ /** @type {NodeJS.Timeout|undefined} */
266
+ let timer;
267
+ const grace = new Promise((resolve) => {
268
+ timer = setTimeout(resolve, SHUTDOWN_GRACE_MS);
269
+ });
270
+ await Promise.race([Promise.all([...inFlight]), grace]);
271
+ if (timer) clearTimeout(timer);
272
+ }
273
+ /** @type {any} */ (process.stdout).write = realStdoutWrite;
274
+ finish();
275
+ }
276
+
277
+ /** @param {string} line */
278
+ function onLine(line) {
279
+ const trimmed = line.endsWith('\r') ? line.slice(0, -1) : line;
280
+ if (trimmed.trim() === '') return;
281
+ /** @type {any} */
282
+ let msg;
283
+ try {
284
+ msg = JSON.parse(trimmed);
285
+ } catch {
286
+ replyError(null, RPC.parseError, 'That line was not valid JSON. Each message must be one JSON object on one line.');
287
+ return;
288
+ }
289
+ // Handling is async; a throw escaping it would take the server down, so it is
290
+ // caught here and reported as an internal error against that one message.
291
+ Promise.resolve()
292
+ .then(() => handle(msg))
293
+ .catch((e) => {
294
+ const id = msg && typeof msg === 'object' && 'id' in msg ? msg.id : null;
295
+ log(`internal error: ${messageOf(e)}`);
296
+ if (id !== null && id !== undefined) replyError(id, RPC.internalError, `Something went wrong inside Stays Fixed: ${messageOf(e)}`);
297
+ });
298
+ }
299
+
300
+ process.stdin.on('data', (chunk) => {
301
+ buffer += decoder.write(/** @type {Buffer} */ (chunk));
302
+
303
+ for (;;) {
304
+ const nl = buffer.indexOf('\n');
305
+ if (nl === -1) break;
306
+ const line = buffer.slice(0, nl);
307
+ buffer = buffer.slice(nl + 1);
308
+ if (overlong) {
309
+ // We already gave up on this message; the newline ends it.
310
+ overlong = false;
311
+ continue;
312
+ }
313
+ onLine(line);
314
+ }
315
+
316
+ // A line that never ends is a broken stream, not a big request. Drop what we
317
+ // are holding rather than growing until the process runs out of memory.
318
+ if (!overlong && buffer.length > MAX_LINE_BYTES) {
319
+ overlong = true;
320
+ buffer = '';
321
+ replyError(null, RPC.parseError, 'That message was too long to read. Each message must be one JSON object on one line.');
322
+ }
323
+ });
324
+
325
+ process.stdin.on('error', (e) => {
326
+ log(`stdin error: ${messageOf(e)}`);
327
+ void shutdown('stdin error');
328
+ });
329
+ process.stdin.on('end', () => void shutdown('the client closed the connection'));
330
+ process.stdin.on('close', () => void shutdown('the client closed the connection'));
331
+
332
+ /** @type {(() => void)[]} */
333
+ const signalHandlers = [];
334
+ for (const signal of /** @type {NodeJS.Signals[]} */ (['SIGINT', 'SIGTERM'])) {
335
+ const onSignal = () => void shutdown(signal);
336
+ process.on(signal, onSignal);
337
+ signalHandlers.push(() => process.removeListener(signal, onSignal));
338
+ }
339
+
340
+ process.stdin.resume();
341
+ log(`Stays Fixed ${version} ready — talking MCP on stdin and stdout, saying everything else here.`);
342
+
343
+ await done;
344
+ for (const off of signalHandlers) off();
345
+ process.stdin.pause();
346
+ }
347
+
348
+ /**
349
+ * Anything that tries to print to stdout gets pushed to stderr instead.
350
+ * @param {any} chunk
351
+ * @param {any} [encoding]
352
+ * @param {any} [callback]
353
+ * @returns {boolean}
354
+ */
355
+ function divertToStderr(chunk, encoding, callback) {
356
+ return process.stderr.write(chunk, encoding, callback);
357
+ }
358
+
359
+ /**
360
+ * The block a person pastes into their editor's MCP settings.
361
+ *
362
+ * Written once, here, so `init`, the README and the capabilities reply cannot
363
+ * drift apart — and quoted verbatim by `staysfixed_capabilities`, which is the
364
+ * point: the agent installing this reads the exact block, not a description of it.
365
+ *
366
+ * @param {{command: string, args?: string[], cwd?: string}} opts
367
+ * @returns {string}
368
+ */
369
+ export function mcpConfigSnippet({ command, args = [], cwd }) {
370
+ /** @type {Record<string, any>} */
371
+ const server = { command, args };
372
+ if (cwd) server.cwd = cwd;
373
+ return JSON.stringify({ mcpServers: { staysfixed: server } }, null, 2);
374
+ }