Skip to content

Commit 20eef4b

Browse files
awizemannclaude
andcommitted
feat(chat,kanban): chat-driven Kanban board + toolset onboarding
Two coordinated workstreams in one feature: WS-A — Discoverability + enablement of the kanban toolset for chat. The Hermes default `platform_toolsets.cli` doesn't include `kanban`, so a user running `/goal …` in chat sees an empty Kanban board with no diagnosis. Adds a one-time onboarding sheet on the user's first `/goal` against a host where the detector classifies the toolset as disabled, plus a banner on the empty Kanban board offering one-click enable. `hermes tools enable kanban --platform cli` runs the same write-path the existing Tools view uses; per-host UserDefaults flag suppresses the sheet after first dismissal. WS-B — Chat → Kanban navigation. A "Kanban" chip on the chat header opens the existing Kanban surface scoped to the chat's project tenant (or global, for unscoped chats). The chip carries a live count badge (running + blocked tasks, polled every 5s with single-flight + error backoff). On click, an `AppCoordinator.pendingKanbanHandoff` slot carries `(tenant, projectPath, projectName, sessionOpenedAt)` to the destination view, which builds `KanbanBoardView` with the tenant pre-applied and the new "Since chat opened" filter pill on by default. The lens approximates "this chat's tasks" via `created_at >= sessionOpenedAt` — best-effort while waiting for upstream session linkage (NousResearch/hermes-agent#23199 / PR #23208). Capability-gated on `HermesCapabilities.hasKanban` (>= v0.12.0). Pre-v0.12 hosts see no chip, no badge, no banner, no sheet. ScarfCore additions: - `KanbanToolsetDetector` (actor) reads `~/.hermes/config.yaml` and classifies the `cli` toolset state (`.enabled / .disabled / .unknown`). Pure parser tests freeze the YAML → list logic. - `KanbanToolsetEnabler` (actor) wraps `hermes tools enable kanban --platform <name>` with structured success/failure reporting. - `HermesKanbanTask.createdAtDate` parses the wire ISO string back into a `Date` for the time-window filter. - `RichChatViewModel.sessionOpenedAt` captures the wall-clock time this VM attached to its current session — set on `setSessionId(_:)`, used as the seed for the time filter. Mac-side additions: - `AppCoordinator.KanbanHandoff` + `pendingKanbanHandoff` slot, sister to the existing `pendingProjectChat` pattern. - `ChatKanbanOnboardingSheet` view + wiring through `ChatViewModel` (`showKanbanOnboardingSheet`, `enableKanbanToolset`, `dismissKanbanToolsetOnboarding`). - `KanbanChatBadgeViewModel` (`@MainActor + @Observable`) polls `KanbanService.list` per tenant, counts running + blocked, and publishes `liveCount` for the chip badge. - `KanbanBoardViewModel.sessionStartedAt` + `filterBySessionStart` applies a client-side `createdAt >=` filter inside `tasks(in:)`. - `KanbanBoardView` accepts `sessionStartedAt`, renders a "Since HH:MM" toolbar pill that toggles the lens, and a toolset-off banner on empty boards offering one-click enable. - `KanbanView` drains the coordinator hand-off via `.task(id:)` and passes the snapshot into `KanbanBoardView`. Verification: - `xcodebuild scheme=scarf` clean. - `swift test --filter KanbanToolsetDetectorTests --filter KanbanModelsTests` passes (43 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d1af431 commit 20eef4b

15 files changed

Lines changed: 1262 additions & 7 deletions

scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesKanbanTask.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,16 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
188188
f.formatOptions = [.withInternetDateTime]
189189
return f
190190
}()
191+
192+
/// `createdAt` parsed back into a `Date` for time-window filtering
193+
/// (e.g. the "Since chat opened" lens on the project board). Nil
194+
/// when the wire field is absent OR the string can't be parsed —
195+
/// callers that filter by time treat unparseable rows as outside
196+
/// the window rather than crashing.
197+
public var createdAtDate: Date? {
198+
guard let createdAt else { return nil }
199+
return Self.isoFormatter.date(from: createdAt)
200+
}
191201
}
192202

193203
// MARK: - Status enum (typed view of the wire string)
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import Foundation
2+
#if canImport(os)
3+
import os
4+
#endif
5+
6+
/// Whether Hermes will register the `kanban_*` tool surface inside the
7+
/// agent loop for a given chat platform. Distinct from
8+
/// `HermesCapabilities.hasKanban`, which only asks "does this Hermes
9+
/// version know about kanban at all?" — the kanban toolset is opt-in
10+
/// per platform/profile, so capability-positive hosts can still ship
11+
/// chats whose agent has zero kanban tools.
12+
public enum KanbanToolsetState: Sendable, Equatable {
13+
/// Tools will register. The associated source explains where the
14+
/// gating signal came from so callers can show a precise hint
15+
/// ("enabled via the cli platform" vs "enabled via top-level
16+
/// toolsets") when surfacing the state in UI.
17+
case enabled(via: Source)
18+
/// Tools will NOT register on the named platform. The board stays
19+
/// empty for chats on that platform until the user adds `kanban`
20+
/// to either the platform's toolset list or the top-level toolset
21+
/// list.
22+
case disabled(platform: String)
23+
/// Detector couldn't classify — config file unreadable, missing,
24+
/// or malformed. Treat as "don't show the disabled banner" rather
25+
/// than as a hard error: the rest of the app shouldn't grind to a
26+
/// halt because the YAML is briefly weird mid-edit.
27+
case unknown(reason: String)
28+
29+
public enum Source: Sendable, Equatable {
30+
case platform(String)
31+
case topLevelToolset
32+
/// `HERMES_KANBAN_TASK` is set in the spawning environment —
33+
/// only ever true inside a dispatcher-launched worker, never
34+
/// in a Scarf-driven ACP chat. Included for completeness so
35+
/// the detector's contract is exhaustive even though Scarf
36+
/// itself never observes this branch.
37+
case dispatcherWorker
38+
}
39+
40+
public var isEnabled: Bool {
41+
if case .enabled = self { return true }
42+
return false
43+
}
44+
}
45+
46+
/// Read-only inspector for "will the agent in a given chat platform
47+
/// have access to kanban tools?". Reads `~/.hermes/config.yaml` via
48+
/// the transport's `readText` so it works against local, SSH, and any
49+
/// future remote backend.
50+
///
51+
/// **Why this lives at the ScarfCore layer.** Both Mac and iOS need
52+
/// to render the gating signal (Mac in the chat header sheet on first
53+
/// `/goal`, iOS for read-only status). A view model `@MainActor`
54+
/// surface owns the cached state; this actor owns the I/O.
55+
public actor KanbanToolsetDetector {
56+
#if canImport(os)
57+
private static let logger = Logger(
58+
subsystem: "com.scarf",
59+
category: "KanbanToolsetDetector"
60+
)
61+
#endif
62+
63+
private let context: ServerContext
64+
65+
public init(context: ServerContext) {
66+
self.context = context
67+
}
68+
69+
/// Inspect the config and return whether the `kanban` toolset is
70+
/// active for the given platform (default `cli`, which is the
71+
/// platform Hermes uses for ACP chats and `hermes chat`).
72+
///
73+
/// Pure read — no side effects, no caching at this layer (the VM
74+
/// caches). Cheap enough to call on view appear + on file-change
75+
/// signals.
76+
public func detect(platform: String = "cli") async -> KanbanToolsetState {
77+
let context = self.context
78+
let path = context.paths.configYAML
79+
let yaml: String? = await Task.detached(priority: .utility) {
80+
context.readText(path)
81+
}.value
82+
83+
guard let yaml, !yaml.isEmpty else {
84+
return .unknown(reason: "config.yaml is empty or unreadable")
85+
}
86+
87+
let topLevel = Self.parseTopLevelToolsets(yaml: yaml)
88+
if topLevel.contains("kanban") {
89+
return .enabled(via: .topLevelToolset)
90+
}
91+
92+
let platformList = Self.parsePlatformToolsets(yaml: yaml, platform: platform)
93+
if platformList.contains("kanban") {
94+
return .enabled(via: .platform(platform))
95+
}
96+
97+
return .disabled(platform: platform)
98+
}
99+
100+
/// Small line-oriented scan for `toolsets:` block at column 0. The
101+
/// repo's bigger `HermesConfig+YAML` parser would also work, but
102+
/// it doesn't currently surface the top-level `toolsets:` field
103+
/// (only `platform_toolsets.<name>`). A 12-line sniff keeps the
104+
/// detector self-contained and avoids growing the larger model.
105+
nonisolated static func parseTopLevelToolsets(yaml: String) -> [String] {
106+
var inBlock = false
107+
var items: [String] = []
108+
for rawLine in yaml.split(separator: "\n", omittingEmptySubsequences: false) {
109+
let line = String(rawLine)
110+
if line == "toolsets:" {
111+
inBlock = true
112+
continue
113+
}
114+
if inBlock {
115+
let trimmed = line.trimmingCharacters(in: .whitespaces)
116+
if trimmed.hasPrefix("- ") {
117+
let value = trimmed.dropFirst(2).trimmingCharacters(
118+
in: CharacterSet(charactersIn: "\"' ")
119+
)
120+
if !value.isEmpty {
121+
items.append(value)
122+
}
123+
continue
124+
}
125+
if line.first == " " || line.first == "\t" {
126+
continue
127+
}
128+
break
129+
}
130+
}
131+
return items
132+
}
133+
134+
/// Pull the named platform's list out of `platform_toolsets.<name>`.
135+
/// Mirrors the dotted-path → list flattening that
136+
/// `HermesConfig+YAML` does, but inline so the detector doesn't
137+
/// pull a full config parse.
138+
nonisolated static func parsePlatformToolsets(
139+
yaml: String,
140+
platform: String
141+
) -> [String] {
142+
let lines = yaml.split(separator: "\n", omittingEmptySubsequences: false)
143+
.map(String.init)
144+
var inPlatformToolsets = false
145+
var inTargetPlatform = false
146+
var items: [String] = []
147+
for line in lines {
148+
if line == "platform_toolsets:" {
149+
inPlatformToolsets = true
150+
continue
151+
}
152+
if inPlatformToolsets {
153+
if line.hasPrefix("\(platform):") || line == " \(platform):" {
154+
inTargetPlatform = true
155+
continue
156+
}
157+
if inTargetPlatform {
158+
let trimmed = line.trimmingCharacters(in: .whitespaces)
159+
if trimmed.hasPrefix("- ") {
160+
let value = trimmed.dropFirst(2).trimmingCharacters(
161+
in: CharacterSet(charactersIn: "\"' ")
162+
)
163+
if !value.isEmpty {
164+
items.append(value)
165+
}
166+
continue
167+
}
168+
if line.first == " " || line.first == "\t" {
169+
if line.hasSuffix(":") && !line.hasPrefix(" ") {
170+
inTargetPlatform = false
171+
}
172+
continue
173+
}
174+
break
175+
}
176+
if line.first != " " && line.first != "\t" && !line.isEmpty {
177+
break
178+
}
179+
}
180+
}
181+
return items
182+
}
183+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import Foundation
2+
#if canImport(os)
3+
import os
4+
#endif
5+
6+
/// Mutates the `kanban` toolset on/off for a given Hermes platform.
7+
/// Wraps `hermes tools enable kanban --platform <name>` so callers
8+
/// don't have to learn the CLI shape.
9+
///
10+
/// **Why a separate actor from `KanbanToolsetDetector`.** Read paths
11+
/// run on every chat appear; mutation paths run once per onboarding
12+
/// flow. Keeping them as separate actors means the detector's read
13+
/// loop never blocks on a write txn — important since Hermes's
14+
/// `tools enable` shells out and can take ~500ms on cold starts.
15+
public actor KanbanToolsetEnabler {
16+
#if canImport(os)
17+
private static let logger = Logger(
18+
subsystem: "com.scarf",
19+
category: "KanbanToolsetEnabler"
20+
)
21+
#endif
22+
23+
public enum EnableResult: Sendable, Equatable {
24+
/// CLI exited 0. The caller should refresh the detector and
25+
/// (if appropriate) post a "Restart your chat to pick this up"
26+
/// hint — the agent's tool list is fixed at session start, so
27+
/// existing chats keep their stale schema.
28+
case enabled
29+
/// CLI exited non-zero or wasn't reachable. The associated
30+
/// message is the trimmed stderr (or transport error) so
31+
/// callers can surface it inline rather than a generic "an
32+
/// error occurred."
33+
case failed(message: String)
34+
}
35+
36+
private let context: ServerContext
37+
38+
public init(context: ServerContext) {
39+
self.context = context
40+
}
41+
42+
/// Enable the `kanban` toolset on the given platform. Default `cli`
43+
/// is what ACP chats run under, so the common path is
44+
/// `enabler.enable()` with no args.
45+
public func enable(platform: String = "cli") async -> EnableResult {
46+
await runToolsCommand(action: "enable", platform: platform)
47+
}
48+
49+
public func disable(platform: String = "cli") async -> EnableResult {
50+
await runToolsCommand(action: "disable", platform: platform)
51+
}
52+
53+
private func runToolsCommand(
54+
action: String,
55+
platform: String
56+
) async -> EnableResult {
57+
let context = self.context
58+
let result: EnableResult = await Task.detached(priority: .utility) {
59+
let transport = context.makeTransport()
60+
let executable = context.paths.hermesBinary
61+
do {
62+
let proc = try transport.runProcess(
63+
executable: executable,
64+
args: ["tools", action, "kanban", "--platform", platform],
65+
stdin: nil,
66+
timeout: 15
67+
)
68+
if proc.exitCode == 0 {
69+
return .enabled
70+
}
71+
let stderr = proc.stderrString.trimmingCharacters(
72+
in: .whitespacesAndNewlines
73+
)
74+
let stdout = proc.stdoutString.trimmingCharacters(
75+
in: .whitespacesAndNewlines
76+
)
77+
let message = stderr.isEmpty
78+
? (stdout.isEmpty ? "exit \(proc.exitCode)" : stdout)
79+
: stderr
80+
return .failed(message: message)
81+
} catch let error as TransportError {
82+
let diag = error.diagnosticStderr.isEmpty
83+
? (error.errorDescription ?? "transport error")
84+
: error.diagnosticStderr
85+
return .failed(message: diag)
86+
} catch {
87+
return .failed(message: error.localizedDescription)
88+
}
89+
}.value
90+
#if canImport(os)
91+
switch result {
92+
case .enabled:
93+
Self.logger.info("kanban toolset \(action, privacy: .public) ok on \(platform, privacy: .public)")
94+
case .failed(let message):
95+
Self.logger.warning("kanban toolset \(action, privacy: .public) failed on \(platform, privacy: .public): \(message, privacy: .public)")
96+
}
97+
#endif
98+
return result
99+
}
100+
}

0 commit comments

Comments
 (0)