@myrtilleio/redline 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/README.md +281 -0
- package/bin/redline.js +375 -0
- package/client/boot.js +146 -0
- package/client/check.js +926 -0
- package/client/composer.js +384 -0
- package/client/core.js +1610 -0
- package/client/devices.js +273 -0
- package/client/edits.js +389 -0
- package/client/keyboard.js +110 -0
- package/client/panel.js +222 -0
- package/client/pen.js +324 -0
- package/client/references.js +114 -0
- package/client/select.js +180 -0
- package/client/shots.js +357 -0
- package/client/variants.js +117 -0
- package/examples/demo-site/hero.svg +18 -0
- package/examples/demo-site/index.html +96 -0
- package/examples/demo-site/styles.css +64 -0
- package/integrations/claude-code/redline.md +27 -0
- package/package.json +42 -0
- package/src/account.js +101 -0
- package/src/bundle.js +33 -0
- package/src/demo.js +137 -0
- package/src/format.js +346 -0
- package/src/guards.js +108 -0
- package/src/inject.js +15 -0
- package/src/mcp.js +380 -0
- package/src/report.js +199 -0
- package/src/server.js +477 -0
- package/src/shots.js +311 -0
- package/src/store.js +544 -0
- package/src/sync.js +126 -0
- package/src/taste.js +80 -0
- package/src/verify.js +71 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Redline as an MCP server over stdio (newline-delimited JSON-RPC 2.0), without
|
|
2
|
+
// dependencies. stdout carries protocol messages only; logs go to stderr.
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { extname, join, resolve } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { formatMarkdown } from './format.js';
|
|
7
|
+
import { createRedlineServer } from './server.js';
|
|
8
|
+
import { Store } from './store.js';
|
|
9
|
+
import { describeVerify, openVerifier, verifyApprovals } from './verify.js';
|
|
10
|
+
|
|
11
|
+
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
12
|
+
|
|
13
|
+
const WAIT_DEFAULT = 240;
|
|
14
|
+
const WAIT_MAX = 1800;
|
|
15
|
+
const PROGRESS_EVERY = 15_000;
|
|
16
|
+
|
|
17
|
+
const TOOLS = [
|
|
18
|
+
{
|
|
19
|
+
name: 'wait_for_round',
|
|
20
|
+
description:
|
|
21
|
+
'Wait until the reviewer presses Send in the Redline overlay, then get their round: the same brief as `feedback`, with screenshots. ' +
|
|
22
|
+
'Returns at once if a round is already waiting. Use it to work in a loop without the human prompting you: ' +
|
|
23
|
+
'wait_for_round → fix each item and `resolve` → `finish_round` → wait_for_round again. ' +
|
|
24
|
+
'If it says the reviewer has not sent anything yet, call it again to keep waiting.',
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
timeout_seconds: { type: 'number', description: `How long to wait before returning "still waiting" (default ${WAIT_DEFAULT}, max ${WAIT_MAX})` },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'feedback',
|
|
34
|
+
description:
|
|
35
|
+
'Get the design feedback the reviewer left on the website with the Redline overlay: notes on components and pen marks, ' +
|
|
36
|
+
'each marked Good (keep), Fix (change) or Idea, with the element selector, text, styles, screen width and page state. ' +
|
|
37
|
+
'Call this before changing the site, then fix each item and call `resolve`.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
page: { type: 'string', description: 'Only items for this page path, e.g. "/pricing"' },
|
|
42
|
+
include_resolved: { type: 'boolean', description: 'Also include resolved items (default false)' },
|
|
43
|
+
format: { type: 'string', enum: ['markdown', 'json'], description: 'markdown (default) is an actionable brief; json is the raw data' },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'propose_variants',
|
|
49
|
+
description:
|
|
50
|
+
'Offer the reviewer options instead of guessing, when a mark could reasonably be fixed several ways (or when they asked for options): give 2–5 alternatives. Each is {label, css, html?, note?}: in `css`, `&` ' +
|
|
51
|
+
'stands for the item\'s element (e.g. "& { font-size: 32px } & h3 { … }"); `html` replaces the element\'s contents. ' +
|
|
52
|
+
'The reviewer flips between them on the live page and picks one; the pick then shows up in `feedback` for you to build.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
id: { type: 'string', description: 'Item id or pin number' },
|
|
57
|
+
variants: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
minItems: 2,
|
|
60
|
+
maxItems: 5,
|
|
61
|
+
items: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
label: { type: 'string', description: 'Two or three words, e.g. "Bold serif"' },
|
|
65
|
+
css: { type: 'string' },
|
|
66
|
+
html: { type: 'string' },
|
|
67
|
+
note: { type: 'string', description: 'One line: the idea behind it' },
|
|
68
|
+
},
|
|
69
|
+
required: ['label'],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ['id', 'variants'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'reply',
|
|
78
|
+
description: 'Answer the reviewer in an item\'s conversation (they asked something, or you explain what you did or why). They see it on the page.',
|
|
79
|
+
inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Item id or pin number' }, text: { type: 'string' } }, required: ['id', 'text'] },
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'finish_round',
|
|
83
|
+
description:
|
|
84
|
+
'Close the round when you have handled what the reviewer sent: a one-paragraph summary of what you changed, how you read the page ' +
|
|
85
|
+
'(its purpose, in two sentences, so they can correct you), and at most 3 questions you need answered. They see it as one report.',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
summary: { type: 'string' },
|
|
90
|
+
reading: { type: 'string', description: 'How you understand the page and what it is for' },
|
|
91
|
+
questions: { type: 'array', items: { type: 'string' }, maxItems: 3 },
|
|
92
|
+
},
|
|
93
|
+
required: ['summary'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'check_approvals',
|
|
98
|
+
description:
|
|
99
|
+
'Before finish_round: check that everything the reviewer approved (green marks, accepted fixes) still looks exactly as approved, ' +
|
|
100
|
+
'at every screen width it is guarded at, by photographing it now in headless Chrome. Records nothing: the reviewer only sees the check ' +
|
|
101
|
+
'Redline runs when you close the round, so fix anything this finds first. Returns what changed, in words, with the approved screenshots. ' +
|
|
102
|
+
'Uses the running review session; otherwise pass `dir` (static folder) or `url` (dev server).',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
dir: { type: 'string', description: 'Static site directory, when no review session is running' },
|
|
107
|
+
url: { type: 'string', description: 'Dev server URL, when no review session is running' },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'resolve',
|
|
113
|
+
description:
|
|
114
|
+
'Mark feedback items as done after changing the code. The reviewer sees a ✓ and your note on the pin in their browser. ' +
|
|
115
|
+
'Also resolve Good items, noting the pattern was kept.',
|
|
116
|
+
inputSchema: {
|
|
117
|
+
type: 'object',
|
|
118
|
+
properties: {
|
|
119
|
+
ids: { type: 'array', items: { type: 'string' }, description: 'Item ids (a_1b2c3d4e) or pin numbers ("3")' },
|
|
120
|
+
note: { type: 'string', description: 'One line: what you changed' },
|
|
121
|
+
target: { type: 'string', description: 'Optional CSS selector for the "after" screenshot, when your fix moved or restructured the element (e.g. "#faq")' },
|
|
122
|
+
},
|
|
123
|
+
required: ['ids', 'note'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'reopen',
|
|
128
|
+
description: 'Mark feedback items as open again.',
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: { ids: { type: 'array', items: { type: 'string' }, description: 'Item ids or pin numbers' } },
|
|
132
|
+
required: ['ids'],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'start_review',
|
|
137
|
+
description:
|
|
138
|
+
'Start a Redline review session and return the URL to give the reviewer. Pass `dir` to serve a static site folder ' +
|
|
139
|
+
'(with live reload), or `url` to put the overlay in front of a running dev server (e.g. http://localhost:5173).',
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: 'object',
|
|
142
|
+
properties: {
|
|
143
|
+
dir: { type: 'string', description: 'Static site directory to serve' },
|
|
144
|
+
url: { type: 'string', description: 'Dev server URL to proxy' },
|
|
145
|
+
port: { type: 'number', description: 'Port to listen on (default 4545)' },
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'stop_review',
|
|
151
|
+
description: 'Stop the review session started with start_review.',
|
|
152
|
+
inputSchema: { type: 'object', properties: {} },
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
export function createMcpHandler({ storeDir, log = () => {}, notify = () => {}, progressEvery = PROGRESS_EVERY, account = null }) {
|
|
157
|
+
const store = new Store(storeDir);
|
|
158
|
+
let session = null; // { server, url }
|
|
159
|
+
const inflight = new Map(); // request id -> AbortController, so a client can cancel a long wait
|
|
160
|
+
|
|
161
|
+
async function findAll(refs) {
|
|
162
|
+
const all = await store.all();
|
|
163
|
+
return refs.map((ref) => {
|
|
164
|
+
const a = all.find((x) => x.id === ref) || all.find((x) => String(x.n) === String(ref).replace(/^#/, ''));
|
|
165
|
+
if (!a) throw new Error(`No feedback item "${ref}". Call \`feedback\` to see current items.`);
|
|
166
|
+
return a;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Screenshots and reference images, as MCP image content the model can look at.
|
|
171
|
+
async function images(list) {
|
|
172
|
+
const out = [];
|
|
173
|
+
let budget = 12;
|
|
174
|
+
for (const a of list) {
|
|
175
|
+
const files = [
|
|
176
|
+
...Object.entries(a.shots || {}).filter(([k]) => k !== 'plain').map(([k, p]) => [`#${a.n} ${k === 'before' ? 'as reviewed (with the mark)' : k === 'proposed' ? 'with the reviewer\'s proposed change' : k}`, p]),
|
|
177
|
+
...(a.references || []).filter((r) => r.path).map((r) => [`#${a.n} reference image${r.name ? ` (${r.name})` : ''}`, r.path]),
|
|
178
|
+
];
|
|
179
|
+
for (const [caption, rel] of files) {
|
|
180
|
+
if (budget-- <= 0) return out;
|
|
181
|
+
const data = await readFile(join(storeDir, rel)).catch(() => null);
|
|
182
|
+
if (!data || data.length > 1.5 * 1024 * 1024) continue;
|
|
183
|
+
const ext = extname(rel).slice(1);
|
|
184
|
+
out.push({ type: 'text', text: caption }, { type: 'image', data: data.toString('base64'), mimeType: `image/${ext === 'jpg' ? 'jpeg' : ext}` });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const tools = {
|
|
191
|
+
async wait_for_round({ timeout_seconds: secs = WAIT_DEFAULT } = {}, { signal, progress } = {}) {
|
|
192
|
+
const timeoutMs = Math.min(Math.max(Number(secs) || WAIT_DEFAULT, 1), WAIT_MAX) * 1000;
|
|
193
|
+
const started = Date.now();
|
|
194
|
+
let last = started;
|
|
195
|
+
const round = await store.waitForRound({
|
|
196
|
+
timeoutMs,
|
|
197
|
+
signal,
|
|
198
|
+
// Progress keeps clients that reset their timeout on progress from giving up on a long wait.
|
|
199
|
+
onTick: () => {
|
|
200
|
+
if (Date.now() - last < progressEvery) return;
|
|
201
|
+
last = Date.now();
|
|
202
|
+
progress?.(Math.round((last - started) / 1000), Math.round(timeoutMs / 1000), 'Waiting for the reviewer to press Send');
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
if (!round) {
|
|
206
|
+
if (signal?.aborted) return 'Stopped waiting.';
|
|
207
|
+
return `The reviewer has not sent a round yet (waited ${Math.round((Date.now() - started) / 1000)}s). Call wait_for_round again to keep waiting.`;
|
|
208
|
+
}
|
|
209
|
+
const brief = await tools.feedback();
|
|
210
|
+
return [{ type: 'text', text: `Round ${round.n} is here: ${round.count} item${round.count === 1 ? '' : 's'} sent at ${round.sentAt}. Fix them, resolve each, call finish_round, then wait_for_round again.` }, ...brief];
|
|
211
|
+
},
|
|
212
|
+
async feedback({ page, include_resolved: includeResolved = false, format = 'markdown' } = {}) {
|
|
213
|
+
let list = await store.list(page);
|
|
214
|
+
if (!includeResolved) list = list.filter((a) => a.status !== 'resolved');
|
|
215
|
+
if (format === 'json') return JSON.stringify(list, null, 2);
|
|
216
|
+
const md = formatMarkdown(list, { includeResolved, rounds: (await store.rounds()).rounds, taste: await store.taste() }).replace(
|
|
217
|
+
/\n## /,
|
|
218
|
+
'\n\n## ',
|
|
219
|
+
);
|
|
220
|
+
return [{ type: 'text', text: md }, ...(await images(list))];
|
|
221
|
+
},
|
|
222
|
+
async reply({ id, text }) {
|
|
223
|
+
const a = await store.agentSays(id, text);
|
|
224
|
+
return `Replied on #${a.n}.`;
|
|
225
|
+
},
|
|
226
|
+
async finish_round({ summary, reading = '', questions = [] }) {
|
|
227
|
+
const round = await store.finishRound({ summary, reading, questions });
|
|
228
|
+
return `Closed round ${round.n}. The reviewer will check your fixes and may answer your questions.`;
|
|
229
|
+
},
|
|
230
|
+
async propose_variants({ id, variants }) {
|
|
231
|
+
const a = await store.proposeVariants(id, variants);
|
|
232
|
+
return `Proposed ${a.variants.length} options for #${a.n}: ${a.variants.map((v) => `${v.key} “${v.label}”`).join(', ')}. The reviewer will preview them and pick one.`;
|
|
233
|
+
},
|
|
234
|
+
async resolve({ ids, note, target }) {
|
|
235
|
+
const items = await findAll(ids);
|
|
236
|
+
for (const a of items) await store.resolve(a.id, note, target);
|
|
237
|
+
return items.map((a) => `✓ resolved #${a.n} (${a.id})`).join('\n');
|
|
238
|
+
},
|
|
239
|
+
async reopen({ ids }) {
|
|
240
|
+
const items = await findAll(ids);
|
|
241
|
+
for (const a of items) await store.update(a.id, { status: 'open' });
|
|
242
|
+
return items.map((a) => `↺ reopened #${a.n} (${a.id})`).join('\n');
|
|
243
|
+
},
|
|
244
|
+
async start_review({ dir, url, port = 4545 } = {}) {
|
|
245
|
+
if (session) return `Already running at ${session.url}. Call stop_review first to change it.`;
|
|
246
|
+
if (!dir === !url) throw new Error('Pass exactly one of `dir` (static folder) or `url` (dev server).');
|
|
247
|
+
const target = url && new URL(/^https?:\/\//.test(url) ? url : `http://${url}`).origin;
|
|
248
|
+
const server = createRedlineServer(dir ? { root: resolve(dir), storeDir, log } : { target, storeDir, log });
|
|
249
|
+
await new Promise((ok, fail) => {
|
|
250
|
+
server.once('error', (err) => fail(err.code === 'EADDRINUSE' ? new Error(`Port ${port} is in use; pass another port.`) : err));
|
|
251
|
+
server.listen(port, '127.0.0.1', ok);
|
|
252
|
+
});
|
|
253
|
+
session = { server, url: `http://127.0.0.1:${server.address().port}/` };
|
|
254
|
+
return (
|
|
255
|
+
`Review session running at ${session.url} (${dir ? `serving ${resolve(dir)}` : `proxying ${target}`}).\n` +
|
|
256
|
+
'Give the reviewer this URL. In the page: P draws on anything (or click something to say why), Send hands you the round, ' +
|
|
257
|
+
'Review is where they accept or redo your fixes. Call `wait_for_round` to be handed the round as soon as they send.'
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
async check_approvals({ dir, url } = {}) {
|
|
261
|
+
let v = session && { store: session.server.store, shots: session.server.shots, close: async () => {} };
|
|
262
|
+
if (!v) {
|
|
263
|
+
if (!dir === !url) throw new Error('No review session is running: pass `dir` (static folder) or `url` (dev server) to check against.');
|
|
264
|
+
v = await openVerifier(dir ? { root: resolve(dir), storeDir } : { target: new URL(/^https?:\/\//.test(url) ? url : `http://${url}`).origin, storeDir });
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
if (!v.shots.available) throw new Error('Checking approvals needs Chrome or Chromium (set CHROME_PATH) and Node 22+.');
|
|
268
|
+
const results = await verifyApprovals(v);
|
|
269
|
+
if (!results.length) return 'Nothing approved yet: no green marks or accepted fixes to check.';
|
|
270
|
+
const { lines, summary, bad } = describeVerify(results, { storeDir: '.redline' });
|
|
271
|
+
const text = `${summary}.\n${lines.join('\n')}\n\n${bad ? 'Restore what changed before you call finish_round.' : 'All approvals hold: safe to close the round.'}`;
|
|
272
|
+
// The approved look, as images, for what changed.
|
|
273
|
+
const pics = [];
|
|
274
|
+
for (const r of results.filter((x) => x.status !== 'held' && x.baseShot).slice(0, 4)) {
|
|
275
|
+
const data = await readFile(join(storeDir, r.baseShot)).catch(() => null);
|
|
276
|
+
if (data) pics.push({ type: 'text', text: `#${r.a.n} as approved:` }, { type: 'image', data: data.toString('base64'), mimeType: 'image/png' });
|
|
277
|
+
}
|
|
278
|
+
return [{ type: 'text', text }, ...pics];
|
|
279
|
+
} finally {
|
|
280
|
+
await v.close();
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
async stop_review() {
|
|
284
|
+
if (!session) return 'No review session is running.';
|
|
285
|
+
await new Promise((ok) => session.server.close(ok));
|
|
286
|
+
session = null;
|
|
287
|
+
return 'Review session stopped.';
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/** Handle one JSON-RPC message; returns the response, or null for notifications. */
|
|
292
|
+
async function handle(msg) {
|
|
293
|
+
const { id, method, params = {} } = msg;
|
|
294
|
+
const reply = (result) => (id === undefined ? null : { jsonrpc: '2.0', id, result });
|
|
295
|
+
const error = (code, message) => (id === undefined ? null : { jsonrpc: '2.0', id, error: { code, message } });
|
|
296
|
+
|
|
297
|
+
switch (method) {
|
|
298
|
+
case 'initialize': {
|
|
299
|
+
const asked = params.protocolVersion;
|
|
300
|
+
return reply({
|
|
301
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
302
|
+
capabilities: { tools: {} },
|
|
303
|
+
serverInfo: { name: 'redline', version: '0.1.0' },
|
|
304
|
+
instructions:
|
|
305
|
+
'Redline collects design feedback a human leaves directly on a website (pen marks and boxes), in rounds. ' +
|
|
306
|
+
'Use start_review to give them a URL, wait_for_round to be handed each round as soon as they press Send (feedback reads it on demand), ' +
|
|
307
|
+
'resolve after each fix, reply under a mark to ask, check_approvals before finish_round (fix anything it finds), ' +
|
|
308
|
+
'and finish_round when you are done; then wait_for_round again for the next round. ' +
|
|
309
|
+
'The brief starts with the reviewer\'s taste (all of it in .redline/TASTE.md): never repeat a rejected fix. ' +
|
|
310
|
+
'What they approved is checked after every round; an item marked "Changed since the reviewer approved it" must be restored.',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
case 'ping':
|
|
314
|
+
return reply({});
|
|
315
|
+
case 'tools/list':
|
|
316
|
+
return reply({ tools: TOOLS });
|
|
317
|
+
case 'tools/call': {
|
|
318
|
+
const fn = tools[params.name];
|
|
319
|
+
if (!fn) return error(-32602, `Unknown tool: ${params.name}`);
|
|
320
|
+
// No valid Redline account: every tool says how to get one (the agent can relay it).
|
|
321
|
+
if (account) {
|
|
322
|
+
const acc = await account;
|
|
323
|
+
if (!acc.ok) return reply({ content: [{ type: 'text', text: acc.message }], isError: true });
|
|
324
|
+
}
|
|
325
|
+
const abort = new AbortController();
|
|
326
|
+
if (id !== undefined) inflight.set(id, abort);
|
|
327
|
+
const token = params._meta?.progressToken;
|
|
328
|
+
const progress =
|
|
329
|
+
token === undefined
|
|
330
|
+
? undefined
|
|
331
|
+
: (done, total, message) => notify({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: token, progress: done, total, message } });
|
|
332
|
+
try {
|
|
333
|
+
const out = await fn(params.arguments || {}, { signal: abort.signal, progress });
|
|
334
|
+
// A cancelled request gets no response (the client has stopped listening for it).
|
|
335
|
+
if (abort.signal.aborted) return null;
|
|
336
|
+
return reply({ content: Array.isArray(out) ? out : [{ type: 'text', text: out }] });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
// Tool failures are results the model can read and recover from, not protocol errors.
|
|
339
|
+
return reply({ content: [{ type: 'text', text: err.message }], isError: true });
|
|
340
|
+
} finally {
|
|
341
|
+
inflight.delete(id);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
case 'notifications/cancelled':
|
|
345
|
+
inflight.get(params.requestId)?.abort();
|
|
346
|
+
return null;
|
|
347
|
+
default:
|
|
348
|
+
if (method && method.startsWith('notifications/')) return null;
|
|
349
|
+
return error(-32601, `Method not found: ${method}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
handle,
|
|
355
|
+
close: () => {
|
|
356
|
+
for (const a of inflight.values()) a.abort();
|
|
357
|
+
return tools.stop_review();
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function runMcpServer({ storeDir, account = null }) {
|
|
363
|
+
const log = (m) => process.stderr.write(`[redline] ${m}\n`);
|
|
364
|
+
const mcp = createMcpHandler({ storeDir, log, account, notify: (msg) => process.stdout.write(JSON.stringify(msg) + '\n') });
|
|
365
|
+
if (account) account.then((acc) => log(acc.ok ? (acc.user ? `signed in as ${acc.user.email}` : 'account check skipped') : 'not signed in: tools will explain how to get a key'));
|
|
366
|
+
const rl = createInterface({ input: process.stdin });
|
|
367
|
+
rl.on('line', async (line) => {
|
|
368
|
+
if (!line.trim()) return;
|
|
369
|
+
let msg;
|
|
370
|
+
try {
|
|
371
|
+
msg = JSON.parse(line);
|
|
372
|
+
} catch {
|
|
373
|
+
return process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n');
|
|
374
|
+
}
|
|
375
|
+
const res = await mcp.handle(msg);
|
|
376
|
+
if (res) process.stdout.write(JSON.stringify(res) + '\n');
|
|
377
|
+
});
|
|
378
|
+
rl.on('close', () => mcp.close().then(() => process.exit(0)));
|
|
379
|
+
log(`MCP server ready (feedback in ${storeDir})`);
|
|
380
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// The round, as proof you can hand to someone who wasn't there: a PM, a client,
|
|
2
|
+
// a PR reviewer. What was asked, what the agent did, before and after, what you
|
|
3
|
+
// accepted or sent back, and what the guards caught. One self-contained HTML page
|
|
4
|
+
// (images inlined, a drag slider per fix), or Markdown for a PR comment.
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
9
|
+
const plain = (s) => String(s ?? '').replace(/`/g, '');
|
|
10
|
+
|
|
11
|
+
/** What happened to each item in round `n` (default: the last one that came back). */
|
|
12
|
+
export function reportModel(list, rounds, n) {
|
|
13
|
+
const all = (rounds && rounds.rounds) || [];
|
|
14
|
+
const round = n ? all.find((r) => r.n === Number(n)) : [...all].reverse().find((r) => r.returnedAt) || all.at(-1);
|
|
15
|
+
if (!round) return null;
|
|
16
|
+
const since = round.returnedAt || round.sentAt;
|
|
17
|
+
const guardNews = (a) => a.guard && a.guard.now && a.guard.now.at >= since && a.guard.now.status !== 'held';
|
|
18
|
+
const items = [];
|
|
19
|
+
const held = [];
|
|
20
|
+
for (const a of list) {
|
|
21
|
+
const sentIn = a.rounds || (a.round ? [a.round] : []);
|
|
22
|
+
const inRound = sentIn.includes(round.n) || (a.draft && (a.history || []).length) || guardNews(a);
|
|
23
|
+
if (!inRound) continue;
|
|
24
|
+
const g = a.guard || {};
|
|
25
|
+
if (guardNews(a)) {
|
|
26
|
+
items.push({ a, kind: 'guard', status: g.sentBack || a.draft ? 'sent-back' : g.now.status === 'missing' ? 'gone' : 'changed',
|
|
27
|
+
diff: g.now.diff || [], before: g.now.baseShot || (g.base && g.base.shot), after: g.now.shot });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (a.verdict === 'good' && !a.accepted && a.status !== 'resolved') {
|
|
31
|
+
if (g.now && g.now.status === 'held') held.push(a);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Sent again in a later round: this round's fix was sent back.
|
|
35
|
+
const redo = (a.draft && (a.history || []).length) || a.round > round.n;
|
|
36
|
+
const status = a.accepted ? 'accepted' : redo ? 'redo' : a.status === 'resolved' ? 'to-check' : 'with-agent';
|
|
37
|
+
const reply = redo ? [...(a.thread || [])].reverse().find((m) => m.by === 'you') : null;
|
|
38
|
+
items.push({ a, kind: 'fix', status, asked: a.comment, did: a.resolution || (a.history || []).at(-1), why: reply && reply.text,
|
|
39
|
+
before: (a.shots || {}).before, after: (a.shots || {}).after });
|
|
40
|
+
}
|
|
41
|
+
const order = { changed: 0, gone: 0, 'to-check': 1, redo: 2, 'sent-back': 3, 'with-agent': 4, accepted: 5 };
|
|
42
|
+
items.sort((x, y) => order[x.status] - order[y.status] || (x.a.n || 0) - (y.a.n || 0));
|
|
43
|
+
const judged = (round.accepted || 0) + (round.redone || 0);
|
|
44
|
+
return { round, items, held, rate: judged ? (round.accepted || 0) / judged : null, judged };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LABEL = {
|
|
48
|
+
accepted: 'Accepted', redo: 'Redo', 'to-check': 'To check', 'with-agent': 'With the agent',
|
|
49
|
+
changed: 'Approval changed', gone: 'Approval gone', 'sent-back': 'Sent back',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// An inlined PNG, with its size (from the header) to decide between a slider and side by side.
|
|
53
|
+
async function image(dir, rel) {
|
|
54
|
+
if (!rel) return null;
|
|
55
|
+
try {
|
|
56
|
+
const buf = await readFile(join(dir, rel));
|
|
57
|
+
return { src: `data:image/png;base64,${buf.toString('base64')}`, w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Markdown for a PR comment or a chat: text first; images as paths into .redline/. */
|
|
64
|
+
export function reportMarkdown(model, { storeDir = '.redline' } = {}) {
|
|
65
|
+
storeDir = storeDir.replace(/\/+$/, '');
|
|
66
|
+
if (!model) return 'No round yet.\n';
|
|
67
|
+
const { round, items, held, rate } = model;
|
|
68
|
+
const out = [`## Design review · round ${round.n}`, ''];
|
|
69
|
+
const facts = [
|
|
70
|
+
rate !== null ? `**${Math.round(rate * 100)}%** accepted first time` : null,
|
|
71
|
+
`${items.filter((i) => i.kind === 'fix').length} fix${items.filter((i) => i.kind === 'fix').length === 1 ? '' : 'es'}`,
|
|
72
|
+
items.some((i) => i.kind === 'guard') ? `**${items.filter((i) => i.kind === 'guard').length} approval${items.filter((i) => i.kind === 'guard').length === 1 ? '' : 's'} changed**` : null,
|
|
73
|
+
held.length ? `${held.length} approval${held.length === 1 ? '' : 's'} held` : null,
|
|
74
|
+
].filter(Boolean);
|
|
75
|
+
out.push(facts.join(' · '), '');
|
|
76
|
+
if (round.summary) out.push(`> ${round.summary}`, '');
|
|
77
|
+
for (const i of items) {
|
|
78
|
+
const where = i.a.target ? `\`${i.a.target.selector}\`` : `page \`${i.a.page}\``;
|
|
79
|
+
out.push(`**${LABEL[i.status]}** · ${where}`);
|
|
80
|
+
if (i.kind === 'fix') {
|
|
81
|
+
if (i.asked) out.push(`- Asked: ${i.asked}`);
|
|
82
|
+
if (i.did) out.push(`- Done: ${i.did}`);
|
|
83
|
+
if (i.why) out.push(`- Still wrong: ${i.why}`);
|
|
84
|
+
} else for (const d of i.diff) out.push(`- ${plain(d)}`);
|
|
85
|
+
const shots = [i.before && `[${i.kind === 'guard' ? 'as approved' : 'before'}](${storeDir}/${i.before})`, i.after && `[${i.kind === 'guard' ? 'now' : 'after'}](${storeDir}/${i.after})`].filter(Boolean);
|
|
86
|
+
if (shots.length) out.push(`- ${shots.join(' · ')}`);
|
|
87
|
+
out.push('');
|
|
88
|
+
}
|
|
89
|
+
out.push('_Reviewed with Redline: mark it, send it, check it, accept it._', '');
|
|
90
|
+
return out.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One self-contained page: open it anywhere, send it to anyone. */
|
|
94
|
+
export async function reportHtml(model, { storeDir, title = 'Design review' } = {}) {
|
|
95
|
+
if (!model) return '<!doctype html><meta charset="utf-8"><title>Redline</title><p>No round yet.</p>';
|
|
96
|
+
const { round, items, held, rate } = model;
|
|
97
|
+
const pct = rate === null ? null : Math.round(rate * 100);
|
|
98
|
+
const C = 2 * Math.PI * 44;
|
|
99
|
+
const cards = [];
|
|
100
|
+
for (const i of items) {
|
|
101
|
+
const [before, after] = await Promise.all([image(storeDir, i.before), image(storeDir, i.after)]);
|
|
102
|
+
const where = i.a.target ? i.a.target.selector : i.a.page;
|
|
103
|
+
const labels = i.kind === 'guard' ? ['As approved', 'Now'] : ['Before', 'After'];
|
|
104
|
+
// Same framing: one picture with a slider. The fix resized it: side by side.
|
|
105
|
+
const alike = before && after && Math.abs(before.w - after.w) / before.w < 0.1 && Math.abs(before.h - after.h) / before.h < 0.2;
|
|
106
|
+
const media = alike
|
|
107
|
+
? `<div class="cmp" style="--at:50%;max-width:${Math.max(before.w, after.w)}px"><img src="${after.src}" alt="${labels[1]}"><img class="top" src="${before.src}" alt="${labels[0]}"><i></i>
|
|
108
|
+
<span class="l">${labels[0]}</span><span class="r">${labels[1]}</span></div>`
|
|
109
|
+
: before && after
|
|
110
|
+
? `<div class="sbs"><figure><img src="${before.src}" alt="" style="max-width:${before.w}px"><figcaption>${labels[0]}</figcaption></figure><figure><img src="${after.src}" alt="" style="max-width:${after.w}px"><figcaption>${labels[1]}</figcaption></figure></div>`
|
|
111
|
+
: before || after ? `<img class="solo" src="${(before || after).src}" alt="" style="max-width:${(before || after).w}px">` : '';
|
|
112
|
+
const text = i.kind === 'fix'
|
|
113
|
+
? `${i.asked ? `<p class="asked"><b>Asked</b>${esc(i.asked)}</p>` : ''}${i.did ? `<p class="did"><b>Done</b>${esc(i.did)}</p>` : ''}${i.why ? `<p class="why"><b>Still wrong</b>${esc(i.why)}</p>` : ''}`
|
|
114
|
+
: `<p class="asked"><b>Approved</b>${esc(i.a.comment || 'Kept as it was')}</p><ul class="diff">${i.diff.map((d) => `<li>${esc(plain(d))}</li>`).join('')}</ul>`;
|
|
115
|
+
cards.push(`<article class="item s-${i.status}">
|
|
116
|
+
<header><span class="pill">${LABEL[i.status]}</span><code>${esc(where)}</code></header>
|
|
117
|
+
${text}${media}
|
|
118
|
+
</article>`);
|
|
119
|
+
}
|
|
120
|
+
return `<!doctype html>
|
|
121
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
122
|
+
<title>${esc(title)} · round ${round.n}</title>
|
|
123
|
+
<style>
|
|
124
|
+
:root { --bg: #fafaf9; --card: #fff; --text: #18181b; --muted: #71717a; --line: #e7e5e4; --good: #16a34a; --fix: #e11d48; --idea: #d97706; --soft: #f4f4f5; }
|
|
125
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0c0c0e; --card: #151518; --text: #f4f4f5; --muted: #a1a1aa; --line: #27272a; --good: #22c55e; --fix: #f43f5e; --idea: #f59e0b; --soft: #1c1c20; } }
|
|
126
|
+
* { box-sizing: border-box; }
|
|
127
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
|
|
128
|
+
main { max-width: 880px; margin: 0 auto; padding: 56px 20px 80px; }
|
|
129
|
+
.kicker { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
|
130
|
+
h1 { font-size: clamp(26px, 4.2vw, 38px); text-wrap: balance; line-height: 1.08; letter-spacing: -.03em; margin: 10px 0 18px; font-weight: 750; }
|
|
131
|
+
.hero { display: grid; grid-template-columns: 1fr auto; gap: 28px; align-items: center; margin-bottom: 40px; }
|
|
132
|
+
.summary { font-size: 17px; color: var(--muted); margin: 0; max-width: 56ch; }
|
|
133
|
+
.facts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; }
|
|
134
|
+
.facts span { font-size: 13px; padding: 4px 10px; border-radius: 999px; background: var(--soft); border: 1px solid var(--line); }
|
|
135
|
+
.facts .bad { color: var(--fix); border-color: color-mix(in srgb, var(--fix) 35%, transparent); }
|
|
136
|
+
.ring { position: relative; width: 132px; height: 132px; display: grid; place-items: center; }
|
|
137
|
+
.ring svg { position: absolute; inset: 0; transform: rotate(-90deg); }
|
|
138
|
+
.ring circle { fill: none; stroke-width: 9; } .ring .t { stroke: var(--line); } .ring .f { stroke: var(--good); stroke-linecap: round; }
|
|
139
|
+
.ring b { font-size: 32px; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
|
|
140
|
+
.ring small { position: absolute; bottom: -24px; width: 160px; text-align: center; font-size: 12px; color: var(--muted); }
|
|
141
|
+
.items { display: grid; gap: 18px; }
|
|
142
|
+
.item { background: var(--card); border: 1px solid var(--line); border-radius: 18px; padding: 18px; display: grid; gap: 12px; }
|
|
143
|
+
.item header { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
144
|
+
.item code { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
145
|
+
.pill { flex: none; font-size: 12px; font-weight: 650; padding: 3px 10px; border-radius: 999px; background: var(--soft); }
|
|
146
|
+
.s-accepted .pill { background: color-mix(in srgb, var(--good) 14%, transparent); color: var(--good); }
|
|
147
|
+
.s-redo .pill, .s-to-check .pill { background: color-mix(in srgb, var(--idea) 16%, transparent); color: var(--idea); }
|
|
148
|
+
.s-changed .pill, .s-gone .pill, .s-sent-back .pill { background: color-mix(in srgb, var(--fix) 14%, transparent); color: var(--fix); }
|
|
149
|
+
.item p { margin: 0; display: grid; grid-template-columns: 92px 1fr; gap: 12px; }
|
|
150
|
+
.item p b { font-size: 12px; font-weight: 600; color: var(--muted); padding-top: 2px; }
|
|
151
|
+
.why { color: var(--idea); }
|
|
152
|
+
.diff { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; font: 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
153
|
+
.diff li { padding: 6px 10px; border-left: 2px solid var(--fix); background: color-mix(in srgb, var(--fix) 7%, transparent); border-radius: 6px; }
|
|
154
|
+
.cmp { position: relative; width: 100%; margin: 0 auto; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); background: #fff; cursor: ew-resize; user-select: none; touch-action: none; }
|
|
155
|
+
.cmp img { display: block; width: 100%; height: auto; pointer-events: none; }
|
|
156
|
+
.cmp .top { position: absolute; inset: 0; height: 100%; object-fit: cover; object-position: top left; clip-path: inset(0 calc(100% - var(--at)) 0 0); }
|
|
157
|
+
.cmp i { position: absolute; top: 0; bottom: 0; left: var(--at); width: 2px; margin-left: -1px; background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.25); }
|
|
158
|
+
.cmp i::after { content: '⇆'; position: absolute; top: 50%; left: 50%; translate: -50% -50%; width: 30px; height: 30px; border-radius: 50%; background: #fff; color: #18181b; display: grid; place-items: center; font-size: 14px; box-shadow: 0 2px 10px rgba(0,0,0,.3); }
|
|
159
|
+
.cmp span { position: absolute; top: 10px; font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 6px; background: rgba(24,24,27,.75); color: #fff; }
|
|
160
|
+
.cmp .l { left: 10px; } .cmp .r { right: 10px; }
|
|
161
|
+
.sbs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: start; }
|
|
162
|
+
.sbs figure { margin: 0; display: grid; gap: 6px; } .sbs img { width: 100%; border-radius: 10px; border: 1px solid var(--line); background: #fff; }
|
|
163
|
+
.sbs figcaption { font-size: 12px; color: var(--muted); }
|
|
164
|
+
.solo { width: 100%; border-radius: 12px; border: 1px solid var(--line); }
|
|
165
|
+
.held { margin-top: 28px; font-size: 14px; color: var(--muted); display: flex; gap: 10px; align-items: flex-start; }
|
|
166
|
+
.held i { flex: none; width: 14px; height: 16px; margin-top: 3px; background: var(--good); clip-path: polygon(50% 0, 100% 18%, 100% 55%, 50% 100%, 0 55%, 0 18%); }
|
|
167
|
+
footer { margin-top: 48px; font-size: 12px; color: var(--muted); }
|
|
168
|
+
@media (max-width: 600px) { .hero { grid-template-columns: 1fr; } .ring { margin-bottom: 20px; } .item p { grid-template-columns: 1fr; gap: 2px; } }
|
|
169
|
+
</style></head>
|
|
170
|
+
<body><main>
|
|
171
|
+
<section class="hero">
|
|
172
|
+
<div>
|
|
173
|
+
<div class="kicker">${esc(title)} · round ${round.n}</div>
|
|
174
|
+
<h1>${esc(round.summary || 'What changed this round')}</h1>
|
|
175
|
+
${round.reading ? `<p class="summary">${esc(round.readingCorrection || round.reading)}</p>` : ''}
|
|
176
|
+
<div class="facts">
|
|
177
|
+
<span>${items.filter((i) => i.kind === 'fix').length} fixes</span>
|
|
178
|
+
${items.filter((i) => i.status === 'accepted').length ? `<span>${items.filter((i) => i.status === 'accepted').length} accepted</span>` : ''}
|
|
179
|
+
${items.filter((i) => i.status === 'redo').length ? `<span>${items.filter((i) => i.status === 'redo').length} to redo</span>` : ''}
|
|
180
|
+
${items.some((i) => i.kind === 'guard') ? `<span class="bad">${items.filter((i) => i.kind === 'guard').length} approval${items.filter((i) => i.kind === 'guard').length === 1 ? '' : 's'} changed</span>` : ''}
|
|
181
|
+
${held.length ? `<span>${held.length} approval${held.length === 1 ? '' : 's'} held</span>` : ''}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
${pct === null ? '' : `<div class="ring"><svg viewBox="0 0 100 100"><circle class="t" cx="50" cy="50" r="44"/><circle class="f" cx="50" cy="50" r="44" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${(C * (1 - rate)).toFixed(1)}"/></svg><b>${pct}%</b><small>accepted first time</small></div>`}
|
|
185
|
+
</section>
|
|
186
|
+
<section class="items">${cards.join('\n')}</section>
|
|
187
|
+
${held.length ? `<p class="held"><i></i><span>Still exactly as approved: ${held.map((a) => `<code>${esc(a.target ? a.target.selector : a.page)}</code>`).join(', ')}</span></p>` : ''}
|
|
188
|
+
<footer>Reviewed with Redline: mark it, send it, check it, accept it. Drag a picture to compare.</footer>
|
|
189
|
+
</main>
|
|
190
|
+
<script>
|
|
191
|
+
for (const c of document.querySelectorAll('.cmp')) {
|
|
192
|
+
const at = (e) => { const r = c.getBoundingClientRect(); c.style.setProperty('--at', Math.max(0, Math.min(100, ((e.clientX - r.left) / r.width) * 100)) + '%'); };
|
|
193
|
+
c.addEventListener('pointerdown', (e) => { c.setPointerCapture(e.pointerId); at(e); c.onpointermove = at; });
|
|
194
|
+
c.addEventListener('pointerup', () => (c.onpointermove = null));
|
|
195
|
+
}
|
|
196
|
+
</script>
|
|
197
|
+
</body></html>
|
|
198
|
+
`;
|
|
199
|
+
}
|