reviewzy 0.2.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.
package/src/db/queries.ts CHANGED
@@ -43,7 +43,7 @@ export type FiledBatch = {
43
43
  type ProjectRow = { id: string };
44
44
  type IdentityRow = { id: string; status: EntryStatus };
45
45
 
46
- /** 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. */
47
47
  export type EntryRow = {
48
48
  readonly id: string;
49
49
  readonly project_id: string;
@@ -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 }),
@@ -34,25 +34,31 @@ const ConstraintsJson = z.strictObject({
34
34
  });
35
35
 
36
36
  /**
37
- * Held loose on purpose. The SDK's own schema check would refuse a malformed constraints with an
38
- * isError tool result naming the tool and the field path ("entries.0.constraints"). The handler's
39
- * refusal adds what that cannot: the entry (file and anchor) and the fix. So `context` and
40
- * `constraints` stay untyped here and are validated below.
37
+ * The wire schema is loose on purpose: a plain z.object lets the sdk strip an unrecognized
38
+ * top-level key before the handler sees it, so a misspelled anchor_text would file an entry with
39
+ * no anchor and a misspelled agent_draft one with no draft. The handler re-parses each entry
40
+ * against StrictEntryArgs and refuses the key by name, with the entry and the fix. `context` and
41
+ * `constraints` stay untyped for the same reason: the sdk's refusal can name only the tool and the
42
+ * field path ("entries.0.constraints"), never the entry (file and anchor) and the fix.
41
43
  */
42
- const FileEntryArgs = z.object({
43
- repo: z.string().min(1),
44
- file: z.string().min(1),
45
- title: z.string().min(1).optional(),
46
- anchor_text: z.string().min(1),
47
- anchor_before: z.string().optional(),
48
- anchor_after: z.string().optional(),
49
- agent_draft: z.string().optional(),
50
- file_content: z.string().optional(),
51
- file_hash: z.string().optional(),
52
- context: z.unknown().optional(),
53
- constraints: z.unknown().optional(),
54
- images: z.unknown().optional(),
55
- });
44
+ const EntryShape = {
45
+ repo: z.string().min(1).describe("Which repo the entry is about: the git remote url preferred, the absolute path when the repo has no remote."),
46
+ file: z.string().min(1).describe("The path relative to the repo root."),
47
+ title: z.string().min(1).optional().describe("Optional human-readable label the dashboard shows in place of the file path."),
48
+ anchor_text: z.string().min(1).describe("The exact current text to replace: one line or many, the whole passage being replaced."),
49
+ anchor_before: z.string().optional().describe("3 lines of context above the anchor, disambiguating a repeated string."),
50
+ anchor_after: z.string().optional().describe("3 lines of context below the anchor, disambiguating a repeated string."),
51
+ agent_draft: z.string().optional().describe("The proposed replacement text; inert until a human touches it."),
52
+ file_content: z.string().optional().describe("The whole file at filing time; the server hashes it."),
53
+ file_hash: z.string().optional().describe("The sha256 of the whole file as 64 lowercase hex chars."),
54
+ context: z.unknown().optional().describe("A json object of free-form notes for the human: where the string renders, surrounding code."),
55
+ constraints: z.unknown().optional().describe("What the copy must keep: max_len (a length cap), placeholders (strings the text must contain), tone, notes — all optional."),
56
+ images: z.unknown().optional().describe("Screenshots and links for the human, shown on the dashboard, never returned by an mcp tool."),
57
+ };
58
+ const FileEntryArgs = z.looseObject(EntryShape);
59
+
60
+ /** Strict twin of the wire shape: the handler re-parses against it to refuse an unrecognized top-level key by name. */
61
+ const StrictEntryArgs = z.strictObject(EntryShape);
56
62
 
57
63
  const FiledOutput = z.object({
58
64
  batch_id: z.string(),
@@ -104,6 +110,17 @@ function refuseConstraints(index: number, file: string, anchorText: string, issu
104
110
  return refuse(where, problem, fix);
105
111
  }
106
112
 
113
+ /** Turns zod's unrecognized_keys issues into the named-key refusal: the key, the entry, the fix. */
114
+ function refuseEntryKeys(index: number, file: string, anchorText: string, issues: readonly z.ZodIssue[]): never {
115
+ const where = entryName(index, file, anchorText);
116
+ const keys = issues.flatMap((issue) => (issue.code === "unrecognized_keys" ? issue.keys : []));
117
+ return refuse(
118
+ where,
119
+ `unrecognized key(s) ${keys.map((k) => JSON.stringify(k).slice(0, 60)).join(", ")}`,
120
+ "entries take only repo, file, title, anchor_text, anchor_before, anchor_after, agent_draft, file_content, file_hash, context, constraints, and images; drop the extra key or spell it snake_case",
121
+ );
122
+ }
123
+
107
124
  /**
108
125
  * One entry from wire shape to store shape: json validated at this boundary, `anchor_hash` always
109
126
  * computed here, and file provenance taken from exactly one of `file_content` (the server hashes
@@ -113,6 +130,12 @@ function refuseConstraints(index: number, file: string, anchorText: string, issu
113
130
  function validateEntry(index: number, raw: z.output<typeof FileEntryArgs>): NewEntry {
114
131
  const where = entryName(index, raw.file, raw.anchor_text);
115
132
 
133
+ // Unknown keys first: a wire-shape mistake outranks every field-level check below.
134
+ const strict = StrictEntryArgs.safeParse(raw);
135
+ if (!strict.success) {
136
+ refuseEntryKeys(index, raw.file, raw.anchor_text, strict.error.issues);
137
+ }
138
+
116
139
  const context = ContextJson.safeParse(raw.context ?? {});
117
140
  if (!context.success) {
118
141
  refuse(
@@ -229,11 +252,11 @@ export function registerFileEntriesTool(server: McpServer, baseUrl: string, stor
229
252
  {
230
253
  title: "File entries for review",
231
254
  description:
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.",
255
+ "File the text you want changed as draft entries for a human to author, approve or reject on the dashboard. One entry is one edit: a multi-line block is ONE entry, not one entry per line. Re-filing a known anchor is safe: a draft updates in place; an approved, applied or rejected entry comes back untouched. The dashboard groups entries by project and batch, newest batches first.",
233
256
  inputSchema: z.object({
234
- project: z.string().min(1),
235
- entries: z.array(FileEntryArgs).min(1),
236
- filed_by: z.string().min(1).optional(),
257
+ project: z.string().min(1).describe("The project slug: 1-63 chars of lowercase letters, digits, and inner hyphens. An unknown slug auto-creates the project on first file."),
258
+ entries: z.array(FileEntryArgs).min(1).describe("The entries to file. One edit per entry."),
259
+ filed_by: z.string().min(1).optional().describe("A free-form label for the filing agent, shown on the dashboard."),
237
260
  }),
238
261
  outputSchema: FiledOutput,
239
262
  },
@@ -1,6 +1,6 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/server";
2
2
  import { z } from "zod";
3
- import { listEntries, projectIdBySlug } from "../db/queries.ts";
3
+ import { listEntries, listProjects, projectIdBySlug } from "../db/queries.ts";
4
4
  import type { EntryRow } from "../db/queries.ts";
5
5
  import type { Store } from "../db/store.ts";
6
6
 
@@ -11,75 +11,60 @@ import type { Store } from "../db/store.ts";
11
11
  * they meant.
12
12
  */
13
13
  const ListEntriesArgs = z.object({
14
- project: z.string().min(1).optional(),
15
- status: z.enum(["draft", "approved", "applied", "rejected"]).optional(),
16
- ids: z.array(z.string().min(1)).min(1).optional(),
17
- q: z.string().min(1).optional(),
18
- limit: z.number().int().min(1).max(200).default(50),
19
- cursor: z.string().min(1).optional(),
14
+ project: z.string().min(1).optional().describe("Only entries of this project slug."),
15
+ status: z.enum(["draft", "approved", "applied", "rejected"]).optional().describe("Only entries in this status: draft, approved, applied, or rejected. Unset = all statuses."),
16
+ ids: z.array(z.string().min(1)).min(1).optional().describe("Only these exact entry ids."),
17
+ q: z.string().min(1).optional().describe("Case-insensitive substring (ASCII-only case fold) over file, anchor_text, agent_draft, and human_text."),
18
+ limit: z.number().int().min(1).max(200).default(50).describe("Rows per page (max: 200, default 50)."),
19
+ cursor: z.string().min(1).optional().describe("Keyset pagination: pass the previous page's next_cursor to continue from after the last row returned."),
20
20
  });
21
21
 
22
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
23
+ * Caller-supplied scalars are capped wire-side: a listed page's worst row is bounded even when a
24
+ * caller filed a huge path or title. The store keeps the full value — the marker names what the
25
+ * wire row dropped, and fetch_approved still returns the exact untruncated apply-back fields.
26
+ */
27
+ const LEAN_CAP_CHARS = 200;
28
+
29
+ function ellipsize(value: string): string {
30
+ // Code points, never UTF-16 units: an astral glyph straddling the boundary must not split.
31
+ const glyphs = [...value];
32
+ return glyphs.length <= LEAN_CAP_CHARS
33
+ ? value
34
+ : `${glyphs.slice(0, LEAN_CAP_CHARS).join("")}…+${glyphs.length - LEAN_CAP_CHARS} chars`;
35
+ }
36
+
37
+ /**
38
+ * The one mapping from stored rows to the wire's lean index row: every kept field spelled out, so
39
+ * a new store column reaches the wire only when someone adds it here deliberately — the default
40
+ * for a new column is OFF the wire (`human_notes` must never reach it; anchors, drafts, texts,
41
+ * context, constraints, hashes, and images stay fetch_approved/dashboard-side). The SDK passes
26
42
  * structuredContent through untouched, so this projection is the strip, not the schema.
27
43
  */
28
- function toWireEntry(row: EntryRow): z.infer<typeof Entry> {
44
+ function toWireEntry(row: EntryRow, slugByProjectId: ReadonlyMap<string, string>): z.infer<typeof Entry> {
29
45
  return {
30
46
  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,
47
+ project: slugByProjectId.get(row.project_id) ?? row.project_id,
48
+ file: ellipsize(row.file),
49
+ title: row.title === null ? null : ellipsize(row.title),
43
50
  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
51
  updated_at: row.updated_at,
52
- applied_at: row.applied_at,
53
- archived_at: row.archived_at,
54
52
  };
55
53
  }
56
54
 
57
- /** The wire row shape, `context`, `constraints`, and `images` kept as their stored JSON strings — no re-parse on the way out. */
55
+ /**
56
+ * The contract's lean index row: short scalar fields only, no text payloads, no images — a listed
57
+ * page of 200 rows must stay a page. The apply-back row is not reachable through this tool at all:
58
+ * fetch_approved returns an approved entry's apply-back fields complete, and the dashboard reads
59
+ * the store directly.
60
+ */
58
61
  const Entry = z.object({
59
62
  id: z.string(),
60
- project_id: z.string(),
61
- batch_id: z.string(),
62
- repo: z.string(),
63
+ project: z.string(),
63
64
  file: z.string(),
64
65
  title: z.string().nullable(),
65
- anchor_text: z.string(),
66
- anchor_before: z.string(),
67
- anchor_after: z.string(),
68
- anchor_hash: z.string(),
69
- file_hash: z.string(),
70
- agent_draft: z.string().nullable(),
71
- human_text: z.string().nullable(),
72
66
  status: z.enum(["draft", "approved", "applied", "rejected"]),
73
- context: z.string(),
74
- constraints: z.string(),
75
- filed_by: z.string().nullable(),
76
- stale_note: z.string().nullable(),
77
- applied_hash: z.string().nullable(),
78
- images: z.string(),
79
- created_at: z.number(),
80
67
  updated_at: z.number(),
81
- applied_at: z.number().nullable(),
82
- archived_at: z.number().nullable(),
83
68
  });
84
69
 
85
70
  const ListEntriesOutput = z.object({
@@ -103,7 +88,7 @@ export function registerListEntriesTool(server: McpServer, store: Store): void {
103
88
  {
104
89
  title: "List entries for review",
105
90
  description:
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.",
91
+ "List the review queue's entries, ordered by ascending entry id. Every filter is optional and combines with AND. Walk pages via next_cursor; the walk is exhausted when it stops coming back. Each row is a lean index: id, project slug, file, title, status, updated_at. Apply-back fields (anchor, hashes, human text, constraints) come back through fetch_approved, and rejected or applied entries expose no agent-readable text anywhere — the dashboard owns authoring.",
107
92
  inputSchema: ListEntriesArgs,
108
93
  outputSchema: ListEntriesOutput,
109
94
  },
@@ -131,9 +116,11 @@ export function registerListEntriesTool(server: McpServer, store: Store): void {
131
116
  });
132
117
 
133
118
  // An exhausted walk carries no `next_cursor` key at all: absent is the signal to stop, so an
134
- // empty page must never smuggle one in.
119
+ // empty page must never smuggle one in. The slug map mirrors the dashboard's own
120
+ // project_id → slug resolution (the store row carries the fk, never the slug).
121
+ const slugByProjectId = new Map(listProjects(store).map((p) => [p.id, p.slug]));
135
122
  const output = {
136
- entries: rows.map(toWireEntry),
123
+ entries: rows.map((row) => toWireEntry(row, slugByProjectId)),
137
124
  ...(nextCursor === null ? {} : { next_cursor: nextCursor }),
138
125
  };
139
126
  return {
@@ -12,10 +12,10 @@ const SHA256_HEX = /^[0-9a-f]{64}$/;
12
12
  * says nothing about what the anchor matched instead.
13
13
  */
14
14
  const MarkAppliedArgs = z.object({
15
- id: z.string().min(1),
16
- result: z.enum(["applied", "anchor_stale"]),
17
- applied_hash: z.string().regex(SHA256_HEX).optional(),
18
- found_text: z.string().min(1).optional(),
15
+ id: z.string().min(1).describe("The entry id to report on."),
16
+ result: z.enum(["applied", "anchor_stale"]).describe('`"applied"`: the approved text is in place; the entry moves to applied. `"anchor_stale"`: the anchor no longer matches the file; the entry returns to approved for re-anchoring on the dashboard.'),
17
+ applied_hash: z.string().regex(SHA256_HEX).optional().describe("The sha256 of the text you applied, as 64 lowercase hex chars; the server does not read repos and records it unverified."),
18
+ found_text: z.string().min(1).optional().describe("What the anchor matched instead, shown on the dashboard for re-anchoring."),
19
19
  });
20
20
 
21
21
  /** The loop-close answer: the id and the resulting status, nothing more. */
@@ -42,7 +42,7 @@ export function registerMarkAppliedTool(server: McpServer, store: Store): void {
42
42
  {
43
43
  title: "Report an applied entry or a stale anchor",
44
44
  description:
45
- "Report the outcome of applying one entry's approved text, closing the loop: result \"applied\" moves an approved entry to applied and stamps applied_at; result \"anchor_stale\" returns the entry to approved and records found_text (optional — what the anchor matched instead; an empty string is refused, so send the matched text or omit the field) in stale_note for re-anchoring on the dashboard. applied_hash (optional) stores the sha256 hex of the text you applied — 64 lowercase hex characters, refused at the boundary when malformed — and is only ever accepted with result \"applied\". Returns {id, status} with the resulting status. Refused, naming the entry, its status, and the fix: an unknown id; an entry in draft (inert until a human approves or rejects it — an agent can never move a draft); an entry in rejected (terminal); an applied entry reported applied again; applied_hash sent with anchor_stale. approved + anchor_stale stays approved; applied + anchor_stale returns to approved; those and approved + applied are the whole agent-side surface.",
45
+ "Report the outcome of applying one approved entry's text, closing the loop. Call it after applying the approved text in place, or when the anchor no longer matches the file.",
46
46
  inputSchema: MarkAppliedArgs,
47
47
  outputSchema: MarkAppliedOutput,
48
48
  },
package/src/mcp/server.ts CHANGED
@@ -11,12 +11,17 @@ import type { Notifier } from "../notify.ts";
11
11
  import { NAME, VERSION } from "../version.ts";
12
12
 
13
13
  /**
14
- * Read by a client before it calls anything, so it states the rule an agent cannot discover from a
15
- * tool signature: prose belongs to the human, and filing a draft is not shipping it.
14
+ * Framing plus the router: one line per tool in the order a caller decides, and the one
15
+ * cross-tool fact no tool owns (a rejected anchor stays rejected). Nothing here restates a
16
+ * tool's own description, and the refusal text teaches what the boundary refuses by name.
16
17
  */
17
18
  const INSTRUCTIONS = `reviewzy is a review queue for user-facing text. Agents draft, a human signs off.
18
19
 
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
+ - \`file_entries\`: file the text you want to change as draft entries.
21
+ - \`await_approved\`: wait when this session needs the human's sign-off before continuing.
22
+ - \`fetch_approved\`: fetch whatever is approved and apply the text in place.
23
+ - \`mark_applied\`: after applying, report the outcome (applied or anchor_stale).
24
+ - rejected stays rejected: re-filing a rejected anchor returns the existing entry.`;
20
25
 
21
26
  /**
22
27
  * A fresh server per request, since the 2026-07-28 revision is stateless and `createMcpHandler`