Files
infra-docker-configs/servers/tools-pve/asr-pipeline/docs/agent-decoupling-implementation.md
T
Codex 3c70861463
Build image / build (push) Canceled after 0s
Build ASR Pipeline / image (push) Canceled after 0s
feat(asr-pipeline): merge PR #1 webauth/CSRF/locking hardening and web_password fallback
2026-11-02 06:37:33 -08:00

24 KiB
Raw Blame History

ASR 管线 Agent 解耦 — 重构实施文档

版本:v1(2026-09-18) 上游 review:docs/agent-decoupling-review.md 执行者:子 agent(Claude Code / Codex / 其他具备文件与 shell 能力的 agent) 授权边界:本文档 Stage 1–4 为已授权范围。超出范围的改动(尤其是 §8 禁止项)必须先回报 Kai 并取得确认。


0. 给执行 agent 的前置须知(先读完本节再动手)

0.1 这套系统正在生产运行,且无 git 保护

/home/claw/claude-workspace 不是 git 仓库(已验证:git rev-parse 返回 fatal: not a git repository)。这意味着:

  • 没有 git checkout 可以回滚。任何改动前必须手动备份。
  • 备份约定沿用本项目既有风格(目录中已有 7 个 *.bak.* 文件):
    cp -a <file> <file>.bak.$(date +%Y%m%d-%H%M%S)
    
  • 磁盘余量充足(43G available),备份成本可忽略。

同时注意:~/obsidian-vault 是独立 git 仓库,管线会向它 commit+push。本次重构不应触碰 vault 内容,只改 OBSIDIAN/ 下的基础设施代码与 systemd unit。

0.2 当前 baseline(改动前请先自行复现,作为对照)

cd /home/claw/claude-workspace/OBSIDIAN/asr-pipeline
PYTHONPATH=. .venv/bin/python -m unittest discover -s tests -v
# 期望:Ran 28 tests ... OK

注意:venv 里没有 pytest,必须用 unittest。这 28 个测试全部离线(test_core.py 在 import 前就把 ASR_STATE_DIR/ASR_VAULT 重定向到 tempdir),可以安全反复跑。

任何 stage 结束时这 28 个测试必须仍然全绿,且新增功能需补充测试(见各 stage 的验收标准)。

0.3 当前运行状态(改动前实测,供对照)

单元 状态 说明
obsidian-recording-watch.service active 音频监听 + 转录 + 桥接
obsidian-asr-refine.path active 监听 .asr-inbox/
asr-review-web.service active Web 审核/上传 UI,.145:8765
obsidian-asr-refine.service inactive 旧 claude -p 链路,保留供 rollback

127.0.0.1:8644 已确认无监听(ss -tlnp | grep 8644 为空)。.48 ICMP 可达,但 8644 等端口从 .145 探测为 closed/filtered,且 SSH 到 .48 被拒(publickey)——执行 agent 无法自行登录 .48 查看其配置。这是本次重构的关键约束,见 §1.3。

0.4 上游 review 的一处补充(执行时必须一并修掉)

在准备本实施文档时复核代码,发现 review 未记录的一个真实 bug,与本次解耦同属一处代码,应合并修复:

asrpipe/vault.py:135 把第一个位置参数写死成字符串 "草稿已生成,待确认":

r = subprocess.run(["python3", str(n), "草稿已生成,待确认", title, body], ...)

而 meeting-status-notify.py 的 argv 契约是 (status, filename, detail)。于是所有 5 处 vault.notify() 调用的语义都被错位丢弃:

调用点 调用方本意(第 1 参数) 实际发出的 status
revise.py:273 "ASR 正式记录已发布" 草稿已生成,待确认 ❌
cli.py:109 "ASR Web 审核启动失败" 草稿已生成,待确认 ❌
cli.py:160 "ASR Web 审核启动失败" 草稿已生成,待确认 ❌
review_web.py:49 "ASR 快速确认" 草稿已生成,待确认 ❌
review_web.py:271 "ASR 快速确认" 草稿已生成,待确认 ❌

即:"发布成功"和"启动失败"在 Telegram 上都显示为"草稿已生成,待确认"。调用方传的 title 被挤到 filename 位、body 被挤到 detail 位。这个 bug 目前被 8644 不通掩盖着(消息根本没发出去),一旦按本文档恢复通知,它会立刻变成可见的错误告警。Stage 2 必须连带修掉。


1. 重构目标与设计约束

1.1 目标

把"管线向人(Kai)发通知"这件事,从**"调用 Hermes 网关某个 IP:port 上的私有 webhook",改造成"调用一个稳定的本地通知接口,Hermes 只是它的一种后端"**。

判据(重构完成后应当成立):

  1. Hermes 再次换主机/换端口/换协议时,只改一个配置文件,不改任何一行 Python、不改任何 systemd unit。
  2. 通知目标不可用时,管线行为与今天一致:记日志、继续跑、不阻塞、不丢数据。
  3. 通知发送逻辑(HMAC 签名 + POST)在代码库里只有一份实现。
  4. 管线不再读取 ~/.hermes/ 下的任何文件。
  5. 上述 4 条对两个调用方(meeting-watch.py 和 asrpipe)同时成立。

1.2 设计风格必须与现有代码一致

这个项目有很清晰的既有风格,重构必须沿用而不是另立一套:

  • 配置集中在 asrpipe/config.py 的 Config dataclass,每项都是 _env("ASR_XXX", default) 形式,env 可覆盖(见该文件已有的 use_termbank / use_hindsight / gate_enabled 等)。
  • 失败降级优先于抛异常:通知失败只 log(..., level="WARN"),绝不中断管线(vault.py:139 已是此风格,meeting-status-notify.py 的 docstring 也明确写了 "Silent no-op (exit 0) ... never block the pipeline")。
  • 注释说明"为什么"而非"是什么",且保留实测数据(参考 config.py 里 clean_model 的选型注释、llm.py 的 temperature 注释)。中文注释与英文注释在本项目中混用,按所改文件的既有语言为准。
  • 凭证走 600 权限的独立 env 文件,不进代码、不进 git(bin/.asr-refine.env 是既有先例)。

1.3 关键约束:.48 的新地址当前未知

执行 agent 无法 SSH 到 .48,因此无法自行确定 Hermes webhook 的新地址。

这不阻塞重构——恰恰相反,这正是要做接口层的理由。处理方式:

  • Stage 1–3 的全部工作不需要知道 .48 的地址,只需要把地址变成"一个配置文件里的一个字段"。
  • 配置文件的初始值填 .145 的旧地址(即当前的 http://127.0.0.1:8644/...),并在文件里注释清楚"此地址在 2026-09-15 Hermes 迁移至 .48 后已失效,待 Kai 提供新地址后更新此处"。
  • 重构完成后,由 Kai 填入 .48 的真实地址,或按 Stage 4 的 direct 后端绕过 Hermes。
  • 禁止猜测端口后写进配置当作"已修复"。宁可保留失效地址 + 明确注释,也不要写一个看似能跑实际错误的值。

2. 实施总览

Stage 内容 依赖 是否需要 Kai 输入
1 建立通知配置层 + 单一发送实现 — 否
2 两个调用方收敛到该实现 + 修 argv 错位 bug 1 否
3 清理 systemd 硬编码 + 文档同步 2 否
4 (可选)direct 后端,彻底不依赖 Hermes 3 是(需 token 授权决定)

Stage 1–3 应一镜到底连续执行,中间不要停下来问;每个 stage 结束跑一次测试即可。Stage 4 涉及凭证使用决策,必须停下来问 Kai。


3. Stage 1 — 建立通知配置层与单一发送实现

3.1 新增配置文件 OBSIDIAN/bin/notify-target.json

这是本次重构的唯一真相源。结构建议(执行时可按需微调字段名,但必须保持"后端可切换 + 目标可配置"两个语义):

{
  "backend": "webhook",
  "_comment": "backend: webhook | none | direct(见 Stage 4)。切换后端不需要改代码。",

  "webhook": {
    "url": "http://127.0.0.1:8644/webhooks/meeting-recording-status",
    "_url_comment": "⚠️ 此地址在 2026-09-15 Hermes 由 .145 迁移到 .48 后已失效。待 Kai 提供 .48 上的新地址后更新此字段。保留失效值而非猜测端口,是为了让故障显式可见。",
    "secret": "",
    "_secret_comment": "HMAC-SHA256 共享密钥。留空则不签名(对端若校验会拒绝)。历史上此值取自 ~/.hermes/webhook_subscriptions.json 的 meeting-recording-status.secret;现在改为本项目自持,不再读 Hermes 私有配置。",
    "timeout": 10
  }
}

权限必须是 600(含密钥):

chmod 600 /home/claw/claude-workspace/OBSIDIAN/bin/notify-target.json

迁移密钥:从 ~/.hermes/webhook_subscriptions.json 的 meeting-recording-status.secret 复制一次到上面的 secret 字段。这是一次性拷贝,之后管线不再读 Hermes 的文件。(该文件当前结构已确认为 {route: {secret, deliver, deliver_extra:{chat_id}, ...}}。)

3.2 改造 OBSIDIAN/bin/meeting-status-notify.py 为唯一实现

保持它现有的双重身份——既是 CLI(供 shell 脚本调用),也要能被 Python import(供 asrpipe 直接调用,省掉一次 subprocess)。要求:

  • 读 notify-target.json 决定后端与目标;删除对 ~/.hermes/webhook_subscriptions.json 的读取(SUBSCRIPTIONS 常量与 secret() 函数)。
  • 保留 env 覆盖能力,优先级 env > notify-target.json > 内置兜底。env 变量名沿用既有前缀风格(如 MEETING_STATUS_WEBHOOK_URL 已在 meeting-watch.py 中使用,继续复用同名,不要另起新名)。
  • backend: "none" 时直接记日志返回,不发网络请求——这让"临时静音"不需要改代码,也让测试可以完全离线。
  • 暴露一个可 import 的函数,签名保持与 CLI 契约一致:
    def send(status: str, filename: str = "", detail: str = "") -> bool
    
    返回是否送达成功;内部吞掉所有异常,绝不向调用方抛(维持"never block the pipeline")。
  • __main__ 分支保持 argv = (status, filename, detail) 契约不变——bin/obsidian-asr-refine.sh:45 仍以此方式调用它(旧 rollback 链路),不要破坏该契约。

3.3 验收标准

cd /home/claw/claude-workspace/OBSIDIAN

# 1. backend=none 时静默成功、不发网络请求
python3 - <<'EOF'
import json, pathlib
p = pathlib.Path("bin/notify-target.json")
d = json.loads(p.read_text()); d["backend"] = "none"
p.write_text(json.dumps(d, ensure_ascii=False, indent=2))
EOF
python3 bin/meeting-status-notify.py "测试" "f.mp3" "detail"   # 期望:退出码 0,日志说明 backend=none

# 2. backend=webhook 且目标不通时,仍然退出码 0(不阻塞)
#    (把 backend 改回 webhook 后重跑,期望打印类似 "skipped: Connection refused",退出码仍为 0)
echo "exit=$?"

# 3. 确认不再引用 Hermes 私有配置
grep -rn "webhook_subscriptions\|\.hermes" bin/meeting-status-notify.py
# 期望:无输出(注释里提及历史来源可以,但不能有真实读取)

# 4. 旧链路 CLI 契约未破坏
bash -n bin/obsidian-asr-refine.sh && echo "refine.sh syntax ok"

4. Stage 2 — 两个调用方收敛 + 修 argv 错位

4.1 asrpipe 侧

asrpipe/config.py:

  • 新增 notify_backend 字段,风格与既有项一致:notify_backend: str = _env("ASR_NOTIFY_BACKEND", "")(空 = 交由 notify-target.json 决定;显式设置则覆盖)。
  • 保留现有 notify: bool(ASR_NOTIFY=0 的总开关)——这是既有行为,doctor 和文档都依赖它,不要删。
  • notifier property 保留(cli.py:207 的 doctor 检查在用),但其含义从"要 subprocess 调用的脚本"变为"通知实现模块的位置"。若改动了 doctor 的检查语义,需同步更新 cli.py 的 chk("notifier", ...)。

asrpipe/vault.py 的 notify():

  • 修掉 argv 错位 bug:不再把 "草稿已生成,待确认" 写死为 status。调用方传入的第一个参数(语义上就是 status/事件名)必须原样传递。
  • 建议直接 import meeting-status-notify.py 暴露的 send(),取代 subprocess.run(["python3", ...])——省一次进程启动,也让类型契约显式。若因文件名带连字符不便 import,可用 importlib.util.spec_from_file_location(路径来自 CFG.notifier),或把共享逻辑提取为 OBSIDIAN/bin/notify_lib.py 再由两边 import。三种都可接受,选一种并在注释里说明理由。
  • 保留 redact_log 参数行为(review_web.py:271 传了 redact_log=True,因为 body 含一次性 capability URL,绝不能进日志)。这是安全相关行为,不要动。
  • 保留"notifier 不存在时只记日志"的降级分支。

5 处调用点(revise.py:273、cli.py:109、cli.py:160、review_web.py:49、review_web.py:271):修好 notify() 后语义即自动正确,调用点本身不需要改。改完后请逐一确认它们发出的 status 与调用方本意一致(对照 §0.4 的表格)。

4.2 meeting-watch.py 侧

  • 删除自建的 HMAC 签名与 POST 逻辑:webhook_secret()(L177)、post_webhook()(L187)、post_status()(L205)。
  • post_status 改为薄封装,转调 Stage 1 的单一实现;3 个调用点(L272 "开始转录"、L286 "转录完成,正在生成粗整理"、L304 "处理失败")的调用形式保持不变。
  • 删除 WEBHOOK_URL / WEBHOOK_ROUTE / WEBHOOK_SECRET / STATUS_WEBHOOK_URL / STATUS_WEBHOOK_ROUTE / SUBSCRIPTIONS 这些常量(L32–L37)。

关于 meeting-recording 路由(携带正文那条):post_webhook() 在当前代码里只被 post_status() 调用过,即 meeting-recording 路由从未真正被 POST(已 grep 确认)。它是旧 Hermes agent 精修架构的残留,CLAUDE.md 明确记载该架构"已取代"。删除这条死路径,不要为它保留接口。(~/.hermes/webhook_subscriptions.json 里那条带 prompt/skills: [memo-inbox] 的 meeting-recording 订阅同属旧架构遗留,但那是 Hermes 侧的配置,不在本次授权改动范围,只在 §7 报告里提一句即可。)

另外注意 meeting-recording-status 订阅带 deliver_only: True——它只做消息投递、不唤起 agent。这印证了"状态通知本质上只是一条消息,不需要 agent 参与",也是 Stage 4 direct 后端在语义上完全等价的依据。

4.3 补充测试(必须新增)

在 asr-pipeline/tests/test_core.py 中新增(沿用该文件既有的 unittest + mock 风格,注意它已在 import 前把 state/vault 重定向到 tempdir):

  1. status 透传测试(覆盖 §0.4 的 bug):mock 掉实际发送,断言 vault.notify("ASR 正式记录已发布", "body") 传出的 status 是 "ASR 正式记录已发布",不是 "草稿已生成,待确认"。这是防止该 bug 回归的关键测试。
  2. 降级测试:目标不可达(mock 抛 URLError)时 vault.notify() 不抛异常、正常返回。
  3. backend=none 测试:不产生任何网络调用。
  4. redact_log 测试:redact_log=True 时日志中不含 body 原文(防止 capability URL 泄漏进日志)。

4.4 验收标准

cd /home/claw/claude-workspace/OBSIDIAN/asr-pipeline
PYTHONPATH=. .venv/bin/python -m unittest discover -s tests -v
# 期望:28 + 新增用例,全绿

# 单一实现确认:HMAC 签名逻辑只应出现在一个文件里
cd /home/claw/claude-workspace/OBSIDIAN
grep -rn "hmac.new" --include="*.py" . | grep -v -E "__pycache__|\.venv|\.bak\."
# 期望:只有 1 处(notify 实现所在文件)

# 管线不再读 Hermes 私有配置
grep -rn "webhook_subscriptions" --include="*.py" --include="*.sh" . | grep -v -E "__pycache__|\.venv|\.bak\."
# 期望:无真实读取(注释提及历史来源可接受)

# doctor 仍通过(会联网测两个模型,属正常)
asr-pipeline/bin/asr-refine doctor

5. Stage 3 — systemd 与文档同步

5.1 systemd

~/.config/systemd/user/obsidian-recording-watch.service 删除这一行(第三处硬编码):

Environment=MEETING_WEBHOOK_URL=http://127.0.0.1:8644/webhooks/meeting-recording

理由:该 URL 对应的 meeting-recording 路由已在 Stage 2 删除;状态通知目标现由 notify-target.json 统一提供,不应再由 unit 注入。

保留 MEETING_INBOX_BASE 与 MEETING_ASR_BACKEND=qwen3cam——这两个是真实生效的部署配置,与本次解耦无关(后者还带着"ASR backend trial"的回滚注释,不要动)。

生效并验证:

systemctl --user daemon-reload
systemctl --user restart obsidian-recording-watch.service
systemctl --user is-active obsidian-recording-watch.service   # 期望 active
journalctl --user -u obsidian-recording-watch.service -n 30 --no-pager

asr-review-web.service 与 asr-review-revise@.service 不需要改(它们通过 asrpipe 间接受益;后者的 EnvironmentFile=.asr-refine.env 只管模型凭证)。

5.2 文档

按"一份权威来源 + 其余引用"的结构收敛(与 vault 侧 AGENTS.md 作索引的模式一致):

文件 动作
asr-pipeline/docs/agent-protocol.md 新建。本次解耦的权威文档:通知接口契约(status/filename/detail 三段语义)、notify-target.json 字段含义、后端切换方式、"Hermes 只是一种后端"的定位、任何 agent 想接入时该读什么。Kai 早在 2026-07-17 就规划过这份共用协议文档但从未落地——这次补上。
asr-pipeline/HANDOFF.md 第 71 行 "Telegram 通知(hermes-gateway :8644 签名 webhook)" 改为指向通知接口层,不再把 Hermes 端口写进设计文档;链接到 agent-protocol.md。
OBSIDIAN/CLAUDE.md 管线段落中的 meeting-status-notify.py 描述更新为"可切换后端的通知接口";补一行指向 agent-protocol.md。保持该文件"只留 Claude + 本服务器专属基础设施"的既有定位,不要把协议细节搬进来。
~/.claude/skills/memo-inbox/references/webhook-toolset-diagnostics.md 标注废弃。它描述的"webhook agent 直接处理 meeting-recording 路由 + 需要 hermes-webhook/file/skills toolset"架构已于 2026-07-25 被取代,该路由在 Stage 2 已删除。建议在文件顶部加醒目废弃声明 + 指向 agent-protocol.md,而不是直接删除(保留历史排障脉络)。注意:这是 skill 目录,改动会影响 Claude 的行为,改完在 §7 里明确告知 Kai。
~/.claude/skills/memo-inbox/references/asr-refine-pipeline.md 五阶段表格等与 HANDOFF.md 逐字重复的内容改为链接引用;保留 skill 独有的纪律性内容("第一轮只出粗整理,不直接写正式纪要"、S3 fidelity gate 存在的理由与实测数据、模型选型不得随意改动的警告)——这些是 Kai 的工作纪律,不是可去重的冗余。

5.3 验收标准

# 确认 8644 不再出现在任何活跃代码/配置里(文档中作为历史说明可保留)
cd /home/claw/claude-workspace/OBSIDIAN
grep -rn "8644" --include="*.py" --include="*.sh" . ~/.config/systemd/user/*.service 2>/dev/null \
  | grep -v -E "__pycache__|\.venv|\.bak\."
# 期望:仅 notify-target.json 的待更新地址 + 注释

# 端到端(不触碰 vault、不 commit)
asr-pipeline/bin/asr-refine dry-run <任一 .asr-inbox/*.txt>
# 期望:五阶段跑通,产物落 asr-pipeline/dry-run/<slug>/,通知步骤降级但不报错

6. Stage 4 —(可选,需 Kai 决策)direct 后端

Stage 1–3 完成后,通知链路仍然经过 Hermes:管线 → Hermes 网关 webhook → Telegram。只要 Hermes 不在线,Kai 就收不到通知。

可选的彻底解耦:增加 backend: "direct",直接调 Telegram Bot API(https://api.telegram.org/bot<token>/sendMessage),不经过 Hermes。

技术上已确认可行:

  • ~/.hermes/.env 中存在 TELEGRAM_BOT_TOKEN;chat_id(1093241065)在 webhook_subscriptions.json 的 deliver_extra 里已确认。
  • meeting-recording-status 订阅标记为 deliver_only: True,即 Hermes 对这条消息只做投递、不唤起 agent。因此 direct 后端在语义上与现状完全等价,不损失任何 agent 能力。

但这需要 Kai 决策,不要擅自实施,因为:

  1. 要把 Telegram bot token 复制进本项目自持的凭证文件(或新增一份 600 env 文件)。这扩大了 token 的存放面,是安全决策,须 Kai 同意。
  2. 绕过 Hermes 后,Telegram 侧的消息格式/限流/重试不再由 Hermes 统一处理,需自行承担。
  3. Kai 可能有意保留"所有对外消息都经 Hermes"的架构约定。

执行方式:Stage 3 完成后,在 §7 报告中列出此选项与上述权衡,等 Kai 明确答复再动手。届时若 Kai 同意,direct 只是 notify-target.json 里加一个后端分支 + 一个 sender 函数,改动很小——这正是 Stage 1 接口层带来的收益。


7. 完成后的回报要求

Stage 1–3 结束后,向 Kai 报告,包含:

  1. 改了哪些文件,每个文件一句话说明,附备份文件名。
  2. 测试结果原文(Ran N tests ... OK)。若有任何失败,如实说明失败项与原因,不要含糊。
  3. §0.4 的 argv 错位 bug 已修复的证明:新增测试的名称与断言内容。
  4. 仍然待办的事,明确区分:
    • 需要 Kai 提供 .48 上 Hermes webhook 新地址,填入 notify-target.json 的 webhook.url(在此之前通知仍不通,但已降级为"改一个配置字段")。
    • Stage 4 direct 后端的决策请求(附 §6 的三点权衡)。
    • Hermes 侧 ~/.hermes/webhook_subscriptions.json 中 meeting-recording 那条旧订阅(带 prompt/skills: [memo-inbox])属旧架构遗留、已无调用方,建议 Kai 自行清理——本次未改动,因为不在授权范围。
  5. 动过 ~/.claude/skills/ 的明确告知(若执行了 §5.2 的 skill 文档改动)——这会影响 Claude 后续行为,Kai 需要知道。

8. 明确禁止(越界前必须先问)

  • ❌ 不要猜 .48 的端口后填进配置当作"已修复"。宁可留失效地址 + 注释。SSH 到 .48 已确认被拒,无法自行核实。
  • ❌ 不要往 asrpipe/ 核心五阶段(segment/flagger/refine/fidelity/revise)里加任何 agent 相关代码。cli.py 与 config.py 头部的 "no Claude Code, no opencode, no agent runtime" 声明是这套系统最值得保留的架构资产。
  • ❌ 不要改模型选型(clean_model / draft_model)。config.py 与 HANDOFF.md 都记录了实测依据(flash-lite 闸门失败 19/87 vs haiku 78/87,且 haiku 有 69 次过度压缩)。与本次解耦无关。
  • ❌ 不要关闭或削弱 S3 fidelity gate。它防的是静默信息丢失(无闸门时英文术语丢失达 22%、数字 11%)。
  • ❌ 不要删除 rollback 链路(bin/obsidian-asr-refine.sh + obsidian-asr-refine.service)。它们当前 inactive 但被 obsidian-asr-refine.path 的注释和 HANDOFF.md 记录为回滚路径。只需保证 refine.sh:45 调用 meeting-status-notify.py 的 CLI 契约不被破坏。
  • ❌ 不要动 ~/obsidian-vault/ 的内容,也不要在本次重构中产生 vault commit。本次只改 OBSIDIAN/ 基础设施与 systemd unit。
  • ❌ 不要清理那 7 个 *.bak.* 文件。它们是 2026-07-26 重构的历史留存,清理属独立决策,需 Kai 单独批准。
  • ❌ 不要引入新的第三方依赖。asr-pipeline/.venv 目前只有 pypinyin / rapidfuzz / numpy,通知功能用标准库 urllib.request + hmac + json 足够(现有实现已证明)。
  • ❌ 不要为解耦搭建通用消息总线/插件框架。当前只有一个通知需求,notify-target.json 里一个 backend 字段 + 几个分支就够。过度工程化会让下一个维护者更难读。

9. 执行顺序速查

0. 读 §0 全部 → 跑 baseline 测试(28 OK)→ 备份将改动的文件
1. Stage 1:notify-target.json + meeting-status-notify.py 单一实现   → 验收 §3.3
2. Stage 2:config.py / vault.py(修 argv bug)/ meeting-watch.py + 4 个新测试 → 验收 §4.4
3. Stage 3:systemd 去硬编码 + agent-protocol.md + 4 份文档同步     → 验收 §5.3
4. 回报 Kai(§7),等 .48 地址与 Stage 4 决策

Stage 1–3 连续执行,不要中途停下来确认;Stage 4 必须等 Kai 答复。