Skip to content

docs(mcp-tools): 正文行号引用改钉符号 —— 两版行号 pin 归零 - #845

Merged
vansin merged 61 commits into
docs/mcp-tools-symbol-anchorsfrom
docs/mcp-tools-inline-anchors
Aug 18, 2026
Merged

docs(mcp-tools): 正文行号引用改钉符号 —— 两版行号 pin 归零#845
vansin merged 61 commits into
docs/mcp-tools-symbol-anchorsfrom
docs/mcp-tools-inline-anchors

Conversation

@vansin

@vansin vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

#831 正解第二批,#844(先合那个)。

这批是正文里 [`tools.ts:168-188`](…#L168) 那种引用 —— 每一处都声称一段具体行为,不能像章节链接那样机械替换,要逐条读它声称什么、再定锚哪个符号。

中英两版各 27 处(25 处单标签 + 2 处复合标签 `tools.ts:129` INSERT + `tools.ts:141` ON CONFLICT)全部换成「文件链接 + 可 grep 的唯一串」。

改完之后 mcp-tools.md 两版的行号 pin 归零。

全站:141 → 53 处,唯一 70 → 27,机械可证失效 32 → 14

🔴 途中我自己制造并抓回一个错

给「只能 reassign 非终态任务」那条选锚串时,我用了:

status IN ('created', 'delivered', 'acked', 'running')

它在仓里有 2 处命中 —— 但那 2 处分别属于 send_message(L1645)和 cancel_task(L1959),没有一处在 reassign_task

也就是说我把一条 reassign 的说明锚到了别的 tool 上。而且**「锚串存在」这个检查会放行它** —— 串确实存在,只是不在该在的地方。这跟 #831 本身是同一类病:引用指向一个真实存在、但不是它声称的位置。

是在核对「多命中锚串各自属于哪个 tool」时发现的。reassign 的真实检查是:

["replied", "failed", "cancelled", "expired"].includes(task.status)     ← 全仓唯一

已改正。

由此加的一条做法:锚串命中 >1 时,把它属于哪个 tool 段写进提示 ——

在 `cancel_task` 段搜 `status IN ('created', 'delivered', 'acked', 'running')`(全仓 2 处,另一处属 `send_message`)

光写「全仓 N 处」不够,读者仍然不知道该看哪一处。

自查

两版共 54 个新链接,逐个确认其后至少有一个锚串确实出现在它指向的源文件里,0 例外

第一版自查的正则排除了反引号,漏掉了带「在 x 段搜」的 3 条(中文检出 24、英文 27,数字对不上才发现)。换成更宽的抓法后重跑,54/54 覆盖。

门的行为

改完之后 #843 的门红在「9 个基线条目已经不再失效,请从基线里删掉」,按它说的删完才转绿。基线 23 → 14tests/test831 的 L0 预期 53/107 → 27/53

unique_pins=27   pin_occurrences=53   broken_pins=14   baseline_entries=14
OK  walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处)
MUTATION_RED new-out-of-range-pin rc=1
MUTATION_RED stale-baseline-entry rc=1
OK  3 条已知盲区仍未被判据覆盖
RESULT: PASS      退出码 0

剩下的

14 条失效 pin 全部在 rest.md / changelog.md,不在本 PR 范围。

#831 正解第二批,接 #844。这批是正文里 `tools.ts:168-188` 那种引用:每一处都
声称一段具体行为,不能像章节链接那样机械替换,要逐条读它声称什么、再定锚哪个符号。

中英两版各 27 处(25 处单标签 + 2 处复合标签 `tools.ts:129 INSERT + tools.ts:141
ON CONFLICT`),全部换成「文件链接 + 可 grep 的唯一串」。改完之后 mcp-tools.md
两版的行号 pin 归零。

全站:pin 141 → 53 处,唯一 70 → 27,机械可证失效 32 → 14。

🔴 途中我自己制造并抓回一个错,值得写下来:

给「只能 reassign 非终态任务」那条选锚串时,我用了
`status IN ('created', 'delivered', 'acked', 'running')`。它在仓里有 2 处命中 ——
但那 2 处分别属于 send_message 和 cancel_task,**没有一处在 reassign_task 里**。
也就是说我把一条 reassign 的说明锚到了别的 tool 上,而且"锚串存在"这个检查
会放行它,因为串确实存在,只是不在该在的地方。

是在核对"多命中锚串各自属于哪个 tool"时发现的。reassign 的真实检查在
`["replied", "failed", "cancelled", "expired"].includes(task.status)`(全仓唯一),
已改正。

由此加的一条做法:锚串命中 >1 时,把它属于哪个 tool 段写进提示,例如
「在 `cancel_task` 段搜 …(全仓 2 处,另一处属 `send_message`)」。光写"全仓 N 处"
不够 —— 读者仍然不知道该看哪一处。

自查:两版共 54 个新链接,逐个确认其后至少有一个锚串确实出现在它指向的源文件里,
0 例外。(第一版自查的正则排除了反引号,漏掉了带「在 `x` 段搜」的 3 条;
换成更宽的抓法后重跑,54/54 覆盖。)

连带:
- 基线 23 → 14,9 条是门自己要求删的
- tests/test831 的 L0 预期 53/107 → 27/53

验证:
  unique_pins=27  pin_occurrences=53  broken_pins=14  baseline_entries=14
  OK  walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处)
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  OK  3 条已知盲区仍未被判据覆盖
  RESULT: PASS  退出码 0

剩下的 14 条失效 pin 全部在 rest.md / changelog.md,不在本 PR 范围。
#844 上那条 P1 同类,这次是主动修的:本 PR 把套件预期分母改成 27/53,
继承来的报告记的还是更早那次。审查还没提到这个 PR,但 docs/pre-pr-selfcheck.md
§12 说了「改动改变了套件下次跑看到的东西,就要连报告一起更新」——
这条规则刚写完,先用在自己身上。

  source_commit=160a6b94d8c6c6c4a7622485baecc9b8f2e18b95
  pin_occurrences=53  unique_pins=27  broken_pins=14  baseline_entries=14
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  RESULT: PASS  exit_code=0

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 160a6b94d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/test831-doc-source-pins/run.sh Outdated
Comment on lines +63 to +64
[[ "$uniq" -eq 27 ]] || fail "预期 27 个唯一 pin,实际 $uniq"
[[ "$occ" -eq 53 ]] || fail "预期 53 处原始出现,实际 $occ"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh the saved test831 Docker report

When these denominator assertions change to 27/53, the checked-in docs/tests/report-test831.txt still records the old a7b1278 run with 70 unique pins and 141 occurrences, so the repository contains no saved Docker evidence that this revised suite passed. Regenerate and commit the test831 report from this source revision.

AGENTS.md reference: AGENTS.md:L7-L8

Useful? React with 👍 / 👎.

Comment thread docs-site/docs/api/mcp-tools.md Outdated
向所有在线 Agent 广播消息。**broadcast 与 `task` 同样会触发收件方 AI 处理**([`agent-node/src/cli.ts`](https://github.com/sleep2agi/agent-network/blob/main/agent-node/src/cli.ts) 只对 `task` 和 `broadcast` 类型 think;其余 `reply` / `message` / `ack` 只展示);如果只是想群发通知不要求 AI 回复,用循环 `send_message` 替代。完整消息类型对照见 [Task 生命周期 — 消息类型](/concepts/task-lifecycle#消息类型)。

**参数**(verify [`server/src/tools.ts:880-885`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts#L880)):
**参数**(verify [`tools.ts`](https://github.com/sleep2agi/agent-network/blob/main/server/src/tools.ts) 搜 `"ack_inbox"`):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Point the broadcast parameter citation at broadcast

For readers verifying this parameter table, the new hint searches for "ack_inbox", which lands in the unrelated acknowledgement tool around server/src/tools.ts:906; the documented message, filter_server, and filter_status schema is under the broadcast registration around line 2035. Use a broadcast-specific anchor here and in the mirrored English page.

Useful? React with 👍 / 👎.

审查指出 broadcast 参数表的引用锚到了 `"ack_inbox"`。成立。

这是同一个错的第二例:上一轮我自己抓到 reassign 那条锚到了 cancel_task,
修完就过去了 —— **没有对全部锚点做一次「锚串落在哪个 tool 段」的审计**。
又是修实例不修类。这次补做了,一并查出三处:

  文档章节            原锚串                真实落点          改成
  report_status      INSERT INTO nodes     ack_probe_request upsertNodeWithSec1Guard
  broadcast          "ack_inbox"           ack_inbox         "Send a message to multiple sessions."
  get_all_status     INSERT INTO sessions  report_status     同串,但写明「在 report_status 段搜」

第一条要说明一下:report_status 确实会 upsert nodes 表,但不是自己写 SQL,
而是走 upsertNodeWithSec1Guard(L731,在 `if (node_id)` 之下 —— 正是文档声称
的那个条件)。`INSERT INTO nodes` 在那个 helper 里,位置在最后一个 tool 注册
之后,所以按"最近的注册点"归属会算到 ack_probe_request 头上。锚到函数名既指得
准,也同时带读者看到调用点和实现。

第三条不是错,是容易误解:那句话在 get_all_status 段里,但解释的是
report_status 写下的列。锚串本身对,补一句"在 report_status 段搜"免得读者
在本节里白找。

途中还犯了一次 pre-pr-selfcheck §11 说的错:改 broadcast 那条时我按字符串全局
替换 `搜 \`"ack_inbox"\``,把 ack_inbox 段里本来正确的两处也一起换掉了 ——
§11 写的正是"批量替换前证明锚点唯一"。是重跑审计时发现的(章节=[ack_inbox]
却落在 broadcast),已按行定位改回。

审计结果:中文 40 条、英文 23 条锚串,属主不匹配 0。
(审计跳过引 db.ts 的行 —— 那些锚串不在 tools.ts 里,按 tool 段归属没有意义。)
…ne-anchors

# Conflicts:
#	docs/tests/report-test831.txt
这个 PR 基于 #844,#844 基于 #843 —— 上一轮我在 #843 改了判据(四条审查缺陷),
这两个 PR 当时都带着旧判据跑出来的绿色。逐级合下来重跑,判据是新的、分母是
本 PR 的 27/53、L5 四条断言都在。

  RESULT: PASS  exit_code=0
  L5 ①不可变 ref 排除 ②#L0 越界 ③drifted 保留 ④路径穿越

报告冲突同样按合完重新生成处理 —— 它是产物,手工合出来的报告不对应任何一次
真实运行。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

两条都处理了。

① 报告过期(P1)—— 提意见时已经修好了

那条针对 160a6b94;fa992ed8 已经把报告刷成 27/53 那次的输出。那次是我主动修的,不是被提醒后修的 —— 上一轮在 #844 收到同一类意见后,我按刚写进 docs/pre-pr-selfcheck.md §12 的规则把这个 PR 的报告一并刷了。

现在又重跑了一次(见下面 ③)。

② broadcast 参数表锚到了 "ack_inbox" —— 成立,已修(b3e14d56)

这是同一个错的第二例。 上一轮我自己抓到「reassign 那条锚到了 cancel_task」,修完就过去了 —— 没有对全部锚点做一次「锚串落在哪个 tool 段」的审计。又是修实例不修类,今天第三次犯这个毛病。

这次补做了审计,一共查出三处:

文档章节 原锚串 真实落点 改成
report_status INSERT INTO nodes ack_probe_request upsertNodeWithSec1Guard
broadcast "ack_inbox" ack_inbox "Send a message to multiple sessions."
get_all_status INSERT INTO sessions report_status 同串,但写明「在 report_status 段搜」

第一条值得说明:report_status 确实会 upsert nodes 表,但不是自己写 SQL,而是走 upsertNodeWithSec1Guard(L731,在 if (node_id) 之下 —— 正是文档声称的那个条件)。INSERT INTO nodes 在那个 helper 里,位置在最后一个 tool 注册之后,所以按「最近的注册点」归属会算到 ack_probe_request 头上。锚到函数名既指得准,也让读者同时看到调用点和实现。

第三条不是错,是容易误解:那句话在 get_all_status 段里,但解释的是 report_status 写下的列。补一句「在 report_status 段搜」免得读者在本节里白找。

审计结果:中文 40 条、英文 23 条锚串,属主不匹配 0。

途中又栽了一次 §11

改 broadcast 那条时我按字符串全局替换 搜 `"ack_inbox"`,把 ack_inbox 段里本来正确的两处也一起换掉了 —— 而 docs/pre-pr-selfcheck.md §11 写的正是「批量替换前证明锚点唯一」。是重跑审计时发现的(章节=[ack_inbox] 却落在 broadcast),已按行定位改回。

③ 还了上一轮欠的债:把 #843 的判据修正逐级合下来重跑

上一轮我在 #843 改了判据(四条审查缺陷),而 #844/#845 基于它 —— 它们当时带着旧判据跑出来的绿色。我在 #843 里写了会补,这轮补了:

#843 → #844 (2440d5f2) → #845 (c3c8fa38),每级合入后重建重跑

#845 的结果:

[L5] the four review findings each have an assertion
  ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿
  ② #L0 判为 line-out-of-range rc=1
  ④ 仓库外路径判为 path-escapes-repo rc=1
  ③ 引用仍在文档里时不判为可删,并给出 drifted 警告
MUTATION_RED new-out-of-range-pin rc=1
MUTATION_RED stale-baseline-entry rc=1
RESULT: PASS      exit_code=0

判据是新的、分母是本 PR 的 27/53、L5 四条断言都在。

两次合并都在 report-test831.txt 上冲突(两边都重新生成过)。报告是产物,解法是合完重新生成,不是手工挑行 —— 手工合出来的报告不对应任何一次真实运行。

顺带一个意外收获:合并尚未提交时那次构建红在 blob 绑定上(「镜像里的 run.sh 与 SOURCE_COMMIT 声称的不是同一份」),说明那道绑定确实在起作用,不只是装饰。

上一轮我说过要做这件事,理由是:那次「锚串属主匹配」的审计抓出三处错,但它
只是我人工跑的一次性脚本 —— 不落进门里,下次还会漏。

#831 把行号锚点换成「文件链接 + 可 grep 的串」,解决了行号会漂,却引入一个更
隐蔽的失效:**锚串确实存在,只是落在别的 tool 段**。「锚串存在」这个检查放行
不了它。#845 里连着出了两例,都不是靠工具发现的:

  reassign_task 段 → 锚到 send_message / cancel_task 里的串(我自己抓到)
  broadcast    段 → 锚到 "ack_inbox"(审查者抓到)

第一例我修完就过去了、没做全量审计,于是有了第二例。这一层就是那次审计固化下来。

scripts/check-doc-symbol-anchors.py 的判据:对每条「链接 + 搜/grep <串>」,比对
「引用所在的文档章节」与「锚串在 tools.ts 里命中处所属的 tool 注册段」,交集为空
就判 mismatch。三类不是错的情形都写在文件头,而且**每一类都由实例催生**:
  a) 文本里明写「在 `x` 段搜」/「inside `x`」时以它为准
     (get_all_status 段解释 report_status 写下的列,是真实存在的合法情形)
  b) 锚串在 db.ts 里 —— 那个文件没有 tool 段,按 tool 归属没有意义
  c) helper 函数的实现在最后一个 tool 注册之后,按「最近注册点」会被算到那个
     tool 头上;所以还要看它被哪些 tool 段调用(upsertNodeWithSec1Guard 就是)

写这一层的过程中,它自己有两个作用域 bug 是被自己的输出抓回来的:
  - 「在 `x` 段搜」按整行匹配 → 同一行里另一个锚串被套上了别人的限定,
    造出一条假 mismatch。改成按锚串作用域。
  - 窗口只看锚串**之前** → 英文写法「grep `串` inside `x`」限定在后面,漏判。
    改成取「上一个锚串结束 → 下一个锚串开始」,两侧都包进来。

witnessed-red 用的是 #845 里真实发生过的那个错:往 broadcast 章节插一条锚到
"ack_inbox" 的引用,必须红且指名 broadcast。

验证(容器内,--network none):
  [L6] tool_registrations=46  anchors_checked=67  mismatches=0
  MUTATION_RED broadcast-anchored-to-ack-inbox rc=1
  复原后回绿 ✓
  RESULT: PASS  退出码 0

边界写在脚本头和 L6 注释里:它只管「落在对的段」,锚串在段内是否指着文档声称
的那件事仍要人读。跟 check-doc-source-pins.py 那个 5/10 召回率同一类 ——
门缩小了错误的种类,没有消灭错误。
source_commit=89e454ba564defbe98784e94d1f87cd46c74d3ac
按 pre-pr-selfcheck §12:改动改变了套件下次跑看到的东西,报告要一起更新。
这次是加了一整层 L6,更该更新。
RESULT: PASS  exit_code=0
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

补上 L6:把「锚串属主匹配」从一次性脚本变成门里的一层(89e454ba)

上一条回复里我承认了这轮的毛病是修实例不修类 —— 那次审计抓出三处错,但它只是我人工跑的一段 Python,不落进门里,下次还会漏。这次落进去了。

scripts/check-doc-symbol-anchors.py + tests/test831-doc-source-pinsL6

判据:对每条「链接 + 搜/grep <串>」,比对「引用所在的文档章节」与「锚串在 tools.ts 里命中处所属的 tool 注册段」,交集为空判 mismatch。

三类不是错的情形,每一类都由实例催生,不是我预设的:

  • 明写段名(在 \x` 段搜/inside `x`)时以它为准 —— get_all_status段解释report_status` 写下的列是真实存在的合法情形
  • 锚串在 db.ts —— 那个文件没有 tool 段,按 tool 归属没有意义
  • helper 函数的实现在最后一个 tool 注册之后,按「最近注册点」会被算到那个 tool 头上;所以还要看它被哪些 tool 段调用(upsertNodeWithSec1Guard 正是)

写它的过程中,它自己有两个作用域 bug 是被自己的输出抓回来的

  1. 「在 x 段搜」我按整行匹配 → 第 577 行有两个锚串,report_status 的限定被套到了同行 get_all_status 那个上,自己造出一条假 mismatch。改成按锚串作用域。
  2. 窗口只看锚串之前 → 英文写法 grep \串` inside `x`` 限定在后面,漏判。改成取「上一个锚串结束 → 下一个锚串开始」,两侧都包进来。

这两个都是「作用域比我以为的宽」,和 §11 那条(批量替换前证明锚点唯一)是同一个毛病的不同形态。

witnessed-red 用的是这个 PR 里真实发生过的那个错

broadcast 章节插一条锚到 "ack_inbox" 的引用 —— 就是你上一条指出的原错 —— 必须红且指名 broadcast。

[L6] symbol anchors land in the tool section they claim
  tool_registrations=46
  anchors_checked=67
  mismatches=0
MUTATION_RED broadcast-anchored-to-ack-inbox rc=1
复原后回绿 ✓
RESULT: PASS      退出码 0

边界

写在脚本头和 L6 注释里:它只管「落在对的段」。锚串在段内是否指着文档声称的那件事,仍要人读。check-doc-source-pins.py 那个 5/10 召回率同一类 —— 门缩小了错误的种类,没有消灭错误。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

用刚写的 §13 自查,当场抓到我自己上一条回复里的一个不准确说法

我上一条写「审计结果:中文 40 条、英文 23 条锚串,属主不匹配 0」。L6 报的是 67(42 + 25)。同一份数据两种方法量出不同的数 —— 按新写的 pre-pr-selfcheck §13,这必须解释,不能放着。

差在跳过规则的作用域:

我人工审计:  if 'server/src/db.ts' in l: continue     ← 行里「出现」db.ts 就跳过整行
L6        :  只有当整行的链接「全部」指向 db.ts 时才跳过

第 577 行同时引了 tools.tsdb.ts。我那条规则把整行跳掉了,连带漏掉那一行的 tools.ts 锚串 —— 每版 2 条,合计 4 条。

所以:

  • L6 的 67 是对的,我人工审计的 63 范围偏窄。
  • 我上一条那句「属主不匹配 0」覆盖的锚串比我说的少 4 条。
  • 那 4 条现在由 L6 检查过,mismatches=0 仍然成立 —— 结论没变,但当时的证据比我声称的弱。

这正是 §13 里那条最省事的经验

同一份数据用两种方法各量一次,数字对不上就说明至少有一次范围错了。

docs/pre-pr-selfcheck.md §13(#815 分支,cef0f4b7)刚写完就用上了。写它的动机是我两天里在「范围」这个毛病上栽了三次;这是第四次,而这次是在发出去之前抓到的。

对账明细(可复验):

                          中文   英文
正则抓到的锚串总数         44     27
锚串在 tools.ts 里无命中    2      2   ← db.ts 的锚串,L6 不计(判据 b)
L6 计入 anchors_checked    42     25   → 合计 67 ✓
我人工审计当时报的          40     23   → 合计 63(少的 4 条来自 577 行整行被跳)

我把这条挂了两轮,理由都是"#809/#810 在改同一个文件,怕撞车"——但一直没真去核。
这轮核了,答案很具体:

  两个 rest.md 里的失效 pin 共 25 处
    与 #809/#810 的改动行段重叠 =  7 处   ← 本轮不动
    安全可改                   = 18 处

也就是说"怕撞车"只挡得住 28%,我却拿它当了两轮不做的理由。

撞车的 7 处(按 (文件,行) 精确排除,不是按文件跳过):
  zh 52 / en 54    tools.ts#L521   与 #810 的 49-55 / 51-57 重叠
  zh 1545 / en 1491 tools.ts#L571  与 #810 的 1539-1548 / 1485-1494 重叠
  zh 1547 / en 1493 push.ts#L38    同上
  en 398           auth.ts#L184    与 #810 的 395-401 重叠

改掉的 9 个唯一 pin(中英各一处,共 18):
  auth.ts#L7    → interface AuthUser
  auth.ts#L99   → invalid username or password(全仓 2 处)
  auth.ts#L102  → // User token (utok_) — not bound to network, …
  auth.ts#L243  → generateToken(全仓 3 处)
  auth.ts#L269  → network_id IS NULL AND token_id !=
  auth.ts#L374  → "auto-join", "full"
  db.ts#L168    → CREATE TABLE IF NOT EXISTS networks
  push.ts#L11   → const clients = new Map<string, SSEClient[]>()
  push.ts#L35   → { type: "connected", session: sessionName

替换按 (文件,行号) 定向,不是全局字符串替换 —— pre-pr-selfcheck §13 刚写完,
这次照做了。其中两条(push.ts:11 和 auth.ts:243)是复合标签
(`[`push.ts:11` `clients`]`),第一遍正则没匹配上,是靠"改了 7 处而不是 9 处"
这个数字对不上发现的 —— 也是 §13 说的那条经验。

连带:基线 14 → 5(门要求删 9 条);L0 预期 27/53 → 18/35。
剩下的 5 条正是上面 4 条撞车 pin 加 changelog 的 index.ts#L253。

验证(容器内,--network none):
  unique_pins=18  broken_pins=5  baseline_entries=5
  L6 anchors_checked=67  mismatches=0
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  MUTATION_RED broadcast-anchored-to-ack-inbox rc=1
  RESULT: PASS  退出码 0
source_commit=d57f72d9007812b7d7e9178f7bbb63d9c3b54e2f   按 §12,套件下次跑看到的东西变了,报告一起更新。
unique_pins=18  broken_pins=5  baseline_entries=5   RESULT: PASS  exit_code=0
上一轮我在 #843/#810/#834 上贴了一条跨 PR 耦合提醒:那两个 PR 一合,基线里
对应的条目会变 stale,门就红在「请从基线里删掉」。提醒是散文,执行的人还是
得自己去数该删哪几条 —— 把一个机械操作交给了记忆力。

这一轮把它变成一条命令:

  python3 scripts/check-doc-source-pins.py . --write-baseline

🔴 这个开关离「一键把门变绿」只差一个条件判断,所以它**只许缩小**:
   重算若会引入基线里没有的条目(= 出现了新的失效 pin),它拒绝写并退出非零。
   新失效该做的是把链接改对,不是追认进基线。

L7 两个方向都断言,不只测它能用:
  ① 干净树:不改写,报「基线已经是最新的」,且文件字节未动
  ② 注入一个新失效 pin:必须拒绝(rc≠0),且**确认基线没被写**
     —— 只断言"它红了"不够,要断言"它红了而且没写"
  ③ 造一个「引用消失」场景(把某条 pin 的引用改钉不可变 SHA):
     必须删对、条数变小、表头注释保留、门随后转绿

顺带在门红的提示里直接给出这条命令,不让人再去翻文档。

验证(容器内,--network none):
  [L7] ① 干净树:不改写,报「已是最新」
       MUTATION_RED write-baseline-refuses-new-failure rc=1
       ③ 引用消失时删对了(5 → 4),表头保留,门转绿
       复原后回绿 ✓
  RESULT: PASS  退出码 0

一处说明:这个改动逻辑上属于 #843 的脚本,但落在链尾(#845)。理由是 #843#844#845 是一条依赖链,改在链首要把两级重新合并重跑一遍;而三个 PR 是按序
合进 main 的,落在链尾到达 main 的时间相同。写在这里免得有人以为放错了地方。
source_commit=d9517384ac192655ed66d8dc9833c9a96d2b3355   按 §12,套件下次跑看到的东西变了,报告一起更新。
RESULT: PASS  exit_code=0
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

这条 CI 红与本 PR 无关(证据是别人的运行,不是我的重跑)

本 PR 的 L0 + L1 (report-only) 红在:

✗ L1 test686-rest-shape-golden
    server/src/rest-explicit-columns-http.test.ts:
    (fail) (unnamed) [5531.15ms]
      ^ a beforeEach/afterEach hook timed out for this test.
     0 pass / 1 fail

三条判据:

  1. 本 PR 不碰 server/src/。改动面是 docs-site/docs/scripts/check-doc-*.pytests/test831-* —— 没有一个文件能影响 rest-explicit-columns-http.test.ts
  2. 失败模式是 hook 超时(5531ms),不是断言失败 —— 计时敏感,典型偶发形态。
  3. 🔴 同一天另一个 PR 的运行里,这道门是 PASS 的:test(#823): 给 L1 并发上限闸门补 Docker 回归(含变异见证) #835 的 L0+L1 日志第 223 行 ✓ L1 test686-rest-shape-golden (RESULT: PASS)。同一道门、同一时段、不同 PR → 非确定性失败。

第 3 条是决定性的,而且它不需要我去重跑 —— 证据来自一次与我无关的运行。

(我另外触发了一次重跑,但结论不依赖它。)

顺带:同一批扫描里 #801 / #803 的红都已定位并修复,CI 已转 SUCCESS;#835 的红是 TEST823_SOURCE_COMMIT 收到空串,根因在 scripts/qa.sh 的逐套件 elif 白名单漏登记,已推修复。这三条都是供给侧没给门要的东西,和本条(偶发超时)性质不同。

vansin and others added 5 commits August 17, 2026 23:31
* fix(cli): stop `anet node start --accept-dev-channels` reporting dead nodes as started

Two independent false greens on this path, both measured while restoring 97
nodes after a power loss on 2026-08-17.

1. The success line was printed on the strength of `tmux new-session -d`
   returning. That call succeeds even when the inner `anet node start` refuses
   and exits 1 a moment later, so a refused node printed
   `✅ node "X" started detached (tmux session live; …)` and exited 0 — with
   `can't find pane: X` on the line directly above it. Byte-identical to a real
   success, so a batch restore counted 64/64 up when 6 had never started.

   Now: unstartable profiles are refused before anything is spawned (same
   resolveStartProfile check launchAgent runs, so the message is the real one),
   and success is claimed only after verifyNodeUp — the function `project up`
   already uses to decide whether a node came alive. The success line quotes
   its evidence (`pid N alive`) instead of asserting a session it never checked.

2. The 45 s auto-confirm window was spent on the wrong prompt. A workspace
   Claude Code has not seen before shows folder-trust FIRST; the watcher knew
   only the dev-channels markers, so it stared at a prompt it would not answer
   until the window closed, and the dev-channels prompt that appeared later was
   never confirmed. The node hung silently and the hub showed it offline
   (TM智空负责人 died exactly this way and needed two manual Enters).

   Now the watcher answers folder-trust too and restarts its clock, because the
   window is meant to bound the wait for ONE prompt, not for the whole
   trust-then-channels sequence.

A failed start deliberately does not kill the tmux session — a node stuck on a
prompt is one keypress from working — but the failure output names the session
and says `tmux has-session` will answer yes for it, since that is the criterion
batch callers use.

Verified against the two failure modes and the happy path in an isolated
workspace, with the inner agent stubbed:

  bogus runtime   before: ✅ exit 0   after: refusal on stderr, exit 1, no spawn
  dies on start   before: ✅ exit 0   after: ❌ exit 1 quoting the pane's reason
  trust sequence  before: ✅ exit 0, 46 s, node hung with no pid
                  after:  ✅ exit 0,  5 s, pid alive, both prompts confirmed

All 6 wiring assertions fail against the unmodified file; both pure-module
mutations turn their tests red. Package suite 454 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): apply the same verify-before-claiming rule to --tmux and codex co-presence

Auditing the other 54 `✅` claims in the CLI for the same class as the
--accept-dev-channels false green. Most are honest — `hub start`, the dashboard
launcher and the co-presence app-server all measure before they claim (a
/health fetch, a listener-pid scan, waiting for the `listening on:` line). Two
were not.

`anet node start <alias> --tmux` polls `tmux has-session` for 2 s and calls that
proof. It isn't: tmux registers the session before the inner command has
finished failing, so an unsupported runtime printed
`✅ tmux session "X" started detached` and exited 0 — the session was gone two
seconds later. Measured, then fixed with the same refuse-before-spawning check.
The narrower claim this path makes (the SESSION started, not the node) is left
as-is; it is true, and unlike --accept-dev-channels this path cannot promise a
working node because it never answers the prompts.

The codex co-presence launcher spawns three tmux sessions and then declares the
node 就绪. Only ① proved itself. Its OpenCode twin already checked its TUI
session before making the same claim, so the two sibling paths disagreed about
whether "ready" is measured; now they agree, and 就绪 requires all three
sessions to be alive at the moment it is printed.

Not verified end-to-end: the codex co-presence path needs a working codex, and
this account's quota is exhausted until 2026-08-20. The change mirrors the
OpenCode twin's shape exactly and only adds a failure path where a session is
genuinely absent.

The first version of the --tmux gate passed against the unfixed file — anchored
loosely enough that it found the OTHER branch's preflight. Rescoped to the
--tmux path itself. 3 of the 4 assertions now fail against both origin/main and
the previous commit; the fourth is the OpenCode twin, green on all three
because it is the reference, not a change. Suite 458 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): say which condition made a resolved agent-node unsafe, and name umask

Chasing why the 5 grok co-presence nodes were unstartable. The published anet
(2.3.0-preview.38) has no grok-build-cli in its runtime whitelist, so the
morning's conclusion was "rewrite their config to grok-build-acp". That was
wrong twice over: origin/main already whitelists grok-build-cli, and once past
that the real blocker turned out to have nothing to do with grok.

Measured chain on this machine:

  umask                                            0002
  npx -y @sleep2agi/agent-node@preview   dist/cli.js 0775, package.json 0664
  the check                              (mode & 0o022) !== 0  → refuse
  0o775 & 0o022                          = 0o020  (group-write alone)
  what the operator saw   [anet] Incompatible grok-build-cli runtime.
                          [anet] resolved agent-node package has unsafe ownership or mode

Owner was correct throughout (uid 1000, my own), so the sentence sent every
reader to look at ownership. `chmod g-w,o-w` on those two files made the same
command run all the way through to the agent-node process, failing only on the
fake hub the test config points at — which is what confirmed the diagnosis.

The check is right and stays: refusing to execute a payload the group can
rewrite is correct, and anet cannot know this box's group has one member. What
changes is that it now names the path, the octal mode, which of the four
conditions fired, and that the usual cause is a stock Debian/Ubuntu umask —
with both fixes spelled out. Ownership failures deliberately do NOT mention
umask, so that message stays about ownership.

Both call sites share the new pure module: the grok preview resolver in cli.ts
and the OpenCode pairing check, which enforces the identical rule and would
have produced the identical dead end. The existing assertion on the old wording
still matches — the sentence is kept as the headline and the diagnosis appended.

Suite 465 pass, tsc clean. The npx directory was left exactly as found
(775/664); the fix is the operator's to apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(doctor): warn about the umask that makes grok-build-cli and opencode-cli unstartable

A better error message only helps someone already stuck. `anet doctor` can see
this coming from local state alone.

Both runtimes refuse a resolved agent-node payload whose mode has a group- or
other-write bit. npm creates files as `0o666 & ~umask`, so a stock
Debian/Ubuntu umask of 0002 — every user gets a private group, so 0002 is the
distro default — guarantees 0775/0664 and guarantees the refusal, which reaches
the operator as "Incompatible grok-build-cli runtime" with no mention of umask.

doctor now reports two things, from the process umask and whatever is already
extracted under ~/.npm/_npx. It never fetches, so an empty payload scan means
"nothing extracted yet", not "safe" — the umask verdict is what speaks to the
next fetch. On this machine:

  ⚠  Package file modes: umask is 0002, so npm extracts packages
     group-writable. grok-build-cli and opencode-cli refuse to execute a
     payload in that state, and the refusal reads as an "Incompatible runtime"
     error. Start those runtimes under `umask 0022`, or run
     `chmod -R g-w,o-w` on the resolved package root.
  ⚠  Resolved agent-node payload: 2 already-extracted file(s) would be
     rejected right now, e.g. …/@sleep2agi/agent-node/dist/cli.js (mode 775).
     Fix: chmod -R g-w,o-w …/@sleep2agi/agent-node

A set umask bit means "withhold that permission", so the predicate reads
inverted from how the symptom presents; that inversion is why judgeUmask is a
tested function rather than an inline expression, and 0002/0022/0000/0077 are
each pinned. Reading the umask requires the POSIX set-and-return call — the
helper puts the old value straight back, verified equal on a second read.

Suite 474 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): address tmux sessions exactly — bare -t prefix-matches a sibling node

Every human-facing string in this CLI already spells the exact form
(`tmux attach -t '=<alias>'`, with a comment at the OpenCode co-presence
launcher explaining that a missing TUI would otherwise silently attach to the
bridge). Every tmux command the CLI actually ran passed the bare name.

Measured on this machine with only `zz-honest-probe-extra` alive:

  tmux has-session -t zz-honest-probe    → success  (it is not running)
  tmux has-session -t =zz-honest-probe   → failure  (correct)
  tmux kill-session -t zz-honest-probe   → killed zz-honest-probe-extra

The live fleet here has four colliding pairs — A站内容/A站内容牛,
A站数据/A站数据牛, P站测试/P站测试牛, P站运维/P站运维牛 — so each of the
three consequences is reachable today:

  * has-session false-positives → `node start --accept-dev-channels` prints
    `tmux session "X" already running — skipping spawn` for a node that is
    down, exits 0, and never starts it. Reproduced end-to-end: with only
    `anet-collide-兄弟` alive, origin/main skipped the spawn and left no pid;
    the fixed build started the node (pid alive) and left the sibling running.
  * kill-session reaps the sibling, and `node stop` reports success.
  * send-keys would deliver an Enter into the sibling's Claude UI — the worst
    of the three, since the prompt watcher fires it unattended.

All eight call sites now go through one helper: kill-session, has-session,
capture-pane ×4, send-keys ×2.

killTmuxSession additionally returns whether the session is actually gone. Its
`kill-session` failure is swallowed on purpose — a session that already exited
is the common case — so the only way to know is to look afterwards. `node stop`
now checks that and refuses to report a stop it did not achieve, instead of
deriving "killed" from the pre-kill has-session probe and notifying the hub
offline over a session that is still up.

Suite 478 pass, tsc clean. Fleet untouched at 89 sessions throughout; the
integration test uses its own `anet-exacttest*` names and cleans up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Follow-up to #895, one level up. That PR fixed the single-node start paths; this
is the same defect class in the batch entry point, and it is the one automation
actually calls.

`anet project up` already measures every node with verifySpawnedNodes and prints
each failure, so its OUTPUT was honest — unlike the single-node path, it never
claimed a dead node was started. What it did not do was set an exit code: both
projectUp and projectRestart returned normally, so a run that brought up 60 of
74 nodes exited 0.

That matters because this is the entry point scripts use. While reviewing a
boot-time sweep for this machine's ~74 agent nodes, the design leaned on a
post-flight tmux audit rather than on `$?` — and the reason turned out to be
load-bearing rather than stylistic: `grep -c process.exit` inside projectUp
returns 0. Any watchdog or CI step that trusted the exit code was being told the
fleet was fine.

`invalid` counts toward failure too. A node whose config cannot start was never
attempted, so exiting 0 hides it exactly as well as a crash does.

The gate runs after printProjectSummary so the operator still gets the full list
before the process dies, and a clean run returns early and stays at exit 0.

All 5 assertions fail against f565e9b and pass here. Suite 483 pass, tsc clean.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Doc-only refresh from 通信狗 review (issue #639). No behavior changes,
no runtime/config edits — every touched file is markdown or a
package.json `description` field.

## Version facts sourced from npm at commit time

Verified via `npm view <pkg> dist-tags` on 2026-08-14:
  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29
  local anet: v2.3.0-preview.38 (matches preview channel, one behind head)

## Per-file changes

P0-1 CHANGELOG.md banner
  - Drop hardcoded "当前 stable 是 v0.10.11" (out of date).
  - Point readers at npm `latest` + docs-site/docs/changelog.md as the
    live source; keep the v0.10.15 archival anchor + v0.8.1 OSS-first
    note. File still an archive of pre-2026-04 v1.0.0-preview history.

P0-2 docs/getting-started.md
  - Runtime table now includes `grok-build-acp` (needs `grok login`).
  - Note that @Preview additionally ships `codex-app-server` and
    `opencode-cli`; the authoritative full runtime table is at
    anet.sh/guide/runtimes.
  - `anet ls` → `anet node ls` (matches current CLI).

P0-3 docs-site/docs/{,en/}guide/getting-started.md
  - Add a preview-channel warning next to the admin/anethub line:
    @Preview prints a one-time random password on first
    `anet hub start`, don't hard-code `anethub`. This aligns with the
    README + cli.md wording that already carries the note.

P0-4 AGENTS.md 项目结构
  - Drop "39 命令" / "4 runtime" hardcoded counts (both drift).
  - Point at docs-site/docs/guide/cli.md as canonical CLI list.
  - Split runtimes into stable (4: claude-code-cli / claude-agent-sdk /
    codex-sdk / grok-build-acp) + preview extra (2: codex-app-server /
    opencode-cli); mark `grok-build-cli` as unreleased in any channel.

P0-5 docs/version/README.md + docs/plans/release-plan.md
        + docs/version/0.11.0/README.md
  - Backfill preview matrix from `npm view <pkg>@preview` (was pinned
    at .34/.26/.20 — now .39/.31/.29). Added timestamp + reminder to
    re-check `npm view` before editing.
  - WAIC 7-月-下旬 anchor is out of the window; strike-through the
    completed date, keep the archival link to waic-release.md, and
    replace with "current promote status per release-plan".
  - release-plan.md defaults table gains a commhub-server column so
    readers see all three packages, not just two.

P1-6 docs-site/docs/guide/architecture.md
  - "14 张表" → "20+ 张表(含 sessions / tasks / nodes / users /
    networks / SkillHub / providers / vault 等,实数按 schema
    版本浮动)"; EN mirrors it. Fixes both the two mermaid diagrams
    and the paragraph.

P1-7 docs/architecture.md
  - Runtime paragraph now says "stable 4 + preview 2" and points at
    anet.sh/guide/runtimes as authoritative.
  - 14 cli.ts deep `#L<line>` anchors defanged (link stays, line
    number dropped — they rot every release; kept the function name
    in the link text so intent survives).
  - Directory tree gains an "已不完整,以仓库实际为准" note so
    readers don't treat it as canonical.

P1-8 server/package.json + server/README.md
  - package.json `description` now says "MCP tools (17 collaboration-
    core + node/provider ops tools; authoritative list at
    docs-site/docs/api/mcp-tools.md)" — was "and 17 MCP tools"
    (readers took it as the total).
  - server/README.md MCP section gains one line saying the 17 in the
    table are the collaboration-core subset; full list at
    docs-site/docs/api/mcp-tools.md.

P1-9 README.md + README.en.md
  - "能做什么" / "What it does" gain one bullet pointing at Codex TUI
    co-presence and OpenCode as preview-channel additions with a link
    to the Runtime page.

## Not touched (per review scope)

  - docs/v3-postgresql-design.md archive banner (do not edit)
  - upgrade-v2 archive banner (do not edit)
  - grok-copresence danger banner (do not edit)
  - runtimes 官方表 (canonical, do not edit)

## Verification

`grep -c` on the touched files confirms:
  - `14 张表` remaining in docs-site/docs/guide/architecture.md: 0
  - `39 命令` remaining in AGENTS.md: 0
  - stale `cli.ts#L<n>` deep anchors in docs/architecture.md: 0
  - `anet ls` (bare, without node prefix) in docs/getting-started.md: 0

Co-authored-by: t <t@t>
Four findings from the open-issue triage, each verified against origin/main
before touching anything. Two of the four numbers in those issues were wrong in
ways worth recording.

## qa.yml path filter missed tests CI actually runs (#860)

qa.yml fires on a path filter. Four directories CI executes were outside it, so
editing the test could not re-run its own gate — and the run looks identical to
a gate that passed on the new code:

  tests/test292-e2e-hard-gate        (referenced by a workflow path)
  tests/test686-rest-shape-golden    ┐
  tests/test765-batch-runtime-gate   ├ reached through scripts/qa.sh L1_TESTS
  tests/test766-bunx-preflight       ┘

#860 reported three; it missed test292-e2e-hard-gate. My own first scan
under-counted in the other direction — it matched `tests/testNNN` and so never
saw L1_TESTS, which names directories bare. The remaining ~160 directories under
tests/ are run by no workflow at all, and are deliberately left out: a filter
entry for an unrun test reads like coverage it does not have.

## …and a guard so it does not drift back

`.github/scripts/check-qa-trigger-coverage.py` asserts every CI-executed test
directory is in the filter. Three behaviours, each exercised:

  fixed repo            → exit 0, "all 7 CI-executed test dirs can re-trigger"
  f565e9b's qa.yml     → exit 1, names all four with the line to add
  L1_TESTS renamed away → exit 2, "no CI-executed test directories detected"

That last one matters most: if the parser stops matching, the honest answer is
"I can no longer see the denominator", not a clean run against nothing.

Its workflow intentionally carries NO `paths:` filter. It guards a path filter;
gating it on paths would let an edit to qa.yml's filter or to L1_TESTS slip past
the check that watches them — the same blind spot it exists to catch.

## public-script safety: TLS verification (#890)

check-public-script-safety.py flagged `rm -rf` outside our paths and unscoped
`pkill`, but nothing about `curl -k` / `--insecure` /
`wget --no-check-certificate` / `NODE_TLS_REJECT_UNAUTHORIZED=0`. These scripts
are fetched over https and piped into bash, so verification is the reader's only
defence against a tampered download; there is no legitimate reason for a script
published at a public https URL to skip verifying it. That meets the file's own
"only unambiguous rules" bar. Zero current hits — this one is preventive.

While adding it I hit a bug in the reporting: the hint was chosen by an
if/else whose `else` branch belonged to the kill rule, so every TLS finding
printed advice about `pkill -u`. Remediation text is now keyed by rule, and an
unknown rule exits 2 rather than borrowing another rule's advice — pointing the
reader at a problem they do not have is worse than printing nothing.

Verified with real exit codes (not through a pipe, where `$?` is the last
command's): known-bad fixture → exit 1 with the right hint on all three forms,
comment lines ignored; real repo → exit 0 across 6 scripts.

## dashboard min_uptime (#892)

deploy/dashboard/ecosystem.config.cjs had `min_uptime: 20_000` while
docs-site/docs/deploy/daemon.md documents 45000 and explains why: below the
time a failing process takes to exit, PM2 counts the start as successful,
backoff never engages, and a crash loop looks like normal restarts. A dashboard
rebuilt from this repo landed exactly in that gap. Aligned to 45000 with the
reasoning inline; `node -e require(...)` confirms it still parses.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Four more from the open-issue triage. Each was verified against origin/main
first, and two of them turned out to be bigger than the issue said.

## docs/qa/weekly/2026-W19.md would not decode as UTF-8 (#887)

Three multi-byte characters were truncated mid-sequence, not one. The issue
reported the first; repairing it revealed the second, and that one the third.
The damage pattern is consistent — every case is `_italic text_` with the
character immediately before the closing `_` eaten — which points at a
truncating edit rather than a bad encoding.

The lost characters are NOT recoverable, so they are marked as damaged rather
than guessed. This is a QA weekly report; inventing a plausible character would
be worse than saying a character is missing.

## …and all 24 of its relative links were dead (#872)

Not "24 broken links" in the sense of a few typos: 0 of 24 resolved. The file
sits three levels deep and the links were written for two, so every `../../`
landed inside docs/ instead of at the repo root. Four more used a single `../`
for directories that live under tests/. All 24 now resolve — verified by
resolving each one against the filesystem, not by eyeballing the diff.

## docs/qa timings contradicted each other three ways (#871)

  docs/qa/README.md          ~16s warm
  docs/qa/strategy.md        ~16s warm
  docs/qa/v0-summary.md      ~93s local, ~40s CI
  v0-summary's own per-test table, summed:   156s

The issue framed this as "pick one and unify". None of the three can be picked,
because not one of them says what it measured — warm or cold, serial or
parallel, which machine. 156s serial against a 93s wall clock just means there
is parallelism nobody wrote down.

So the dead numbers are gone from README and strategy, replaced with
`time bash scripts/qa.sh` and the one fact that stays true: the per-test table
sums to 156s, anything lower implies parallelism, cold starts are worse.
v0-summary keeps its 93s but now says it is a 2026-05 measurement.

## scripts/verify-published-pins.sh and verify-release-tag.sh had zero callers (#862)

Both committed, both executable, both carrying the incident that motivated them
in their header — and `grep -rl` across .github/ and scripts/ found nothing that
invokes either. A guard nothing calls protects nothing, while its presence reads
as if the risk were covered.

Running verify-published-pins.sh by hand, for the first time, failed on its
first invocation:

    ❌ OPENCODE_AGENT_NODE_VERSION 期望 2.5.0-preview.31,
       产物里是: 2.5.0-preview.28
    1 个 pin 与已发布产物不一致 —— main 修了但用户装到的包没修

That is the exact distinction its own header says bit this repo three times in
one day, live in the published preview and undetected. (Independently confirmed
by hand earlier the same night: installing preview.39 demands agent-node
preview.28, while main's source constant reads preview.31.)

Now scheduled daily plus manual dispatch, with the exit codes mapped so that
"could not measure" does not become the same green as "measured and fine":
rc=2 (registry unreachable) fails with a notice saying the run verified nothing.
Per-PR would be wrong — it inspects the published artifact, which a PR does not
change.

## A guard for the first two

`.github/scripts/check-docs-integrity.py` checks UTF-8 validity across every
tracked .md and relative-link resolution under docs/qa/. Three behaviours, each
exercised: repaired tree → exit 0 (359 files, 80 links); f565e9b's W19 → exit 1
with 25 errors naming each; LINK_SCOPE pointed at a missing directory → exit 2,
"scope regression, refusing to pass".

It starts green, so it is not a backlog canary — a red here will always mean
something just broke. Link checking is scoped to docs/qa/ and says so: some
pages elsewhere link to generated paths, and a guard that cries wolf gets
disabled.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
vansin and others added 25 commits August 18, 2026 03:56
deploy/fleet/README.md 的安装步骤里本来就有这条:

    test "$(git hash-object deploy/fleet/pm2-fleet-boot.sh)" = \
         "$(git hash-object "$HOME/.local/bin/pm2-fleet-boot.sh")"

🔴 但它只在**安装的那一刻**跑一次,所以它只能证明「装的那一刻是对的」——它管不住
之后任何一次手改。不是没人想到要校验;校验就写在安装步骤正中间,缺的是有人在装完
之后再跑它一次。

2026-08-18 在生产主机上跑本脚本,4 对里 1 对不一致:

    🔴 deploy/fleet/pm2-fleet-boot.sh   仓=ba9f214f  机器=a667de68
    ✅ deploy/fleet/pm2-fleet.service
    ✅ deploy/hub/hub-daemon.sh
    ✅ deploy/dashboard/dash-start.sh

漂移是**孤立的一处**,不是系统性的——这个信息本身有用:另外三对说明这条安装链
平时是被遵守的。

而且方向是反的:仓里那份**更新**、多一道 `pm2 jlist` 失败时拒绝 resurrect 的
fail-closed 护栏,机器上那份是 7 月 30 日的、没有。护栏写好了、提交了,**但从来
没有部署到会真正执行它的地方**。见 #839。

判据用仓库自己那条(`git hash-object`),不另造等价物——结论谁都能重跑,且不依赖
任何人对「什么算不同」的理解。

**证据(都不接管道取 exit code)**

    真跑(本机)                     exit 1  4 对/漂移 1/缺失 0,点名那一对并给出 diff 命令
    不在 git 仓库里跑                exit 2  "拒绝通过"
    清单指向不存在的仓库文件(变异)  exit 2  "清单过期,拒绝通过"
    清单为空                         exit 2  (代码路径,未构造)

后两条是 fail-closed:判不了的时候红,而不是「检查了 0 对,全部一致」。

🔴 它只报告,不修。把主机上的文件换成仓里的版本是一次真实运维动作——会改变所有
pm2 托管进程的重启路径,需要人挑窗口,不该由一个检查脚本顺手做掉。同理它没有被
装到主机上、没有挂进任何定时器:那也是运维动作。

放在 deploy/ 而不是 .github/scripts/,因为它要在**主机**上跑;CI 看不到
~/.local/bin,在 CI 里跑它只会得到「主机缺失 4 个」。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#817 实测:origin/main 上删掉 46 个 agent-network/src 测试里的 40 个,test745 仍

  test_files=6  executed_files=6  RESULT: PASS  rc=0

因为分母和执行数会跟着现实一起缩水 —— `executed >= test_files` 只能抓「runner
少跑了文件」,抓不到「文件没了」。test725 更彻底:它连 src 的分母都没有,
只有一行 `bun test src/`。

这次:

  test745  加 AGENT_NETWORK_SRC_FLOOR=40(磁盘上现有 46)
  test725  补 src 分母 + AGENT_NODE_SRC_FLOOR=80(现有 91)
           并照 test745 的形状补上 executed >= discovered

下限是「大量删除」的绊线,不是精确计数:真删了测试就故意改这个数,
让删除这件事必须在 diff 里显形。

见证红(把插入的块逐字抽出来跑,ROOT 指向构造的树):

  文件齐 46 / 91        → OK
  按 #817 删到只剩 6 个 → FAIL: only 6 test file(s) under agent-network/src, floor is 40
                          FAIL: only 6 test file(s) under agent-node/src, floor is 80

不动 #800 的范围:那个 PR 守的是 tests/ 目录(FLOOR 15 / 5),这个补的是 src/。

Co-authored-by: vansin <smartflowaiteam@gmail.com>
run.sh 里那一行是:

    log "network: disabled by runner"

它挨着的每一条都真的在验:

    [ ! -e "$ROOT/.git" ]   || fail "image contains repository metadata"
    [ ! -e /root/.grok ]    || fail "image contains a Grok home"

**只有它在复述一个别人应该做过的事。**

而 [L2] 那一步的全部意义是「在没有网络的情况下构建候选包」。如果 runner 忘了
`--network none`,那一步照样绿——而它证明的东西并不成立。🔴 忘记加那个 flag 与
正确加了它,产出的证据逐字相同。

判据用直接观察:`--network none` 的容器里 `/sys/class/net` 只有 `lo`。

    net_ifaces=$(ls /sys/class/net | tr '\n' ' ' | sed 's/ *$//')
    [ -n "$net_ifaces" ] || fail "cannot read /sys/class/net — …refusing to claim it is"
    [ "$net_ifaces" = "lo" ] || fail "network is NOT disabled: …[$net_ifaces]…"
    pass "network is off (verified: /sys/class/net = [$net_ifaces])"

两个方向都收:读不到 `/sys/class/net` 时**拒绝声称网络是关的**(fail-closed,
而不是当成"看不见就是没有");看到第二个接口时点名它,并说明后面的绿因此不作数。

本机对照(有网的宿主):`/sys/class/net` = `docker0 eth0 lo` → 这条断言会红。

注:test224 目前不被任何 CI 引用(#861 统计的 180 个孤儿之一),PR #803 正在把它
注册进 qa.sh。本 commit 只修判据,不改注册状态——一道会被跑的假断言和一道不会被跑
的真断言,前者更危险,先修前者。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#819) (#925)

* test(server): 给 resolveRestWriteNetworkId 补测试——它是 network-scope.ts 里唯一没被任何测试点名的导出函数(#819)

同文件五个兄弟各有 1-2 个测试文件点名,只有它是 0:

    resolveRestNetworkScope 1   canRestWriteNetwork 1   singleNetworkId 2
    addNetworkScope 1           getUserNetworkIds 1
    resolveRestWriteNetworkId   🔴 0

而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的
规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了
「这个管理员恰好只属于一个网络」;**写**不能继承这个歧义。

规则两半都钉了,因为它们坏掉的后果完全不同:
  - **放行那半**(admin + 恰好 1 个成员关系 → 用它)坏了 = 管理员写任何东西都要
    显式带 network_id。很吵,但安全,所以没人急着修。
  - **收紧那半**(admin + 0 或 ≥2 → null)坏了 = 一次写入**落到一个没被指定的
    网络里**,而调用方看到的是成功。

只写反向断言不够:一个「永远返回 null」的实现能通过所有收紧用例。所以每条收紧
断言都配了正控。

**变异验证(先红后绿)**

    未变异                                    10 pass / 0 fail
    A:函数体开头直接 return null             6 pass / 4 fail   ← 打掉「放行」那几条
    B:去掉 memberships.length === 1 判断      8 pass / 2 fail   ← 打掉「≥2→null」「0→null」
    还原                                      10 pass / 0 fail

两次变异都带 `assert 锚点命中 == 1`,确认不是 no-op。

🔴 **有一条断言我一开始写错了,改成记录留在文件里**:我以为「作用域为空数组 →
null」,实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员
关系回退分支。

追下去发现 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义(`:46`):
**非 admin 请求了一个自己没有角色的网络**,带着 `denied: "access denied to
requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。

现在**没有**问题:那一行只对非 admin 产生(admin 在 `:42` 提前返回
networkIds:null),而非 admin 走到这里必然 null。但如果将来有任何路径让 admin
拿到 `networkIds: []`,这次写入会**忽略那条明确的拒绝**。所以两条都钉住了:当前
行为,以及那条让它安全的前提(并对前提本身也断了一次)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(ci): 把 #819 那条新测试挂进 L0——否则它是一条永远不会被跑的测试

🔴 自查发现的:`server/src` 下有 **71** 个 `*.test.ts`,而 CI 的 L0 只点名 **5** 个。
我在上一个 commit 里加的那条测试**不在这 5 个里**——也就是说,如果只加文件不改
qa.sh,我刚写的这条测试**在 CI 里一次都不会跑**。

这正是我这两天在别人代码里反复指出的形状(#861:204 个套件里 21 个被 CI 引用;
#817:门连分母都没有),而这一次是我自己差点交出去。

**够不够格进 L0,是量出来的不是假设的**:

    依赖链   network-scope → db → db-adapter(bun:sqlite) / auth(node:crypto)
             全部是 bun/node 内置,**不需要 bun install** —— 满足 L0 的零依赖预算
    耗时     538 ms
    对照     已在 L0 的 auth-tokens = 592 ms  ← 比它还快

(L0 的排除标准写在数组下方的注释里:observer-avatar-http 被排除是因为它启真
HTTP server、import 链需要 MCP SDK。这条测试不碰这两样。)

跑一次确认没破:

    ✓ L0 password-dict / auth-tokens / auth-validate / observer-push
    ✓ L0 avatar-validate / rest-write-scope
    ✓ ALL PASS in 2s

顺带留一个数字给 #798(它要给 server 补聚合单测门):**71 个里进 CI 的现在是 6 个**。
本 commit 只把新增的这条挂上,不动其余 65 个的归属——那是 #798 的范围。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`waitForTmuxPaneText` 用的是 `capture-pane -p`——**不带 `-S` 时它只返回当前可见
区**。app-server 打完 `listening on: <ws>` 之后如果还有输出,那一行滚出屏幕,这个
轮询就再也看不到它,于是等满 25s 报「did not bind within 25s」——而服务早就绑上了。
#849 实测:**1.1s 绑上,25s 判失败**。

**本地复现(同一个 pane,先打 needle 再刷 200 行日志)**

    capture-pane -p            → includes(needle) = false
    capture-pane -p -S -500    → includes(needle) = true

判据是 `-S`,不是别的:这个函数找的是**曾经出现过一次**的那一行,不是「此刻屏幕上
有什么」。

🔴 **同一个 flag,四处调用里两处该加、一处不该、一处早就加了:**

    :249  waitForTmuxPaneText   找「曾出现过的就绪信号」   → 加 -S -200  ← 本条修的
    :818  bridge 尾部日志        找「曾出现过的上下文」     → 早就有 -S -80(正确写法一直在同一个文件里)
    :8016 capturePaneReason      找「曾出现过的失败原因」   → 加 -S -200
          一个已死 pane 的报错常被后续输出顶走;拿不到就回退成一句泛化文案,
          而真正的原因还在回滚里
    :7979 dev-channels 自动应答  判「此刻屏幕上有没有提示框」→ **故意不加**
          加上回滚,一个早被答掉、已滚走的提示框会被重新识别成待处理,
          于是往一个并没有显示它的会话里 send-keys

第四处旁边写了「故意不加」的理由,免得下一个人看到三处有一处没有就顺手补齐。

`bun build` 通过;`tmux-pane-prompt` + `tmux-exact-target` 单测 14/14。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`resolvePreviewAgentNodeEntrypoint` 原来是:

    } catch {
      throw new Error("could not install and resolve @sleep2agi/agent-node@preview");
    }

`execFileSync` 明明 `stdio: [..., "pipe", "pipe"]` 抓了 stderr,而 catch 把它整个
丢掉。

🔴 **这是全新安装第一次 `anet node start` 的必经之路**——agent-node 按设计由 npx
懒取(checkRuntimeDependency 里那句 `note: agent-node will be lazy-fetched via npx
on first start (this is normal)` 就是在说它)。所以它失败时,用户拿到的是一句**没有
原因**的话,而真正的原因就在被丢掉的 stderr 里:registry 不可达 / 权限 / 磁盘满 /
120s 超时——**每一种的下一步动作都不同**。

**stub 掉 npx(让它报 EACCES)实测对照:**

    修前:could not install and resolve @sleep2agi/agent-node@preview
    修后:could not install and resolve @sleep2agi/agent-node@preview
          --- npx said ---
          npm error code EACCES
          npm error syscall mkdir
          npm error path /usr/lib/node_modules/@sleep2agi

同一个形状在 docs-site/docs/public/install.sh 上修过一次(#908):那次是
`>/dev/null 2>&1` 吞掉首次尝试的 stderr、然后把每一种失败都叙述成「registry 失败」。
**这里更进一步——它连一个猜测都不给。**

细节:
- 超时单独点名(`npx exceeded the 120s budget`),因为 120s 超时和 npx 报错在原来
  那句话里完全一样;
- npx 一个字都没输出时明说 `(npx produced no output — check that \`npx\` itself works)`,
  而不是留一句空的原因;
- stderr 只取最后 8 行、截断到 1200 字符——够定位,不刷屏。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
这个套件跑同一个测试文件三次(基线 / 变异后 / 还原后),三次都只看退出码。而退出码
分不出**「5 个测试全过」**和**「只注册到 1 个、它挂了」**。

🔴 2026-08-17 21:20 UTC 的 CI 上真的发生了后者:

    (fail) (unnamed) [5247.62ms]  ^ a beforeEach/afterEach hook timed out
     0 pass  1 fail
    Ran 1 test across 1 file. [5.47s]

同一个文件在正常环境是 `5 pass / 0 fail / Ran 5 tests / 620ms`。

摘要里那句 `0 pass 1 fail` 读起来像「跑了 1 个、挂了 1 个」——**没有任何一行说本该
跑 5 个**。另外 4 个既没跑,也没被提到。

改动:三个阶段各自 `tee` 到日志并断言 `Ran N tests` 中的 N ≥ GOLDEN_MIN_TESTS(5)。

用**下限**而不是等号:加测试是常态,加了不该让这道门红;**少跑了才是要抓的**。
下限旁边注了日期(截至 2026-08-18 实际为 5),免得它变成一个悄悄失去意义的常量。

🔴 **变异那一轮同样要断分母**——如果那一轮压根没跑起来,它也会「红」,而那是一个
**为了错误的理由变红**的 witnessed-red,证明不了变异真的被抓住。这是三处里最容易
被漏掉的一处:前后两次绿的断言很直觉,中间那次红的断言不直觉。

读不到 `Ran N tests` 时 fail-closed(「判不了跑了几个,拒绝通过」),而不是当成通过。

**离线验过判据本身**(三种输入喂给那个函数):

    正常输出(Ran 5 tests)            exit 0
    塌陷输出(Ran 1 test)             exit 1  "只注册到 1 个测试,下限是 5"
    垃圾输出(读不到 Ran N)           exit 1  "判不了跑了几个,拒绝通过"

`sh -n` 通过。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
**一、5 条死链,指向的东西从来就不存在**

    report-test-v092-preview5.md   git log --all --diff-filter=A → 0 次新增
    report-test-v092-preview6.md   同上 0
    report-test-v092-preview7.md   同上 0
    v010-chain-test-baseline.md    同上 0
    ../../memory                   仓外目录

不是「文件被删了」,是**从未提交过**——这份已提交的文档引用的是只存在于某台机器上
的东西。(同一形状:docs/rfcs/RFC-022 链到 RFC-017,而 RFC-017 也不在 main 上。)

对照同一份文件里**能用的**那些引用:它们全部指向 GitHub issue。**这个文件自己的
主流写法是对的,坏掉的是少数几条指向本地文件的。**

改法:把链接去掉、保留归属,并说明那份报告未进仓。**每一条的内容本来就写在正文
里**(「bash backticks in echo strings → cmd substitution spawns interactive wizard
→ container hang. Use single quotes」),链接不提供任何额外可取回的东西。

**二、2 处内部 memory slug 出现在公开仓**

    :7    **Per [`feedback_docker_smoke_real_tty`]**
    :224  (per [[feedback_gate_evidence_must_be_runner_generated]])

第二处是双方括号形式,本该被 check-no-memory-slugs.py 抓到——但 `docs/tests/` 在
它的 ALLOWLIST_PATH_PREFIXES 里(那个豁免是有意的、写了理由的,见 #772)。
第一处是**单方括号**,任何一道现有的门都不匹配。

两处都换成**它们各自的理由本身**,而不是指向一个外人拿不到的 slug:

    :224  → 「证据必须由 runner 在被检对象之外产出,否则『被检的东西』和『检它的
             东西』来自同一次提交,红不了。」

🔴 **写下理由比写下 slug 名有用**:读者当场就能判断这条约束讲不讲得通,而 slug 名
只告诉他「有个地方记着这件事,而你进不去」。

验证:本文件剩余死链 **0**、剩余 slug 引用 **0**;`check-no-memory-slugs.py` exit 0。

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
这四个套件从 2026-07-02 起就是红的,而**没有任何东西会跑它们**,所以没人知道。
#861 里实测过:4/4 退出码 1,全部死在第 2 步,错误完全相同。

## 三处独立的漂移,逐个修

**① report_status 的身份绑定(#203 / #376,2026-07-02)**

`server/src/tools.ts:584` 之后,用 network token 上报时 args 里的 alias 必须等于
该 token 绑定的 alias。而 `callerAlias` 的推导(`server/src/server.ts:733-735`)是:

    tokenName 以 "node:" 开头 → 取其后缀;否则**回落到用户名**

四个套件都拿 `register_user` 返回的 `network_token`(名字不是 `node:…`)去上报
任意 alias,于是一律 `alias_identity_mismatch`。

修法:加 `node_token()` helper,为每个要上报的 alias 铸它自己的 node token
(`POST /api/auth/node-token`)。取法与**已注册且长期绿**的 `qa-hub-05-roundtrip`
完全一致 —— 不是我发明的写法。
qa-hub-12/13 上报多个不同 alias,所以再加一层 `report_as()`:直接从 args 里取
alias 再铸 token,循环调用点不必逐个改、也不会漏。

**② send 侧的对称检查(fromIdentityMismatchReply)**

用 network token 发送时 `from_session` 也必须等于 token 绑定的 alias。
qa-hub-10 / qa-hub-13 的发送方同样改成持有自己 node token。
两处的断言(`"from":"alpha-sender"` 等)**原样保留**。

**③ qa-hub-10 第 3 步断言的是一个已被有意修掉的 bug(#517)**

原断言:utok 不带 network_id 发送 → `permission_denied: network_id required`。
`#517` 的标题就是「节点发消息报 permission_denied: network_id required,而工具 schema
没有这个入参(**一晚三个节点抄送全部静默失败**)」—— 那个报错本身是 bug,
修法是单网络 utok 自动解析。

所以这一条**改断言**(参照 #804 / test682:产品有意改掉的东西,该改断言而不是把行为改回去),
改成断言新的正确行为:自动解析并投递成功。这是四个套件里**唯一**被改掉的断言。

## 🔴 一个我猜错、被实跑纠正的假设

我原本怀疑 qa-hub-13 测的端点被改名了(`/api/server/:host/health` vs 产品里的
`/api/server-health/:host`),依据是 `git grep "/api/server/"` 在 `server.ts` 里 0 命中。
**证据看起来很硬,但结论是错的** —— 修完之后:

    [4] /api/server/:host/health exposes latest alert + history for network A only   ✓
    [5] /api/server/:host/agents exposes per-agent details and process telemetry     ✓

两个端点都在,都正常。静态比对给出的是「哪里可疑」,不是「实际会怎样」。

## 验证:四个都实跑到绿

    qa-hub-10  rc=0  PASS network scope regressions (#67 message ✓ / #54 SSE isolation ✓)   7 步
    qa-hub-11  rc=0  PASS node-delete-sse (#74 node_deleted push ✓ / network isolation ✓)   5 步
    qa-hub-12  rc=0  PASS servers endpoint (#119 host telemetry aggregation ✓)              5 步
    qa-hub-13  rc=0  PASS server health/agents endpoints (#140 Hero 1+2 ✓)                  9 步

exact `origin/main` 上构建运行,跑完逐个删镜像。
**这些断言此前一次都没被执行过** —— 它们全部倒在第 2 步。

## 本 PR 不做注册

按 #861 里定的次序:先修好、确认能绿,**再**谈要不要进 `L1_TESTS`。
注册是另一次改动(且会影响 CI 时长),应当单独决定。
若决定注册,`tests/qa-*/**` 已在 `qa.yml` 的 paths 里,不用改 paths(见 #860)。

Co-authored-by: vansin <t@t>
* test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个)

server/src 下 69 个 *.test.ts,CI 可达的只有 6 个(scripts/qa.sh 的 L0_TESTS
点名 5 个 + test686 引用 1 个),另外 63 个没有任何 job 会碰。server 是 hub 本体
—— 认证、token、网络隔离都在这里,盲区比 agent-network 那 46 个严重。

形状抄 test745/test725,但按 server 自己的契约做了两处改动:

1) 逐文件跑,每个文件一个独立 DB。scripts/qa.sh 的 L0 本来就是
   `COMMHUB_DB=/tmp/qa-l0-$name.db bun test <one-file>` —— 这是既有契约。
   用一个共享 DB 聚合跑会红 4 条(admin-networks 的 global-admin 可见性、
   scheduled-tasks 三条),而这 4 条单跑全绿,是跨文件状态污染。
   把"聚合能不能跑"当门等于给它加了一条它从没承诺过的性质。

2) cwd 必须是仓根。task-lifecycle-watcher 用 process.cwd() 拼
   ./server/src/db.js,scheduled-tasks-http 按仓根相对路径 import
   tests/test601-.../race-worker.ts。从 server/ 目录跑会让这两个红在路径上,
   看起来像产品坏了。

红线:COMMHUB_DB 不设默认指向生产库。容器里够不到宿主的库,但不靠"够不到"
保证 —— run.sh 显式钉到 /tmp 并断言钉住了。31/69 个测试引用 sqlite/COMMHUB_DB。

分母承重:executed_files 必须等于 find 出来的 test_files,少一个就红。

witnessed-red:把 auth.ts 注册密码下限 `< 8` 改成 `< 1`(7 位密码会被接受,
一条真的安全回退),先校验字节非 no-op,再要求红落在指名的
"rejects 7-char password" 上。

实测:test_files=69 executed_files=69 failed_files=0,
MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS,耗时 51s。
完整输出见 docs/tests/report-test798-server-unit-ci.txt。

* ci: server 单测门抽成独立 job,别挂在 agent-network 名下

上一版把 build/run 两步插进了 agent-network-unit job 里,所以它确实跑了
(CI 日志实测 test_files=69 executed_files=69 failed_files=0
MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS),
但会以 "agent-network unit (Docker, non-root)" 的名义显示 ——
server 挂了会归错帐,而且两个重 Docker build 串在一个 job 里。

抽成 server-unit job,显示名 "server unit (Docker, non-root)"。

* ci: test601 的 race-worker 也要能触发 server 单测门

自查清单第 4 条(判据范围要与被判对象一致)在自己 PR 上的第一次应用:
把 test798 镜像 COPY 的每一项,回去核 qa.yml 的触发路径有没有覆盖。

  COPY server ./server                      → 'server/**'        ✅
  COPY agent-node/src ./agent-node/src      → 'agent-node/**'    ✅
  COPY tests/test601-hub-scheduled-tasks    → 无                 ❌

server/src/scheduled-tasks-http.test.ts 会执行那个 race-worker 做
「两个真 Hub 进程抢同一个 occurrence 恰好一次」的用例 —— 只改 worker 的 PR
不该跳过这道门。两处 path 过滤都补上。

这条是 codex 在 #798 上提的 P2,当时我认了但没修;现在按清单扫一遍就扫到了。

* test(ci): mutation 的命名断言要锚在 (fail) 行,否则通过时也会命中

自查清单(#815)第 ⑤ 条「断言要精确到不合规会被拒绝」在自己门上的应用。

原来写的是 `grep -Fq 'rejects 7-char password'`。bun test 对每个用例都打
`(pass) <名字>` 或 `(fail) <名字>` —— 只 grep 名字的话,那条用例**通过**时
也会命中。于是这条断言只证明了「这条用例存在」,而不是「红落在它身上」。

A/B(把断言指向一条在该 mutation 下**不会红**的用例
`accepts 8-char strong password`,其余完全不动):

  松版 grep -Fq '<名字>'                → rc=0  RESULT: PASS   ← 收下了不合规
  严版 grep -Eq '^\(fail\).*<名字>'      → rc=1  FAIL: mutation red did not reach the named…

改成锚定形式后正常绿:MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS。

同类问题在 tests/test725-agent-node-unit-ci/run.sh 也有(它 grep 的
'the inbox choke point feeds the augmented text into processTask' 同样是测试名);
在 #800 里一并收紧,那边有单独说明。
tests/test745 那条不受影响 —— 它 grep 的是断言失败信息
`Expected to contain: "anet config [path|json]"`,只在失败时出现。

* test(ci): 分母要有绝对下限 —— 删掉 85% 的测试,这道门原来照样绿

自查清单(#815)第 ⑥ 条「mutation 要跑到曾经活下来为止」的直接产物。
这道门原来只有「削弱被测代码」一个 mutation 维度。换一个维度试:删测试文件。

第一次删 60/69 时门红了 —— 但那是**碰巧**:mutation 靶点所在的
auth-validate.test.ts 恰好在被删之列。做决定性验证,删 59 个但保留它:

  test_files=10  executed_files=10  discovered_files=10  failed_files=0
  MUTATION_RED registration-password-floor-weakened rc=1
  RESULT: PASS        rc=0

**门放行了一个删掉 85% server 单测的改动。**

根因:`executed >= discovered` 只能抓「runner 跳过了文件」,抓不到
「文件消失了」—— 分母跟着现实自动缩水,原来的 `-gt 0` 形同虚设。

加 SERVER_TEST_FLOOR=60,并写明「真删了测试就故意改它,并在 PR 里说明」。
双向验过:69 个 → RESULT: PASS;同一删除 mutation → rc=1
`FAIL: only 10 server test file(s) under src/, floor is 60`。

* docs(tests): report-only —— 修掉报告内部两个不一致的锚点

自查发现本报告里有两个不同的 source_commit:抬头是 2617987(正确,==源码提交),
但嵌入的运行输出里是 187a6ff。

根因是我上一轮的操作顺序错了:**先 `git rev-parse HEAD` 打戳、后提交下限改动**。
于是镜像里跑的是含下限的代码,戳进日志的却是提交前的 SHA ——
证据本身有效,但它自称的锚点指向一个不含该改动的提交。

这与 #798/#800/#803 早先被独审抓到的假锚点是**同一个根因的第二次发作**
(那次是把 --build-arg 传成了 origin/main,这次是传成了未提交前的 HEAD)。

已在真源码提交 2617987 上重跑并重出报告,全文 source_commit 只指向一个值:
  test_files=69 executed_files=69 failed_files=0
  MUTATION_RED registration-password-floor-weakened rc=1
  RESULT: PASS

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ci: 注册三个从没进 CI 的 Docker 门,并把 build-arg 从硬编码链改成推导

tests/ 下有四个形状完整的 Docker 门(Dockerfile + run.sh + 自己的 mutation)
从没被注册进 L1_TESTS,所以一直没人跑。逐个跑过之后:

  test224-grok-preview-security      PASS  39s
  test597-dashboard-slash-namespace  PASS  15s
  test679-task-trace                 PASS  36s
  test682-uncovered-task-trace       FAIL  ← 不注册,另开 issue,见下

三个通过的注册进 L1_TESTS(L1 并行跑,最差加 ~39s 墙钟)。

顺带把 build_args 从硬编码 if/elif 链改成从套件自己的 Dockerfile 推导。
那条链的失效方式是静默的:把套件加进 L1_TESTS 却忘了加分支,它会在没有
SHA 绑定的情况下跑,输出看起来一切正常。而新加的 test224/test597 用的正是
不带前缀的 `ARG SOURCE_COMMIT`,是原链无法表达、只能再加分支的形状。

替换前核过等价性:对原链覆盖的 test686/765/766/746 四个套件,推导结果与
硬编码逐字相同。

推导是否承重,分三种(不传 build-arg 时):
  test224 → rc=1 FAIL: SOURCE_COMMIT must bind…   fail-closed,推导承重
  test597 → rc=0 PASS                             声明了却不强制
  test679 → rc=0 PASS                             声明了却不强制
后两个是那两道门自己的弱点,本 PR 不修,写进 NOT COVERED。

test682-uncovered-task-trace 不注册:它断言 cli.ts 里 sendPeerReplyTaskWithTrace(
恰好出现 1 次,实际 0 次。查下来不是烂了,是**过时了** —— #698 有意把 peer reply
改成协商 send_peer_reply 原子工具,那条 send_task 老路被删掉,并由
agent-node/src/reply-routing-source.test.ts 断言它**不得出现**
(expect(source).not.toContain("sendPeerReplyTaskWithTrace({"))。
两道门方向相反,而后者在 CI 里跑着且是绿的。另外
agent-node/src/peer-reply-task-trace.ts 现在零生产调用方,只被 test682 自己引用。
单独开 issue,不在本 PR 里删任何东西。

* fix(ci): build-arg 推导要 || true —— pipefail 让它打死了整个 L1 runner

第一版在 CI 上挂了,而且挂得很有欺骗性:失败停在
`· build qa-cli-01-hub-start`,一个套件都没跑成,看起来像「L1 挂了」,
实际是参数推导那一行把 runner 打死了。

根因:scripts/qa.sh 是 set -euo pipefail,而多数套件的 Dockerfile 根本没有
ARG SOURCE_COMMIT —— grep 无命中退 1,pipefail 把 1 传给整个命令替换,
set -e 于是在第一个这样的套件上退出。

我上一版只验了「推导算出来的参数名对不对」(对 7 个套件逐个核过),
没验它在 qa.sh 里跑不跑得通 —— 验了零件没验装配。

修法:命令替换末尾加 || true,并把原因写进注释。

witnessed-red(在真脚本上,不是最小复现):
  去掉 || true → rc=1,日志停在 `· build qa-cli-01-hub-start`,与 CI 症状逐字一致
  加回 || true → 三种 Dockerfile 形状各取一个跑真 qa.sh --l1:
    qa-cli-01-hub-start          无 ARG            ✓ PASS
    test765-batch-runtime-gate   TEST765_ 前缀 ARG  ✓ PASS
    test597-dashboard-slash-namespace  裸 ARG       ✓ PASS
    ✓ ALL PASS in 84s

* ci: 三个孤儿门改放独立 job,不塞进 L1

上一版把 test224/test597/test679 加进了 L1_TESTS。选错家了。

CI 上 L0+L1 job 的真实耗时(main 近四次):141s / 135s / 148s,预算 300s,
余量约 150s。而 qa.sh 的 build 是**串行**的(只有 docker run 并行),这三个
套件要各加一次 build,其中 test679 带 javascript-obfuscator;单跑 run 已是
39s / 15s / 36s。L1 自称「~16s parallel」,是快层 —— 塞进去是拿余量赌。

改成 qa.yml 里的独立 job `recovered-suites`,预算 12 分钟,形状同单测门。
撤出 L1_TESTS 的原因写进了那里的注释,免得有人再塞一次。

build_args 推导保留在 qa.sh —— 它独立成立:原硬编码 if/elif 链的失效方式是
静默的(套件加进 L1_TESTS 却忘了加分支,会在没有 SHA 绑定的情况下跑)。
等价性核过:对 test686/765/766/746 四个套件,推导与硬编码逐字相同。

三个套件按 job 里逐字相同的命令验证(只传 --build-arg,run 不带 -e):
  test224  SOURCE_COMMIT          rc=0  Summary: PASS
  test597  SOURCE_COMMIT          rc=0  RESULT: PASS
  test679  TEST679_SOURCE_COMMIT  rc=0  RESULT: PASS

NOT COVERED:不传 build-arg 时只有 test224 是 fail-closed(rc=1),
test597/test679 照样 PASS —— 它们声明了 SOURCE_COMMIT 却不强制。
那是那两道门自己的弱点,本 PR 不修。

* ci: test224 必须带 --network none;tests/lib/** 补进触发路径

两条都是独立审(codex)在本 PR 上提的 P1,核过属实。

1) test224 是安全套件。它的 Dockerfile 第 13 行明写
   「the actual gate is run with --network none」,run.sh 第 160 行会打印
   「runtime executed with network disabled」。而我的 job 是裸 docker run --rm ——
   **那句话在网络实际可用时照样打印**。

   实测对照:带与不带 --network none,两次都 rc=0、都打印同一句 Summary,
   差异只有时间戳和 tarball sha256。也就是说**套件自己不会拦住这个错误**,
   只能由调用方保证。这是我引入的缺陷:把一道安全门接进 CI 时没照它自己的契约调用。

2) test224 的镜像 COPY 了 tests/lib/safe-rm.sh 并 source 它,但 qa.yml 的两处
   path 过滤都没有 tests/lib/** —— 只改那个 helper 的 PR 不会触发这道门。

有一条我**不在本 PR 里改**:套件用一行硬编码 log "network: disabled by runner"
**声明**前提,而不是探测它。要让它自己红,得加 fail-closed 探测(比如真去
resolve/connect 一次,通了就 fail)。那是改别人的门、会影响所有调用方,
交给 owner 决定,我只报不动。

codex 另外三条我的处置:
- 「pin oven/bun digest」:成立,但属于 test224/test597 自身的 Dockerfile,
  与 #799/#802 的 pin 工作同族,不夹进本 PR;
- 「report 写在容器里被 --rm 丢掉」:成立,是观测缺口,同样属套件自身;
- 「把安全套件排在低层套件之后」:是取舍不是缺陷,独立 job 里三个都会跑完,
  排序不影响是否产出证据。

* docs(tests): report-only —— 锚点 aeec4b9,含 --network none 的对照与四条 NOT COVERED

* ci(qa): 落实三条已接受未实施的意见 —— 顺序、产物、Bun 输入

这三条我在窄审后都写过"成立",然后挂在"待收口后落"。收口从没到来,
而这条 PR 的意见已经躺了一天。不再等。

(e) 安全套件排在最前,与 CLAUDE.md 的分层规则相反
    「分层测试:环境→认证→单点通信→完整流程→多用户→安全」
    「前一层不过就不跑后面的」
    改成 test597 → test679 → test224(安全最后)。后果不是"跑了会错",
    而是底层套件红时安全证据已经先产出 —— 而那份证据的前提没成立。

(f) --rm 把套件报告删掉。test224 把 report-test224.txt 写在容器内
    /artifacts 下,--rm 随即删掉那个文件系统;test597/test679 只有 stdout。
    结果是三个套件每次 CI 都真跑,跑完什么都不留。
    改法:test224 挂出 /artifacts;三个都 tee 到 $RUNNER_TEMP/suite-artifacts;
    加 upload-artifact 且 if: always()(红了才最需要看输出)。
    🔴 三处都显式 set -o pipefail —— GitHub Actions 默认 shell 是 bash -e,
    不带 pipefail,不加这句 tee 的 0 会盖掉套件的非零退出。这正是本仓
    #805 上被判 MAJOR 的同一个形态,不能在修别的问题时又引进来。

(d) Bun 输入未钉死。test224 的 oven/bun:1.3.1 与 test597 的 oven/bun:1.3.14
    都是可变 tag —— 同一个 commit 在不同时间构建会跑在不同字节上。
    已钉成 digest(manifest inspect 取得)。

    ⚠️ test679 仍未钉:它是 node:22-bookworm-slim + curl bun.sh/install | bash,
    构建时装到什么算什么。改成仓里既有的"下载指定版本 zip + 校验 SHA256"
    (test745 的做法)属于改动该套件的构建方式,我没有实跑过它,不敢
    盲改。这一条如实留作 NOT COVERED,不假装已修。

* ci(test679): 钉死 Bun 输入 —— 上一版我标了"需先实跑"就留着没做

上一版我把这条列为 NOT COVERED,理由是"改构建方式需先实跑,盲改可能让
本来能跑的套件跑不起来"。那个理由成立,但消除它的办法就是先跑一次 ——
而我没跑。

这次跑了,而且不必重建整套:风险只在装 bun 那一层,所以隔离验证那一层。
两个最小镜像(原样 curl|bash vs 钉死下载+校验和)都构建成功,结果完全相同:
  bun 版本  1.3.14  ==  1.3.14
  路径      /root/.bun/bin/bun  ==  /root/.bun/bin/bun
所以转换今天是等价的,而且从此确定。

顺带这也证明了风险是真的:不钉版本时"今天恰好是 1.3.14",bun 一发 1.3.15,
同一个 commit 就会构建在不同字节上 —— 而套件本身不会察觉。

验证边界:我验的是 bun 那一层,不是整套 test679 通过。后面三个 bun install
与 run.sh 未动,但它们没有被重跑过 —— 首次 CI 运行才是完整证据。

* ci(recovered-suites): 上传前修正产物属主 —— 门全绿却因 EACCES 判红

exact-head CI 上 `recovered suites (Docker)` 稳定红,但红的**不是任何一道门**:

  RESULT: PASS                                        (×2)
  PASS: targeted Docker context contains no host auth/config state
  PASS: real child env equals the reviewed set; …
  PASS: candidate tarballs contain runnable entrypoints …
  Summary: PASS (Docker-only; runtime executed with network disabled; …)

红在最后一步 `Upload recovered-suite artifacts`:

  With the provided path, there will be 4 files uploaded
  ##[error]An error has occurred while creating the zip file for upload
  Error: EACCES: permission denied, open '.../suite-artifacts/report-test224.txt'

三个 suite 都是 root 容器写进 bind mount(`-v "$RUNNER_TEMP/suite-artifacts:/artifacts"`),
产物属主 root、mode 0600;upload-artifact 以 runner 用户打包,打开即 EACCES。
注意 `if-no-files-found: warn` 且日志明说「4 files uploaded」——
不是「没找到文件」,是找到了读不了。

后果不是 cosmetic:这个 PR 的目的正是把三道长期失联的信号恢复成 CI 里的常驻门,
而现在 job 必红、证据也归档不了,等于恢复了个红灯。

修法:上传前把产物目录的属主/权限归一化。用 `if: always()`,因为前面步骤红时
更需要把证据传出来。

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: vansin <t@t>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`startHub owns a live watcher timer` 这一条在 CI 上红了一次:

    (fail) startHub owns a live watcher timer ... [5000.57ms]
      ^ this test timed out after 5000ms.
     4 pass  1 fail   Ran 5 tests across 1 file.

它不是偶发慢,是**结构上就没有余量**:两处定长 `Bun.sleep(800)` + `Bun.sleep(3_200)`
合计 4.0s,而 bun 每条测试默认预算 5.0s —— 剩 1.0s 要装下两次 bun 进程启动
(`bun -e import db.js` 初始化 + `bun run server/src/index.ts` 起一个真 hub)。
本机够,CI 里(冷 bun、Docker、72 个文件排队)不够。

改动:

1. **等事件那处改成轮询**。巡检周期是 `COMMHUB_DELIVERED_STALE_PATROL_MS=25`,
   事件在插入后几十毫秒就该出现,3_200ms 纯粹是余量。轮询后常态快 ~60 倍,
   慢的时候等得起(上限 20s)。

2. **等进程那处不假装在等就绪**。
   🔴 我第一版写的是「等到 db 文件存在」—— 而那个文件在上一步 init 里就已经
   建好了,条件恒真,等于没等。**一个不是目标状态独有的等待条件,和没有等待
   是一回事,但读起来像有。** 现在这里只保留原断言的原意(子进程没有立刻崩):
   在 800ms 窗口内轮询「它是否退出了」,一退出就立刻停下,不睡满。

3. **给这条加显式 30s 超时**。它要起两个真进程,默认 5s 对它本来就不成立。
   前两处改完常态用不到这个上限;它只保证「慢」不会被报成「坏」。

`waitUntil` 到期时把**在等什么**写进异常消息 —— 定长 sleep 超时最坏的地方不是慢,
是红落在「事件没写」这条断言上,读的人会去查 watcher,而真实原因可能是 hub 还没起来。

为什么现在才暴露:这个文件**从来没进过 CI**,直到 #798 把 server/src 下 72 个
测试全接进去。

⚠️ 本地没跑:这条会 `bun run server/src/index.ts` 起一个真 hub,不在宿主机上跑。
仅做了转译检查(`bun build --external '*'` → Bundled 1 module,rc=0)。
**判据是 CI 里的 `server unit (Docker, non-root)`。**

Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
test682 断言 cli.ts 里 sendPeerReplyTaskWithTrace( 恰好出现 1 次。#698 有意把
peer reply 改成协商 send_peer_reply 原子工具,那条老路被删,所以 main 上它出现 0 次
—— 这道门跑起来是红的。它从没注册进 L1_TESTS,所以一直没人发现。

而且方向和现役的门相反:agent-node/src/reply-routing-source.test.ts 断言

  expect(source).not.toContain("sendPeerReplyTaskWithTrace({");

一次都不许有。那道门在 test725 覆盖下、在 CI 里跑着、是绿的。两道门不可能同时满足。

顺带删掉同源的两个死模块(都只被测试引用,零生产调用方):

  agent-node/src/peer-reply-task-trace.ts     只被 test682 的两个文件 import
  agent-node/src/runtime/reply-routing.ts     只被自己的 reply-routing.test.ts import
                                              导出的 ReplyRoute = "send_reply" | "send_task"
                                              正是 #698 废掉的那个概念

核过的边界:

- task-trace.ts 不受影响 —— 它另有 4 个生产引用者(channel-task-trace /
  client-task-trace / commhub-mcp / explicit-task-trace)。
- reply-routing-source.test.ts 保留:它那句 not.toContain 是反向断言,
  锁住老形状不许回来,和被删的模块无关。
- 删后全仓不再有对这三个文件的引用(负向断言里的字符串字面量除外)。
- agent-node/src 测试文件数 91 → 90。

Co-authored-by: vansin <smartflowaiteam@gmail.com>
… 条告警) (#842)

* chore(deps): agent-network lockfile 把 hono 推过修复线(4.12.25 → 4.13.1)

关 #840。

agent-network/package-lock.json 把 hono 钉在 4.12.25,Dependabot 有 6 条 open
告警指向它,全部挂在这个 manifest 上:

  #106 medium  修复于 4.12.34   memo() 跨请求残留 SSR 输出
  #105 low     修复于 4.12.34   Proxy Helper 不清 Connection 列出的响应头
  #104 medium  修复于 4.12.34   Language 中间件算法复杂度 DoS
  # 60 medium  修复于 4.12.27   cx() 的 JSX 转义绕过导致服务端 XSS
  # 59 medium  修复于 4.12.27   API Gateway v1 adapter 丢重复响应头
  # 58 medium  修复于 4.12.27   hono/jsx 不按请求隔离 context

(我开 #840 时只列了前三条,漏了 #58/#59/#60 —— 它们创建于 2026-07-24,
修复线更低。已在 issue 里更正。)4.13.1 高于两条修复线,6 条全覆盖。

🔴 这不是安全修复,别在 release notes 里写成安全修复。两条理由:

1. 实际暴露面为零。这些告警分别需要 hono/jsx 的 memo() / cx() / per-request
   context、hono/proxy、hono/language、API Gateway adapter,而仓里 576 个
   tracked .ts/.tsx 对 hono 零引用(大小写不敏感)——它是经
   @modelcontextprotocol/sdk → hono ^4.11.4 传递进来的,没有任何一行代码用它。
2. lockfile 不随 npm 包发布。消费者 npm i 时重新解析,所以这个改动不改变
   已发布包的用户拿到的依赖,只影响本仓与 CI 的构建。

改动刻意做成最小:npm update hono --package-lock-only,不整体刷新 lockfile。
实测波及范围:

  版本变化 = 1  新增 = 0  移除 = 0
    hono  4.12.25 → 4.13.1
  package-lock.json | 6 +++---   (3 insertions, 3 deletions)

308 个包里只有它一个动了。

验证:tests/test745-agent-network-unit-ci(它用 npm ci 且 COPY lockfile,
所以改动会真正生效)
  438 pass  0 fail  executed_files=46 discovered_files=46
  MUTATION_RED stale-config-help rc=1
  RESULT: PASS  退出码 0

* docs(tests): 刷新 test745 报告 —— 记录新锁的 hono 跑绿的那次

审查(#842)指出:这个 PR 改了 agent-network/package-lock.json,而 test745 用
npm ci 装依赖 —— 改动改变了这道门实际跑的依赖图,而报告仍记着 b4e13f4 那版
镜像。仓里因此没有新锁制品的留存证据。指控成立。

新增一节,记录 source 507bae6 那次:
  image id sha256:ac8b956a…
  镜像内读回 TEST745_SOURCE_COMMIT=507bae6f…
  镜像内实装 hono = 4.13.1     ← 这是本次改动的主张本身
  438 pass / 0 fail / executed_files=46 discovered_files=46
  MUTATION_RED stale-config-help rc=1  RESULT: PASS 退出码 0

hono 版本那一步不是凑数:套件全绿不证明 lockfile 生效 —— 构建缓存没失效、或
Dockerfile 没 COPY lockfile,都会给出一模一样的 438 绿。

建门那次的记录整段保留为附录。

自评:同一条审查意见我一小时前刚在 #841 上收到并修复,却没把同一个检查用到
同一次会话里创建的这个 PR 上 —— 修了实例,没修类。已对我全部 open PR 做了一遍
审计:改了套件输入且零报告更新的,只有这一个。

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
…800)

* test(ci): 让 test725/test745 覆盖 tests/ 目录,兑现"complete unit domain"

两个门的抬头都写着 "complete agent-node/agent-network unit domain",
但只跑 src/,把 tests/ 下 25 个文件排除在外 —— 其中不乏安全相关的:
feishu-markdown-image-ssrf、secret-mask ×3、vendor-error-sanitize、feishu-tool-deny。
这些正是静默失效代价最高的那类。

这个目录里混着两种测试,任何单一命令都跑不全:
- 脚本式(16+6 个):自己打 "N/N passed",失败 process.exit(1),必须 bun <file>;
  用 bun test 跑会因为 top-level 的 process.exit 把整个 run 打断在第一个文件
  (实测:bun test tests/ 只跑完第一个就结束)。
- bun:test 式(3 个):describe/it,必须 bun test <file>;用 bun <file> 跑会报
  "Cannot use describe outside of the test runner"。
所以按文件内容分派,并把两条判据都写进注释。

退出码可用已先验:这些脚本失败时确实 process.exit(1),不是 fail-open。

落地前实测:
  agent-node/tests   6/6 直接过
  agent-network/tests 单命令 14/19 → 按内容分派 17/19 → 补两处环境契约 19/19
两处契约都在 Dockerfile 内解决,并写明原因:
  - feishu-envelope-compat 跨包 import agent-node/src/runtime/feishu-envelope
  - feishu-bridge-ipc 硬编码绝对路径 /work/feishu-attachments,容器里 node 建不了

分母承重:tests_dir_executed 必须等于 find 出来的数,且 >0。

实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS;
     test745 tests_dir 19/19/0 + MUTATION_RED + PASS。

* test(ci): test725 的 mutation 命名断言也锚在 (fail) 行

与 #798 同一类:原来 grep 的
'the inbox choke point feeds the augmented text into processTask'
是**测试名**,而 bun test 对每个用例都打 `(pass) <名字>` / `(fail) <名字>` ——
那条用例通过时也会命中,断言只证明了「它存在」,不证明「红落在它身上」。

A/B 在 #798 上做过(把断言指向一条该 mutation 下不会红的用例):
  松版 → rc=0 RESULT: PASS(收下了不合规);严版 → rc=1 点名失败。

这道门不是我写的,我在本 PR 里本来就在改它的 run.sh(加 tests/ 分派),
所以顺手收紧;改动只让门更严,并在下面重跑验证仍绿。
若 owner 认为不该由本 PR 动它,我可以拆出去。

* test(ci): tests/ 分派也要绝对下限,同 #798 那个洞

#798 实测:只比「executed == discovered」的门,在删掉 85% 测试文件后
照样 RESULT: PASS —— 分母跟着现实自动缩水。

我在本 PR 里加的 tests/ 分派用的是同一形状,所以有同一个洞。
补 AGENT_NETWORK_TESTS_FLOOR=15(现 19 个)、AGENT_NODE_TESTS_FLOOR=5(现 6 个),
并写明「真删了测试就故意改这个数」。

范围说明:只给**我在本 PR 新加的 tests/ 分派**加下限,没有动这两个门原有的
src/ 分母判定(那是 #791/#725 的既有代码,同类下限缺失我另报,不夹进本 PR)。

* docs(tests): report-only —— 锚点 1e9e75d,含下限的双向验证

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test(ci): 给 server 补上聚合单测门(69 个单测此前 CI 只跑 6 个)

server/src 下 69 个 *.test.ts,CI 可达的只有 6 个(scripts/qa.sh 的 L0_TESTS
点名 5 个 + test686 引用 1 个),另外 63 个没有任何 job 会碰。server 是 hub 本体
—— 认证、token、网络隔离都在这里,盲区比 agent-network 那 46 个严重。

形状抄 test745/test725,但按 server 自己的契约做了两处改动:

1) 逐文件跑,每个文件一个独立 DB。scripts/qa.sh 的 L0 本来就是
   `COMMHUB_DB=/tmp/qa-l0-$name.db bun test <one-file>` —— 这是既有契约。
   用一个共享 DB 聚合跑会红 4 条(admin-networks 的 global-admin 可见性、
   scheduled-tasks 三条),而这 4 条单跑全绿,是跨文件状态污染。
   把"聚合能不能跑"当门等于给它加了一条它从没承诺过的性质。

2) cwd 必须是仓根。task-lifecycle-watcher 用 process.cwd() 拼
   ./server/src/db.js,scheduled-tasks-http 按仓根相对路径 import
   tests/test601-.../race-worker.ts。从 server/ 目录跑会让这两个红在路径上,
   看起来像产品坏了。

红线:COMMHUB_DB 不设默认指向生产库。容器里够不到宿主的库,但不靠"够不到"
保证 —— run.sh 显式钉到 /tmp 并断言钉住了。31/69 个测试引用 sqlite/COMMHUB_DB。

分母承重:executed_files 必须等于 find 出来的 test_files,少一个就红。

witnessed-red:把 auth.ts 注册密码下限 `< 8` 改成 `< 1`(7 位密码会被接受,
一条真的安全回退),先校验字节非 no-op,再要求红落在指名的
"rejects 7-char password" 上。

实测:test_files=69 executed_files=69 failed_files=0,
MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS,耗时 51s。
完整输出见 docs/tests/report-test798-server-unit-ci.txt。

* ci: server 单测门抽成独立 job,别挂在 agent-network 名下

上一版把 build/run 两步插进了 agent-network-unit job 里,所以它确实跑了
(CI 日志实测 test_files=69 executed_files=69 failed_files=0
MUTATION_RED registration-password-floor-weakened rc=1 RESULT: PASS),
但会以 "agent-network unit (Docker, non-root)" 的名义显示 ——
server 挂了会归错帐,而且两个重 Docker build 串在一个 job 里。

抽成 server-unit job,显示名 "server unit (Docker, non-root)"。

* test(ci): 让 test725/test745 覆盖 tests/ 目录,兑现"complete unit domain"

两个门的抬头都写着 "complete agent-node/agent-network unit domain",
但只跑 src/,把 tests/ 下 25 个文件排除在外 —— 其中不乏安全相关的:
feishu-markdown-image-ssrf、secret-mask ×3、vendor-error-sanitize、feishu-tool-deny。
这些正是静默失效代价最高的那类。

这个目录里混着两种测试,任何单一命令都跑不全:
- 脚本式(16+6 个):自己打 "N/N passed",失败 process.exit(1),必须 bun <file>;
  用 bun test 跑会因为 top-level 的 process.exit 把整个 run 打断在第一个文件
  (实测:bun test tests/ 只跑完第一个就结束)。
- bun:test 式(3 个):describe/it,必须 bun test <file>;用 bun <file> 跑会报
  "Cannot use describe outside of the test runner"。
所以按文件内容分派,并把两条判据都写进注释。

退出码可用已先验:这些脚本失败时确实 process.exit(1),不是 fail-open。

落地前实测:
  agent-node/tests   6/6 直接过
  agent-network/tests 单命令 14/19 → 按内容分派 17/19 → 补两处环境契约 19/19
两处契约都在 Dockerfile 内解决,并写明原因:
  - feishu-envelope-compat 跨包 import agent-node/src/runtime/feishu-envelope
  - feishu-bridge-ipc 硬编码绝对路径 /work/feishu-attachments,容器里 node 建不了

分母承重:tests_dir_executed 必须等于 find 出来的数,且 >0。

实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS;
     test745 tests_dir 19/19/0 + MUTATION_RED + PASS。

* docs(tests): report-only —— 锚点 46e752c(含 current main 034f006)

按独审要求重做 provenance:append current main → 在精确源码提交上重跑 → report-only 子提交。

main 的新增提交 #802 只动 tests/qa-180-rename-ghost/,与本 PR 四个文件零相交,
rebase 无冲突;qa.yml 两处 path(test746 / test798)都保留;
server 步骤已是独立 job server-unit(name="server unit (Docker, non-root)", timeout 12),
不再嵌在 agent-network-unit 里。

实测:executed_files=69 discovered_files=69 failed_files=0,
MUTATION_RED registration-password-floor-weakened rc=1,RESULT: PASS。

* docs(tests): report-only —— 锚点 a4fd375(含 current main 034f006)

按独审 SUPERSEDE 的要求重做 provenance。原 CLEAN 判定被撤回是对的:
上一份报告声称的锚点 92d9612 比本 PR base 还早两个提交,那上面的 run.sh 里
没有 tests_dir_executed 那段代码,报告内容 provably 不可能由它产出。
根因是我跑门时 --build-arg SOURCE_COMMIT 传的是当时的 origin/main。

实测:test725 tests_dir 6/6/0 + MUTATION_RED + PASS;
     test745 tests_dir 19/19/0 + MUTATION_RED + PASS。

* ci: 元门 —— 修掉独立审抓出的三条 P1(其中一条是元门自己的漏网)

独立审(codex)在本 PR 上提了三条 P1,逐条复现后全部成立:

1) **深度不感知 —— 元门自己放行了没人会跑的测试。**
   两个 unit runner 扫 `<pkg>/tests` 用的是 `find … -maxdepth 1`,而本脚本
   原来只按前缀判覆盖。复现:把一个测试放到 `agent-network/tests/sub/` 下,
   元门报「0 个漏网」rc=0,而 runner 的 find 对它命中 0。
   **这正是这道门存在的意义所在,它却在自己身上漏了。**
   修法:深度从门里推导(scan_depth),不假定递归;`bun test <dir>/` 形式按递归算。
   双向验过:子目录文件 → rc=1 且点名;直属文件 → rc=0。

2) **套件豁免不校验套件是否真实存在。**
   原来只要路径以 `tests/` 开头就放行,于是 `tests/test999-example/new.test.ts`
   这种既没 Dockerfile 也没 run.sh 的目录也能过 —— 豁免变成「只要放对地方
   就不用被任何东西跑」。改成要求套件目录里 Dockerfile 和 run.sh 都在。
   双向验过:伪套件 → rc=1;补上两个文件 → rc=0。

3) **qa.yml 改动不触发本门。**
   qa.yml 决定那三个聚合门到底跑不跑,它一改本门的前提就可能塌,
   但它不在触发路径里。已加进两处 path 过滤。

NOT COVERED(第 2 条修完仍存在的缺口):校验了「套件是一套门」,
但**没有**校验「该套件已注册进 CI」。test224/test597/test679 就长期
有完整 Dockerfile+run.sh 却没人跑 —— 那是 #803 在解决的问题,不是本门的判据。

* ci: 元门要验「这道门真的被 CI 跑」,不只是「它存在且声明了范围」

独立审(codex P1)指出的缺口,我上一版只在 NOT COVERED 里记了没修:
qa.yml 一旦删掉/改名某个 job、或不再 build/run 它的 Dockerfile,
本脚本照样发绿 —— 因为它从没看过 qa.yml。
**这正是本门要防的那类问题(有门、没人跑),不能留在自己身上。**

判据要求 qa.yml 里同时出现两件事,单独一条不算:
  -f tests/<suite>/Dockerfile        真的构建了它
  docker run … <这次 build 打的 tag>  真的跑了那个产物

两条解耦 mutation,各自红在不同原因上(基线绿):
  F1 删掉 server-unit 的 docker run(build 保留)
     → rc=1「qa.yml 构建了 anet-test798-server-unit 但没有 docker run 它」
  F2 把 test745 的 build -f 路径改名
     → rc=1「qa.yml 里没有 build tests/test745-agent-network-unit-ci/Dockerfile」

一道门可能覆盖多个根(test745 覆盖 src 与 tests),接线问题去重后只报一次。

* docs(tests): report-only —— 锚点 9626c98,七条 mutation

* ci: 落实 ⑤⑥ 两条已接受未实施的意见;② 需所有者决定,如实标注

⑥ SOURCE_COMMIT 只验格式不验字节
   原来只验 ^[0-9a-f]{40}$。任何 SHA 都能过,而审查指出提交进来的 report
   里那个 SHA 早于本套件自身 —— 那份证据无法从它自称的版本复现。
   改成与 test823 相同的做法:构建时把 run.sh 在该 commit 下的 git blob
   哈希作为 build-arg 传入,容器内就地重算比对(blob 哈希 =
   sha1("blob <len>\0"+内容),不需要容器里装 git)。
   已验脚本内算法与 git hash-object 结果一致;该机制的端到端红/绿在 #835
   上证过两次(传错 blob、blob 对但文件被篡改,都 exit 1)。

⑤ qa.yml 缺 test601 路径
   test798 的镜像 COPY 了 test601 的 race-worker.ts,而
   server/src/scheduled-tasks-http.test.ts 会执行它做「两个真 Hub 抢同一
   occurrence」。只改那个 worker 的 PR 不该跳过这道门。已在两处 paths 补上。
   (这 4 行原本只存在于 #798;若只合本 PR、把 #798 当冗余关掉,它们永远
   不会落地 —— 此前已在本 PR 记录过这个坑。)

② server 的 npm install 无 lockfile —— 我没有改,需要所有者决定
   实测:server/package.json 有 4 个依赖,4 个全用 caret 范围,且仓里没有
   任何 lockfile/shrinkwrap。所以同一个 commit 在不同时间构建确实会解析出
   不同依赖图,审查这条成立。
   但修法只有一条:提交一份 lockfile。那是仓库级的依赖钉死决策 —— 它影响
   每一次 server 构建,不只是这道门;而且生成出来的树我无法在这里验证是否
   仍然全绿。这不该由我单方面决定,如实留作待决,不假装已修。

* ci(test798): server 依赖钉死 —— 提交 lockfile 并改用 npm ci

审查 ② 说的成立:server/package.json 4 个依赖全用 caret 且仓里没有 lockfile,
所以同一个 commit 在不同时间构建会解析出不同依赖图 —— 上游发一个兼容版本
就能让这道门变红或改变被测行为,而仓库一个字节都没动。

我上一版把这条标成"仓库级决策,不该由我单方面做"。那个定性是错的:
  agent-network/package-lock.json              已提交
  docs-site/package-lock.json                  已提交
  prototype/anet-client-app/package-lock.json  已提交
5 个包里 3 个已经提交 lockfile,.gitignore 的 *.lock 也匹配不到
package-lock.json。提交它是本仓既有做法,server 与 agent-node 只是不一致。
真正卡住的是一次验证跑,不是授权 —— 我把成本问题说成了权限问题。

本次改动:
- npm install --package-lock-only 生成 server/package-lock.json(1212 行,
  未装 node_modules)。锁到的直接依赖:
    @modelcontextprotocol/sdk 1.30.0 / bun-types 1.3.14 / hono 4.13.1 / zod 4.4.3
- Dockerfile 改为 COPY package.json + package-lock.json,并把 npm install
  换成 npm ci(ci 严格按 lockfile 装,install 会按 caret 取"当下最新兼容版")。

验证:带 lockfile 重建后跑完整套件
  test_files=69  executed_files=69  failed_files=0
  MUTATION_RED registration-password-floor-weakened rc=1
  RESULT: PASS  退出码 0

* ci(test798): 把 RUNSH_BLOB 真的传进去 —— 门在要求它,workflow 从没供给

CI 上 `server unit (Docker, non-root)` 稳定红,日志里唯一的失败行:

  FAIL: TEST798_RUNSH_BLOB 缺失或格式不对 —— 无法把 SOURCE_COMMIT 绑到被测字节

链条断在最后一环:

  run.sh:25-27      要求 TEST798_RUNSH_BLOB 且校验 ^[0-9a-f]{40}$,否则 fail-closed
  Dockerfile:41,45  ARG RUNSH_BLOB → ENV TEST798_RUNSH_BLOB
  qa.yml:79-84      docker build 只传 SOURCE_COMMIT,**没传 RUNSH_BLOB**

于是 ARG 取空、ENV 为空串、正则不过。门本身是对的 —— 它正确拒绝了一次
「说不清自己测了哪份字节」的运行,缺的只是供给那一行。

补法用 git 自己的 blob 哈希,和 run.sh:31 的算法是同一个东西:

  run.sh 算的是 sha1("blob <len>\0" + 内容),那正是 git 的 blob object id。

本地实测两者一致(在本分支 head 上):

  git rev-parse HEAD:tests/test798-server-unit-ci/run.sh
    = 0e48c36
  { printf 'blob %d\0' "$(wc -c < run.sh)"; cat run.sh; } | sha1sum
    = 0e48c36

对照 #798:它的 run.sh 里 RUNSH_BLOB 命中 0 次 —— 所以这不是 #798 的回归,
是本 PR 新加的要求没接完线。

🔴 这一条只修 CI 红。独立审查另指出本 PR 仍夹带 #798 的旧版本、需在 #798 之后
rebase —— 那件事不在本提交范围内。

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: vansin <t@t>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs: docs/ 里 13 条 cli.ts 行号引用改钉符号锚(22 → 9)

#852 量过:docs/ 下按 blob/main 钉 cli.ts 行号的引用,锚文本带符号名、可机器判定的
11 条里 **漂移 11、仍对 0**。这次把能确定唯一锚串的都改掉。

改法照 #845 已确立的形状:

  改前  [`cli.ts:228 loadProfile`](…/blob/main/agent-network/bin/cli.ts#L228)
  改后  [`cli.ts`](…/blob/main/agent-network/bin/cli.ts) —— 搜 `function loadProfile(`

13 条的真实位置(改之前它们全都指错了):

  adminUtokPath        文档说 28      实际 138
  saveGlobal           文档说 77-81   实际 1000
  saveServerConfig     文档说 89-95   实际 1047
  saveAdminUtok        文档说 105-111 实际 1062
  loadProfile          文档说 228     实际 1208
  saveProfile          文档说 246-273 实际 1272
  setupCommand         文档说 556     实际 1860
  ensureMcpJson        文档说 1644    实际 4300
  runCommand           文档说 2044    实际 5641
  renameCommand        文档说 2583 / 2629  实际 6911
  deleteCommand        文档说 2800-2840    实际另处
  dashboardReleaseTag  文档说 347          实际另处

每一条新锚串都逐条核过在 cli.ts 里**唯一**(13 条,非唯一 0 条)。
`RuntimeName` 那条**没有改**:它在 cli.ts 里出现 13 次,做不出唯一锚 —— 与其钉一个
含糊的锚,不如留着行号,等有人给它一个能唯一定位的写法。

剩下 9 条锚文本里没有符号名(形如 `[cli.ts:1724](…#L1724)`),机械改不了,
需要人读源码判断它当初想指的是什么。留给 #852。

* docs: 再修 6 条需要人读源码的 cli.ts 引用(9 → 3)

接上一提交。这 6 条的锚文本里没有符号名,机械改不了,是逐条读上下文判出它当初
想指什么、再去源码里定位的:

  node-lifecycle.md:206  正文说 notifyServerOffline  → 搜 `async function notifyServerOffline(`
  node-lifecycle.md:213  正文说「确认流程」          → 搜 `This will delete "${displayName}" (node_id:`
  node-lifecycle.md:383  正文直接写了 resolveNodeRef → 搜 `function resolveNodeRef(`
  architecture.md:316    正文说写 .mcp.json          → 搜 `.mcp.json: commhub → .anet/node-server.js`
  architecture.md:326    正文引了 compare-by-content → 搜 `if (src !== dst)`
  architecture.md:328    RuntimeName type            → 🔴 它已经不在 cli.ts 里了

最后一条值得单说:文档写「RuntimeName type cli.ts:145」,但 cli.ts 里
`type RuntimeName =` 出现 **0 次** —— 这个类型已经搬到
`agent-network/src/normalize-runtime.ts:16`。这不是行号漂移,是文件都换了。
所以这条改的是链接目标,不只是锚。

「确认流程」那条要小心:`Run again with --force to confirm.` 在 cli.ts 里出现 **2 次**
(deleteCommand 8143 / networkCommand 10173),不能拿它当锚。读上下文确认文档说的是
节点删除,才选了 deleteCommand 里唯一的那句。

每条锚串都核过唯一。node-lifecycle.md 的行号 pin 已归零。

* docs: 收掉最后 3 条 cli.ts 行号引用 —— docs/ 归零(22 → 0)

这 3 条的锚文本里没有符号名,是逐条读正文判出来的:

  architecture.md:520  正文自己就写着「`sub === "dashboard"` 分支」
                       → 链接去掉行号即可,锚已经在正文里(唯一,cli.ts:6092)
  RFC-002:37           「参考实现」指的是 anet channel add/ls
                       → 搜 `async function channelCommand(`(唯一,cli.ts:8159)
  pitfalls.md:80       「candidates 数组:源文件搜索顺序」
                       → cli.ts 里 `const candidates` 有 4 处,其中 1024 与 4333
                         都是 node-server.js 解析顺序。判据是 4333 那处的注释
                         `// dist/src/node-server.js(npm 包混淆后产物,优先)`
                         与 pitfalls 正文第 1 条逐字对应,1024 那处不是。
                       → 搜那条注释(唯一)

至此 docs/ 下 blob/main 形式的 cli.ts 行号引用为 0。每条锚串都核过在源码里唯一。

---------

Co-authored-by: t <t@t>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#857 把 docs 里的行号 pin 换成了符号锚点。换得对:那 13 条行号逐条对下来
**13 条全错** —— `loadProfile` 实际在 cli.ts:1274(doc 写 228),`runCommand`
在 5812(doc 写 2044),`ensureMcpJson` 指的那一行是**空行**。而它们全都长得像
有效引用:格式对、行号在文件范围内、点开能打开,所以读的人不会怀疑。

但换完之后留了一格没人看:**符号锚点不会因为「上面插了几行」失效,却会因为
改名而失效,而失效之后同样没有任何东西会喊。** #843 那道门在数行号 pin(守住
不再变多),符号锚点在变多,一直没有对应的门。

判据:每一条 `搜 \`X\`` 里的 X,必须在**它左边最近的那个源码链接**指向的文件里
逐字存在。两类失败都报 —— 找不到(改名/删除/写错),以及前面根本没有链接
(无法判定它指哪个文件,这本身就是缺陷)。

## 起点(与手工核对一致)

    checked 21 symbol anchor(s) across 262 tracked doc(s); 21 resolved
    every symbol anchor exists in the file it names.

21 这个数是先手工数出来的,再让脚本跑 —— 两边对上才用。

## 见红(三种坏法,三种输出,互不相同)

    A 改一个锚点为源码里不存在的名字   → rc=1  "not found in 'agent-network/bin/cli.ts'"
    B 去掉锚点前面的链接               → rc=1  "no source link precedes this anchor"
    C 把扫描范围改成一个不存在的目录   → rc=2  "0 tracked .md … 扫描范围塌了"

三份输出的 md5 两两不同。C 是分母承重:🔴 这道门最可能的坏法不是判据写错,
是**一条都没扫到然后打印一片绿** —— 那种假绿和真绿逐字相同。所以扫到 0 个 md
或 0 条锚点一律 exit 2,让「没问题」和「没有看」在输出上长得不一样。

变异做完全部还原:`cmp docs/architecture.md` 与 main 逐字节相同。

## 🔴 刻意不加 paths 过滤

这道门的主要失效场景是**有人在 cli.ts 里把一个函数改名**,不是有人改了 doc。
按 `docs/**` 过滤的话,改源码的 PR 不会触发它 —— 门在、判据也对,但在最需要它
的那一类改动上永远不会被触发。整个脚本跑完不到一秒,省这点没有意义。

`--selftest` 8 条,含「无锚点文本 → 计数必须为 0」。job 名 `doc-symbol-anchors`
全仓唯一(25 个 job,25 个不同的名字)。

Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
changelog:713 的两条引用按 blob/main 钉行号,现在都已经指错了 —— 因为没有越界,
所以 #834 那种「文件行数 vs 引用行号」的判据抓不到它们。

  cli.ts:61   声称是 PINNED_SERVER_VERSION → main 上真实在 791 行,
                                             61 行现在是 } from "../src/opencode-preset";
  cli.ts:2589 声称是 bunx commhub-server 启动点 → 真实在 5765 行,
                                             2589 行现在是 opencode auth-login 的帮助文本

钉到 3a38720(2026-05-17),不是修复提交 4d24024,理由:

  两个提交上 L61 / L2589 都精确命中,但 3a387204d24024 的父提交,
  它的 PINNED_SERVER_VERSION 值是 "0.8.0" —— 正是正文描述的那个 bug 状态
  (「仍 hardcode 0.8.0」「实际 bunx --bun @sleep2agi/commhub-server@0.8.0」)。
  钉修复后那次会让链接显示 0.8.2,和正文对不上。

3a38720 是 origin/main 的祖先,blob 链接实打 HTTP 200。

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
changelog 中英两处都指向 blob/main/server/src/index.ts#L253。这个链接
现在指到一个只有 15 行的文件的第 253 行 —— 因为 #438 把 index.ts 改成了
run-entry shim,真实代码搬到了 server.ts:

  // Run-entry shim (#438 corrective).
  // All real code lives in ./server.ts …

改钉 22ed188(写这条 changelog 的那次提交)。核过:那时 index.ts 有
1623 行,:253 正是这条 changelog 描述的 disk 告警逻辑
(disk_avail_gb < 1 → red),:253-326 区间里 disk_*_gb 出现 6 次。

病因不是"行号漂了",是 ref 选错了:changelog 条目描述的是一个冻结的
历史时刻,却指向会移动的 main —— 这样的链接必然烂,而且是无声地烂。
换成当时的 SHA 之后,它永远成立。

这与参考页(api/rest.md 等)的修法不同:那边该改钉符号,因为它描述的是
"现在的行为";changelog 描述的是"当时发生了什么",该钉当时的 commit。
把两者混为一谈会修错 —— 给 changelog 更新行号,下次重构又坏。

详见 #831。

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
## 🔴 撞名了,而且是我造成的

今早我在 main 上加了 `.github/scripts/check-doc-symbol-anchors.py`。本 PR 里有一个
**同名**文件 `scripts/check-doc-symbol-anchors.py`,而且**早于我那个**(本 PR 更新于
2026-08-18 03:06 CST,我的门是 07:35 才合的)。

我加之前查了 main 上有没有这道门,**没查开着的 PR 里有没有** —— 这是我的疏漏。

好在两者**不是重复,是互补的**,而且这一点是实测出来的,不是我事后找补:

    变异:把 `report_completion` 段的锚串换成 `"send_task"`
      → 本 PR 这道门:  mismatches=1,rc=1  (红)
      → main 上那道门:  74/74 全过        (串确实存在于 tools.ts)

| | main 上那道(宽而浅) | 本 PR 这道(窄而深) |
|---|---|---|
| 范围 | docs/ + docs-site/ **全部** md,指向**任意**源码 | 只有 mcp-tools.md → tools.ts |
| 判据 | 锚串在它点名的文件里**存在** | 锚串落在**对的 tool 段**里 |

反过来也一样:一条指向 `cli.ts` 的坏锚点,本 PR 这道根本不看。

所以两个都留,把本 PR 的改名为 `scripts/check-mcp-tool-anchor-sections.py`
(名字说清它到底管什么),并在文件头写明两道门的分工和上面那次实测。
引用它的 `tests/test831-doc-source-pins/{run.sh,Dockerfile}` 与
`docs/tests/report-test831.txt` 一并改;全仓 grep 无残留引用。

## qa.yml 冲突

纯新增撞纯新增,取并集(main 那批 test 目录在前,本 PR 的 docs-site 路径在后)。
24 条 path,零重复;6 个 job 名两两不同(新增 `doc source-pin floor (Docker)`)。

## 合并后跑过

  scripts/check-mcp-tool-anchor-sections.py   OK  67 条锚串全部落在对的 tool 段
  scripts/check-doc-source-pins.py            OK
  .github/scripts/check-doc-symbol-anchors.py OK  74 条(main 上是 21,本 PR 带来 53 条新的)
  .github/scripts/check-doc-symbol-anchors.py --selftest  ok
  check-l1-paths-sync.py                      OK
  bash -n tests/test831-doc-source-pins/run.sh OK

变异做完全部还原(`cmp` 逐字节相同)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI 里 `doc source-pin floor (Docker)` 红了,报的是:

    FAIL: 1 个基线条目对应的引用已经不在文档里了,请从基线里删掉
      server/src/index.ts#L253

**这是这道门在正确工作,不是它坏了。** 两小时前合的 #834 把 changelog 里
那条 `server/src/index.ts#L253` 从 `blob/main/…#L253` 改成了钉在提交
`blob/22ed1886/…#L253`,于是它不再是一条「指向 main 的失效行号 pin」——
`pins_on_immutable_ref=6` 里包含了它。基线里那条就成了残留。

它自己的文件头写着「修一条就把它从这里删掉 —— 不删的话基线会变成坟场」。照做。

🔴 **没有用 `--write-baseline`。** 那会整份重新生成,而重新生成的基线会把
**这期间新出现的坏 pin 一起收进来** —— 一次「修好了一条」的提交,顺手把
新的失效面也洗白了,而 diff 看起来只是几行增减。改成只删这一行,并断言:

    removed=1  added=0

跑完:`broken_pins=4  baseline_entries=4`,rc=0
    「失效 pin 4 个,基线 4 条 —— 没有新增,也没有该清的残留。」

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI 第二轮红在:

    FAIL: 预期 18 个唯一 pin,实际 15

**这道断言就是设计成「变了要人确认」的**(run.sh 里原注释写着)。所以我去确认了,
而不是把它改宽。

变化来自今晚合的两个 PR —— 三条引用**钉了提交**,于是从 `unique_pins` 挪进了
`pins_on_immutable_ref`:

    agent-network/bin/cli.ts#L61     -> 2 个文件(中/英 changelog)   #851
    agent-network/bin/cli.ts#L2589   -> 2 个文件                     #851
    server/src/index.ts#L253         -> 2 个文件                     #834

正好 3 条,18 − 3 = 15;`pins_on_immutable_ref=6` = 3 × 2,对得上。

🔴 另外两个数**一个都没动**:`occ` 仍 35(钉提交不减少「出现次数」,只改引用形式),
`files` 仍 106。**只有一个数变了,而且变的原因能逐条指名** —— 如果三个数一起变,
那才是该怀疑扫漏的信号。

把这段推导写进 run.sh 的注释里,下一个人不用重新推一遍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI 第三轮红在:

    FAIL: ① 钉 SHA 的引用没有被单独计数:pins_on_immutable_ref=7

L5① 注入**一条**钉 SHA 的引用,然后断言 `pins_on_immutable_ref=1`。

🔴 这条断言的意图是「注入的那一条被单独计数了」,但它写成了绝对值 ——
**只有在仓里原本一条钉 SHA 的引用都没有时才等价。** 写下它的时候确实是 0,
所以它当时是对的,而且输出和一个真正正确的断言**逐字相同**。

今晚仓里多了 6 条(#851 的 `cli.ts#L61` / `#L2589` 各 ×2 语言,#834 的
`server/src/index.ts#L253` ×2),注入第 7 条,写死的 1 就红了 ——
**而红它的正是「有人按这道门建议的做法,把会漂的行号 pin 钉成了提交」,
也就是这道门自己想促成的进展。**

改成先量基线再断言恰好 +1:

    base_imm=$(… pins_on_immutable_ref …)      # 注入前
    …注入…
    after_imm 必须 == base_imm + 1

本机实测:`base=6 → after=7 rc=0`,与期望一致;victim 文件 `cmp` 还原确认。

同类提醒写进注释:**一个「只有在某个背景事实恰好为 0 时才成立」的断言,
和一个正确的断言,在当时的输出上完全一样。**

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI 第四轮红在:

    FAIL: 1 个新的失效 pin(不在基线里)
    FAIL: L7 复原之后没有回绿

L7 ③ 造「引用消失」的场景:把基线里某条 pin 的引用从 `blob/main/…` 改成
`blob/22ed1886/…`;末尾复原时**全局**把 `blob/22ed1886/` 换回 `blob/main/`。

🔴 而 #834 今晚往 changelog 里写进了一条**真实的** `blob/22ed1886/server/src/index.ts#L253`
(那正是这道门建议的修法)。于是复原那一步把**别人有意钉的提交引用改回了会漂的
main 引用**,留下一个不在基线里的失效 pin —— L7 永远回不了绿。

**「借来的 SHA 恰好不与真实数据相撞」是一个会过期的巧合,不是一条性质。**
而在它过期之前,这段代码和正确的代码在输出上完全一样。

两处改动:

  1. 造场景改用合成 SHA `0123456789abcdef…4567`(L5① 已在用同一个)——
     仓里不可能出现,撞不上真实数据;
  2. 把改过的文件名记进 `.l7-touched`,复原只碰这些文件,并在复原后断言
     **合成 SHA 在整个 docs-site 里一个都不剩**,不彻底就直接抛错
     (原来复原是否彻底没有任何断言,失败会拖到最后一步才以别的面目出现)。

本机实跑整段 L7:

    ① 干净树:不改写,报「已是最新」
    MUTATION_RED write-baseline-refuses-new-failure rc=1
      (造场景:把 server/src/auth.ts#L184 的 1 处引用改钉合成 SHA,涉及 1 个文件)
    ③ 引用消失时删对了(4 → 3),表头保留,门转绿
    复原后回绿 ✓
    RESULT: PASS   rc=0

跑完 `git status --porcelain` 与跑前逐字节相同。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vansin

vansin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

合了。19 个 check 全绿。这个 PR 我改了四处才让它绿,四次都是真问题,没有一次是重跑就好的 —— 逐条记在这里。

先说这个 PR 本身:它带的那道门是必要的,而且我实测了它抓什么

scripts/check-doc-symbol-anchors.py(合并时改名为 check-mcp-tool-anchor-sections.py,原因见下)判的是:锚串落在它声称的那个 tool 段里 —— 而不只是「存在」。

我做了一次变异来确认这不是多余的:

把 report_completion 段的锚串换成 `"send_task"`
  → 这道门:          mismatches=1  rc=1   🔴 抓到
  → main 上那道存在性门: 74/74 全过        (那个串确实存在于 tools.ts)

「锚串存在」和「锚串落在对的段」是两件事,而前者放行后者的错误。你在文件头写的那两个实例(reassign→cancel_task、broadcast→ack_inbox)属于同一类,靠人读发现的。

🔴 改名的原因(这是我的疏漏)

我今早 07:35 往 main 加了 .github/scripts/check-doc-symbol-anchors.py,和本 PR 的文件重名,而本 PR 更早(03:06)。我加之前查了 main 上有没有,没查开着的 PR 里有没有

好在两者互补(见上面那次实测),所以两个都留,把本 PR 的改成 scripts/check-mcp-tool-anchor-sections.py —— 名字直接说清它管什么。引用它的 run.sh / Dockerfile / report 一并改,全仓 grep 无残留。

四轮红,四个原因

① 基线残留 server/src/index.ts#L253 —— 红它的是两小时前合的 #834门在正确工作。
我没有用它建议的 --write-baseline(那会整份重生成,把这期间新出现的坏 pin 一起洗白),只删那一行,断言 removed=1 added=0

预期 18 个唯一 pin,实际 15 —— 这条断言的注释自己写着「变了要人确认」。我确认了:三条引用钉了提交(#851 两条、#834 一条),各 ×2 语言 = pins_on_immutable_ref=6,18 − 3 = 15
🔴 佐证:另外两个数一个都没动(occ 仍 35、files 仍 106)。只有一个数变、且能逐条指名。三个数一起变才是该怀疑扫漏的信号。

③ L5① pins_on_immutable_ref=7 —— 注入一条,却断言绝对值 == 1
它的意图是「注入的那条被单独计了」,但只有在仓里原本一条都没有时才等价。 写下时确实是 0,所以它当时是对的,输出和一个真正正确的断言逐字相同。改成先量基线再断言恰好 +1

🔴 注意红它的是什么:是「有人按这道门建议的做法把行号 pin 钉成了提交」—— 也就是它自己想促成的进展。一道门因为自己起了作用而报错,只在成功之后才现形。

④ L7 复原不彻底 —— 造场景用的是真实提交 22ed1886,复原时全局把 blob/22ed1886/ 换回 blob/main/。而 #834 今晚往 changelog 里写进了一条真实的 blob/22ed1886/…#L253,于是复原把别人有意钉的提交引用改回了会漂的 main 引用
改用合成 SHA,并把改过的文件名记下来只碰那些文件,复原后断言合成 SHA 一个都不剩(原来复原是否彻底没有任何断言)。

本机实跑整段 L7:RESULT: PASS rc=0,跑完 git status --porcelain 与跑前逐字节相同。

一句总结

③ 和 ④ 是同一个形状:一段代码之所以正确,依赖了一个当时恰好成立的背景事实(「仓里没有钉 SHA 的引用」/「22ed1886 不出现在真实文档里」)。在那个事实过期之前,它和真正正确的代码在输出上完全一样。

而这两个事实的过期,恰恰是这道门自己推动的。

覆盖关系

#843 ⊂ #844 ⊂ #845(git 祖先关系实测)。合这一个就够,另外两个我一并关掉。

@vansin
vansin merged commit 04e4429 into docs/mcp-tools-symbol-anchors Aug 18, 2026
19 checks passed
@vansin

vansin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

🔴 更正:我在这里写的「合了」是真的,但它没有到 main

我合这个 PR 的时候只看了 draft / CI / mergeable_state,没有看 .base.ref

$ gh api repos/sleep2agi/agent-network/pulls/845 --jq '.base.ref'
docs/mcp-tools-symbol-anchors

base 不是 main,是另一条特性分支。 合并 API 照样返回 merged=true —— 它说的是「合进了它的 base」,而我读成了「进了 main」。

实测确认产物不在 main 上:

$ git cat-file -e origin/main:scripts/check-doc-source-pins.py           → 缺
$ git cat-file -e origin/main:scripts/check-mcp-tool-anchor-sections.py  → 缺
$ git cat-file -e origin/main:tests/test831-doc-source-pins/run.sh       → 缺

没有丢东西 —— 全部内容都在那条远端分支上,我已开新 PR 提到 main:#935

上面那条 review 的技术结论逐条仍然成立(实测数字、变异见红、冲突取舍),只有「已经进 main」这一句是错的

🔴 这和我今晚早些时候在 #836 上撞到的 draft 是同一类:我的合并判据缺了一格,而没出事只是因为别人替我兜住了(那次是 GitHub 的 405)。这次没有兜底,所以就真的漏了。判据已补成四格:base.ref == "main"draft == false 且 无 pending 且 无 failure。

vansin added a commit that referenced this pull request Aug 18, 2026
…base) (#935)

* ci(test831): 文档站源码行号 pin 的下限门(不是 #831 的解决方案)

#831 量出来的:docs-site 下 141 处 blob/<ref>/<file>#L<N> 引用**全部**钉在
main 上,零个钉在不可变 commit。钉 main 的锚点每次重构都会漂,而漂了不会有
任何东西报错 —— 读者点进去看到一行毫不相干的代码,文档仍然理直气壮。

🔴 这道门守的是下限,不解决 #831。它只保证已知失效的那批不会变多。
   召回率是实测的,不是估计的:拿 #831 里已人工确认失效的 10 条回测,
   抓到 5、漏掉 5。漏掉的都指向一行长得很正常的代码,只是不是它声称的那一行 ——
   那类只有人读上下文才判得出。别拿这道门的绿色去论证 #831 已解决;
   #831 的解决方案是把行号锚点换成符号锚点。

判据(scripts/check-doc-source-pins.py,判据与边界写在文件头):
  1. 文件不存在
  2. 行号越界
  3. 那一行是"平凡行" —— } / }], / ); / 空行 / 某段注释的中间一行
     理由:没有人会故意把说明文字的锚点钉在 } 或空行上

基线 docs/doc-source-pins-baseline.txt 记当前已知失效的 32 条,语义是**只许缩小**:
  - 出现基线之外的新失效 → 红(这是这道门存在的理由)
  - 基线里某条已经修好 → 也红,要求删掉它
    不这么做基线会变成坟场:修好的和没修的混在一起,数字再也不说明任何事

套件 tests/test831-doc-source-pins(alpine 按 digest 钉版,--network none 可跑):
  L0 分母:镜像内走目录遍历,断言与仓库里 git ls-files 得出同一份清单
     (106 文件 / 70 唯一 pin / 141 处出现)。分叉了就红 —— 否则"容器里绿"
     推不出"仓库里绿"
  L1 干净树必须绿
  L2 witnessed-red:新增一个越界 pin → 红在"新的失效 pin"上;复原后回绿
  L3 witnessed-red:基线塞一条不失效的条目 → 红在"已经不再失效"上;复原后回绿
  L4 边界断言:那 3 条已知盲区必须仍然判不出来。哪天有人"改进"判据这里会红,
     提醒他去更新文档里 5/10 那个数,而不是让边界悄悄漂移

已接进 .github/workflows/qa.yml(新增 doc-source-pins job + 4 条触发路径)——
没接进 CI 的门只是装饰。

途中修掉自己两个错:
- 第一版 Dockerfile 里的 python:3.12-slim digest 是我编造的,不对应任何真实
  镜像。换成本地实际核对过的 alpine:3.20 digest,python3 由 apk 装。
- checker 第一版把「(pin, 文档) 对数」当成「引用总数」打印,得到 139,而原始
  出现次数是 141。是同一份数据我先后量出两个数才发现的;两个数现在都打印。

* docs(tests): 留存 test831 报告

镜像按 SOURCE_COMMIT=a7b12780 构建;报告自带 runsh_blob,可用
  git rev-parse a7b12780:tests/test831-doc-source-pins/run.sh
独立比对。套件原样输出,未手工编辑。

RESULT: PASS  exit_code=0
MUTATION_RED new-out-of-range-pin rc=1
MUTATION_RED stale-baseline-entry rc=1

* docs(mcp-tools): 章节源码链接改钉符号 —— 17 处行号锚点 17 处全是错的

#831 的正解方向,第一批。有 #843 的下限门兜底,可以逐个文档改而不怕改出新失效。

先说测出来的事实,因为它比改动本身重要:

docs-site/docs/api/mcp-tools.md 每个 tool 章节顶部有一个 `> [源码 ↗]` 行号
链接,共 17 个。逐个算出该 tool 在 server/src/tools.ts 里的真实注册行再比对:

  tool                     文档 pin    真实注册行      偏差
  report_status              #L88          441       -353
  report_completion         #L213          772       -559
  get_inbox                 #L837          872        -35
  ack_inbox                 #L871          906        -35
  send_task                 #L450         1180       -730
  send_message              #L550         1415       -865
  send_reply                #L589         1783      -1194
  send_ack                  #L667         1797      -1130
  retry_task                #L693         1824      -1131
  cancel_task               #L803         1944      -1141
  reassign_task             #L835         1981      -1146
  get_task                  #L740         1879      -1139
  list_tasks                #L763         1902      -1139
  get_all_status            #L369         1105       -736
  get_session_status        #L415         1145       -730
  get_completions           #L925         2088      -1163
  broadcast                 #L878         2035      -1157

  指对(±3 行内)= 0/17   指错 = 17/17

**17 个全错**,漂移 35 到 1194 行。而 #843 的机械判据只标出其中 9 个 ——
其余 8 个落在"长得很正常的一行代码"上,判据看不出来。这正是那道门文档里写的
"46% 是下界不是估计",这次是实证。

改法:把行号锚点换成符号锚点。链接仍指向文件(可点),行号去掉,后面补一句
grep 提示:

  > [源码 ↗](…/server/src/tools.ts) —— 搜 `"report_status"`(注册点在
    `server/src/tools.ts`,全仓唯一)

17 个 tool 名在 tools.ts 里各自恰好一个注册点(registerTool/tool("<name>")),
逐个核过;不唯一的不动,留给人处理(本次无此类)。中英两版同样处理。

连带:
- 基线从 32 条降到 23 条。这 9 条是门自己要求删的 —— 改完之后它红在
  "9 个基线条目已经不再失效,请从基线里删掉",按它说的删完才转绿。
  这道门第一次真正被使用,行为符合设计。
- tests/test831 的 L0 预期值 70/141 → 53/107。那层断言本来就是设计成
  "数字变了要人确认"的,这次的变化是逐条核过的,不是扫漏。

验证:
  unique_pins=53  pin_occurrences=107  broken_pins=23  baseline_entries=23
  OK  walk 路径与 git 路径给出同一份清单
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  RESULT: PASS  退出码 0

剩下的:两版各 25 处正文行号引用(`tools.ts:168-188` 这种)未动 —— 它们各自
声称一段具体行为,要逐条判断该锚到哪个符号,不能机械替换。

* docs(tests): 刷新 test831 报告 —— 记录 53/107 那次跑

审查(#844)指出:这个 PR 把套件的预期分母改成 53/107,而
docs/tests/report-test831.txt 还记着 a7b1278 那次(旧 run.sh blob 6d4dca…,
计数 70/141)。仓里因此没有「改过的套件跑绿了」的留存证据。指控成立。

  source_commit=b6168cc700268a30a4325f49832e894d35d17e55
  runsh_blob=8820cffe8cd3c95006e2389912bcb7263961a4cf
  pin_occurrences=107  unique_pins=53  broken_pins=23  baseline_entries=23
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  RESULT: PASS  exit_code=0

blob 与 git rev-parse b6168cc7:tests/test831-doc-source-pins/run.sh 逐字相符。

这是同一条意见在一天内第三次(#841 / #842 / #844)。已在
docs/pre-pr-selfcheck.md 里补成 §12(#815 分支,提交 9fb311c3)——
这次是真写了,上一次我在 #842 的回复里说「已记进待办」是假的。

* ci(test831): 修审查提的四条判据缺陷,每条配一个断言

#843 的四条,全部成立。修了判据却没有断言等于没修,所以每条都加了 L5 里的
一个断言(注入 → 期望的红/绿 → 复原 → 回绿)。

① ref 被丢掉(最重的一条)
   第一版把 URL 里的 ref 解析出来就扔了,一律拿当前检出去判。后果是:**有人
   按这个工具自己给的建议、把 main 改成不可变 commit,反而会被判成失效** ——
   那条链接在它自己的 commit 上是对的,在 HEAD 上未必。反过来,历史版本里
   本来就错的链接,也可能因为 HEAD 恰好长得对而蒙混过关。
   改:钉了 7–40 位 hex ref 的引用不属于这道门(它管会漂的引用),单独计数
   pins_on_immutable_ref。断言:注入一个在 HEAD 上必然越界的 SHA pin,门仍绿。

② #L0 没被挡
   只挡了上界,Python 的 content[-1] 会读到最后一行 —— 最后一行非平凡时,一个
   畸形的非 1-based 锚点被判成健康。改成 line < 1 也算越界。

③ 基线语义写反了
   原来是 fixed = baseline - broken:判据不再标某条,就叫人删基线条目。但源码
   一漂,一个**仍然错**的锚点会从「平凡行」挪到「普通但不相干的一行」,判据就
   标不出它了 —— 文档一个字没动。照原规则 CI 会主动要求删掉这条已知缺陷,
   等于把它推进本工具自己的盲区。
   改:条目只在**文档里那个引用不存在了**时才判为可删(gone = baseline -
   present);仍被引用但判据标不出来的单列为 drifted 警告,保留在基线里、也不
   计入绿色。

④ 路径穿越
   文档里写 blob/main/../../etc/passwd#L1 时,直接拼到 REPO 上会读出仓库外的
   文件,而 /etc/passwd 第一行非平凡 —— 一个根本不指向本仓的链接被判成健康。
   改:拒绝绝对路径与 .. 分量,并核解析后仍在 REPO 之下,类别 path-escapes-repo。

L3 的断言文案跟着 ③ 一起改了:它塞进基线的 auth.ts#L1 没有任何文档引用,
所以现在走的是「对应的引用已经不在文档里了」这条。

验证(容器内,--network none):
  [L5] ① 不可变 ref 被排除且单独计数(pins_on_immutable_ref=1),门仍绿
       ② #L0 判为 line-out-of-range rc=1
       ④ 仓库外路径判为 path-escapes-repo rc=1
       ③ 引用仍在文档里时不判为可删,并给出 drifted 警告
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  RESULT: PASS  退出码 0

* docs(tests): 刷新 test831 报告(含 L5 四条断言)

source_commit=c6338f272a0474d84dc7f22c0fd482b9ca5de77a
按 pre-pr-selfcheck §12:改动改变了套件下次跑看到的东西,报告要一起更新。
RESULT: PASS  exit_code=0

* docs(tests): 合入 #843 的判据修正后重跑 test831

上一轮我在 #843 里改了判据(四条审查缺陷),而这个 PR 基于它 —— 也就是说它
当时带着旧判据跑出来的绿色。这次把 ci/doc-source-pin-guard 合进来重跑,
L5 的四条断言都在,分母仍是 53/107。

  RESULT: PASS  exit_code=0
  L5 ①不可变 ref 排除 ②#L0 越界 ③drifted 保留 ④路径穿越

合并时报告文件冲突(两边都重新生成过)。报告是产物,解法是合完重新生成,
不是手工挑行 —— 手工合出来的报告不对应任何一次真实运行。

顺带:合并未提交时那次构建红在 blob 绑定上(镜像里的 run.sh 与 HEAD 声称的
不是同一份),说明那道绑定确实在起作用。

* docs(mcp-tools): 正文行号引用改钉符号 —— 两版行号 pin 归零 (#845)

* docs(mcp-tools): 正文行号引用也改钉符号 —— 两版行号 pin 归零

#831 正解第二批,接 #844。这批是正文里 `tools.ts:168-188` 那种引用:每一处都
声称一段具体行为,不能像章节链接那样机械替换,要逐条读它声称什么、再定锚哪个符号。

中英两版各 27 处(25 处单标签 + 2 处复合标签 `tools.ts:129 INSERT + tools.ts:141
ON CONFLICT`),全部换成「文件链接 + 可 grep 的唯一串」。改完之后 mcp-tools.md
两版的行号 pin 归零。

全站:pin 141 → 53 处,唯一 70 → 27,机械可证失效 32 → 14。

🔴 途中我自己制造并抓回一个错,值得写下来:

给「只能 reassign 非终态任务」那条选锚串时,我用了
`status IN ('created', 'delivered', 'acked', 'running')`。它在仓里有 2 处命中 ——
但那 2 处分别属于 send_message 和 cancel_task,**没有一处在 reassign_task 里**。
也就是说我把一条 reassign 的说明锚到了别的 tool 上,而且"锚串存在"这个检查
会放行它,因为串确实存在,只是不在该在的地方。

是在核对"多命中锚串各自属于哪个 tool"时发现的。reassign 的真实检查在
`["replied", "failed", "cancelled", "expired"].includes(task.status)`(全仓唯一),
已改正。

由此加的一条做法:锚串命中 >1 时,把它属于哪个 tool 段写进提示,例如
「在 `cancel_task` 段搜 …(全仓 2 处,另一处属 `send_message`)」。光写"全仓 N 处"
不够 —— 读者仍然不知道该看哪一处。

自查:两版共 54 个新链接,逐个确认其后至少有一个锚串确实出现在它指向的源文件里,
0 例外。(第一版自查的正则排除了反引号,漏掉了带「在 `x` 段搜」的 3 条;
换成更宽的抓法后重跑,54/54 覆盖。)

连带:
- 基线 23 → 14,9 条是门自己要求删的
- tests/test831 的 L0 预期 53/107 → 27/53

验证:
  unique_pins=27  pin_occurrences=53  broken_pins=14  baseline_entries=14
  OK  walk 路径与 git 路径给出同一份清单(106 文件 / 27 唯一 pin / 53 处)
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  OK  3 条已知盲区仍未被判据覆盖
  RESULT: PASS  退出码 0

剩下的 14 条失效 pin 全部在 rest.md / changelog.md,不在本 PR 范围。

* docs(tests): 刷新 test831 报告 —— 记录 27/53 那次跑

与 #844 上那条 P1 同类,这次是主动修的:本 PR 把套件预期分母改成 27/53,
继承来的报告记的还是更早那次。审查还没提到这个 PR,但 docs/pre-pr-selfcheck.md
§12 说了「改动改变了套件下次跑看到的东西,就要连报告一起更新」——
这条规则刚写完,先用在自己身上。

  source_commit=160a6b94d8c6c6c4a7622485baecc9b8f2e18b95
  pin_occurrences=53  unique_pins=27  broken_pins=14  baseline_entries=14
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  RESULT: PASS  exit_code=0

* docs(mcp-tools): 修三处锚错 tool 的引用,并补上「属主是否匹配」的全量审计

审查指出 broadcast 参数表的引用锚到了 `"ack_inbox"`。成立。

这是同一个错的第二例:上一轮我自己抓到 reassign 那条锚到了 cancel_task,
修完就过去了 —— **没有对全部锚点做一次「锚串落在哪个 tool 段」的审计**。
又是修实例不修类。这次补做了,一并查出三处:

  文档章节            原锚串                真实落点          改成
  report_status      INSERT INTO nodes     ack_probe_request upsertNodeWithSec1Guard
  broadcast          "ack_inbox"           ack_inbox         "Send a message to multiple sessions."
  get_all_status     INSERT INTO sessions  report_status     同串,但写明「在 report_status 段搜」

第一条要说明一下:report_status 确实会 upsert nodes 表,但不是自己写 SQL,
而是走 upsertNodeWithSec1Guard(L731,在 `if (node_id)` 之下 —— 正是文档声称
的那个条件)。`INSERT INTO nodes` 在那个 helper 里,位置在最后一个 tool 注册
之后,所以按"最近的注册点"归属会算到 ack_probe_request 头上。锚到函数名既指得
准,也同时带读者看到调用点和实现。

第三条不是错,是容易误解:那句话在 get_all_status 段里,但解释的是
report_status 写下的列。锚串本身对,补一句"在 report_status 段搜"免得读者
在本节里白找。

途中还犯了一次 pre-pr-selfcheck §11 说的错:改 broadcast 那条时我按字符串全局
替换 `搜 \`"ack_inbox"\``,把 ack_inbox 段里本来正确的两处也一起换掉了 ——
§11 写的正是"批量替换前证明锚点唯一"。是重跑审计时发现的(章节=[ack_inbox]
却落在 broadcast),已按行定位改回。

审计结果:中文 40 条、英文 23 条锚串,属主不匹配 0。
(审计跳过引 db.ts 的行 —— 那些锚串不在 tools.ts 里,按 tool 段归属没有意义。)

* docs(tests): 合入 #843/#844 后重跑 test831

这个 PR 基于 #844,#844 基于 #843 —— 上一轮我在 #843 改了判据(四条审查缺陷),
这两个 PR 当时都带着旧判据跑出来的绿色。逐级合下来重跑,判据是新的、分母是
本 PR 的 27/53、L5 四条断言都在。

  RESULT: PASS  exit_code=0
  L5 ①不可变 ref 排除 ②#L0 越界 ③drifted 保留 ④路径穿越

报告冲突同样按合完重新生成处理 —— 它是产物,手工合出来的报告不对应任何一次
真实运行。

* ci(test831): 加 L6 —— 符号锚点必须落在它声称的那个 tool 段

上一轮我说过要做这件事,理由是:那次「锚串属主匹配」的审计抓出三处错,但它
只是我人工跑的一次性脚本 —— 不落进门里,下次还会漏。

#831 把行号锚点换成「文件链接 + 可 grep 的串」,解决了行号会漂,却引入一个更
隐蔽的失效:**锚串确实存在,只是落在别的 tool 段**。「锚串存在」这个检查放行
不了它。#845 里连着出了两例,都不是靠工具发现的:

  reassign_task 段 → 锚到 send_message / cancel_task 里的串(我自己抓到)
  broadcast    段 → 锚到 "ack_inbox"(审查者抓到)

第一例我修完就过去了、没做全量审计,于是有了第二例。这一层就是那次审计固化下来。

scripts/check-doc-symbol-anchors.py 的判据:对每条「链接 + 搜/grep <串>」,比对
「引用所在的文档章节」与「锚串在 tools.ts 里命中处所属的 tool 注册段」,交集为空
就判 mismatch。三类不是错的情形都写在文件头,而且**每一类都由实例催生**:
  a) 文本里明写「在 `x` 段搜」/「inside `x`」时以它为准
     (get_all_status 段解释 report_status 写下的列,是真实存在的合法情形)
  b) 锚串在 db.ts 里 —— 那个文件没有 tool 段,按 tool 归属没有意义
  c) helper 函数的实现在最后一个 tool 注册之后,按「最近注册点」会被算到那个
     tool 头上;所以还要看它被哪些 tool 段调用(upsertNodeWithSec1Guard 就是)

写这一层的过程中,它自己有两个作用域 bug 是被自己的输出抓回来的:
  - 「在 `x` 段搜」按整行匹配 → 同一行里另一个锚串被套上了别人的限定,
    造出一条假 mismatch。改成按锚串作用域。
  - 窗口只看锚串**之前** → 英文写法「grep `串` inside `x`」限定在后面,漏判。
    改成取「上一个锚串结束 → 下一个锚串开始」,两侧都包进来。

witnessed-red 用的是 #845 里真实发生过的那个错:往 broadcast 章节插一条锚到
"ack_inbox" 的引用,必须红且指名 broadcast。

验证(容器内,--network none):
  [L6] tool_registrations=46  anchors_checked=67  mismatches=0
  MUTATION_RED broadcast-anchored-to-ack-inbox rc=1
  复原后回绿 ✓
  RESULT: PASS  退出码 0

边界写在脚本头和 L6 注释里:它只管「落在对的段」,锚串在段内是否指着文档声称
的那件事仍要人读。跟 check-doc-source-pins.py 那个 5/10 召回率同一类 ——
门缩小了错误的种类,没有消灭错误。

* docs(tests): 刷新 test831 报告(含 L6)

source_commit=89e454ba564defbe98784e94d1f87cd46c74d3ac
按 pre-pr-selfcheck §12:改动改变了套件下次跑看到的东西,报告要一起更新。
这次是加了一整层 L6,更该更新。
RESULT: PASS  exit_code=0

* docs(rest): 18 处不与 #809/#810 冲突的行号锚点改钉符号

我把这条挂了两轮,理由都是"#809/#810 在改同一个文件,怕撞车"——但一直没真去核。
这轮核了,答案很具体:

  两个 rest.md 里的失效 pin 共 25 处
    与 #809/#810 的改动行段重叠 =  7 处   ← 本轮不动
    安全可改                   = 18 处

也就是说"怕撞车"只挡得住 28%,我却拿它当了两轮不做的理由。

撞车的 7 处(按 (文件,行) 精确排除,不是按文件跳过):
  zh 52 / en 54    tools.ts#L521   与 #810 的 49-55 / 51-57 重叠
  zh 1545 / en 1491 tools.ts#L571  与 #810 的 1539-1548 / 1485-1494 重叠
  zh 1547 / en 1493 push.ts#L38    同上
  en 398           auth.ts#L184    与 #810 的 395-401 重叠

改掉的 9 个唯一 pin(中英各一处,共 18):
  auth.ts#L7    → interface AuthUser
  auth.ts#L99   → invalid username or password(全仓 2 处)
  auth.ts#L102  → // User token (utok_) — not bound to network, …
  auth.ts#L243  → generateToken(全仓 3 处)
  auth.ts#L269  → network_id IS NULL AND token_id !=
  auth.ts#L374  → "auto-join", "full"
  db.ts#L168    → CREATE TABLE IF NOT EXISTS networks
  push.ts#L11   → const clients = new Map<string, SSEClient[]>()
  push.ts#L35   → { type: "connected", session: sessionName

替换按 (文件,行号) 定向,不是全局字符串替换 —— pre-pr-selfcheck §13 刚写完,
这次照做了。其中两条(push.ts:11 和 auth.ts:243)是复合标签
(`[`push.ts:11` `clients`]`),第一遍正则没匹配上,是靠"改了 7 处而不是 9 处"
这个数字对不上发现的 —— 也是 §13 说的那条经验。

连带:基线 14 → 5(门要求删 9 条);L0 预期 27/53 → 18/35。
剩下的 5 条正是上面 4 条撞车 pin 加 changelog 的 index.ts#L253。

验证(容器内,--network none):
  unique_pins=18  broken_pins=5  baseline_entries=5
  L6 anchors_checked=67  mismatches=0
  MUTATION_RED new-out-of-range-pin rc=1
  MUTATION_RED stale-baseline-entry rc=1
  MUTATION_RED broadcast-anchored-to-ack-inbox rc=1
  RESULT: PASS  退出码 0

* docs(tests): 刷新 test831 报告(rest.md 18 处改完之后)

source_commit=d57f72d9007812b7d7e9178f7bbb63d9c3b54e2f   按 §12,套件下次跑看到的东西变了,报告一起更新。
unique_pins=18  broken_pins=5  baseline_entries=5   RESULT: PASS  exit_code=0

* ci(test831): 加 --write-baseline(只许缩小)与 L7 的两向断言

上一轮我在 #843/#810/#834 上贴了一条跨 PR 耦合提醒:那两个 PR 一合,基线里
对应的条目会变 stale,门就红在「请从基线里删掉」。提醒是散文,执行的人还是
得自己去数该删哪几条 —— 把一个机械操作交给了记忆力。

这一轮把它变成一条命令:

  python3 scripts/check-doc-source-pins.py . --write-baseline

🔴 这个开关离「一键把门变绿」只差一个条件判断,所以它**只许缩小**:
   重算若会引入基线里没有的条目(= 出现了新的失效 pin),它拒绝写并退出非零。
   新失效该做的是把链接改对,不是追认进基线。

L7 两个方向都断言,不只测它能用:
  ① 干净树:不改写,报「基线已经是最新的」,且文件字节未动
  ② 注入一个新失效 pin:必须拒绝(rc≠0),且**确认基线没被写**
     —— 只断言"它红了"不够,要断言"它红了而且没写"
  ③ 造一个「引用消失」场景(把某条 pin 的引用改钉不可变 SHA):
     必须删对、条数变小、表头注释保留、门随后转绿

顺带在门红的提示里直接给出这条命令,不让人再去翻文档。

验证(容器内,--network none):
  [L7] ① 干净树:不改写,报「已是最新」
       MUTATION_RED write-baseline-refuses-new-failure rc=1
       ③ 引用消失时删对了(5 → 4),表头保留,门转绿
       复原后回绿 ✓
  RESULT: PASS  退出码 0

一处说明:这个改动逻辑上属于 #843 的脚本,但落在链尾(#845)。理由是 #843 →
#844 → #845 是一条依赖链,改在链首要把两级重新合并重跑一遍;而三个 PR 是按序
合进 main 的,落在链尾到达 main 的时间相同。写在这里免得有人以为放错了地方。

* docs(tests): 刷新 test831 报告(含 L7)

source_commit=d9517384ac192655ed66d8dc9833c9a96d2b3355   按 §12,套件下次跑看到的东西变了,报告一起更新。
RESULT: PASS  exit_code=0

* fix(cli): 起/停这几条路不再宣布没量过的成功 (#895)

* fix(cli): stop `anet node start --accept-dev-channels` reporting dead nodes as started

Two independent false greens on this path, both measured while restoring 97
nodes after a power loss on 2026-08-17.

1. The success line was printed on the strength of `tmux new-session -d`
   returning. That call succeeds even when the inner `anet node start` refuses
   and exits 1 a moment later, so a refused node printed
   `✅ node "X" started detached (tmux session live; …)` and exited 0 — with
   `can't find pane: X` on the line directly above it. Byte-identical to a real
   success, so a batch restore counted 64/64 up when 6 had never started.

   Now: unstartable profiles are refused before anything is spawned (same
   resolveStartProfile check launchAgent runs, so the message is the real one),
   and success is claimed only after verifyNodeUp — the function `project up`
   already uses to decide whether a node came alive. The success line quotes
   its evidence (`pid N alive`) instead of asserting a session it never checked.

2. The 45 s auto-confirm window was spent on the wrong prompt. A workspace
   Claude Code has not seen before shows folder-trust FIRST; the watcher knew
   only the dev-channels markers, so it stared at a prompt it would not answer
   until the window closed, and the dev-channels prompt that appeared later was
   never confirmed. The node hung silently and the hub showed it offline
   (TM智空负责人 died exactly this way and needed two manual Enters).

   Now the watcher answers folder-trust too and restarts its clock, because the
   window is meant to bound the wait for ONE prompt, not for the whole
   trust-then-channels sequence.

A failed start deliberately does not kill the tmux session — a node stuck on a
prompt is one keypress from working — but the failure output names the session
and says `tmux has-session` will answer yes for it, since that is the criterion
batch callers use.

Verified against the two failure modes and the happy path in an isolated
workspace, with the inner agent stubbed:

  bogus runtime   before: ✅ exit 0   after: refusal on stderr, exit 1, no spawn
  dies on start   before: ✅ exit 0   after: ❌ exit 1 quoting the pane's reason
  trust sequence  before: ✅ exit 0, 46 s, node hung with no pid
                  after:  ✅ exit 0,  5 s, pid alive, both prompts confirmed

All 6 wiring assertions fail against the unmodified file; both pure-module
mutations turn their tests red. Package suite 454 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): apply the same verify-before-claiming rule to --tmux and codex co-presence

Auditing the other 54 `✅` claims in the CLI for the same class as the
--accept-dev-channels false green. Most are honest — `hub start`, the dashboard
launcher and the co-presence app-server all measure before they claim (a
/health fetch, a listener-pid scan, waiting for the `listening on:` line). Two
were not.

`anet node start <alias> --tmux` polls `tmux has-session` for 2 s and calls that
proof. It isn't: tmux registers the session before the inner command has
finished failing, so an unsupported runtime printed
`✅ tmux session "X" started detached` and exited 0 — the session was gone two
seconds later. Measured, then fixed with the same refuse-before-spawning check.
The narrower claim this path makes (the SESSION started, not the node) is left
as-is; it is true, and unlike --accept-dev-channels this path cannot promise a
working node because it never answers the prompts.

The codex co-presence launcher spawns three tmux sessions and then declares the
node 就绪. Only ① proved itself. Its OpenCode twin already checked its TUI
session before making the same claim, so the two sibling paths disagreed about
whether "ready" is measured; now they agree, and 就绪 requires all three
sessions to be alive at the moment it is printed.

Not verified end-to-end: the codex co-presence path needs a working codex, and
this account's quota is exhausted until 2026-08-20. The change mirrors the
OpenCode twin's shape exactly and only adds a failure path where a session is
genuinely absent.

The first version of the --tmux gate passed against the unfixed file — anchored
loosely enough that it found the OTHER branch's preflight. Rescoped to the
--tmux path itself. 3 of the 4 assertions now fail against both origin/main and
the previous commit; the fourth is the OpenCode twin, green on all three
because it is the reference, not a change. Suite 458 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): say which condition made a resolved agent-node unsafe, and name umask

Chasing why the 5 grok co-presence nodes were unstartable. The published anet
(2.3.0-preview.38) has no grok-build-cli in its runtime whitelist, so the
morning's conclusion was "rewrite their config to grok-build-acp". That was
wrong twice over: origin/main already whitelists grok-build-cli, and once past
that the real blocker turned out to have nothing to do with grok.

Measured chain on this machine:

  umask                                            0002
  npx -y @sleep2agi/agent-node@preview   dist/cli.js 0775, package.json 0664
  the check                              (mode & 0o022) !== 0  → refuse
  0o775 & 0o022                          = 0o020  (group-write alone)
  what the operator saw   [anet] Incompatible grok-build-cli runtime.
                          [anet] resolved agent-node package has unsafe ownership or mode

Owner was correct throughout (uid 1000, my own), so the sentence sent every
reader to look at ownership. `chmod g-w,o-w` on those two files made the same
command run all the way through to the agent-node process, failing only on the
fake hub the test config points at — which is what confirmed the diagnosis.

The check is right and stays: refusing to execute a payload the group can
rewrite is correct, and anet cannot know this box's group has one member. What
changes is that it now names the path, the octal mode, which of the four
conditions fired, and that the usual cause is a stock Debian/Ubuntu umask —
with both fixes spelled out. Ownership failures deliberately do NOT mention
umask, so that message stays about ownership.

Both call sites share the new pure module: the grok preview resolver in cli.ts
and the OpenCode pairing check, which enforces the identical rule and would
have produced the identical dead end. The existing assertion on the old wording
still matches — the sentence is kept as the headline and the diagnosis appended.

Suite 465 pass, tsc clean. The npx directory was left exactly as found
(775/664); the fix is the operator's to apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(doctor): warn about the umask that makes grok-build-cli and opencode-cli unstartable

A better error message only helps someone already stuck. `anet doctor` can see
this coming from local state alone.

Both runtimes refuse a resolved agent-node payload whose mode has a group- or
other-write bit. npm creates files as `0o666 & ~umask`, so a stock
Debian/Ubuntu umask of 0002 — every user gets a private group, so 0002 is the
distro default — guarantees 0775/0664 and guarantees the refusal, which reaches
the operator as "Incompatible grok-build-cli runtime" with no mention of umask.

doctor now reports two things, from the process umask and whatever is already
extracted under ~/.npm/_npx. It never fetches, so an empty payload scan means
"nothing extracted yet", not "safe" — the umask verdict is what speaks to the
next fetch. On this machine:

  ⚠  Package file modes: umask is 0002, so npm extracts packages
     group-writable. grok-build-cli and opencode-cli refuse to execute a
     payload in that state, and the refusal reads as an "Incompatible runtime"
     error. Start those runtimes under `umask 0022`, or run
     `chmod -R g-w,o-w` on the resolved package root.
  ⚠  Resolved agent-node payload: 2 already-extracted file(s) would be
     rejected right now, e.g. …/@sleep2agi/agent-node/dist/cli.js (mode 775).
     Fix: chmod -R g-w,o-w …/@sleep2agi/agent-node

A set umask bit means "withhold that permission", so the predicate reads
inverted from how the symptom presents; that inversion is why judgeUmask is a
tested function rather than an inline expression, and 0002/0022/0000/0077 are
each pinned. Reading the umask requires the POSIX set-and-return call — the
helper puts the old value straight back, verified equal on a second read.

Suite 474 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): address tmux sessions exactly — bare -t prefix-matches a sibling node

Every human-facing string in this CLI already spells the exact form
(`tmux attach -t '=<alias>'`, with a comment at the OpenCode co-presence
launcher explaining that a missing TUI would otherwise silently attach to the
bridge). Every tmux command the CLI actually ran passed the bare name.

Measured on this machine with only `zz-honest-probe-extra` alive:

  tmux has-session -t zz-honest-probe    → success  (it is not running)
  tmux has-session -t =zz-honest-probe   → failure  (correct)
  tmux kill-session -t zz-honest-probe   → killed zz-honest-probe-extra

The live fleet here has four colliding pairs — A站内容/A站内容牛,
A站数据/A站数据牛, P站测试/P站测试牛, P站运维/P站运维牛 — so each of the
three consequences is reachable today:

  * has-session false-positives → `node start --accept-dev-channels` prints
    `tmux session "X" already running — skipping spawn` for a node that is
    down, exits 0, and never starts it. Reproduced end-to-end: with only
    `anet-collide-兄弟` alive, origin/main skipped the spawn and left no pid;
    the fixed build started the node (pid alive) and left the sibling running.
  * kill-session reaps the sibling, and `node stop` reports success.
  * send-keys would deliver an Enter into the sibling's Claude UI — the worst
    of the three, since the prompt watcher fires it unattended.

All eight call sites now go through one helper: kill-session, has-session,
capture-pane ×4, send-keys ×2.

killTmuxSession additionally returns whether the session is actually gone. Its
`kill-session` failure is swallowed on purpose — a session that already exited
is the common case — so the only way to know is to look afterwards. `node stop`
now checks that and refuses to report a stop it did not achieve, instead of
deriving "killed" from the pre-kill has-session probe and notifying the hub
offline over a session that is still up.

Suite 478 pass, tsc clean. Fleet untouched at 89 sessions throughout; the
integration test uses its own `anet-exacttest*` names and cleans up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): project up / restart 的退出码要反映节点是否真的起来了 (#896)

Follow-up to #895, one level up. That PR fixed the single-node start paths; this
is the same defect class in the batch entry point, and it is the one automation
actually calls.

`anet project up` already measures every node with verifySpawnedNodes and prints
each failure, so its OUTPUT was honest — unlike the single-node path, it never
claimed a dead node was started. What it did not do was set an exit code: both
projectUp and projectRestart returned normally, so a run that brought up 60 of
74 nodes exited 0.

That matters because this is the entry point scripts use. While reviewing a
boot-time sweep for this machine's ~74 agent nodes, the design leaned on a
post-flight tmux audit rather than on `$?` — and the reason turned out to be
load-bearing rather than stylistic: `grep -c process.exit` inside projectUp
returns 0. Any watchdog or CI step that trusted the exit code was being told the
fleet was fine.

`invalid` counts toward failure too. A node whose config cannot start was never
attempted, so exiting 0 hides it exactly as well as a crash does.

The gate runs after printProjectSummary so the operator still gets the full list
before the process dies, and a clean run returns early and stays at exit 0.

All 5 assertions fail against f565e9b8 and pass here. Suite 483 pass, tsc clean.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs: 去掉过期版本号与硬编码计数,改为指向权威来源 (#869)

Doc-only refresh from 通信狗 review (issue #639). No behavior changes,
no runtime/config edits — every touched file is markdown or a
package.json `description` field.

## Version facts sourced from npm at commit time

Verified via `npm view <pkg> dist-tags` on 2026-08-14:
  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29
  local anet: v2.3.0-preview.38 (matches preview channel, one behind head)

## Per-file changes

P0-1 CHANGELOG.md banner
  - Drop hardcoded "当前 stable 是 v0.10.11" (out of date).
  - Point readers at npm `latest` + docs-site/docs/changelog.md as the
    live source; keep the v0.10.15 archival anchor + v0.8.1 OSS-first
    note. File still an archive of pre-2026-04 v1.0.0-preview history.

P0-2 docs/getting-started.md
  - Runtime table now includes `grok-build-acp` (needs `grok login`).
  - Note that @preview additionally ships `codex-app-server` and
    `opencode-cli`; the authoritative full runtime table is at
    anet.sh/guide/runtimes.
  - `anet ls` → `anet node ls` (matches current CLI).

P0-3 docs-site/docs/{,en/}guide/getting-started.md
  - Add a preview-channel warning next to the admin/anethub line:
    @preview prints a one-time random password on first
    `anet hub start`, don't hard-code `anethub`. This aligns with the
    README + cli.md wording that already carries the note.

P0-4 AGENTS.md 项目结构
  - Drop "39 命令" / "4 runtime" hardcoded counts (both drift).
  - Point at docs-site/docs/guide/cli.md as canonical CLI list.
  - Split runtimes into stable (4: claude-code-cli / claude-agent-sdk /
    codex-sdk / grok-build-acp) + preview extra (2: codex-app-server /
    opencode-cli); mark `grok-build-cli` as unreleased in any channel.

P0-5 docs/version/README.md + docs/plans/release-plan.md
        + docs/version/0.11.0/README.md
  - Backfill preview matrix from `npm view <pkg>@preview` (was pinned
    at .34/.26/.20 — now .39/.31/.29). Added timestamp + reminder to
    re-check `npm view` before editing.
  - WAIC 7-月-下旬 anchor is out of the window; strike-through the
    completed date, keep the archival link to waic-release.md, and
    replace with "current promote status per release-plan".
  - release-plan.md defaults table gains a commhub-server column so
    readers see all three packages, not just two.

P1-6 docs-site/docs/guide/architecture.md
  - "14 张表" → "20+ 张表(含 sessions / tasks / nodes / users /
    networks / SkillHub / providers / vault 等,实数按 schema
    版本浮动)"; EN mirrors it. Fixes both the two mermaid diagrams
    and the paragraph.

P1-7 docs/architecture.md
  - Runtime paragraph now says "stable 4 + preview 2" and points at
    anet.sh/guide/runtimes as authoritative.
  - 14 cli.ts deep `#L<line>` anchors defanged (link stays, line
    number dropped — they rot every release; kept the function name
    in the link text so intent survives).
  - Directory tree gains an "已不完整,以仓库实际为准" note so
    readers don't treat it as canonical.

P1-8 server/package.json + server/README.md
  - package.json `description` now says "MCP tools (17 collaboration-
    core + node/provider ops tools; authoritative list at
    docs-site/docs/api/mcp-tools.md)" — was "and 17 MCP tools"
    (readers took it as the total).
  - server/README.md MCP section gains one line saying the 17 in the
    table are the collaboration-core subset; full list at
    docs-site/docs/api/mcp-tools.md.

P1-9 README.md + README.en.md
  - "能做什么" / "What it does" gain one bullet pointing at Codex TUI
    co-presence and OpenCode as preview-channel additions with a link
    to the Runtime page.

## Not touched (per review scope)

  - docs/v3-postgresql-design.md archive banner (do not edit)
  - upgrade-v2 archive banner (do not edit)
  - grok-copresence danger banner (do not edit)
  - runtimes 官方表 (canonical, do not edit)

## Verification

`grep -c` on the touched files confirms:
  - `14 张表` remaining in docs-site/docs/guide/architecture.md: 0
  - `39 命令` remaining in AGENTS.md: 0
  - stale `cli.ts#L<n>` deep anchors in docs/architecture.md: 0
  - `anet ls` (bare, without node prefix) in docs/getting-started.md: 0

Co-authored-by: t <t@t>

* fix(ci): 让 CI 真会跑的测试能重触发自己那道门,并加一道防漂回去的门 (#897)

Four findings from the open-issue triage, each verified against origin/main
before touching anything. Two of the four numbers in those issues were wrong in
ways worth recording.

## qa.yml path filter missed tests CI actually runs (#860)

qa.yml fires on a path filter. Four directories CI executes were outside it, so
editing the test could not re-run its own gate — and the run looks identical to
a gate that passed on the new code:

  tests/test292-e2e-hard-gate        (referenced by a workflow path)
  tests/test686-rest-shape-golden    ┐
  tests/test765-batch-runtime-gate   ├ reached through scripts/qa.sh L1_TESTS
  tests/test766-bunx-preflight       ┘

#860 reported three; it missed test292-e2e-hard-gate. My own first scan
under-counted in the other direction — it matched `tests/testNNN` and so never
saw L1_TESTS, which names directories bare. The remaining ~160 directories under
tests/ are run by no workflow at all, and are deliberately left out: a filter
entry for an unrun test reads like coverage it does not have.

## …and a guard so it does not drift back

`.github/scripts/check-qa-trigger-coverage.py` asserts every CI-executed test
directory is in the filter. Three behaviours, each exercised:

  fixed repo            → exit 0, "all 7 CI-executed test dirs can re-trigger"
  f565e9b8's qa.yml     → exit 1, names all four with the line to add
  L1_TESTS renamed away → exit 2, "no CI-executed test directories detected"

That last one matters most: if the parser stops matching, the honest answer is
"I can no longer see the denominator", not a clean run against nothing.

Its workflow intentionally carries NO `paths:` filter. It guards a path filter;
gating it on paths would let an edit to qa.yml's filter or to L1_TESTS slip past
the check that watches them — the same blind spot it exists to catch.

## public-script safety: TLS verification (#890)

check-public-script-safety.py flagged `rm -rf` outside our paths and unscoped
`pkill`, but nothing about `curl -k` / `--insecure` /
`wget --no-check-certificate` / `NODE_TLS_REJECT_UNAUTHORIZED=0`. These scripts
are fetched over https and piped into bash, so verification is the reader's only
defence against a tampered download; there is no legitimate reason for a script
published at a public https URL to skip verifying it. That meets the file's own
"only unambiguous rules" bar. Zero current hits — this one is preventive.

While adding it I hit a bug in the reporting: the hint was chosen by an
if/else whose `else` branch belonged to the kill rule, so every TLS finding
printed advice about `pkill -u`. Remediation text is now keyed by rule, and an
unknown rule exits 2 rather than borrowing another rule's advice — pointing the
reader at a problem they do not have is worse than printing nothing.

Verified with real exit codes (not through a pipe, where `$?` is the last
command's): known-bad fixture → exit 1 with the right hint on all three forms,
comment lines ignored; real repo → exit 0 across 6 scripts.

## dashboard min_uptime (#892)

deploy/dashboard/ecosystem.config.cjs had `min_uptime: 20_000` while
docs-site/docs/deploy/daemon.md documents 45000 and explains why: below the
time a failing process takes to exit, PM2 counts the start as successful,
backoff never engages, and a crash loop looks like normal restarts. A dashboard
rebuilt from this repo landed exactly in that gap. Aligned to 45000 with the
reasoning inline; `node -e require(...)` confirms it still parses.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(docs,ci): 修 W19 编码与死链、给矛盾耗时标条件、把两个没人调的验证脚本挂上 (#899)

Four more from the open-issue triage. Each was verified against origin/main
first, and two of them turned out to be bigger than the issue said.

## docs/qa/weekly/2026-W19.md would not decode as UTF-8 (#887)

Three multi-byte characters were truncated mid-sequence, not one. The issue
reported the first; repairing it revealed the second, and that one the third.
The damage pattern is consistent — every case is `_italic text_` with the
character immediately before the closing `_` eaten — which points at a
truncating edit rather than a bad encoding.

The lost characters are NOT recoverable, so they are marked as damaged rather
than guessed. This is a QA weekly report; inventing a plausible character would
be worse than saying a character is missing.

## …and all 24 of its relative links were dead (#872)

Not "24 broken links" in the sense of a few typos: 0 of 24 resolved. The file
sits three levels deep and the links were written for two, so every `../../`
landed inside docs/ instead of at the repo root. Four more used a single `../`
for directories that live under tests/. All 24 now resolve — verified by
resolving each one against the filesystem, not by eyeballing the diff.

## docs/qa timings contradicted each other three ways (#871)

  docs/qa/README.md          ~16s warm
  docs/qa/strategy.md        ~16s warm
  docs/qa/v0-summary.md      ~93s local, ~40s CI
  v0-summary's own per-test table, summed:   156s

The issue framed this as "pick one and unify". None of the three can be picked,
because not one of them says what it measured — warm or cold, serial or
parallel, which machine. 156s serial against a 93s wall clock just means there
is parallelism nobody wrote down.

So the dead numbers are gone from README and strategy, replaced with
`time bash scripts/qa.sh` and the one fact that stays true: the per-test table
sums to 156s, anything lower implies parallelism, cold starts are worse.
v0-summary keeps its 93s but now says it is a 2026-05 measurement.

## scripts/verify-published-pins.sh and verify-release-tag.sh had zero callers (#862)

Both committed, both executable, both carrying the incident that motivated them
in their header — and `grep -rl` across .github/ and scripts/ found nothing that
invokes either. A guard nothing calls protects nothing, while its presence reads
as if the risk were covered.

Running verify-published-pins.sh by hand, for the first time, failed on its
first invocation:

    ❌ OPENCODE_AGENT_NODE_VERSION 期望 2.5.0-preview.31,
       产物里是: 2.5.0-preview.28
    1 个 pin 与已发布产物不一致 —— main 修了但用户装到的包没修

That is the exact distinction its own header says bit this repo three times in
one day, live in the published preview and undetected. (Independently confirmed
by hand earlier the same night: installing preview.39 demands agent-node
preview.28, while main's source constant reads preview.31.)

Now scheduled daily plus manual dispatch, with the exit codes mapped so that
"could not measure" does not become the same green as "measured and fine":
rc=2 (registry unreachable) fails with a notice saying the run verified nothing.
Per-PR would be wrong — it inspects the published artifact, which a PR does not
change.

## A guard for the first two

`.github/scripts/check-docs-integrity.py` checks UTF-8 validity across every
tracked .md and relative-link resolution under docs/qa/. Three behaviours, each
exercised: repaired tree → exit 0 (359 files, 80 links); f565e9b8's W19 → exit 1
with 25 errors naming each; LINK_SCOPE pointed at a missing directory → exit 2,
"scope regression, refusing to pass".

It starts green, so it is not a backlog canary — a red here will always mean
something just broke. Link checking is scoped to docs/qa/ and says so: some
pages elsewhere link to generated paths, and a guard that cries wolf gets
disabled.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): 让 dev-channels 自动应答真的能用 —— pane 目标用坐标 + 候选按 server: channel (#901)

* fix(cli): revert `=name` on pane commands — it cannot resolve non-ASCII sessions

Regression I introduced in #895 and merged. #895 replaced eight bare `-t <name>`
tmux targets with `-t =<name>` to stop prefix matching. That is correct for
session-targeting commands and WRONG for pane-targeting ones.

Measured on tmux 3.4 with a session literally named `zz中文探针`:

    tmux has-session   -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=0
    tmux kill-session  -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=0
    tmux capture-pane  -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=1  can't find pane
    tmux send-keys     -t 'zz中文探针'   rc=0     -t '=zz中文探针'   rc=1  can't find pane

This fleet's session names are nearly all Chinese, so #895 silently disabled the
dev-channels prompt watcher for essentially every node: capture-pane throws, the
watcher reads that as "session gone", returns false immediately, and the confirm
box is never answered. The node then sits on the prompt forever. That is worse
than the prefix ambiguity the `=` was added to fix, and it is the same failure
mode #895's second half existed to eliminate.

Caught on a live node. `SDK马` was sitting on the dev-channels box with a live
pid, and:

    capture-pane -t '=SDK马'    → rc!=0
    capture-pane -t 'SDK马:0.0' → rc=0, 16 lines, prompt visible

The exact-and-portable form for a pane is the coordinate
`<session>:<window>.<pane>`, resolved by listing panes and matching the session
name with string equality in our own code — which is both unambiguous and
encoding-agnostic, instead of asking tmux to disambiguate. `has-session` and
`kill-session` keep `=name`; they accept it for non-ASCII and still need the
prefix guard.

The watcher now re-resolves the coordinate on every poll rather than caching it:
a session may have no pane on the first iteration, and "no pane yet" must not be
mistaken for "prompt absent" — it keeps waiting and lets the deadline decide.

The wiring assertion fails against f565e9b8..7752437f and passes here; the pure
parser is pinned for prefix siblings, missing sessions, non-zero pane indexes,
and malformed rows. There is also an integration test that creates a real
non-ASCII session and asserts the exact rc difference above, so this cannot
regress silently again. Suite 491 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): auto-confirm dev channels for every node that loads one, not only claude-code-cli

Second half of the same failure. #895's `=name` change stopped the watcher from
seeing the pane; this is why the watcher was never even asked to look at a whole
family of nodes.

`autoConfirmDevChannels` selected candidates with

    normalizeRuntime(n.profile) === "claude-code-cli" && channels has "server:"

but the runtime is not what causes the prompt — loading a `server:` channel is.
`claude-agent-sdk` nodes with `server:commhub` show the same confirm box, and
`claude-code` normalizes to `claude-agent-sdk`, so legacy-named nodes were
excluded too. Those nodes sat on the box forever during `project up` /
`node start --all` with no watcher assigned to them.

The correct predicate was already in this file. The #494 warning on the `--tmux`
path keys purely on `server:` channels with no runtime test:

    if ((resolved.profile.channels ?? []).some(ch => ch.startsWith("server:")))
      console.warn(`[anet] ⚠ this node loads dev channels (server:*): …`)

Two places answering the same question with different rules, and the narrow one
was the one doing the work.

Measured on this machine, all three with `channels: ['server:commhub']`:

    微信马       claude-code-cli    → was selected, came up (late, but up)
    评估m马      claude-agent-sdk   → not selected, sat on the confirm box
    I站工程马     claude-code        → not selected (normalizes to agent-sdk)

Widening is safe because dismissDevChannelPrompt is detection-gated: Enter is
sent only when the prompt's exact text is on screen, so a node that never shows
it times out without a keystroke.

Both assertions fail against main and pass here. One of them initially failed
against the FIXED code too — the new comment quotes the old predicate verbatim,
and a plain `toContain` matched the comment. The test now strips comment lines
before asserting absence, because the claim is about the code. Suite 493 pass,
tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* docs(refresh): stale-snapshot caveats on 4 独立面 (task 27faa700) (#898)

* docs(refresh): 4 独立面 stale-snapshot caveats (task 27faa700)

Local-only branch. NOT pushed, NO PR opened — awaiting 通信龙 review after
PR #869 merges (per instruction 2026-08-14).

Doc-only, no behavior changes. Each edit adds a snapshot-date caveat and
points at the live source of truth (release-plan.md / `npm view`); no
existing evidence-anchored text was removed.

## 1. docs-site/docs/{,en/}preview/index.md:16 (章节标题死数)

- Old heading: "当前 preview = canonical(2.3.0-preview.34 / 2.5.0-preview.26,2026-07-16)"
- New heading: "当前 preview channel canonical build(snapshot 2026-08-14)"
- Added 1 paragraph with:
  - real 2026-08-14 npm-view numbers (agent-network preview.39 /
    agent-node preview.31 / commhub-server preview.29)
  - the main-source-vs-published-binary caveat (通信龙 Fact 1):
    preview.39 binary's embedded .d.ts pair still names
    agent-node@2.5.0-preview.28
  - install-via-@preview-tag reminder (already stated once above,
    reinforced with npm view <pkg> dist-tags recipe)
- English mirror updated with parallel wording.

## 2. docs/release/v2.3.0/plan.md:30 (GA-gate 6-week-old snapshot)

- Prepended one blockquote line marking the段 as a 2026-07-05 snapshot,
  pointing at release-plan.md as the live source, and citing the
  2026-08-14 real preview numbers.
- Kept the original "最后更新:2026-07-05 …" line intact for GA-gate
  milestone history.

## 3. docs/release/versioning-and-compatibility.md:37-43 (fleet snapshot table)

- Prepended one warning line: the first three rows are 2026-06
  preview-iteration snapshots; live numbers via release-plan.md +
  npm view.
- Renamed rows 39-41 to append "(2026-06 快照)" so readers cannot
  misread them as current.
- Added one new row "已发布 preview 头(snapshot 2026-08-14)" with
  real npm-view numbers + the .d.ts pair caveat (通信龙 Fact 1).
- Kept "v2.3.0 GA 目标" and "latest(稳定线)" rows unchanged (真值
  仍准).

## 4. docs/runbooks/feishu-channel-ops.md:11-18 (runbook 死数)

- Runbook section title now names as-of 2026-07-01 deployment
  snapshot + instructs to复核 real deployed version via
  `docker exec anet-feishu-local anet -v` (does not touch prod —
  runbook only tells the operator what to run).
- agent-network / agent-node rows retain the deployed values but
  add "当前 preview 头 2026-08-14 快照为 preview.39 / preview.31" +
  release-plan.md link for cross-check.

## Version facts used (npm view 2026-08-14)

| pkg | latest | preview |
|---|---|---|
| agent-network | 2.2.21 | 2.3.0-preview.39 |
| agent-node | 2.4.13 | 2.5.0-preview.31 |
| commhub-server | 0.8.8 | 0.9.0-preview.29 |

## Not landed here

- Q2 段全部 8+ 条 (行为句缺版本) — 需 git log 追溯精确 version anchor,
  单独任务处理 (通信龙 视 #869 merge 时机再拍)
- 通信龙 Fact 2 (anet node start ✅ vs tmux has-session) — origin/main
  未直接命中"看到 ✅ 就成功"教学句, README / clean-server.md / feature-
  audit.md 可通过通用页脚 note 批量处理, 单独任务处理
- docs/RELEASE-SOP.md:232/243 pairing caveat 详写 — 属 SOP 内部, 请
  RELEASE-SOP owner 决定文案
- docs/grok-build-runtime.md:111 (v0.10.11 anchor stale) — 属 Q2 段
  item 16, 单独 issue

* docs(refresh): bump snapshot date 2026-08-14 → 2026-08-17 (rebase day)

Per 通信龙 instruction on task 27faa700: snapshot dates should reflect
the rebase/push day, not the authoring day.

Re-verified `npm view <pkg> dist-tags` on 2026-08-17 — numbers unchanged
since 2026-08-14 authoring:

  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29

Fact-1 pairing caveat also still holds (通信龙 tested 2026-08-17 evening):
main-source constant `OPENCODE_AGENT_NODE_VERSION` = agent-node preview.31,
but the published preview.39 binary embeds a `.d.ts` pair naming
agent-node preview.28. They installed preview.28 to get opencode-指挥狗
to start.

---------

Co-authored-by: t <t@t>

* docs(refresh): Q2 anchors for password + #450 + Fact-2 (#895/#896) notes (#900)

Follow-up to PR #898 (task 27faa700). Doc-only; 5 files, +20 -7.

## Anchors nailed via git log -S / gh issue view (2026-08-18)

### 1) README.md:50 + README.en.md:50 — 一次性随机密码 anchor
Introducing commit: `3e4e190c` (PR #264 fixing #261 P0-2), merged
2026-06-28. First npm-published preview containing the behavior:
`@sleep2agi/agent-network@2.2.22-preview.4`. All subsequent
`2.3.0-preview.0..39` inherit.

Stable `@latest` at the time of writing is `2.2.21` — pre-dates the
fix. So @latest users still get the fixed default `admin` / `anethub`.
Older preview `≤ 2.2.22-preview.3` also pre-dates the fix.

Rewrote both README lines to state the anchor version, PR/issue links,
and the explicit "you are still on the fixed default if you're on
@latest 2.2.21 or preview ≤ 2.2.22-preview.3" caveat.

### 2) docs-site/docs/{,en/}guide/getting-started.md:97 — #450 anchor
Issue #450 is **OPEN** (verified `gh issue view 450`). Root fix landed
in PR #239 commit `1eff3a4d` on 2026-06-28. Vincent's 2026-08-09 audit
verified the fix in an isolated Docker probe on
`agent-network@2.3.0-preview.38` reaching SSE connected.

But #450 is not closed because four acceptance gates need to run
green before promoting to latest:
  1. promote a release containing 1eff3a4d to npm latest and repeat
     the cold-install journey against @latest
  2. exercise a >60s cold fetch scenario
  3. verify actionable DNS/registry/timeout/version failure
     classification
  4. run the credentialed idle → send_task → non-empty reply layer

So: current `@preview` (2.3.0-preview.39) has the fix; `@latest`
(2.2.21) still ships the bug. Rewrote the warning block on both zh
and en getting-started to name that split explicitly and provide the
workaround.

Method-note (for the audit trail): PR #239's title only names #237,
not #450 — so the standard `gh pr list --search "fixes:#450"` returns
nothing. The link is only recoverable via `git log -S` on the error
string. Worth remembering.

## Fact-2 notes (通信龙 D1-D3) — #895 / #896 in main NOT yet in npm

- PR #895 (`f565e9b8`) fixed `anet node start` false-`✅` /
  false-`started detached (tmux session live)` in detached scenarios.
  Merged to main.
- PR #896 (`40574a02`) fixed `anet project up / project restart`
  exit-code lie. Merged to main.

**Neither has been cut into an npm release yet** — so for anyone on
`@preview` (currently `2.3.0-preview.39`), the trap still exists. The
real check remains `tmux has-session -t "=<alias>"` — the `=` is
required (bare alias is a prefix match and can go green on the wrong
session).

Added this note in three places:

1. `README.md` + `README.en.md` quickstart — right after
   `curl /health` verify, before the "open localhost:3000" line, so
   the first-run reader sees the caveat while their brain is still
   on `anet node start`.
2. `docs-site/docs/deploy/clean-server.md` §7.1 — right below the
   `tmux new -s anet-<alias> + anet node start <alias>` recipe.
3. `docs-site/docs/deploy/clean-server.md` §故障排查表 — added a
   new row 5.5 (`✅ printed but tmux session not there`) with the
   diagnostic recipe.

Not touched (per current scope):
- `docs/version/0.11.0/feature-audit.md:33` (D4) — the audit already
  correctly reports the bug and its `✅` refers to `anet node create`
  (wizard), not `anet node start`; no misleading claim to correct.
- The 6-10 `anet node start` command demonstrations in
  `docs-site/docs/deploy/npm.md`, `.../concepts/networks.md`,
  `.../concepts/tokens.md` — those are pure command samples with no
  success-criteria text; the central note in clean-server.md is
  where they land.

## Not touched — Q2 leftovers (independent follow-ups)

Sixteen other Q2 lines identified in the exhaustive scan still carry
`TBD 追溯` anchors (multi-model.md, agent-node.md, batch.md,
dashboard.md, feishu.md, runtimes.md, upgrade.md, RELEASE-SOP.md,
etc.). Each needs a targeted `git log -S` pass. Scheduled for
subsequent follow-ups so this PR stays reviewable.

## Verification

```
$ git diff origin/main...HEAD --stat
 README.en.md                               | 8 ++++++--
 README.md                                  | 8 ++++++--
 docs-site/docs/deploy/clean-server.md      | 7 ++++++-
 docs-site/docs/en/guide/getting-started.md | 2 +-
 docs-site/docs/guide/getting-started.md    | 2 +-
 5 files changed, 20 insertions(+), 7 deletions(-)

$ git grep -c '2.2.22-preview.4' README.md README.en.md
README.en.md:1
README.md:1

$ git grep -c '1eff3a4d' docs-site/docs/{,en/}guide/getting-started.md
docs-site/docs/en/guide/getting-started.md:1
docs-site/docs/guide/getting-started.md:1

$ git grep -c '#895' README.md README.en.md docs-site/docs/deploy/clean-server.md
README.en.md:1
README.md:1
docs-site/docs/deploy/clean-server.md:2
```

## Version facts sourced from npm (2026-08-18)

Verified `npm view <pkg> dist-tags` on 2026-08-18 — unchanged since
2026-08-14 authoring pass:

  latest  : agent-network 2.2.21 / agent-node 2.4.13 / commhub-server 0.8.8
  preview : agent-network 2.3.0-preview.39 / agent-node 2.5.0-preview.31
            commhub-server 0.9.0-preview.29

These are snapshots, not promises. Both `@latest` and `@preview` tags
keep drifting; re-read via `npm view <pkg> dist-tags` before quoting
elsewhere.

Co-authored-by: t <t@t>

* fix(tests): derive the opencode pair versions from source instead of pinning them in tests (#902)

Found by running the RELEASE-SOP pre-flight for preview.40 rather than by
reading it. `scripts/sync-pinned-versions.sh` bumps
`OPENCODE_AGENT_NETWORK_VERSION` / `OPENCODE_AGENT_NODE_VERSION`, but it does not
touch the tests — and two suites hard-code that pair in nine places:

  test386  5 assertions + 3 fixtures (bin/npx spec, two package.json versions)
  test384  run.sh defaults + Dockerfile ARG defaults

The sharpest one is test386:398. It `grep -Fq`s the exact install command that
`opencodeExactPairInstallCommand()` builds FROM those constants, so bumping a
constant makes the assertion fail by construction. Following the SOP as written
produces a red, and the cheapest way to make that red go away is to edit the
number in the assertion — which turns the test into a copy of the current value
that checks nothing.

Everything now reads the constants at run time, fail-closed: if the source file
cannot be parsed the run fails rather than continuing with an empty string,
because `grep -Fq ""` matches everything and would silently turn five assertions
into permanent passes. The two fixture package.json files are rewritten by
run.sh before use and carry a note saying not to chase the constant by hand.

Two traps hit while doing this, both worth recording because the fix and the
no-op look identical:

  * test384's Dockerfile sets `ENV *_UNDER_TEST=${ARG}` from an ARG that had a
    hardcoded default, and run.sh reads `${*_UNDER_TEST:-<derived>}`. With a
    non-empty ARG default the ENV is never empty, so the derived branch could
    never run — the "now it derives" change would have been inert while the
    suite kept passing against the previous version. The ARG defaults are now
    empty so `:-` reaches the derivation; `--build-arg` still overrides.
  * bin/npx compared against a literal spec. It now compares against
    `$EXPECT_NODE_SPEC` exported by run.sh and exits 65 if that is unset, rather
    than falling through to "unexpected npx arguments" — which would have read
    as a product failure instead of a harness one.

RELEASE-SOP gains a calibration note saying these files are deliberately NOT in
the Live versions table: they are self-consistent now, and registering them
would re-introduce a second copy to drift.

Co-authored-by: t <t@t>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(docs): changelogs must not line-anchor into main — the anchor rots by construction (#903)

A changelog entry describes a state that was true at some past release. A
`blob/main/<file>#L<n>` link resolves against today's code. Those two facts are
incompatible: the link is wrong after the next commit that touches that file,
and nothing reports it.

Measured, not assumed. Two of the six such links in the changelogs:

    cli.ts#L61    docume…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants