dsh-gokifu-compact 1.11.7 → 1.11.9

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,95 @@
1
1
  # 更新记录
2
2
 
3
+ ## 1.11.9 — 2026-10-31
4
+
5
+ ### 发布脚本新增门禁:CHANGELOG 写的「N 个测试文件」必须等于事实
6
+
7
+ **这条门禁上线当天就抓到了真实漂移**:CHANGELOG 从 1.11.5 起一直写着同一个旧数字(**37**),
8
+ 实际早就是 **42** —— 连续几个版本没人对过账(它不影响构建,所以从来不会自己叫)。
9
+
10
+ **新增(`scripts/release-lib.mjs`,纯逻辑、可单测)**
11
+
12
+ | 导出 | 行为 |
13
+ |---|---|
14
+ | `declaredTestFiles(section)` | 读「N 个测试文件」;**没写返回 `null`**;容错 `**42** 个测试文件` / `(42 个测试文件;493 + 7)` / `42个测试文件` |
15
+ | `checkTestFileCount(section, actual)` | **三态**:一致放行 / 不一致给出 `{declared, actual, reason}` / 无声明放行 |
16
+
17
+ **`scripts/release.mjs` 体检 ②b**:当前版本那一节的数字 ≠ `test/*.test.mjs` 实际数 ⇒ **红,`--yes` 也不动手**;
18
+ **没写这句不报错**(允许不声明)。红文案同时给出两个数与「怎么数」(含历史版本数法):
19
+
20
+ ```
21
+ · CHANGELOG 写的「N 个测试文件」与实际 42 个不符 —— 改了测试文件就必须同步这句(或删掉它,脚本不会因此报错)。
22
+ 实际值怎么数:ls test/*.test.mjs | wc -l(看历史版本:git ls-tree -r --name-only <tag>^{commit} test/ | grep -c '\.test\.mjs$')
23
+ ```
24
+
25
+ ### 顺带修掉两个「只在预演期才撞到」的发布脚本缺陷
26
+
27
+ 写这条门禁时跑 dry-run,脚本**直接崩了**,顺藤摸出两个真缺陷(都不影响发版那一刻,但会让「已发版本上再跑一次预演」变成堆栈):
28
+
29
+ 1. **tag 已存在时 dry-run 崩**:`git rev-parse -q --verify refs/tags/x^{commit}` —— Windows 上 `execSync` 走 **cmd.exe**,
30
+ `^` 是转义字符 ⇒ 被吃成 `{commit}`,命令失败抛异常(手动在 Git Bash 里跑同一命令是好的,极难从表面看出)。
31
+ 改用 `git rev-list -n1 refs/tags/<tag>`(无 shell 元字符,annotated / lightweight 都剥到 commit)+ try/catch。
32
+ 2. **「tag 指向别的 commit」误报**:给 `ls-remote` 传**精确 pattern** 时**不会**输出 `^{}` 剥皮行 ⇒ 拿到的是 tag 对象 sha(≠ commit);
33
+ 而且判据原本是「≠ HEAD」,但「发过之后 main 又往前走了」是**正常态**。改为列全部 tag 再在 JS 里过滤剥皮行,
34
+ 判据改成「**既不是 HEAD 也不是 HEAD 的祖先**」(`git merge-base --is-ancestor`)才报。
35
+
36
+ ### 验证
37
+
38
+ - 全量:**498 passed / 0 skipped / 0 failed**(**42 个测试文件**;493 + 5 条 `release-hardening`)
39
+ - `release-hardening` **15/0** · `verify-install` **16/16**
40
+ - `npm pack --dry-run --json`:**71 文件**(`test/` 不进包,数量不变)
41
+ - 三态端到端(临时克隆、版本 9.9.9):声明 37 / 实际 42 ⇒ **exit 1**;**无声明** ⇒ **exit 0**(附跳过说明);声明 42 ⇒ **exit 0**
42
+ - 仓库记录更正:1.11.8 节的「37」已改为「42」并在原处注明「发布时误写;npm 上已发内容无法改」
43
+
44
+ **未验证**
45
+
46
+ - 门禁只校验**当前 `package.json` 版本那一节**;历史各节不追溯。
47
+ - 没把「仓库自洽」做成单测:若在测试套件里断言「当前版本的数字 == 实际数」,每加一个测试文件都会红到下一版
48
+ (已发布那一节是冻结的)⇒ 这条只在**发布时**才对得起来。
49
+
50
+ ## 1.11.8 — 2026-10-31
51
+
52
+ ### sidecar 现在能**回灌**:归档掉的悬空边可以放回图里
53
+
54
+ 1.11.1 起 `migrate-dangling --apply` 把 settled residue 归档到 `graph.dangling.json`(sidecar),
55
+ **但一直没有回灌工具** —— 报告里写「字段齐全、可回灌」,实际只能靠人手写 JSON。这一版把这条路补完。
56
+
57
+ **新增(R14 `src/rooms/migrate.mjs`)**
58
+
59
+ | 导出 | 作用 |
60
+ |---|---|
61
+ | `readSidecar(file)` | 读 + 校验:`format` / `version` / **非空 `edges`** / 每条边必须有 `id` / 会话匹配;**坏文件一律拒绝且不动图** |
62
+ | `mergeSidecar(graph, sidecar, opts)` | 按 `edge.id` 合并;**同 id 跳过、不覆盖**;保留 sidecar 原字段;**原子写 `graph.json` + 重建 `index.md`** |
63
+ | `mergeMany(target, opts)` | 目标可以是**文件或目录**;批量 |
64
+ | `mergeBackupDir(opts)` | 回灌前先备份 `graph.json` / `index.md` 并落 `MERGE.json` |
65
+
66
+ - **默认 dry-run**:只有 `--apply` 才写盘;**幂等** —— 二次 merge 0 条命中且**不写盘**。
67
+ - CLI:`--merge-sidecar=<file|dir> [--apply] [--backup-dir=] [--json]`;有错误 **exit 1**。
68
+
69
+ **CLI 摘要(`scripts/migrate-dangling.mjs`)**
70
+
71
+ - 修掉 `main()` 里 merge 分支的**重复执行链**(只留 `mergeMany`,错误 exit 1),人类摘要移到输出链。
72
+ - 无 `--json` 时打印:标题 / target / `sessions|merged|skipped` / 逐会话行 / backup 目录 / errors / dry-run 或已回灌提示;
73
+ **`--json` 仍是纯 JSON**(可 `JSON.parse`)。
74
+ - `mergeMany`:目标不存在时**直接报「目标不存在」**(不再默默去读 `./graph.json`)。
75
+
76
+ **测试 +7**(486 → 493)
77
+
78
+ - M1 回灌与备份 · M2 幂等 · M3 **五类拒绝**(format/version/空 edges/边无 id/会话不匹配)· M4 同 id 不覆盖 · M5 `mergeMany` 批量
79
+ - C1 **真跑 CLI 六态**(人类摘要 / `--json` / apply 备份 / 幂等 / 坏 version / 目标不存在)· C2 结构回归(merge 分支恰好 2 处)
80
+ - 变异自检:覆盖已存在边 / dry-run 写盘 / 不校验 version / 执行链重复分支 / `--json` 走人类摘要 / 删人类摘要分支 —— 逐条红
81
+
82
+ ### 验证
83
+
84
+ - 全量:**493 passed / 0 skipped / 0 failed**(**42** 个测试文件 —— 发布时误写 37,此处已按实际更正;npm 上 1.11.8 的 CHANGELOG 仍是 37,无法改)
85
+ - `migrate` **21/0** · `rooms` 15/0 · `verify-install` **16/16**
86
+ - `npm pack --dry-run --json`:**71 文件**(数量不变;只改 `src/` `scripts/` `test/`)
87
+
88
+ **未验证**
89
+
90
+ - 回灌**没有对真实库跑过** —— 全部证据来自夹具;真实 `sessions/**` 的 apply 仍需主人授权 + 停插件窗口。
91
+ - 回灌只按 `edge.id` 去重:**同一个「边」若在 sidecar 里 id 不同(历史重复归档),会被当成两条各插一次**。
92
+
3
93
  ## 1.11.7 — 2026-10-31
4
94
 
5
95
  ### 真实测试与门禁都不再「靠输出猜」
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-GOkifu-compact
2
2
 
3
- > 当前版本:**`1.11.7`** · 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.9`** · 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
- 验证 486 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
11
+ 验证 498 项单测(0 skipped) · 真实包 8/8 † · 真实进程 fold 3~6/6 †
12
12
  ```
13
13
 
14
14
  GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gokifu-compact",
3
- "version": "1.11.7",
3
+ "version": "1.11.9",
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}`);
@@ -101,3 +101,68 @@ export function runTransaction(steps, opts = {}) {
101
101
  }
102
102
  return { ok: true, done, failed: null, error: null, aborted: [] }
103
103
  }
104
+
105
+ /**
106
+ * 从 CHANGELOG 某节里读出**所有**「N 个测试文件」的声明。
107
+ * 支持这些写法(本仓历史都用过):
108
+ * `(37 个测试文件)` · `(37 个测试文件;481 + 5)` · `37 个测试文件` · `**42** 个测试文件`
109
+ * @returns {number[]} 没写就是空数组(不是错误)
110
+ */
111
+ export function declaredTestFilesAll(section) {
112
+ const text = String(section ?? "")
113
+ const re = /(\d+)\s*\*{0,2}\s*个测试文件/g
114
+ const out = []
115
+ let m
116
+ while ((m = re.exec(text)) !== null) out.push(Number(m[1]))
117
+ return out
118
+ }
119
+
120
+ /**
121
+ * 从 CHANGELOG 某节里读出「N 个测试文件」的**声明**(第一个)。
122
+ * @returns {number|null} 没写就返回 null(不是错误)
123
+ */
124
+ export function declaredTestFiles(section) {
125
+ const all = declaredTestFilesAll(section)
126
+ return all.length > 0 ? all[0] : null
127
+ }
128
+
129
+ /**
130
+ * 校验「N 个测试文件」的声明与实际发现的 `test/*.test.mjs` 数量是否一致。
131
+ *
132
+ * **三态**(task-160 的契约):
133
+ * · 一致 ⇒ `{ ok: true, declared: N }`
134
+ * · 不一致 ⇒ `{ ok: false, declared: N, reason }` —— 发布必须被拦住
135
+ * · 没有声明 ⇒ `{ ok: true, declared: null }` —— 不报错(允许不写这个数)
136
+ *
137
+ * ⚠ 一节里可能出现**多个**数字(例如正文引用了旧版报错原文)。那种情况按「**全部都要等**」判:
138
+ * 只要出现一个不等于实际的数字就红,并把所有值列出来 —— 否则这段文档自己就是一锅粥,
139
+ * 门禁被自己的举例骗过(1.11.9 起草时真实发生过)。
140
+ *
141
+ * 为什么要有这条:本仓 CHANGELOG 长期写「N 个测试文件」,而 N 从 1.11.5 起就停在 37、
142
+ * 实际已经 42 —— 数字漂了 5 个版本没人发现。用户会拿这个数判断覆盖面。
143
+ *
144
+ * @param {string} section 当前版本那一节的正文
145
+ * @param {number} actual 实际 `test/*.test.mjs` 文件数
146
+ */
147
+ export function checkTestFileCount(section, actual) {
148
+ const all = declaredTestFilesAll(section)
149
+ if (all.length === 0) return { ok: true, declared: null, actual, declaredAll: [] }
150
+ const declared = all[0]
151
+ const bad = all.filter((n) => n !== actual)
152
+ if (bad.length === 0) return { ok: true, declared, actual, declaredAll: all }
153
+ const distinct = [...new Set(all)]
154
+ return {
155
+ ok: false,
156
+ declared,
157
+ actual,
158
+ declaredAll: all,
159
+ reason:
160
+ distinct.length > 1
161
+ ? `CHANGELOG 这一节里出现了多个不一致的「N 个测试文件」:${distinct.join(" / ")}(实际 ${actual} 个)—— ` +
162
+ `一处写对、另一处写错同样会误导;引用旧数字时请用占位符(如「N 个测试文件」),别写真实旧值`
163
+ : `CHANGELOG 写的「${declared} 个测试文件」与实际 ${actual} 个不符 —— ` +
164
+ `改了测试文件就必须同步这句(或删掉它,脚本不会因此报错)。` +
165
+ `实际值怎么数:ls test/*.test.mjs | wc -l(看历史版本:` +
166
+ `git ls-tree -r --name-only <tag>^{commit} test/ | grep -c '\\.test\\.mjs$')`,
167
+ }
168
+ }
@@ -12,6 +12,7 @@
12
12
  //
13
13
  // 体检项(任一不过就退出码 1,且 --yes 也不动手):
14
14
  // ① package.json 有 version ② CHANGELOG 有这一版那一段
15
+ // ②b 「N 个测试文件」的声明 == test/*.test.mjs 实际数(**没写这句不报错**)
15
16
  // ③ 工作区干净 ④ 当前在默认分支
16
17
  // ⑤ remote 是合法 GitHub URL(拒 git+https 之类) ⑥ 远端可达(ls-remote 必须成功)
17
18
  // ⑦ 不落后远端(fetch 必须成功,失败=红,不再吞成 note) ⑧ tag 还没被占
@@ -24,9 +25,9 @@
24
25
  // GITHUB_TOKEN / GH_TOKEN 环境变量。没有也能跑 —— 它会打印手工建 Release 的命令。
25
26
 
26
27
  import { execSync, execFileSync } from "node:child_process"
27
- import { readFileSync, existsSync } from "node:fs"
28
+ import { readFileSync, readdirSync, existsSync } from "node:fs"
28
29
  import { join } from "node:path"
29
- import { classifyRemote, runTransaction } from "./release-lib.mjs"
30
+ import { classifyRemote, runTransaction, checkTestFileCount } from "./release-lib.mjs"
30
31
 
31
32
  const ROOT = process.cwd()
32
33
  const argv = new Set(process.argv.slice(2))
@@ -94,6 +95,20 @@ function changelogSection(v) {
94
95
  const section = version ? changelogSection(version) : ""
95
96
  if (version && !section) bad(`CHANGELOG.md 里没有「## ${version}」那一段 —— 先写更新内容再发版`)
96
97
 
98
+ // ── 「N 个测试文件」的声明必须等于事实 ────────────────────────────────
99
+ // 这条是 task-160 加的:本仓 CHANGELOG 连续 5 个版本写「37 个测试文件」,实际早就是 42
100
+ // —— 数字漂了没人发现,而用户会拿它判断覆盖面。**没写这句不报错**(允许不写)。
101
+ const testDir = join(ROOT, "test")
102
+ const actualTestFiles = existsSync(testDir)
103
+ ? readdirSync(testDir).filter((f) => f.endsWith(".test.mjs")).length
104
+ : 0
105
+ const testFileCheck = checkTestFileCount(section, actualTestFiles)
106
+ if (testFileCheck.declared === null) {
107
+ note(`CHANGELOG 没写「N 个测试文件」⇒ 跳过该数字校验(实际 ${actualTestFiles} 个)`)
108
+ } else if (!testFileCheck.ok) {
109
+ bad(testFileCheck.reason)
110
+ }
111
+
97
112
  /**
98
113
  * 去掉 `> 起因:…` 那段 —— 它写给维护者,不进 Release 正文。
99
114
  * (UPDATING 四:一条一行说的是改了什么,`> 起因` 说的是为什么。用户感知不到后者。)
@@ -183,22 +198,45 @@ let tagCommitLocal = ""
183
198
  let tagCommitRemote = ""
184
199
  if (gh) {
185
200
  tagExistsLocal = shOk(`git rev-parse -q --verify refs/tags/${tag}`)
186
- if (tagExistsLocal) tagCommitLocal = sh(`git rev-parse -q --verify refs/tags/${tag}^{commit}`)
201
+ // ⚠ 不能用 `refs/tags/x^{commit}`:Windows 上 execSync 走 cmd.exe,`^` 是转义字符 ⇒
202
+ // 会被吃成 `refs/tags/x{commit}`,命令失败、脚本崩(2026-10-31 实测:tag 已存在时跑预演直接抛异常)。
203
+ // `rev-list -n1` 对 annotated / lightweight tag 都会剥到 commit,且没有 shell 元字符。
204
+ if (tagExistsLocal) {
205
+ try {
206
+ tagCommitLocal = sh(`git rev-list -n1 refs/tags/${tag}`)
207
+ } catch {
208
+ tagCommitLocal = ""
209
+ }
210
+ }
187
211
  try {
188
- const out = sh(`git ls-remote --tags origin refs/tags/${tag}`)
189
- tagExistsRemote = out.length > 0
190
- // ls-remote 对 annotated tag 会同时给 `<tagobj>\trefs/tags/x` 和 `<commit>\trefs/tags/x^{}`;
191
- // 取 deref 行(`^{}`)的 sha 才是它指向的 commit。
192
- const deref = out.split("\n").find((l) => l.includes(`refs/tags/${tag}^{}`))
193
- const plain = out.split("\n").find((l) => l.endsWith(`refs/tags/${tag}`))
194
- tagCommitRemote = (deref ?? plain ?? "").split(/\s+/)[0] ?? ""
212
+ // ⚠ 不能给 ls-remote 传 `refs/tags/x` 这种精确 pattern:那样**不会**输出 `^{}` 剥皮行,
213
+ // 拿到的只是 tag 对象 sha(annotated tag 时 ≠ commit)。列全部 tag 再由 JS 过滤。
214
+ const out = sh(`git ls-remote --tags origin`)
215
+ const lines = out.split("\n").filter(Boolean)
216
+ const deref = lines.find((l) => l.endsWith(`refs/tags/${tag}^{}`))
217
+ const plain = lines.find((l) => l.endsWith(`refs/tags/${tag}`))
218
+ tagExistsRemote = Boolean(plain || deref)
219
+ // annotated tag:deref 行的 sha 才是 commit;lightweight tag:只有 plain 行且它本身就是 commit。
220
+ tagCommitRemote = ((deref ?? plain ?? "").split(/\s+/)[0] ?? "").trim()
195
221
  } catch {}
196
222
  const headSha = shOk("git rev-parse HEAD") ? sh("git rev-parse HEAD") : ""
197
- const pointsAtHead = (sha) => !!sha && !!headSha && (sha === headSha || headSha.startsWith(sha))
223
+ /** tag 指向的 commit 是不是 HEAD 本身或 HEAD 的祖先(= 正常「发过了,之后又提交了几次」) */
224
+ const tagIsBehindHead = (sha) => {
225
+ if (!sha) return false
226
+ if (!headSha) return false
227
+ if (sha === headSha || headSha.startsWith(sha)) return true
228
+ try {
229
+ sh(`git merge-base --is-ancestor ${sha} HEAD`)
230
+ return true
231
+ } catch {
232
+ return false
233
+ }
234
+ }
198
235
  if (tagExistsLocal || tagExistsRemote) {
199
- if (tagExistsRemote && tagCommitRemote && !pointsAtHead(tagCommitRemote)) {
236
+ const remoteSha = tagCommitRemote || tagCommitLocal
237
+ if (tagExistsRemote && remoteSha && !tagIsBehindHead(remoteSha)) {
200
238
  bad(
201
- `远端 tag ${tag} 已存在,但指向 ${tagCommitRemote.slice(0, 12)}(不是 HEAD ${headSha.slice(0, 12)})—— ` +
239
+ `远端 tag ${tag} 已存在,但指向 ${remoteSha.slice(0, 12)},既不是 HEAD 也不是 HEAD 的祖先 —— ` +
202
240
  `可能是上次发布中断时由 Release API 自动建的轻量 tag。脚本**不会自动 force**。` +
203
241
  `确认这个 tag 发错之后,手工修:git push --force origin refs/tags/${tag}:refs/tags/${tag}`,
204
242
  )
@@ -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
  /**