dsh-gokifu-compact 1.11.0 → 1.11.1
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 +67 -0
- package/README.md +2 -2
- package/docs//346/236/266/346/236/204-/346/210/277/351/227/264/345/245/221/347/272/246.md +15 -8
- package/docs//346/236/266/346/236/204-/350/277/201/347/247/273/350/256/241/345/210/222.md +15 -1
- package/package.json +1 -1
- package/scripts/check-maintenance.mjs +13 -0
- package/scripts/cli.mjs +9 -0
- package/scripts/migrate-dangling.mjs +110 -0
- package/scripts/release.mjs +2 -2
- package/src/rooms/ledger.mjs +64 -12
- package/src/rooms/migrate.mjs +491 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 1.11.1 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
### P6-C 落地:未落地尝试不再被当成残留(**热清单新增 OPTIONS 段**)
|
|
6
|
+
|
|
7
|
+
1.11.0 之前,**超过 10 分钟仍没落地的尝试**(`to===null && result==="untested"`)被归进 `residue` 一起从热清单里丢掉。
|
|
8
|
+
这是错的:它不是残留,而是**「你可以继续,也可以放弃」的选项** —— 模型有权知道它存在。
|
|
9
|
+
|
|
10
|
+
**P6C-1 · 悬空边从四类变五类**
|
|
11
|
+
|
|
12
|
+
| 类 | 判据 | 进热清单? |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `residue` | `to===null` 且**已结算**(`result !== "untested"`)且 >10 分钟 | 不进(真残留,可归档) |
|
|
15
|
+
| **`candidate`(新)** | `to===null` 且 `result === "untested"` 且 >10 分钟 | **进 OPTIONS 段**,陈旧标 `[stale 42m]` |
|
|
16
|
+
| `live-unsettled` | `to===null` 且 `untested` 且 ≤10 分钟 | 进 OPTIONS(新鲜) |
|
|
17
|
+
| `from-broken` | `from` 指向不存在的节点 | 保留可见 |
|
|
18
|
+
| `none` | 其余 | — |
|
|
19
|
+
|
|
20
|
+
- 新增导出 `isDanglingOption(edge)`:只认 `result==="untested"` 的无终点边;**刚结算的失败边不算选项**
|
|
21
|
+
- `renderLedger` 新增 `OPTIONS (not yet landed — you may continue or abandon):` 区段;**UNVERIFIED 不再包含未落地的边**
|
|
22
|
+
- `renderFacts` 同样拆出 OPTIONS 段,并加 `STALE_UNSETTLED: N edges are untested and older than 10min — listed as OPTIONS …, NOT residue.` 计数行
|
|
23
|
+
- 测试 +3(`[stale 42m]` 标记 / settled 仍归 residue 的回归 / renderFacts 拆分与计数)
|
|
24
|
+
|
|
25
|
+
### P6C-2 · 迁移工具晋升为房间 R14 + 独立 CLI
|
|
26
|
+
|
|
27
|
+
- **`src/rooms/migrate.mjs`(20,913 B / 21 导出)**:`planSession` / `buildSidecar` / `removeEdges` / `listSessions` / `dryRun` / `freshSessions` / `backupTree` / `rollback` / `apply` / `verify` + 路径规则
|
|
28
|
+
- **`scripts/migrate-dangling.mjs`** 新 CLI(默认 dry-run)+ `scripts/cli.mjs` 增 `migrate-dangling` 子命令;参数 `--apply` / `--verify` / `--rollback` / `--backup` / `--root` / `--backup-dir` / `--sessions=` / `--force` / `--now` / `--json` / `--help`;退出码 0/1/2
|
|
29
|
+
- **只归档 settled residue** —— `candidate` 一条都不动,且 `planSession` 有硬断言:`residue` 集合里出现 `result==="untested"` 就抛错(防判据回退时悄悄归档)
|
|
30
|
+
- **路径经 R01**:`sessionsRoot()` = `join(graphRoot({...}), "sessions")`,不自己拼
|
|
31
|
+
- **120s 新鲜度守卫**:`--apply` 前扫会话目录,任一 `graph.json` 的 `|now − mtime| < 120s` ⇒ **拒绝 apply 且不备份、不写任何文件**(用绝对值:NTFS 时间戳可能比 `Date.now()` 超前几毫秒);`--force` 才跳过
|
|
32
|
+
- **备份写到 `sessions/` 之外**(`<DSH_HOME>/chess-context/migrate-backups/backup-<ts>`),否则会被自身 `cpSync` 递归
|
|
33
|
+
- **坏图逐会话汇总**:`dryRun`/`apply`/`verify` 都返回 `badGraphs:[{session, ok:false, reason}]`(旧原型静默跳过)
|
|
34
|
+
- 原型 `test/archive-dangling.mjs` 删除,测试迁为 `test/migrate.test.mjs`(**10/0**:7 条原有 + 路径经 R01 + 守卫 + 坏图)
|
|
35
|
+
|
|
36
|
+
**P6C-3 · 隔离目录端到端演练(PASS)**
|
|
37
|
+
|
|
38
|
+
夹具 2 会话 × 5 边(residue / candidate / live / from-broken / 真失败各一),跑前跑后 `git status` = 0:
|
|
39
|
+
|
|
40
|
+
| 阶段 | 结果 |
|
|
41
|
+
|---|---|
|
|
42
|
+
| dry-run | `residue:2 / candidates:2 / liveUnsettled:2 / fromBroken:2`,`deltaBytes:-68`;**整树 sha 前后完全相同** ⇒ 真的没写 |
|
|
43
|
+
| apply | 归档 **2 条 residue**、**`candidate` 一条未动**(重算 residue=0 / candidate=1)、`-68 B` |
|
|
44
|
+
| sidecar | 每会话 `graph.dangling.json`:源图 `sha256`/`bytes`/`criteria` + 归档边**全字段** ⇒ 可回灌 |
|
|
45
|
+
| verify | `ok:true`,抽样边与备份图逐字段相同,`problems:[]` |
|
|
46
|
+
| rollback | 整树 sha **回到原值** ⇒ 逐字恢复 |
|
|
47
|
+
|
|
48
|
+
### 发布流程修复:`release.mjs` 现在打 **annotated tag**
|
|
49
|
+
|
|
50
|
+
1.10.0 / 1.11.0 的 tag 是**轻量 tag**(`git cat-file -t` 只看到 `commit`)—— 没有作者、日期、消息,
|
|
51
|
+
GitHub Release 的 Tag 一栏是空的,「这一版改了什么」在 tag 上查不回来。
|
|
52
|
+
**修复**:`scripts/release.mjs` 的计划文本与真做命令统一为 `git tag -a <tag> -m "release <tag>"`;
|
|
53
|
+
`scripts/check-maintenance.mjs` 增加静态断言 ⑦ 盯着这条,谁改回 lightweight 立刻红。
|
|
54
|
+
⚠ **已发布的 `v1.11.0` 不追改**(tag 是对外契约);规则从**本版起**生效。
|
|
55
|
+
|
|
56
|
+
### 验证
|
|
57
|
+
|
|
58
|
+
- 全量:**436 passed / 0 failed**(36 个测试文件;A3 收口 430 + P6C-1 的 3 + P6C-2 的 10 − 原型 7)
|
|
59
|
+
- `rooms` 架构门禁 **15/0** · `migrate` **10/0** · `verify-install` **16/16**
|
|
60
|
+
- `bench 2000`:**351 tokens** / 1.333 ms(81,260 / 386,945 字符、1.41 MB、2001 节点)—— 与基线逐字一致
|
|
61
|
+
- `npm pack --dry-run --json`:**69 文件**(67 + `rooms/migrate.mjs` + `scripts/migrate-dangling.mjs`)/ 552,403 B;`test/`·`tmp/`·harness 命中 **0**
|
|
62
|
+
|
|
63
|
+
**未验证(必须知道)**
|
|
64
|
+
|
|
65
|
+
- **对 live `sessions/**` 的 apply 从未执行** —— P6C-3 是隔离夹具(2 会话),真实库是 150 个会话 / 13,043 个 agent 节点;规模与真实判据分布都没经历过。
|
|
66
|
+
- **停插件窗口流程一次都没跑**(隔离目录压根没碰运行中的插件)。
|
|
67
|
+
- **sidecar 回灌未实现**(字段齐全 ≠ 有回灌工具)。
|
|
68
|
+
- P6C-2 有一处**契约文字偏差**已披露:契约 R14 的允许依赖未列 R06,但判据必须单一来源(`classifyDangling` 只在 R06)⇒ 房间实际 import `./ledger.mjs` 的判据函数与 `DANGLING_STALE_MS`,已补进 ROOMS 的 allowed 表。
|
|
69
|
+
|
|
3
70
|
## 1.11.0 — 2026-10-30
|
|
4
71
|
|
|
5
72
|
### 最小交付房间架构收口:A2 + A3 全部完成(**首个把 16 房间落到底的正式版**)
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# dsh-GOkifu-compact
|
|
2
2
|
|
|
3
|
-
> 当前版本:**`1.11.
|
|
3
|
+
> 当前版本:**`1.11.1`** · 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 + 每步只有 323 tokens 的热清单」。**
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
热清单 323 tokens (2000 边压力测试;旧版全量渲染 28,800)
|
|
9
9
|
失败保留 8 / 8 (真实模型对照;官方 8 段模板 4.67 / 8)
|
|
10
10
|
清单开销 0.2% of prompt (v1.3.1 起;事故会话的旧版是 21.3%)
|
|
11
|
-
验证
|
|
11
|
+
验证 436 项单测 · 真实包 8/8 · 真实进程 fold 3~6/6
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
|
|
@@ -55,6 +55,7 @@ export const ROOMS = {
|
|
|
55
55
|
|
|
56
56
|
> **A3 已完成(2026-10-30)**:11 个房间全在 `src/rooms/<id>.mjs`;`src/` 顶层只剩 `src/engine.mjs`(R09)与 `src/ui.mjs`(R11)。`src/engine.mjs`、`src/ui.mjs`、`lib/client.js`、`scripts/*` **永久不动**(`package.json` 的 `main`/`exports`/`dsh.client` 与 `bin` 已把它们钉成公共契约)。
|
|
57
57
|
> **「命名导出(实测)」= `Object.keys(await import(path)).length` 的运行时值**(`src/engine.mjs` 因 peer 依赖不可直接 import,用静态扫描得 5;`lib/client.js` 是浏览器模块、无 ESM export;`scripts/cli.mjs` 是 `bin` 入口、无命名导出)。
|
|
58
|
+
> **P6-C 之后**:`src/rooms/` 共 **12 个文件**(A3 收口 11 + P6C-2 的 `migrate.mjs`);`R14` 是**唯一有「纯逻辑房间 + CLI 外壳」两层**的房间。
|
|
58
59
|
> **⇒ 与 §2.3 名字列表的差**:R00 45→**43**、R01 14→**10**、R03 12→**11**、R06 15→**14**、R15 8→**9** —— **运行时导出面才是事实**,§2.3 列表是设计意图;两者不一致时**以本节实测为准,并回头修 §2.3**。
|
|
59
60
|
|
|
60
61
|
| id | 房间 | A3 前原位置 | **最终路径(A3 已完成)** | 命名导出(实测) | 允许依赖(房间 id) | 墓碑 filter |
|
|
@@ -73,7 +74,7 @@ export const ROOMS = {
|
|
|
73
74
|
| R11 | ui-host | `src/ui.mjs`(809) | `src/ui.mjs`(**永久**) | **21** | R13.`preset-format`、R15 | `ui` |
|
|
74
75
|
| R12 | ui-client | `lib/client.js`(272) | `lib/client.js`(**永久**) | **0(浏览器模块,无 ESM export)** | `react` | `ui`(装载用例) |
|
|
75
76
|
| R13 | install | `scripts/install.mjs`(497)+ `link-path.mjs`(333)+ `cli.mjs`(49) | `scripts/`(**永久**)+ `src/rooms/preset-format.mjs` | **13 + 12 + 0** | R15 | `install-link`、`ws-install` |
|
|
76
|
-
| R14 | migrate | `test/archive-dangling.mjs`(474
|
|
77
|
+
| R14 | migrate | `test/archive-dangling.mjs`(474,**原型已删**) | **`src/rooms/migrate.mjs`** + `scripts/migrate-dangling.mjs` + `scripts/cli.mjs` 子命令(**P6C-2 已晋升**) | **21** | R00、R01、R06(判据)· `node:fs/path/crypto` | `migrate`(**10/0**) |
|
|
77
78
|
| R15 | config-schema | 无(5 处散落,见 A1 §3.6) | `src/rooms/config-schema.mjs` | **9** | — | `ui-config`、`config-schema` |
|
|
78
79
|
|
|
79
80
|
### 2.2 目录与命名(一个决定,不并列)
|
|
@@ -304,18 +305,24 @@ src/rooms/<room-id>.mjs ← 其余全部房间,**单文件一房间,
|
|
|
304
305
|
- **墓碑**:`test/install-link.test.mjs`(D2-D8 六组变异必红)+ `test/ui.test.mjs:55/66`(preset 往返)→ 删掉后「一条命令接管压缩后端 + 可回滚」消失。
|
|
305
306
|
|
|
306
307
|
#### R14 `migrate` — 数据迁移(P6-C)
|
|
307
|
-
- **职责**:把
|
|
308
|
-
|
|
309
|
-
-
|
|
310
|
-
-
|
|
308
|
+
- **职责**:把 **`classifyDangling === "residue"`**(`to=null` **且** `result !== "untested"` 且超 10 分钟)的**已结算残留**归档到 sidecar 并从主图移除;**只读判据来自 R06**。
|
|
309
|
+
**⚠ 陈旧 `untested` 边(`candidate`)不是残留,一条都不动** —— 它是「你可以继续,也可以放弃」的 **OPTIONS**,由 R06 渲染进热清单(P6C-1)。
|
|
310
|
+
- **入口**:`src/rooms/migrate.mjs`(**P6C-2 已晋升,20,913 B**)+ `scripts/migrate-dangling.mjs`(CLI 外壳,5,545 B)+ `scripts/cli.mjs` 的 `migrate-dangling` 子命令。原型 `test/archive-dangling.mjs` **已删除**(git 识别为 rename)。
|
|
311
|
+
- **export(21,实测)**:`SIDECAR_FORMAT`、`SIDECAR_NAME`、`SIDECAR_VERSION`、`CRITERIA`、`APPLY_FRESHNESS_MS`、`listSessions`、`planSession`、`buildSidecar`、`dryRun`、`apply`、`verify`、`rollback`、`backupTree`、`freshSessions`、`hashTree`、`sha256`、`writeFileAtomic`、`removeEdges`、`dshHome`、`sessionsRoot`、`backupsRoot`。
|
|
312
|
+
(相对计划稿的 13 个:合并掉的 `planMigration/applyMigration/verifyMigration/rollbackMigration` 改为 `dryRun/apply/verify/rollback`,并新增 `CRITERIA`、`APPLY_FRESHNESS_MS`、`freshSessions`、`backupTree`、三个路径函数。)
|
|
313
|
+
- **输入→输出**:`<sessionsRoot>` 文件系统 → `{mode, sessions, affectedSessions, residue, **candidates**, liveUnsettled, fromBroken, sidecarEdges, graphBytesBefore/After, deltaBytes, badGraphs[], perSession[]}`;`apply` 额外 `backupDir`;`verify` 额外 `ok` / `problems[]`。
|
|
314
|
+
**`candidates` 是一个必须存在且必须不变的字段** —— `verify` 与 `APPLY.json` 都比对它,变了即 `problem`。
|
|
311
315
|
- **错误语义**:单会话失败不中断,逐会话 `{ok:false,reason}` 汇总;`verify` 有问题 → 退出码 1;参数错 → 退出码 2;**默认 dry-run,一个字节都不写**(现有 `archive-dangling.test.mjs:103-126` 已锁)。
|
|
312
316
|
- **配置键**:**只有 CLI 参数**,不进 preset:`--root=<sessionsRoot>`、`--dry-run`(默认)/`--apply`、`--verify --backup=<dir>`、`--rollback=<dir>`、`--backup-dir=<dir>`、`--now=<epochMs>`、`--json`。新增 `--sessions=<csv>` 与 `--force`(见迁移计划 P6C-1)。
|
|
313
|
-
- **测试入口**:`node test/run.mjs migrate
|
|
317
|
+
- **测试入口**:`node test/run.mjs migrate` → **10/0**(7 条等价迁移 + **P6C-8 路径经 R01 `graphRoot`** + **P6C-9 120s 新鲜度守卫**)。
|
|
314
318
|
- **允许依赖**:R00(只读 `fromJSON/toJSON/renderIndexMd`)、R01(`graphRoot/sessionPaths` 取路径)、R06(`classifyDangling`/`DANGLING_STALE_MS`——**判据只有这一份,禁止复制**)、`node:fs/path/crypto`。
|
|
315
319
|
- **禁止依赖**:R09(engine 不参与)、R11、R12、R05。
|
|
316
320
|
- **兼容策略**:sidecar `format:"dsh-gokifu-dangling-sidecar"`、`version:1`、`source.sha256`、`criteria` 字段冻结;备份目录 `MANIFEST.json` 的 `{createdAt,source,files[{rel,bytes,sha256}],fileCount,totalBytes}` 冻结;回滚必须逐字节还原并**删除 apply 之后新增的文件**(`archive-dangling.mjs:235-242`)。
|
|
317
321
|
- **状态归属**:无。
|
|
318
322
|
- **墓碑**:`test/migrate.test.mjs::P6C-4`(apply 后 sidecar 数 = 删边数、备份逐字可恢复)→ 删掉后「5,567 条残留边能被安全归档且可回滚」消失。
|
|
323
|
+
- **两条不可放宽的安全约束(P6C-2 实测换来的)**:
|
|
324
|
+
1. **`planSession` 硬断言 residue 里不许有 `untested`** —— 判据一旦回退(`classifyDangling` 被改回四类),这里**直接抛错**,而不是悄悄把 candidate archive 掉;
|
|
325
|
+
2. **`freshSessions` 用 `|now − mtime| < 120_000` 的绝对值** —— NTFS 时间戳可能比 `Date.now()` 超前几毫秒,只判 `age >= 0` 会漏判;命中即**拒绝 apply 且不写任何文件**(`--force` = `maxAgeMs: 0` 可跳过)。
|
|
319
326
|
|
|
320
327
|
#### R15 `config-schema` — 配置的单一来源
|
|
321
328
|
- **职责**:把 5 份来源(23/20/16/15/15 键)收敛成 1 张表;`resolve` 是「生效值 + 来源」的唯一实现。
|
|
@@ -450,7 +457,7 @@ DSH compactIfNeeded ──► R09 ──► R02.planFold(surface/priced/eventA
|
|
|
450
457
|
| R-B | R00-R08 与 R15 的文件不得 import `@deepseek-ai/*`、R09、R11、`scripts/` | 绿 |
|
|
451
458
|
| R-C | R09 之外任何文件不得 import `@deepseek-ai/*`(保证 harness 的 4 条替换够用) | 绿 |
|
|
452
459
|
| R-D | `lib/client.js` 不得 import 仓库内任何文件;R10/R14 不得 import R09 | 绿 |
|
|
453
|
-
| R-E(新) | `src/engine.mjs` 不得 import R14(**P6-C 禁止 engine 启动钩子**的机器判据);R14 不得 import R09 |
|
|
460
|
+
| R-E(新) | `src/engine.mjs` 不得 import R14(**P6-C 禁止 engine 启动钩子**的机器判据);R14 不得 import R09 | **绿(R14 已有代码,判据真正生效)** |
|
|
454
461
|
| R-F(新) | `src/**`、`scripts/**`、`lib/**` 中不得出现 `allowedModels` 字样(宿主全局配置,插件不读不写) | 绿 |
|
|
455
462
|
| A3-2-fix | `src/` 顶层只允许 `engine.mjs` / `ui.mjs`;`DELETED_FACADES` 的 8 个名字不得在顶层出现、不得被任何文件 import | 绿 |
|
|
456
463
|
| A3-3 | `ROOMS` 每项 `file === <id>.mjs` 且真实存在;房间集合恰为契约 11 个;C1/R-C 的宿主说明符集合恰为 4 个 | 绿 |
|
|
@@ -607,7 +614,7 @@ DSH compactIfNeeded ──► R09 ──► R02.planFold(surface/priced/eventA
|
|
|
607
614
|
| R11 | `ui`、`ui-security`、`ui-config`、`ui-perf`、`version-path` | ✓ | — |
|
|
608
615
|
| R12 | `ui`(源码文本) | ✓(弱) | Node 装载用例 |
|
|
609
616
|
| R13 | `install-link`、`ws-install`、`ui` | ✓ | — |
|
|
610
|
-
| R14 |
|
|
617
|
+
| R14 | `migrate`、`dangling-exclusion` | ✅ **10/0**(7 等价迁移 + P6C-8 路径经 R01 + P6C-9 120s 守卫) | — |
|
|
611
618
|
| R15 | `ui-config` | ✓(只比 label) | `config-schema`(表驱动全键) |
|
|
612
619
|
|
|
613
620
|
## 附录 B · 对 A1 的勘误(读代码发现)
|
|
@@ -438,7 +438,21 @@ npm pack --dry-run --json # 文件数 == 基线 +4(A2 的 4 个 rooms
|
|
|
438
438
|
|
|
439
439
|
### 5.1 P6-C:归 `migrate` 房间 + 显式 CLI,**禁止** engine 启动钩子
|
|
440
440
|
|
|
441
|
-
|
|
441
|
+
> ## ✅ **P6-C 已落地(2026-10-30)—— 本节其余内容是**立项时的计划稿**,保留作决策留痕**
|
|
442
|
+
>
|
|
443
|
+
> | 刀 | 内容 | 结果 |
|
|
444
|
+
> |---|---|---|
|
|
445
|
+
> | **P6C-1** | **候选语义**(`462a814`) | `classifyDangling` **四类 → 五类**:陈旧 `untested` 从 `residue` **拆出为 `candidate`**;新增 `isDanglingOption`;`danglingStats` = `{residue, candidates, liveUnsettled, fromBroken}`;`renderLedger`/`renderFacts` 新增 `OPTIONS (not yet landed — you may continue or abandon):` 段 + `STALE_UNSETTLED` 计数行。全量 433/0(430+3)· pack 67 不变 |
|
|
446
|
+
> | **P6C-2** | **R14 + CLI 晋升**(`20ffa94`) | `src/rooms/migrate.mjs`(**21 导出**,20,913 B)+ `scripts/migrate-dangling.mjs`(5,545 B)+ `scripts/cli.mjs` 子命令;原型 `test/archive-dangling.mjs` **删除**(git 识别为 rename)。**436/0** · `rooms` 15/0 · `migrate` **10/0** · pack **69**(+2) |
|
|
447
|
+
> | **P6C-3** | **隔离目录端到端**(task-115) | **PASS**:dry-run 整树 sha `dc547cb8…` 前后相同(真的没写);apply 归档 2 条 residue、**candidate 2 条一条未动**、residue 清零、−68 B;verify `ok=true`;**rollback 逐字恢复** |
|
|
448
|
+
> | **真实 apply** | — | **未做** —— 隔离夹具(2 会话 × 5 边)≠ 真实 150 个会话;**需主人授权 + 停插件窗口 + 先 dry-run 对账** |
|
|
449
|
+
>
|
|
450
|
+
> **⇒ 语义收窄(这条最重要)**:立项时写的是「把 `to=null` 且超 10 分钟的**历史残留边**归档」。落地后**只归档 `residue`**(`result !== "untested"` 的**已结算**边);**陈旧 `untested` 边改称 `candidate`,一律保留**,作为「继续或放弃」的 OPTIONS 渲染进热清单 —— **它不是脏数据,是可行动的选择**。
|
|
451
|
+
> **⇒ 守卫**:`planSession` 硬断言 residue 里不许出现 `untested`(判据回退时抛错,不悄悄归档);`--apply` 前 `freshSessions` 用 **`|now − mtime| < 120s` 绝对值**拒绝(NTFS 时间戳可能超前几毫秒),命中即不写任何文件,`--force` 才跳过。
|
|
452
|
+
>
|
|
453
|
+
> ---
|
|
454
|
+
>
|
|
455
|
+
> **立项时的现状(历史记录,已被上表取代)**:`test/archive-dangling.mjs`(474 行)+ `test/archive-dangling.test.mjs`(221 行 / 7 用例)**未跟踪**。它已经是 C 方案(sidecar)的可运行原型:`planSession`/`buildSidecar`/`dryRun`/`apply`/`verify`/`rollback`/`hashTree`/`sha256`/`listSessions`,判据复用 `ledger.classifyDangling`(`:30`),默认 dry-run,apply 先整目录备份(`backupTree` 逐文件 sha256 比对)。
|
|
442
456
|
|
|
443
457
|
**立项边界**
|
|
444
458
|
- 归属:**R14 `migrate`**(`src/rooms/migrate.mjs`)+ **显式 CLI**(`scripts/migrate-dangling.mjs` + `scripts/cli.mjs` 增 `migrate-dangling` 子命令)。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-gokifu-compact",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.1",
|
|
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",
|
|
@@ -128,6 +128,19 @@ if (from && to) {
|
|
|
128
128
|
"补 tag:git tag <tag> <commit>(补历史版本是可以的,不改代码)")
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// ── ⑦ release.mjs 必须打 annotated tag ───────────────────────────────
|
|
132
|
+
// lightweight tag 只指向 commit,没有作者/日期/消息:GitHub Release 的「Tag」一栏是空的,
|
|
133
|
+
// `git cat-file -t` 只看到 commit —— 「这一版到底改了什么」在 tag 上查不回来。
|
|
134
|
+
// 1.11.0 就是 lightweight tag(`v1.11.0`),事后不可改;规则从下一版起生效。
|
|
135
|
+
const releaseSrc = existsSync(join(ROOT, "scripts", "release.mjs"))
|
|
136
|
+
? readFileSync(join(ROOT, "scripts", "release.mjs"), "utf8")
|
|
137
|
+
: ""
|
|
138
|
+
const usesAnnotated = releaseSrc.includes("git tag -a") && !/git tag \$\{tag\} /.test(releaseSrc)
|
|
139
|
+
ok(usesAnnotated,
|
|
140
|
+
"release.mjs 打的是 annotated tag(git tag -a … -m …)",
|
|
141
|
+
"用 lightweight tag ⇒ Release 页没有 tag 说明、`git cat-file -t` 只看到 commit,历史无法解释",
|
|
142
|
+
'把 scripts/release.mjs 里的 "git tag <tag>" 改成 "git tag -a <tag> -m \\"release <tag>\\""')
|
|
143
|
+
|
|
131
144
|
// ── 输出 ──────────────────────────────────────────────────────────────
|
|
132
145
|
|
|
133
146
|
report()
|
package/scripts/cli.mjs
CHANGED
|
@@ -31,6 +31,13 @@ if (command === "install" || command === "revert") {
|
|
|
31
31
|
} catch (error) {
|
|
32
32
|
process.exitCode = typeof error?.status === "number" ? error.status : 1;
|
|
33
33
|
}
|
|
34
|
+
} else if (command === "migrate-dangling") {
|
|
35
|
+
const migrate = nodePath.join(HERE, "migrate-dangling.mjs");
|
|
36
|
+
try {
|
|
37
|
+
execFileSync(process.execPath, [migrate, ...argv.slice(1)], { stdio: "inherit" });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
process.exitCode = typeof error?.status === "number" ? error.status : 1;
|
|
40
|
+
}
|
|
34
41
|
} else {
|
|
35
42
|
console.log(`dsh-gokifu-compact — DSH 的棋谱式上下文压缩后端
|
|
36
43
|
|
|
@@ -41,6 +48,8 @@ if (command === "install" || command === "revert") {
|
|
|
41
48
|
还原上一次的 preset 备份
|
|
42
49
|
dsh-gokifu-compact doctor
|
|
43
50
|
体检:安装 / 摘要模型 / 压缩成败 / 棋谱 / 树新鲜度
|
|
51
|
+
dsh-gokifu-compact migrate-dangling [--apply] [--verify --backup=<dir>]
|
|
52
|
+
P6-C 悬空边 sidecar 归档(默认 dry-run;apply 需停插件窗口)
|
|
44
53
|
|
|
45
54
|
装包(先做这一步):
|
|
46
55
|
dsh plugin --profile <name> add dsh-gokifu-compact
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* migrate-dangling.mjs —— P6-C 悬空边 sidecar 归档 CLI(R10 调用 R14)
|
|
4
|
+
*
|
|
5
|
+
* 逻辑全在 `src/rooms/migrate.mjs`(R14);本文件只做参数解析、输出与退出码。
|
|
6
|
+
* 也可以经 `dsh-gokifu-compact migrate-dangling …` 进入(`scripts/cli.mjs` 转发)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node scripts/migrate-dangling.mjs # dry-run(默认,不写任何文件)
|
|
10
|
+
* node scripts/migrate-dangling.mjs --json # dry-run + JSON
|
|
11
|
+
* node scripts/migrate-dangling.mjs --apply # 真执行(先整目录备份;120s 新鲜度守卫)
|
|
12
|
+
* node scripts/migrate-dangling.mjs --verify --backup=<dir> # 只读校验
|
|
13
|
+
* node scripts/migrate-dangling.mjs --rollback=<dir> # 从备份逐字恢复
|
|
14
|
+
* --root=<sessionsRoot> --backup-dir=<dir> --now=<epochMs>
|
|
15
|
+
*
|
|
16
|
+
* 退出码:0 正常;1 校验失败/执行失败;2 参数错误。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import nodeFs from "node:fs";
|
|
20
|
+
import nodePath from "node:path";
|
|
21
|
+
import { dryRun, apply, verify, rollback, sessionsRoot, backupsRoot } from "../src/rooms/migrate.mjs";
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const opts = { mode: "dry-run", json: false, force: false, only: null, root: sessionsRoot(), backupRoot: backupsRoot(), backupDir: "", now: Date.now() };
|
|
25
|
+
for (const arg of argv) {
|
|
26
|
+
if (arg === "--dry-run") opts.mode = "dry-run";
|
|
27
|
+
else if (arg === "--apply") opts.mode = "apply";
|
|
28
|
+
else if (arg === "--force") opts.force = true;
|
|
29
|
+
else if (arg === "--verify") opts.mode = "verify";
|
|
30
|
+
else if (arg === "--json") opts.json = true;
|
|
31
|
+
else if (arg.startsWith("--root=")) opts.root = arg.slice("--root=".length);
|
|
32
|
+
else if (arg.startsWith("--backup-dir=")) opts.backupRoot = arg.slice("--backup-dir=".length);
|
|
33
|
+
else if (arg.startsWith("--backup=")) opts.backupDir = arg.slice("--backup=".length);
|
|
34
|
+
else if (arg.startsWith("--rollback=")) {
|
|
35
|
+
opts.mode = "rollback";
|
|
36
|
+
opts.backupDir = arg.slice("--rollback=".length);
|
|
37
|
+
} else if (arg.startsWith("--sessions=")) {
|
|
38
|
+
opts.only = arg.slice("--sessions=".length).split(",").map((x) => x.trim()).filter(Boolean);
|
|
39
|
+
} else if (arg.startsWith("--now=")) opts.now = Number(arg.slice("--now=".length));
|
|
40
|
+
else if (arg === "--help" || arg === "-h") {
|
|
41
|
+
printHelp();
|
|
42
|
+
process.exit(0);
|
|
43
|
+
} else {
|
|
44
|
+
console.error(`未知参数:${arg}`);
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return opts;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function printHelp() {
|
|
52
|
+
console.log(`migrate-dangling —— P6-C 悬空边 sidecar 归档工具(R14)
|
|
53
|
+
node scripts/migrate-dangling.mjs [--dry-run] [--root=<sessionsRoot>] [--json] # dry-run(默认)
|
|
54
|
+
--sessions=<a,b> 只处理指定会话;--force 跳过 apply 的 120s 新鲜度守卫(危险)
|
|
55
|
+
node scripts/migrate-dangling.mjs --apply [--force] # 执行(整目录备份 + 120s 新鲜度守卫)
|
|
56
|
+
node scripts/migrate-dangling.mjs --verify --backup=<backupDir> # 只读校验
|
|
57
|
+
node scripts/migrate-dangling.mjs --rollback=<backupDir> # 从备份恢复
|
|
58
|
+
|
|
59
|
+
默认路径:<DSH_HOME>/chess-context/sessions ;备份:<DSH_HOME>/chess-context/migrate-backups`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function main() {
|
|
63
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
64
|
+
let result;
|
|
65
|
+
let code = 0;
|
|
66
|
+
try {
|
|
67
|
+
if (opts.mode === "dry-run") {
|
|
68
|
+
result = dryRun(opts.root, { now: opts.now, only: opts.only });
|
|
69
|
+
} else if (opts.mode === "apply") {
|
|
70
|
+
result = apply(opts.root, { now: opts.now, backupRoot: opts.backupRoot, only: opts.only, maxAgeMs: opts.force ? 0 : undefined });
|
|
71
|
+
} else if (opts.mode === "verify") {
|
|
72
|
+
result = verify(opts.root, { now: opts.now, backupDir: opts.backupDir, only: opts.only });
|
|
73
|
+
if (!result.ok) code = 1;
|
|
74
|
+
} else {
|
|
75
|
+
result = rollback(opts.backupDir);
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(`✗ ${String(error?.message ?? error)}`);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (opts.json) {
|
|
83
|
+
console.log(JSON.stringify(result, null, 1));
|
|
84
|
+
} else if (opts.mode === "dry-run") {
|
|
85
|
+
console.log("== migrate-dangling dry-run ==");
|
|
86
|
+
console.log(`root: ${result.root}`);
|
|
87
|
+
console.log(`sessions: ${result.sessions}(affected ${result.affectedSessions})`);
|
|
88
|
+
console.log(`residue(将归档): ${result.residue}`);
|
|
89
|
+
console.log(`candidates(保留,P6C-1): ${result.candidates}`);
|
|
90
|
+
console.log(`liveUnsettled: ${result.liveUnsettled}(保留)`);
|
|
91
|
+
console.log(`fromBroken: ${result.fromBroken}(保留)`);
|
|
92
|
+
console.log(`sidecar edges: ${result.sidecarEdges}`);
|
|
93
|
+
console.log(`graph bytes: ${result.graphBytesBefore} → ${result.graphBytesAfter}(${result.deltaBytes})`);
|
|
94
|
+
if (result.badGraphs.length) console.log(`坏图 ${result.badGraphs.length} 个:${result.badGraphs.map((b) => `${b.session}(${b.reason})`).join("; ")}`);
|
|
95
|
+
} else if (opts.mode === "verify") {
|
|
96
|
+
console.log(
|
|
97
|
+
result.ok
|
|
98
|
+
? `✓ verify ok:sidecar ${result.sidecarEdges} 条 / residue ${result.residue} / candidates ${result.candidates} / fromBroken ${result.fromBroken}`
|
|
99
|
+
: `✗ verify 失败:${result.problems.join("; ")}`,
|
|
100
|
+
);
|
|
101
|
+
if (result.badGraphs.length) console.log(`坏图 ${result.badGraphs.length} 个:${result.badGraphs.map((b) => b.session).join(", ")}`);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(JSON.stringify(result, null, 1));
|
|
104
|
+
}
|
|
105
|
+
process.exit(code);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 只在被直接执行时跑 main(被 import 时保持安静)。 */
|
|
109
|
+
const direct = process.argv[1] && nodePath.basename(process.argv[1]) === "migrate-dangling.mjs" && nodeFs.existsSync(process.argv[1]);
|
|
110
|
+
if (direct) main();
|
package/scripts/release.mjs
CHANGED
|
@@ -206,7 +206,7 @@ for (const n of notes) console.log(C.yellow(` ! ${n}`))
|
|
|
206
206
|
|
|
207
207
|
const plan = []
|
|
208
208
|
plan.push({ what: `git push origin ${branch}`, why: "第 4 步:git 源检测看默认分支 HEAD" })
|
|
209
|
-
plan.push({ what: `git tag ${tag} && git push origin ${tag}`, why: "第 6 步:tag
|
|
209
|
+
plan.push({ what: `git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, why: "第 6 步:annotated tag(带消息)让「某 bug 出在哪版」可查,且 GitHub Release 才有说明" })
|
|
210
210
|
plan.push({
|
|
211
211
|
what: `POST /repos/${gh.owner}/${gh.repo}/releases`,
|
|
212
212
|
why: `第 6 步:Release 正文 = CHANGELOG 的 ${version} 段(${releaseBody.split("\n").length} 行,已去掉 > 起因)`,
|
|
@@ -247,7 +247,7 @@ function run(cmd, label) {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
const pushed = run(`git push origin ${branch}`, `push ${branch}`)
|
|
250
|
-
const tagged = run(`git tag ${tag} && git push origin ${tag}`, `tag ${tag}`)
|
|
250
|
+
const tagged = run(`git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, `tag ${tag}`)
|
|
251
251
|
|
|
252
252
|
let released = false
|
|
253
253
|
|
package/src/rooms/ledger.mjs
CHANGED
|
@@ -32,18 +32,23 @@ import { weakKindOf } from "./verify.mjs";
|
|
|
32
32
|
* · 另有 214 条 `from` 悬空(全在 session-5acafe42,mergeNodeInto 老 bug)——**另一类**,
|
|
33
33
|
* 判据必须以 `to == null` 开头,绝不能写成「任一端为空」(会误删)。
|
|
34
34
|
*
|
|
35
|
-
* 不变量(报告 §2
|
|
36
|
-
* residue(e) := e.to == null &&
|
|
37
|
-
* live_unsettled(e) := e.to == null && e.result === "untested" && age <= 10min
|
|
35
|
+
* 不变量(报告 §2,P6C-1 起四类):
|
|
36
|
+
* residue(e) := e.to == null && e.result !== "untested" && age > 10min (已结算却没终点 = 不可能)
|
|
37
|
+
* live_unsettled(e) := e.to == null && e.result === "untested" && age <= 10min (正在执行)
|
|
38
|
+
* candidate(e) := e.to == null && e.result === "untested" && age > 10min (未落地:可继续、也可放弃)
|
|
38
39
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* ★ P6C-1(2026-10-30):**超过 10 分钟的 untested 不再是 residue,而是 candidate。**
|
|
41
|
+
* 旧判据把它当「死边」整条丢掉 —— 但模型崩溃/中断留下的未结算边恰恰是「下一步还能接着做」
|
|
42
|
+
* 的信息,丢掉等于把线索一起丢了。现在它进热清单的 OPTIONS 区段(标 `[stale Xm]`),
|
|
43
|
+
* 明确写成「not yet landed — you may continue or abandon」,不混进 UNVERIFIED(那是「跑了但没验证」)。
|
|
44
|
+
*
|
|
45
|
+
* 为什么 residue 只留「已结算」:结算过(success/failure/unverified)的边必须有终点,
|
|
46
|
+
* 无终点只可能是 1.9.1 rewash 留下的历史残留(数据事实 5,564/5,567)。
|
|
42
47
|
*/
|
|
43
48
|
export const DANGLING_STALE_MS = 10 * 60 * 1000;
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
|
-
* @returns {"residue"|"live-unsettled"|"from-broken"|"none"}
|
|
51
|
+
* @returns {"residue"|"candidate"|"live-unsettled"|"from-broken"|"none"}
|
|
47
52
|
*/
|
|
48
53
|
export function classifyDangling(edge, { graph = null, now = Date.now() } = {}) {
|
|
49
54
|
if (!edge) return "none";
|
|
@@ -57,22 +62,36 @@ export function classifyDangling(edge, { graph = null, now = Date.now() } = {})
|
|
|
57
62
|
// 生产数字不受影响:全库 5,564 条 settled 残留的 ts 全部是 10-29 的老边,远超 10 分钟。
|
|
58
63
|
const ts = Number(edge.ts);
|
|
59
64
|
if (Number.isFinite(ts) && now - ts <= DANGLING_STALE_MS) return "live-unsettled";
|
|
60
|
-
|
|
65
|
+
// 陈旧(或没有时间戳、无法证明新鲜)的 untested = candidate(P6C-1)
|
|
66
|
+
if (edge.result === "untested") return "candidate";
|
|
61
67
|
return "residue";
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* 该边是否属于「OPTIONS」区段(未落地的尝试:新鲜 live 或陈旧 candidate)。
|
|
72
|
+
* ★ 只认 `result === "untested"`:刚结算的失败边也可能因年龄护栏落进 live-unsettled,
|
|
73
|
+
* 但它是**已发生的失败**(已在 LEARNED 里),不是「还没落地、可以继续或放弃」的选项。
|
|
74
|
+
*/
|
|
75
|
+
export function isDanglingOption(edge, { graph = null, now = Date.now() } = {}) {
|
|
76
|
+
if (!edge || edge.result !== "untested" || edge.to != null) return false;
|
|
77
|
+
const kind = classifyDangling(edge, { graph, now });
|
|
78
|
+
return kind === "live-unsettled" || kind === "candidate";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 全图悬空统计:{residue, candidates, liveUnsettled, fromBroken}。只读,不改图。 */
|
|
65
82
|
export function danglingStats(g, { now = Date.now() } = {}) {
|
|
66
83
|
let residue = 0;
|
|
84
|
+
let candidates = 0;
|
|
67
85
|
let liveUnsettled = 0;
|
|
68
86
|
let fromBroken = 0;
|
|
69
87
|
for (const e of Object.values(g?.edges ?? {})) {
|
|
70
88
|
const kind = classifyDangling(e, { graph: g, now });
|
|
71
89
|
if (kind === "residue") residue += 1;
|
|
90
|
+
else if (kind === "candidate") candidates += 1;
|
|
72
91
|
else if (kind === "live-unsettled") liveUnsettled += 1;
|
|
73
92
|
else if (kind === "from-broken") fromBroken += 1;
|
|
74
93
|
}
|
|
75
|
-
return { residue, liveUnsettled, fromBroken };
|
|
94
|
+
return { residue, candidates, liveUnsettled, fromBroken };
|
|
76
95
|
}
|
|
77
96
|
|
|
78
97
|
/** 该边是否应从「失败边分析 / 热清单」排除。live_unsettled 永远保留。 */
|
|
@@ -90,6 +109,19 @@ export function isDanglingResidue(edge, { graph = null, now = Date.now() } = {})
|
|
|
90
109
|
// 「k of n hops 未验证 = 不构成进展证据」。
|
|
91
110
|
const WEAK_LEGEND = "# weak k/n = k of n hops unverified — not proof of progress";
|
|
92
111
|
|
|
112
|
+
/**
|
|
113
|
+
* OPTIONS 行尾的陈旧标记(P6C-1):只有超过 10 分钟的 untested 才加。
|
|
114
|
+
* 没有时间戳 ⇒ 无法证明新鲜,标 `[stale ?]`(宁可让模型知道「这条来路不明」)。
|
|
115
|
+
*/
|
|
116
|
+
function staleTagOf(edge, now) {
|
|
117
|
+
if (edge?.result !== "untested") return "";
|
|
118
|
+
const ts = Number(edge.ts);
|
|
119
|
+
if (!Number.isFinite(ts)) return " [stale ?]";
|
|
120
|
+
const age = now - ts;
|
|
121
|
+
if (age <= DANGLING_STALE_MS) return "";
|
|
122
|
+
return ` [stale ${Math.floor(age / 60000)}m]`;
|
|
123
|
+
}
|
|
124
|
+
|
|
93
125
|
/** 保守 token 估算(CJK 1 字≈1 token,英文 ~4 字/token;取 3 折中偏保守)。 */
|
|
94
126
|
export function estimateTokens(text) {
|
|
95
127
|
return Math.ceil(String(text ?? "").length / 3);
|
|
@@ -423,7 +455,11 @@ export function renderLedger(g, { maxTokens = 500, tailFailures = 20, markers =
|
|
|
423
455
|
// ★ P6-A:UNVERIFIED_CLAIMS 里的 rewash 残留(to=null 的 unverified)同样是噪声 ——
|
|
424
456
|
// 它们既没有终点、也不可能再被结算(result 已写死 unverified),展示出来只会占行数。
|
|
425
457
|
// live_unsettled 必须保留(正在执行、还没结算)。
|
|
426
|
-
|
|
458
|
+
// ★ P6C-1(2026-10-30):**未落地(untested + to=null)的边不再混进 UNVERIFIED** ——
|
|
459
|
+
// 「跑了但没外部验证信号」和「还没落地、可以继续也可以放弃」是两类信息,混在一起模型会当成
|
|
460
|
+
// 「已验证过但不可信」,于是不敢用也不敢弃。现在它们单独进 OPTIONS 区段。
|
|
461
|
+
const options = Object.values(g.edges).filter((e) => isDanglingOption(e, { graph: g, now }));
|
|
462
|
+
const open = openEdges(g).filter((e) => !isDanglingResidue(e, { graph: g, now }) && !isDanglingOption(e, { graph: g, now }));
|
|
427
463
|
const latest = latestAgentNode(g);
|
|
428
464
|
const path = latest ? currentPath(g, latest.id) : [];
|
|
429
465
|
const root = g.rootNode ?? g.rootUserNodeIds.map((id) => g.nodes[id]).filter(Boolean)[0];
|
|
@@ -569,6 +605,12 @@ export function renderLedger(g, { maxTokens = 500, tailFailures = 20, markers =
|
|
|
569
605
|
}
|
|
570
606
|
for (const e of shown) tailLines.push(renderFailureLine(e, level, tewariMap.get(e.id) ?? null, markers));
|
|
571
607
|
}
|
|
608
|
+
// ★ P6C-1:OPTIONS 区段(新鲜 + 陈旧,陈旧标 `[stale Xm]`)。只在 L0/L1 出现,
|
|
609
|
+
// 与 UNVERIFIED 同档 —— 它们是「下一步要不要接着做」的决策信息,预算紧张时先让位给失败记忆。
|
|
610
|
+
if (options.length && level <= 1) {
|
|
611
|
+
tailLines.push("OPTIONS (not yet landed — you may continue or abandon):");
|
|
612
|
+
for (const e of options.slice(-6)) tailLines.push(`- ${e.id} "${clip(e.intent || e.action, 50)}"${staleTagOf(e, now)}`);
|
|
613
|
+
}
|
|
572
614
|
if (open.length && level <= 1) {
|
|
573
615
|
tailLines.push("UNVERIFIED (ran, but no external signal — treat as unproven):");
|
|
574
616
|
for (const e of open.slice(-6)) tailLines.push(`- ${e.id} "${clip(e.intent || e.action, 50)}"`);
|
|
@@ -859,8 +901,18 @@ export function renderFacts(g, { maxFailures = 800, headFailures = 80, range = n
|
|
|
859
901
|
if (stats.liveUnsettled > 0) {
|
|
860
902
|
lines.push(`LIVE_UNSETTLED: ${stats.liveUnsettled} edges are newly unsettled (untested, <=10min) — kept visible on purpose.`);
|
|
861
903
|
}
|
|
904
|
+
if (stats.candidates > 0) {
|
|
905
|
+
lines.push(`STALE_UNSETTLED: ${stats.candidates} edges are untested and older than 10min — listed as OPTIONS (continue or abandon), NOT residue.`);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// ★ P6C-1:未落地的尝试(untested + to=null)单独成段,不再混进 UNVERIFIED_CLAIMS。
|
|
909
|
+
const openAll = openEdges(g);
|
|
910
|
+
const options = openAll.filter((e) => isDanglingOption(e, { graph: g, now }));
|
|
911
|
+
const open = openAll.filter((e) => !isDanglingOption(e, { graph: g, now }));
|
|
912
|
+
if (options.length) {
|
|
913
|
+
lines.push("OPTIONS (not yet landed — you may continue or abandon):");
|
|
914
|
+
for (const e of options.slice(-40)) lines.push(`* ${e.id} "${clip(e.intent || e.action, 90)}"${staleTagOf(e, now)}`);
|
|
862
915
|
}
|
|
863
|
-
const open = openEdges(g);
|
|
864
916
|
if (open.length) {
|
|
865
917
|
lines.push("UNVERIFIED_CLAIMS (no external verification signal):");
|
|
866
918
|
for (const e of open.slice(-40)) lines.push(`* ${e.id} "${clip(e.intent || e.action, 90)}"`);
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate.mjs —— R14 数据迁移房间(P6-C:悬空边 sidecar 归档)
|
|
3
|
+
*
|
|
4
|
+
* 从 `test/archive-dangling.mjs` 原型晋升(task-112 / P6C-2)。职责分层:
|
|
5
|
+
* · 本房间:**纯逻辑 + 文件操作**(plan / dry-run / apply / verify / rollback / 备份)
|
|
6
|
+
* · `scripts/migrate-dangling.mjs`:CLI 参数与输出
|
|
7
|
+
* · `scripts/cli.mjs`:`migrate-dangling` 子命令转发
|
|
8
|
+
*
|
|
9
|
+
* ★ 硬约束(契约 §2.5):
|
|
10
|
+
* 1. **禁止被 engine import**(P6-C 禁止 engine 启动钩子)—— `test/rooms.test.mjs` 的 R-E 直接断言;
|
|
11
|
+
* 2. 默认 **dry-run**;`apply` 必须先整目录备份 + 校验,且要求图**至少 120 秒没被写过**
|
|
12
|
+
* (运行中的插件会 flush 覆盖磁盘清理,实测 netCleaned=0);
|
|
13
|
+
* 3. 判据**只复用 R06**(`classifyDangling`)—— 判据集中一处,本房间不另写;
|
|
14
|
+
* 4. `candidates`(未落地的 untested,P6C-1)**一条都不许归档**,只归档 settled residue。
|
|
15
|
+
*
|
|
16
|
+
* 为什么是 sidecar 而不是直接删:主图 −2.99MB / flush −21.4%,但失败边是历史证据,不能丢。
|
|
17
|
+
* sidecar(`graph.dangling.json`)保留全部字段 + 源图 sha256 + 生成时间 + 判据,随时可 merge 回来。
|
|
18
|
+
*
|
|
19
|
+
* 允许依赖:R00(fromJSON/toJSON/renderIndexMd)· R06(classifyDangling/DANGLING_STALE_MS)
|
|
20
|
+
* · `node:fs`/`node:path`/`node:crypto`。**禁止** `@deepseek-ai/*`、engine、scripts、lib。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import nodeFs from "node:fs";
|
|
24
|
+
import nodePath from "node:path";
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { fromJSON, toJSON, renderIndexMd } from "./kifu-model.mjs";
|
|
27
|
+
import { graphRoot } from "./kifu-persist.mjs";
|
|
28
|
+
import { classifyDangling, DANGLING_STALE_MS } from "./ledger.mjs";
|
|
29
|
+
|
|
30
|
+
export const SIDECAR_NAME = "graph.dangling.json";
|
|
31
|
+
export const SIDECAR_FORMAT = "dsh-gokifu-dangling-sidecar";
|
|
32
|
+
export const SIDECAR_VERSION = 1;
|
|
33
|
+
|
|
34
|
+
/** apply 的新鲜度守卫:图在这么多毫秒内被写过 ⇒ 认为插件还在跑,拒绝 apply。 */
|
|
35
|
+
export const APPLY_FRESHNESS_MS = 120_000;
|
|
36
|
+
|
|
37
|
+
export const CRITERIA = Object.freeze({
|
|
38
|
+
residue: "to==null && result!=='untested' && age>10min (classifyDangling==='residue') → archived",
|
|
39
|
+
candidates: "to==null && result==='untested' && age>10min (classifyDangling==='candidate') → KEPT (P6C-1)",
|
|
40
|
+
excludedLive: "to==null && untested && age<=10min — kept in graph",
|
|
41
|
+
excludedFromBroken: "edge.from not in g.nodes — separate class, kept in graph",
|
|
42
|
+
staleMs: DANGLING_STALE_MS,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/* ───────────────────────────── 路径规则(R01 口径) ───────────────────────────── */
|
|
46
|
+
|
|
47
|
+
/** `DSH_HOME`(缺省 `~/.dsh`,与 engine 的 resolveHome 同口径)。 */
|
|
48
|
+
export function dshHome(env = process.env) {
|
|
49
|
+
if (env.DSH_HOME && String(env.DSH_HOME).length > 0) return env.DSH_HOME;
|
|
50
|
+
return nodePath.join(env.USERPROFILE ?? env.HOME ?? ".", ".dsh");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 会话根:`<DSH_HOME>/chess-context/sessions`(路径规则复用 R01 `graphRoot`)。 */
|
|
54
|
+
export function sessionsRoot(home = dshHome()) {
|
|
55
|
+
return nodePath.join(graphRoot({ graphDir: "", dshHome: home }), "sessions");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 备份根:`<DSH_HOME>/chess-context/migrate-backups`(P6C-2 定稿;不再是 tmp/)。 */
|
|
59
|
+
export function backupsRoot(home = dshHome()) {
|
|
60
|
+
return nodePath.join(home, "chess-context", "migrate-backups");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* ───────────────────────────── 基础工具 ───────────────────────────── */
|
|
64
|
+
|
|
65
|
+
export function sha256(text) {
|
|
66
|
+
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 同目录 tmp + rename(Windows 上 rename 覆盖已存在文件是原子的)。 */
|
|
70
|
+
export function writeFileAtomic(file, text) {
|
|
71
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
72
|
+
nodeFs.writeFileSync(tmp, text, "utf8");
|
|
73
|
+
try {
|
|
74
|
+
nodeFs.renameSync(tmp, file);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
try {
|
|
77
|
+
nodeFs.rmSync(tmp, { force: true });
|
|
78
|
+
} catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 递归列文件(相对路径、字节、sha256),用于备份清单。 */
|
|
86
|
+
export function hashTree(root) {
|
|
87
|
+
const out = [];
|
|
88
|
+
const walk = (dir, rel) => {
|
|
89
|
+
for (const ent of nodeFs.readdirSync(dir, { withFileTypes: true })) {
|
|
90
|
+
const abs = nodePath.join(dir, ent.name);
|
|
91
|
+
const r = rel ? `${rel}/${ent.name}` : ent.name;
|
|
92
|
+
if (ent.isDirectory()) walk(abs, r);
|
|
93
|
+
else if (ent.isFile()) {
|
|
94
|
+
const buf = nodeFs.readFileSync(abs);
|
|
95
|
+
out.push({ rel: r, bytes: buf.length, sha256: crypto.createHash("sha256").update(buf).digest("hex") });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(root, "");
|
|
100
|
+
out.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ───────────────────────────── 纯函数层 ───────────────────────────── */
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 计算一个会话图里要归档的边、要保留的边、以及 from 悬空计数。**不修改图**。
|
|
108
|
+
*
|
|
109
|
+
* ★ P6C-2:`residue` **只含 settled**(`result !== "untested"`);`candidates` 单独列出且**永远不归档**。
|
|
110
|
+
* 这里再断言一次 —— 判据在 R06,但「迁移只动 settled」是迁移自己的责任。
|
|
111
|
+
*
|
|
112
|
+
* @returns {{residue: object[], candidates: object[], live: object[], fromBroken: object[]}}
|
|
113
|
+
*/
|
|
114
|
+
export function planSession(graph, { now = Date.now() } = {}) {
|
|
115
|
+
const residue = [];
|
|
116
|
+
const candidates = [];
|
|
117
|
+
const live = [];
|
|
118
|
+
const fromBroken = [];
|
|
119
|
+
for (const edge of Object.values(graph.edges ?? {})) {
|
|
120
|
+
const kind = classifyDangling(edge, { graph, now });
|
|
121
|
+
if (kind === "residue") {
|
|
122
|
+
if (edge.result === "untested") throw new Error(`migrate: untested 边 ${edge.id} 被判 residue —— 判据与 P6C-1 冲突,拒绝归档`);
|
|
123
|
+
residue.push(edge);
|
|
124
|
+
} else if (kind === "candidate") candidates.push(edge);
|
|
125
|
+
else if (kind === "live-unsettled") live.push(edge);
|
|
126
|
+
else if (kind === "from-broken") fromBroken.push(edge);
|
|
127
|
+
}
|
|
128
|
+
return { residue, candidates, live, fromBroken };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 构造 sidecar 对象(含源图 sha256 / 生成时间 / 判据 / 全部字段的 edges)。 */
|
|
132
|
+
export function buildSidecar(graph, residueEdges, { now = Date.now(), sourceSha = "", sessionId = "" } = {}) {
|
|
133
|
+
return {
|
|
134
|
+
format: SIDECAR_FORMAT,
|
|
135
|
+
version: SIDECAR_VERSION,
|
|
136
|
+
sessionId: sessionId || graph.sessionId || "",
|
|
137
|
+
source: { graph: "graph.json", sha256: sourceSha, bytes: 0 },
|
|
138
|
+
generatedAt: new Date(now).toISOString(),
|
|
139
|
+
criteria: CRITERIA,
|
|
140
|
+
edges: residueEdges.map((e) => JSON.parse(JSON.stringify(e))),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 从图里删除给定 id 的边(原地)。返回删除条数。 */
|
|
145
|
+
export function removeEdges(graph, ids) {
|
|
146
|
+
let n = 0;
|
|
147
|
+
for (const id of ids) {
|
|
148
|
+
if (graph.edges && graph.edges[id]) {
|
|
149
|
+
delete graph.edges[id];
|
|
150
|
+
n += 1;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (n > 0) graph.updatedAt = Date.now();
|
|
154
|
+
return n;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ───────────────────────────── 扫描 / dry-run ───────────────────────────── */
|
|
158
|
+
|
|
159
|
+
/** 扫描 `<root>` 下所有会话目录(每个含 graph.json)。 */
|
|
160
|
+
export function listSessions(root) {
|
|
161
|
+
if (!nodeFs.existsSync(root)) return [];
|
|
162
|
+
return nodeFs
|
|
163
|
+
.readdirSync(root, { withFileTypes: true })
|
|
164
|
+
.filter((e) => e.isDirectory())
|
|
165
|
+
.map((e) => nodePath.join(root, e.name))
|
|
166
|
+
.filter((dir) => nodeFs.existsSync(nodePath.join(dir, "graph.json")))
|
|
167
|
+
.sort();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 坏图记录(P6C-2 定稿形状):`{session, ok:false, reason}`。 */
|
|
171
|
+
function recordBad(session, err) {
|
|
172
|
+
return { session, ok: false, reason: String(err?.message ?? err) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 会话选择器:`only` 为 null/空表示全量;否则只认列表里的目录名。 */
|
|
176
|
+
function selectedSessions(root, only) {
|
|
177
|
+
const all = listSessions(root);
|
|
178
|
+
if (!only || only.length === 0) return all;
|
|
179
|
+
const set = new Set(only);
|
|
180
|
+
return all.filter((dir) => set.has(nodePath.basename(dir)));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* dry-run:只读扫描,返回全部数字。**绝不写文件**。
|
|
185
|
+
* ★ P6C-2:坏图不再静默 `continue` —— 逐会话汇总进 `badGraphs`(谁的图坏了必须能查)。
|
|
186
|
+
*/
|
|
187
|
+
export function dryRun(root, { now = Date.now(), only = null } = {}) {
|
|
188
|
+
const sessions = selectedSessions(root, only);
|
|
189
|
+
let residue = 0;
|
|
190
|
+
let candidates = 0;
|
|
191
|
+
let live = 0;
|
|
192
|
+
let fromBroken = 0;
|
|
193
|
+
let bytesBefore = 0;
|
|
194
|
+
let bytesAfter = 0;
|
|
195
|
+
let affected = 0;
|
|
196
|
+
const perSession = [];
|
|
197
|
+
const badGraphs = [];
|
|
198
|
+
for (const dir of sessions) {
|
|
199
|
+
const text = nodeFs.readFileSync(nodePath.join(dir, "graph.json"), "utf8");
|
|
200
|
+
let g;
|
|
201
|
+
try {
|
|
202
|
+
g = fromJSON(text);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
badGraphs.push(recordBad(nodePath.basename(dir), err));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const plan = planSession(g, { now });
|
|
208
|
+
candidates += plan.candidates.length;
|
|
209
|
+
live += plan.live.length;
|
|
210
|
+
fromBroken += plan.fromBroken.length;
|
|
211
|
+
bytesBefore += Buffer.byteLength(text);
|
|
212
|
+
if (plan.residue.length === 0) {
|
|
213
|
+
bytesAfter += Buffer.byteLength(text);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
affected += 1;
|
|
217
|
+
residue += plan.residue.length;
|
|
218
|
+
const clone = fromJSON(text);
|
|
219
|
+
removeEdges(clone, plan.residue.map((e) => e.id));
|
|
220
|
+
bytesAfter += Buffer.byteLength(toJSON(clone));
|
|
221
|
+
perSession.push({ session: nodePath.basename(dir), residue: plan.residue.length, bytesBefore: Buffer.byteLength(text) });
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
mode: "dry-run",
|
|
225
|
+
root,
|
|
226
|
+
sessions: sessions.length,
|
|
227
|
+
affectedSessions: affected,
|
|
228
|
+
residue,
|
|
229
|
+
candidates,
|
|
230
|
+
liveUnsettled: live,
|
|
231
|
+
fromBroken,
|
|
232
|
+
sidecarEdges: residue,
|
|
233
|
+
graphBytesBefore: bytesBefore,
|
|
234
|
+
graphBytesAfter: bytesAfter,
|
|
235
|
+
deltaBytes: bytesAfter - bytesBefore,
|
|
236
|
+
badGraphs,
|
|
237
|
+
perSession,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 新鲜度守卫:返回「最近 maxAgeMs 内被写过」的会话(apply 前必须为空)。
|
|
243
|
+
* 为什么需要:运行中的插件会在内存 flush 时把清理结果覆盖回去(实测 netCleaned=0)。
|
|
244
|
+
* @returns {string[]} 会话目录名
|
|
245
|
+
*/
|
|
246
|
+
export function freshSessions(root, { now = Date.now(), maxAgeMs = APPLY_FRESHNESS_MS, only = null } = {}) {
|
|
247
|
+
const out = [];
|
|
248
|
+
for (const dir of selectedSessions(root, only)) {
|
|
249
|
+
const st = nodeFs.statSync(nodePath.join(dir, "graph.json"));
|
|
250
|
+
// 用绝对值:NTFS 的时间戳可能比 Date.now() 超前几毫秒(时钟粒度/回拨),
|
|
251
|
+
// 只看 age>=0 会把「刚写过」误判成「旧文件」,守卫就失效了。
|
|
252
|
+
const age = now - st.mtimeMs;
|
|
253
|
+
if (Math.abs(age) < maxAgeMs) out.push(nodePath.basename(dir));
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ───────────────────────────── 备份 ───────────────────────────── */
|
|
259
|
+
|
|
260
|
+
/** 整目录备份到 `<backupRoot>/backup-<ts>/`,返回备份目录与清单。 */
|
|
261
|
+
export function backupTree(root, { backupRoot = backupsRoot(), stamp = new Date() } = {}) {
|
|
262
|
+
const ts = stamp.toISOString().replace(/[:.]/g, "-");
|
|
263
|
+
const dest = nodePath.join(backupRoot, `backup-${ts}`);
|
|
264
|
+
if (nodeFs.existsSync(dest)) throw new Error(`备份目录已存在:${dest}`);
|
|
265
|
+
nodeFs.mkdirSync(nodePath.dirname(dest), { recursive: true });
|
|
266
|
+
nodeFs.cpSync(root, dest, { recursive: true });
|
|
267
|
+
const files = hashTree(dest);
|
|
268
|
+
const sourceFiles = hashTree(root);
|
|
269
|
+
if (files.length !== sourceFiles.length) throw new Error(`备份文件数不一致:源 ${sourceFiles.length} / 备份 ${files.length}`);
|
|
270
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
271
|
+
if (files[i].rel !== sourceFiles[i].rel || files[i].sha256 !== sourceFiles[i].sha256) {
|
|
272
|
+
throw new Error(`备份校验失败:${files[i].rel}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const manifest = { createdAt: stamp.toISOString(), source: root, files, fileCount: files.length, totalBytes: files.reduce((n, f) => n + f.bytes, 0) };
|
|
276
|
+
writeFileAtomic(nodePath.join(dest, "MANIFEST.json"), JSON.stringify(manifest, null, 1));
|
|
277
|
+
return { dir: dest, manifest };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 从备份恢复(覆盖回 source),返回恢复文件数。 */
|
|
281
|
+
export function rollback(backupDir) {
|
|
282
|
+
const manifestFile = nodePath.join(backupDir, "MANIFEST.json");
|
|
283
|
+
if (!nodeFs.existsSync(manifestFile)) throw new Error(`备份缺少 MANIFEST.json:${backupDir}`);
|
|
284
|
+
const manifest = JSON.parse(nodeFs.readFileSync(manifestFile, "utf8"));
|
|
285
|
+
const wanted = new Set(manifest.files.map((f) => f.rel));
|
|
286
|
+
let restored = 0;
|
|
287
|
+
for (const f of manifest.files) {
|
|
288
|
+
if (f.rel === "MANIFEST.json") continue;
|
|
289
|
+
const src = nodePath.join(backupDir, f.rel);
|
|
290
|
+
const dst = nodePath.join(manifest.source, f.rel);
|
|
291
|
+
nodeFs.mkdirSync(nodePath.dirname(dst), { recursive: true });
|
|
292
|
+
nodeFs.copyFileSync(src, dst);
|
|
293
|
+
const sha = sha256(nodeFs.readFileSync(dst, "utf8"));
|
|
294
|
+
if (sha !== f.sha256) throw new Error(`恢复后哈希不一致:${f.rel}`);
|
|
295
|
+
restored += 1;
|
|
296
|
+
}
|
|
297
|
+
let removed = 0;
|
|
298
|
+
for (const cur of hashTree(manifest.source)) {
|
|
299
|
+
if (wanted.has(cur.rel)) continue;
|
|
300
|
+
nodeFs.rmSync(nodePath.join(manifest.source, cur.rel), { force: true });
|
|
301
|
+
removed += 1;
|
|
302
|
+
}
|
|
303
|
+
return { restored, removed, source: manifest.source };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ───────────────────────────── apply ───────────────────────────── */
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 真执行:新鲜度守卫 → 整目录备份 → 逐会话写 sidecar + 删 residue + 重建 index.md。
|
|
310
|
+
* @throws 有「最近 120s 内被写过」的会话时拒绝执行(插件可能正在跑)
|
|
311
|
+
*/
|
|
312
|
+
export function apply(root, { now = Date.now(), backupRoot = backupsRoot(), stamp = new Date(), maxAgeMs = APPLY_FRESHNESS_MS, only = null } = {}) {
|
|
313
|
+
const fresh = freshSessions(root, { now, maxAgeMs, only });
|
|
314
|
+
if (fresh.length > 0) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`migrate: 拒绝 apply —— ${fresh.length} 个会话的 graph.json 在 ${maxAgeMs / 1000}s 内被写过(${fresh.slice(0, 3).join(", ")}${fresh.length > 3 ? " …" : ""})。` +
|
|
317
|
+
" 运行中的插件会用内存图 flush 覆盖磁盘清理(实测 netCleaned=0);先停插件窗口再重试。",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const dry = dryRun(root, { now, only });
|
|
321
|
+
const backup = backupTree(root, { backupRoot, stamp });
|
|
322
|
+
let archived = 0;
|
|
323
|
+
let deleted = 0;
|
|
324
|
+
let bytesBefore = 0;
|
|
325
|
+
let bytesAfter = 0;
|
|
326
|
+
const perSession = [];
|
|
327
|
+
const badGraphs = [...dry.badGraphs];
|
|
328
|
+
for (const dir of selectedSessions(root, only)) {
|
|
329
|
+
const graphFile = nodePath.join(dir, "graph.json");
|
|
330
|
+
const text = nodeFs.readFileSync(graphFile, "utf8");
|
|
331
|
+
let g;
|
|
332
|
+
try {
|
|
333
|
+
g = fromJSON(text);
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (!badGraphs.some((b) => b.session === nodePath.basename(dir))) badGraphs.push(recordBad(nodePath.basename(dir), err));
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const plan = planSession(g, { now });
|
|
339
|
+
if (plan.residue.length === 0) continue;
|
|
340
|
+
const before = Buffer.byteLength(text);
|
|
341
|
+
const sourceSha = sha256(text);
|
|
342
|
+
const sidecar = buildSidecar(g, plan.residue, { now, sourceSha, sessionId: nodePath.basename(dir) });
|
|
343
|
+
sidecar.source.bytes = before;
|
|
344
|
+
writeFileAtomic(nodePath.join(dir, SIDECAR_NAME), JSON.stringify(sidecar, null, 1));
|
|
345
|
+
const n = removeEdges(g, plan.residue.map((e) => e.id));
|
|
346
|
+
const next = toJSON(g);
|
|
347
|
+
writeFileAtomic(graphFile, next);
|
|
348
|
+
writeFileAtomic(nodePath.join(dir, "index.md"), renderIndexMd(g));
|
|
349
|
+
archived += plan.residue.length;
|
|
350
|
+
deleted += n;
|
|
351
|
+
bytesBefore += before;
|
|
352
|
+
bytesAfter += Buffer.byteLength(next);
|
|
353
|
+
perSession.push({ session: nodePath.basename(dir), archived: plan.residue.length, deleted: n, bytesBefore: before, bytesAfter: Buffer.byteLength(next) });
|
|
354
|
+
}
|
|
355
|
+
const summary = {
|
|
356
|
+
mode: "apply",
|
|
357
|
+
root,
|
|
358
|
+
backupDir: backup.dir,
|
|
359
|
+
sessions: dry.sessions,
|
|
360
|
+
affectedSessions: perSession.length,
|
|
361
|
+
archived,
|
|
362
|
+
deleted,
|
|
363
|
+
candidates: dry.candidates,
|
|
364
|
+
liveUnsettled: dry.liveUnsettled,
|
|
365
|
+
fromBroken: dry.fromBroken,
|
|
366
|
+
graphBytesBefore: bytesBefore,
|
|
367
|
+
graphBytesAfter: bytesAfter,
|
|
368
|
+
deltaBytes: bytesAfter - bytesBefore,
|
|
369
|
+
badGraphs,
|
|
370
|
+
perSession,
|
|
371
|
+
};
|
|
372
|
+
writeFileAtomic(nodePath.join(backup.dir, "APPLY.json"), JSON.stringify(summary, null, 1));
|
|
373
|
+
return summary;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* ───────────────────────────── verify ───────────────────────────── */
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 只读校验:
|
|
380
|
+
* ① sidecar 行数 == 被删边数(sidecar 存在 ⇒ graph 里该会话 residue 必须 0);
|
|
381
|
+
* ② fromBroken 与 apply 时一致;
|
|
382
|
+
* ③ residue = 0;candidates 必须与 apply 时一致(P6C-2:candidates 一条都不许动);
|
|
383
|
+
* ④ 固定种子抽 10 条 sidecar 边与**备份图**里的同 id 边逐字段相同。
|
|
384
|
+
*/
|
|
385
|
+
export function verify(root, { now = Date.now(), backupDir = "", seed = 20261030, only = null } = {}) {
|
|
386
|
+
const problems = [];
|
|
387
|
+
const sessions = selectedSessions(root, only);
|
|
388
|
+
let sidecarEdges = 0;
|
|
389
|
+
let residue = 0;
|
|
390
|
+
let candidates = 0;
|
|
391
|
+
let fromBroken = 0;
|
|
392
|
+
let checkedSessions = 0;
|
|
393
|
+
const samples = [];
|
|
394
|
+
const badGraphs = [];
|
|
395
|
+
const backupGraphs = new Map();
|
|
396
|
+
if (backupDir && nodeFs.existsSync(backupDir)) {
|
|
397
|
+
for (const dir of listSessions(backupDir)) {
|
|
398
|
+
const p = nodePath.join(dir, "graph.json");
|
|
399
|
+
try {
|
|
400
|
+
backupGraphs.set(nodePath.basename(dir), JSON.parse(nodeFs.readFileSync(p, "utf8")));
|
|
401
|
+
} catch {
|
|
402
|
+
/* ignore */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const dir of sessions) {
|
|
407
|
+
const sid = nodePath.basename(dir);
|
|
408
|
+
let g;
|
|
409
|
+
try {
|
|
410
|
+
g = fromJSON(nodeFs.readFileSync(nodePath.join(dir, "graph.json"), "utf8"));
|
|
411
|
+
} catch (err) {
|
|
412
|
+
badGraphs.push(recordBad(sid, err));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const plan = planSession(g, { now });
|
|
416
|
+
residue += plan.residue.length;
|
|
417
|
+
candidates += plan.candidates.length;
|
|
418
|
+
fromBroken += plan.fromBroken.length;
|
|
419
|
+
const sidecarFile = nodePath.join(dir, SIDECAR_NAME);
|
|
420
|
+
if (!nodeFs.existsSync(sidecarFile)) continue;
|
|
421
|
+
checkedSessions += 1;
|
|
422
|
+
let sidecar;
|
|
423
|
+
try {
|
|
424
|
+
sidecar = JSON.parse(nodeFs.readFileSync(sidecarFile, "utf8"));
|
|
425
|
+
} catch (err) {
|
|
426
|
+
problems.push(`${sid}: sidecar 不是合法 JSON(${err.message})`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (sidecar.format !== SIDECAR_FORMAT || sidecar.version !== SIDECAR_VERSION) problems.push(`${sid}: sidecar 格式/版本不对`);
|
|
430
|
+
if (!Array.isArray(sidecar.edges)) problems.push(`${sid}: sidecar.edges 不是数组`);
|
|
431
|
+
const ids = new Set((sidecar.edges ?? []).map((e) => e.id));
|
|
432
|
+
sidecarEdges += ids.size;
|
|
433
|
+
for (const id of ids) if (g.edges[id]) problems.push(`${sid}: 归档后 graph 仍含 ${id}`);
|
|
434
|
+
if (plan.residue.length !== 0) problems.push(`${sid}: 归档后仍有 ${plan.residue.length} 条 residue`);
|
|
435
|
+
if (backupGraphs.has(sid)) {
|
|
436
|
+
const before = backupGraphs.get(sid);
|
|
437
|
+
for (const e of sidecar.edges ?? []) {
|
|
438
|
+
if (!before.edges[e.id]) problems.push(`${sid}: 备份图里找不到 ${e.id}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (backupDir) {
|
|
443
|
+
const applyFile = nodePath.join(backupDir, "APPLY.json");
|
|
444
|
+
if (nodeFs.existsSync(applyFile)) {
|
|
445
|
+
const applyInfo = JSON.parse(nodeFs.readFileSync(applyFile, "utf8"));
|
|
446
|
+
if (applyInfo.fromBroken !== fromBroken) problems.push(`fromBroken 变了:apply 时 ${applyInfo.fromBroken},现在 ${fromBroken}`);
|
|
447
|
+
if (typeof applyInfo.candidates === "number" && applyInfo.candidates !== candidates) {
|
|
448
|
+
problems.push(`candidates 变了:apply 时 ${applyInfo.candidates},现在 ${candidates}(P6C-2 要求候选一条不动)`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
let state = seed >>> 0;
|
|
453
|
+
const rand = () => {
|
|
454
|
+
state = (state * 1664525 + 1013904223) >>> 0;
|
|
455
|
+
return state / 0x100000000;
|
|
456
|
+
};
|
|
457
|
+
const all = [];
|
|
458
|
+
for (const dir of sessions) {
|
|
459
|
+
const f = nodePath.join(dir, SIDECAR_NAME);
|
|
460
|
+
if (!nodeFs.existsSync(f)) continue;
|
|
461
|
+
const sc = JSON.parse(nodeFs.readFileSync(f, "utf8"));
|
|
462
|
+
for (const e of sc.edges ?? []) all.push({ sid: nodePath.basename(dir), edge: e });
|
|
463
|
+
}
|
|
464
|
+
for (let i = 0; i < Math.min(10, all.length); i += 1) {
|
|
465
|
+
const pick = all[Math.floor(rand() * all.length)];
|
|
466
|
+
const before = backupGraphs.get(pick.sid);
|
|
467
|
+
const same = before?.edges?.[pick.edge.id];
|
|
468
|
+
if (!same) {
|
|
469
|
+
problems.push(`抽样 ${pick.sid}/${pick.edge.id}: 备份里找不到`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const a = JSON.stringify(same);
|
|
473
|
+
const b = JSON.stringify(pick.edge);
|
|
474
|
+
if (a !== b) problems.push(`抽样 ${pick.sid}/${pick.edge.id}: 字段不一致`);
|
|
475
|
+
samples.push({ session: pick.sid, edge: pick.edge.id, identical: a === b });
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
mode: "verify",
|
|
479
|
+
root,
|
|
480
|
+
sessions: sessions.length,
|
|
481
|
+
withSidecar: checkedSessions,
|
|
482
|
+
sidecarEdges,
|
|
483
|
+
residue,
|
|
484
|
+
candidates,
|
|
485
|
+
fromBroken,
|
|
486
|
+
samples,
|
|
487
|
+
badGraphs,
|
|
488
|
+
problems,
|
|
489
|
+
ok: problems.length === 0 && residue === 0,
|
|
490
|
+
};
|
|
491
|
+
}
|