md-review-server 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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +236 -0
  3. package/bin/md-review.js +217 -0
  4. package/bin/skill-manager.js +145 -0
  5. package/dist/assets/_baseUniq-DI2TZgiU.js +1 -0
  6. package/dist/assets/arc-0aOBgqln.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-D1WnZX0i.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-CMmHyk4v.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-CnjNpHKo.js +10 -0
  10. package/dist/assets/channel-DKOgBY_w.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-_1bE-T-E.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-dlxfCuoj.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-C42iQvc0.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-B5Uv0h4o.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-BdfjKS1C.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-bYBDKYcm.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-2hdQ_fkV.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-C8LiAShG.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-A7wHDY1p.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-A7wHDY1p.js +1 -0
  21. package/dist/assets/clone-FuZyOQgB.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-DsxEv76Y.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-CzhKzxyL.js +4 -0
  25. package/dist/assets/defaultLocale-C4B-KCzX.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-DchJfjQS.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-CxBYETfP.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-U9S5ZOME.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BqZKWv9l.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-7E3VaRAM.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-C3giu5WC.js +267 -0
  32. package/dist/assets/gitGraphDiagram-NY62KEGX-09l3qi9Y.js +65 -0
  33. package/dist/assets/graph-CIHF1jxj.js +1 -0
  34. package/dist/assets/index-D__pdEdb.css +19 -0
  35. package/dist/assets/index-DonetEir.js +346 -0
  36. package/dist/assets/infoDiagram-WHAUD3N6-CxrQKkZ7.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CafZCYAC.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BPnBXjFX.js +89 -0
  40. package/dist/assets/katex-Cu_Erd72.js +261 -0
  41. package/dist/assets/layout-CuN5D054.js +1 -0
  42. package/dist/assets/linear-CVBbq0yW.js +1 -0
  43. package/dist/assets/min-DqAzei1c.js +1 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-C9JzG0Gk.js +68 -0
  45. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-DPLAvqYj.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-CF0Op1tv.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-CTYaZjq6.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-CVsSH6ag.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-_5LQ8ply.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-lGntU0qp.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-D4z3Ploi.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-B2Cv_EhF.js +61 -0
  54. package/dist/assets/treemap-KMMF4GRG-C7myvUeN.js +128 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-BlM5iMNi.js +7 -0
  56. package/dist/index.html +13 -0
  57. package/package.json +105 -0
  58. package/server/app.js +239 -0
  59. package/server/comment-store.js +277 -0
  60. package/server/index.js +161 -0
  61. package/skills/markdown-review-loop/SKILL.md +187 -0
  62. package/skills/markdown-review-loop/VERSION +1 -0
  63. package/skills/markdown-review-loop/agents/openai.yaml +4 -0
  64. package/skills/markdown-review-loop/references/review-template.md +42 -0
@@ -0,0 +1,277 @@
1
+ import { mkdir, readFile, readdir, rename, writeFile } from 'fs/promises';
2
+ import { dirname, extname, isAbsolute, join, resolve } from 'path';
3
+
4
+ const VALID_STATUSES = new Set([
5
+ 'open',
6
+ 'resolved',
7
+ 'partially_resolved',
8
+ 'unresolved',
9
+ 'ignored',
10
+ ]);
11
+
12
+ function normalizeReviewFile(file) {
13
+ if (!file || typeof file !== 'string') {
14
+ throw new Error('Comment file is required');
15
+ }
16
+
17
+ const normalized = file.replaceAll('\\', '/');
18
+ if (normalized.startsWith('/') || normalized.split('/').includes('..')) {
19
+ throw new Error(`Invalid comment file: ${file}`);
20
+ }
21
+
22
+ return normalized;
23
+ }
24
+
25
+ function reviewFilenameFor(file) {
26
+ const normalized = normalizeReviewFile(file);
27
+ const ext = extname(normalized);
28
+ const withoutExt = ext ? normalized.slice(0, -ext.length) : normalized;
29
+ return `${withoutExt.replaceAll('/', '__').replace(/[^A-Za-z0-9._-]/g, '_')}.review.json`;
30
+ }
31
+
32
+ function extractDocumentVersion(file) {
33
+ const filename = file.split('/').pop() || '';
34
+ const match = filename.match(/(?:^|[._-])(v\d+)(?=[._-]|$)/);
35
+ return match?.[1];
36
+ }
37
+
38
+ function nowIso() {
39
+ return new Date().toISOString();
40
+ }
41
+
42
+ function initialReviewDocument(file) {
43
+ const now = nowIso();
44
+ return {
45
+ schemaVersion: 1,
46
+ document: file,
47
+ createdAt: now,
48
+ updatedAt: now,
49
+ comments: [],
50
+ };
51
+ }
52
+
53
+ function nextCommentId(comments) {
54
+ const max = comments.reduce((currentMax, comment) => {
55
+ const match = /^c(\d+)$/.exec(comment.id || '');
56
+ return match ? Math.max(currentMax, Number(match[1])) : currentMax;
57
+ }, 0);
58
+ return `c${String(max + 1).padStart(3, '0')}`;
59
+ }
60
+
61
+ function applyPatch(comment, patch, timestamp) {
62
+ const next = { ...comment };
63
+ const fields = [
64
+ 'comment',
65
+ 'status',
66
+ 'targetFile',
67
+ 'resolution',
68
+ 'consumedBy',
69
+ 'startLine',
70
+ 'endLine',
71
+ 'startOffset',
72
+ 'endOffset',
73
+ 'selectedText',
74
+ 'beforeText',
75
+ 'afterText',
76
+ ];
77
+
78
+ for (const field of fields) {
79
+ if (patch[field] !== undefined) {
80
+ next[field] = patch[field];
81
+ }
82
+ }
83
+
84
+ if (patch.status !== undefined) {
85
+ if (!VALID_STATUSES.has(patch.status)) {
86
+ throw new Error(`Invalid comment status: ${patch.status}`);
87
+ }
88
+ if (patch.status !== 'open' && patch.status !== comment.status) {
89
+ next.consumedAt = timestamp;
90
+ }
91
+ }
92
+
93
+ next.updatedAt = timestamp;
94
+ return next;
95
+ }
96
+
97
+ export class FileCommentStore {
98
+ constructor({ rootDir, reviewDir = '.reviews' }) {
99
+ this.rootDir = resolve(rootDir);
100
+ this.reviewDir = isAbsolute(reviewDir) ? reviewDir : resolve(this.rootDir, reviewDir);
101
+ }
102
+
103
+ async ensureReviewDir() {
104
+ await mkdir(this.reviewDir, { recursive: true });
105
+ }
106
+
107
+ getReviewFilePath(file) {
108
+ return join(this.reviewDir, reviewFilenameFor(file));
109
+ }
110
+
111
+ async listComments(filter = {}) {
112
+ if (filter.file) {
113
+ const review = await this.readReviewFile(filter.file);
114
+ return this.applyFilter(review.comments, filter);
115
+ }
116
+
117
+ const reviews = await this.readAllReviewFiles();
118
+ return this.applyFilter(
119
+ reviews.flatMap((review) => review.comments),
120
+ filter,
121
+ );
122
+ }
123
+
124
+ async createComment(input) {
125
+ const file = normalizeReviewFile(input.file);
126
+ const review = await this.readReviewFile(file);
127
+ const timestamp = nowIso();
128
+ const comment = {
129
+ id: nextCommentId(review.comments),
130
+ file,
131
+ documentVersion: extractDocumentVersion(file),
132
+ startLine: input.startLine,
133
+ endLine: input.endLine,
134
+ startOffset: input.startOffset,
135
+ endOffset: input.endOffset,
136
+ selectedText: input.selectedText,
137
+ beforeText: input.beforeText,
138
+ afterText: input.afterText,
139
+ comment: input.comment,
140
+ status: 'open',
141
+ createdAt: timestamp,
142
+ updatedAt: timestamp,
143
+ };
144
+
145
+ review.comments.push(comment);
146
+ await this.writeReviewFile(file, review, timestamp);
147
+ return comment;
148
+ }
149
+
150
+ async updateComment(id, patch) {
151
+ const { file, review } = await this.findReviewForComment(id, patch.file);
152
+ const timestamp = nowIso();
153
+ const index = review.comments.findIndex((comment) => comment.id === id);
154
+ if (index === -1) {
155
+ throw new Error(`Comment not found: ${id}`);
156
+ }
157
+
158
+ review.comments[index] = applyPatch(review.comments[index], patch, timestamp);
159
+ await this.writeReviewFile(file, review, timestamp);
160
+ return review.comments[index];
161
+ }
162
+
163
+ async batchUpdateComments(updates) {
164
+ const updated = [];
165
+ for (const update of updates) {
166
+ updated.push(await this.updateComment(update.id, update));
167
+ }
168
+ return updated;
169
+ }
170
+
171
+ async deleteComment(id, options = {}) {
172
+ const { file, review } = await this.findReviewForComment(id, options.file);
173
+ const nextComments = review.comments.filter((comment) => comment.id !== id);
174
+ if (nextComments.length === review.comments.length) {
175
+ throw new Error(`Comment not found: ${id}`);
176
+ }
177
+
178
+ review.comments = nextComments;
179
+ await this.writeReviewFile(file, review, nowIso());
180
+ }
181
+
182
+ applyFilter(comments, filter) {
183
+ return comments.filter((comment) => {
184
+ if (filter.status && comment.status !== filter.status) {
185
+ return false;
186
+ }
187
+ return true;
188
+ });
189
+ }
190
+
191
+ async readReviewFile(file) {
192
+ const normalized = normalizeReviewFile(file);
193
+ const reviewFile = this.getReviewFilePath(normalized);
194
+
195
+ try {
196
+ const parsed = JSON.parse(await readFile(reviewFile, 'utf-8'));
197
+ return {
198
+ ...parsed,
199
+ document: parsed.document || normalized,
200
+ comments: Array.isArray(parsed.comments) ? parsed.comments : [],
201
+ };
202
+ } catch (err) {
203
+ if (err?.code === 'ENOENT') {
204
+ return initialReviewDocument(normalized);
205
+ }
206
+ if (err instanceof SyntaxError) {
207
+ throw new Error(`Invalid review file JSON: ${reviewFile}`);
208
+ }
209
+ throw err;
210
+ }
211
+ }
212
+
213
+ async readAllReviewFiles() {
214
+ await this.ensureReviewDir();
215
+ const entries = await readdir(this.reviewDir, { withFileTypes: true });
216
+ const reviews = [];
217
+
218
+ for (const entry of entries) {
219
+ if (!entry.isFile() || !entry.name.endsWith('.review.json')) {
220
+ continue;
221
+ }
222
+
223
+ const reviewFile = join(this.reviewDir, entry.name);
224
+ try {
225
+ const parsed = JSON.parse(await readFile(reviewFile, 'utf-8'));
226
+ reviews.push({
227
+ ...parsed,
228
+ comments: Array.isArray(parsed.comments) ? parsed.comments : [],
229
+ });
230
+ } catch (err) {
231
+ if (err instanceof SyntaxError) {
232
+ throw new Error(`Invalid review file JSON: ${reviewFile}`);
233
+ }
234
+ throw err;
235
+ }
236
+ }
237
+
238
+ return reviews;
239
+ }
240
+
241
+ async writeReviewFile(file, review, timestamp) {
242
+ await this.ensureReviewDir();
243
+ const reviewFile = this.getReviewFilePath(file);
244
+ const nextReview = {
245
+ schemaVersion: review.schemaVersion || 1,
246
+ document: review.document || file,
247
+ createdAt: review.createdAt || timestamp,
248
+ updatedAt: timestamp,
249
+ comments: review.comments,
250
+ };
251
+ const tempFile = `${reviewFile}.${process.pid}.${Date.now()}.tmp`;
252
+
253
+ await mkdir(dirname(reviewFile), { recursive: true });
254
+ await writeFile(tempFile, `${JSON.stringify(nextReview, null, 2)}\n`, 'utf-8');
255
+ await rename(tempFile, reviewFile);
256
+ }
257
+
258
+ async findReviewForComment(id, file) {
259
+ if (file) {
260
+ const normalized = normalizeReviewFile(file);
261
+ return { file: normalized, review: await this.readReviewFile(normalized) };
262
+ }
263
+
264
+ const reviews = await this.readAllReviewFiles();
265
+ const matches = reviews.filter((review) =>
266
+ review.comments.some((comment) => comment.id === id),
267
+ );
268
+ if (matches.length === 0) {
269
+ throw new Error(`Comment not found: ${id}`);
270
+ }
271
+ if (matches.length > 1) {
272
+ throw new Error(`Ambiguous comment id without file: ${id}`);
273
+ }
274
+
275
+ return { file: normalizeReviewFile(matches[0].document), review: matches[0] };
276
+ }
277
+ }
@@ -0,0 +1,161 @@
1
+ // server/index.js
2
+ import { Hono } from 'hono';
3
+ import { serve } from '@hono/node-server';
4
+ import { serveStatic } from '@hono/node-server/serve-static';
5
+ import { readFile } from 'fs/promises';
6
+ import { dirname, relative, resolve } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { watch } from 'chokidar';
9
+ import { createApp, isMarkdownFile } from './app.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ const packageRoot = resolve(__dirname, '..');
14
+ const distDir = resolve(packageRoot, 'dist');
15
+
16
+ function validatePort(value) {
17
+ const port = parseInt(value, 10);
18
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
19
+ throw new Error(`Invalid port: ${value}`);
20
+ }
21
+ return port;
22
+ }
23
+
24
+ async function startServer(app, port, host, maxRetries = 10) {
25
+ for (let i = 0; i < maxRetries; i++) {
26
+ const tryPort = port + i;
27
+ try {
28
+ await new Promise((resolveServer, reject) => {
29
+ const server = serve({
30
+ fetch: app.fetch,
31
+ port: tryPort,
32
+ hostname: host,
33
+ });
34
+ server.once('listening', () => resolveServer(server));
35
+ server.once('error', reject);
36
+ });
37
+ return tryPort;
38
+ } catch (err) {
39
+ if (err.code === 'EADDRINUSE') {
40
+ console.log(`Port ${tryPort} is in use, trying ${tryPort + 1}...`);
41
+ continue;
42
+ }
43
+ throw err;
44
+ }
45
+ }
46
+ throw new Error(`Could not find an available port after ${maxRetries} attempts`);
47
+ }
48
+
49
+ const PORT = validatePort(process.env.API_PORT || 3030);
50
+ const HOST = process.env.API_HOST || '127.0.0.1';
51
+ const MARKDOWN_FILE_PATH = process.env.MARKDOWN_FILE_PATH;
52
+ const BASE_DIR = process.env.BASE_DIR || process.cwd();
53
+ const REVIEW_DIR = process.env.REVIEW_DIR || '.reviews';
54
+ const ACTIVE_FILE = process.env.ACTIVE_FILE || null;
55
+ const READONLY = process.env.READONLY === 'true';
56
+
57
+ if (HOST === '0.0.0.0') {
58
+ console.warn('Warning: md-review-server is listening on 0.0.0.0 without authentication.');
59
+ }
60
+
61
+ const app = new Hono();
62
+ const serverOptions = {
63
+ markdownFilePath: MARKDOWN_FILE_PATH,
64
+ baseDir: BASE_DIR,
65
+ reviewDir: REVIEW_DIR,
66
+ activeFile: ACTIVE_FILE,
67
+ readonly: READONLY,
68
+ port: PORT,
69
+ host: HOST,
70
+ };
71
+ const apiApp = createApp(serverOptions);
72
+
73
+ const sseClients = new Set();
74
+
75
+ app.get('/api/watch', (c) => {
76
+ const stream = new ReadableStream({
77
+ start(controller) {
78
+ const encoder = new TextEncoder();
79
+ controller.enqueue(encoder.encode('data: {"type":"connected"}\n\n'));
80
+
81
+ const client = { controller, encoder };
82
+ sseClients.add(client);
83
+
84
+ c.req.raw.signal.addEventListener('abort', () => {
85
+ sseClients.delete(client);
86
+ });
87
+ },
88
+ });
89
+
90
+ return new Response(stream, {
91
+ headers: {
92
+ 'Content-Type': 'text/event-stream',
93
+ 'Cache-Control': 'no-cache',
94
+ Connection: 'keep-alive',
95
+ },
96
+ });
97
+ });
98
+
99
+ app.route('/', apiApp);
100
+ app.use('/*', serveStatic({ root: relative(process.cwd(), distDir) || '.' }));
101
+
102
+ app.get('*', async (c) => {
103
+ try {
104
+ const indexPath = resolve(distDir, 'index.html');
105
+ const html = await readFile(indexPath, 'utf-8');
106
+ return c.html(html);
107
+ } catch {
108
+ return c.text('Not found', 404);
109
+ }
110
+ });
111
+
112
+ const SERVER_READY_MESSAGE = 'md-review server started';
113
+
114
+ const watchTarget = MARKDOWN_FILE_PATH || BASE_DIR;
115
+ const watchBase = MARKDOWN_FILE_PATH ? dirname(MARKDOWN_FILE_PATH) : BASE_DIR;
116
+ const watcher = watch(watchTarget, {
117
+ ignored: MARKDOWN_FILE_PATH ? undefined : /(^|[/\\])\..|(node_modules|dist)/,
118
+ persistent: true,
119
+ ignoreInitial: true,
120
+ awaitWriteFinish: {
121
+ stabilityThreshold: 100,
122
+ pollInterval: 100,
123
+ },
124
+ });
125
+
126
+ function broadcastWatchEvent(type, path) {
127
+ if (!isMarkdownFile(path)) {
128
+ return;
129
+ }
130
+
131
+ const relativePath = relative(watchBase, path);
132
+ console.log(`File ${type === 'file-added' ? 'added' : 'changed'}: ${relativePath}`);
133
+
134
+ const message = JSON.stringify({
135
+ type,
136
+ path: relativePath,
137
+ });
138
+
139
+ sseClients.forEach((client) => {
140
+ try {
141
+ client.controller.enqueue(client.encoder.encode(`data: ${message}\n\n`));
142
+ } catch {
143
+ sseClients.delete(client);
144
+ }
145
+ });
146
+ }
147
+
148
+ watcher.on('change', (path) => broadcastWatchEvent('file-changed', path));
149
+ watcher.on('add', (path) => broadcastWatchEvent('file-added', path));
150
+
151
+ startServer(app, PORT, HOST)
152
+ .then((actualPort) => {
153
+ serverOptions.port = actualPort;
154
+ console.log(`API Server running on http://${HOST}:${actualPort}`);
155
+ console.log(`Watching for file changes in: ${watchTarget}`);
156
+ console.log(SERVER_READY_MESSAGE);
157
+ })
158
+ .catch((err) => {
159
+ console.error('Failed to start server:', err.message);
160
+ process.exit(1);
161
+ });
@@ -0,0 +1,187 @@
1
+ ---
2
+ name: markdown-review-loop
3
+ description: 运行面向 Codex 的 Markdown 文档评审循环,提供浏览器可视化预览、选区批注、评论收集、下一版修订稿生成和评论状态跟踪。适用于用户要求可视化评审 Markdown、打开浏览器批注文档、启动评审、继续评审、读取评论、应用评论、生成下一版 Markdown、处理 md-review-server 评论或维护 Markdown review queue 的场景。
4
+ ---
5
+
6
+ # Markdown Review Loop
7
+
8
+ ## 目标
9
+
10
+ 使用本 skill 帮助用户以稳定流程迭代 Markdown 文档:
11
+
12
+ 1. 创建或识别一个版本化 Markdown 草稿。
13
+ 2. 启动或复用本地 `md-review-server` 评审会话。
14
+ 3. 通过 review HTTP API 读取 `open` 评论;API 不可用时使用粘贴评论作为降级路径。
15
+ 4. 根据评论生成下一版 Markdown,不覆盖历史版本。
16
+ 5. 汇总每条评论的处理结果,并通过 HTTP API 回写状态。
17
+
18
+ 该流程面向 Codex 协作场景。默认让用户只在浏览器中选区批注,由 Codex 负责启动服务、读取评论、生成新版本和回写状态。
19
+
20
+ ## 启动评审
21
+
22
+ 当用户要求启动 Markdown 评审循环时,按以下步骤处理:
23
+
24
+ 1. 确认源 Markdown 文档。
25
+ 2. 如果文档尚不存在,创建版本化文件,例如 `topic.v1.md`。
26
+ 3. 保留旧版本。生成 `v2`、`v3` 时不要覆盖 `v1`。
27
+ 4. 本地执行可用时,优先尝试静默更新本 skill;更新失败时继续使用当前本地版本:
28
+
29
+ ```bash
30
+ npx -y md-review-server@latest skill update --quiet
31
+ ```
32
+
33
+ 5. 自动启动 `md-review-server`。迭代评审优先服务父目录,使新生成的 `v2`、`v3` 可在文件树中直接切换:
34
+
35
+ ```bash
36
+ npx md-review-server path/to/docs --port 3030 --active-file path/to/docs/document.v1.md
37
+ ```
38
+
39
+ 6. 如果任务只涉及单文件且目录模式会增加歧义,可以使用文件模式:
40
+
41
+ ```bash
42
+ npx md-review-server path/to/document.v1.md --port 3030
43
+ ```
44
+
45
+ 7. 将 `3030` 视为首选起始端口,不视为固定端口。`md-review-server` 会在请求端口被占用时自动尝试后续端口,服务输出会打印实际 URL。后续链接和 API 调用必须使用实际端口。
46
+ 8. 只有在确认已有 `md-review-server` 服务正在服务正确文件或父目录时,才复用已有服务。
47
+ 9. 回复用户时,用文档文件名作为 Markdown 链接文本,不直接暴露裸 URL。目录模式下链接应包含 `?file=<document>`,便于用户直接打开目标文件。
48
+
49
+ 生成新版本后保持评审面可继续使用:如果父目录已在服务中,提示用户选择新文件;否则为新文件启动新的 review session。
50
+
51
+ ## 读取评论
52
+
53
+ 当用户表示评论已准备好时,按以下顺序读取:
54
+
55
+ 1. 优先使用当前 review HTTP API。先请求 `GET /api/session`,确认服务根目录、active file 和 reviewDir。
56
+ 2. 使用服务实际端口读取 `open` 评论:
57
+
58
+ ```bash
59
+ curl 'http://127.0.0.1:<port>/api/comments?file=<document>&status=open'
60
+ ```
61
+
62
+ 3. 将 API 评论转换为评审项:
63
+ - `selectedText` 对应 `quote`
64
+ - `startLine` / `endLine` 对应源行范围
65
+ - `startOffset` / `endOffset` 与 `beforeText` / `afterText` 用作长行局部定位
66
+ - `comment` 对应评审指令
67
+ 4. HTTP API 不可用时,要求用户粘贴复制出的评论或 review queue。
68
+ 5. 粘贴评论缺少选区文本时,仍可按行号处理;如果评论只要求修改长行中的局部内容且定位有歧义,应要求用户提供精确 quote。
69
+
70
+ 读取评论不会消费评论。只有在下一版 Markdown 已生成并形成处理结论后,才回写评论状态。
71
+
72
+ ## 应用评论
73
+
74
+ 应用评论时按以下规则处理:
75
+
76
+ 1. 修改前读取目标 Markdown 文件。
77
+ 2. 创建下一版本文件:
78
+ - `name.v1.md` -> `name.v2.md`
79
+ - `name.v2.md` -> `name.v3.md`
80
+ 3. 如果源文件未版本化,优先创建 `name.v1.md`;如果该选择不符合用户语境,可创建 `name.reviewed.md` 并说明原因。
81
+ 4. 将改动限制在评论指向的范围:
82
+ - 有 `quote` 时,只修改目标行范围内匹配的选区文本。
83
+ - `quote` 出现多次时,用行范围和上下文作为定位依据。
84
+ - 仍存在歧义时,将该评论标记为 `unresolved` 并说明原因。
85
+ - 无 `quote` 时,按行范围处理。
86
+ 5. 除非评论明确要求调整结构,否则保持原文档结构。
87
+ 6. 除非评论明确指向事实、引用或代码块,否则保留这些内容。
88
+ 7. 不静默丢弃评论。每条评论都必须出现在处理结果中。
89
+
90
+ ## 回写评论状态
91
+
92
+ 生成下一版 Markdown 后,通过 review HTTP API 回写每条已处理评论。优先使用批量 PATCH:
93
+
94
+ ```bash
95
+ curl -X PATCH 'http://127.0.0.1:<port>/api/comments' \
96
+ -H 'Content-Type: application/json' \
97
+ -d '{
98
+ "updates": [
99
+ {
100
+ "id": "c001",
101
+ "file": "docs/example.v1.md",
102
+ "status": "resolved",
103
+ "targetFile": "docs/example.v2.md",
104
+ "resolution": "已补充一致性边界说明。"
105
+ }
106
+ ]
107
+ }'
108
+ ```
109
+
110
+ 状态使用规则:
111
+
112
+ - `resolved`:评论已完整处理。
113
+ - `partially_resolved`:评论已部分处理,`resolution` 中说明剩余问题。
114
+ - `unresolved`:评论未处理,`resolution` 中说明阻塞原因。
115
+ - `ignored`:仅在用户或 agent 明确跳过评论时使用。
116
+
117
+ ## 接收粘贴评论
118
+
119
+ 当用户粘贴评论时,将 `md-review-server` API 评论、复制评论和 review queue 条目统一视为评审项。
120
+
121
+ 支持以下格式:
122
+
123
+ ```text
124
+ docs/example.v1.md:L42-L48
125
+ 评论内容...
126
+ ```
127
+
128
+ ```text
129
+ docs/example.v1.md:L42
130
+ 评论内容...
131
+ ```
132
+
133
+ ```md
134
+ ## C001
135
+ source: docs/example.v1.md:L42-L48
136
+ quote: "长行中的局部选区"
137
+ status: open
138
+ comment: 评论内容...
139
+ ```
140
+
141
+ 定位信息缺失时,使用 `quote` 或上下文推断目标位置。推断会改变语义时,将评论标记为 `unresolved` 并要求更清晰的定位信息。
142
+
143
+ ## 维护 Review Queue
144
+
145
+ 当评论超过三条,或用户要求保留可读队列时,创建或更新同级 review queue 文件:
146
+
147
+ ```text
148
+ <document-stem>.review.md
149
+ ```
150
+
151
+ 使用 `references/review-template.md` 作为格式参考。队列保持简洁,每条评论使用以下状态之一:
152
+
153
+ - `open`
154
+ - `resolved`
155
+ - `partially_resolved`
156
+ - `unresolved`
157
+
158
+ ## 完成一轮评审
159
+
160
+ 生成下一版本后,回复内容包含:
161
+
162
+ 1. 新文件路径。
163
+ 2. 评论处理摘要。
164
+ 3. 未处理评论或关键假设。
165
+ 4. 当前 review 链接,以及用户应选择的新文件。只有在用户需要手动启动时才给出命令。
166
+
167
+ review 链接使用文件名作为链接文本,例如:
168
+
169
+ ```md
170
+ [sample.v2.md](http://127.0.0.1:3030/?file=sample.v2.md)
171
+ ```
172
+
173
+ 回复保持简短,使用户可以立即继续下一轮评审。
174
+
175
+ ## md-review-server 说明
176
+
177
+ `md-review-server` 是本地 Markdown 评审 UI,用作人类批注界面和机器可读评论 API:
178
+
179
+ ```bash
180
+ npx md-review-server docs --port 3030 --active-file docs/example.v1.md
181
+ ```
182
+
183
+ 评论不存储在 Markdown 文件中,而是存储在 `.reviews/*.review.json` sidecar 文件中。Codex 读取 `GET /api/comments?status=open`,并通过 `PATCH /api/comments` 写回处理结果。
184
+
185
+ ## 参考文件
186
+
187
+ 创建或更新 review queue 时读取 `references/review-template.md`。
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Markdown 评审循环"
3
+ short_description: "可视化批注、修订并跟踪 Markdown 评审。"
4
+ default_prompt: "使用 $markdown-review-loop 打开可视化 Markdown 评审,收集批注并生成下一版修订稿。"
@@ -0,0 +1,42 @@
1
+ # Review Queue 模板
2
+
3
+ 该模板用于 Markdown 评审循环。存在 `quote` 时优先使用 `quote` 定位,因为它比行号更适合处理长行中的局部反馈。
4
+
5
+ ```md
6
+ # Review Queue
7
+
8
+ document: docs/example.v1.md
9
+ next_version: docs/example.v2.md
10
+
11
+ ## C001
12
+ source: docs/example.v1.md:L42-L48
13
+ quote: "ABase 写入不在 RDS 事务内"
14
+ status: open
15
+ comment: 需要补充 RDS 与 ABase 一致性边界。
16
+ resolution:
17
+
18
+ ## C002
19
+ source: docs/example.v1.md:L77
20
+ quote:
21
+ status: open
22
+ comment: 在该段后补充读取链路时序。
23
+ resolution:
24
+ ```
25
+
26
+ 应用评论后更新每条记录:
27
+
28
+ ```md
29
+ ## C001
30
+ source: docs/example.v1.md:L42-L48
31
+ quote: "ABase 写入不在 RDS 事务内"
32
+ status: resolved
33
+ comment: 需要补充 RDS 与 ABase 一致性边界。
34
+ resolution: 已补充说明:ABase 写入位于 RDS 事务外,当前实现先写 ABase 内容,再写 RDS 消息元数据。
35
+ ```
36
+
37
+ 状态说明:
38
+
39
+ - `open`:尚未处理。
40
+ - `resolved`:已完整处理。
41
+ - `partially_resolved`:已部分处理,在 `resolution` 中说明剩余问题。
42
+ - `unresolved`:未处理,在 `resolution` 中说明阻塞原因。