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 +114 -0
- package/README.md +12 -8
- package/package.json +3 -1
- package/scripts/check-maintenance.mjs +23 -0
- package/scripts/release-lib.mjs +103 -0
- package/scripts/release.mjs +137 -52
- package/src/rooms/hot-inject.mjs +28 -8
- package/src/rooms/ledger.mjs +43 -0
- package/src/rooms/verify.mjs +83 -15
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.
|
|
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 + 每步只有
|
|
5
|
+
**DSH 的棋谱式上下文压缩后端 —— 把「历史 → 一段摘要」换成「历史 → 可复盘的棋谱 DAG + 每步只有 351 tokens 的热清单」。**
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
热清单
|
|
9
|
-
失败保留 8 / 8
|
|
10
|
-
清单开销 0.2%
|
|
11
|
-
验证
|
|
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
|
-
| 热上下文 | 无 |
|
|
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)
|
|
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.
|
|
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
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
// 体检项(任一不过就退出码 1,且 --yes 也不动手):
|
|
14
14
|
// ① package.json 有 version ② CHANGELOG 有这一版那一段
|
|
15
15
|
// ③ 工作区干净 ④ 当前在默认分支
|
|
16
|
-
// ⑤
|
|
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
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
} catch
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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)
|
package/src/rooms/hot-inject.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
193
|
-
maxTokens: config.hotLedgerMaxTokens ?? 500
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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);
|
package/src/rooms/ledger.mjs
CHANGED
|
@@ -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`);
|
package/src/rooms/verify.mjs
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
*
|
|
13
13
|
* 现在的分层(顺序即优先级):
|
|
14
14
|
* ① isError(宿主标记) → failure(任何工具)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
"
|
|
41
|
-
"get_goal",
|
|
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
|
-
|
|
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
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 };
|