dsh-gokifu-compact 1.11.2 → 1.11.3

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,71 @@
1
1
  # 更新记录
2
2
 
3
+ ## 1.11.3 — 2026-10-31
4
+
5
+ ### P0-1 · `exit` 判据收紧:不再被正文子串误判
6
+
7
+ 旧实现用 `slice(-200)` 在尾部 200 字符里**子串搜索** `[exit code: N]` ⇒ 命令**输出正文里提到**这串就误判成「有退出码」,
8
+ 且「标记之后还有输出」时前后结论不一致。
9
+
10
+ - 新增 `lastLineExitCode()`:取**末条非空行**,**整行等于** `[exit code: N]` 才算有退出码;否则视为 no-exit。
11
+ - `verify.mjs` 顶部注释同步;`_internal` 导出 `LOCAL_MUTATE_TOOLS` / `lastLineExitCode`。
12
+ - 测试 +3(`verifier` OPT-V-1 / OPT-V-2、`weak-shell` S2b-14);变异:退回 `slice(-200)` ⇒ OPT-V-1 变红。
13
+
14
+ ### P0-2 · `local-mutate` 从只读表拆出(不再被当成「只读工具」)
15
+
16
+ `todo_write` / `update_goal` / `create_goal` / `hindsight_ingest_document` / `team_task_*` / `send_message` /
17
+ `spawn_teammate` / `subagent` / `schedule_*` / `job_kill` … 这些**会改状态但不是文件写**的工具,原先把它们混在只读表里,
18
+ 分类结果自相矛盾。现在:
19
+
20
+ - 新增 `LOCAL_MUTATE_TOOLS`;`toolClass()` 返回 **`local-mutate`**(与 `read-only` / `write` 并列)
21
+ - `classify()` 对 local-mutate 给 **`unverified` + kind `none`,且不带 `weak`**(它既不是「进展」也不是「失败」)
22
+ - `WRITE_TOOLS` / `present` 与 `isError` 的**优先级不变**
23
+ - 变异:把 local-mutate 放回只读表 ⇒ S2b-14 + OPT-V-2 变红
24
+
25
+ ### 外部入口 · 新增 `dsh-gokifu-compact/graph` 与 `/persist`
26
+
27
+ 1.11.2 的 README 教外部工具去读 `node_modules/dsh-gokifu-compact/src/rooms/kifu-model.mjs` —— 那是**深层路径**,
28
+ `exports` 里没有 ⇒ 现代 ESM 解析会直接拒绝。现在补两个正式入口:
29
+
30
+ | 入口 | 目标 | 用途 |
31
+ |---|---|---|
32
+ | `dsh-gokifu-compact/graph` | `src/rooms/kifu-model.mjs` | `fromJSON` / `toJSON` / `decodeDeltaFrames` / `applyDelta` / `renderIndexMd` … |
33
+ | `dsh-gokifu-compact/persist` | `src/rooms/kifu-persist.mjs` | `graphRoot` / `sessionPaths` / `createPersistStore` / `load` / `flush` … |
34
+
35
+ - 新增 `test/package-exports.test.mjs`(**7 条**):声明齐全、子路径可解析、真实 `import` 成功、导出面非空、不泄漏内部路径
36
+ - README 的「怎么拿最新状态」一行改为指向这两个入口
37
+
38
+ ### 零失败边最小注入(OPTIONS 之后又一刀「别让热清单吃白饭」)
39
+
40
+ **问题**:只读统计 **167 个真实会话**,**42 个(25.1%)零失败边** —— 它们照样要付一份完整热清单的 token。
41
+
42
+ **改法**:
43
+
44
+ - `ledger.mjs` 新增 `renderMinimalLedger`:`<chess-ledger>` 内只有三行 —— `GOAL` / `CONSTRAINTS: N` / `NO_FAILED_ATTEMPTS_YET`;
45
+ **`renderLedger` 一个字节不动**(167 会话新旧对比 `byteDiff=0`)
46
+ - `hot-inject.decideInjection`:零失败边且 `hasLedgerState` ⇒ 用最小渲染走**原注入流程**(digest / 限流 / replace / fold 补回全不变);空图仍 `skip no-ledger`
47
+ - 实测(42 个零失败会话):最小注入 **avg 25.4 tok / max 57**(同批会话的完整最小档 avg 386 / max 497)
48
+ - 分布:root 覆盖 **42/42**、constraints **32/42**、完全空 **0**(不会出现「三行全空」的哑注入)
49
+ - 测试 +4:OPT-E-1 触发与三项内容 / OPT-E-2 空图 skip / OPT-E-3 有失败边时字节不变(sha 钉死)/ OPT-E-4 fold 补回
50
+ - 变异:零失败退回 skip ⇒ E-1/E-4 红;删 `NO_FAILED_ATTEMPTS_YET` 行 ⇒ E-1/E-4 红
51
+
52
+ ### tier-2 行为矩阵
53
+
54
+ - 新增 `test/tier2.test.mjs`(**6 条**,engine 零改动)+ `engine-harness` 导出 4 个 helper —— 把此前「文档说有、代码没测」的第二档行为逐条核实。
55
+
56
+ ### 验证
57
+
58
+ - 全量:**463 passed / 0 failed**(36 个测试文件;443 + 7 exports + 3 verify + 6 tier2 + 4 hot-inject)
59
+ - `rooms` **15/0** · `hot-inject` **12/0** · `verify-install` **16/16**
60
+ - `bench 2000`:**351 tokens** / 1.336 ms(81,260 / 386,945 字符、1.41 MB、2001 节点)
61
+ - `npm pack --dry-run --json`:**70 文件**(数量不变)/ 562,344 B;`test/`·`tmp/` 命中 0
62
+
63
+ **未验证(必须知道)**
64
+
65
+ - 最小注入的**运行期收益未在真机观测** —— 25.4 tok 是离线夹具口径,167 会话统计是只读扫描。
66
+ - tier-2 矩阵跑在假 ctx 上,不是真机行为。
67
+ - README 里的「真实包 8/8 †」「真实进程 fold 3~6/6 †」带 **†** = 历史口径,**本版未复跑**。
68
+
3
69
  ## 1.11.2 — 2026-10-30
4
70
 
5
71
  ### 本包改成 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.3`** · 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
+ 验证 463 项单测 · 真实包 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.3",
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
  },
@@ -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 };