From cedee04f2afc8973271e68f3102499f0d33a5e34 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 19:06:38 +0200 Subject: [PATCH] feat(kanban): v0.13 diagnostics + recovery UX (WS-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layers Hermes v0.13's reliability + recovery affordances on top of the v2.7.5 Kanban v3 board. New surface — gated end-to-end on `HermesCapabilities.hasKanbanDiagnostics` (>= v0.13.0): - **Hallucination gate.** Worker-created cards land in `pending` until the user verifies the underlying work exists. Inspector renders a yellow Verify / Reject banner above the body; cards dim to 0.6 with a question-mark glyph. Verify is optimistic — banner clears immediately, polling confirms. Reject routes through `comment` + `archive` so there's an audit trail. - **Generic diagnostics engine.** `HermesKanbanDiagnostic` (new model + typed-mirror enum `KanbanDiagnosticKind`) renders cross-run signals on the inspector header and per-run signals under each Runs row. Card footer gains a stethoscope dot when any signal is attached. - **`max_retries` create-time field + inspector chip.** Toggle-gated Stepper in the create sheet sends `--max-retries N`; chip on the inspector header reads it back read-only with a tooltip explaining there's no update verb. - **Multi-line title input.** Create sheet's title becomes a `TextField(axis: .vertical, lineLimit: 1...4)`. Newlines are stripped client-side on pre-v0.13 hosts (which truncate at the first `\n`). - **Auto-blocked reason banner.** When `task.auto_blocked_reason` is set, replaces the generic "Last run: blocked" with a red banner rendering the server reason verbatim. Card footer shows a 1-line truncated copy in red. - **Tolerant decode contract.** Every new field is `Optional` with `decodeIfPresent`; diagnostics arrays use `try?` so a single malformed entry doesn't poison the row. v0.12 hosts decode unchanged. Implements WS-3 of Scarf v2.8.0 (Hermes v0.13.0 catch-up). Plan: scarf/docs/v2.8/WS-3-kanban-v0.13-plan.md (on coordination/v2.8.0-plans). TODOs marked inline pending integration against a live v0.13 binary: WS-3-Q1 (verify verb name), WS-3-Q2 (diagnostics envelope vs task), WS-3-Q4 (failure_count placement), WS-3-Q5 (darwin-zombie kind string), WS-3-Q6 (max_retries default). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/HermesKanbanDiagnostic.swift | 158 +++++++++++ .../ScarfCore/Models/HermesKanbanRun.swift | 28 +- .../ScarfCore/Models/HermesKanbanTask.swift | 77 +++++- .../Models/HermesKanbanTaskDetail.swift | 31 ++- .../Models/KanbanCreateRequest.swift | 16 +- .../ScarfCore/Services/KanbanService.swift | 55 ++++ .../ScarfCoreTests/KanbanModelsTests.swift | 192 ++++++++++++++ .../ViewModels/KanbanBoardViewModel.swift | 137 ++++++++-- .../Kanban/Views/KanbanBoardView.swift | 28 +- .../Kanban/Views/KanbanCardView.swift | 87 ++++++- .../Kanban/Views/KanbanColumnView.swift | 38 ++- .../Kanban/Views/KanbanCreateSheet.swift | 88 ++++++- .../Kanban/Views/KanbanInspectorPane.swift | 245 +++++++++++++++--- 13 files changed, 1103 insertions(+), 77 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanDiagnostic.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanDiagnostic.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanDiagnostic.swift new file mode 100644 index 0000000..8b0d2e9 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanDiagnostic.swift @@ -0,0 +1,158 @@ +import Foundation + +/// A structured signal Hermes emits when it observes worker / task +/// distress. Hermes v0.13 introduced a generic diagnostics engine that +/// attaches these to a task (cross-run signals) and/or a run (per-attempt +/// signals). Pre-v0.13 hosts never emit diagnostics so the array decodes +/// empty and downstream UI no-ops. +/// +/// **Wire shape (best inference from release notes — verify against live +/// JSON during integration):** an array of objects with `kind`, optional +/// `message`, optional `detected_at` (ISO-8601 string OR Unix integer, +/// matching the rest of `HermesKanbanTask`'s timestamp tolerance). +/// +/// **Forward compat:** `kind` stays a `String` so a future Hermes can +/// add new diagnostic kinds without a Scarf release. `KanbanDiagnosticKind` +/// is the typed mirror — it falls back to `.unknown` for unrecognized +/// kinds and renders the raw string verbatim. +public struct HermesKanbanDiagnostic: Sendable, Equatable, Identifiable, Codable { + /// Synthetic id — not on the wire. Lets SwiftUI `ForEach` over a + /// diagnostic array without forcing a deterministic id from the + /// server (Hermes doesn't currently mint one). + public let id: UUID + /// Wire-side `kind` string. Compared case-insensitively via + /// `KanbanDiagnosticKind.from(_:)`. + public let kind: String + /// Human-friendly elaboration ("no heartbeat for 4m20s", "exit code + /// 0 with no complete call", etc.). May be nil; render the raw + /// `kind` then. + public let message: String? + /// ISO-8601 string. Decoder accepts Unix integer seconds (Hermes's + /// SQLite-backed shape) and converts to ISO-8601 so consumers see + /// one type — same pattern as `HermesKanbanTask.decodeFlexibleTimestamp`. + public let detectedAt: String? + + public init( + kind: String, + message: String? = nil, + detectedAt: String? = nil + ) { + self.id = UUID() + self.kind = kind + self.message = message + self.detectedAt = detectedAt + } + + enum CodingKeys: String, CodingKey { + case kind + case message + case detectedAt = "detected_at" + } + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.id = UUID() + self.kind = try c.decodeIfPresent(String.self, forKey: .kind) ?? "unknown" + self.message = try c.decodeIfPresent(String.self, forKey: .message) + // Flexible timestamp decode mirrors HermesKanbanTask's pattern. + if !c.contains(.detectedAt) { + self.detectedAt = nil + } else if let unix = try? c.decodeIfPresent(Double.self, forKey: .detectedAt) { + let date = Date(timeIntervalSince1970: unix) + self.detectedAt = Self.isoFormatter.string(from: date) + } else { + self.detectedAt = try c.decodeIfPresent(String.self, forKey: .detectedAt) + } + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(kind, forKey: .kind) + try c.encodeIfPresent(message, forKey: .message) + try c.encodeIfPresent(detectedAt, forKey: .detectedAt) + } + + public static func == (lhs: HermesKanbanDiagnostic, rhs: HermesKanbanDiagnostic) -> Bool { + // Compare on wire fields, not synthetic id — round-trip decoding + // mints fresh ids. + lhs.kind == rhs.kind + && lhs.message == rhs.message + && lhs.detectedAt == rhs.detectedAt + } + + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() +} + +// MARK: - Typed mirror + +/// Typed view of `HermesKanbanDiagnostic.kind`. Models keep the raw +/// string for forward compatibility; UI helpers read this enum to pick +/// the right glyph + tint without string-matching at every callsite. +/// +/// `unknown` is the fallback for any kind a future Hermes adds that +/// Scarf doesn't recognize. Views render the raw string verbatim in +/// that case so the user still sees what Hermes flagged. +// TODO(WS-3-Q5): The exact `kind` string for darwin-zombie detection is +// inferred from the v0.13 release notes ("Detect darwin zombie workers"); +// confirm against live `hermes kanban show --json` output during +// integration. Same for `worker_exit_no_complete` and the heartbeat-stalled +// kinds — typed mirror falls through to `.unknown` if the wire string +// drifts, and the raw string is still rendered. +public enum KanbanDiagnosticKind: String, Sendable, CaseIterable { + case heartbeatStalled = "heartbeat_stalled" + case toolErrorLoop = "tool_error_loop" + case retryCapHit = "retry_cap_hit" + case unboundedRetry = "unbounded_retry" + case darwinZombieDetected = "darwin_zombie_detected" + case spawnFailure = "spawn_failure" + case workerExitNoComplete = "worker_exit_no_complete" + case unknown + + /// Map a wire string (case-insensitive) to a typed kind. Unknown + /// values fall through to `.unknown` so callers can still surface + /// the raw string. + public static func from(_ raw: String) -> KanbanDiagnosticKind { + KanbanDiagnosticKind(rawValue: raw.lowercased()) ?? .unknown + } + + /// SF Symbol name to render alongside the diagnostic. View code + /// reaches through the typed enum so glyph choices live in one + /// place. + public var glyphName: String { + switch self { + case .heartbeatStalled: return "waveform.path.badge.minus" + case .toolErrorLoop: return "arrow.triangle.2.circlepath.exclamationmark" + case .retryCapHit: return "nosign" + case .unboundedRetry: return "arrow.clockwise.circle.fill" + case .darwinZombieDetected: return "apple.logo" + case .spawnFailure: return "bolt.slash" + case .workerExitNoComplete: return "figure.walk.departure" + case .unknown: return "stethoscope" + } + } + + /// Severity tier for this kind — drives badge tint. `.danger` for + /// terminal-class signals (retry cap hit, zombie, spawn failure); + /// `.warning` for recoverable signals (heartbeat stalled, tool + /// error loop); `.neutral` only for unknown / forward-compat kinds. + public var severity: DiagnosticSeverity { + switch self { + case .retryCapHit, .darwinZombieDetected, .spawnFailure: + return .danger + case .heartbeatStalled, .toolErrorLoop, .unboundedRetry, .workerExitNoComplete: + return .warning + case .unknown: + return .neutral + } + } + + public enum DiagnosticSeverity: Sendable { + case warning + case danger + case neutral + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanRun.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanRun.swift index ec27ad7..cfe6319 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanRun.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanRun.swift @@ -24,6 +24,19 @@ public struct HermesKanbanRun: Sendable, Equatable, Identifiable, Codable { /// raw string so we don't lock the typed shape. public let metadataJSON: String? + // v0.13 (v2026.5.7) fields. Both Optional / empty-default so a v0.12 + // host's run row decodes without error. + /// Per-attempt distress signals. Cross-run signals (retry cap hit, + /// etc.) hang off `HermesKanbanTask.diagnostics`; in-flight signals + /// (heartbeat stalled, darwin zombie detected) attach here. + public let diagnostics: [HermesKanbanDiagnostic] + /// Server-side unified failure counter (renamed from three separate + /// spawn / timeout / crash counters in v0.13). Optional — when nil, + /// callers fall back to counting failed runs in the runs array. + // TODO(WS-3-Q4): Verify whether v0.13 exposes this field on the per-run + // shape OR only at the task level. Tolerant decode handles either. + public let failureCount: Int? + public init( id: Int, taskId: String, @@ -40,7 +53,9 @@ public struct HermesKanbanRun: Sendable, Equatable, Identifiable, Codable { outcome: String? = nil, summary: String? = nil, error: String? = nil, - metadataJSON: String? = nil + metadataJSON: String? = nil, + diagnostics: [HermesKanbanDiagnostic] = [], + failureCount: Int? = nil ) { self.id = id self.taskId = taskId @@ -58,6 +73,8 @@ public struct HermesKanbanRun: Sendable, Equatable, Identifiable, Codable { self.summary = summary self.error = error self.metadataJSON = metadataJSON + self.diagnostics = diagnostics + self.failureCount = failureCount } enum CodingKeys: String, CodingKey { @@ -77,6 +94,8 @@ public struct HermesKanbanRun: Sendable, Equatable, Identifiable, Codable { case summary case error case metadata + case diagnostics + case failureCount = "failure_count" } public init(from decoder: any Decoder) throws { @@ -120,6 +139,11 @@ public struct HermesKanbanRun: Sendable, Equatable, Identifiable, Codable { } else { self.metadataJSON = nil } + + // v0.13 diagnostics array — `try?` so a malformed entry doesn't + // poison the whole run row. Empty default for pre-v0.13 hosts. + self.diagnostics = (try? c.decodeIfPresent([HermesKanbanDiagnostic].self, forKey: .diagnostics)) ?? [] + self.failureCount = try c.decodeIfPresent(Int.self, forKey: .failureCount) } public func encode(to encoder: any Encoder) throws { @@ -140,5 +164,7 @@ public struct HermesKanbanRun: Sendable, Equatable, Identifiable, Codable { try c.encodeIfPresent(summary, forKey: .summary) try c.encodeIfPresent(error, forKey: .error) try c.encodeIfPresent(metadataJSON, forKey: .metadata) + try c.encode(diagnostics, forKey: .diagnostics) + try c.encodeIfPresent(failureCount, forKey: .failureCount) } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift index a3a28a6..0f71c13 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift @@ -9,8 +9,9 @@ import Foundation /// `link`/`unlink`, `comment`, `dispatch`). /// /// Hermes has no `update` verb — `priority` / `title` / `body` / -/// `tenant` are write-once at create time. Mutations after that are -/// expressed as state transitions (status, assignee) or new comments. +/// `tenant` / `max_retries` are write-once at create time. Mutations +/// after that are expressed as state transitions (status, assignee) or +/// new comments. public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable { public let id: String public let title: String @@ -34,6 +35,29 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable { public let maxRuntimeSeconds: Int? public let currentRunId: Int? + // v0.13 (v2026.5.7) reliability + recovery fields. All Optional with + // `nil` decoded for pre-v0.13 hosts so the v2.7.5 surface keeps + // rendering unchanged when the connected Hermes hasn't shipped them. + /// Per-task retry budget set at create time via `--max-retries N`. + /// Hermes pattern is write-once — no `set_max_retries` verb. Scarf + /// surfaces this read-only on the inspector header. + public let maxRetries: Int? + /// Server-supplied reason a task was auto-blocked (e.g. "worker + /// exited (code 0) without calling `kanban complete`"). Surfaced + /// verbatim in the inspector banner. + public let autoBlockedReason: String? + /// `pending` / `verified` / `rejected` / nil. Pending means a worker + /// claimed it created this card but Hermes hasn't confirmed the + /// underlying work exists. Read through `KanbanHallucinationGate.from` + /// to map to a typed mirror — kept as a String at the wire level so + /// Hermes can add new gate states (e.g. `quarantined`) without a + /// Scarf release. + public let hallucinationGateStatus: String? + /// Cross-run distress signals (retry cap hit, etc.). Per-run signals + /// hang off `HermesKanbanRun.diagnostics`. Empty array for pre-v0.13 + /// hosts AND for tasks the diagnostics engine hasn't flagged. + public let diagnostics: [HermesKanbanDiagnostic] + public init( id: String, title: String, @@ -53,7 +77,11 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable { idempotencyKey: String? = nil, lastHeartbeatAt: String? = nil, maxRuntimeSeconds: Int? = nil, - currentRunId: Int? = nil + currentRunId: Int? = nil, + maxRetries: Int? = nil, + autoBlockedReason: String? = nil, + hallucinationGateStatus: String? = nil, + diagnostics: [HermesKanbanDiagnostic] = [] ) { self.id = id self.title = title @@ -74,6 +102,10 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable { self.lastHeartbeatAt = lastHeartbeatAt self.maxRuntimeSeconds = maxRuntimeSeconds self.currentRunId = currentRunId + self.maxRetries = maxRetries + self.autoBlockedReason = autoBlockedReason + self.hallucinationGateStatus = hallucinationGateStatus + self.diagnostics = diagnostics } enum CodingKeys: String, CodingKey { @@ -89,6 +121,10 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable { case lastHeartbeatAt = "last_heartbeat_at" case maxRuntimeSeconds = "max_runtime_seconds" case currentRunId = "current_run_id" + case maxRetries = "max_retries" + case autoBlockedReason = "auto_blocked_reason" + case hallucinationGateStatus = "hallucination_gate_status" + case diagnostics } public init(from decoder: any Decoder) throws { @@ -117,6 +153,17 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable { self.lastHeartbeatAt = try Self.decodeFlexibleTimestamp(c, forKey: .lastHeartbeatAt) self.maxRuntimeSeconds = try c.decodeIfPresent(Int.self, forKey: .maxRuntimeSeconds) self.currentRunId = try c.decodeIfPresent(Int.self, forKey: .currentRunId) + // v0.13 fields — every one is `decodeIfPresent` so a v0.12 host's + // task row decodes successfully with these all nil/empty. The + // tolerant-decode contract is pinned by KanbanModelsTests. + self.maxRetries = try c.decodeIfPresent(Int.self, forKey: .maxRetries) + self.autoBlockedReason = try c.decodeIfPresent(String.self, forKey: .autoBlockedReason) + self.hallucinationGateStatus = try c.decodeIfPresent(String.self, forKey: .hallucinationGateStatus) + // Wrap diagnostics decode in `try?` so a single malformed entry + // (or the whole array being the wrong shape) doesn't poison the + // task row — the rest of the decoder still produces a usable + // task. Empty default matches the `skills` pattern. + self.diagnostics = (try? c.decodeIfPresent([HermesKanbanDiagnostic].self, forKey: .diagnostics)) ?? [] } /// Decode a timestamp that may arrive as a Unix integer or an @@ -209,3 +256,27 @@ public enum KanbanBoardColumn: String, Sendable, CaseIterable, Identifiable { .triage, .upNext, .running, .blocked, .done ] } + +// MARK: - Hallucination gate (v0.13) + +/// Typed mirror of Hermes v0.13's hallucination-gate state. Worker-created +/// cards land in `pending` until something verifies the underlying work +/// exists; Scarf surfaces a Verify / Reject UX above the task body so the +/// user can act as the verification gate. +/// +/// Kept separate from `KanbanStatus` because hallucination state is +/// orthogonal to the lifecycle — a card can be `ready` *and* `pending`, +/// for example. +public enum KanbanHallucinationGate: String, Sendable, CaseIterable { + case pending + case verified + case rejected + + /// Map a raw `hallucination_gate_status` string (case-insensitive) to + /// a typed gate. Returns nil for empty/nil/unknown values so callers + /// can short-circuit "no gate" branches with `if let gate = …`. + public static func from(_ raw: String?) -> KanbanHallucinationGate? { + guard let raw, !raw.isEmpty else { return nil } + return KanbanHallucinationGate(rawValue: raw.lowercased()) + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTaskDetail.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTaskDetail.swift index 31b1126..f13e327 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTaskDetail.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTaskDetail.swift @@ -12,17 +12,27 @@ public struct HermesKanbanTaskDetail: Sendable, Equatable, Codable { /// to the worker as upstream context; surfacing them in the /// inspector is useful for understanding why a task started. public let parentResults: [String: String] + /// Envelope-level diagnostics array (sibling to `task`, not nested + /// inside it). Defensive — Hermes v0.13's wire shape may attach + /// diagnostics to the task itself OR to the envelope. + /// `allDiagnostics` dedupes both sources by `(kind, detected_at)`. + // TODO(WS-3-Q2): Confirm against live `hermes kanban show --json` + // whether diagnostics live on the task envelope, the inner task, or + // both. Current decode is tolerant of either. + public let envelopeDiagnostics: [HermesKanbanDiagnostic]? public init( task: HermesKanbanTask, comments: [HermesKanbanComment] = [], events: [HermesKanbanEvent] = [], - parentResults: [String: String] = [:] + parentResults: [String: String] = [:], + envelopeDiagnostics: [HermesKanbanDiagnostic]? = nil ) { self.task = task self.comments = comments self.events = events self.parentResults = parentResults + self.envelopeDiagnostics = envelopeDiagnostics } enum CodingKeys: String, CodingKey { @@ -30,6 +40,7 @@ public struct HermesKanbanTaskDetail: Sendable, Equatable, Codable { case comments case events case parentResults = "parent_results" + case envelopeDiagnostics = "diagnostics" } public init(from decoder: any Decoder) throws { @@ -48,6 +59,9 @@ public struct HermesKanbanTaskDetail: Sendable, Equatable, Codable { self.comments = (try? container.decodeIfPresent([HermesKanbanComment].self, forKey: .comments)) ?? [] self.events = (try? container.decodeIfPresent([HermesKanbanEvent].self, forKey: .events)) ?? [] self.parentResults = (try? container.decodeIfPresent([String: String].self, forKey: .parentResults)) ?? [:] + // Same `try?` shield as the rest — a malformed envelope + // diagnostics array shouldn't reject the whole show response. + self.envelopeDiagnostics = try? container.decodeIfPresent([HermesKanbanDiagnostic].self, forKey: .envelopeDiagnostics) } public func encode(to encoder: any Encoder) throws { @@ -56,5 +70,20 @@ public struct HermesKanbanTaskDetail: Sendable, Equatable, Codable { try c.encode(comments, forKey: .comments) try c.encode(events, forKey: .events) try c.encode(parentResults, forKey: .parentResults) + try c.encodeIfPresent(envelopeDiagnostics, forKey: .envelopeDiagnostics) + } + + /// Unified diagnostics view for the inspector. Combines `task.diagnostics` + /// with envelope-level diagnostics (when present) and dedupes on the + /// `(kind, detectedAt)` tuple. Wire-side dupes are unlikely but cheap to + /// filter. Empty for pre-v0.13 hosts. + public var allDiagnostics: [HermesKanbanDiagnostic] { + let onTask = task.diagnostics + let onEnvelope = envelopeDiagnostics ?? [] + var seen = Set() + return (onTask + onEnvelope).filter { diag in + let key = "\(diag.kind)|\(diag.detectedAt ?? "")" + return seen.insert(key).inserted + } } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/KanbanCreateRequest.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/KanbanCreateRequest.swift index 8fa94f5..3e0f744 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/KanbanCreateRequest.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/KanbanCreateRequest.swift @@ -17,6 +17,15 @@ public struct KanbanCreateRequest: Sendable, Equatable { public var maxRuntimeSeconds: Int? public var createdBy: String? public var skills: [String] + /// v0.13: per-task retry budget. `--max-retries N` is write-once at + /// create time — no `set_max_retries` verb. Pass `nil` to let Hermes + /// pick its built-in default (3 as of v0.13.0). Capability-gated in + /// the create sheet on `hasKanbanDiagnostics`. + // TODO(WS-3-Q6): Confirm Hermes's global default for `max_retries` + // (v0.13 release notes don't enumerate it). The create sheet defaults + // the field to 3; if Hermes config exposes a different default, mirror + // it. + public var maxRetries: Int? public init( title: String, @@ -30,7 +39,8 @@ public struct KanbanCreateRequest: Sendable, Equatable { idempotencyKey: String? = nil, maxRuntimeSeconds: Int? = nil, createdBy: String? = nil, - skills: [String] = [] + skills: [String] = [], + maxRetries: Int? = nil ) { self.title = title self.body = body @@ -44,6 +54,7 @@ public struct KanbanCreateRequest: Sendable, Equatable { self.maxRuntimeSeconds = maxRuntimeSeconds self.createdBy = createdBy self.skills = skills + self.maxRetries = maxRetries } /// Build the argv suffix this request maps to (everything after @@ -78,6 +89,9 @@ public struct KanbanCreateRequest: Sendable, Equatable { if let maxRuntimeSeconds { args.append(contentsOf: ["--max-runtime", "\(maxRuntimeSeconds)s"]) } + if let maxRetries { + args.append(contentsOf: ["--max-retries", String(maxRetries)]) + } if let createdBy, !createdBy.isEmpty { args.append(contentsOf: ["--created-by", createdBy]) } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift index 75d18fc..7f766a9 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift @@ -321,6 +321,61 @@ public actor KanbanService { try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "unlink") } + // MARK: - Hallucination gate (v0.13) + + /// Mark a worker-created card as user-verified — flips + /// `hallucination_gate_status` from `pending` to `verified` so the + /// dispatcher can pick it up. The polling loop picks up the new + /// state on the next tick (and the VM optimistically clears the + /// pending banner immediately on the click). + /// + /// **Pre-v0.13 hosts:** the verb doesn't exist; callers MUST gate + /// on `HermesCapabilities.hasKanbanDiagnostics` before invoking this. + /// A pre-v0.13 binary will surface the failure as + /// `KanbanError.nonZeroExit` with stderr containing "unknown command". + // TODO(WS-3-Q1): Confirm the exact CLI verb name for the + // hallucination-gate verify path against a v0.13 binary (`hermes + // kanban --help`). The v0.13 release notes describe "hallucination + // gate + recovery UX" but don't enumerate the verb name. This + // implementation assumes `hermes kanban verify `. If Hermes ships + // it as `hermes kanban gate verify `, `hermes kanban hallucination + // verify `, or another name, update the args here. The Reject + // path does NOT depend on this verb (it routes through + // `archive` + a comment), so the recovery UX stays functional even + // if Verify is a stub for an early v0.13.x. + public func verify(taskId: String) async throws { + let args = ["kanban", "verify", taskId] + let (code, _, stderr) = await runHermes(args: args, timeout: 15) + try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "verify") + } + + /// Reject a worker-created card as a hallucinated reference. There + /// is no dedicated `kanban reject` verb in v0.13; the right action + /// per the v0.13 release notes is to archive the card (the work + /// doesn't exist) with a comment recording the rejection reason for + /// the audit trail. Routing this through the existing `comment` + + /// `archive` verbs keeps the wire shape stable across versions. + /// + /// If a future Hermes adds a dedicated `kanban reject` verb, swap + /// the body here — the public surface stays "reject" returning Void. + public func rejectHallucinated(taskId: String) async throws { + // Best-effort comment first so the audit trail records the + // rejection. A failure here shouldn't block the archive — log + // and continue. + do { + try await comment( + taskId: taskId, + text: "Rejected as hallucinated (no underlying work).", + author: nil + ) + } catch { + #if canImport(os) + Self.logger.warning("kanban reject: comment failed, proceeding to archive (\(error.localizedDescription, privacy: .public))") + #endif + } + try await archive(taskIds: [taskId]) + } + // MARK: - Drag-drop transition mapper /// Map a board-level column transition to the right Hermes verb call. diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/KanbanModelsTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/KanbanModelsTests.swift index 3550797..3d74a61 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/KanbanModelsTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/KanbanModelsTests.swift @@ -327,4 +327,196 @@ import Foundation #expect(stats.glanceString.isEmpty) #expect(stats.activeCount == 0) } + + // MARK: - v0.13 (Hermes 2026.5.7) tolerant decode + // + // The contract these tests pin: a v0.13 host's task / run / detail + // JSON decodes successfully WITH the new fields populated, AND a + // pre-v0.13 (v0.12) host's task / run / detail JSON decodes + // successfully WITHOUT the new fields (everything resolves to nil + // or empty). Drift from this pair = a regression that bites every + // user not yet on Hermes v0.13. + + @Test func decodeV013TaskFields() throws { + let json = """ + { + "id": "t_v013", + "title": "v0.13 task", + "status": "blocked", + "max_retries": 5, + "auto_blocked_reason": "worker exited without `kanban complete`", + "hallucination_gate_status": "pending", + "diagnostics": [ + {"kind": "worker_exit_no_complete", "message": "exit code 0 with no complete call", "detected_at": 1778160614}, + {"kind": "darwin_zombie_detected", "detected_at": "2026-05-09T12:00:00Z"} + ] + } + """ + let task = try JSONDecoder().decode(HermesKanbanTask.self, from: Data(json.utf8)) + #expect(task.maxRetries == 5) + #expect(task.autoBlockedReason?.contains("kanban complete") == true) + #expect(task.hallucinationGateStatus == "pending") + #expect(task.diagnostics.count == 2) + #expect(task.diagnostics.first?.kind == "worker_exit_no_complete") + #expect(task.diagnostics.last?.detectedAt?.contains("2026") == true) + } + + @Test func decodeV012TaskHasNoNewFields() throws { + // The most damaging failure mode is a v0.12 user upgrading Scarf + // and having the board stop loading because a v0.13-only field + // is required. Pin the contract. + let json = """ + {"id": "t_legacy", "title": "v0.12 task", "status": "ready"} + """ + let task = try JSONDecoder().decode(HermesKanbanTask.self, from: Data(json.utf8)) + #expect(task.maxRetries == nil) + #expect(task.autoBlockedReason == nil) + #expect(task.hallucinationGateStatus == nil) + #expect(task.diagnostics.isEmpty) + } + + @Test func decodeMalformedDiagnosticTolerated() throws { + // If Hermes emits a malformed diagnostics value, the rest of the + // task should still decode. We use try? on the diagnostics decode + // so a single bad entry doesn't reject the whole row. + let json = """ + { + "id": "t_x", + "title": "x", + "status": "ready", + "diagnostics": "not-an-array" + } + """ + let task = try JSONDecoder().decode(HermesKanbanTask.self, from: Data(json.utf8)) + #expect(task.id == "t_x") + // Diagnostics field couldn't decode — treat as empty. + #expect(task.diagnostics.isEmpty) + } + + @Test func hallucinationGateMirrorMapsKnownValues() { + #expect(KanbanHallucinationGate.from("pending") == .pending) + #expect(KanbanHallucinationGate.from("verified") == .verified) + #expect(KanbanHallucinationGate.from("REJECTED") == .rejected) // case-insensitive + #expect(KanbanHallucinationGate.from(nil) == nil) + #expect(KanbanHallucinationGate.from("") == nil) + // Unknown wire values fall through to nil so the banner stays + // hidden; future Hermes versions can add `quarantined` etc. + // without a Scarf release. + #expect(KanbanHallucinationGate.from("quarantined") == nil) + } + + @Test func diagnosticKindMirrorMapsKnownValues() { + #expect(KanbanDiagnosticKind.from("heartbeat_stalled") == .heartbeatStalled) + #expect(KanbanDiagnosticKind.from("DARWIN_ZOMBIE_DETECTED") == .darwinZombieDetected) + // Unknown kinds fall through to .unknown so views can render + // the raw string verbatim. + #expect(KanbanDiagnosticKind.from("future_kind_v014") == .unknown) + } + + @Test func diagnosticSeverityMapping() { + #expect(KanbanDiagnosticKind.retryCapHit.severity == .danger) + #expect(KanbanDiagnosticKind.darwinZombieDetected.severity == .danger) + #expect(KanbanDiagnosticKind.heartbeatStalled.severity == .warning) + #expect(KanbanDiagnosticKind.workerExitNoComplete.severity == .warning) + #expect(KanbanDiagnosticKind.unknown.severity == .neutral) + } + + @Test func createRequestArgvIncludesMaxRetries() { + let req = KanbanCreateRequest(title: "t", maxRetries: 5) + let argv = req.argv() + #expect(argv.contains("--max-retries")) + #expect(argv.contains("5")) + } + + @Test func createRequestArgvOmitsMaxRetriesWhenAbsent() { + let req = KanbanCreateRequest(title: "t") + let argv = req.argv() + #expect(!argv.contains("--max-retries")) + } + + @Test func decodeRunWithDiagnostics() throws { + let json = """ + { + "id": 1, + "task_id": "t_x", + "status": "failed", + "started_at": 1778160000, + "ended_at": 1778160300, + "outcome": "crashed", + "error": "OOM", + "diagnostics": [ + {"kind": "retry_cap_hit", "message": "3/3 retries exhausted"} + ], + "failure_count": 3 + } + """ + let run = try JSONDecoder().decode(HermesKanbanRun.self, from: Data(json.utf8)) + #expect(run.diagnostics.count == 1) + #expect(run.diagnostics.first?.kind == "retry_cap_hit") + #expect(run.failureCount == 3) + } + + @Test func decodeRunWithoutDiagnostics() throws { + // v0.12 run row — no diagnostics, no failure_count, must still + // decode cleanly. + let json = """ + {"id": 1, "task_id": "t_x", "status": "running", "started_at": 1778160000} + """ + let run = try JSONDecoder().decode(HermesKanbanRun.self, from: Data(json.utf8)) + #expect(run.diagnostics.isEmpty) + #expect(run.failureCount == nil) + } + + @Test func taskDetailMergesEnvelopeAndTaskDiagnostics() throws { + // Hermes's wire shape may put diagnostics on the task envelope OR + // on the inner task. `allDiagnostics` dedupes by (kind, detected_at) + // so a server emitting both sides doesn't surface dupes. + let json = """ + { + "task": { + "id": "t_y", + "title": "y", + "status": "blocked", + "diagnostics": [ + {"kind": "heartbeat_stalled", "detected_at": "2026-05-09T12:00:00Z"} + ] + }, + "comments": [], + "events": [], + "diagnostics": [ + {"kind": "heartbeat_stalled", "detected_at": "2026-05-09T12:00:00Z"}, + {"kind": "retry_cap_hit"} + ] + } + """ + let detail = try JSONDecoder().decode(HermesKanbanTaskDetail.self, from: Data(json.utf8)) + let merged = detail.allDiagnostics + #expect(merged.count == 2) + #expect(merged.contains(where: { $0.kind == "heartbeat_stalled" })) + #expect(merged.contains(where: { $0.kind == "retry_cap_hit" })) + } + + @Test func taskDetailWithoutEnvelopeDiagnosticsDecodes() throws { + // Pre-v0.13 task detail — no envelope diagnostics. Must decode. + let json = """ + { + "task": {"id": "t_z", "title": "z", "status": "ready"}, + "comments": [], + "events": [] + } + """ + let detail = try JSONDecoder().decode(HermesKanbanTaskDetail.self, from: Data(json.utf8)) + #expect(detail.envelopeDiagnostics == nil) + #expect(detail.allDiagnostics.isEmpty) + } + + @Test func diagnosticDecodesUnixTimestamp() throws { + let json = """ + {"kind": "spawn_failure", "detected_at": 1778160614} + """ + let diag = try JSONDecoder().decode(HermesKanbanDiagnostic.self, from: Data(json.utf8)) + #expect(diag.kind == "spawn_failure") + // Decoder normalizes Unix int → ISO-8601 string. + #expect(diag.detectedAt?.contains("2026") == true) + } } diff --git a/scarf/scarf/Features/Kanban/ViewModels/KanbanBoardViewModel.swift b/scarf/scarf/Features/Kanban/ViewModels/KanbanBoardViewModel.swift index 95ccfce..5d8e091 100644 --- a/scarf/scarf/Features/Kanban/ViewModels/KanbanBoardViewModel.swift +++ b/scarf/scarf/Features/Kanban/ViewModels/KanbanBoardViewModel.swift @@ -55,9 +55,22 @@ final class KanbanBoardViewModel { var assigneeFilter: String? // nil = all assignees var showArchived: Bool = false - /// Optimistic moves keyed by task id; cleared when the polled - /// response includes the same status the optimistic move set. - private var optimisticOverrides: [String: String] = [:] + /// Optimistic in-flight overrides keyed by task id; cleared when the + /// polled response confirms the new state. + /// - Status side: drag-drop column moves. + /// - Hallucination-gate side (v0.13): Verify clicks flip `pending` → + /// `verified` locally so the banner disappears immediately. + /// The override entry is dropped from the dictionary entirely once + /// both sides are nil (no override needed). + private struct OptimisticOverride { + var status: String? + var hallucinationGate: KanbanHallucinationGate? + + var isEmpty: Bool { + status == nil && hallucinationGate == nil + } + } + private var optimisticOverrides: [String: OptimisticOverride] = [:] /// Tasks dropped into invalid columns produce a transient "denied" /// banner. Stored as an explicit error to support the Cmd-Z style /// undo we don't ship in v2.7.5 but want to leave room for. @@ -177,8 +190,10 @@ final class KanbanBoardViewModel { // Optimistic mutation — flip the local row's status to a // value within the destination column's range. We pick a // representative status per column. - let optimisticStatus = optimisticStatus(for: destination) - optimisticOverrides[taskId] = optimisticStatus + let optimisticStatusValue = optimisticStatus(for: destination) + var override = optimisticOverrides[taskId] ?? OptimisticOverride() + override.status = optimisticStatusValue + optimisticOverrides[taskId] = override let svc = service Task { @@ -190,11 +205,11 @@ final class KanbanBoardViewModel { // without waiting for the 5s tick. await refresh() } catch let err as KanbanError { - optimisticOverrides.removeValue(forKey: taskId) + clearStatusOverride(for: taskId) lastError = err.errorDescription logger.warning("kanban move failed: \(err.errorDescription ?? "", privacy: .public)") } catch { - optimisticOverrides.removeValue(forKey: taskId) + clearStatusOverride(for: taskId) lastError = error.localizedDescription } } @@ -269,6 +284,48 @@ final class KanbanBoardViewModel { return task } + // MARK: - Hallucination gate (v0.13) + + /// User confirmed the worker-created card is real. Optimistically + /// flip the gate to `verified` so the banner disappears immediately; + /// the polling loop confirms the new state on the next tick. On + /// failure (e.g. the verb name is wrong on this v0.13.x build), the + /// override is cleared and the error surfaces in `lastError`. + func verifyHallucination(taskId: String) { + var override = optimisticOverrides[taskId] ?? OptimisticOverride() + override.hallucinationGate = .verified + optimisticOverrides[taskId] = override + Task { + do { + try await service.verify(taskId: taskId) + await refresh() + } catch let err as KanbanError { + clearHallucinationOverride(for: taskId) + lastError = err.errorDescription + logger.warning("kanban verify failed: \(err.errorDescription ?? "", privacy: .public)") + } catch { + clearHallucinationOverride(for: taskId) + lastError = error.localizedDescription + } + } + } + + /// User rejected the worker-created card as a hallucinated reference. + /// Routes through `comment` + `archive` per `KanbanService.rejectHallucinated` + /// so there's an audit trail for why the card disappeared. + func rejectHallucination(taskId: String) { + Task { + do { + try await service.rejectHallucinated(taskId: taskId) + await refresh() + } catch let err as KanbanError { + lastError = err.errorDescription + } catch { + lastError = error.localizedDescription + } + } + } + // MARK: - Private helpers private func mergePolledTasks(_ polled: [HermesKanbanTask]) { @@ -282,25 +339,75 @@ final class KanbanBoardViewModel { filtered = polled } let presentIds = Set(filtered.map(\.id)) - // Drop optimistic overrides for tasks Hermes confirmed. - for (id, optimistic) in optimisticOverrides { - if let row = filtered.first(where: { $0.id == id }) { - if columnFromStatus(optimistic) == columnFromStatus(row.status) { + // Drop optimistic overrides for tasks Hermes confirmed. Two + // independent sides — clear them separately so a Verify click + // still in-flight survives a status-side poll confirmation, and + // vice versa. + for (id, override) in optimisticOverrides { + guard let row = filtered.first(where: { $0.id == id }) else { + if !presentIds.contains(id) { + // Task no longer in the polled set (archived, deleted, + // or filtered out). Drop the override entirely. optimisticOverrides.removeValue(forKey: id) } - } else if !presentIds.contains(id) { - // Task no longer in the polled set (archived, deleted, - // or filtered out). Drop the optimistic entry. + continue + } + // Status side — optimistic move confirmed. + if let optStatus = override.status, + columnFromStatus(optStatus) == columnFromStatus(row.status) { + optimisticOverrides[id]?.status = nil + } + // Hallucination-gate side — optimistic verify/reject confirmed. + if let optGate = override.hallucinationGate, + KanbanHallucinationGate.from(row.hallucinationGateStatus) == optGate { + optimisticOverrides[id]?.hallucinationGate = nil + } + if optimisticOverrides[id]?.isEmpty ?? true { optimisticOverrides.removeValue(forKey: id) } } tasks = filtered } + /// Drop the status side of a task's override (preserving any + /// in-flight hallucination-gate optimistic state). + private func clearStatusOverride(for taskId: String) { + guard var override = optimisticOverrides[taskId] else { return } + override.status = nil + if override.isEmpty { + optimisticOverrides.removeValue(forKey: taskId) + } else { + optimisticOverrides[taskId] = override + } + } + + /// Drop the hallucination-gate side of a task's override (preserving + /// any in-flight status-side drag-drop). + private func clearHallucinationOverride(for taskId: String) { + guard var override = optimisticOverrides[taskId] else { return } + override.hallucinationGate = nil + if override.isEmpty { + optimisticOverrides.removeValue(forKey: taskId) + } else { + optimisticOverrides[taskId] = override + } + } + + /// Effective hallucination gate for a task — the optimistic override + /// wins if one is in flight; otherwise the polled value. View code + /// reads through this so the banner / dim state matches the moment- + /// after-click experience. + func effectiveHallucinationGate(_ task: HermesKanbanTask) -> KanbanHallucinationGate? { + if let override = optimisticOverrides[task.id]?.hallucinationGate { + return override + } + return KanbanHallucinationGate.from(task.hallucinationGateStatus) + } + /// Return the effective board column for a task — the optimistic /// override wins if one is in flight; otherwise the polled status. private func effectiveColumn(_ task: HermesKanbanTask) -> KanbanBoardColumn { - if let overrideStatus = optimisticOverrides[task.id] { + if let overrideStatus = optimisticOverrides[task.id]?.status { return columnFromStatus(overrideStatus) } return columnFromStatus(task.status) diff --git a/scarf/scarf/Features/Kanban/Views/KanbanBoardView.swift b/scarf/scarf/Features/Kanban/Views/KanbanBoardView.swift index 3674f99..dc28741 100644 --- a/scarf/scarf/Features/Kanban/Views/KanbanBoardView.swift +++ b/scarf/scarf/Features/Kanban/Views/KanbanBoardView.swift @@ -13,6 +13,7 @@ import ScarfDesign /// tenant. struct KanbanBoardView: View { @State private var viewModel: KanbanBoardViewModel + @Environment(\.hermesCapabilities) private var capabilitiesStore /// When non-nil, a project board hosts this view. Drives header /// chrome (subtitle, hidden tenant filter) and create-sheet @@ -33,6 +34,15 @@ struct KanbanBoardView: View { self.projectName = projectName } + /// Convenience read for the v0.13 diagnostics flag — gates the + /// max_retries field, hallucination banner, diagnostics rendering, + /// and the auto-blocked reason banner. Pre-v0.13 hosts get the + /// v2.7.5 surface unchanged. Treats a missing store as "off" so + /// harness contexts (Previews) don't accidentally surface gated UI. + private var supportsKanbanDiagnostics: Bool { + capabilitiesStore?.capabilities.hasKanbanDiagnostics ?? false + } + @State private var inspectorTaskId: String? @State private var showingCreateSheet = false @State private var blockSheetTaskId: String? @@ -71,7 +81,8 @@ struct KanbanBoardView: View { KanbanCreateSheet( assignees: viewModel.assignees, tenantPrefill: viewModel.tenantFilter, - projectWorkspacePath: viewModel.projectPath + projectWorkspacePath: viewModel.projectPath, + supportsKanbanDiagnostics: supportsKanbanDiagnostics ) { request in _ = try await viewModel.createTask(request) } @@ -188,7 +199,9 @@ struct KanbanBoardView: View { onDrop: { ref in handleDrop(ref.id, on: column) }, - canCreate: column == .upNext || column == .triage + canCreate: column == .upNext || column == .triage, + supportsKanbanDiagnostics: supportsKanbanDiagnostics, + effectiveHallucinationGate: { viewModel.effectiveHallucinationGate($0) } ) } Spacer(minLength: ScarfSpace.s4) @@ -208,6 +221,8 @@ struct KanbanBoardView: View { service: viewModel.service, taskId: taskId, availableAssignees: viewModel.assignees, + supportsKanbanDiagnostics: supportsKanbanDiagnostics, + effectiveHallucinationGate: { viewModel.effectiveHallucinationGate($0) }, onClose: { inspectorTaskId = nil }, onClaim: { viewModel.attemptMove(taskId: taskId, to: .running) @@ -232,6 +247,15 @@ struct KanbanBoardView: View { }, onReassign: { profile in viewModel.reassignTask(taskId: taskId, to: profile) + }, + onVerifyHallucination: { + viewModel.verifyHallucination(taskId: taskId) + }, + onRejectHallucination: { + viewModel.rejectHallucination(taskId: taskId) + // Card vanishes from active board after archive — close + // the inspector so it doesn't dangle on a deleted task. + inspectorTaskId = nil } ) } diff --git a/scarf/scarf/Features/Kanban/Views/KanbanCardView.swift b/scarf/scarf/Features/Kanban/Views/KanbanCardView.swift index 0a26139..e5721d2 100644 --- a/scarf/scarf/Features/Kanban/Views/KanbanCardView.swift +++ b/scarf/scarf/Features/Kanban/Views/KanbanCardView.swift @@ -24,12 +24,40 @@ struct KanbanTaskRef: Transferable { /// - **Running** gets a blue left-edge accent + live shimmer /// - **Blocked** gets a warning left-edge accent + ⚠ glyph /// - **Done** dims to 0.7 opacity (0.55 in dark mode) +/// - **Hallucination-gate pending** (v0.13+) dims to 0.6 + ⚠ glyph and +/// shows a one-line auto-blocked reason in the footer when present. struct KanbanCardView: View { let task: HermesKanbanTask let onTap: () -> Void + /// True when the connected Hermes is on v0.13+ — gates the + /// hallucination dim/glyph, auto-block sub-line, and diagnostics + /// dot on the card. Pre-v0.13 hosts see the v2.7.5 chrome unchanged. + let supportsKanbanDiagnostics: Bool + /// Optimistic-aware accessor. Pre-v0.13 always nil. Otherwise delegates + /// to the board VM so a Verify click un-dims the card immediately. + let effectiveHallucinationGate: (HermesKanbanTask) -> KanbanHallucinationGate? + + init( + task: HermesKanbanTask, + supportsKanbanDiagnostics: Bool = false, + effectiveHallucinationGate: @escaping (HermesKanbanTask) -> KanbanHallucinationGate? = { _ in nil }, + onTap: @escaping () -> Void + ) { + self.task = task + self.supportsKanbanDiagnostics = supportsKanbanDiagnostics + self.effectiveHallucinationGate = effectiveHallucinationGate + self.onTap = onTap + } @Environment(\.colorScheme) private var colorScheme + /// Cached gate read — derived once per body eval rather than recomputed + /// in each subview helper. + private var hallucinationGate: KanbanHallucinationGate? { + guard supportsKanbanDiagnostics else { return nil } + return effectiveHallucinationGate(task) + } + var body: some View { Button(action: onTap) { VStack(alignment: .leading, spacing: ScarfSpace.s2) { @@ -66,13 +94,22 @@ struct KanbanCardView: View { } .buttonStyle(.plain) .scarfShadow(.sm) - .opacity(task.isDone ? doneOpacity : 1.0) + // v0.13: hallucination-pending cards dim to 0.6 to signal "needs + // verification before running" without making them unreadable. + // Done cards stay at the established doneOpacity (0.7 / 0.55). + .opacity(cardOpacity) .draggable(KanbanTaskRef(id: task.id)) { // Drag preview — the live card with a heavier shadow. self.dragPreview } } + private var cardOpacity: Double { + if task.isDone { return doneOpacity } + if hallucinationGate == .pending { return 0.6 } + return 1.0 + } + private var titleRow: some View { HStack(alignment: .top, spacing: ScarfSpace.s2) { statusGlyph @@ -82,7 +119,15 @@ struct KanbanCardView: View { .lineLimit(2) .multilineTextAlignment(.leading) Spacer(minLength: 0) - if needsAssignmentWarning { + // v0.13 hallucination glyph takes precedence over the + // unassigned glyph — the hallucination state is the more + // specific signal (a worker created this card; verify it). + if hallucinationGate == .pending { + Image(systemName: "questionmark.diamond.fill") + .foregroundStyle(ScarfColor.warning) + .font(.system(size: 11, weight: .semibold)) + .help("Worker-created — verify before running") + } else if needsAssignmentWarning { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(ScarfColor.warning) .font(.system(size: 11, weight: .semibold)) @@ -186,13 +231,37 @@ struct KanbanCardView: View { } private var footerRow: some View { - HStack(spacing: ScarfSpace.s2) { - Text(relativeTimeLabel) - .scarfStyle(.caption) - .foregroundStyle(ScarfColor.foregroundFaint) - Spacer(minLength: 0) - if let priority = task.priority, priority >= 70 { - priorityIndicator(priority) + VStack(alignment: .leading, spacing: 2) { + // v0.13: server-supplied auto-blocked reason. Renders verbatim + // (truncated to one line; full reason in the inspector). + // Pre-v0.13 hosts always have task.autoBlockedReason == nil. + if supportsKanbanDiagnostics, + KanbanStatus.from(task.status) == .blocked, + let reason = task.autoBlockedReason, !reason.isEmpty { + Text(reason) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.danger) + .lineLimit(1) + .truncationMode(.tail) + .help(reason) + } + HStack(spacing: ScarfSpace.s2) { + Text(relativeTimeLabel) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + Spacer(minLength: 0) + // v0.13: diagnostics dot — small stethoscope glyph when + // any cross-run distress signal is attached. Matches the + // chip count in the inspector. + if supportsKanbanDiagnostics, !task.diagnostics.isEmpty { + Image(systemName: "stethoscope") + .font(.system(size: 9)) + .foregroundStyle(ScarfColor.warning) + .help("\(task.diagnostics.count) diagnostic signal\(task.diagnostics.count == 1 ? "" : "s")") + } + if let priority = task.priority, priority >= 70 { + priorityIndicator(priority) + } } } } diff --git a/scarf/scarf/Features/Kanban/Views/KanbanColumnView.swift b/scarf/scarf/Features/Kanban/Views/KanbanColumnView.swift index ec9796a..963163b 100644 --- a/scarf/scarf/Features/Kanban/Views/KanbanColumnView.swift +++ b/scarf/scarf/Features/Kanban/Views/KanbanColumnView.swift @@ -17,6 +17,38 @@ struct KanbanColumnView: View { let onCreate: () -> Void let onDrop: (KanbanTaskRef) -> Void let canCreate: Bool + /// True when the connected Hermes is on v0.13+. Forwarded to each + /// `KanbanCardView` so the hallucination dim/glyph + diagnostics dot + /// + auto-block sub-line gate uniformly. + let supportsKanbanDiagnostics: Bool + /// Optimistic-aware accessor forwarded to cards. Default is + /// "no override" so Previews and harness contexts still render + /// without wiring up a board VM. + let effectiveHallucinationGate: (HermesKanbanTask) -> KanbanHallucinationGate? + + init( + column: KanbanBoardColumn, + tasks: [HermesKanbanTask], + isLive: Bool, + readyPillCount: Int, + onTaskTap: @escaping (HermesKanbanTask) -> Void, + onCreate: @escaping () -> Void, + onDrop: @escaping (KanbanTaskRef) -> Void, + canCreate: Bool, + supportsKanbanDiagnostics: Bool = false, + effectiveHallucinationGate: @escaping (HermesKanbanTask) -> KanbanHallucinationGate? = { _ in nil } + ) { + self.column = column + self.tasks = tasks + self.isLive = isLive + self.readyPillCount = readyPillCount + self.onTaskTap = onTaskTap + self.onCreate = onCreate + self.onDrop = onDrop + self.canCreate = canCreate + self.supportsKanbanDiagnostics = supportsKanbanDiagnostics + self.effectiveHallucinationGate = effectiveHallucinationGate + } @State private var isTargeted = false @@ -36,7 +68,11 @@ struct KanbanColumnView: View { .padding(.top, ScarfSpace.s4) } else { ForEach(tasks) { task in - KanbanCardView(task: task) { + KanbanCardView( + task: task, + supportsKanbanDiagnostics: supportsKanbanDiagnostics, + effectiveHallucinationGate: effectiveHallucinationGate + ) { onTaskTap(task) } } diff --git a/scarf/scarf/Features/Kanban/Views/KanbanCreateSheet.swift b/scarf/scarf/Features/Kanban/Views/KanbanCreateSheet.swift index 2db94b6..21be8c5 100644 --- a/scarf/scarf/Features/Kanban/Views/KanbanCreateSheet.swift +++ b/scarf/scarf/Features/Kanban/Views/KanbanCreateSheet.swift @@ -14,6 +14,12 @@ struct KanbanCreateSheet: View { /// Pre-filled project workspace path on per-project boards. When /// non-nil, the workspace picker is locked to "Project Dir". let projectWorkspacePath: String? + /// True when the connected Hermes is on v0.13+ — gates the + /// `--max-retries` field and decides whether to strip newlines from + /// the title at submit time. Pre-v0.13 hosts may truncate at the + /// first `\n`; we keep the multi-line input rendering on either way + /// since a taller `TextField` is harmless on v0.12. + let supportsKanbanDiagnostics: Bool /// Closure invoked when the user submits — VM owner constructs the /// `KanbanService.create` call. let onSubmit: (KanbanCreateRequest) async throws -> Void @@ -33,6 +39,11 @@ struct KanbanCreateSheet: View { @State private var skillsInput: String = "" @State private var tenant: String = "" @State private var sendToTriage: Bool = false + /// v0.13: per-task retry budget. Toggle-gated so the user can opt + /// into "send the flag" vs. "let Hermes pick its default" (the + /// release notes default to 3 — see TODO in KanbanCreateRequest). + @State private var maxRetriesEnabled: Bool = false + @State private var maxRetries: Int = 3 @State private var isSubmitting: Bool = false @State private var submitError: String? @FocusState private var titleFocused: Bool @@ -62,6 +73,9 @@ struct KanbanCreateSheet: View { assigneePicker workspaceField priorityField + if supportsKanbanDiagnostics { + maxRetriesField + } skillsField if projectWorkspacePath == nil { tenantField @@ -114,10 +128,60 @@ struct KanbanCreateSheet: View { // MARK: - Fields private var titleField: some View { + // v0.13 server tolerates multi-line titles. We keep the + // multi-line input rendering on for ALL versions of Hermes — + // visually a taller TextField is harmless on v0.12 — and decide + // at submit time whether to strip newlines (see `makeRequest`). VStack(alignment: .leading, spacing: 4) { ScarfSectionHeader("Title") - ScarfTextField("What needs doing?", text: $title) - .focused($titleFocused) + TextField( + "What needs doing?", + text: $title, + axis: .vertical + ) + .lineLimit(1...4) + .textFieldStyle(.plain) + .scarfStyle(.body) + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, ScarfSpace.s2) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .fill(ScarfColor.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .strokeBorder(ScarfColor.borderStrong, lineWidth: 1) + ) + .focused($titleFocused) + } + } + + /// v0.13: per-task retry budget. Toggle gates whether `--max-retries` + /// is sent at all so the user can preserve "let Hermes pick the + /// default" semantics by leaving the toggle off. + private var maxRetriesField: some View { + VStack(alignment: .leading, spacing: 4) { + ScarfSectionHeader( + "Max retries", + subtitle: "0 = no retries. Defaults to 3." + ) + HStack(spacing: ScarfSpace.s3) { + Toggle("Override default", isOn: $maxRetriesEnabled) + .toggleStyle(.switch) + .labelsHidden() + Stepper(value: $maxRetries, in: 0...20) { + Text("\(maxRetries)") + .scarfStyle(.bodyEmph) + .frame(minWidth: 24, alignment: .trailing) + .foregroundStyle( + maxRetriesEnabled + ? ScarfColor.foregroundPrimary + : ScarfColor.foregroundFaint + ) + } + .disabled(!maxRetriesEnabled) + Spacer() + } } } @@ -307,7 +371,14 @@ struct KanbanCreateSheet: View { } private func makeRequest() -> KanbanCreateRequest { - let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + var trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + // Pre-v0.13 hosts may truncate titles at the first `\n`. Strip + // newlines client-side when we know the connected Hermes hasn't + // shipped multi-line title support — replace with a space to + // keep the user's intent visible. v0.13+ keeps newlines verbatim. + if !supportsKanbanDiagnostics { + trimmedTitle = trimmedTitle.replacingOccurrences(of: "\n", with: " ") + } let trimmedBody = bodyText.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedAssignee = assignee.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedTenant = tenant.trimmingCharacters(in: .whitespacesAndNewlines) @@ -330,6 +401,14 @@ struct KanbanCreateSheet: View { } } + // Belt-and-suspenders: the `maxRetriesField` is only rendered + // when `supportsKanbanDiagnostics` is true, but gate again here + // so a programmatic state change can't smuggle the flag onto a + // pre-v0.13 host (where the verb would error). + let resolvedMaxRetries: Int? = (supportsKanbanDiagnostics && maxRetriesEnabled) + ? maxRetries + : nil + return KanbanCreateRequest( title: trimmedTitle, body: trimmedBody.isEmpty ? nil : trimmedBody, @@ -342,7 +421,8 @@ struct KanbanCreateSheet: View { idempotencyKey: nil, maxRuntimeSeconds: nil, createdBy: nil, - skills: parsedSkills + skills: parsedSkills, + maxRetries: resolvedMaxRetries ) } } diff --git a/scarf/scarf/Features/Kanban/Views/KanbanInspectorPane.swift b/scarf/scarf/Features/Kanban/Views/KanbanInspectorPane.swift index c6bb3f2..e2eeb45 100644 --- a/scarf/scarf/Features/Kanban/Views/KanbanInspectorPane.swift +++ b/scarf/scarf/Features/Kanban/Views/KanbanInspectorPane.swift @@ -8,6 +8,16 @@ import ScarfDesign struct KanbanInspectorPane: View { @State private var viewModel: KanbanTaskDetailViewModel let availableAssignees: [HermesKanbanAssignee] + /// True when the connected Hermes is on v0.13+ — gates the + /// hallucination banner, max_retries chip, diagnostics block, + /// and auto-blocked reason banner. Pre-v0.13 hosts see the v2.7.5 + /// inspector unchanged. + let supportsKanbanDiagnostics: Bool + /// Resolves an effective hallucination gate — the board VM owns the + /// optimistic-override merge so the banner disappears immediately on + /// Verify before the polled state confirms the new gate. Falls back + /// to the wire-level value when no override is in flight. + let effectiveHallucinationGate: (HermesKanbanTask) -> KanbanHallucinationGate? let onClose: () -> Void let onClaim: () -> Void let onComplete: () -> Void @@ -15,6 +25,8 @@ struct KanbanInspectorPane: View { let onUnblock: () -> Void let onArchive: () -> Void let onReassign: (String?) -> Void + let onVerifyHallucination: () -> Void + let onRejectHallucination: () -> Void @State private var selectedTab: DetailTab = .comments @@ -30,16 +42,22 @@ struct KanbanInspectorPane: View { service: KanbanService, taskId: String, availableAssignees: [HermesKanbanAssignee] = [], + supportsKanbanDiagnostics: Bool = false, + effectiveHallucinationGate: @escaping (HermesKanbanTask) -> KanbanHallucinationGate? = { _ in nil }, onClose: @escaping () -> Void, onClaim: @escaping () -> Void, onComplete: @escaping () -> Void, onBlock: @escaping () -> Void, onUnblock: @escaping () -> Void, onArchive: @escaping () -> Void, - onReassign: @escaping (String?) -> Void = { _ in } + onReassign: @escaping (String?) -> Void = { _ in }, + onVerifyHallucination: @escaping () -> Void = {}, + onRejectHallucination: @escaping () -> Void = {} ) { _viewModel = State(initialValue: KanbanTaskDetailViewModel(service: service, taskId: taskId)) self.availableAssignees = availableAssignees + self.supportsKanbanDiagnostics = supportsKanbanDiagnostics + self.effectiveHallucinationGate = effectiveHallucinationGate self.onClose = onClose self.onClaim = onClaim self.onComplete = onComplete @@ -47,6 +65,8 @@ struct KanbanInspectorPane: View { self.onUnblock = onUnblock self.onArchive = onArchive self.onReassign = onReassign + self.onVerifyHallucination = onVerifyHallucination + self.onRejectHallucination = onRejectHallucination } var body: some View { @@ -159,6 +179,16 @@ struct KanbanInspectorPane: View { ScarfBadge(workspace, kind: .neutral) .fixedSize() } + // v0.13: max_retries chip. Read-only — Hermes + // has no `update --max-retries` verb. The + // `if let` guards pre-v0.13 hosts (always nil) + // and the explicit capability gate adds + // belt-and-suspenders. + if supportsKanbanDiagnostics, let maxRetries = task.maxRetries { + ScarfBadge("retries: \(maxRetries)", kind: .neutral) + .fixedSize() + .help("Max retries set at create time. Hermes has no update verb — re-create the task to change this.") + } if let tenant = task.tenant, !tenant.isEmpty { ScarfBadge(tenant, kind: .brand) .fixedSize() @@ -251,13 +281,18 @@ struct KanbanInspectorPane: View { // MARK: - Body /// Inline health banner shown above the task body when something - /// requires user attention. Two conditions trigger today: - /// 1. Task is in `ready`/`todo` with no assignee — explains that - /// the dispatcher silently skips unassigned tasks. - /// 2. The most recent run ended in a non-success outcome - /// (`stale_lock`/`crashed`/`gave_up`/`timed_out`/`spawn_failed`/ - /// `reclaimed`/`failed`) — surfaces the error so the user - /// doesn't have to dig into the Runs tab to discover it. + /// requires user attention. Stack vertically (multiple can apply at + /// once on a v0.13 task — e.g. unassigned + hallucination pending + + /// last-run-blocked). + /// Order top-to-bottom: + /// 1. **Hallucination gate (v0.13+)** — pending worker-created card. + /// User must verify or reject before any other action makes sense. + /// 2. **Auto-blocked reason (v0.13+)** — server-supplied reason + /// overrides the generic "Last run: blocked" banner. + /// 3. Task is in `ready`/`todo` with no assignee — explains that the + /// dispatcher silently skips unassigned tasks. + /// 4. The most recent run ended in a non-success outcome — surfaces + /// the error so the user doesn't have to dig into the Runs tab. @ViewBuilder private func healthBanner(for task: HermesKanbanTask) -> some View { let status = KanbanStatus.from(task.status) @@ -292,25 +327,137 @@ struct KanbanInspectorPane: View { // Also suppress for `done` (terminal success). let suppressFailureBanner = (status == .running) || (status == .done) - if needsAssignee { - bannerRow( - icon: "exclamationmark.triangle.fill", - tint: ScarfColor.warning, - title: "Won't run automatically", - message: "Unassigned tasks are silently skipped by Hermes's dispatcher. Add an assignee to get this scheduled." - ) - } else if hadFailedEndedRun, let lastEndedRun, !suppressFailureBanner { - let label = (lastEndedRun.outcome ?? lastEndedRun.status).lowercased() - let detail = lastEndedRun.error ?? lastEndedRun.summary ?? "no details" - bannerRow( - icon: "exclamationmark.octagon.fill", - tint: ScarfColor.danger, - title: "Last run: \(label)", - message: detail - ) + // v0.13: hallucination-gate state. Read through the VM's + // optimistic-aware accessor so a Verify click takes effect + // before the polled state confirms. Belt-and-suspenders gate + // on capability flag. + let hallucination: KanbanHallucinationGate? = supportsKanbanDiagnostics + ? effectiveHallucinationGate(task) + : nil + // v0.13: structured auto-blocked reason. Renders the server's + // string verbatim; takes precedence over the generic "Last run: + // blocked" banner. + let autoBlockedReason: String? = (supportsKanbanDiagnostics + && status == .blocked + && (task.autoBlockedReason?.isEmpty == false)) + ? task.autoBlockedReason + : nil + // Suppress the generic last-run banner when the more specific + // server-side reason supersedes it. + let suppressGenericFailure = autoBlockedReason != nil + + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + if hallucination == .pending { + hallucinationBanner + } + if let reason = autoBlockedReason { + bannerRow( + icon: "exclamationmark.octagon.fill", + tint: ScarfColor.danger, + title: "Auto-blocked", + // Verbatim — Hermes-side message is the source of truth. + message: reason + ) + } + if needsAssignee { + bannerRow( + icon: "exclamationmark.triangle.fill", + tint: ScarfColor.warning, + title: "Won't run automatically", + message: "Unassigned tasks are silently skipped by Hermes's dispatcher. Add an assignee to get this scheduled." + ) + } + if hadFailedEndedRun, let lastEndedRun, + !suppressFailureBanner, !suppressGenericFailure { + let label = (lastEndedRun.outcome ?? lastEndedRun.status).lowercased() + let detail = lastEndedRun.error ?? lastEndedRun.summary ?? "no details" + bannerRow( + icon: "exclamationmark.octagon.fill", + tint: ScarfColor.danger, + title: "Last run: \(label)", + message: detail + ) + } + // v0.13: cross-run diagnostics on the task header. + if supportsKanbanDiagnostics, !task.diagnostics.isEmpty { + diagnosticsBlock(task.diagnostics) + } } } + /// v0.13 hallucination-gate banner — Verify / Reject affordances for + /// worker-created cards waiting on user verification. + private var hallucinationBanner: some View { + HStack(alignment: .top, spacing: ScarfSpace.s2) { + Image(systemName: "questionmark.diamond.fill") + .foregroundStyle(ScarfColor.warning) + .font(.system(size: 13, weight: .semibold)) + VStack(alignment: .leading, spacing: 4) { + Text("Created by a worker — verify before running") + .scarfStyle(.captionStrong) + .foregroundStyle(ScarfColor.foregroundPrimary) + Text("A worker claimed it created this card; Hermes hasn't confirmed the underlying work exists. Verify the card matches a real follow-up, or reject if it's a hallucinated reference.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + HStack(spacing: ScarfSpace.s2) { + Button("Verify", action: onVerifyHallucination) + .buttonStyle(ScarfPrimaryButton()) + Button("Reject", action: onRejectHallucination) + .buttonStyle(ScarfDestructiveButton()) + } + .padding(.top, 2) + } + Spacer(minLength: 0) + } + .padding(ScarfSpace.s2) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .fill(ScarfColor.warning.opacity(0.10)) + ) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .strokeBorder(ScarfColor.warning.opacity(0.4), lineWidth: 1) + ) + } + + /// v0.13 diagnostics block — renders a list of distress signals. + /// Used both at the task-header level (cross-run signals) and per + /// run on the Runs tab (in-flight signals). Wraps in a horizontal + /// scroll so a long diag list doesn't blow out inspector width. + private func diagnosticsBlock(_ diags: [HermesKanbanDiagnostic]) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Diagnostics") + .scarfStyle(.captionUppercase) + .foregroundStyle(ScarfColor.foregroundFaint) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(diags) { diag in + diagnosticBadge(diag) + } + } + } + } + .padding(.top, 4) + } + + @ViewBuilder + private func diagnosticBadge(_ diag: HermesKanbanDiagnostic) -> some View { + let kind = KanbanDiagnosticKind.from(diag.kind) + let badgeKind: ScarfBadgeKind = { + switch kind.severity { + case .danger: return .danger + case .warning: return .warning + case .neutral: return .neutral + } + }() + // Render the raw kind string — view code stays in sync with + // whatever future kinds Hermes ships. The typed mirror picks + // the badge tint and tooltip glyph; the verbatim wire string + // is the user-facing label. + ScarfBadge(diag.kind, kind: badgeKind) + .help(diag.message ?? diag.kind) + } + private func bannerRow( icon: String, tint: Color, @@ -562,6 +709,9 @@ struct KanbanInspectorPane: View { private func runRow(_ run: HermesKanbanRun) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(spacing: ScarfSpace.s2) { + // Render the wire-side outcome / status string verbatim so + // v0.13's richer outcome strings ("zombied — reclaimed by + // reaper", etc.) surface unchanged. ScarfBadge(run.outcome ?? run.status, kind: outcomeKind(run.outcome ?? run.status)) if let profile = run.profile { Text(profile) @@ -585,6 +735,12 @@ struct KanbanInspectorPane: View { .foregroundStyle(ScarfColor.danger) .frame(maxWidth: .infinity, alignment: .leading) } + // v0.13: per-run diagnostics. Gated on capability so a future + // server-side change can't accidentally surface partial UX + // on a pre-v0.13 host. + if supportsKanbanDiagnostics, !run.diagnostics.isEmpty { + diagnosticsBlock(run.diagnostics) + } } .padding(ScarfSpace.s2) .background( @@ -619,23 +775,32 @@ struct KanbanInspectorPane: View { @ViewBuilder private var primaryAction: some View { if let task = viewModel.detail?.task { - switch KanbanStatus.from(task.status) { - case .ready, .todo: - Button("Start", action: onClaim) - .buttonStyle(ScarfPrimaryButton()) - .help("Atomically claim this task and start the worker. Moves it to Running.") - case .running: - Button("Complete", action: onComplete) - .buttonStyle(ScarfPrimaryButton()) - .help("Mark this task as Done. You'll be prompted for an optional result summary.") - case .blocked: - Button("Unblock", action: onUnblock) - .buttonStyle(ScarfPrimaryButton()) - .help("Return this task to the Up Next queue so the dispatcher can pick it up again.") - case .triage: - EmptyView() - default: + // v0.13: when the hallucination gate is pending, suppress the + // primary action — the banner provides Verify / Reject as the + // gate. Showing "Start" alongside the banner would let the + // user dispatch a card Hermes hasn't confirmed exists. + if supportsKanbanDiagnostics, + effectiveHallucinationGate(task) == .pending { EmptyView() + } else { + switch KanbanStatus.from(task.status) { + case .ready, .todo: + Button("Start", action: onClaim) + .buttonStyle(ScarfPrimaryButton()) + .help("Atomically claim this task and start the worker. Moves it to Running.") + case .running: + Button("Complete", action: onComplete) + .buttonStyle(ScarfPrimaryButton()) + .help("Mark this task as Done. You'll be prompted for an optional result summary.") + case .blocked: + Button("Unblock", action: onUnblock) + .buttonStyle(ScarfPrimaryButton()) + .help("Return this task to the Up Next queue so the dispatcher can pick it up again.") + case .triage: + EmptyView() + default: + EmptyView() + } } } }