# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this repo is

**Dispatch** (originally codenamed **Manager**) — *Mission Control for the Autonomous Workforce.* A tool that lets a human supervise a team of AI agents like an air-traffic controller: live multi-agent monitoring (**The Radar**), methodology tracking (**The Trace**), mid-flight **Intercept** (nudge / redirect / rollback), and **Protocol** broadcast (skill propagation across the team).

Read first:

- [`README.md`](README.md) — product overview + Dispatch lexicon.
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — components, mermaid diagrams, telemetry/event schema. Source of truth for the wire contract.
- [`docs/ROADMAP.md`](docs/ROADMAP.md) — milestones (v0 → v2+).
- [`specs.md`](specs.md) — original product vision (preserved verbatim) + current-state mapping.

## Status

v1.2 code-layer rename shipped. Daemon binary is `dispatch`, env vars are `DISPATCH_*` (legacy `MANAGER_*` fallback removed in v1.3 — `DISPATCH_*` only), state dir is `~/.claude/dispatch/` (one-shot migration in `bin/install.sh` from `~/.claude/manager/`), launchd label `com.dispatch.daemon`, Xcode project `Dispatch.xcodeproj`, bundle id `com.dispatch.app`, app file `/Applications/Dispatch.app`, custom logo shipped. Swift module name is `DispatchApp` (not `Dispatch`) to avoid colliding with system libdispatch.

**v1.4 Symphony orchestration shipped (substeps 0–4):** orchestrator state machine + retry/reconciliation, workspace manager + hooks, Claude Code agent runner (option A — spawn per turn), `WORKFLOW.md` loader + Linear adapter + dynamic reload, approval-required intervention kind + `/workstreams/:id/interventions/:intId/decide` endpoint + macOS Approve/Deny strip in `AgentDetailView`. Observation mode unchanged when no `--workflow` is passed. See `TODO.md` for deferred items (workstream_links SQLite join, gc CLI, MCP tool that emits approval requests).

**v1.4.5 UX wave shipped:** Settings → **Providers** tab (`GET/PATCH /settings`, `~/.claude/dispatch/settings.json`, redacted API key on the wire) so local-model setup no longer needs env vars; Sleeping Robot mascot — daemon emits `live_session` per workstream (latest `session_start` newer than `session_end`), `RobotMascot` dims + closes eyes + floats `Zzz` when the underlying `claude` session ends; first-launch demo-data race fix in `DaemonResolver`; `WelcomeView` rewrite (3 numbered steps + copy-able `claude` snippet + Tutorial/Providers/Architecture links); `AgentDetailView` HSplitView reflows below 820 width; `DigestRailView` chips horizontal-scroll; tooltips across HomeView/WelcomeView/PreferencesView; bundled `TUTORIAL.md` + `LOCAL_MODELS.md` refreshed for v1.4 + Settings → Providers.

**v1.4.17 Autonomous Linear loop (transition_ticket + open_pr) shipped:** closes the "agent just builds features and manages Linear" gap. New `Tracker.applyTransition?(issueId, stateName, opts?)` interface method composes `resolveStateIdByName` + `setIssueState`; Linear impl uses per-team resolution from v1.4.10.4. Mock impl logs to in-memory `transitionsApplied()` for tests. New `POST /trackers/transition` HTTP endpoint takes `{workstream_id, state, comment?}`, looks up the linked issue via `workstream_links`, optionally posts a Markdown comment first (best-effort), then transitions; returns 422 with `state_not_found` when the name doesn't resolve, 404 when the workstream isn't linked. Two new MCP tools: `dispatch__transition_ticket(state, comment?)` POSTs to the daemon over `DISPATCH_PORT` (mirrors a `decision` event with `choice='transition_ticket'`); `dispatch__open_pr(title, body, base?)` runs `git push -u origin <branch>` + `gh pr create` in the agent's `cwd` via injectable `OpenPrSpawn` test seam — refuses on protected branches (`main`/`master`/`trunk`), refuses on a dirty tree, refuses when `gh` isn't on PATH (clear error). New `examples/WORKFLOW.build.md` template ties it all together: clone-per-ticket workspace hooks + `branch dispatch/<id>` convention + 5-step build prompt that ends in `open_pr` → `transition_ticket("In Review")`. Builds on top of v1.4.16's triage template — triage feeds Todo, build picks up from there. 371 daemon tests green (+10 across mcp-server / http-server / trackers/linear).

**v1.4.16 LLMErrorView handles ollama-not-installed shipped:** `LLMErrorView.detectsMissingOllamaCLI(in:)` recognizes the daemon's "ollama CLI not found on PATH" hint (mapped from child_process ENOENT inside `pullOllamaModel`), the bare shell `ollama: command not found`, and `spawn ollama ENOENT`. When matched (and the message isn't ALSO a model-not-found), the view shows a `Get Ollama` Link to ollama.com/download instead of the Pull button — Pull would fail the same way. +5 macOS unit tests covering both the new detector and the priority order (model-not-found wins when both patterns are present, since the user can act on it without leaving the app). 13 macOS LLMErrorView tests green.

**v1.4.15 Triage workflow template + file_ticket rate limit shipped:** new `examples/WORKFLOW.triage.md` — opinionated triage agent template with all v1.4.10.x / v1.4.11 / v1.4.12 knobs in one annotated config + a 4-step prompt biasing the agent toward groom/dedupe/file. New `BuildOptions.fileTicketMaxPerHour` (default 30, 0 = disabled) on the http-server with in-memory ring of timestamps + injectable clock. `POST /trackers/issues` returns 429 with `{retry_after_ms, code: 'file_ticket_rate_limited', limit}` when exceeded. Failed tracker writes do NOT consume a slot (so a misconfigured Linear key won't lock the user out). CLI reads `DISPATCH_FILE_TICKET_MAX_PER_HOUR` env override. +3 http-server tests (rate-limited, failed-don't-count, =0 disables). 361 daemon tests green.

**v1.4.14 Actionable model-not-found error toasts shipped:** new reusable `LLMErrorView` (Swift) wraps any error string, regex-detects `model '<name>' not found` (single/double/backtick quotes, case-insensitive), and inlines a "Pull <name>" button that calls `client.pullModel(name)` from the toast itself — no need to navigate to Settings. Defense-in-depth: regex pattern `[A-Za-z0-9._:/-]` rejects shell-injection attempts on the extracted name. Wired into `GenerateReportSheet` (the surface that prompted the screenshot in v1.4.13's dogfood). +8 macOS unit tests in `LLMErrorViewTests.swift` (single/double/backtick quotes, case-insensitive, HuggingFace path-style names, unrelated errors return nil, shell-injection rejected, lastNonEmptyLine helper). 358 daemon + 8 macOS tests green.

**v1.4.13 Pull-model button shipped:** new `POST /admin/llm/pull-model` endpoint runs `ollama pull <model>` synchronously via injectable `pullOllamaModel` helper in `daemon/src/admin.ts`. Captures last ~4 KB of stdout+stderr (truncates progress-bar output), maps ENOENT to "ollama CLI not found" hint. macOS Settings → Providers tab gains a "Pull" button next to the Model field (Ollama-only, disabled when model is empty); shows spinner while pulling, surfaces last-line success/error inline via `PullStatus` enum. Wire shape `PullModelResult { ok, exit_code, output, error? }`. Triggered after seeing `model 'qwen3:8b' not found` 404s — gives users a one-click path to download missing models without leaving the app. 358 daemon tests green.

**v1.4.12 Linear → Radar mirror shipped:** new `daemon/src/tracker-mirror.ts` ticker class — fetches `mirror_states` issues every `mirror_interval_ms` and creates a `status='backlog'` workstream + `workstream_link` for each unlinked issue. Idempotent via `WorkstreamLinksStore.findByIssueId` (new method, leverages existing `idx_links_issue` index). Never demotes existing `active`/`paused` workstreams — only creates new ones. `WORKFLOW.md` `tracker.mirror_to_radar` (default off), `mirror_states` (defaults to `active_states ∪ ['Backlog', 'Triage']`), `mirror_interval_ms` (default 60s), `mirror_max_age_days` (defaults null/no cutoff). Hot-reloadable via watcher; flipping enabled in/out start/stops the ticker. CLI lifecycle: stops on shutdown alongside orchestrator. macOS app sees mirrored issues as new backlog cards in the existing Kanban — no client changes needed. 351 daemon tests green.

**v1.4.11 Tracker.createIssue + file_ticket MCP tool shipped:** `Tracker.createIssue?({title, description?, labels?, priority?, teamId?})` interface method (optional). `LinearTracker` impl runs `issueCreate` mutation against the project's primary team (resolved + cached on first call), with label-name → id resolution (cached). Mock returns synthetic `MOCK-N` identifier and exposes `createdIssues()` for test inspection. New `POST /trackers/issues` HTTP endpoint (404 no tracker, 501 if tracker doesn't implement, 400 missing title, 502 on TrackerError, 201 + body on success). New MCP tool `dispatch__file_ticket(title, description?, labels?, priority?)` POSTs to the daemon over `DISPATCH_PORT`, mirrors a `decision` event with `choice='file_ticket'` so the Radar shows the agent filed a ticket. Tracker is late-bound into `buildHttpServer` via the same closure-getter pattern as orchestrator. 345 daemon tests green.

**v1.4.10.5 stale-claim sweeper shipped:** `Tracker.fetchStaleSelfClaimedIssues?(activeStates, ttlMs)` added; `LinearTracker` impl runs `STALE_SELF_CLAIMED_QUERY` (filter `assignee.id = self AND state in active AND updatedAt < cutoff`). `Mock` returns all self-claimed active issues regardless of age (no `updatedAt`). Orchestrator gains `staleClaimTtlMs` option (default 0/disabled) — every tick after dispatch loop, sweeps stale-self issues NOT in `running`/`claimed` and fires `releaseHook` (best-effort fire-and-forget). `WORKFLOW.md` `tracker.stale_claim_ttl_ms` threaded through `coerceTracker` + `applyConfig`. 335 daemon tests green.

**v1.4.10.4 multi-team claim_state resolution shipped:** `Tracker.resolveStateIdByName?(name, opts?: { teamId? })` interface extended; `LinearTracker` adds `cachedStateIdByNameByTeam` map keyed by `team.id` and a `TeamStates` GraphQL query. `ISSUE_ASSIGNEE_QUERY` peek now also fetches `team.id`, so claimIssue can resolve `stateName` per the issue's actual team in multi-team projects. `ClaimOptions` gains `stateName`; CLI passes both `stateName` and pre-resolved `stateId` (the eager v1.4.10.1 fallback). 330 daemon tests green.

**v1.4.10.3 hot-swap claim hooks on workflow reload shipped:** orchestrator's `claimHook` / `releaseHook` made mutable; `applyConfig` extended to swap them via `'claimHook' in opts` presence check (so passing `null` clears, omitting leaves alone). `OrchestratorOptions.claimHook` widened to `ClaimHook | null` so `Partial<>` propagates. CLI workflow watcher rebuilds `claimConfig` + hooks each reload, re-resolves `claim_state` only when name changed (else preserves cached `claimStateId` to avoid a Linear round-trip per save). Toggling `tracker.claim_on_dispatch` in WORKFLOW.md now takes effect without daemon restart. 327 daemon tests green.

**v1.4.10.2 stale-claim re-discovery shipped:** new `AssigneeFilter` value `'unassigned_or_self'` — Linear filter clause `or: [{assignee:{null:{eq:true}}}, {assignee:{id:{eq:"<self>"}}}]`; mock matches null OR == selfId. CLI's `assigneeFilterFor` defaults to this when `unassigned_only` is on, so a daemon that crashed mid-claim re-discovers its own zombie tickets on restart (selfUserId is stable across boots). Strict `'unassigned'` retained on the union for callers that want hard "leave already-claimed alone" semantics. 326 daemon tests green.

**v1.4.10.1 claim_state resolver shipped:** `LinearTracker.resolveStateIdByName(name)` queries `issues(filter: project, first: 1) { team { states } }` to map state names to ids; cached on first hit. CLI eager-resolves `cfg.tracker.claim_state` at boot; resolution miss degrades to assignee-only claim with a one-line warning. `MockTracker.resolveStateIdByName` returns name as id (identity). `Tracker` interface gains optional `resolveStateIdByName?(name): Promise<string | null>`. 324 daemon tests green.

**v1.4.10 Tracker write-back (Linear claim) in flight:** `Tracker` interface gained optional `claimIssue(issueId, { stateId?, assigneeId? })` + `releaseIssue(issueId, opts?)` — observation-only adapters skip them, orchestrator probes for presence. `LinearTracker` resolves + caches `viewer.id` lazily; `fetchCandidateIssues` accepts `{ assigneeFilter: 'any' | 'unassigned' | 'self' }` (Linear inlines the GraphQL clause as a string fragment because typed variables can't carry "is null"). Orchestrator gains `claimHook` (pre-spawn — `linear_assignee_taken` collisions skip-this-issue, all other errors skip-this-tick) + `releaseHook` (best-effort fire-and-forget on terminal-state reconciliation; `orchestrator.release_failed` logged but never blocks). `WORKFLOW.md` `tracker.claim_on_dispatch` + `assign_to_self` + `unassigned_only` flags wired through `workflow-loader` + `applyConfig` (assigneeFilter is hot-reloadable; toggling claim_on_dispatch requires daemon restart). `tracker.claim_state` parsed but not yet acted on — state-name → state-id resolver is a v1.4.10.x follow-up. Without `claim_on_dispatch: true` behavior is identical to v1.4.6.

**v1.4.9 Reactivate from Kanban shipped:** retired-card context menu in `KanbanBoardView.cardMenu` switched from `EmptyView()` to a "Move to Active" button wired to the existing `.resume` lifecycle action (`ContentView.applyLifecycleAction` already maps `.resume` → `status=.active`). Drag-from-retired→Active was already supported end-to-end (every card `.draggable`, every column `.dropDestination`, `handleDrop` PATCHes); the menu is purely a discoverability fix. No daemon, schema, or wire-format change. `daemon/package.json` re-synced from drifting 1.4.6 → 1.4.9 to match `CFBundleShortVersionString`.

**v1.4.6 Diagnostics shipped:** Settings → **Diagnostics** tab with four buttons — Kill Local LLM (`POST /admin/llm/kill` → `lsof` listener PID → SIGTERM with 3s grace → SIGKILL), Restart Local LLM (`POST /admin/llm/restart` → kill + `bash -lc <localLLMStartCommand>` detached), Restart Daemon — Soft (`POST /admin/restart` → cancel + re-instantiate Headliner / SubgoalSynthesizer / LinearCommentSyncer in place; daemon process keeps its PID), and Restart Daemon — Hard (macOS-side `launchctl kickstart -k gui/<uid>/com.dispatch.daemon` via `LaunchctlController` — the daemon would die mid-response if this were a daemon endpoint). New `localLLMStartCommand` field in `GET/PATCH /settings` (cleartext on the wire — not a credential) and a matching TextField on the Providers tab.

**v1.2 shipped:** 4-column Kanban board replaces the v1.1 LazyVGrid team-floor — cards `.draggable(WorkstreamDragPayload)`, columns `.dropDestination` calls `PATCH /workstreams/:id` with the new status; Active column auto-folds into a "Pod-grouped" disclosure when it holds more than 12 cards. Linear-link UI: every workstream can be linked to a Linear issue from the agent-detail header, backed by a new SQLite `workstream_links` table (FK to `workstreams(id)`) + 4 endpoints (`GET/PUT/DELETE /workstreams/:id/link`, `GET /links`). `LinearChip` renders linked vs unlinked (Link… opens `LinkLinearSheet`); Settings → Providers gains a redacted `linearApiKey` SecureField. `LinearCommentSyncer` runs every 60s for bidirectional sync — forward posts deterministic decision-event Markdown comments (deduped via `linear_comments_posted`), reverse flips status when Linear's state changes (only if the user hasn't manually overridden). `Workstream.status` enum gains `'backlog'`; macOS Codable falls back to `.active` for unknown future values.

**v1.3 shipped:** local-LLM transport now speaks OpenAI-compatible `/v1/chat/completions` keyed on `DISPATCH_LLM_BASE_URL` (default `http://localhost:8080/v1` for `mlx_lm.server`; bare `localhost:11434` and any URL without `/v1` get auto-promoted so existing Ollama setups upgrade transparently). `dispatch://workstream/<slug>` URL scheme registered in macOS `CFBundleURLTypes` + `commands/dispatcher.md` slash command for jumping from `claude` into the matching Radar card. `MANAGER_*` env-var fallback retired across daemon, hooks, macOS resolver.

## Locked architectural decisions

These are decided. Do not relitigate without a reason; do propose changes if you find a reason.

- **First runtime: Claude Code.** Future runtimes (ADK 2.0, marketing/non-coding workflows) implement the same event contract.
- **First client surface: macOS native (SwiftUI).** iOS / web are future thin clients over the same daemon API.
- **Daemon-as-brain, client-as-thin-view.** All persistent state (events, memory, queues) lives in the daemon. This is the single design move that makes mobile/remote viable later without a rewrite.
- **Workstream is the unit of identity, not session.** A workstream spans many Claude Code sessions and persists context via a Markdown memory file.
- **Tap, not interrupt.** Observation never blocks the agent. Append-only event log; manager reads projections.
- **Two operational modes share one telemetry path** (v1.4+): *Observation* (human runs `claude`, daemon collects via hooks/MCP) and *Autonomous* (orchestrator picks tickets via Symphony-shaped tracker + spawns `claude` in a per-issue workspace). Autonomous mode requires a `WORKFLOW.md`; without it, the daemon stays purely observational. Hooks/MCP don't care who launched the session — same event store, same Radar, same Intercept.
- **Three Intercept modes:** nudge (advisory), redirect (hard course-correct), rollback (replay-with-hint, not session-state restoration).
- **Telemetry channels:** lifecycle hooks (zero-touch, low-fidelity) + MCP tools (`emit_decision`, `emit_subgoal`, `emit_confidence`, `flag_blocked`, `update_memory`, `read_memory`) for high-fidelity structured events.
- **Daemon language: TypeScript / Node.** Chosen for the official MCP SDK and fastest iteration; a Rust port is a future option only if footprint matters.

## Still-open decisions

- **Auth for remote/mobile API access**: deferred to v1.5; localhost-only through v1.

## Working in this repo

- Build/lint/test live per-component: `cd daemon && npm run build|test|lint`; `cd client-macos && xcodebuild -project Dispatch.xcodeproj -scheme Dispatch`. Hooks are POSIX shell — no build step.
- Local state at runtime lives at `~/.claude/dispatch/` (events, memory, db, handbook, scheduler). Do not commit — `.gitignore` excludes it.
- When making non-trivial decisions about the build, update `docs/ARCHITECTURE.md` and `docs/ROADMAP.md` rather than letting decisions drift.
- **When writing user-facing copy** (UI strings, README sections, error messages surfaced to the human) prefer the Dispatch lexicon — Radar / Trace / Intercept / Protocol / Dossier. Internal identifiers (event types `decision`, `intervention_delivered`, `skill_proposed`; MCP tool names; SQLite columns) stay as the technical contract — do not rename them on the brand pass.
- **Backwards-compat env vars** — `MANAGER_*` removed in v1.3. `DISPATCH_*` only. Filesystem-state migration `~/.claude/manager → ~/.claude/dispatch` still runs in `bin/install.sh` (not an env-var fallback).
- **Swift module is `DispatchApp`, not `Dispatch`** — required because `Dispatch` is a system framework (libdispatch / GCD). Bundle id, target name, and `.app` filename are all `Dispatch`; only the Swift module identifier is suffixed. Test imports therefore use `@testable import DispatchApp`.
