reviewzy 0.1.0 → 0.3.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 (35) hide show
  1. package/.env.example +5 -0
  2. package/README.md +29 -10
  3. package/package.json +1 -1
  4. package/src/config.ts +4 -0
  5. package/src/dashboard/mount.ts +118 -1
  6. package/src/dashboard/repo-link.ts +126 -0
  7. package/src/dashboard/static/components.css +6780 -534
  8. package/src/dashboard/static/cursor-rules.js +83 -0
  9. package/src/dashboard/static/cursor.css +105 -0
  10. package/src/dashboard/static/cursor.js +358 -0
  11. package/src/dashboard/static/dashboard.css +139 -77
  12. package/src/dashboard/static/dashboard.js +147 -29
  13. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  14. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  15. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  16. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  17. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  18. package/src/dashboard/static/icons.svg +184 -0
  19. package/src/dashboard/static/tokens.css +421 -110
  20. package/src/dashboard/static/ui.js +1992 -0
  21. package/src/dashboard/views.tsx +600 -282
  22. package/src/dashboard/word-diff.ts +89 -0
  23. package/src/db/human-save.ts +112 -0
  24. package/src/db/migrations.ts +38 -0
  25. package/src/db/queries.ts +36 -13
  26. package/src/db/sweep.ts +2 -2
  27. package/src/mcp/await-approved.ts +17 -10
  28. package/src/mcp/fetch-approved.ts +11 -6
  29. package/src/mcp/file-entries.ts +95 -20
  30. package/src/mcp/list-entries.ts +54 -29
  31. package/src/mcp/mark-applied.ts +6 -6
  32. package/src/mcp/route.ts +6 -9
  33. package/src/mcp/server.ts +8 -3
  34. package/src/shim/daemon.ts +23 -16
  35. package/src/shim/main.ts +26 -5
@@ -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 = {
@@ -41,13 +43,14 @@ export type FiledBatch = {
41
43
  type ProjectRow = { id: string };
42
44
  type IdentityRow = { id: string; status: EntryStatus };
43
45
 
44
- /** One entry row as `list_entries` returns it: the columns `docs/mcp-contract.md`'s entry schema lists, `archived_at` included (every v1 row carries null — the archive sweep is not built yet). */
46
+ /** One entry row as the store's shared list query returns it: the columns `docs/mcp-contract.md`'s entry schema lists, `archived_at` included (every v1 row carries null — the archive sweep is not built yet). The dashboard consumes the full row; the mcp `list_entries` tool projects it down to the contract's lean index row. */
45
47
  export type EntryRow = {
46
48
  readonly id: string;
47
49
  readonly project_id: string;
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],
@@ -25,8 +25,8 @@ export function clampTimeoutMs(ms: number): number {
25
25
  }
26
26
 
27
27
  const AwaitApprovedArgs = z.object({
28
- ids: z.array(z.string().min(1)).min(1),
29
- timeout_ms: z.number().int().min(1).default(AWAIT_TIMEOUT_DEFAULT_MS),
28
+ ids: z.array(z.string().min(1)).min(1).describe("The exact entry ids to wait for; duplicates are dropped."),
29
+ timeout_ms: z.number().int().min(1).default(AWAIT_TIMEOUT_DEFAULT_MS).describe("How long to block (max: 600000, default 60000). A larger value is clamped."),
30
30
  });
31
31
 
32
32
  const AwaitApprovedOutput = z.object({
@@ -34,12 +34,14 @@ const AwaitApprovedOutput = z.object({
34
34
  statuses: z.record(z.string(), z.enum(["draft", "approved", "applied", "rejected"])),
35
35
  entries: z.array(ApprovedEntry).optional(),
36
36
  poll_again_after_ms: z.number().int().min(1).optional(),
37
+ missing_ids: z.array(z.string()).optional(),
37
38
  });
38
39
 
39
40
  type AwaitOutcome = {
40
41
  readonly resolved: boolean;
41
42
  readonly statuses: Record<string, EntryStatus>;
42
43
  readonly entries: z.infer<typeof ApprovedEntry>[];
44
+ readonly missingIds: string[];
43
45
  };
44
46
 
45
47
  /** A refusal is an ordinary thrown Error: the SDK answers it as a tool result with `isError: true`, never as a json-rpc protocol error. */
@@ -77,16 +79,16 @@ function waitForEntries(store: Store, ids: readonly string[], timeoutMs: number)
77
79
  statuses[row.id] = row.status;
78
80
  if (row.status === "approved") approved.push(row);
79
81
  }
80
- // The status map's contract is one key per named id, so a row that vanished since the
81
- // up-front check (the planned retention sweep moves rows to the archive) must not resolve
82
- // the wait with the id silently missing: keep waiting, and the caller's next call surfaces
83
- // the missing id in the up-front refusal.
82
+ // A row that vanished since the up-front check (the retention sweep moves rows to the
83
+ // archive) must not resolve the wait, and its id must not sit silently absent from the
84
+ // statuses map on a non-resolved reply: the gap is named in missing_ids instead.
85
+ const missingIds = ids.filter((id) => statuses[id] === undefined);
84
86
  const done =
85
87
  rows.length === ids.length &&
86
88
  rows.every((row) => row.status === "approved" || row.status === "rejected");
87
89
  return done
88
- ? { resolved: true, statuses, entries: approvedRowsToWire(approved) }
89
- : { resolved: false, statuses, entries: [] };
90
+ ? { resolved: true, statuses, entries: approvedRowsToWire(approved), missingIds: [] }
91
+ : { resolved: false, statuses, entries: [], missingIds };
90
92
  }
91
93
 
92
94
  function recheck(): void {
@@ -125,7 +127,7 @@ export function registerAwaitApprovedTool(server: McpServer, store: Store): void
125
127
  {
126
128
  title: "Wait for entries to be approved or rejected",
127
129
  description:
128
- "Block until every named entry reaches approved or rejected, holding the call open — the one long-poll on this server, for a live session that filed entries and wants to sleep until the human signs off instead of polling fetch_approved. ids: the exact entry ids to wait for; duplicates are deduped silently, and the status map carries one key per id. timeout_ms: how long to block, default 60000, capped at 600000 — a larger value is clamped, never refused. Resolves when every named id is approved or rejected: resolved true, statuses one per named id, and entries the approved rows only, each in fetch_approved's row shape (id, repo, file, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, text, constraints parsed) — a rejected id appears in statuses only, never in entries. On timeout: resolved false, the current per-id statuses, and poll_again_after_ms 5000 — never an error; call again with the same ids to keep waiting. Refused, naming the id and the fix: an id that does not exist at call time (a read tool never creates one). An entry in applied does not resolve the wait: only approved or rejected does.",
130
+ "Blocks until every named entry reaches approved or rejected; the client may park the call and deliver the result when it lands. This is the long-poll for a live session that filed entries and wants to sleep until the human signs off, rather than poll `fetch_approved`. Only approved or rejected resolves the wait; applied does not.",
129
131
  inputSchema: AwaitApprovedArgs,
130
132
  outputSchema: AwaitApprovedOutput,
131
133
  },
@@ -146,7 +148,12 @@ export function registerAwaitApprovedTool(server: McpServer, store: Store): void
146
148
  const outcome = await waitForEntries(store, ids, timeoutMs);
147
149
  const output = outcome.resolved
148
150
  ? { resolved: true, statuses: outcome.statuses, entries: outcome.entries }
149
- : { resolved: false, statuses: outcome.statuses, poll_again_after_ms: AWAIT_POLL_AGAIN_MS };
151
+ : {
152
+ resolved: false,
153
+ statuses: outcome.statuses,
154
+ poll_again_after_ms: AWAIT_POLL_AGAIN_MS,
155
+ missing_ids: outcome.missingIds.length > 0 ? outcome.missingIds : undefined,
156
+ };
150
157
  return {
151
158
  content: [{ type: "text" as const, text: JSON.stringify(output) }],
152
159
  structuredContent: output,
@@ -9,9 +9,9 @@ import type { Store } from "../db/store.ts";
9
9
  * style guide is per-project, and the applying agent needs the voice of the project it writes into.
10
10
  */
11
11
  const FetchApprovedArgs = z.object({
12
- project: z.string().min(1),
13
- ids: z.array(z.string().min(1)).min(1).optional(),
14
- since: z.string().min(1).optional(),
12
+ project: z.string().min(1).describe("The project slug to fetch from."),
13
+ ids: z.array(z.string().min(1)).min(1).optional().describe("Only these exact entry ids. Combines with since: both filters apply."),
14
+ since: z.string().min(1).optional().describe("Resume a walk: pass the previous page's next_since to get the rows after it."),
15
15
  });
16
16
 
17
17
  /**
@@ -42,7 +42,7 @@ export const ApprovedEntry = z.object({
42
42
 
43
43
  const FetchApprovedOutput = z.object({
44
44
  entries: z.array(ApprovedEntry),
45
- style_guide: z.string(),
45
+ style_guide: z.string().optional(),
46
46
  next_since: z.string().optional(),
47
47
  });
48
48
 
@@ -100,7 +100,7 @@ export function registerFetchApprovedTool(server: McpServer, store: Store): void
100
100
  {
101
101
  title: "Fetch approved entries to apply",
102
102
  description:
103
- "Fetch the approved entries of a project, ready to apply: the human-signed prose (text), the anchor to locate and replace it by, and the constraints the replacement must keep. Only approved entries are ever returned — a draft, applied, or rejected entry never appears, and an id naming one simply contributes nothing. Entries come back ordered by ascending entry id, at most 50 per call (fixed server-side page); when exactly 50 come back, next_since holds the last entry's id, and the walk continues by passing that id back as since (rows with id > since) — when next_since is absent the walk is exhausted, stop fetching. ids (exact entry ids) combines with since by AND. The project slug is required and must already exist: an unknown slug is refused (read tools never create one). Each entry carries id, repo, file, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, text (the approved prose; null only on an approved row nobody ever wrote into), and constraints — parsed into {max_len?, placeholders?, tone?, notes?} so the apply-back can check the text against them, an unparseable stored value rendering as {}. style_guide is the same merged text the reviewzy://projects/{slug}/style-guide resource serves, embedded so bulk rewrites share one voice.",
103
+ "Fetch a project's approved entries, ready to apply: the human-signed text, the anchor to locate and replace it by, and the constraints the replacement must keep. Only approved entries are returned: an id naming a draft, applied or rejected entry contributes nothing. At most 50 per page, ordered by ascending id; when next_since comes back, pass it back as since to continue. The merged style guide rides the first page of a walk (neither ids nor since) so bulk rewrites pay it once.",
104
104
  inputSchema: FetchApprovedArgs,
105
105
  outputSchema: FetchApprovedOutput,
106
106
  },
@@ -118,7 +118,12 @@ export function registerFetchApprovedTool(server: McpServer, store: Store): void
118
118
 
119
119
  const output = {
120
120
  entries: approvedRowsToWire(rows),
121
- style_guide: mergedStyleGuide(store, args.project),
121
+ // The guide rides a walk's first page only — a call carrying neither ids nor since.
122
+ // Continuation pages and targeted id fetches omit it: the same text is the style-guide
123
+ // resource, so a bulk rewrite pays it once per walk.
124
+ ...(args.ids === undefined && args.since === undefined
125
+ ? { style_guide: mergedStyleGuide(store, args.project) }
126
+ : {}),
122
127
  // An exhausted walk carries no `next_since` key at all: absent is the signal to stop, so an
123
128
  // empty page must never smuggle one in.
124
129
  ...(nextSince === null ? {} : { next_since: nextSince }),