local-diff-reviewer 4.0.1 → 4.0.3

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 CHANGED
@@ -29,6 +29,7 @@ npx skills add Mone-Lee/diff-review
29
29
 
30
30
  - 查看当前工作区 diff、staged diff、指定 revision diff。
31
31
  - 代码文件使用 GitHub 风格 unified diff。
32
+ - 图片文件支持 diff 查看。
32
33
  - Markdown 文件支持 `Preview / Code diff` 切换;`Code diff` 当前只支持 side-by-side,不支持 inline。
33
34
  - 支持文件级评论、代码行级评论、Markdown source line 评论。
34
35
  - Markdown 评论在两种视图间会按视图能力降级展示:
@@ -143,7 +144,13 @@ npx --yes local-diff-reviewer \
143
144
 
144
145
  `thread` 评论会以 `author: "agent"` 写入:如果同一锚点已经存在 thread,会作为新的 comment 追加进去;否则创建 replied thread。`reply` 会向目标 thread 追加一条 agent 回复并把状态切到 replied。为避免 agent finding 反复注入导致刷屏,同一 thread 内相同正文的 agent comment 会被视为重复并跳过。若路径不在当前 diff 中、行号无法定位或内容重复,脚本会跳过并在终端打印 warning。
145
146
 
146
- 当 agent 收到从 UI 复制出的 `[thread:<id>]` prompt 并完成处理后,应使用 `type: "reply"` 把处理结果写回原 thread,作为 `author: "agent"` 的 comment 保留在评论流里。回复内容应简要说明已修改什么,或说明为什么没有修改。
147
+ 当 agent 收到从 UI 复制出的 `[thread:<id>]` prompt 并完成处理后,应使用 `type: "reply"` 把处理结果写回原 thread,作为 `author: "agent"` 的 comment 保留在评论流里。回复内容默认保持结论式、尽量短:
148
+
149
+ - 已按评论完成修改时,优先使用 `已处理`。
150
+ - 需要补充一点点定位信息时,可使用 `已处理:xxx`。
151
+ - 未修改时,再简短说明原因,例如 `未处理:当前实现已覆盖该场景。`
152
+
153
+ 除非用户明确需要更详细的回写说明,否则不要重复整段 diff、实现细节或长篇解释。
147
154
 
148
155
  从 UI 复制出的 prompt 示例:
149
156
 
package/SKILL.md CHANGED
@@ -36,7 +36,7 @@ npx --yes local-diff-reviewer [args...] \
36
36
 
37
37
  After a newly started script prints a local URL, open it in the Codex browser when available. If the script reports `Diff Review refreshed`, do not open another page: the already open review page updates automatically. If browser automation is not available, report the URL.
38
38
 
39
- When the user gives you copied prompt text containing `[thread:<id>]`, treat it as an existing review thread. After you answer or make code/doc changes for that thread, append a concise agent reply to the same thread with `--comment '{"type":"reply","threadId":"<id>","body":"..."}'` the next time you launch or refresh the viewer. If the viewer is already running and you can reach the API, post the same reply to `/api/threads/<id>/comments` with `author: "agent"`. Do this for every handled thread unless the user explicitly asks you not to write back. The reply should say what changed or why no change was made, not repeat the full diff.
39
+ When the user gives you copied prompt text containing `[thread:<id>]`, treat it as an existing review thread. After you answer or make code/doc changes for that thread, append a concise agent reply to the same thread with `--comment '{"type":"reply","threadId":"<id>","body":"..."}'` the next time you launch or refresh the viewer. If the viewer is already running and you can reach the API, post the same reply to `/api/threads/<id>/comments` with `author: "agent"`. Do this for every handled thread unless the user explicitly asks you not to write back. Prefer conclusion-first, minimal replies such as `已处理`, `已处理:xxx`, or `未处理:原因...`; do not repeat the full diff or long implementation details unless the user explicitly wants that detail.
40
40
 
41
41
  ## Review Scope
42
42
 
package/dist/cli/start.js CHANGED
@@ -46,24 +46,46 @@ function getMergedThreadStatus(threads) {
46
46
  }
47
47
 
48
48
  // src/server/storage.ts
49
+ var commentWriteQueues = /* @__PURE__ */ new Map();
49
50
  async function readComments(repoRoot) {
50
51
  return readCommentStore(repoRoot);
51
52
  }
52
- async function attachLegacyComments(repoRoot, diffHash2, diffFiles) {
53
- const store = await readCommentStore(repoRoot);
54
- let changed = false;
55
- for (const thread of store.threads) {
56
- if (!thread.diffHash) {
57
- thread.diffHash = diffHash2;
58
- changed = true;
53
+ async function updateComments(repoRoot, updater) {
54
+ const previousQueue = commentWriteQueues.get(repoRoot) ?? Promise.resolve();
55
+ const queuedWrite = previousQueue.catch(() => void 0).then(async () => {
56
+ const store = await readCommentStore(repoRoot);
57
+ const { changed, result } = await updater(store);
58
+ if (changed) {
59
+ await writeComments(repoRoot, store);
59
60
  }
60
- const file = diffFiles.find((item) => item.path === thread.filePath);
61
- if (!thread.fileSnapshotHash && file && (thread.diffHash === diffHash2 || getThreadStatus(thread) === "submit")) {
62
- thread.fileSnapshotHash = file.snapshotHash;
63
- changed = true;
61
+ return result;
62
+ });
63
+ const queueTail = queuedWrite.then(() => void 0, () => void 0);
64
+ commentWriteQueues.set(repoRoot, queueTail);
65
+ try {
66
+ return await queuedWrite;
67
+ } finally {
68
+ if (commentWriteQueues.get(repoRoot) === queueTail) {
69
+ commentWriteQueues.delete(repoRoot);
64
70
  }
65
71
  }
66
- if (changed) await writeComments(repoRoot, store);
72
+ }
73
+ async function attachLegacyComments(repoRoot, diffHash2, diffFiles) {
74
+ await updateComments(repoRoot, (store) => {
75
+ let changed = false;
76
+ for (const thread of store.threads) {
77
+ if (!thread.diffHash) {
78
+ thread.diffHash = diffHash2;
79
+ changed = true;
80
+ }
81
+ const file = diffFiles.find((item) => item.path === thread.filePath);
82
+ if (!thread.fileSnapshotHash && file && (thread.diffHash === diffHash2 || getThreadStatus(thread) === "submit")) {
83
+ thread.fileSnapshotHash = file.snapshotHash;
84
+ changed = true;
85
+ }
86
+ }
87
+ return { changed, result: void 0 };
88
+ });
67
89
  }
68
90
  async function readCommentStore(repoRoot) {
69
91
  const path = commentsPath(repoRoot);
@@ -203,36 +225,35 @@ function earliestTimestamp(values) {
203
225
  async function importAgentComments(repoRoot, diffHash2, diffFiles, rawComments) {
204
226
  const result = { imported: 0, skipped: [] };
205
227
  if (rawComments.length === 0) return result;
206
- const store = await readComments(repoRoot);
207
- for (let index = 0; index < rawComments.length; index += 1) {
208
- const label = `--comment #${index + 1}`;
209
- const parsed = parseImportComment(rawComments[index], label, result);
210
- if (!parsed) continue;
211
- if (parsed.type === "reply") {
212
- if (appendAgentReply(store.threads, parsed, result, label)) {
213
- result.imported += 1;
228
+ await updateComments(repoRoot, (store) => {
229
+ for (let index = 0; index < rawComments.length; index += 1) {
230
+ const label = `--comment #${index + 1}`;
231
+ const parsed = parseImportComment(rawComments[index], label, result);
232
+ if (!parsed) continue;
233
+ if (parsed.type === "reply") {
234
+ if (appendAgentReply(store.threads, parsed, result, label)) {
235
+ result.imported += 1;
236
+ }
237
+ continue;
214
238
  }
215
- continue;
216
- }
217
- const thread = buildAgentThread(parsed, diffHash2, diffFiles, result, label);
218
- if (!thread) continue;
219
- const existingThread = store.threads.find((item) => item.fileSnapshotHash === thread.fileSnapshotHash && sameAnchor(item.anchor, thread.anchor));
220
- if (hasDuplicateAgentComment(store.threads, thread)) {
221
- result.skipped.push(`${label}: duplicate agent comment skipped`);
222
- continue;
223
- }
224
- if (existingThread) {
225
- existingThread.comments.push(thread.comments[0]);
226
- existingThread.status = getOpenThreadStatus(existingThread);
227
- existingThread.updatedAt = thread.updatedAt;
228
- } else {
229
- store.threads.push(thread);
239
+ const thread = buildAgentThread(parsed, diffHash2, diffFiles, result, label);
240
+ if (!thread) continue;
241
+ const existingThread = store.threads.find((item) => item.fileSnapshotHash === thread.fileSnapshotHash && sameAnchor(item.anchor, thread.anchor));
242
+ if (hasDuplicateAgentComment(store.threads, thread)) {
243
+ result.skipped.push(`${label}: duplicate agent comment skipped`);
244
+ continue;
245
+ }
246
+ if (existingThread) {
247
+ existingThread.comments.push(thread.comments[0]);
248
+ existingThread.status = getOpenThreadStatus(existingThread);
249
+ existingThread.updatedAt = thread.updatedAt;
250
+ } else {
251
+ store.threads.push(thread);
252
+ }
253
+ result.imported += 1;
230
254
  }
231
- result.imported += 1;
232
- }
233
- if (result.imported > 0) {
234
- await writeComments(repoRoot, store);
235
- }
255
+ return { changed: result.imported > 0, result: void 0 };
256
+ });
236
257
  return result;
237
258
  }
238
259
  function parseImportComment(raw, label, result) {
@@ -923,6 +944,7 @@ function getThreadLocation(thread) {
923
944
 
924
945
  // src/server/file-watcher-service.ts
925
946
  import { watch } from "node:fs";
947
+ var IGNORED_REPO_PATH_PREFIXES = ["node_modules", "dist"];
926
948
  var FileWatcherService = class {
927
949
  constructor(repoRoot) {
928
950
  this.repoRoot = repoRoot;
@@ -998,7 +1020,10 @@ var FileWatcherService = class {
998
1020
  }
999
1021
  tryWatch(path, options) {
1000
1022
  try {
1001
- const watcher = watch(path, options ?? {}, () => {
1023
+ const watcher = watch(path, options ?? {}, (_eventType, filename) => {
1024
+ if (path === this.repoRoot && this.shouldIgnoreRepoChange(filename)) {
1025
+ return;
1026
+ }
1002
1027
  this.handleChange();
1003
1028
  });
1004
1029
  this.watchers.push(watcher);
@@ -1012,6 +1037,14 @@ var FileWatcherService = class {
1012
1037
  this.send(client, event);
1013
1038
  });
1014
1039
  }
1040
+ /**
1041
+ * 过滤 dev server 产物目录,避免 npm run dev 之类的启动副作用误触发 Refresh。
1042
+ */
1043
+ shouldIgnoreRepoChange(filename) {
1044
+ if (!filename) return false;
1045
+ const normalizedPath = String(filename).replaceAll("\\", "/").replace(/^\.\//, "");
1046
+ return IGNORED_REPO_PATH_PREFIXES.some((prefix) => normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
1047
+ }
1015
1048
  send(res, event) {
1016
1049
  res.write(`data: ${JSON.stringify(event)}
1017
1050
 
@@ -1178,8 +1211,6 @@ async function startServer(state, port = 4966) {
1178
1211
  res.status(400).json({ error: "Comment file is not present in the current diff" });
1179
1212
  return;
1180
1213
  }
1181
- const store = await readComments(state.session.repoRoot);
1182
- const existingThread = store.threads.find((thread2) => thread2.fileSnapshotHash === file.snapshotHash && sameAnchor(thread2.anchor, body.anchor));
1183
1214
  const comment = {
1184
1215
  id: crypto.randomUUID(),
1185
1216
  body: commentBody,
@@ -1187,27 +1218,28 @@ async function startServer(state, port = 4966) {
1187
1218
  createdAt: now,
1188
1219
  updatedAt: now
1189
1220
  };
1190
- if (existingThread) {
1191
- existingThread.comments.push(comment);
1192
- existingThread.status = getOpenThreadStatus(existingThread);
1193
- existingThread.updatedAt = now;
1194
- await writeComments(state.session.repoRoot, store);
1195
- res.status(201).json(existingThread);
1196
- return;
1197
- }
1198
- const thread = {
1199
- id: crypto.randomUUID(),
1200
- filePath: body.filePath,
1201
- anchor: body.anchor,
1202
- diffHash: state.session.diffHash,
1203
- fileSnapshotHash: file.snapshotHash,
1204
- status: "submit",
1205
- comments: [comment],
1206
- createdAt: now,
1207
- updatedAt: now
1208
- };
1209
- store.threads.push(thread);
1210
- await writeComments(state.session.repoRoot, store);
1221
+ const thread = await updateComments(state.session.repoRoot, (store) => {
1222
+ const existingThread = store.threads.find((item) => item.fileSnapshotHash === file.snapshotHash && sameAnchor(item.anchor, body.anchor));
1223
+ if (existingThread) {
1224
+ existingThread.comments.push(comment);
1225
+ existingThread.status = getOpenThreadStatus(existingThread);
1226
+ existingThread.updatedAt = now;
1227
+ return { changed: true, result: existingThread };
1228
+ }
1229
+ const nextThread = {
1230
+ id: crypto.randomUUID(),
1231
+ filePath: body.filePath,
1232
+ anchor: body.anchor,
1233
+ diffHash: state.session.diffHash,
1234
+ fileSnapshotHash: file.snapshotHash,
1235
+ status: "submit",
1236
+ comments: [comment],
1237
+ createdAt: now,
1238
+ updatedAt: now
1239
+ };
1240
+ store.threads.push(nextThread);
1241
+ return { changed: true, result: nextThread };
1242
+ });
1211
1243
  res.status(201).json(thread);
1212
1244
  } catch (error) {
1213
1245
  next(error);
@@ -1216,12 +1248,6 @@ async function startServer(state, port = 4966) {
1216
1248
  app.post("/api/threads/:id/comments", async (req, res, next) => {
1217
1249
  try {
1218
1250
  const now = (/* @__PURE__ */ new Date()).toISOString();
1219
- const store = await readComments(state.session.repoRoot);
1220
- const thread = store.threads.find((item) => item.id === req.params.id);
1221
- if (!thread) {
1222
- res.status(404).json({ error: "Thread not found" });
1223
- return;
1224
- }
1225
1251
  const body = req.body;
1226
1252
  const commentBody = body.body?.trim();
1227
1253
  if (!commentBody) {
@@ -1235,40 +1261,59 @@ async function startServer(state, port = 4966) {
1235
1261
  createdAt: now,
1236
1262
  updatedAt: now
1237
1263
  };
1238
- thread.comments.push(comment);
1239
- if (thread.status !== "resolved") {
1240
- thread.status = getOpenThreadStatus(thread);
1241
- }
1242
- thread.updatedAt = now;
1243
- await writeComments(state.session.repoRoot, store);
1244
- res.status(201).json(comment);
1264
+ const commentResult = await updateComments(state.session.repoRoot, (store) => {
1265
+ const thread = store.threads.find((item) => item.id === req.params.id);
1266
+ if (!thread) {
1267
+ throw new Error("THREAD_NOT_FOUND");
1268
+ }
1269
+ thread.comments.push(comment);
1270
+ if (thread.status !== "resolved") {
1271
+ thread.status = getOpenThreadStatus(thread);
1272
+ }
1273
+ thread.updatedAt = now;
1274
+ return { changed: true, result: comment };
1275
+ });
1276
+ res.status(201).json(commentResult);
1245
1277
  } catch (error) {
1278
+ if (error instanceof Error && error.message === "THREAD_NOT_FOUND") {
1279
+ res.status(404).json({ error: "Thread not found" });
1280
+ return;
1281
+ }
1246
1282
  next(error);
1247
1283
  }
1248
1284
  });
1249
1285
  app.patch("/api/threads/:id", async (req, res, next) => {
1250
1286
  try {
1251
- const store = await readComments(state.session.repoRoot);
1252
- const thread = store.threads.find((item) => item.id === req.params.id);
1253
- if (!thread) {
1287
+ const thread = await updateComments(state.session.repoRoot, (store) => {
1288
+ const currentThread = store.threads.find((item) => item.id === req.params.id);
1289
+ if (!currentThread) {
1290
+ throw new Error("THREAD_NOT_FOUND");
1291
+ }
1292
+ let changed = false;
1293
+ if (req.body.status === "resolved" || req.body.status === "replied" || req.body.status === "submit") {
1294
+ currentThread.status = req.body.status === "resolved" ? "resolved" : getOpenThreadStatus(currentThread);
1295
+ currentThread.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1296
+ changed = true;
1297
+ }
1298
+ return { changed, result: currentThread };
1299
+ });
1300
+ res.json(thread);
1301
+ } catch (error) {
1302
+ if (error instanceof Error && error.message === "THREAD_NOT_FOUND") {
1254
1303
  res.status(404).json({ error: "Thread not found" });
1255
1304
  return;
1256
1305
  }
1257
- if (req.body.status === "resolved" || req.body.status === "replied" || req.body.status === "submit") {
1258
- thread.status = req.body.status === "resolved" ? "resolved" : getOpenThreadStatus(thread);
1259
- thread.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1260
- }
1261
- await writeComments(state.session.repoRoot, store);
1262
- res.json(thread);
1263
- } catch (error) {
1264
1306
  next(error);
1265
1307
  }
1266
1308
  });
1267
1309
  app.delete("/api/threads/:id", async (req, res, next) => {
1268
1310
  try {
1269
- const store = await readComments(state.session.repoRoot);
1270
- store.threads = store.threads.filter((item) => item.id !== req.params.id);
1271
- await writeComments(state.session.repoRoot, store);
1311
+ await updateComments(state.session.repoRoot, (store) => {
1312
+ const nextThreads = store.threads.filter((item) => item.id !== req.params.id);
1313
+ const changed = nextThreads.length !== store.threads.length;
1314
+ store.threads = nextThreads;
1315
+ return { changed, result: void 0 };
1316
+ });
1272
1317
  res.status(204).end();
1273
1318
  } catch (error) {
1274
1319
  next(error);
@@ -1277,67 +1322,93 @@ async function startServer(state, port = 4966) {
1277
1322
  app.patch("/api/threads/:id/comments/:commentId", async (req, res, next) => {
1278
1323
  try {
1279
1324
  const now = (/* @__PURE__ */ new Date()).toISOString();
1280
- const store = await readComments(state.session.repoRoot);
1281
- const thread = store.threads.find((item) => item.id === req.params.id);
1282
- if (!thread) {
1325
+ const nextBody = String(req.body?.body ?? "").trim();
1326
+ if (!nextBody) {
1327
+ res.status(400).json({ error: "Comment body is required" });
1328
+ return;
1329
+ }
1330
+ const comment = await updateComments(state.session.repoRoot, (store) => {
1331
+ const thread = store.threads.find((item) => item.id === req.params.id);
1332
+ if (!thread) {
1333
+ throw new Error("THREAD_NOT_FOUND");
1334
+ }
1335
+ if (getThreadStatus(thread) !== "submit") {
1336
+ throw new Error("THREAD_NOT_EDITABLE");
1337
+ }
1338
+ const currentComment = thread.comments.find((item) => item.id === req.params.commentId);
1339
+ if (!currentComment) {
1340
+ throw new Error("COMMENT_NOT_FOUND");
1341
+ }
1342
+ if (currentComment.author === "agent") {
1343
+ throw new Error("AGENT_COMMENT_READ_ONLY");
1344
+ }
1345
+ currentComment.body = nextBody;
1346
+ currentComment.updatedAt = now;
1347
+ thread.updatedAt = now;
1348
+ return { changed: true, result: currentComment };
1349
+ });
1350
+ res.json(comment);
1351
+ } catch (error) {
1352
+ if (error instanceof Error && error.message === "THREAD_NOT_FOUND") {
1283
1353
  res.status(404).json({ error: "Thread not found" });
1284
1354
  return;
1285
1355
  }
1286
- if (getThreadStatus(thread) !== "submit") {
1356
+ if (error instanceof Error && error.message === "THREAD_NOT_EDITABLE") {
1287
1357
  res.status(400).json({ error: "Only submitted comments can be edited" });
1288
1358
  return;
1289
1359
  }
1290
- const comment = thread.comments.find((item) => item.id === req.params.commentId);
1291
- if (!comment) {
1360
+ if (error instanceof Error && error.message === "COMMENT_NOT_FOUND") {
1292
1361
  res.status(404).json({ error: "Comment not found" });
1293
1362
  return;
1294
1363
  }
1295
- if (comment.author === "agent") {
1364
+ if (error instanceof Error && error.message === "AGENT_COMMENT_READ_ONLY") {
1296
1365
  res.status(400).json({ error: "Agent comments are read-only" });
1297
1366
  return;
1298
1367
  }
1299
- const nextBody = String(req.body?.body ?? "").trim();
1300
- if (!nextBody) {
1301
- res.status(400).json({ error: "Comment body is required" });
1302
- return;
1303
- }
1304
- comment.body = nextBody;
1305
- comment.updatedAt = now;
1306
- thread.updatedAt = now;
1307
- await writeComments(state.session.repoRoot, store);
1308
- res.json(comment);
1309
- } catch (error) {
1310
1368
  next(error);
1311
1369
  }
1312
1370
  });
1313
1371
  app.delete("/api/threads/:id/comments/:commentId", async (req, res, next) => {
1314
1372
  try {
1315
1373
  const now = (/* @__PURE__ */ new Date()).toISOString();
1316
- const store = await readComments(state.session.repoRoot);
1317
- const thread = store.threads.find((item) => item.id === req.params.id);
1318
- if (!thread) {
1374
+ await updateComments(state.session.repoRoot, (store) => {
1375
+ const thread = store.threads.find((item) => item.id === req.params.id);
1376
+ if (!thread) {
1377
+ throw new Error("THREAD_NOT_FOUND");
1378
+ }
1379
+ if (getThreadStatus(thread) !== "submit") {
1380
+ throw new Error("THREAD_NOT_EDITABLE");
1381
+ }
1382
+ const comment = thread.comments.find((item) => item.id === req.params.commentId);
1383
+ if (!comment) {
1384
+ throw new Error("COMMENT_NOT_FOUND");
1385
+ }
1386
+ if (comment.author === "agent") {
1387
+ throw new Error("AGENT_COMMENT_READ_ONLY");
1388
+ }
1389
+ thread.comments = thread.comments.filter((item) => item.id !== req.params.commentId);
1390
+ thread.updatedAt = now;
1391
+ store.threads = store.threads.filter((item) => item.id !== thread.id || thread.comments.length > 0);
1392
+ return { changed: true, result: void 0 };
1393
+ });
1394
+ res.status(204).end();
1395
+ } catch (error) {
1396
+ if (error instanceof Error && error.message === "THREAD_NOT_FOUND") {
1319
1397
  res.status(404).json({ error: "Thread not found" });
1320
1398
  return;
1321
1399
  }
1322
- if (getThreadStatus(thread) !== "submit") {
1400
+ if (error instanceof Error && error.message === "THREAD_NOT_EDITABLE") {
1323
1401
  res.status(400).json({ error: "Only submitted comments can be deleted" });
1324
1402
  return;
1325
1403
  }
1326
- const comment = thread.comments.find((item) => item.id === req.params.commentId);
1327
- if (!comment) {
1404
+ if (error instanceof Error && error.message === "COMMENT_NOT_FOUND") {
1328
1405
  res.status(404).json({ error: "Comment not found" });
1329
1406
  return;
1330
1407
  }
1331
- if (comment.author === "agent") {
1408
+ if (error instanceof Error && error.message === "AGENT_COMMENT_READ_ONLY") {
1332
1409
  res.status(400).json({ error: "Agent comments are read-only" });
1333
1410
  return;
1334
1411
  }
1335
- thread.comments = thread.comments.filter((item) => item.id !== req.params.commentId);
1336
- thread.updatedAt = now;
1337
- store.threads = store.threads.filter((item) => item.id !== thread.id || thread.comments.length > 0);
1338
- await writeComments(state.session.repoRoot, store);
1339
- res.status(204).end();
1340
- } catch (error) {
1341
1412
  next(error);
1342
1413
  }
1343
1414
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-diff-reviewer",
3
- "version": "4.0.1",
3
+ "version": "4.0.3",
4
4
  "private": false,
5
5
  "description": "Open a local GitHub-style diff review Web UI for the current repository.",
6
6
  "repository": {