Files
wuguanganandClaude Opus 5 501334cb65 fix: 老实回答「没做」的要点会被永远重复问;盯梢升级成追问
## 诚实反而被惩罚

答 done:false 的项保持 .open,而 .open 正是注入的筛选条件 ——
于是每一轮问的都是同一批 6 条,后面的要点一次都轮不到。
实测在我自己身上撞到:连着两轮被拦,问的是一模一样的 6 条。

加 lastAskedAt / askCount,按「最久没问过」轮换。问过 3 次还说没做的
标成「疑似误拆」并停止再问 —— 多半不是它偷懒,是这条拆偏了。
真实例子:用户说「加个人工确认吧 不然都清理不掉了」(意思是要一个手动
确认的入口),被拆成了「清理操作前增加确认步骤」和「取消确认时中止清理」
两条根本不存在的需求。

「疑似误拆」必须在界面上标出来:它已经被排除出注入了,不标的话就是
安静地消失,用户永远不知道有一条要点再也不会被问。

## 盯梢:从「说继续」升级成「追问」

「继续」只能防它停,防不了它做浅。它完全可以每次都继续、每次都做表面功夫,
最后给一份看起来很完整的报告。

scripts/keep-going.sh 改成读追问清单轮着问;
scripts/probes/agentx-training.txt 是 agentx 训练的 11 条:
训练包逐条对照、AI 浏览器实际验证、定时任务、handoff 覆盖、
测了几轮调了什么参数、卡点落点、有没有落成 skills、有没有新增子员工、
上下文被压缩后有没有遗漏、拿给真实用户最可能在哪翻车。

413 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 02:36:31 +08:00

455 lines
20 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// 一条证据。
///
/// **刻意把「Claude 说的」和「Hub 跑出来的」做成不同的 case。**
///
/// 最自然的写法是 `evidence: [String]`,把两者都塞进去。但那样等于在数据结构
/// 层面就承认了"陈述和证明是一回事"—— 而这恰好是这个功能要解决的问题本身:
/// Claude 说"改了 x.swift、跑了测试"和它真的改了、真的跑了,是两件事。
///
/// 类型分开之后,"有几条是真证明"就成了一个编译期就说得清的问题(`isProof`),
/// UI 和报告都没法不小心把自述当成证明去展示。
public enum Evidence: Codable, Sendable, Equatable {
/// Claude 自报的说辞。**不是证明**,只是待核验的线索。
case claimed(String)
/// Hub 自己跑 `git diff` 找到的真实改动。
case diff(path: String, added: Int, removed: Int)
/// Hub 自己执行命令的结果 —— 命令原文、退出码、输出尾部。
case ran(command: String, exitCode: Int32, tail: String)
/// 只有 Hub 亲手产出的才算证明。`.claimed` 永远是 false。
///
/// 别在这里给 `.claimed` 开口子(比如"内容里带了文件路径就算")——
/// 那等于回到了"听它怎么说"。
public var isProof: Bool {
switch self {
case .claimed: return false
case .diff, .ran: return true
}
}
/// 列表里的一行短标签。
public var shortLabel: String {
switch self {
case .claimed:
return "仅自述"
case .diff(let path, let added, let removed):
return "\((path as NSString).lastPathComponent) +\(added)/-\(removed)"
case .ran(let command, let exitCode, _):
return "\(command) → exit \(exitCode)"
}
}
}
/// 一条要点里的分项。
///
/// Claude 的 todo 经常一条塞好几件事:
/// 「批次2:设备自动连接(Android 地基修复→状态机→UI→iOS)」是四件。
/// 整条勾掉时,那四件里做了三件还是四件,从条目本身看不出来 ——
/// 而漏掉的往往正是最后那件。
public struct SubPoint: Codable, Sendable, Equatable, Identifiable {
public var text: String
public var done: Bool
public var id: String { text }
public init(text: String, done: Bool = false) {
self.text = text
self.done = done
}
}
/// 清单里的一个要点。
///
/// 「要点」不等于 Claude 的 TodoWrite 条目。后者是**施工步骤**("重写轮播箭头"),
/// 前者是**用户要的功能**("移动端能正常翻页")。翻译发生在 Claude 脑子里,
/// 而遗漏就发生在那一步 —— 所以基线必须取自用户原话和用户批准过的计划。
public struct AcceptanceItem: Codable, Sendable, Identifiable, Equatable {
/// 这条要点是从哪儿来的。UI 上做成徽章,让用户一眼看出"这是我说的"还是"机器推的"。
public enum Origin: String, Codable, Sendable {
/// 用户原话里拆出来的。
case userPrompt
/// 用户批准过的计划里拆出来的 —— 权威性最高。
case plan
/// 分析推导的隐含项(改了 A 就得同步 B)。用户没明说,所以要标出来。
case inferred
/// 用户手敲的。
case manual
/// **Claude 自己列的 todo。**
///
/// 它不能当基线 —— 用户需求翻译成它的那一步就已经丢东西了,
/// 这正是整个功能存在的理由。但把它整个排除也是错的:
/// 「它自己列了 6 项,做完 3 项就说完事了」是遗漏最直接的证据,
/// 而且这类承诺**不在用户的原话里,别处根本抓不到**。
///
/// 所以它进清单,但单独标一档,和用户原话那些分开看。
case assistantTask
public var label: String {
switch self {
case .userPrompt: return "原话"
case .plan: return "计划"
case .inferred: return "推导"
case .manual: return "手敲"
case .assistantTask: return "AI 计划"
}
}
/// 来自用户的(权威基线)还是来自 AI 的(待核对的自述)。
public var isFromUser: Bool {
switch self {
case .userPrompt, .plan, .manual: return true
case .inferred, .assistantTask: return false
}
}
}
public enum Status: String, Codable, Sendable {
/// 没人碰过。
case open
/// Claude 自报做完了,还没复核。
case claimed
/// 旁路复核在真实 diff 里找到了对应改动。
case confirmed
/// 自报做完但 diff 里找不到 —— **这一档最值钱**,是整个功能存在的理由。
case disputed
/// 用户亲手勾的。终裁。
case accepted
/// 用户亲手划掉的(不做了 / 判断有误)。终裁。
case dropped
public var label: String {
switch self {
case .open: return "未验收"
case .claimed: return "待复核"
case .confirmed: return "已确认"
case .disputed: return "存疑"
case .accepted: return "已接受"
case .dropped: return "已划掉"
}
}
}
public let id: String
public var text: String
/// 怎么算做到。尽量是可执行的命令(`swift test --filter Xxx`),
/// 拆不出来就留自然语言 —— 那种只能人工看,报告里会如实标注。
public var acceptance: String?
public var origin: Origin
public var status: Status
public var evidence: [Evidence]
/// 这条要点里拆出来的分项。空 = 这条本来就是单一的一件事。
public var parts: [SubPoint]
/// 机器给的理由,或用户的批注。
public var note: String?
public let createdAt: Date
public var updatedAt: Date
public let sourceSessionId: String?
/// 上一次被塞进注入正文的时间。nil = 还没问过。
///
/// 用来轮换。没有它的话,注入永远取排在前面的那 6 条 ——
/// 老实回答「没做」的条目保持 `.open`,于是每一轮问的都是同一批,
/// 后面的永远轮不到。**诚实反而被惩罚**,这是实测撞到的。
public var lastAskedAt: Date?
/// 一共被问过几次。
public var askCount: Int
/// 入库时的 HEAD。diff 回溯的起点 —— 没有它就没法回答"这条要点之后代码变了什么"。
public var baselineCommit: String?
public init(
id: String = UUID().uuidString,
text: String,
acceptance: String? = nil,
origin: Origin,
status: Status = .open,
evidence: [Evidence] = [],
parts: [SubPoint] = [],
note: String? = nil,
createdAt: Date = Date(),
updatedAt: Date = Date(),
sourceSessionId: String? = nil,
baselineCommit: String? = nil,
lastAskedAt: Date? = nil,
askCount: Int = 0
) {
self.lastAskedAt = lastAskedAt
self.askCount = askCount
self.id = id
self.text = text
self.acceptance = acceptance
self.origin = origin
self.status = status
self.evidence = evidence
self.parts = parts.isEmpty ? Self.split(text) : parts
self.note = note
self.createdAt = createdAt
self.updatedAt = updatedAt
self.sourceSessionId = sourceSessionId
self.baselineCommit = baselineCommit
}
/// 手写解码,理由同 `AcceptanceLedger.init(from:)`:加字段不能让老数据全废。
///
/// 只有 `id` / `text` / `origin` 是必须的 —— 少了它们这条要点就没有意义了。
/// 其余一律给默认值。
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
text = try container.decode(String.self, forKey: .text)
origin = try container.decode(Origin.self, forKey: .origin)
acceptance = try container.decodeIfPresent(String.self, forKey: .acceptance)
status = try container.decodeIfPresent(Status.self, forKey: .status) ?? .open
evidence = try container.decodeIfPresent([Evidence].self, forKey: .evidence) ?? []
parts = try container.decodeIfPresent([SubPoint].self, forKey: .parts) ?? []
note = try container.decodeIfPresent(String.self, forKey: .note)
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date()
sourceSessionId = try container.decodeIfPresent(String.self, forKey: .sourceSessionId)
baselineCommit = try container.decodeIfPresent(String.self, forKey: .baselineCommit)
lastAskedAt = try container.decodeIfPresent(Date.self, forKey: .lastAskedAt)
askCount = try container.decodeIfPresent(Int.self, forKey: .askCount) ?? 0
}
/// 问过好几次它都说没做 —— 多半不是它偷懒,是这条要点本身拆错了。
///
/// 继续问下去只会一轮轮浪费,还把真正该问的挤出去。标出来让用户去处理。
public var likelyMisextracted: Bool { askCount >= 3 && status == .open }
/// 把一条塞了好几件事的要点拆开。
///
/// 「批次2:设备自动连接(Android 地基修复→状态机→UI→iOS)」拆成四件。
/// 整条勾掉时,那四件里做了三件还是四件从条目本身看不出来 ——
/// 而漏掉的往往正是最后那件。
///
/// ## 为什么是机械切分而不是交给模型
///
/// 这一步要对**每一条** todo 都跑,交给模型是每条一次调用。而它要解决的
/// 问题很窄:把明确的并列分隔符切开。分隔符切不出来的("深色模式双端"
/// 其实是 iOS + Android)就不切 —— 切错比不切更糟,用户会对着一堆
/// 莫名其妙的碎片。
///
/// 冒号前的前缀("批次2:")不算一件事,是标题,切完要去掉。
static func split(_ text: String) -> [SubPoint] {
// 先剥掉 "批次2:" / "阶段一 - " 这类标题前缀。
var body = text
for separator in [":", ":"] where body.contains(separator) {
let pieces = body.components(separatedBy: separator)
// 只剥第一层,且前缀必须**很短**。
//
// 标题前缀是「批次2」「阶段一」「T24」这种量级;
// 门槛一开始定的 12 字,结果「把整个权限体系重构一遍:改A、改B」
// 的前半句被当成标题剥掉了 —— 中文里 11 个字是实打实的内容。
if pieces.count >= 2, pieces[0].count <= 6 {
body = pieces.dropFirst().joined(separator: separator)
}
break
}
// 括号里的常常正是并列清单,一起参与切分。
body = body
.replacingOccurrences(of: "(", with: "、")
.replacingOccurrences(of: "(", with: "、")
.replacingOccurrences(of: ")", with: "")
.replacingOccurrences(of: ")", with: "")
let delimiters = CharacterSet(charactersIn: "→、;;+/")
let pieces = body.components(separatedBy: delimiters)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
// 太短的多半是切碎的噪音("UI"、"iOS" 例外,所以门槛压到 2)。
.filter { $0.count >= 2 }
// 只有一件事就别拆,返回空表示"这条本来就是单一的"。
guard pieces.count >= 2 else { return [] }
return pieces.map { SubPoint(text: $0) }
}
/// 分项里做完了几件。没有分项时按整条算。
public var partsDone: Int { parts.isEmpty ? (status == .confirmed ? 1 : 0) : parts.filter(\.done).count }
public var partsTotal: Int { parts.isEmpty ? 1 : parts.count }
/// 用户终裁过的项。机器结论一律盖不掉 —— 见 `AcceptanceStore.applyAudit`。
public var isSettledByUser: Bool { status == .accepted || status == .dropped }
/// 这一项有没有**实测过的**证明。
///
/// 用户的原话是"不是说说而已,包括实际功能证明"——那句要求就落在这个属性上。
/// 汇报条的「已证」只数它为 true 的,否则那个数字又变成了转述 Claude 的话。
public var hasProof: Bool { evidence.contains(where: \.isProof) }
/// 还没有定论、需要用户关注的。
public var needsAttention: Bool { status == .open || status == .disputed }
/// Claude 最近一次的自报说辞。复核时拿它去定位该看哪几个文件 ——
/// **只是线索,不是证据**,所以取的是 `.claimed` 那一档。
public var latestClaim: String? {
for case .claimed(let text) in evidence.reversed() { return text }
return nil
}
}
/// 用户说过的一句原话。
///
/// `UserPromptSubmit` 一到就零成本落这里,**不调模型**。拆解是异步消费这个缓冲的,
/// 因为拆解要花好几秒,而那个 hook 必须立刻返回。
/// 逐条解码的要点数组:坏的那条跳过,其余照常。
///
/// Swift 没有内置的"容错数组解码",只能自己包一层。
private struct LossyItems: Decodable {
let values: [AcceptanceItem]
init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
var collected: [AcceptanceItem] = []
while !container.isAtEnd {
// 解不出来时也必须**消费掉**这一个元素,否则 isAtEnd 永远不为真 ——
// 死循环。用一个空的占位类型把游标推过去。
if let item = try? container.decode(AcceptanceItem.self) {
collected.append(item)
} else {
_ = try? container.decode(Skip.self)
}
}
values = collected
}
/// 什么都不读,只为了让 unkeyedContainer 的游标前进一格。
private struct Skip: Decodable {
init(from decoder: Decoder) throws {}
}
}
public struct RawPrompt: Codable, Sendable, Equatable {
public let text: String
public let sessionId: String
public let at: Date
public init(text: String, sessionId: String, at: Date = Date()) {
self.text = text
self.sessionId = sessionId
self.at = at
}
}
/// 一个项目的验收清单。
///
/// **挂在项目上而不是会话上**:用户关了终端明天再开,要点不该跟着消失。
/// 会话只是"哪一轮动过它",记在 `AcceptanceItem.sourceSessionId` 里。
public struct AcceptanceLedger: Codable, Sendable, Equatable {
/// 展开后的项目绝对路径。主键。
public var projectPath: String
public var items: [AcceptanceItem]
/// 还没拆解的用户原话缓冲。
public var rawPrompts: [RawPrompt]
/// 用户点过「允许执行」的命令原文。
///
/// 按项目存,因为授权是有语境的:在自己的项目里跑 `swift test` 没问题,
/// 不代表在别人 clone 来的仓库里也没问题。
///
/// **它只是第三道闸。** 白名单和元字符检查在它之前,手工往这个数组里
/// 塞一条危险命令绕不过前两道 —— 有测试守这一点。
public var authorizedCommands: [String]
public var updatedAt: Date
public init(
projectPath: String,
items: [AcceptanceItem] = [],
rawPrompts: [RawPrompt] = [],
authorizedCommands: [String] = [],
updatedAt: Date = Date()
) {
self.projectPath = projectPath
self.items = items
self.rawPrompts = rawPrompts
self.authorizedCommands = authorizedCommands
self.updatedAt = updatedAt
}
/// **手写解码,每个字段都 decodeIfPresent。**
///
/// 合成的 Codable 对非可选字段要求必须存在 —— 给这个结构体加一个新字段,
/// 所有已存在的清单文件当场全部解码失败。而 `AcceptanceStore` 的读取是
/// `try?` 静默吞错误的,于是下一次写入会从空清单开始,**把用户攒的东西
/// 整个覆盖掉**。
///
/// 这不是假想:加 `authorizedCommands` 那次就这么丢了一份 13 条的清单,
/// 而且当时还有两个项目的文件正等着被同样地清空。
///
/// 清单是长期累积的数据,向后兼容不是可选项。以后加字段照着这里写。
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
projectPath = try container.decode(String.self, forKey: .projectPath)
// 逐条解,坏的跳过。
//
// 直接 `decode([AcceptanceItem].self)` 的话,**一条坏了整份清单就报废** ——
// 而报废的后果是这个项目的几十条要点全部读不出来。同 TaskStateReader
// 的处理:清单是边跑边写的,撞上半条数据是正常情况,不是异常。
items = try container.decodeIfPresent(LossyItems.self, forKey: .items)?.values ?? []
rawPrompts = try container.decodeIfPresent([RawPrompt].self, forKey: .rawPrompts) ?? []
authorizedCommands = try container.decodeIfPresent(
[String].self, forKey: .authorizedCommands
) ?? []
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date()
}
public var isEmpty: Bool { items.isEmpty && rawPrompts.isEmpty }
// MARK: - 汇报级统计
//
// app 窗口窄,塞不下逐条证据,所以列表上方那一行汇报条是主要的信息载体。
// 这几个计数就是那一行。
public var disputedCount: Int { items.filter { $0.status == .disputed }.count }
public var openCount: Int { items.filter { $0.status == .open }.count }
public var claimedCount: Int { items.filter { $0.status == .claimed }.count }
/// 有实测证明的条数。
///
/// **只数 `hasProof`,不数 `.confirmed`。** 一条要点可以因为 diff 里有痕迹而
/// 被判 confirmed,但那仍然只是"代码动过",不是"功能能用"。这个数字要能
/// 回答的是后者。
public var provenCount: Int { items.filter(\.hasProof).count }
/// 列表上方的分段切换。
///
/// 六种状态平铺成六个分组时,页面要滚很久才能看到「疑似未做」——
/// 而那恰恰是唯一真正需要你动手的一档。切成三段之后,最要紧的那段
/// 是一次点击就能到的。
public enum Lane: String, CaseIterable, Sendable, Identifiable {
case pending = "待验收"
case disputed = "疑似未做"
case done = "已验收"
public var id: String { rawValue }
public func contains(_ status: AcceptanceItem.Status) -> Bool {
switch self {
case .pending: return status == .open || status == .claimed
case .disputed: return status == .disputed
// 已划掉的归到「已验收」而不是单开一段:它同样是"有定论了、不用再管",
// 单开一段只会让最需要注意的那一档更难找。
case .done: return status == .confirmed || status == .accepted || status == .dropped
}
}
}
public func items(in lane: Lane) -> [AcceptanceItem] {
items.filter { lane.contains($0.status) }
}
public func count(in lane: Lane) -> Int {
items.reduce(0) { lane.contains($1.status) ? $0 + 1 : $0 }
}
/// 一句话汇报。UI 和导出报告共用,避免两处各写一份然后漂移。
public var headline: String {
var parts: [String] = []
if disputedCount > 0 { parts.append("存疑 \(disputedCount)") }
if openCount > 0 { parts.append("未验收 \(openCount)") }
if claimedCount > 0 { parts.append("待复核 \(claimedCount)") }
parts.append("已证 \(provenCount)/\(items.count)")
return parts.joined(separator: " · ")
}
}