feat: 追踪 AI 自己答应的事,并能推回对应 session

用户一眼看出的缺口:清单里全是他说过的话,**AI 自己列的 todo 一条都没有**。

我先前的判断是"Claude 的 todo 不能当基线"——这没错,翻译那一步就已经丢
东西了。但我顺手把它整个排除了,而「它自己列了 6 项做完 3 项就说完事了」
恰恰是遗漏最直接的证据,且这类承诺不在用户原话里,别处根本抓不到。

现在收,但单独标成「AI 计划」,Origin.isFromUser 把"用户的权威基线"和
"待核对的 AI 自述"在类型层面分开 —— 混成一档就等于把自述当成了基线。

- TaskStateReader.readTasks 逐条读 ~/.claude/tasks/(零成本,不调模型)。
  原来只给计数,而验收要的是"没做的那几项分别是什么"。
- Claude 自己勾掉的跟着同步成已确认(否则永远挂在未验收,用户手动清不动,
  清不动的清单最后没人看)。只动 .assistantTask 那一档:它勾掉自己的 todo
  不代表用户提的需求做完了,同名时尤其危险("发布新版本")。
- 按**会话**统计而不是按项目:用户问的是"这一轮它答应的做全了没"。
- 「告知这个会话」走 TerminalReply.send 把未做项注入回那个终端 ——
  不是发通知让用户自己切过去,是直接把话说到那个 Claude 面前。

实测本机某会话读出 6 项完成 + 1 项 pending「真机验证」——
正是典型的遗漏形态:活干完了,验证挂着,而它已经说"完成了"。

401 个测试。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
wuguanganandClaude Opus 5 committed 2026-08-07 22:31:44 +08:00
1 parent f1cdcd2547
commit 920ad7505f
6 files changed
+421

No files matched your search

+18
View File
@@ -59,6 +59,15 @@ public struct AcceptanceItem: Codable, Sendable, Identifiable, Equatable {
case inferred
/// 用户手敲的。
case manual
/// **Claude 自己列的 todo。**
///
/// 它不能当基线 —— 用户需求翻译成它的那一步就已经丢东西了,
/// 这正是整个功能存在的理由。但把它整个排除也是错的:
/// 「它自己列了 6 项,做完 3 项就说完事了」是遗漏最直接的证据,
/// 而且这类承诺**不在用户的原话里,别处根本抓不到**。
///
/// 所以它进清单,但单独标一档,和用户原话那些分开看。
case assistantTask
public var label: String {
switch self {
@@ -66,6 +75,15 @@ public struct AcceptanceItem: Codable, Sendable, Identifiable, Equatable {
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
}
}
}
@@ -31,6 +31,23 @@ public struct TaskSnapshot: Sendable, Equatable {
}
}
/// Claude 自己列的一条任务。
///
/// 和 `TaskSnapshot` 的区别:那个只给计数,这个给逐条内容。
/// 验收清单要的是后者 —— 「它列了 6 项只做了 3 项」这句话里,
/// 值钱的是**没做的那 3 项分别是什么**,光知道数量没法验收。
public struct ClaudeTask: Sendable, Equatable {
public let order: Int
public let subject: String
public let done: Bool
public init(order: Int, subject: String, done: Bool) {
self.order = order
self.subject = subject
self.done = done
}
}
/// 读 `~/.claude/tasks/<会话目录>/<id>.json` —— Claude Code 自己维护的任务清单。
///
/// 这是"这个会话还有后续没做完"的**确定性**判据,不用 AI、不花额度。
@@ -66,6 +83,53 @@ public struct TaskStateReader: Sendable {
return result
}
/// 逐条读出 Claude 自己列的任务。
///
/// ## 为什么验收清单要收它
///
/// 这份清单**不能当基线**——用户的需求翻译成它的那一步就已经丢东西了,
/// 这正是整个功能存在的理由。但把它整个排除掉也是错的:
/// 「它自己列了 6 项,做完 3 项就说完事了」是遗漏最直接的证据,
/// 而且这类承诺不在用户的原话里,别处根本抓不到。
///
/// 所以它进清单,但**单独标成「AI 计划」**,和用户原话那一档分开显示。
///
/// 零成本:读的是本地 JSON,不调模型。
public func readTasks(sessionId: String) -> [ClaudeTask] {
let fm = FileManager.default
guard let dir = candidateDirectories(for: sessionId).first(where: {
var isDir: ObjCBool = false
return fm.fileExists(atPath: $0.path, isDirectory: &isDir) && isDir.boolValue
}) else { return [] }
guard let entries = try? fm.contentsOfDirectory(
at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
) else { return [] }
var tasks: [ClaudeTask] = []
for file in entries where file.pathExtension == "json" {
// 同 read():单个文件坏掉跳过,别让整个目录失效。
guard let data = try? Data(contentsOf: file),
let object = try? JSONSerialization.jsonObject(with: data),
let task = object as? [String: Any],
let status = task["status"] as? String,
let subject = (task["subject"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!subject.isEmpty
else { continue }
tasks.append(
ClaudeTask(
// 文件名是 "7.json"、"10.json",按数值排才是真实顺序。
order: Int(file.deletingPathExtension().lastPathComponent) ?? Int.max,
subject: subject,
done: status == "completed"
)
)
}
return tasks.sorted { $0.order < $1.order }
}
public func read(sessionId: String) -> TaskSnapshot? {
let fm = FileManager.default
guard let dir = candidateDirectories(for: sessionId).first(where: {
+83
View File
@@ -29,6 +29,11 @@ struct AcceptancePane: View {
/// 正在跑验证的那一条。同时只允许跑一条 —— 并发跑 `swift build`
/// 会互相抢构建目录锁,结果全是假失败。
@State private var running: String?
/// 正在往哪个会话推消息。防连点 —— 注入是往终端敲字,重复注入会串行叠加。
@State private var notifying: String?
/// 往终端注入文字的通道。和岛上「去终端回答」用的是同一条路。
private let reply = TerminalReply()
@State private var expanded: Set<String> = []
@State private var draft = ""
@@ -93,6 +98,7 @@ struct AcceptancePane: View {
)
if !pickerPaths.isEmpty { controls }
sessionPromises
if ledger?.items.isEmpty ?? true {
ContentUnavailableView(
@@ -214,6 +220,83 @@ struct AcceptancePane: View {
.padding(.bottom, 10)
}
// MARK: - AI 答应的事做全了没
/// 这个项目下每个在跑的会话,AI 自己列的 todo 完成情况。
///
/// 单独一条,不混进下面的清单里:用户问的是「**这一轮**它答应的做全了没」,
/// 而清单是按项目跨会话累积的,混在一起答不了这个问题。
@ViewBuilder
private var sessionPromises: some View {
let sessions = store.sessions.filter {
currentPath != nil
&& AcceptanceStore.projectPath(forCWD: $0.cwd, projects: projects) == currentPath
}
if !sessions.isEmpty, let path = currentPath {
VStack(spacing: 4) {
ForEach(sessions) { session in
let pending = acceptance.unfinishedAssistantTasks(
sessionId: session.sessionId, in: path
)
let total = acceptance.ledger(for: path).items.filter {
$0.sourceSessionId == session.sessionId && $0.origin == .assistantTask
}.count
if total > 0 {
HStack(spacing: 8) {
Image(systemName: pending.isEmpty
? "checkmark.circle.fill" : "exclamationmark.circle.fill")
.font(.system(size: 11))
.foregroundStyle(
pending.isEmpty ? IslandTheme.shell : IslandTheme.waiting
)
Text(session.name ?? session.sessionId.prefix(8).description)
.font(.system(size: 12, weight: .medium))
Text(pending.isEmpty
? "AI 答应的 \(total) 项都做完了"
: "AI 答应了 \(total) 项,还差 \(pending.count) 项")
.font(.system(size: 12)).monospacedDigit()
.foregroundStyle(.secondary)
Spacer()
if !pending.isEmpty {
Button(notifying == session.sessionId ? "已发" : "告知这个会话") {
notify(session: session, pending: pending)
}
.buttonStyle(.borderless)
.font(.system(size: 11, weight: .medium))
.disabled(notifying != nil)
}
}
.padding(.horizontal, 10).padding(.vertical, 6)
.background(.background.secondary, in: .rect(cornerRadius: 8))
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
}
/// 把没做完的推回那个会话的终端。
///
/// 走 `TerminalReply.send` 注入到对应 pane —— 不是发通知让用户自己切过去,
/// 而是直接把话说到那个 Claude 面前。用户的原话:
/// 「可以在这边交互的时候映射到对应的 session 里告知」。
private func notify(session: AgentSession, pending: [AcceptanceItem]) {
notifying = session.sessionId
let lines = pending.prefix(8).map { "- \($0.text)" }.joined(separator: "\n")
let more = pending.count > 8 ? "\n(另有 \(pending.count - 8) 项)" : ""
let text = """
【Claude Hub】你自己列的 todo 里这几项还没做完,逐条确认一下现在的状态:
\(lines)\(more)
"""
let reply = self.reply
Task {
_ = await reply.send(text: text, to: session.sessionId)
try? await Task.sleep(for: .seconds(2))
notifying = nil
}
}
// MARK: - 项目搜索
@ViewBuilder
@@ -217,6 +217,45 @@ public final class AcceptanceStore {
}
}
/// 同步 Claude 自己勾掉的那些。
///
/// 只动 `.assistantTask` 来源、且还没定论的项。上一轮进来时没做完的,
/// 这一轮可能已经勾掉了 —— 不同步的话它们会永远挂在「未验收」,
/// 用户得手动一条条清,那清单就没人看了。
///
/// **不碰用户终裁过的项**,理由同 `applyAudit`。
public func syncAssistantTasks(done subjects: [String], in projectPath: String) {
let keys = Set(subjects.map(Self.dedupKey))
guard !keys.isEmpty else { return }
mutate(projectPath) { ledger in
for index in ledger.items.indices {
let item = ledger.items[index]
guard item.origin == .assistantTask,
!item.isSettledByUser,
item.status != .confirmed,
keys.contains(Self.dedupKey(item.text))
else { continue }
ledger.items[index].status = .confirmed
ledger.items[index].note = "Claude 自己勾了完成"
ledger.items[index].updatedAt = Date()
}
}
}
/// 某个会话里 AI 答应做但还没做的事。
///
/// 用于「告知会话」:把这些推回那个终端。按会话而不是按项目 ——
/// 用户问的是「这一轮它答应的做全了没」,跨会话混在一起答不了这个问题。
public func unfinishedAssistantTasks(sessionId: String, in projectPath: String)
-> [AcceptanceItem]
{
ledger(for: projectPath).items.filter {
$0.sourceSessionId == sessionId
&& $0.origin == .assistantTask
&& $0.needsAttention
}
}
/// 去重键:去掉空白和常见标点,忽略大小写。
///
/// 只做到这个程度就够了 —— 真正的语义去重交给拆解那次模型调用
@@ -25,6 +25,8 @@ public final class HookCoordinator {
private let dedup = HookDedup()
private let extractor = AcceptanceExtractor()
private let auditor = AcceptanceAuditor()
/// 读 Claude 自己的 todo(`~/.claude/tasks/`)。零成本,不调模型。
private let tasks = TaskStateReader()
/// 正在复核中的项目。同一个项目不并发跑,理由同 `extracting`。
private var auditing: Set<String> = []
/// 正在拆解中的项目。同一个项目的拆解不并发跑 —— 两次并发会拿到同一份
@@ -177,6 +179,9 @@ public final class HookCoordinator {
let path = AcceptanceStore.projectPath(forCWD: event.cwd, projects: projects)
// 把 Claude 自己列的 todo 并进清单。零成本,读本地 JSON。
collectAssistantTasks(from: event, projectPath: path)
// 先收自查回答,再决定拦不拦。
//
// 顺序不能反:上一轮拦下来之后 Claude 的回答就在这条事件的
@@ -231,6 +236,44 @@ public final class HookCoordinator {
return HookDecision(verdict: .deny, reason: text)
}
/// 把 Claude 自己列的 todo 并进清单。
///
/// ## 为什么要收它
///
/// 这份清单**不能当基线** —— 用户需求翻译成它的那一步就已经丢东西了。
/// 但先前我把它整个排除了,而用户一眼看出问题:清单里全是他说过的话,
/// **AI 自己答应要做的事一条都没有**。
///
/// 「它自己列了 6 项,做完 3 项就说完事了」是遗漏最直接的证据,
/// 而这类承诺不在用户原话里,别处根本抓不到。所以收,但单独标成「AI 计划」。
///
/// 已完成的直接记成已确认:那是 Claude 自己勾掉的,和它嘴上说做完了不一样 ——
/// 勾状态是它边干边写进文件的,事后改动的动机低得多。但仍然只算
/// `.confirmed` 而不是 `.accepted`,终裁权还在用户手上。
@MainActor
private func collectAssistantTasks(from event: HookEvent, projectPath: String) {
let tasks = tasks.readTasks(sessionId: event.sessionId)
guard !tasks.isEmpty else { return }
acceptance.merge(
tasks.map { task in
AcceptanceItem(
text: task.subject,
origin: .assistantTask,
status: task.done ? .confirmed : .open,
note: task.done ? "Claude 自己勾了完成" : nil,
sourceSessionId: event.sessionId
)
},
into: projectPath
)
// 已有的那些也要跟着更新状态 —— 上一轮进来时还没做完的,
// 这一轮可能已经勾掉了。不同步的话它们会永远挂在「未验收」。
acceptance.syncAssistantTasks(
done: tasks.filter(\.done).map(\.subject), in: projectPath
)
}
/// 收下 Claude 上一轮的逐条自查回答。
///
/// 解析不出来就什么都不做 —— 它很可能是直接去补做遗漏项了(那其实是好事),
@@ -0,0 +1,174 @@
import HubCore
import HubProbe
import XCTest
@testable import HubUI
/// 追踪「AI 自己答应要做的事」。
///
/// 这一档来自用户的一句观察:清单里全是他说过的话,**AI 自己列的 todo
/// 一条都没有**。而「它自己列了 6 项,做完 3 项就说完事了」恰恰是遗漏
/// 最直接的证据,且这类承诺不在用户原话里,别处根本抓不到。
@MainActor
final class AssistantTaskTests: XCTestCase {
private let project = "/tmp/demo-project"
private let session = "s1"
private func task(_ text: String, done: Bool = false) -> AcceptanceItem {
AcceptanceItem(
text: text, origin: .assistantTask,
status: done ? .confirmed : .open, sourceSessionId: session
)
}
/// AI 的计划和用户的原话必须**看得出区别**。
///
/// 混成一档就等于把待核对的自述当成了权威基线 ——
/// 而那正是这个功能要防的事。
func testAssistantTasksAreDistinguishableFromUserRequirements() {
XCTAssertFalse(AcceptanceItem.Origin.assistantTask.isFromUser)
XCTAssertFalse(AcceptanceItem.Origin.inferred.isFromUser)
XCTAssertTrue(AcceptanceItem.Origin.userPrompt.isFromUser)
XCTAssertTrue(AcceptanceItem.Origin.plan.isFromUser)
XCTAssertTrue(AcceptanceItem.Origin.manual.isFromUser)
XCTAssertEqual(AcceptanceItem.Origin.assistantTask.label, "AI 计划")
}
/// 上一轮没做完、这一轮 Claude 自己勾掉了的,要跟着变成已确认。
///
/// 不同步的话它们会永远挂在「未验收」,用户得手动一条条清 ——
/// 清不动的清单最后就没人看了。
func testTasksClaudeFinishedAreSynced() {
let store = AcceptanceStore(directory: nil)
store.add(task("写数据层"), to: project)
store.add(task("接 UI"), to: project)
store.syncAssistantTasks(done: ["写数据层"], in: project)
let items = store.ledger(for: project).items
XCTAssertEqual(items.first { $0.text == "写数据层" }?.status, .confirmed)
XCTAssertEqual(items.first { $0.text == "接 UI" }?.status, .open)
}
/// **同步不能碰用户终裁过的项。** 理由同 applyAudit:
/// 用户划掉了「这条不做了」,Claude 那边勾了完成也不该把它翻回来。
func testSyncDoesNotTouchUserVerdicts() {
let store = AcceptanceStore(directory: nil)
let item = task("这条我不要了")
store.add(item, to: project)
store.setStatus(.dropped, forID: item.id, in: project)
store.syncAssistantTasks(done: ["这条我不要了"], in: project)
XCTAssertEqual(store.ledger(for: project).items.first?.status, .dropped)
}
/// 同步只影响 `.assistantTask` 那一档。
///
/// Claude 勾掉自己的 todo,不代表用户提的需求就做完了 ——
/// 两者恰好同名时尤其危险("发布新版本")。
func testSyncOnlyAffectsAssistantTasks() {
let store = AcceptanceStore(directory: nil)
store.add(AcceptanceItem(text: "发布新版本", origin: .userPrompt), to: project)
store.syncAssistantTasks(done: ["发布新版本"], in: project)
XCTAssertEqual(
store.ledger(for: project).items.first?.status, .open,
"Claude 勾掉自己的 todo,不等于用户提的需求做完了"
)
}
/// 「还差几项」必须按**会话**算,不能按项目。
///
/// 用户问的是「这一轮它答应的做全了没」,跨会话混在一起答不了这个问题。
func testUnfinishedIsScopedToOneSession() {
let store = AcceptanceStore(directory: nil)
store.add(task("这一轮的"), to: project)
store.add(
AcceptanceItem(text: "上一轮的", origin: .assistantTask, sourceSessionId: "other"),
to: project
)
let pending = store.unfinishedAssistantTasks(sessionId: session, in: project)
XCTAssertEqual(pending.map(\.text), ["这一轮的"])
}
func testFinishedTasksAreNotReportedAsPending() {
let store = AcceptanceStore(directory: nil)
store.add(task("已经做完了", done: true), to: project)
XCTAssertTrue(store.unfinishedAssistantTasks(sessionId: session, in: project).isEmpty)
}
}
/// 逐条读 Claude 自己的 todo。
final class ClaudeTaskReadingTests: XCTestCase {
private var root: URL!
override func setUpWithError() throws {
root = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-tasks-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: root)
}
private func write(session: String, files: [(String, String)]) throws {
let dir = root.appendingPathComponent(session)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
for (name, body) in files {
try body.write(
to: dir.appendingPathComponent(name), atomically: true, encoding: .utf8
)
}
}
func testReadsSubjectsAndStatus() throws {
try write(session: "session-abcd1234", files: [
("1.json", #"{"id":"1","subject":"写数据层","status":"completed"}"#),
("2.json", #"{"id":"2","subject":"接 UI","status":"in_progress"}"#),
("3.json", #"{"id":"3","subject":"补测试","status":"pending"}"#),
])
let tasks = TaskStateReader(tasksDirectory: root).readTasks(sessionId: "abcd1234efgh")
XCTAssertEqual(tasks.map(\.subject), ["写数据层", "接 UI", "补测试"])
XCTAssertEqual(tasks.map(\.done), [true, false, false])
}
/// **按文件名的数值排序**,不是字符串序 —— 否则 "10" 会排到 "7" 前面,
/// 用户看到的任务顺序就和 Claude 界面上的对不上。
func testOrdersNumericallyNotLexicographically() throws {
try write(session: "session-abcd1234", files: [
("7.json", #"{"subject":"第七","status":"pending"}"#),
("10.json", #"{"subject":"第十","status":"pending"}"#),
])
let tasks = TaskStateReader(tasksDirectory: root).readTasks(sessionId: "abcd1234")
XCTAssertEqual(tasks.map(\.subject), ["第七", "第十"])
}
/// 单个文件坏掉不能让整个目录失效 —— 清单是边跑边写的,
/// 撞上半截 JSON 完全正常。
func testSkipsBrokenFiles() throws {
try write(session: "session-abcd1234", files: [
("1.json", #"{"subject":"好的","status":"pending"}"#),
("2.json", #"{"subject":"半截"#),
("3.json", #"{"status":"pending"}"#), // 没有 subject
])
let tasks = TaskStateReader(tasksDirectory: root).readTasks(sessionId: "abcd1234")
XCTAssertEqual(tasks.map(\.subject), ["好的"])
}
func testMissingSessionYieldsNothing() {
XCTAssertTrue(TaskStateReader(tasksDirectory: root).readTasks(sessionId: "nope").isEmpty)
}
}