dsh-gokifu-compact 1.11.6 → 1.11.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,87 @@
1
1
  # 更新记录
2
2
 
3
+ ## 1.11.8 — 2026-10-31
4
+
5
+ ### sidecar 现在能**回灌**:归档掉的悬空边可以放回图里
6
+
7
+ 1.11.1 起 `migrate-dangling --apply` 把 settled residue 归档到 `graph.dangling.json`(sidecar),
8
+ **但一直没有回灌工具** —— 报告里写「字段齐全、可回灌」,实际只能靠人手写 JSON。这一版把这条路补完。
9
+
10
+ **新增(R14 `src/rooms/migrate.mjs`)**
11
+
12
+ | 导出 | 作用 |
13
+ |---|---|
14
+ | `readSidecar(file)` | 读 + 校验:`format` / `version` / **非空 `edges`** / 每条边必须有 `id` / 会话匹配;**坏文件一律拒绝且不动图** |
15
+ | `mergeSidecar(graph, sidecar, opts)` | 按 `edge.id` 合并;**同 id 跳过、不覆盖**;保留 sidecar 原字段;**原子写 `graph.json` + 重建 `index.md`** |
16
+ | `mergeMany(target, opts)` | 目标可以是**文件或目录**;批量 |
17
+ | `mergeBackupDir(opts)` | 回灌前先备份 `graph.json` / `index.md` 并落 `MERGE.json` |
18
+
19
+ - **默认 dry-run**:只有 `--apply` 才写盘;**幂等** —— 二次 merge 0 条命中且**不写盘**。
20
+ - CLI:`--merge-sidecar=<file|dir> [--apply] [--backup-dir=] [--json]`;有错误 **exit 1**。
21
+
22
+ **CLI 摘要(`scripts/migrate-dangling.mjs`)**
23
+
24
+ - 修掉 `main()` 里 merge 分支的**重复执行链**(只留 `mergeMany`,错误 exit 1),人类摘要移到输出链。
25
+ - 无 `--json` 时打印:标题 / target / `sessions|merged|skipped` / 逐会话行 / backup 目录 / errors / dry-run 或已回灌提示;
26
+ **`--json` 仍是纯 JSON**(可 `JSON.parse`)。
27
+ - `mergeMany`:目标不存在时**直接报「目标不存在」**(不再默默去读 `./graph.json`)。
28
+
29
+ **测试 +7**(486 → 493)
30
+
31
+ - M1 回灌与备份 · M2 幂等 · M3 **五类拒绝**(format/version/空 edges/边无 id/会话不匹配)· M4 同 id 不覆盖 · M5 `mergeMany` 批量
32
+ - C1 **真跑 CLI 六态**(人类摘要 / `--json` / apply 备份 / 幂等 / 坏 version / 目标不存在)· C2 结构回归(merge 分支恰好 2 处)
33
+ - 变异自检:覆盖已存在边 / dry-run 写盘 / 不校验 version / 执行链重复分支 / `--json` 走人类摘要 / 删人类摘要分支 —— 逐条红
34
+
35
+ ### 验证
36
+
37
+ - 全量:**493 passed / 0 skipped / 0 failed**(37 个测试文件)
38
+ - `migrate` **21/0** · `rooms` 15/0 · `verify-install` **16/16**
39
+ - `npm pack --dry-run --json`:**71 文件**(数量不变;只改 `src/` `scripts/` `test/`)
40
+
41
+ **未验证**
42
+
43
+ - 回灌**没有对真实库跑过** —— 全部证据来自夹具;真实 `sessions/**` 的 apply 仍需主人授权 + 停插件窗口。
44
+ - 回灌只按 `edge.id` 去重:**同一个「边」若在 sidecar 里 id 不同(历史重复归档),会被当成两条各插一次**。
45
+
46
+ ## 1.11.7 — 2026-10-31
47
+
48
+ ### 真实测试与门禁都不再「靠输出猜」
49
+
50
+ **问题**:两个门禁的判据都在**猜**,而不是**问退出码**。
51
+
52
+ 1. `test/all.mjs` 用正则 `/failed|✗/` 扫子进程输出判失败 —— 测试名里可能**字面含 `✗`**
53
+ (例如 `✓ S10c-3 manifestError 进 ✗ 详情…`)⇒ **把绿当红**;反过来,子进程真失败但输出没命中关键字 ⇒ **漏报**。
54
+ 2. `test/real-fold-trigger.mjs` 无论 4/5 还是 5/5 **都 exit 0** ⇒ 它作为门禁时几乎没有意义。
55
+
56
+ **改法**
57
+
58
+ | 文件 | 变更 |
59
+ |---|---|
60
+ | `test/all.mjs` | 删掉 `/failed\|✗/` 正则判据,**只按子进程退出码**;**超时 / 起不来 / 无退出码一律算红**(不许当成「没输出=没事」);导出 `STEPS` / `stepPassed` / `runStep` / `allPassed` 供单测直测 |
61
+ | `test/real-fold-trigger.mjs` | **加退出码**:4/5 ⇒ `exit 1`;依赖未提取(跑不了)⇒ `exit 2` 表示「未运行」而不是「通过」 |
62
+ | 同上 · ⑤ 溢出自救 | 没有可压内容时记 **N/A**(不计失败)—— 先注入新内容让它有东西可压;⑤ 判红不再是「环境没给料」的假失败 |
63
+ | 同上 · usage 对账 | 打印模型返回的 `usage`(**prompt / completion / total / calls**)并累加落盘,真实调用的成本可对账 |
64
+ | 同上 · 预清理 | 跑前**清空 `graphs-trigger`** —— 旧状态会让 ② 的 growth gate 误拦,**第二次跑必红** |
65
+
66
+ **新增 `test/test-gate.test.mjs`(5 条)**:`exit 0` 但输出含 `✗` **不得**误红 / 非 0 **必红** /
67
+ 起不来 · 超时 **必红** / 4 通过 1 失败 **必败** / 结构回归(`STEPS` 与导出面)。
68
+
69
+ **变异自检**:`stepPassed` 恒 `true` ⇒ TG-2/TG-3 红;判据退回正则 ⇒ TG-5 红。
70
+
71
+ ### 验证
72
+
73
+ - 全量:**486 passed / 0 skipped / 0 failed**(37 个测试文件;481 + 5)
74
+ - `node test/all.mjs` → **exit 0**(5 个步骤全绿:单测 / 挂载校验 / 真实模块 / 真实事务 / 性能基准)
75
+ - `node test/real-fold-trigger.mjs` → **5/5 exit 0**,usage 已打印
76
+ - `verify-install` **16/16** · `bench 2000` **351 tokens**
77
+ - `npm pack --dry-run --json`:**71 文件**(数量不变;`test/` 不进包)
78
+
79
+ **未验证**
80
+
81
+ - `real-fold-trigger` 依赖**真实模型**(走 magpie `127.0.0.1:3425`)与已提取的模块目录;缺依赖时它会 **exit 2(未运行)**,
82
+ 这是有意的语义 —— 但也就意味着**它在别的机器上可能根本不跑**,不能当「任何环境都能复现」的证据。
83
+ - `all.mjs` 的「超时必红」用 300s 默认超时,**没有真的等满 300s 去验证**(用的是注入假 `exec` 的单测)。
84
+
3
85
  ## 1.11.6 — 2026-10-31
4
86
 
5
87
  ### tier-2 解耦:checkpoint 的常驻成本不再随 fold 次数线性涨
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-GOkifu-compact
2
2
 
3
- > 当前版本:**`1.11.6`** · npm [`dsh-gokifu-compact`](https://www.npmjs.com/package/dsh-gokifu-compact) · [GitHub Releases](https://github.com/MYCF711/dsh-GOkifu-compact/releases) · 更新记录见 [CHANGELOG.md](CHANGELOG.md)
3
+ > 当前版本:**`1.11.8`** · npm [`dsh-gokifu-compact`](https://www.npmjs.com/package/dsh-gokifu-compact) · [GitHub Releases](https://github.com/MYCF711/dsh-GOkifu-compact/releases) · 更新记录见 [CHANGELOG.md](CHANGELOG.md)
4
4
 
5
5
  **DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 351 tokens 的热清单」。**
6
6
 
@@ -8,7 +8,7 @@
8
8
  热清单 351 tokens (2000 边合成夹具 · L5 降级档 · 2026-10-30/10-31 两次实测同值;旧版全量 28,800)
9
9
  失败保留 8 / 8 (历史口径 †;官方 8 段模板 4.67 / 8)
10
10
  清单开销 未复现/待测 (原载 0.2% = 历史单会话 v1.3.1 后实测;无复跑命令、无对照组;源 evidence/真实计费核对.md)
11
- 验证 481 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
11
+ 验证 493 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
12
12
  ```
13
13
 
14
14
  GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
@@ -1,31 +1,51 @@
1
1
  {
2
- "generatedAt": "2026-10-28T07:54:08.545Z",
2
+ "generatedAt": "2026-10-31T07:57:46.306Z",
3
3
  "model": "group/deepseek-v4.1-flash1017",
4
4
  "window": 200000,
5
5
  "threshold": 90000,
6
6
  "results": [
7
7
  {
8
8
  "step": "below-threshold",
9
- "folded": false
9
+ "folded": false,
10
+ "status": "ok"
10
11
  },
11
12
  {
12
13
  "step": "over-threshold",
13
- "folded": false,
14
+ "folded": true,
14
15
  "before": 25,
15
- "after": 25,
16
- "summaryChars": 0
16
+ "after": 6,
17
+ "summaryChars": 368,
18
+ "status": "ok"
17
19
  },
18
20
  {
19
21
  "step": "growth-gate",
20
- "folded": false
22
+ "folded": false,
23
+ "status": "ok"
21
24
  },
22
25
  {
23
26
  "step": "second-fold",
24
- "folded": false
27
+ "folded": true,
28
+ "status": "ok"
25
29
  },
26
30
  {
27
31
  "step": "overflow",
28
- "folded": true
32
+ "folded": true,
33
+ "injected": 6,
34
+ "compressibleTail": 7,
35
+ "status": "ok"
29
36
  }
30
- ]
37
+ ],
38
+ "summary": {
39
+ "ok": 5,
40
+ "na": 0,
41
+ "fail": 0,
42
+ "total": 5
43
+ },
44
+ "usage": {
45
+ "calls": 3,
46
+ "prompt": 1534,
47
+ "completion": 2738,
48
+ "total": 4272,
49
+ "empty": 0
50
+ }
31
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gokifu-compact",
3
- "version": "1.11.6",
3
+ "version": "1.11.8",
4
4
  "description": "DSH context-compression backend with a go game record (kifu) DAG: node = position, edge = attempted move + result. Incremental fold (each span summarized once), failure memory that survives compaction, transposition detection (chess-engine style), and a tiny hot ledger rebuilt every step.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -11,6 +11,8 @@
11
11
  * node scripts/migrate-dangling.mjs --apply # 真执行(先整目录备份;120s 新鲜度守卫)
12
12
  * node scripts/migrate-dangling.mjs --verify --backup=<dir> # 只读校验
13
13
  * node scripts/migrate-dangling.mjs --rollback=<dir> # 从备份逐字恢复
14
+ * node scripts/migrate-dangling.mjs --merge-sidecar=<file|dir> # 回灌 sidecar(默认 dry-run)
15
+ * node scripts/migrate-dangling.mjs --merge-sidecar=<file|dir> --apply # 真回灌(备份到 --backup-dir)
14
16
  * --root=<sessionsRoot> --backup-dir=<dir> --now=<epochMs>
15
17
  *
16
18
  * 退出码:0 正常;1 校验失败/执行失败;2 参数错误。
@@ -18,13 +20,13 @@
18
20
 
19
21
  import nodeFs from "node:fs";
20
22
  import nodePath from "node:path";
21
- import { dryRun, apply, verify, rollback, sessionsRoot, backupsRoot } from "../src/rooms/migrate.mjs";
23
+ import { dryRun, apply, verify, rollback, mergeMany, sessionsRoot, backupsRoot } from "../src/rooms/migrate.mjs";
22
24
 
23
25
  function parseArgs(argv) {
24
- const opts = { mode: "dry-run", json: false, force: false, only: null, root: sessionsRoot(), backupRoot: backupsRoot(), backupDir: "", now: Date.now() };
26
+ const opts = { mode: "dry-run", json: false, force: false, only: null, root: sessionsRoot(), backupRoot: backupsRoot(), backupDir: "", mergeTarget: "", now: Date.now() };
25
27
  for (const arg of argv) {
26
28
  if (arg === "--dry-run") opts.mode = "dry-run";
27
- else if (arg === "--apply") opts.mode = "apply";
29
+ else if (arg === "--apply") { opts.applyMerge = true; if (!opts.mergeTarget) opts.mode = "apply"; }
28
30
  else if (arg === "--force") opts.force = true;
29
31
  else if (arg === "--verify") opts.mode = "verify";
30
32
  else if (arg === "--json") opts.json = true;
@@ -34,6 +36,13 @@ function parseArgs(argv) {
34
36
  else if (arg.startsWith("--rollback=")) {
35
37
  opts.mode = "rollback";
36
38
  opts.backupDir = arg.slice("--rollback=".length);
39
+ } else if (arg.startsWith("--merge-sidecar=")) {
40
+ opts.mode = "merge";
41
+ opts.mergeTarget = arg.slice("--merge-sidecar=".length);
42
+ if (opts.mergeTarget.length === 0) {
43
+ console.error("--merge-sidecar= 需要一个 sidecar 文件或会话目录");
44
+ process.exit(2);
45
+ }
37
46
  } else if (arg.startsWith("--sessions=")) {
38
47
  opts.only = arg.slice("--sessions=".length).split(",").map((x) => x.trim()).filter(Boolean);
39
48
  } else if (arg.startsWith("--now=")) opts.now = Number(arg.slice("--now=".length));
@@ -55,6 +64,8 @@ function printHelp() {
55
64
  node scripts/migrate-dangling.mjs --apply [--force] # 执行(整目录备份 + 120s 新鲜度守卫)
56
65
  node scripts/migrate-dangling.mjs --verify --backup=<backupDir> # 只读校验
57
66
  node scripts/migrate-dangling.mjs --rollback=<backupDir> # 从备份恢复
67
+ node scripts/migrate-dangling.mjs --merge-sidecar=<file|dir> [--apply] [--backup-dir=<dir>]
68
+ # 回灌 sidecar:默认 dry-run(只报告将合并几条);--apply 才写(先备份 graph.json/index.md)
58
69
 
59
70
  默认路径:<DSH_HOME>/chess-context/sessions ;备份:<DSH_HOME>/chess-context/migrate-backups`);
60
71
  }
@@ -68,6 +79,9 @@ function main() {
68
79
  result = dryRun(opts.root, { now: opts.now, only: opts.only });
69
80
  } else if (opts.mode === "apply") {
70
81
  result = apply(opts.root, { now: opts.now, backupRoot: opts.backupRoot, only: opts.only, maxAgeMs: opts.force ? 0 : undefined });
82
+ } else if (opts.mode === "merge") {
83
+ result = mergeMany(opts.mergeTarget, { apply: opts.applyMerge, backupRoot: opts.backupRoot, now: opts.now });
84
+ if (!result.ok) code = 1;
71
85
  } else if (opts.mode === "verify") {
72
86
  result = verify(opts.root, { now: opts.now, backupDir: opts.backupDir, only: opts.only });
73
87
  if (!result.ok) code = 1;
@@ -81,6 +95,24 @@ function main() {
81
95
  }
82
96
  if (opts.json) {
83
97
  console.log(JSON.stringify(result, null, 1));
98
+ } else if (opts.mode === "merge") {
99
+ // ★ task-157:merge 的人类摘要(--json 时走上一条分支给 JSON)。
100
+ // 旧版这里的 merge 分支被误插进**执行链**里(与同条件的 dispatch 分支重复),
101
+ // 结果永远不执行 ⇒ 不带 --json 时只能看到兜底的 JSON。
102
+ console.log(`== migrate-dangling ${result.mode} ==`);
103
+ console.log(`target: ${result.target}`);
104
+ console.log(`sessions: ${result.sessions}|merged ${result.merged}|skipped ${result.skipped}`);
105
+ for (const one of result.perSession ?? []) {
106
+ const bad = one.errors.length ? ` errors ${one.errors.length}` : "";
107
+ console.log(` · ${one.session}: merged ${one.merged} / skipped ${one.skipped}${bad}`);
108
+ for (const err of one.errors) console.log(` ✗ ${err}`);
109
+ }
110
+ if (result.backupDirs.length) console.log(`backup: ${result.backupDirs.join(", ")}`);
111
+ if (result.errors.length) {
112
+ console.log("errors:");
113
+ for (const err of result.errors) console.log(`- ${err}`);
114
+ }
115
+ console.log(opts.applyMerge ? "(已回灌并重建 index.md)" : "(dry-run:什么都没写;加 --apply 才回灌)");
84
116
  } else if (opts.mode === "dry-run") {
85
117
  console.log("== migrate-dangling dry-run ==");
86
118
  console.log(`root: ${result.root}`);
@@ -410,6 +410,210 @@ export function apply(root, { now = Date.now(), backupRoot = backupsRoot(), stam
410
410
  return summary;
411
411
  }
412
412
 
413
+ /* ───────────────────────────── merge(sidecar 回灌) ───────────────────────────── */
414
+
415
+ /**
416
+ * merge 的备份目录:`<backupRoot>/merge-<iso>`(apply 且真的写了才创建)。
417
+ * 与 apply 的整目录备份同根(`<DSH_HOME>/chess-context/migrate-backups`),便于排障时一处找齐。
418
+ */
419
+ export function mergeBackupDir({ backupRoot = backupsRoot(), stamp = new Date() } = {}) {
420
+ const dir = nodePath.join(backupRoot, `merge-${stamp.toISOString().replace(/[:.]/g, "-")}`);
421
+ nodeFs.mkdirSync(dir, { recursive: true });
422
+ return dir;
423
+ }
424
+
425
+ /**
426
+ * 校验 sidecar 并读出边(纯只读,不碰图)。
427
+ * @returns {{ok:boolean, errors:string[], sidecar:any, edges:any[]}}
428
+ */
429
+ export function readSidecar(file, { sessionId = "" } = {}) {
430
+ const errors = [];
431
+ let sidecar = null;
432
+ if (!nodeFs.existsSync(file)) return { ok: false, errors: [`sidecar 不存在:${file}`], sidecar: null, edges: [] };
433
+ try {
434
+ sidecar = JSON.parse(nodeFs.readFileSync(file, "utf8"));
435
+ } catch (error) {
436
+ return { ok: false, errors: [`sidecar 不是合法 JSON(${String(error?.message ?? error)})`], sidecar: null, edges: [] };
437
+ }
438
+ if (sidecar === null || typeof sidecar !== "object") errors.push("sidecar 顶层不是对象");
439
+ if (sidecar?.format !== SIDECAR_FORMAT) errors.push(`sidecar format 不对:${String(sidecar?.format)}(应为 ${SIDECAR_FORMAT})`);
440
+ if (sidecar?.version !== SIDECAR_VERSION) errors.push(`sidecar version 不支持:${String(sidecar?.version)}(应为 ${SIDECAR_VERSION})`);
441
+ if (sessionId && typeof sidecar?.sessionId === "string" && sidecar.sessionId.length > 0 && sidecar.sessionId !== sessionId) {
442
+ errors.push(`sidecar 属于别的会话:${sidecar.sessionId}(当前 ${sessionId})`);
443
+ }
444
+ const edges = Array.isArray(sidecar?.edges) ? sidecar.edges : null;
445
+ if (edges === null) errors.push("sidecar.edges 不是数组");
446
+ else if (edges.length === 0) errors.push("sidecar 里没有边(空边)");
447
+ if (Array.isArray(edges)) {
448
+ edges.forEach((edge, i) => {
449
+ if (edge === null || typeof edge !== "object") errors.push(`edges[${i}] 不是对象`);
450
+ else if (typeof edge.id !== "string" || edge.id.length === 0) errors.push(`edges[${i}] 缺 id`);
451
+ });
452
+ }
453
+ return { ok: errors.length === 0, errors, sidecar, edges: errors.length === 0 ? edges : [] };
454
+ }
455
+
456
+ /**
457
+ * 把一个 sidecar 回灌进会话图(R14)。
458
+ *
459
+ * 语义(与 apply 对称):
460
+ * · 按 **edge.id** 合并;图里已有同 id ⇒ **跳过**(绝不覆盖当前边);
461
+ * · 保留 sidecar 里的原字段(JSON 深拷贝,不重算/不改写);
462
+ * · 有改动才 `updatedAt = now`、原子写 graph.json、**重建 index.md**;
463
+ * · **默认 dry-run**(不写任何文件);`apply:true` 才写,写之前把 graph.json / index.md
464
+ * 备份到 `<backupRoot>/merge-<iso>/`,并落一份 `MERGE.json` 审计;
465
+ * · 幂等:同一 sidecar 回灌第二次 ⇒ `merged:0`、`skipped:N`、不写盘、不建备份。
466
+ *
467
+ * @param {string} dir 会话目录(含 graph.json)
468
+ * @param {{sidecarFile?:string, apply?:boolean, backupRoot?:string, stamp?:Date, now?:number}} [options]
469
+ * @returns {{mode:string, session:string, sidecar:string, ok:boolean, dryRun:boolean, total:number, merged:number, skipped:number, duplicates:number, mergedIds:string[], errors:string[], backupDir:string, graphBytesBefore:number, graphBytesAfter:number, indexRebuilt:boolean}}
470
+ */
471
+ export function mergeSidecar(dir, { sidecarFile = "", apply = false, backupRoot = backupsRoot(), stamp = new Date(), now = Date.now() } = {}) {
472
+ const session = nodePath.basename(dir);
473
+ const file = sidecarFile || nodePath.join(dir, SIDECAR_NAME);
474
+ const result = {
475
+ mode: apply ? "merge-apply" : "merge-dry-run",
476
+ session,
477
+ sidecar: file,
478
+ ok: false,
479
+ dryRun: !apply,
480
+ total: 0,
481
+ merged: 0,
482
+ skipped: 0,
483
+ duplicates: 0,
484
+ mergedIds: [],
485
+ errors: [],
486
+ backupDir: "",
487
+ graphBytesBefore: 0,
488
+ graphBytesAfter: 0,
489
+ indexRebuilt: false,
490
+ };
491
+
492
+ const graphFile = nodePath.join(dir, "graph.json");
493
+ if (!nodeFs.existsSync(graphFile)) {
494
+ result.errors.push(`会话目录里没有 graph.json:${graphFile}`);
495
+ return result;
496
+ }
497
+ const read = readSidecar(file, { sessionId: session });
498
+ if (!read.ok) {
499
+ result.errors.push(...read.errors);
500
+ return result;
501
+ }
502
+ result.total = read.edges.length;
503
+
504
+ let text;
505
+ let g;
506
+ try {
507
+ text = nodeFs.readFileSync(graphFile, "utf8");
508
+ g = fromJSON(text);
509
+ } catch (error) {
510
+ result.errors.push(`graph.json 读不出(${String(error?.message ?? error)})`);
511
+ return result;
512
+ }
513
+ result.graphBytesBefore = Buffer.byteLength(text);
514
+
515
+ const seen = new Set();
516
+ for (const edge of read.edges) {
517
+ if (seen.has(edge.id)) {
518
+ result.duplicates += 1;
519
+ continue;
520
+ }
521
+ seen.add(edge.id);
522
+ if (g.edges && g.edges[edge.id]) {
523
+ result.skipped += 1;
524
+ continue;
525
+ }
526
+ g.edges[edge.id] = JSON.parse(JSON.stringify(edge));
527
+ result.mergedIds.push(edge.id);
528
+ result.merged += 1;
529
+ }
530
+
531
+ if (result.merged === 0) {
532
+ // 幂等:没有新边 ⇒ 一个字节都不写(不建备份、不碰 index.md)
533
+ result.graphBytesAfter = result.graphBytesBefore;
534
+ result.ok = true;
535
+ return result;
536
+ }
537
+
538
+ g.updatedAt = now;
539
+ const next = toJSON(g);
540
+ result.graphBytesAfter = Buffer.byteLength(next);
541
+
542
+ if (!apply) {
543
+ result.ok = true;
544
+ return result;
545
+ }
546
+
547
+ // ① 显式备份(graph.json + index.md);② 原子写 graph.json;③ 重建 index.md;④ MERGE.json 审计
548
+ const backupDir = mergeBackupDir({ backupRoot, stamp });
549
+ result.backupDir = backupDir;
550
+ nodeFs.copyFileSync(graphFile, nodePath.join(backupDir, "graph.json"));
551
+ const indexFile = nodePath.join(dir, "index.md");
552
+ if (nodeFs.existsSync(indexFile)) nodeFs.copyFileSync(indexFile, nodePath.join(backupDir, "index.md"));
553
+ writeFileAtomic(graphFile, next);
554
+ writeFileAtomic(indexFile, renderIndexMd(g));
555
+ result.indexRebuilt = true;
556
+ writeFileAtomic(
557
+ nodePath.join(backupDir, "MERGE.json"),
558
+ JSON.stringify({ ...result, appliedAt: new Date(now).toISOString(), root: dir }, null, 1),
559
+ );
560
+ result.ok = true;
561
+ return result;
562
+ }
563
+
564
+ /**
565
+ * 回灌一批会话(CLI 用):`target` 可以是单个 sidecar 文件、单个会话目录、或会话根目录。
566
+ * 返回汇总 + 每会话结果。任何一条出错 ⇒ `ok:false`(但已成功的仍然写完了,逐条可查)。
567
+ */
568
+ export function mergeMany(target, { apply = false, backupRoot = backupsRoot(), stamp = new Date(), now = Date.now() } = {}) {
569
+ const dirs = [];
570
+ const sidecars = [];
571
+ if (!nodeFs.existsSync(target)) {
572
+ return {
573
+ mode: apply ? "merge-apply" : "merge-dry-run",
574
+ target,
575
+ sessions: 0,
576
+ merged: 0,
577
+ skipped: 0,
578
+ ok: false,
579
+ errors: [`目标不存在:${target}`],
580
+ backupDirs: [],
581
+ perSession: [],
582
+ };
583
+ }
584
+ if (nodeFs.statSync(target).isDirectory()) {
585
+ const direct = nodePath.join(target, SIDECAR_NAME);
586
+ if (nodeFs.existsSync(direct) && nodeFs.existsSync(nodePath.join(target, "graph.json"))) {
587
+ dirs.push(target);
588
+ } else {
589
+ for (const dir of listSessions(target)) {
590
+ if (nodeFs.existsSync(nodePath.join(dir, SIDECAR_NAME))) dirs.push(dir);
591
+ }
592
+ }
593
+ } else {
594
+ sidecars.push(target);
595
+ }
596
+ const perSession = dirs.map((dir) => mergeSidecar(dir, { apply, backupRoot, stamp, now }));
597
+ for (const file of sidecars) {
598
+ // sidecar 文件 → 会话目录 = 它的父目录
599
+ perSession.push(mergeSidecar(nodePath.dirname(file), { sidecarFile: file, apply, backupRoot, stamp, now }));
600
+ }
601
+ const merged = perSession.reduce((n, r) => n + r.merged, 0);
602
+ const skipped = perSession.reduce((n, r) => n + r.skipped, 0);
603
+ const errors = perSession.flatMap((r) => r.errors.map((e) => `${r.session}: ${e}`));
604
+ return {
605
+ mode: apply ? "merge-apply" : "merge-dry-run",
606
+ target,
607
+ sessions: perSession.length,
608
+ merged,
609
+ skipped,
610
+ ok: perSession.length > 0 && errors.length === 0,
611
+ errors,
612
+ backupDirs: perSession.map((r) => r.backupDir).filter(Boolean),
613
+ perSession,
614
+ };
615
+ }
616
+
413
617
  /* ───────────────────────────── verify ───────────────────────────── */
414
618
 
415
619
  /**