// // Planner.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The externalized todo list (docs/AGENT-RESEARCH.md §5.1): the model // creates and maintains the plan by calling the `update_plan` TOOL — the // reliable pattern (Claude Code's TodoWrite) versus parsing a plan out of // prose. `update_plan` is intercepted by AgentLoop and handled here; it is // NOT a side effect, so it never passes the PolicyEngine and never reaches // the ToolRegistry. The plan persists to `.zyquo/plan.json` on every change // and is re-injected into context after each compaction, so it survives by // construction (§3.3 rule 1). // import Foundation /// State of one plan item, mirrored by the Plan panel in Phase 6. enum PlanItemStatus: String, Codable, Sendable { case pending case active case done case failed } /// One checklist entry of the task plan. struct PlanItem: Codable, Identifiable, Hashable, Sendable { var id: UUID = UUID() var title: String var status: PlanItemStatus = .pending /// Short model-provided note (why it failed, what changed…). var note: String? } /// The live task plan the agent maintains. struct TaskPlan: Codable, Hashable, Sendable { var items: [PlanItem] = [] var updatedAt: Date = Date() var doneCount: Int { items.filter { $0.status == .done }.count } var failedCount: Int { items.filter { $0.status == .failed }.count } /// Human-readable checklist (context injection, CLI, exports). func rendered() -> String { guard !items.isEmpty else { return "(empty plan)" } return items.map { item in let box: String switch item.status { case .pending: box = "[ ]" case .active: box = "[>]" case .done: box = "[x]" case .failed: box = "[!]" } let note = item.note.map { " — \($0)" } ?? "" return "\(box) \(item.title)\(note)" }.joined(separator: "\n") } } /// Owns the plan for one run: applies `update_plan` calls, persists to /// `.zyquo/plan.json`, and renders the plan for context injection. Plain /// struct — mutated only inside the AgentLoop actor. struct Planner { /// Wire name of the internal plan tool (intercepted by AgentLoop). static let toolName = "update_plan" /// The `update_plan` spec offered to the model alongside the real tools. static let toolSpec = ToolSpec( name: toolName, description: """ Create or update your task plan (a short ordered checklist the \ user watches live). Call this FIRST on any non-trivial task to \ draft the plan, then again whenever an item's status changes or \ the approach changes — resend the COMPLETE list every time (it \ replaces the previous plan). Keep exactly one item "active" \ while working; mark items "done" immediately when verified and \ "failed" (with a note) when abandoned. This tool only records \ the plan — it has no other effect and needs no approval. """, parametersJSONSchema: #""" {"type":"object","properties":{"items":{"type":"array","description":"The complete current plan, in execution order.","items":{"type":"object","properties":{"title":{"type":"string","description":"Short imperative step description."},"status":{"type":"string","enum":["pending","active","done","failed"],"description":"Current state of this step."},"note":{"type":"string","description":"Optional short note (failure reason, change of approach…)."}},"required":["title","status"]}}},"required":["items"]} """# ) private(set) var plan: TaskPlan? private let fileURL: URL /// `workspaceRoot/.zyquo/plan.json`; reattaching an existing workspace /// reloads its persisted plan. init(workspaceRoot: URL) { self.fileURL = workspaceRoot .appendingPathComponent(".zyquo") .appendingPathComponent("plan.json") if let data = try? Data(contentsOf: fileURL), let existing = try? Self.decoder.decode(TaskPlan.self, from: data) { self.plan = existing } } /// Applies one `update_plan` call: parses the items, preserves stable /// item IDs by position where titles match, persists, and returns the /// ToolResult to thread back plus whether the plan actually changed /// (LoopGuard progress signal). mutating func apply(call: ToolCall) -> (result: ToolResult, changed: Bool) { guard let arguments = call.argumentsDictionary, let rawItems = arguments["items"] as? [[String: Any]] else { return (ToolResult( toolCallID: call.id, content: "update_plan: expected {\"items\":[{\"title\":…,\"status\":…}]} — arguments were not a valid items array. Re-issue with the full plan.", isError: true ), false) } var newItems: [PlanItem] = [] for (index, raw) in rawItems.enumerated() { guard let title = raw["title"] as? String, !title.isEmpty else { return (ToolResult( toolCallID: call.id, content: "update_plan: item \(index + 1) is missing a non-empty `title`.", isError: true ), false) } let status = (raw["status"] as? String).flatMap(PlanItemStatus.init(rawValue:)) ?? .pending let note = raw["note"] as? String // Keep the item ID stable when the same title sits at the same // position (the Plan panel animates state changes, not churn). let previous = plan?.items.indices.contains(index) == true ? plan?.items[index] : nil let id = (previous?.title == title) ? previous!.id : UUID() newItems.append(PlanItem(id: id, title: title, status: status, note: note?.isEmpty == true ? nil : note)) } func signature(_ items: [PlanItem]) -> String { items.map { "\($0.title)|\($0.status.rawValue)|\($0.note ?? "")" }.joined(separator: "\n") } let changed = plan.map { signature($0.items) != signature(newItems) } ?? true plan = TaskPlan(items: newItems, updatedAt: Date()) persist() let counts = statusCounts(of: newItems) return (ToolResult( toolCallID: call.id, content: "Plan recorded (\(newItems.count) item\(newItems.count == 1 ? "" : "s"): \(counts))." ), changed) } /// The plan rendered for context re-injection after compaction; nil when /// no plan exists yet. func renderedForContext() -> String? { plan?.rendered() } // MARK: - Helpers private func statusCounts(of items: [PlanItem]) -> String { var counts: [PlanItemStatus: Int] = [:] for item in items { counts[item.status, default: 0] += 1 } return [PlanItemStatus.done, .active, .pending, .failed] .compactMap { status in counts[status].map { "\($0) \(status.rawValue)" } } .joined(separator: ", ") } private func persist() { guard let plan else { return } do { try FileManager.default.createDirectory( at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true ) try Self.encoder.encode(plan).write(to: fileURL, options: .atomic) } catch { FileHandle.standardError.write(Data("Planner: plan.json save failed: \(error)\n".utf8)) } } private static let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] encoder.dateEncodingStrategy = .iso8601 return encoder }() private static let decoder: JSONDecoder = { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder }() }