dsh-gokifu-compact 1.11.2 → 1.11.4

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,119 @@
1
1
  # 更新记录
2
2
 
3
+ ## 1.11.4 — 2026-10-31
4
+
5
+ ### 发布脚本加固:坏 remote 在预演阶段就红,git 步骤事务化
6
+
7
+ **背景是一起真实事故**:1.11.3 发布时 `scripts/release.mjs --yes --npm` 返回 **EXIT 1** ——
8
+ 仓库 `remote.origin.url` 被写成 `git+https://…`(那串正是 `package.json` 的 `repository.url`,
9
+ 在 npm 里是规范写法,**但不是合法的 git remote**)。旧体检用正则匹配 `github.com:` 照样通过;
10
+ 真做时 `git push` 全崩,**而脚本继续建 Release、继续 `npm publish`**,留下
11
+ 「npm 已发 / Release 已建 / main 没推 / tag 指向旧 commit」的半途状态。
12
+
13
+ **新增 `scripts/release-lib.mjs`**(纯逻辑、可单测、零副作用)
14
+
15
+ | 导出 | 作用 |
16
+ |---|---|
17
+ | `parseRepo(url)` | 只认 scp 式 `git@github.com:o/r(.git)`、`ssh://git@github.com/o/r`、`https://github.com/o/r`;`git+*`、`file://`、非 GitHub、空 → `null` |
18
+ | `classifyRemote(url)` | **先判 scheme 再判仓库**;`git+…` 直接拒,并给出可执行的修法(`git remote set-url origin git@github.com:<owner>/<repo>.git`) |
19
+ | `runTransaction(steps)` | 顺序执行 + **首步失败即中止**,返回 `{ok, done, failed, error, aborted}` |
20
+
21
+ **`scripts/release.mjs` 体检从 5 项增到 8 项**
22
+
23
+ - ⑤ **remote 必须是合法 GitHub URL**(不再「正则匹配到 github.com 就算过」)
24
+ - ⑥ **`git ls-remote --heads origin` 必须成功** —— 远端不可达直接红
25
+ - ⑦ **`git fetch` 失败从 note 改成 red** —— 旧版把「不知道远端正不正常」当成「跳过检查继续发版」
26
+ - ⑧ **tag 已存在时区分两种情况**:指向本 commit ⇒「不能发两次」;**指向别的 commit**(1.11.3 的现场:Release API 自动建了轻量 tag)⇒ **不自动 force**,报错并给 `git push --force origin refs/tags/<tag>:refs/tags/<tag>` 的人工修法
27
+
28
+ **真做改成事务**:`runTransaction([push 分支, tag -a && push tag])` → 任一失败即 `abort()`,
29
+ 打印「已完成 / 未执行:tag · npm · Release」;**`npm publish` 移到 Release 之前** ——
30
+ npm 失败就不建 Release(避免「Release 说有、npm 上没有」);Release 失败仍 exit 1。
31
+
32
+ **`scripts/check-maintenance.mjs` 也补了两项**(日常维护就能提前发现)
33
+
34
+ - ⑧ `remote.origin` 必须是合法 GitHub URL(复用 `classifyRemote`,同一份判据)
35
+ - ⑨ `git rev-parse --show-toplevel` 必须等于 cwd(防串仓 —— `ROOT` 取 `process.cwd()`,
36
+ 在子目录或别人的仓库里跑会把版本号/CHANGELOG/tag 写错对象而看起来一切正常)
37
+
38
+ **验证**
39
+
40
+ - 全量:**473 passed / 0 failed**(36 个测试文件;463 + 10 条 `release-hardening`)
41
+ - `check-maintenance`:**9 项**
42
+ - `npm pack --dry-run --json`:**71 文件**(70 + `scripts/release-lib.mjs`;`release.mjs` 运行时依赖它,必须随包)
43
+ - **预演**:release-ready 的临时克隆上 `node scripts/release.mjs` → **EXIT 0**;
44
+ 同一克隆把 remote 换成 `git+https://…` → **EXIT 1**,报错点名协议并给修法
45
+
46
+ **未验证**
47
+
48
+ - 未改 `.github/workflows/release.yml`(存在时脚本走「推 tag 自动建 Release」分支,本次未触碰)。
49
+ - `check-maintenance` 的 ⑧ 只判 URL 形状,**不探远端可达性**(保持「不需要网络」的定位;可达性由 `release.mjs` 的 ⑥ 在发版时把关)。
50
+
51
+ ## 1.11.3 — 2026-10-31
52
+
53
+ ### P0-1 · `exit` 判据收紧:不再被正文子串误判
54
+
55
+ 旧实现用 `slice(-200)` 在尾部 200 字符里**子串搜索** `[exit code: N]` ⇒ 命令**输出正文里提到**这串就误判成「有退出码」,
56
+ 且「标记之后还有输出」时前后结论不一致。
57
+
58
+ - 新增 `lastLineExitCode()`:取**末条非空行**,**整行等于** `[exit code: N]` 才算有退出码;否则视为 no-exit。
59
+ - `verify.mjs` 顶部注释同步;`_internal` 导出 `LOCAL_MUTATE_TOOLS` / `lastLineExitCode`。
60
+ - 测试 +3(`verifier` OPT-V-1 / OPT-V-2、`weak-shell` S2b-14);变异:退回 `slice(-200)` ⇒ OPT-V-1 变红。
61
+
62
+ ### P0-2 · `local-mutate` 从只读表拆出(不再被当成「只读工具」)
63
+
64
+ `todo_write` / `update_goal` / `create_goal` / `hindsight_ingest_document` / `team_task_*` / `send_message` /
65
+ `spawn_teammate` / `subagent` / `schedule_*` / `job_kill` … 这些**会改状态但不是文件写**的工具,原先把它们混在只读表里,
66
+ 分类结果自相矛盾。现在:
67
+
68
+ - 新增 `LOCAL_MUTATE_TOOLS`;`toolClass()` 返回 **`local-mutate`**(与 `read-only` / `write` 并列)
69
+ - `classify()` 对 local-mutate 给 **`unverified` + kind `none`,且不带 `weak`**(它既不是「进展」也不是「失败」)
70
+ - `WRITE_TOOLS` / `present` 与 `isError` 的**优先级不变**
71
+ - 变异:把 local-mutate 放回只读表 ⇒ S2b-14 + OPT-V-2 变红
72
+
73
+ ### 外部入口 · 新增 `dsh-gokifu-compact/graph` 与 `/persist`
74
+
75
+ 1.11.2 的 README 教外部工具去读 `node_modules/dsh-gokifu-compact/src/rooms/kifu-model.mjs` —— 那是**深层路径**,
76
+ `exports` 里没有 ⇒ 现代 ESM 解析会直接拒绝。现在补两个正式入口:
77
+
78
+ | 入口 | 目标 | 用途 |
79
+ |---|---|---|
80
+ | `dsh-gokifu-compact/graph` | `src/rooms/kifu-model.mjs` | `fromJSON` / `toJSON` / `decodeDeltaFrames` / `applyDelta` / `renderIndexMd` … |
81
+ | `dsh-gokifu-compact/persist` | `src/rooms/kifu-persist.mjs` | `graphRoot` / `sessionPaths` / `createPersistStore` / `load` / `flush` … |
82
+
83
+ - 新增 `test/package-exports.test.mjs`(**7 条**):声明齐全、子路径可解析、真实 `import` 成功、导出面非空、不泄漏内部路径
84
+ - README 的「怎么拿最新状态」一行改为指向这两个入口
85
+
86
+ ### 零失败边最小注入(OPTIONS 之后又一刀「别让热清单吃白饭」)
87
+
88
+ **问题**:只读统计 **167 个真实会话**,**42 个(25.1%)零失败边** —— 它们照样要付一份完整热清单的 token。
89
+
90
+ **改法**:
91
+
92
+ - `ledger.mjs` 新增 `renderMinimalLedger`:`<chess-ledger>` 内只有三行 —— `GOAL` / `CONSTRAINTS: N` / `NO_FAILED_ATTEMPTS_YET`;
93
+ **`renderLedger` 一个字节不动**(167 会话新旧对比 `byteDiff=0`)
94
+ - `hot-inject.decideInjection`:零失败边且 `hasLedgerState` ⇒ 用最小渲染走**原注入流程**(digest / 限流 / replace / fold 补回全不变);空图仍 `skip no-ledger`
95
+ - 实测(42 个零失败会话):最小注入 **avg 25.4 tok / max 57**(同批会话的完整最小档 avg 386 / max 497)
96
+ - 分布:root 覆盖 **42/42**、constraints **32/42**、完全空 **0**(不会出现「三行全空」的哑注入)
97
+ - 测试 +4:OPT-E-1 触发与三项内容 / OPT-E-2 空图 skip / OPT-E-3 有失败边时字节不变(sha 钉死)/ OPT-E-4 fold 补回
98
+ - 变异:零失败退回 skip ⇒ E-1/E-4 红;删 `NO_FAILED_ATTEMPTS_YET` 行 ⇒ E-1/E-4 红
99
+
100
+ ### tier-2 行为矩阵
101
+
102
+ - 新增 `test/tier2.test.mjs`(**6 条**,engine 零改动)+ `engine-harness` 导出 4 个 helper —— 把此前「文档说有、代码没测」的第二档行为逐条核实。
103
+
104
+ ### 验证
105
+
106
+ - 全量:**463 passed / 0 failed**(36 个测试文件;443 + 7 exports + 3 verify + 6 tier2 + 4 hot-inject)
107
+ - `rooms` **15/0** · `hot-inject` **12/0** · `verify-install` **16/16**
108
+ - `bench 2000`:**351 tokens** / 1.336 ms(81,260 / 386,945 字符、1.41 MB、2001 节点)
109
+ - `npm pack --dry-run --json`:**70 文件**(数量不变)/ 562,344 B;`test/`·`tmp/` 命中 0
110
+
111
+ **未验证(必须知道)**
112
+
113
+ - 最小注入的**运行期收益未在真机观测** —— 25.4 tok 是离线夹具口径,167 会话统计是只读扫描。
114
+ - tier-2 矩阵跑在假 ctx 上,不是真机行为。
115
+ - README 里的「真实包 8/8 †」「真实进程 fold 3~6/6 †」带 **†** = 历史口径,**本版未复跑**。
116
+
3
117
  ## 1.11.2 — 2026-10-30
4
118
 
5
119
  ### 本包改成 DSH bundle(UI 挂载不再需要用户手改 YAML)
package/README.md CHANGED
@@ -1,20 +1,24 @@
1
1
  # dsh-GOkifu-compact
2
2
 
3
- > 当前版本:**`1.11.2`** · 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.4`** · 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
- **DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 323 tokens 的热清单」。**
5
+ **DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 351 tokens 的热清单」。**
6
6
 
7
7
  ```
8
- 热清单 323 tokens (2000 边压力测试;旧版全量渲染 28,800)
9
- 失败保留 8 / 8 (真实模型对照;官方 8 段模板 4.67 / 8)
10
- 清单开销 0.2% of prompt (v1.3.1 起;事故会话的旧版是 21.3%)
11
- 验证 443 项单测 · 真实包 8/8 · 真实进程 fold 3~6/6
8
+ 热清单 351 tokens (2000 边合成夹具 · L5 降级档 · 2026-10-30/10-31 两次实测同值;旧版全量 28,800)
9
+ 失败保留 8 / 8 (历史口径 †;官方 8 段模板 4.67 / 8)
10
+ 清单开销 未复现/待测 (原载 0.2% = 历史单会话 v1.3.1 后实测;无复跑命令、无对照组;源 evidence/真实计费核对.md)
11
+ 验证 473 项单测 · 真实包 8/8 † · 真实进程 fold 3~6/6 †
12
12
  ```
13
13
 
14
14
  GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不只有落子顺序,还有每一手的评语。
15
15
  这个插件把 agent 的每一步变成一手棋(节点)、把当时的思考变成评语(目标 / 约束 / 失败原因),
16
16
  整个任务变成一局可以复盘的棋。**棋谱是给模型用的,不是给人看的** —— 热清单里的每个字都为模型决策服务。
17
17
 
18
+ > **† 历史口径(未复现)**:`失败保留 8/8`、`真实包 8/8`、`真实进程 fold 3~6/6` 都是**早期真机测量**,**本轮没有复跑命令、没有复现**,不要当现行指标引用。
19
+ >
20
+ > **351 tokens 的口径**:这是 **2000 边合成夹具**在 **L5 降级档**下的结果,**不是真实会话均值**。真实会话里预算按 `字符数/3` 估算,实测**系统性低估 5.2%~22.1%(中位 9.7%)**(见 `计划表.md` 的 OPT-TOKENS 节);**真实会话口径尚未校准** —— 正解是引入宿主 tokenizer(`ctx.tokenMeter`)后重测,**未做**。
21
+
18
22
  ---
19
23
 
20
24
  ## 功能
@@ -48,7 +52,7 @@ GO = 围棋,kifu = 棋谱,compact = 压缩。一局棋下完,棋谱上不
48
52
  | 压缩范围 | 从 surface 第一个非 system 节点起(**含上次 checkpoint**) | 上一个 checkpoint **之后** |
49
53
  | 保留率 | 每轮重摘 → 按 ρ^k 衰减(实测安全规则 5 轮后剩 10%) | 一段内容只摘一次 |
50
54
  | 失败记录 | 融进散文摘要,模型可以丢 | 规则抽取 + 逐字注入,模型无权丢 |
51
- | 热上下文 | 无 | 323 tokens / 步,带语义标记与报错原文 |
55
+ | 热上下文 | 无 | 351 tokens / 步(**2000 边合成夹具 · L5 降级档**;真实会话口径未校准),带语义标记与报错原文 |
52
56
 
53
57
  ### ⚠ 棋谱落盘:`graph.json` 的语义(1.9.2 起)
54
58
 
@@ -67,7 +71,7 @@ graph.json = 最新 graph.json = 上一个回合边界
67
71
 
68
72
  | 你要什么 | 怎么拿 |
69
73
  |---|---|
70
- | **最新状态**(推荐) | **基线 + 增量一起读**:`fromJSON(graph.json 文本)` → 用基线内容哈希 `decodeDeltaFrames(delta 文本, baseHash)` → 逐帧 `applyDelta(g, frame)`。三个函数都在 `src/graph.mjs`(插件源码随包发布:`node_modules/dsh-gokifu-compact/src/graph.mjs`) |
74
+ | **最新状态**(推荐) | **基线 + 增量一起读**:`fromJSON(graph.json 文本)` → 用基线内容哈希 `decodeDeltaFrames(delta 文本, baseHash)` → 逐帧 `applyDelta(g, frame)`。三个函数都从包入口 `dsh-gokifu-compact/graph` 导出(源码 `src/rooms/kifu-model.mjs`,随包发布);落盘/读盘侧(`graphRoot` / `sessionPaths` / `createPersistStore` 等)走 `dsh-gokifu-compact/persist` |
71
75
  | 上一回合基线(做 diff / 审计) | 直接读 `graph.json` 可以,但**要说明「这是基线不是最新」** |
72
76
  | 崩溃安全 | 每帧带 `baseHash`(generation)+ `checksum31`:半写帧解码时就地停止,加载时把 delta 截到最后一条好帧;**旧 generation 的帧整体忽略** |
73
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-gokifu-compact",
3
- "version": "1.11.2",
3
+ "version": "1.11.4",
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",
@@ -19,6 +19,8 @@
19
19
  },
20
20
  "./engine": "./src/engine.mjs",
21
21
  "./client": "./lib/client.js",
22
+ "./graph": "./src/rooms/kifu-model.mjs",
23
+ "./persist": "./src/rooms/kifu-persist.mjs",
22
24
  "./package.json": "./package.json",
23
25
  "./cordis.patch.yml": "./cordis.patch.yml"
24
26
  },
@@ -18,6 +18,7 @@
18
18
  import { readFileSync, existsSync } from "node:fs"
19
19
  import { join, resolve } from "node:path"
20
20
  import { spawnSync } from "node:child_process"
21
+ import { classifyRemote } from "./release-lib.mjs"
21
22
 
22
23
  const ROOT = resolve(process.argv[2] ?? ".")
23
24
  const results = []
@@ -141,6 +142,28 @@ ok(usesAnnotated,
141
142
  "用 lightweight tag ⇒ Release 页没有 tag 说明、`git cat-file -t` 只看到 commit,历史无法解释",
142
143
  '把 scripts/release.mjs 里的 "git tag <tag>" 改成 "git tag -a <tag> -m \\"release <tag>\\""')
143
144
 
145
+ // ── ⑧ remote 必须是合法的 GitHub URL ─────────────────────────────────
146
+ // 2026-10-31 事故:`remote.origin.url` 被写成 `git+https://github.com/…`(从 package.json 的
147
+ // repository.url 抄来的写法)。旧判据用正则匹配 `github.com:` ⇒ 照样通过;真做时 `git push`
148
+ // 全崩(git 去找 remote helper `git-remote-git+https`)。这里复用 release-lib 的同一份判据,
149
+ // 让日常维护就能提前发现坏 remote,而不是等到发版那一刻。
150
+ const remoteUrl = git(["config", "--get", "remote.origin.url"]) ?? ""
151
+ const remoteCheck = classifyRemote(remoteUrl)
152
+ ok(remoteCheck.ok,
153
+ "remote.origin 是合法的 GitHub URL",
154
+ remoteCheck.ok ? "" : remoteCheck.reason,
155
+ "git remote set-url origin git@github.com:<owner>/<repo>.git")
156
+
157
+ // ── ⑨ 当前目录必须就是仓库根(防串仓)──────────────────────────────
158
+ // `ROOT` 取 `process.cwd()`:若在子目录或另一个仓库里跑,版本号 / CHANGELOG / tag
159
+ // 全会指向错误的对象,而输出看起来一切正常。
160
+ const topLevel = git(["rev-parse", "--show-toplevel"])
161
+ const sameRepo = !!topLevel && resolve(topLevel) === resolve(ROOT)
162
+ ok(sameRepo,
163
+ "当前目录就是仓库根(git rev-parse --show-toplevel === cwd)",
164
+ sameRepo ? "" : `cwd=${ROOT},仓库根=${topLevel || "(取不到)"} —— 发布脚本会写错对象`,
165
+ "cd 到 git rev-parse --show-toplevel 打印的那个目录再跑")
166
+
144
167
  // ── 输出 ──────────────────────────────────────────────────────────────
145
168
 
146
169
  report()
@@ -0,0 +1,103 @@
1
+ /**
2
+ * release-lib.mjs —— `release.mjs` 的纯逻辑层(可单测,零副作用)
3
+ *
4
+ * 为什么单独拆出来:2026-10-31 的真实事故 —— 仓库 `remote.origin.url` 被写成
5
+ * `git+https://github.com/…`(非法 scheme),`release.mjs` 的体检**照样通过**(正则能匹配
6
+ * `github.com:`),直到真做 `git push` 才炸;而当时的执行顺序是「push → tag → **Release → npm**」,
7
+ * 于是留下「npm 已发、Release 已建、但 main 没推、tag 指向错误 commit」的半途状态。
8
+ *
9
+ * 本文件把两件事变成可测的纯函数:
10
+ * ① `classifyRemote()`:**先判 scheme 再判仓库**,`git+*` 直接拒,并给出可执行的修法;
11
+ * ② `runTransaction()`:**顺序执行 + 首个失败即中止**,让「git 步骤成功之后才允许副作用」成为结构保证,
12
+ * 而不是散落在调用点的 if。
13
+ */
14
+
15
+ /** 非法 remote scheme 前缀:`git+https:` / `git+ssh:` / `git+file:` … */
16
+ const BAD_SCHEME_RE = /^git\+/i
17
+
18
+ /** GitHub 仓库的三种合法写法:scp 式 ssh、`ssh://`、`https://`。 */
19
+ const SSH_RE = /^[a-z0-9._-]+@github\.com:([^/\s]+)\/([^/\s]+)$/i
20
+ const SSH_URL_RE = /^ssh:\/\/(?:[a-z0-9._-]+@)?github\.com(?::\d+)?\/([^/\s]+)\/([^/\s]+)\/?$/i
21
+ const HTTPS_RE = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/?$/i
22
+
23
+ /**
24
+ * 解析 GitHub remote。
25
+ * @returns {{owner:string, repo:string}|null} 不是 GitHub 或用了非法 scheme 时返回 null
26
+ */
27
+ export function parseRepo(url) {
28
+ const raw = String(url ?? "").trim()
29
+ if (!raw || BAD_SCHEME_RE.test(raw)) return null
30
+ for (const re of [SSH_RE, SSH_URL_RE, HTTPS_RE]) {
31
+ const m = raw.match(re)
32
+ if (m) {
33
+ const repo = m[2].replace(/\.git$/i, "")
34
+ if (!m[1] || !repo) return null
35
+ return { owner: m[1], repo }
36
+ }
37
+ }
38
+ return null
39
+ }
40
+
41
+ /**
42
+ * 判断 remote 能不能用,并给出人话理由。
43
+ * @returns {{ok:true, repo:{owner:string,repo:string}, url:string} | {ok:false, reason:string}}
44
+ */
45
+ export function classifyRemote(url) {
46
+ const raw = String(url ?? "").trim()
47
+ if (!raw) {
48
+ return { ok: false, reason: "remote.origin.url 为空 —— 没有远端就发不了版(git remote add origin …)" }
49
+ }
50
+ if (BAD_SCHEME_RE.test(raw)) {
51
+ const scheme = raw.split(":").slice(0, 2).join(":").split("+")[0]
52
+ return {
53
+ ok: false,
54
+ reason:
55
+ `remote.origin.url 用了非法协议(${raw.slice(0, raw.indexOf(":") + 1)}…)—— ` +
56
+ `git 会去找 remote helper「git-remote-${scheme}+」并报 "is not a git command"。` +
57
+ `改成 git@github.com:<owner>/<repo>.git(推荐)或 https://github.com/<owner>/<repo>.git:` +
58
+ `git remote set-url origin git@github.com:<owner>/<repo>.git`,
59
+ }
60
+ }
61
+ const repo = parseRepo(raw)
62
+ if (!repo) {
63
+ return { ok: false, reason: `remote.origin.url 不是 GitHub 仓库(${raw})—— 这个脚本只处理 GitHub` }
64
+ }
65
+ return { ok: true, repo, url: raw }
66
+ }
67
+
68
+ /**
69
+ * 顺序执行一串步骤,**第一个失败就停**,后面的步骤一律不执行。
70
+ *
71
+ * 这就是「git push 成功之后才允许 npm / Release」的结构保证:把有副作用的动作排进 steps,
72
+ * 前面任何一步抛错,`ok` 就是 false,调用方据此直接退出(不再执行剩余副作用)。
73
+ *
74
+ * @param {{name:string, run:() => any}[]} steps
75
+ * @param {{
76
+ * onStart?:(step:{name:string}, index:number)=>void,
77
+ * onSettled?:(step:{name:string}, error:any, value:any)=>void,
78
+ * }} [opts]
79
+ * @returns {{ok:boolean, done:string[], failed:string|null, error:any, aborted:string[]}}
80
+ */
81
+ export function runTransaction(steps, opts = {}) {
82
+ const list = Array.isArray(steps) ? steps : []
83
+ const done = []
84
+ for (let i = 0; i < list.length; i++) {
85
+ const step = list[i]
86
+ if (opts.onStart) opts.onStart(step, i)
87
+ try {
88
+ const value = step.run()
89
+ if (opts.onSettled) opts.onSettled(step, null, value)
90
+ done.push(step.name)
91
+ } catch (error) {
92
+ if (opts.onSettled) opts.onSettled(step, error, undefined)
93
+ return {
94
+ ok: false,
95
+ done,
96
+ failed: step.name,
97
+ error,
98
+ aborted: list.slice(i + 1).map((s) => s.name),
99
+ }
100
+ }
101
+ }
102
+ return { ok: true, done, failed: null, error: null, aborted: [] }
103
+ }
@@ -13,8 +13,12 @@
13
13
  // 体检项(任一不过就退出码 1,且 --yes 也不动手):
14
14
  // ① package.json 有 version ② CHANGELOG 有这一版那一段
15
15
  // ③ 工作区干净 ④ 当前在默认分支
16
- // ⑤ 不落后远端 ⑥ tag 还没被占
17
- // ⑦ 推得上去(远端可达)
16
+ // ⑤ remote 是合法 GitHub URL(拒 git+https 之类) ⑥ 远端可达(ls-remote 必须成功)
17
+ // ⑦ 不落后远端(fetch 必须成功,失败=红,不再吞成 note) ⑧ tag 还没被占
18
+ //
19
+ // 执行顺序(**事务化**:前一步失败 ⇒ 后面一步根本不执行):
20
+ // push 分支 → tag -a + push tag → npm publish(--npm 时)→ GitHub Release
21
+ // ⇒ git 步骤没过,绝不会出现「npm 已发 / Release 已建,但 main 没推」的半途状态。
18
22
  //
19
23
  // 需要 GitHub token 才能建 Release:
20
24
  // GITHUB_TOKEN / GH_TOKEN 环境变量。没有也能跑 —— 它会打印手工建 Release 的命令。
@@ -22,6 +26,7 @@
22
26
  import { execSync, execFileSync } from "node:child_process"
23
27
  import { readFileSync, existsSync } from "node:fs"
24
28
  import { join } from "node:path"
29
+ import { classifyRemote, runTransaction } from "./release-lib.mjs"
25
30
 
26
31
  const ROOT = process.cwd()
27
32
  const argv = new Set(process.argv.slice(2))
@@ -136,40 +141,70 @@ if (branch) {
136
141
  if (!dirty) bad("工作区有未提交的改动 —— 发布的内容必须和提交一致")
137
142
  }
138
143
 
139
- /** owner/repo —— 支持 ssh 与 https 两种 remote */
140
- function parseRepo(url) {
141
- const m = String(url).match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
142
- return m ? { owner: m[1], repo: m[2] } : null
143
- }
144
- const gh = remote ? parseRepo(remote) : null
145
- if (!gh) bad(`remote.origin 不是 GitHub 仓库(${remote || "空"})—— 这个脚本只处理 GitHub`)
144
+ /**
145
+ * remote 能不能用 —— 先判 scheme 再判仓库。
146
+ * 这一层是 2026-10-31 事故的直接产物:`git+https://…` 曾被当成合法 GitHub URL,
147
+ * 体检全绿、push 全崩。判定逻辑在 release-lib.mjs 里(可单测)。
148
+ */
149
+ const remoteCheck = remote ? classifyRemote(remote) : { ok: false, reason: "没有 remote.origin" }
150
+ const gh = remoteCheck.ok ? remoteCheck.repo : null
151
+ if (!remoteCheck.ok) bad(remoteCheck.reason)
146
152
 
147
153
  let behind = 0
148
154
  if (gh && branch) {
149
- try {
150
- sh("git fetch origin --quiet")
151
- const counts = sh(`git rev-list --left-right --count origin/${branch}...HEAD`)
152
- const [b, a] = counts.split(/\s+/).map(Number)
153
- behind = b || 0
154
- if (behind > 0) bad(`落后远端 ${behind} 个提交 —— 先 pull / merge,别在旧基线上发版`)
155
- if (a > 0) note(`本地领先远端 ${a} 个提交(发布时会一起推上去)`)
156
- } catch {
157
- note("git fetch 失败 —— 跳过「是否落后远端」检查")
155
+ // ⑤/⑥ 远端可达:ls-remote 必须成功。失败 ⇒ 红(不许「跳过检查」继续发版)。
156
+ if (!shOk("git ls-remote --heads origin")) {
157
+ bad(
158
+ "git ls-remote origin 失败 —— 远端不可达(URL 写错 / 凭据失效 / 网络不通)。" +
159
+ "先跑 `git ls-remote origin` 亲自确认能通,再发版。",
160
+ )
161
+ } else {
162
+ try {
163
+ sh("git fetch origin --quiet")
164
+ const counts = sh(`git rev-list --left-right --count origin/${branch}...HEAD`)
165
+ const [b, a] = counts.split(/\s+/).map(Number)
166
+ behind = b || 0
167
+ if (behind > 0) bad(`落后远端 ${behind} 个提交 —— 先 pull / merge,别在旧基线上发版`)
168
+ if (a > 0) note(`本地领先远端 ${a} 个提交(发布时会一起推上去)`)
169
+ } catch (e) {
170
+ // ⑦ fetch 失败不再是 note —— 那就是「不知道远端正不正常」,不能发版。
171
+ bad(`git fetch origin 失败(${String(e.stderr || e.message).trim().split("\n")[0]})—— 远端状态未知,先修好再发版`)
172
+ }
158
173
  }
159
174
  }
160
175
 
161
- // ── ⑥ tag 是否已被占 ─────────────────────────────────────────────────
176
+ // ── ⑧ tag 是否已被占(并区分「指向本 commit」与「指向别的 commit」)──────
177
+ // 1.11.3 的现场:Release API 在 push 失败后**自动建了一个轻量 tag**,指向当时远端的旧 commit。
178
+ // 那种情况不能默默 force —— 必须让人确认「这个 tag 是不是发错了」。
162
179
 
163
180
  let tagExistsLocal = false
164
181
  let tagExistsRemote = false
182
+ let tagCommitLocal = ""
183
+ let tagCommitRemote = ""
165
184
  if (gh) {
166
185
  tagExistsLocal = shOk(`git rev-parse -q --verify refs/tags/${tag}`)
186
+ if (tagExistsLocal) tagCommitLocal = sh(`git rev-parse -q --verify refs/tags/${tag}^{commit}`)
167
187
  try {
168
188
  const out = sh(`git ls-remote --tags origin refs/tags/${tag}`)
169
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] ?? ""
170
195
  } catch {}
196
+ const headSha = shOk("git rev-parse HEAD") ? sh("git rev-parse HEAD") : ""
197
+ const pointsAtHead = (sha) => !!sha && !!headSha && (sha === headSha || headSha.startsWith(sha))
171
198
  if (tagExistsLocal || tagExistsRemote) {
172
- bad(`tag ${tag} 已存在 —— 同一个版本号不能发两次;先 bump version`)
199
+ if (tagExistsRemote && tagCommitRemote && !pointsAtHead(tagCommitRemote)) {
200
+ bad(
201
+ `远端 tag ${tag} 已存在,但指向 ${tagCommitRemote.slice(0, 12)}(不是 HEAD ${headSha.slice(0, 12)})—— ` +
202
+ `可能是上次发布中断时由 Release API 自动建的轻量 tag。脚本**不会自动 force**。` +
203
+ `确认这个 tag 发错之后,手工修:git push --force origin refs/tags/${tag}:refs/tags/${tag}`,
204
+ )
205
+ } else {
206
+ bad(`tag ${tag} 已存在 —— 同一个版本号不能发两次;先 bump version`)
207
+ }
173
208
  }
174
209
  }
175
210
 
@@ -205,16 +240,16 @@ for (const n of notes) console.log(C.yellow(` ! ${n}`))
205
240
  // ── 计划 ─────────────────────────────────────────────────────────────
206
241
 
207
242
  const plan = []
208
- plan.push({ what: `git push origin ${branch}`, why: "第 4 步:git 源检测看默认分支 HEAD" })
243
+ plan.push({ what: `git push origin ${branch}`, why: "第 4 步:git 源检测看默认分支 HEAD(**失败即中止,后面一步都不做**)" })
209
244
  plan.push({ what: `git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, why: "第 6 步:annotated tag(带消息)让「某 bug 出在哪版」可查,且 GitHub Release 才有说明" })
245
+ if (ALSO_NPM) {
246
+ const script = pkg.scripts?.["publish:npm"] ? "npm run publish:npm" : "npm publish"
247
+ plan.push({ what: script, why: "第 5 步:npm 源的用户比 registry 上的 version(**排在 Release 之前**:npm 失败就不建 Release)" })
248
+ }
210
249
  plan.push({
211
250
  what: `POST /repos/${gh.owner}/${gh.repo}/releases`,
212
251
  why: `第 6 步:Release 正文 = CHANGELOG 的 ${version} 段(${releaseBody.split("\n").length} 行,已去掉 > 起因)`,
213
252
  })
214
- if (ALSO_NPM) {
215
- const script = pkg.scripts?.["publish:npm"] ? "npm run publish:npm" : "npm publish"
216
- plan.push({ what: script, why: "第 5 步:npm 源的用户比 registry 上的 version" })
217
- }
218
253
  plan.push({ what: "四条验证", why: "第 7 步:npm 版本 / tarball 内容 / magpie plugin list / Release 在" })
219
254
 
220
255
  console.log(C.bold("计划:\n"))
@@ -228,27 +263,84 @@ if (DRY) {
228
263
  process.exit(0)
229
264
  }
230
265
 
231
- // ── 真做 ─────────────────────────────────────────────────────────────
266
+ // ── 真做(事务化:前一步失败 ⇒ 后面一步根本不执行)────────────────────
232
267
 
233
268
  const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ""
234
269
  console.log()
235
270
 
236
- function run(cmd, label) {
237
- process.stdout.write(` ${label} … `)
271
+ const doneSteps = []
272
+
273
+ /**
274
+ * 失败即中止:把「后面还有什么没做」写清楚,然后退出 1。
275
+ * 这是 2026-10-31 事故的教训 —— 当时 push/tag 全失败,脚本却继续建 Release、继续 npm publish,
276
+ * 留下「npm 有、main 没推、tag 指向旧 commit」的半途状态。
277
+ */
278
+ function abort(step, detail) {
279
+ console.log(C.red(`\n✗ 已中止:${step} 失败 —— 后续步骤全部未执行`))
280
+ console.log(C.dim(` 已完成:${doneSteps.length ? doneSteps.join(" → ") : "(无)"}`))
281
+ if (detail) console.log(C.dim(` ${detail}`))
282
+ console.log(
283
+ C.dim(
284
+ ` 未执行:${["tag -a " + tag, ALSO_NPM ? "npm publish" : null, "GitHub Release"]
285
+ .filter(Boolean)
286
+ .join(" · ")}`,
287
+ ),
288
+ )
289
+ console.log(C.dim(" ⇒ 没有任何新的对外副作用被制造出来。修好远端后重跑同一条命令。\n"))
290
+ process.exit(1)
291
+ }
292
+
293
+ // ①② 推分支 + annotated tag —— 走 runTransaction(首步失败 ⇒ tag 步根本不执行)
294
+ const gitTx = runTransaction(
295
+ [
296
+ {
297
+ name: `push ${branch}`,
298
+ label: `push ${branch}`,
299
+ run: () => sh(`git push origin ${branch}`, { stdio: "pipe" }),
300
+ hint: "远端不可达或被拒。**tag / npm / Release 一件都没做。**",
301
+ },
302
+ {
303
+ name: `tag ${tag}`,
304
+ label: `tag ${tag}`,
305
+ run: () => sh(`git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, { stdio: "pipe" }),
306
+ hint: "本地 tag 可能已建;远端 tag 没上去。修好后单独补:git push origin " + tag,
307
+ },
308
+ ],
309
+ {
310
+ onStart: (step) => process.stdout.write(` ${step.label} … `),
311
+ onSettled: (step, err) => {
312
+ if (!err) return console.log(C.green("ok"))
313
+ console.log(C.red("失败"))
314
+ console.log(C.dim(String(err.stderr || err.message).trim().split("\n").slice(0, 4).join("\n")))
315
+ },
316
+ },
317
+ )
318
+ if (!gitTx.ok) {
319
+ const failedStep = gitTx.done.length === 0 ? `git push origin ${branch}` : `tag -a ${tag} && git push origin ${tag}`
320
+ const hint = gitTx.done.length === 0
321
+ ? "远端不可达或被拒。**tag / npm / Release 一件都没做。**"
322
+ : "本地 tag 可能已建;远端 tag 没上去。修好后单独补:git push origin " + tag
323
+ abort(failedStep, hint)
324
+ }
325
+ doneSteps.push(`push ${branch}`, `tag ${tag}`)
326
+
327
+ // ③ npm 发布(在 Release 之前:npm 失败就不该留下一个「Release 说有、npm 上没有」的状态)
328
+ let published = null
329
+ if (ALSO_NPM) {
330
+ const npmScript = pkg.scripts?.["publish:npm"] ? "npm run publish:npm" : "npm publish"
331
+ console.log(C.dim(`\n ${npmScript}(npm 登记有 1~2 分钟延迟,别在这窗口里重试)\n`))
238
332
  try {
239
- sh(cmd, { stdio: "pipe" })
240
- console.log(C.green("ok"))
241
- return true
242
- } catch (e) {
243
- console.log(C.red("失败"))
244
- console.log(C.dim(String(e.stderr || e.message).trim().split("\n").slice(0, 4).join("\n")))
245
- return false
333
+ execSync(npmScript, { cwd: ROOT, stdio: "inherit" })
334
+ published = true
335
+ doneSteps.push(`npm publish ${version}`)
336
+ } catch {
337
+ published = false
338
+ }
339
+ if (!published) {
340
+ abort(`${npmScript}`, "npm 没发出去(OTP / 2FA / 权限 / 网络)。**GitHub Release 未创建。**")
246
341
  }
247
342
  }
248
343
 
249
- const pushed = run(`git push origin ${branch}`, `push ${branch}`)
250
- const tagged = run(`git tag -a ${tag} -m "release ${tag}" && git push origin ${tag}`, `tag ${tag}`)
251
-
252
344
  let released = false
253
345
 
254
346
  /** 这个仓库有没有 release.yml(有的话推 tag 就会自动建 Release,我们不该再建一遍) */
@@ -354,19 +446,6 @@ if (token) {
354
446
  console.log(C.dim(" 正文直接把 CHANGELOG 里 " + version + " 那一段粘过去"))
355
447
  }
356
448
 
357
- let published = null
358
- if (ALSO_NPM) {
359
- const script = pkg.scripts?.["publish:npm"] ? "npm run publish:npm" : "npm publish"
360
- console.log(C.dim(`\n ${script}(npm 登记有 1~2 分钟延迟,别在这窗口里重试)\n`))
361
- try {
362
- execSync(script, { cwd: ROOT, stdio: "inherit" })
363
- published = true
364
- } catch {
365
- published = false
366
- console.log(C.red(" npm 发布失败"))
367
- }
368
- }
369
-
370
449
  // ── 第 7 步留给人看的清单 ─────────────────────────────────────────────
371
450
 
372
451
  console.log(C.bold("\n第 7 步 · 四条验证(逐条跑,别只看版本号):\n"))
@@ -385,5 +464,11 @@ console.log(
385
464
  ),
386
465
  )
387
466
 
388
- const failed = !pushed || !tagged || (ALSO_NPM && !published)
467
+ // 走到这里说明 push / tag /(npm)全部成功。Release 失败仍是失败(否则「npm 有、Release 没有」)。
468
+ const failed = !!token && !released
469
+ if (failed) {
470
+ console.log(C.red("\n✗ GitHub Release 没建成 —— push/tag/npm 都已完成,只差 Release:"))
471
+ console.log(C.dim(" 手工建:GitHub → Releases → Draft a new release → 选 " + tag))
472
+ console.log(C.dim(" 或重跑本脚本(tag 已存在会先被 ⑧ 拦住;那就手工建一次)"))
473
+ }
389
474
  process.exit(failed ? 1 : 0)
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { failureEdges, sha } from "./kifu-model.mjs";
28
- import { renderLedger } from "./ledger.mjs";
28
+ import { renderLedger, renderMinimalLedger } from "./ledger.mjs";
29
29
 
30
30
  /** 限流:两次注入至少隔这么多事件。 */
31
31
  export const INJECT_MIN_EVENTS = 40;
@@ -47,6 +47,17 @@ export function messageText(message) {
47
47
  .join("\n");
48
48
  }
49
49
 
50
+ /**
51
+ * ★ OPT-EMPTY(task-130):这张图有没有「值得注入」的最小状态?
52
+ * 空图(连 root / agent / constraints 都没有)⇒ 仍然 skip,不拿一行空壳占位。
53
+ */
54
+ export function hasLedgerState(graph) {
55
+ const nodes = graph?.nodes ?? {};
56
+ if ((graph?.rootUserNodeIds ?? []).some((id) => nodes[id])) return true;
57
+ if (Object.values(nodes).some((n) => n?.kind === "agent" && (n.goal || n.worldHash))) return true;
58
+ return Object.values(nodes).some((n) => (n.constraints ?? []).length > 0);
59
+ }
60
+
50
61
  /**
51
62
  * 热上下文的「骨架摘要」:只由失败边 + 根目标 + 约束决定。
52
63
  * 不含 CURRENT PATH / CURRENT GOAL —— 它们每一步都变,用整段文本做 digest
@@ -187,14 +198,23 @@ export function decideInjection({
187
198
  // ★ 2026-10-29(三份评估共同的 P0-2):压缩「连续失败」必须被看见。
188
199
  // 一条失败边都没有时也要注入 —— 「没有失败边 + 压缩坏了」才是最危险的组合。
189
200
  const warning = foldFailureWarning(state);
190
- if (failureEdges(graph).length === 0 && warning === "") return { decision, action: "skip", reason: "no-ledger" };
201
+ // ★ OPT-EMPTY(task-130):零失败边 **不再整段跳过** —— 26% 的真实会话没有任何失败边,
202
+ // 旧逻辑让它们连根目标/约束都拿不到。现在仍然走 renderLedger(零失败时它输出
203
+ // ROOT / CONSTRAINTS / CURRENT GOAL / WORKING + 一行 NO_FAILED_ATTEMPTS_YET);
204
+ // 只有**空图**(无 root / 无 agent / 无约束)才 skip。
205
+ // 限流与 digest 全部沿用既有规则:digest = 根目标 + 约束(零失败时天然稳定),
206
+ // 同源且清单还在 ⇒ same-digest skip;被 fold 遮蔽 ⇒ lostToFold 补回。
207
+ const zeroFailure = failureEdges(graph).length === 0 && warning === "";
208
+ if (zeroFailure && !hasLedgerState(graph)) return { decision, action: "skip", reason: "no-ledger" };
191
209
 
192
- const ledger = renderLedger(graph, {
193
- maxTokens: config.hotLedgerMaxTokens ?? 500,
194
- markers: config.hotLedgerMarkers,
195
- treeDir: sessionDir,
196
- traceRoot: config.hotLedgerTraceRoot,
197
- });
210
+ const ledger = zeroFailure
211
+ ? renderMinimalLedger(graph, { maxTokens: config.hotLedgerMaxTokens ?? 500 })
212
+ : renderLedger(graph, {
213
+ maxTokens: config.hotLedgerMaxTokens ?? 500,
214
+ markers: config.hotLedgerMarkers,
215
+ treeDir: sessionDir,
216
+ traceRoot: config.hotLedgerTraceRoot,
217
+ });
198
218
  // ★ P7:消费 fallback(over-budget 记 warn + overBudgetBy;impossible 记 error),并落盘给 doctor。
199
219
  try {
200
220
  onBudget(ledger, session);
@@ -768,6 +768,49 @@ export function renderLedger(g, { maxTokens = 500, tailFailures = 20, markers =
768
768
  };
769
769
  }
770
770
 
771
+ /**
772
+ * ★ OPT-EMPTY(task-130):**零失败边**会话的最小注入内容。
773
+ *
774
+ * 背景:真实 167 个会话里 42 个(25.1%)没有任何失败边 —— 旧版 `decideInjection` 直接
775
+ * `skip("no-ledger")`,这些会话连根目标/约束都拿不到。但它们**不是空的**:42/42 有 root,
776
+ * 32/42 有 constraints(见 ws-opt-empty-ledger/REPORT.md 的只读统计)。
777
+ *
778
+ * 为什么不用 `renderLedger` 的最小档:那档仍会带 ROOT 原文,实测 42 个零失败会话
779
+ * avg 386 tok / max 497 —— 对「没有任何失败记忆」的会话是纯噪声。
780
+ * 这里只给三行事实(仍是既有的 `<chess-ledger>` 包裹,不是新的消息格式):
781
+ * GOAL: … (有 goal 用 goal,否则用 root 原话)
782
+ * CONSTRAINTS: N (数量,不展开原文)
783
+ * NO_FAILED_ATTEMPTS_YET
784
+ *
785
+ * @returns {{text:string, tokens:number, level:number, omittedEdges:number, failureCount:number, shownFailures:number, fallback:string, budgetMax:number, overBudgetBy:number}}
786
+ */
787
+ export function renderMinimalLedger(g, { maxTokens = 500 } = {}) {
788
+ const root = g?.rootNode ?? (g?.rootUserNodeIds ?? []).map((id) => g?.nodes?.[id]).filter(Boolean)[0];
789
+ const latest = latestAgentNode(g);
790
+ const goalText = clip(String(latest?.goal ?? root?.text ?? "").replace(/\s+/g, " ").trim(), 120);
791
+ const constraints = root?.constraints ?? [];
792
+ const lines = ["<chess-ledger>"];
793
+ if (goalText) lines.push(`GOAL: ${goalText}`);
794
+ lines.push(`CONSTRAINTS: ${constraints.length}`);
795
+ lines.push("NO_FAILED_ATTEMPTS_YET");
796
+ lines.push("</chess-ledger>");
797
+ const text = lines.join("\n");
798
+ const tokens = estimateTokens(text);
799
+ const over = Math.max(0, tokens - maxTokens);
800
+ return {
801
+ text,
802
+ tokens,
803
+ level: 5,
804
+ omittedEdges: 0,
805
+ failureCount: 0,
806
+ shownFailures: 0,
807
+ fallback: over > 0 ? "over-budget" : "none",
808
+ budgetMax: maxTokens,
809
+ overBudgetBy: over,
810
+ minimal: true,
811
+ };
812
+ }
813
+
771
814
  /**
772
815
  * L4/L5 的裁决(C-17)。两个兜底档都可能越过预算,选的时候按三层优先:
773
816
  * 1. 预算内优先(`tokens <= maxTokens`);
@@ -12,8 +12,9 @@
12
12
  *
13
13
  * 现在的分层(顺序即优先级):
14
14
  * ① isError(宿主标记) → failure(任何工具)
15
- * ② 有 [exit code: N]:N≠0 → failure;N=0 → success(**最终裁决**)
16
- * ②.b shell 但没有退出码 → unverified(**不猜**:输出可以是任何东西)
15
+ * ①.b local-mutate(本地状态写入类) → unverified + kind:"none",**不带 weak**
16
+ * ② 末条非空行**精确**是 [exit code: N]:N≠0 → failure;N=0 → success(**最终裁决**)
17
+ * ②.b shell 但没有精确退出码 → unverified(**不猜**:输出可以是任何东西)
17
18
  * ③ 非 shell:N passed / N failing → success / failure
18
19
  * ④ 非 shell:行首错误行 → failure
19
20
  * ④.b 只读工具:行首硬错误(ENOENT…) → failure(注释里的 EACCES 不算)
@@ -26,10 +27,15 @@
26
27
  * 为什么改这里而不是新加判据:`verify.kind === "read"` 就是 classify 自己给出的
27
28
  * 「只读工具、没抓到外部验证信号」的判定(toolClass 的显式表 + MCP query/mutate 词形),
28
29
  * 旧图里已经落盘了这个字段 —— 复用它,就不必在渲染阶段重新猜工具名。
29
- * ⚠ 已知局限(实测):toolClass 的只读表里混着 `todo_write` / `hindsight_ingest_document`
30
- * 这类「实际会改本地状态」的工具(25 个最新会话 641 条只读结果里 48 条,7.5%)。
31
- * 它们被标 weak 的语义仍然成立(工具自报无错、但无外部验证),但标记文案只用 `[weak]`,
32
- * 不说 "read-only",避免把写操作说成读。
30
+ *
31
+ * ★ OPT-VERIFY(task-124):上面那条「已知局限」已修 —— `todo_write` / `update_goal` /
32
+ * `create_goal` / `hindsight_ingest_document` / `team_task_*` / `send_message` / `schedule_*` /
33
+ * `job_kill` / `spawn_teammate` / `subagent` 这些**会改本地状态**的工具,已从只读表拆到
34
+ * `LOCAL_MUTATE_TOOLS`(toolClass → `local-mutate`),classify 给
35
+ * `result:"unverified"` + `kind:"none"` 且**不带 weak** ⇒ 不再进 WORKING 路径。
36
+ * `weak:"read"` 现在只属于真正只读的工具(read / grep / web_fetch / list_* 等)。
37
+ * 兼容:旧图里这些工具已落盘 `kind:"read"` + `N chars`,`weakKindOf` 仍按旧形态识别它们
38
+ * (历史数据不改写;新产生的边不再这样标)。
33
39
  *
34
40
  * 纯函数,零依赖,可单测。
35
41
  */
@@ -37,12 +43,37 @@
37
43
  const READ_ONLY_TOOLS = new Set([
38
44
  "read", "read_file", "glob", "grep", "ls", "list", "list_dir", "find",
39
45
  "web_search", "web_fetch", "read_page", "read_image", "library_search",
40
- "todo_write", "todo_read", "todo_list", "ask_user_question",
41
- "get_goal", "update_goal", "create_goal", "exit_plan_mode",
46
+ "todo_read", "todo_list", "ask_user_question",
47
+ "get_goal",
42
48
  "skill", "load_workspace_dependencies", "list_agents", "list_subagent_models",
43
49
  "job_list", "job_output", "team_task_list", "team_task_get",
44
50
  ]);
45
51
 
52
+ /**
53
+ * ★ OPT-VERIFY(task-124):**本地状态写入类**工具 —— 既不是只读,也不是仓库写入。
54
+ *
55
+ * 它们不产生外部验证信号(没有 exit code / 测试计数 / diff),但**确实改了本地状态**
56
+ * (todo 列表、goal、记忆库、任务板、消息、定时任务、子 agent、job)。
57
+ * 旧版把它们混在 `READ_ONLY_TOOLS` 里 ⇒ 走 ⑥ 兜底分支拿到 `weak:"read"`,
58
+ * 于是热清单的 WORKING 路径会把「写了一条 todo」当成「读过一眼的弱成功」——
59
+ * 25 个最新会话 641 条只读结果里 48 条(7.5%)正是这种形态(S2a 注释里已记为已知局限)。
60
+ *
61
+ * 现在的语义:`toolClass` 返回 **`local-mutate`**,`classify` 给
62
+ * `result:"unverified"` + `kind:"none"`,**不带任何 weak** —— 不进 WORKING 路径,
63
+ * 也不进 success / failureEdges / facts / tewari。
64
+ *
65
+ * ⚠ 与 `WRITE_TOOLS` 的区别:`WRITE_TOOLS` 是**仓库写入**(write / edit / present …),
66
+ * 它们的回执本身算成功信号(`kind:"write"` / `kind:"diff"`),**本刀一个字不改**。
67
+ */
68
+ const LOCAL_MUTATE_TOOLS = new Set([
69
+ "todo_write", "update_goal", "create_goal", "exit_plan_mode",
70
+ "hindsight_ingest_document", "hindsight_capture_initiative",
71
+ "team_task_create", "team_task_update", "team_task_delete",
72
+ "send_message", "spawn_teammate", "subagent", "subagent_fork", "subagent_role",
73
+ "schedule_create", "schedule_update", "schedule_delete",
74
+ "job_kill", "job_start",
75
+ ]);
76
+
46
77
  const WRITE_TOOLS = new Set([
47
78
  "write", "edit", "write_file", "edit_file", "apply_patch", "multi_edit",
48
79
  "str_replace", "create_file", "delete_file", "notebook_edit", "present",
@@ -58,6 +89,8 @@ export function toolClass(name) {
58
89
  const n = raw.toLowerCase();
59
90
  if (SHELL_TOOLS.has(n)) return "shell";
60
91
  if (WRITE_TOOLS.has(n)) return "write";
92
+ // ★ 顺序要紧:local-mutate 必须排在 read 之前,否则这些工具又会被当只读。
93
+ if (LOCAL_MUTATE_TOOLS.has(n)) return "local-mutate";
61
94
  if (READ_ONLY_TOOLS.has(n)) return "read";
62
95
  const isMcp = n.startsWith("mcp__") || n.includes("__") || n.startsWith("hindsight_") || n.startsWith("bili_");
63
96
  if (isMcp) return MUTATE_RE.test(n) && !QUERY_RE.test(n) ? "mcp-write" : "mcp-read";
@@ -75,7 +108,28 @@ const ERR_LINE_RE = /^\s*(?:bash: line \d+: )?(?:Error:|error:|ERROR:|Exception\
75
108
  /** 行首硬错误(只读工具也认——读一个不存在的文件)。 */
76
109
  const HARD_LINE_RE = /^\s*(?:ENOENT|EACCES|EPERM|ETIMEDOUT|ECONNREFUSED|No such file|Permission denied|Error:|error:|Traceback|Cannot find module|MODULE_NOT_FOUND)/;
77
110
 
78
- const EXIT_RE = /\[exit code:\s*(-?\d+)\]/;
111
+ /**
112
+ * ★ OPT-VERIFY(task-124):退出码 marker 必须**独占最后一条非空行**(允许行内前后空白)。
113
+ *
114
+ * 旧写法 `EXIT_RE.exec(body.slice(-200))` 双向都有错:
115
+ * ① 只搜末尾 200 字符 ⇒ 标记之后还有别的输出时,**真退出码直接丢失**(判成 no-exit);
116
+ * ② 只要落在末尾 200 字符内就命中 ⇒ `cat` 出来的源码里写着 `const text = "[exit code: 1]" + …`
117
+ * 这种**正文子串**会被误当成命令退出码(独立复核活证据 e8)。
118
+ * 现在的判据只有一条:末条非空行整行等于 `[exit code: N]`;否则一律 no-exit(**不猜**)。
119
+ */
120
+ const EXIT_LINE_RE = /^\s*\[exit code:\s*(-?\d+)\]\s*$/;
121
+
122
+ /** 末条非空行的精确退出码;不是精确 marker ⇒ `null`。 */
123
+ export function lastLineExitCode(body) {
124
+ const lines = String(body ?? "").split("\n");
125
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
126
+ if (lines[i].trim().length === 0) continue;
127
+ const m = EXIT_LINE_RE.exec(lines[i]);
128
+ return m === null ? null : Number(m[1]);
129
+ }
130
+ return null;
131
+ }
132
+
79
133
  const TEST_PASS_RE = /(\d+)\s+(?:passed|passing|tests? passed|个?通过)|all tests passed|0 failing/i;
80
134
  const TEST_FAIL_RE = /\b([1-9]\d*)\s+(?:failed|failing|个?失败|errors?)\b/i;
81
135
  const DIFF_RE = /(\d+)\s*(?:insertions?\(\+\)|deletions?\(-\))/;
@@ -224,6 +278,7 @@ export function classify({ tool, text, isError = false }) {
224
278
  const body = String(text ?? "");
225
279
  const cls = toolClass(tool);
226
280
  const readOnly = cls === "read" || cls === "mcp-read";
281
+ const localMutate = cls === "local-mutate";
227
282
 
228
283
  if (isError) {
229
284
  return {
@@ -233,16 +288,29 @@ export function classify({ tool, text, isError = false }) {
233
288
  };
234
289
  }
235
290
 
291
+ // ★ OPT-VERIFY(task-124):本地状态写入类工具(todo_write / update_goal / send_message /
292
+ // team_task_update / hindsight_ingest_document …)没有外部验证信号,也**不是**「读了一眼」。
293
+ // ⇒ unverified + kind:"none" + **不带 weak**:不许进 WORKING 路径,更不许当进展证据。
294
+ // 放在 isError 之后、shell 之前:宿主报错仍然是 failure(isError 优先级最高,不变)。
295
+ if (localMutate) {
296
+ return {
297
+ result: "unverified",
298
+ verify: { kind: "none", summary: body ? `${body.length} chars` : "empty" },
299
+ errorSnippet: "",
300
+ };
301
+ }
302
+
236
303
  // ★ 退出码只对 shell 有意义。真实踩坑:`read` 读一个包含 `[exit code: 1]` 字样的测试文件,
237
304
  // 旧逻辑会把那行字当成「这个 read 的退出码」→ 整条 read 被判 failure。
238
305
  // read/write/mcp 工具的输出是**内容**,不是执行结果。
239
306
  if (cls === "shell") {
240
307
  // P1-7:退出码只看**末尾**。DSH 把 `[exit code: N]` 追加在结果最后;
241
- // 从全文第一处匹配会抓到输出正文里的字样(比如 sed 打印的测试文件里写着 `[exit code: 1]`),
242
- // 把一条 exit 0 的命令判成失败(真实日志里 e119/e189/e227 都是这么误报的)。
243
- const exit = EXIT_RE.exec(body.slice(-200));
244
- if (exit) {
245
- const code = Number(exit[1]);
308
+ // P1-7 → OPT-VERIFY(task-124):判据从「末尾 200 字符里搜到 marker」改成
309
+ // **末条非空行整行等于 `[exit code: N]`**。旧写法两头都错:标记后面还有输出时真退出码丢失;
310
+ // 正文里恰好出现 `[exit code: 1]` 子串时误判失败(复核活证据 e8)。
311
+ // 不是精确 marker ⇒ 走下面的 no-exit(**不猜**)。
312
+ const code = lastLineExitCode(body);
313
+ if (code !== null) {
246
314
  if (code !== 0) {
247
315
  return { result: "failure", verify: { kind: "exit", summary: `exit ${code}` }, errorSnippet: errorSnippet(body) };
248
316
  }
@@ -322,4 +390,4 @@ function firstLine(text) {
322
390
  return line.trim().slice(0, 160);
323
391
  }
324
392
 
325
- export const _internal = { READ_ONLY_TOOLS, WRITE_TOOLS, SHELL_TOOLS, firstLine, HARD_ERR_RE, ERR_LINE_RE, HARD_LINE_RE };
393
+ export const _internal = { READ_ONLY_TOOLS, LOCAL_MUTATE_TOOLS, WRITE_TOOLS, SHELL_TOOLS, firstLine, lastLineExitCode, HARD_ERR_RE, ERR_LINE_RE, HARD_LINE_RE };