Files
wuguanganandClaude Opus 5 920ad7505f 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>
2026-08-07 22:31:44 +08:00

194 lines
8.0 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
/// 一个会话的任务清单快照。
public struct TaskSnapshot: Sendable, Equatable {
public let pending: Int
public let inProgress: Int
public let completed: Int
/// 下一件该干的事的标题。优先取 `in_progress`,没有再取第一条 `pending`。
///
/// 提醒文案里"还差:阶段三 admin 网关面板"比"还有 2 项未完成"有用得多 ——
/// 后者还得跳过去才知道是什么。
public let nextSubject: String?
/// 任务目录的最后修改时刻。用来判断"用户是不是已经动过了"。
public let modifiedAt: Date?
public var unfinished: Int { pending + inProgress }
public var total: Int { pending + inProgress + completed }
public var isEmpty: Bool { total == 0 }
public init(
pending: Int, inProgress: Int, completed: Int,
nextSubject: String? = nil, modifiedAt: Date? = nil
) {
self.pending = pending
self.inProgress = inProgress
self.completed = completed
self.nextSubject = nextSubject
self.modifiedAt = modifiedAt
}
}
/// 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、不花额度。
/// 本机对过账:会话 `gateway-config-management` 读出
/// `{completed: 2, in_progress: 1, pending: 1}`,
/// 与 Claude 自己界面上显示的 `4 tasks (2 done, 1 in progress, 1 open)` 完全一致。
///
/// 注意**不是每个会话都有**:实测 7 个会话里 1 个有清单、4 个目录在但是空的、
/// 2 个连目录都没有。所以这个探针命中率有限,测不到的交给 AI 那层兜底。
public struct TaskStateReader: Sendable {
public let tasksDirectory: URL
public init(tasksDirectory: URL? = nil) {
self.tasksDirectory = tasksDirectory ?? FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".claude/tasks", isDirectory: true)
}
/// 会话目录有两种历史命名,都要认:
/// - `session-<sessionId 前 8 位>`(当前形态)
/// - `<完整 sessionId>`(旧形态,本机仍有残留)
func candidateDirectories(for sessionId: String) -> [URL] {
var result: [URL] = []
if sessionId.count >= 8 {
result.append(
tasksDirectory.appendingPathComponent(
"session-\(sessionId.prefix(8))", isDirectory: true
)
)
}
result.append(tasksDirectory.appendingPathComponent(sessionId, isDirectory: true))
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: {
var isDir: ObjCBool = false
return fm.fileExists(atPath: $0.path, isDirectory: &isDir) && isDir.boolValue
}) else { return nil }
guard let entries = try? fm.contentsOfDirectory(
at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]
) else { return nil }
var pending = 0, inProgress = 0, completed = 0
var openTasks: [(order: Int, subject: String, running: Bool)] = []
for file in entries where file.pathExtension == "json" {
// 目录里混着 .lock 和 .highwatermark。`.skipsHiddenFiles` 已经滤掉了它们
// (都是点开头),扩展名判断是第二道保险。
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
else {
// **单个文件坏掉不能让整个目录失效。** 任务清单是边跑边写的,
// 撞上正在写入的半截 json 完全正常,跳过它继续读其余的。
continue
}
let subject = (task["subject"] as? String) ?? ""
// 文件名是 "7.json"、"10.json",按数值排序才是任务的真实顺序;
// 按字符串排的话 "10" 会排到 "7" 前面。
let order = Int(file.deletingPathExtension().lastPathComponent) ?? Int.max
switch status {
case "completed": completed += 1
case "in_progress":
inProgress += 1
openTasks.append((order, subject, true))
default:
pending += 1
openTasks.append((order, subject, false))
}
}
// 正在做的优先;同类按任务编号。
let next = openTasks
.sorted { lhs, rhs in
if lhs.running != rhs.running { return lhs.running }
return lhs.order < rhs.order
}
.first
.map(\.subject)
.flatMap { $0.isEmpty ? nil : $0 }
let modified = (try? fm.attributesOfItem(atPath: dir.path)[.modificationDate]) as? Date
return TaskSnapshot(
pending: pending, inProgress: inProgress, completed: completed,
nextSubject: next, modifiedAt: modified
)
}
}