Skip to content

ci(test831): 文档站行号 pin 的下限门(守住不再变多,不解决 #831) - #843

Closed
vansin wants to merge 4 commits into
mainfrom
ci/doc-source-pin-guard
Closed

ci(test831): 文档站行号 pin 的下限门(守住不再变多,不解决 #831)#843
vansin wants to merge 4 commits into
mainfrom
ci/doc-source-pin-guard

Conversation

@vansin

@vansin vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

#831 的下限。不解决 #831,先把这句放最前面。

它承诺什么、不承诺什么

上一条评论量出来的:docs-site 下 141 处 blob/<ref>/<file>#L<N> 引用全部钉在 main,零个钉在不可变 commit。钉 main 的锚点每次重构都会漂,而漂了不会有任何东西报错。

这道门只保证一件事:已知失效的那批不会变多。

召回率是实测的,不是估计的 —— 拿 #831 里已人工确认失效的 10 条回测判据:

  tools.ts#L521   抓到(平凡行)
  tools.ts#L286   ✘ 漏掉   network_id: z.string().max(200).optional(),
  tools.ts#L646   ✘ 漏掉   cpu_pct: processCpuPct,
  tools.ts#L571   抓到(平凡行)
  tools.ts#L911   ✘ 漏掉   message_id: z.string().min(1).max(200),
  tools.ts#L244   ✘ 漏掉   FROM skillhub_skills WHERE network_id = ?1`;
  tools.ts#L271   ✘ 漏掉   const reviewer = !callerTokenIsNetwork && (role === …
   auth.ts#L99    抓到(平凡行)
   push.ts#L11    抓到(平凡行)
  index.ts#L253   抓到(越界)

  抓到 5/10

漏掉的都指向一行长得很正常的代码,只是不是它声称的那一行。别拿这个 job 的绿色去论证 #831 已解决 —— 这句话同时写在 checker 文件头、run.sh 头、qa.yml 的 job 注释和基线文件头,四个地方。

判据与基线

scripts/check-doc-source-pins.py 判三类:文件不存在 / 行号越界 / 那一行是平凡行(}}],);、空行、某段注释的中间一行)。第三类的理由:没有人会故意把说明文字的锚点钉在 } 或空行上。

docs/doc-source-pins-baseline.txt 记当前已知失效的 32 条,语义是只许缩小:

  • 出现基线之外的新失效 → 红
  • 基线里某条已经修好 → 也红,并要求删掉它

第二条是刻意的。不这么做基线会变成坟场,修好的和没修的混在一起,数字再也不说明任何事。

套件

tests/test831-doc-source-pins,alpine 按 digest 钉版,--network none 可跑,不碰网络也不碰任何真实节点。

[L0] denominator
  listing_mode=walk
  scanned_doc_files=106   pin_occurrences=141   pin_doc_pairs=139
  unique_pins=70   broken_pins=32   baseline_entries=32
  OK  walk 路径与 git 路径给出同一份清单
[L1] clean tree passes                      rc=0
[L2] MUTATION_RED new-out-of-range-pin      rc=1  → 复原后回绿 ✓
[L3] MUTATION_RED stale-baseline-entry      rc=1  → 复原后回绿 ✓
[L4] 3 条已知盲区仍未被判据覆盖(与 5/10 一致)
RESULT: PASS      exit_code=0

几点值得单独说:

  • L0 的分母对齐:镜像里没有 .git,checker 走目录遍历;这一层断言它与仓库里 git ls-files 得出同一份清单。分叉了就红 —— 否则「容器里绿」推不出「仓库里绿」。
  • L2/L3 都验了复原后回绿,不然那个红可能来自变异之外的东西。
  • L4 是边界断言:那 3 条已知盲区必须仍然判不出来。哪天有人「改进」判据,这里会红,提醒他去更新 5/10 那个数,而不是让边界悄悄漂移。

报告 docs/tests/report-test831.txt 自带 runsh_blob=6d4dca00…,可用 git rev-parse a7b12780:tests/test831-doc-source-pins/run.sh 独立比对。

已接进 CI

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

⚠️ #803 也在改 qa.yml,但它改的是 recovered-suites 里的步骤顺序,这里是在文件末尾追加新 job + 在触发路径列表里插 4 行,合并顺序上应该是机械并集。

途中修掉自己两个错

  1. 第一版 Dockerfile 里的 python:3.12-slim digest 是我编造的,不对应任何真实镜像。换成本地实际拉到并核对过的 alpine:3.20 digest,python3 由 apk 装(3.12.13)。
  2. checker 第一版把「(pin, 文档) 对数」当成「引用总数」打印,得到 139,而原始出现次数是 141。是同一份数据我先后量出两个数才发现的 —— 现在两个数都打印,并在函数 docstring 里写明它们不是一回事。

#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。是同一份数据我先后量出两个数才发现的;两个数现在都打印。
镜像按 SOURCE_COMMIT=a7b12780 构建;报告自带 runsh_blob,可用
  git rev-parse a7b1278: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

@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: ccde2842a5

ℹ️ 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 scripts/check-doc-source-pins.py Outdated
Comment on lines +115 to +116
_ref, path, line = match.groups()
pins.setdefault((path, int(line)), set()).add(rel)

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 Classify each pin against its captured ref

When a document follows the checker's suggested remediation and changes a main URL to an immutable commit, _ref is discarded here and classify() still reads the current checkout. A valid historical link will therefore be reported as broken whenever that file or line is absent/trivial in HEAD; conversely, an invalid historical link can pass if the same line looks valid in HEAD. Preserve the ref and resolve the referenced revision, or explicitly exclude immutable refs from this drift gate.

Useful? React with 👍 / 👎.

Comment thread scripts/check-doc-source-pins.py Outdated
Comment on lines +130 to +132
if line > len(content):
return ("line-out-of-range", f"(文件只有 {len(content)} 行)")
text = content[line - 1]

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 Reject zero-valued line anchors

When a new link uses #L0, the regex accepts it but this check only rejects values above the file length. Python then evaluates content[-1], so if the target file ends with a nontrivial line and no trailing newline, the checker reports the malformed, non-one-based GitHub anchor as valid and the floor gate remains green. Treat line numbers below 1 as out of range.

Useful? React with 👍 / 👎.

Comment thread scripts/check-doc-source-pins.py Outdated
Comment on lines +175 to +176
new = sorted(set(broken) - baseline)
fixed = sorted(baseline - set(broken))

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 Retain baseline entries until their links are repaired

When a source edit shifts a known-broken main anchor from a trivial line onto ordinary but unrelated code, classify() stops including it in broken and this subtraction labels it fixed. CI then explicitly tells the maintainer to delete the baseline entry even though the document was untouched and the anchor remains invalid, moving the known defect into the checker's documented blind spot. A baseline entry should only be removed when the corresponding document link is removed or changed, not merely when its current target stops matching the heuristic.

Useful? React with 👍 / 👎.

Comment on lines +123 to +124
target = REPO / path
if not target.is_file():

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 Keep source paths inside the repository

When a document contains a path such as ../../etc/passwd, joining it directly to REPO allows filesystem traversal outside the checkout. For example, blob/main/../../etc/passwd#L1 reads /etc/passwd; because its first line is nontrivial, the checker reports this invalid repository link as healthy. Reject absolute paths and .. components, or resolve the target and verify that it remains beneath REPO before reading it.

Useful? React with 👍 / 👎.

#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
source_commit=c6338f272a0474d84dc7f22c0fd482b9ca5de77a
按 pre-pr-selfcheck §12:改动改变了套件下次跑看到的东西,报告要一起更新。
RESULT: PASS  exit_code=0
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

四条全部成立,已修(c6338f27),每条配一个断言 —— 修了判据却没有断言等于没修。

① ref 被丢掉 —— 这条最重

第一版把 URL 里的 ref 解析出来就扔了,一律拿当前检出去判。后果是:

有人按这个工具自己给的建议、把 main 改成不可变 commit,反而会被判成失效。 那条链接在它自己的 commit 上是对的,在 HEAD 上未必。反过来,历史版本里本来就错的链接,也可能因为 HEAD 恰好长得对而蒙混过关。

这等于我一边在报告里劝人「钉 SHA 或改符号锚点」,一边让门去惩罚照做的人。

改:钉了 7–40 位 hex ref 的引用不属于这道门(它管的是会漂的引用),单独计数 pins_on_immutable_ref

断言:注入一个在 HEAD 上必然越界的 SHA pin,门必须仍然绿且计数 +1。

#L0

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

③ 基线语义写反了

原来是 fixed = baseline - broken:判据不再标某条,就叫人删基线条目。

但源码一漂,一个仍然错的锚点会从「平凡行」挪到「普通但不相干的一行」,判据就标不出它了 —— 而文档一个字没动。照原规则 CI 会主动要求删掉这条已知缺陷,等于把它推进本工具自己的盲区。你这条抓得很准。

改成:

gone    = baseline - present     # 文档里那个引用不存在了 → 才判为可删(红)
drifted = (baseline ∩ present) - broken   # 仍被引用、但判据标不出来 → 保留在基线里

drifted 单独打警告并保留,不删、也不当成绿色成绩。

④ 路径穿越

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

验证

[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 警告(pin=agent-network/bin/cli.ts#L61)
MUTATION_RED new-out-of-range-pin rc=1
MUTATION_RED stale-baseline-entry rc=1
RESULT: PASS      退出码 0

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

报告已一并刷新(08c95a8a)。

⚠️ 下游两个 PR 需要跟着走

#844 / #845 基于这个分支,判据变了它们要重新验证。我下一轮把这个分支合进那两个再各跑一次,不会让它们带着旧判据的绿色进 main。

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

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

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

顺带:合并未提交时那次构建红在 blob 绑定上(镜像里的 run.sh 与 HEAD 声称的
不是同一份),说明那道绑定确实在起作用。
vansin pushed a commit that referenced this pull request Aug 13, 2026
这个 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

一个还没人知道的跨 PR 耦合:#810 / #834 合入后,这道门会红

本轮没有新写代码,做的是一次合并前的耦合测量 —— 结果是三个 PR 之间有一处硬耦合,谁先合谁就得多做一步,而现在四个 PR 里都没有写这件事。

测出来的

#845 的树上(已含 #843 的门 + 已改完的 mcp-tools / rest 锚点),模拟 #810#834 各自的改动,然后跑门:

FAIL: 4 个基线条目对应的引用已经不在文档里了,请从基线里删掉
  server/src/auth.ts#L184      ← #810 删掉了引用它的那一行
  server/src/index.ts#L253     ← #834 把它重锚到不可变 SHA(22ed1886)
  server/src/tools.ts#L521     ← #810 删掉
  server/src/tools.ts#L571     ← #810 删掉

pins_on_immutable_ref=2
unique_pins=14   broken_pins=1   baseline_entries=5
退出码=1

也就是说:#810#834 合入之后,docs/doc-source-pins-baseline.txt 必须同步删掉对应条目,否则 CI 的 doc-source-pins job 会红。

这不是门的 bug,正是它按设计在做的事 —— 基线只许缩小,修好了就得删,不删会变成坟场(见 #843)。但它构成一个合并顺序上的动作项,需要有人知道。

每种顺序下要做什么

顺序 需要的额外动作
#843 先合,#810 / #834 后合 后者的 PR 里加一行:从基线删掉对应条目
#810 / #834 先合,#843 后合 #843 的基线重新生成一次即可(那 4 条本来就不会进去)

两种都很小,但都不能忘。忘了的表现是「一个只改文档的 PR 把 CI 弄红了」,而红的原因看起来跟那个 PR 毫无关系。

顺带一个好消息

合完之后机械可证的失效面从 32 降到 1(只剩 server/src/push.ts#L38,#810 没动它)。#831 里我写的「至少 32 个已失效,真实更大」,那个 32 到这里基本清完了 —— 剩下的是判据看不见的那部分,仍要人读。

这次测量本身也栽了一跤

第一遍我用 tail -14 看门的输出,把 FAIL 列表的第一行截掉了,于是只看到 3 条、还以为 auth.ts#L184 有什么特殊之处。是重新不截断地跑了一遍才对上。

同一件事在 docs/pre-pr-selfcheck.md §13 里刚写过(「输出的截断」),今天第二次栽在它上面。另外那次 git ls-files … | xargs command grep 静默返回空 —— command 是 shell 内建,xargs 找不到这个可执行文件,于是「文件里没有」和「我的管道坏了」打印出来是同一片空白。

vansin pushed a commit that referenced this pull request Aug 13, 2026
上一轮我在 #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 的时间相同。写在这里免得有人以为放错了地方。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

把上一条的耦合提醒变成一条命令(d9517384,落在链尾 #845)

上一条我贴了「#810 / #834 合入后基线会变 stale,门会红」的测量。但那是散文 —— 执行合并的人还是得自己去数该删哪几条,等于把一个机械操作交给记忆力

现在是:

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

门红的时候也会直接把这条命令打出来,不用再回来翻评论。

🔴 它只许缩小

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

L7 两个方向都断言,不只测它能用:

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

②那一条特意断言了两件事:它红了,而且基线文件字节未动。只断言「它红了」不够 —— 一个先写盘再报错的实现同样会红,而那正是最坏的情况。

③ 用的场景是把某条 pin 的引用改钉不可变 SHA,也就是 #834 的做法 —— 不是编的。

位置说明

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

vansin pushed a commit that referenced this pull request Aug 13, 2026
我在建 test831 时写过「没接进 CI 的门只是装饰」,下一轮建 test846 时自己就没接。
是这轮做 qa.yml 协调分析、查各 PR 各改了什么时发现的 —— 不是别人提的。

新增 doc-claims job + 3 条触发路径,形状与 #843 的 doc-source-pins 一致
(同一个锚点后追加、job 附在文件末尾)。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

qa.yml 的四处改动我摊开对照过了,冲突面只在两处 paths 清单、且是机械并集;建议合并顺序 #803#843#846。完整对照贴在 #803 的这条评论

vansin pushed a commit that referenced this pull request Aug 13, 2026
上一轮我给出的是「冲突了怎么解」。这一轮做的是让冲突不发生。

冲突源于所有人都追加在同一处:
  paths  五个 PR 都插在 - 'tests/test746-setup-bun-pin/**' 之后
  job    #843 与本 PR 都追加在文件末尾(#803 插在 qa: 之前,#798/#801 插在 59 行)

改动:
  paths  改插到 - 'server/**' 之后 —— 距离 test746 九行,超出 git 默认上下文窗口
  job    从文件末尾挪到 jobs: 之后(这个位置没有别的 PR 用)

paths 是集合、jobs 是映射,位置变化不改变行为。结构断言(每个 job 有 runs-on
与非空 steps)已跑过。

这样合并时不需要任何人去解那个「公共上下文属于双方」的冲突 —— 那个坑我在
#803 上写清楚了,但最好的处理是不让人踩到它。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

这个 PR 是我写的,自审没有独立性,所以我做的是评审者会想要的那件事:跑变异,证明它不是假绿
另外发现一条我自己此前说错的范围,一并写在这里。

变异证据

基线(未变异):

pin_doc_pairs=139  unique_pins=70  broken_pins=32  baseline_entries=32
OK: 失效 pin 32 个,基线 32 条 —— 没有新增,也没有该清的残留。      退出码 0

变异A —— 新增一条失效 pin(这道门的立身之本:守住不再变多):

在 docs-site/docs/community.md 末尾追加一条
  .../blob/main/server/src/index.ts#L99999

FAIL: 1 个新的失效 pin(不在基线里)
pin_doc_pairs=140  unique_pins=71  broken_pins=33  baseline_entries=32      退出码 1

见证真红,而且三个计数都跟着动了(139→140 / 70→71 / 32→33),不是靠一句 FAIL 唬人。

变异B —— 清空基线文件(基线不该能被抹掉换绿):

: > docs/doc-source-pins-baseline.txt
broken_pins=32  baseline_entries=0                                          退出码 1

fail-closed。

🔴 我第一发变异打偏了,值得记下来

第一次我把那条假 pin 追加到 docs/architecture.md,门报绿、退出码 0、broken_pins 纹丝不动(32)
差点被我读成「门瞎了」。真实原因是 check-doc-source-pins.py:54:

DOC_ROOT = "docs-site"

docs/ 根本不在扫描根内 —— 是我的变异打在了门的范围之外。
「变异后没变红」有两种可能:门瞎,或者变异打偏。不先分清这两种,结论就是错的。

🔴 由此暴露的范围限制:这道门不覆盖 docs/

我在 #850 / #851 里量过:docs/ 下按 blob/maincli.ts 行号的引用共 15 条,
其中锚里带符号名、可机器判定的 11 条 —— 漂移 11 条,仍然对的 0 条,分布在

docs/architecture.md        docs/node-lifecycle.md
docs/design-auth-network.md docs/pitfalls.md

这四个文件全部在 DOC_ROOT="docs-site" 之外,这道门一条都照不到。

我在上一轮汇报里说过「#843 是那 11 条漂移唯一的机械解」—— 这句话是错的,两个方向都错:

  1. 范围:docs/ 不在扫描根内;
  2. 类别:那 11 条是「行号在范围内、但指的不是声称的那个符号」,
    而本文件头部已经写明这道门抓不到这一类(实测召回率 5/10)。

所以合了这个 PR,docs/ 下那 11 条依然要靠人肉发现。

建议

这个 PR 别扩范围(它的标题就写着「不解决 #831」,范围小是它能绿的原因)。
DOC_ROOT 是个单点常量,把 docs/ 纳进来是独立的一步 —— 纳进来会立刻多出一批失效 pin
需要进基线,那是另一次改动、另一次评审。我会另开一条跟踪。

CI 现状:mergeable=MERGEABLEmergeState=CLEAN、全部 check SUCCESS
(含它自己那道 doc source-pin floor (Docker))。目前只有 codex bot 的一条评审,
缺一次人的深审 —— 我写的,不该我批。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

量了一下这道门当前扫描根内有没有盲区 —— 因为它对读不出的文件是静默跳过的:

except (OSError, UnicodeDecodeError):
    continue

实测(与 checker 的 SCANNED_SUFFIXES 同口径,排除 node_modules / dist / .vitepress/cache):

docs-site/   扫描面 106 个文件   UTF-8 解码失败 0 个

当前 DOC_ROOT="docs-site" 下没有任何文件会被静默跳过 —— 这道门现在报的覆盖是实的,不打折。

(对照:若按 #852DOC_ROOT 扩到 docs/,扫描面变成 181 个文件、其中 1 个会被静默跳过 —— 就是 docs/qa/weekly/2026-W19.md,PR #858 正在修它。所以那是扩范围时要先解决的前置条件,不是本 PR 的问题。)

顺带补一句给评审:这个 except … continue 本身不算 bug(读不出的文件确实没法检查),但它是静默的 —— 建议将来把跳过的文件数打印出来(skipped=N),否则「扫了多少」和「看见了多少」这两个数在输出里是分不开的。这与本 issue 家族一贯的「分母承重」是同一条原则。

vansin added a commit that referenced this pull request Aug 17, 2026
#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>
vansin added a commit that referenced this pull request Aug 18, 2026
* 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    documented as `PINNED_SERVER_VERSION`
                  now lands on  `} from "../src/opencode-preset";`
    cli.ts#L2589  documented as the `bunx --bun @sleep2agi/commhub-server@…`
                  line in `anet hub start`
                  now lands on  a line of `anet project restart` help text

Both now link the file without the anchor and name the symbol instead, which is
what a reader can actually search for. The original line number is kept in
parentheses as historical context — it was true when written, and saying so is
more useful than deleting it. This follows the precedent RELEASE-SOP records at
R367, which replaced `cli.ts:NNN` references with symbol references for the same
reason.

Scoped to changelogs on purpose. `docs-site/docs/api/mcp-tools.md` carries 44 of
these anchors and all 44 are still in range, landing on plausible content — they
are maintained, because that page documents current behaviour rather than past
releases. A guard reddening on ~100 maintained links would be a backlog canary
that dies the day the backlog clears, and would train people to ignore it.

check-docs-integrity.py gains the rule, exercised three ways: repaired tree →
exit 0 (2 changelogs, 0 anchors), f565e9b8's changelog → exit 1 naming each,
CHANGELOG_GLOB pointed at a missing filename → exit 2 rather than a clean pass
against nothing.

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

* feat(hub): let get_all_status filter by alias, and say what its summary counted (#904)

`get_all_status` returns one row per session with 31 columns and no way to ask
about specific nodes. On this hub — 222 sessions — the response is about 259 KB,
past what an MCP client takes in one result. So the caller who wanted the status
of THREE nodes could not get it from this tool at all, and had to go around it
to the REST API. That is the whole of #824, hit first-hand.

`filter_alias` takes one alias or several separated by commas, matched exactly
through bound parameters. The patrol loop still gets everything, so the argument
is optional and nothing existing changes.

Blank entries are dropped rather than matched. A trailing comma would otherwise
produce `alias = ''`, which matches no row — and "no rows" reads exactly like
"those nodes do not exist". The failure and the true answer would be
indistinguishable to the caller. That behaviour is the reason the parsing lives
in its own module with tests rather than inline: nine cases pin it, including
that placeholder count always equals alias count so parameters cannot misalign,
and that a filter of only commas means "no filter" rather than "match nothing".

The response also now carries `summary_scope` and `sessions_returned`. `summary`
has always counted every session in the read scope while ignoring the filters,
which is right for the patrol loop — but a caller who asked about three aliases
and gets back three rows plus `idle: 96` can easily read the 96 as being about
their three. Rather than change the semantics under existing callers, the
response says what the number covered.

Verified: the wiring assertion fails against main and passes here; the project's
own runner reports server/src/alias-filter.test.ts pass=9 fail=0.

Pre-existing and NOT from this change: `server/src/task-lifecycle-watcher.test.ts`
fails on main today ("startHub owns a live watcher timer instead of relying on
import side effects", expected 0 received 1). Confirmed by running that file
against main's tools.ts in this same tree — identical failure. Aggregate is
937 pass / 1 fail either way.

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

* fix(hub): PORT=0 must mean an ephemeral port, not the production Hub port (#906)

`server/src/server.ts:51` read

    const PORT = Number(process.env.PORT) || 9200;

`Number("0")` is `0`, which is falsy, so `PORT=0` — the conventional way to ask
the OS for an ephemeral port — silently resolved to 9200, the production Hub
port. Three consequences, and the middle one is the worst:

  1. On a host where 9200 is taken (a running Hub), a test that sets PORT=0 dies
     with EADDRINUSE and reads as a product bug.
  2. On a host where 9200 is FREE, that same test PASSES — by binding 9200. It
     is green because it grabbed the production port, not because PORT=0 did
     anything. Green for the wrong reason is worse than red.
  3. Anyone asking for an ephemeral port gets the production port instead.

This is not hypothetical. `server/src/task-lifecycle-watcher.test.ts` fails on
main today, and that is why: it spawns the Hub with `PORT: "0"`, the child binds
9200, 9200 is already in use on this machine, the child exits 1, and the
assertion `expect(child.exitCode).toBeNull()` fails. The test reports "the
watcher did not stay alive" and says nothing about ports — the message points at
the wrong layer entirely.

The file already knew. `bootServer` uses `opts.port ?? PORT` with a comment
saying `||` "would swallow a legitimate 0". The correct rule was one level above
the line that needed it.

`resolvePort` also rejects a malformed value instead of defaulting. Falling back
to 9200 on `PORT=abc` means a typo starts the server somewhere the operator did
not ask for, and on this fleet that somewhere is production. Parsing is decimal
digits only after trimming: `Number()` alone accepts `"0x10"` as 16, so a value
that does not look like a port would still resolve to one, quietly and to a
different number than was typed.

Verified as an A/B on the same tree, same DB layout, cwd at the repo root:

    main's server.ts   4 pass, 1 fail   (EADDRINUSE, child exit 1)
    this branch        5 pass, 0 fail   (child binds 41885 and stays up)

The project's own runner now reports 946 pass / 0 fail / bad=false. Before this,
it was 937 pass / 1 fail.

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

* fix(tests): outbound 工具集断言改为从真相源派生(#816 —— 门是错的,而且没人跑) (#905)

* fix(tests): assert the outbound tool set from the source of truth, not a stale copy (#816)

test235's harness asserted the outbound MCP surface as a hard-coded array of
three names. `OUTBOUND_TOOL_NAMES` in node-server.ts has held FOUR since
`commhub_upload_file` shipped in #693, so that assertion has been wrong on main
— and nothing reported it, because no workflow and neither of qa.sh's L0/L1
lists runs test235. A gate that is wrong and unrun is indistinguishable from a
gate that passes.

Third instance of the same shape tonight, after qa.yml's path filter missing the
tests it runs and the two orphaned verify scripts: the check exists, it is
plausible to read, and nothing invokes it.

The names now live in `agent-network/src/outbound-tool-names.ts` and both sides
import them. That module exists as its own file rather than an export from
node-server.ts for a measured reason: importing node-server.ts to read a
constant BOOTS THE SERVER.

    $ bun -e 'import { OUTBOUND_TOOL_NAMES } from "./src/node-server.ts"; …'
    [commhub] MCP stdio connected
    [commhub] starting SSE listener...
      OUTBOUND_TOOL_NAMES: commhub_get_all_status, …

A harness that opens a live MCP connection to read a list fails for reasons that
have nothing to do with what it tests. I hit that while writing this fix.

The assertion also sorts both sides. It is about WHICH tools are exposed, not
about the order the server registers them in, and an order-sensitive comparison
would have turned a reordering into a mystery failure.

Both new assertions fail against main. Suite 497 pass, tsc clean.

One note on the credit: I first reported this issue as not reproducible. My grep
searched for the NEW tool names, so of course it found nothing — the assertion
pins the OLD three and never mentions upload_file. 通信团队's triage node caught
the mistake and pointed at socket-harness.ts:210. Probe for the assertion, not
for the symptom.

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

* fix(tests): scope the cross-package assertion — it fails ENOENT inside the unit image

My own CI break, one commit old. The new test read
tests/test235-grok-mcp-outbound-only/socket-harness.ts, but
tests/test745-agent-network-unit-ci/Dockerfile copies ONLY agent-network/ (plus
agent-node/package.json and its own run.sh). So the assertion passed on a full
checkout and failed with ENOENT in the container — the same "works where I ran
it, not where it runs" shape I spent tonight cataloguing, this time in a test I
wrote to catch that shape.

The harness assertion now skips when the file is absent. Skipping is fail-open,
so it is paired with an assertion that refuses to let the skip be silent: on a
full checkout the harness MUST exist (its absence there is a real regression),
and in a package-scoped image the run prints which assertion did not execute.
A green in that image is therefore never mistaken for "the harness was checked".

Verified both ways:

  full checkout                  5 pass, 0 skip   (assertion really runs)
  simulated package-only image   4 pass, 1 skip   + "the socket-harness
                                 assertion did NOT run in this image"

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

* fix(tests): stop detecting the checkout shape — assert only what is in this package

Third attempt at the same 20 lines, and the first two were both wrong in the
same way.

  1. Read tests/test235-.../socket-harness.ts unconditionally.
     → ENOENT inside tests/test745-agent-network-unit-ci, whose image copies
       only agent-network/ (plus agent-node/package.json and its own run.sh).
  2. Skip when `tests/` is absent.
     → the container HAS a `tests/` directory: test745's own run.sh lives in it.
       The probe answered "full checkout", the assertion ran, and it failed.

The second is the first mistake repeated: probing an incidental feature ("is
there a tests/ directory") instead of the thing itself ("is THIS harness here").
A third detector would be a third guess, so this suite now asserts only what
lives inside its own package — the constant's contents, that reading it does not
boot a server, and that node-server.ts consumes it instead of redeclaring it.

The gap is written down rather than papered over: nothing gates the fact that
socket-harness.ts derives its expectation from OUTBOUND_TOOL_NAMES. That is not
new here — no workflow and neither of qa.sh's L0/L1 lists runs test235 at all,
which is exactly why its assertion could sit wrong on main for as long as it
did. Wiring test235 into CI fixes that and is a separate change: it needs a real
hub and a socket harness, not a unit runner.

Verified in both shapes: fu…
@vansin

vansin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

关掉 —— 不是被否掉,是被 #845 完整包含了。

git 祖先关系实测,不是看文件名猜的:

$ git merge-base --is-ancestor pr843 pr844  → YES
$ git merge-base --is-ancestor pr844 pr845  → YES

也就是 #843#844#845,三个是同一条链上的三次推进。#845 刚刚合了(19 个 check 全绿),本 PR 的每一个提交都在 main 上了。

分别合三次的话,后两个会在同一批文件上反复解冲突,而且中间那次的状态谁也用不上。

#845 时发生的事,与本 PR 相关的那部分

它连红四轮,四次都是真问题,其中两次是这条链自己带的门被自己的成果绊倒:

🔴 两处是同一个形状:代码之所以正确,依赖了一个当时恰好成立的背景事实。在那个事实过期之前,它和真正正确的代码在输出上完全一样。 而让事实过期的,正是这道门自己推动的进展。

细节见 #845 的 review。

@vansin

vansin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

🔴 更正:我关掉这个 PR 的理由是错的,而且这个错误代价不小

我当时写的是「不是被否掉,是被 #845 完整包含了」。祖先关系那半是对的(#843 ⊂ #844 ⊂ #845,git merge-base --is-ancestor 实测)。

错的是「#845 刚刚合了」这个前提。

#845 的 base 是 docs/mcp-tools-symbol-anchors,也就是本 PR 的分支 —— 不是 main。所以那次合并只是把 #845 的内容合进了本 PR 的分支,而本 PR 才是这条链通往 main 的那一个

我把它关了 ⇒ 这条链上 9 个提交的工作滞留在分支上,而且没有任何 open PR 指向 main。

实测:

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

没有丢东西(全在远端分支上),已开新 PR 把整条链提到 main:#935

教训

我用 merge-base --is-ancestor 核了「谁包含谁」,却没核「哪一个 PR 的 base 是 main」 —— 前者回答的是「内容够不够」,后者回答的是「哪条路能走到 main」。在一条 stacked PR 链上,这是两个不同的问题,而我只问了第一个。

🔴 判据已补成四格: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…
vansin added a commit that referenced this pull request Aug 18, 2026
* docs: 留一份「陈旧 issue 怎么复核」的做法

直接原因:仓里 79 个 open issue,30 个超过 30 天没动,而这 30 个没有一个被任何
open PR 引用。我手工核了其中四条(#175 / #166 / #114 / #177),四条各花十几分钟,
方法没留下来 —— 剩下 26 条又得从头想一遍。

这份不是流程规范,是那四次的做法加踩到的坑:

- 「陈旧」本身不是判据。做完没关 / 做了一半 / 前提不成立 / 真没排到,这四种在
  issue 列表里长得一模一样,时间和 label 区分不了。所以不能批量关 —— 批量关会
  把「做了一半」和「走不通」一起埋掉,而那两种最值得写清楚。
- 先读正文再搜代码。#114 标题像从零开始,正文只有两句;#166 标题说一件事,
  正文实际列了四件 —— 只按标题搜会把「四件里做了三件」判成做完了。
- 对 origin/main 取证,不是对本地工作树。我有一次在老分支上 grep server/src,
  那儿 20 个 .ts 而 main 上是 106,结论建在了另一份代码上。
- 计数只是候选。#114 grep 命中 5 个文件、#177 命中 38 个,看着都像做了;实际
  #114 那 5 个全是日志与测试(数据采到就丢),#177 那 38 个没有一个是实现。
  反例:db.ts 里 grep cost 有 6 处,全是 scrypt KDF 的 cost 参数,与钱无关。
- 找「它要退役的东西还在不在」——比「新东西做了没」更快更硬。#177 要让 #176 的
  capture-pane workaround 退役,而那个 workaround 和 dev-channel flag 都还在。
- 检查正文里的「待确认前提」。#177 写着需先确认 Claude Code 的 managed-settings
  对自定义 plugin 是否可用,而这个确认至今没结论 —— 那它可能是走不通,不是没排到。
- 判不了就写明判不了,不给软结论。
- 🔴 不替 owner 关别人的 issue。复核者拿到的证据往往只覆盖标题那一句,用窄证据
  关一个宽承诺是不对的。

文末附四次复核的结论与各自的决定性证据,可作样例。文档里引用的每处行号与文件
在提交前逐条对 origin/main 复验过(11 项,全部命中)。

* docs(stale-issue-review): 补第 6 步 —— 查代码里有没有指向该 issue 的注释

又核了三条(#332 / #195 / #207),发现一个共同点值得写进方法:很多陈旧 issue
不是被遗忘的,代码里留着指针,只是没人回来更新 issue。

对 30 条跑了一遍按编号 grep:6 条被源码引用(#31/#166/#182/#191/#246/#338)。

但 6 是下界 —— #332#207 也被代码引用却抓不到,因为注释是描述式的、不带编号:
  feishu-tool-deny.ts:250   … a bubblewrap sandbox follow-up tracks the …
  cli.ts:3450               … Cross-machine artifact distribution is a P2 follow-up.

所以这一步要两样都做:按编号 grep + 读正在核的那块代码的注释。只做前者会漏掉
「代码知道、但没写号」的那些,而那些恰恰最该保留 —— 它们证明 issue 还活着。

样例表补三条,其中 #207 的结论形态变了:它开的时候是「跨机分发没人做」,现在是
「通用跨机附件通道(#222)已完成并有 e2e 套件钉着,缺的是把 grok 的 video
artifact 接进去」——而接之前要先评估一个可见性变化(/api/files/<id> 是
any-valid-token、不按 network 隔离,把 0600 的 session-private mp4 传上去会扩大
可见范围)。这类「缺口性质变了」的结论,比「还没做」有用得多。

* docs(stale-issue-review): 修表格断裂与计数

上一次提交把新增的三行贴在了表格之后、中间隔了一个空行 —— markdown 里那会
把一张表切成两张,第二张没有表头。是自查表格结构时发现的(表头 1 个但数据行
分在两处)。

同时把「四次复核」改成七次,开头的「剩下 26 条」改成 23 条。

* docs(stale-issue-review): 修审查提的五条 —— 其中三条是样例在示范本文警告的错误

#846 的审查提了五条,全部成立。三条是我的样例表自己违反了文档写的规则:

① #175 我标成「已交付」,而结论表把已交付等同于建议关闭 —— 可文档第 2 段刚
   说过证据只覆盖标题那一句。这正是「用窄证据关闭宽承诺」。改成「部分核验」。

② #166 我标成「已交付」,而文档刚警告过「四件事里做了三件会被判成做完了」——
   我列的恰好是三项证据。改成「四项中三项已交付」,并写明第四项的真实状态:
   仓库改不了外部会话的工具面板,现状是把边界写进文档并用测试钉住。

③ #114 我用错了判据。拿「completions/tasks 没有用量列」当决定性证据,但
   RFC-015 设计的是独立的 agent_token_usage 表,根本不改那两张表 —— 也就是说
   即使将来完全按 RFC 实现,我那条证据依然成立,却会把它误判成未交付。
   改成核验 RFC 点名的三个符号:agent_token_usage / usage_event_id /
   token_usage_delta 在全仓各只命中 1 个文件,就是 RFC 自己。结论不变,证据换了,
   而且更硬。
   这条最值得记:判据要对着「做完之后会长什么样」设计,不是对着「我猜它会改
   哪里」。我当时没读 RFC-015 的存储设计就选了判据,而那份 RFC 就在仓里。

④ 计数示例没记范围与 flag。审查在全仓重跑得到 26 和 123,而我写的是 5 和 38。
   已补全命令:git grep -lE '<模式>' origin/main -- 'server/src/*.ts'
   'agent-node/src/*.ts'。并记了 -lE 与 -liE 差一个文件
   (readable-attachment-prompt.ts)—— flag 也算范围。

⑤ 本仓要求所有改动跑 Docker E2E。这份文档没有可执行断言,我没有假装它有:
   新增一节说明现状(每个事实附可手工复验的命令),并写出要变成门的可行形态
   (像 test831 那样扫文档引用的 <文件>:<行号>,核它们在 origin/main 上仍指着
   声称的内容)—— 那是独立改动,不在本 PR 里。

新增一节「这份文档的第一版自己违反了它写的规则」,把①②③原样留在文档里。

* ci(test846): 给这份文档补一道行号断言门 —— 兑现审查第 5 条

#846 的审查提了本仓要求所有改动跑 Docker E2E,而这份文档是纯散文、没有可执行
断言。我当时答的是「可以像 test831 那样把它变成门,但那是独立改动」。这就是那步。

做法:文档里嵌一个 ```doc-claims 清单(路径 :: 行号 :: 该行必须包含的子串),
scripts/check-doc-claims.py 逐条打开核对,行号一漂就红。

🔴 为什么是显式清单而不是从正文正则抽:正文的引用是裸文件名(cli.ts:3450、
db.ts:393),而 cli.ts 在 agent-network/bin 和 agent-node/src 各有一个 ——
正则抽出来不知道该开哪个文件。第一版我想直接从正文抽,试到这里才发现。
代价写进了文档:清单和正文可能各写各的,门检查的是清单。

套件 tests/test846-doc-claims(alpine 按 digest 钉版,--network none):
  L0 分母:抽不到断言就红。0 条全过和压根没抽到,打印出来是同一片绿
  L1 witnessed-red:把清单里某条行号 393→394,必须红在 drifted 上;复原回绿
  L2 witnessed-red:清单清空必须红(分母承重),而不是「0 条全过」;复原回绿
  L3 清单里写 ../../etc/passwd 必须红在 path-escapes-repo 上;复原回绿
     (这条是从 check-doc-source-pins.py 那次审查学来的,不是我自己想到的)

验证(容器内):
  claims_checked=8  claims_failed=0
  MUTATION_RED drifted-line-number rc=1
  MUTATION_RED empty-manifest rc=1
  MUTATION_RED path-escapes-repo rc=1
  RESULT: PASS  退出码 0

边界写在三处(脚本头、run.sh 头、文档正文):这道门绿只说明引的行号没漂;
正文的结论对不对、引用之外的散文,它都不检查。别拿它的绿去论证那份文档的
判定是对的。

* docs(tests): 留存 test846 报告

source_commit=c00f5560a490bc39e669149030d157acb8cc6ef9;报告自带 runsh_blob,可用 git rev-parse 独立比对。
按 pre-pr-selfcheck §12,套件是新建的,报告一并落。
RESULT: PASS  exit_code=0

* ci: 把 test846 接进 qa.yml —— 补上我自己漏的那一步

我在建 test831 时写过「没接进 CI 的门只是装饰」,下一轮建 test846 时自己就没接。
是这轮做 qa.yml 协调分析、查各 PR 各改了什么时发现的 —— 不是别人提的。

新增 doc-claims job + 3 条触发路径,形状与 #843 的 doc-source-pins 一致
(同一个锚点后追加、job 附在文件末尾)。

* ci: 把 doc-claims 的插入点挪开,让它与其他四个改 qa.yml 的 PR 不再冲突

上一轮我给出的是「冲突了怎么解」。这一轮做的是让冲突不发生。

冲突源于所有人都追加在同一处:
  paths  五个 PR 都插在 - 'tests/test746-setup-bun-pin/**' 之后
  job    #843 与本 PR 都追加在文件末尾(#803 插在 qa: 之前,#798/#801 插在 59 行)

改动:
  paths  改插到 - 'server/**' 之后 —— 距离 test746 九行,超出 git 默认上下文窗口
  job    从文件末尾挪到 jobs: 之后(这个位置没有别的 PR 用)

paths 是集合、jobs 是映射,位置变化不改变行为。结构断言(每个 job 有 runs-on
与非空 steps)已跑过。

这样合并时不需要任何人去解那个「公共上下文属于双方」的冲突 —— 那个坑我在
#803 上写清楚了,但最好的处理是不让人踩到它。

* 重算 6 条 claim 的行号 —— 这道门在自己的干净树上就红了,而红得对

    FAIL: 干净树上这道门就红了:doc=docs/stale-issue-review.md claims=8
    claims_failed=6
      [drifted] agent-network/bin/cli.ts:5038 不含 «dangerously-load-development-channels»
      [drifted] agent-node/src/cli.ts:3450 不含 «video_gen»
      … 共 6 条

🔴 **一篇讲「陈旧 issue 怎么复核」的文档,自己的引用陈旧了,而且是被它自己带来的门抓到的。**
这不是尴尬,这正是这道门存在的理由 —— 它在合并之前就把作者写下与合并之间那段
时间里发生的漂移暴露了出来。

在合并 main 之后的树上逐条重算(每条都是 grep 那个 needle 拿到的真实行号):

    dangerously-load-development-channels  5038 → 5151   (`claudeArgs.push(...)` 那一行,
                                                          与正文第 74 行「仍在 push(...)」对得上)
    video_gen                              3450 → 3468   (全文件唯一命中)
    Expose CURRENT_TASK_ID                 4273 → 4291   (全文件唯一命中)
    total_cost_usd                         2363 → 2388   (首个命中)
    list_providers                         3867 → 3899   (3897 是注释行,3899 才是注册)
    addNetworkScope                        2979 → 9      (见下)

⚠️ `addNetworkScope` 这条我**没有恢复原意**:它在 `server/src/server.ts` 里有 **24 处**命中,
而正文里没有一行说明当初钉的 2979 指的是哪一处。我选了第 9 行的 `import` ——
它是稳定的、也确实是「server.ts 用了这个符号」的证据,但**它未必是作者当初想指的那处**。
作者若知道原意,请改成那一处。

跑过:
    正常   claims=8  claims_checked=8  claims_failed=0  rc=0
    变异   把一个 needle 改成 `list_providers_gone` → 红,rc=1
    还原   `cmp` 逐字节相同,rc=0

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

---------

Co-authored-by: vansin <smartflowaiteam@gmail.com>
Co-authored-by: t <t@x>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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