阶段 4 的教训直接催生了这一步:diff 复核把 `case ran(command:exitCode:)` 这个**枚举声明**当成了功能已实现,而没有任何代码产生它。读代码判断不了 "功能是否真的能用",只有跑一遍能。 执行者必须是 Hub 而不是 Claude —— 让它跑完再转述,就又回到了「说说而已」, 转述那一步正是失真发生的地方。产出 Evidence.ran(isProof = true)。 三道闸,全部有测试,元字符那道做过变异验证: 1. 白名单:只放行构建/测试类。刻意排除 npx / pnpm dlx(会下载执行任意包, 放行它等于白名单形同虚设)、npm install(跑 postinstall 等于执行任意 代码)、git 的写操作。bash 限定在项目的 scripts/ 下。 2. 元字符:16 个字符逐个测。**这道检查必须在白名单之前** —— 变异验证时把它挪到后面,`swift test | tee /tmp/x` 立刻因为前缀匹配上 `swift test` 而被放行。配合 Shell.run 走 execve 数组参数(不是 sh -c)。 3. 总开关默认 off + 逐条授权。授权按命令原文精确匹配,改一个字要重新授权; 而且手工往 authorized 里塞危险命令也绕不过前两道(单独一条测试守)。 顺带修了注入正文太长:真实项目上拦了 12 条带完整验收条件,糊了满屏。 要点一多就没人看,反而比不拦更容易被忽略。上限 6 条,存疑的排前面, 省略了多少明确说出来。 Shell.run 加 currentDirectory —— 验收命令必须在项目目录里跑, 否则 swift test 找不到 Package.swift。 388 个测试。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
270 lines
11 KiB
Swift
270 lines
11 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 的 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
|
|
|
|
public var label: String {
|
|
switch self {
|
|
case .userPrompt: return "原话"
|
|
case .plan: return "计划"
|
|
case .inferred: return "推导"
|
|
case .manual: return "手敲"
|
|
}
|
|
}
|
|
}
|
|
|
|
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 note: String?
|
|
public let createdAt: Date
|
|
public var updatedAt: Date
|
|
public let sourceSessionId: String?
|
|
/// 入库时的 HEAD。diff 回溯的起点 —— 没有它就没法回答"这条要点之后代码变了什么"。
|
|
public var baselineCommit: String?
|
|
|
|
public init(
|
|
id: String = UUID().uuidString,
|
|
text: String,
|
|
acceptance: String? = nil,
|
|
origin: Origin,
|
|
status: Status = .open,
|
|
evidence: [Evidence] = [],
|
|
note: String? = nil,
|
|
createdAt: Date = Date(),
|
|
updatedAt: Date = Date(),
|
|
sourceSessionId: String? = nil,
|
|
baselineCommit: String? = nil
|
|
) {
|
|
self.id = id
|
|
self.text = text
|
|
self.acceptance = acceptance
|
|
self.origin = origin
|
|
self.status = status
|
|
self.evidence = evidence
|
|
self.note = note
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt
|
|
self.sourceSessionId = sourceSessionId
|
|
self.baselineCommit = baselineCommit
|
|
}
|
|
|
|
/// 用户终裁过的项。机器结论一律盖不掉 —— 见 `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 必须立刻返回。
|
|
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
|
|
}
|
|
|
|
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: " · ")
|
|
}
|
|
}
|