Compare commits

..

15 Commits

Author SHA1 Message Date
Alan Wizemann 963d0e1a5c feat(capabilities): add isV013OrLater convenience predicate
Surfaces a v0.12 → v0.13 boundary check that doesn't proxy through any
specific feature flag. Used by WS-8 (redaction default-state hint copy,
"v0.13 features active" Settings badge in iOS WS-9) where the call site
isn't actually about a specific feature — it's about whether the host is
on the v0.13 line.

Equivalent to any individual v0.13 flag (e.g. `hasGoals`); both resolve
to the same `>= 0.13.0` threshold. Convenience exists to keep call sites
honest: `caps.isV013OrLater` reads better than `caps.hasGoals` when the
context isn't goal-related.

Tests: 4 new fixtures covering v0.13 host (true), v0.12 host (false),
empty/undetected (false), and v0.14 host (true). 19 total tests in the
suite, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:08:14 +02:00
Alan Wizemann 52c802676f feat(capabilities): add Hermes v0.13 capability flags + version bump
Adds 22 new capability flags grouped under a v0.13 (v2026.5.7) MARK
section in HermesCapabilities, covering Persistent Goals, ACP /queue
+ /steer-on-idle, Kanban diagnostics + recovery UX, Curator archive
+ prune, Google Chat (20th platform), cross-platform allowlists,
MCP SSE transport, Cron --no-agent, Web Tools backend split, Profiles
--no-skills, context compression count, /new <name>, OpenRouter cache,
image_gen.model, display.language, xAI voice cloning, video_analyze,
and the transform_llm_output plugin hook.

Each flag gates on >= 0.13.0 so v0.13 patch releases (0.13.4 etc.)
still light up every flag. Existing v0.12 flags unchanged. Test suite
extends with v0.13.0/2026.5.7 fixtures, a v0.13.4 patch-release case,
explicit "v0.13 flags off on v0.12 host" coverage, and updates the
future-version test to v0.14.0.

CLAUDE.md target line bumps to v2026.5.7 (v0.13.0); a new v2026.5.7
section mirrors the v0.12 / v0.11 scaffolding describing the Scarf-
relevant subset. The v0.12 + v0.11 historical sections remain intact
since pre-v0.13 hosts still consume those flags.

Foundation for the v2.8.0 Scarf release — every subsequent work-stream
(WS-2 through WS-9) consumes flags added here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 17:31:51 +02:00
Alan Wizemann 5d8873d305 chore: Bump version to 2.7.5 2026-05-08 13:59:21 +02:00
Alan Wizemann 49bc4efe83 fix(kanban): enrich LocalTransport subprocess env so kanban dispatcher can spawn workers
GUI-launched Scarf inherits macOS's launch-services PATH
(`/usr/bin:/bin:/usr/sbin:/sbin`). Scarf itself finds `hermes` via
absolute-path resolution in `HermesPathSet.hermesBinaryCandidates`,
but when the kanban dispatcher (a child of Scarf) tries to spawn a
worker, the worker inherits the same stripped PATH and Hermes's spawn
machinery prints `\`hermes\` executable not found on PATH. Install
Hermes Agent or activate its venv before running the kanban
dispatcher.` — recording `outcome=spawn_failed` on the run.

`LocalTransport` now mirrors `SSHTransport.environmentEnricher`:
adds an `environmentEnricher: (() -> [String: String])?` static, and
applies it to every subprocess. `scarfApp.swift` wires it at launch
to the same `HermesFileService.enrichedEnvironment()` login-shell
probe (`zsh -l -i` → `zsh -l` fallback) the SSH transport already
uses, so subprocesses see `~/.local/bin`, `/opt/homebrew/bin`, and
the user's credential env vars.

Defense-in-depth: `subprocessEnvironment(forExecutable:)` always
prepends the executable's own directory to PATH if missing — covers
early-startup paths and test harnesses where the enricher hasn't
been wired yet.

Two new tests in `KanbanModelsTests` lock in:
1. The fallback (no enricher → executable's dir lands on PATH)
2. The enricher win for PATH + the empty-string-aware copy semantics
   for credential env vars (process env happens to set
   `ANTHROPIC_API_KEY=""` as an empty string in some environments;
   the enricher's non-empty value must still take effect)

Release notes for v2.7.5 updated to document the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:59:21 +02:00
Alan Wizemann adcc984091 feat(kanban): full read/write board with per-project tenants
Lifts Scarf's Kanban surface from the v2.6 read-only list to a
drag-and-drop board with the complete Hermes v0.12 mutation surface
wired up, plus per-project boards bound to a Scarf-minted tenant slug
and a read-only board on iOS.

Why now: the v2.6 list was a placeholder shipped while upstream Kanban
collab was still mid-rework. v0.12 stabilized the 27-verb CLI; this
release makes Scarf a real GUI client for it. Driving real tasks
end-to-end exposed and closed a connected bug pattern (claim vs
dispatch, silent skipped_unassigned, integer-vs-ISO timestamps,
parser-leaked "(no" sentinel) that would have shipped as latent UX
papercuts otherwise.

ScarfCore: KanbanService actor (Sendable, pure I/O) wrapping every
verb; KanbanTenantReader cross-platform manifest projection; eight
new model types (TaskDetail, Comment, Event, Run, Stats, Assignee,
CreateRequest, Filters); KanbanError; pure transition planner that
maps drag-drop column changes to verb sequences, tested against
canonical Hermes JSON fixtures.

Mac: KanbanBoardView orchestrator with five-column drag-drop layout,
optimistic-merge state, KanbanInspectorPane side-pane (Comments /
Events / Runs / Log tabs, Log streams worker stdout every 2s while
running), inline assignee picker, health banner for unassigned and
last-failed-run states. New Task sheet defaults to active profile
and auto-fires kanban dispatch on submit. Sidebar moved Kanban from
Manage to Monitor. Read-only KanbanListView preserved as Board|List
toggle for narrow windows / accessibility.

Per-project: DashboardTab.kanban tab on every project gated on
hasKanban; KanbanTenantResolver mints scarf:<slug> tenants on first
interaction and persists to .scarf/manifest.json (immutable across
rename); ProjectAgentContextService surfaces the tenant in the
AGENTS.md scarf-managed block so agents pass --tenant <slug> on
kanban create. New kanban_summary dashboard widget; vocabulary
mirrored in tools/widget-schema.json and site/widgets.js.

iOS: read-only board on the project tab via paged single-column
Picker, modal detail sheet with Comments / Events / Runs. Mutations
+ drag-drop deferred to v2.8.

Tests: 19 new pure-logic tests covering decoding, planner verb
mapping, argv assembly, glance string formatting, and parser
rejection of the kanban assignees empty-state sentinel. All 348
ScarfCore tests pass.

Constraints documented in CLAUDE.md: no within-column reorder
(Hermes has no update --priority verb); no live watch streaming
yet (5s polling for board, 2s for log); no bulk re-tag for legacy
NULL-tenant tasks. Pre-v0.12 Hermes hosts gracefully hide the
surface end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:59:21 +02:00
Alan Wizemann fd80f4f95a Create FUNDING.yml 2026-05-07 12:55:53 +02:00
Alan Wizemann 9f240ae291 chore: Bump version to 2.7.1 2026-05-07 12:46:11 +02:00
Alan Wizemann 9c149b288b fix(docs): restore Sonoma compatibility messaging in BUILDING.md + CONTRIBUTING.md
Scarf's `MACOSX_DEPLOYMENT_TARGET` is `14.6` (Sonoma) on the main
`scarf` target, set in 86762ea. Sonoma support is intentional —
several users dogfood on macOS 14.x and we want to keep them on the
release channel. Yesterday's BUILDING.md and the long-stale
CONTRIBUTING.md statement both claimed macOS/Xcode 26.x as minimums,
which would have steered Sonoma contributors and users away from a
build that actually runs on their box.

Correct values:

- Runtime min: **macOS 14.6 (Sonoma)** — matches the deployment target.
- Build min: **Xcode 16.0** — needed for Swift 6 strict-concurrency
  features the codebase uses.

Add a load-bearing-callout to BUILDING.md so future doc edits don't
silently raise the floor again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:13:18 +02:00
Alan Wizemann 37afbdeffc feat(build): contributor-friendly local-build.sh + BUILDING.md
Adds `scripts/local-build.sh` for unsigned command-line Debug builds
so contributors without an Apple Developer account can clone, build,
and run without provisioning gymnastics. The script:

- Detects arm64 / x86_64
- Verifies xcode-select, xcrun, xcodebuild are present
- Probes the Metal toolchain and offers an interactive install (gated
  on `[[ -t 0 && -z "${CI:-}" ]]` — CI never gets prompted)
- Resolves Swift packages, builds Debug with signing disabled
- Optionally `ditto`s the result to /Applications/scarf.app on
  explicit y/N

`BUILDING.md` documents prerequisites alongside the script. Existing
canonical Release universal CLI in README stays — `local-build.sh`
is an alternative for contributors, not a replacement for the
shipping build.

Cherry-picked from #76 with thanks to @unixwzrd. BUILDING.md's
prerequisites are corrected to match the actual deployment target
(macOS 26.2, Xcode 26.2+).

Co-Authored-By: M S <unixwzrd.register@mac.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:08:33 +02:00
Alan Wizemann bfd9bab9a0 fix(health): stop external dashboards by port, not pkill -f
`stopDashboard()` used to fall back to `pkill -f "hermes dashboard"`
when the running dashboard wasn't a Scarf-spawned subprocess. That's
broad enough to match shell history, log tails, README readers, and
this very source file — anything with the substring "hermes
dashboard" in its argv was a kill target.

Replace with a port-anchored lookup: `lsof -tiTCP:<port> -sTCP:LISTEN`
returns the PID actually bound to the dashboard port, then we
`SIGTERM` only that one process. Trusting the port is correct here:
Scarf owns the configured port and the user-visible intent is "stop
the thing on this port."

We deliberately omit `lsof -c hermes`. Hermes installs as a Python
shebang script (verified locally — `file ~/.local/bin/hermes` →
"a python3 script text executable"), so the kernel COMM is `python` /
`python3`, never `hermes`. A `-c hermes` filter would silently miss
every standard install.

Cherry-picked from #76 with thanks to @unixwzrd for the direction;
this version drops the `-c hermes` filter to actually fire on real
Hermes installs.

Co-Authored-By: M S <unixwzrd.register@mac.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:08:23 +02:00
Alan Wizemann 2e0eb63ea4 fix(health): tighten Hermes gateway pgrep so unrelated commands don't match
`hermesPIDResult()` was running `pgrep -f hermes`, which matched any
process with "hermes" anywhere in its argv — `hermes acp` chat
sessions Scarf itself spawns, `hermes -z` one-shots, log tails, even
this very file in an editor. The Dashboard "Hermes is running" badge
read true even when the gateway daemon was down.

Narrow the match to the gateway shape specifically. Two alternations
cover both invocation forms used in the wild:

- `python -m hermes_cli.main gateway run …` (the launchctl form)
- `/path/to/hermes gateway run …` (the script-path form)

Verified locally against an actual gateway PID:

    cmd=/Users/.../python -m hermes_cli.main gateway run --replace

The first alternation matches via the `-m hermes_cli.main gateway run`
boundary. All callers — `stopHermes()`, `DashboardViewModel`,
`HealthViewModel`, `SettingsViewModel`, `scarfApp` — semantically
want the gateway PID specifically, so the narrower match is the
right shape, not a behavior change.

Cherry-picked from #76 with thanks to @unixwzrd for the diagnosis
and the regex.

Co-Authored-By: M S <unixwzrd.register@mac.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:08:11 +02:00
Alan Wizemann 3a3c87e033 fix(skills): scope What's New pill to Installed tab + reword updated→changed
Issue #78 — The "What's New" pill at the top of the Skills page
announced "18 new, 3 updated since you last looked" while the Updates
sub-tab simultaneously said "No Updates / All skills are up to date."
Two surfaces measuring two different things both used the word
"update": the pill counts local file deltas since the user last
clicked "Mark as seen", while the Updates body runs `hermes skills
check` to find skills with newer upstream versions available. From
the user's seat the screen contradicted itself.

Two changes:

1. Render the pill only on the Installed sub-tab (Mac + ScarfGo).
   Local file deltas are contextually meaningful only on the tab
   that surfaces installed skills; showing them above Browse Hub or
   Updates was misleading.

2. Reword the pill: "X updated since you last looked" → "X changed
   since you last looked". Keeps `SkillSnapshotDiff.updatedCount` as
   the field name (it's still about file changes, not version bumps);
   only the user-visible string changes. Removes the vocabulary
   collision with the Updates tab's separate upstream-update check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:51:05 +02:00
Alan Wizemann f9e3cd38f5 fix(skills): client-side filter for All-Sources hub search
Issue #79 — Browse Hub clearly listed "honcho" but searching for
"honcho" with the source picker on "All Sources" returned nothing.
Root cause is on the Hermes side: `hermes skills search <query>`
without a `--source` flag routes through the centralized
`hermes-index` source and skips the external API sources
(skills-sh, github, clawhub, lobehub, well-known, claude-marketplace).
Browse aggregates those sources too, so any skill that lives only in
the API tier shows up in browse but disappears in search. Same picker,
same query, contradictory results.

Rather than chase Hermes's index gaps, redefine "All Sources" search
in Scarf to mean filter-what-you-see — the canonical type-to-filter
UX users already expect on a list. Source-specific searches keep the
CLI shell-out for full upstream search semantics on that registry.

Implementation:
- New `lastBrowseResults` cache populated on every successful
  `browseHub()`. Setter is `internal` so the test suite can seed
  without invoking the live CLI; out-of-module callers can still
  only read.
- `searchHub()` now branches on `hubSource`. The "all" branch filters
  the cache via `localizedCaseInsensitiveContains` against name,
  description, and identifier, runs synchronously on the calling
  actor (UI invocations are already on MainActor) so the user sees
  the narrowed list without a render-tick gap.
- If the cache is empty (search-before-browse), `browseHubThenFilter`
  performs one CLI fetch, populates the cache, then applies the
  filter — failure surfaces a "Search failed" banner instead of a
  silent empty state.
- Source-specific search still shells out to
  `hermes skills search <query> --source <s> --limit 40`.

Adds five regression tests covering name match, description match,
case-insensitive folding, no-match message state, and the empty-query
fallthrough to browse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:50:52 +02:00
Alan Wizemann a6a8cae8ff fix(transport): drain ssh stdout/stderr concurrently to unwedge >64KB payloads
Issue #77 — Sessions screen rendered empty even though Dashboard
reported 161 sessions and Activity reported 116. Root cause was a
classic pipe-buffer deadlock in SSHScriptRunner: stdout was read via
`readToEnd()` AFTER the subprocess had exited. macOS pipes default to
a 16–64 KB kernel buffer; once the remote `sqlite3 -json` script wrote
more than that to its stdout, ssh back-pressured across the wire,
sshd back-pressured sqlite3, sqlite3 blocked, the script never
finished, the 30-second timeout fired, `streamScript` threw, and
`HermesDataService.sessionListSnapshot()` swallowed the failure into
an empty array. Empty Sessions list. Dashboard kept working because
its smaller LIMIT 5 payload fit under the threshold.

Why this was a v2.7 regression specifically: 20cc3a2 folded the
previously-separate sessions + previews queries into a single batched
round-trip (perf win for remote users). The new combined payload for
~150+ sessions crossed the buffer threshold for the first time.

Fix: drain stdout/stderr concurrently with the running process via
Foundation's `FileHandle.readabilityHandler`, accumulating chunks
into an NSLock-guarded `Data` buffer. The kernel pipe never fills,
the subprocess never blocks, the script returns the full payload.
Same change applied to both the SSH path (`runOverSSH`) and the
local path (`runLocally`) — they had identical bug shapes.

Adds SSHScriptRunnerTests with three regression checks: a 256 KB
synthetic payload that would have wedged pre-fix, a small-payload
sanity round-trip, and a non-zero exit propagation check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 11:50:34 +02:00
Alan Wizemann 6b66b1c96f perf(ios): wire v2.7 perf parity — instrument iOS-only VMs + surface hydration banner + opt-in toggle
Most of the v2.7 perf work was already covered on iOS via shared
code in ScarfCore — `RichChatViewModel.loadSessionHistory` (and
its skeleton-then-hydrate path), `hydrateAssistantToolCalls`,
`fetchSkeletonMessages`, `fetchRecentToolCallSkeleton`,
`ModelPreflight.detectMismatch`, and the `RemoteSQLiteBackend`
cancellation handler all flow through to the ScarfGo chat
unchanged. `CitadelServerTransport.streamScript` already
honors `Task.isCancelled` correctly via `withThrowingTaskGroup` +
`Task.checkCancellation()`, so the SSH-cancellation-on-nav-away
chain works on iOS without the Mac-side `SSHScriptRunner` fix.

Three iOS-specific gaps closed:

* IOSCronViewModel.load + IOSMemoryViewModel.load wrapped in
  `ScarfMon.measureAsync(.diskIO, "ios.cron.load")` /
  `"ios.memory.load"` — parity with the Mac `cron.load` /
  `memory.load` events. `ios.memory.load.bytes` records the
  payload size for the loaded file.
* iOS Settings → "Chat (Scarf)" section gains a toggle bound to
  `RichChatViewModel.loadHistoricalToolResultsKey` so iOS users
  can opt into Phase 2b bulk tool-result hydration, same as the
  Mac DisplayTab. The shared key means the gate inside
  `startToolHydration` reads the right value automatically — no
  extra plumbing needed.
* iOS ChatView surfaces `isHydratingTools` as a "Loading tool
  details…" connection banner (mirrors the Mac toolbar pill
  added in v2.7 perf work). Sits between the existing
  "Thinking…" banner and the empty-view fallback so chat status
  is always honest about what the agent and Scarf are doing.

Both Mac and iOS targets build clean; all 321 ScarfCore tests
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:26:25 +02:00
64 changed files with 6950 additions and 236 deletions
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: awizemann
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+24
View File
@@ -0,0 +1,24 @@
# Building Scarf
Scarf is a native macOS app built with Xcode. For contributor builds, use the local script:
```bash
./scripts/local-build.sh
```
Requirements:
- macOS 14.6 (Sonoma) or newer at runtime — that's the app's `MACOSX_DEPLOYMENT_TARGET`. Sonoma support is intentional and load-bearing; do not raise this without an explicit decision to drop Sonoma users
- Xcode 16.0 or newer, selected by `xcode-select` (needed for Swift 6 strict-concurrency features the project uses)
- Metal toolchain installed
- Hermes installed at `~/.hermes/` (see the project README for setup)
If the Metal toolchain is missing, the script will offer to install it in interactive shells. You can also install it manually:
```bash
xcodebuild -downloadComponent MetalToolchain
```
`scripts/local-build.sh` resolves Swift package dependencies, detects `arm64` vs `x86_64`, and builds the Debug app unsigned. Signing is intentionally disabled for local Debug builds so contributors do not need the maintainer's Apple Developer account.
Release signing is separate from contributor builds. Maintainers should continue using the existing release process for signed distributable builds.
+57 -3
View File
@@ -113,9 +113,29 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
## Hermes Version
Targets Hermes v2026.4.30 (v0.12.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
Targets Hermes v2026.5.7 (v0.13.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
**Capability gating.** Scarf detects the target's Hermes version once per server connection via [HermesCapabilities](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift) (`hermes --version` → semver + `YYYY.M.D` parse). The resulting `HermesCapabilitiesStore` is injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`; UI that depends on a v0.12+ surface (Curator, Kanban, ACP image input, `auxiliary.curator`, `prompt_caching.cache_ttl`, Piper TTS, Vercel terminal) reads it through the typed environment key. Pre-v0.12 hosts gracefully hide the new affordances rather than throwing on unknown CLI subcommands. Add a new flag at the top of `HermesCapabilities` whenever Scarf gains a release-gated UI surface.
**Capability gating.** Scarf detects the target's Hermes version once per server connection via [HermesCapabilities](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift) (`hermes --version` → semver + `YYYY.M.D` parse). The resulting `HermesCapabilitiesStore` is injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`; UI that depends on a release-gated surface reads it through the typed environment key. Pre-target hosts gracefully hide the new affordances rather than throwing on unknown CLI subcommands. Add a new flag at the top of `HermesCapabilities` whenever Scarf gains a release-gated UI surface — group flags by the Hermes release that introduced them (`MARK: v0.13 (v2026.5.7) flags`, etc.).
**v2026.5.7 (v0.13.0)** added (Scarf-relevant subset; full v2.8.0 implementation lands across WS-2 through WS-9):
- **Persistent Goals** — `/goal <text>` slash command locks the agent onto a target across turns. Checkpoints v2 single-store rewrite + auto-resume after gateway restart. Surfaced in Scarf chat as a non-interruptive command + a "🎯 Goal locked: <text>" pill in the chat header. Gated on `HermesCapabilities.hasGoals`.
- **ACP `/queue` slash command** — queues a prompt to run after the current turn completes. Joins `/steer` in `RichChatViewModel.nonInterruptiveCommands` with a transient "Queued" toast. Gated on `hasACPQueue`. `/steer` now also runs as a regular prompt on idle sessions (`hasACPSteerOnIdle`).
- **Kanban v0.13 reliability + recovery UX** — hallucination gate on worker-created cards, generic diagnostics engine (per-task distress signals), per-task `max_retries` override, multiline title/body create, `auto_blocked_reason` rendered in the inspector banner, darwin zombie detection, unify failure counter across spawn/timeout/crash. New fields decode through tolerant `HermesKanbanRun` / `HermesKanbanTaskDetail` extensions; pre-v0.13 hosts ignore unknown keys. Gated on `hasKanbanDiagnostics`.
- **Curator archive + prune** — `hermes curator archive <skill>` + `prune` + `list-archived` subcommands. The synchronous manual `hermes curator run` blocks until done (pre-v0.13 returned immediately). Surfaced as an "Archived" tab in CuratorView with per-row Restore + Prune actions and a destructive prune-confirm sheet. Gated on `hasCuratorArchive`.
- **Messaging Gateway expansion** — Google Chat (20th platform; `hasGoogleChatPlatform`), cross-platform allowlists (`allowed_channels` / `allowed_chats` / `allowed_rooms` per platform; `hasGatewayAllowlists`), per-platform `gateway_restart_notification` (`hasGatewayRestartNotification`), `busy_ack_enabled` toggle (`hasGatewayBusyAckToggle`), slash-command auto-delete TTL, `[[as_document]]` skill media routing directive, `hermes gateway list` cross-profile status verb (`hasGatewayList`).
- **Provider catalog refresh** — new models on Nous Portal + OpenRouter: `deepseek/deepseek-v4-pro`, `x-ai/grok-4.3`, `openrouter/owl-alpha` (free), `tencent/hy3-preview`, `arcee/trinity-large-thinking` (with temperature + compression overrides). `x-ai/grok-4.20-beta` renamed to `x-ai/grok-4.20` — keep alias map. Vercel AI Gateway demoted to bottom of the picker. `image_gen.model` from `config.yaml` now honored by Hermes (was advertised but ignored pre-v0.13); surfaced in `Settings → Auxiliary` (`hasImageGenModel`). OpenRouter response caching toggle (`hasOpenRouterResponseCache`).
- **MCP SSE transport** — MCP servers can be configured with SSE transport + `sse_read_timeout`. Surfaced in MCPServersView add-server flow alongside stdio/pipe. Gated on `hasMCPSSETransport`.
- **Cron `--no-agent` mode** — script-only watchdog jobs that skip the AI call. Surfaced in CronView edit sheet. Gated on `hasCronNoAgent`.
- **Web Tools per-capability backends** — `web_search` and `web_extract` can use distinct backends; SearXNG joined as a search-only backend. Surfaced in the Web Tools settings tab. Gated on `hasWebToolsBackendSplit`.
- **Profiles `--no-skills`** — `hermes profile create --no-skills` for empty-profile creation. Surfaced as a toggle in the create-profile flow. Gated on `hasProfileNoSkills`.
- **CLI / UX additions** — context compression count in the status feed (rendered next to the token count in chat status bar; `hasContextCompressionCount`), `/new <name>` slash-command argument (`hasNewWithSessionName`), `hermes update --yes` non-interactive (`hasUpdateNonInteractive`), `display.language` static-message translation (zh / ja / de / es / fr / uk / tr; `hasDisplayLanguage`), xAI Custom Voices (voice-cloning badge next to xAI TTS provider; `hasXAIVoiceCloning`).
- **Server-side defaults flipped** — secret redaction defaults back to ON in v0.13 (was off by default in v0.12). The Settings redaction toggle remains for opt-out; the default-state hint reflects the v0.13 semantics when the host advertises v0.13+.
- **`video_analyze` tool** — native video understanding on Gemini-class models. Hermes handles transparently inside the agent loop; Scarf has no UI surface yet but `hasVideoAnalyze` is reserved for future widget gating.
- **`transform_llm_output` plugin hook** — plugin-author concern; surfaced indirectly through PluginsView when a plugin advertises the hook. `hasTransformLLMOutputHook` gates the metadata badge.
- **Schema is unchanged from v0.11/v0.12** — same state.db columns. No migration needed.
**v2026.4.30 (v0.12.0)** added (Scarf-relevant subset):
**v2026.4.30 (v0.12.0)** added (Scarf-relevant subset):
@@ -124,7 +144,7 @@ Targets Hermes v2026.4.30 (v0.12.0). Log lines may carry an optional `[session_i
- **`flush_memories` aux task removed (server side)** — `auxiliary.flush_memories` is gone from v0.12 Hermes config but remains alive on pre-v0.12 hosts. Scarf preserves `AuxiliarySettings.flushMemories: AuxiliaryModel`, the YAML reader still emits an `aux("flush_memories")` row, and `AuxiliaryTab` only renders the row when `HermesCapabilities.hasFlushMemoriesAux` is `true` (inverse semantics — pre-v0.12 only). v0.12 users never see the row; v0.11 users keep their edit surface.
- **`auxiliary.curator` aux task added** — Curator's review model is configurable independently of the main model. Surfaced in `Settings → Auxiliary` next to the other aux rows.
- **Multimodal ACP `session/prompt`** — ACP advertises and forwards image content blocks. Scarf chat composers (Mac drag/drop + paste; iOS PhotosPicker) attach images that flow through `ACPClient.sendPrompt(sessionId:text:images:)` as `[{"type":"text","text":...}, {"type":"image","data":"<base64>","mimeType":"image/jpeg"}]` — wire shape matches `acp.schema.ImageContentBlock`. `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85 detached (never blocks MainActor). Gated on `HermesCapabilities.hasACPImagePrompts`.
- **CLI additions:** `hermes -z <prompt>` (non-interactive one-shot), `hermes update --check` (preflight), `hermes fallback` (manage fallback providers), `hermes curator` (status / run / pause / resume / pin / unpin / restore), `hermes kanban` (full task-board CLI; multi-profile collab was reverted upstream so Scarf ships a read-only Kanban view only). All capability-gated.
- **CLI additions:** `hermes -z <prompt>` (non-interactive one-shot), `hermes update --check` (preflight), `hermes fallback` (manage fallback providers), `hermes curator` (status / run / pause / resume / pin / unpin / restore), `hermes kanban` (full 27-verb task-board CLI). All capability-gated. **v2.7.5 lifts Kanban from a read-only list to a full drag-and-drop board.** See the dedicated [Kanban v3](#kanban-v3-drag-and-drop-board--per-project-tenants-v275) section below for the complete architecture.
- **Skills surface:** `hermes skills install <https-url>` direct-URL install (SkillsView "Install from URL…" toolbar button), reload via `hermes skills audit` (Skills "Reload" button — equivalent to the `/reload-skills` slash command for non-ACP contexts), enabled/disabled state read from `skills.disabled` in config.yaml (rendered as strikethrough + "OFF" pill), Curator pin badge from `~/.hermes/skills/.curator_state` (rendered as a pin glyph). The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb, and Scarf prefers reading accurately to risking a clobbered list.
- **Two new gateway platforms:** Microsoft Teams (19th, plugin-shipped) + Tencent 元宝 / Yuanbao (18th, native). Surfaced in the Mac Platforms tab.
- **Cron upgrades:** per-job `--workdir <abs-path>` (project-aware cwd that pulls AGENTS.md / CLAUDE.md / .cursorrules) is exposed in the editor sheet, gated on `HermesCapabilities.hasCronWorkdir` so pre-v0.12 hosts don't see the field (and a defensive override in `CronView` strips the value before calling `createJob`/`updateJob` even if it was hydrated from a pre-existing job). Pass an empty string on edit to clear an existing workdir, mirroring the `--script` shape. Hermes also added a `context_from` field for chaining cron outputs but only via YAML so far — Scarf reads it (HermesCronJob.contextFrom) but doesn't write it.
@@ -153,6 +173,40 @@ v0.10.0 introduced the **Tool Gateway** — paid Nous Portal subscribers route w
**Keep `ModelCatalogService.overlayOnlyProviders` in sync** with `HERMES_OVERLAYS` in `~/.hermes/hermes-agent/hermes_cli/providers.py`. When Hermes adds a new overlay-only provider, mirror the entry (display name, base URL, auth type, subscription-gated flag, doc URL) or the picker won't reach it.
## Kanban v3: drag-and-drop board + per-project tenants (v2.7.5)
Scarf v2.7.5 promotes Kanban from a read-only list to a full board with drag-and-drop, every Hermes write verb wired up, and per-project boards bound to a Scarf-minted tenant slug. The list view is preserved as a `Board | List` toggle for accessibility / narrow-window fallback.
**Sidebar move.** `.kanban` moved from *Manage**Monitor* in `SidebarView` (between `.activity` and the remaining Monitor entries). Kanban is runtime work-in-progress, not configuration. Position kept inside the same enum case — only the section bucket changed.
**Hermes constraints that drive design.**
1. **No `update` verb.** `priority`, `title`, `body`, `tenant` are write-once at `kanban create`. Mutations after create are state transitions (`assign` / `claim` / `complete` / `block` / `unblock` / `archive`) or new comments. Inline-edit on a card title is impossible at the wire level.
2. **No `project_id` column.** Hermes Kanban is one global SQLite DB at `~/.hermes/kanban.db`. Closest namespace is the optional `tenant TEXT` column. Scarf hijacks it: each project gets a `scarf:<slug>` tenant minted on first kanban interaction.
3. **No within-column position field.** Drag-to-reorder inside a column has no Hermes persistence path and is **disabled** in v2.7.5. Sort key is `priority DESC, created_at DESC` — matches dispatcher's actual run order. Cross-column drag is the only persisted gesture.
4. **No file-watch / webhooks.** Polling at 5s while foregrounded; live `watch` streaming deferred to a later release (a `hasKanbanWatch` flag will gate it).
5. **Status enum has 7 values, board collapses to 5 columns:** Triage / **Up Next** (`todo` + `ready`) / Running / Blocked / Done. Triage hides when empty; Archived hides behind a toolbar toggle.
**Service layer.** [KanbanService](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift) is a Sendable `actor` in ScarfCore — pure I/O, no UI state. Wraps every v0.12 verb (`list / show / runs / stats / assignees / create / assign / claim / comment / complete / block / unblock / archive / dispatch / link / unlink`). Every method dispatches its CLI invocation through `Task.detached(priority: .utility)`, matching the existing `KanbanViewModel.load` pattern (re: Swift 6 rules in `~/.claude/CLAUDE.md`). Errors land in [KanbanError](scarf/Packages/ScarfCore/Sources/ScarfCore/Models/KanbanError.swift) and surface as inline banners (not modal alerts) since the board is high-frequency. The "no matching tasks" stdout sentinel is normalized to `[]`.
**Drag-drop transition planner.** `KanbanService.plan(for: KanbanTransition)` is a pure function that maps `(from, to)` columns to the right verb sequence — `(.upNext, .running) → [.claim]`, `(.blocked, .running) → [.unblock, .claim]`, etc. Disallowed transitions throw `KanbanError.forbiddenTransition` with a user-facing reason: drop on Done from anywhere triggers "Done is terminal — create a follow-up task to continue work."; drop on Triage from outside triggers "Triage tasks are promoted by a specifier agent." The view's drop handler short-circuits forbidden transitions with red-stroke target feedback.
**Per-project tenant.** [KanbanTenantResolver](scarf/scarf/Core/Services/KanbanTenantResolver.swift) (Mac) mints `scarf:<slug>` on first kanban interaction inside a project, persisting to `<project>/.scarf/manifest.json`'s new optional `kanbanTenant: String?` field. Tenants are **immutable across rename** (existing tasks already carry the old slug). Bare projects (no manifest) get a sentinel manifest written with `id: scarf/<project-id>` + `version: 0.0.0` + just the `kanbanTenant` set; `ProjectAgentContextService` recognizes the sentinel and refuses to surface it as a "Template" line. The cross-platform read-only counterpart is [KanbanTenantReader](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanTenantReader.swift) in ScarfCore — iOS uses it to filter the per-project board without linking the full manifest model.
**Agent-side tenant injection.** `ProjectAgentContextService.renderBlock` adds a "Kanban tenant" line to the AGENTS.md scarf-managed block whenever a tenant exists. Since `ChatViewModel.startACPSession` calls `refresh(for:)` before opening every project chat, the agent sees the tenant on every session start and is told to pass `--tenant scarf:<slug>` on `hermes kanban create`. Agents are imperfect at flag discipline; misuse just sends the task to the global "Untagged" group on the global board, which is acceptable v2.7.5 behavior. A dedicated retag UX is a follow-up.
**View model.** [KanbanBoardViewModel](scarf/scarf/Features/Kanban/ViewModels/KanbanBoardViewModel.swift) is `@MainActor + @Observable`, holds the column-grouped task array, and applies optimistic-merge logic around drag-drops: an in-flight move records `optimisticOverrides[taskId] = newStatus`, mutates the local array immediately, and clears the override only when the polled response confirms the new status. Without this, a stale poll response can clobber a card the user just dragged. On CLI failure the override is removed and an error message lands in the inline banner.
**Mac surface.** [KanbanBoardView](scarf/scarf/Features/Kanban/Views/KanbanBoardView.swift) is the orchestrator (header + columns + side-pane inspector + create/block/complete sheets). [KanbanColumnView](scarf/scarf/Features/Kanban/Views/KanbanColumnView.swift) owns its `dropDestination(for: KanbanTaskRef.self)`. [KanbanCardView](scarf/scarf/Features/Kanban/Views/KanbanCardView.swift) handles the `.draggable` source, status-specific chrome (running edge accent + shimmer; blocked warning glyph; done dim 0.7/0.55), and a custom drag preview. [KanbanInspectorPane](scarf/scarf/Features/Kanban/Views/KanbanInspectorPane.swift) is a 420pt side-pane (not modal) so the user can keep dragging cards after inspecting one. [KanbanCreateSheet](scarf/scarf/Features/Kanban/Views/KanbanCreateSheet.swift) maps form state to a `KanbanCreateRequest`; the Workspace picker locks to "Project Dir" on per-project boards. [KanbanBlockReasonSheet](scarf/scarf/Features/Kanban/Views/KanbanBlockReasonSheet.swift) and [KanbanCompleteResultSheet](scarf/scarf/Features/Kanban/Views/KanbanCompleteResultSheet.swift) prompt for optional `--reason` / `--result` text on those transitions.
**Per-project surface.** New `DashboardTab.kanban` case in `ProjectsView.swift`, dispatched to [ProjectKanbanTab](scarf/scarf/Features/Projects/Views/ProjectKanbanTab.swift) which mints the tenant on appearance and wraps `KanbanBoardView` with `tenantFilter` + `projectPath` pre-applied. Capability-gated on `HermesCapabilities.hasKanban` so pre-v0.12 hosts don't see a broken destination. Plus a new `kanban_summary` widget — top 3 tasks by priority across `running` + `blocked` + `todo` for the project's tenant, with stats glance footer. Mirror in `tools/widget-schema.json`, `tools/build-catalog.py`, and `site/widgets.js`. Templates can reference it as `{ kind: kanban_summary, max_rows: 3 }` in dashboard.json.
**iOS surface.** Read-only board on the project Kanban tab ([ScarfGoKanbanView](Scarf%20iOS/Kanban/ScarfGoKanbanView.swift) + [ScarfGoKanbanDetailSheet](Scarf%20iOS/Kanban/ScarfGoKanbanDetailSheet.swift)). Renders the 5 columns as a horizontally-paged `Picker` of single-column lists — HIG-friendly on iPhone. No mutations, no drag-drop in v2.7.5 (deferred to a later release). Card titles use semantic `.headline` (not `ScarfFont`) so Dynamic Type works; chrome (badges) keeps `ScarfBadge` for fixed visual weight. Gated on `HermesCapabilities.hasKanban`; pre-v0.12 hosts don't see the segment.
**Capability gating.** Kept the single `HermesCapabilities.hasKanban` flag (`>= 0.12.0`). All 27 verbs shipped together; finer-grained gating is YAGNI. A `hasKanbanWatch` flag will land in a later release if `watch` semantics drift between point releases.
**Don't:** introduce within-column reorder via a client-side ordering sidecar — sort order would diverge from dispatcher's actual run order, which is worse than no manual order. Use `priority` on `kanban create` to set initial order; revisit when Hermes ships an `update --priority` verb. Don't try to mutate `priority` / `title` / `body` post-create — there's no verb. Don't drop cards from `done` into anything — Done is terminal. Don't call `transport.runProcess` directly from view bodies; route through `KanbanService` (the actor) so polling and writes share the same concurrency model.
## Project Templates
Scarf ships a `.scarftemplate` format (v1 as of 2.2.0) for sharing pre-packaged projects across users and machines. A bundle is a zip containing:
+4 -2
View File
@@ -5,8 +5,10 @@ Thanks for your interest in contributing to Scarf.
## Getting Started
1. Fork and clone the repo
2. Open `scarf/scarf.xcodeproj` in Xcode 26.3+
3. Build and run (requires macOS 26.2+ and Hermes installed at `~/.hermes/`)
2. Open `scarf/scarf.xcodeproj` in Xcode 16.0+
3. Build and run (Scarf runs on macOS 14.6 Sonoma or newer; Hermes must be installed at `~/.hermes/`)
For an unsigned command-line Debug build without an Apple Developer account, run [`./scripts/local-build.sh`](scripts/local-build.sh). See [BUILDING.md](BUILDING.md) for prerequisites.
## Architecture
+2
View File
@@ -238,6 +238,8 @@ Or from the command line:
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
```
For an unsigned local Debug build without an Apple Developer account (handy for contributors), use [`./scripts/local-build.sh`](scripts/local-build.sh) — see [BUILDING.md](BUILDING.md) for prerequisites.
## Architecture
Scarf follows the **MVVM-Feature** pattern with zero external dependencies beyond SwiftTerm:
+34
View File
@@ -0,0 +1,34 @@
## What's in 2.7.1
A patch release covering three bug reports filed against 2.7.0, plus follow-up cleanups in the same neighborhood. No data migrations, no UI surface changes — drop-in replacement for 2.7.0 on Mac.
### Bug fixes
#### Mac
- **[#77](https://github.com/awizemann/scarf/issues/77) — Sessions screen renders empty even when Dashboard reports sessions exist.** v2.7.0 folded the Sessions tab's two SQL queries (sessions list + previews) into a single batched SSH round-trip for perf. The combined wire payload for any user with ~150+ sessions crossed macOS's 1664 KB pipe-buffer threshold; without a concurrent reader draining the pipe, the remote `sqlite3 -json` blocked, the script never finished, our 30-second timeout fired, and the call returned an empty result. `SSHScriptRunner` now drains stdout/stderr concurrently with the running process via `FileHandle.readabilityHandler`, so the kernel pipe never fills. Same fix applied to the local-execution path. New regression test pushes 256 KB of synthetic output through the runner and asserts full delivery — would have wedged pre-fix.
- **[#78](https://github.com/awizemann/scarf/issues/78) — Skills "What's New" pill contradicts the Updates sub-tab.** The pill at the top of the Skills page was rendering on every sub-tab, including Updates. It counts **local** file deltas since the user last clicked "Mark as seen" (e.g. "18 new" = 18 skills landed on disk that you haven't acknowledged), while the Updates body runs `hermes skills check` to find skills with newer **upstream** versions available — a different concept. Two surfaces using the word "update" for two different things made the screen contradict itself. Two changes: the pill now renders only on the Installed sub-tab (Mac and ScarfGo), and its label says "X **changed** since you last looked" instead of "X updated" so the local-file vocabulary doesn't collide with upstream-update vocabulary anywhere on the page.
- **[#79](https://github.com/awizemann/scarf/issues/79) — Skills hub search returns nothing for terms visible in Browse.** With the source picker on "All Sources", `hermes skills search <query>` (no `--source` flag) routes through Hermes's centralized index and skips external API sources (skills-sh, github, clawhub, lobehub, well-known) — but Browse still aggregates from those sources, so a skill like `honcho` would show up in Browse and disappear in search. Same picker, same query, contradictory results. Rather than chase Hermes's index gaps, "All Sources" search now means "filter what you can already see": Scarf caches the most recent Browse payload and runs a client-side substring filter (case-insensitive against name, description, and identifier) against it, instantly. Source-specific searches still shell out to `hermes skills search --source <s>` for full upstream search semantics. Five new tests cover the filter behavior.
- **`hermesPIDResult()` — narrow the Hermes "is it running?" probe to the gateway.** Previously `pgrep -f hermes`, which matched any process with "hermes" in its argv: chat sessions Scarf itself spawns, `hermes -z` one-shots, log tails, even the README in an editor. The Dashboard "Hermes is running" badge could read true even when the gateway daemon was down. Tightened to a regex that matches only the gateway shape — `python -m hermes_cli.main gateway run …` and `/path/to/hermes gateway run …`. All callers (DashboardViewModel, HealthViewModel, SettingsViewModel, scarfApp, stopHermes) want the gateway PID specifically. Cherry-picked from [#76](https://github.com/awizemann/scarf/pull/76) — thanks to [@unixwzrd](https://github.com/unixwzrd) for the diagnosis and regex.
- **`HealthViewModel.stopDashboard()` — stop the dashboard by port, not `pkill -f`.** External-instance fallback used to be `pkill -f "hermes dashboard"`, broad enough to match shell history, log tails, README readers — anything with the substring in its argv. Now `lsof -tiTCP:<port> -sTCP:LISTEN` resolves the PID actually bound to the dashboard port and only that one process gets `SIGTERM`. Trusting the port is correct here: Scarf owns the configured port and the user-visible intent is "stop the thing on this port." Direction cherry-picked from [#76](https://github.com/awizemann/scarf/pull/76); the `-c hermes` filter from the original was dropped because Hermes installs as a Python shebang script and the kernel COMM is `python`, not `hermes``-c hermes` would silently miss every standard install.
### Documentation + tooling
- **`scripts/local-build.sh` + `BUILDING.md` for contributor builds.** New unsigned single-arch Debug build script for contributors without an Apple Developer account. Detects arm64 / x86_64, verifies xcode-select / xcrun / xcodebuild, probes the Metal toolchain (offers an interactive install on TTY, errors cleanly on CI), resolves Swift packages, builds Debug with signing disabled. Optional one-touch `ditto` to `/Applications/scarf.app` on explicit y/N. The canonical Release universal CLI in `README.md` is unchanged — `local-build.sh` is an alternative for contributors, not a replacement for the shipping build. Cherry-picked from [#76](https://github.com/awizemann/scarf/pull/76).
- **`BUILDING.md` + `CONTRIBUTING.md` — restored Sonoma compatibility messaging.** The runtime min is **macOS 14.6 (Sonoma)** — that's the `MACOSX_DEPLOYMENT_TARGET` on the main `scarf` target and is intentional. Build min is **Xcode 16.0** (needed for Swift 6 strict-concurrency features). The legacy CONTRIBUTING.md line had drifted to "Xcode 26.3+ / macOS 26.2+", which would have steered Sonoma contributors and users away from a build that actually runs on their box. Corrected, with a load-bearing-callout in BUILDING.md so future doc edits don't silently raise the floor again.
### Migrating from 2.7.0
Sparkle will offer the update automatically. No config migration, no schema changes. Existing sessions, skills, and projects are untouched.
If you've been working around #77 by collapsing the sidebar or restarting Scarf to repopulate the Sessions list, you can stop — sessions should load reliably now.
### Acknowledgements
- [@bricelb](https://github.com/bricelb) for the three v2.7.0 bug reports ([#77](https://github.com/awizemann/scarf/issues/77), [#78](https://github.com/awizemann/scarf/issues/78), [#79](https://github.com/awizemann/scarf/issues/79)) — well-instrumented reproductions including screenshots and environment details made the diagnosis straightforward.
- [@unixwzrd](https://github.com/unixwzrd) for [#76](https://github.com/awizemann/scarf/pull/76) — the gateway-pgrep tighten, the `pkill -f "hermes dashboard"` direction, and the `local-build.sh` contributor flow are all cherry-picked from that PR.
+83
View File
@@ -0,0 +1,83 @@
## What's in 2.7.5
A feature release that lifts Scarf's Kanban surface from a read-only list (the v2.6 placeholder shipped while upstream Kanban was still mid-rework) to a full drag-and-drop board with the complete Hermes v0.12 mutation surface wired up — plus per-project boards bound to a Scarf-minted tenant slug, and a read-only board on iOS for at-a-glance status from your phone. No data migrations, no schema changes; pre-v0.12 hosts gracefully hide the surface.
### New features
#### Mac
- **Drag-and-drop Kanban board** ([scarf/Features/Kanban/Views/KanbanBoardView.swift](scarf/scarf/Features/Kanban/Views/KanbanBoardView.swift)). Five visible columns — Triage / Up Next (`todo` + `ready`) / Running / Blocked / Done — collapsing Hermes's seven status values into a layout that doesn't waste space on `ready`, which the dispatcher only ever holds for a few seconds. Triage hides itself when empty; archived hides behind a header toggle. Drop a card onto a column and Scarf maps the gesture to the right Hermes verbs through a pure transition planner: drop-on-Running fires `kanban dispatch` (the dispatcher then spawns a worker), drop-on-Blocked opens a sheet asking for a reason and calls `kanban block`, drop-on-Done opens a result sheet and calls `kanban complete`, blocked → running chains `unblock` + `dispatch`. Forbidden transitions (anything dropped on Done; anything dragged out of Triage) reject with a red drop-target stroke and a tooltip explaining why — Done is terminal, Triage is promoted by a specifier worker, neither has a CLI verb that maps cleanly. Optimistic local updates apply on drop and revert on CLI failure with a toast, so the UI feels instant.
- **Side-pane inspector** ([KanbanInspectorPane.swift](scarf/scarf/Features/Kanban/Views/KanbanInspectorPane.swift)). Click a card and a 420 px pane slides in from the trailing edge. Not a modal sheet — modal would block triaging the next card after closing. Header carries the status, an inline assignee menu (more on that below), workspace kind, and tenant; below that, four tabs render `hermes kanban show <id>` data: **Comments** (with an inline composer that calls `kanban comment`), **Events** (the `task_events` log with per-kind glyphs), **Runs** (one row per attempt with outcome badge + summary + error), and **Log** — the worker's captured stdout/stderr from `hermes kanban log <id>`, polled every 2 s while the task is running with a "● streaming" indicator and auto-scroll to the latest line, snapshot-only with a refresh button when the task is in a terminal state. The action bar at the bottom has all the per-status verbs — Start (which is `claim` rebranded as a user-visible action), Complete, Block, Unblock, Archive — every one with a help tooltip explaining what it does and what Hermes verb it invokes. The "Archive" tooltip explicitly notes Hermes has no hard-delete: archived tasks remain in `~/.hermes/kanban.db` and are recoverable via the "Show archived" toggle until `hermes kanban gc` runs.
- **Inspector auto-refresh.** While the inspector is open, the detail (header, action buttons, comments, events, runs) re-fetches every 5 s on the same cadence as the board itself, so a worker transition (e.g. running → done elsewhere) is reflected without the user having to close + reopen. The Log tab's 2 s poll runs separately and self-cancels the moment the task transitions out of `running`.
- **Inline assignee picker on the inspector header.** The assignee badge is a clickable menu — set means a `.brand` (rust) chip, unassigned means a `.warning` (yellow) chip so the eye catches it instantly. Tapping opens a menu of every known profile (union of `~/.hermes/profiles/`, current task assignees, and the active local profile from `HermesProfileResolver`) plus an "Unassigned" option. Selection routes through `kanban assign` and immediately follows with `kanban dispatch` so the task gets picked up promptly. Solves the "I assigned a profile but nothing happened" gap end-to-end without the user touching a terminal.
- **Health banner in the inspector.** Surfaces two conditions that previously left users staring at a stuck task with no explanation. **Yellow** when the task is unassigned in `ready` / `todo`: *"Won't run automatically — Hermes's dispatcher silently skips tasks with no assignee."* The dispatcher's own `--json` output literally lists these under `skipped_unassigned`; we now surface that to the human. **Red** when the most-recently-completed run ended in a non-success outcome (`stale_lock` / `crashed` / `gave_up` / `timed_out` / `spawn_failed` / `reclaimed` / `failed`): banner displays the outcome label + the raw `error` field from the run record, so you don't have to dig into the Runs tab to discover it. The red banner is suppressed while a fresh attempt is running — once status flips back to `running`, the previous outcome is stale signal and the Log tab's live stream is the right thing to look at.
- **Card-level signals.** Cards in `running` get a 2 px `ScarfColor.info` left edge + a subtle title shimmer so live work is obvious at a glance. Blocked cards get a 2 px `ScarfColor.warning` left edge + a ⚠ glyph next to the title. Done cards dim to 0.7 opacity in light mode, 0.55 in dark, with a green ✓ in the title row. Cards in `ready` / `todo` with no assignee get a yellow ⚠ glyph in the title row with a tooltip explaining the dispatcher won't pick them up — same signal as the inspector banner, just at the board level so triage is one keypress away.
- **`Board | List` toggle at the top of the route.** The v2.6 read-only list view is preserved in `KanbanListView.swift` and surfaced via a segmented picker, so users on narrow windows or anyone who prefers a flat sortable list can opt in. Choice persists across launches via `@AppStorage`.
- **New Task sheet** ([KanbanCreateSheet.swift](scarf/scarf/Features/Kanban/Views/KanbanCreateSheet.swift)). Title, body (markdown supported), assignee (defaults to `HermesProfileResolver.activeProfileName()` so newly-created tasks actually run), workspace kind (segmented `Scratch / Worktree / Project Dir`; locked to Project Dir on per-project boards), priority slider, comma-separated skills with autocomplete from `~/.hermes/skills/`, optional tenant (hidden on per-project boards — the slug is implicit), and a "Send to triage" toggle. Submit fires `kanban create --json` and immediately follows with `kanban dispatch` so an assigned task transitions `ready``running` within seconds rather than waiting for the gateway dispatcher's internal cycle.
- **Kanban moved from Manage → Monitor in the sidebar.** It's runtime work-in-progress, not configuration. Sits between Activity and the rest of Manage so users see "what's happening right now" at a glance.
#### Per-project Kanban
- **`DashboardTab.kanban` on every project**, capability-gated on `HermesCapabilities.hasKanban`. Renders a project-scoped `KanbanBoardView` filtered to the project's tenant slug. Workspace defaults in the New Task sheet are pre-pinned to `dir:<project.path>`. Empty state explains the project doesn't have any tasks yet and offers a "New Task" CTA — the empty board IS the discovery surface.
- **Tenant minting via [KanbanTenantResolver](scarf/scarf/Core/Services/KanbanTenantResolver.swift).** Each Scarf project gets a stable `scarf:<slug>` tenant minted on first kanban interaction and persisted to `<project>/.scarf/manifest.json` (new optional `kanbanTenant` field on `ProjectTemplateManifest`). Slug rules: lowercased, hyphenated, ≤ 48 chars, `scarf:` prefix to avoid collision with hand-typed tenants. Once minted, the tenant is **immutable across rename** — tasks already on the board carry the original slug, so renaming the project doesn't orphan them. Bare projects (no manifest) get a sentinel manifest written with `id: scarf/<project-id>` + `version: 0.0.0` + just the `kanbanTenant` set; the `ProjectAgentContextService` reader recognizes the sentinel and refuses to surface it as a "Template" line in the AGENTS.md block, so the project doesn't suddenly start advertising a fake template to the agent.
- **Agent-side tenant injection.** [ProjectAgentContextService.renderBlock](scarf/scarf/Core/Services/ProjectAgentContextService.swift) emits a "Kanban tenant" line inside the `<!-- scarf-project -->` markers in `<project>/AGENTS.md` whenever a tenant exists, instructing the agent to pass `--tenant scarf:<slug>` on `hermes kanban create`. `ChatViewModel.startACPSession` already calls `refresh(for:)` before opening every project chat, so the agent reads a fresh tenant on every session start with no extra wiring. Agents are imperfect at flag discipline; a forgotten `--tenant` lands the task in the global "Untagged" group rather than failing — acceptable v2.7.5 behavior.
- **`kanban_summary` dashboard widget** ([KanbanSummaryWidgetView.swift](scarf/scarf/Features/Projects/Views/Widgets/KanbanSummaryWidgetView.swift)). New widget kind for project dashboards: shows the top three `running` / `blocked` / `todo` tasks for the project's tenant by priority, plus a glance footer (`"12 todo · 3 running · 5 blocked"`) sourced from `kanban stats`. Polls every 10 s while the dashboard is foregrounded. Widget vocabulary registered in [tools/widget-schema.json](tools/widget-schema.json) and rendered on the catalog site via [site/widgets.js](site/widgets.js); template authors can drop a `{ kind: kanban_summary, max_rows: 3 }` block into `dashboard.json`.
#### iOS / iPadOS
- **Read-only Kanban tab on `ProjectDetailView`** ([Scarf iOS/Kanban/ScarfGoKanbanView.swift](scarf/Scarf%20iOS/Kanban/ScarfGoKanbanView.swift)). Same five-column collapse rendered as a horizontally-paged segmented `Picker` of single-column lists — HIG-friendly on iPhone where a 5-column grid forces unreadable card widths. Pulls live status, assignee, workspace, skills, priority chips. Tap a card → modal `NavigationStack` detail sheet ([ScarfGoKanbanDetailSheet.swift](scarf/Scarf%20iOS/Kanban/ScarfGoKanbanDetailSheet.swift)) with the same Comments / Events / Runs tabs the Mac inspector has. Read-only in v2.7.5 — mutations + drag-drop on iPad land in v2.8 once the Mac flow is fully shaken out. Card titles use semantic `.headline` (not `ScarfFont`) so Dynamic Type works; chrome (badges) stays on `ScarfBadge` for fixed visual weight per the project's iOS conventions.
#### ScarfCore
- **`KanbanService` actor** ([Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanService.swift)) — pure-I/O Sendable actor wrapping every Hermes v0.12 verb (`list / show / runs / stats / assignees / create / assign / claim / comment / complete / block / unblock / archive / dispatch / link / unlink / log`). Dispatches each CLI invocation through `Task.detached(priority: .utility)` matching the existing concurrency conventions. Errors land in [KanbanError](scarf/Packages/ScarfCore/Sources/ScarfCore/Models/KanbanError.swift) and surface as inline banners (not modal alerts) since the board is high-frequency. The "no matching tasks" stdout sentinel is normalized to `[]` rather than thrown.
- **Pure transition planner.** `KanbanService.plan(for: KanbanTransition)` is a synchronous function that maps a `(from, to)` column pair to the right verb sequence — `(.upNext, .running) → [.dispatch]`, `(.blocked, .running) → [.unblock, .dispatch]`, etc. Disallowed transitions throw `KanbanError.forbiddenTransition` with a user-actionable reason. The planner is fully tested in `KanbanModelsTests.swift`. Critically: `dispatch` (not `claim`) is the verb used for Up-Next → Running. Hermes's `claim` is documented as "manual alternative to the dispatcher" and assumes the caller spawns the worker themselves — Scarf doesn't, so calling `claim` from drag-drop reserved tasks but never spawned work, and the dispatcher reclaimed them ~15 minutes later (`stale_lock`). `dispatch` is the right primitive for a GUI client.
- **Cross-platform [KanbanTenantReader](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/KanbanTenantReader.swift).** Read-only projection over `<project>/.scarf/manifest.json`'s `kanbanTenant` field. The full `ProjectTemplateManifest` type lives in the Mac target; this lightweight reader gives iOS a way to filter the per-project board by tenant without linking the full manifest model.
- **Timestamp decoding tolerates both shapes.** Hermes emits `created_at` / `started_at` / `completed_at` / `last_heartbeat_at` etc. as Unix integer seconds (its SQLite columns are INTEGER), but earlier wire docs implied ISO-8601 strings. The decoder now accepts either an integer or a string and normalizes to ISO-8601 so downstream code only handles one type. Locked in by `decodeUnixIntegerTimestamps` in `KanbanModelsTests`.
- **`KanbanBoardViewModel` optimistic merge.** Holds `optimisticOverrides: [taskId: status]` for in-flight drags; the polled response merges with optimistic state until the server confirms the new status, so a stale poll arriving milliseconds after a drop can't snap the card back to its old column. On CLI failure the override is removed and the message lands in the inline banner.
### Dispatch + assignee fixes
A diagnostic round driving real tasks end-to-end exposed a connected bug pattern that the polish pass closed:
- **Hermes's dispatcher silently skips unassigned tasks** — its `kanban dispatch --json` output literally lists them under a `skipped_unassigned` key and moves on. Tasks created without an assignee sat in `ready` indefinitely and the user had no signal anything was wrong. The New Task sheet now defaults to the active Hermes profile, the inspector header shows a yellow "Unassigned" chip + warning banner, every `ready` / `todo` card without an assignee gets a ⚠ glyph + tooltip, and the inspector's inline assignee picker fixes it in one click.
- **Drag-to-Running used to call `claim`**, which is a manual alternative to the dispatcher. Status flipped to `running`, but no worker spawned (Scarf doesn't host workers), and 15 minutes later the dispatcher reclaimed the task with a `stale_lock` outcome. Replaced with `dispatch` end-to-end so the gateway-running dispatcher actually does the spawning.
- **`hermes kanban assignees` empty-state was leaking into the picker.** The CLI prints a literal sentinel `(no assignees — create a profile with hermes -p <name> setup)` when the table is empty; the parser was tokenizing it on whitespace and offering `(no` as a profile in the menu. Parser now skips the sentinel, validates each candidate against `^[a-zA-Z0-9_-]+$`, and falls back cleanly to the active local profile when the table is empty.
- **`spawn_failed` from "executable not found on PATH"** — most subtle of the lot. macOS GUI apps inherit a launch-services PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) that doesn't include `~/.local/bin` (where pipx installs `hermes`) or `/opt/homebrew/bin`. Scarf was finding `hermes` for its own invocation via the absolute-path resolver in `HermesPathSet.hermesBinaryCandidates`, but when the dispatcher then spawned a worker process, that worker inherited Scarf's GUI PATH and couldn't find `hermes` by name — recording an `outcome=spawn_failed` run with the exact "executable not found on PATH" message. `LocalTransport` now grows an `environmentEnricher` static (mirroring `SSHTransport.environmentEnricher`) wired by `scarfApp.swift` to the same `HermesFileService.enrichedEnvironment()` login-shell probe the SSH transport uses. Every local subprocess Scarf spawns now sees the user's full PATH and credential env, so a spawned-from-Scarf hermes can spawn its children by name without reaching for absolute paths. Defense-in-depth: `subprocessEnvironment(forExecutable:)` also unconditionally prepends the executable's parent directory to PATH, so the fix works even if the enricher hasn't been wired (early startup, tests).
### Migrating from 2.7.1
Sparkle will offer the update automatically. No config migration, no schema changes — `~/.hermes/kanban.db` is shared across all Hermes clients and Scarf only reads/writes through the documented CLI surface. Existing Scarf projects pick up the new project Kanban tab on first open; the tenant slug is minted lazily on first kanban interaction inside the project, so projects with no kanban activity stay byte-identical until the user opens the tab.
If you have an existing project with a Scarf-managed `manifest.json`, the new optional `kanbanTenant` field is added on next mint and lives alongside any template-author config schema without touching it. Templates do not ship `kanbanTenant` (it's user-machine-scoped state); the export pipeline strips it.
If you've been running tasks via the v2.6 read-only list and your Hermes host already runs the gateway dispatcher, your existing kanban tasks should appear on the board automatically — there's no migration step. Tasks created without an assignee in v2.6 will now show the yellow "Unassigned" warning until you fix them through the inline picker.
### Known limitations
- **Within-column reorder is not supported.** Hermes has no `update` verb and no `position` column on the tasks table — `priority` is write-once at create time. Sort order inside each column is `priority DESC, created_at DESC`, matching the dispatcher's actual run order. We considered a client-side ordering sidecar; rejected because the on-screen order would diverge from what runs next, which is worse than no manual order. Will revisit if Hermes ships an `update --priority` verb.
- **No live `watch` streaming yet.** The board polls every 5 s; the inspector polls detail on the same cadence and the Log tab on a 2 s cadence while running. `hermes kanban watch --json` event streaming + reconnect-with-backoff lands in v2.8 along with iOS write surfaces.
- **No bulk re-tag for legacy NULL-tenant tasks.** Tasks created before this release (assignee or no assignee) appear in the global "Untagged" group on the global board. Hermes has no `tenant` mutation verb post-create, so retagging would be archive + recreate — too destructive to ship in this release.
### Acknowledgements
- Driven end-to-end against a fresh local Hermes v0.12.0 install with the gateway dispatcher running. Real bug surface mostly came from doing instead of speculating: the `claim` vs `dispatch` distinction, the silent `skipped_unassigned` behavior, the `(no` parse leak, the integer-vs-ISO timestamp shape, and the stale "Last run" banner during a fresh attempt all surfaced from driving real tasks and watching what actually happened.
@@ -0,0 +1,32 @@
import Foundation
/// One row from `hermes kanban assignees --json`. The output is the
/// union of profiles configured on the host (`~/.hermes/profiles/`)
/// and any names appearing in the live board's `assignee` column
/// covers the case where a profile was renamed but historical tasks
/// still reference the old name.
public struct HermesKanbanAssignee: Sendable, Equatable, Identifiable, Codable {
public var id: String { profile }
public let profile: String
public let activeCount: Int
public let totalCount: Int
public init(profile: String, activeCount: Int = 0, totalCount: Int = 0) {
self.profile = profile
self.activeCount = activeCount
self.totalCount = totalCount
}
enum CodingKeys: String, CodingKey {
case profile
case activeCount = "active"
case totalCount = "total"
}
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.profile = try c.decode(String.self, forKey: .profile)
self.activeCount = try c.decodeIfPresent(Int.self, forKey: .activeCount) ?? 0
self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount) ?? 0
}
}
@@ -0,0 +1,51 @@
import Foundation
/// One comment from `hermes kanban show <id> --json` or appended via
/// `hermes kanban comment <id> <text>`. Comments are append-only there's
/// no edit/delete verb.
public struct HermesKanbanComment: Sendable, Equatable, Identifiable, Codable {
public let id: Int
public let taskId: String
public let author: String
public let body: String
public let createdAt: String
public init(
id: Int,
taskId: String,
author: String,
body: String,
createdAt: String
) {
self.id = id
self.taskId = taskId
self.author = author
self.body = body
self.createdAt = createdAt
}
enum CodingKeys: String, CodingKey {
case id
case taskId = "task_id"
case author
case body
case createdAt = "created_at"
}
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(Int.self, forKey: .id)
self.taskId = try c.decodeIfPresent(String.self, forKey: .taskId) ?? ""
self.author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
self.body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
// Hermes emits Unix integer timestamps from its SQLite columns;
// accept both ints and ISO strings.
if let unix = try? c.decodeIfPresent(Double.self, forKey: .createdAt) {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
self.createdAt = f.string(from: Date(timeIntervalSince1970: unix))
} else {
self.createdAt = (try? c.decodeIfPresent(String.self, forKey: .createdAt)) ?? ""
}
}
}
@@ -0,0 +1,175 @@
import Foundation
/// One event from the `task_events` log emitted by `hermes kanban show`
/// (within a `HermesKanbanTaskDetail`) and streamed live by
/// `hermes kanban watch --json`. Event kinds are open-ended on the Hermes
/// side; v0.12 emits a small known set listed in `KanbanEventKind`. Unknown
/// kinds map to `.unknown` so new Hermes builds don't break decoding.
public struct HermesKanbanEvent: Sendable, Equatable, Identifiable, Codable {
public let id: Int
public let taskId: String
public let runId: Int?
/// Wire string for the event kind. Use `kindEnum` to interpret.
public let kind: String
public let createdAt: String
/// Opaque diagnostics payload from the `task_events.payload` column.
/// Stored as a JSON string so callers that don't need it pay no
/// decoding cost; callers that do can re-parse.
public let payloadJSON: String?
public init(
id: Int,
taskId: String,
runId: Int? = nil,
kind: String,
createdAt: String,
payloadJSON: String? = nil
) {
self.id = id
self.taskId = taskId
self.runId = runId
self.kind = kind
self.createdAt = createdAt
self.payloadJSON = payloadJSON
}
public var kindEnum: KanbanEventKind { KanbanEventKind.from(kind) }
enum CodingKeys: String, CodingKey {
case id
case taskId = "task_id"
case runId = "run_id"
case kind
case createdAt = "created_at"
case payload
}
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decodeIfPresent(Int.self, forKey: .id) ?? 0
self.taskId = try c.decodeIfPresent(String.self, forKey: .taskId) ?? ""
self.runId = try c.decodeIfPresent(Int.self, forKey: .runId)
self.kind = try c.decodeIfPresent(String.self, forKey: .kind) ?? "unknown"
if let unix = try? c.decodeIfPresent(Double.self, forKey: .createdAt) {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
self.createdAt = f.string(from: Date(timeIntervalSince1970: unix))
} else {
self.createdAt = (try? c.decodeIfPresent(String.self, forKey: .createdAt)) ?? ""
}
// payload may be absent, a JSON object, or already a string.
if let raw = try? c.decodeIfPresent(String.self, forKey: .payload) {
self.payloadJSON = raw
} else if c.contains(.payload) {
// Re-encode arbitrary JSON into a string so we can carry it
// around without committing to a typed shape.
let nested = try c.decode(JSONAny.self, forKey: .payload)
let data = try JSONEncoder().encode(nested)
self.payloadJSON = String(data: data, encoding: .utf8)
} else {
self.payloadJSON = nil
}
}
public func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(taskId, forKey: .taskId)
try c.encodeIfPresent(runId, forKey: .runId)
try c.encode(kind, forKey: .kind)
try c.encode(createdAt, forKey: .createdAt)
try c.encodeIfPresent(payloadJSON, forKey: .payload)
}
}
/// Known event kinds emitted by Hermes v0.12+. New kinds are surfaced
/// as `.unknown` until the model catches up; UI defaults to a generic
/// rendering for those.
public enum KanbanEventKind: String, Sendable, CaseIterable {
case created
case claimed
case released
case started
case completed
case blocked
case unblocked
case commented
case archived
case heartbeat
case statusChange = "status_change"
case error
case crashed
case timedOut = "timed_out"
case spawnFailed = "spawn_failed"
case unknown
public static func from(_ raw: String) -> KanbanEventKind {
KanbanEventKind(rawValue: raw.lowercased()) ?? .unknown
}
}
// MARK: - JSON-any helper
/// Minimal type-erased JSON wrapper used for opaque event payloads. We
/// don't commit to a typed shape because Hermes treats payload as
/// diagnostics and may evolve it freely. Used only inside Codable
/// init/encode (a single decodere-encodestring pass), so the `Any`
/// payload never crosses an actor boundary `@unchecked Sendable`
/// is the appropriate seal here.
struct JSONAny: Codable, @unchecked Sendable {
let raw: Any
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self.raw = NSNull()
} else if let b = try? container.decode(Bool.self) {
self.raw = b
} else if let i = try? container.decode(Int64.self) {
self.raw = i
} else if let d = try? container.decode(Double.self) {
self.raw = d
} else if let s = try? container.decode(String.self) {
self.raw = s
} else if let arr = try? container.decode([JSONAny].self) {
self.raw = arr.map(\.raw)
} else if let dict = try? container.decode([String: JSONAny].self) {
self.raw = dict.mapValues(\.raw)
} else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Unsupported JSON value"
)
}
}
func encode(to encoder: any Encoder) throws {
var c = encoder.singleValueContainer()
switch raw {
case is NSNull:
try c.encodeNil()
case let b as Bool:
try c.encode(b)
case let i as Int64:
try c.encode(i)
case let i as Int:
try c.encode(Int64(i))
case let d as Double:
try c.encode(d)
case let s as String:
try c.encode(s)
case let arr as [Any]:
try c.encode(arr.map { JSONAny(unsafeRaw: $0) })
case let dict as [String: Any]:
try c.encode(dict.mapValues { JSONAny(unsafeRaw: $0) })
default:
throw EncodingError.invalidValue(
raw,
EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported")
)
}
}
private init(unsafeRaw: Any) { self.raw = unsafeRaw }
}
@@ -0,0 +1,144 @@
import Foundation
/// One attempt to execute a kanban task `hermes kanban runs <id> --json`
/// returns an array of these per task. Each run records the worker
/// profile that claimed the task, the outcome, and a structured
/// metadata blob the worker handed back.
public struct HermesKanbanRun: Sendable, Equatable, Identifiable, Codable {
public let id: Int
public let taskId: String
public let profile: String?
public let stepKey: String?
public let status: String // running | done | blocked | crashed | timed_out | failed | released
public let claimLock: String? // "host:pid" at spawn time
public let claimExpires: Int?
public let workerPid: Int?
public let maxRuntimeSeconds: Int?
public let lastHeartbeatAt: String?
public let startedAt: String
public let endedAt: String?
public let outcome: String? // completed | blocked | crashed | timed_out | spawn_failed | gave_up | reclaimed
public let summary: String?
public let error: String?
/// `metadata` is an opaque JSON dict from the worker. Carried as a
/// raw string so we don't lock the typed shape.
public let metadataJSON: String?
public init(
id: Int,
taskId: String,
profile: String? = nil,
stepKey: String? = nil,
status: String,
claimLock: String? = nil,
claimExpires: Int? = nil,
workerPid: Int? = nil,
maxRuntimeSeconds: Int? = nil,
lastHeartbeatAt: String? = nil,
startedAt: String,
endedAt: String? = nil,
outcome: String? = nil,
summary: String? = nil,
error: String? = nil,
metadataJSON: String? = nil
) {
self.id = id
self.taskId = taskId
self.profile = profile
self.stepKey = stepKey
self.status = status
self.claimLock = claimLock
self.claimExpires = claimExpires
self.workerPid = workerPid
self.maxRuntimeSeconds = maxRuntimeSeconds
self.lastHeartbeatAt = lastHeartbeatAt
self.startedAt = startedAt
self.endedAt = endedAt
self.outcome = outcome
self.summary = summary
self.error = error
self.metadataJSON = metadataJSON
}
enum CodingKeys: String, CodingKey {
case id
case taskId = "task_id"
case profile
case stepKey = "step_key"
case status
case claimLock = "claim_lock"
case claimExpires = "claim_expires"
case workerPid = "worker_pid"
case maxRuntimeSeconds = "max_runtime_seconds"
case lastHeartbeatAt = "last_heartbeat_at"
case startedAt = "started_at"
case endedAt = "ended_at"
case outcome
case summary
case error
case metadata
}
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decodeIfPresent(Int.self, forKey: .id) ?? 0
self.taskId = try c.decodeIfPresent(String.self, forKey: .taskId) ?? ""
self.profile = try c.decodeIfPresent(String.self, forKey: .profile)
self.stepKey = try c.decodeIfPresent(String.self, forKey: .stepKey)
self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
self.claimLock = try c.decodeIfPresent(String.self, forKey: .claimLock)
self.claimExpires = try c.decodeIfPresent(Int.self, forKey: .claimExpires)
self.workerPid = try c.decodeIfPresent(Int.self, forKey: .workerPid)
self.maxRuntimeSeconds = try c.decodeIfPresent(Int.self, forKey: .maxRuntimeSeconds)
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
if let unix = try? c.decodeIfPresent(Double.self, forKey: .lastHeartbeatAt) {
self.lastHeartbeatAt = f.string(from: Date(timeIntervalSince1970: unix))
} else {
self.lastHeartbeatAt = try c.decodeIfPresent(String.self, forKey: .lastHeartbeatAt)
}
if let unix = try? c.decodeIfPresent(Double.self, forKey: .startedAt) {
self.startedAt = f.string(from: Date(timeIntervalSince1970: unix))
} else {
self.startedAt = (try? c.decodeIfPresent(String.self, forKey: .startedAt)) ?? ""
}
if let unix = try? c.decodeIfPresent(Double.self, forKey: .endedAt) {
self.endedAt = f.string(from: Date(timeIntervalSince1970: unix))
} else {
self.endedAt = try c.decodeIfPresent(String.self, forKey: .endedAt)
}
self.outcome = try c.decodeIfPresent(String.self, forKey: .outcome)
self.summary = try c.decodeIfPresent(String.self, forKey: .summary)
self.error = try c.decodeIfPresent(String.self, forKey: .error)
if let raw = try? c.decodeIfPresent(String.self, forKey: .metadata) {
self.metadataJSON = raw
} else if c.contains(.metadata) {
let nested = try c.decode(JSONAny.self, forKey: .metadata)
let data = try JSONEncoder().encode(nested)
self.metadataJSON = String(data: data, encoding: .utf8)
} else {
self.metadataJSON = nil
}
}
public func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(taskId, forKey: .taskId)
try c.encodeIfPresent(profile, forKey: .profile)
try c.encodeIfPresent(stepKey, forKey: .stepKey)
try c.encode(status, forKey: .status)
try c.encodeIfPresent(claimLock, forKey: .claimLock)
try c.encodeIfPresent(claimExpires, forKey: .claimExpires)
try c.encodeIfPresent(workerPid, forKey: .workerPid)
try c.encodeIfPresent(maxRuntimeSeconds, forKey: .maxRuntimeSeconds)
try c.encodeIfPresent(lastHeartbeatAt, forKey: .lastHeartbeatAt)
try c.encode(startedAt, forKey: .startedAt)
try c.encodeIfPresent(endedAt, forKey: .endedAt)
try c.encodeIfPresent(outcome, forKey: .outcome)
try c.encodeIfPresent(summary, forKey: .summary)
try c.encodeIfPresent(error, forKey: .error)
try c.encodeIfPresent(metadataJSON, forKey: .metadata)
}
}
@@ -0,0 +1,68 @@
import Foundation
/// Output of `hermes kanban stats --json`. Drives the toolbar glance
/// ("12 todo · 3 running · 5 blocked"), the per-project Kanban summary
/// widget, and the column-count badges on the board header.
public struct HermesKanbanStats: Sendable, Equatable, Codable {
public let byStatus: [String: Int]
public let byAssignee: [String: Int]
public let byTenant: [String: Int]
/// Age in seconds of the oldest task currently in the `ready` status.
/// `nil` when no tasks are ready. Helps surface a stuck dispatcher.
public let oldestReadyAgeSeconds: Double?
public init(
byStatus: [String: Int],
byAssignee: [String: Int] = [:],
byTenant: [String: Int] = [:],
oldestReadyAgeSeconds: Double? = nil
) {
self.byStatus = byStatus
self.byAssignee = byAssignee
self.byTenant = byTenant
self.oldestReadyAgeSeconds = oldestReadyAgeSeconds
}
public static let empty = HermesKanbanStats(byStatus: [:])
enum CodingKeys: String, CodingKey {
case byStatus = "by_status"
case byAssignee = "by_assignee"
case byTenant = "by_tenant"
case oldestReadyAgeSeconds = "oldest_ready_age_seconds"
}
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.byStatus = try c.decodeIfPresent([String: Int].self, forKey: .byStatus) ?? [:]
self.byAssignee = try c.decodeIfPresent([String: Int].self, forKey: .byAssignee) ?? [:]
self.byTenant = try c.decodeIfPresent([String: Int].self, forKey: .byTenant) ?? [:]
self.oldestReadyAgeSeconds = try c.decodeIfPresent(Double.self, forKey: .oldestReadyAgeSeconds)
}
/// "12 todo · 3 running · 5 blocked" formatted glance string. Skips
/// empty buckets and never includes archived. Returns an empty
/// string when there's nothing to show so callers can hide chrome.
public var glanceString: String {
let order: [(String, String)] = [
("todo", "todo"),
("ready", "ready"),
("running", "running"),
("blocked", "blocked"),
("done", "done")
]
let parts = order.compactMap { (key, label) -> String? in
guard let n = byStatus[key], n > 0 else { return nil }
return "\(n) \(label)"
}
return parts.joined(separator: " · ")
}
/// Active task count across the board (everything except archived
/// and done). Used as a badge on the sidebar / project tab.
public var activeCount: Int {
["triage", "todo", "ready", "running", "blocked"]
.map { byStatus[$0] ?? 0 }
.reduce(0, +)
}
}
@@ -2,11 +2,15 @@ import Foundation
/// One task from `hermes kanban list --json` (v0.12+).
///
/// Hermes ships a SQLite-backed task board under `~/.hermes/kanban.db`
/// multi-profile collaboration was reverted upstream while the
/// design is reworked, so Scarf v2.6 surfaces this as a read-only
/// list. Create / claim / dispatch / dependency-link UI is deferred
/// until upstream stabilizes.
/// Hermes ships a SQLite-backed task board under `~/.hermes/kanban.db`.
/// v2.6 surfaced this as a read-only list; v2.7.5 lifts it to a full
/// drag-and-drop board with the complete write surface (`create`,
/// `claim`, `complete`, `block`, `unblock`, `archive`, `assign`,
/// `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.
public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
public let id: String
public let title: String
@@ -24,6 +28,12 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
public let result: String?
public let skills: [String]
// v2.7.5 fields exposed by `kanban show --json` and `kanban watch`.
public let idempotencyKey: String?
public let lastHeartbeatAt: String?
public let maxRuntimeSeconds: Int?
public let currentRunId: Int?
public init(
id: String,
title: String,
@@ -39,7 +49,11 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
startedAt: String? = nil,
completedAt: String? = nil,
result: String? = nil,
skills: [String] = []
skills: [String] = [],
idempotencyKey: String? = nil,
lastHeartbeatAt: String? = nil,
maxRuntimeSeconds: Int? = nil,
currentRunId: Int? = nil
) {
self.id = id
self.title = title
@@ -56,6 +70,10 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
self.completedAt = completedAt
self.result = result
self.skills = skills
self.idempotencyKey = idempotencyKey
self.lastHeartbeatAt = lastHeartbeatAt
self.maxRuntimeSeconds = maxRuntimeSeconds
self.currentRunId = currentRunId
}
enum CodingKeys: String, CodingKey {
@@ -67,6 +85,10 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
case startedAt = "started_at"
case completedAt = "completed_at"
case result, skills
case idempotencyKey = "idempotency_key"
case lastHeartbeatAt = "last_heartbeat_at"
case maxRuntimeSeconds = "max_runtime_seconds"
case currentRunId = "current_run_id"
}
public init(from decoder: any Decoder) throws {
@@ -81,10 +103,109 @@ public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
self.workspaceKind = try c.decodeIfPresent(String.self, forKey: .workspaceKind)
self.workspacePath = try c.decodeIfPresent(String.self, forKey: .workspacePath)
self.createdBy = try c.decodeIfPresent(String.self, forKey: .createdBy)
self.createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt)
self.startedAt = try c.decodeIfPresent(String.self, forKey: .startedAt)
self.completedAt = try c.decodeIfPresent(String.self, forKey: .completedAt)
// Hermes emits timestamps as Unix integer seconds for tasks
// returned from `create`/`show`/`list` (its SQLite columns are
// INTEGER) but ISO-8601 strings in some other paths. Normalize
// both shapes into ISO-8601 strings so UI code only deals with
// one type.
self.createdAt = try Self.decodeFlexibleTimestamp(c, forKey: .createdAt)
self.startedAt = try Self.decodeFlexibleTimestamp(c, forKey: .startedAt)
self.completedAt = try Self.decodeFlexibleTimestamp(c, forKey: .completedAt)
self.result = try c.decodeIfPresent(String.self, forKey: .result)
self.skills = try c.decodeIfPresent([String].self, forKey: .skills) ?? []
self.idempotencyKey = try c.decodeIfPresent(String.self, forKey: .idempotencyKey)
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)
}
/// Decode a timestamp that may arrive as a Unix integer or an
/// ISO-8601 string. Returns the ISO-8601 string form so downstream
/// code only deals with one type.
static func decodeFlexibleTimestamp(
_ container: KeyedDecodingContainer<CodingKeys>,
forKey key: CodingKeys
) throws -> String? {
if !container.contains(key) { return nil }
// Try the SQLite-style integer first (most common from Hermes).
if let unix = try? container.decodeIfPresent(Double.self, forKey: key) {
let date = Date(timeIntervalSince1970: unix)
return Self.isoFormatter.string(from: date)
}
// Fall back to a plain string.
return try container.decodeIfPresent(String.self, forKey: key)
}
static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
}
// MARK: - Status enum (typed view of the wire string)
/// Typed mirror of Hermes's status enum. Models keep `status: String` for
/// forward compatibility with new statuses Hermes might add; UI code uses
/// `KanbanStatus.from(_:)` to map known values into typed categories and
/// fall back to `.unknown` for anything new.
public enum KanbanStatus: String, Sendable, CaseIterable, Identifiable {
case triage
case todo
case ready
case running
case blocked
case done
case archived
case unknown
public var id: String { rawValue }
public static func from(_ raw: String) -> KanbanStatus {
KanbanStatus(rawValue: raw.lowercased()) ?? .unknown
}
/// Coarse 5-column board grouping. `triage` is a column; `todo` and
/// `ready` collapse to one ("Up Next"); everything else maps 1:1.
/// `archived` lives outside the board (toggle).
public var boardColumn: KanbanBoardColumn {
switch self {
case .triage: return .triage
case .todo, .ready, .unknown: return .upNext
case .running: return .running
case .blocked: return .blocked
case .done: return .done
case .archived: return .archived
}
}
}
public enum KanbanBoardColumn: String, Sendable, CaseIterable, Identifiable {
case triage
case upNext
case running
case blocked
case done
case archived
public var id: String { rawValue }
public var displayName: String {
switch self {
case .triage: return "Triage"
case .upNext: return "Up Next"
case .running: return "Running"
case .blocked: return "Blocked"
case .done: return "Done"
case .archived: return "Archived"
}
}
/// Visible columns in the default board layout. `archived` appears
/// only when the "Show archived" toggle is on. `triage` is shown
/// only when the board has at least one triage task (collapsed
/// otherwise to keep the default layout focused).
public static let defaultVisible: [KanbanBoardColumn] = [
.triage, .upNext, .running, .blocked, .done
]
}
@@ -0,0 +1,60 @@
import Foundation
/// Output of `hermes kanban show <id> --json`. Wraps a task with its full
/// audit trail: comments + events + parent results. Loaded on-demand
/// when the user opens the inspector pane; the board itself only carries
/// the lightweight `HermesKanbanTask` rows.
public struct HermesKanbanTaskDetail: Sendable, Equatable, Codable {
public let task: HermesKanbanTask
public let comments: [HermesKanbanComment]
public let events: [HermesKanbanEvent]
/// Parent-task results keyed by parent task id. Hermes hands these
/// to the worker as upstream context; surfacing them in the
/// inspector is useful for understanding why a task started.
public let parentResults: [String: String]
public init(
task: HermesKanbanTask,
comments: [HermesKanbanComment] = [],
events: [HermesKanbanEvent] = [],
parentResults: [String: String] = [:]
) {
self.task = task
self.comments = comments
self.events = events
self.parentResults = parentResults
}
enum CodingKeys: String, CodingKey {
case task
case comments
case events
case parentResults = "parent_results"
}
public init(from decoder: any Decoder) throws {
// Hermes emits `kanban show --json` either as a nested
// {task: {...}, comments: [...], events: [...]} object or
// as a flat task object with extra `comments`/`events`
// keys at top level. Try the nested form first; fall
// back to top-level decode.
let container = try decoder.container(keyedBy: CodingKeys.self)
if let nested = try? container.decode(HermesKanbanTask.self, forKey: .task) {
self.task = nested
} else {
let single = try decoder.singleValueContainer()
self.task = try single.decode(HermesKanbanTask.self)
}
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)) ?? [:]
}
public func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(task, forKey: .task)
try c.encode(comments, forKey: .comments)
try c.encode(events, forKey: .events)
try c.encode(parentResults, forKey: .parentResults)
}
}
@@ -0,0 +1,120 @@
import Foundation
/// Swift-side parameter struct that maps 1:1 onto `hermes kanban create`
/// flags. Constructing one then handing it to `KanbanService.create`
/// keeps the CLI argv assembly in one place VMs build a `KanbanCreateRequest`
/// from form state and never assemble argv directly.
public struct KanbanCreateRequest: Sendable, Equatable {
public var title: String
public var body: String?
public var assignee: String?
public var parentIds: [String]
public var workspace: KanbanWorkspaceSpec?
public var tenant: String?
public var priority: Int?
public var triage: Bool
public var idempotencyKey: String?
public var maxRuntimeSeconds: Int?
public var createdBy: String?
public var skills: [String]
public init(
title: String,
body: String? = nil,
assignee: String? = nil,
parentIds: [String] = [],
workspace: KanbanWorkspaceSpec? = nil,
tenant: String? = nil,
priority: Int? = nil,
triage: Bool = false,
idempotencyKey: String? = nil,
maxRuntimeSeconds: Int? = nil,
createdBy: String? = nil,
skills: [String] = []
) {
self.title = title
self.body = body
self.assignee = assignee
self.parentIds = parentIds
self.workspace = workspace
self.tenant = tenant
self.priority = priority
self.triage = triage
self.idempotencyKey = idempotencyKey
self.maxRuntimeSeconds = maxRuntimeSeconds
self.createdBy = createdBy
self.skills = skills
}
/// Build the argv suffix this request maps to (everything after
/// `["kanban", "create"]`). Public for tests; consumers should
/// call `KanbanService.create` instead of building argv directly.
public func argv() -> [String] {
var args: [String] = []
if let body, !body.isEmpty {
args.append(contentsOf: ["--body", body])
}
if let assignee, !assignee.isEmpty {
args.append(contentsOf: ["--assignee", assignee])
}
for parent in parentIds {
args.append(contentsOf: ["--parent", parent])
}
if let workspace {
args.append(contentsOf: ["--workspace", workspace.cliValue])
}
if let tenant, !tenant.isEmpty {
args.append(contentsOf: ["--tenant", tenant])
}
if let priority {
args.append(contentsOf: ["--priority", String(priority)])
}
if triage {
args.append("--triage")
}
if let idempotencyKey, !idempotencyKey.isEmpty {
args.append(contentsOf: ["--idempotency-key", idempotencyKey])
}
if let maxRuntimeSeconds {
args.append(contentsOf: ["--max-runtime", "\(maxRuntimeSeconds)s"])
}
if let createdBy, !createdBy.isEmpty {
args.append(contentsOf: ["--created-by", createdBy])
}
for skill in skills {
args.append(contentsOf: ["--skill", skill])
}
args.append("--json")
// Title is the positional argument appended last so flags
// can't be confused for it.
args.append(title)
return args
}
}
/// Typed mirror of Hermes's `--workspace` flag. `scratch` and `worktree`
/// are bare strings on the wire; `dir:<absolute path>` is a colon-prefixed
/// path. We keep them typed in Swift so callers can't typo "scrach".
public enum KanbanWorkspaceSpec: Sendable, Equatable {
case scratch
case worktree
case directory(String)
public var cliValue: String {
switch self {
case .scratch: return "scratch"
case .worktree: return "worktree"
case .directory(let p): return "dir:\(p)"
}
}
/// "scratch" / "worktree" / "dir" the kind segment, suitable
/// for badge labels.
public var displayKind: String {
switch self {
case .scratch: return "scratch"
case .worktree: return "worktree"
case .directory: return "dir"
}
}
}
@@ -0,0 +1,52 @@
import Foundation
/// Errors thrown by `KanbanService`. Each case carries enough detail
/// to render a user-actionable message VMs surface these inline in
/// the board's error banner rather than blocking with alerts, since
/// kanban interactions are high-frequency.
public enum KanbanError: Error, LocalizedError, Sendable {
/// `hermes` binary couldn't be located (local) or the remote
/// `hermesBinaryHint` is unset (SSH).
case cliMissing
/// Subprocess returned non-zero exit. `stderr` may be empty if the
/// transport itself failed; carries a synthetic message in that case.
case nonZeroExit(code: Int32, stderr: String)
/// JSON decoding failed. Underlying `Error` is wrapped for
/// diagnostics; the user-facing message is generic.
case decoding(message: String)
/// `hermes kanban list --json` printed the literal string
/// "no matching tasks" instead of `[]`. Treated as a successful
/// empty result by callers but exposed here so VMs can distinguish
/// it from "transport error" if they want to.
case noMatchingTasks
/// Verb is not supported by this Hermes version (gated upstream
/// by `HermesCapabilities.hasKanban` + reasoned-about feature
/// drift). Carries the verb name + a hint.
case notSupported(verb: String, reason: String)
/// Disallowed transition the UI tried to perform (e.g. dragging a
/// `done` card back to `todo`). Caller surfaces a tooltip; this is
/// thrown only when a programmatic transition is requested instead
/// of being filtered out at the drag-target gate.
case forbiddenTransition(from: String, to: String, reason: String)
public var errorDescription: String? {
switch self {
case .cliMissing:
return "Hermes CLI couldn't be found. Install Hermes v0.12+ and ensure it's on your PATH."
case .nonZeroExit(let code, let stderr):
let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return "Hermes exited with code \(code)."
}
return trimmed
case .decoding(let message):
return "Couldn't decode Hermes output: \(message)"
case .noMatchingTasks:
return "No matching tasks."
case .notSupported(let verb, let reason):
return "`hermes kanban \(verb)` isn't available: \(reason)"
case .forbiddenTransition(let from, let to, let reason):
return "Can't move a \(from) task to \(to): \(reason)"
}
}
}
@@ -0,0 +1,146 @@
import Foundation
/// Filter options for `hermes kanban list --json`. Empty filter (default)
/// returns all non-archived tasks across all tenants.
public struct KanbanListFilter: Sendable, Equatable {
public var status: KanbanStatus?
public var assignee: String?
/// `nil` = all tenants. Empty string "untagged" (NULL tenant)
/// Hermes treats `--tenant ""` as "no tenant".
public var tenant: String?
public var includeArchived: Bool
/// Show only my profile's tasks (`--mine`).
public var mineOnly: Bool
public init(
status: KanbanStatus? = nil,
assignee: String? = nil,
tenant: String? = nil,
includeArchived: Bool = false,
mineOnly: Bool = false
) {
self.status = status
self.assignee = assignee
self.tenant = tenant
self.includeArchived = includeArchived
self.mineOnly = mineOnly
}
public static let all = KanbanListFilter()
/// Build the argv suffix after `["kanban", "list"]`.
public func argv() -> [String] {
var args: [String] = ["--json"]
if mineOnly {
args.append("--mine")
}
if let status, status != .unknown {
args.append(contentsOf: ["--status", status.rawValue])
}
if let assignee, !assignee.isEmpty {
args.append(contentsOf: ["--assignee", assignee])
}
if let tenant {
args.append(contentsOf: ["--tenant", tenant])
}
if includeArchived {
args.append("--archived")
}
return args
}
}
/// Filter options for `hermes kanban watch --json` (live event stream).
public struct KanbanWatchFilter: Sendable, Equatable {
public var assignee: String?
public var tenant: String?
public var kinds: [KanbanEventKind]
public var intervalSeconds: Double
public init(
assignee: String? = nil,
tenant: String? = nil,
kinds: [KanbanEventKind] = [],
intervalSeconds: Double = 0.5
) {
self.assignee = assignee
self.tenant = tenant
self.kinds = kinds
self.intervalSeconds = intervalSeconds
}
public static let all = KanbanWatchFilter()
public func argv() -> [String] {
var args: [String] = []
if let assignee, !assignee.isEmpty {
args.append(contentsOf: ["--assignee", assignee])
}
if let tenant, !tenant.isEmpty {
args.append(contentsOf: ["--tenant", tenant])
}
if !kinds.isEmpty {
let joined = kinds.map(\.rawValue).joined(separator: ",")
args.append(contentsOf: ["--kinds", joined])
}
if intervalSeconds > 0 && intervalSeconds != 0.5 {
args.append(contentsOf: ["--interval", String(format: "%.2f", intervalSeconds)])
}
return args
}
}
/// Summary of one `hermes kanban dispatch` pass. Used by the optional
/// "Dispatch now" button to show what happened.
public struct KanbanDispatchSummary: Sendable, Equatable, Codable {
public let promoted: Int
public let failed: Int
public let dryRun: Bool
public let perTask: [DispatchedTask]
public init(
promoted: Int = 0,
failed: Int = 0,
dryRun: Bool = false,
perTask: [DispatchedTask] = []
) {
self.promoted = promoted
self.failed = failed
self.dryRun = dryRun
self.perTask = perTask
}
public struct DispatchedTask: Sendable, Equatable, Codable, Identifiable {
public var id: String { taskId }
public let taskId: String
public let decision: String // "promoted" | "skipped" | "failed"
public let reason: String?
public init(taskId: String, decision: String, reason: String? = nil) {
self.taskId = taskId
self.decision = decision
self.reason = reason
}
enum CodingKeys: String, CodingKey {
case taskId = "task_id"
case decision
case reason
}
}
enum CodingKeys: String, CodingKey {
case promoted
case failed
case dryRun = "dry_run"
case perTask = "per_task"
}
public init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.promoted = try c.decodeIfPresent(Int.self, forKey: .promoted) ?? 0
self.failed = try c.decodeIfPresent(Int.self, forKey: .failed) ?? 0
self.dryRun = try c.decodeIfPresent(Bool.self, forKey: .dryRun) ?? false
self.perTask = try c.decodeIfPresent([DispatchedTask].self, forKey: .perTask) ?? []
}
}
@@ -8,9 +8,13 @@ import os
///
/// Scarf tracks Hermes feature releases by date-version + semver. v0.12 added
/// a dozen surfaces (Curator, Kanban, multimodal ACP, ...) and removed a few
/// (`flush_memories` aux task). UI that branches on these surfaces calls
/// the boolean accessors here so older Hermes installs degrade silently
/// instead of throwing on an unknown CLI subcommand.
/// (`flush_memories` aux task); v0.13 added Persistent Goals, ACP `/queue`,
/// Kanban diagnostics + recovery UX, Curator archive/prune, Google Chat (20th
/// platform), cross-platform allowlists, MCP SSE transport, Cron `no_agent`
/// mode, Web Tools per-capability backends, Profiles `--no-skills`, and a
/// handful of UX additions. UI that branches on these surfaces calls the
/// boolean accessors here so older Hermes installs degrade silently instead
/// of throwing on an unknown CLI subcommand.
///
/// Pure value type no side effects. The async detection lives in
/// `HermesCapabilitiesStore`.
@@ -45,8 +49,11 @@ public struct HermesCapabilities: Sendable, Equatable {
// MARK: - Capability flags
//
// Add a new flag here when Scarf gains UI that conditionally branches on
// a Hermes capability. Keep the comparison conservative: `>= 0.12.0`
// covers users still on the 0.12 line who haven't upgraded to 0.13 yet.
// a Hermes capability. Keep the comparison conservative: a flag introduced
// in v0.13.0 should gate on `>= 0.13.0`, not `>= 0.13.5`, so users on
// an early 0.13 patch still see the surface.
// MARK: v0.12 (v2026.4.30) flags
/// `hermes curator` autonomous skill maintenance (v0.12+).
public var hasCurator: Bool { atLeastSemver(0, 12, 0) }
@@ -96,9 +103,123 @@ public struct HermesCapabilities: Sendable, Equatable {
public var hasPromptCacheTTL: Bool { atLeastSemver(0, 12, 0) }
/// `redaction.enabled` is now off by default in v0.12 Scarf surfaces
/// the toggle so users can flip it back on.
/// the toggle so users can flip it back on. v0.13 flips the server-side
/// default back to ON; the toggle remains so users on v0.13 can opt out.
public var hasRedactionToggle: Bool { atLeastSemver(0, 12, 0) }
// MARK: v0.13 (v2026.5.7) flags
/// `/goal` slash command + Persistent Goals + Checkpoints v2 single-store
/// (v0.13+). Used by RichChatViewModel to add `/goal` to the
/// non-interruptive command list and to render the "Goal locked" pill in
/// the chat header.
public var hasGoals: Bool { atLeastSemver(0, 13, 0) }
/// `/queue` slash command in the ACP adapter (v0.13+). Queues a prompt
/// to run after the current turn completes without interrupting.
public var hasACPQueue: Bool { atLeastSemver(0, 13, 0) }
/// `/steer` runs as a regular prompt on idle ACP sessions (v0.13+). Pre-
/// v0.13 hosts silently no-op `/steer` when no turn is in flight; with
/// this flag on, Scarf can surface `/steer` even when the agent isn't
/// mid-turn without confusing UX.
public var hasACPSteerOnIdle: Bool { atLeastSemver(0, 13, 0) }
/// Kanban v0.13 reliability surface: hallucination gate on worker-created
/// cards, generic diagnostics engine, per-task `max_retries`, multiline
/// title/body create, `auto_blocked_reason` on blocked tasks, darwin
/// zombie detection. All read through the `kanban show` JSON surface.
public var hasKanbanDiagnostics: Bool { atLeastSemver(0, 13, 0) }
/// `hermes curator archive`, `prune`, and `list-archived` subcommands
/// (v0.13+). The synchronous manual `hermes curator run` lives behind
/// this flag too pre-v0.13 `run` returns immediately and the work
/// happens in the background.
public var hasCuratorArchive: Bool { atLeastSemver(0, 13, 0) }
/// Google Chat 20th messaging-gateway platform (v0.13+).
public var hasGoogleChatPlatform: Bool { atLeastSemver(0, 13, 0) }
/// Cross-platform allowlist keys: `allowed_channels` (Slack / Mattermost
/// / Google Chat), `allowed_chats` (Telegram / WhatsApp), `allowed_rooms`
/// (Matrix / DingTalk). Settable per platform in `config.yaml` (v0.13+).
public var hasGatewayAllowlists: Bool { atLeastSemver(0, 13, 0) }
/// `busy_ack_enabled` config to suppress per-message "agent is working"
/// acks across platforms (v0.13+).
public var hasGatewayBusyAckToggle: Bool { atLeastSemver(0, 13, 0) }
/// Per-platform `gateway_restart_notification` flag controls whether the
/// platform posts a "Gateway restarted" notice on boot (v0.13+).
public var hasGatewayRestartNotification: Bool { atLeastSemver(0, 13, 0) }
/// `hermes gateway list` cross-profile status verb (v0.13+). Lets Scarf
/// show which profile is currently running which platform.
public var hasGatewayList: Bool { atLeastSemver(0, 13, 0) }
/// MCP servers can use SSE transport (v0.13+). Adds an `sse_read_timeout`
/// knob alongside the existing stdio/pipe transports.
public var hasMCPSSETransport: Bool { atLeastSemver(0, 13, 0) }
/// Cron `--no-agent` mode for script-only watchdog jobs (v0.13+). Skips
/// the AI call entirely useful for keep-alive / periodic-check jobs.
public var hasCronNoAgent: Bool { atLeastSemver(0, 13, 0) }
/// Web Tools split into per-capability backend selection: `web_search`
/// and `web_extract` can now use distinct backends (v0.13+). SearXNG
/// joined as a search-only backend.
public var hasWebToolsBackendSplit: Bool { atLeastSemver(0, 13, 0) }
/// `hermes profile create --no-skills` flag for empty profiles (v0.13+).
public var hasProfileNoSkills: Bool { atLeastSemver(0, 13, 0) }
/// Context compression count surfaced in the status feed (v0.13+). Scarf
/// renders it next to the token count in the chat status bar.
public var hasContextCompressionCount: Bool { atLeastSemver(0, 13, 0) }
/// `/new` slash command accepts an optional session-name argument (v0.13+).
public var hasNewWithSessionName: Bool { atLeastSemver(0, 13, 0) }
/// `hermes update --yes` / `-y` skips interactive prompts (v0.13+). Used
/// by Scarf's "Update Hermes" affordance to run unattended.
public var hasUpdateNonInteractive: Bool { atLeastSemver(0, 13, 0) }
/// OpenRouter response caching toggle in `config.yaml` (v0.13+).
public var hasOpenRouterResponseCache: Bool { atLeastSemver(0, 13, 0) }
/// `image_gen.model` honored from `config.yaml` (v0.13+). Pre-v0.13 the
/// value was advertised but ignored at runtime.
public var hasImageGenModel: Bool { atLeastSemver(0, 13, 0) }
/// `display.language` config key for static-message translation: zh / ja /
/// de / es / fr / uk / tr (v0.13+).
public var hasDisplayLanguage: Bool { atLeastSemver(0, 13, 0) }
/// xAI Custom Voices voice cloning support (v0.13+). Exposed in Scarf
/// as a "Cloning supported" badge next to the xAI TTS provider entry.
public var hasXAIVoiceCloning: Bool { atLeastSemver(0, 13, 0) }
/// `video_analyze` tool native video understanding on Gemini and
/// compatible models (v0.13+). Hermes handles this transparently inside
/// the agent loop; Scarf has no UI surface yet, but the flag lets future
/// dashboards / activity views light up video-tool annotations.
public var hasVideoAnalyze: Bool { atLeastSemver(0, 13, 0) }
/// `transform_llm_output` plugin hook for shaping LLM output before the
/// conversation receives it (v0.13+). Plugin-author concern; Scarf's
/// PluginsView surfaces it as a documented hook in plugin metadata.
public var hasTransformLLMOutputHook: Bool { atLeastSemver(0, 13, 0) }
// MARK: Convenience predicates
/// Whether the connected host is on the v0.13 line or newer. Convenience
/// for UI copy that needs to switch on the v0.12 v0.13 boundary without
/// proxying through a feature-specific flag (e.g. "v0.13 features active"
/// badges, redaction default-state hints). Equivalent to any individual
/// v0.13 flag; prefer this when the call site isn't actually about a
/// specific feature.
public var isV013OrLater: Bool { atLeastSemver(0, 13, 0) }
private func atLeastSemver(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
guard let s = semver else { return false }
return s >= SemVer(major: major, minor: minor, patch: patch)
@@ -0,0 +1,503 @@
import Foundation
#if canImport(os)
import os
#endif
/// Async, transport-aware client for `hermes kanban `. Wraps every CLI
/// verb the v0.12 board exposes in a typed Swift surface.
///
/// **Concurrency.** This is a pure-I/O `actor` no UI state. View models
/// (`@MainActor` `@Observable`) hold a service reference and `await`
/// methods. Each public method serializes through the actor, but the
/// underlying CLI invocation runs on a `Task.detached(priority: .utility)`
/// so two concurrent reads from different VMs don't queue end-to-end on
/// a single thread.
///
/// **Hermes constraints surfaced as Swift constraints:**
/// - There is no `update` verb, so there's no `update(taskId:title:body:)`.
/// Mutations after create are state transitions (assign / claim /
/// complete / block / unblock / archive / comment) or new comments.
/// - The board is global with optional `tenant` namespacing pass a
/// tenant via `KanbanListFilter.tenant` for project-scoped views.
/// - The CLI prints `"no matching tasks"` instead of `[]` when nothing
/// matches a filter. We fold that into `[]` rather than throwing.
public actor KanbanService {
#if canImport(os)
private static let logger = Logger(subsystem: "com.scarf", category: "KanbanService")
#endif
private let context: ServerContext
public init(context: ServerContext) {
self.context = context
}
// MARK: - Reads
public func list(_ filter: KanbanListFilter = .all) async throws -> [HermesKanbanTask] {
var args = ["kanban", "list"]
args.append(contentsOf: filter.argv())
let (code, stdout, stderr) = await runHermes(args: args, timeout: 20)
try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "list")
// Empty filter on an empty board prints "no matching tasks" instead
// of `[]`. Treat as empty rather than letting the JSON decode fail.
if stdout.contains("no matching tasks") {
return []
}
guard let data = stdout.data(using: .utf8) else {
throw KanbanError.decoding(message: "non-UTF8 stdout")
}
do {
return try JSONDecoder().decode([HermesKanbanTask].self, from: data)
} catch {
throw KanbanError.decoding(message: error.localizedDescription)
}
}
public func show(taskId: String) async throws -> HermesKanbanTaskDetail {
let args = ["kanban", "show", taskId, "--json"]
let (code, stdout, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "show")
guard let data = stdout.data(using: .utf8) else {
throw KanbanError.decoding(message: "non-UTF8 stdout")
}
do {
return try JSONDecoder().decode(HermesKanbanTaskDetail.self, from: data)
} catch {
throw KanbanError.decoding(message: error.localizedDescription)
}
}
public func runs(taskId: String) async throws -> [HermesKanbanRun] {
let args = ["kanban", "runs", taskId, "--json"]
let (code, stdout, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "runs")
guard let data = stdout.data(using: .utf8) else {
throw KanbanError.decoding(message: "non-UTF8 stdout")
}
do {
return try JSONDecoder().decode([HermesKanbanRun].self, from: data)
} catch {
// Some Hermes builds emit a `{"runs": [...]}` envelope.
struct Wrapper: Decodable { let runs: [HermesKanbanRun] }
if let wrapped = try? JSONDecoder().decode(Wrapper.self, from: data) {
return wrapped.runs
}
throw KanbanError.decoding(message: error.localizedDescription)
}
}
public func stats() async throws -> HermesKanbanStats {
let args = ["kanban", "stats", "--json"]
let (code, stdout, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "stats")
guard let data = stdout.data(using: .utf8) else {
throw KanbanError.decoding(message: "non-UTF8 stdout")
}
do {
return try JSONDecoder().decode(HermesKanbanStats.self, from: data)
} catch {
throw KanbanError.decoding(message: error.localizedDescription)
}
}
/// Print the captured worker log for a task `hermes kanban log
/// <id>`. Returns whatever `$HERMES_HOME/kanban/logs/<id>` contains.
/// Empty string when the worker hasn't written anything yet (or
/// the task has never been claimed). Pass `tailBytes` to cap the
/// returned size (useful when polling at high cadence).
public func log(taskId: String, tailBytes: Int? = nil) async throws -> String {
var args = ["kanban", "log"]
if let tailBytes {
args.append(contentsOf: ["--tail", String(tailBytes)])
}
args.append(taskId)
let (code, stdout, stderr) = await runHermes(args: args, timeout: 15)
// `kanban log` exits with code 0 even when no log file exists
// it just prints "No log file." or similar to stdout. Tolerate
// non-zero codes too: some Hermes versions emit a warning to
// stderr and exit 1 when the log dir is missing.
if code != 0 {
let combined = stderr.isEmpty ? stdout : stderr
// Treat "no log" sentinels as empty rather than as errors.
let lower = combined.lowercased()
if lower.contains("no log") || lower.contains("not found") {
return ""
}
throw KanbanError.nonZeroExit(code: code, stderr: combined)
}
return stdout
}
public func assignees() async throws -> [HermesKanbanAssignee] {
// The `assignees` verb doesn't take `--json` consistently across
// 0.12.x pass it anyway and fall back to a tab-delimited parse
// if Hermes printed a human table.
let args = ["kanban", "assignees"]
let (code, stdout, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "assignees")
if let data = stdout.data(using: .utf8),
let arr = try? JSONDecoder().decode([HermesKanbanAssignee].self, from: data) {
return arr
}
// Fallback: each non-blank line of the form
// "<profile>\t<active>\t<total>"
// OR "<profile> <active> <total>" (whitespace separated).
return parseAssigneeTable(stdout)
}
private nonisolated func parseAssigneeTable(_ text: String) -> [HermesKanbanAssignee] {
var result: [HermesKanbanAssignee] = []
// Profile names follow the same convention as `hermes -p <name>`
// letters, digits, hyphen, underscore. Anything else is
// chrome (header rows, Rich box-drawing, fallback messages
// like "(no assignees create a profile with `hermes -p
// <name> setup`)") and gets skipped.
for raw in text.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
if line.isEmpty { continue }
// Skip the column header row.
if line.lowercased().hasPrefix("profile") { continue }
// Skip the empty-state sentinel without trying to tokenize
// it (used to leak "(no" into the picker).
if line.lowercased().contains("no assignees") { continue }
// Skip Rich box-drawing separators (only + whitespace).
if line.unicodeScalars.allSatisfy({ $0.value == 0x2500 || $0.properties.isWhitespace }) {
continue
}
// Strip the active marker `` (U+25C6) some `hermes`
// commands prefix to the active profile.
var working = line
if working.hasPrefix("") {
working = String(working.dropFirst()).trimmingCharacters(in: .whitespaces)
}
let parts = working
.split(whereSeparator: { $0 == "\t" || $0 == " " })
.map { String($0) }
.filter { !$0.isEmpty }
guard let profile = parts.first else { continue }
// Validate: must look like a real profile slug, not a word
// out of an English sentence.
guard profile.range(of: "^[a-zA-Z0-9_-]+$", options: .regularExpression) != nil else {
continue
}
let active = (parts.count > 1) ? Int(parts[1]) ?? 0 : 0
let total = (parts.count > 2) ? Int(parts[2]) ?? 0 : active
result.append(HermesKanbanAssignee(profile: profile, activeCount: active, totalCount: total))
}
return result
}
// MARK: - Writes
public func create(_ request: KanbanCreateRequest) async throws -> HermesKanbanTask {
var args = ["kanban", "create"]
args.append(contentsOf: request.argv())
let (code, stdout, stderr) = await runHermes(args: args, timeout: 30)
try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "create")
guard let data = stdout.data(using: .utf8) else {
throw KanbanError.decoding(message: "non-UTF8 stdout")
}
// Hermes returns the full task object when --json is set.
do {
return try JSONDecoder().decode(HermesKanbanTask.self, from: data)
} catch {
// Some builds emit just the new id on stdout. Fall back to a
// follow-up `show` so the caller always gets a typed task.
let trimmed = stdout.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !trimmed.contains("\n"), !trimmed.contains("{") {
let detail = try await show(taskId: trimmed)
return detail.task
}
throw KanbanError.decoding(message: error.localizedDescription)
}
}
public func assign(taskId: String, profile: String?) async throws {
let target = (profile?.isEmpty ?? true) ? "none" : profile!
let args = ["kanban", "assign", taskId, target]
let (code, _, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "assign")
}
@discardableResult
public func claim(taskId: String, ttlSeconds: Int = 900) async throws -> String {
let args = ["kanban", "claim", taskId, "--ttl", String(ttlSeconds)]
let (code, stdout, stderr) = await runHermes(args: args, timeout: 20)
try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "claim")
// claim prints the resolved workspace path on stdout.
return stdout.trimmingCharacters(in: .whitespacesAndNewlines)
}
public func comment(taskId: String, text: String, author: String? = nil) async throws {
var args = ["kanban", "comment"]
if let author, !author.isEmpty {
args.append(contentsOf: ["--author", author])
}
args.append(taskId)
args.append(text)
let (code, _, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "comment")
}
public func complete(
taskIds: [String],
result: String? = nil,
summary: String? = nil,
metadataJSON: String? = nil
) async throws {
guard !taskIds.isEmpty else { return }
var args = ["kanban", "complete"]
if let result, !result.isEmpty {
args.append(contentsOf: ["--result", result])
}
if let summary, !summary.isEmpty {
args.append(contentsOf: ["--summary", summary])
}
if let metadataJSON, !metadataJSON.isEmpty {
args.append(contentsOf: ["--metadata", metadataJSON])
}
args.append(contentsOf: taskIds)
let (code, _, stderr) = await runHermes(args: args, timeout: 30)
try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "complete")
}
public func block(taskId: String, reason: String? = nil) async throws {
var args = ["kanban", "block", taskId]
if let reason, !reason.trimmingCharacters(in: .whitespaces).isEmpty {
// Hermes accepts free-form trailing words as the reason.
args.append(contentsOf: reason.split(separator: " ").map(String.init))
}
let (code, _, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "block")
}
public func unblock(taskIds: [String]) async throws {
guard !taskIds.isEmpty else { return }
var args = ["kanban", "unblock"]
args.append(contentsOf: taskIds)
let (code, _, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "unblock")
}
public func archive(taskIds: [String]) async throws {
guard !taskIds.isEmpty else { return }
var args = ["kanban", "archive"]
args.append(contentsOf: taskIds)
let (code, _, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "archive")
}
@discardableResult
public func dispatch(maxTasks: Int? = nil, dryRun: Bool = false) async throws -> KanbanDispatchSummary {
var args = ["kanban", "dispatch", "--json"]
if dryRun { args.append("--dry-run") }
if let maxTasks { args.append(contentsOf: ["--max", String(maxTasks)]) }
let (code, stdout, stderr) = await runHermes(args: args, timeout: 60)
try ensureSuccess(code: code, stdout: stdout, stderr: stderr, verb: "dispatch")
guard let data = stdout.data(using: .utf8) else {
throw KanbanError.decoding(message: "non-UTF8 stdout")
}
do {
return try JSONDecoder().decode(KanbanDispatchSummary.self, from: data)
} catch {
// Older builds may print human output. Return a stub summary.
return KanbanDispatchSummary(promoted: 0, failed: 0, dryRun: dryRun, perTask: [])
}
}
public func link(parent: String, child: String) async throws {
let args = ["kanban", "link", parent, child]
let (code, _, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "link")
}
public func unlink(parent: String, child: String) async throws {
let args = ["kanban", "unlink", parent, child]
let (code, _, stderr) = await runHermes(args: args, timeout: 15)
try ensureSuccess(code: code, stdout: "", stderr: stderr, verb: "unlink")
}
// MARK: - Drag-drop transition mapper
/// Map a board-level column transition to the right Hermes verb call.
/// Returns the list of CLI invocations the caller should run in order.
/// Pure no I/O. Called from VMs to build an action plan; the VM
/// then either prompts the user (e.g. for a block reason) or calls
/// the matching `KanbanService` methods.
///
/// Forbidden transitions throw `KanbanError.forbiddenTransition`
/// rather than returning an empty plan, so callers can surface the
/// reason to the user.
public nonisolated static func plan(
for transition: KanbanTransition
) throws -> KanbanTransitionPlan {
let from = transition.from
let to = transition.to
if from == to {
return KanbanTransitionPlan(steps: [])
}
// "Done" is terminal Hermes has no `reopen` verb.
if from == .done {
throw KanbanError.forbiddenTransition(
from: from.displayName,
to: to.displayName,
reason: "Done is terminal — create a follow-up task to continue work."
)
}
// Triage promotion isn't a CLI verb in v0.12 it happens via
// a specifier worker. UI should disallow drag from triage.
if from == .triage {
throw KanbanError.forbiddenTransition(
from: from.displayName,
to: to.displayName,
reason: "Triage tasks are promoted by a specifier agent. Use the specifier worker pipeline."
)
}
// Archive lives outside the board only via context menu.
if to == .archived {
return KanbanTransitionPlan(steps: [.archive])
}
switch (from, to) {
case (.upNext, .running):
return KanbanTransitionPlan(steps: [.dispatch])
case (.upNext, .blocked):
return KanbanTransitionPlan(steps: [.block(reasonRequired: true)])
case (.upNext, .done):
// Direct tododone is unusual but allowed (manual checkoff).
return KanbanTransitionPlan(steps: [.complete(resultRequired: false)])
case (.running, .blocked):
return KanbanTransitionPlan(steps: [.block(reasonRequired: true)])
case (.running, .done):
return KanbanTransitionPlan(steps: [.complete(resultRequired: false)])
case (.running, .upNext):
// Release back to ready no direct verb. Closest is unblock,
// which only works for blocked tasks. Forbid for now.
throw KanbanError.forbiddenTransition(
from: from.displayName,
to: to.displayName,
reason: "Use the inspector's Comment + Unassign actions to hand a running task back."
)
case (.blocked, .upNext):
return KanbanTransitionPlan(steps: [.unblock])
case (.blocked, .running):
return KanbanTransitionPlan(steps: [.unblock, .dispatch])
case (.blocked, .done):
return KanbanTransitionPlan(steps: [.unblock, .complete(resultRequired: false)])
default:
throw KanbanError.forbiddenTransition(
from: from.displayName,
to: to.displayName,
reason: "No CLI path exists for this transition."
)
}
}
// 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 transport = context.makeTransport()
let executable = context.paths.hermesBinary
do {
let result = try transport.runProcess(
executable: executable,
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)
}
}.value
}
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 KanbanError.cliMissing
}
let combined = stderr.isEmpty ? stdout : stderr
#if canImport(os)
Self.logger.warning("kanban \(verb) exit=\(code, privacy: .public) stderr=\(combined, privacy: .public)")
#endif
throw KanbanError.nonZeroExit(code: code, stderr: combined)
}
}
// MARK: - Transition planning
/// Source/destination columns for a single drag-drop. Comparable to
/// SwiftUI's `.dropDestination` payload but kept Sendable + Hashable
/// so it can also drive iOS context-menu "Move to" actions.
public struct KanbanTransition: Sendable, Hashable {
public let from: KanbanBoardColumn
public let to: KanbanBoardColumn
public init(from: KanbanBoardColumn, to: KanbanBoardColumn) {
self.from = from
self.to = to
}
}
/// One Hermes verb call produced by `KanbanService.plan(for:)`. The VM
/// resolves any user-input requirements (block reason, completion
/// result) before invoking the corresponding actor method.
///
/// **Why `.dispatch` and not `.claim`.** `hermes kanban claim` reserves
/// a task atomically and prints the workspace path but it's a
/// "manual alternative to the dispatcher" that assumes the caller will
/// spawn the worker themselves. Scarf is not a worker host; the
/// gateway-running dispatcher is. Calling `claim` from drag-drop
/// flipped status to `running` without spawning any work, and the
/// task got reclaimed (stale_lock) ~15 minutes later. The right
/// verb is `dispatch`, which causes the dispatcher to spawn workers
/// for every assigned `ready` task in one pass.
public enum KanbanTransitionStep: Sendable, Equatable {
/// Force a dispatcher pass so the gateway spawns workers for
/// assigned `ready` tasks. Requires the task have an assignee
/// the dispatcher silently skips unassigned tasks.
case dispatch
case unblock
case block(reasonRequired: Bool)
case complete(resultRequired: Bool)
case archive
}
public struct KanbanTransitionPlan: Sendable, Equatable {
public let steps: [KanbanTransitionStep]
public init(steps: [KanbanTransitionStep]) {
self.steps = steps
}
public var requiresBlockReason: Bool {
steps.contains { if case .block(true) = $0 { return true } else { return false } }
}
public var requiresCompleteResult: Bool {
steps.contains { if case .complete(true) = $0 { return true } else { return false } }
}
}
@@ -0,0 +1,39 @@
import Foundation
/// Cross-platform read-only helper for `<project>/.scarf/manifest.json`'s
/// `kanbanTenant` field. The full `ProjectTemplateManifest` Codable
/// type lives in the Mac app target (with all the install/export
/// machinery); iOS doesn't link it, so this lightweight projection
/// gives both targets a way to read just the tenant slug without
/// duplicating the entire manifest model.
public struct KanbanTenantReader: Sendable {
public let context: ServerContext
public nonisolated init(context: ServerContext) {
self.context = context
}
/// Read the project's Kanban tenant slug, or `nil` if the manifest
/// doesn't exist or doesn't carry one. Cheap single JSON parse
/// of a tiny projection.
public nonisolated func tenant(forProjectPath projectPath: String) -> String? {
let manifestPath = projectPath + "/.scarf/manifest.json"
let transport = context.makeTransport()
guard transport.fileExists(manifestPath),
let data = try? transport.readFile(manifestPath)
else {
return nil
}
return Self.tenant(fromManifestData: data)
}
/// Pure-input variant for tests + tooling that already have the
/// JSON bytes in hand. Returns `nil` when the bytes don't decode
/// or the field isn't present.
public nonisolated static func tenant(fromManifestData data: Data) -> String? {
struct Projection: Decodable {
let kanbanTenant: String?
}
return (try? JSONDecoder().decode(Projection.self, from: data))?.kanbanTenant
}
}
@@ -133,12 +133,20 @@ public struct SkillSnapshotDiff: Sendable, Equatable {
}
/// Compact label for the "What's New" pill, e.g.
/// "2 new, 4 updated since you last looked" or "1 new skill".
/// "2 new, 4 changed since you last looked" or "1 new skill".
///
/// Wording note (issue #78): we used to say "X updated since you
/// last looked" but the same screen also surfaces an "Updates"
/// sub-tab driven by `hermes skills check` (skills with newer
/// **upstream** versions available). Two surfaces with the word
/// "update" meaning two different things read as a contradiction
/// to the user. "Changed" describes the local file delta without
/// colliding with upstream-update vocabulary.
public var label: String {
switch (newCount, updatedCount) {
case (let n, 0): return n == 1 ? "1 new skill since you last looked" : "\(n) new skills since you last looked"
case (0, let u): return u == 1 ? "1 updated skill since you last looked" : "\(u) updated skills since you last looked"
default: return "\(newCount) new, \(updatedCount) updated since you last looked"
case (0, let u): return u == 1 ? "1 changed skill since you last looked" : "\(u) changed skills since you last looked"
default: return "\(newCount) new, \(updatedCount) changed since you last looked"
}
}
}
@@ -25,6 +25,63 @@ public struct LocalTransport: ServerTransport {
self.contextID = contextID
}
// MARK: - Environment enrichment
/// Injection point for local-subprocess environment enrichment.
/// Mirrors `SSHTransport.environmentEnricher` the Mac app wires
/// this at launch to `HermesFileService.enrichedEnvironment()`,
/// which probes the user's login shell for PATH + credential env
/// vars. Without it, GUI-launched Scarf hands subprocesses a
/// stripped `/usr/bin:/bin:/usr/sbin:/sbin` PATH and child
/// `hermes` invocations from inside spawned workers fail with
/// `executable not found on PATH`.
///
/// Set once at app launch (startup is single-threaded). Tests may
/// inject a stub. iOS leaves this `nil` because LocalTransport
/// doesn't run subprocesses there.
nonisolated(unsafe) public static var environmentEnricher: (@Sendable () -> [String: String])?
/// Build the environment dict for a single subprocess. Process
/// env wins for keys it has; the enricher fills gaps + always
/// owns PATH (which is the whole point of running it). The
/// executable's parent directory is appended as a final fallback
/// so `runProcess` works even before the enricher has been wired
/// (during very early startup, in tests, etc.).
nonisolated static func subprocessEnvironment(forExecutable executable: String) -> [String: String] {
var env = ProcessInfo.processInfo.environment
if let enricher = Self.environmentEnricher {
let extra = enricher()
for (key, value) in extra where !value.isEmpty {
if key == "PATH" {
// Enricher always wins for PATH that's the
// whole reason the enricher exists. The GUI
// process PATH is the broken thing we're
// replacing.
env[key] = value
} else if (env[key] ?? "").isEmpty {
// For other keys (credential env, locale, etc.)
// an explicit non-empty value in the GUI
// environment wins; an empty or absent value
// gets filled by the shell-harvested copy.
env[key] = value
}
}
}
// Always make sure the executable's own directory is on PATH
// covers the case where the enricher hasn't been wired (tests,
// pre-launch helpers) but a child process still tries to spawn
// its sibling tools by bare name.
let dir = (executable as NSString).deletingLastPathComponent
if !dir.isEmpty {
let currentPATH = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
let parts = currentPATH.split(separator: ":").map(String.init)
if !parts.contains(dir) {
env["PATH"] = "\(dir):\(currentPATH)"
}
}
return env
}
// MARK: - Files
public func readFile(_ path: String) throws -> Data {
@@ -116,6 +173,17 @@ public struct LocalTransport: ServerTransport {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
// Hand subprocesses an environment that includes the user's
// login-shell PATH. Without this, `hermes` (pipx-installed at
// `~/.local/bin/hermes`) ends up running with macOS's GUI
// launch-services PATH (`/usr/bin:/bin:/usr/sbin:/sbin`), and
// when Hermes itself shells out to spawn a worker (e.g. the
// kanban dispatcher invoking `hermes` by name from a Python
// subprocess), it returns "executable not found on PATH" and
// the run records `outcome=spawn_failed`. Mirrors the SSH
// transport's environmentEnricher hook and is wired by
// `scarfApp.swift` at launch.
proc.environment = Self.subprocessEnvironment(forExecutable: executable)
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe = Pipe()
@@ -49,6 +49,34 @@ public enum SSHScriptRunner {
}
}
/// Lock-protected `Data` accumulator used by the stdout/stderr
/// readability handlers below. Two of these per script run, one per
/// stream. `@unchecked Sendable` because mutation goes through the
/// `NSLock` Swift can't see that.
///
/// Why this exists (issue #77): the previous implementation read
/// stdout/stderr via `readToEnd()` *after* the subprocess exited.
/// On macOS pipes default to a 1664 KB kernel buffer; once
/// `sqlite3 -json` writes more than that, the SSH client back-
/// pressures over the wire, the remote sqlite3 blocks, the script
/// never finishes, the 30 s timeout fires, and the caller sees
/// "Script timed out" + an empty result set. v2.7's
/// `sessionListSnapshot(limit: 500)` crossed that threshold for
/// any user with ~150+ sessions. Draining concurrently with
/// `readabilityHandler` removes the back-pressure.
private final class LockedData: @unchecked Sendable {
private let lock = NSLock()
private var buf = Data()
func append(_ chunk: Data) {
lock.lock(); defer { lock.unlock() }
buf.append(chunk)
}
func snapshot() -> Data {
lock.lock(); defer { lock.unlock() }
return buf
}
}
public enum Outcome: Sendable {
/// Couldn't even reach the remote (process spawn failed,
/// timeout before any output, network refused). Carries the
@@ -151,9 +179,35 @@ public enum SSHScriptRunner {
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
// Drain stdout/stderr concurrently with the running process
// see the LockedData docstring above for the issue-#77
// back-story. Without these handlers a >64 KB script output
// wedges the pipe + ssh + remote sqlite3 chain and the only
// visible symptom is a timeout.
let outBuf = LockedData()
let errBuf = LockedData()
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
handle.readabilityHandler = nil
} else {
outBuf.append(chunk)
}
}
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
handle.readabilityHandler = nil
} else {
errBuf.append(chunk)
}
}
do {
try proc.run()
} catch {
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
return .connectFailure("Failed to launch ssh: \(error.localizedDescription)")
}
@@ -172,6 +226,8 @@ public enum SSHScriptRunner {
// belt-and-suspenders.
if cancelFlag.isCancelled || Task.isCancelled {
proc.terminate()
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
return .connectFailure("Script cancelled")
@@ -180,6 +236,8 @@ public enum SSHScriptRunner {
}
if proc.isRunning {
proc.terminate()
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
// Pipe fds leak otherwise closing on the timeout branch
// matches the success-path discipline (see CLAUDE.md
// "Always close both fileHandleForReading and
@@ -188,8 +246,14 @@ public enum SSHScriptRunner {
try? stderrPipe.fileHandleForReading.close()
return .connectFailure("Script timed out after \(Int(timeout))s")
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
// Detach the readabilityHandlers and capture whatever the
// accumulator has. The handler may have already seen EOF
// (`chunk.isEmpty`) and self-cleared, but assigning nil is
// idempotent and guards against a late tick from the queue.
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
let out = outBuf.snapshot()
let err = errBuf.snapshot()
// Best-effort fd close Pipe leaks fd's otherwise.
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
@@ -213,15 +277,43 @@ public enum SSHScriptRunner {
let stderrPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
// Drain concurrently same pipe-buffer fix as runOverSSH.
// Local scripts can also blow past the 1664 KB pipe buffer
// (e.g. local `sqlite3 -json` over a fat result set) and
// would wedge in exactly the same way.
let outBuf = LockedData()
let errBuf = LockedData()
stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
handle.readabilityHandler = nil
} else {
outBuf.append(chunk)
}
}
stderrPipe.fileHandleForReading.readabilityHandler = { handle in
let chunk = handle.availableData
if chunk.isEmpty {
handle.readabilityHandler = nil
} else {
errBuf.append(chunk)
}
}
do {
try proc.run()
} catch {
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
return .connectFailure("Failed to launch /bin/sh: \(error.localizedDescription)")
}
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
if cancelFlag.isCancelled || Task.isCancelled {
proc.terminate()
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
return .connectFailure("Script cancelled")
@@ -230,12 +322,16 @@ public enum SSHScriptRunner {
}
if proc.isRunning {
proc.terminate()
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
return .connectFailure("Script timed out after \(Int(timeout))s")
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
stdoutPipe.fileHandleForReading.readabilityHandler = nil
stderrPipe.fileHandleForReading.readabilityHandler = nil
let out = outBuf.snapshot()
let err = errBuf.snapshot()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
return .completed(
@@ -29,17 +29,24 @@ public final class IOSCronViewModel {
let ctx = context
let path = ctx.paths.cronJobsJSON
let result: Result<CronJobsFile, Error> = await Task.detached {
do {
guard let data = ctx.readData(path) else {
throw LoadError.missingFile(path: path)
// v2.7 instrumented for parity with Mac `cron.load`. iOS
// Cron load is a single SFTP read of jobs.json so should be
// snappy on most remotes; this measure point makes the cost
// visible in ScarfMon traces alongside the rest of the iOS
// load paths.
let result: Result<CronJobsFile, Error> = await ScarfMon.measureAsync(.diskIO, "ios.cron.load") {
await Task.detached {
do {
guard let data = ctx.readData(path) else {
throw LoadError.missingFile(path: path)
}
let decoded = try JSONDecoder().decode(CronJobsFile.self, from: data)
return .success(decoded)
} catch {
return Result<CronJobsFile, Error>.failure(error)
}
let decoded = try JSONDecoder().decode(CronJobsFile.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}.value
}.value
}
switch result {
case .success(let file):
@@ -96,15 +96,24 @@ public final class IOSMemoryViewModel {
// Run the file read on a detached task `readTextThrowing`
// blocks on transport I/O, and we don't want the MainActor
// hanging during a remote SFTP fetch.
// v2.7 instrumented for parity with Mac `memory.load`.
// iOS path is one SFTP read per Memory tab open (per kind:
// memory / user / soul); the bytes counter shows payload
// size alongside latency.
let ctx = context
let path = kind.path(on: context)
let result: Result<String?, Error> = await Task.detached {
do {
return .success(try ctx.readTextThrowing(path))
} catch {
return .failure(error)
}
}.value
let result: Result<String?, Error> = await ScarfMon.measureAsync(.diskIO, "ios.memory.load") {
await Task.detached {
do {
return Result<String?, Error>.success(try ctx.readTextThrowing(path))
} catch {
return Result<String?, Error>.failure(error)
}
}.value
}
if case .success(.some(let loaded)) = result {
ScarfMon.event(.diskIO, "ios.memory.load.bytes", count: 0, bytes: loaded.utf8.count)
}
switch result {
case .success(.some(let loaded)):
@@ -49,6 +49,18 @@ public final class SkillsViewModel {
public var hubMessage: String?
public var hubSource: String = "all"
/// Last successful `browseHub` payload, kept around so that the
/// "All Sources" search path can filter client-side (issue #79).
/// `hermes skills search` with no `--source` flag routes through
/// the centralized `hermes-index` source which can miss skills
/// that are visible in browse we'd rather give the user the
/// canonical "type-to-filter" UX than chase Hermes's index gaps.
/// Source-specific searches still shell out to the CLI for full
/// upstream semantics. Setter is `internal` so the in-tree test
/// suite can seed the cache without invoking the live CLI;
/// out-of-module callers can still only read.
public internal(set) var lastBrowseResults: [HermesHubSkill] = []
public let hubSources = ["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]
public var filteredCategories: [HermesSkillCategory] {
@@ -260,14 +272,34 @@ public final class SkillsViewModel {
browseHub()
return
}
let source = hubSource
let query = hubQuery
// Issue #79 for "All Sources", filter the cached browse list
// client-side instead of shelling out. Hermes's all-source
// search routes through its centralized index which can miss
// skills (e.g. honcho) that browse surfaces from non-indexed
// registries. Specific-source searches keep the CLI path so
// power users still get full upstream search semantics.
if source == "all" {
if lastBrowseResults.isEmpty {
// No cache yet kick off a browse, then filter on
// completion. The chained call lets the user type a
// query before ever clicking Browse.
browseHubThenFilter(query: query)
} else {
// Pure in-memory filter runs synchronously on the
// calling actor (UI invocations are already on
// MainActor) so the user sees the narrowed list
// without a render-tick gap.
applyClientSideFilter(query: query, against: lastBrowseResults)
}
return
}
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
let source = hubSource
let query = hubQuery
Task.detached { [weak self] in
var args = ["skills", "search", query, "--limit", "40"]
if source != "all" { args += ["--source", source] }
let args = ["skills", "search", query, "--limit", "40", "--source", source]
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
let parsed = HermesSkillsHubParser.parseHubList(result.output)
await self?.finishBrowse(
@@ -279,6 +311,66 @@ public final class SkillsViewModel {
}
}
/// Run a browse fetch and then immediately apply a client-side
/// filter. Used by `searchHub` when the user types into search
/// before any browse has cached results.
private func browseHubThenFilter(query: String) {
isHubLoading = true
let bin = context.paths.hermesBinary
let xport = transport
Task.detached { [weak self] in
let args = ["skills", "browse", "--size", "40"]
let result = Self.runHermes(executable: bin, args: args, transport: xport, timeout: 30)
let parsed = HermesSkillsHubParser.parseHubList(result.output)
await self?.finishBrowseThenFilter(
browseResults: parsed,
query: query,
exitCode: result.exitCode,
rawOutput: result.output
)
}
}
@MainActor
private func finishBrowseThenFilter(
browseResults: [HermesHubSkill],
query: String,
exitCode: Int32,
rawOutput: String
) async {
if exitCode == 0 {
lastBrowseResults = browseResults
applyClientSideFilter(query: query, against: browseResults)
} else {
// Surface the underlying browse failure rather than a
// blank "no matches" state the user typed a query, not
// a browse request, but the cache was empty so we tried.
isHubLoading = false
hubResults = []
let detail = Self.firstSignificantLine(rawOutput)
hubMessage = detail.isEmpty
? "Search failed (exit \(exitCode))"
: "Search failed: \(detail)"
}
}
private func applyClientSideFilter(query: String, against pool: [HermesHubSkill]) {
let needle = query.trimmingCharacters(in: .whitespaces)
let matches: [HermesHubSkill]
if needle.isEmpty {
matches = pool
} else {
matches = pool.filter { skill in
skill.name.localizedCaseInsensitiveContains(needle)
|| skill.description.localizedCaseInsensitiveContains(needle)
|| skill.identifier.localizedCaseInsensitiveContains(needle)
}
}
isHubLoading = false
hubResults = matches
hubMessage = matches.isEmpty ? "No matches" : nil
}
public func installHubSkill(_ skill: HermesHubSkill) {
isHubLoading = true
hubMessage = "Installing \(skill.identifier)"
@@ -421,6 +513,13 @@ public final class SkillsViewModel {
) async {
isHubLoading = false
hubResults = results
// Cache the fresh browse payload so the "All Sources" search
// path can filter client-side (issue #79). Search results are
// not cached they're already filtered by the user's query
// and would poison the filter pool.
if !isSearch && exitCode == 0 {
lastBrowseResults = results
}
if results.isEmpty {
if exitCode == 0 {
hubMessage = isSearch ? "No matches" : "No results"
@@ -9,6 +9,13 @@ import Foundation
// MARK: - Version line parsing
@Test func parseV013ReleaseLine() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.5.7)")
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 13, patch: 0))
#expect(caps.dateVersion == HermesCapabilities.DateVersion(year: 2026, month: 5, day: 7))
#expect(caps.detected)
}
@Test func parseV012ReleaseLine() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
#expect(caps.semver == HermesCapabilities.SemVer(major: 0, minor: 12, patch: 0))
@@ -75,8 +82,42 @@ import Foundation
// MARK: - Capability flags
@Test func v013FlagsAllOn() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.5.7)")
// v0.12 surfaces remain on.
#expect(caps.hasCurator)
#expect(caps.hasKanban)
#expect(caps.hasACPImagePrompts)
#expect(!caps.hasFlushMemoriesAux)
// v0.13 surfaces light up.
#expect(caps.hasGoals)
#expect(caps.hasACPQueue)
#expect(caps.hasACPSteerOnIdle)
#expect(caps.hasKanbanDiagnostics)
#expect(caps.hasCuratorArchive)
#expect(caps.hasGoogleChatPlatform)
#expect(caps.hasGatewayAllowlists)
#expect(caps.hasGatewayBusyAckToggle)
#expect(caps.hasGatewayRestartNotification)
#expect(caps.hasGatewayList)
#expect(caps.hasMCPSSETransport)
#expect(caps.hasCronNoAgent)
#expect(caps.hasWebToolsBackendSplit)
#expect(caps.hasProfileNoSkills)
#expect(caps.hasContextCompressionCount)
#expect(caps.hasNewWithSessionName)
#expect(caps.hasUpdateNonInteractive)
#expect(caps.hasOpenRouterResponseCache)
#expect(caps.hasImageGenModel)
#expect(caps.hasDisplayLanguage)
#expect(caps.hasXAIVoiceCloning)
#expect(caps.hasVideoAnalyze)
#expect(caps.hasTransformLLMOutputHook)
}
@Test func v012FlagsAllOn() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
// v0.12 surfaces on.
#expect(caps.hasCurator)
#expect(caps.hasFallbackCommand)
#expect(caps.hasKanban)
@@ -94,6 +135,22 @@ import Foundation
#expect(caps.hasRedactionToggle)
// flush_memories was REMOVED in v0.12 flag inverts.
#expect(!caps.hasFlushMemoriesAux)
// v0.13 surfaces stay off on a v0.12 host.
#expect(!caps.hasGoals)
#expect(!caps.hasACPQueue)
#expect(!caps.hasKanbanDiagnostics)
#expect(!caps.hasCuratorArchive)
#expect(!caps.hasGoogleChatPlatform)
#expect(!caps.hasGatewayAllowlists)
#expect(!caps.hasMCPSSETransport)
#expect(!caps.hasCronNoAgent)
#expect(!caps.hasWebToolsBackendSplit)
#expect(!caps.hasProfileNoSkills)
#expect(!caps.hasContextCompressionCount)
#expect(!caps.hasOpenRouterResponseCache)
#expect(!caps.hasImageGenModel)
#expect(!caps.hasDisplayLanguage)
#expect(!caps.hasXAIVoiceCloning)
}
@Test func v011FlagsAllOff() {
@@ -126,11 +183,45 @@ import Foundation
}
@Test func futureVersionRetainsCapabilities() {
// A v0.13 (hypothetical) should still see all v0.12 capabilities on.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.6.1)")
// A v0.14 (hypothetical) should still see all v0.12 + v0.13 capabilities on.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.14.0 (2026.7.1)")
#expect(caps.hasCurator)
#expect(caps.hasACPImagePrompts)
#expect(caps.hasGoals)
#expect(caps.hasKanbanDiagnostics)
#expect(caps.hasCuratorArchive)
// And flush_memories stays gone.
#expect(!caps.hasFlushMemoriesAux)
}
@Test func v0_13_patchReleaseStillEnablesAllFlags() {
// A v0.13.4 patch release should still enable every v0.13 flag.
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.4 (2026.5.20)")
#expect(caps.hasGoals)
#expect(caps.hasACPQueue)
#expect(caps.hasKanbanDiagnostics)
#expect(caps.hasGoogleChatPlatform)
}
// MARK: - isV013OrLater convenience predicate
@Test func isV013OrLater_v013HostTrue() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.5.7)")
#expect(caps.isV013OrLater)
}
@Test func isV013OrLater_v012HostFalse() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
#expect(!caps.isV013OrLater)
}
@Test func isV013OrLater_emptyFalse() {
let caps = HermesCapabilities.empty
#expect(!caps.isV013OrLater)
}
@Test func isV013OrLater_v014HostTrue() {
let caps = HermesCapabilities.parseLine("Hermes Agent v0.14.0 (2026.7.1)")
#expect(caps.isV013OrLater)
}
}
@@ -0,0 +1,330 @@
import Testing
import Foundation
@testable import ScarfCore
/// Pure-logic tests for the v2.7.5 Kanban model layer. The actor-based
/// `KanbanService` is exercised separately under integration tests
/// since it spawns `hermes kanban ` subprocesses; this suite covers
/// the wire-shape contracts and the synchronous transition planner.
@Suite struct KanbanModelsTests {
// MARK: - HermesKanbanTask decoding
@Test func decodeListRow() throws {
let json = """
{
"id": "t_9f2a",
"title": "Investigate flaky test",
"body": "Repro on CI but not local.",
"assignee": "researcher",
"status": "running",
"priority": 50,
"tenant": "scarf:demo",
"workspace_kind": "scratch",
"workspace_path": "/Users/alan/.hermes/kanban/workspaces/t_9f2a",
"created_by": "user",
"created_at": "2026-05-06T12:00:00Z",
"started_at": "2026-05-06T12:01:00Z",
"skills": ["debugging"],
"idempotency_key": "abc",
"last_heartbeat_at": "2026-05-06T12:05:00Z",
"max_runtime_seconds": 1800,
"current_run_id": 1
}
"""
let task = try JSONDecoder().decode(HermesKanbanTask.self, from: Data(json.utf8))
#expect(task.id == "t_9f2a")
#expect(task.assignee == "researcher")
#expect(task.status == "running")
#expect(task.tenant == "scarf:demo")
#expect(task.workspaceKind == "scratch")
#expect(task.skills == ["debugging"])
#expect(task.idempotencyKey == "abc")
#expect(task.maxRuntimeSeconds == 1800)
#expect(task.currentRunId == 1)
}
// MARK: - Assignee table parsing
//
// `hermes kanban assignees` prints either a JSON array (when
// `--json` is honored) OR a Rich-style human table OR an
// empty-state sentinel "(no assignees create a profile with
// `hermes -p <name> setup`)". The first iteration of the parser
// tokenized the sentinel and emitted `(no` as a profile name,
// which surfaced in the Mac inspector's assignee dropdown.
// MARK: - LocalTransport subprocess environment
@Test func localTransportSubprocessEnvIncludesExecutableDir() {
// GUI-launched Scarf would otherwise hand subprocesses
// `/usr/bin:/bin:/usr/sbin:/sbin`, which doesn't include
// `~/.local/bin` so when Hermes's kanban dispatcher
// spawns a worker by bare name, it fails with
// `executable not found on PATH` and the run records
// `outcome=spawn_failed`. Unblock by always making sure
// the directory of the executable we're launching is on
// PATH for the child.
let previous = LocalTransport.environmentEnricher
defer { LocalTransport.environmentEnricher = previous }
LocalTransport.environmentEnricher = nil
let env = LocalTransport.subprocessEnvironment(
forExecutable: "/Users/alanwizemann/.local/bin/hermes"
)
let path = env["PATH"] ?? ""
#expect(path.contains("/Users/alanwizemann/.local/bin"))
}
@Test func localTransportSubprocessEnvLetsEnricherWinPATH() {
let previous = LocalTransport.environmentEnricher
defer { LocalTransport.environmentEnricher = previous }
LocalTransport.environmentEnricher = {
// Simulate a login-shell probe returning a fuller PATH +
// some credential env. The enricher's PATH must override
// the GUI-process PATH.
return [
"PATH": "/opt/homebrew/bin:/usr/local/bin:/Users/me/.local/bin",
"ANTHROPIC_API_KEY": "sk-test-fake"
]
}
let env = LocalTransport.subprocessEnvironment(
forExecutable: "/usr/local/bin/hermes"
)
// Enricher's PATH wins (PATH is the whole point of running it).
#expect(env["PATH"]?.contains("/opt/homebrew/bin") == true)
// Credential env is forwarded (process env didn't have it).
#expect(env["ANTHROPIC_API_KEY"] == "sk-test-fake")
}
@Test func parseAssigneeTableSkipsNoAssigneesSentinel() {
// Use the same parser via its public stand-in: round-trip
// through a fixture that decodes via JSON would skip the
// table parser, so we test the fallback indirectly by
// constructing the same decoder pipeline. The parser is
// private to KanbanService; this test asserts the visible
// contract (no garbage profile names appear in the picker)
// by verifying the decode path on the real CLI fixture
// returns an empty array rather than a `(no` row.
let fixture = "(no assignees — create a profile with `hermes -p <name> setup`)"
// Through the public surface: we know `KanbanService.assignees`
// would consume this stdout when --json fails. The validator
// we care about is the regex check; reproduce inline:
let pattern = "^[a-zA-Z0-9_-]+$"
let firstToken = fixture
.split(whereSeparator: { $0 == "\t" || $0 == " " })
.first.map(String.init) ?? ""
// Confirms the parser's regex would reject "(no".
#expect(firstToken.range(of: pattern, options: .regularExpression) == nil)
}
@Test func decodeUnixIntegerTimestamps() throws {
// Real `hermes kanban create --json` output uses Unix integer
// seconds for created_at / started_at its SQLite columns are
// INTEGER. The decoder must normalize them into ISO-8601 strings
// so downstream code works with one type.
let json = """
{
"id": "t_2a0be199",
"title": "smoke",
"status": "ready",
"priority": 50,
"created_at": 1778160614,
"started_at": null,
"skills": []
}
"""
let task = try JSONDecoder().decode(HermesKanbanTask.self, from: Data(json.utf8))
#expect(task.id == "t_2a0be199")
// Should have been converted from Unix int to an ISO-8601 string
// exact format is platform-stable.
#expect(task.createdAt?.contains("2026") == true)
#expect(task.startedAt == nil)
}
@Test func decodeMissingOptionalsBecomesNil() throws {
// Hermes emits a minimal task object when many fields are
// absent; the decoder must tolerate it.
let json = """
{ "id": "t_x", "title": "ok", "status": "todo" }
"""
let task = try JSONDecoder().decode(HermesKanbanTask.self, from: Data(json.utf8))
#expect(task.id == "t_x")
#expect(task.assignee == nil)
#expect(task.priority == nil)
#expect(task.tenant == nil)
#expect(task.skills.isEmpty)
}
// MARK: - Status / column projection
@Test func statusToColumnMapping() {
#expect(KanbanStatus.from("triage").boardColumn == .triage)
#expect(KanbanStatus.from("todo").boardColumn == .upNext)
#expect(KanbanStatus.from("ready").boardColumn == .upNext)
#expect(KanbanStatus.from("running").boardColumn == .running)
#expect(KanbanStatus.from("blocked").boardColumn == .blocked)
#expect(KanbanStatus.from("done").boardColumn == .done)
#expect(KanbanStatus.from("archived").boardColumn == .archived)
#expect(KanbanStatus.from("WHATEVER").boardColumn == .upNext) // unknown upNext
}
// MARK: - KanbanCreateRequest argv assembly
@Test func createRequestArgvIncludesAllFields() {
let req = KanbanCreateRequest(
title: "Translate doc",
body: "Spanish, please",
assignee: "researcher",
parentIds: ["t_parent"],
workspace: .directory("/tmp/proj"),
tenant: "scarf:demo",
priority: 75,
triage: true,
idempotencyKey: "key-1",
maxRuntimeSeconds: 1800,
createdBy: "alan",
skills: ["translation", "github-code-review"]
)
let argv = req.argv()
#expect(argv.contains("--body"))
#expect(argv.contains("--assignee"))
#expect(argv.contains("--parent"))
#expect(argv.contains("--workspace"))
#expect(argv.contains("dir:/tmp/proj"))
#expect(argv.contains("--tenant"))
#expect(argv.contains("scarf:demo"))
#expect(argv.contains("--priority"))
#expect(argv.contains("75"))
#expect(argv.contains("--triage"))
#expect(argv.contains("--idempotency-key"))
#expect(argv.contains("--max-runtime"))
#expect(argv.contains("--created-by"))
#expect(argv.contains("--skill"))
#expect(argv.last == "Translate doc") // positional title is last
#expect(argv.contains("--json"))
}
@Test func createRequestArgvOmitsAbsent() {
let req = KanbanCreateRequest(title: "minimal")
let argv = req.argv()
#expect(argv.contains("--json"))
#expect(argv.last == "minimal")
#expect(!argv.contains("--body"))
#expect(!argv.contains("--assignee"))
#expect(!argv.contains("--triage"))
}
// MARK: - KanbanListFilter argv
@Test func listFilterEmptyOnlyJSON() {
let argv = KanbanListFilter.all.argv()
#expect(argv == ["--json"])
}
@Test func listFilterStatusFlag() {
let argv = KanbanListFilter(status: .running).argv()
#expect(argv.contains("--status"))
#expect(argv.contains("running"))
}
@Test func listFilterTenantPasses() {
let argv = KanbanListFilter(tenant: "scarf:demo").argv()
#expect(argv.contains("--tenant"))
#expect(argv.contains("scarf:demo"))
}
@Test func listFilterArchivedAndMine() {
let argv = KanbanListFilter(includeArchived: true, mineOnly: true).argv()
#expect(argv.contains("--mine"))
#expect(argv.contains("--archived"))
}
// MARK: - Transition planning
@Test func planUpNextToRunningDispatches() throws {
// `dispatch`, not `claim`. See KanbanTransitionStep doc for the
// rationale claim doesn't spawn a worker; the dispatcher does.
let plan = try KanbanService.plan(
for: KanbanTransition(from: .upNext, to: .running)
)
#expect(plan.steps == [.dispatch])
}
@Test func planRunningToBlockedRequiresReason() throws {
let plan = try KanbanService.plan(
for: KanbanTransition(from: .running, to: .blocked)
)
#expect(plan.requiresBlockReason)
}
@Test func planBlockedToRunningChainsTwoVerbs() throws {
let plan = try KanbanService.plan(
for: KanbanTransition(from: .blocked, to: .running)
)
// unblock then dispatch
#expect(plan.steps.count == 2)
if case .unblock = plan.steps.first {} else {
Issue.record("expected first step .unblock, got \(plan.steps)")
}
if case .dispatch = plan.steps.last {} else {
Issue.record("expected last step .dispatch, got \(plan.steps)")
}
}
@Test func planDoneToAnythingForbidden() {
do {
_ = try KanbanService.plan(
for: KanbanTransition(from: .done, to: .upNext)
)
Issue.record("expected error")
} catch let err as KanbanError {
if case .forbiddenTransition = err {
// ok
} else {
Issue.record("wrong error: \(err)")
}
} catch {
Issue.record("unexpected error: \(error)")
}
}
@Test func planTriageToUpNextForbidden() {
do {
_ = try KanbanService.plan(
for: KanbanTransition(from: .triage, to: .upNext)
)
Issue.record("expected error")
} catch let err as KanbanError {
if case .forbiddenTransition = err {
// ok
} else {
Issue.record("wrong error: \(err)")
}
} catch {
Issue.record("unexpected error: \(error)")
}
}
@Test func planNoOpProducesEmptyPlan() throws {
let plan = try KanbanService.plan(
for: KanbanTransition(from: .running, to: .running)
)
#expect(plan.steps.isEmpty)
}
// MARK: - Stats glance
@Test func glanceStringJoinsNonEmptyBuckets() {
let stats = HermesKanbanStats(
byStatus: ["todo": 12, "running": 3, "blocked": 5, "done": 0]
)
#expect(stats.glanceString == "12 todo · 3 running · 5 blocked")
#expect(stats.activeCount == 12 + 3 + 5)
}
@Test func glanceStringEmptyWhenZero() {
let stats = HermesKanbanStats(byStatus: [:])
#expect(stats.glanceString.isEmpty)
#expect(stats.activeCount == 0)
}
}
@@ -0,0 +1,85 @@
import Testing
import Foundation
@testable import ScarfCore
/// Regression tests for `SSHScriptRunner`. Mac-only because the
/// implementation relies on `Foundation.Process`, which doesn't exist
/// on Swift Linux. Drives the `runLocally` path so we don't need an
/// SSH endpoint in CI.
#if os(macOS)
@Suite struct SSHScriptRunnerTests {
/// Issue #77 regression. Pre-fix the runner read stdout via
/// `readToEnd()` *after* the subprocess exited; once the script's
/// output crossed the kernel's pipe buffer (1664 KB on macOS) the
/// process wedged because nothing was draining the read end. The
/// only visible symptom was a 30-second timeout and an empty
/// result.
///
/// This script writes ~256 KB of bytes comfortably past every
/// pipe-buffer threshold. With the readabilityHandler drain in
/// place the run should complete in well under a second and
/// return the full payload.
@Test func drainsLargeStdoutWithoutTimeout() async throws {
// 256 lines × 1024 bytes/line = 256 KB.
let script = """
for i in $(seq 1 256); do
printf '%04d:' "$i"
printf '%.0sx' $(seq 1 1018)
printf '\\n'
done
"""
let outcome = await SSHScriptRunner.run(
script: script,
context: .local,
timeout: 10
)
switch outcome {
case .completed(let stdout, _, let exitCode):
#expect(exitCode == 0)
// 256 lines + final newline.
let lines = stdout.split(separator: "\n", omittingEmptySubsequences: false)
#expect(lines.count >= 256)
#expect(stdout.utf8.count >= 256 * 1024)
case .connectFailure(let reason):
Issue.record("Expected completion, got connectFailure: \(reason)")
}
}
/// Sanity check that small scripts still come back the way they
/// did before the drain refactor. Guards against an off-by-one in
/// the readability handler that swallowed trailing bytes.
@Test func smallScriptPayloadRoundTrips() async throws {
let outcome = await SSHScriptRunner.run(
script: "printf 'hello\\n' && printf 'world\\n' >&2 && exit 0",
context: .local,
timeout: 5
)
switch outcome {
case .completed(let stdout, let stderr, let exitCode):
#expect(exitCode == 0)
#expect(stdout == "hello\n")
#expect(stderr == "world\n")
case .connectFailure(let reason):
Issue.record("Expected completion, got connectFailure: \(reason)")
}
}
/// Non-zero exit codes should still be reported as `.completed`
/// with the captured stdout/stderr unchanged contract.
@Test func nonZeroExitIsReportedAsCompleted() async throws {
let outcome = await SSHScriptRunner.run(
script: "echo nope >&2 && exit 7",
context: .local,
timeout: 5
)
switch outcome {
case .completed(_, let stderr, let exitCode):
#expect(exitCode == 7)
#expect(stderr.contains("nope"))
case .connectFailure(let reason):
Issue.record("Expected completion, got connectFailure: \(reason)")
}
}
}
#endif
@@ -0,0 +1,98 @@
import Testing
import Foundation
@testable import ScarfCore
/// Issue #79 regression. `searchHub()` with `hubSource == "all"` must
/// filter the cached browse list client-side (instead of shelling out
/// to `hermes skills search`, which routes through Hermes's
/// centralized index and can miss skills that browse aggregates from
/// non-indexed registries `honcho` was the user-reported example).
///
/// Source-specific searches keep the CLI path; that's not exercised
/// here because it requires a live `hermes` binary the existing
/// HermesSkillsHubParser tests cover the parser side.
@Suite("SkillsViewModel hub filter")
@MainActor
struct SkillsViewModelHubFilterTests {
private func makeViewModel() -> SkillsViewModel {
SkillsViewModel(context: .local)
}
private let stubBrowse: [HermesHubSkill] = [
HermesHubSkill(
identifier: "honcho",
name: "honcho",
description: "Memory provider for chat-scoped facts.",
source: "github"
),
HermesHubSkill(
identifier: "1password",
name: "1password",
description: "Set up and use 1Password integration.",
source: "official"
),
HermesHubSkill(
identifier: "spotify",
name: "spotify",
description: "Spotify skill — playback control via OAuth.",
source: "official"
),
]
@Test func allSourcesFilterMatchesByName() {
let vm = makeViewModel()
vm.lastBrowseResults = stubBrowse
vm.hubSource = "all"
vm.hubQuery = "honcho"
vm.searchHub()
#expect(vm.hubResults.count == 1)
#expect(vm.hubResults.first?.identifier == "honcho")
#expect(vm.isHubLoading == false)
#expect(vm.hubMessage == nil)
}
@Test func allSourcesFilterMatchesByDescription() {
let vm = makeViewModel()
vm.lastBrowseResults = stubBrowse
vm.hubSource = "all"
vm.hubQuery = "OAuth"
vm.searchHub()
#expect(vm.hubResults.count == 1)
#expect(vm.hubResults.first?.identifier == "spotify")
}
@Test func allSourcesFilterIsCaseInsensitive() {
let vm = makeViewModel()
vm.lastBrowseResults = stubBrowse
vm.hubSource = "all"
vm.hubQuery = "HONCHO"
vm.searchHub()
#expect(vm.hubResults.count == 1)
#expect(vm.hubResults.first?.identifier == "honcho")
}
@Test func allSourcesFilterEmptyMatchSetsMessage() {
let vm = makeViewModel()
vm.lastBrowseResults = stubBrowse
vm.hubSource = "all"
vm.hubQuery = "ringtone"
vm.searchHub()
#expect(vm.hubResults.isEmpty)
#expect(vm.hubMessage == "No matches")
}
/// Empty query should fall through to `browseHub()`, which on
/// `.local` with no Hermes installed will set isHubLoading=true
/// and not block the test. We just assert the early-return guard
/// kicked in by checking the cache was untouched.
@Test func emptyQueryFallsThroughToBrowse() {
let vm = makeViewModel()
vm.lastBrowseResults = stubBrowse
vm.hubSource = "all"
vm.hubQuery = ""
let cacheBefore = vm.lastBrowseResults
vm.searchHub()
#expect(vm.lastBrowseResults == cacheBefore)
}
}
+10
View File
@@ -412,6 +412,16 @@ struct ChatView: View {
tint: ScarfColor.info,
showSpinner: true
)
} else if controller.vm.isHydratingTools {
// v2.7 Phase 2 tool-call hydration is in flight.
// Bare conversation skeleton is already on screen;
// this banner tells the user the tool cards are
// about to fill in.
connectionBannerStrip(
text: "Loading tool details…",
tint: ScarfColor.info,
showSpinner: true
)
} else {
EmptyView()
}
@@ -0,0 +1,243 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Read-only Kanban task detail sheet for iOS. Mirrors the Mac
/// inspector's 3-tab layout (Comments | Events | Runs) but routes
/// through a `NavigationStack` for iOS-native chrome and dismisses
/// to the parent kanban view, not to the board.
///
/// No mutations in v2.7.5 write actions land on iOS in a later
/// release via a bottom action bar with explicit verb buttons (no
/// drag-drop).
struct ScarfGoKanbanDetailSheet: View {
let taskId: String
let context: ServerContext
@Environment(\.dismiss) private var dismiss
@State private var detail: HermesKanbanTaskDetail?
@State private var runs: [HermesKanbanRun] = []
@State private var isLoading = true
@State private var error: String?
@State private var selectedTab: DetailTab = .comments
enum DetailTab: String, CaseIterable, Identifiable {
case comments = "Comments"
case events = "Events"
case runs = "Runs"
var id: String { rawValue }
}
var body: some View {
NavigationStack {
content
.navigationTitle(detail?.task.title ?? "Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.task(id: taskId) { await load() }
}
@ViewBuilder
private var content: some View {
if isLoading && detail == nil {
ProgressView("Loading…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error {
ContentUnavailableView {
Label("Couldn't load task", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
} actions: {
Button("Try Again") {
Task { await load() }
}
}
} else if let detail {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
headerCard(detail.task)
if let body = detail.task.body, !body.isEmpty {
if let attributed = try? AttributedString(markdown: body) {
Text(attributed)
.font(.body)
} else {
Text(body)
.font(.body)
}
}
Picker("Section", selection: $selectedTab) {
ForEach(DetailTab.allCases) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
switch selectedTab {
case .comments: commentsSection(detail.comments)
case .events: eventsSection(detail.events)
case .runs: runsSection
}
}
.padding()
}
}
}
private func headerCard(_ task: HermesKanbanTask) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
ScarfBadge(task.status.lowercased(), kind: badgeKind(for: task.status))
if let assignee = task.assignee, !assignee.isEmpty {
ScarfBadge(assignee, kind: .neutral)
}
if let workspace = task.workspaceKind {
ScarfBadge(workspace, kind: .neutral)
}
if let tenant = task.tenant, !tenant.isEmpty {
ScarfBadge(tenant, kind: .brand)
}
}
if let priority = task.priority {
Text("Priority \(priority)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func commentsSection(_ comments: [HermesKanbanComment]) -> some View {
VStack(alignment: .leading, spacing: 8) {
if comments.isEmpty {
Text("No comments yet.")
.font(.callout)
.foregroundStyle(.tertiary)
} else {
ForEach(comments) { comment in
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(comment.author)
.font(.subheadline)
.bold()
Text(comment.createdAt)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Text(comment.body)
.font(.body)
.foregroundStyle(.secondary)
}
.padding(8)
.background(ScarfColor.backgroundSecondary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous))
}
}
}
}
private func eventsSection(_ events: [HermesKanbanEvent]) -> some View {
VStack(alignment: .leading, spacing: 6) {
if events.isEmpty {
Text("No events yet.")
.font(.callout)
.foregroundStyle(.tertiary)
} else {
ForEach(events) { event in
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text(event.kind)
.font(.subheadline)
.bold()
Text(event.createdAt)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
}
}
private var runsSection: some View {
VStack(alignment: .leading, spacing: 8) {
if runs.isEmpty {
Text("No runs yet.")
.font(.callout)
.foregroundStyle(.tertiary)
} else {
ForEach(runs) { run in
VStack(alignment: .leading, spacing: 2) {
HStack {
ScarfBadge(run.outcome ?? run.status, kind: outcomeKind(run.outcome ?? run.status))
if let profile = run.profile {
Text(profile)
.font(.subheadline)
}
Spacer()
Text(run.startedAt)
.font(.caption2)
.foregroundStyle(.tertiary)
}
if let summary = run.summary, !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
}
if let err = run.error, !err.isEmpty {
Text(err)
.font(.caption)
.foregroundStyle(.red)
}
}
.padding(8)
.background(ScarfColor.backgroundSecondary.opacity(0.4))
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous))
}
}
}
}
private func badgeKind(for status: String) -> ScarfBadgeKind {
switch KanbanStatus.from(status) {
case .running, .ready: return .info
case .done: return .success
case .blocked: return .warning
default: return .neutral
}
}
private func outcomeKind(_ outcome: String) -> ScarfBadgeKind {
switch outcome.lowercased() {
case "completed", "done": return .success
case "blocked": return .warning
case "crashed", "timed_out", "spawn_failed", "failed": return .danger
case "running": return .info
default: return .neutral
}
}
// MARK: - Loading
private func load() async {
isLoading = true
defer { isLoading = false }
let svc = KanbanService(context: context)
do {
async let detailLoaded = svc.show(taskId: taskId)
async let runsLoaded = svc.runs(taskId: taskId)
self.detail = try await detailLoaded
self.runs = (try? await runsLoaded) ?? []
self.error = nil
} catch let err as KanbanError {
self.error = err.errorDescription
} catch {
self.error = error.localizedDescription
}
}
}
@@ -0,0 +1,236 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Read-only Kanban surface for iOS / iPadOS, scoped to one project's
/// tenant. Renders the 5 standard board columns as a horizontally-
/// paged `TabView` of single-column lists HIG-friendly on iPhone
/// where a 5-column grid would force unreadable card widths.
///
/// Mutations + drag-drop are deferred to a later release per
/// CLAUDE.md's iOS catch-up policy. Tap a card to open a read-only
/// detail sheet that surfaces the same comments / events / runs the
/// Mac inspector shows. iPad gets the same view (no drag-drop yet)
/// same UI for both form factors keeps the future mutation path
/// straightforward.
struct ScarfGoKanbanView: View {
let project: ProjectEntry
let context: ServerContext
@State private var tasks: [HermesKanbanTask] = []
@State private var stats: HermesKanbanStats = .empty
@State private var isLoading = true
@State private var error: String?
@State private var selectedColumn: KanbanBoardColumn = .upNext
@State private var inspectorTaskId: String?
@State private var pollTask: Task<Void, Never>?
private var resolvedTenant: String? {
KanbanTenantReader(context: context).tenant(forProjectPath: project.path)
}
var body: some View {
VStack(spacing: 0) {
if !stats.glanceString.isEmpty {
Text(stats.glanceString)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 4)
}
columnPicker
.padding(.horizontal)
.padding(.bottom, 4)
Divider()
content
}
.background(ScarfColor.backgroundPrimary)
.task(id: project.id) {
await refresh()
startPolling()
}
.onDisappear { pollTask?.cancel() }
.sheet(item: Binding(
get: { inspectorTaskId.map { TaskIDBox(id: $0) } },
set: { inspectorTaskId = $0?.id }
)) { box in
ScarfGoKanbanDetailSheet(
taskId: box.id,
context: context
)
}
}
private var columnPicker: some View {
Picker("Column", selection: $selectedColumn) {
ForEach(visibleColumns, id: \.self) { column in
Text("\(column.displayName) (\(taskCount(in: column)))").tag(column)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var content: some View {
if let error {
errorView(error)
} else if isLoading && tasks.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
taskList
}
}
private var taskList: some View {
let rows = tasks(in: selectedColumn)
return Group {
if rows.isEmpty {
ContentUnavailableView(
emptyTitle(for: selectedColumn),
systemImage: "rectangle.split.3x1",
description: Text(emptyCopy(for: selectedColumn))
)
} else {
List(rows) { task in
Button {
inspectorTaskId = task.id
} label: {
cardRow(task)
}
.buttonStyle(.plain)
}
.listStyle(.plain)
.refreshable {
await refresh()
}
}
}
}
private func cardRow(_ task: HermesKanbanTask) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(task.title)
.font(.headline)
.foregroundStyle(.primary)
.lineLimit(2)
HStack(spacing: 8) {
if let assignee = task.assignee, !assignee.isEmpty {
Label(assignee, systemImage: "person.fill")
.labelStyle(.titleAndIcon)
.font(.caption)
.foregroundStyle(.secondary)
}
if let workspace = task.workspaceKind {
ScarfBadge(workspace, kind: .neutral)
}
if let priority = task.priority, priority >= 70 {
ScarfBadge("p\(priority)", kind: priority >= 90 ? .danger : .warning)
}
Spacer()
}
if !task.skills.isEmpty {
Text(task.skills.prefix(2).joined(separator: ", ") + (task.skills.count > 2 ? " +\(task.skills.count - 2)" : ""))
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
.padding(.vertical, 4)
}
private func errorView(_ message: String) -> some View {
ContentUnavailableView {
Label("Couldn't load tasks", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("Try Again") {
Task { await refresh() }
}
}
}
// MARK: - Loading
private func startPolling() {
pollTask?.cancel()
pollTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
if Task.isCancelled { break }
await refresh()
}
}
}
private func refresh() async {
isLoading = true
defer { isLoading = false }
guard let tenant = resolvedTenant, !tenant.isEmpty else {
tasks = []
error = "No Kanban tenant has been minted for this project yet. Open the Kanban tab on the Mac app to mint one."
return
}
let svc = KanbanService(context: context)
let filter = KanbanListFilter(tenant: tenant)
do {
let polled = try await svc.list(filter)
tasks = polled
stats = (try? await svc.stats()) ?? .empty
error = nil
} catch let err as KanbanError {
error = err.errorDescription
} catch {
self.error = error.localizedDescription
}
}
// MARK: - Column projection
private var visibleColumns: [KanbanBoardColumn] {
var cols: [KanbanBoardColumn] = []
if !tasks(in: .triage).isEmpty { cols.append(.triage) }
cols.append(contentsOf: [.upNext, .running, .blocked, .done])
return cols
}
private func taskCount(in column: KanbanBoardColumn) -> Int {
tasks(in: column).count
}
private func tasks(in column: KanbanBoardColumn) -> [HermesKanbanTask] {
tasks.filter { KanbanStatus.from($0.status).boardColumn == column }
.sorted { lhs, rhs in
let lp = lhs.priority ?? 0
let rp = rhs.priority ?? 0
if lp != rp { return lp > rp }
return (lhs.createdAt ?? "") > (rhs.createdAt ?? "")
}
}
private func emptyTitle(for column: KanbanBoardColumn) -> String {
switch column {
case .triage: return "Triage empty"
case .upNext: return "Queue empty"
case .running: return "No live workers"
case .blocked: return "Nothing blocked"
case .done: return "No completions yet"
case .archived: return "No archived tasks"
}
}
private func emptyCopy(for column: KanbanBoardColumn) -> String {
switch column {
case .triage: return "No tasks waiting on a specifier."
case .upNext: return "Drop a task on the Mac board, or create one with `hermes kanban create`."
case .running: return "No workers are running tasks for this project right now."
case .blocked: return "Nothing is blocked. When a worker hits a block, it'll show up here."
case .done: return "Recent completions will land here."
case .archived: return "Archived tasks are hidden by default."
}
}
}
private struct TaskIDBox: Identifiable {
let id: String
}
@@ -19,6 +19,7 @@ struct ProjectDetailView: View {
let config: IOSServerConfig
@Environment(\.scarfGoCoordinator) private var coordinator
@Environment(\.hermesCapabilities) private var capabilitiesStore
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A2"
@@ -35,7 +36,7 @@ struct ProjectDetailView: View {
@State private var lastDashboardMtime: Date?
enum DetailTab: Hashable {
case dashboard, site, sessions
case dashboard, site, sessions, kanban
}
private var serverContext: ServerContext {
@@ -55,6 +56,9 @@ struct ProjectDetailView: View {
var tabs: [DetailTab] = [.dashboard]
if siteWidget != nil { tabs.append(.site) }
tabs.append(.sessions)
if capabilitiesStore?.capabilities.hasKanban ?? false {
tabs.append(.kanban)
}
return tabs
}
@@ -111,6 +115,7 @@ struct ProjectDetailView: View {
case .dashboard: return "Dashboard"
case .site: return "Site"
case .sessions: return "Sessions"
case .kanban: return "Kanban"
}
}
@@ -129,6 +134,8 @@ struct ProjectDetailView: View {
}
case .sessions:
ProjectSessionsView_iOS(project: project)
case .kanban:
ScarfGoKanbanView(project: project, context: serverContext)
}
}
@@ -13,6 +13,13 @@ struct SettingsView: View {
@State private var vm: IOSSettingsViewModel
@State private var showRawYAML = false
@State private var editingSpec: SettingSpec?
/// v2.7 Scarf-local opt-in to bulk-fetch tool result CONTENT
/// when resuming past chats. Default off; the shared
/// `RichChatViewModel` reads this same UserDefaults key on
/// every chat resume so iOS gets the same skeleton-then-hydrate
/// behavior as Mac.
@AppStorage(RichChatViewModel.loadHistoricalToolResultsKey)
private var loadHistoricalToolResults: Bool = false
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
@@ -164,6 +171,28 @@ struct SettingsView: View {
yesNoRow("Inline diffs", vm.config.display.inlineDiffs)
LabeledContent("Personality", value: vm.config.personality)
}
chatScarfSection
}
/// v2.7 Scarf-local chat preferences. Mirrors the Mac Settings
/// Display "Load tool results in past chats" toggle. Lives in
/// its own section so it's clear these are app-side settings, not
/// Hermes config values.
@ViewBuilder
private var chatScarfSection: some View {
Section {
Toggle(isOn: $loadHistoricalToolResults) {
VStack(alignment: .leading, spacing: 2) {
Text("Load tool results in past chats")
.font(.body)
Text("Off (default) keeps past chat resumes fast on slow remotes — tool call cards still render, but the inspector lazy-loads each result when you open it.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} header: {
Text("Chat (Scarf)")
}
}
@ViewBuilder
+6 -1
View File
@@ -48,7 +48,12 @@ struct SkillsView: View {
// picker when the per-server snapshot diff has changes.
// First-load with no prior snapshot silently primes (no
// pill, the snapshot just records what's there).
if let diff = snapshotDiff,
//
// Issue #78: scope the pill to the Installed tab. It
// describes local file deltas; rendering it on Updates
// contradicts the upstream-version-check pane below.
if currentTab == .installed,
let diff = snapshotDiff,
diff.hasChanges,
!diff.previousSnapshotEmpty {
whatsNewPill(diff: diff)
+20 -20
View File
@@ -529,7 +529,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -546,7 +546,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.7.0;
MARKETING_VERSION = 2.7.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -571,7 +571,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -588,7 +588,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.7.0;
MARKETING_VERSION = 2.7.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -612,7 +612,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
@@ -635,7 +635,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
@@ -658,7 +658,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
@@ -680,7 +680,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
@@ -834,7 +834,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
@@ -848,7 +848,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.0;
MARKETING_VERSION = 2.7.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -870,7 +870,7 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
@@ -884,7 +884,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.7.0;
MARKETING_VERSION = 2.7.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -902,12 +902,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.7.0;
MARKETING_VERSION = 2.7.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -924,12 +924,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.7.0;
MARKETING_VERSION = 2.7.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -945,11 +945,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.7.0;
MARKETING_VERSION = 2.7.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -965,11 +965,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 32;
CURRENT_PROJECT_VERSION = 34;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 2.7.0;
MARKETING_VERSION = 2.7.5;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -31,6 +31,16 @@ struct ProjectTemplateManifest: Codable, Sendable, Equatable {
/// optional-field decoding keeps them working unchanged.
let config: TemplateConfigSchema?
/// Per-project Kanban tenant slug (manifest schemaVersion 3+, v2.7.5).
/// Minted by `KanbanTenantResolver` on first kanban interaction
/// inside this project. Templates never set this it's
/// user-machine-scoped state but Codable's optional decoding
/// means template manifests stay valid alongside user-minted ones.
/// Once minted, immutable across renames so existing tasks stay
/// attributable to the project. Read by `ProjectAgentContextService`
/// to surface the tenant to the agent in the AGENTS.md block.
var kanbanTenant: String? = nil
/// Filesystem-safe slug derived from `id` (`"owner/name"` `"owner-name"`).
/// Used for the install directory name, skills namespace, and cron-job tag.
nonisolated var slug: String {
@@ -1233,14 +1233,25 @@ struct HermesFileService: Sendable {
}
/// Error-surfacing variant. `.success(nil)` means `pgrep` ran successfully
/// and found no hermes process (Hermes is genuinely not running).
/// and found no Hermes gateway process (Hermes is genuinely not running).
/// `.failure` means we couldn't probe at all (pgrep missing, connection
/// down, permission issue) a *different* UX from "not running".
///
/// The regex narrows the match to the gateway daemon shape so unrelated
/// commands that happen to contain "hermes" `hermes acp` chat sessions,
/// `hermes -z` one-shots, log tails, README readers don't get flagged
/// as "Hermes is running" in the dashboard banner. Two alternations cover
/// both invocation forms: the python-module path (`python -m
/// hermes_cli.main gateway run `) and the script-path form
/// (`/usr/local/bin/hermes gateway run `). All callers semantically
/// want the gateway PID specifically `stopHermes()` issues
/// `hermes gateway stop` first and only falls back to killing this
/// PID, and the dashboard health probe only cares about the gateway.
nonisolated func hermesPIDResult() -> Result<pid_t?, Error> {
do {
let result = try transport.runProcess(
executable: "/usr/bin/pgrep",
args: ["-f", "hermes"],
args: ["-f", #"(^|[[:space:]])-m[[:space:]]+hermes_cli\.main[[:space:]]+gateway[[:space:]]+run([[:space:]]|$)|(^|[[:space:]/])hermes[[:space:]]+gateway[[:space:]]+run([[:space:]]|$)"#],
stdin: nil,
timeout: 5
)
@@ -0,0 +1,184 @@
import Foundation
import os
import ScarfCore
/// Resolves and mints per-project Kanban tenant slugs.
///
/// Hermes Kanban has no `project_id` column the closest namespace
/// primitive is the optional `tenant TEXT` column on `tasks`. Scarf
/// uses it as a surrogate project key: each Scarf project gets a
/// stable `scarf:<slug>` tenant minted on first kanban interaction
/// and persisted to `<project>/.scarf/manifest.json`.
///
/// **Invariants:**
/// - Once minted, the tenant is immutable across renames. Tasks
/// already on the board carry the original slug; renaming the
/// project would orphan them.
/// - The `scarf:` prefix prevents collisions with hand-typed
/// tenants from CLI users.
/// - Bare projects (no manifest) get a minimal `manifest.json`
/// with only `kanbanTenant` set on first mint.
struct KanbanTenantResolver: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "KanbanTenantResolver")
/// Prefix that distinguishes Scarf-minted tenants from hand-typed
/// ones. Public for callers that group "scarf-managed" projects in
/// the global tenant filter.
static let prefix = "scarf:"
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Public
/// Returns the existing tenant for a project, or `nil` if none has
/// been minted yet. Read-only never writes.
nonisolated func tenant(for project: ProjectEntry) -> String? {
readManifest(for: project)?.kanbanTenant
}
/// Returns the existing tenant or mints a new one if absent. Writes
/// the new tenant back to the project's manifest.json. Idempotent
/// calling twice on a fresh project returns the same value.
nonisolated func resolveOrMint(for project: ProjectEntry) throws -> String {
if let existing = tenant(for: project), !existing.isEmpty {
return existing
}
let candidate = Self.makeSlug(for: project.name)
let unique = uniquify(candidate, against: project)
try persist(tenant: unique, for: project)
Self.logger.info("minted kanban tenant '\(unique, privacy: .public)' for project '\(project.name, privacy: .public)'")
return unique
}
// MARK: - Slug generation (pure)
/// Build a `scarf:<slug>` tenant from a project name. Lowercased,
/// hyphenated, 48 chars after the prefix. Public for tests.
nonisolated static func makeSlug(for name: String) -> String {
let lower = name.lowercased()
let mapped = lower.unicodeScalars.map { scalar -> Character in
let c = Character(scalar)
if c.isLetter || c.isNumber { return c }
return "-"
}
let collapsed = String(mapped)
.split(separator: "-", omittingEmptySubsequences: true)
.joined(separator: "-")
let trimmed = collapsed.isEmpty ? "project" : collapsed
let bounded = String(trimmed.prefix(48))
return prefix + bounded
}
// MARK: - Private
/// Disambiguate against tenants already used by other projects on
/// this host. Reads every project's manifest; `O(projects)` fine
/// for typical project counts (handful to dozens). Suffixes `-2`,
/// `-3`, until unique.
nonisolated private func uniquify(_ candidate: String, against project: ProjectEntry) -> String {
let used = Set(allMintedTenants(excluding: project))
if !used.contains(candidate) { return candidate }
var n = 2
while n < 1000 {
let next = candidate + "-\(n)"
if !used.contains(next) { return next }
n += 1
}
// Defensive should never hit. Fall back to a UUID suffix.
return candidate + "-" + UUID().uuidString.prefix(6).lowercased()
}
/// Collect every Scarf-minted tenant currently on disk, excluding
/// the given project. Used to dedup new mints.
nonisolated private func allMintedTenants(excluding project: ProjectEntry) -> [String] {
let registryPath = context.paths.home + "/scarf/projects.json"
guard let data = context.readData(registryPath),
let registry = try? JSONDecoder().decode(ProjectRegistry.self, from: data)
else {
return []
}
return registry.projects.compactMap { other in
guard other.id != project.id else { return nil }
return readManifest(for: other)?.kanbanTenant
}
}
nonisolated private func readManifest(for project: ProjectEntry) -> ProjectTemplateManifest? {
let path = manifestPath(for: project)
let transport = context.makeTransport()
guard transport.fileExists(path),
let data = try? transport.readFile(path)
else {
return nil
}
return try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
}
/// Write the tenant back to `<project>/.scarf/manifest.json`. If
/// the file doesn't exist yet (bare project), create a minimal
/// manifest with just the kanbanTenant set. The remaining
/// manifest fields use sentinel values that the
/// `ProjectAgentContextService` reader tolerates: id stays at the
/// project's slug-form, version stays "0.0.0", and contents claims
/// nothing none of which the reader requires for the Kanban
/// tenant line.
nonisolated private func persist(tenant: String, for project: ProjectEntry) throws {
let path = manifestPath(for: project)
let transport = context.makeTransport()
// Ensure .scarf/ exists.
let scarfDir = project.scarfDir
if !transport.fileExists(scarfDir) {
try transport.createDirectory(scarfDir)
}
let updated: ProjectTemplateManifest
if let existing = readManifest(for: project) {
// Mutate the existing manifest in place. var fields permit
// this; let fields are preserved.
var copy = existing
copy.kanbanTenant = tenant
updated = copy
} else {
updated = ProjectTemplateManifest(
schemaVersion: 3,
id: "scarf/\(project.id)",
name: project.name,
version: "0.0.0",
minScarfVersion: nil,
minHermesVersion: nil,
author: nil,
description: "",
category: nil,
tags: nil,
icon: nil,
screenshots: nil,
contents: TemplateContents(
dashboard: false,
agentsMd: false,
instructions: nil,
skills: nil,
cron: nil,
memory: nil,
config: nil,
slashCommands: nil
),
config: nil,
kanbanTenant: tenant
)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(updated)
try transport.writeFile(path, data: data)
}
nonisolated private func manifestPath(for project: ProjectEntry) -> String {
project.scarfDir + "/manifest.json"
}
}
@@ -130,6 +130,7 @@ struct ProjectAgentContextService: Sendable {
let configFieldsLine = renderConfigFieldsLine(for: project)
let cronLines = renderCronLines(for: project, templateId: templateInfo?.id)
let slashCommandNames = readSlashCommandNames(for: project)
let kanbanTenant = readKanbanTenant(for: project)
let lockFilePresent = context.makeTransport().fileExists(
project.path + "/.scarf/template.lock.json"
)
@@ -164,6 +165,10 @@ struct ProjectAgentContextService: Sendable {
lines.append("- **Project slash commands:** \(formatted). The user invokes these via the chat slash menu; you'll see the expanded prompt as a normal user message preceded by `<!-- scarf-slash:<name> -->`.")
}
if let tenant = kanbanTenant, !tenant.isEmpty {
lines.append("- **Kanban tenant:** `\(tenant)` — when creating Hermes Kanban tasks for this project, always pass `--tenant \(tenant)` to `hermes kanban create` so the tasks land on this project's board instead of the global \"Untagged\" pile.")
}
if lockFilePresent {
lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)")
}
@@ -202,9 +207,31 @@ struct ProjectAgentContextService: Sendable {
guard transport.fileExists(manifestPath) else { return nil }
guard let data = try? transport.readFile(manifestPath) else { return nil }
guard let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data) else { return nil }
// Bare-project manifests minted by KanbanTenantResolver carry
// a sentinel id of "scarf/<project-id>" and version "0.0.0".
// Don't surface those as a template the template line is
// for actual installed templates only.
if manifest.id.hasPrefix("scarf/") && manifest.version == "0.0.0" {
return nil
}
return (id: manifest.id, version: manifest.version)
}
/// Read `<project>/.scarf/manifest.json` for the Scarf-minted Kanban
/// tenant. Nil when no tenant has been minted yet (no kanban
/// interaction has happened for this project).
nonisolated private func readKanbanTenant(for project: ProjectEntry) -> String? {
let manifestPath = project.path + "/.scarf/manifest.json"
let transport = context.makeTransport()
guard transport.fileExists(manifestPath),
let data = try? transport.readFile(manifestPath),
let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
else {
return nil
}
return manifest.kanbanTenant
}
/// Build the "Configuration fields" bullet's tail. Returns a
/// comma-joined list of backticked field names with inline type
/// hints (`(secret)`), or the literal string "(none)" when the
@@ -1,4 +1,5 @@
import Foundation
import Darwin
import ScarfCore
#if canImport(AppKit)
import AppKit
@@ -592,8 +593,10 @@ final class HealthViewModel {
}
/// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an
/// external instance is running, fall back to `pkill -f "hermes dashboard"`
/// so the Stop button works regardless of who launched it.
/// external instance is running, find the PID listening on the dashboard
/// port via `lsof` and signal that one process never broadcast a
/// `pkill -f "hermes dashboard"` that could match shell history, log
/// tails, or any unrelated argv containing the substring.
func stopDashboard() {
guard !context.isRemote else { return }
dashboardStatus = WebDashboardStatus(
@@ -606,13 +609,11 @@ final class HealthViewModel {
if let proc = dashboardProcess, proc.isRunning {
proc.terminate()
dashboardProcess = nil
} else {
// External instance best-effort pkill.
let kill = Process()
kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
kill.arguments = ["-f", "hermes dashboard"]
_ = try? kill.run()
kill.waitUntilExit()
} else if let pid = Self.dashboardListenerPID(port: dashboardStatus.port) {
// External instance signal only the process actually
// bound to our dashboard port, not anything that happens
// to mention "hermes dashboard" in its argv.
_ = Darwin.kill(pid, SIGTERM)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
@@ -631,6 +632,44 @@ final class HealthViewModel {
}
}
/// Resolve the PID currently listening on the dashboard port via
/// `lsof -tiTCP:<port> -sTCP:LISTEN`. Returns nil when nothing is
/// bound or lsof fails. Trusting the port is correct here: Scarf
/// owns the configured port, and stopping the listener is exactly
/// the user-visible "Stop Dashboard" intent. We deliberately skip
/// `lsof -c hermes` Hermes installs as a Python shebang script,
/// so the process COMM is `python` / `python3` and a `-c hermes`
/// filter silently misses every standard install.
private static func dashboardListenerPID(port: Int) -> pid_t? {
let lsof = Process()
lsof.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof")
lsof.arguments = ["-tiTCP:\(port)", "-sTCP:LISTEN"]
let output = Pipe()
lsof.standardOutput = output
lsof.standardError = FileHandle.nullDevice
do {
try lsof.run()
lsof.waitUntilExit()
// lsof exits 1 when nothing matches that's "no listener",
// not an error. Anything else is something we can't recover
// from in this code path; log and bail.
guard lsof.terminationStatus == 0 else { return nil }
let data = output.fileHandleForReading.readDataToEndOfFile()
let text = String(data: data, encoding: .utf8) ?? ""
return text
.split(whereSeparator: \.isNewline)
.compactMap { pid_t($0.trimmingCharacters(in: .whitespaces)) }
.first
} catch {
Self.dashboardLogger.warning(
"Failed to locate dashboard listener: \(error.localizedDescription, privacy: .public)"
)
return nil
}
}
/// Open the dashboard in the default browser. Safe to call only when the
/// probe reports `running: true` UI gates the button on that.
func openDashboardInBrowser() {
@@ -0,0 +1,385 @@
import Foundation
import Observation
import ScarfCore
import os
/// Drives the drag-and-drop Kanban board. Holds the column-grouped task
/// state, polls Hermes every 5s while foregrounded, and applies
/// optimistic updates around drag-drops so the UI feels instant.
///
/// **Optimistic merge.** When the user drops a card on a new column,
/// the VM records the in-flight task id + intended status, mutates the
/// local array immediately, and fires the corresponding CLI verb. Until
/// the next poll response confirms the new status, polled rows for
/// in-flight tasks are merged with the optimistic state preventing a
/// stale poll from snapping the card back to its old column. On CLI
/// failure, the optimistic mutation is reverted and an error message
/// is surfaced.
@Observable
@MainActor
final class KanbanBoardViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "KanbanBoardViewModel")
let context: ServerContext
let service: KanbanService
/// When non-nil, the board filters list/watch calls to this tenant
/// and `New Task` pre-fills the tenant field. Used by per-project
/// boards; global board leaves it nil.
var tenantFilter: String?
/// When non-nil, `New Task` pre-fills the workspace to
/// `dir:<projectPath>` and locks it so project-scoped task
/// creation always lands inside the project tree.
let projectPath: String?
init(
context: ServerContext = .local,
tenantFilter: String? = nil,
projectPath: String? = nil
) {
self.context = context
self.service = KanbanService(context: context)
self.tenantFilter = tenantFilter
self.projectPath = projectPath
}
// MARK: - State
var tasks: [HermesKanbanTask] = []
var stats: HermesKanbanStats = .empty
var assignees: [HermesKanbanAssignee] = []
var isLoading = false
var lastError: String?
var lastPollAt: Date?
/// Filters above the board.
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] = [:]
/// 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.
var transientNotice: String?
// MARK: - Polling
private var pollTask: Task<Void, Never>?
func startPolling() {
stopPolling()
pollTask = Task { [weak self] in
while !Task.isCancelled {
await self?.refresh()
try? await Task.sleep(nanoseconds: 5_000_000_000)
}
}
}
func stopPolling() {
pollTask?.cancel()
pollTask = nil
}
// MARK: - Loading
/// One-shot refresh. Polling drives the auto-refresh; this is
/// exposed for explicit user-triggered reloads (e.g. the toolbar
/// refresh button).
func refresh() async {
isLoading = true
defer { isLoading = false }
do {
let filter = KanbanListFilter(
assignee: assigneeFilter,
tenant: tenantFilter,
includeArchived: showArchived
)
let polled = try await service.list(filter)
mergePolledTasks(polled)
lastPollAt = Date()
lastError = nil
// Stats refresh is best-effort failure here doesn't
// poison the board, just leaves the glance string stale.
if let stats = try? await service.stats() {
self.stats = stats
}
} catch let err as KanbanError {
lastError = err.errorDescription
} catch {
lastError = error.localizedDescription
}
}
/// Refresh the assignee picker. Cheap; called once on appear.
func refreshAssignees() async {
if let list = try? await service.assignees() {
assignees = list
}
}
// MARK: - Column projection
/// Group tasks into the 5-column board layout. Triage column
/// hides itself when empty; archived only appears when
/// `showArchived` is on.
func tasks(in column: KanbanBoardColumn) -> [HermesKanbanTask] {
let raw = tasks.filter { effectiveColumn($0) == column }
return sortColumn(raw)
}
/// Visible columns for the current state. Triage hidden when
/// empty; archived hidden unless toggle is on.
var visibleColumns: [KanbanBoardColumn] {
var cols: [KanbanBoardColumn] = []
if !tasks(in: .triage).isEmpty {
cols.append(.triage)
}
cols.append(contentsOf: [.upNext, .running, .blocked, .done])
if showArchived {
cols.append(.archived)
}
return cols
}
// MARK: - Drag-drop
/// Apply an optimistic move and fire the matching Hermes verbs.
/// Returns immediately; the CLI calls run in the background.
/// Inputs the drag layer must collect upstream:
/// - `blockReason` when the destination is `.blocked`
/// - `completeResult` when the destination is `.done`
func attemptMove(
taskId: String,
to destination: KanbanBoardColumn,
blockReason: String? = nil,
completeResult: String? = nil
) {
guard let task = tasks.first(where: { $0.id == taskId }) else { return }
let source = effectiveColumn(task)
if source == destination { return }
let plan: KanbanTransitionPlan
do {
plan = try KanbanService.plan(
for: KanbanTransition(from: source, to: destination)
)
} catch let err as KanbanError {
transientNotice = err.errorDescription
return
} catch {
transientNotice = error.localizedDescription
return
}
// 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 svc = service
Task {
do {
for step in plan.steps {
try await applyStep(step, taskId: taskId, blockReason: blockReason, completeResult: completeResult, service: svc)
}
// Refresh once on success so the polled state catches up
// without waiting for the 5s tick.
await refresh()
} catch let err as KanbanError {
optimisticOverrides.removeValue(forKey: taskId)
lastError = err.errorDescription
logger.warning("kanban move failed: \(err.errorDescription ?? "", privacy: .public)")
} catch {
optimisticOverrides.removeValue(forKey: taskId)
lastError = error.localizedDescription
}
}
}
/// Archive via context menu (not drag).
func archive(taskId: String) {
Task {
do {
try await service.archive(taskIds: [taskId])
await refresh()
} catch let err as KanbanError {
lastError = err.errorDescription
} catch {
lastError = error.localizedDescription
}
}
}
/// Reassign a task to a different profile (or clear the assignee
/// when `profile` is nil/empty). Fires a dispatcher pass after a
/// successful assignment so the task transitions promptly when
/// the gateway dispatcher's own cycle is slow. Best-effort:
/// failures surface in `lastError`. Used by the inspector's
/// inline assignee picker.
func reassignTask(taskId: String, to profile: String?) {
Task {
do {
let normalized = (profile?.isEmpty ?? true) ? nil : profile
try await service.assign(taskId: taskId, profile: normalized)
if normalized != nil {
// Best-effort nudge.
_ = try? await service.dispatch(maxTasks: nil, dryRun: false)
}
await refresh()
} catch let err as KanbanError {
lastError = err.errorDescription
} catch {
lastError = error.localizedDescription
}
}
}
/// Append a comment from the inspector pane.
func comment(taskId: String, text: String) {
Task {
do {
try await service.comment(taskId: taskId, text: text, author: nil)
await refresh()
} catch let err as KanbanError {
lastError = err.errorDescription
} catch {
lastError = error.localizedDescription
}
}
}
/// Create a new task wired up to the New Task sheet.
/// Fires a dispatcher pass immediately after successful creation
/// so an assigned task transitions from `ready` `running`
/// promptly without waiting for whatever cadence the gateway's
/// internal dispatcher loop runs at.
func createTask(_ request: KanbanCreateRequest) async throws -> HermesKanbanTask {
let task = try await service.create(request)
if let assignee = task.assignee, !assignee.isEmpty {
// Best-effort: failure here is non-fatal the task still
// exists, the user just won't see it transition to running
// until the next gateway dispatcher tick.
_ = try? await service.dispatch(maxTasks: nil, dryRun: false)
}
await refresh()
return task
}
// MARK: - Private helpers
private func mergePolledTasks(_ polled: [HermesKanbanTask]) {
// Filter polled rows to the requested tenant if one is set
// belt-and-suspenders against Hermes versions that ignore
// an empty `--tenant ""` argument.
let filtered: [HermesKanbanTask]
if let tenant = tenantFilter, !tenant.isEmpty {
filtered = polled.filter { $0.tenant == tenant }
} else {
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) {
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.
optimisticOverrides.removeValue(forKey: id)
}
}
tasks = filtered
}
/// 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] {
return columnFromStatus(overrideStatus)
}
return columnFromStatus(task.status)
}
private nonisolated func columnFromStatus(_ status: String) -> KanbanBoardColumn {
KanbanStatus.from(status).boardColumn
}
private nonisolated func optimisticStatus(for column: KanbanBoardColumn) -> String {
switch column {
case .triage: return "triage"
case .upNext: return "todo"
case .running: return "running"
case .blocked: return "blocked"
case .done: return "done"
case .archived: return "archived"
}
}
/// Within-column ordering. Hermes has no `position` field, so we
/// derive ordering from `priority` (descending) then `created_at`
/// (descending). This matches the dispatcher's actual run order
/// what shows up first is what runs next.
private nonisolated func sortColumn(_ rows: [HermesKanbanTask]) -> [HermesKanbanTask] {
rows.sorted { lhs, rhs in
let lp = lhs.priority ?? 0
let rp = rhs.priority ?? 0
if lp != rp { return lp > rp }
return (lhs.createdAt ?? "") > (rhs.createdAt ?? "")
}
}
private func applyStep(
_ step: KanbanTransitionStep,
taskId: String,
blockReason: String?,
completeResult: String?,
service: KanbanService
) async throws {
switch step {
case .dispatch:
// The dispatcher silently skips tasks without an assignee.
// Refusing here, with a user-actionable message, beats
// letting Hermes lock the task into a 15-minute zombie
// state until stale_lock reclaim kicks in.
if let task = tasks.first(where: { $0.id == taskId }),
(task.assignee?.isEmpty ?? true) {
throw KanbanError.forbiddenTransition(
from: "Up Next",
to: "Running",
reason: "This task has no assignee. Hermes's dispatcher only spawns workers for assigned tasks. Open the task and assign a profile, or recreate it with an assignee."
)
}
_ = try await service.dispatch(maxTasks: nil, dryRun: false)
case .unblock:
try await service.unblock(taskIds: [taskId])
case .block(let reasonRequired):
let reason = (blockReason?.isEmpty ?? true) ? nil : blockReason
if reasonRequired && reason == nil {
throw KanbanError.forbiddenTransition(
from: "",
to: "Blocked",
reason: "A reason is required to mark a task blocked."
)
}
try await service.block(taskId: taskId, reason: reason)
case .complete(let resultRequired):
let result = (completeResult?.isEmpty ?? true) ? nil : completeResult
if resultRequired && result == nil {
throw KanbanError.forbiddenTransition(
from: "",
to: "Done",
reason: "A result summary is required to complete this task."
)
}
try await service.complete(taskIds: [taskId], result: result, summary: nil, metadataJSON: nil)
case .archive:
try await service.archive(taskIds: [taskId])
}
}
}
@@ -0,0 +1,137 @@
import Foundation
import Observation
import ScarfCore
import os
/// Drives the inspector pane for a single Kanban task. Loads the full
/// `kanban show` detail (comments + events + parent results) and the
/// run history (`kanban runs`). Mutations route back through the
/// shared `KanbanService` so the board's optimistic merge picks them
/// up on the next poll tick.
@Observable
@MainActor
final class KanbanTaskDetailViewModel {
private let logger = Logger(subsystem: "com.scarf", category: "KanbanTaskDetailViewModel")
let service: KanbanService
let taskId: String
var detail: HermesKanbanTaskDetail?
var runs: [HermesKanbanRun] = []
var isLoading = false
var lastError: String?
var commentDraft: String = ""
// MARK: - Worker log
/// Captured worker stdout/stderr from `hermes kanban log <id>`.
/// Empty until the first poll completes; updates every ~2s while
/// the task is running.
var log: String = ""
var isLogStreaming: Bool = false
private var logPollTask: Task<Void, Never>?
private var detailPollTask: Task<Void, Never>?
init(service: KanbanService, taskId: String) {
self.service = service
self.taskId = taskId
}
// No deinit-side cancellation: `logPollTask` is MainActor-isolated
// and `deinit` is nonisolated; relying on the Task's `[weak self]`
// capture is enough, and the inspector calls `stopLogPolling()`
// from `onDisappear` for predictable cleanup.
/// Start polling task detail (header / comments / events / runs)
/// every 5s while the inspector is open. Same cadence as the board
/// so a worker transition (e.g. running done) is reflected in
/// the inspector header + primary-action button without the user
/// having to close and reopen. Idempotent. The first iteration
/// runs immediately so the initial fetch matches one-shot
/// `load()` semantics.
func startDetailPolling() {
guard detailPollTask == nil else { return }
detailPollTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
await self.load()
try? await Task.sleep(nanoseconds: 5_000_000_000)
}
}
}
func stopDetailPolling() {
detailPollTask?.cancel()
detailPollTask = nil
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
async let detail = service.show(taskId: taskId)
async let runs = service.runs(taskId: taskId)
self.detail = try await detail
self.runs = (try? await runs) ?? []
lastError = nil
} catch let err as KanbanError {
lastError = err.errorDescription
} catch {
lastError = error.localizedDescription
}
}
/// One-shot log refresh. Use when the user opens the Log tab and
/// the task isn't running (so we don't want to start a poll loop).
func refreshLogOnce() async {
do {
let text = try await service.log(taskId: taskId, tailBytes: nil)
self.log = text
} catch let err as KanbanError {
lastError = err.errorDescription
} catch {
lastError = error.localizedDescription
}
}
/// Start polling the worker log every 2s. Called when the Log tab
/// is opened on a running task. Idempotent: a second call is a
/// no-op while the previous loop is alive.
func startLogPolling() {
guard logPollTask == nil else { return }
isLogStreaming = true
logPollTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
await self.refreshLogOnce()
try? await Task.sleep(nanoseconds: 2_000_000_000)
// Auto-stop when the task transitions out of running.
if let status = self.detail?.task.status,
KanbanStatus.from(status) != .running {
self.isLogStreaming = false
self.logPollTask = nil
return
}
}
}
}
func stopLogPolling() {
logPollTask?.cancel()
logPollTask = nil
isLogStreaming = false
}
func submitComment() async {
let text = commentDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
do {
try await service.comment(taskId: taskId, text: text, author: nil)
commentDraft = ""
await load()
} catch let err as KanbanError {
lastError = err.errorDescription
} catch {
lastError = error.localizedDescription
}
}
}
@@ -0,0 +1,55 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Modal sheet that prompts for an optional "reason" string before
/// firing `kanban block`. Used by the drag-drop layer when a card
/// lands on the Blocked column.
struct KanbanBlockReasonSheet: View {
@Environment(\.dismiss) private var dismiss
let taskTitle: String
let onSubmit: (String?) -> Void
@State private var reason: String = ""
@FocusState private var fieldFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
VStack(alignment: .leading, spacing: 4) {
Text("Block task")
.scarfStyle(.title3)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(taskTitle)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.lineLimit(2)
}
ScarfTextField("Reason (optional)", text: $reason)
.focused($fieldFocused)
Text("Reasons appear as a comment on the task and feed into the worker's context if it's later unblocked.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
.buttonStyle(ScarfSecondaryButton())
Button("Block") {
onSubmit(reason.trimmingCharacters(in: .whitespacesAndNewlines))
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(ScarfPrimaryButton())
}
}
.padding(ScarfSpace.s5)
.frame(width: 420)
.onAppear { fieldFocused = true }
}
}
@@ -0,0 +1,337 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Full drag-and-drop Kanban board. Renders the visible columns side
/// by side, supports drag-drop for column transitions, and slides in
/// a side-pane inspector when a card is tapped.
///
/// Two flavors:
/// - **Global**: pass `tenantFilter: nil` and `projectPath: nil`.
/// - **Per-project**: pass the project's `kanbanTenant` slug + the
/// project path so the New Task sheet pre-fills the workspace and
/// tenant.
struct KanbanBoardView: View {
@State private var viewModel: KanbanBoardViewModel
/// When non-nil, a project board hosts this view. Drives header
/// chrome (subtitle, hidden tenant filter) and create-sheet
/// defaults.
let projectName: String?
init(
context: ServerContext,
tenantFilter: String? = nil,
projectPath: String? = nil,
projectName: String? = nil
) {
_viewModel = State(initialValue: KanbanBoardViewModel(
context: context,
tenantFilter: tenantFilter,
projectPath: projectPath
))
self.projectName = projectName
}
@State private var inspectorTaskId: String?
@State private var showingCreateSheet = false
@State private var blockSheetTaskId: String?
@State private var blockSheetTitle: String = ""
@State private var blockSheetDestination: KanbanBoardColumn = .blocked
@State private var completeSheetTaskId: String?
@State private var completeSheetTitle: String = ""
var body: some View {
VStack(spacing: 0) {
header
ScarfDivider()
if let err = viewModel.lastError {
errorBanner(err)
}
if let notice = viewModel.transientNotice {
noticeBanner(notice)
}
HStack(spacing: 0) {
boardArea
if inspectorTaskId != nil {
ScarfDivider()
.frame(width: 1)
inspectorPane
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
}
.background(ScarfColor.backgroundPrimary)
.onAppear {
viewModel.startPolling()
Task { await viewModel.refreshAssignees() }
}
.onDisappear { viewModel.stopPolling() }
.sheet(isPresented: $showingCreateSheet) {
KanbanCreateSheet(
assignees: viewModel.assignees,
tenantPrefill: viewModel.tenantFilter,
projectWorkspacePath: viewModel.projectPath
) { request in
_ = try await viewModel.createTask(request)
}
}
.sheet(isPresented: blockSheetBinding) {
KanbanBlockReasonSheet(taskTitle: blockSheetTitle) { reason in
if let taskId = blockSheetTaskId {
viewModel.attemptMove(
taskId: taskId,
to: blockSheetDestination,
blockReason: reason
)
}
blockSheetTaskId = nil
}
}
.sheet(isPresented: completeSheetBinding) {
KanbanCompleteResultSheet(taskTitle: completeSheetTitle) { result in
if let taskId = completeSheetTaskId {
viewModel.attemptMove(
taskId: taskId,
to: .done,
completeResult: result
)
}
completeSheetTaskId = nil
}
}
}
// MARK: - Header
private var header: some View {
ScarfPageHeader(
"Kanban",
subtitle: subtitle
) {
HStack(spacing: ScarfSpace.s2) {
glanceText
if viewModel.tenantFilter == nil {
assigneeFilterMenu
}
Toggle("Show archived", isOn: $viewModel.showArchived)
.toggleStyle(.switch)
.labelsHidden()
.help("Show archived tasks")
Button {
Task { await viewModel.refresh() }
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(ScarfGhostButton())
.help("Refresh now")
Button {
showingCreateSheet = true
} label: {
Label("New Task", systemImage: "plus")
}
.buttonStyle(ScarfPrimaryButton())
}
}
}
private var subtitle: String {
if let projectName, let tenant = viewModel.tenantFilter, !tenant.isEmpty {
return "\(projectName) · tenant \(tenant)"
}
return "Hermes task board"
}
private var glanceText: some View {
let text = viewModel.stats.glanceString
return Text(text.isEmpty ? " " : text)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(minWidth: 60)
}
private var assigneeFilterMenu: some View {
Menu {
Button("All assignees") { viewModel.assigneeFilter = nil }
if !viewModel.assignees.isEmpty {
Divider()
ForEach(viewModel.assignees) { row in
Button(row.profile) { viewModel.assigneeFilter = row.profile }
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "line.3.horizontal.decrease.circle")
Text(viewModel.assigneeFilter ?? "All")
.scarfStyle(.caption)
}
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
}
// MARK: - Board area
private var boardArea: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: ScarfSpace.s4) {
ForEach(viewModel.visibleColumns, id: \.self) { column in
KanbanColumnView(
column: column,
tasks: viewModel.tasks(in: column),
isLive: column == .running && isLive,
readyPillCount: column == .upNext ? readyCount : 0,
onTaskTap: { task in
inspectorTaskId = task.id
},
onCreate: { showingCreateSheet = true },
onDrop: { ref in
handleDrop(ref.id, on: column)
},
canCreate: column == .upNext || column == .triage
)
}
Spacer(minLength: ScarfSpace.s4)
}
.padding(ScarfSpace.s4)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Inspector
@ViewBuilder
private var inspectorPane: some View {
if let taskId = inspectorTaskId,
let task = viewModel.tasks.first(where: { $0.id == taskId }) {
KanbanInspectorPane(
service: viewModel.service,
taskId: taskId,
availableAssignees: viewModel.assignees,
onClose: { inspectorTaskId = nil },
onClaim: {
viewModel.attemptMove(taskId: taskId, to: .running)
inspectorTaskId = nil
},
onComplete: {
completeSheetTaskId = taskId
completeSheetTitle = task.title
},
onBlock: {
blockSheetTaskId = taskId
blockSheetTitle = task.title
blockSheetDestination = .blocked
},
onUnblock: {
viewModel.attemptMove(taskId: taskId, to: .upNext)
inspectorTaskId = nil
},
onArchive: {
viewModel.archive(taskId: taskId)
inspectorTaskId = nil
},
onReassign: { profile in
viewModel.reassignTask(taskId: taskId, to: profile)
}
)
}
}
// MARK: - Drop handling
private func handleDrop(_ taskId: String, on destination: KanbanBoardColumn) {
guard let task = viewModel.tasks.first(where: { $0.id == taskId }) else { return }
// Sheets first when the transition needs user input.
switch destination {
case .blocked:
blockSheetTaskId = taskId
blockSheetTitle = task.title
blockSheetDestination = .blocked
case .done:
// Manual checkoffs from running don't strictly need a result,
// but we offer the sheet anyway so users can record one
// when relevant. The move fires regardless on submit.
if KanbanStatus.from(task.status) == .running {
completeSheetTaskId = taskId
completeSheetTitle = task.title
} else {
viewModel.attemptMove(taskId: taskId, to: destination)
}
default:
viewModel.attemptMove(taskId: taskId, to: destination)
}
}
private var blockSheetBinding: Binding<Bool> {
Binding(
get: { blockSheetTaskId != nil },
set: { if !$0 { blockSheetTaskId = nil } }
)
}
private var completeSheetBinding: Binding<Bool> {
Binding(
get: { completeSheetTaskId != nil },
set: { if !$0 { completeSheetTaskId = nil } }
)
}
// MARK: - Helpers
private var isLive: Bool {
guard let lastPoll = viewModel.lastPollAt else { return false }
return Date().timeIntervalSince(lastPoll) < 6
}
/// Tasks currently in `ready` (a Hermes status that the dispatcher
/// will promote to `running` next tick). Surfaced as a pill on the
/// To Do column header.
private var readyCount: Int {
viewModel.tasks.filter { KanbanStatus.from($0.status) == .ready }.count
}
private func errorBanner(_ message: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer()
Button {
viewModel.lastError = nil
Task { await viewModel.refresh() }
} label: {
Text("Retry")
.scarfStyle(.caption)
}
.buttonStyle(ScarfGhostButton())
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ScarfColor.warning.opacity(0.12))
}
private func noticeBanner(_ message: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "info.circle")
.foregroundStyle(ScarfColor.info)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer()
Button {
viewModel.transientNotice = nil
} label: {
Image(systemName: "xmark")
.font(.system(size: 10))
}
.buttonStyle(ScarfGhostButton())
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ScarfColor.info.opacity(0.12))
}
}
@@ -0,0 +1,302 @@
import SwiftUI
import ScarfCore
import ScarfDesign
import CoreTransferable
/// Transferable wrapper for a kanban task id. We tunnel the payload
/// through `String` via `ProxyRepresentation` (no custom UTI needed)
/// because SwiftUI's drag-drop with custom-UTI `CodableRepresentation`
/// requires a registered exported type in Info.plist to round-trip
/// reliably; the proxy form skips that ceremony and consistently lands
/// drops in v15 / 26.
struct KanbanTaskRef: Transferable {
let id: String
static var transferRepresentation: some TransferRepresentation {
ProxyRepresentation(
exporting: { (ref: KanbanTaskRef) in ref.id },
importing: { (id: String) in KanbanTaskRef(id: id) }
)
}
}
/// Single Kanban card. Variant chrome differs by status:
/// - **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)
struct KanbanCardView: View {
let task: HermesKanbanTask
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
titleRow
if hasMetaRow1 {
metaRow1
}
if !task.skills.isEmpty {
skillsRow
}
footerRow
}
.padding(ScarfSpace.s3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.fill(ScarfColor.backgroundPrimary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.stroke(ScarfColor.border, lineWidth: 1)
)
.overlay(alignment: .leading) {
if let edgeColor {
Rectangle()
.fill(edgeColor)
.frame(width: 2)
.clipShape(
RoundedRectangle(cornerRadius: 1, style: .continuous)
)
.padding(.vertical, 4)
}
}
}
.buttonStyle(.plain)
.scarfShadow(.sm)
.opacity(task.isDone ? doneOpacity : 1.0)
.draggable(KanbanTaskRef(id: task.id)) {
// Drag preview the live card with a heavier shadow.
self.dragPreview
}
}
private var titleRow: some View {
HStack(alignment: .top, spacing: ScarfSpace.s2) {
statusGlyph
Text(task.title)
.scarfStyle(.bodyEmph)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
if needsAssignmentWarning {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
.font(.system(size: 11, weight: .semibold))
.help("Unassigned — Hermes's dispatcher silently skips tasks with no assignee, so this task will never run automatically. Open the task and add an assignee, or recreate it with one set.")
}
}
}
/// Cards in `todo` or `ready` with no `assignee` are about to land
/// in a silent zombie state Hermes's dispatcher's `--json`
/// output literally lists them under `skipped_unassigned` and
/// moves on. Surfacing this on the card itself (vs. only inside
/// the inspector) is the only way the user has a chance to notice
/// before they sit there confused.
private var needsAssignmentWarning: Bool {
let column = KanbanStatus.from(task.status).boardColumn
guard column == .upNext || column == .triage else { return false }
return (task.assignee?.isEmpty ?? true)
}
@ViewBuilder
private var statusGlyph: some View {
switch KanbanStatus.from(task.status) {
case .blocked:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
.font(.system(size: 11, weight: .semibold))
.padding(.top, 2)
case .done:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(ScarfColor.success)
.font(.system(size: 11, weight: .semibold))
.padding(.top, 2)
case .running:
// No leading glyph the left-edge accent + shimmer
// already encodes the live state.
EmptyView()
default:
EmptyView()
}
}
private var hasMetaRow1: Bool {
task.assignee?.isEmpty == false || task.workspaceKind != nil
}
private var metaRow1: some View {
HStack(spacing: ScarfSpace.s2) {
if let assignee = task.assignee, !assignee.isEmpty {
assigneeChip(assignee)
} else {
unassignedChip
}
if let workspace = task.workspaceKind {
ScarfBadge(workspace, kind: .neutral)
}
Spacer(minLength: 0)
}
}
private func assigneeChip(_ name: String) -> some View {
HStack(spacing: 4) {
Text(initials(of: name))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(ScarfColor.accentActive)
.frame(width: 16, height: 16)
.background(ScarfColor.accentTint)
.clipShape(Circle())
Text(name)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
private var unassignedChip: some View {
Text("Unassigned")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.sm, style: .continuous)
.stroke(
ScarfColor.borderStrong,
style: StrokeStyle(lineWidth: 1, dash: [2, 2])
)
)
}
private var skillsRow: some View {
HStack(spacing: 4) {
let visible = task.skills.prefix(2)
ForEach(Array(visible.enumerated()), id: \.offset) { _, skill in
ScarfBadge(skill, kind: .brand)
}
if task.skills.count > 2 {
ScarfBadge("+\(task.skills.count - 2)", kind: .neutral)
}
Spacer(minLength: 0)
}
}
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)
}
}
}
private func priorityIndicator(_ priority: Int) -> some View {
let color: Color = priority >= 90 ? ScarfColor.danger : ScarfColor.warning
return RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(color)
.frame(width: 8, height: 8)
.help("Priority \(priority)")
}
private var dragPreview: some View {
VStack(alignment: .leading, spacing: 2) {
Text(task.title)
.scarfStyle(.bodyEmph)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
if let assignee = task.assignee, !assignee.isEmpty {
Text(assignee)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
.padding(.horizontal, ScarfSpace.s2)
.padding(.vertical, 6)
.background(ScarfColor.backgroundPrimary)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.stroke(ScarfColor.accent, lineWidth: 1)
)
.scarfShadow(.lg)
}
// MARK: - Helpers
private var edgeColor: Color? {
switch KanbanStatus.from(task.status) {
case .running: return ScarfColor.info
case .blocked: return ScarfColor.warning
default: return nil
}
}
private var doneOpacity: Double {
colorScheme == .dark ? 0.55 : 0.7
}
/// Display string for the footer's relative time slot. The "since"
/// reference depends on status running tasks show how long
/// they've been running; blocked show how long blocked, etc.
private var relativeTimeLabel: String {
switch KanbanStatus.from(task.status) {
case .running:
if let started = task.startedAt, let label = relativeShort(from: started) {
return "running \(label)"
}
return "running"
case .blocked:
// Hermes doesn't expose blocked-since separately; fall
// back to created_at as a coarse signal.
if let created = task.createdAt, let label = relativeShort(from: created) {
return "blocked \(label)"
}
return "blocked"
case .done:
if let completed = task.completedAt, let label = relativeShort(from: completed) {
return "done \(label) ago"
}
return "done"
default:
if let created = task.createdAt, let label = relativeShort(from: created) {
return "\(label) ago"
}
return ""
}
}
private func relativeShort(from iso: String) -> String? {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: iso) {
return Self.relativeFormatter.localizedString(for: date, relativeTo: Date())
}
formatter.formatOptions = [.withInternetDateTime]
if let date = formatter.date(from: iso) {
return Self.relativeFormatter.localizedString(for: date, relativeTo: Date())
}
return nil
}
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
return f
}()
private func initials(of name: String) -> String {
let parts = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
let letters = parts.prefix(2).compactMap { $0.first.map(String.init) }
return letters.joined().uppercased()
}
}
private extension HermesKanbanTask {
var isDone: Bool { KanbanStatus.from(status) == .done }
}
@@ -0,0 +1,134 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// One column of the Kanban board. Owns its drop target, header chrome,
/// scroll viewport, and per-column empty state. Cards are rendered via
/// `KanbanCardView`.
struct KanbanColumnView: View {
let column: KanbanBoardColumn
let tasks: [HermesKanbanTask]
/// Live indicator for the Running column true when polling has
/// ticked within the last 6 seconds.
let isLive: Bool
/// "ready: N " pill on the To Do column.
let readyPillCount: Int
let onTaskTap: (HermesKanbanTask) -> Void
let onCreate: () -> Void
let onDrop: (KanbanTaskRef) -> Void
let canCreate: Bool
@State private var isTargeted = false
var body: some View {
VStack(spacing: 0) {
header
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, ScarfSpace.s2)
.background(ScarfColor.backgroundSecondary.opacity(0.001))
.background(.ultraThinMaterial)
Divider()
.opacity(0.5)
ScrollView {
LazyVStack(spacing: ScarfSpace.s2) {
if tasks.isEmpty {
emptyState
.padding(.top, ScarfSpace.s4)
} else {
ForEach(tasks) { task in
KanbanCardView(task: task) {
onTaskTap(task)
}
}
}
}
.padding(ScarfSpace.s3)
}
}
.frame(minWidth: 240, idealWidth: 300, maxWidth: 360)
.frame(maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
.fill(ScarfColor.backgroundSecondary.opacity(0.6))
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
.stroke(borderColor, lineWidth: isTargeted ? 2 : 1)
)
.animation(.easeInOut(duration: 0.12), value: isTargeted)
.dropDestination(for: KanbanTaskRef.self) { items, _ in
if let ref = items.first {
onDrop(ref)
return true
}
return false
} isTargeted: { targeted in
isTargeted = targeted
}
}
// MARK: - Header
private var header: some View {
HStack(spacing: ScarfSpace.s2) {
Text(column.displayName.uppercased())
.scarfStyle(.captionUppercase)
.foregroundStyle(ScarfColor.foregroundMuted)
ScarfBadge(String(tasks.count), kind: .neutral)
if column == .upNext, readyPillCount > 0 {
Text("ready: \(readyPillCount)")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.info)
}
if column == .running, isLive {
liveIndicator
}
Spacer(minLength: 0)
if canCreate {
Button(action: onCreate) {
Image(systemName: "plus")
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(ScarfGhostButton())
.help("New task in \(column.displayName)")
}
}
}
private var liveIndicator: some View {
HStack(spacing: 4) {
Circle()
.fill(ScarfColor.success)
.frame(width: 6, height: 6)
Text("live")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
private var borderColor: Color {
isTargeted ? ScarfColor.accent : ScarfColor.border
}
// MARK: - Empty state
private var emptyState: some View {
Text(emptyCopy)
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.vertical, ScarfSpace.s4)
}
private var emptyCopy: String {
switch column {
case .triage: return "Nothing waiting on you."
case .upNext: return "Empty queue. Drop a task here."
case .running: return "No live workers."
case .blocked: return "Nothing blocked."
case .done: return "Recent completions appear here."
case .archived: return "No archived tasks."
}
}
}
@@ -0,0 +1,56 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Modal sheet that prompts for an optional "result summary" before
/// firing `kanban complete`. Optional leaving it blank still
/// completes the task; the field captures the most useful Hermes
/// flag for downstream child tasks.
struct KanbanCompleteResultSheet: View {
@Environment(\.dismiss) private var dismiss
let taskTitle: String
let onSubmit: (String?) -> Void
@State private var result: String = ""
@FocusState private var fieldFocused: Bool
var body: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
VStack(alignment: .leading, spacing: 4) {
Text("Complete task")
.scarfStyle(.title3)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(taskTitle)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.lineLimit(2)
}
ScarfTextField("Result summary (optional)", text: $result)
.focused($fieldFocused)
Text("If this task has child tasks, the result is handed to them as upstream context. Leave blank for a quiet completion.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
HStack {
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
.buttonStyle(ScarfSecondaryButton())
Button("Complete") {
onSubmit(result.trimmingCharacters(in: .whitespacesAndNewlines))
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(ScarfPrimaryButton())
}
}
.padding(ScarfSpace.s5)
.frame(width: 460)
.onAppear { fieldFocused = true }
}
}
@@ -0,0 +1,348 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// New Task sheet creates a Kanban task via `hermes kanban create`.
/// Workspace defaults to the project directory when shown from a per-
/// project board (locked); on the global board defaults to scratch.
struct KanbanCreateSheet: View {
@Environment(\.dismiss) private var dismiss
let assignees: [HermesKanbanAssignee]
/// Pre-filled tenant on per-project boards. Empty on global board.
let tenantPrefill: String?
/// Pre-filled project workspace path on per-project boards. When
/// non-nil, the workspace picker is locked to "Project Dir".
let projectWorkspacePath: String?
/// Closure invoked when the user submits VM owner constructs the
/// `KanbanService.create` call.
let onSubmit: (KanbanCreateRequest) async throws -> Void
@State private var title: String = ""
@State private var bodyText: String = ""
/// Default assignee on first appearance. Hermes's dispatcher
/// silently skips unassigned tasks (`skipped_unassigned` field on
/// `kanban dispatch --json` output) so leaving this empty produces
/// tasks that never run. We preselect the active Hermes profile
/// and let the user opt out if they really want unassigned (which
/// is rarely useful typically only when they plan to assign
/// later via CLI or another flow).
@State private var assignee: String = HermesProfileResolver.activeProfileName()
@State private var workspaceKind: WorkspaceKind = .scratch
@State private var priority: Double = 50
@State private var skillsInput: String = ""
@State private var tenant: String = ""
@State private var sendToTriage: Bool = false
@State private var isSubmitting: Bool = false
@State private var submitError: String?
@FocusState private var titleFocused: Bool
enum WorkspaceKind: String, CaseIterable, Identifiable {
case scratch
case worktree
case projectDir
var id: String { rawValue }
var label: String {
switch self {
case .scratch: return "Scratch"
case .worktree: return "Worktree"
case .projectDir: return "Project Dir"
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
header
ScarfDivider()
ScrollView {
VStack(alignment: .leading, spacing: ScarfSpace.s4) {
titleField
descriptionField
assigneePicker
workspaceField
priorityField
skillsField
if projectWorkspacePath == nil {
tenantField
}
triageToggle
}
.padding(.vertical, ScarfSpace.s2)
}
if let error = submitError {
errorBanner(error)
}
ScarfDivider()
footerButtons
}
.padding(ScarfSpace.s5)
.frame(width: 540, height: 660)
.onAppear {
if let path = projectWorkspacePath, !path.isEmpty {
workspaceKind = .projectDir
}
if let prefill = tenantPrefill, !prefill.isEmpty {
tenant = prefill
}
titleFocused = true
}
}
// MARK: - Header
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("New task")
.scarfStyle(.title3)
.foregroundStyle(ScarfColor.foregroundPrimary)
if let prefill = tenantPrefill, !prefill.isEmpty {
Text("Tenant: `\(prefill)`")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
} else {
Text("Adds to the global Kanban board")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
Spacer()
}
}
// MARK: - Fields
private var titleField: some View {
VStack(alignment: .leading, spacing: 4) {
ScarfSectionHeader("Title")
ScarfTextField("What needs doing?", text: $title)
.focused($titleFocused)
}
}
private var descriptionField: some View {
VStack(alignment: .leading, spacing: 4) {
ScarfSectionHeader("Description", subtitle: "Markdown supported")
TextEditor(text: $bodyText)
.scrollContentBackground(.hidden)
.padding(ScarfSpace.s2)
.frame(minHeight: 120, maxHeight: 200)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.fill(ScarfColor.backgroundSecondary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.strokeBorder(ScarfColor.borderStrong, lineWidth: 1)
)
.scarfStyle(.body)
}
}
private var assigneePicker: some View {
VStack(alignment: .leading, spacing: 4) {
ScarfSectionHeader("Assignee")
Menu {
Button("Unassigned") { assignee = "" }
if !assignees.isEmpty {
Divider()
ForEach(assignees) { profile in
Button(profile.profile) { assignee = profile.profile }
}
}
} label: {
HStack {
Text(assignee.isEmpty ? "Unassigned" : assignee)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
.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)
)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
}
}
private var workspaceField: some View {
VStack(alignment: .leading, spacing: 4) {
ScarfSectionHeader("Workspace")
Picker("", selection: $workspaceKind) {
ForEach(allowedWorkspaces) { kind in
Text(kind.label).tag(kind)
}
}
.pickerStyle(.segmented)
.disabled(projectWorkspacePath != nil)
if projectWorkspacePath != nil {
Text("Locked to project directory.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
}
private var allowedWorkspaces: [WorkspaceKind] {
// Project Dir is only meaningful when we have a path.
if projectWorkspacePath == nil {
return [.scratch, .worktree]
}
return WorkspaceKind.allCases
}
private var priorityField: some View {
VStack(alignment: .leading, spacing: 4) {
ScarfSectionHeader("Priority", subtitle: "0100; higher runs first")
HStack(spacing: ScarfSpace.s3) {
Slider(value: $priority, in: 0...100, step: 1)
Text("\(Int(priority))")
.scarfStyle(.bodyEmph)
.frame(width: 32, alignment: .trailing)
.foregroundStyle(ScarfColor.foregroundPrimary)
}
HStack {
Text("low").scarfStyle(.caption).foregroundStyle(ScarfColor.foregroundFaint)
Spacer()
Text("normal").scarfStyle(.caption).foregroundStyle(ScarfColor.foregroundFaint)
Spacer()
Text("high").scarfStyle(.caption).foregroundStyle(ScarfColor.foregroundFaint)
}
}
}
private var skillsField: some View {
VStack(alignment: .leading, spacing: 4) {
ScarfSectionHeader("Skills", subtitle: "Comma-separated names from ~/.hermes/skills/")
ScarfTextField("e.g. translation, github-code-review", text: $skillsInput)
}
}
private var tenantField: some View {
VStack(alignment: .leading, spacing: 4) {
ScarfSectionHeader("Tenant", subtitle: "Optional namespace")
ScarfTextField("(none)", text: $tenant)
}
}
private var triageToggle: some View {
HStack(alignment: .top, spacing: ScarfSpace.s2) {
Toggle("Send to triage", isOn: $sendToTriage)
.toggleStyle(.switch)
Spacer()
}
.padding(.top, 4)
}
private func errorBanner(_ message: String) -> some View {
HStack(spacing: ScarfSpace.s2) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, ScarfSpace.s2)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.fill(ScarfColor.warning.opacity(0.12))
)
}
private var footerButtons: some View {
HStack {
Spacer()
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
.buttonStyle(ScarfSecondaryButton())
Button {
submit()
} label: {
if isSubmitting {
ProgressView()
.controlSize(.small)
} else {
Text("Create task")
}
}
.keyboardShortcut(.defaultAction)
.buttonStyle(ScarfPrimaryButton())
.disabled(title.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
}
}
// MARK: - Submit
private func submit() {
let request = makeRequest()
isSubmitting = true
submitError = nil
Task {
do {
try await onSubmit(request)
isSubmitting = false
dismiss()
} catch let err as KanbanError {
isSubmitting = false
submitError = err.errorDescription
} catch {
isSubmitting = false
submitError = error.localizedDescription
}
}
}
private func makeRequest() -> KanbanCreateRequest {
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedBody = bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAssignee = assignee.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTenant = tenant.trimmingCharacters(in: .whitespacesAndNewlines)
let parsedSkills = skillsInput
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
let workspace: KanbanWorkspaceSpec?
switch workspaceKind {
case .scratch:
workspace = .scratch
case .worktree:
workspace = .worktree
case .projectDir:
if let path = projectWorkspacePath, !path.isEmpty {
workspace = .directory(path)
} else {
workspace = .scratch
}
}
return KanbanCreateRequest(
title: trimmedTitle,
body: trimmedBody.isEmpty ? nil : trimmedBody,
assignee: trimmedAssignee.isEmpty ? nil : trimmedAssignee,
parentIds: [],
workspace: workspace,
tenant: trimmedTenant.isEmpty ? nil : trimmedTenant,
priority: Int(priority),
triage: sendToTriage,
idempotencyKey: nil,
maxRuntimeSeconds: nil,
createdBy: nil,
skills: parsedSkills
)
}
}
@@ -0,0 +1,686 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Side-pane inspector for one Kanban task. Rendered alongside the board
/// (not modally) so the user can drag another card immediately after
/// closing this one. 420pt wide; slides in from the trailing edge.
struct KanbanInspectorPane: View {
@State private var viewModel: KanbanTaskDetailViewModel
let availableAssignees: [HermesKanbanAssignee]
let onClose: () -> Void
let onClaim: () -> Void
let onComplete: () -> Void
let onBlock: () -> Void
let onUnblock: () -> Void
let onArchive: () -> Void
let onReassign: (String?) -> Void
@State private var selectedTab: DetailTab = .comments
enum DetailTab: String, CaseIterable, Identifiable {
case comments = "Comments"
case events = "Events"
case runs = "Runs"
case log = "Log"
var id: String { rawValue }
}
init(
service: KanbanService,
taskId: String,
availableAssignees: [HermesKanbanAssignee] = [],
onClose: @escaping () -> Void,
onClaim: @escaping () -> Void,
onComplete: @escaping () -> Void,
onBlock: @escaping () -> Void,
onUnblock: @escaping () -> Void,
onArchive: @escaping () -> Void,
onReassign: @escaping (String?) -> Void = { _ in }
) {
_viewModel = State(initialValue: KanbanTaskDetailViewModel(service: service, taskId: taskId))
self.availableAssignees = availableAssignees
self.onClose = onClose
self.onClaim = onClaim
self.onComplete = onComplete
self.onBlock = onBlock
self.onUnblock = onUnblock
self.onArchive = onArchive
self.onReassign = onReassign
}
var body: some View {
VStack(spacing: 0) {
header
ScarfDivider()
if let detail = viewModel.detail {
ScrollView {
VStack(alignment: .leading, spacing: ScarfSpace.s3) {
healthBanner(for: detail.task)
bodySection(detail.task)
Picker("", selection: $selectedTab) {
ForEach(DetailTab.allCases) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
switch selectedTab {
case .comments: commentsSection(detail.comments)
case .events: eventsSection(detail.events)
case .runs: runsSection
case .log: logSection(for: detail.task)
}
}
.padding(ScarfSpace.s4)
}
} else if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let err = viewModel.lastError {
errorState(err)
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
ScarfDivider()
actionBar
}
.frame(width: 420)
.frame(maxHeight: .infinity)
.background(ScarfColor.backgroundPrimary)
.task {
// Start the 5s detail-poll loop. First iteration runs the
// initial fetch so the user sees the same load latency as
// the previous one-shot `viewModel.load()` did.
viewModel.startDetailPolling()
}
.onChange(of: viewModel.taskId) { _, _ in
viewModel.stopLogPolling()
viewModel.stopDetailPolling()
viewModel.startDetailPolling()
}
.onChange(of: selectedTab) { _, newTab in
handleTabChange(newTab)
}
.onChange(of: viewModel.detail?.task.status ?? "") { _, _ in
// If the task transitions to running while the log tab is
// open, start polling. If it transitions out, the polling
// loop self-cancels.
if selectedTab == .log {
handleTabChange(.log)
}
}
.onDisappear {
viewModel.stopLogPolling()
viewModel.stopDetailPolling()
}
}
private func handleTabChange(_ tab: DetailTab) {
guard tab == .log else {
viewModel.stopLogPolling()
return
}
let isRunning = (viewModel.detail?.task.status).flatMap {
KanbanStatus.from($0)
} == .running
if isRunning {
viewModel.startLogPolling()
} else {
// Static fetch for terminal-state tasks (done/blocked/etc).
viewModel.stopLogPolling()
Task { await viewModel.refreshLogOnce() }
}
}
// MARK: - Header
private var header: some View {
HStack(alignment: .top, spacing: ScarfSpace.s2) {
VStack(alignment: .leading, spacing: 4) {
if let task = viewModel.detail?.task {
Text(task.title)
.scarfStyle(.title3)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(2)
// Horizontal scroll lets the chip row degrade
// gracefully on narrow inspectors (or with long
// profile / tenant names) instead of wrapping
// chips onto a second visual line, which looked
// broken when a single name pushed past the
// available width.
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ScarfBadge(task.status.lowercased(), kind: badgeKind(for: task.status))
.fixedSize()
assigneeMenu(for: task)
.fixedSize()
if let workspace = task.workspaceKind {
ScarfBadge(workspace, kind: .neutral)
.fixedSize()
}
if let tenant = task.tenant, !tenant.isEmpty {
ScarfBadge(tenant, kind: .brand)
.fixedSize()
}
}
}
} else {
Text("Loading…")
.scarfStyle(.title3)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(ScarfGhostButton())
.keyboardShortcut(.cancelAction)
}
.padding(ScarfSpace.s4)
}
/// Inline assignee picker. Renders as a clickable badge styled to
/// match neighboring chips: `.brand` when set, `.warning` when
/// unassigned (so the user immediately sees the signal). Menu
/// items list every known profile + "Unassigned"; selection
/// routes through `onReassign`, which on the board side calls
/// `kanban assign <id> <profile>` and then `kanban dispatch`.
private func assigneeMenu(for task: HermesKanbanTask) -> some View {
let current = task.assignee?.isEmpty == false ? task.assignee : nil
let options = mergedAssigneeOptions(currentAssignee: current)
let label = current ?? "Unassigned"
let kind: ScarfBadgeKind = (current == nil) ? .warning : .brand
return Menu {
Button("Unassigned") { onReassign(nil) }
if !options.isEmpty {
Divider()
ForEach(options, id: \.self) { profile in
Button(profile) { onReassign(profile) }
}
}
} label: {
HStack(spacing: 4) {
ScarfBadge(label, kind: kind)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(ScarfColor.foregroundMuted)
}
.fixedSize() // prevent chevron + badge from wrapping
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.help(current == nil
? "Assign a profile so the dispatcher can spawn a worker."
: "Reassign this task. Hermes's dispatcher only runs assigned tasks.")
}
/// Build the assignee dropdown list. Sources, in order:
/// 1. The board's known-assignees list (passed in via init
/// union of `~/.hermes/profiles/` and current task assignees).
/// 2. The active local Hermes profile.
/// 3. The task's current assignee (so reassigning back is one tap).
/// Deduped, sorted for stability.
private func mergedAssigneeOptions(currentAssignee: String?) -> [String] {
var set = Set<String>()
for entry in availableAssignees {
set.insert(entry.profile)
}
let active = HermesProfileResolver.activeProfileName()
if !active.isEmpty {
set.insert(active)
}
if let currentAssignee {
set.insert(currentAssignee)
}
return set.sorted()
}
private func badgeKind(for status: String) -> ScarfBadgeKind {
switch KanbanStatus.from(status) {
case .running, .ready: return .info
case .done: return .success
case .blocked: return .warning
case .archived: return .neutral
default: return .neutral
}
}
// 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.
@ViewBuilder
private func healthBanner(for task: HermesKanbanTask) -> some View {
let status = KanbanStatus.from(task.status)
let column = status.boardColumn
let isUnassigned = (task.assignee?.isEmpty ?? true)
let needsAssignee = (column == .upNext || column == .triage) && isUnassigned
// Pick the most recent **completed** run by id descending
// skipping any in-flight run so a fresh worker doesn't show
// up here. The previous reclaimed/crashed run is only
// user-relevant *until* the next attempt actually starts;
// the moment status flips to running, the Log tab's live
// stream is the right signal and a stale banner just adds
// noise.
let lastEndedRun = viewModel.runs
.filter { $0.endedAt != nil }
.max(by: { $0.id < $1.id })
let failureOutcomes: Set<String> = [
"stale_lock", "reclaimed", "crashed",
"timed_out", "spawn_failed", "gave_up", "failed"
]
let hadFailedEndedRun = lastEndedRun
.flatMap { (run: HermesKanbanRun) -> String? in
run.outcome ?? run.status
}
.map { failureOutcomes.contains($0.lowercased()) }
?? false
// Suppress the failure banner during an active attempt once
// status is `running` again, the previous outcome is stale.
// 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
)
}
}
private func bannerRow(
icon: String,
tint: Color,
title: String,
message: String
) -> some View {
HStack(alignment: .top, spacing: ScarfSpace.s2) {
Image(systemName: icon)
.foregroundStyle(tint)
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.scarfStyle(.captionStrong)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
Spacer(minLength: 0)
}
.padding(ScarfSpace.s2)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.fill(tint.opacity(0.10))
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.strokeBorder(tint.opacity(0.4), lineWidth: 1)
)
}
@ViewBuilder
private func bodySection(_ task: HermesKanbanTask) -> some View {
if let body = task.body, !body.isEmpty {
if let attributed = try? AttributedString(markdown: body) {
Text(attributed)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(body)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
}
} else {
Text("No description.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
private func commentsSection(_ comments: [HermesKanbanComment]) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
if comments.isEmpty {
Text("No comments yet.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
} else {
ForEach(comments) { comment in
commentRow(comment)
}
}
commentComposer
}
}
private func commentRow(_ comment: HermesKanbanComment) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: ScarfSpace.s2) {
Text(comment.author)
.scarfStyle(.captionStrong)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(comment.createdAt)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
Text(comment.body)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(ScarfSpace.s2)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.fill(ScarfColor.backgroundSecondary.opacity(0.5))
)
}
private var commentComposer: some View {
VStack(alignment: .leading, spacing: 4) {
ScarfTextField("Add a comment…", text: Binding(
get: { viewModel.commentDraft },
set: { viewModel.commentDraft = $0 }
))
HStack {
Spacer()
Button("Comment") {
Task { await viewModel.submitComment() }
}
.buttonStyle(ScarfPrimaryButton())
.disabled(viewModel.commentDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(.top, ScarfSpace.s2)
}
private func eventsSection(_ events: [HermesKanbanEvent]) -> some View {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
if events.isEmpty {
Text("No events yet.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
} else {
ForEach(events) { event in
eventRow(event)
}
}
}
}
private func eventRow(_ event: HermesKanbanEvent) -> some View {
HStack(alignment: .top, spacing: ScarfSpace.s2) {
Image(systemName: glyphForEventKind(event.kindEnum))
.foregroundStyle(colorForEventKind(event.kindEnum))
.frame(width: 16)
VStack(alignment: .leading, spacing: 2) {
Text(event.kind)
.scarfStyle(.captionStrong)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(event.createdAt)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
Spacer(minLength: 0)
}
}
private func glyphForEventKind(_ kind: KanbanEventKind) -> String {
switch kind {
case .created: return "plus.circle"
case .claimed: return "hand.raised"
case .started: return "play.circle"
case .completed: return "checkmark.circle.fill"
case .blocked: return "exclamationmark.triangle.fill"
case .unblocked: return "arrow.uturn.backward"
case .commented: return "text.bubble"
case .archived: return "archivebox"
case .heartbeat: return "waveform.path"
case .crashed, .timedOut, .spawnFailed, .error: return "xmark.octagon.fill"
case .statusChange, .released, .unknown: return "arrow.right"
}
}
private func colorForEventKind(_ kind: KanbanEventKind) -> Color {
switch kind {
case .completed: return ScarfColor.success
case .blocked, .crashed, .timedOut, .spawnFailed, .error: return ScarfColor.warning
case .claimed, .started, .unblocked: return ScarfColor.info
default: return ScarfColor.foregroundMuted
}
}
@ViewBuilder
private func logSection(for task: HermesKanbanTask) -> some View {
let isRunning = KanbanStatus.from(task.status) == .running
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
HStack(spacing: 6) {
if isRunning && viewModel.isLogStreaming {
Circle()
.fill(ScarfColor.success)
.frame(width: 6, height: 6)
Text("streaming")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
} else if isRunning {
Text("waiting for first poll…")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
} else {
Text("snapshot from `hermes kanban log \(task.id)`")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
Spacer()
Button {
Task { await viewModel.refreshLogOnce() }
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11))
}
.buttonStyle(ScarfGhostButton())
.help("Refresh worker log")
}
if viewModel.log.isEmpty {
Text(isRunning
? "No output yet. The worker may not have written anything to stdout / stderr."
: "No log captured for this task.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
.padding(.vertical, ScarfSpace.s2)
} else {
ScrollViewReader { proxy in
ScrollView {
Text(viewModel.log)
.font(.system(size: 11, weight: .regular, design: .monospaced))
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(ScarfSpace.s2)
// Invisible anchor pinned to the bottom so we
// can `scrollTo(.bottom)` whenever the log
// grows during a poll tick.
Color.clear.frame(height: 1).id("log-bottom-anchor")
}
.onChange(of: viewModel.log) { _, _ in
withAnimation(.linear(duration: 0.1)) {
proxy.scrollTo("log-bottom-anchor", anchor: .bottom)
}
}
}
.frame(maxHeight: 280)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.fill(ScarfColor.backgroundSecondary.opacity(0.5))
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
}
}
}
private var runsSection: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
if viewModel.runs.isEmpty {
Text("No runs yet.")
.scarfStyle(.footnote)
.foregroundStyle(ScarfColor.foregroundFaint)
} else {
ForEach(viewModel.runs) { run in
runRow(run)
}
}
}
}
private func runRow(_ run: HermesKanbanRun) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: ScarfSpace.s2) {
ScarfBadge(run.outcome ?? run.status, kind: outcomeKind(run.outcome ?? run.status))
if let profile = run.profile {
Text(profile)
.scarfStyle(.captionStrong)
.foregroundStyle(ScarfColor.foregroundPrimary)
}
Spacer()
Text(run.startedAt)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
if let summary = run.summary, !summary.isEmpty {
Text(summary)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let error = run.error, !error.isEmpty {
Text(error)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.danger)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(ScarfSpace.s2)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous)
.fill(ScarfColor.backgroundSecondary.opacity(0.4))
)
}
private func outcomeKind(_ outcome: String) -> ScarfBadgeKind {
switch outcome.lowercased() {
case "completed", "done": return .success
case "blocked": return .warning
case "crashed", "timed_out", "spawn_failed", "failed": return .danger
case "running": return .info
default: return .neutral
}
}
// MARK: - Action bar
@ViewBuilder
private var actionBar: some View {
HStack(spacing: ScarfSpace.s2) {
primaryAction
secondaryActions
Spacer()
archiveAction
}
.padding(ScarfSpace.s3)
}
@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:
EmptyView()
}
}
}
@ViewBuilder
private var secondaryActions: some View {
if let task = viewModel.detail?.task {
switch KanbanStatus.from(task.status) {
case .ready, .todo, .running:
Button("Block", action: onBlock)
.buttonStyle(ScarfSecondaryButton())
.help("Mark this task blocked with a reason. The reason is appended as a comment.")
default:
EmptyView()
}
}
}
@ViewBuilder
private var archiveAction: some View {
if let task = viewModel.detail?.task,
KanbanStatus.from(task.status) != .archived {
Button("Archive", action: onArchive)
.buttonStyle(ScarfDestructiveButton())
.help("Hide this task from the active board. Hermes has no hard-delete; archived tasks remain in `~/.hermes/kanban.db` and are recoverable via the \"Show archived\" toggle until `hermes kanban gc` runs.")
}
}
// MARK: - Error
private func errorState(_ message: String) -> some View {
VStack(spacing: ScarfSpace.s2) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundStyle(ScarfColor.warning)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.multilineTextAlignment(.center)
Button("Retry") {
Task { await viewModel.load() }
}
.buttonStyle(ScarfSecondaryButton())
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(ScarfSpace.s4)
}
}
@@ -0,0 +1,168 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// The v2.6 read-only list view, preserved as a presentation fallback
/// alongside the v2.7.5 drag-and-drop board. Reuses the existing
/// `KanbanViewModel` (status-filter polling) so the list stays
/// independent of the board's optimistic-merge state.
struct KanbanListView: View {
@State private var viewModel: KanbanViewModel
init(context: ServerContext) {
_viewModel = State(initialValue: KanbanViewModel(context: context))
}
var body: some View {
VStack(spacing: 0) {
ScarfPageHeader(
"Kanban",
subtitle: "Hermes v0.12+ task board (list view)"
) {
HStack(spacing: ScarfSpace.s2) {
Picker("Status", selection: $viewModel.statusFilter) {
ForEach(KanbanViewModel.StatusFilter.allCases) { f in
Text(f.label).tag(f)
}
}
.pickerStyle(.menu)
.frame(width: 120)
Button {
Task { await viewModel.load() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(ScarfGhostButton())
}
}
Divider()
if let err = viewModel.lastError {
errorBanner(err)
}
ScrollView {
if viewModel.tasks.isEmpty && !viewModel.isLoading {
emptyState
} else {
taskTable
}
}
}
.background(ScarfColor.backgroundPrimary)
.onChange(of: viewModel.statusFilter) { _, _ in
Task { await viewModel.load() }
}
.onAppear { viewModel.startPolling() }
.onDisappear { viewModel.stopPolling() }
}
private var taskTable: some View {
VStack(spacing: 0) {
ForEach(viewModel.tasks) { task in
taskRow(task)
Divider()
}
}
.padding(ScarfSpace.s3)
}
private func taskRow(_ task: HermesKanbanTask) -> some View {
HStack(alignment: .top, spacing: ScarfSpace.s3) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: ScarfSpace.s2) {
statusBadge(for: task.status)
Text(task.title)
.scarfStyle(.bodyEmph)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
}
HStack(spacing: 12) {
metaChip(systemImage: "number", value: String(task.id.prefix(8)))
if let assignee = task.assignee, !assignee.isEmpty {
metaChip(systemImage: "person.fill", value: assignee)
}
if let workspace = task.workspaceKind {
metaChip(systemImage: "folder", value: workspace)
}
if let tenant = task.tenant, !tenant.isEmpty {
metaChip(systemImage: "tag", value: tenant)
}
if !task.skills.isEmpty {
metaChip(systemImage: "lightbulb", value: task.skills.joined(separator: ", "))
}
Spacer(minLength: 0)
}
}
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 2) {
if let createdAt = task.createdAt {
Text(createdAt)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
if let priority = task.priority {
Text("p\(priority)")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
}
.padding(.vertical, ScarfSpace.s2)
}
private func statusBadge(for status: String) -> some View {
let kind: ScarfBadgeKind
switch status.lowercased() {
case "done": kind = .success
case "running": kind = .info
case "ready": kind = .info
case "blocked": kind = .warning
case "archived": kind = .neutral
default: kind = .neutral
}
return ScarfBadge(status, kind: kind)
}
private func metaChip(systemImage: String, value: String) -> some View {
HStack(spacing: 3) {
Image(systemName: systemImage)
.font(.system(size: 10))
Text(value)
.font(ScarfFont.monoSmall)
}
.foregroundStyle(ScarfColor.foregroundMuted)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "rectangle.split.3x1")
.font(.system(size: 36))
.foregroundStyle(ScarfColor.foregroundFaint)
Text("No kanban tasks")
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Create one with `hermes kanban create \"task title\"`. Tasks dispatched by the gateway show up here automatically.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.multilineTextAlignment(.center)
.frame(maxWidth: 460)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 60)
}
private func errorBanner(_ message: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ScarfColor.warning.opacity(0.12))
}
}
@@ -2,166 +2,49 @@ import SwiftUI
import ScarfCore
import ScarfDesign
/// Mac UI for `hermes kanban list` (v0.12+). Read-only create / claim
/// / dispatch / dependency-link UI is deferred until upstream
/// stabilizes the multi-profile collaboration design.
/// Top-level Mac Kanban surface toggles between the v2.7.5 board view
/// (drag-and-drop, full read/write) and the legacy v2.6 read-only list.
/// Kept as a single AppCoordinator route so users can switch between
/// presentations without leaving the route, and so accessibility users
/// (or anyone with a narrow window) keep a usable list fallback.
///
/// Capability-gated upstream: AppCoordinator only routes to this view
/// when `HermesCapabilities.hasKanban` is true.
/// Capability-gated upstream: `SidebarView` only lists this route when
/// `HermesCapabilities.hasKanban` is true.
struct KanbanView: View {
@State private var viewModel: KanbanViewModel
let context: ServerContext
init(context: ServerContext) {
_viewModel = State(initialValue: KanbanViewModel(context: context))
@AppStorage("kanban.viewMode") private var rawMode: String = ViewMode.board.rawValue
enum ViewMode: String {
case board
case list
}
var body: some View {
VStack(spacing: 0) {
ScarfPageHeader(
"Kanban",
subtitle: "Hermes v0.12+ task board (read-only)"
) {
HStack(spacing: ScarfSpace.s2) {
Picker("Status", selection: $viewModel.statusFilter) {
ForEach(KanbanViewModel.StatusFilter.allCases) { f in
Text(f.label).tag(f)
}
}
.pickerStyle(.menu)
.frame(width: 120)
Button {
Task { await viewModel.load() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(ScarfGhostButton())
}
}
Divider()
if let err = viewModel.lastError {
errorBanner(err)
}
ScrollView {
if viewModel.tasks.isEmpty && !viewModel.isLoading {
emptyState
} else {
taskTable
}
modeBar
ScarfDivider()
switch ViewMode(rawValue: rawMode) ?? .board {
case .board:
KanbanBoardView(context: context)
case .list:
KanbanListView(context: context)
}
}
.background(ScarfColor.backgroundPrimary)
.onChange(of: viewModel.statusFilter) { _, _ in
Task { await viewModel.load() }
}
.onAppear { viewModel.startPolling() }
.onDisappear { viewModel.stopPolling() }
}
private var taskTable: some View {
VStack(spacing: 0) {
ForEach(viewModel.tasks) { task in
taskRow(task)
Divider()
private var modeBar: some View {
HStack(spacing: ScarfSpace.s2) {
Spacer()
Picker("View", selection: $rawMode) {
Text("Board").tag(ViewMode.board.rawValue)
Text("List").tag(ViewMode.list.rawValue)
}
}
.padding(ScarfSpace.s3)
}
private func taskRow(_ task: HermesKanbanTask) -> some View {
HStack(alignment: .top, spacing: ScarfSpace.s3) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: ScarfSpace.s2) {
statusBadge(for: task.status)
Text(task.title)
.scarfStyle(.bodyEmph)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
}
HStack(spacing: 12) {
metaChip(systemImage: "number", value: task.id.prefix(8) + "")
if let assignee = task.assignee, !assignee.isEmpty {
metaChip(systemImage: "person.fill", value: assignee)
}
if let workspace = task.workspaceKind {
metaChip(systemImage: "folder", value: workspace)
}
if !task.skills.isEmpty {
metaChip(systemImage: "lightbulb", value: task.skills.joined(separator: ", "))
}
Spacer(minLength: 0)
}
}
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 2) {
if let createdAt = task.createdAt {
Text(createdAt)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
}
if let priority = task.priority {
Text("p\(priority)")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
}
.padding(.vertical, ScarfSpace.s2)
}
private func statusBadge(for status: String) -> some View {
let kind: ScarfBadgeKind
switch status.lowercased() {
case "done": kind = .success
case "running": kind = .info
case "ready": kind = .info
case "blocked": kind = .warning
case "archived": kind = .neutral
default: kind = .neutral
}
return ScarfBadge(status, kind: kind)
}
private func metaChip(systemImage: String, value: String) -> some View {
HStack(spacing: 3) {
Image(systemName: systemImage)
.font(.system(size: 10))
Text(value)
.font(ScarfFont.monoSmall)
}
.foregroundStyle(ScarfColor.foregroundMuted)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "rectangle.split.3x1")
.font(.system(size: 36))
.foregroundStyle(ScarfColor.foregroundFaint)
Text("No kanban tasks")
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text("Create one with `hermes kanban create \"task title\"`. Tasks dispatched by the gateway show up here automatically.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.multilineTextAlignment(.center)
.frame(maxWidth: 460)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.vertical, 60)
}
private func errorBanner(_ message: String) -> some View {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundPrimary)
.pickerStyle(.segmented)
.frame(width: 160)
}
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ScarfColor.warning.opacity(0.12))
.padding(.vertical, ScarfSpace.s2)
}
}
@@ -0,0 +1,68 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// Per-project Kanban tab. Wraps `KanbanBoardView` with the project's
/// tenant pre-applied + the workspace pre-pinned to the project
/// directory. On first appearance it mints the project's
/// `scarf:<slug>` tenant if one isn't already on disk.
///
/// Capability-gated by `HermesCapabilities.hasKanban` upstream this
/// view is only added to the project tab list when v0.12+ is detected.
struct ProjectKanbanTab: View {
@Environment(\.serverContext) private var serverContext
let project: ProjectEntry
@State private var resolvedTenant: String?
@State private var resolveError: String?
var body: some View {
Group {
if let tenant = resolvedTenant {
KanbanBoardView(
context: serverContext,
tenantFilter: tenant,
projectPath: project.path,
projectName: project.name
)
} else if let error = resolveError {
VStack(spacing: ScarfSpace.s3) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 32))
.foregroundStyle(ScarfColor.warning)
Text("Couldn't set up the project's Kanban tenant.")
.scarfStyle(.headline)
Text(error)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.multilineTextAlignment(.center)
Button("Retry") {
resolveError = nil
resolveTenant()
}
.buttonStyle(ScarfSecondaryButton())
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task(id: project.id) {
resolveTenant()
}
}
private func resolveTenant() {
let resolver = KanbanTenantResolver(context: serverContext)
// Always-mint behaviour: even if the project board is empty
// and the user hasn't created a task yet, the tenant is
// pre-allocated so AGENTS.md surfaces it on the next chat.
do {
resolvedTenant = try resolver.resolveOrMint(for: project)
} catch {
resolveError = error.localizedDescription
}
}
}
@@ -7,6 +7,7 @@ private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard"
case site = "Site"
case sessions = "Sessions"
case kanban = "Kanban"
case slashCommands = "Slash"
var displayName: LocalizedStringResource {
@@ -14,6 +15,7 @@ private enum DashboardTab: String, CaseIterable {
case .dashboard: return "Dashboard"
case .site: return "Site"
case .sessions: return "Sessions"
case .kanban: return "Kanban"
case .slashCommands: return "Slash Commands"
}
}
@@ -23,6 +25,7 @@ private enum DashboardTab: String, CaseIterable {
case .dashboard: return "square.grid.2x2"
case .site: return "globe"
case .sessions: return "bubble.left.and.bubble.right"
case .kanban: return "rectangle.split.3x1"
case .slashCommands: return "slash.circle"
}
}
@@ -35,6 +38,7 @@ struct ProjectsView: View {
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
@Environment(\.serverContext) private var serverContext
@Environment(\.hermesCapabilities) private var capabilitiesStore
@State private var showingAddSheet = false
@State private var showingNewProjectSheet = false
@State private var showingInstallSheet = false
@@ -444,6 +448,12 @@ struct ProjectsView: View {
} else {
ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right")
}
case .kanban:
if let project = viewModel.selectedProject {
ProjectKanbanTab(project: project)
} else {
ContentUnavailableView("No project selected", systemImage: "rectangle.split.3x1")
}
case .slashCommands:
if let project = viewModel.selectedProject {
ProjectSlashCommandsView(project: project)
@@ -488,9 +498,16 @@ struct ProjectsView: View {
/// Tabs that should appear for the current project. `.site` is
/// gated on the dashboard actually containing a webview widget,
/// per v2.2 behavior the Site tab is meaningless without one.
/// `.kanban` is gated on `HermesCapabilities.hasKanban` so
/// pre-v0.12 hosts don't see a broken destination.
private var visibleTabs: [DashboardTab] {
DashboardTab.allCases.filter { tab in
tab != .site || siteWidget != nil
let caps = capabilitiesStore?.capabilities
return DashboardTab.allCases.filter { tab in
switch tab {
case .site: return siteWidget != nil
case .kanban: return caps?.hasKanban ?? false
default: return true
}
}
}
@@ -656,6 +673,8 @@ struct WidgetView: View {
ImageWidgetView(widget: widget)
case "status_grid":
StatusGridWidgetView(widget: widget)
case "kanban_summary":
KanbanSummaryWidgetView(widget: widget)
default:
WidgetErrorCard(
title: widget.title,
@@ -0,0 +1,194 @@
import SwiftUI
import ScarfCore
import ScarfDesign
/// `kanban_summary` dashboard widget. Renders a compact 3-row list of
/// the most-pressing tasks (running + blocked + todo, by priority)
/// for the active project's tenant, plus a glance string footer.
///
/// Looks up the project's tenant from `<project>/.scarf/manifest.json`
/// at first render (cheap; cached). Falls back to "no tasks" copy when
/// no tenant is minted yet (i.e. the user hasn't opened the Kanban
/// tab yet).
struct KanbanSummaryWidgetView: View {
let widget: DashboardWidget
@Environment(\.serverContext) private var serverContext
@Environment(\.selectedProjectRoot) private var projectRoot
@State private var tenant: String?
@State private var tasks: [HermesKanbanTask] = []
@State private var stats: HermesKanbanStats = .empty
@State private var isLoading = false
@State private var error: String?
@State private var pollTask: Task<Void, Never>?
private var maxRows: Int {
if case .number(let n) = widget.value { return max(1, Int(n)) }
return 3
}
var body: some View {
ScarfCard {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
header
if let error {
errorRow(error)
} else if tasks.isEmpty && !isLoading {
emptyRow
} else {
ForEach(tasks.prefix(maxRows)) { task in
taskRow(task)
}
}
if !stats.glanceString.isEmpty {
Text(stats.glanceString)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
.padding(.top, 4)
}
}
}
.onAppear { startPolling() }
.onDisappear { pollTask?.cancel() }
}
private var header: some View {
HStack {
Text(widget.title.isEmpty ? "Kanban" : widget.title)
.scarfStyle(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer()
Image(systemName: "rectangle.split.3x1")
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
private func taskRow(_ task: HermesKanbanTask) -> some View {
HStack(spacing: ScarfSpace.s2) {
statusDot(for: task.status)
Text(task.title)
.scarfStyle(.body)
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
Spacer(minLength: 0)
if let assignee = task.assignee, !assignee.isEmpty {
Text(initials(of: assignee))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(ScarfColor.accentActive)
.frame(width: 16, height: 16)
.background(ScarfColor.accentTint)
.clipShape(Circle())
}
}
.padding(.vertical, 2)
}
private func statusDot(for status: String) -> some View {
let color: Color
switch KanbanStatus.from(status) {
case .running: color = ScarfColor.info
case .blocked: color = ScarfColor.warning
case .done: color = ScarfColor.success
default: color = ScarfColor.foregroundMuted
}
return Circle()
.fill(color)
.frame(width: 8, height: 8)
}
private var emptyRow: some View {
Text("No active tasks for this project.")
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundFaint)
.padding(.vertical, ScarfSpace.s2)
}
private func errorRow(_ message: String) -> some View {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
.font(.caption)
Text(message)
.scarfStyle(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
.lineLimit(2)
}
}
// MARK: - Loading
private func startPolling() {
pollTask?.cancel()
pollTask = Task {
while !Task.isCancelled {
await loadOnce()
try? await Task.sleep(nanoseconds: 10_000_000_000)
}
}
}
private func loadOnce() async {
guard let projectRoot, !projectRoot.isEmpty else { return }
if tenant == nil {
tenant = readTenant(at: projectRoot)
}
guard let tenant, !tenant.isEmpty else {
tasks = []
return
}
isLoading = true
defer { isLoading = false }
let svc = KanbanService(context: serverContext)
let filter = KanbanListFilter(tenant: tenant)
do {
let polled = try await svc.list(filter)
// Sort by priority DESC, status preference (running > blocked > todo).
tasks = polled
.filter {
let status = KanbanStatus.from($0.status)
return status != .done && status != .archived
}
.sorted { lhs, rhs in
let lp = lhs.priority ?? 0
let rp = rhs.priority ?? 0
if lp != rp { return lp > rp }
return statusRank(lhs.status) < statusRank(rhs.status)
}
stats = (try? await svc.stats()) ?? .empty
error = nil
} catch let err as KanbanError {
error = err.errorDescription
} catch {
self.error = error.localizedDescription
}
}
private nonisolated func statusRank(_ status: String) -> Int {
switch KanbanStatus.from(status) {
case .running: return 0
case .blocked: return 1
case .ready: return 2
case .todo: return 3
default: return 4
}
}
private nonisolated func readTenant(at projectPath: String) -> String? {
let manifestPath = projectPath + "/.scarf/manifest.json"
let transport = serverContext.makeTransport()
guard transport.fileExists(manifestPath),
let data = try? transport.readFile(manifestPath),
let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
else {
return nil
}
return manifest.kanbanTenant
}
private func initials(of name: String) -> String {
let parts = name.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
let letters = parts.prefix(2).compactMap { $0.first.map(String.init) }
return letters.joined().uppercased()
}
}
@@ -71,7 +71,14 @@ struct SkillsView: View {
// changes against a non-empty prior snapshot (first launch
// is silent so users aren't drowned in "everything is
// new!" noise).
if let diff = snapshotDiff,
//
// Issue #78: keep the pill scoped to the Installed tab.
// It describes local file deltas in the installed-skill
// tree; surfacing it above the Hub or Updates tab read as
// a contradiction with the Updates body's separate
// upstream-version check.
if currentTab == .installed,
let diff = snapshotDiff,
diff.hasChanges,
!diff.previousSnapshotEmpty {
whatsNewPill(diff: diff)
+197
View File
@@ -2109,6 +2109,9 @@
}
}
}
},
"Adds to the global Kanban board" : {
},
"Advanced" : {
"localizations" : {
@@ -2277,6 +2280,10 @@
}
}
},
"All assignees" : {
"comment" : "A button that filters tasks by all assignees.",
"isCommentAutoGenerated" : true
},
"All installed hub skills are up to date." : {
"localizations" : {
"de" : {
@@ -2842,6 +2849,10 @@
"comment" : "A label displayed above the arguments of a tool call.",
"isCommentAutoGenerated" : true
},
"Assign a profile so the dispatcher can spawn a worker." : {
"comment" : "A help message for the assignee picker.",
"isCommentAutoGenerated" : true
},
"Assistant Message" : {
"extractionState" : "stale",
"localizations" : {
@@ -2887,6 +2898,10 @@
"comment" : "A description of the dashboard.",
"isCommentAutoGenerated" : true
},
"Atomically claim this task and start the worker. Moves it to Running." : {
"comment" : "A button that starts a task.",
"isCommentAutoGenerated" : true
},
"Attach image (%lld/%lld)" : {
"comment" : "A button that opens a file picker to select an image to attach.",
"isCommentAutoGenerated" : true,
@@ -3518,11 +3533,23 @@
}
}
},
"Block" : {
"comment" : "A button that blocks a task.",
"isCommentAutoGenerated" : true
},
"Block task" : {
"comment" : "A title for a modal sheet that asks for a reason before blocking a task.",
"isCommentAutoGenerated" : true
},
"BlueBubbles Docs" : {
},
"BlueBubbles Server" : {
},
"Board" : {
"comment" : "A label for a Kanban view mode.",
"isCommentAutoGenerated" : true
},
"Browse" : {
"localizations" : {
@@ -5177,6 +5204,17 @@
}
}
}
},
"Comment" : {
"comment" : "A button that adds a comment to a task.",
"isCommentAutoGenerated" : true
},
"Complete" : {
"comment" : "A button that completes a task.",
"isCommentAutoGenerated" : true
},
"Complete task" : {
},
"Component" : {
"localizations" : {
@@ -6054,6 +6092,10 @@
"comment" : "A title displayed when a configuration save fails.",
"isCommentAutoGenerated" : true
},
"Couldn't set up the project's Kanban tenant." : {
"comment" : "A message displayed when the Kanban tenant can't be resolved.",
"isCommentAutoGenerated" : true
},
"Couldn't update slash commands" : {
"comment" : "A title for a banner that appears when an error occurs while updating slash commands.",
"isCommentAutoGenerated" : true
@@ -6470,6 +6512,10 @@
}
}
},
"Create task" : {
"comment" : "A button to create a task.",
"isCommentAutoGenerated" : true
},
"Credential Pools" : {
"localizations" : {
"de" : {
@@ -10428,10 +10474,18 @@
"comment" : "A label for hiding the sessions list.",
"isCommentAutoGenerated" : true
},
"Hide this task from the active board. Hermes has no hard-delete; archived tasks remain in `~/.hermes/kanban.db` and are recoverable via the \"Show archived\" toggle until `hermes kanban gc` runs." : {
"comment" : "A button that archives a task.",
"isCommentAutoGenerated" : true
},
"Hide tool inspector" : {
"comment" : "A label for hiding the tool inspector.",
"isCommentAutoGenerated" : true
},
"high" : {
"comment" : "A label for a high priority.",
"isCommentAutoGenerated" : true
},
"Home Assistant Docs" : {
},
@@ -10689,6 +10743,10 @@
}
}
},
"If this task has child tasks, the result is handed to them as upstream context. Leave blank for a quiet completion." : {
"comment" : "A description of the result field.",
"isCommentAutoGenerated" : true
},
"If you trust the change, remove the stale entry and reconnect:" : {
"localizations" : {
"de" : {
@@ -11868,6 +11926,14 @@
}
}
},
"List" : {
"comment" : "A label for a Kanban view mode.",
"isCommentAutoGenerated" : true
},
"live" : {
"comment" : "A live task indicator.",
"isCommentAutoGenerated" : true
},
"Live tail across the gateway, agent, tools, MCP servers, and cron." : {
"comment" : "A description of the logs feature.",
"isCommentAutoGenerated" : true
@@ -11977,6 +12043,10 @@
},
"Loading tool details…" : {
},
"Loading…" : {
"comment" : "A placeholder text that appears when a task is being loaded.",
"isCommentAutoGenerated" : true
},
"Local" : {
"localizations" : {
@@ -12098,6 +12168,10 @@
}
}
},
"Locked to project directory." : {
"comment" : "A message that indicates a workspace is locked to the project directory.",
"isCommentAutoGenerated" : true
},
"Log File" : {
"localizations" : {
"de" : {
@@ -12218,6 +12292,10 @@
}
}
},
"low" : {
"comment" : "A label for a low priority.",
"isCommentAutoGenerated" : true
},
"Lowercase letters, digits, and hyphens. Must start with a letter." : {
"comment" : "A description of the format of a slash command name.",
"isCommentAutoGenerated" : true
@@ -12351,6 +12429,14 @@
"comment" : "A button that marks the current skill set as seen and dismisses the \"What's New\" pill.",
"isCommentAutoGenerated" : true
},
"Mark this task as Done. You'll be prompted for an optional result summary." : {
"comment" : "A button that marks a task as done.",
"isCommentAutoGenerated" : true
},
"Mark this task blocked with a reason. The reason is appended as a comment." : {
"comment" : "A description of the action of blocking a task.",
"isCommentAutoGenerated" : true
},
"markdown" : {
"comment" : "A label displayed in the footer of a Markdown editor.",
"isCommentAutoGenerated" : true
@@ -13242,6 +13328,18 @@
}
}
},
"New task" : {
"comment" : "A label for a new task form.",
"isCommentAutoGenerated" : true
},
"New Task" : {
"comment" : "A button that creates a new task.",
"isCommentAutoGenerated" : true
},
"New task in %@" : {
"comment" : "A button that adds a new task to the Kanban board.",
"isCommentAutoGenerated" : true
},
"New Webhook Subscription" : {
"localizations" : {
"de" : {
@@ -13406,6 +13504,10 @@
}
}
},
"No active tasks for this project." : {
"comment" : "A message displayed when a Kanban project has no tasks.",
"isCommentAutoGenerated" : true
},
"No Activity" : {
"extractionState" : "stale",
"localizations" : {
@@ -13575,6 +13677,10 @@
}
}
},
"No comments yet." : {
"comment" : "A message displayed when a task has no comments.",
"isCommentAutoGenerated" : true
},
"No configuration" : {
},
@@ -13750,6 +13856,9 @@
}
}
}
},
"No description." : {
},
"No env vars configured." : {
"localizations" : {
@@ -13831,6 +13940,10 @@
}
}
},
"No events yet." : {
"comment" : "A label displayed when a task has no events.",
"isCommentAutoGenerated" : true
},
"No fields" : {
"comment" : "A label that describes a template with no configuration fields.",
"isCommentAutoGenerated" : true
@@ -13923,6 +14036,10 @@
"comment" : "A message displayed when there are no kanban tasks.",
"isCommentAutoGenerated" : true
},
"No log captured for this task." : {
"comment" : "A message displayed when a task has no log.",
"isCommentAutoGenerated" : true
},
"No matches" : {
"comment" : "A message that appears when a search query matches no",
"isCommentAutoGenerated" : true
@@ -14067,6 +14184,10 @@
"comment" : "A message displayed when a tool call has not yet produced output.",
"isCommentAutoGenerated" : true
},
"No output yet. The worker may not have written anything to stdout / stderr." : {
"comment" : "A message displayed when a task's log is empty.",
"isCommentAutoGenerated" : true
},
"No paired users" : {
"localizations" : {
"de" : {
@@ -14355,6 +14476,10 @@
}
}
},
"No runs yet." : {
"comment" : "A message displayed when a task has no runs.",
"isCommentAutoGenerated" : true
},
"No samples yet. Use the app for a few seconds." : {
"comment" : "A message displayed when there are no stats to show.",
"isCommentAutoGenerated" : true
@@ -14668,6 +14793,10 @@
}
}
},
"normal" : {
"comment" : "A label for a priority level.",
"isCommentAutoGenerated" : true
},
"not running" : {
"comment" : "A label displayed when the web dashboard is not running.",
"isCommentAutoGenerated" : true
@@ -16428,6 +16557,10 @@
"comment" : "A label displayed above the preview of a slash command.",
"isCommentAutoGenerated" : true
},
"Priority %lld" : {
"comment" : "A tooltip that shows the priority level of a task.",
"isCommentAutoGenerated" : true
},
"Probe" : {
"localizations" : {
"de" : {
@@ -17268,6 +17401,10 @@
"comment" : "Text displayed in a progress view while the template is being read.",
"isCommentAutoGenerated" : true
},
"ready: %lld →" : {
"comment" : "A pill that shows the number of tasks that are ready to be moved to the next column.",
"isCommentAutoGenerated" : true
},
"Reasoning" : {
"localizations" : {
"de" : {
@@ -17312,6 +17449,14 @@
"comment" : "A label displayed before the reasoning section of a message.",
"isCommentAutoGenerated" : true
},
"Reasons appear as a comment on the task and feed into the worker's context if it's later unblocked." : {
"comment" : "A description of how the reason is used.",
"isCommentAutoGenerated" : true
},
"Reassign this task. Hermes's dispatcher only runs assigned tasks." : {
"comment" : "A description of the task reassigning feature.",
"isCommentAutoGenerated" : true
},
"Recent activity" : {
"comment" : "A heading for the user's recent activity.",
"isCommentAutoGenerated" : true
@@ -17541,6 +17686,14 @@
"comment" : "A button that refreshes the list of templates.",
"isCommentAutoGenerated" : true
},
"Refresh now" : {
"comment" : "A button that refreshes the Kanban board.",
"isCommentAutoGenerated" : true
},
"Refresh worker log" : {
"comment" : "A button that refreshes the log of a task.",
"isCommentAutoGenerated" : true
},
"refresh-only" : {
"comment" : "A label for a refresh-only OAuth provider.",
"isCommentAutoGenerated" : true
@@ -18844,6 +18997,10 @@
}
}
},
"Return this task to the Up Next queue so the dispatcher can pick it up again." : {
"comment" : "Button label.",
"isCommentAutoGenerated" : true
},
"Return to Active Session (%@...)" : {
"extractionState" : "stale",
"localizations" : {
@@ -20277,6 +20434,10 @@
}
}
},
"Send to triage" : {
"comment" : "A toggle that sends a task to triage.",
"isCommentAutoGenerated" : true
},
"Series" : {
"localizations" : {
"de" : {
@@ -20996,10 +21157,18 @@
}
}
},
"Show archived" : {
"comment" : "A toggle to show archived tasks.",
"isCommentAutoGenerated" : true
},
"Show archived projects" : {
"comment" : "A toggle that shows/hides archived projects.",
"isCommentAutoGenerated" : true
},
"Show archived tasks" : {
"comment" : "A toggle to show archived tasks.",
"isCommentAutoGenerated" : true
},
"Show details" : {
"localizations" : {
"de" : {
@@ -21535,6 +21704,10 @@
},
"Slash Commands" : {
},
"snapshot from `hermes kanban log %@`" : {
"comment" : "A label indicating that the log is a snapshot of a previous log.",
"isCommentAutoGenerated" : true
},
"SOUL.md" : {
@@ -22288,6 +22461,10 @@
"comment" : "A description of the quick commands feature.",
"isCommentAutoGenerated" : true
},
"streaming" : {
"comment" : "A label indicating that the log is being streamed.",
"isCommentAutoGenerated" : true
},
"Strip the prefix from model.default, leaving model.provider = %@." : {
},
@@ -22622,6 +22799,10 @@
"comment" : "A label for the \"Templates\" menu.",
"isCommentAutoGenerated" : true
},
"Tenant: `%@`" : {
"comment" : "A label below the \"New task\" title that shows the pre-filled tenant.",
"isCommentAutoGenerated" : true
},
"Terminal" : {
"localizations" : {
"de" : {
@@ -24156,10 +24337,22 @@
"comment" : "A button that unarchives a project.",
"isCommentAutoGenerated" : true
},
"Unassigned" : {
"comment" : "A label for a task without an assigned user.",
"isCommentAutoGenerated" : true
},
"Unassigned — Hermes's dispatcher silently skips tasks with no assignee, so this task will never run automatically. Open the task and add an assignee, or recreate it with one set." : {
"comment" : "A warning message for unassigned tasks.",
"isCommentAutoGenerated" : true
},
"Unattributed" : {
"comment" : "A label for a session filter that shows",
"isCommentAutoGenerated" : true
},
"Unblock" : {
"comment" : "A button that unblocks a task.",
"isCommentAutoGenerated" : true
},
"Uninstall" : {
"localizations" : {
"de" : {
@@ -25075,6 +25268,10 @@
},
"Waiting for browser approval…" : {
},
"waiting for first poll…" : {
"comment" : "A message displayed when the user is waiting for the first log poll to complete.",
"isCommentAutoGenerated" : true
},
"Waiting for first probe" : {
"localizations" : {
+9 -4
View File
@@ -37,18 +37,23 @@ struct SidebarView: View {
}
interact.append(.skills)
var manage: [SidebarSection] = [.tools, .mcpServers, .gateway, .cron]
// Kanban moved from Manage Monitor in v2.7.5: it's runtime
// work-in-progress, not configuration. Sits between Activity
// and the remaining Manage entries so users see "what's
// happening right now" at a glance.
var monitor: [SidebarSection] = [.dashboard, .insights, .sessions, .activity]
if caps?.hasKanban ?? false {
manage.append(.kanban)
monitor.append(.kanban)
}
manage.append(contentsOf: [.health, .logs, .settings])
let manage: [SidebarSection] = [.tools, .mcpServers, .gateway, .cron, .health, .logs, .settings]
return [
// Projects sits first now promoting it to a first-class
// entry point reflects how users actually open Scarf
// (start with a project, not the dashboard).
Section(title: "Projects", items: [.projects]),
Section(title: "Monitor", items: [.dashboard, .insights, .sessions, .activity]),
Section(title: "Monitor", items: monitor),
Section(title: "Interact", items: interact),
Section(title: "Configure", items: [.platforms, .personalities, .quickCommands, .credentialPools, .plugins, .webhooks, .profiles]),
Section(title: "Manage", items: manage),
+11
View File
@@ -56,6 +56,17 @@ struct ScarfApp: App {
// owns the agent there.
SSHTransport.environmentEnricher = { HermesFileService.enrichedEnvironment() }
// Same enrichment for LocalTransport. Without this, GUI-launched
// Scarf hands every local subprocess (hermes acp, hermes kanban
// dispatch, sqlite3, etc.) macOS's stripped launch-services PATH
// `/usr/bin:/bin:/usr/sbin:/sbin` and child invocations
// (notably the kanban dispatcher's `hermes` worker spawn) fail
// with `executable not found on PATH`, recording an
// `outcome=spawn_failed` run on the task. The login-shell probe
// populates PATH with `~/.local/bin`, Homebrew, etc., matching
// what a Terminal session sees.
LocalTransport.environmentEnricher = { HermesFileService.enrichedEnvironment() }
// Warm up the login-shell env probe off-main at launch. Without
// this, the first MainActor caller (chat preflight, OAuth flow,
// signal-cli detect, etc.) blocks for 5-8 seconds while
@@ -0,0 +1,47 @@
import Testing
import Foundation
import ScarfCore
@testable import scarf
/// Pure slug-generation tests for `KanbanTenantResolver`. The disk
/// I/O paths (`resolveOrMint`, `persist`) need a real `ServerContext`
/// + filesystem and are covered by integration tests.
@Suite struct KanbanTenantResolverSlugTests {
@Test func basicNameSlugifiesCleanly() {
#expect(KanbanTenantResolver.makeSlug(for: "My Project") == "scarf:my-project")
}
@Test func punctuationCollapsesToHyphens() {
#expect(KanbanTenantResolver.makeSlug(for: "Foo: Bar / Baz!") == "scarf:foo-bar-baz")
}
@Test func consecutiveSeparatorsCollapse() {
#expect(KanbanTenantResolver.makeSlug(for: "a b___c") == "scarf:a-b-c")
}
@Test func emptyNameFallsBackToProjectLiteral() {
#expect(KanbanTenantResolver.makeSlug(for: "!@#") == "scarf:project")
}
@Test func slugBoundedTo48CharsAfterPrefix() {
let huge = String(repeating: "x", count: 200)
let slug = KanbanTenantResolver.makeSlug(for: huge)
#expect(slug.hasPrefix("scarf:"))
// 6 chars for "scarf:" + 48 for the slug body
#expect(slug.count <= 6 + 48)
}
@Test func unicodeNormalizesToAscii() {
// The slug rule lowercases and replaces non-letter/digit with
// hyphens; Latin-extended letters survive lowercase but accented
// chars route through Foundation's lowercasing path.
let slug = KanbanTenantResolver.makeSlug(for: "Mañana")
#expect(slug.hasPrefix("scarf:"))
#expect(!slug.contains(" "))
}
@Test func prefixIsStable() {
#expect(KanbanTenantResolver.prefix == "scarf:")
}
}
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
PROJECT="$REPO_ROOT/scarf/scarf.xcodeproj"
SCHEME="${SCHEME:-scarf}"
CONFIG="${CONFIG:-Debug}"
DERIVED_DATA="$REPO_ROOT/build/DerivedData"
PACKAGE_RESOLVED_REL="scarf/scarf.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
PACKAGE_RESOLVED="$REPO_ROOT/$PACKAGE_RESOLVED_REL"
log() { printf '==> %s\n' "$*"; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
cleanup_generated_files() {
if [[ "${REMOVE_GENERATED_PACKAGE_RESOLVED:-0}" == "1" && -f "$PACKAGE_RESOLVED" ]]; then
rm -f "$PACKAGE_RESOLVED"
rmdir "$REPO_ROOT/scarf/scarf.xcodeproj/project.xcworkspace/xcshareddata/swiftpm" 2>/dev/null || true
rmdir "$REPO_ROOT/scarf/scarf.xcodeproj/project.xcworkspace/xcshareddata" 2>/dev/null || true
fi
}
trap cleanup_generated_files EXIT
log "Detecting architecture"
case "$(uname -m)" in
arm64) BUILD_ARCH="arm64" ;;
x86_64) BUILD_ARCH="x86_64" ;;
*) die "unsupported architecture: $(uname -m)" ;;
esac
log "Using architecture: $BUILD_ARCH"
log "Checking Xcode command line tools"
command -v xcode-select >/dev/null 2>&1 || die "xcode-select not found; install Xcode or Xcode command line tools"
if ! xcode-select -p >/dev/null 2>&1; then
die "Xcode command line tools not selected. Run: xcode-select --install"
fi
command -v xcrun >/dev/null 2>&1 || die "xcrun not found; install Xcode or Xcode command line tools"
command -v xcodebuild >/dev/null 2>&1 || die "xcodebuild not found; install Xcode"
log "Checking Metal toolchain"
if ! xcrun metal --version >/dev/null 2>&1 && ! xcrun -f metal >/dev/null 2>&1; then
if [[ -t 0 && -z "${CI:-}" ]]; then
printf 'Metal toolchain is missing. Install it now with xcodebuild -downloadComponent MetalToolchain? [y/N] '
read -r reply
if [[ "$reply" =~ ^[Yy]$ ]]; then
xcodebuild -downloadComponent MetalToolchain
if xcrun metal --version >/dev/null 2>&1 || xcrun -f metal >/dev/null 2>&1; then
log "Metal toolchain installed"
else
die "Metal toolchain still not available after install"
fi
else
cat >&2 <<'EOF'
error: Metal toolchain missing.
Install it when you are ready with:
xcodebuild -downloadComponent MetalToolchain
EOF
exit 1
fi
else
cat >&2 <<'EOF'
error: Metal toolchain missing.
Install it with:
xcodebuild -downloadComponent MetalToolchain
EOF
exit 1
fi
fi
log "Resolving Swift packages"
if [[ ! -e "$PACKAGE_RESOLVED" ]] && ! git -C "$REPO_ROOT" ls-files --error-unmatch "$PACKAGE_RESOLVED_REL" >/dev/null 2>&1; then
REMOVE_GENERATED_PACKAGE_RESOLVED=1
fi
xcodebuild \
-resolvePackageDependencies \
-project "$PROJECT"
log "Building unsigned $CONFIG app"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration "$CONFIG" \
-derivedDataPath "$DERIVED_DATA" \
-arch "$BUILD_ARCH" \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
DEVELOPMENT_TEAM="" \
build
APP_PATH="$DERIVED_DATA/Build/Products/$CONFIG/scarf.app"
[[ -d "$APP_PATH" ]] || die "build completed, but app bundle was not found at $APP_PATH"
printf '\nBuild complete:\n %s\n\n' "$APP_PATH"
if [[ -t 0 && -z "${CI:-}" ]]; then
read -r -p "Copy to /Applications? [y/N] " reply
if [[ "$reply" =~ ^[Yy]$ ]]; then
rm -rf "/Applications/scarf.app"
ditto "$APP_PATH" "/Applications/scarf.app"
echo "Installed to /Applications/scarf.app"
fi
fi
+22
View File
@@ -81,6 +81,7 @@
case "markdown_file": return renderMarkdownFile(widget);
case "image": return renderImage(widget);
case "status_grid": return renderStatusGrid(widget);
case "kanban_summary": return renderKanbanSummary(widget);
default: return renderUnknown(widget);
}
} catch (e) {
@@ -536,6 +537,27 @@
return card;
}
// ---------------------------------------------------------------------
// Kanban summary (catalog preview — no live kanban data)
// ---------------------------------------------------------------------
function renderKanbanSummary(widget) {
const card = elt("div", "widget widget-kanban-summary");
const head = elt("div", "widget-cron-head");
const icon = elt("span", "widget-cron-icon", "▤");
head.appendChild(icon);
head.appendChild(elt("span", "widget-title", widget.title || "Kanban"));
card.appendChild(head);
card.appendChild(elt("div", "widget-cron-meta",
"Live Kanban summary appears in Scarf after install."));
const maxRows = (widget.value && typeof widget.value === "number")
? Math.max(1, Math.floor(widget.value))
: 3;
card.appendChild(elt("div", "widget-cron-hint",
`Shows up to ${maxRows} top in-progress / blocked / todo tasks for the project's Kanban tenant.`));
return card;
}
// ---------------------------------------------------------------------
// Cron status (catalog preview — no live cron data)
// ---------------------------------------------------------------------
+6
View File
@@ -73,6 +73,12 @@
"since": "v2.7",
"required": ["title", "cells"],
"optional": ["columns"]
},
"kanban_summary": {
"description": "Compact mini-list of the top in-progress / blocked / todo Kanban tasks for this project's tenant, plus a glance string footer (\"12 todo · 3 running · 5 blocked\"). Pulls from `hermes kanban list` filtered by the project's `kanbanTenant` from manifest.json. Use `value` to set max_rows (default 3).",
"since": "v2.7.5",
"required": ["title"],
"optional": ["value"]
}
}
}