dsh-vscode-mode 0.21.3 → 0.22.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/lib/index.js CHANGED
@@ -2488,6 +2488,26 @@ async function resolveTargetIn(ctx, cwd, path) {
2488
2488
  return ctx.get("fs").resolve(path, cwd ? { cwd } : {});
2489
2489
  }
2490
2490
  /**
2491
+ * 把解析结果还原成宿主路径字符串。
2492
+ * 官方 provider 的 resolve 返回不透明 FsTarget,而多数调用点按字符串使用;
2493
+ * 直接把对象当路径传下去会落进 SQL 绑定或 path.resolve,报出与真实原因无关的错误。
2494
+ * @author ddj 2026年10月30号
2495
+ * @param ctx DSH 上下文
2496
+ * @param target resolve 的返回值:宿主路径字符串或 FsTarget
2497
+ * @returns 宿主路径字符串;无法还原时返回空串
2498
+ */
2499
+ function targetPathOf(ctx, target) {
2500
+ if (typeof target === "string") return target;
2501
+ const fs = ctx.get("fs");
2502
+ if (typeof fs?.processPath !== "function") return "";
2503
+ try {
2504
+ const resolved = fs.processPath(target);
2505
+ return typeof resolved === "string" && resolved !== "[object Object]" ? resolved : "";
2506
+ } catch {
2507
+ return "";
2508
+ }
2509
+ }
2510
+ /**
2491
2511
  * 严格读取旧 sidecar:仅确认确实不存在时才允许从空记录迁移。
2492
2512
  * @author ddj 2026年09月30号
2493
2513
  * @param ctx DSH 上下文
@@ -5041,7 +5061,7 @@ function checkTarget(dir, target) {
5041
5061
  * @param path 安装入口或备份入口
5042
5062
  * @throws 删除失败
5043
5063
  */
5044
- function removeEntry(path) {
5064
+ function removeEntry$1(path) {
5045
5065
  const info = lstatSync(path, { throwIfNoEntry: false });
5046
5066
  if (!info) return;
5047
5067
  if (info.isSymbolicLink()) unlinkSync(path);
@@ -5098,7 +5118,7 @@ function backupProfile(dir) {
5098
5118
  function restoreProfile(saved) {
5099
5119
  const failures = [];
5100
5120
  try {
5101
- removeEntry(saved.link);
5121
+ removeEntry$1(saved.link);
5102
5122
  if (saved.hadLink) renameSync(saved.savedLink, saved.link);
5103
5123
  } catch (error) {
5104
5124
  failures.push(String(error));
@@ -5140,7 +5160,7 @@ async function switchProfile(ctx, dir, enabled, target) {
5140
5160
  throw error;
5141
5161
  }
5142
5162
  try {
5143
- removeEntry(saved.savedLink);
5163
+ removeEntry$1(saved.savedLink);
5144
5164
  rmSync(saved.dir, {
5145
5165
  recursive: true,
5146
5166
  force: true
@@ -7340,10 +7360,19 @@ async function captureToolResult(ctx, registry, exec, result) {
7340
7360
  }
7341
7361
  //#endregion
7342
7362
  //#region src/shared/lineEndings.ts
7363
+ /** Shared logical-line comparison; never rewrites stored patch or file bytes. @author ddj 2026年10月28号 */
7364
+ /** @public @author ddj 2026年10月28号 @param text Decoded text. @returns Text without one leading Unicode BOM. */
7365
+ function stripBom(text) {
7366
+ return text.replace(/^\uFEFF/, "");
7367
+ }
7343
7368
  /** @public @author ddj 2026年10月28号 @param line Physical line. @returns Line without one terminal CRLF, CR or LF; other whitespace stays intact. */
7344
7369
  function logicalLine(line) {
7345
7370
  return line.replace(/(?:\r\n|\r|\n)$/, "");
7346
7371
  }
7372
+ /** @public @author ddj 2026年10月28号 @param text Decoded file text. @returns Logical lines excluding BOM, retaining the final empty line after a terminator. */
7373
+ function logicalLines(text) {
7374
+ return stripBom(text).split(/\r\n|\r|\n/);
7375
+ }
7347
7376
  //#endregion
7348
7377
  //#region src/shared/reviewRegions.ts
7349
7378
  /** @public @author ddj 2026年10月27号 @param diff Immutable official patch. @returns Contiguous change runs with stable source-coordinate identities. */
@@ -7551,7 +7580,7 @@ function captureState(cwd, gap, state) {
7551
7580
  DO UPDATE SET state=excluded.state,error=excluded.error WHERE review_capture.state<>'complete'`).run(gap.sessionId, gap.turn, gap.seq, state, gap.error);
7552
7581
  }
7553
7582
  /** @private @author ddj 2026年10月26号 @param row File and state. @returns Wire-safe summary without hunks. */
7554
- function itemOf(row) {
7583
+ function itemOf$1(row) {
7555
7584
  const summary = JSON.parse(String(row.summary));
7556
7585
  const kind = row.kind ?? JSON.parse(String(row.diff)).kind;
7557
7586
  return {
@@ -7595,7 +7624,7 @@ function reviewPage(cwd, cursor = 0, history = false, path) {
7595
7624
  })),
7596
7625
  coveredPaths: covered.map((row) => String(row.identity))
7597
7626
  },
7598
- items: rows.slice(0, 50).map(itemOf),
7627
+ items: rows.slice(0, 50).map(itemOf$1),
7599
7628
  pending: Number(count.n),
7600
7629
  nextCursor: rows.length > 50 ? offset + 50 : null,
7601
7630
  gaps: [...gaps.map((row) => ({
@@ -7613,6 +7642,7 @@ function reviewPage(cwd, cursor = 0, history = false, path) {
7613
7642
  }
7614
7643
  /** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @param path Legacy write target. @returns Whether any official version owns the file, including decided history. */
7615
7644
  function hasOfficial(cwd, path) {
7645
+ if (typeof path !== "string") return false;
7616
7646
  const db = dbFor(cwd);
7617
7647
  if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return false;
7618
7648
  if (/^[A-Za-z]:[\\/]/.test(path)) return !!db.prepare("SELECT 1 FROM review_files WHERE lower(replace(identity,char(92),'/'))=? LIMIT 1").get(path.replace(/\\/g, "/").toLowerCase());
@@ -7630,7 +7660,7 @@ function reviewDetail(cwd, id) {
7630
7660
  const pending = row.current === 1 && row.decision === "pending";
7631
7661
  const undo = pending && !!evidence && diff.kind === "text" && diff.before && diff.after && !diff.coarse;
7632
7662
  return {
7633
- item: itemOf(row),
7663
+ item: itemOf$1(row),
7634
7664
  diff,
7635
7665
  capturedAt: String(row.captured_at),
7636
7666
  blocks,
@@ -8079,20 +8109,2066 @@ function installEvidence(ctx) {
8079
8109
  chains.clear();
8080
8110
  staged.clear();
8081
8111
  }
8082
- return {
8083
- persist,
8084
- dispose
8112
+ return {
8113
+ persist,
8114
+ dispose
8115
+ };
8116
+ }
8117
+ /** @private @author ddj 2026年10月27号 @param fs Public filesystem. @param target Backend identity. @returns Raw bounded file or confirmed missing. @throws Any non-missing read error. */
8118
+ async function readRaw(fs, target, signal) {
8119
+ try {
8120
+ return rawReviewFile(await fs.readBytes(target, signal, 1048576));
8121
+ } catch (error) {
8122
+ const code = error.code;
8123
+ if (code === "ENOENT" || code === "FS_NOT_FOUND") return rawReviewFile(null);
8124
+ throw error;
8125
+ }
8126
+ }
8127
+ //#endregion
8128
+ //#region src/pending/capture.ts
8129
+ /**
8130
+ * 待审差异采集:环绕一次文件写入读取真实前后内容,只有内容确实变化才落库。
8131
+ * @author ddj 2026年10月29号
8132
+ */
8133
+ /** 可被采信为待审来源的写文件工具。 */
8134
+ const WRITE_TOOLS = [
8135
+ "edit",
8136
+ "write",
8137
+ "str_replace_editor"
8138
+ ];
8139
+ /** 单文件文本上限:超过即标注 oversized,不静默截断。 */
8140
+ const MAX_TEXT = 1048576;
8141
+ /**
8142
+ * @author ddj 2026年10月29号
8143
+ * @description: 安装写文件采集;读写失败只影响待审来源,绝不影响用户工具本身。
8144
+ * @param ctx Host 生命周期
8145
+ * @param sink 已确认写入的接收方
8146
+ * @returns 可释放的采集器
8147
+ */
8148
+ function installPendingCapture(ctx, sink) {
8149
+ const busy = /* @__PURE__ */ new Set();
8150
+ let stopped = false;
8151
+ ctx.on("tools/execute", aroundTool);
8152
+ return { dispose };
8153
+ /**
8154
+ * @author ddj 2026年10月29号
8155
+ * @description: 写工具执行前后各读一次,内容变化才上报。
8156
+ * @param exec 工具分发上下文
8157
+ * @param next 原始分发
8158
+ * @returns 原始工具结果,永不因采集失败而改变
8159
+ */
8160
+ async function aroundTool(exec, next) {
8161
+ const session = exec.agent?.session;
8162
+ const path = exec.arguments?.file_path ?? exec.arguments?.path;
8163
+ if (stopped || !session?.id || typeof path !== "string" || !WRITE_TOOLS.includes(exec.name)) return next();
8164
+ const cwd = cwdOf(session);
8165
+ if (!cwd) return next();
8166
+ let before = null;
8167
+ let identity = null;
8168
+ try {
8169
+ const fs = ctx.get("fs");
8170
+ const target = await resolveTarget(ctx, session, path);
8171
+ const key = fs.processPath(target);
8172
+ if (typeof key !== "string" || !key) return next();
8173
+ if (busy.has(key)) return next();
8174
+ before = await readText(fs, target);
8175
+ if (before !== null && before.length > MAX_TEXT) return next();
8176
+ identity = key;
8177
+ busy.add(key);
8178
+ } catch (error) {
8179
+ log.debug("待审采集跳过:" + String(error));
8180
+ return next();
8181
+ }
8182
+ try {
8183
+ return await next();
8184
+ } finally {
8185
+ if (identity !== null) {
8186
+ const key = identity;
8187
+ try {
8188
+ report(String(cwd), String(session.id), String(path), key, before, exec);
8189
+ } catch (error) {
8190
+ log.debug("待审上报失败:" + String(error));
8191
+ }
8192
+ busy.delete(key);
8193
+ }
8194
+ }
8195
+ }
8196
+ /**
8197
+ * @author ddj 2026年10月29号
8198
+ * @description: 写后读回并比较,内容一致视为无净变化不产生条目。
8199
+ * @param cwd 工作区
8200
+ * @param sessionId 会话
8201
+ * @param path 展示路径
8202
+ * @param identity 目标身份
8203
+ * @param before 写前内容
8204
+ * @param exec 工具分发上下文
8205
+ * @returns 无
8206
+ */
8207
+ function report(cwd, sessionId, path, identity, before, exec) {
8208
+ const fs = ctx.get("fs");
8209
+ resolveTarget(ctx, exec.agent.session, path).then(async (target) => {
8210
+ const after = await readText(fs, target);
8211
+ if (after !== null && after.length > MAX_TEXT) return;
8212
+ if (before === after) return;
8213
+ sink({
8214
+ cwd,
8215
+ sessionId,
8216
+ path,
8217
+ identity,
8218
+ before,
8219
+ after,
8220
+ now: Date.now()
8221
+ });
8222
+ }).catch((error) => log.debug("待审读回失败:" + String(error)));
8223
+ }
8224
+ /**
8225
+ * @author ddj 2026年10月29号
8226
+ * @description: 释放监听。
8227
+ * @returns 无
8228
+ */
8229
+ function dispose() {
8230
+ stopped = true;
8231
+ busy.clear();
8232
+ }
8233
+ }
8234
+ /**
8235
+ * @author ddj 2026年10月29号
8236
+ * @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
8237
+ * @param fs 文件系统
8238
+ * @param target 目标
8239
+ * @returns 文本或 null
8240
+ */
8241
+ async function readText(fs, target) {
8242
+ try {
8243
+ return String(await fs.readText(target));
8244
+ } catch (error) {
8245
+ const code = error.code;
8246
+ if (code === "ENOENT" || code === "FS_NOT_FOUND" || code === "FS_TOO_LARGE") return null;
8247
+ throw error;
8248
+ }
8249
+ }
8250
+ //#endregion
8251
+ //#region src/pending/vcs.ts
8252
+ /**
8253
+ * 版本库导入:把工作副本相对基线的改动读成待审条目,来源对齐 dsh-diff-approval 的 vcs 内核(MIT)。
8254
+ * 只读命令,不修改工作副本;基线读取失败一律上报,不猜"文件不存在"。
8255
+ * @author ddj 2026年10月30号
8256
+ */
8257
+ /** 命令超时:与上游一致,避免大仓 status 卡死。 */
8258
+ const COMMAND_TIMEOUT = 6e4;
8259
+ /** stdout 预算:够放一个常见源文件,超出交给上游判断为超大。 */
8260
+ const STDOUT_BUDGET = 8388608;
8261
+ /** 只有这些 git 报错才算"该版本不存在",其余一律抛出。 */
8262
+ const ABSENT_AT_REVISION = /does not exist in|exists on disk, but not in|not a valid object name|invalid object name|unknown revision|bad revision/i;
8263
+ /** 只有这些 P4 报错才算"该版本不存在",其余一律抛出。 */
8264
+ const P4_ABSENT_AT_REVISION = /no such file|not opened|does not exist|no file\(s\) to print|invalid file|no revisions/i;
8265
+ /** git 相对路径用正斜杠。 */
8266
+ const GIT_SEPARATOR = "/";
8267
+ /**
8268
+ * @author ddj 2026年10月30号
8269
+ * @description: 向上查找版本库根目录,同目录 git 优先于 svn,最后才是 Perforce 配置。
8270
+ * @param cwd 工作区
8271
+ * @returns 探测结果,未找到时 undefined
8272
+ */
8273
+ async function detectVcsRoot(cwd) {
8274
+ let current = resolve(cwd);
8275
+ for (;;) {
8276
+ if (await exists(join(current, ".git"))) return {
8277
+ kind: "git",
8278
+ root: current
8279
+ };
8280
+ if (await exists(join(current, ".svn"))) return {
8281
+ kind: "svn",
8282
+ root: current
8283
+ };
8284
+ if (await isP4Root(current)) return {
8285
+ kind: "p4",
8286
+ root: current
8287
+ };
8288
+ const parent = dirname(current);
8289
+ if (parent === current) return void 0;
8290
+ current = parent;
8291
+ }
8292
+ }
8293
+ /**
8294
+ * @author ddj 2026年10月30号
8295
+ * @description: 判断目录是否是 Perforce 工作区根(P4CONFIG 文件所在目录)。
8296
+ * @param dir 目录
8297
+ * @returns 是否 P4 根
8298
+ */
8299
+ async function isP4Root(dir) {
8300
+ return await exists(join(dir, ".p4config")) || await exists(join(dir, ".p4config.txt"));
8301
+ }
8302
+ /**
8303
+ * @author ddj 2026年10月30号
8304
+ * @description: 读出当前工作副本相对基线的改动,只返回给定范围(含未跟踪按选项)。
8305
+ * @param shell shell 能力
8306
+ * @param cwd 工作区
8307
+ * @param options 导入选项
8308
+ * @returns 改动列表
8309
+ */
8310
+ async function importVcsChanges(shell, cwd, options = {}) {
8311
+ const detected = await detectVcsRoot(cwd);
8312
+ if (!detected) return [];
8313
+ const changes = await readChanges(shell, detected, cwd, options);
8314
+ const only = options.path ? resolve(cwd, options.path) : void 0;
8315
+ return changes.filter((change) => !only || samePath(change.path, only));
8316
+ }
8317
+ /**
8318
+ * @author ddj 2026年10月30号
8319
+ * @description: 按探测到的种类取改动,新增种类只在这里分流。
8320
+ * @param shell shell 能力
8321
+ * @param detected 版本库种类与根目录
8322
+ * @param cwd 工作区
8323
+ * @param options 导入选项
8324
+ * @returns 改动列表
8325
+ */
8326
+ async function readChanges(shell, detected, cwd, options) {
8327
+ if (detected.kind === "git") return gitChanges(shell, cwd, detected.root, options);
8328
+ if (detected.kind === "svn") return svnChanges(shell, cwd, detected.root, options);
8329
+ return p4Changes(shell, cwd, detected.root, options);
8330
+ }
8331
+ /**
8332
+ * @author ddj 2026年10月30号
8333
+ * @description: git 改动:status -z 解析状态,基线取 HEAD 版本,未跟踪可按选项导入为新建。
8334
+ * @param shell shell 能力
8335
+ * @param cwd 工作区
8336
+ * @param root 仓库根
8337
+ * @param options 导入选项
8338
+ * @returns 改动列表
8339
+ */
8340
+ async function gitChanges(shell, cwd, root, options) {
8341
+ const status = await runCommand(shell, "git -c status.renames=false status --porcelain=v1 -z --untracked-files=all", root);
8342
+ if (!status.ok) throw new Error("git status 失败:" + status.stderr.slice(0, 200));
8343
+ const changes = [];
8344
+ for (const entry of parseGitStatus(status.stdout)) {
8345
+ if (entry.status === "??" && !options.includeUntracked) continue;
8346
+ const absolute = join(root, ...entry.path.split(GIT_SEPARATOR));
8347
+ if (!insideRoot(root, absolute)) continue;
8348
+ const current = await readTextOf(absolute);
8349
+ if (entry.status === "??" || entry.status === "A ") {
8350
+ changes.push({
8351
+ path: absolute,
8352
+ earlierVersion: "none",
8353
+ oldText: "",
8354
+ newText: current
8355
+ });
8356
+ continue;
8357
+ }
8358
+ const baseline = await gitBaseline(shell, root, entry.path);
8359
+ changes.push({
8360
+ path: absolute,
8361
+ earlierVersion: baseline === null ? "none" : "file",
8362
+ oldText: baseline ?? "",
8363
+ newText: current
8364
+ });
8365
+ }
8366
+ return changes;
8367
+ }
8368
+ /**
8369
+ * @author ddj 2026年10月30号
8370
+ * @description: svn 改动:status --xml 解析状态,基线取 BASE 版本。
8371
+ * @param shell shell 能力
8372
+ * @param cwd 工作区
8373
+ * @param root 工作副本根
8374
+ * @param options 导入选项
8375
+ * @returns 改动列表
8376
+ */
8377
+ async function svnChanges(shell, cwd, root, options) {
8378
+ const status = await runCommand(shell, "svn status --xml", root);
8379
+ if (!status.ok) throw new Error("svn status 失败:" + status.stderr.slice(0, 200));
8380
+ const changes = [];
8381
+ for (const entry of parseSvnStatus(status.stdout)) {
8382
+ if (entry.status === "unversioned" && !options.includeUntracked) continue;
8383
+ const absolute = resolve(root, entry.path);
8384
+ if (!insideRoot(root, absolute)) continue;
8385
+ const current = await readTextOf(absolute);
8386
+ if (entry.status === "added" || entry.status === "unversioned" || entry.status === "replaced") {
8387
+ changes.push({
8388
+ path: absolute,
8389
+ earlierVersion: "none",
8390
+ oldText: "",
8391
+ newText: current
8392
+ });
8393
+ continue;
8394
+ }
8395
+ const baseline = await svnBaseline(shell, root, entry.path);
8396
+ changes.push({
8397
+ path: absolute,
8398
+ earlierVersion: "file",
8399
+ oldText: baseline,
8400
+ newText: current
8401
+ });
8402
+ }
8403
+ return changes;
8404
+ }
8405
+ /**
8406
+ * @author ddj 2026年10月30号
8407
+ * @description: 取 HEAD 版本内容;仅"该版本确实没有此文件"时返回 null,其余失败抛出。
8408
+ * @param shell shell 能力
8409
+ * @param root 仓库根
8410
+ * @param relative 仓库相对路径(正斜杠)
8411
+ * @returns 基线内容或 null
8412
+ */
8413
+ async function gitBaseline(shell, root, relative) {
8414
+ const size = await runCommand(shell, `git cat-file -s HEAD:${quote(relative)}`, root);
8415
+ const budget = size.ok ? Number(size.stdout.trim()) + 1024 : STDOUT_BUDGET;
8416
+ const shown = await runCommand(shell, `git show HEAD:${quote(relative)}`, root, Math.min(budget, STDOUT_BUDGET));
8417
+ if (shown.ok) return shown.stdout;
8418
+ if (ABSENT_AT_REVISION.test(shown.stderr)) return null;
8419
+ throw new Error("读取 git 基线失败:" + shown.stderr.slice(0, 200));
8420
+ }
8421
+ /**
8422
+ * @author ddj 2026年10月30号
8423
+ * @description: 取 svn BASE 版本内容;失败一律抛出(svn 没有可靠的"不存在"判据)。
8424
+ * @param shell shell 能力
8425
+ * @param root 工作副本根
8426
+ * @param relative 相对路径
8427
+ * @returns 基线内容
8428
+ */
8429
+ async function svnBaseline(shell, root, relative) {
8430
+ const shown = await runCommand(shell, `svn cat -r BASE ${quote(relative)}`, root);
8431
+ if (!shown.ok) throw new Error("读取 svn 基线失败:" + shown.stderr.slice(0, 200));
8432
+ return shown.stdout;
8433
+ }
8434
+ /**
8435
+ * @author ddj 2026年10月30号
8436
+ * @description: Perforce 改动:opened(含 status 的未跟踪项)解析 depot 路径,再折成本地路径。
8437
+ * @param shell shell 能力
8438
+ * @param cwd 工作区
8439
+ * @param root P4 工作区根
8440
+ * @param options 导入选项
8441
+ * @returns 改动列表
8442
+ */
8443
+ async function p4Changes(shell, cwd, root, options) {
8444
+ const depotPaths = /* @__PURE__ */ new Set();
8445
+ const opened = await runCommand(shell, "p4 opened", root);
8446
+ if (!opened.ok) throw new Error("p4 opened 失败:" + opened.stderr.slice(0, 200));
8447
+ for (const path of parseP4Opened(opened.stdout)) depotPaths.add(path);
8448
+ if (options.includeUntracked) {
8449
+ const status = await runCommand(shell, "p4 status", root);
8450
+ if (!status.ok) throw new Error("p4 status 失败:" + status.stderr.slice(0, 200));
8451
+ for (const path of parseP4Status(status.stdout)) depotPaths.add(path);
8452
+ }
8453
+ const changes = [];
8454
+ for (const depot of depotPaths) {
8455
+ const absolute = await p4LocalPath(shell, root, depot);
8456
+ if (!absolute || !insideRoot(root, absolute)) continue;
8457
+ const current = await readTextOf(absolute);
8458
+ const baseline = await p4Baseline(shell, root, depot);
8459
+ changes.push({
8460
+ path: absolute,
8461
+ earlierVersion: baseline === null ? "none" : "file",
8462
+ oldText: baseline ?? "",
8463
+ newText: current
8464
+ });
8465
+ }
8466
+ return changes;
8467
+ }
8468
+ /**
8469
+ * @author ddj 2026年10月30号
8470
+ * @description: 解析 `p4 opened` 输出,只取 depot 路径(`//depot/x#3 - edit ...`)。
8471
+ * @param text 输出
8472
+ * @returns depot 路径列表
8473
+ */
8474
+ function parseP4Opened(text) {
8475
+ const paths = [];
8476
+ for (const line of text.split(/\r?\n/)) {
8477
+ const match = /^(\/\/[^#\s]+)#\d+/.exec(line.trim());
8478
+ if (match) paths.push(match[1]);
8479
+ }
8480
+ return paths;
8481
+ }
8482
+ /**
8483
+ * @author ddj 2026年10月30号
8484
+ * @description: 解析 `p4 status` 输出;每行以本地路径或 depot 路径开头,取路径本身。
8485
+ * @param text 输出
8486
+ * @returns depot 或本地路径列表
8487
+ */
8488
+ function parseP4Status(text) {
8489
+ const paths = [];
8490
+ for (const line of text.split(/\r?\n/)) {
8491
+ const trimmed = line.trim();
8492
+ if (!trimmed || trimmed.startsWith("-")) continue;
8493
+ const head = trimmed.split(/\s+-\s+/)[0]?.trim();
8494
+ if (!head) continue;
8495
+ paths.push(head);
8496
+ }
8497
+ return paths;
8498
+ }
8499
+ /**
8500
+ * @author ddj 2026年10月30号
8501
+ * @description: `p4 where` 把小写 depot 路径折成本地绝对路径。
8502
+ * @param shell shell 能力
8503
+ * @param root P4 工作区根
8504
+ * @param depot depot 路径
8505
+ * @returns 本地路径,失败时 undefined
8506
+ */
8507
+ async function p4LocalPath(shell, root, depot) {
8508
+ if (depot.startsWith("//")) {
8509
+ const where = await runCommand(shell, `p4 where ${quote(depot)}`, root);
8510
+ if (!where.ok) return void 0;
8511
+ const tokens = where.stdout.trim().split(/\s+/);
8512
+ return tokens[tokens.length - 1] ?? void 0;
8513
+ }
8514
+ return resolve(root, depot);
8515
+ }
8516
+ /**
8517
+ * @author ddj 2026年10月30号
8518
+ * @description: 取 `#have` 版本内容;只有明确"没有该版本"时返回 null,其余失败抛出。
8519
+ * @param shell shell 能力
8520
+ * @param root P4 工作区根
8521
+ * @param depot depot 路径
8522
+ * @returns 基线内容或 null
8523
+ */
8524
+ async function p4Baseline(shell, root, depot) {
8525
+ if (!depot.startsWith("//")) return null;
8526
+ const printed = await runCommand(shell, `p4 print -q ${quote(depot + "#have")}`, root);
8527
+ if (printed.ok) return printed.stdout;
8528
+ if (P4_ABSENT_AT_REVISION.test(printed.stderr)) return null;
8529
+ throw new Error("读取 p4 基线失败:" + printed.stderr.slice(0, 200));
8530
+ }
8531
+ /**
8532
+ * @author ddj 2026年10月30号
8533
+ * @description: 解析 git status -z 输出;重命名/复制记录按目标路径处理。
8534
+ * @param text -z 输出
8535
+ * @returns 状态与仓库相对路径
8536
+ */
8537
+ function parseGitStatus(text) {
8538
+ const parts = text.split("\0").filter((part) => part.length > 2);
8539
+ const entries = [];
8540
+ for (let index = 0; index < parts.length; index++) {
8541
+ const part = parts[index];
8542
+ const status = part.slice(0, 2);
8543
+ const path = part.slice(3);
8544
+ if (status.includes("R") || status.includes("C")) index++;
8545
+ entries.push({
8546
+ status,
8547
+ path
8548
+ });
8549
+ }
8550
+ return entries;
8551
+ }
8552
+ /**
8553
+ * @author ddj 2026年10月30号
8554
+ * @description: 解析 svn status --xml 输出。
8555
+ * @param xml xml 文本
8556
+ * @returns 条目状态与相对路径
8557
+ */
8558
+ function parseSvnStatus(xml) {
8559
+ const entries = [];
8560
+ for (const match of xml.matchAll(/<entry\s+path="([^"]+)"([\s\S]*?)<\/entry>/g)) {
8561
+ const status = /<wc-status[^>]*\sitem="([^"]+)"/.exec(match[2] ?? "")?.[1];
8562
+ if (status) entries.push({
8563
+ status,
8564
+ path: decodeXml(match[1])
8565
+ });
8566
+ }
8567
+ return entries;
8568
+ }
8569
+ /**
8570
+ * @author ddj 2026年10月30号
8571
+ * @description: 执行一条只读命令并收集输出;超时/取消/非零退出都回 ok=false,不抛出。
8572
+ * @param shell shell 能力
8573
+ * @param command 命令
8574
+ * @param workdir 工作目录
8575
+ * @param stdoutMaxBytes stdout 预算
8576
+ * @returns 输出与状态
8577
+ */
8578
+ async function runCommand(shell, command, workdir, stdoutMaxBytes = STDOUT_BUDGET) {
8579
+ try {
8580
+ const spec = shell.resolve({
8581
+ command,
8582
+ workdir,
8583
+ timeoutMs: COMMAND_TIMEOUT,
8584
+ stdoutMaxBytes
8585
+ });
8586
+ const result = await (await shell.execute(spec)).result();
8587
+ return {
8588
+ ok: result.exitCode === 0 && !result.timedOut && !result.aborted,
8589
+ stdout: result.stdout.text,
8590
+ stderr: result.stderr.text
8591
+ };
8592
+ } catch (error) {
8593
+ return {
8594
+ ok: false,
8595
+ stdout: "",
8596
+ stderr: String(error)
8597
+ };
8598
+ }
8599
+ }
8600
+ /**
8601
+ * @author ddj 2026年10月30号
8602
+ * @description: 读单元格文本,不存在时返回 null。
8603
+ * @param path 文件路径
8604
+ * @returns 内容或 null
8605
+ */
8606
+ async function readTextOf(path) {
8607
+ try {
8608
+ if (!(await stat(path)).isFile()) return null;
8609
+ return await readFile(path, "utf8");
8610
+ } catch {
8611
+ return null;
8612
+ }
8613
+ }
8614
+ /**
8615
+ * @author ddj 2026年10月30号
8616
+ * @description: 判断路径是否存在。
8617
+ * @param path 路径
8618
+ * @returns 是否存在
8619
+ */
8620
+ async function exists(path) {
8621
+ try {
8622
+ await stat(path);
8623
+ return true;
8624
+ } catch {
8625
+ return false;
8626
+ }
8627
+ }
8628
+ /**
8629
+ * @author ddj 2026年10月30号
8630
+ * @description: 判断目标是否位于仓库根之内(Windows 下按大小写不敏感比较)。
8631
+ * @param root 仓库根
8632
+ * @param target 目标绝对路径
8633
+ * @returns 是否在根内
8634
+ */
8635
+ function insideRoot(root, target) {
8636
+ const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
8637
+ const rootKey = normalize(resolve(root)).replace(/[\\/]+$/, "") + sep;
8638
+ return normalize(resolve(target)).startsWith(rootKey);
8639
+ }
8640
+ /**
8641
+ * @author ddj 2026年10月30号
8642
+ * @description: 比较两个路径是否同一文件(Windows 下大小写不敏感)。
8643
+ * @param left 路径
8644
+ * @param right 路径
8645
+ * @returns 是否相同
8646
+ */
8647
+ function samePath(left, right) {
8648
+ const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
8649
+ return normalize(resolve(left)) === normalize(resolve(right));
8650
+ }
8651
+ /**
8652
+ * @author ddj 2026年10月30号
8653
+ * @description: 为 shell 命令参数加引号,避免空格与特殊字符改变命令含义。
8654
+ * @param value 参数
8655
+ * @returns 加引号后的参数
8656
+ */
8657
+ function quote(value) {
8658
+ return "\"" + value.replace(/"/g, "\\\"") + "\"";
8659
+ }
8660
+ /**
8661
+ * @author ddj 2026年10月30号
8662
+ * @description: 还原 xml 实体(svn status 会转义非 ASCII 与空格)。
8663
+ * @param value 属性值
8664
+ * @returns 解码后的路径
8665
+ */
8666
+ function decodeXml(value) {
8667
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
8668
+ }
8669
+ //#endregion
8670
+ //#region src/pending/hunks.ts
8671
+ /** 每个 hunk 的上下文行数。 */
8672
+ const CONTEXT = 3;
8673
+ /** LCS 单元格上限:超过后退化为整段替换,避免大文件卡死。 */
8674
+ const CELL_LIMIT = 1e6;
8675
+ /**
8676
+ * @author ddj 2026年10月29号
8677
+ * @description: 构造可直接交给官方渲染器的差异;两侧完全相同时返回空 hunk 列表。
8678
+ * @param path 相对路径
8679
+ * @param display 展示名
8680
+ * @param oldText 基线全文
8681
+ * @param newText 当前全文
8682
+ * @returns 文本差异
8683
+ */
8684
+ function textDiff(path, display, oldText, newText) {
8685
+ const hunks = buildHunks(logicalLines(oldText), logicalLines(newText));
8686
+ return {
8687
+ kind: "text",
8688
+ path,
8689
+ display,
8690
+ before: true,
8691
+ after: true,
8692
+ hunks,
8693
+ coarse: hunks.length > 0 && isCoarse(oldText, newText)
8694
+ };
8695
+ }
8696
+ /**
8697
+ * @author ddj 2026年10月29号
8698
+ * @description: 块级 Keep:把该块的新内容写进基线,返回推进后的基线。
8699
+ * @param oldText 当前基线
8700
+ * @param block 目标块
8701
+ * @returns 新基线;块在当前基线中无法唯一定位时返回 null
8702
+ */
8703
+ function keepBlock(oldText, block) {
8704
+ return spliceBlock(oldText, block.oldLines, block.newLines, block);
8705
+ }
8706
+ /**
8707
+ * @author ddj 2026年10月29号
8708
+ * @description: 块级 Revert:把该块在最新内容里换回基线内容,返回换回后的内容。
8709
+ * @param newText 当前内容
8710
+ * @param block 目标块
8711
+ * @returns 新内容;块无法唯一定位时返回 null
8712
+ */
8713
+ function revertBlock(newText, block) {
8714
+ return spliceBlock(newText, block.newLines, block.oldLines, block);
8715
+ }
8716
+ /**
8717
+ * @author ddj 2026年10月29号
8718
+ * @description: 按"上下文 + 目标行"唯一定位后做一次整块替换,定位不唯一就不动。
8719
+ * @param content 目标全文
8720
+ * @param from 要被替换的块内行
8721
+ * @param to 替换成的块内行
8722
+ * @param block 来源块,提供上下文;缺上下文时退化为只看块内行
8723
+ * @returns 新全文或 null
8724
+ */
8725
+ function spliceBlock(content, from, to, block) {
8726
+ if (from.length === 0 && to.length === 0) return null;
8727
+ const lines = logicalLines(content);
8728
+ const withContext = locateSpan(lines, [
8729
+ ...block.contextBefore,
8730
+ ...from,
8731
+ ...block.contextAfter
8732
+ ]);
8733
+ if (withContext !== null) return spliceAt(lines, withContext + block.contextBefore.length, from.length, to);
8734
+ const bare = locateSpan(lines, from);
8735
+ return bare === null ? null : spliceAt(lines, bare, from.length, to);
8736
+ }
8737
+ /**
8738
+ * @author ddj 2026年10月29号
8739
+ * @description: 在指定偏移处把连续若干行换成另一组行。
8740
+ * @param lines 目标行集合
8741
+ * @param start 起始偏移
8742
+ * @param count 被替换行数
8743
+ * @param to 替换成的行
8744
+ * @returns 新全文
8745
+ */
8746
+ function spliceAt(lines, start, count, to) {
8747
+ return [
8748
+ ...lines.slice(0, start),
8749
+ ...to,
8750
+ ...lines.slice(start + count)
8751
+ ].join("\n");
8752
+ }
8753
+ /**
8754
+ * @author ddj 2026年10月29号
8755
+ * @description: 唯一定位一段行;出现多处命中即视为不可定位。
8756
+ * @param lines 目标行集合
8757
+ * @param pattern 待定位行
8758
+ * @returns 唯一偏移或 null
8759
+ */
8760
+ function locateSpan(lines, pattern) {
8761
+ if (pattern.length === 0) return null;
8762
+ let at = -1;
8763
+ for (let offset = 0; offset + pattern.length <= lines.length; offset++) {
8764
+ if (!pattern.every((text, index) => lines[offset + index] === text)) continue;
8765
+ if (at >= 0) return null;
8766
+ at = offset;
8767
+ }
8768
+ return at >= 0 ? at : null;
8769
+ }
8770
+ /**
8771
+ * @author ddj 2026年10月29号
8772
+ * @description: 生成带上下文的标准 hunk 列表。
8773
+ * @param oldLines 基线行
8774
+ * @param newLines 当前行
8775
+ * @returns hunk 列表
8776
+ */
8777
+ function buildHunks(oldLines, newLines) {
8778
+ const ops = diffLines(oldLines, newLines);
8779
+ const hunks = [];
8780
+ let index = 0;
8781
+ let oldLine = 0;
8782
+ let newLine = 0;
8783
+ while (index < ops.length) {
8784
+ if (ops[index].kind === " ") {
8785
+ oldLine++;
8786
+ newLine++;
8787
+ index++;
8788
+ continue;
8789
+ }
8790
+ const head = ops.slice(Math.max(0, index - CONTEXT), index);
8791
+ const collected = [];
8792
+ let probe = index;
8793
+ while (probe < ops.length) {
8794
+ const op = ops[probe];
8795
+ if (op.kind !== " ") {
8796
+ collected.push(op);
8797
+ probe++;
8798
+ continue;
8799
+ }
8800
+ if (ops.slice(probe, probe + 6 + 1).some((item) => item.kind !== " ")) {
8801
+ collected.push(op);
8802
+ probe++;
8803
+ continue;
8804
+ }
8805
+ break;
8806
+ }
8807
+ const tail = ops.slice(probe, probe + CONTEXT);
8808
+ const oldStartLine = oldLine - head.length + 1;
8809
+ const newStartLine = newLine - head.length + 1;
8810
+ hunks.push(makeHunk([
8811
+ ...head,
8812
+ ...collected,
8813
+ ...tail
8814
+ ], oldStartLine, newStartLine));
8815
+ for (const op of collected) {
8816
+ if (op.kind !== "+") oldLine++;
8817
+ if (op.kind !== "-") newLine++;
8818
+ }
8819
+ oldLine += head.length + tail.length;
8820
+ newLine += head.length + tail.length;
8821
+ index = probe + tail.length;
8822
+ }
8823
+ return hunks;
8824
+ }
8825
+ /**
8826
+ * @author ddj 2026年10月29号
8827
+ * @description: 由一段操作装配 hunk。
8828
+ * @param ops 该 hunk 的操作
8829
+ * @param oldStart 基线起始行
8830
+ * @param newStart 当前起始行
8831
+ * @returns hunk
8832
+ */
8833
+ function makeHunk(ops, oldStart, newStart) {
8834
+ return {
8835
+ oldStart,
8836
+ oldLines: ops.filter((op) => op.kind !== "+").length,
8837
+ newStart,
8838
+ newLines: ops.filter((op) => op.kind !== "-").length,
8839
+ lines: ops.map((op) => op.kind + op.text)
8840
+ };
8841
+ }
8842
+ /**
8843
+ * @author ddj 2026年10月29号
8844
+ * @description: 行级差异:先剪公共前后缀,剩余部分在限额内做 LCS,超限按整段替换。
8845
+ * @param oldLines 基线行
8846
+ * @param newLines 当前行
8847
+ * @returns 顺序完整的操作列表
8848
+ */
8849
+ function diffLines(oldLines, newLines) {
8850
+ let head = 0;
8851
+ while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head++;
8852
+ let tail = 0;
8853
+ while (tail < oldLines.length - head && tail < newLines.length - head && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]) tail++;
8854
+ const left = oldLines.slice(head, oldLines.length - tail);
8855
+ const right = newLines.slice(head, newLines.length - tail);
8856
+ const middle = left.length * right.length > CELL_LIMIT ? coarseOps(left, right) : lcsOps(left, right);
8857
+ return [
8858
+ ...oldLines.slice(0, head).map((text) => ({
8859
+ kind: " ",
8860
+ text
8861
+ })),
8862
+ ...middle,
8863
+ ...oldLines.slice(oldLines.length - tail).map((text) => ({
8864
+ kind: " ",
8865
+ text
8866
+ }))
8867
+ ];
8868
+ }
8869
+ /**
8870
+ * @author ddj 2026年10月29号
8871
+ * @description: 超限时的退化口径:整段删除再整段新增,绝不谎报逐行对应。
8872
+ * @param left 基线剩余行
8873
+ * @param right 当前剩余行
8874
+ * @returns 操作列表
8875
+ */
8876
+ function coarseOps(left, right) {
8877
+ return [...left.map((text) => ({
8878
+ kind: "-",
8879
+ text
8880
+ })), ...right.map((text) => ({
8881
+ kind: "+",
8882
+ text
8883
+ }))];
8884
+ }
8885
+ /**
8886
+ * @author ddj 2026年10月29号
8887
+ * @description: 限额内的 LCS 行差异。
8888
+ * @param left 基线剩余行
8889
+ * @param right 当前剩余行
8890
+ * @returns 操作列表
8891
+ */
8892
+ function lcsOps(left, right) {
8893
+ const rows = left.length + 1;
8894
+ const cols = right.length + 1;
8895
+ const table = new Uint32Array(rows * cols);
8896
+ for (let i = left.length - 1; i >= 0; i--) for (let j = right.length - 1; j >= 0; j--) table[i * cols + j] = left[i] === right[j] ? table[(i + 1) * cols + j + 1] + 1 : Math.max(table[(i + 1) * cols + j], table[i * cols + j + 1]);
8897
+ const ops = [];
8898
+ let i = 0;
8899
+ let j = 0;
8900
+ while (i < left.length && j < right.length) {
8901
+ if (left[i] === right[j]) {
8902
+ ops.push({
8903
+ kind: " ",
8904
+ text: left[i]
8905
+ });
8906
+ i++;
8907
+ j++;
8908
+ continue;
8909
+ }
8910
+ if (table[(i + 1) * cols + j] >= table[i * cols + j + 1]) {
8911
+ ops.push({
8912
+ kind: "-",
8913
+ text: left[i]
8914
+ });
8915
+ i++;
8916
+ } else {
8917
+ ops.push({
8918
+ kind: "+",
8919
+ text: right[j]
8920
+ });
8921
+ j++;
8922
+ }
8923
+ }
8924
+ while (i < left.length) ops.push({
8925
+ kind: "-",
8926
+ text: left[i++]
8927
+ });
8928
+ while (j < right.length) ops.push({
8929
+ kind: "+",
8930
+ text: right[j++]
8931
+ });
8932
+ return ops;
8933
+ }
8934
+ /**
8935
+ * @author ddj 2026年10月29号
8936
+ * @description: 判断这次差异是否触发了大文件退化口径。
8937
+ * @param oldText 基线全文
8938
+ * @param newText 当前全文
8939
+ * @returns 是否粗粒度
8940
+ */
8941
+ function isCoarse(oldText, newText) {
8942
+ return logicalLines(oldText).length * logicalLines(newText).length > CELL_LIMIT;
8943
+ }
8944
+ //#endregion
8945
+ //#region src/pending/kernel.ts
8946
+ /**
8947
+ * @author ddj 2026年10月29号
8948
+ * @description: 严格比较两侧文本:不做 BOM/行尾归一化,归一化只允许用于展示。
8949
+ * @param left 一侧全文
8950
+ * @param right 另一侧全文
8951
+ * @returns 两者是否逐码元相等
8952
+ */
8953
+ function sameEntryText(left, right) {
8954
+ return left === right;
8955
+ }
8956
+ /**
8957
+ * @author ddj 2026年10月29号
8958
+ * @description: 净零判定:基线等于当前内容即为已解决,但条目仍留在列表等待移除。
8959
+ * @param entry 待审条目
8960
+ * @returns 是否已无净差异
8961
+ */
8962
+ function isSettled(entry) {
8963
+ return sameEntryText(entry.oldText, entry.newText);
8964
+ }
8965
+ /**
8966
+ * @author ddj 2026年10月29号
8967
+ * @description: 合并同一身份的两条条目:基线取较早来源,最新内容取较晚来源,会话取并集。
8968
+ * @param earlier 较早记录
8969
+ * @param later 较晚记录
8970
+ * @returns 合并后的新条目
8971
+ */
8972
+ function mergeEntries(earlier, later) {
8973
+ const merged = {
8974
+ ...later,
8975
+ updatedAt: later.updatedAt,
8976
+ sessionIds: unionSessions(earlier, later)
8977
+ };
8978
+ if (later.lineage === void 0 && earlier.lineage !== void 0) merged.lineage = earlier.lineage;
8979
+ if (earlier.earlierVersion === "none" && later.earlierVersion === "none") {
8980
+ merged.earlierVersion = "none";
8981
+ merged.oldText = earlier.oldText;
8982
+ return merged;
8983
+ }
8984
+ merged.earlierVersion = "file";
8985
+ merged.oldText = earlier.earlierVersion === "none" ? earlier.newText : earlier.oldText;
8986
+ return merged;
8987
+ }
8988
+ /**
8989
+ * @author ddj 2026年10月29号
8990
+ * @description: 由新建入参构造条目,并把首个会话写进会话集合。
8991
+ * @param input 新建入参
8992
+ * @returns 新条目
8993
+ */
8994
+ function entryFromNew(input) {
8995
+ const entry = {
8996
+ id: input.id,
8997
+ path: input.path,
8998
+ earlierVersion: input.earlierVersion,
8999
+ oldText: input.oldText,
9000
+ newText: input.newText,
9001
+ updatedAt: input.updatedAt,
9002
+ sessionId: input.sessionId,
9003
+ sessionIds: [input.sessionId]
9004
+ };
9005
+ if (input.lineage !== void 0) entry.lineage = input.lineage;
9006
+ return entry;
9007
+ }
9008
+ /**
9009
+ * @author ddj 2026年10月29号
9010
+ * @description: 应用一次决定带来的局部推进:块级 Keep 推进基线,块级 Revert 推进当前内容。
9011
+ * @param entry 待审条目
9012
+ * @param patch 要推进的一侧
9013
+ * @param now 本次更新时间
9014
+ * @returns 新条目;基线被推进时"创建"身份结束
9015
+ */
9016
+ function applyUpdate(entry, patch, now) {
9017
+ const next = {
9018
+ ...entry,
9019
+ updatedAt: now
9020
+ };
9021
+ if (patch.oldText !== void 0) {
9022
+ next.oldText = patch.oldText;
9023
+ next.earlierVersion = "file";
9024
+ }
9025
+ if (patch.newText !== void 0) next.newText = patch.newText;
9026
+ delete next.unseen;
9027
+ return next;
9028
+ }
9029
+ /**
9030
+ * @author ddj 2026年10月29号
9031
+ * @description: 合并两条条目的会话集合并去重,且保证首会话在列。
9032
+ * @param earlier 较早记录
9033
+ * @param later 较晚记录
9034
+ * @returns 去重后的会话集合
9035
+ */
9036
+ function unionSessions(earlier, later) {
9037
+ const merged = [];
9038
+ for (const sessionId of [
9039
+ earlier.sessionId,
9040
+ ...earlier.sessionIds,
9041
+ later.sessionId,
9042
+ ...later.sessionIds
9043
+ ]) if (sessionId && !merged.includes(sessionId)) merged.push(sessionId);
9044
+ return merged;
9045
+ }
9046
+ //#endregion
9047
+ //#region src/pending/decide.ts
9048
+ /**
9049
+ * @author ddj 2026年10月29号
9050
+ * @description: 执行一次 Keep/Revert;失败时返回原状态,调用方不得落库。
9051
+ * @param input 判定输入
9052
+ * @returns 判定结果
9053
+ */
9054
+ async function decide(input) {
9055
+ const failed = (reason) => ({
9056
+ ok: false,
9057
+ reason,
9058
+ entry: input.entry,
9059
+ flags: input.flags,
9060
+ blocks: input.blocks,
9061
+ wrote: false
9062
+ });
9063
+ if (input.request.revision !== input.flags.revision) return failed("revision-mismatch");
9064
+ if (input.flags.oversized) return failed("oversized");
9065
+ if (input.entry.oldText === input.entry.newText) return failed("settled");
9066
+ const targets = pickBlocks(reviewBlocks(textDiff(input.entry.path, input.entry.path, input.entry.oldText, input.entry.newText)), input);
9067
+ if (targets === null) return failed("unknown-block");
9068
+ if (input.request.scope !== "file" && input.request.scope !== "all" && targets.length === 0) return failed("no-block");
9069
+ const isKeep = input.request.decision === "accepted";
9070
+ const whole = input.request.scope === "file" || input.request.scope === "all";
9071
+ return isKeep ? keep(input, targets, whole) : revert(input, targets, whole);
9072
+ }
9073
+ /**
9074
+ * @author ddj 2026年10月29号
9075
+ * @description: Keep:只把目标块或整文件的新内容并入基线,绝不写目标文件。
9076
+ * @param input 判定输入
9077
+ * @param targets 目标块(整文件时为空)
9078
+ * @param whole 是否整文件
9079
+ * @returns 判定结果
9080
+ */
9081
+ function keep(input, targets, whole) {
9082
+ if (whole) return {
9083
+ ok: true,
9084
+ reason: "",
9085
+ entry: applyUpdate(input.entry, { oldText: input.entry.newText }, input.now),
9086
+ wrote: false,
9087
+ flags: {
9088
+ ...input.flags,
9089
+ revision: input.flags.revision + 1
9090
+ },
9091
+ blocks: markAll(input.blocks, targets, "accepted")
9092
+ };
9093
+ let oldText = input.entry.oldText;
9094
+ for (const block of targets) {
9095
+ const next = keepBlock(oldText, block);
9096
+ if (next === null) return {
9097
+ ok: false,
9098
+ reason: "ambiguous-block",
9099
+ entry: input.entry,
9100
+ flags: input.flags,
9101
+ blocks: input.blocks,
9102
+ wrote: false
9103
+ };
9104
+ oldText = next;
9105
+ }
9106
+ return {
9107
+ ok: true,
9108
+ reason: "",
9109
+ entry: applyUpdate(input.entry, { oldText }, input.now),
9110
+ wrote: false,
9111
+ flags: {
9112
+ ...input.flags,
9113
+ revision: input.flags.revision + 1
9114
+ },
9115
+ blocks: markAll(input.blocks, targets, "accepted")
9116
+ };
9117
+ }
9118
+ /**
9119
+ * @author ddj 2026年10月29号
9120
+ * @description: Revert:合成回退内容后交给 writer 写盘并读回,写失败则状态不变。
9121
+ * @param input 判定输入
9122
+ * @param targets 目标块(整文件时为空)
9123
+ * @param whole 是否整文件
9124
+ * @returns 判定结果
9125
+ */
9126
+ async function revert(input, targets, whole) {
9127
+ const failed = (reason) => ({
9128
+ ok: false,
9129
+ reason,
9130
+ entry: input.entry,
9131
+ flags: input.flags,
9132
+ blocks: input.blocks,
9133
+ wrote: false
9134
+ });
9135
+ const next = whole ? wholeRevert(input.entry) : blockRevert(input.entry, targets);
9136
+ if (next === void 0) return failed("ambiguous-block");
9137
+ const written = await input.write(next);
9138
+ if (!written.ok) return failed(written.reason);
9139
+ return {
9140
+ ok: true,
9141
+ reason: "",
9142
+ entry: applyUpdate(input.entry, { newText: written.content ?? "" }, input.now),
9143
+ wrote: true,
9144
+ flags: {
9145
+ ...input.flags,
9146
+ revision: input.flags.revision + 1,
9147
+ missing: written.content === null
9148
+ },
9149
+ blocks: markAll(input.blocks, targets, "rejected")
9150
+ };
9151
+ }
9152
+ /**
9153
+ * @author ddj 2026年10月29号
9154
+ * @description: 整文件回退目标:基线为"文件不存在"时删除,否则写回基线全文。
9155
+ * @param entry 待审条目
9156
+ * @returns 目标内容,null 表示删除
9157
+ */
9158
+ function wholeRevert(entry) {
9159
+ return entry.earlierVersion === "none" ? null : entry.oldText;
9160
+ }
9161
+ /**
9162
+ * @author ddj 2026年10月29号
9163
+ * @description: 块级回退:在最新内容里依次换回目标块,任一不可定位即整体放弃。
9164
+ * @param entry 待审条目
9165
+ * @param targets 目标块
9166
+ * @returns 新内容或 undefined
9167
+ */
9168
+ function blockRevert(entry, targets) {
9169
+ let newText = entry.newText;
9170
+ for (const block of targets) {
9171
+ const next = revertBlock(newText, block);
9172
+ if (next === null) return void 0;
9173
+ newText = next;
9174
+ }
9175
+ return newText;
9176
+ }
9177
+ /**
9178
+ * @author ddj 2026年10月29号
9179
+ * @description: 按请求粒度挑出目标块;块不存在时返回 null 以区分"没有块"。
9180
+ * @param blocks 当前差异块
9181
+ * @param input 判定输入
9182
+ * @returns 目标块,未知块 ID 时为 null
9183
+ */
9184
+ function pickBlocks(blocks, input) {
9185
+ const scope = input.request.scope;
9186
+ if (scope === "file" || scope === "all") return [];
9187
+ if (scope === "block") {
9188
+ const block = blocks.find((item) => item.id === input.request.blockId);
9189
+ return block ? [block] : null;
9190
+ }
9191
+ const start = input.request.startLine ?? 0;
9192
+ const end = input.request.endLine ?? start;
9193
+ return blocks.filter((block) => block.newStart <= end && block.newStart + Math.max(block.newLines.length, 1) >= start);
9194
+ }
9195
+ /**
9196
+ * @author ddj 2026年10月29号
9197
+ * @description: 把目标块标记为已决定;整文件时把全部块一起标记。
9198
+ * @param states 已存块决定
9199
+ * @param targets 目标块
9200
+ * @param decision 决定
9201
+ * @returns 新的块决定列表
9202
+ */
9203
+ function markAll(states, targets, decision) {
9204
+ const next = states.slice();
9205
+ for (const block of targets) {
9206
+ const at = next.findIndex((state) => state.id === block.id);
9207
+ if (at >= 0) next[at] = {
9208
+ id: block.id,
9209
+ decision
9210
+ };
9211
+ else next.push({
9212
+ id: block.id,
9213
+ decision
9214
+ });
9215
+ }
9216
+ return next;
9217
+ }
9218
+ /**
9219
+ * @author ddj 2026年10月29号
9220
+ * @description: 能力口径:Keep 恒可;Revert 受超大/缺失/已解决限制,拒绝原因要能展示。
9221
+ * @param entry 待审条目
9222
+ * @param flags 持久标记
9223
+ * @returns 能力与原因
9224
+ */
9225
+ function capabilityOf(entry, flags) {
9226
+ if (isSettled(entry)) return {
9227
+ keep: false,
9228
+ revert: false,
9229
+ reason: "settled"
9230
+ };
9231
+ if (flags.oversized) return {
9232
+ keep: false,
9233
+ revert: false,
9234
+ reason: "oversized"
9235
+ };
9236
+ if (entry.earlierVersion === "none" && entry.newText.length === 0) return {
9237
+ keep: true,
9238
+ revert: false,
9239
+ reason: "empty-create"
9240
+ };
9241
+ return {
9242
+ keep: true,
9243
+ revert: true,
9244
+ reason: ""
9245
+ };
9246
+ }
9247
+ /**
9248
+ * @author ddj 2026年10月29号
9249
+ * @description: 供详情接口复用的差异构造。
9250
+ * @param entry 待审条目
9251
+ * @returns 官方差异
9252
+ */
9253
+ function diffOf(entry) {
9254
+ return textDiff(entry.path, entry.path, entry.oldText, entry.newText);
9255
+ }
9256
+ //#endregion
9257
+ //#region src/pending/persist.ts
9258
+ /** 待审库版本号,写进 metadata 便于日后迁移。v3 给评论表加了引文与上下文列。 */
9259
+ const SCHEMA_VERSION = "3";
9260
+ /**
9261
+ * @author ddj 2026年10月29号
9262
+ * @description: 打开工作区库并确保待审表存在;DDL 与版本标记在同一事务里提交。
9263
+ * @param cwd 已迁移工作区
9264
+ * @returns 可读写的连接
9265
+ * @throws 未完成活跃库迁移时拒绝
9266
+ */
9267
+ function pendingDb(cwd) {
9268
+ if (!activeReady(cwd)) throw new Error("待审投影要求先完成活跃库迁移");
9269
+ const db = dbFor(cwd);
9270
+ if (db.prepare("SELECT value FROM metadata WHERE name='pending_schema'").get()?.value === SCHEMA_VERSION) return db;
9271
+ db.exec("BEGIN IMMEDIATE");
9272
+ try {
9273
+ db.exec(DDL);
9274
+ migrateHistory(db);
9275
+ db.prepare("INSERT INTO metadata(name,value) VALUES('pending_schema',?) ON CONFLICT(name) DO UPDATE SET value=excluded.value").run(SCHEMA_VERSION);
9276
+ db.exec("COMMIT");
9277
+ } catch (error) {
9278
+ if (db.isTransaction) db.exec("ROLLBACK");
9279
+ throw error;
9280
+ }
9281
+ return db;
9282
+ }
9283
+ /** 待审表结构:条目、块决定、幂等操作、撤销栈。 */
9284
+ const DDL = `
9285
+ CREATE TABLE IF NOT EXISTS pending_entries (
9286
+ id TEXT PRIMARY KEY, path TEXT NOT NULL, earlier TEXT NOT NULL,
9287
+ old_text TEXT NOT NULL, new_text TEXT NOT NULL, updated_at INTEGER NOT NULL,
9288
+ unseen INTEGER NOT NULL DEFAULT 0, missing INTEGER NOT NULL DEFAULT 0,
9289
+ diverged INTEGER NOT NULL DEFAULT 0, oversized INTEGER NOT NULL DEFAULT 0,
9290
+ revision INTEGER NOT NULL DEFAULT 0, session_id TEXT NOT NULL,
9291
+ session_ids TEXT NOT NULL, lineage TEXT);
9292
+ CREATE TABLE IF NOT EXISTS pending_blocks (
9293
+ entry_id TEXT NOT NULL, block_id TEXT NOT NULL, decision TEXT NOT NULL,
9294
+ PRIMARY KEY(entry_id,block_id));
9295
+ CREATE TABLE IF NOT EXISTS pending_ops (
9296
+ operation_id TEXT PRIMARY KEY, request TEXT NOT NULL, state TEXT NOT NULL,
9297
+ result TEXT, undo TEXT);
9298
+ CREATE TABLE IF NOT EXISTS pending_history (
9299
+ id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, entry_id TEXT,
9300
+ snapshot TEXT NOT NULL, created_at INTEGER NOT NULL,
9301
+ state TEXT NOT NULL DEFAULT 'applied');
9302
+ `;
9303
+ /** v1 历史表补 state 列(rebuild 成本高于一次 ALTER)。 */
9304
+ function migrateHistory(db) {
9305
+ if (db.prepare("PRAGMA table_info(pending_history)").all().some((column) => column.name === "state")) return;
9306
+ db.exec("ALTER TABLE pending_history ADD COLUMN state TEXT NOT NULL DEFAULT 'applied'");
9307
+ }
9308
+ /**
9309
+ * @author ddj 2026年10月29号
9310
+ * @description: 读出全部待审条目,按更新时间升序。
9311
+ * @param cwd 工作区
9312
+ * @returns 条目与标记
9313
+ */
9314
+ function loadEntries(cwd) {
9315
+ return pendingDb(cwd).prepare("SELECT * FROM pending_entries ORDER BY updated_at ASC").all().map((row) => ({
9316
+ entry: entryOfRow(row),
9317
+ flags: flagsOfRow(row)
9318
+ }));
9319
+ }
9320
+ /**
9321
+ * @author ddj 2026年10月29号
9322
+ * @description: 写入或更新一条条目(含标记),单条原子完成。
9323
+ * @param cwd 工作区
9324
+ * @param stored 条目与标记
9325
+ * @returns 无
9326
+ */
9327
+ function saveEntry(cwd, stored) {
9328
+ const { entry, flags } = stored;
9329
+ pendingDb(cwd).prepare(`INSERT INTO pending_entries
9330
+ (id,path,earlier,old_text,new_text,updated_at,unseen,missing,diverged,oversized,revision,session_id,session_ids,lineage)
9331
+ VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
9332
+ ON CONFLICT(id) DO UPDATE SET path=excluded.path,earlier=excluded.earlier,old_text=excluded.old_text,
9333
+ new_text=excluded.new_text,updated_at=excluded.updated_at,unseen=excluded.unseen,missing=excluded.missing,
9334
+ diverged=excluded.diverged,oversized=excluded.oversized,revision=excluded.revision,
9335
+ session_id=excluded.session_id,session_ids=excluded.session_ids,lineage=excluded.lineage`).run(entry.id, entry.path, entry.earlierVersion, entry.oldText, entry.newText, entry.updatedAt, entry.unseen === true ? 1 : 0, flags.missing ? 1 : 0, flags.diverged ? 1 : 0, flags.oversized ? 1 : 0, flags.revision, entry.sessionId, JSON.stringify(entry.sessionIds), entry.lineage ? JSON.stringify(entry.lineage) : null);
9336
+ }
9337
+ /**
9338
+ * @author ddj 2026年10月29号
9339
+ * @description: 删除条目及其块决定,用于"已解决条目移除"。
9340
+ * @param cwd 工作区
9341
+ * @param id 条目身份
9342
+ * @returns 无
9343
+ */
9344
+ function dropEntry(cwd, id) {
9345
+ const db = pendingDb(cwd);
9346
+ db.exec("BEGIN IMMEDIATE");
9347
+ try {
9348
+ db.prepare("DELETE FROM pending_entries WHERE id=?").run(id);
9349
+ db.prepare("DELETE FROM pending_blocks WHERE entry_id=?").run(id);
9350
+ db.exec("COMMIT");
9351
+ } catch (error) {
9352
+ if (db.isTransaction) db.exec("ROLLBACK");
9353
+ throw error;
9354
+ }
9355
+ }
9356
+ /**
9357
+ * @author ddj 2026年10月29号
9358
+ * @description: 覆盖某条目的块决定集合。
9359
+ * @param cwd 工作区
9360
+ * @param entryId 条目身份
9361
+ * @param blocks 块决定
9362
+ * @returns 无
9363
+ */
9364
+ function saveBlocks(cwd, entryId, blocks) {
9365
+ const db = pendingDb(cwd);
9366
+ db.exec("BEGIN IMMEDIATE");
9367
+ try {
9368
+ db.prepare("DELETE FROM pending_blocks WHERE entry_id=?").run(entryId);
9369
+ const insert = db.prepare("INSERT INTO pending_blocks VALUES(?,?,?)");
9370
+ for (const block of blocks) insert.run(entryId, block.id, block.decision);
9371
+ db.exec("COMMIT");
9372
+ } catch (error) {
9373
+ if (db.isTransaction) db.exec("ROLLBACK");
9374
+ throw error;
9375
+ }
9376
+ }
9377
+ /**
9378
+ * @author ddj 2026年10月29号
9379
+ * @description: 读取幂等操作记录;同 operationId 的重复请求必须复用结果。
9380
+ * @param cwd 工作区
9381
+ * @param operationId 操作身份
9382
+ * @returns 请求原文、状态与结果
9383
+ */
9384
+ function readOp(cwd, operationId) {
9385
+ const row = pendingDb(cwd).prepare("SELECT request,state,result FROM pending_ops WHERE operation_id=?").get(operationId);
9386
+ return row ? {
9387
+ request: String(row.request),
9388
+ state: String(row.state),
9389
+ result: row.result === null ? null : String(row.result)
9390
+ } : void 0;
9391
+ }
9392
+ /**
9393
+ * @author ddj 2026年10月29号
9394
+ * @description: 记录一次操作的阶段与撤销快照。
9395
+ * @param cwd 工作区
9396
+ * @param operationId 操作身份
9397
+ * @param request 请求原文
9398
+ * @param state prepared / committed
9399
+ * @param payload 结果与撤销快照
9400
+ * @returns 无
9401
+ */
9402
+ function writeOp(cwd, operationId, request, state, payload = {}) {
9403
+ pendingDb(cwd).prepare(`INSERT INTO pending_ops VALUES(?,?,?,?,?)
9404
+ ON CONFLICT(operation_id) DO UPDATE SET state=excluded.state,
9405
+ result=COALESCE(excluded.result,pending_ops.result), undo=COALESCE(excluded.undo,pending_ops.undo)`).run(operationId, request, state, payload.result ?? null, payload.undo ?? null);
9406
+ }
9407
+ /**
9408
+ * @author ddj 2026年10月29号
9409
+ * @description: 追加一条撤销栈记录,并截断重做分支(新决定之后旧的"已撤销"记录不能再重做)。
9410
+ * @param cwd 工作区
9411
+ * @param kind 操作种类
9412
+ * @param entryId 涉及的条目
9413
+ * @param snapshot 可重放快照
9414
+ * @param now 时间
9415
+ * @returns 无
9416
+ */
9417
+ function pushHistory(cwd, kind, entryId, snapshot, now) {
9418
+ const db = pendingDb(cwd);
9419
+ db.prepare("DELETE FROM pending_history WHERE state='undone'").run();
9420
+ db.prepare("INSERT INTO pending_history(kind,entry_id,snapshot,created_at,state) VALUES(?,?,?,?,'applied')").run(kind, entryId, snapshot, now);
9421
+ }
9422
+ /**
9423
+ * @author ddj 2026年10月30号
9424
+ * @description: 取最近一条仍生效的历史记录,撤销就作用在它上面。
9425
+ * @param cwd 工作区
9426
+ * @returns 记录或 undefined
9427
+ */
9428
+ function latestApplied(cwd) {
9429
+ const row = pendingDb(cwd).prepare("SELECT id,kind,entry_id,snapshot FROM pending_history WHERE state='applied' ORDER BY id DESC LIMIT 1").get();
9430
+ return row ? rowOfHistory(row) : void 0;
9431
+ }
9432
+ /**
9433
+ * @author ddj 2026年10月30号
9434
+ * @description: 取最早一条被撤销的记录,重做按"撤销的逆序"重新生效。
9435
+ * @param cwd 工作区
9436
+ * @returns 记录或 undefined
9437
+ */
9438
+ function earliestUndone(cwd) {
9439
+ const row = pendingDb(cwd).prepare("SELECT id,kind,entry_id,snapshot FROM pending_history WHERE state='undone' ORDER BY id ASC LIMIT 1").get();
9440
+ return row ? rowOfHistory(row) : void 0;
9441
+ }
9442
+ /**
9443
+ * @author ddj 2026年10月30号
9444
+ * @description: 翻转一条历史记录的生效状态。
9445
+ * @param cwd 工作区
9446
+ * @param id 记录主键
9447
+ * @param state applied / undone
9448
+ * @returns 无
9449
+ */
9450
+ function setHistoryState(cwd, id, state) {
9451
+ pendingDb(cwd).prepare("UPDATE pending_history SET state=? WHERE id=?").run(state, id);
9452
+ }
9453
+ /**
9454
+ * @author ddj 2026年10月30号
9455
+ * @description: 把数据库行还原成历史记录。
9456
+ * @param row 数据库行
9457
+ * @returns 记录
9458
+ */
9459
+ function rowOfHistory(row) {
9460
+ return {
9461
+ id: Number(row.id),
9462
+ kind: String(row.kind),
9463
+ entryId: String(row.entry_id),
9464
+ snapshot: String(row.snapshot)
9465
+ };
9466
+ }
9467
+ /**
9468
+ * @author ddj 2026年10月29号
9469
+ * @description: 由数据库行还原条目。
9470
+ * @param row 数据库行
9471
+ * @returns 条目
9472
+ */
9473
+ function entryOfRow(row) {
9474
+ const lineage = row.lineage === null ? void 0 : JSON.parse(String(row.lineage));
9475
+ const entry = {
9476
+ id: String(row.id),
9477
+ path: String(row.path),
9478
+ earlierVersion: String(row.earlier) === "none" ? "none" : "file",
9479
+ oldText: String(row.old_text),
9480
+ newText: String(row.new_text),
9481
+ updatedAt: Number(row.updated_at),
9482
+ sessionId: String(row.session_id),
9483
+ sessionIds: JSON.parse(String(row.session_ids))
9484
+ };
9485
+ if (Number(row.unseen) === 1) entry.unseen = true;
9486
+ if (lineage !== void 0) entry.lineage = lineage;
9487
+ return entry;
9488
+ }
9489
+ /**
9490
+ * @author ddj 2026年10月29号
9491
+ * @description: 由数据库行还原标记。
9492
+ * @param row 数据库行
9493
+ * @returns 标记
9494
+ */
9495
+ function flagsOfRow(row) {
9496
+ return {
9497
+ missing: Number(row.missing) === 1,
9498
+ diverged: Number(row.diverged) === 1,
9499
+ oversized: Number(row.oversized) === 1,
9500
+ revision: Number(row.revision)
9501
+ };
9502
+ }
9503
+ //#endregion
9504
+ //#region src/pending/store.ts
9505
+ /** 统计一次差异的增删行数。 */
9506
+ function countsOf(entry) {
9507
+ const diff = diffOf(entry);
9508
+ if (diff.kind !== "text") return {
9509
+ added: 0,
9510
+ deleted: 0
9511
+ };
9512
+ let added = 0;
9513
+ let deleted = 0;
9514
+ for (const hunk of diff.hunks) for (const line of hunk.lines) if (line.startsWith("+")) added++;
9515
+ else if (line.startsWith("-")) deleted++;
9516
+ return {
9517
+ added,
9518
+ deleted
9519
+ };
9520
+ }
9521
+ /**
9522
+ * @author ddj 2026年10月30号
9523
+ * @description: 由当前累计条目现场推导块身份;块是差异的纯函数,落库的只是决定状态。
9524
+ * @param entry 累计条目
9525
+ * @returns 块状态列表(决定一律 pending,判定推进后由下一次推导自然收窄)
9526
+ */
9527
+ function blockStatesOf(entry) {
9528
+ const diff = diffOf(entry);
9529
+ if (diff.kind !== "text") return [];
9530
+ return reviewBlocks(diff).map((block) => ({
9531
+ id: block.id,
9532
+ decision: "pending"
9533
+ }));
9534
+ }
9535
+ /**
9536
+ * @author ddj 2026年10月29号
9537
+ * @description: 把一次已确认写入折进累计条目:同一身份只保留一条,基线留最早、内容取最新。
9538
+ * @param stored 已有条目(可为空)
9539
+ * @param write 采集到的写入
9540
+ * @returns 折叠后的条目与标记
9541
+ */
9542
+ function foldWrite(stored, write) {
9543
+ const fresh = entryFromNew({
9544
+ id: write.identity,
9545
+ path: write.path,
9546
+ sessionId: write.sessionId,
9547
+ earlierVersion: write.before === null ? "none" : "file",
9548
+ oldText: write.before ?? "",
9549
+ newText: write.after ?? "",
9550
+ updatedAt: write.now
9551
+ });
9552
+ if (!stored) return {
9553
+ entry: fresh,
9554
+ flags: {
9555
+ missing: write.after === null,
9556
+ diverged: false,
9557
+ oversized: false,
9558
+ revision: 0
9559
+ }
9560
+ };
9561
+ const merged = mergeEntries(stored.entry, fresh);
9562
+ merged.path = write.path;
9563
+ return {
9564
+ entry: merged,
9565
+ flags: {
9566
+ missing: write.after === null,
9567
+ diverged: false,
9568
+ oversized: stored.flags.oversized,
9569
+ revision: stored.flags.revision + 1
9570
+ }
9571
+ };
9572
+ }
9573
+ /**
9574
+ * @author ddj 2026年10月29号
9575
+ * @description: 条目转列表投影,已解决条目仍保留但不可再决定。
9576
+ * @param stored 条目与标记
9577
+ * @returns 列表项
9578
+ */
9579
+ function itemOf(stored) {
9580
+ const { entry, flags } = stored;
9581
+ const counts = countsOf(entry);
9582
+ const item = {
9583
+ id: entry.id,
9584
+ path: entry.path,
9585
+ earlierVersion: entry.earlierVersion,
9586
+ added: counts.added,
9587
+ deleted: counts.deleted,
9588
+ settled: isSettled(entry),
9589
+ unseen: entry.unseen === true,
9590
+ missing: flags.missing,
9591
+ diverged: flags.diverged,
9592
+ oversized: flags.oversized,
9593
+ revision: flags.revision,
9594
+ updatedAt: entry.updatedAt,
9595
+ sessionId: entry.sessionId,
9596
+ sessionIds: entry.sessionIds
9597
+ };
9598
+ if (entry.lineage !== void 0) item.lineage = entry.lineage;
9599
+ return item;
9600
+ }
9601
+ /**
9602
+ * @author ddj 2026年10月29号
9603
+ * @description: 读一页待审条目,按更新时间升序;会话过滤走血缘集合。
9604
+ * @param cwd 工作区
9605
+ * @param sessionId 当前会话
9606
+ * @returns 列表
9607
+ */
9608
+ function loadPage(cwd, sessionId) {
9609
+ const all = loadEntries(cwd);
9610
+ const visible = sessionId ? all.filter((stored) => stored.entry.sessionIds.includes(sessionId)) : all;
9611
+ const items = visible.map(itemOf);
9612
+ const gaps = [];
9613
+ for (const stored of visible) if (stored.flags.oversized) gaps.push(`${stored.entry.path}: 文本超出上限,仅保留标记`);
9614
+ return {
9615
+ items,
9616
+ pending: items.filter((item) => !item.settled).length,
9617
+ nextCursor: null,
9618
+ gaps
9619
+ };
9620
+ }
9621
+ /**
9622
+ * @author ddj 2026年10月29号
9623
+ * @description: 读单条详情,包含可交给现有面板渲染的差异与块决定。
9624
+ * @param cwd 工作区
9625
+ * @param id 条目身份
9626
+ * @returns 详情,条目不存在时 null
9627
+ */
9628
+ function loadDetail(cwd, id) {
9629
+ const stored = loadEntries(cwd).find((item) => item.entry.id === id);
9630
+ if (!stored) return null;
9631
+ const diff = diffOf(stored.entry);
9632
+ return {
9633
+ item: itemOf(stored),
9634
+ diff,
9635
+ blocks: blockStatesOf(stored.entry),
9636
+ capability: capabilityOf(stored.entry, stored.flags)
9637
+ };
9638
+ }
9639
+ /**
9640
+ * @author ddj 2026年10月29号
9641
+ * @description: 采集入口:折叠写入并落库。
9642
+ * @param write 已确认写入
9643
+ * @returns 无
9644
+ */
9645
+ function recordWrite(write) {
9646
+ const stored = loadEntries(write.cwd).find((item) => item.entry.id === write.identity);
9647
+ if (stored && stored.entry.oldText === stored.entry.newText && stored.entry.newText === (write.after ?? "")) return;
9648
+ saveEntry(write.cwd, foldWrite(stored, write));
9649
+ }
9650
+ /**
9651
+ * @author ddj 2026年10月29号
9652
+ * @description: 外部改动吸收:当前内容与条目最新内容不同时采纳并标分歧,基线不动。
9653
+ * @param stored 条目
9654
+ * @param content 磁盘当前内容
9655
+ * @param now 时间
9656
+ * @returns 新条目与标记
9657
+ */
9658
+ function absorbed(stored, content, now) {
9659
+ return {
9660
+ entry: {
9661
+ ...stored.entry,
9662
+ newText: content,
9663
+ updatedAt: now,
9664
+ unseen: true
9665
+ },
9666
+ flags: {
9667
+ ...stored.flags,
9668
+ diverged: true,
9669
+ missing: false,
9670
+ revision: stored.flags.revision + 1
9671
+ }
9672
+ };
9673
+ }
9674
+ /**
9675
+ * @author ddj 2026年10月29号
9676
+ * @description: 读回磁盘并把可读的外部改动吸收进条目;缺失只标标记不删条目。
9677
+ * @param stored 条目
9678
+ * @param content 磁盘当前内容,null 表示缺失或不可读
9679
+ * @param now 时间
9680
+ * @returns 需要落库的新状态,无需改动时为 null
9681
+ */
9682
+ function refreshed(stored, content, now) {
9683
+ if (content === null) {
9684
+ if (stored.flags.missing) return null;
9685
+ return {
9686
+ entry: stored.entry,
9687
+ flags: {
9688
+ ...stored.flags,
9689
+ missing: true,
9690
+ revision: stored.flags.revision + 1
9691
+ }
9692
+ };
9693
+ }
9694
+ if (content === stored.entry.newText) {
9695
+ if (!stored.flags.missing) return null;
9696
+ return {
9697
+ entry: stored.entry,
9698
+ flags: {
9699
+ ...stored.flags,
9700
+ missing: false,
9701
+ revision: stored.flags.revision + 1
9702
+ }
9703
+ };
9704
+ }
9705
+ return absorbed(stored, content, now);
9706
+ }
9707
+ /**
9708
+ * @author ddj 2026年10月29号
9709
+ * @description: 构造带版本守卫与读回校验的写盘函数;内容已被外部改动时直接拒绝。
9710
+ * @param fs 文件系统
9711
+ * @param cwd 工作区
9712
+ * @param identity 目标身份
9713
+ * @param expected 写前应当具备的内容
9714
+ * @param signal 取消信号
9715
+ * @param policy 沙箱策略
9716
+ * @param allowCreate 文件已被删除时是否允许按快照重建(仅撤销/重做路径使用)
9717
+ * @returns 写盘函数
9718
+ */
9719
+ function writerFor(fs, cwd, identity, expected, signal, policy, allowCreate = false) {
9720
+ return async (content) => {
9721
+ let target;
9722
+ try {
9723
+ target = await fs.resolve(identity, {
9724
+ cwd,
9725
+ signal
9726
+ });
9727
+ } catch {
9728
+ return {
9729
+ ok: false,
9730
+ reason: "resolve-failed"
9731
+ };
9732
+ }
9733
+ const current = await readText(fs, target).catch(() => void 0);
9734
+ if (current === void 0) return {
9735
+ ok: false,
9736
+ reason: "read-failed"
9737
+ };
9738
+ if (!(allowCreate && current === null) && current !== expected) return {
9739
+ ok: false,
9740
+ reason: "diverged"
9741
+ };
9742
+ const info = await fs.stat(target, signal).catch(() => void 0);
9743
+ try {
9744
+ await fs.writeText(target, content ?? "", info ? {
9745
+ kind: "replaceIfVersion",
9746
+ version: info.version
9747
+ } : void 0, signal, policy);
9748
+ const readBack = await readText(fs, target);
9749
+ if ((content ?? "") !== (readBack ?? "")) return {
9750
+ ok: false,
9751
+ reason: "read-back-mismatch"
9752
+ };
9753
+ if (content === null && readBack !== null) return {
9754
+ ok: false,
9755
+ reason: "delete-failed"
9756
+ };
9757
+ return {
9758
+ ok: true,
9759
+ content
9760
+ };
9761
+ } catch (error) {
9762
+ return {
9763
+ ok: false,
9764
+ reason: reasonOf(error)
9765
+ };
9766
+ }
9767
+ };
9768
+ }
9769
+ /**
9770
+ * @author ddj 2026年10月29号
9771
+ * @description: 执行一次 Keep/Revert 并把结果落库;失败只返回原因,不写任何状态。
9772
+ * @param fs 文件系统
9773
+ * @param cwd 工作区
9774
+ * @param request 判定请求
9775
+ * @param signal 取消信号
9776
+ * @returns 判定结果
9777
+ */
9778
+ async function applyDecide(fs, cwd, request, signal, policy) {
9779
+ const stored = loadEntries(cwd).find((item) => item.entry.id === request.id);
9780
+ if (!stored) return {
9781
+ ok: false,
9782
+ reason: "unknown-item"
9783
+ };
9784
+ const beforeBlocks = blockStatesOf(stored.entry);
9785
+ const outcome = await decide({
9786
+ request,
9787
+ entry: stored.entry,
9788
+ flags: stored.flags,
9789
+ blocks: beforeBlocks,
9790
+ now: Date.now(),
9791
+ write: (content) => {
9792
+ if (content === null) return deleteTarget(fs, cwd, stored.entry.id, signal);
9793
+ return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy)(content);
9794
+ }
9795
+ });
9796
+ if (!outcome.ok) return {
9797
+ ok: false,
9798
+ reason: outcome.reason
9799
+ };
9800
+ saveEntry(cwd, {
9801
+ entry: outcome.entry,
9802
+ flags: outcome.flags
9803
+ });
9804
+ saveBlocks(cwd, request.id, outcome.blocks);
9805
+ pushHistory(cwd, "decide", request.id, JSON.stringify({
9806
+ before: {
9807
+ entry: stored.entry,
9808
+ flags: stored.flags,
9809
+ blocks: beforeBlocks
9810
+ },
9811
+ after: {
9812
+ entry: outcome.entry,
9813
+ flags: outcome.flags,
9814
+ blocks: outcome.blocks
9815
+ },
9816
+ wrote: outcome.wrote
9817
+ }), Date.now());
9818
+ return outcome;
9819
+ }
9820
+ /**
9821
+ * @author ddj 2026年10月30号
9822
+ * @description: 版本库导入:把工作副本相对基线的改动折进待审条目,已存在条目按折叠规则并入。
9823
+ * @param shell shell 能力
9824
+ * @param cwd 工作区
9825
+ * @param sessionId 触发导入的会话
9826
+ * @param options 导入选项
9827
+ * @returns 导入条数与探测到的改动数
9828
+ */
9829
+ async function importFromVcs(shell, cwd, sessionId, options = {}) {
9830
+ const changes = await importVcsChanges(shell, cwd, options);
9831
+ const now = Date.now();
9832
+ for (const change of changes) recordWrite({
9833
+ cwd,
9834
+ sessionId,
9835
+ path: change.path,
9836
+ identity: change.path,
9837
+ before: change.earlierVersion === "none" ? null : change.oldText,
9838
+ after: change.newText,
9839
+ now
9840
+ });
9841
+ return {
9842
+ imported: changes.length,
9843
+ changes: changes.length
9844
+ };
9845
+ }
9846
+ /**
9847
+ * @author ddj 2026年10月30号
9848
+ * @description: 撤销或重做最近一次判定。先按 operationId 判幂等,再据栈顶记录判定方向是否合法,
9849
+ * 只有写盘过的判定才反向写盘,写盘失败时条目状态保持原样。
9850
+ * @param fs 文件系统
9851
+ * @param cwd 工作区
9852
+ * @param move 撤销/重做请求
9853
+ * @param signal 取消信号
9854
+ * @returns 结果条目与是否写盘
9855
+ */
9856
+ async function undoDecide(fs, cwd, move, signal, policy) {
9857
+ const replay = readOp(cwd, move.operationId);
9858
+ if (replay && replay.state === "committed" && replay.result) return JSON.parse(replay.result);
9859
+ const record = move.direction === "undo" ? latestApplied(cwd) : earliestUndone(cwd);
9860
+ if (!record) return {
9861
+ ok: false,
9862
+ reason: move.direction === "undo" ? "no-undo" : "no-redo"
9863
+ };
9864
+ const snapshot = parseSnapshot(record.snapshot);
9865
+ if (!snapshot) return {
9866
+ ok: false,
9867
+ reason: "bad-history"
9868
+ };
9869
+ const stored = loadEntries(cwd).find((item) => item.entry.id === record.entryId);
9870
+ if (!stored) return {
9871
+ ok: false,
9872
+ reason: "unknown-item"
9873
+ };
9874
+ const target = move.direction === "undo" ? snapshot.before : snapshot.after;
9875
+ writeOp(cwd, move.operationId, JSON.stringify(move), "prepared");
9876
+ let wrote = false;
9877
+ if (snapshot.wrote) {
9878
+ const result = await writeBack(fs, cwd, stored, target, signal, policy);
9879
+ if (!result.ok) return {
9880
+ ok: false,
9881
+ reason: result.reason
9882
+ };
9883
+ wrote = true;
9884
+ }
9885
+ saveEntry(cwd, {
9886
+ entry: target.entry,
9887
+ flags: target.flags
9888
+ });
9889
+ saveBlocks(cwd, record.entryId, target.blocks);
9890
+ setHistoryState(cwd, record.id, move.direction === "undo" ? "undone" : "applied");
9891
+ const outcome = {
9892
+ ok: true,
9893
+ item: itemOf({
9894
+ entry: target.entry,
9895
+ flags: target.flags
9896
+ }),
9897
+ wrote
8085
9898
  };
9899
+ writeOp(cwd, move.operationId, JSON.stringify(move), "committed", { result: JSON.stringify(outcome) });
9900
+ return outcome;
8086
9901
  }
8087
- /** @private @author ddj 2026年10月27号 @param fs Public filesystem. @param target Backend identity. @returns Raw bounded file or confirmed missing. @throws Any non-missing read error. */
8088
- async function readRaw(fs, target, signal) {
9902
+ /**
9903
+ * @author ddj 2026年10月30号
9904
+ * @description: 把文件内容恢复到目标快照:目标为缺失则删除,否则按版本守卫写回并读回校验。
9905
+ * @param fs 文件系统
9906
+ * @param cwd 工作区
9907
+ * @param stored 当前条目
9908
+ * @param target 目标快照
9909
+ * @param signal 取消信号
9910
+ * @returns 写盘结果
9911
+ */
9912
+ async function writeBack(fs, cwd, stored, target, signal, policy) {
9913
+ if (target.flags.missing) return deleteTarget(fs, cwd, stored.entry.id, signal);
9914
+ return writerFor(fs, cwd, stored.entry.id, stored.entry.newText, signal, policy, true)(target.entry.newText);
9915
+ }
9916
+ /**
9917
+ * @author ddj 2026年10月30号
9918
+ * @description: 解析历史快照;损坏时返回 null,不猜测内容。
9919
+ * @param text 快照文本
9920
+ * @returns 快照或 null
9921
+ */
9922
+ function parseSnapshot(text) {
8089
9923
  try {
8090
- return rawReviewFile(await fs.readBytes(target, signal, 1048576));
9924
+ const parsed = JSON.parse(text);
9925
+ if (!parsed?.before?.entry || !parsed?.after?.entry) return null;
9926
+ return parsed;
9927
+ } catch {
9928
+ return null;
9929
+ }
9930
+ }
9931
+ /**
9932
+ * @author ddj 2026年10月29号
9933
+ * @description: Revert 的删除分支:FileSystem 抽象没有删除能力,只有确认仍在工作区内才用宿主 unlink。
9934
+ * @param fs 文件系统
9935
+ * @param cwd 工作区
9936
+ * @param identity 目标身份
9937
+ * @param signal 取消信号
9938
+ * @returns 删除结果
9939
+ */
9940
+ async function deleteTarget(fs, cwd, identity, signal) {
9941
+ let target;
9942
+ try {
9943
+ target = await fs.resolve(identity, {
9944
+ cwd,
9945
+ signal
9946
+ });
9947
+ } catch {
9948
+ return {
9949
+ ok: false,
9950
+ reason: "resolve-failed"
9951
+ };
9952
+ }
9953
+ if (!await fs.stat(target, signal).catch(() => void 0)) return {
9954
+ ok: true,
9955
+ content: null
9956
+ };
9957
+ const absolute = String(fs.processPath(target));
9958
+ if (!insideWorkspace(cwd, absolute)) return {
9959
+ ok: false,
9960
+ reason: "outside-workspace"
9961
+ };
9962
+ try {
9963
+ await unlink(absolute);
8091
9964
  } catch (error) {
8092
- const code = error.code;
8093
- if (code === "ENOENT" || code === "FS_NOT_FOUND") return rawReviewFile(null);
8094
- throw error;
9965
+ return {
9966
+ ok: false,
9967
+ reason: reasonOf(error)
9968
+ };
9969
+ }
9970
+ return await fs.stat(target, signal).catch(() => void 0) ? {
9971
+ ok: false,
9972
+ reason: "delete-failed"
9973
+ } : {
9974
+ ok: true,
9975
+ content: null
9976
+ };
9977
+ }
9978
+ /**
9979
+ * @author ddj 2026年10月29号
9980
+ * @description: 判断宿主路径是否位于工作区内,Windows 下按大小写不敏感比较。
9981
+ * @param cwd 工作区
9982
+ * @param absolute 目标绝对路径
9983
+ * @returns 是否在工作区内
9984
+ */
9985
+ function insideWorkspace(cwd, absolute) {
9986
+ const root = resolve(cwd);
9987
+ const candidate = resolve(absolute);
9988
+ const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
9989
+ const rootKey = normalize(root).endsWith(sep) ? normalize(root) : normalize(root) + sep;
9990
+ return normalize(candidate).startsWith(rootKey);
9991
+ }
9992
+ /**
9993
+ * @author ddj 2026年10月29号
9994
+ * @description: 从列表移除已解决条目;未解决条目拒绝移除,避免表里看不到却被删掉。
9995
+ * @param cwd 工作区
9996
+ * @param id 条目身份
9997
+ * @param revision 并发校验版本
9998
+ * @returns 是否移除
9999
+ */
10000
+ function removeEntry(cwd, id, revision) {
10001
+ const stored = loadEntries(cwd).find((item) => item.entry.id === id);
10002
+ if (!stored || stored.flags.revision !== revision || !isSettled(stored.entry)) return false;
10003
+ dropEntry(cwd, id);
10004
+ return true;
10005
+ }
10006
+ /**
10007
+ * @author ddj 2026年10月29号
10008
+ * @description: 取错误码用于拒绝原因,没有则回落到消息文本。
10009
+ * @param error 抛出的错误
10010
+ * @returns 原因字符串
10011
+ */
10012
+ function reasonOf(error) {
10013
+ const code = error.code;
10014
+ return typeof code === "string" ? code : String(error);
10015
+ }
10016
+ //#endregion
10017
+ //#region src/shared/pendingReview.ts
10018
+ /** 待审条目在面板列表里的 id 前缀;客户端与 Host 共用这一份口径。 */
10019
+ const PENDING_PREFIX = "pending:";
10020
+ //#endregion
10021
+ //#region src/pendingCompat.ts
10022
+ /** 进程内"已被待审接管"的路径集合;旧写入口守卫只读它,绝不碰数据库。 */
10023
+ const coveredPaths = /* @__PURE__ */ new Set();
10024
+ /**
10025
+ * @author ddj 2026年10月29号
10026
+ * @description: 由条目 id 派生稳定序号,保证轮询期间面板锚点不跳。
10027
+ * @param id 待审条目身份
10028
+ * @returns 正整数序号
10029
+ */
10030
+ function stableSeq(id) {
10031
+ let hash = 2166136261;
10032
+ for (let index = 0; index < id.length; index++) {
10033
+ hash ^= id.charCodeAt(index);
10034
+ hash = Math.imul(hash, 16777619);
8095
10035
  }
10036
+ return Math.abs(hash) % 2147483647 + 1;
10037
+ }
10038
+ /**
10039
+ * @author ddj 2026年10月29号
10040
+ * @description: 统一路径比较口径,消除分隔符与 Windows 大小写差异;远端身份保留原大小写。
10041
+ * @param path 路径
10042
+ * @returns 比较键
10043
+ */
10044
+ function pathKey$1(path) {
10045
+ const unified = path.replace(/\\/g, "/");
10046
+ if (/^[a-z]+:\/\//i.test(unified)) return unified;
10047
+ if (/^[a-z]:\//i.test(unified)) return unified.toLowerCase();
10048
+ return process.platform === "win32" ? unified.toLowerCase() : unified;
10049
+ }
10050
+ /**
10051
+ * @author ddj 2026年10月30号
10052
+ * @description: 判断两个路径是否指同一文件:比较键相同,或一方是另一方的工作区相对写法。
10053
+ * 面板传下来的路径可能是相对路径,而条目里存的是绝对路径。
10054
+ * @param left 路径
10055
+ * @param right 路径
10056
+ * @returns 是否同一文件
10057
+ */
10058
+ function pendingSamePath(left, right) {
10059
+ const a = pathKey$1(left);
10060
+ const b = pathKey$1(right);
10061
+ if (!a || !b) return false;
10062
+ return a === b || a.endsWith("/" + b) || b.endsWith("/" + a);
10063
+ }
10064
+ /**
10065
+ * @author ddj 2026年10月30号
10066
+ * @description: 列表里的条目 id 带待审前缀用于区分通道,内核按原始身份存取,这里统一还原。
10067
+ * @param id 请求里的条目 id
10068
+ * @returns 待审内核使用的原始 id
10069
+ */
10070
+ function pendingRawId(id) {
10071
+ return id.startsWith("pending:") ? id.slice(8) : id;
10072
+ }
10073
+ /**
10074
+ * @author ddj 2026年10月30号
10075
+ * @description: 列表显示用的短路径:工作区内显示相对路径,区外保留原文。
10076
+ * @param cwd 工作区
10077
+ * @param path 条目路径
10078
+ * @returns 显示用路径
10079
+ */
10080
+ function pendingDisplay(cwd, path) {
10081
+ const root = cwd.replace(/[\\/]+$/, "");
10082
+ const unified = path.replace(/\\/g, "/");
10083
+ const base = root.replace(/\\/g, "/");
10084
+ if (base && (unified === base || unified.toLowerCase().startsWith(base.toLowerCase() + "/"))) return unified.slice(base.length + 1);
10085
+ return path;
10086
+ }
10087
+ /**
10088
+ * @author ddj 2026年10月29号
10089
+ * @description: 待审条目转面板列表项。
10090
+ * @param item 待审条目
10091
+ * @param cwd 工作区,用于显示相对路径;缺省显示原始路径
10092
+ * @returns 面板列表项
10093
+ */
10094
+ function pendingItemOf(item, cwd) {
10095
+ return {
10096
+ id: PENDING_PREFIX + item.id,
10097
+ sessionId: item.sessionId,
10098
+ turn: 0,
10099
+ seq: stableSeq(item.id),
10100
+ path: item.path,
10101
+ currentPath: item.path,
10102
+ display: cwd ? pendingDisplay(cwd, item.path) : item.path,
10103
+ added: item.added,
10104
+ deleted: item.deleted,
10105
+ kind: item.oversized ? "oversized" : "text",
10106
+ decision: item.settled ? "accepted" : "pending",
10107
+ revision: item.revision,
10108
+ current: true
10109
+ };
10110
+ }
10111
+ /**
10112
+ * @author ddj 2026年10月29号
10113
+ * @description: 待审详情转面板详情;Revert 映射到面板的 Undo 能力位。
10114
+ * @param detail 待审详情
10115
+ * @param capturedAt 采集时间文案
10116
+ * @returns 面板详情
10117
+ */
10118
+ function pendingDetailOf(detail, capturedAt, cwd) {
10119
+ return {
10120
+ item: pendingItemOf(detail.item, cwd),
10121
+ diff: detail.diff,
10122
+ capturedAt,
10123
+ blocks: detail.blocks,
10124
+ capability: {
10125
+ keep: detail.capability.keep,
10126
+ undo: detail.capability.revert,
10127
+ reason: detail.capability.reason
10128
+ }
10129
+ };
10130
+ }
10131
+ /**
10132
+ * @author ddj 2026年10月29号
10133
+ * @description: 记住/忘记"该路径由待审投影接管"。进程内集合,绝不在旧写入口上查库(避免撞上写事务锁)。
10134
+ * @param cwd 工作区
10135
+ * @param path 路径
10136
+ * @param covered 是否接管
10137
+ * @returns 无
10138
+ */
10139
+ function trackPendingCover(cwd, path, covered) {
10140
+ const key = `${pathKey$1(cwd)}|${pathKey$1(path)}`;
10141
+ if (covered) coveredPaths.add(key);
10142
+ else coveredPaths.delete(key);
10143
+ }
10144
+ /**
10145
+ * @author ddj 2026年10月29号
10146
+ * @description: 判断某路径是否已由待审投影接管;仅读进程内集合,不产生任何 IO。
10147
+ * @param cwd 工作区
10148
+ * @param path 请求路径
10149
+ * @returns 是否已被接管
10150
+ */
10151
+ function pendingCovers(cwd, path) {
10152
+ return coveredPaths.has(`${pathKey$1(cwd)}|${pathKey$1(path)}`);
10153
+ }
10154
+ /**
10155
+ * @author ddj 2026年10月29号
10156
+ * @description: 合并官方页与待审页:待审覆盖的路径从官方结果中剔除,其余原样保留。
10157
+ * @param official 官方页
10158
+ * @param pending 待审页
10159
+ * @param cwd 工作区,用于把待审行显示成相对路径
10160
+ * @returns 合并后的页
10161
+ */
10162
+ function mergedPage(official, pending, cwd) {
10163
+ const covered = new Set(pending.items.map((item) => pathKey$1(item.path)));
10164
+ const kept = official.items.filter((item) => !covered.has(pathKey$1(item.currentPath)) && !covered.has(pathKey$1(item.path)));
10165
+ return {
10166
+ items: [...pending.items.map((item) => pendingItemOf(item, cwd)), ...kept],
10167
+ pending: pending.pending + kept.filter((item) => item.decision === "pending").length,
10168
+ nextCursor: official.nextCursor,
10169
+ gaps: official.gaps,
10170
+ ...official.overview ? { overview: official.overview } : {}
10171
+ };
8096
10172
  }
8097
10173
  //#endregion
8098
10174
  //#region src/search/query.ts
@@ -13152,15 +15228,67 @@ function reviewHandlers(ctx, registry) {
13152
15228
  await bucketOf(registry, ctx, scope.cwd);
13153
15229
  return scope.cwd;
13154
15230
  }
15231
+ /**
15232
+ * @private
15233
+ * @author ddj 2026年10月30号
15234
+ * @description: 读取待审页;待审库尚未建立或迁移失败时按空页处理。
15235
+ * 待审投影是增量能力,不能因为它让官方列表整体不可用。
15236
+ * @param cwd 工作区
15237
+ * @param sessionId 请求会话
15238
+ * @returns 待审页(失败为空页)
15239
+ */
15240
+ function pendingPageOf(cwd, sessionId) {
15241
+ try {
15242
+ return loadPage(cwd, sessionId);
15243
+ } catch {
15244
+ return {
15245
+ items: [],
15246
+ pending: 0,
15247
+ nextCursor: null,
15248
+ gaps: []
15249
+ };
15250
+ }
15251
+ }
13155
15252
  return {
13156
15253
  "edrv.review.list": async (args) => {
15254
+ const cwd = await workspace(args.sessionId);
15255
+ const official = reviewPage(cwd, args.cursor, args.history, args.path);
15256
+ const pending = pendingPageOf(cwd, args.sessionId);
15257
+ if (args.history) return {
15258
+ ok: true,
15259
+ page: official
15260
+ };
15261
+ if (args.path !== void 0) {
15262
+ const key = args.path;
15263
+ const own = pending.items.filter((item) => pendingSamePath(item.path, key)).map((item) => pendingItemOf(item, cwd));
15264
+ const history = own.length ? official.items.filter((item) => !pendingSamePath(item.path, key)) : official.items;
15265
+ return {
15266
+ ok: true,
15267
+ page: {
15268
+ ...official,
15269
+ items: [...own, ...history]
15270
+ }
15271
+ };
15272
+ }
13157
15273
  return {
13158
15274
  ok: true,
13159
- page: reviewPage(await workspace(args.sessionId), args.cursor, args.history, args.path)
15275
+ page: mergedPage(official, pending, cwd)
13160
15276
  };
13161
15277
  },
13162
15278
  "edrv.review.detail": async (args) => {
13163
- const detail = reviewDetail(await workspace(args.sessionId), args.id);
15279
+ const cwd = await workspace(args.sessionId);
15280
+ if (args.id.startsWith("pending:")) {
15281
+ const detail = loadDetail(cwd, args.id.slice(8));
15282
+ if (!detail) return {
15283
+ ok: false,
15284
+ error: "待审条目不存在"
15285
+ };
15286
+ return {
15287
+ ok: true,
15288
+ detail: pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd)
15289
+ };
15290
+ }
15291
+ const detail = reviewDetail(cwd, args.id);
13164
15292
  return detail ? {
13165
15293
  ok: true,
13166
15294
  detail
@@ -13171,6 +15299,25 @@ function reviewHandlers(ctx, registry) {
13171
15299
  },
13172
15300
  "edrv.review.decide": async (args) => {
13173
15301
  const cwd = await liveWorkspace(args.sessionId);
15302
+ if (args.id.startsWith("pending:")) {
15303
+ const result = await applyDecide(ctx.get("fs"), cwd, {
15304
+ sessionId: args.sessionId,
15305
+ id: args.id.slice(8),
15306
+ revision: args.revision,
15307
+ operationId: args.operationId,
15308
+ decision: args.decision,
15309
+ scope: args.scope === "hunk" ? "block" : "file",
15310
+ ...args.blockId === void 0 ? {} : { blockId: args.blockId }
15311
+ });
15312
+ if (!result.ok) return {
15313
+ ok: false,
15314
+ error: result.reason
15315
+ };
15316
+ return {
15317
+ ok: true,
15318
+ revision: result.flags.revision
15319
+ };
15320
+ }
13174
15321
  try {
13175
15322
  return {
13176
15323
  ok: true,
@@ -13186,6 +15333,179 @@ function reviewHandlers(ctx, registry) {
13186
15333
  };
13187
15334
  }
13188
15335
  //#endregion
15336
+ //#region src/pendingRpc.ts
15337
+ /**
15338
+ * @author ddj 2026年10月29号
15339
+ * @description: 待审投影处理器。读路径允许跨实例冷读,写路径必须本端 live 会话。
15340
+ * @param ctx Host 上下文
15341
+ * @param registry 工作区记录桶缓存
15342
+ * @returns 待审相关处理器表
15343
+ */
15344
+ function pendingHandlers(ctx, registry) {
15345
+ /** @private @author ddj 2026年10月29号 @param sessionId 请求作用域 @returns 工作区 */
15346
+ async function workspace(sessionId) {
15347
+ const scope = await resolveScope(ctx, sessionId);
15348
+ if ("err" in scope) throw new Error("会话不存在或没有工作区");
15349
+ await bucketOf(registry, ctx, scope.cwd);
15350
+ return scope.cwd;
15351
+ }
15352
+ /** @private @author ddj 2026年10月29号 @param sessionId 请求作用域 @returns 本端 live 会话的工作区 */
15353
+ async function liveWorkspace(sessionId) {
15354
+ const cwd = await workspace(sessionId);
15355
+ if (!sessionOf(ctx, sessionId)) throw new Error("写入要求本端活跃会话");
15356
+ return cwd;
15357
+ }
15358
+ /**
15359
+ * @private
15360
+ * @author ddj 2026年10月30号
15361
+ * @description: 面板列表里的条目 id 带待审前缀(用于和官方条目区分),
15362
+ * 而待审内核按原始身份存取;各处理器统一走 pendingRawId 剥掉前缀。
15363
+ * @param id 请求里的条目 id
15364
+ * @returns 待审内核使用的原始 id
15365
+ */
15366
+ const rawId = pendingRawId;
15367
+ return {
15368
+ "edrv.pending.list": async (args) => {
15369
+ const cwd = await workspace(args.sessionId);
15370
+ const page = loadPage(cwd, args.sessionId);
15371
+ for (const item of page.items) trackPendingCover(cwd, item.path, true);
15372
+ return {
15373
+ ok: true,
15374
+ page
15375
+ };
15376
+ },
15377
+ "edrv.pending.detail": async (args) => {
15378
+ const detail = loadDetail(await workspace(args.sessionId), rawId(args.id));
15379
+ return detail ? {
15380
+ ok: true,
15381
+ detail
15382
+ } : {
15383
+ ok: false,
15384
+ error: "待审条目不存在"
15385
+ };
15386
+ },
15387
+ "edrv.pending.decide": async (args) => {
15388
+ const cwd = await liveWorkspace(args.sessionId);
15389
+ try {
15390
+ const id = rawId(args.id);
15391
+ const result = await applyDecide(ctx.get("fs"), cwd, {
15392
+ ...args,
15393
+ id
15394
+ }, void 0, policyOf(ctx, sessionOf(ctx, args.sessionId)));
15395
+ if (!result.ok) return {
15396
+ ok: false,
15397
+ error: result.reason
15398
+ };
15399
+ const detail = loadDetail(cwd, id);
15400
+ if (!detail) return {
15401
+ ok: false,
15402
+ error: "待审条目不存在"
15403
+ };
15404
+ return {
15405
+ ok: true,
15406
+ item: detail.item,
15407
+ wrote: result.wrote
15408
+ };
15409
+ } catch (error) {
15410
+ return {
15411
+ ok: false,
15412
+ error: error instanceof Error ? error.message : String(error)
15413
+ };
15414
+ }
15415
+ },
15416
+ "edrv.pending.remove": async (args) => {
15417
+ const cwd = await liveWorkspace(args.sessionId);
15418
+ const id = rawId(args.id);
15419
+ const path = loadEntries(cwd).find((stored) => stored.entry.id === id)?.entry.path;
15420
+ const removed = removeEntry(cwd, id, args.revision);
15421
+ if (removed && path) trackPendingCover(cwd, path, false);
15422
+ return {
15423
+ ok: true,
15424
+ removed
15425
+ };
15426
+ },
15427
+ "edrv.pending.history": async (args) => {
15428
+ const cwd = await liveWorkspace(args.sessionId);
15429
+ try {
15430
+ const result = await undoDecide(ctx.get("fs"), cwd, args, void 0, policyOf(ctx, sessionOf(ctx, args.sessionId)));
15431
+ if (!result.ok) return {
15432
+ ok: false,
15433
+ error: result.reason
15434
+ };
15435
+ return {
15436
+ ok: true,
15437
+ item: result.item,
15438
+ wrote: result.wrote
15439
+ };
15440
+ } catch (error) {
15441
+ return {
15442
+ ok: false,
15443
+ error: error instanceof Error ? error.message : String(error)
15444
+ };
15445
+ }
15446
+ },
15447
+ "edrv.pending.import": async (args) => {
15448
+ const cwd = await liveWorkspace(args.sessionId);
15449
+ const raw = ctx.get("shell");
15450
+ if (!raw) return {
15451
+ ok: false,
15452
+ error: "当前环境没有 shell 能力,无法读取版本库基线"
15453
+ };
15454
+ const policy = policyOf(ctx, sessionOf(ctx, args.sessionId));
15455
+ const shell = {
15456
+ resolve: (request) => raw.resolve(policy ? {
15457
+ ...request,
15458
+ sandboxPolicy: policy
15459
+ } : request),
15460
+ execute: (spec) => raw.execute(spec)
15461
+ };
15462
+ try {
15463
+ const result = await importFromVcs(shell, cwd, args.sessionId, {
15464
+ includeUntracked: args.includeUntracked,
15465
+ path: args.path
15466
+ });
15467
+ const page = loadPage(cwd, args.sessionId);
15468
+ for (const item of page.items) trackPendingCover(cwd, item.path, true);
15469
+ return {
15470
+ ok: true,
15471
+ imported: result.imported,
15472
+ changes: result.changes
15473
+ };
15474
+ } catch (error) {
15475
+ return {
15476
+ ok: false,
15477
+ error: error instanceof Error ? error.message : String(error)
15478
+ };
15479
+ }
15480
+ },
15481
+ "edrv.pending.refresh": async (args) => {
15482
+ const cwd = await liveWorkspace(args.sessionId);
15483
+ const id = rawId(args.id);
15484
+ const stored = loadEntries(cwd).find((entry) => entry.entry.id === id);
15485
+ if (!stored) return {
15486
+ ok: false,
15487
+ error: "待审条目不存在"
15488
+ };
15489
+ const session = sessionOf(ctx, args.sessionId);
15490
+ const fs = ctx.get("fs");
15491
+ const content = await readText(fs, await fs.resolve(id, {
15492
+ cwd,
15493
+ agent: session
15494
+ })).catch(() => void 0);
15495
+ if (content === void 0) return {
15496
+ ok: false,
15497
+ error: "读取失败"
15498
+ };
15499
+ const next = refreshed(stored, content, Date.now());
15500
+ if (next) saveEntry(cwd, next);
15501
+ return {
15502
+ ok: true,
15503
+ item: itemOf(next ?? stored)
15504
+ };
15505
+ }
15506
+ };
15507
+ }
15508
+ //#endregion
13189
15509
  //#region src/missingReview.ts
13190
15510
  /** Missing historical files: frozen, whole-file deletion reviews. */
13191
15511
  const frozen = /* @__PURE__ */ new Map();
@@ -13209,13 +15529,18 @@ function revisionOf(records) {
13209
15529
  }));
13210
15530
  return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
13211
15531
  }
15532
+ /** @private @author ddj 2026年10月30号 @param ctx Host capability. @param target Resolve 结果:宿主路径字符串或不透明 FsTarget。 @returns 平台一致的物理身份;无法还原时返回空串。 */
15533
+ function fileKeyOf(ctx, target) {
15534
+ const path = targetPathOf(ctx, target);
15535
+ return path ? fileKey(path) : "";
15536
+ }
13212
15537
  /** @private @author ddj 2026年10月28号 @param ctx Host capability. @param cwd Workspace. @param bucket Records. @param target Resolved file. @returns Records for the same physical path spelling, oldest first. */
13213
15538
  async function fileRecords(ctx, cwd, bucket, target) {
13214
15539
  const matches = [];
13215
- const identity = fileKey(target);
15540
+ const identity = fileKeyOf(ctx, target);
13216
15541
  for (const record of bucket.values()) {
13217
15542
  if (record.superseded) continue;
13218
- if (fileKey(await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record);
15543
+ if (fileKeyOf(ctx, await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record);
13219
15544
  }
13220
15545
  return matches.sort((left, right) => left.at.localeCompare(right.at));
13221
15546
  }
@@ -13233,10 +15558,11 @@ async function fileAbsent(ctx, cwd, path) {
13233
15558
  }
13234
15559
  /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Live session. @param cwd Workspace. @param bucket Records. @param path Requested file. @returns Frozen complete snapshot; never constructs text from hunks or writes files. @throws Missing evidence or nonmissing target. */
13235
15560
  async function missingReview(ctx, session, cwd, bucket, path) {
13236
- const target = await resolveTargetIn(ctx, cwd, path);
13237
- if (hasOfficial(cwd, path) || hasOfficial(cwd, target)) throw new Error("该文件已有官方审查记录,请使用官方审查入口");
13238
- if (!await fileAbsent(ctx, cwd, target)) throw new Error("文件已重新出现,请刷新");
13239
- const records = await fileRecords(ctx, cwd, bucket, target);
15561
+ const abs = targetPathOf(ctx, await resolveTargetIn(ctx, cwd, path));
15562
+ if (!abs) throw new Error("无法解析目标文件路径");
15563
+ if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error("该文件已有官方审查记录,请使用官方审查入口");
15564
+ if (!await fileAbsent(ctx, cwd, abs)) throw new Error("文件已重新出现,请刷新");
15565
+ const records = await fileRecords(ctx, cwd, bucket, abs);
13240
15566
  const latest = records.at(-1);
13241
15567
  if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error("没有待审历史记录");
13242
15568
  if (!latest || typeof latest.after !== "string") throw new Error("缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件");
@@ -13276,37 +15602,48 @@ async function applyDisk(ctx, item, target, undo) {
13276
15602
  if (absent && operation.restored && !await fs.stat(target)) throw new Error("恢复后的文件再次缺失,未结清记录");
13277
15603
  if (await fs.readText(target) !== item.view.content) throw new Error("恢复内容校验失败,未结清记录");
13278
15604
  }
15605
+ /**
15606
+ * @author ddj 2026年10月29号
15607
+ * @description: 取删除预览对应的文件路径,供服务端判定该路径是否已由待审投影接管。
15608
+ * @param token 冻结预览 token
15609
+ * @returns 文件路径,token 无效时为 undefined
15610
+ */
15611
+ function missingPath(token) {
15612
+ return frozen.get(token)?.view.path;
15613
+ }
13279
15614
  /** @public @author ddj 2026年10月28号 @param ctx Host. @param session Requesting session. @param cwd Workspace. @param bucket Records. @param token Frozen preview token. @param operationId Stable retry ID. @param undo True restores shown snapshot; false keeps deletion. @returns Covered call IDs. @throws Changed evidence, concurrent action or sandbox/IO failure. */
13280
15615
  async function missingDecide(ctx, session, cwd, bucket, token, operationId, undo) {
13281
15616
  const item = frozen.get(token);
13282
15617
  if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error("删除预览已过期,请刷新后重试");
13283
15618
  if (!operationId || operationId.length > 128) throw new Error("缺少有效操作 ID");
13284
15619
  const target = await resolveTargetIn(ctx, cwd, item.view.path);
13285
- const key = JSON.stringify([cwd, fileKey(target)]);
15620
+ const abs = targetPathOf(ctx, target);
15621
+ if (!abs) throw new Error("无法解析目标文件路径");
15622
+ const key = JSON.stringify([cwd, fileKey(abs)]);
13286
15623
  if (locks.has(key)) throw new Error("该文件正在处理,请稍后重试");
13287
15624
  locks.add(key);
13288
15625
  item.session = session;
13289
15626
  try {
13290
- return await commitMissing(ctx, item, bucket, target, operationId, undo);
15627
+ return await commitMissing(ctx, item, bucket, target, abs, operationId, undo);
13291
15628
  } finally {
13292
15629
  locks.delete(key);
13293
15630
  }
13294
15631
  }
13295
- /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
13296
- async function commitMissing(ctx, item, bucket, target, operationId, undo) {
15632
+ /** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param abs Host path of the target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
15633
+ async function commitMissing(ctx, item, bucket, target, abs, operationId, undo) {
13297
15634
  const previous = item.operation;
13298
15635
  if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error("已有不同操作,请刷新后重试");
13299
15636
  if (previous?.done) return item.view.callIds;
13300
- if (hasOfficial(item.cwd, target) || hasOfficial(item.cwd, item.view.path)) throw new Error("该文件已有官方审查记录,请刷新");
13301
- if (revisionOf(await fileRecords(ctx, item.cwd, bucket, target)) !== item.revision) throw new Error("历史记录已变化,请刷新删除预览");
15637
+ if (hasOfficial(item.cwd, abs) || hasOfficial(item.cwd, item.view.path)) throw new Error("该文件已有官方审查记录,请刷新");
15638
+ if (revisionOf(await fileRecords(ctx, item.cwd, bucket, abs)) !== item.revision) throw new Error("历史记录已变化,请刷新删除预览");
13302
15639
  item.operation ??= {
13303
15640
  id: operationId,
13304
15641
  undo,
13305
15642
  restored: false,
13306
15643
  done: false
13307
15644
  };
13308
- await applyDisk(ctx, item, target, undo);
13309
- const current = await fileRecords(ctx, item.cwd, bucket, target);
15645
+ await applyDisk(ctx, item, abs, undo);
15646
+ const current = await fileRecords(ctx, item.cwd, bucket, abs);
13310
15647
  if (revisionOf(current) !== item.revision) throw new Error("历史记录已变化,未结清记录,请刷新");
13311
15648
  const pending = current.filter((record) => item.view.callIds.includes(record.callId));
13312
15649
  const decisions = pending.map((record) => record.decisions);
@@ -13364,6 +15701,18 @@ function recView(record, ws = null) {
13364
15701
  ...ws ? { displayPath: remoteDisplayOf(record.path, ws) ?? void 0 } : {}
13365
15702
  };
13366
15703
  }
15704
+ /**
15705
+ * 旧写入口守卫:路径已由待审累计基线接管时一律拒绝,避免两套权威同时写同一文件。
15706
+ * @author ddj 2026年10月29号
15707
+ * @param cwd 工作区
15708
+ * @param path 请求涉及的路径(可为空表示无法定位)
15709
+ * @returns 拒绝原因,未接管时为 null
15710
+ */
15711
+ function legacyBlocked(cwd, path) {
15712
+ if (!path) return null;
15713
+ if (!pendingCovers(cwd, path)) return null;
15714
+ return "该文件已由待审累计基线接管,请使用面板 Keep/Revert(edrv.review.decide)";
15715
+ }
13367
15716
  /** 会话/工作区公共前置:返回 {session,cwd} 或错误文案。 */
13368
15717
  async function requireSession(ctx, sessionId) {
13369
15718
  const session = sessionOf(ctx, sessionId);
@@ -13846,6 +16195,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
13846
16195
  ...svnHandlers ?? {},
13847
16196
  ...dapHandlers ?? {},
13848
16197
  ...reviewHandlers(ctx, registry),
16198
+ ...pendingHandlers(ctx, registry),
13849
16199
  "edrv.list": async (args) => {
13850
16200
  const start = performance.now();
13851
16201
  let staleMs = 0;
@@ -13937,6 +16287,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
13937
16287
  error: sc.err
13938
16288
  };
13939
16289
  const bucket = await bucketOf(registry, ctx, sc.cwd);
16290
+ const blocked = legacyBlocked(sc.cwd, bucket.get(args.callId)?.path);
16291
+ if (blocked) return {
16292
+ ok: false,
16293
+ error: blocked
16294
+ };
13940
16295
  const item = (await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{
13941
16296
  callId: args.callId,
13942
16297
  scope: args.scope,
@@ -13959,6 +16314,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
13959
16314
  error: sc.err
13960
16315
  };
13961
16316
  const bucket = await bucketOf(registry, ctx, sc.cwd);
16317
+ const blocked = legacyBlocked(sc.cwd, bucket.get(args.callId)?.path);
16318
+ if (blocked) return {
16319
+ ok: false,
16320
+ error: blocked
16321
+ };
13962
16322
  const item = (await applyDecisions(ctx, sc.session, sc.cwd, bucket, [{
13963
16323
  callId: args.callId,
13964
16324
  scope: args.scope,
@@ -13980,6 +16340,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
13980
16340
  ok: false,
13981
16341
  error: sc.err
13982
16342
  };
16343
+ const blocked = legacyBlocked(sc.cwd, args.path);
16344
+ if (blocked) return {
16345
+ ok: false,
16346
+ error: blocked
16347
+ };
13983
16348
  try {
13984
16349
  const bucket = await bucketOf(registry, ctx, sc.cwd);
13985
16350
  if (!await fileAbsent(ctx, sc.cwd, args.path)) return {
@@ -14003,6 +16368,11 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
14003
16368
  ok: false,
14004
16369
  error: sc.err
14005
16370
  };
16371
+ const blocked = legacyBlocked(sc.cwd, missingPath(args.token));
16372
+ if (blocked) return {
16373
+ ok: false,
16374
+ error: blocked
16375
+ };
14006
16376
  try {
14007
16377
  const bucket = await bucketOf(registry, ctx, sc.cwd);
14008
16378
  return {
@@ -14023,9 +16393,15 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
14023
16393
  error: sc.err
14024
16394
  };
14025
16395
  const bucket = await bucketOf(registry, ctx, sc.cwd);
16396
+ const items = Array.isArray(args.items) ? args.items : [];
16397
+ const blocked = items.map((item) => legacyBlocked(sc.cwd, bucket.get(item.callId)?.path)).find((reason) => reason !== null);
16398
+ if (blocked) return {
16399
+ ok: false,
16400
+ error: blocked
16401
+ };
14026
16402
  return {
14027
16403
  ok: true,
14028
- results: await applyDecisions(ctx, sc.session, sc.cwd, bucket, Array.isArray(args.items) ? args.items : [])
16404
+ results: await applyDecisions(ctx, sc.session, sc.cwd, bucket, items)
14029
16405
  };
14030
16406
  },
14031
16407
  /** @author ddj 2026年10月22号 @param args Read-only request. @returns Bounded official range read, or unsupported on older providers; never bypasses Host IO. */
@@ -26039,6 +28415,16 @@ function attachRuntime(ctx, runtime) {
26039
28415
  evidence.persist(cwd, snapshot);
26040
28416
  return true;
26041
28417
  });
28418
+ const pending = installPendingCapture(ctx, (write) => {
28419
+ try {
28420
+ trackPendingCover(write.cwd, write.path, true);
28421
+ } catch {}
28422
+ try {
28423
+ recordWrite(write);
28424
+ } catch (error) {
28425
+ log.warn("待审差异落库失败:" + String(error));
28426
+ }
28427
+ });
26042
28428
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
26043
28429
  function onResult(exec, result) {
26044
28430
  captureToolResult(ctx, runtime.registry, exec, result);
@@ -26058,6 +28444,7 @@ function attachRuntime(ctx, runtime) {
26058
28444
  async function cleanup() {
26059
28445
  await stopCapture();
26060
28446
  evidence.dispose();
28447
+ pending.dispose();
26061
28448
  runtime.fileVersions.dispose();
26062
28449
  closeArchiveDbs();
26063
28450
  disposeAllServers();