spb/zyquo-agent Public MIT
The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.
Swift 94.7%
Shell 4.1%
Python 0.7%
Makefile 0.5%
1//2// Planner.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// The externalized todo list (docs/AGENT-RESEARCH.md §5.1): the model9// creates and maintains the plan by calling the `update_plan` TOOL — the10// reliable pattern (Claude Code's TodoWrite) versus parsing a plan out of11// prose. `update_plan` is intercepted by AgentLoop and handled here; it is12// NOT a side effect, so it never passes the PolicyEngine and never reaches13// the ToolRegistry. The plan persists to `.zyquo/plan.json` on every change14// and is re-injected into context after each compaction, so it survives by15// construction (§3.3 rule 1).16//1718import Foundation1920/// State of one plan item, mirrored by the Plan panel in Phase 6.21enum PlanItemStatus: String, Codable, Sendable {22 case pending23 case active24 case done25 case failed26}2728/// One checklist entry of the task plan.29struct PlanItem: Codable, Identifiable, Hashable, Sendable {30 var id: UUID = UUID()31 var title: String32 var status: PlanItemStatus = .pending33 /// Short model-provided note (why it failed, what changed…).34 var note: String?35}3637/// The live task plan the agent maintains.38struct TaskPlan: Codable, Hashable, Sendable {39 var items: [PlanItem] = []40 var updatedAt: Date = Date()4142 var doneCount: Int { items.filter { $0.status == .done }.count }43 var failedCount: Int { items.filter { $0.status == .failed }.count }4445 /// Human-readable checklist (context injection, CLI, exports).46 func rendered() -> String {47 guard !items.isEmpty else { return "(empty plan)" }48 return items.map { item in49 let box: String50 switch item.status {51 case .pending: box = "[ ]"52 case .active: box = "[>]"53 case .done: box = "[x]"54 case .failed: box = "[!]"55 }56 let note = item.note.map { " — \($0)" } ?? ""57 return "\(box) \(item.title)\(note)"58 }.joined(separator: "\n")59 }60}6162/// Owns the plan for one run: applies `update_plan` calls, persists to63/// `.zyquo/plan.json`, and renders the plan for context injection. Plain64/// struct — mutated only inside the AgentLoop actor.65struct Planner {66 /// Wire name of the internal plan tool (intercepted by AgentLoop).67 static let toolName = "update_plan"6869 /// The `update_plan` spec offered to the model alongside the real tools.70 static let toolSpec = ToolSpec(71 name: toolName,72 description: """73 Create or update your task plan (a short ordered checklist the \74 user watches live). Call this FIRST on any non-trivial task to \75 draft the plan, then again whenever an item's status changes or \76 the approach changes — resend the COMPLETE list every time (it \77 replaces the previous plan). Keep exactly one item "active" \78 while working; mark items "done" immediately when verified and \79 "failed" (with a note) when abandoned. This tool only records \80 the plan — it has no other effect and needs no approval.81 """,82 parametersJSONSchema: #"""83 {"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"]}84 """#85 )8687 private(set) var plan: TaskPlan?88 private let fileURL: URL8990 /// `workspaceRoot/.zyquo/plan.json`; reattaching an existing workspace91 /// reloads its persisted plan.92 init(workspaceRoot: URL) {93 self.fileURL = workspaceRoot94 .appendingPathComponent(".zyquo")95 .appendingPathComponent("plan.json")96 if let data = try? Data(contentsOf: fileURL),97 let existing = try? Self.decoder.decode(TaskPlan.self, from: data) {98 self.plan = existing99 }100 }101102 /// Applies one `update_plan` call: parses the items, preserves stable103 /// item IDs by position where titles match, persists, and returns the104 /// ToolResult to thread back plus whether the plan actually changed105 /// (LoopGuard progress signal).106 mutating func apply(call: ToolCall) -> (result: ToolResult, changed: Bool) {107 guard let arguments = call.argumentsDictionary,108 let rawItems = arguments["items"] as? [[String: Any]] else {109 return (ToolResult(110 toolCallID: call.id,111 content: "update_plan: expected {\"items\":[{\"title\":…,\"status\":…}]} — arguments were not a valid items array. Re-issue with the full plan.",112 isError: true113 ), false)114 }115116 var newItems: [PlanItem] = []117 for (index, raw) in rawItems.enumerated() {118 guard let title = raw["title"] as? String, !title.isEmpty else {119 return (ToolResult(120 toolCallID: call.id,121 content: "update_plan: item \(index + 1) is missing a non-empty `title`.",122 isError: true123 ), false)124 }125 let status = (raw["status"] as? String).flatMap(PlanItemStatus.init(rawValue:)) ?? .pending126 let note = raw["note"] as? String127 // Keep the item ID stable when the same title sits at the same128 // position (the Plan panel animates state changes, not churn).129 let previous = plan?.items.indices.contains(index) == true ? plan?.items[index] : nil130 let id = (previous?.title == title) ? previous!.id : UUID()131 newItems.append(PlanItem(id: id, title: title, status: status, note: note?.isEmpty == true ? nil : note))132 }133134 func signature(_ items: [PlanItem]) -> String {135 items.map { "\($0.title)|\($0.status.rawValue)|\($0.note ?? "")" }.joined(separator: "\n")136 }137 let changed = plan.map { signature($0.items) != signature(newItems) } ?? true138139 plan = TaskPlan(items: newItems, updatedAt: Date())140 persist()141142 let counts = statusCounts(of: newItems)143 return (ToolResult(144 toolCallID: call.id,145 content: "Plan recorded (\(newItems.count) item\(newItems.count == 1 ? "" : "s"): \(counts))."146 ), changed)147 }148149 /// The plan rendered for context re-injection after compaction; nil when150 /// no plan exists yet.151 func renderedForContext() -> String? {152 plan?.rendered()153 }154155 // MARK: - Helpers156157 private func statusCounts(of items: [PlanItem]) -> String {158 var counts: [PlanItemStatus: Int] = [:]159 for item in items { counts[item.status, default: 0] += 1 }160 return [PlanItemStatus.done, .active, .pending, .failed]161 .compactMap { status in counts[status].map { "\($0) \(status.rawValue)" } }162 .joined(separator: ", ")163 }164165 private func persist() {166 guard let plan else { return }167 do {168 try FileManager.default.createDirectory(169 at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true170 )171 try Self.encoder.encode(plan).write(to: fileURL, options: .atomic)172 } catch {173 FileHandle.standardError.write(Data("Planner: plan.json save failed: \(error)\n".utf8))174 }175 }176177 private static let encoder: JSONEncoder = {178 let encoder = JSONEncoder()179 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]180 encoder.dateEncodingStrategy = .iso8601181 return encoder182 }()183184 private static let decoder: JSONDecoder = {185 let decoder = JSONDecoder()186 decoder.dateDecodingStrategy = .iso8601187 return decoder188 }()189}190