用户一眼看出的缺口:清单里全是他说过的话,**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>
194 lines
8.0 KiB
Swift
194 lines
8.0 KiB
Swift
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
|
||
)
|
||
}
|
||
}
|