From 686fb376300f6ce1672f8f5191c026fba5e58470 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Fri, 1 May 2026 12:37:48 +0200 Subject: [PATCH] feat(hermes-v12): Curator feature module on Mac + iOS (Phase D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes v0.12 ships an autonomous Curator that prunes / consolidates agent-created skills on a 7-day cycle. This phase brings that surface into Scarf so users can see status, trigger runs, pin protected skills, and restore archived ones. Pipeline: - HermesCuratorStatus + HermesCuratorSkillRow: Sendable value types for parsed status + per-skill leaderboard rows. - HermesCuratorStatusParser: pure text parser for `hermes curator status` stdout (no `--json` flag exists upstream). Tolerates Hermes's whitespace-padded leaderboard layout (`activity= 0` with N spaces between `=` and the value) by slicing between known key positions rather than splitting on whitespace. State-file JSON overrides text-parsed values for last_run_at / last_run_summary / last_report_path because the file carries full ISO timestamps the text output may have rounded. - CuratorViewModel: @Observable @MainActor, drives the CLI verbs (status / run / pause / resume / pin / unpin / restore) via transport.runProcess so it works equally over local and Citadel SSH. - HermesPathSet: adds curatorLogsDir + curatorStateFile (the latter is `.curator_state` with no extension despite holding JSON). Mac: - Features/Curator/Views/CuratorView.swift — page-header + status card + skill counts + pinned chips + 3 leaderboard tables (least recent, most active, least active) with inline pin toggles and a per-skill counter chip row. "Run Now" button + a kebab menu for Pause/Resume + Restore Archived. - Features/Curator/Views/CuratorRestoreSheet.swift — name-entry sheet for `hermes curator restore `. Free-form text field; Hermes doesn't ship a `curator list-archived` yet so we don't synthesize a picker. - Sidebar: AppCoordinator + SidebarView gain a `.curator` case under Interact (between Memory and Skills); the row is filtered out by SidebarView's capability-aware `sections` computed property when `HermesCapabilities.hasCurator` is false. ContentView routes `.curator` to CuratorView. Pre-v0.12 hosts see the v0.11 sidebar unchanged. iOS: - Scarf iOS/Curator/CuratorView.swift — read-mostly List with the same status / skill counts / pinned / leaderboards + inline pin toggles. Run Now / Pause / Resume actions in the section footer. - ScarfGoTabRoot's System tab gains a Curator NavigationLink under Features, gated on `hasCurator`. Uses a stable `systemTabContextID` so the SSH transport pool reuses the cached Citadel connection keyed by that id. Tests: 6 new parser tests (215 total, all green). Locks the empty-state output captured from a real v0.12.0 install + paused-state + state-file override + multi-word-name-row parsing. Both Mac and iOS schemes build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/HermesCuratorReport.swift | 361 ++++++++++++++++++ .../ScarfCore/Models/HermesPathSet.swift | 17 +- .../ViewModels/CuratorViewModel.swift | 125 ++++++ .../HermesCuratorParserTests.swift | 154 ++++++++ scarf/Scarf iOS/App/ScarfGoTabRoot.swift | 19 + scarf/Scarf iOS/Curator/CuratorView.swift | 193 ++++++++++ scarf/scarf/ContentView.swift | 1 + .../Curator/Views/CuratorRestoreSheet.swift | 67 ++++ .../Features/Curator/Views/CuratorView.swift | 322 ++++++++++++++++ scarf/scarf/Navigation/AppCoordinator.swift | 3 + scarf/scarf/Navigation/SidebarView.swift | 31 +- 11 files changed, 1279 insertions(+), 14 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/CuratorViewModel.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCuratorParserTests.swift create mode 100644 scarf/Scarf iOS/Curator/CuratorView.swift create mode 100644 scarf/scarf/Features/Curator/Views/CuratorRestoreSheet.swift create mode 100644 scarf/scarf/Features/Curator/Views/CuratorView.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift new file mode 100644 index 0000000..4a5e109 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorReport.swift @@ -0,0 +1,361 @@ +import Foundation + +/// Parsed view of `hermes curator status` text + the on-disk +/// `~/.hermes/skills/.curator_state` JSON. +/// +/// Hermes v0.12 doesn't ship a `--json` flag for `curator status` — the +/// CLI writes a human-readable report. CuratorViewModel parses the text +/// output for the human-readable bits ("least recently active", "most +/// active") and reads the state file directly for last-run metadata. +public struct HermesCuratorStatus: Sendable, Equatable { + public enum RunState: String, Sendable, Equatable { + case enabled + case paused + case disabled + case unknown + } + + public let state: RunState + public let runCount: Int + public let lastRunISO: String? // raw timestamp string, parsed by callers + public let lastSummary: String? // free-text summary line + public let lastReportPath: String? // absolute path to / dir + public let intervalLabel: String // e.g. "every 7d" + public let staleAfterLabel: String // e.g. "30d unused" + public let archiveAfterLabel: String // e.g. "90d unused" + + public let totalSkills: Int + public let activeSkills: Int + public let staleSkills: Int + public let archivedSkills: Int + + public let pinnedNames: [String] + + /// Top-5 lists rendered in the curator output. Each row carries the + /// skill name + the four counters Hermes prints. + public let leastRecentlyActive: [HermesCuratorSkillRow] + public let mostActive: [HermesCuratorSkillRow] + public let leastActive: [HermesCuratorSkillRow] + + public init( + state: RunState, + runCount: Int, + lastRunISO: String?, + lastSummary: String?, + lastReportPath: String?, + intervalLabel: String, + staleAfterLabel: String, + archiveAfterLabel: String, + totalSkills: Int, + activeSkills: Int, + staleSkills: Int, + archivedSkills: Int, + pinnedNames: [String], + leastRecentlyActive: [HermesCuratorSkillRow], + mostActive: [HermesCuratorSkillRow], + leastActive: [HermesCuratorSkillRow] + ) { + self.state = state + self.runCount = runCount + self.lastRunISO = lastRunISO + self.lastSummary = lastSummary + self.lastReportPath = lastReportPath + self.intervalLabel = intervalLabel + self.staleAfterLabel = staleAfterLabel + self.archiveAfterLabel = archiveAfterLabel + self.totalSkills = totalSkills + self.activeSkills = activeSkills + self.staleSkills = staleSkills + self.archivedSkills = archivedSkills + self.pinnedNames = pinnedNames + self.leastRecentlyActive = leastRecentlyActive + self.mostActive = mostActive + self.leastActive = leastActive + } + + public static let empty = HermesCuratorStatus( + state: .unknown, + runCount: 0, + lastRunISO: nil, + lastSummary: nil, + lastReportPath: nil, + intervalLabel: "—", + staleAfterLabel: "—", + archiveAfterLabel: "—", + totalSkills: 0, + activeSkills: 0, + staleSkills: 0, + archivedSkills: 0, + pinnedNames: [], + leastRecentlyActive: [], + mostActive: [], + leastActive: [] + ) +} + +public struct HermesCuratorSkillRow: Sendable, Equatable, Identifiable { + public var id: String { name } + public let name: String + public let activityCount: Int + public let useCount: Int + public let viewCount: Int + public let patchCount: Int + public let lastActivityLabel: String // raw label as printed (e.g. "never", "2d ago") + + public init( + name: String, + activityCount: Int, + useCount: Int, + viewCount: Int, + patchCount: Int, + lastActivityLabel: String + ) { + self.name = name + self.activityCount = activityCount + self.useCount = useCount + self.viewCount = viewCount + self.patchCount = patchCount + self.lastActivityLabel = lastActivityLabel + } +} + +/// Pure parser for `hermes curator status` stdout. Public for tests. +/// +/// Format is stable enough to text-parse; we never error on missing +/// sections — we just leave the corresponding field empty so +/// CuratorView can render "—" without crashing on a future layout +/// tweak. State file overrides text-parsed values when both are present. +public enum HermesCuratorStatusParser { + public static func parse(text: String, stateFileJSON: Data? = nil) -> HermesCuratorStatus { + let lines = text.components(separatedBy: "\n") + var status = HermesCuratorStatus.empty + + // Header section: `curator: ENABLED` / `runs:` / `last run:` / + // `last summary:` / `interval:` / `stale after:` / `archive after:` + var state = HermesCuratorStatus.RunState.unknown + var runCount = 0 + var lastRunISO: String? + var lastSummary: String? + var lastReportPath: String? + var interval = "—" + var stale = "—" + var archive = "—" + + // Skill counts: `agent-created skills: N total` then + // ` active N` / ` stale N` / ` archived N` + var total = 0 + var active = 0 + var staleCount = 0 + var archived = 0 + + var pinned: [String] = [] + + // Lists: `least recently active (top 5):` / `most active (top 5):` / + // `least active (top 5):` followed by indented row lines. + enum Section { + case header + case leastRecent + case mostActive + case leastActive + } + var section = Section.header + var leastRecent: [HermesCuratorSkillRow] = [] + var mostActiveRows: [HermesCuratorSkillRow] = [] + var leastActiveRows: [HermesCuratorSkillRow] = [] + + for raw in lines { + let line = raw.trimmingCharacters(in: .whitespaces) + // Section markers + if line.hasPrefix("least recently active") { + section = .leastRecent + continue + } + if line.hasPrefix("most active") { + section = .mostActive + continue + } + if line.hasPrefix("least active") { + section = .leastActive + continue + } + + // Header section single-line keys + if line.hasPrefix("curator:") { + let val = String(line.dropFirst("curator:".count)).trimmingCharacters(in: .whitespaces).uppercased() + switch val { + case "ENABLED": state = .enabled + case "PAUSED": state = .paused + case "DISABLED": state = .disabled + default: state = .unknown + } + continue + } + if line.hasPrefix("runs:") { + runCount = Int(line.dropFirst("runs:".count).trimmingCharacters(in: .whitespaces)) ?? 0 + continue + } + if line.hasPrefix("last run:") { + let val = String(line.dropFirst("last run:".count)).trimmingCharacters(in: .whitespaces) + lastRunISO = val == "never" ? nil : val + continue + } + if line.hasPrefix("last summary:") { + let val = String(line.dropFirst("last summary:".count)).trimmingCharacters(in: .whitespaces) + lastSummary = (val == "(none)" || val.isEmpty) ? nil : val + continue + } + if line.hasPrefix("last report:") { + let val = String(line.dropFirst("last report:".count)).trimmingCharacters(in: .whitespaces) + lastReportPath = val.isEmpty ? nil : val + continue + } + if line.hasPrefix("interval:") { + interval = String(line.dropFirst("interval:".count)).trimmingCharacters(in: .whitespaces) + continue + } + if line.hasPrefix("stale after:") { + stale = String(line.dropFirst("stale after:".count)).trimmingCharacters(in: .whitespaces) + continue + } + if line.hasPrefix("archive after:") { + archive = String(line.dropFirst("archive after:".count)).trimmingCharacters(in: .whitespaces) + continue + } + + // `agent-created skills: 18 total` + if line.hasPrefix("agent-created skills:") { + let after = line.dropFirst("agent-created skills:".count).trimmingCharacters(in: .whitespaces) + if let n = Int(after.split(separator: " ").first ?? "") { + total = n + } + section = .header + continue + } + // Counts: "active 18" / "stale 0" / "archived 0" + if let row = parseStateCountRow(line) { + switch row.state { + case "active": active = row.count + case "stale": staleCount = row.count + case "archived": archived = row.count + default: break + } + continue + } + // pinned (3): foo, bar, baz + if line.hasPrefix("pinned (") { + if let colon = line.firstIndex(of: ":") { + let names = line[line.index(after: colon)...] + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + pinned = names + } + continue + } + + // Skill rows like: + // activity= N use= N view= N patches= N last_activity=