feat(hermes-v12): Curator feature module on Mac + iOS (Phase D)

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 <skill>`. 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 12:37:48 +02:00
parent 1354568992
commit 686fb37630
11 changed files with 1279 additions and 14 deletions
@@ -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 <YYYYMMDD-HHMMSS>/ 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:
// <name> activity= N use= N view= N patches= N last_activity=<label>
if section != .header, let parsed = parseSkillRow(line) {
switch section {
case .leastRecent: leastRecent.append(parsed)
case .mostActive: mostActiveRows.append(parsed)
case .leastActive: leastActiveRows.append(parsed)
case .header: break
}
}
}
// Apply state-file overrides if present. The .curator_state JSON
// is authoritative for last_run_at / last_run_summary /
// last_report_path because those carry timestamps the text
// output rounds.
if let json = stateFileJSON,
let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] {
if obj["paused"] as? Bool == true { state = .paused }
if let count = obj["run_count"] as? Int { runCount = count }
if let lr = obj["last_run_at"] as? String { lastRunISO = lr }
if let summary = obj["last_run_summary"] as? String, !summary.isEmpty { lastSummary = summary }
if let path = obj["last_report_path"] as? String, !path.isEmpty { lastReportPath = path }
}
status = HermesCuratorStatus(
state: state,
runCount: runCount,
lastRunISO: lastRunISO,
lastSummary: lastSummary,
lastReportPath: lastReportPath,
intervalLabel: interval,
staleAfterLabel: stale,
archiveAfterLabel: archive,
totalSkills: total,
activeSkills: active,
staleSkills: staleCount,
archivedSkills: archived,
pinnedNames: pinned,
leastRecentlyActive: leastRecent,
mostActive: mostActiveRows,
leastActive: leastActiveRows
)
return status
}
/// `active 18` style row inside the skill-count block.
private static func parseStateCountRow(_ line: String) -> (state: String, count: Int)? {
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
guard parts.count >= 2,
["active", "stale", "archived"].contains(parts[0]),
let count = Int(parts[1])
else { return nil }
return (parts[0], count)
}
/// Skill-list row parser. Tolerates Hermes's whitespace-padded
/// layout `activity= 0` has two spaces between `=` and the
/// number, so we can't split-on-space-then-split-on-`=`. Instead
/// we slide a key-detection cursor across the row and grab the
/// next non-whitespace token after each known key.
private static func parseSkillRow(_ line: String) -> HermesCuratorSkillRow? {
guard let activityRange = line.range(of: "activity=") else { return nil }
let name = String(line[..<activityRange.lowerBound]).trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return nil }
// Map each known key to its value substring. Read positionally
// by slicing between consecutive known keys handles arbitrary
// whitespace padding without depending on column positions.
let knownKeys = ["activity=", "use=", "view=", "patches=", "last_activity="]
var positions: [(key: String, range: Range<String.Index>)] = []
for key in knownKeys {
if let r = line.range(of: key) {
positions.append((key, r))
}
}
positions.sort { $0.range.lowerBound < $1.range.lowerBound }
var activity = 0, use = 0, view = 0, patch = 0
var lastActivity = ""
for (idx, entry) in positions.enumerated() {
let valueStart = entry.range.upperBound
let valueEnd = idx + 1 < positions.count
? positions[idx + 1].range.lowerBound
: line.endIndex
let raw = String(line[valueStart..<valueEnd]).trimmingCharacters(in: .whitespaces)
switch entry.key {
case "activity=": activity = Int(raw) ?? 0
case "use=": use = Int(raw) ?? 0
case "view=": view = Int(raw) ?? 0
case "patches=": patch = Int(raw) ?? 0
case "last_activity=": lastActivity = raw
default: break
}
}
return HermesCuratorSkillRow(
name: name,
activityCount: activity,
useCount: use,
viewCount: view,
patchCount: patch,
lastActivityLabel: lastActivity
)
}
}
@@ -75,12 +75,17 @@ public struct HermesPathSet: Sendable, Hashable {
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
public nonisolated var agentLog: String { home + "/logs/agent.log" }
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
/// Curator run report, JSON (v0.12+). Written by `hermes curator` on
/// each cycle; consumed by `CuratorViewModel` for structured stats.
public nonisolated var curatorReportJSON: String { home + "/logs/curator/run.json" }
/// Curator human-readable run report (v0.12+). Renders as the
/// "Last run" text in CuratorView.
public nonisolated var curatorReportMD: String { home + "/logs/curator/REPORT.md" }
/// Curator run-reports root (v0.12+). Hermes writes per-cycle dirs
/// under here named `<YYYYMMDD-HHMMSS>/` containing `run.json` and
/// `REPORT.md`. The `last_report_path` field on `curator_state`
/// points at the most recent dir; `CuratorViewModel` resolves the
/// JSON/Markdown files relative to it.
public nonisolated var curatorLogsDir: String { home + "/logs/curator" }
/// JSON-encoded curator state (v0.12+). Filename has no extension
/// despite holding JSON Hermes writes it via
/// `~/.hermes/skills/.curator_state`. Carries last-run metadata,
/// run count, pause flag, and the path to the most recent report.
public nonisolated var curatorStateFile: String { home + "/skills/.curator_state" }
public nonisolated var scarfDir: String { home + "/scarf" }
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
@@ -0,0 +1,125 @@
import Foundation
import Observation
#if canImport(os)
import os
#endif
/// Mac + iOS view model for the v0.12 Curator surface.
///
/// Drives `hermes curator status / run / pause / resume / pin / unpin /
/// restore` plus a parsed view of `~/.hermes/skills/.curator_state`
/// JSON. The CLI doesn't ship a `--json` flag for `status`, so we
/// text-parse stdout (HermesCuratorStatusParser) and use the state
/// file for richer last-run metadata.
///
/// Capability-gated: callers should construct this only when
/// `HermesCapabilities.hasCurator` is true. The view model does not
/// gate itself the gate happens at sidebar/tab routing time.
@Observable
@MainActor
public final class CuratorViewModel {
#if canImport(os)
private let logger = Logger(subsystem: "com.scarf", category: "CuratorViewModel")
#endif
public let context: ServerContext
public private(set) var status: HermesCuratorStatus = .empty
public private(set) var isLoading = false
public private(set) var lastReportMarkdown: String?
public var transientMessage: String?
public init(context: ServerContext) {
self.context = context
}
public func load() async {
isLoading = true
defer { isLoading = false }
let context = self.context
let parsed = await Task.detached(priority: .userInitiated) { () -> (HermesCuratorStatus, String?) in
let textResult = Self.runCuratorStatus(context: context)
let stateData = context.readData(context.paths.curatorStateFile)
let parsed = HermesCuratorStatusParser.parse(text: textResult, stateFileJSON: stateData)
// Best-effort markdown report: the state file points at the
// most recent <YYYYMMDD-HHMMSS>/ dir; load REPORT.md from
// there. Missing on first run, which is fine.
var report: String?
if let reportDir = parsed.lastReportPath {
let reportPath = reportDir.hasSuffix("/")
? "\(reportDir)REPORT.md"
: "\(reportDir)/REPORT.md"
report = context.readText(reportPath)
}
return (parsed, report)
}.value
self.status = parsed.0
self.lastReportMarkdown = parsed.1
}
public func runNow() async {
await runAndReload(args: ["curator", "run"], successMessage: "Curator run started")
}
public func pause() async {
await runAndReload(args: ["curator", "pause"], successMessage: "Curator paused")
}
public func resume() async {
await runAndReload(args: ["curator", "resume"], successMessage: "Curator resumed")
}
public func pin(_ skill: String) async {
await runAndReload(args: ["curator", "pin", skill], successMessage: "Pinned \(skill)")
}
public func unpin(_ skill: String) async {
await runAndReload(args: ["curator", "unpin", skill], successMessage: "Unpinned \(skill)")
}
public func restore(_ skill: String) async {
await runAndReload(args: ["curator", "restore", skill], successMessage: "Restored \(skill)")
}
private func runAndReload(args: [String], successMessage: String) async {
let context = self.context
let exitCode = await Task.detached(priority: .userInitiated) {
Self.runHermes(context: context, args: args).exitCode
}.value
transientMessage = exitCode == 0 ? successMessage : "Command failed"
await load()
// Auto-clear toast after 3s.
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 3_000_000_000)
self?.transientMessage = nil
}
}
/// Wrap the transport-level `runProcess` so the call sites don't
/// have to reach for it directly. Combined stdout+stderr.
nonisolated private static func runHermes(
context: ServerContext,
args: [String]
) -> (exitCode: Int32, output: String) {
let transport = context.makeTransport()
do {
let result = try transport.runProcess(
executable: context.paths.hermesBinary,
args: args,
stdin: nil,
timeout: 30
)
return (result.exitCode, result.stdoutString + result.stderrString)
} catch let error as TransportError {
return (-1, error.diagnosticStderr.isEmpty
? (error.errorDescription ?? "transport error")
: error.diagnosticStderr)
} catch {
return (-1, error.localizedDescription)
}
}
nonisolated private static func runCuratorStatus(context: ServerContext) -> String {
runHermes(context: context, args: ["curator", "status"]).output
}
}
@@ -0,0 +1,154 @@
import Testing
import Foundation
@testable import ScarfCore
@Suite struct HermesCuratorParserTests {
/// Real `hermes curator status` output captured from a v0.12.0
/// install with no curator runs yet. Locks in the empty-state
/// happy path so a Hermes layout tweak surfaces here before
/// CuratorView starts rendering "" placeholders silently.
private static let realFreshOutput = """
curator: ENABLED
runs: 0
last run: never
last summary: (none)
interval: every 7d
stale after: 30d unused
archive after: 90d unused
agent-created skills: 18 total
active 18
stale 0
archived 0
least recently active (top 5):
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
least active (top 5):
Scarf Dashboard Chart Widget Parse Error Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
Scarf Project Registry Format Fix activity= 0 use= 0 view= 0 patches= 0 last_activity=never
clip activity= 0 use= 0 view= 0 patches= 0 last_activity=never
find-nearby activity= 0 use= 0 view= 0 patches= 0 last_activity=never
gguf-quantization activity= 0 use= 0 view= 0 patches= 0 last_activity=never
"""
@Test func parseRealFreshOutput() {
let s = HermesCuratorStatusParser.parse(text: Self.realFreshOutput)
#expect(s.state == .enabled)
#expect(s.runCount == 0)
#expect(s.lastRunISO == nil)
#expect(s.lastSummary == nil)
#expect(s.intervalLabel == "every 7d")
#expect(s.staleAfterLabel == "30d unused")
#expect(s.archiveAfterLabel == "90d unused")
#expect(s.totalSkills == 18)
#expect(s.activeSkills == 18)
#expect(s.staleSkills == 0)
#expect(s.archivedSkills == 0)
#expect(s.pinnedNames.isEmpty)
#expect(s.leastRecentlyActive.count == 5)
#expect(s.leastActive.count == 5)
#expect(s.mostActive.isEmpty)
let firstRow = s.leastRecentlyActive.first
#expect(firstRow?.name == "Scarf Dashboard Chart Widget Parse Error Fix")
#expect(firstRow?.activityCount == 0)
#expect(firstRow?.lastActivityLabel == "never")
}
@Test func parsedPausedState() {
let text = """
curator: PAUSED
runs: 5
last run: 2026-04-29T03:10:00Z
last summary: pruned 2 skills, consolidated 1
interval: every 7d
stale after: 30d unused
archive after: 90d unused
agent-created skills: 12 total
active 8
stale 3
archived 1
pinned (2): kanban-orchestrator, scarf-template-author
"""
let s = HermesCuratorStatusParser.parse(text: text)
#expect(s.state == .paused)
#expect(s.runCount == 5)
#expect(s.lastRunISO == "2026-04-29T03:10:00Z")
#expect(s.lastSummary == "pruned 2 skills, consolidated 1")
#expect(s.totalSkills == 12)
#expect(s.activeSkills == 8)
#expect(s.staleSkills == 3)
#expect(s.archivedSkills == 1)
#expect(s.pinnedNames == ["kanban-orchestrator", "scarf-template-author"])
}
@Test func stateFileOverridesTextSummary() {
// The state file is authoritative for last_run_at /
// last_run_summary / last_report_path because it carries full
// ISO timestamps the text output may have rounded. Verify that
// a state file with richer values overrides parsed text.
let text = """
curator: ENABLED
runs: 1
last run: 2026-04-30T11:00:00Z
last summary: short
interval: every 7d
stale after: 30d unused
archive after: 90d unused
agent-created skills: 3 total
active 3
stale 0
archived 0
"""
let stateJSON: [String: Any] = [
"run_count": 4,
"last_run_at": "2026-04-30T18:42:13.001Z",
"last_run_summary": "richer summary from state file",
"last_report_path": "/Users/u/.hermes/logs/curator/20260430-184213"
]
let data = try! JSONSerialization.data(withJSONObject: stateJSON)
let s = HermesCuratorStatusParser.parse(text: text, stateFileJSON: data)
#expect(s.runCount == 4)
#expect(s.lastRunISO == "2026-04-30T18:42:13.001Z")
#expect(s.lastSummary == "richer summary from state file")
#expect(s.lastReportPath == "/Users/u/.hermes/logs/curator/20260430-184213")
}
@Test func parsedDisabledStatus() {
let s = HermesCuratorStatusParser.parse(text: "curator: DISABLED\n runs: 0\n")
#expect(s.state == .disabled)
}
@Test func parsedEmptyOutputStaysSafe() {
let s = HermesCuratorStatusParser.parse(text: "")
#expect(s.state == .unknown)
#expect(s.totalSkills == 0)
#expect(s.leastRecentlyActive.isEmpty)
}
@Test func skillRowParserHandlesMultiWordNames() {
// Names with spaces are common (Scarf Dashboard Chart Widget)
// The parser slices at the first `activity=` so names can be
// arbitrary length without breaking the counter columns.
let row = " Some Long Skill Name v2 activity= 12 use= 4 view= 6 patches= 2 last_activity=2026-04-25"
let s = HermesCuratorStatusParser.parse(text: """
least recently active (top 5):
\(row)
""")
let parsed = s.leastRecentlyActive.first
#expect(parsed?.name == "Some Long Skill Name v2")
#expect(parsed?.activityCount == 12)
#expect(parsed?.useCount == 4)
#expect(parsed?.viewCount == 6)
#expect(parsed?.patchCount == 2)
#expect(parsed?.lastActivityLabel == "2026-04-25")
}
}
+19
View File
@@ -30,6 +30,14 @@ struct ScarfGoTabRoot: View {
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
/// Stable per-tab context UUID used for the System tab's Curator
/// row so its CuratorViewModel reuses the cached SSH connection
/// keyed by this id rather than building a fresh one. Same pattern
/// as `sharedContextID` on ChatView.
static let systemTabContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
)!
/// One coordinator per server-connected session. Cross-tab
/// signalling (Dashboard row Chat tab resume, Project Detail
/// in-project chat handoff, notification deep-link Chat) flows
@@ -172,6 +180,8 @@ private struct SystemTab: View {
let onSoftDisconnect: @MainActor () async -> Void
let onForget: @MainActor () async -> Void
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var showForgetConfirmation = false
@State private var isForgetting = false
@State private var isDisconnecting = false
@@ -206,6 +216,15 @@ private struct SystemTab: View {
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
if capabilitiesStore?.capabilities.hasCurator ?? false {
NavigationLink {
CuratorView(context: config.toServerContext(id: ScarfGoTabRoot.systemTabContextID))
} label: {
Label("Curator", systemImage: "sparkles")
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
}
NavigationLink {
CronListView(config: config)
} label: {
+193
View File
@@ -0,0 +1,193 @@
import SwiftUI
import ScarfCore
import ScarfDesign
#if canImport(SQLite3)
/// iOS Curator surface read-mostly view of `hermes curator status`
/// with Run Now / Pause / Resume actions and inline pin toggles on
/// the leaderboard rows. Mirrors the Mac surface visually but folds
/// into a single SwiftUI List for thumb-friendly scrolling.
///
/// Capability-gated upstream: only routed when
/// `HermesCapabilities.hasCurator` is true.
struct CuratorView: View {
@State private var viewModel: CuratorViewModel
init(context: ServerContext) {
_viewModel = State(initialValue: CuratorViewModel(context: context))
}
var body: some View {
List {
Section {
statusRow
LabeledContent("Last run", value: viewModel.status.lastRunISO ?? "Never")
if let summary = viewModel.status.lastSummary {
LabeledContent("Summary", value: summary)
}
LabeledContent("Interval", value: viewModel.status.intervalLabel)
LabeledContent("Stale after", value: viewModel.status.staleAfterLabel)
LabeledContent("Archive after", value: viewModel.status.archiveAfterLabel)
LabeledContent("Runs", value: "\(viewModel.status.runCount)")
} header: {
Text("Status")
} footer: {
actionFooter
}
Section("Skills") {
LabeledContent("Total", value: "\(viewModel.status.totalSkills)")
LabeledContent("Active", value: "\(viewModel.status.activeSkills)")
LabeledContent("Stale", value: "\(viewModel.status.staleSkills)")
LabeledContent("Archived", value: "\(viewModel.status.archivedSkills)")
}
if !viewModel.status.pinnedNames.isEmpty {
Section("Pinned") {
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
HStack {
Image(systemName: "pin.fill")
.foregroundStyle(ScarfColor.accent)
Text(name)
Spacer()
Button("Unpin") {
Task { await viewModel.unpin(name) }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
}
if !viewModel.status.leastRecentlyActive.isEmpty {
rowsSection(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
}
if !viewModel.status.mostActive.isEmpty {
rowsSection(title: "Most active", rows: viewModel.status.mostActive)
}
if !viewModel.status.leastActive.isEmpty {
rowsSection(title: "Least active", rows: viewModel.status.leastActive)
}
if let report = viewModel.lastReportMarkdown {
Section("Last report") {
Text(report)
.font(ScarfFont.monoSmall)
.textSelection(.enabled)
}
}
}
.navigationTitle("Curator")
.navigationBarTitleDisplayMode(.large)
.refreshable {
await viewModel.load()
}
.overlay(alignment: .bottom) {
if let toast = viewModel.transientMessage {
toastView(toast)
}
}
.task { await viewModel.load() }
}
private var statusRow: some View {
HStack {
Text("Curator")
Spacer()
statusBadge
}
}
private var statusBadge: some View {
let kind: ScarfBadgeKind
let label: String
switch viewModel.status.state {
case .enabled: kind = .success; label = "Enabled"
case .paused: kind = .warning; label = "Paused"
case .disabled: kind = .neutral; label = "Disabled"
case .unknown: kind = .neutral; label = "Unknown"
}
return ScarfBadge(label, kind: kind)
}
private var actionFooter: some View {
HStack(spacing: 8) {
Button {
Task { await viewModel.runNow() }
} label: {
Label("Run now", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(viewModel.isLoading)
if viewModel.status.state == .enabled {
Button {
Task { await viewModel.pause() }
} label: {
Label("Pause", systemImage: "pause.fill")
}
.buttonStyle(.bordered)
.controlSize(.small)
} else if viewModel.status.state == .paused {
Button {
Task { await viewModel.resume() }
} label: {
Label("Resume", systemImage: "play.fill")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
Spacer()
}
.padding(.top, 6)
}
private func rowsSection(title: String, rows: [HermesCuratorSkillRow]) -> some View {
Section(title) {
ForEach(rows) { row in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(row.name)
.font(.body)
.lineLimit(1)
Spacer()
Button {
Task { await viewModel.pin(row.name) }
} label: {
Image(systemName: viewModel.status.pinnedNames.contains(row.name) ? "pin.fill" : "pin")
}
.buttonStyle(.plain)
}
HStack(spacing: 6) {
Text("use \(row.useCount) · view \(row.viewCount) · patch \(row.patchCount)")
.font(ScarfFont.monoSmall)
.foregroundStyle(.secondary)
Spacer()
Text(row.lastActivityLabel)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
}
}
private func toastView(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ScarfColor.success)
Text(text).font(.caption)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.bottom, 12)
.transition(.opacity)
}
}
#endif
+1
View File
@@ -61,6 +61,7 @@ struct ContentView: View {
case .projects: ProjectsView(context: serverContext)
case .chat: ChatView()
case .memory: MemoryView(context: serverContext)
case .curator: CuratorView(context: serverContext)
case .skills: SkillsView(context: serverContext)
case .platforms: PlatformsView(context: serverContext)
case .personalities: PersonalitiesView(context: serverContext)
@@ -0,0 +1,67 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Modal that lists archived skills (state active) and exposes a
/// one-click "Restore" action per row. v0.12 archives are recoverable
/// `hermes curator restore <name>` brings the skill back into
/// `~/.hermes/skills/<category>/<name>/` and re-marks it active.
///
/// The Curator's `status` text doesn't enumerate archived skills with
/// names; we surface what's available (counts + pinned list) and rely
/// on the user knowing the names. Hermes ergo does an interactive
/// `--name` arg if missing but Scarf prefers explicit selection so
/// users don't have to remember names. For v2.6 we render a free-form
/// text field; once Hermes ships a `curator list-archived` (tracked
/// upstream), swap to a pickable list.
struct CuratorRestoreSheet: View {
let viewModel: CuratorViewModel
@Environment(\.dismiss) private var dismiss
@State private var skillName: String = ""
@State private var isRestoring = false
var body: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
Text("Restore Archived Skill")
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Hermes archives skills the curator decides are stale or redundant. Restoring brings the original SKILL.md back into place — no data lost.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
VStack(alignment: .leading, spacing: ScarfSpace.s1) {
Text("Skill name")
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
ScarfTextField("e.g. legacy-helper", text: $skillName)
}
Text("\(viewModel.status.archivedSkills) archived skill(s) available — list them with `hermes curator status`.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
HStack {
Spacer()
Button("Cancel") { dismiss() }
.buttonStyle(ScarfGhostButton())
Button("Restore") {
let trimmed = skillName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
isRestoring = true
Task {
await viewModel.restore(trimmed)
isRestoring = false
dismiss()
}
}
.buttonStyle(ScarfPrimaryButton())
.keyboardShortcut(.defaultAction)
.disabled(isRestoring || skillName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(ScarfSpace.s5)
.frame(width: 420)
}
}
@@ -0,0 +1,322 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Mac UI for Hermes v0.12's autonomous skill curator.
///
/// Surfaces the running state (enabled / paused / disabled), last-run
/// metadata, agent-created skill counts, and the most/least-active /
/// least-recently-active leaderboards. Pin-and-restore actions hit
/// `hermes curator pin/unpin/restore` via CuratorViewModel.
///
/// Capability-gated upstream: AppCoordinator only wires the sidebar
/// item when `HermesCapabilities.hasCurator` is true. This view assumes
/// it's reachable on a v0.12+ host.
struct CuratorView: View {
@State private var viewModel: CuratorViewModel
@State private var showRestoreSheet = false
init(context: ServerContext) {
_viewModel = State(initialValue: CuratorViewModel(context: context))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
ScarfPageHeader(
"Curator",
subtitle: "Autonomous skill maintenance — Hermes v0.12+"
) {
HStack(spacing: ScarfSpace.s2) {
if viewModel.isLoading {
ProgressView().controlSize(.small)
}
Button("Run Now") {
Task { await viewModel.runNow() }
}
.buttonStyle(ScarfPrimaryButton())
.disabled(viewModel.isLoading)
Menu {
switch viewModel.status.state {
case .paused:
Button("Resume") { Task { await viewModel.resume() } }
case .enabled:
Button("Pause") { Task { await viewModel.pause() } }
default:
EmptyView()
}
Button("Restore Archived…") {
showRestoreSheet = true
}
.disabled(viewModel.status.archivedSkills == 0)
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
if let toast = viewModel.transientMessage {
transientToast(toast)
}
statusSummary
skillCountsSection
pinnedSection
activityTables
if let report = viewModel.lastReportMarkdown {
lastReportSection(markdown: report)
}
}
.padding(ScarfSpace.s4)
}
.background(ScarfColor.backgroundPrimary)
.task { await viewModel.load() }
.sheet(isPresented: $showRestoreSheet) {
CuratorRestoreSheet(viewModel: viewModel)
}
}
private var statusSummary: some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
HStack {
statusBadge
Spacer()
Text("\(viewModel.status.runCount) runs")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
ScarfDivider()
infoRow(label: "Last run", value: viewModel.status.lastRunISO ?? "Never")
if let summary = viewModel.status.lastSummary {
infoRow(label: "Last summary", value: summary)
}
infoRow(label: "Interval", value: viewModel.status.intervalLabel)
infoRow(label: "Stale after", value: viewModel.status.staleAfterLabel)
infoRow(label: "Archive after", value: viewModel.status.archiveAfterLabel)
}
}
}
private var statusBadge: some View {
let kind: ScarfBadgeKind
let label: String
switch viewModel.status.state {
case .enabled: kind = .success; label = "Enabled"
case .paused: kind = .warning; label = "Paused"
case .disabled: kind = .neutral; label = "Disabled"
case .unknown: kind = .neutral; label = "Unknown"
}
return ScarfBadge(label, kind: kind)
}
private var skillCountsSection: some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
ScarfSectionHeader("Agent-created skills")
HStack(spacing: ScarfSpace.s4) {
countCell(value: viewModel.status.totalSkills, label: "Total")
countCell(value: viewModel.status.activeSkills, label: "Active")
countCell(value: viewModel.status.staleSkills, label: "Stale")
countCell(value: viewModel.status.archivedSkills, label: "Archived")
Spacer(minLength: 0)
}
}
}
}
@ViewBuilder
private var pinnedSection: some View {
if !viewModel.status.pinnedNames.isEmpty {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
ScarfSectionHeader("Pinned")
Text("Pinned skills are never auto-archived or rewritten by the curator.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
FlowLayout(spacing: ScarfSpace.s2) {
ForEach(viewModel.status.pinnedNames, id: \.self) { name in
HStack(spacing: 4) {
Image(systemName: "pin.fill")
.font(.system(size: 10))
Text(name)
.scarfStyle(.caption)
Button {
Task { await viewModel.unpin(name) }
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 11))
.foregroundStyle(ScarfColor.foregroundMuted)
}
.buttonStyle(.plain)
.help("Unpin")
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md)
.fill(ScarfColor.accentTint)
)
}
}
}
}
}
}
private var activityTables: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
if !viewModel.status.leastRecentlyActive.isEmpty {
skillTable(title: "Least recently active", rows: viewModel.status.leastRecentlyActive)
}
if !viewModel.status.mostActive.isEmpty {
skillTable(title: "Most active", rows: viewModel.status.mostActive)
}
if !viewModel.status.leastActive.isEmpty {
skillTable(title: "Least active", rows: viewModel.status.leastActive)
}
}
}
private func skillTable(title: String, rows: [HermesCuratorSkillRow]) -> some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
ScarfSectionHeader(title)
ForEach(rows) { row in
HStack(alignment: .center, spacing: ScarfSpace.s2) {
Text(row.name)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
counterChip(label: "use", value: row.useCount)
counterChip(label: "view", value: row.viewCount)
counterChip(label: "patch", value: row.patchCount)
Text(row.lastActivityLabel)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
.frame(width: 92, alignment: .trailing)
Button {
Task { await viewModel.pin(row.name) }
} label: {
Image(systemName: viewModel.status.pinnedNames.contains(row.name)
? "pin.fill" : "pin")
.font(.system(size: 12))
}
.buttonStyle(.plain)
.help(viewModel.status.pinnedNames.contains(row.name) ? "Pinned" : "Pin skill")
}
.padding(.vertical, 2)
}
}
}
}
private func counterChip(label: String, value: Int) -> some View {
Text("\(label) \(value)")
.font(ScarfFont.monoSmall)
.foregroundStyle(ScarfColor.foregroundMuted)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.sm)
.fill(ScarfColor.backgroundTertiary)
)
}
private func lastReportSection(markdown: String) -> some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
ScarfSectionHeader("Last report")
Text(markdown)
.scarfStyle(.mono)
.foregroundStyle(ScarfColor.foregroundPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
}
}
private func infoRow(label: String, value: String) -> some View {
HStack(alignment: .top) {
Text(label)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(width: 110, alignment: .leading)
Text(value)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer(minLength: 0)
}
}
private func countCell(value: Int, label: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text("\(value)")
.scarfStyle(.title2)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(label)
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.frame(minWidth: 64, alignment: .leading)
}
private func transientToast(_ text: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ScarfColor.success)
Text(text)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer(minLength: 0)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 6)
.background(ScarfColor.accentTint)
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md))
}
}
/// Simple `FlowLayout` for the pinned-skill chips. Custom layout
/// keeps the chip wrap behaviour predictable across DynamicType
/// scales without resorting to LazyVGrid (which forces fixed columns).
private struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > maxWidth {
x = 0
y += rowHeight + spacing
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
return CGSize(width: maxWidth, height: y + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
@@ -11,6 +11,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
// Interact
case chat = "Chat"
case memory = "Memory"
case curator = "Curator"
case skills = "Skills"
// Configure (Phase 2/3 additions)
case platforms = "Platforms"
@@ -40,6 +41,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .projects: return "Projects"
case .chat: return "Chat"
case .memory: return "Memory"
case .curator: return "Curator"
case .skills: return "Skills"
case .platforms: return "Platforms"
case .personalities: return "Personalities"
@@ -67,6 +69,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
case .projects: return "square.grid.2x2"
case .chat: return "text.bubble"
case .memory: return "brain"
case .curator: return "sparkles"
case .skills: return "lightbulb"
case .platforms: return "dot.radiowaves.left.and.right"
case .personalities: return "theatermasks"
+23 -8
View File
@@ -14,21 +14,36 @@ struct SidebarView: View {
@Environment(AppCoordinator.self) private var coordinator
@Environment(ServerLiveStatusRegistry.self) private var liveRegistry
@Environment(\.serverContext) private var serverContext
@Environment(\.hermesCapabilities) private var capabilitiesStore
private static let sections: [Section] = [
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
Section(title: "Projects", items: [.projects]),
Section(title: "Interact", items: [.chat, .memory, .skills]),
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]),
]
/// Capability-gated sections. Curator is v0.12+ only; older Hermes
/// hosts get the same Interact section minus the Curator row.
/// Building the list lazily off the env keeps the sidebar honest
/// when the user reconnects to a different-version host.
private var sections: [Section] {
let caps = capabilitiesStore?.capabilities
var interact: [SidebarSection] = [.chat, .memory]
if caps?.hasCurator ?? false {
interact.append(.curator)
}
interact.append(.skills)
return [
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
Section(title: "Projects", items: [.projects]),
Section(title: "Interact", items: interact),
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
Section(title: "Manage", items: [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]),
]
}
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 14) {
ForEach(Self.sections) { section in
ForEach(sections) { section in
sectionView(section)
}
}