Skip to content

docs(api): trial 配额两个数字与真实 0.8.8 不符;一处行号 pin 已漂到无关代码 - #810

Merged
vansin merged 5 commits into
mainfrom
docs/license-trial-limits
Aug 18, 2026
Merged

docs(api): trial 配额两个数字与真实 0.8.8 不符;一处行号 pin 已漂到无关代码#810
vansin merged 5 commits into
mainfrom
docs/license-trial-limits

Conversation

@vansin

@vansin vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

docs(api): 修正 trial 档的两个配额数字,并把漂掉的行号 pin 改成函数名

1. trial limits 有两个数字是错的

文档 GET /api/license 的 trial 样例写:

"limits": { "max_agents": 5, "max_networks": 1, "max_tasks_day": 100 }

真实 0.8.8(干净容器 bunx --bun @sleep2agi/commhub-server@0.8.8,全新库,type=trial):

"limits": { "max_agents": 5, "max_networks": 3, "max_tasks_day": 500 }

max_networks 1→3、max_tasks_day 100→500。源码侧确认不是环境差异 ——
server/src/db.ts 的 licenses 表 schema 默认就是:

max_agents INTEGER DEFAULT 5
max_networks INTEGER DEFAULT 3
max_tasks_day INTEGER DEFAULT 500

而 /api/license 直接读这张表(server.ts:888)。

这条会影响用户决策:照文档看会以为试用期只能建 1 个网络、每天 100 个任务。

2. 同页一处行号 pin 已经漂了

第 396 行(en 398)说 plan quota "在 auth.ts:184-189 enforced"。
实测 auth.ts:184-189 现在是发 user token 的代码,与配额无关;
真正的关卡在 createNetwork() 里(QUOTAS 表 + max_networks_owned 判定)。

根因是钉行号 —— 行号必然漂。改成钉函数名。
(RELEASE-SOP 里 R367 对 sdk-deep-dive.md 做过同样的动作:取消行号 pin。)

原文里「free plan 默认 max_networks_owned=2」核过,是对的,保留。

范围

只改 docs-site 的 zh + en 两个文件,不动产品源码。
探测全部在容器里做,生产只做了只读 curl。

## 1. trial limits 有两个数字是错的

文档 GET /api/license 的 trial 样例写:

  "limits": { "max_agents": 5, "max_networks": 1, "max_tasks_day": 100 }

真实 0.8.8(干净容器 bunx --bun @sleep2agi/commhub-server@0.8.8,全新库,type=trial):

  "limits": { "max_agents": 5, "max_networks": 3, "max_tasks_day": 500 }

max_networks 1→3、max_tasks_day 100→500。源码侧确认不是环境差异 ——
server/src/db.ts 的 licenses 表 schema 默认就是:

  max_agents    INTEGER DEFAULT 5
  max_networks  INTEGER DEFAULT 3
  max_tasks_day INTEGER DEFAULT 500

而 /api/license 直接读这张表(server.ts:888)。

这条会影响用户决策:照文档看会以为试用期只能建 1 个网络、每天 100 个任务。

## 2. 同页一处行号 pin 已经漂了

第 396 行(en 398)说 plan quota "在 auth.ts:184-189 enforced"。
实测 auth.ts:184-189 现在是**发 user token** 的代码,与配额无关;
真正的关卡在 createNetwork() 里(QUOTAS 表 + max_networks_owned 判定)。

根因是钉行号 —— 行号必然漂。改成钉函数名。
(RELEASE-SOP 里 R367 对 sdk-deep-dive.md 做过同样的动作:取消行号 pin。)

原文里「free plan 默认 max_networks_owned=2」核过,是对的,保留。

## 范围

只改 docs-site 的 zh + en 两个文件,不动产品源码。
探测全部在容器里做,生产只做了只读 curl。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

接受这条 MINOR —— 是我措辞过宽

审查意见指出中文的「当前 quota 仍在 createNetwork() 里 enforced」过宽,会把两层混成一层。这条成立,我认。

我的本意只覆盖建网门,但句子里 quota 是单数、无限定,读者完全可能理解成「trial 的 max_agents / max_networks / max_tasks_day 三个字段都在这个函数里 enforce」—— 而我没有验过后两者。en 那侧因为保留了 free max_networks_owned=2 反而不易误读,所以这是 zh/en 不对称,不是两边都错。

拟采纳的替换(来自审查意见,我未改动):

建网配额在 auth.tscreateNetwork() 按 plan 的 max_networks_owned 校验(free=2,admin 豁免);trial limits 默认见 licenses 的 5/3/500,与行号 184–189 发 token 无关。

现在不改分支 —— 本 PR 在冻结中(我此前反复改动导致复审无法收口,已停手)。这条记在这里,等复审收口后随其它意见一并落。

另外确认一条 NOT COVERED 的定性:作者(我)那句 0.8.8 干净容器动态探测,审查方判定「仅作者声明、无独立日志、不得当独立复验证据」是对的。这里数字的正确性应当只由 base 的 db.ts:695-700 默认值支撑,不该由我那次探测背书 —— 两者结论一致纯属应该如此,不构成互证。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

自审(本 PR 至今零行内审查):把 5/3/500 这条链路从头验到尾,成立;顺带关掉一条 NOT COVERED

本 PR 是三条从没被自动审查器碰过的最后一条(#823 已自审出一条 fail-open 并修,#800 自审为无发现)。

文档 PR 的生死线就是数字对不对,所以直接走源码。

完整链路(三跳,全部命中)

① 建表默认值server/src/db.ts:698-700

max_agents    INTEGER DEFAULT 5,
max_networks  INTEGER DEFAULT 3,
max_tasks_day INTEGER DEFAULT 500,

② trial 插入不指定这三列server/src/db.ts:713

INSERT INTO licenses (id, license_key, type, expires_at)
VALUES (?1, ?2, 'trial', datetime('now', '+14 days'))

只有四列,三个配额列缺席 → 取①的默认值

(对照:server/src/server.ts:916pro 插入是显式给值 50, 10, 10000 —— 两条路径分开,不会互相污染。)

③ REST 原样回传server/src/server.ts:888:899

"SELECT type, expires_at, max_agents, max_networks, max_tasks_day FROM licenses ORDER BY created_at LIMIT 1"

limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },

逐字段透传,没有任何变换、覆盖或兜底。

结论

文档里的 "limits": { "max_agents": 5, "max_networks": 3, "max_tasks_day": 500 } ,且是端到端可追的,不依赖任何运行时观察。

这关掉了一条 NOT COVERED

此前的窄审留了这条:

NOT COVERED:trial JSON 与 REST 响应路径、作者 0.8.8 动态探测(未复验)

现在 REST 响应路径这一支已验

值得说清楚的是用什么验的:不是重跑我自己那次 0.8.8 动态探测 —— 审查方判定「仅作者声明、无原始日志,不得当独立复验证据」是对的,而我再跑一次仍然是同一作者侧,不会变得更独立。走源码链路才是能独立复现的那条路:任何人拿这三个行号就能自己走一遍。

仍未覆盖(不夸大)

  • ORDER BY created_at LIMIT 1 取的是最早那行 license。文中数字描述的是全新安装的状态;若一台机器上后来激活过 pro,首行仍是 trial,但这属于超出本文档范围的情形;
  • createNetwork() 是否 enforce max_agents / max_tasks_day —— 仍未验。这也正是此前那条 MINOR(中文措辞过宽)指向的同一处,我已接受并记录,待收口后一并落。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

最后一条 NOT COVERED 验完了,而结论比原来那条 MINOR 更重

此前留的是:

NOT COVERED:createNetwork 是否 enforce max_agents / max_tasks_day

验完:不但没 enforce,这三个数在整个仓里没有任何人 enforce。

createNetwork() 只认一个字段

// server/src/auth.ts:313-317
const QUOTAS: Record<string, { max_networks_owned: number; max_networks_joined: number }> = {
  free:  { max_networks_owned: 2,  max_networks_joined: 3 },
  pro:   { max_networks_owned: 10, max_networks_joined: 20 },
  admin: { max_networks_owned: Infinity, max_networks_joined: Infinity },

QUOTAS 只有两个字段,max_agents / max_tasks_day 根本不在这个结构里createNetwork()(:320)只比 max_networks_owned(:325-327)。

② 三个 license 字段全仓只被「读出来给人看」

引用点 做什么
server/src/server.ts:899 塞进 /api/license 响应
agent-network/bin/cli.ts:12819 打印:Soft limits: agents=…, networks=…, tasks/day=…

除此之外没有比较、没有拒绝、没有门。产品自己的 CLI 就把它们叫 Soft limits

(扫描范围:origin/main 全仓 *.ts / *.tsx,排除 *.test.*auth.ts 里那几处 max_networks另一个东西 —— plan QUOTAS 的 max_networks_owned,不是 licenses 表的列。)

所以原来那条 MINOR 说轻了

原判是「中文措辞过宽,易把 license trial 的 5/3/500 与 free plan 建网门 max_networks_owned=2 混成一层」。方向对,但实际情况更尖:

这两组数不只是"层不同",而是一组生效、一组根本不生效,而且:

  • license 说 max_networks: 3,plan 门实际按 max_networks_owned: 2 拒绝 —— 两个数不一样,读者按 3 去建第 3 个网络会被拒;
  • 文档把 "limits": {5, 3, 500} 摆在讲已生效配额门的段落旁边,读者几乎必然把它读成"这就是被 enforce 的限额"。

这跟文档自己已经点名的 max_members(dormant)是同一类,只是这三个还没被点名。

建议改法(比之前那版更准)

/api/licenselimits(trial 默认 max_agents=5 / max_networks=3 / max_tasks_day=500)是软限额:服务端只存储和返回,不做任何拦截(CLI 里也直接标作 Soft limits)。真正会拒绝请求的是 plan 配额 —— auth.tscreateNetwork()max_networks_owned 校验(free=2,admin 豁免)。注意两者的 networks 数字不同(3 vs 2),以实际生效的 plan 配额为准。

冻结中未改分支,与此前接受的那条 MINOR 一并待收口后落。


至此本 PR 的 NOT COVERED 只剩一条:ORDER BY created_at LIMIT 1 取最早那行 license,文中数字描述的是全新安装状态。这条我认为是文档范围的自然边界,不打算再收窄。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

同一页上还有 4 个行号 pin 也漂了 —— 本 PR 修了一个,没扫同类

本 PR 的立意之一就是「原文钉的 auth.ts:184-189 已漂到发 token 的代码上,所以改钉函数名」。这个判断对。但同一个文件里还有 5 处 tools.ts:NNN 形式的 pin,逐个核完:全部漂了,一个都没命中。

文档位置 pin 声称那里有 origin/main 上实际是
rest.md:52 tools.ts:521 license_expired 仍 emit // discovery (#337 extracts this field). "host_supervisor" = (daemon role 的 zod 注释)
rest.md:1542 tools.ts:286 chained_reply network_id: z.string().max(200).optional(),
rest.md:1542 tools.ts:646 chained_reply cpu_pct: processCpuPct,
rest.md:1545 tools.ts:571 new_message/broadcast 实际 payload }],
rest.md:1545 tools.ts:911 同上 message_id: z.string().min(1).max(200),

真正的 license_expiredtools.ts:1265(if (license.expires_at < now) { … error: "license_expired" })。

其中一处特别值得指出来

rest.md:1545 的原句是:

旧 doc 在 new_message 上写过 message 字段、broadcast 上写过 {content, from} —— 都不对。verify [tools.ts:571 + 911] 实际 payload 以上表为准。

这句话的全部作用就是「别信旧文档,去这两个坐标自己核」—— 而这两个坐标核不了任何东西。 一条指向失效的验证指引,比没有指引更糟:它让人以为已经可以核实。

建议

按本 PR 已经确立的做法处理,不是更新数字,是改钉符号:

  • tools.ts:521 → 钉 license_expired 这个错误码本身(它是唯一的,grep -n 'license_expired' server/src/tools.ts 一步到位);
  • tools.ts:286/646 → 钉 chained_reply;
  • tools.ts:571 + 911 → 钉 new_message / broadcast 的构造点符号。

理由和本 PR 对 auth.ts 的处理完全一样:行号每次重构都会漂,符号不会。

一条对我自己的批评

本 PR 发现了「行号 pin 会漂」这个问题,却只修了触发它的那一处。修了一个实例、没扫同一类,而同页另外四处一直是坏的。

这跟我这两天反复在别人和自己代码里查的是同一件事:发现一个形态之后,必须查它是不是模式。我在 #815 的清单里写过这条,自己在这条 PR 上没做。

冻结中未改分支,与前两条(MINOR 措辞、软限额)一并待收口后落。

@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

本 PR 至今零审查记录(自动审查器只在开 PR / 草稿转就绪 / 显式 @ 时触发,push 不触发)。这是第二次请求。

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 147a9f0ff9

ℹ️ 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".

这三条我在窄审后都写过"我认",然后一直停在"冻结中,待收口后落"。
而"收口"这个条件实际上从没到来 —— 我自己设的延后规则,变成了阻塞
自己修复的东西。本 PR 至今零审查记录,根本不存在会被打断的在途复审。

① 措辞过宽 + 软限额定性(两条合一处改):
   原文"当前 quota 仍在 createNetwork() 里 enforced"会把两层混成一层。
   实际更尖:/api/license 的 limits(trial 5/3/500)全仓无人 enforce ——
   唯一去处是塞进响应、以及 CLI 打印成 "Soft limits";真正会拒绝建网的
   是 plan 配额 max_networks_owned(free=2)。两者 networks 数字还不同
   (3 vs 2),读者按 3 去建第 3 个网络会被拒。中英两侧都改。

② 行号 pin:该页 tools.ts#L521 / #L286 / #L571 全部已漂(#L521 指到
   daemon role 的 zod 注释,真正的 license_expired 在别处)。按 #831 对
   参考页的结论,去掉误导的行号锚点 —— 链接指向文件是准的,而符号名
   (license_expired / chained_reply / payload)本来就写在句子里。
   中英各 3 处,共 6 处,改后残留 0。
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

三条一直停在「已接受」的意见,现在落地了(147a9f0f → a89372f2)

先说为什么拖到现在 —— 这是我自己造成的

三条我在窄审后都写过「我认」,然后统一挂在「冻结中,待收口后落」。而**「收口」这个条件实际上从没到来**:本 PR 至今零审查记录,根本不存在会被打断的在途复审。

我设的延后规则,变成了阻塞我自己修复的东西。 而且它无声 —— 每次汇报里写「待收口后落」都像是在管理节奏,实际是九条同类意见(本 PR 3 条 + #803 3 条 + #801 3 条)在原地不动。

①② 措辞过宽 + 软限额定性(合并成一处改)

原文:「当前 quota 仍在 createNetwork() 里 enforced」。窄审说它把两层混成一层 —— 方向对,但实际更尖:

/api/license 的 limits(trial 5/3/500)  →  全仓无人 enforce
  server.ts   塞进响应
  cli.ts      打印成 "Soft limits"
  除此之外没有比较、没有拒绝、没有门

真正会拒绝建网的  →  plan 配额 max_networks_owned(free=2)

两者的 networks 数字还不同(3 vs 2) —— 读者按 3 去建第 3 个网络会被拒。中英两侧都补上了这个区分。

③ 行号 pin 全部已漂

该页的 tools.ts#L521 / #L286 / #L571 全部指不到声称的东西(#L521 指到 daemon role 的 zod 注释,真正的 license_expired 在别处)。

#831 对参考页的结论(钉符号不钉行号),去掉误导的行号锚点 —— 链接指向文件是准的,而符号名(license_expired / chained_reply / payload)本来就写在句子里。中英各 3 处,共 6 处,改后残留 0


一句

我这两天反复在写「接受一条意见和实施它是两件事」。这条 PR 是那句话的完整代价:三条正确的意见,在「已接受」状态里躺了整整一天。

剩下六条同类(#803 的 Bun 未钉死 / 安全套件顺序 / --rm 丢报告,#801 的 ②⑥⑤)我会按同样方式逐条落,不再等一个不会到来的「收口」。

vansin pushed a commit that referenced this pull request Aug 13, 2026
我把这条挂了两轮,理由都是"#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
@vansin

vansin commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

提醒:这个 PR 与 #843(文档行号 pin 的下限门)有一处硬耦合

我在 #843 里加了 docs/doc-source-pins-baseline.txt,记录已知失效的行号 pin,语义是只许缩小:某条对应的引用从文档里消失了,门会要求把它从基线里删掉,不删就红。

这个 PR 恰好会让基线里的条目消失:

  • 删掉了引用 tools.ts#L521 / tools.ts#L571 / auth.ts#L184 的那几行

所以合并顺序上有一个动作项:

我在 #845 的树上模拟跑过,完整输出贴在 #843 的这条评论

不做的话,表现是「一个只改文档的 PR 把 CI 弄红了」,而红的原因看起来跟它毫无关系 —— 这条提醒的价值就在这里。

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

补充上一条提醒:现在不用手工数该删哪几条了 —— 合并后跑 python3 scripts/check-doc-source-pins.py . --write-baseline 即可,它只许缩小(出现新失效会拒绝写)。实现与断言见 #843 的这条评论

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 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…
t and others added 3 commits August 18, 2026 10:05
`doc source-pin floor (Docker)` 在本 PR 上红了两处,**两处都是这道门在正确工作**。

## ① 基线里 3 条已经不指向文档了

    FAIL: 3 个基线条目对应的引用已经不在文档里了
      server/src/auth.ts#L184
      server/src/tools.ts#L521
      server/src/tools.ts#L571

本 PR 把这些引用的 `#L` 锚点去掉了(链接保留),于是它们不再是「文档里的失效行号 pin」。
按这道门自己的说明「修一条就把它从基线里删掉」处理。

🔴 **没有用 `--write-baseline`**(整份重生成会把新出现的坏 pin 一起洗白)。
只删这 3 行,并断言 `removed=3 added=0`。之后:`失效 pin 1 个 / 基线 1 条`,rc=0。

## ② 分母预期 15 → 11、35 → 28

这条断言的设计意图是「变了要人确认」。确认了,而且能逐条数:

    server/src/tools.ts#L521   ×2(中/英)
    server/src/tools.ts#L571   ×2
    server/src/tools.ts#L286   ×2
    server/src/auth.ts#L184    ×1
    ─────────────────────────────
    唯一 4 个,出现 7 处

15 − 4 = 11,35 − 7 = 28,`files` 仍 106。

🔴 关键佐证:**diff 的 `+` 侧一个 `#L` 都没有** —— 加回来的锚点数是 0。
所以这是**净减少**,不是「换了个位置」。如果 `+` 侧也有,那 15→11 就可能是
「删了 4 个、加了 4 个但扫描漏了」,数字一样、含义相反。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`doc source-pin floor (Docker)` 在本 PR 上再次红,而且**红得没有任何信息**:
日志最后一行是 L7③ 造场景的输出,之后直接 `exit 1`,**stderr 一个字都没有**。

本地 `set -x` 追到了确切位置:

    +++ grep -cv '^\s*#\|^\s*$' docs/doc-source-pins-baseline.txt
    ++ BL_AFTER=0
    (到此为止)

下一行是 `[[ "$BL_AFTER" -lt "$BL_BEFORE" ]] || fail …`,而 `0 -lt 1` 为真,
本该通过。真正的原因在**上一行的赋值本身**:

🔴 **`grep -c` 在计数为 0 时退出码是 1**;而 L7 这一段里 `set -e` 是开着的
(第 ② 步末尾 `set -e` 恢复过),于是 `VAR=$(grep -c …)` 直接**静默终止整个脚本**。

**而计数为 0 恰恰是这道门「清干净了」才会出现的状态** —— 本 PR 把基线从 4 条清到
1 条,L7③ 再把最后 1 条也清掉,`BL_AFTER` 就成了 0。

**今晚第三次同一形状:门因为自己起了作用而坏掉。**
(前两次:L5① 写死 `pins_on_immutable_ref == 1` 只在「一条钉 SHA 的引用都没有」时成立;
L7 造场景借用真实提交 `22ed1886`,而 #834 让它变成了真实数据。)

修法:两处 `grep -c` 都加 `|| true`(`BL_BEFORE` 也一并加 —— 它今天不会是 0,
但明天基线清空之后就会是)。

本地重放整段 L7:

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

跑完 `git status --porcelain` 干净。

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

vansin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

合了。四条说法我逐个从源码验了,全部成立,而且其中一条比 PR 说得还准。

① 三个数字

$ git show origin/main:server/src/db.ts   (licenses 表)
    max_agents    INTEGER DEFAULT 5
    max_networks  INTEGER DEFAULT 3
    max_tasks_day INTEGER DEFAULT 500

文档原样例写的是 {max_agents:5, max_networks:1, max_tasks_day:100} —— 后两个都错。改成 5/3/500 与建表默认值逐字一致。

auth.ts:184-189 确实漂了,而且漂到了很误导的位置

$ git show origin/main:server/src/auth.ts | sed -n '182,190p'
  // Issue a NEW user token — do NOT rotate/invalidate existing ones. Each
  // login (cli, dashboard, second machine) gets its own row so they don't
  // kick each other out of session.
  const userToken = generateUserToken();

那是发 token 的代码,和网络配额毫无关系。 而真正的配额在:

auth.ts:314  const QUOTAS = { free: { max_networks_owned: 2, ... }, ... }
auth.ts:326  if ((ownedCount?.cnt || 0) >= quota.max_networks_owned) {

🔴 这类漂移比「指向空行」更坏:它指向一段看起来很正经、也确实在讲鉴权的代码 —— 读的人容易以为自己找到了,然后基于错的地方推理。改钉函数名是对的。

③ 「软限额 vs 真配额」这一段是本 PR 最有价值的部分

它指出 /api/licenselimits(max_networks=3)和真正拦截建网的 plan 配额(max_networks_owned=2)是两回事,而且数字不同

我验了两边:db.ts:699 默认 3,auth.ts:315 free 是 2。确实是 3 vs 2。

也验了「软」这个说法:CLI 自己就把它印成 Soft limits:

cli.ts:13117  console.log(`    Soft limits: agents=${lim.max_agents}, networks=${lim.max_networks}, …`)

一个用户看到 max_networks: 3、建到第 3 个被拒,会以为是 bug。 两个数字都真实存在、都在 API 里露出、只有一个会拦人 —— 而在这个 PR 之前,文档只说了会拦的那个的错误行号

④ 其余几处行号 pin 去掉 #L 锚点

tools.ts:521 / 286 / 571 / 911 保留在行内文本里(说明当时的位置),但链接不再带 #L —— 与今晚 #857 / #851 / #834 定下的做法一致:指向当前代码的引用不钉行号,历史陈述钉提交。

@vansin

vansin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

合并时红了三轮,三次都是这道门在正确工作

① 基线残留 3 条 —— 本 PR 把它们的 #L 去掉了,于是不再是「文档里的失效行号 pin」。只删这 3 行,断言 removed=3 added=0;没有用 --write-baseline(整份重生成会把新出现的坏 pin 一起洗白)。

② 分母预期 15→11 / 35→28 —— 逐条数得出来:tools.ts#L521/#L571/#L286 各 ×2、auth.ts#L184 ×1 = 唯一 4 个 / 出现 7 处。15−4=11,35−7=28,files 仍 106。
🔴 关键佐证:diff 的 + 侧一个 #L 都没有 ⇒ 净减少,不是「挪了个位置」。如果 + 侧也有,数字一样但含义相反。

③ 🔴 L7 静默死 —— 日志最后一行是造场景的输出,之后直接 exit 1,stderr 一个字都没有。

本地 set -x 追到确切位置:

+++ grep -cv '^\s*#\|^\s*$' docs/doc-source-pins-baseline.txt
++ BL_AFTER=0
(到此为止)

下一行是 [[ 0 -lt 1 ]] || fail …,本该通过。真正原因在赋值本身:

grep -c 在计数为 0 时退出码是 1,而那一段 set -e 是开着的 ⇒ VAR=$(grep -c …) 静默终止整个脚本

而计数为 0 恰恰是这道门「清干净了」才会出现的状态 —— 本 PR 把基线从 4 条清到 1 条,L7③ 再清掉最后 1 条,BL_AFTER 就是 0。

🔴 今晚第三次同一形状:门因为自己起了作用而坏掉。 前两次是 L5①(写死 == 1 只在「一条钉 SHA 的引用都没有」时成立)和 L7 造场景借用真实提交 22ed1886(而 #834 让它变成了真实数据)。

修法:两处 grep -c|| true。本地重放整段 L7:③ 引用消失时删对了(1 → 0)…复原后回绿 ✓ RESULT: PASS rc=0,跑完 git status --porcelain 干净。

CI 17 个 check 全绿,合。

@vansin
vansin merged commit 103ee35 into main Aug 18, 2026
17 checks passed
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