## 诚实反而被惩罚 答 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>
455 lines
20 KiB
Swift
455 lines
20 KiB
Swift
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: " · ")
|
||
}
|
||
}
|