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/README.md +1 -1
- package/package.json +1 -1
- package/src/dashboard/static/components.css +6780 -546
- package/src/dashboard/static/cursor-rules.js +83 -0
- package/src/dashboard/static/cursor.css +105 -0
- package/src/dashboard/static/cursor.js +358 -0
- package/src/dashboard/static/dashboard.css +52 -125
- package/src/dashboard/static/dashboard.js +6 -29
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/icons.svg +184 -0
- package/src/dashboard/static/tokens.css +421 -110
- package/src/dashboard/static/ui.js +1992 -0
- package/src/dashboard/views.tsx +158 -201
- package/src/db/queries.ts +1 -1
- package/src/mcp/await-approved.ts +17 -10
- package/src/mcp/fetch-approved.ts +11 -6
- package/src/mcp/file-entries.ts +45 -22
- package/src/mcp/list-entries.ts +42 -55
- package/src/mcp/mark-applied.ts +5 -5
- package/src/mcp/server.ts +8 -3
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
|
|
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
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
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
|
-
"
|
|
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
|
-
: {
|
|
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
|
|
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
|
-
|
|
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 }),
|
package/src/mcp/file-entries.ts
CHANGED
|
@@ -34,25 +34,31 @@ const ConstraintsJson = z.strictObject({
|
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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
|
|
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
|
},
|
package/src/mcp/list-entries.ts
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 {
|
package/src/mcp/mark-applied.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
15
|
-
* tool
|
|
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
|
-
|
|
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`
|