reviewzy 0.1.0 → 0.2.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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The word-level diff between the anchor text and the proposed text, computed server-side at
3
+ * render time so the marks need no client js. Whitespace-token based: the split keeps whitespace
4
+ * runs as their own tokens, so both streams re-join to their exact input, bytes included.
5
+ */
6
+
7
+ /** One token of a diffed string, carrying its mark. Whitespace runs are tokens too, always `same` when both sides share them. */
8
+ export type WordDiffToken = {
9
+ readonly text: string;
10
+ readonly kind: "same" | "removed" | "added";
11
+ };
12
+
13
+ export type WordDiff = {
14
+ /** The anchor text tokenized: tokens the proposal does not keep are `removed`, the rest `same`. */
15
+ readonly before: readonly WordDiffToken[];
16
+ /** The proposal tokenized: tokens the anchor did not have are `added`, the rest `same`. */
17
+ readonly after: readonly WordDiffToken[];
18
+ };
19
+
20
+ /** Splits on whitespace boundaries, keeping the runs: `"a b"` becomes `["a", " ", "b"]`. */
21
+ function tokenize(text: string): string[] {
22
+ return text.split(/(\s+)/).filter((token) => token !== "");
23
+ }
24
+
25
+ /**
26
+ * The LCS table is quadratic in token count, and the anchor is agent-controlled with no length
27
+ * cap, so past this cell ceiling the walk is skipped: each side reads fully changed, which is
28
+ * honest (no word is claimed unchanged) and renders in linear time.
29
+ */
30
+ const MAX_LCS_CELLS = 1_000_000;
31
+
32
+ /**
33
+ * A longest-common-subsequence walk over the two token streams: a token both sides share is
34
+ * `same`, one only the anchor has is `removed`, one only the proposal has is `added`. Identical
35
+ * strings produce all-`same` streams, so nothing renders a mark.
36
+ */
37
+ export function diffWords(before: string, after: string): WordDiff {
38
+ const a = tokenize(before);
39
+ const b = tokenize(after);
40
+
41
+ if (a.length * b.length > MAX_LCS_CELLS) {
42
+ return {
43
+ before: a.map((text) => ({ text, kind: "removed" as const })),
44
+ after: b.map((text) => ({ text, kind: "added" as const })),
45
+ };
46
+ }
47
+
48
+ // dp[i][j] is the LCS length of a[i:] against b[j:], filled bottom-up so the walk can pick the
49
+ // branch that keeps the longest match. One flat typed array: 8 bytes a cell, no nested rows.
50
+ const stride = b.length + 1;
51
+ const dp = new Float64Array((a.length + 1) * stride);
52
+ for (let i = a.length - 1; i >= 0; i--) {
53
+ for (let j = b.length - 1; j >= 0; j--) {
54
+ const below = dp[(i + 1) * stride + j]!;
55
+ const right = dp[i * stride + j + 1]!;
56
+ dp[i * stride + j] = a[i] === b[j] ? below + 1 : Math.max(below, right);
57
+ }
58
+ }
59
+
60
+ const beforeTokens: WordDiffToken[] = [];
61
+ const afterTokens: WordDiffToken[] = [];
62
+ let i = 0;
63
+ let j = 0;
64
+ while (i < a.length && j < b.length) {
65
+ if (a[i] === b[j]) {
66
+ beforeTokens.push({ text: a[i]!, kind: "same" });
67
+ afterTokens.push({ text: b[j]!, kind: "same" });
68
+ i++;
69
+ j++;
70
+ } else if (dp[(i + 1) * stride + j]! >= dp[i * stride + j + 1]!) {
71
+ // Skipping the anchor's token keeps at least as long a match: it is removed.
72
+ beforeTokens.push({ text: a[i]!, kind: "removed" });
73
+ i++;
74
+ } else {
75
+ afterTokens.push({ text: b[j]!, kind: "added" });
76
+ j++;
77
+ }
78
+ }
79
+ while (i < a.length) {
80
+ beforeTokens.push({ text: a[i]!, kind: "removed" });
81
+ i++;
82
+ }
83
+ while (j < b.length) {
84
+ afterTokens.push({ text: b[j]!, kind: "added" });
85
+ j++;
86
+ }
87
+
88
+ return { before: beforeTokens, after: afterTokens };
89
+ }
@@ -240,3 +240,115 @@ export function listRevisions(store: Store, entryId: string): RevisionRow[] {
240
240
  )
241
241
  .all(entryId) as RevisionRow[];
242
242
  }
243
+
244
+ /**
245
+ * The tolerant parse for `entries.images`, mirroring `parseConstraints`: the column holds foreign
246
+ * data (it is also fed by the wire), and an unparseable or shape-broken value must read as no
247
+ * images, never as a 500. Non-string items are dropped rather than propagated.
248
+ */
249
+ export function parseEntryImages(json: string): string[] {
250
+ let parsed: unknown;
251
+ try {
252
+ parsed = JSON.parse(json);
253
+ } catch {
254
+ return [];
255
+ }
256
+ if (!Array.isArray(parsed)) return [];
257
+ return parsed.filter((item): item is string => typeof item === "string");
258
+ }
259
+
260
+ /** The per-entry image cap `docs/mcp-contract.md` pins for `images`; the wire's boundary carries the same number in its own layer. */
261
+ const MAX_ENTRY_IMAGES = 8;
262
+
263
+ /** Everything the image writes refuse; the dashboard's upload and remove routes format the messages. */
264
+ export type ImageWriteRefusal =
265
+ | { readonly kind: "unknown" }
266
+ | { readonly kind: "too_many" }
267
+ | { readonly kind: "no_image"; readonly index: number };
268
+
269
+ /**
270
+ * Appends one image the human uploaded to the entry's image list. A note-like write, not a save:
271
+ * it works on every status, writes no revision row, changes no status, and fires no status event
272
+ * (a waiter cares about approval, not about a new screenshot). `updated_at` moves so the row
273
+ * reflects the write. The data url already passed the route's mime and size checks, and the
274
+ * stored list must stay inside the contract's image cap.
275
+ *
276
+ * The read and the write share one immediate transaction: a deferred one would let two racing
277
+ * uploads (a double submit) both read the old list and drop one image on the later write, while
278
+ * the immediate lock serializes them at the read.
279
+ */
280
+ export function appendEntryImage(
281
+ store: Store,
282
+ id: string,
283
+ dataUrl: string,
284
+ ): { readonly ok: true } | { readonly ok: false; readonly refusal: ImageWriteRefusal } {
285
+ const write = store.db.transaction(() => {
286
+ const row = store.db.query("SELECT images FROM entries WHERE id = ?").get(id) as { images: string } | null;
287
+ if (row === null) return { ok: false as const, refusal: { kind: "unknown" as const } };
288
+
289
+ const images = parseEntryImages(row.images);
290
+ if (images.length >= MAX_ENTRY_IMAGES) {
291
+ return { ok: false as const, refusal: { kind: "too_many" as const } };
292
+ }
293
+ images.push(dataUrl);
294
+ store.db.run("UPDATE entries SET images = ?, updated_at = ? WHERE id = ?", [
295
+ JSON.stringify(images),
296
+ Date.now(),
297
+ id,
298
+ ]);
299
+ return { ok: true as const };
300
+ });
301
+ return write.immediate();
302
+ }
303
+
304
+ /**
305
+ * Removes one image by its index in the stored array. Same write class as `appendEntryImage`: no
306
+ * revision, no status change, no event; `updated_at` moves, and the read + write share one
307
+ * immediate transaction for the same double-submit race the append guards. An index past either
308
+ * end is a refusal carrying the index the route's message names.
309
+ */
310
+ export function removeEntryImage(
311
+ store: Store,
312
+ id: string,
313
+ index: number,
314
+ ): { readonly ok: true } | { readonly ok: false; readonly refusal: ImageWriteRefusal } {
315
+ const write = store.db.transaction(() => {
316
+ const row = store.db.query("SELECT images FROM entries WHERE id = ?").get(id) as { images: string } | null;
317
+ if (row === null) return { ok: false as const, refusal: { kind: "unknown" as const } };
318
+
319
+ const images = parseEntryImages(row.images);
320
+ if (index < 0 || index >= images.length) {
321
+ return { ok: false as const, refusal: { kind: "no_image" as const, index } };
322
+ }
323
+ images.splice(index, 1);
324
+ store.db.run("UPDATE entries SET images = ?, updated_at = ? WHERE id = ?", [
325
+ JSON.stringify(images),
326
+ Date.now(),
327
+ id,
328
+ ]);
329
+ return { ok: true as const };
330
+ });
331
+ return write.immediate();
332
+ }
333
+
334
+ /**
335
+ * Saves the human's own notes. Deliberately unlike `saveHumanText`: the notes are a scratchpad, so
336
+ * the write works on every status (an applied or rejected entry's notes stay editable), an empty
337
+ * string clears them rather than refusing, and nothing else moves — no revision row (only prose
338
+ * saves make history), no status change, no status event (a waiter cares about approval, not about
339
+ * a note). `updated_at` moves so the row reflects the write. The only refusal is an unknown id.
340
+ */
341
+ export function saveHumanNotes(
342
+ store: Store,
343
+ input: { readonly id: string; readonly notes: string },
344
+ ): { readonly ok: true } | { readonly ok: false; readonly refusal: { readonly kind: "unknown" } } {
345
+ const row = store.db.query("SELECT id FROM entries WHERE id = ?").get(input.id) as { id: string } | null;
346
+ if (row === null) return { ok: false, refusal: { kind: "unknown" } };
347
+
348
+ store.db.run("UPDATE entries SET human_notes = ?, updated_at = ? WHERE id = ?", [
349
+ input.notes,
350
+ Date.now(),
351
+ input.id,
352
+ ]);
353
+ return { ok: true };
354
+ }
@@ -146,4 +146,42 @@ export const MIGRATIONS: Migration[] = [
146
146
  ALTER TABLE entries_archive ADD COLUMN applied_hash TEXT;
147
147
  `,
148
148
  },
149
+ {
150
+ version: 4,
151
+ name: "entry-title",
152
+ sql: `
153
+ -- An optional human-readable label the dashboard shows in place of the file basename, so a
154
+ -- row reads as what it changes rather than where it lives (cloudy, 2026-08-20). Nullable:
155
+ -- an entry without one falls back to its anchor text at render. The archive mirrors entries
156
+ -- column-for-column, so it gains the column too.
157
+ ALTER TABLE entries ADD COLUMN title TEXT;
158
+ ALTER TABLE entries_archive ADD COLUMN title TEXT;
159
+ `,
160
+ },
161
+ {
162
+ version: 5,
163
+ name: "entry-images",
164
+ sql: `
165
+ -- Screenshots and links attached to an entry, filed by agents and appended to by the human
166
+ -- on the dashboard. A json array of strings, each a data:image/ data url or an http(s) url;
167
+ -- the wire boundary enforces the shape (max 8, max 5 MiB each). NOT NULL with an empty
168
+ -- array default: an entry without images stores [] like the other json columns. The archive
169
+ -- mirrors entries column-for-column, so it gains the column too.
170
+ ALTER TABLE entries ADD COLUMN images TEXT NOT NULL DEFAULT '[]';
171
+ ALTER TABLE entries_archive ADD COLUMN images TEXT NOT NULL DEFAULT '[]';
172
+ `,
173
+ },
174
+ {
175
+ version: 6,
176
+ name: "entry-human-notes",
177
+ sql: `
178
+ -- The human's own scratchpad, dashboard-only: agents can neither set nor read it, so the
179
+ -- filing insert leaves it NULL and the list_entries tool projects it out of the wire row.
180
+ -- Nullable, unlike the agent-fed json columns: no agent ever sends a value, and NULL is
181
+ -- what renders as "no notes yet". The archive mirrors entries column-for-column, so it
182
+ -- gains the column too.
183
+ ALTER TABLE entries ADD COLUMN human_notes TEXT;
184
+ ALTER TABLE entries_archive ADD COLUMN human_notes TEXT;
185
+ `,
186
+ },
149
187
  ];
package/src/db/queries.ts CHANGED
@@ -8,14 +8,15 @@ export type EntryStatus = "draft" | "approved" | "applied" | "rejected";
8
8
  /**
9
9
  * One entry as it crosses into the store: every field already typed, hashed, and stringified.
10
10
  * The json boundary belongs to the tool that reads the wire (`src/mcp/file-entries.ts`); nothing
11
- * here re-parses what it already accepted. `agentDraft`, `contextJson`, and `constraintsJson` are
12
- * null when the caller did not send the field: a first filing stores the empty default, and a
13
- * draft re-file keeps the stored value (the schema refuses explicit null, so no caller can
14
- * express a wipe).
11
+ * here re-parses what it already accepted. `agentDraft`, `contextJson`, `constraintsJson`, and
12
+ * `imagesJson` are null when the caller did not send the field: a first filing stores the empty
13
+ * default, and a draft re-file keeps the stored value (the schema refuses explicit null, so no
14
+ * caller can express a wipe).
15
15
  */
16
16
  export type NewEntry = {
17
17
  readonly repo: string;
18
18
  readonly file: string;
19
+ readonly title: string | null;
19
20
  readonly anchorText: string;
20
21
  readonly anchorBefore: string;
21
22
  readonly anchorAfter: string;
@@ -24,6 +25,7 @@ export type NewEntry = {
24
25
  readonly agentDraft: string | null;
25
26
  readonly contextJson: string | null;
26
27
  readonly constraintsJson: string | null;
28
+ readonly imagesJson: string | null;
27
29
  };
28
30
 
29
31
  export type FiledEntry = {
@@ -48,6 +50,7 @@ export type EntryRow = {
48
50
  readonly batch_id: string;
49
51
  readonly repo: string;
50
52
  readonly file: string;
53
+ readonly title: string | null;
51
54
  readonly anchor_text: string;
52
55
  readonly anchor_before: string;
53
56
  readonly anchor_after: string;
@@ -61,6 +64,8 @@ export type EntryRow = {
61
64
  readonly filed_by: string | null;
62
65
  readonly stale_note: string | null;
63
66
  readonly applied_hash: string | null;
67
+ readonly images: string;
68
+ readonly human_notes: string | null;
64
69
  readonly created_at: number;
65
70
  readonly updated_at: number;
66
71
  readonly applied_at: number | null;
@@ -189,16 +194,17 @@ function fileOneEntry(
189
194
  const now = Date.now();
190
195
  db.run(
191
196
  `INSERT INTO entries (
192
- id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
197
+ id, project_id, batch_id, repo, file, title, anchor_text, anchor_before, anchor_after,
193
198
  anchor_hash, file_hash, agent_draft, human_text, status, context, constraints,
194
- filed_by, stale_note, created_at, updated_at, applied_at, archived_at
195
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'draft', ?, ?, ?, NULL, ?, ?, NULL, NULL)`,
199
+ filed_by, stale_note, images, created_at, updated_at, applied_at, archived_at
200
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'draft', ?, ?, ?, NULL, ?, ?, ?, NULL, NULL)`,
196
201
  [
197
202
  id,
198
203
  projectId,
199
204
  batchId,
200
205
  entry.repo,
201
206
  entry.file,
207
+ entry.title,
202
208
  entry.anchorText,
203
209
  entry.anchorBefore,
204
210
  entry.anchorAfter,
@@ -208,6 +214,7 @@ function fileOneEntry(
208
214
  entry.contextJson ?? "{}",
209
215
  entry.constraintsJson ?? "{}",
210
216
  filedBy,
217
+ entry.imagesJson ?? "[]",
211
218
  now,
212
219
  now,
213
220
  ],
@@ -219,8 +226,8 @@ function fileOneEntry(
219
226
  // `COALESCE(?, column)`: a field the newer call did not carry (null) keeps the stored value.
220
227
  // The schema refuses explicit null, so a re-file can only move the fields it names.
221
228
  db.run(
222
- "UPDATE entries SET agent_draft = COALESCE(?, agent_draft), context = COALESCE(?, context), constraints = COALESCE(?, constraints), updated_at = ? WHERE id = ?",
223
- [entry.agentDraft, entry.contextJson, entry.constraintsJson, Date.now(), existing.id],
229
+ "UPDATE entries SET agent_draft = COALESCE(?, agent_draft), context = COALESCE(?, context), constraints = COALESCE(?, constraints), title = COALESCE(?, title), images = COALESCE(?, images), updated_at = ? WHERE id = ?",
230
+ [entry.agentDraft, entry.contextJson, entry.constraintsJson, entry.title, entry.imagesJson, Date.now(), existing.id],
224
231
  );
225
232
  return { id: existing.id, status: "draft", deduped: true, updated: true };
226
233
  }
@@ -254,9 +261,10 @@ const APPROVED_PAGE_SIZE = 50;
254
261
 
255
262
  /** The columns `docs/mcp-contract.md`'s entry schema names; the one place the output row's shape is spelled out in SQL. */
256
263
  const ENTRY_COLUMNS = [
257
- "id", "project_id", "batch_id", "repo", "file", "anchor_text", "anchor_before",
258
- "anchor_after", "anchor_hash", "file_hash", "agent_draft", "human_text", "status",
259
- "context", "constraints", "filed_by", "stale_note", "applied_hash", "created_at", "updated_at", "applied_at", "archived_at",
264
+ "id", "project_id", "batch_id", "repo", "file", "title", "anchor_text", "anchor_before",
265
+ "anchor_after", "anchor_hash", "file_hash", "agent_draft", "human_notes", "human_text", "status",
266
+ "context", "constraints", "filed_by", "stale_note", "applied_hash", "images", "created_at",
267
+ "updated_at", "applied_at", "archived_at",
260
268
  ] as const;
261
269
 
262
270
  /**
@@ -304,6 +312,21 @@ export function listEntries(store: Store, filter: ListEntriesFilter): ListEntrie
304
312
  return { rows, nextCursor };
305
313
  }
306
314
 
315
+ /**
316
+ * The entries immediately before and after `id` in the ascending-id walk the list renders, for the
317
+ * editor's full-height prev/next rails; null at either boundary. The `id` column is a primary key,
318
+ * so both comparisons run on its unique index.
319
+ */
320
+ export function adjacentEntryIds(store: Store, id: string): { prevId: string | null; nextId: string | null } {
321
+ const prev = store.db.query("SELECT id FROM entries WHERE id < ? ORDER BY id DESC LIMIT 1").get(id) as
322
+ | { id: string }
323
+ | null;
324
+ const next = store.db.query("SELECT id FROM entries WHERE id > ? ORDER BY id ASC LIMIT 1").get(id) as
325
+ | { id: string }
326
+ | null;
327
+ return { prevId: prev?.id ?? null, nextId: next?.id ?? null };
328
+ }
329
+
307
330
  /**
308
331
  * The `fetch_approved` read. The status is not a filter: the tool's invariant is `'approved'`
309
332
  * (a draft, applied, or rejected row never leaves this query), and the page size is the contract's
package/src/db/sweep.ts CHANGED
@@ -55,12 +55,12 @@ export function sweepArchive(store: Store, now: number, archiveAfterDays: number
55
55
  `INSERT INTO entries_archive (
56
56
  id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
57
57
  anchor_hash, file_hash, agent_draft, human_text, status, context, constraints,
58
- filed_by, stale_note, applied_hash, created_at, updated_at, applied_at, archived_at
58
+ filed_by, stale_note, applied_hash, images, human_notes, created_at, updated_at, applied_at, archived_at
59
59
  )
60
60
  SELECT
61
61
  id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
62
62
  anchor_hash, file_hash, agent_draft, human_text, status, context, constraints,
63
- filed_by, stale_note, applied_hash, created_at, updated_at, applied_at, ?
63
+ filed_by, stale_note, applied_hash, images, human_notes, created_at, updated_at, applied_at, ?
64
64
  FROM entries
65
65
  WHERE ${EXPIRED_APPLIED}`,
66
66
  [now, cutoff],
@@ -42,6 +42,7 @@ const ConstraintsJson = z.strictObject({
42
42
  const FileEntryArgs = z.object({
43
43
  repo: z.string().min(1),
44
44
  file: z.string().min(1),
45
+ title: z.string().min(1).optional(),
45
46
  anchor_text: z.string().min(1),
46
47
  anchor_before: z.string().optional(),
47
48
  anchor_after: z.string().optional(),
@@ -50,6 +51,7 @@ const FileEntryArgs = z.object({
50
51
  file_hash: z.string().optional(),
51
52
  context: z.unknown().optional(),
52
53
  constraints: z.unknown().optional(),
54
+ images: z.unknown().optional(),
53
55
  });
54
56
 
55
57
  const FiledOutput = z.object({
@@ -84,6 +86,10 @@ const CONSTRAINT_FIX: Record<string, string> = {
84
86
  notes: "send constraints.notes as a string",
85
87
  };
86
88
 
89
+ /** The caps `docs/mcp-contract.md` pins for `images`: at most 8 per entry, each at most 5 MiB. */
90
+ const MAX_IMAGES_PER_ENTRY = 8;
91
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
92
+
87
93
  /** Turns zod's issues into the named-field refusal the contract's error row promises. */
88
94
  function refuseConstraints(index: number, file: string, anchorText: string, issues: readonly z.ZodIssue[]): never {
89
95
  const where = entryName(index, file, anchorText);
@@ -123,6 +129,50 @@ function validateEntry(index: number, raw: z.output<typeof FileEntryArgs>): NewE
123
129
  refuseConstraints(index, raw.file, raw.anchor_text, constraints.error.issues);
124
130
  }
125
131
 
132
+ // Held loose like context and constraints, so the refusal names the entry and the fix instead
133
+ // of stopping at the sdk's field-path error. Absent (or explicit null) means "the newer call
134
+ // does not carry this field": a draft re-file then keeps the stored images.
135
+ let imagesJson: string | null;
136
+ if (raw.images == null) {
137
+ imagesJson = null;
138
+ } else {
139
+ if (!Array.isArray(raw.images)) {
140
+ refuse(
141
+ where,
142
+ "images must be an array of image urls or data urls",
143
+ 'send an array such as ["https://example.com/screenshot.png"], or omit images',
144
+ );
145
+ }
146
+ if (raw.images.length > MAX_IMAGES_PER_ENTRY) {
147
+ refuse(
148
+ where,
149
+ `images has ${raw.images.length} items; the limit is ${MAX_IMAGES_PER_ENTRY}`,
150
+ `send at most ${MAX_IMAGES_PER_ENTRY} images per entry`,
151
+ );
152
+ }
153
+ raw.images.forEach((item, imageIndex) => {
154
+ if (typeof item !== "string") {
155
+ refuse(where, `images[${imageIndex}] must be a string`, "send each image as a string url or data url");
156
+ }
157
+ if (!item.startsWith("data:image/") && !/^https?:\/\//.test(item)) {
158
+ refuse(
159
+ where,
160
+ `images[${imageIndex}] must start with "data:image/" or an http(s) url, got "${item.slice(0, 40)}"`,
161
+ "send a data:image/ data url or an http(s) url",
162
+ );
163
+ }
164
+ const bytes = Buffer.byteLength(item, "utf8");
165
+ if (bytes > MAX_IMAGE_BYTES) {
166
+ refuse(
167
+ where,
168
+ `images[${imageIndex}] is ${bytes} bytes; the limit is ${MAX_IMAGE_BYTES} bytes (5 MiB)`,
169
+ "send a smaller image, or link an http(s) url instead",
170
+ );
171
+ }
172
+ });
173
+ imagesJson = JSON.stringify(raw.images);
174
+ }
175
+
126
176
  let fileHash: string;
127
177
  if (raw.file_content !== undefined && raw.file_hash !== undefined) {
128
178
  refuse(
@@ -152,6 +202,7 @@ function validateEntry(index: number, raw: z.output<typeof FileEntryArgs>): NewE
152
202
  return {
153
203
  repo: raw.repo,
154
204
  file: raw.file,
205
+ title: raw.title ?? null,
155
206
  anchorText: raw.anchor_text,
156
207
  anchorBefore: raw.anchor_before ?? "",
157
208
  anchorAfter: raw.anchor_after ?? "",
@@ -162,6 +213,7 @@ function validateEntry(index: number, raw: z.output<typeof FileEntryArgs>): NewE
162
213
  // re-file then keeps the stored value, per the COALESCE in src/db/queries.ts.
163
214
  contextJson: raw.context == null ? null : JSON.stringify(context.data),
164
215
  constraintsJson: raw.constraints == null ? null : JSON.stringify(constraints.data),
216
+ imagesJson,
165
217
  };
166
218
  }
167
219
 
@@ -177,7 +229,7 @@ export function registerFileEntriesTool(server: McpServer, baseUrl: string, stor
177
229
  {
178
230
  title: "File entries for review",
179
231
  description:
180
- "File draft entries for a human to author or approve. Each entry names one exact string to replace: repo (git remote url preferred), file path, anchor_text, and anchor_before/anchor_after context lines. Identity is (project, repo, file, sha256(anchor_text)): re-filing a known draft overwrites the agent draft, context, and constraints in place; an entry already approved, applied, or rejected is returned untouched with its own status, and a rejected anchor stays rejected — stop proposing a turned-down line. file provenance: send exactly one of file_content (the whole file at filing time; the server hashes it) or file_hash (its sha256 hex). constraints: {max_len?, placeholders?: string[], tone?, notes?} — a save breaking max_len or dropping a placeholder is refused later, so declare what the copy must keep. Keys the schema does not list are ignored.",
232
+ "File draft entries for a human to author or approve. One entry is one edit: anchor_text is the whole passage being replaced and agent_draft the proposed replacement; either may be a single line or span many lines, and a multi-line block is ONE entry, never one entry per line. Each entry names repo (git remote url preferred), file path, an optional title (a human-readable label the dashboard shows in place of the file path), anchor_text, and anchor_before/anchor_after context lines. Identity is (project, repo, file, sha256(anchor_text)): re-filing a known draft overwrites the agent draft, context, and constraints in place; an entry already approved, applied, or rejected is returned untouched with its own status, and a rejected anchor stays rejected — stop proposing a turned-down passage. file provenance: send exactly one of file_content (the whole file at filing time; the server hashes it) or file_hash (its sha256 hex). constraints: {max_len?, placeholders?: string[], tone?, notes?} — a save breaking max_len or dropping a placeholder is refused later, so declare what the copy must keep. images: up to 8 per entry, each a data:image/ data url or an http(s) url of at most 5 MiB, rendered on the dashboard. Keys the schema does not list are ignored. The dashboard shows entries in the order the agent sends them, so file the most important entries first.",
181
233
  inputSchema: z.object({
182
234
  project: z.string().min(1),
183
235
  entries: z.array(FileEntryArgs).min(1),
@@ -1,6 +1,7 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
3
  import { listEntries, projectIdBySlug } from "../db/queries.ts";
4
+ import type { EntryRow } from "../db/queries.ts";
4
5
  import type { Store } from "../db/store.ts";
5
6
 
6
7
  /**
@@ -18,13 +19,49 @@ const ListEntriesArgs = z.object({
18
19
  cursor: z.string().min(1).optional(),
19
20
  });
20
21
 
21
- /** The wire row is the stored row, `context` and `constraints` kept as their stored JSON strings — no re-parse on the way out. */
22
+ /**
23
+ * The one mapping from stored rows to the wire shape: every field spelled out, so a new store
24
+ * column reaches the wire only when someone adds it here deliberately — `human_notes` must never
25
+ * (the contract pins it dashboard-only), and `images` is carried on purpose. The SDK passes
26
+ * structuredContent through untouched, so this projection is the strip, not the schema.
27
+ */
28
+ function toWireEntry(row: EntryRow): z.infer<typeof Entry> {
29
+ return {
30
+ id: row.id,
31
+ project_id: row.project_id,
32
+ batch_id: row.batch_id,
33
+ repo: row.repo,
34
+ file: row.file,
35
+ title: row.title,
36
+ anchor_text: row.anchor_text,
37
+ anchor_before: row.anchor_before,
38
+ anchor_after: row.anchor_after,
39
+ anchor_hash: row.anchor_hash,
40
+ file_hash: row.file_hash,
41
+ agent_draft: row.agent_draft,
42
+ human_text: row.human_text,
43
+ status: row.status,
44
+ context: row.context,
45
+ constraints: row.constraints,
46
+ filed_by: row.filed_by,
47
+ stale_note: row.stale_note,
48
+ applied_hash: row.applied_hash,
49
+ images: row.images,
50
+ created_at: row.created_at,
51
+ updated_at: row.updated_at,
52
+ applied_at: row.applied_at,
53
+ archived_at: row.archived_at,
54
+ };
55
+ }
56
+
57
+ /** The wire row shape, `context`, `constraints`, and `images` kept as their stored JSON strings — no re-parse on the way out. */
22
58
  const Entry = z.object({
23
59
  id: z.string(),
24
60
  project_id: z.string(),
25
61
  batch_id: z.string(),
26
62
  repo: z.string(),
27
63
  file: z.string(),
64
+ title: z.string().nullable(),
28
65
  anchor_text: z.string(),
29
66
  anchor_before: z.string(),
30
67
  anchor_after: z.string(),
@@ -38,6 +75,7 @@ const Entry = z.object({
38
75
  filed_by: z.string().nullable(),
39
76
  stale_note: z.string().nullable(),
40
77
  applied_hash: z.string().nullable(),
78
+ images: z.string(),
41
79
  created_at: z.number(),
42
80
  updated_at: z.number(),
43
81
  applied_at: z.number().nullable(),
@@ -65,7 +103,7 @@ export function registerListEntriesTool(server: McpServer, store: Store): void {
65
103
  {
66
104
  title: "List entries for review",
67
105
  description:
68
- "List entries in the review queue, ordered by ascending entry id (ulid, i.e. filing order) — the same order a cursor walk covers. Every filter is optional and they combine with AND: project (a slug with no project is refused: read tools never create one), status (draft, approved, applied, rejected), ids (exact entry ids), q (case-insensitive substring, ASCII-only case fold: a row matches when any of file, anchor_text, agent_draft, human_text contains it), limit (default 50, max 200 — a higher value is refused), cursor (keyset pagination: pass the previous page's next_cursor to continue from after the last entry returned). next_cursor is present only when the page returned exactly limit rows, meaning more may exist; absent means the walk is exhausted — stop paging then, never craft a cursor of your own. Each entry carries its stored fields as stored: id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, agent_draft, human_text, status, context, constraints, filed_by, stale_note, applied_hash, created_at, updated_at, applied_at, archived_at — with context and constraints as their stored JSON strings, not parsed objects.",
106
+ "List entries in the review queue, ordered by ascending entry id (ulid, i.e. filing order) — the same order a cursor walk covers. Every filter is optional and they combine with AND: project (a slug with no project is refused: read tools never create one), status (draft, approved, applied, rejected), ids (exact entry ids), q (case-insensitive substring, ASCII-only case fold: a row matches when any of file, anchor_text, agent_draft, human_text contains it), limit (default 50, max 200 — a higher value is refused), cursor (keyset pagination: pass the previous page's next_cursor to continue from after the last entry returned). next_cursor is present only when the page returned exactly limit rows, meaning more may exist; absent means the walk is exhausted — stop paging then, never craft a cursor of your own. Each entry carries its stored fields as stored: id, project_id, batch_id, repo, file, title, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, agent_draft, human_text, status, context, constraints, filed_by, stale_note, applied_hash, images, created_at, updated_at, applied_at, archived_at — with context, constraints, and images as their stored JSON strings, not parsed objects. The human's own notes (human_notes) are dashboard-only and never appear in a listed entry.",
69
107
  inputSchema: ListEntriesArgs,
70
108
  outputSchema: ListEntriesOutput,
71
109
  },
@@ -95,7 +133,7 @@ export function registerListEntriesTool(server: McpServer, store: Store): void {
95
133
  // An exhausted walk carries no `next_cursor` key at all: absent is the signal to stop, so an
96
134
  // empty page must never smuggle one in.
97
135
  const output = {
98
- entries: rows,
136
+ entries: rows.map(toWireEntry),
99
137
  ...(nextCursor === null ? {} : { next_cursor: nextCursor }),
100
138
  };
101
139
  return {
@@ -81,7 +81,7 @@ export function registerMarkAppliedTool(server: McpServer, store: Store): void {
81
81
  refuse(
82
82
  `entry "${args.id}"`,
83
83
  "status rejected: rejected is terminal and silences the agent",
84
- "file a new entry with a new anchor if the line still needs changing",
84
+ "file a new entry with a new anchor if the passage still needs changing",
85
85
  );
86
86
  }
87
87
  // The only pair left is an applied entry reported applied again: anchor_stale on an
package/src/mcp/route.ts CHANGED
@@ -58,17 +58,14 @@ export function mountMcp(
58
58
  store: Store,
59
59
  notifier: Notifier = new Notifier({ config, baseUrl: config.baseUrl }),
60
60
  ): void {
61
- // `legacy: 'reject'` is the whole of the revision guard, so the endpoint serves only the one
62
- // revision `docs/mcp-contract.md` freezes. `supportedProtocolVersions` on the server cannot do
63
- // it and is deliberately unset: the SDK's `installDiscoverHandler` unions its served modern
64
- // revisions back into that array regardless of what was passed, and on a legacy leg
65
- // (`Server._oninitialize`) the list only picks WHICH 2025-era revision to answer with —
66
- // `legacyVersions[0] ?? LATEST_PROTOCOL_VERSION`, and `LATEST_PROTOCOL_VERSION` is
67
- // `"2025-11-25"`. A modern-only list empties `legacyVersions`, which is exactly what reaches
68
- // that hardcoded fallback, so narrowing it is what would serve one.
61
+ // The native surface is the one revision `docs/mcp-contract.md` freezes, but `legacy: 'reject'`
62
+ // locked out the claude harness fleet, whose mcp client negotiates `2025-11-25` (measured
63
+ // 2026-08-21: 2.1.238 sends a legacy initialize and reads the refusal as a dead server).
64
+ // `legacy: 'stateless'` is the SDK default and answers 2025-era traffic from the same factory
65
+ // per request, so both legs stay stateless and no session is minted on either one.
69
66
  // The store is the daemon's own open handle (`startDaemon` opens it before the port binds), so
70
67
  // every tool call writes through the one connection the daemon will close on shutdown.
71
- const handler = createMcpHandler(() => createMcpServer(config, store, notifier), { legacy: "reject" });
68
+ const handler = createMcpHandler(() => createMcpServer(config, store, notifier), { legacy: "stateless" });
72
69
 
73
70
  // The `Origin` rung is `originGate`, mounted app-wide in `createApp`; it runs before this
74
71
  // handler, so the pinned order (origin, then method, then credential) holds without a repeat of
package/src/mcp/server.ts CHANGED
@@ -16,7 +16,7 @@ import { NAME, VERSION } from "../version.ts";
16
16
  */
17
17
  const INSTRUCTIONS = `reviewzy is a review queue for user-facing text. Agents draft, a human signs off.
18
18
 
19
- File the strings you want to change as draft entries against a project. A human authors or approves the wording on the dashboard, then you fetch those words back and apply them in place. You can create a draft, move an approved entry to applied, and report an anchor that no longer matches. You can never approve or reject an entry, and a rejected anchor stays rejected: re-filing it returns the existing entry instead of proposing the line again.`;
19
+ File the text you want to change as draft entries against a project. One entry is one edit: anchor_text is the whole passage being replaced and agent_draft the proposed replacement; either may be a single line or span many lines, and a multi-line block is ONE entry, never one entry per line. A human authors or approves the wording on the dashboard, then you fetch those words back and apply them in place. You can create a draft, move an approved entry to applied, and report an anchor that no longer matches. You can never approve or reject an entry, and a rejected anchor stays rejected: re-filing it returns the existing entry instead of proposing the passage again.`;
20
20
 
21
21
  /**
22
22
  * A fresh server per request, since the 2026-07-28 revision is stateless and `createMcpHandler`