reviewzy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +30 -0
  2. package/LICENSE +661 -0
  3. package/README.md +170 -0
  4. package/bin/reviewzy +7 -0
  5. package/package.json +29 -0
  6. package/src/config.ts +76 -0
  7. package/src/daemon/app.ts +159 -0
  8. package/src/daemon/main.ts +103 -0
  9. package/src/daemon/origin.ts +60 -0
  10. package/src/dashboard/auth.ts +122 -0
  11. package/src/dashboard/mount.ts +373 -0
  12. package/src/dashboard/static/components.css +1116 -0
  13. package/src/dashboard/static/dashboard.css +182 -0
  14. package/src/dashboard/static/dashboard.js +169 -0
  15. package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
  16. package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
  17. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  18. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  19. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  20. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  21. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  22. package/src/dashboard/static/htmx.min.js +2 -0
  23. package/src/dashboard/static/tokens.css +213 -0
  24. package/src/dashboard/views.tsx +1388 -0
  25. package/src/db/human-save.ts +242 -0
  26. package/src/db/migrate.ts +71 -0
  27. package/src/db/migrations.ts +149 -0
  28. package/src/db/queries.ts +425 -0
  29. package/src/db/store.ts +83 -0
  30. package/src/db/style-guide.ts +236 -0
  31. package/src/db/sweep.ts +88 -0
  32. package/src/mcp/await-approved.ts +156 -0
  33. package/src/mcp/fetch-approved.ts +132 -0
  34. package/src/mcp/file-entries.ts +219 -0
  35. package/src/mcp/list-entries.ts +107 -0
  36. package/src/mcp/mark-applied.ts +103 -0
  37. package/src/mcp/route.ts +88 -0
  38. package/src/mcp/server.ts +59 -0
  39. package/src/mcp/style-guide-resource.ts +40 -0
  40. package/src/notify.ts +142 -0
  41. package/src/shim/daemon.ts +318 -0
  42. package/src/shim/lockfile.ts +153 -0
  43. package/src/shim/main.ts +172 -0
  44. package/src/shim/semver.ts +29 -0
  45. package/src/version.ts +4 -0
  46. package/tsconfig.json +26 -0
@@ -0,0 +1,236 @@
1
+ import { ulid } from "ulid";
2
+ import { projectIdBySlug } from "./queries.ts";
3
+ import type { Store } from "./store.ts";
4
+
5
+ /** One `style_guides` row as stored: the json columns are opaque strings to every reader. */
6
+ export type StyleGuideRow = {
7
+ readonly id: string;
8
+ readonly project_id: string | null;
9
+ readonly markdown: string;
10
+ readonly banned_words: string;
11
+ readonly glossary: string;
12
+ };
13
+
14
+ /** The typed shape every writer hands the store; the json encoding happens here, never at a call site. */
15
+ export type StyleGuideInput = {
16
+ readonly markdown: string;
17
+ readonly bannedWords: readonly string[];
18
+ readonly glossary: Readonly<Record<string, string>>;
19
+ };
20
+
21
+ /** The save form at the boundary: the raw strings straight off the POST body, before any typing. */
22
+ export type StyleGuideForm = {
23
+ readonly markdown: string;
24
+ readonly bannedWordsCsv: string;
25
+ readonly glossaryText: string;
26
+ };
27
+
28
+ /**
29
+ * The one refusal the parse knows: a glossary line that cannot become an entry. The line is named
30
+ * by number and content, and the reason tells the message which fix to offer. A line with a colon
31
+ * but an empty key refuses too: the merge renders an empty key as no item, so accepting it would
32
+ * silently drop what the author typed.
33
+ */
34
+ export type StyleGuideFormRefusal = {
35
+ readonly kind: "glossary_line";
36
+ readonly lineNumber: number;
37
+ readonly line: string;
38
+ readonly reason: "no_colon" | "empty_key";
39
+ };
40
+
41
+ export type StyleGuideFormParse =
42
+ | { readonly ok: true; readonly input: StyleGuideInput }
43
+ | { readonly ok: false; readonly refusal: StyleGuideFormRefusal };
44
+
45
+ /**
46
+ * Types the three form fields at the boundary. Banned words split on commas, trimmed, empties
47
+ * dropped, duplicates dropped at first occurrence (the merge's union keeps first occurrence too,
48
+ * so what the form shows after a save is what the merge would list). Glossary lines split on the
49
+ * first colon, both sides trimmed; blank lines are no entries (a textarea naturally ends in a
50
+ * newline); an empty value is a deliberate override, matching the merge's project-wins semantics.
51
+ * Markdown stays raw, never trimmed: the merge trims at render, and trimming here would store
52
+ * bytes the author never saw.
53
+ */
54
+ export function parseStyleGuideForm(form: StyleGuideForm): StyleGuideFormParse {
55
+ const bannedWords: string[] = [];
56
+ for (const raw of form.bannedWordsCsv.split(",")) {
57
+ const word = raw.trim();
58
+ if (word !== "" && !bannedWords.includes(word)) bannedWords.push(word);
59
+ }
60
+
61
+ // The glossary accumulates on a null-prototype object: `glossary["__proto__"] = value` on a plain
62
+ // object would vanish through the Object.prototype setter, silently dropping a key the merge
63
+ // would render. An own property stores and round-trips like any other key.
64
+ const glossary: Record<string, string> = Object.create(null);
65
+ const lines = form.glossaryText.split(/\r?\n/);
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const line = lines[i]!;
68
+ if (line.trim() === "") continue;
69
+ const colon = line.indexOf(":");
70
+ if (colon === -1) {
71
+ return { ok: false, refusal: { kind: "glossary_line", lineNumber: i + 1, line, reason: "no_colon" } };
72
+ }
73
+ const key = line.slice(0, colon).trim();
74
+ if (key === "") {
75
+ return { ok: false, refusal: { kind: "glossary_line", lineNumber: i + 1, line, reason: "empty_key" } };
76
+ }
77
+ glossary[key] = line.slice(colon + 1).trim();
78
+ }
79
+ return { ok: true, input: { markdown: form.markdown, bannedWords, glossary } };
80
+ }
81
+
82
+ /**
83
+ * Upserts one guide row: `projectSlug` null addresses the single global row, a slug its project
84
+ * row. The project must already exist — the dashboard editor (queue task 15) only ever edits
85
+ * projects that have entries, and reads never create one, so a slug that resolves to nothing is a
86
+ * caller bug, refused by name rather than minted.
87
+ */
88
+ function resolveProjectId(store: Store, slug: string): string {
89
+ const id = projectIdBySlug(store, slug);
90
+ if (id === null) {
91
+ throw new Error(
92
+ `reviewzy: upsertStyleGuide: project "${slug}" does not exist; file an entry into it first, or pass null for the global row`,
93
+ );
94
+ }
95
+ return id;
96
+ }
97
+
98
+ export function upsertStyleGuide(store: Store, projectSlug: string | null, input: StyleGuideInput): void {
99
+ const projectId = projectSlug === null ? null : resolveProjectId(store, projectSlug);
100
+ const now = Date.now();
101
+ const write = store.db.transaction(() => {
102
+ // `IS ?` over `= ?`: a bound NULL must still match the global row, and `= NULL` never matches.
103
+ const existing = store.db.query("SELECT id FROM style_guides WHERE project_id IS ?").get(projectId) as
104
+ | { id: string }
105
+ | null;
106
+ if (existing === null) {
107
+ store.db.run(
108
+ `INSERT INTO style_guides (id, project_id, markdown, banned_words, glossary, created_at, updated_at)
109
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
110
+ [ulid(), projectId, input.markdown, JSON.stringify(input.bannedWords), JSON.stringify(input.glossary), now, now],
111
+ );
112
+ } else {
113
+ store.db.run(
114
+ "UPDATE style_guides SET markdown = ?, banned_words = ?, glossary = ?, updated_at = ? WHERE id = ?",
115
+ [input.markdown, JSON.stringify(input.bannedWords), JSON.stringify(input.glossary), now, existing.id],
116
+ );
117
+ }
118
+ });
119
+ write();
120
+ }
121
+
122
+ /** The stored json array, or the empty list when it is not an array of strings: only the editor (task 15) writes these columns, so a malformed value is a foreign or hand-edited row, not something to crash a read over. */
123
+ function parseBannedWords(json: string): readonly string[] {
124
+ try {
125
+ const parsed: unknown = JSON.parse(json);
126
+ if (Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) return parsed;
127
+ } catch {
128
+ // fall through to the empty list
129
+ }
130
+ return [];
131
+ }
132
+
133
+ /** The stored json map, or the empty map when it is not an object of strings; same tolerance as `parseBannedWords`. */
134
+ function parseGlossary(json: string): Readonly<Record<string, string>> {
135
+ try {
136
+ const parsed: unknown = JSON.parse(json);
137
+ if (
138
+ typeof parsed === "object" &&
139
+ parsed !== null &&
140
+ !Array.isArray(parsed) &&
141
+ Object.values(parsed).every((value) => typeof value === "string")
142
+ ) {
143
+ return parsed as Record<string, string>;
144
+ }
145
+ } catch {
146
+ // fall through to the empty map
147
+ }
148
+ return {};
149
+ }
150
+
151
+ /** A stored item cannot carry a line break into the merged markdown: the break would render as a new bullet or a broken `key: value` line, saying things no stored item said. Foreign rows are tolerated, never their line breaks. */
152
+ function renderItem(item: string): string {
153
+ return item.replace(/[\r\n]+/g, " ");
154
+ }
155
+
156
+ /** The one row a section addresses: `projectSlug` null is the single global row, a slug its project row. Null when the section has no row yet. */
157
+ function guideRow(store: Store, projectSlug: string | null): StyleGuideRow | null {
158
+ if (projectSlug === null) {
159
+ return store.db.query(
160
+ "SELECT id, project_id, markdown, banned_words, glossary FROM style_guides WHERE project_id IS NULL",
161
+ ).get() as StyleGuideRow | null;
162
+ }
163
+ return store.db.query(
164
+ `SELECT s.id, s.project_id, s.markdown, s.banned_words, s.glossary
165
+ FROM style_guides s JOIN projects p ON p.id = s.project_id WHERE p.slug = ?`,
166
+ ).get(projectSlug) as StyleGuideRow | null;
167
+ }
168
+
169
+ /** The stored section read back typed, the read side of `parseStyleGuideForm`: the editor renders it, and a store row the editor never wrote (a foreign or hand-edited row) reads as empty values, never crashes. Null when the section has no row. */
170
+ export function readStyleGuideSection(store: Store, projectSlug: string | null): StyleGuideInput | null {
171
+ const row = guideRow(store, projectSlug);
172
+ if (row === null) return null;
173
+ return { markdown: row.markdown, bannedWords: parseBannedWords(row.banned_words), glossary: parseGlossary(row.glossary) };
174
+ }
175
+
176
+ /**
177
+ * The merged style guide `docs/mcp-contract.md`'s resource section pins: the global row's markdown,
178
+ * a blank line, the project row's markdown, then the union of banned words (global items first,
179
+ * each once, at its first occurrence) and the glossary (global entries first, a key both rows
180
+ * define rendering the project value, project-only keys appended). Blocks join with exactly one
181
+ * blank line and the stored markdown is trimmed of trailing whitespace so an editor's trailing
182
+ * newline cannot stack blank lines. Both rows are optional: no rows at all renders the empty
183
+ * string. Deterministic for any fixed store.
184
+ *
185
+ * Queue task 8 (`fetch_approved`) embeds this same text, so this is the one place the merge is
186
+ * spelled out.
187
+ */
188
+ export function mergedStyleGuide(store: Store, projectSlug: string): string {
189
+ const globalRow = guideRow(store, null);
190
+ const projectRow = guideRow(store, projectSlug);
191
+
192
+ const blocks: string[] = [];
193
+ for (const markdown of [globalRow?.markdown, projectRow?.markdown]) {
194
+ const trimmed = markdown?.trimEnd() ?? "";
195
+ if (trimmed !== "") blocks.push(trimmed);
196
+ }
197
+
198
+ const banned: string[] = [];
199
+ const seen = new Set<string>();
200
+ for (const word of [...parseBannedWords(globalRow?.banned_words ?? "[]"), ...parseBannedWords(projectRow?.banned_words ?? "[]")]) {
201
+ if (!seen.has(word)) {
202
+ seen.add(word);
203
+ banned.push(word);
204
+ }
205
+ }
206
+ // An empty word bans nothing and an empty key names no term: each would render a dangling bullet
207
+ // ("- " or "- : value"), so both are no items at all. Empty values are different — the
208
+ // project-wins `??` below makes an intentionally empty project value a deliberate override.
209
+ const bannedLines = banned.filter((word) => word !== "").map((word) => `- ${renderItem(word)}`);
210
+ if (bannedLines.length > 0) blocks.push(`## banned\n${bannedLines.join("\n")}`);
211
+
212
+ const glossary: [string, string][] = [];
213
+ const globalGlossary = parseGlossary(globalRow?.glossary ?? "{}");
214
+ const projectGlossary = parseGlossary(projectRow?.glossary ?? "{}");
215
+ const keys = new Set<string>();
216
+ for (const [key, value] of Object.entries(globalGlossary)) {
217
+ // The project wins a key it owns; the own-property read keeps a `__proto__` key the project
218
+ // lacks on the global value, where a bare index would return the inherited accessor. `??`
219
+ // falls through only on null/undefined, so an intentionally empty project value renders empty.
220
+ const projectValue = Object.hasOwn(projectGlossary, key) ? projectGlossary[key] : undefined;
221
+ glossary.push([key, projectValue ?? value]);
222
+ keys.add(key);
223
+ }
224
+ for (const [key, value] of Object.entries(projectGlossary)) {
225
+ if (!keys.has(key)) {
226
+ glossary.push([key, value]);
227
+ keys.add(key);
228
+ }
229
+ }
230
+ const glossaryLines = glossary
231
+ .filter(([key]) => key !== "")
232
+ .map(([key, value]) => `- ${renderItem(key)}: ${renderItem(value)}`);
233
+ if (glossaryLines.length > 0) blocks.push(`## glossary\n${glossaryLines.join("\n")}`);
234
+
235
+ return blocks.join("\n\n");
236
+ }
@@ -0,0 +1,88 @@
1
+ import type { Store } from "./store.ts";
2
+
3
+ /** The hourly cadence `src/daemon/main.ts` arms the sweep timer with. */
4
+ export const SWEEP_INTERVAL_MS = 3_600_000;
5
+
6
+ const MS_PER_DAY = 86_400_000;
7
+
8
+ /**
9
+ * The retention cutoff for a sweep taken "now": entries applied before this instant are past the
10
+ * `ARCHIVE_AFTER_DAYS` window (`docs/design.md` "retention").
11
+ */
12
+ export function archiveCutoff(now: number, archiveAfterDays: number): number {
13
+ return now - archiveAfterDays * MS_PER_DAY;
14
+ }
15
+
16
+ /**
17
+ * The one classification every sweep statement re-asserts, bound with the cutoff: an applied
18
+ * entry whose apply is strictly older than the cutoff and not already archived. `applied_at < ?`
19
+ * never matches a NULL `applied_at` (sqlite folds the comparison to NULL, which a WHERE drops),
20
+ * and `archived_at IS NULL` keeps a live row already stamped out of the selection.
21
+ */
22
+ const EXPIRED_APPLIED = "status = 'applied' AND applied_at < ? AND archived_at IS NULL";
23
+
24
+ /**
25
+ * The candidate snapshot: what one sweep would move if it ran right now. Advisory only — the act
26
+ * (`sweepArchive`) re-asserts the same classification in every statement, so a row that changed
27
+ * after this read is skipped there, never moved.
28
+ */
29
+ export function selectArchiveCandidates(store: Store, now: number, archiveAfterDays: number): string[] {
30
+ const rows = store.db
31
+ .query(`SELECT id FROM entries WHERE ${EXPIRED_APPLIED} ORDER BY id`)
32
+ .all(archiveCutoff(now, archiveAfterDays)) as { id: string }[];
33
+ return rows.map((row) => row.id);
34
+ }
35
+
36
+ /**
37
+ * The retention sweep: moves every expired applied entry, plus its revisions, into the archive
38
+ * tables, in one transaction, and returns the number of entries moved. A second run over the same
39
+ * data has nothing left to insert and returns 0.
40
+ *
41
+ * The statement order is load-bearing: the archive inserts run before the live deletes because
42
+ * `entry_revisions_archive.entry_id` references `entries_archive(id)` and `entry_revisions.entry_id`
43
+ * references `entries(id)`, both `NO ACTION` (migrations.ts) — a sweep that moved an entry without
44
+ * first moving its revisions, or deleted an entry before its revisions, fails loudly on the
45
+ * foreign key instead of orphaning or destroying history. Every statement re-asserts the
46
+ * classification, so a row that changed since a candidate snapshot was taken (the selection
47
+ * above) is skipped at act time rather than trusted from the snapshot.
48
+ */
49
+ export function sweepArchive(store: Store, now: number, archiveAfterDays: number): number {
50
+ const cutoff = archiveCutoff(now, archiveAfterDays);
51
+ const move = store.db.transaction(() => {
52
+ // The entry copy is column-for-column, `archived_at` stamped with this sweep's now. The
53
+ // write count comes from this insert: it is exactly the entries moved.
54
+ const moved = store.db.run(
55
+ `INSERT INTO entries_archive (
56
+ id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
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
59
+ )
60
+ SELECT
61
+ id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after,
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, ?
64
+ FROM entries
65
+ WHERE ${EXPIRED_APPLIED}`,
66
+ [now, cutoff],
67
+ ).changes;
68
+
69
+ store.db.run(
70
+ `INSERT INTO entry_revisions_archive (id, entry_id, human_text, status, created_at, archived_at)
71
+ SELECT r.id, r.entry_id, r.human_text, r.status, r.created_at, ?
72
+ FROM entry_revisions r
73
+ JOIN entries e ON e.id = r.entry_id
74
+ WHERE e.${EXPIRED_APPLIED}`,
75
+ [now, cutoff],
76
+ );
77
+
78
+ // Live revisions die before their entries: the NO ACTION foreign key makes the reverse order
79
+ // an immediate failure.
80
+ store.db.run(`DELETE FROM entry_revisions WHERE entry_id IN (SELECT id FROM entries WHERE ${EXPIRED_APPLIED})`, [
81
+ cutoff,
82
+ ]);
83
+ store.db.run(`DELETE FROM entries WHERE ${EXPIRED_APPLIED}`, [cutoff]);
84
+
85
+ return moved;
86
+ });
87
+ return move();
88
+ }
@@ -0,0 +1,156 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import { z } from "zod";
3
+ import { entriesByIds, type ApprovedEntryRow, type EntryStatus } from "../db/queries.ts";
4
+ import { STATUS_CHANGE_EVENT, type Store } from "../db/store.ts";
5
+ import { ApprovedEntry, approvedRowsToWire } from "./fetch-approved.ts";
6
+
7
+ /** The contract's wait ceiling: `timeout_ms` caps at 600000. */
8
+ export const AWAIT_TIMEOUT_MAX_MS = 600_000;
9
+
10
+ /** The contract's wait default: 60000ms. */
11
+ export const AWAIT_TIMEOUT_DEFAULT_MS = 60_000;
12
+
13
+ /** The sqlite poll cadence that backs the event bus: resolution comes from an event when one is available, a status read at this interval otherwise. */
14
+ const AWAIT_POLL_MS = 500;
15
+
16
+ /** The hint `docs/mcp-contract.md` pins for a timeout or a drain: retry after this long, never sooner. */
17
+ const AWAIT_POLL_AGAIN_MS = 5_000;
18
+
19
+ /**
20
+ * The contract's clamp, pure so a unit test can pin it — a wire test cannot wait out 600 seconds.
21
+ * The zod boundary already refuses anything below 1, so the floor here only guards direct calls.
22
+ */
23
+ export function clampTimeoutMs(ms: number): number {
24
+ return Math.min(Math.max(ms, 1), AWAIT_TIMEOUT_MAX_MS);
25
+ }
26
+
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),
30
+ });
31
+
32
+ const AwaitApprovedOutput = z.object({
33
+ resolved: z.boolean(),
34
+ statuses: z.record(z.string(), z.enum(["draft", "approved", "applied", "rejected"])),
35
+ entries: z.array(ApprovedEntry).optional(),
36
+ poll_again_after_ms: z.number().int().min(1).optional(),
37
+ });
38
+
39
+ type AwaitOutcome = {
40
+ readonly resolved: boolean;
41
+ readonly statuses: Record<string, EntryStatus>;
42
+ readonly entries: z.infer<typeof ApprovedEntry>[];
43
+ };
44
+
45
+ /** 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. */
46
+ function refuse(where: string, problem: string, fix: string): never {
47
+ throw new Error(`await_approved refused: ${where}: ${problem}. fix: ${fix}`);
48
+ }
49
+
50
+ /**
51
+ * The one wait: every named id must be approved or rejected. The fast path is the store-scoped
52
+ * event bus — any status change wakes a re-check, and the re-check is the decision — with a 500ms
53
+ * sqlite poll as the backstop for a change that never dispatched, and the timeout as the ceiling.
54
+ * Settles once: the first resolution wins, and every timer and listener is torn down so the
55
+ * process exits cleanly. The drain path resolves the waiter through the store's waiter registry
56
+ * with the same poll-again outcome as a timeout, whatever the statuses say at that moment.
57
+ */
58
+ function waitForEntries(store: Store, ids: readonly string[], timeoutMs: number): Promise<AwaitOutcome> {
59
+ return new Promise((resolve) => {
60
+ let settled = false;
61
+
62
+ function settle(outcome: AwaitOutcome): void {
63
+ if (settled) return;
64
+ settled = true;
65
+ clearTimeout(timeout);
66
+ clearInterval(poll);
67
+ store.events.removeEventListener(STATUS_CHANGE_EVENT, recheck);
68
+ unregister();
69
+ resolve(outcome);
70
+ }
71
+
72
+ function evaluate(): AwaitOutcome {
73
+ const rows = entriesByIds(store, ids);
74
+ const statuses: Record<string, EntryStatus> = {};
75
+ const approved: ApprovedEntryRow[] = [];
76
+ for (const row of rows) {
77
+ statuses[row.id] = row.status;
78
+ if (row.status === "approved") approved.push(row);
79
+ }
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.
84
+ const done =
85
+ rows.length === ids.length &&
86
+ rows.every((row) => row.status === "approved" || row.status === "rejected");
87
+ return done
88
+ ? { resolved: true, statuses, entries: approvedRowsToWire(approved) }
89
+ : { resolved: false, statuses, entries: [] };
90
+ }
91
+
92
+ function recheck(): void {
93
+ const outcome = evaluate();
94
+ // The event and the poll settle only a resolved wait; the timeout settles with the current
95
+ // state, and a drain with the current statuses forced into the poll-again shape.
96
+ if (outcome.resolved) settle(outcome);
97
+ }
98
+
99
+ function settleDrain(): void {
100
+ const outcome = evaluate();
101
+ // The drain's pin: always the poll-again shape, because the daemon is leaving — a
102
+ // resolved:true would hand the client a completed answer with no daemon behind it.
103
+ settle({ ...outcome, resolved: false });
104
+ }
105
+
106
+ const timeout = setTimeout(() => settle(evaluate()), timeoutMs);
107
+ const poll = setInterval(recheck, AWAIT_POLL_MS);
108
+ store.events.addEventListener(STATUS_CHANGE_EVENT, recheck);
109
+ const unregister = store.registerWaiter(settleDrain);
110
+
111
+ // The rows may already be resolved when the wait starts: the answer then is immediate.
112
+ recheck();
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Registers the contract's blocking tool: a live session waits until every named id reaches
118
+ * approved or rejected. The up-front existence check and the refusal sit here, not in the query:
119
+ * reads never auto-create anything, so an unknown id must surface as a business refusal naming it
120
+ * before any waiting begins.
121
+ */
122
+ export function registerAwaitApprovedTool(server: McpServer, store: Store): void {
123
+ server.registerTool(
124
+ "await_approved",
125
+ {
126
+ title: "Wait for entries to be approved or rejected",
127
+ 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.",
129
+ inputSchema: AwaitApprovedArgs,
130
+ outputSchema: AwaitApprovedOutput,
131
+ },
132
+ async (args) => {
133
+ const ids = [...new Set(args.ids)];
134
+ const timeoutMs = clampTimeoutMs(args.timeout_ms);
135
+
136
+ const existing = new Set(entriesByIds(store, ids).map((row) => row.id));
137
+ const missing = ids.filter((id) => !existing.has(id));
138
+ if (missing.length > 0) {
139
+ refuse(
140
+ `entry "${missing[0]!}"${missing.length > 1 ? ` and ${missing.length - 1} more` : ""}`,
141
+ "no such entry, and a read tool never creates one",
142
+ "file it with file_entries first, or list entries to see the ids that exist",
143
+ );
144
+ }
145
+
146
+ const outcome = await waitForEntries(store, ids, timeoutMs);
147
+ const output = outcome.resolved
148
+ ? { resolved: true, statuses: outcome.statuses, entries: outcome.entries }
149
+ : { resolved: false, statuses: outcome.statuses, poll_again_after_ms: AWAIT_POLL_AGAIN_MS };
150
+ return {
151
+ content: [{ type: "text" as const, text: JSON.stringify(output) }],
152
+ structuredContent: output,
153
+ };
154
+ },
155
+ );
156
+ }
@@ -0,0 +1,132 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import { z } from "zod";
3
+ import { approvedEntries, projectIdBySlug, type ApprovedEntryRow } from "../db/queries.ts";
4
+ import { mergedStyleGuide } from "../db/style-guide.ts";
5
+ import type { Store } from "../db/store.ts";
6
+
7
+ /**
8
+ * The contract's three params, `project` required: there is no cross-project fetch — the merged
9
+ * style guide is per-project, and the applying agent needs the voice of the project it writes into.
10
+ */
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(),
15
+ });
16
+
17
+ /**
18
+ * The read-side constraints shape: the same four fields the write boundary refuses to misspell,
19
+ * but tolerated loosely — a row storing anything else (a hand-edited or foreign row, since no
20
+ * writer of this server produced it) renders `{}` instead of failing an apply-back.
21
+ */
22
+ const ParsedConstraints = z.strictObject({
23
+ max_len: z.number().int().positive().optional(),
24
+ placeholders: z.array(z.string()).optional(),
25
+ tone: z.string().optional(),
26
+ notes: z.string().optional(),
27
+ });
28
+
29
+ /** The wire entry the contract's parenthetical names: text + anchor + constraints, no status (everything here is approved) and no context. */
30
+ export const ApprovedEntry = z.object({
31
+ id: z.string(),
32
+ repo: z.string(),
33
+ file: z.string(),
34
+ anchor_text: z.string(),
35
+ anchor_before: z.string(),
36
+ anchor_after: z.string(),
37
+ anchor_hash: z.string(),
38
+ file_hash: z.string(),
39
+ text: z.string().nullable(),
40
+ constraints: ParsedConstraints,
41
+ });
42
+
43
+ const FetchApprovedOutput = z.object({
44
+ entries: z.array(ApprovedEntry),
45
+ style_guide: z.string(),
46
+ next_since: z.string().optional(),
47
+ });
48
+
49
+ /** 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. */
50
+ function refuse(where: string, problem: string, fix: string): never {
51
+ throw new Error(`fetch_approved refused: ${where}: ${problem}. fix: ${fix}`);
52
+ }
53
+
54
+ /**
55
+ * The stored constraints json, or the empty object when it does not parse into the typed shape —
56
+ * the same tolerance class as the style-guide reads: an unparseable value is a foreign or
57
+ * hand-edited row, never a reason to fail the fetch.
58
+ */
59
+ function parseConstraints(json: string): z.infer<typeof ParsedConstraints> {
60
+ try {
61
+ const parsed: unknown = JSON.parse(json);
62
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
63
+ const result = ParsedConstraints.safeParse(parsed);
64
+ if (result.success) return result.data;
65
+ }
66
+ } catch {
67
+ // fall through to the empty object
68
+ }
69
+ return {};
70
+ }
71
+
72
+ /**
73
+ * The one mapping from approved store rows to the wire shape; fetch_approved and await_approved
74
+ * share it, so the two tools' entries arrays cannot drift.
75
+ */
76
+ export function approvedRowsToWire(rows: readonly ApprovedEntryRow[]): z.infer<typeof ApprovedEntry>[] {
77
+ return rows.map((row) => ({
78
+ id: row.id,
79
+ repo: row.repo,
80
+ file: row.file,
81
+ anchor_text: row.anchor_text,
82
+ anchor_before: row.anchor_before,
83
+ anchor_after: row.anchor_after,
84
+ anchor_hash: row.anchor_hash,
85
+ file_hash: row.file_hash,
86
+ text: row.human_text,
87
+ constraints: parseConstraints(row.constraints),
88
+ }));
89
+ }
90
+
91
+ /**
92
+ * Registers the contract's apply-back tool: approved prose, anchors, and constraints, with the
93
+ * merged style guide embedded so a bulk rewrite shares one voice. The project lookup and the
94
+ * refusal sit here, not in the query: reads never auto-create a project, so an unknown slug must
95
+ * surface as a business refusal naming the slug and the fix before any query runs.
96
+ */
97
+ export function registerFetchApprovedTool(server: McpServer, store: Store): void {
98
+ server.registerTool(
99
+ "fetch_approved",
100
+ {
101
+ title: "Fetch approved entries to apply",
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.",
104
+ inputSchema: FetchApprovedArgs,
105
+ outputSchema: FetchApprovedOutput,
106
+ },
107
+ async (args) => {
108
+ const projectId = projectIdBySlug(store, args.project);
109
+ if (projectId === null) {
110
+ refuse(
111
+ `project "${args.project}"`,
112
+ "no such project, and a read tool never creates one",
113
+ "file an entry into it with file_entries first, then fetch again",
114
+ );
115
+ }
116
+
117
+ const { rows, nextSince } = approvedEntries(store, projectId, { ids: args.ids, since: args.since });
118
+
119
+ const output = {
120
+ entries: approvedRowsToWire(rows),
121
+ style_guide: mergedStyleGuide(store, args.project),
122
+ // An exhausted walk carries no `next_since` key at all: absent is the signal to stop, so an
123
+ // empty page must never smuggle one in.
124
+ ...(nextSince === null ? {} : { next_since: nextSince }),
125
+ };
126
+ return {
127
+ content: [{ type: "text" as const, text: JSON.stringify(output) }],
128
+ structuredContent: output,
129
+ };
130
+ },
131
+ );
132
+ }