Files
wuguanganandClaude Opus 5 f0f6cd7454 feat: 验收守望 —— 独立于 Claude 自述的需求清单
Claude 会把用户需求翻译成自己的 TodoWrite,翻译那一步就已经丢东西了:
「修复 apple-header sticky」是施工步骤,不是原始需求里的功能要点。
再拿它自己的 todo 去验收,等于让它自己判自己的卷子。

清单挂在项目上跨会话累积,基线取自用户原话和用户批准过的计划。

几个刻意的设计:

- Evidence 在类型层面就分开 claimed / diff / ran。全塞进 [String] 等于在
  数据结构上承认「它说改了」和「它真改了」是一回事,而这正是要解决的问题。
  汇报条的「已证」只数 isProof 的,想把自述渲染成已验证得先改类型。

- 拦截的「上膛」只有 UserPromptSubmit 能做,Stop 拦过立即卸膛。Claude
  没有任何路径能给自己上膛,所以「拦一次 → 它回一轮 → 又拦」这个死循环
  是构造不出来的,不是被 if 挡住的。

- applyAudit 跳过用户终裁过的项。勾了「接受」一转身被机器打回 disputed,
  那个勾就白点了。

- extract 区分「失败」(nil) 和「没有新要点」([])。混在一起的话模型抽风
  一次就会清掉原话缓冲,而那是这个功能唯一的基线来源。

hook 从 5 类补到 9 类。PostToolUse 是最大的收获:Write/Edit 之后 Hub
第一手知道哪个文件被改了,不用回头问 Claude——问这个问题本身就给了
转述失真的机会。

实测确认(都不是推断):
- hubctl hook userprompt 的 stdout 是 0 字节。这条必须验,因为
  UserPromptSubmit 的 stdout 会被当成附加 context 注入进对话。
- 项目级和全局 hook:命令字符串相同时 Claude Code 自己去重,不同才叠加。
  第一次实验用了两条不同命令,据此普遍化成「一定会重复」是错的。
  HookDedup 保留为深度防御(路径不一致时才真用得上),不是必需品。
- 真实拆解链路 20 秒出结果,正确忽略了缓冲里的闲聊、正确去重了已有条目。

ensure-project-config.sh 接了三个调用点。其中 TerminalDispatch.open() 是
关键:app 从岛/主窗口启动项目完全不经过 project-menu.sh,漏了那里等于
最常用的入口一个都没检查。

326 个测试。上膛、自述不算证明、外部编辑不被覆盖三条做过变异验证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:50:25 +08:00

54 lines
2.0 KiB
Swift

import Foundation
import HubIPC
import Observation
/// 每类 hook 通道的健康度。
///
/// ## 为什么需要它
///
/// `hubctl doctor` 只能回答"socket 通不通"。但 hook 链路断掉的方式远不止这一种:
/// - `~/.claude/settings.json` 里某一类 hook 被别的工具覆盖掉了;
/// - 项目级配置写错了 event 名字(`PostToolUse` 拼成 `PostToolUSE`);
/// - 用户升级了 Claude Code,某个 event 改了名。
///
/// 这几种情况下 socket 是通的、doctor 是绿的,但那一类事件**一条都收不到** ——
/// 而验收清单会安静地不工作。「配置里有」和「真的在通」是两回事,
/// 只有真收到过事件才能证明后者。
@Observable
@MainActor
public final class HookChannelMonitor {
public private(set) var lastSeen: [HookEvent.Kind: Date] = [:]
public private(set) var counts: [HookEvent.Kind: Int] = [:]
public init() {}
public func record(_ kind: HookEvent.Kind) {
lastSeen[kind] = Date()
counts[kind, default: 0] += 1
}
/// 所有应该被装上的通道。UI 按这个列表逐条显示"通了没"。
///
/// nonisolated:它是一份常量清单,跟 actor 状态无关,
/// 而 `setup-swift-hooks.sh` 装了哪几类要能被测试拿来比对。
public nonisolated static let expected: [HookEvent.Kind] = [
.sessionStart, .userPromptSubmit, .preToolUse, .postToolUse,
.notification, .stop, .subagentStop, .preCompact, .sessionEnd,
]
public nonisolated static func label(for kind: HookEvent.Kind) -> String {
switch kind {
case .sessionStart: return "会话开始"
case .userPromptSubmit: return "用户提交"
case .preToolUse: return "工具调用前"
case .postToolUse: return "工具调用后"
case .notification: return "通知"
case .stop: return "收工"
case .subagentStop: return "子 agent 收工"
case .preCompact: return "上下文压缩前"
case .sessionEnd: return "会话结束"
}
}
}