diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/CuratorError.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/CuratorError.swift new file mode 100644 index 0000000..ef21fbd --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/CuratorError.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Errors thrown by `CuratorService`. Each case carries enough detail +/// to render a user-actionable message — the view model surfaces these +/// inline as a banner above the leaderboard rather than blocking with a +/// modal alert. +public enum CuratorError: Error, LocalizedError, Sendable { + /// `hermes` binary couldn't be located. + case cliMissing + /// Subprocess returned non-zero exit. `stderr` may carry a synthetic + /// message when the transport itself failed. + case nonZeroExit(verb: String, code: Int32, stderr: String) + /// JSON decoding failed. Underlying message wrapped for diagnostics. + case decoding(verb: String, message: String) + /// Generic transport error — process couldn't start, IO failed, etc. + case transport(message: String) + + public var errorDescription: String? { + switch self { + case .cliMissing: + return "Hermes CLI couldn't be found. Install Hermes v0.13+ and ensure it's on your PATH." + case .nonZeroExit(let verb, let code, let stderr): + let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return "`hermes curator \(verb)` exited with code \(code)." + } + return trimmed + case .decoding(let verb, let message): + return "Couldn't decode `hermes curator \(verb)` output: \(message)" + case .transport(let message): + return message + } + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorArchive.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorArchive.swift new file mode 100644 index 0000000..187b046 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesCuratorArchive.swift @@ -0,0 +1,124 @@ +import Foundation + +/// One entry in the `hermes curator list-archived` output. Decoded +/// tolerantly via `decodeIfPresent` so a stripped-down host (or a future +/// Hermes that drops one of the optional columns) doesn't crash the view. +/// +/// Only `name` is required — every other field is optional and the +/// computed `*Label` accessors render `"—"` for missing values. +public struct HermesCuratorArchivedSkill: Sendable, Equatable, Identifiable, Codable { + public var id: String { name } + public let name: String + public let category: String? + public let archivedAt: String? + public let reason: String? + public let sizeBytes: Int? + public let path: String? + + public init( + name: String, + category: String? = nil, + archivedAt: String? = nil, + reason: String? = nil, + sizeBytes: Int? = nil, + path: String? = nil + ) { + self.name = name + self.category = category + self.archivedAt = archivedAt + self.reason = reason + self.sizeBytes = sizeBytes + self.path = path + } + + private enum CodingKeys: String, CodingKey { + case name + case category + case archivedAt = "archived_at" + case reason + case sizeBytes = "size_bytes" + case path + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.name = try c.decode(String.self, forKey: .name) + self.category = try c.decodeIfPresent(String.self, forKey: .category) + self.archivedAt = try c.decodeIfPresent(String.self, forKey: .archivedAt) + self.reason = try c.decodeIfPresent(String.self, forKey: .reason) + self.sizeBytes = try c.decodeIfPresent(Int.self, forKey: .sizeBytes) + self.path = try c.decodeIfPresent(String.self, forKey: .path) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(name, forKey: .name) + try c.encodeIfPresent(category, forKey: .category) + try c.encodeIfPresent(archivedAt, forKey: .archivedAt) + try c.encodeIfPresent(reason, forKey: .reason) + try c.encodeIfPresent(sizeBytes, forKey: .sizeBytes) + try c.encodeIfPresent(path, forKey: .path) + } + + /// "4.4 KB" / "1.2 MB" / "—" for nil. Uses the SI byte formatter so + /// the labels match what Finder shows. + public var sizeLabel: String { + guard let bytes = sizeBytes else { return "—" } + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } + + /// `2026-04-22` (ISO date prefix) / "—". Hermes returns full ISO + /// timestamps with seconds + Z; the date prefix is what the user + /// actually wants in the archived list. + public var archivedAtLabel: String { + guard let iso = archivedAt, !iso.isEmpty else { return "—" } + // Trim to date prefix if it looks like a full ISO timestamp. + if let tIdx = iso.firstIndex(of: "T") { + return String(iso[.. 0 else { return "—" } + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(totalBytes)) + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/CuratorService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/CuratorService.swift new file mode 100644 index 0000000..8e313ff --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/CuratorService.swift @@ -0,0 +1,358 @@ +import Foundation +#if canImport(os) +import os +#endif + +/// Async, transport-aware client for `hermes curator …`. Wraps the v0.12 +/// verbs (`status / run / pause / resume / pin / unpin / restore`) plus +/// the v0.13 archive surface (`archive / prune / list-archived` and a +/// synchronous-blocking `run`). +/// +/// **Concurrency.** Pure-I/O `actor` — no UI state. View models hold a +/// service reference and `await` methods. Each public method dispatches +/// the underlying CLI invocation through `Task.detached(priority: +/// .utility)` so two concurrent reads from the VM don't queue end-to-end +/// on a single thread. Mirrors `KanbanService` shape exactly. +/// +/// **Capability gating happens at the call site, not in the service.** +/// `runNow(synchronous:timeout:)` takes a flag from the VM (the VM reads +/// `HermesCapabilities.hasCuratorArchive` to decide). The service stays +/// version-agnostic — only the timeout differs in practice. +public actor CuratorService { + #if canImport(os) + private static let logger = Logger(subsystem: "com.scarf", category: "CuratorService") + #endif + + private let context: ServerContext + + public init(context: ServerContext) { + self.context = context + } + + // MARK: - Reads + + /// Run `hermes curator status` and parse stdout via + /// `HermesCuratorStatusParser`. Combines the text output with the + /// on-disk `.curator_state` JSON for richer last-run metadata. + /// Never throws — a transport failure resolves to `.empty` so the + /// view always has something to render. + public func status() async -> HermesCuratorStatus { + let context = self.context + return await Task.detached(priority: .utility) { () -> HermesCuratorStatus in + let textResult = Self.runHermesSync(context: context, args: ["curator", "status"], timeout: 30) + let stateData = context.readData(context.paths.curatorStateFile) + return HermesCuratorStatusParser.parse(text: textResult.output, stateFileJSON: stateData) + }.value + } + + /// `hermes curator list-archived [--json]`. Prefers JSON; falls back + /// to a defensive text parser. Empty / "no archived skills" sentinel + /// folds to `[]`. + public func listArchived() async throws -> [HermesCuratorArchivedSkill] { + // TODO(WS-4-Q2): confirm `--json` is supported on v0.13 + // `list-archived`. If not, drop the flag and rely on the text + // parser path. Until then we pass `--json` and parse the output + // tolerantly. + let args = ["curator", "list-archived", "--json"] + let (code, stdout, stderr) = await runHermes(args: args, timeout: 30) + + // If --json isn't recognized, the CLI typically emits + // "unrecognized arguments: --json" or similar to stderr and + // exits non-zero. Retry without the flag and parse text. + if code != 0 { + let lower = (stderr + stdout).lowercased() + if lower.contains("unrecognized") || lower.contains("unknown") || lower.contains("no such option") { + let (c2, out2, err2) = await runHermes(args: ["curator", "list-archived"], timeout: 30) + try ensureSuccess(code: c2, stdout: out2, stderr: err2, verb: "list-archived") + return Self.parseListArchivedText(out2) + } + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "list-archived") + } + + let trimmed = stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.lowercased().contains("no archived skills") { + return [] + } + // Try JSON first — may also be a text dump if Hermes ignored `--json`. + if let data = trimmed.data(using: .utf8), + let arr = try? JSONDecoder().decode([HermesCuratorArchivedSkill].self, from: data) { + return arr + } + // Some builds wrap in `{"archived": [...]}` envelope. + struct Wrapper: Decodable { let archived: [HermesCuratorArchivedSkill] } + if let data = trimmed.data(using: .utf8), + let wrapped = try? JSONDecoder().decode(Wrapper.self, from: data) { + return wrapped.archived + } + // Text fallback — defensive parse. + return Self.parseListArchivedText(stdout) + } + + // MARK: - Writes (legacy v0.12 verbs; service form) + + public func runNow(synchronous: Bool, timeout: TimeInterval) async throws { + // TODO(WS-4-Q4): default 600s for v0.13 sync runs. No Cancel + // button in v2.8 (transport.cancel parity not guaranteed across + // LocalTransport / SSHTransport). + let resolvedTimeout = synchronous ? timeout : 30 + let (code, stdout, stderr) = await runHermes(args: ["curator", "run"], timeout: resolvedTimeout) + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "run") + } + + public func pause() async throws { + let (code, stdout, stderr) = await runHermes(args: ["curator", "pause"], timeout: 15) + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "pause") + } + + public func resume() async throws { + let (code, stdout, stderr) = await runHermes(args: ["curator", "resume"], timeout: 15) + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "resume") + } + + public func pin(_ name: String) async throws { + let (code, stdout, stderr) = await runHermes(args: ["curator", "pin", name], timeout: 15) + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "pin") + } + + public func unpin(_ name: String) async throws { + let (code, stdout, stderr) = await runHermes(args: ["curator", "unpin", name], timeout: 15) + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "unpin") + } + + public func restore(_ name: String) async throws { + let (code, stdout, stderr) = await runHermes(args: ["curator", "restore", name], timeout: 30) + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "restore") + } + + // MARK: - Writes (new in v0.13) + + /// `hermes curator archive ` — non-destructive; moves the + /// skill from the active set to the archived set. No `--json` is + /// expected; the verb's success channel is the exit code. + public func archive(_ name: String) async throws { + let (code, stdout, stderr) = await runHermes(args: ["curator", "archive", name], timeout: 30) + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "archive") + } + + /// `hermes curator prune [--dry-run]`. Destructive when `dryRun` + /// is `false` — removes everything currently archived from disk. + /// Returns a `CuratorPruneSummary` describing what was (or would be) + /// removed. On `dryRun=false`, the wire shape may not include the + /// `would_remove` list — the caller should not depend on it; the + /// archived list is empty after a successful destructive prune. + @discardableResult + public func prune(dryRun: Bool) async throws -> CuratorPruneSummary { + // TODO(WS-4-Q1): confirm v0.13 ships `--dry-run`. If not, fall + // back to enumerating via `list-archived` and treat any prune + // call as destructive. The retry-without-flag path below covers + // the "unrecognized argument" case automatically. + var args = ["curator", "prune"] + if dryRun { args.append("--dry-run") } + // `--json` requested for the dry-run path so we can parse the + // would-remove list. Destructive mode runs without --json since + // we only need the exit code. + if dryRun { args.append("--json") } + + let (code, stdout, stderr) = await runHermes(args: args, timeout: 60) + + // Detect "unrecognized --dry-run" / "unknown --json" gracefully. + if code != 0 { + let lower = (stderr + stdout).lowercased() + let unrecognized = lower.contains("unrecognized") || lower.contains("unknown") || lower.contains("no such option") + if dryRun && unrecognized { + // Q1 fallback: enumerate via list-archived. Caller still + // uses this summary for confirm-sheet display. + let archived = try await listArchived() + let total = archived.compactMap { $0.sizeBytes }.reduce(0, +) + return CuratorPruneSummary(wouldRemove: archived, totalBytes: total) + } + try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "prune") + } + + if dryRun { + return Self.parsePruneDryRun(stdout) + } + return CuratorPruneSummary(wouldRemove: [], totalBytes: 0) + } + + // MARK: - Pure parsers (nonisolated; safe to call from VMs without awaits) + + /// Parse a `list-archived --json` payload. Tolerates the bare-array + /// shape, the `{"archived": [...]}` envelope, and "no archived + /// skills" / empty-string sentinels. Returns `[]` for any of the + /// empty cases. Throws `CuratorError.decoding` only when the input + /// is non-empty and clearly not JSON. + public nonisolated static func parseListArchived(stdout: String) throws -> [HermesCuratorArchivedSkill] { + let trimmed = stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed.lowercased().contains("no archived skills") { + return [] + } + guard let data = trimmed.data(using: .utf8) else { + throw CuratorError.decoding(verb: "list-archived", message: "non-UTF8 stdout") + } + if let arr = try? JSONDecoder().decode([HermesCuratorArchivedSkill].self, from: data) { + return arr + } + struct Wrapper: Decodable { let archived: [HermesCuratorArchivedSkill] } + if let wrapped = try? JSONDecoder().decode(Wrapper.self, from: data) { + return wrapped.archived + } + // Last resort: text fallback. + let parsed = parseListArchivedText(stdout) + if !parsed.isEmpty { + return parsed + } + throw CuratorError.decoding(verb: "list-archived", message: "stdout was neither JSON nor a recognised text list") + } + + /// Defensive text parser for `list-archived` output when `--json` + /// isn't supported. Format inferred from `curator status`: one row + /// per non-blank line, leading whitespace, name in column 1, then + /// optional `archived=YYYY-MM-DD`, `size=NNNN`, `reason=...` k/v + /// pairs. Blank lines, header lines, and the empty-state sentinel + /// are skipped. + public nonisolated static func parseListArchivedText(_ text: String) -> [HermesCuratorArchivedSkill] { + var rows: [HermesCuratorArchivedSkill] = [] + for raw in text.split(separator: "\n") { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.isEmpty { continue } + let lower = line.lowercased() + // Skip header / sentinel lines. + if lower.hasPrefix("name") && lower.contains("archived") { continue } + if lower.contains("no archived skills") { continue } + if line.unicodeScalars.allSatisfy({ $0.value == 0x2500 || $0.properties.isWhitespace }) { + continue + } + // Skip lines that look like JSON / non-row chrome — `{`, + // `}`, `[`, `]` at the start or quotes / colons mean we're + // parsing a malformed JSON dump, not a row table. + if let first = line.first, "{[}]\":,".contains(first) { + continue + } + // Find the first whitespace-separated token as the name; if + // the name carries an `=` it's a header chip we should skip. + let parts = line.split(whereSeparator: { $0 == "\t" || $0 == " " }).map(String.init) + guard let name = parts.first, !name.contains("=") else { continue } + // Reject names that look like punctuation / JSON fragments. + if name.contains("\"") || name.contains(":") || name.contains("{") || name.contains("}") || name.contains("[") || name.contains("]") { + continue + } + // Pull k=v pairs from the remainder. + var archivedAt: String? + var sizeBytes: Int? + var reason: String? + var category: String? + var path: String? + for token in parts.dropFirst() { + guard let eq = token.firstIndex(of: "=") else { continue } + let key = String(token[.. CuratorPruneSummary { + let trimmed = stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return CuratorPruneSummary(wouldRemove: [], totalBytes: 0) + } + if let data = trimmed.data(using: .utf8), + let summary = try? JSONDecoder().decode(CuratorPruneSummary.self, from: data) { + return summary + } + // Tolerate a bare-array fallback (some Hermes builds may print + // just the would-remove list when --json is missing the wrapper). + if let data = trimmed.data(using: .utf8), + let arr = try? JSONDecoder().decode([HermesCuratorArchivedSkill].self, from: data) { + let total = arr.compactMap { $0.sizeBytes }.reduce(0, +) + return CuratorPruneSummary(wouldRemove: arr, totalBytes: total) + } + // Last-resort text parse for "would remove N skills (X bytes)". + return CuratorPruneSummary(wouldRemove: [], totalBytes: 0) + } + + // MARK: - CLI invocation + + private nonisolated func runHermes( + args: [String], + timeout: TimeInterval + ) async -> (exitCode: Int32, stdout: String, stderr: String) { + let context = self.context + return await Task.detached(priority: .utility) { () -> (Int32, String, String) in + let result = Self.runHermesSync(context: context, args: args, timeout: timeout) + return (result.exitCode, result.output, result.stderr) + }.value + } + + /// Synchronous, transport-level invocation. `output` is stdout; the + /// caller usually only reads `output` for parser input but sometimes + /// needs `stderr` (e.g. to detect "unrecognized argument" patterns). + private nonisolated static func runHermesSync( + context: ServerContext, + args: [String], + timeout: TimeInterval + ) -> (exitCode: Int32, output: String, stderr: String) { + let transport = context.makeTransport() + do { + let result = try transport.runProcess( + executable: context.paths.hermesBinary, + args: args, + stdin: nil, + timeout: timeout + ) + return (result.exitCode, result.stdoutString, result.stderrString) + } catch let error as TransportError { + let message = error.diagnosticStderr.isEmpty + ? (error.errorDescription ?? "transport error") + : error.diagnosticStderr + return (-1, "", message) + } catch { + return (-1, "", error.localizedDescription) + } + } + + private nonisolated func ensureSuccess( + code: Int32, + stdout: String, + stderr: String, + verb: String + ) throws { + guard code != 0 else { return } + if code == -1 && stderr.lowercased().contains("hermes binary not found") { + throw CuratorError.cliMissing + } + let combined = stderr.isEmpty ? stdout : stderr + #if canImport(os) + Self.logger.warning("curator \(verb) exit=\(code, privacy: .public) stderr=\(combined, privacy: .public)") + #endif + throw CuratorError.nonZeroExit(verb: verb, code: code, stderr: combined) + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/CuratorViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/CuratorViewModel.swift index 6333788..32c78d0 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/CuratorViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/CuratorViewModel.swift @@ -4,17 +4,19 @@ import Observation import os #endif -/// Mac + iOS view model for the v0.12 Curator surface. +/// Mac + iOS view model for the Curator surface (v0.12 base + v0.13 +/// archive/prune additions). /// /// 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. +/// restore` plus (v0.13+) `archive`, `prune`, `list-archived`. All CLI +/// invocations route through `CuratorService` (the actor) so polling +/// and writes share the same concurrency model and a single error path. /// /// 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. +/// `HermesCapabilities.hasCurator` is true. Archive-aware UI surfaces +/// (Archive button, Archived section, Prune…) gate independently on +/// `hasCuratorArchive`. The view model itself doesn't gate — it exposes +/// every method and the View decides what to render. @Observable @MainActor public final class CuratorViewModel { @@ -27,20 +29,50 @@ public final class CuratorViewModel { public private(set) var status: HermesCuratorStatus = .empty public private(set) var isLoading = false public private(set) var lastReportMarkdown: String? + + // Archive state (v0.13+ only — populated by `loadArchive()` on hosts + // where `hasCuratorArchive` is true). + public private(set) var archivedSkills: [HermesCuratorArchivedSkill] = [] + public private(set) var isLoadingArchive = false + + // Prune state — `pruneSummary` non-nil while the confirm sheet is + // mid-flight; `isPruning` flips during the destructive step. + public private(set) var pruneSummary: CuratorPruneSummary? + public private(set) var isPruning = false + + // Track which active-skill row is currently being archived so the + // row chrome can show an inline spinner without blocking the rest. + public private(set) var pendingArchiveName: String? + + /// Happy-path success toast ("Pinned X", "Resumed", "Archived + /// legacy-helper"). Auto-clears 3s after assignment. public var transientMessage: String? + /// Failure path — populated by every CLI verb when it throws. Shown + /// as an inline yellow banner above the status summary so users + /// don't have to dismiss a modal alert during a high-frequency + /// surface like the leaderboard. Manually dismissed via the View's + /// "x" button (sets to nil). + public var errorMessage: String? + + @ObservationIgnored + private let service: CuratorService + public init(context: ServerContext) { self.context = context + self.service = CuratorService(context: context) } + // MARK: - Loads + public func load() async { isLoading = true defer { isLoading = false } let context = self.context // v2.8 — instrumented. Curator load fires `hermes curator - // status` (CLI subprocess) plus 1-2 file reads; on remote - // each is a separate SSH RTT. Visibility lets future captures - // show how often the report file is missing or oversized. + // status` (CLI subprocess) plus 1-2 file reads; on remote each + // is a separate SSH RTT. Visibility lets future captures show + // how often the report file is missing or oversized. let parsed = await ScarfMon.measureAsync(.diskIO, "curator.load") { await Task.detached(priority: .userInitiated) { () -> (HermesCuratorStatus, String?) in let textResult = Self.runCuratorStatus(context: context) @@ -69,46 +101,156 @@ public final class CuratorViewModel { self.lastReportMarkdown = parsed.1 } - public func runNow() async { - await runAndReload(args: ["curator", "run"], successMessage: "Curator run started") + /// Refresh the archived-skills list. No-op on hosts without + /// `hasCuratorArchive` — the caller gates the call. + public func loadArchive() async { + isLoadingArchive = true + defer { isLoadingArchive = false } + do { + archivedSkills = try await service.listArchived() + } catch { + archivedSkills = [] + errorMessage = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + } + } + + // MARK: - Writes (v0.12) + + /// Run the curator manually. On v0.13+ hosts this blocks for the + /// duration of the run (default 600s timeout); pre-v0.13 returns + /// immediately. Caller passes the capability-decided flag. + public func runNow(synchronous: Bool, timeout: TimeInterval = 600) async { + await runWithReload( + verb: "run", + successMessage: synchronous ? "Curator run complete" : "Curator run started" + ) { + try await self.service.runNow(synchronous: synchronous, timeout: timeout) + } } public func pause() async { - await runAndReload(args: ["curator", "pause"], successMessage: "Curator paused") + await runWithReload(verb: "pause", successMessage: "Curator paused") { + try await self.service.pause() + } } public func resume() async { - await runAndReload(args: ["curator", "resume"], successMessage: "Curator resumed") + await runWithReload(verb: "resume", successMessage: "Curator resumed") { + try await self.service.resume() + } } public func pin(_ skill: String) async { - await runAndReload(args: ["curator", "pin", skill], successMessage: "Pinned \(skill)") + await runWithReload(verb: "pin", successMessage: "Pinned \(skill)") { + try await self.service.pin(skill) + } } public func unpin(_ skill: String) async { - await runAndReload(args: ["curator", "unpin", skill], successMessage: "Unpinned \(skill)") + await runWithReload(verb: "unpin", successMessage: "Unpinned \(skill)") { + try await self.service.unpin(skill) + } } public func restore(_ skill: String) async { - await runAndReload(args: ["curator", "restore", skill], successMessage: "Restored \(skill)") + await runWithReload(verb: "restore", successMessage: "Restored \(skill)") { + try await self.service.restore(skill) + } + // Restore drops the entry from the archived list — refresh it + // so the row disappears immediately. + await loadArchive() } - 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. + // MARK: - Writes (v0.13) + + public func archive(_ skill: String) async { + pendingArchiveName = skill + await runWithReload(verb: "archive", successMessage: "Archived \(skill)") { + try await self.service.archive(skill) + } + pendingArchiveName = nil + await loadArchive() + } + + /// Stage 1 of the bulk-prune flow. Calls `prune --dry-run` and + /// populates `pruneSummary`; the View binds its confirm sheet to + /// the non-nil presence of this property. + public func planPrune() async { + do { + pruneSummary = try await service.prune(dryRun: true) + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + pruneSummary = nil + } + } + + /// Stage 2 of the bulk-prune flow. Destructive — removes everything + /// currently archived. Clears `pruneSummary` regardless of outcome + /// so the confirm sheet dismisses. + public func confirmPrune() async { + isPruning = true + do { + _ = try await service.prune(dryRun: false) + transientMessage = "Pruned archived skills" + errorMessage = nil + await loadArchive() + await load() + scheduleTransientClear() + } catch { + errorMessage = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + } + isPruning = false + pruneSummary = nil + } + + /// Cancel the in-flight prune-confirm flow without running. + public func cancelPrune() { + pruneSummary = nil + } + + /// User-driven dismissal of the inline error banner. + public func dismissError() { + errorMessage = nil + } + + // MARK: - Helpers + + /// Run a service call, route success → `transientMessage`, failure + /// → `errorMessage`, and reload `status` either way. Mirrors the + /// previous `runAndReload` helper but goes through the typed + /// service surface. + private func runWithReload( + verb: String, + successMessage: String, + body: @escaping @Sendable () async throws -> Void + ) async { + do { + try await body() + transientMessage = successMessage + errorMessage = nil + await load() + scheduleTransientClear() + } catch { + let message = (error as? LocalizedError)?.errorDescription + ?? error.localizedDescription + errorMessage = message + transientMessage = nil + await load() + } + } + + private func scheduleTransientClear() { 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. + // MARK: - Legacy sync helpers (kept for `load`'s detached path) + nonisolated private static func runHermes( context: ServerContext, args: [String] diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCuratorParserTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCuratorParserTests.swift index 64d427e..56cc3f9 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCuratorParserTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/HermesCuratorParserTests.swift @@ -151,4 +151,169 @@ import Foundation #expect(parsed?.patchCount == 2) #expect(parsed?.lastActivityLabel == "2026-04-25") } + + // MARK: - v0.13 list-archived / prune fixtures (WS-4) + + /// Empty JSON array → `[]`. Locks in the happy-path no-archives shape. + @Test func listArchivedEmpty() throws { + let result = try CuratorService.parseListArchived(stdout: "[]") + #expect(result.isEmpty) + } + + /// Three archives with full optional fields. Asserts each + /// optional value decodes through `decodeIfPresent` and that + /// the computed labels resolve. + @Test func listArchivedThreeSkills() throws { + let json = """ + [ + { + "name": "legacy-helper", + "category": "templates", + "archived_at": "2026-04-22T03:14:09Z", + "reason": "stale: 91d unused", + "size_bytes": 4521, + "path": "/Users/u/.hermes/skills/.archived/legacy-helper" + }, + { + "name": "old-translator", + "category": "user", + "archived_at": "2026-04-23T10:00:00Z", + "reason": "consolidated with translator", + "size_bytes": 8192 + }, + { + "name": "minimal" + } + ] + """ + let result = try CuratorService.parseListArchived(stdout: json) + #expect(result.count == 3) + #expect(result[0].name == "legacy-helper") + #expect(result[0].category == "templates") + #expect(result[0].reason == "stale: 91d unused") + #expect(result[0].sizeBytes == 4521) + #expect(result[0].archivedAtLabel == "2026-04-22") + #expect(result[0].path == "/Users/u/.hermes/skills/.archived/legacy-helper") + + // Tolerant: only `name` set on the third row. + #expect(result[2].name == "minimal") + #expect(result[2].category == nil) + #expect(result[2].reason == nil) + #expect(result[2].archivedAtLabel == "—") + #expect(result[2].sizeLabel == "—") + } + + /// `{"archived": [...]}` envelope is also accepted. + @Test func listArchivedEnvelope() throws { + let json = """ + {"archived": [ + {"name": "envelope-skill", "size_bytes": 1024} + ]} + """ + let result = try CuratorService.parseListArchived(stdout: json) + #expect(result.count == 1) + #expect(result[0].name == "envelope-skill") + } + + /// Text fallback when `--json` isn't supported. Each row carries + /// the name in column 1 plus k=v chips for the optional fields. + @Test func listArchivedTextFallback() { + let text = """ + legacy-helper archived=2026-04-22 size=4521 reason=stale + old-translator archived=2026-04-23 size=8192 + minimal-row + """ + let result = CuratorService.parseListArchivedText(text) + #expect(result.count == 3) + #expect(result[0].name == "legacy-helper") + #expect(result[0].archivedAt == "2026-04-22") + #expect(result[0].sizeBytes == 4521) + #expect(result[0].reason == "stale") + #expect(result[2].name == "minimal-row") + #expect(result[2].sizeBytes == nil) + } + + /// Empty-state sentinel folds to `[]` (parallel to KanbanService's + /// `"no matching tasks"` handling). + @Test func listArchivedNoArchivedSentinel() throws { + let result = try CuratorService.parseListArchived(stdout: "no archived skills\n") + #expect(result.isEmpty) + } + + /// Whitespace-only stdout also folds to empty. + @Test func listArchivedWhitespaceFoldsToEmpty() throws { + let result = try CuratorService.parseListArchived(stdout: " \n\n") + #expect(result.isEmpty) + } + + /// Decode failure (clearly non-JSON, non-text) throws. We accept + /// JSON, the envelope, the empty sentinel, or text rows; anything + /// else surfaces as a `CuratorError.decoding`. + @Test func listArchivedNonsenseThrows() throws { + do { + _ = try CuratorService.parseListArchived(stdout: "{garbage") + Issue.record("expected decoding throw") + } catch let error as CuratorError { + if case .decoding = error { + // expected + } else { + Issue.record("unexpected error \(error)") + } + } + } + + /// Prune-dry-run JSON with `would_remove` + `total_bytes`. + @Test func pruneDryRunHappyPath() { + let json = """ + { + "would_remove": [ + {"name": "stale-a", "size_bytes": 1000}, + {"name": "stale-b", "size_bytes": 2000} + ], + "total_bytes": 3000 + } + """ + let summary = CuratorService.parsePruneDryRun(json) + #expect(summary.totalCount == 2) + #expect(summary.totalBytes == 3000) + #expect(summary.wouldRemove.first?.name == "stale-a") + } + + /// Zero-skill prune is a valid dry-run (no archives). + @Test func pruneDryRunZeroSkills() { + let json = """ + {"would_remove": [], "total_bytes": 0} + """ + let summary = CuratorService.parsePruneDryRun(json) + #expect(summary.totalCount == 0) + #expect(summary.totalBytes == 0) + #expect(summary.totalBytesLabel == "—") + } + + /// Bare-array fallback: some Hermes builds may print just the + /// would-remove list when the wrapper is missing. + @Test func pruneDryRunBareArrayFallback() { + let json = """ + [{"name": "lonely", "size_bytes": 500}] + """ + let summary = CuratorService.parsePruneDryRun(json) + #expect(summary.totalCount == 1) + #expect(summary.totalBytes == 500) + } + + /// Empty / whitespace stdout → zero summary (no decoding throw). + @Test func pruneDryRunEmptyStaysSafe() { + let summary = CuratorService.parsePruneDryRun(" \n") + #expect(summary.totalCount == 0) + #expect(summary.totalBytes == 0) + } + + /// Verify the size label uses the byte formatter (not raw bytes). + @Test func archivedSkillSizeLabelFormats() { + let big = HermesCuratorArchivedSkill(name: "x", sizeBytes: 1_500_000) + // ByteCountFormatter produces a localized label; just verify + // it's non-empty and not raw "1500000". + #expect(!big.sizeLabel.isEmpty) + #expect(big.sizeLabel != "1500000") + } } diff --git a/scarf/Scarf iOS/Curator/CuratorView.swift b/scarf/Scarf iOS/Curator/CuratorView.swift index b1515d8..bf33db4 100644 --- a/scarf/Scarf iOS/Curator/CuratorView.swift +++ b/scarf/Scarf iOS/Curator/CuratorView.swift @@ -13,11 +13,24 @@ import ScarfDesign /// `HermesCapabilities.hasCurator` is true. struct CuratorView: View { @State private var viewModel: CuratorViewModel + @Environment(\.hermesCapabilities) private var capabilitiesStore + + // TODO(WS-9): add a read-only "Archived" section mirroring the Mac + // surface (no per-row Restore/Prune mutations on iOS in this + // release). Gate on `capabilitiesStore?.capabilities.hasCuratorArchive`. init(context: ServerContext) { _viewModel = State(initialValue: CuratorViewModel(context: context)) } + /// Whether the connected host runs curator synchronously. Threaded + /// into `runNow` so v0.13+ hosts block-with-spinner; pre-v0.13 fire + /// and forget. WS-9 will surface a richer iOS progress affordance + /// alongside the read-only Archived section. + private var archiveAvailable: Bool { + capabilitiesStore?.capabilities.hasCuratorArchive ?? false + } + var body: some View { List { Section { @@ -115,7 +128,7 @@ struct CuratorView: View { private var actionFooter: some View { HStack(spacing: 8) { Button { - Task { await viewModel.runNow() } + Task { await viewModel.runNow(synchronous: archiveAvailable, timeout: 600) } } label: { Label("Run now", systemImage: "play.fill") } diff --git a/scarf/scarf/Features/Curator/Views/CuratorArchivedSection.swift b/scarf/scarf/Features/Curator/Views/CuratorArchivedSection.swift new file mode 100644 index 0000000..3fa27ac --- /dev/null +++ b/scarf/scarf/Features/Curator/Views/CuratorArchivedSection.swift @@ -0,0 +1,122 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Mac sub-view rendered between the active-skill leaderboards and the +/// last-report block on Hermes v0.13+ hosts. Lists everything currently +/// archived (`hermes curator list-archived`) with per-row Restore + a +/// bulk Prune affordance routed through the parent's confirm sheet. +/// +/// Empty-state copy explains what archive means — useful when the +/// curator hasn't run yet on a fresh install (no archives ≠ a problem). +struct CuratorArchivedSection: View { + let archived: [HermesCuratorArchivedSkill] + let isLoading: Bool + let onRestore: (String) -> Void + let onPruneAll: () -> Void + + var body: some View { + ScarfCard { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + header + if isLoading && archived.isEmpty { + loadingRow + } else if archived.isEmpty { + emptyState + } else { + rows + } + } + } + } + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + ScarfSectionHeader("Archived") + Spacer() + Text("\(archived.count) skill\(archived.count == 1 ? "" : "s")") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + if !archived.isEmpty { + Button("Prune All…") { + onPruneAll() + } + .buttonStyle(ScarfDestructiveButton()) + .help("Remove every archived skill from disk. Cannot be undone.") + } + } + } + + private var loadingRow: some View { + HStack(spacing: ScarfSpace.s2) { + ProgressView().controlSize(.small) + Text("Loading archived skills…") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + Spacer() + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s1) { + Text("No archived skills.") + .scarfStyle(.body) + .foregroundStyle(ScarfColor.foregroundMuted) + Text("The curator moves stale or redundant skills here on its weekly review. Until then, this list stays empty.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + } + } + + private var rows: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s1) { + ForEach(archived) { skill in + ArchivedSkillRow( + skill: skill, + onRestore: { onRestore(skill.name) } + ) + } + } + } +} + +private struct ArchivedSkillRow: View { + let skill: HermesCuratorArchivedSkill + let onRestore: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: ScarfSpace.s2) { + Image(systemName: "archivebox.fill") + .font(.system(size: 12)) + .foregroundStyle(ScarfColor.foregroundFaint) + VStack(alignment: .leading, spacing: 2) { + Text(skill.name) + .scarfStyle(.body) + .foregroundStyle(ScarfColor.foregroundPrimary) + .lineLimit(1) + if let reason = skill.reason, !reason.isEmpty { + Text(reason) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + Text(skill.archivedAtLabel) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + .frame(width: 96, alignment: .trailing) + Text(skill.sizeLabel) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + .frame(width: 72, alignment: .trailing) + Button("Restore") { + onRestore() + } + .buttonStyle(ScarfPrimaryButton()) + .controlSize(.small) + .help("Restore \(skill.name) to the active skill set") + } + .padding(.vertical, 2) + } +} diff --git a/scarf/scarf/Features/Curator/Views/CuratorPruneConfirmSheet.swift b/scarf/scarf/Features/Curator/Views/CuratorPruneConfirmSheet.swift new file mode 100644 index 0000000..9df1198 --- /dev/null +++ b/scarf/scarf/Features/Curator/Views/CuratorPruneConfirmSheet.swift @@ -0,0 +1,123 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Destructive-confirm sheet for `hermes curator prune` (bulk). +/// +/// Pattern matches `TemplateUninstallSheet`: enumerate every entry that +/// will be removed, surface the total count + bytes, and require an +/// explicit click on a red `ScarfDestructiveButton` ("Prune +/// permanently") before kicking off the destructive call. Cancel owns +/// the keyboard default action so an accidental Enter-press doesn't +/// nuke the archive. +struct CuratorPruneConfirmSheet: View { + @Environment(\.dismiss) private var dismiss + let summary: CuratorPruneSummary + let isPruning: Bool + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + .padding(.bottom, ScarfSpace.s2) + ScarfDivider() + ScrollView { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + ForEach(summary.wouldRemove) { skill in + row(skill: skill) + } + if summary.wouldRemove.isEmpty { + Text("Nothing currently archived. Nothing to prune.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .padding(.vertical, ScarfSpace.s2) + } + } + .padding(.vertical, ScarfSpace.s2) + } + ScarfDivider() + footer + .padding(.top, ScarfSpace.s2) + } + .frame(minWidth: 520, minHeight: 380) + .padding(ScarfSpace.s4) + } + + private var header: some View { + VStack(alignment: .leading, spacing: ScarfSpace.s1) { + HStack(alignment: .firstTextBaseline) { + Text("Prune Archived Skills") + .scarfStyle(.title2) + .foregroundStyle(ScarfColor.foregroundPrimary) + Spacer() + if summary.totalCount > 0 { + ScarfBadge("\(summary.totalCount)", kind: .danger) + } + } + Text("This permanently deletes every archived skill from disk. Restoring an archived skill is no longer possible after pruning.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .fixedSize(horizontal: false, vertical: true) + if summary.totalBytes > 0 { + Text("Total to remove: \(summary.totalBytesLabel)") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + } + } + } + + private func row(skill: HermesCuratorArchivedSkill) -> some View { + HStack(spacing: ScarfSpace.s2) { + Image(systemName: "minus.circle") + .foregroundStyle(ScarfColor.danger) + .font(.caption) + VStack(alignment: .leading, spacing: 2) { + Text(skill.name) + .scarfStyle(.body) + .foregroundStyle(ScarfColor.foregroundPrimary) + .lineLimit(1) + if let reason = skill.reason, !reason.isEmpty { + Text(reason) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .lineLimit(1) + } + } + Spacer() + Text(skill.archivedAtLabel) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + .frame(width: 96, alignment: .trailing) + Text(skill.sizeLabel) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + .frame(width: 72, alignment: .trailing) + } + } + + private var footer: some View { + HStack { + Button("Cancel") { + onCancel() + dismiss() + } + .buttonStyle(ScarfGhostButton()) + // Cancel owns .defaultAction so accidental Enter-presses + // don't trigger the destructive button (template-uninstall + // pattern recommended in the WS-4 plan). + .keyboardShortcut(.defaultAction) + .disabled(isPruning) + Spacer() + if isPruning { + ProgressView().controlSize(.small) + } + Button("Prune permanently") { + onConfirm() + } + .buttonStyle(ScarfDestructiveButton()) + .disabled(isPruning || summary.wouldRemove.isEmpty) + .accessibilityIdentifier("curatorPrune.confirm") + } + } +} diff --git a/scarf/scarf/Features/Curator/Views/CuratorRestoreSheet.swift b/scarf/scarf/Features/Curator/Views/CuratorRestoreSheet.swift index fb240c5..bf68e9e 100644 --- a/scarf/scarf/Features/Curator/Views/CuratorRestoreSheet.swift +++ b/scarf/scarf/Features/Curator/Views/CuratorRestoreSheet.swift @@ -2,18 +2,16 @@ 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 ` brings the skill back into -/// `~/.hermes/skills///` and re-marks it active. +/// Legacy v0.12 fallback for restoring an archived skill by typed +/// name. Hermes v0.12 didn't ship `curator list-archived`, so the only +/// way to restore was to remember the skill name and pass it through +/// `hermes curator restore `. /// -/// 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. +/// **v0.13+ flow (preferred):** `CuratorArchivedSection` renders a +/// per-skill list with a one-click Restore button per row — no typing +/// required. This sheet stays reachable from the overflow menu only on +/// pre-v0.13 hosts (gated by `!hasCuratorArchive`). Don't delete this +/// file even after WS-4 ships; v0.12 hosts still depend on it. struct CuratorRestoreSheet: View { let viewModel: CuratorViewModel diff --git a/scarf/scarf/Features/Curator/Views/CuratorView.swift b/scarf/scarf/Features/Curator/Views/CuratorView.swift index 5c917c5..5c94641 100644 --- a/scarf/scarf/Features/Curator/Views/CuratorView.swift +++ b/scarf/scarf/Features/Curator/Views/CuratorView.swift @@ -2,57 +2,52 @@ import SwiftUI import ScarfCore import ScarfDesign -/// Mac UI for Hermes v0.12's autonomous skill curator. +/// Mac UI for Hermes's autonomous skill curator (v0.12 base + v0.13 +/// archive/prune surface). /// /// 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. +/// metadata, agent-created skill counts, the most/least-active / +/// least-recently-active leaderboards, and on v0.13+ hosts the new +/// archived-skills section + per-row Archive button on each leaderboard +/// entry. Pin / unpin / restore / archive / prune route through +/// CuratorViewModel → CuratorService. /// /// 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. +/// item when `HermesCapabilities.hasCurator` is true. Archive surfaces +/// gate independently on `hasCuratorArchive`; pre-v0.13 hosts see the +/// v2.7.x layout unchanged (legacy `CuratorRestoreSheet` reachable from +/// the overflow menu, no Archive section, fire-and-forget Run Now). struct CuratorView: View { @State private var viewModel: CuratorViewModel @State private var showRestoreSheet = false + @Environment(\.hermesCapabilities) private var capabilitiesStore + init(context: ServerContext) { _viewModel = State(initialValue: CuratorViewModel(context: context)) } + /// Single source of truth for "v0.13 archive surface visible". Read + /// once in `body` and threaded into sub-views. Defensive default to + /// `false` so previews / smoke tests behave like a pre-v0.13 host. + private var archiveAvailable: Bool { + capabilitiesStore?.capabilities.hasCuratorArchive ?? false + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: ScarfSpace.s4) { ScarfPageHeader( "Curator", - subtitle: "Autonomous skill maintenance — Hermes v0.12+" + subtitle: archiveAvailable + ? "Autonomous skill maintenance — archive, prune, restore" + : "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") - } - } + headerActions + } + + if let errorMessage = viewModel.errorMessage { + errorBanner(errorMessage) } if let toast = viewModel.transientMessage { @@ -64,6 +59,19 @@ struct CuratorView: View { pinnedSection activityTables + if archiveAvailable { + CuratorArchivedSection( + archived: viewModel.archivedSkills, + isLoading: viewModel.isLoadingArchive, + onRestore: { name in + Task { await viewModel.restore(name) } + }, + onPruneAll: { + Task { await viewModel.planPrune() } + } + ) + } + if let report = viewModel.lastReportMarkdown { lastReportSection(markdown: report) } @@ -71,10 +79,84 @@ struct CuratorView: View { .padding(ScarfSpace.s4) } .background(ScarfColor.backgroundPrimary) - .task { await viewModel.load() } + .task { + await viewModel.load() + if archiveAvailable { + await viewModel.loadArchive() + } + } .sheet(isPresented: $showRestoreSheet) { CuratorRestoreSheet(viewModel: viewModel) } + .sheet( + isPresented: Binding( + get: { viewModel.pruneSummary != nil }, + set: { isShown in + if !isShown { viewModel.cancelPrune() } + } + ) + ) { + if let summary = viewModel.pruneSummary { + CuratorPruneConfirmSheet( + summary: summary, + isPruning: viewModel.isPruning, + onConfirm: { + Task { await viewModel.confirmPrune() } + }, + onCancel: { + viewModel.cancelPrune() + } + ) + } + } + } + + @ViewBuilder + private var headerActions: some View { + HStack(spacing: ScarfSpace.s2) { + if viewModel.isLoading { + ProgressView().controlSize(.small) + } + Button("Run Now") { + Task { + await viewModel.runNow( + synchronous: archiveAvailable, + timeout: 600 + ) + } + } + .buttonStyle(ScarfPrimaryButton()) + .disabled(viewModel.isLoading) + .help(archiveAvailable + ? "Curator runs synchronously on Hermes v0.13+. Usually 10–90s." + : "Trigger a curator run. Returns immediately on pre-v0.13 hosts.") + + Menu { + switch viewModel.status.state { + case .paused: + Button("Resume") { Task { await viewModel.resume() } } + case .enabled: + Button("Pause") { Task { await viewModel.pause() } } + default: + EmptyView() + } + + if archiveAvailable { + Divider() + Button("Prune Archived…", role: .destructive) { + Task { await viewModel.planPrune() } + } + .disabled(viewModel.archivedSkills.isEmpty && !viewModel.isLoadingArchive) + } else { + Button("Restore Archived…") { + showRestoreSheet = true + } + .disabled(viewModel.status.archivedSkills == 0) + } + } label: { + Image(systemName: "ellipsis.circle") + } + } } private var statusSummary: some View { @@ -206,6 +288,10 @@ struct CuratorView: View { } .buttonStyle(.plain) .help(viewModel.status.pinnedNames.contains(row.name) ? "Pinned" : "Pin skill") + + if archiveAvailable { + archiveButton(for: row.name) + } } .padding(.vertical, 2) } @@ -213,6 +299,25 @@ struct CuratorView: View { } } + @ViewBuilder + private func archiveButton(for name: String) -> some View { + if viewModel.pendingArchiveName == name { + ProgressView() + .controlSize(.small) + .frame(width: 14, height: 14) + } else { + Button { + Task { await viewModel.archive(name) } + } label: { + Image(systemName: "archivebox") + .font(.system(size: 12)) + } + .buttonStyle(.plain) + .help("Archive (move out of active set)") + .disabled(viewModel.pendingArchiveName != nil) + } + } + private func counterChip(label: String, value: Int) -> some View { Text("\(label) \(value)") .font(ScarfFont.monoSmall) @@ -277,6 +382,35 @@ struct CuratorView: View { .background(ScarfColor.accentTint) .clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md)) } + + /// Inline yellow banner for CLI failures. Non-blocking — sits above + /// the status summary and dismisses with the "x" so users can keep + /// interacting with the leaderboard. Mirrors the pattern in + /// KanbanBoardView. + private func errorBanner(_ message: String) -> some View { + HStack(alignment: .top, spacing: ScarfSpace.s2) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(ScarfColor.warning) + Text(message) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + Button { + viewModel.dismissError() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(ScarfColor.foregroundMuted) + } + .buttonStyle(.plain) + .help("Dismiss") + } + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, ScarfSpace.s2) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.md) + .fill(ScarfColor.warning.opacity(0.12)) + ) + } } /// Simple `FlowLayout` for the pinned-skill chips. Custom layout