Swarm Swarm

Roadmap

Status: living

M0 — See everything (observe only)

Goal: one dashboard shows every Claude Code session on the machine, live, across repos. No enforcement yet.

ID Task Depends Status
M0.1 Scaffold monorepo, CI, Biome, Vitest, Apache-2.0, README skeleton — ✅ 2026-08-20 — 7 packages, in-memory event log + SSE, hook→daemon→SSE smoke
M0.2 core: event types, Claude Code hook adapter, project identity (git common dir) M0.1 ✅ 2026-08-20
M0.3 daemon: SQLite schema, /v1/hook/* ingestion, /v1/events SSE, port file, auto-start M0.2 ✅ 2026-08-20 SQLite persisted; port file + auto-start done (M0.9.1); unix socket deferred
M0.4 hook shim + `swarm install uninstall` (user-level settings edit, idempotent, reversible) M0.3
M0.5 cli: add, ls, status, tail, doctor M0.3 ✅ 2026-08-20 incl. tail, setup, start/stop/restart
M0.6 web: Fleet + Session views over SSE, served by daemon M0.3 ✅ 2026-08-22 vanilla HTML/JS served from web/public (one React island for menus, M5.8); views: Fleet / Board / PRs / Timeline / Spend / Stats + Session detail; data-grid everywhere (M0.9.9); add/remove/pin project
M0.7 Smoke test: fake hook events → SSE assertions; dogfood on a real project M0.4–M0.6 ✅ 2026-08-20 (tools/smoke.ts)

M0.8 — What agents are doing (transcript intelligence) ✅ 2026-08-20

Landed ahead of M1 by request. Reads each session's transcript JSONL (path from the hook) plus its subagents/*.jsonl, tailing on hook activity and on a 5s timer (long turns emit no hooks).

ID Task Status
M0.8.1 core/pricing: static table + fromLiteLLM, longest-prefix match, cache tiers ✅
M0.8.2 core/transcript: JSONL → turns (model, per-tier usage, thinking, text, tools, sidechain); collapses streamed lines ✅
M0.8.3 daemon: turns table, offset-tracked tailer, subagent files, session title/model/branch/context ✅
M0.8.4 daemon: cost via pricing overlay (~/.swarm/pricing.json, LiteLLM refresh); reprice on refresh ✅
M0.8.5 web: Fleet shows title/model/out/ctx/cost; Session detail (cost, context %cached, thinking, tool histogram, live reasoning stream); Spend view (by project/model, today/all-time, 14-day bars) ✅
M0.8.7 web: design system pass (theme-aware tokens light/dark, cards, refined type/tables/status); pin/unpin discovered projects; per-session live model + multi-model +N; latest-turn model via SQL ✅ 2026-08-20
M0.8.6 tooling: migrate vitest→bun:test, stop emitting per-package dist, biome 2.5 ✅

M0.9 — Ship it (community-ready) ✅ 2026-08-22

Goal: a stranger clones or npx-installs Swarm and it works in under two minutes, with no dev paths, no manual daemon, and docs that answer the obvious questions. This is the open-source release track.

ID Task Depends Status
M0.9.1 Daemon lifecycle: ~/.swarm/daemon.json (port/pid/version), graceful shutdown, swarm start/stop/restart, client ensureDaemon() auto-spawn M0.3 ✅ 2026-08-20 daemon.json (port/pid/version), graceful SIGTERM cleanup, start/stop/restart, client ensureDaemon() auto-spawn
M0.9.2 Portable install: write bin commands that work both from a clone and from a global install (no hard-coded dev paths); swarm setup one-shot (ensure daemon → install hooks → open UI) M0.9.1 ✅ 2026-08-20 install writes portable command (bare bin under node_modules, else bun+abs path); swarm setup one-shot
M0.9.3 Community scaffolding: real README (quickstart, what/why, screenshots), CONTRIBUTING, CODE_OF_CONDUCT (Contributor Covenant), SECURITY.md, LICENSE headers, issue/PR templates — ✅ 2026-08-20 README, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, issue/PR templates
M0.9.4 Package metadata for publish: description, keywords, repo/bugs/homepage, engines, files; decide publish name (OQ-1) M0.9.1 ✅ 2026-08-21 @ra3orblade/swarm (npm/package.json): metadata, engines, files, publishConfig
M0.9.5 swarm doctor as the setup guide: checks bun/claude/daemon/hooks/db and prints the exact next command for each gap M0.9.1 ✅ 2026-08-20 doctor checks bun/claude/daemon/hooks and prints the fix per gap
M0.9.6 Release: bundle bins with bun build into one publishable package; optional standalone single-file binaries per OS on GitHub Releases; CI publish workflow M0.9.4 ✅ 2026-08-21 tools/build-pkg.ts → npm/ (4 bundled bins + web, zero deps); resolveBin() makes hooks/MCP/daemon spawn work from clone, global install and bunx; verified by packing + installing into a clean prefix; npm job in release.yml (provenance, tag==version check) + CI builds the bundle; tools/version.ts bumps all version fields. Standalone per-OS CLI binaries left optional (desktop already ships the compiled sidecar)
M0.9.7 Config: .swarm.toml loader (optional, per-repo) + ~/.swarm/config.toml (global: port, lease TTL, offline) M0.9.1 ✅ 2026-08-22 core/config.ts: two TOML layers deep-merged over defaults, lenient validation (bad values → default + warning, bad TOML ignored), repo config cached ~30s, port preference SWARM_PORT > [daemon].port > 7777; keys today are [daemon].port + [rules].* (see 13-config) — lease TTL / offline still ⚪
M0.9.8 Website: getswarm.vercel.app landing page + rendered docs and changelog (site/, built by tools/build-site.ts, bun run site:build) M0.9.3 ✅ 2026-08-22
M0.9.9 Data-grid everywhere: sortable / resizable / filterable columns with column-visibility menu, layouts persisted per table; design tokens (12-design-tokens) with no raw values in CSS rules M0.6 ✅ 2026-08-22 web/public/table.js; Fleet, Board, PRs, Spend, Stats all on the grid

M1 — Hold things (ledger) ✅ 2026-08-22

ID Task Depends Status
M1.1 Claims: claim/renew/release/list/reap with worktree create/remove; fail-closed; dirty/unpushed refusal M0 ✅ 2026-08-20 daemon claims table + real git worktree add/remove, heldWork dirty/unpushed gate, events; swarm claim/renew/release/claims/reap CLI; validated end-to-end incl. fail-closed + dirty-refusal
M1.2 Auto-renew on holder activity; orphan detection + incidents M1.1 ✅ 2026-08-22 shouldAutoRenew in core/ledger.ts: a session working inside a claimed worktree (cwd) renews the lease on any hook / transcript activity once it is past half-way (throttled to once a minute per session); daemon tick sweeps expired leases holding work every minute → orphaned + incident.opened (orphaned_claim); detection only, worktree removal stays an explicit reap/release
M1.3 Handoff/resume payloads; injected via SessionStart context M1.1 ✅ 2026-08-22 handoffs table; swarm handoff <task> --done --remaining [--files] [--verify] / swarm resume; MCP swarm_handoff / swarm_resume; GET/POST /v1/handoffs; the SessionStart hook returns additionalContext built by store.sessionContext(cwd): what the session holds (+ lease), the latest handoff, gate status, held resources, and the rule modes (always when a rule is deny)
M1.4 Runtime resources + process registry (serve, proc; port allocation; pid-based liveness) (port of serve.ts, workers.ts) M0 ✅ 2026-08-22 Phase 1: named singletons (acquire/release/reap, pid+lease liveness, held ports auto-protected); MCP + CLI + Board. Phase 2: swarm serve start [--name] [--from-port] -- <cmd> allocates a free port (ledger + bind probe), spawns detached with PORT, logs to ~/.swarm/logs, registers pid + start time and acquires the singleton; `serve ls
M1.5 mcp server with the claim tools; registered by swarm install M1.1 ✅ 2026-08-21 stdio MCP server: swarm_status/claim/renew/release/reap forwarding to the daemon (project from cwd); registered in ~/.claude/settings.json by install; integration-tested via an MCP client → fail-closed claim into a worktree
M1.6 Claims board in the dashboard (lease countdown, orphan highlighting, release/force-release); .swarm.toml task source M1.1 ✅ 2026-08-22 Claims section on the Board (held/expired/orphaned, lease countdown, release + force-release); task source: .swarm.toml [tasks] source = "plan.md" parsed by core/tasks.ts (markdown `ID
M1.7 Board view: one page per project with Claims + Worktrees + Resources + Incidents (the "View 2 — Project" of 08-interface, minus tasks/processes/gates) M1.1, M1.4 ✅ 2026-08-22
M1.8 PRs / merge queue: open PRs/MRs across every tracked repo via the locally-authenticated gh / glab (GitHub + GitLab, no tokens stored), gentle per-project polling, merge from the dashboard; GET /v1/prs, POST /v1/prs/merge M0.6 ✅ 2026-08-22 core/forge.ts (remote parsing + normalizers), daemon/forge.ts, PRs view

M2 — Enforce (rules) ✅ 2026-08-22

ID Task Depends Status
M2.1 Rule engine + built-ins: shared-tree-readonly, no-pattern-kill, claim-required-to-write, protected-ports, no-foreign-worktree M1 ✅ 2026-08-22 rules v2 in core/rules.ts, each configurable per repo as ask / deny / off (13-config): shared_tree / destructive_git / pattern_kill / protected_ports on Bash; no_foreign_worktree (default ask) and claim_required_to_write (opt-in) on Write/Edit/MultiEdit/NotebookEdit paths and Bash cwd, with holding inferred from the session cwd being inside a claimed worktree; held resource ports auto-protected; deny returned to Claude Code as a real permission denial
M2.2 Gates: record/query, latest-run-wins, rubric required; swarm gate + MCP tool M1 ✅ 2026-08-22 core/gates.ts (validateGateRun rejects a missing rubric, gateStatus latest-wins with id tie-break, gatesSatisfied); gates table, GET/POST /v1/gates, a fail opens a gate_failed incident; .swarm.toml [gates] required; `swarm gate record
M2.3 Incidents view; ack; denied-action feed M2.1 ✅ 2026-08-22 every rule hit is recorded (incident.opened); Incidents view = the denied-action feed (Open / All, per-rule counts, reason, session link) with per-row Ack and Ack-all (incident_acks table, POST /v1/incidents/:seq/ack, POST /v1/incidents/ack); GET /v1/incidents?open=1&project=; open count in the nav badge and /v1/state.openIncidents; the Board keeps a short open-only section

M3 — Drive (spawned agents) ✅ 2026-08-22

ID Task Depends Status
M3.1 swarm run: spawn claude -p stream-json in a claimed worktree; ingest; stdin steering M1 ✅ 2026-08-22 daemon/runner.ts: claim (reuses the caller's held worktree), claude -p --output-format stream-json --input-format stream-json --session-id <uuid> spawned by the daemon in the worktree with stdin kept open; prompt + run send go in as stream-json user messages; session pre-registered as spawned so hooks/transcripts ingest it like any session; result lines → run.result (cost, turns, error); pid in the process registry (stop = close stdin, then TERM/KILL by pid + start time); GET/POST /v1/runs, POST /v1/runs/:id/send, DELETE /v1/runs/:id; CLI `swarm run --task --prompt
M3.2 Permission broker via --permission-prompt-tool → rules → dashboard M3.1, M2.1 ✅ 2026-08-22 spawned runs pass --permission-prompt-tool stdio; the runner intercepts control_request{can_use_tool} and evaluates the tool through the shared store.evaluateTool (same rules as the hook): deny → auto-control_response deny with the reason, allow → auto-allow (spawned agent, no TTY), anything the rules flag as ask → held pending and surfaced (permission.requested), resolved by a human via POST /v1/runs/:id/permissions/:reqId {allow} → deny/allow control_response (permission.resolved); Allow/Deny cards on the spawned session; wire format verified against Claude Code 2.1.240
M3.3 Run from dashboard; stop/kill; cost + token rollups per project M3.1 ✅ 2026-08-22 Run on ready/held task rows → drawer (prompt prefilled from the task, permission mode, model, max turns; ⌘⏎) → POST /v1/runs → opens the session; spawned sessions get a stdin box (Enter to send) + Stop with run id / pid / cost so far; cost + tokens roll up through the transcript like any session (Spend by project)

M4 — Learn (the data pays off) ✅ 2026-08-22

ID Task Depends Status
M4.1 Session replay: scrub per tool call, diff per step; "while you were away" digest per project M0 ✅ 2026-08-22 Replay on the session page: a stepper over the session's tool.requested events, each showing the full toolInput + paired toolResponse (lazy-fetched from /v1/events/:seq); Prev/Next, slider, ←/→ keys. "while you were away" digest still ⚪
M4.2 Cost/token attribution per task, gate, rule; repeated-read detector (context budget view) M0, M1 ✅ 2026-08-22 store.attribution(project): cost/tokens/turns per task (sessions matched to a claim by cwd inside its worktree) + a context-budget list (sessions ranked by re-processed cache-read tokens = re-reading); GET /v1/attribution; By task + Context budget on Spend
M4.3 Incident → rule: generate hook predicate from an incident; "write lesson to CLAUDE.md" M2 ✅ 2026-08-22 core/lessons.ts: each incident → a .swarm.toml rule snippet + a CLAUDE.md lesson (recurring ask escalates to deny via per rule/target counts); Codify action on the Incidents feed with copy buttons; no repo writes (user applies)
M4.4 Structured auto-handoff at Stop/SessionEnd; "resume where this died" spawns with handoff + tail M1.3, M3.1 ✅ 2026-08-22 deriveHandoff in core/ledger.ts (files edited, last verify-looking command, last prompt, last assistant text); the daemon upserts one auto:<session> handoff per session on every Stop/SessionEnd inside a held worktree, silenced by a manual handoff from that session. GET/POST /v1/sessions/:id/resume builds a prompt from the latest handoff + the session's last 12 actions and spawns a run on the task (reusing the held claim); swarm run resume <session>; Resume where it died on ended sessions
M4.5 Memory search over Swarm data (sqlite-vec, local embeddings) — see OQ-9 M4.4 ✅ 2026-08-22 FTS5/BM25, not vectors (OQ-9 decision): memory virtual table in swarm.db over handoffs, incidents, gate runs and session last-text, indexed at each write + one-time backfill; core/memory.ts builds docs and sanitises queries (kind: / task: filters, phrase quotes, last-word prefix). GET /v1/memory?q=, swarm search, MCP swarm_search, Search view with snippets
M4.6 Rule dry-run over historical events; flaky-signal detection M2.1 ✅ 2026-08-22 core/dryrun.ts replays recorded tool.requested calls through guardBash/guardWrite under any modes, liveness reconstructed from the stream; a flaky signal is a rule that fired ≥3× on the same command and the call ran anyway ≥80% of the time. GET /v1/rules/dryrun?project=&<rule>=<mode>, swarm rules dryrun [--set rule=mode], Dry-run rules on the Incidents view. Never records incidents
M4.7 Desktop notifications with Allow/Deny actions M3.2 ✅ 2026-08-22 web Notification API (browser + desktop webview): permission prompts on spawned runs and orphaned claims fire a native notification, click opens the Allow/Deny card / Board; enabled from the settings menu, quiet while focused. Native Tauri notification-action buttons not used (M4.2 permission broker card covers the action)
M4.8 Task-source adapters: GitHub Issues, Linear M1.6 ✅ 2026-08-22 [tasks] source = "github" (via the authenticated gh issue list; labels filter; ids GH-<n>, closed=done, in-progress label=active, depends on #n in the body) or "linear" (GraphQL with LINEAR_API_KEY from the daemon env, never stored; team key; blocked-by relations → depends). core/tasks.ts normalizers; daemon/task-sources.ts cached + background-refreshed so the Board never blocks; a source error shows on the Board/swarm tasks instead of an empty list

M5 — Beyond Claude (multi-agent) ✅ 2026-08-24 ← direction set 2026-08-20

Goal: Swarm observes and coordinates AI coding agents generally, not only Claude Code. The event model already carries raw + normalized fields, and adapters live under core/adapters/<name>.

ID Task Status
M5.1 Provider-agnostic pricing: price any model (Anthropic/OpenAI/Google/DeepSeek/…), not just claude-* ✅ 2026-08-20 static table + LiteLLM refresh generalized; 10 tests
M5.2 AgentAdapter interface + registry in core; Claude Code refactored behind it ✅ 2026-08-20 adapters/types.ts (AgentAdapter/LogParseResult), registry, claude-code wrapper
M5.3 First non-Claude adapter — Codex CLI: parser + daemon discovery/tailing, agent-tagged sessions in the dashboard ✅ 2026-08-20 tails ~/.codex rollout logs (bounded scan, offset-tracked, one-time backfill), maps to projects by cwd, prices gpt-5.5; validated on real sessions (integral 70 turns/$3.52, brainstorm 9/$0.42)
M5.4 More adapters behind the same interface ✅ 2026-08-24 five adapters behind AgentAdapter: Grok (ACP updates.jsonl, validated on real sessions), Gemini CLI (~/.gemini/tmp/<hash>/chats/*.jsonl, schema from upstream chatRecordingService; real-session validation still pending — none on this machine), Aider (<repo>/.aider.chat.history.md — aider's own default history file, markdown parsed with a carry state, one file = many sessions, aider's exact Cost: kept via cost_fixed turns that repricing never touches; format verified against aider io.py/base_coder.py), opencode (~/.local/share/opencode/opencode*.db read-only SQLite/WAL, message.data JSON → turns, exact per-message cost carried; schema verified against sst/opencode schema.gen.ts + session-message.ts, current model: {id, providerID} + legacy modelID both handled). Aider/opencode are fixture-tested incl. daemon tailers (temp history file / fixture db); no local sessions existed to validate live — run any aider or opencode session and it appears on the Fleet. Cline deferred: VS Code globalStorage surface, no local install to verify against
M5.5 Dashboard: agent badge, Fleet agent-filter chips, unified per-agent spend breakdown ✅ 2026-08-20 badge + filter chips + Spend 'by agent' (Claude/Codex/Grok in one view); Grok session titles from summary.json
M5.6 Visualisations (inline SVG, no chart lib): stacked daily cost by agent with 7/14/30/90d range, weekday×hour activity heatmap, KPI tiles, Fleet sparklines (output/turn), Session token-composition bar + cost-per-turn strip + tool-mix bars, Timeline view (session lanes per project, coloured by agent, 3–72h) ✅ 2026-08-20 web/public/viz.js; daemon adds daily.agent, hourly, sessions[].spark
M5.7 Visualisation follow-ups: turn ticks / idle gaps on Timeline lanes, claim & lease overlays once M1 lands, gate pass/fail history (M2), per-project spend sparkline in sidebar, log-scale toggle when one day dwarfs the rest ✅ 2026-08-24 turn ticks + visible idle gaps on Timeline bars (GET /v1/timeline per-session turn ts), claim & lease overlay lane per project, per-gate pass/fail history strip on Recent gates, 14-day spend sparkline per pinned project in the sidebar (spendSparks in the snapshot)
M5.9 Stats view (engagement / "funny numbers"): all-time spend + tokens + turns + streak KPIs, playful equivalents (words written ≈ novels, context re-read ≈ War and Peace, thinking share, cost in coffee), 52-week activity calendar with current/longest streak, tokens-per-day by class (30/90/365d), output-per-day, cumulative spend line, turns by hour of day, model mix, token composition, tool leaderboard, record holders (costliest / longest / biggest turn / busiest day) ✅ 2026-08-22 GET /v1/stats?project= (store.stats()), viz.line/calendar/streaks; fetched per view-open, not in the 5s snapshot
M5.8 Dashboard chrome: Phosphor icon system (one family, inline SVG subset generated at build), fancy-menus (@react-fancy-menus/core) as a React island — project ⋯/right-click menu, session ⋯/right-click menu, settings menu with theme (system/light/dark), pricing refresh, docs; bun run build:web + generic static route in the daemon ✅ 2026-08-20 web/tools/build.ts, web/src/menus.tsx; first React in the web package (on-plan: Vite+React is the web stack)

M6 — Desktop app (Tauri) + autoupdate ✅ 2026-08-22 ← direction set 2026-08-20

Goal: ship Swarm as a real desktop app with automatic updates, not just a CLI + browser tab.

Approach: a Tauri v2 shell hosting the dashboard, with the daemon shipped as a sidecar (the daemon compiled to a single binary via bun build --compile) that the app starts on launch; the webview points at the local daemon. A tray icon shows live-session count and opens the window. Autoupdate via Tauri's built-in updater against signed artifacts + an update manifest on GitHub Releases.

ID Task Depends Status
M6.1 Compile the daemon to a standalone binary (bun build --compile); app supervises it as a sidecar M0.9.6 ✅ 2026-08-21 bun build --compile → swarmd; SWARM_WEB_DIR lets it serve the bundled dashboard; Tauri spawns it via tauri-plugin-shell sidecar
M6.2 Tauri v2 scaffold: window + tray, points at the daemon; dev + build scripts M6.1 ✅ 2026-08-21 apps/desktop (Rust: sidecar spawn, health-wait, window at daemon URL, tray with Open/Quit); pixel-logo icon set; desktop:prep/dev/build scripts
M6.3 Autoupdate: Tauri updater, signed artifacts, update manifest on GitHub Releases M6.2 ✅ 2026-08-22 updater plugin + signed keypair; v0.2.2 shipped the updater artifacts (latest.json + signatures); user-visible since M6.5
M6.4 Signing/notarization (macOS); CI release pipeline (macOS + Windows + Linux) M6.3 ✅ 2026-08-21 signed+notarized macOS shipped (v0.0.4); release.yml now a 3-OS matrix building .dmg / .msi+.exe / .deb+.rpm, native sidecar per runner; Windows/Linux unsigned (no Windows cert yet). v0.2.2 (2026-08-21): mac + Windows + updater artifacts green. Linux AppImage fixed 2026-09-06 — reproduced in an ubuntu-22.04 container: linuxdeploy runs patchelf --set-rpath over every ELF in the AppDir (usr/bin and the resource dir), which appends to the file and destroys the payload bun build --compile glues to the end of its executables; the gtk plugin then re-runs ldd on the wrecked binary, that exits 1, and linuxdeploy aborts (134). The Linux build now ships the daemon gzipped as a resource (tauri.linux.conf.json clears externalBin; tools/desktop.ts writes bin/swarmd.gz) and the app unpacks it to ~/.swarm/bin/swarmd-<version> on the first run of each version (daemon_command in src-tauri/src/lib.rs). AppImage + updater artifacts are back on in release.yml, so Linux auto-updates again — for AppImage users; a .deb/.rpm copy is told to update through its package manager. .github/workflows/desktop.yml now bundles the AppImage on every desktop change and asserts the daemon reaches the AppDir as an unpatched .gz that still runs — nothing between releases used to run the bundler, which is why the break went unnoticed for eleven months
M6.5 Desktop "Check for Updates…": tray item that queries GitHub Releases, native dialogs (up to date / new version), install-and-restart M6.3 ✅ 2026-08-22 apps/desktop/src-tauri/src/lib.rs

M7 — Orchestrate (agents drive Swarm) ✅ 2026-08-24 ← direction set 2026-08-23

Goal: close the loop. Today agents report to Swarm (claim, hand off, gate, search); in M7 one session can dispatch work into worktrees, agents talk to each other and to the human through the daemon, and gates are executed rather than self-reported — so autonomous dispatch is safe. Worktrees become first-class so every spawned run starts warm.

v0.7.0 cut (decided 2026-08-23): M7.1–M7.5, M7.7, M7.10 plus budgets and run permission profiles; M7.6 (full messaging), M7.8 (workflows) and M7.9 (review gate) move to 0.8 — swarm_ask ships with a minimal messages table that M7.6 extends.

Ordering: worktree lifecycle (M7.1–M7.3) and executed gates (M7.4) first — they are what makes dispatch (M7.5) trustworthy; messaging and ask-human (M7.6–M7.7) make it steerable; workflows (M7.8) compose it. Everything is optional .swarm.toml + ~/.swarm/ state — nothing required in the monitored repo.

ID Task Depends Status
M7.1 Worktree bootstrap: .swarm.toml [worktree] setup = "<cmd>" + copy = [".env.local", …] run after git worktree add (claims and swarm run alike); output logged to ~/.swarm/logs, failure recorded as an incident, claim still held M1.1 ✅ 2026-08-23 core/worktree.ts plans (repo-relative only, deduped), daemon/bootstrap.ts copies synchronously + runs setup via sh -c in the background (SWARM_WORKTREE/SWARM_TASK env, log ~/.swarm/logs/<project>/bootstrap-<task>.log); store.claim returns bootstrap (log path) and worktree.bootstrapped event on exit, bootstrap_failed incident on non-zero; runner awaits store.awaitBootstrap(worktree) before spawning; CLI/MCP claim replies mention the log
M7.2 Worktrees first-class: `swarm wt create ls open
M7.3 Worktree diff + PR in one step: GET /v1/worktrees/:id/diff (stat + per-file), Diff tab on the Board row and on the session page (what did this agent change); "Open PR" = push + gh pr create / glab mr create prefilled from the task + latest handoff M7.2, M1.3 ✅ 2026-08-23 daemon/git.ts worktreeDiff (vs merge-base with the main checkout's branch: commits, numstat + name-status, working tree, untracked) / worktreePatch; core/forge.ts parseNumstat + prDraft (task title, handoff summary, gates checklist, files); ForgeService.openPR (push -u, gh pr create / glab mr create, refuses dirty/detached/main, reuses an open PR); GET /v1/worktrees/diff, GET /v1/prs/draft, POST /v1/prs/open (+ pr.opened event); swarm wt diff, swarm pr open [--dry-run], MCP swarm_pr_open; Board: Diff drawer (file list + coloured patch) and PR drawer on worktree rows, Diff on session pages inside a worktree
M7.4 Executed gates: .swarm.toml [gates.<name>] cmd = "<cmd>" (+ optional timeout, cwd); swarm gate run <name> / POST /v1/gates/run executes in the held worktree, records the run with rubric = command, evidence = tail of output; auto-gate on Stop/SessionEnd in a held worktree writes results into the auto-handoff (M4.4); Run gates on the Board M2.2, M4.4 ✅ 2026-08-23 parseGateDefs + executedGateInput in core (rubric = ran \cmd` — exit N in Ns, evidence = log tail); store.runGatespawnssh -cin the held worktree via the process registry (kindgate, singleton gate::, log ~/.swarm/logs//gate--.log, timeout→ kill + fail) and records on exit (OQ-13);runGatessequential;[gates] auto = "session-end"|"stop"|"off"runs the executable required gates after Stop/SessionEnd inside a held worktree and writesauto-gates: …into the auto-handoffverify; POST /v1/gates/run, swarm gate run, MCP swarm_gate_run`, Gates on held task rows
M7.5 Dispatch: swarm dispatch <task…> [--ready] [--max N] / MCP swarm_dispatch / Dispatch on the Board — claims a worktree per task and spawns a swarm run in each with the task text as prompt (M3.1), respecting a per-project concurrency cap ([dispatch] max_parallel, default 2) and the required gates: a run whose gates fail does not get "done" flipped, it gets a handoff + an incident; dispatch status rolls up per task on the Board M7.1, M7.4, M3.3 ✅ 2026-08-23 core/dispatch.ts: planDispatch (ready-only, reasons for every refusal, --max, slots), taskPrompt (worktree-only, gates split executable/recorded, handoff, PR, stop-if-blocked), dispatchOutcome (stopped > crashed > gates-failed > no-pr > done); daemon/dispatcher.ts hooks Runner.onEnd: re-runs unmet executable gates, checks the forge for a PR on task/<id>, dispatch.queued/started/finished events, dispatch_failed incident, claim kept, queue drained; [dispatch] max_parallel/permission_mode/model/max_turns/require_pr; GET/POST/DELETE /v1/dispatch; swarm dispatch [--ready|tasks] [--max] [--parallel] | status | clear; MCP swarm_dispatch; Board Dispatch chip → picker + Dispatch section. Swarm's own .swarm.toml now dispatches itself (tests/lint gates executable, bun install on new worktrees)
M7.6 Agent messaging: messages table; MCP `swarm_send(to: session task "lead", text)+swarm_inbox(); delivered to running sessions as additionalContexton the next hook and as stdin to spawned runs (M3.1), pulled on demand viaswarm_inbox; **Messages** thread on the session page and a compose box; swarm msg send
M7.7 Ask the human: MCP swarm_ask(question, options?) parks the session's question as a card (same shape as the permission card, M3.2) + desktop notification (M4.7); the answer lands in the session's inbox (M7.6) and is injected on the next hook; unanswered questions show on Fleet as waiting; swarm questions / swarm answer <id> M7.6, M4.7 ✅ 2026-08-23 shipped ahead of M7.6 on a minimal messages table (kind question; M7.6 extends it). core/questions.ts (validation, context formatting); store ask (task inferred from the held worktree) / answer (once) / inbox (delivered-once) / questionContext for SessionStart; answers ride as additionalContext on the next UserPromptSubmit/PreToolUse/PostToolUse, or stdin for a live spawned run; events question.asked / question.answered; GET/POST /v1/questions, POST /v1/questions/:id/answer, GET /v1/inbox; MCP swarm_ask / swarm_inbox; CLI swarm questions / swarm answer; session page waiting on you cards with option buttons, Fleet Asking badge, notification
M7.8 Workflows: .swarm.toml [[workflows]] name = "ship" steps = ["implement", "gate:test", "gate:review", "pr"] — a declarative per-task sequence the daemon advances: each step is a spawned run (with a prompt template), an executed gate, or a built-in (pr = M7.3); a failed step stops the workflow with a handoff + incident; swarm run --workflow ship --task X, Workflow progress on the task row — see OQ-13 M7.3, M7.4, M7.5 ✅ 2026-08-24 core/src/workflows.ts ([[workflows]] parsing, step prompts), daemon WorkflowEngine + workflow_runs (boot sweep, OQ-13), gate/pr/run steps, workflow_failed incidents, GET/POST /v1/workflows, swarm workflow <name> <task>|ls|stop, Workflows Board section with step chips
M7.9 Review as a gate: built-in gate:review spawns a read-only claude -p review of the worktree diff (M7.3) with a fixed rubric, records pass/fail + findings as the gate evidence (never edits); Review on the Board row M7.3, M7.4 ✅ 2026-08-23 [gates.review] builtin = "review" (+ model, timeout): core/src/review.ts prompt/argv/verdict parsing, daemon runReviewGate (read-only claude -p --output-format json, registry + log + timeout like executed gates), findings as evidence, rubric-derived verdict; Board shows it as any gate
M7.10 swarm_context MCP tool: returns exactly what SessionStart injects (holds, lease, latest handoff, gates, resources, rule modes, pending messages/questions) so an agent can refresh mid-session; the same server registered for Codex / Gemini CLI once M5.4 adapters can host MCP (coordination becomes two-way for non-Claude agents) M1.3, M5.4 ✅ 2026-08-23 store.contextFor(cwd, session) = sessionContext + undelivered answers (delivered) + open questions; GET /v1/context; MCP swarm_context. swarm install also registers the stdio server in ~/.codex/config.toml ([mcp_servers.swarm], replaced in place) and ~/.gemini/settings.json (mcpServers.swarm) when those dirs exist; uninstall removes exactly those; doctor reports them. Tested idempotent + non-destructive

Follow-ups likely to fold into earlier milestones once M7 lands: budgets ✅ 2026-08-23 ([budget] daily/weekly/warn_at/on_exceed; core/budget.ts budgetStatus; store.checkBudgets on the tick opens one budget incident per level per day; on_exceed = "ask" makes evaluateTool ask on Bash/Edit/Write, "stop" halts spawned runs + dispatch queue; GET /v1/budget; Spend tile) and run permission profiles ✅ 2026-08-23 (RUN_PROFILES full / no-edits / read-only → --disallowedTools/--allowedTools; swarm run --profile, swarm dispatch --profile, [dispatch] profile, Run + Dispatch drawers), custom rule DSL in .swarm.toml ([[rules.custom]] match = … mode = …, path globs for writes; makes Codify M4.3 write a format the daemon reads; M2.x), secrets guard (ask on writes to .env* / *.pem and on Bash that prints them; M2.x), "while you were away" digest (M4.1 leftover), (profiles: tests-only became no-edits — Claude Code deny rules beat allow rules, so Bash can't be narrowed to test commands).

M8 — Teams (enterprise) ✅ 2026-08-24 ← direction set 2026-08-23

v0.9.0 cut (decided 2026-08-24): the crew release — M7.6 agent messaging, M7.8 workflows, M5.4 Gemini CLI adapter, M5.7 visualisation follow-ups, plus launch polish (update-restart banner, onboarding, external links — shipped; demo mode shipped (swarm demo — an isolated seeded home, tailers off); Windows smoke remains. M8.3+ stays after 0.9.

v0.8.0 cut (decided 2026-08-23): the trust release — M8.1 (✅), M8.2 complete (a: actor + schema versioning ✅, b: daemon token + auth, c: audit view/export + retention/redaction), M7.9 review-as-a-gate, plus the interface pass (row menus, Board kanban + worktree map, project settings). M7.6 messaging and M7.8 workflows move to 0.9; M8.3 team daemon starts after 0.8. Goal: one view for a team, not a laptop — without giving up local-first. The local daemon stays the hook target (fast, fails open) and forwards to a self-hosted team daemon; policy gets an owner; the ledger gets an actor. Gap analysis that produced this list: multi-user (shared daemon, identity, RBAC, org/team/project hierarchy), governance (policy precedence, tamper detection, approval routing), audit & compliance (append-only log with actor, export/SIEM, retention/redaction), cost control (budgets, chargeback, model allow-lists), operations (fleet install, air-gapped pricing, migrations/backup, /metrics), and agent coverage (M5 adapters matter more for teams). Open-core boundary: OQ-15. Ordering: M8.1–M8.2 are free, pure core/daemon, and are prerequisites for anything shared; M8.3 is the paid milestone; M8.4 rides on it.

ID Task Depends Status
M8.1 Policy precedence + tamper detection (free): org policy > repo .swarm.toml > user config, with org rules marked locked that user/repo config cannot relax; swarm doctor detects hooks removed from settings.json / rule modes downgraded and records an incident; critical rules evaluated from a signed local cache when the daemon is down (resolves OQ-3); folds the custom rule DSL + secrets guard follow-ups M2, M4.3 ✅ 2026-08-23 — M8.1a 2026-08-23: org layer ~/.swarm/policy.toml / SWARM_POLICY, locked dotted keys, loadConfigDetailed() provenance + overridden (core/src/config.ts, docs/13); M8.1b 2026-08-23: core/src/policy.ts hook coverage + findings, doctor per-event/override lines, daemon checkPolicy on SessionStart → incident.opened {rule:"policy"}, SWARM_GUARD=off ignored under locked rules, GET /v1/policy; M8.1c: policy.cache.json (locked modes + snapshot, sha256) written by the daemon, hook shim evaluates locked rules offline (OQ-3 resolved)
M8.2 Auth + actor + audit export (free): bearer token in daemon.json required for every non-loopback request (and optional on loopback); actor (human user / agent session / spawned run) on every ledger record — claims, gate runs, permission answers, incident acks; append-only audit view; swarm audit export --since --format jsonl + retention / redaction settings (transcript text, secrets on ingest) M1, M3.2 ✅ 2026-08-23 — M8.2a 2026-08-23: core/src/actor.ts Actor {kind,id,session?}, actor on every event, actor_kind/actor_id on claims/resources/processes/handoffs/gates/incident_acks/sessions, versioned migrate() + meta.schema_version with v1 back-fill, /v1/health.schema; M8.2b 2026-08-23: ~/.swarm/token (daemon-created, 0600), [daemon] auth = loopback-optional|required, /v1/* middleware (Bearer / ?token= for SSE, wrong token always 401, non-loopback always required), authedFetch in client → CLI/MCP/hook, dashboard takes ?token= from swarm ui; M8.2c: core/src/audit.ts (audit types, built-in + [privacy] redact redaction, CSV/JSONL), store.audit(), GET /v1/audit, swarm audit export, [events]/[audit] retain_days retention split, store_prompts / store_reasoning
M8.3 Team daemon (paid, separate package, self-hosted): [team] url = … in config makes the local daemon forward events, claims, gates and cost (never transcript text unless opted in) to a team daemon; cluster-wide claims and resource leases; OIDC login; viewer / developer / admin roles; org → team → project hierarchy; team dashboard reuses web M8.1, M8.2 ✅ 2026-08-24 full design in docs/14 (FSL-1.1-ALv2 packages/team, OQ-15 decided; OQ-18/19 raised). M8.3a scaffold: @ra3orblade/swarm-team / swarm-teamd, TeamStore v1 schema (machines, users+roles, teams/projects, idempotent events, spend, cluster claims, policies, tokens), /t1/health, per-package FSL license, excluded from the free bundle. M8.3b forwarding: audit events enqueue to an outbox at store.append when [team].url set (one local INSERT, never the hook path), TeamForwarder batches to /t1/ingest (at-least-once, idempotent by (machine, seq), backoff), daily spend rollups upsert, cluster project key = normalized origin remote (clusterProjectKey), [team] config (13-config), GET /v1/team, doctor forward-lag line. M8.3c auth: three modes (oidc via SWARM_TEAM_OIDC_ISSUER/CLIENT_ID, token via SWARM_TEAM_TOKEN, open for labs); device-code flow with teamd as the OAuth client (/t1/auth/device+/t1/auth/token, RS256 id_token verified against the issuer's JWKS, opaque swt_ tokens hashed in the tokens table, first user = admin); /t1/me; /t1/machines/register (developer+; machine token bound to the registering user, OQ-19) and /t1/ingest machine-authed (a token speaks only for its machine); swarm login [url] [--token] stores the human token in ~/.swarm/team-token and hands the machine token to the daemon (POST /v1/team/credentials), forwarder sends it as Bearer. M8.3d cluster claims: every held local claim re-registers on each flush (POST /t1/claims, machine-authed; same-machine registration = renewal), a conflict answer names the holder (actor@machine) and revokes the local claim — worktree deliberately untouched, claim.released + incident.opened {rule:"claim_conflict"}; releases/orphans/expiries clear the cluster row via the forwarded event stream (same-machine only); GET /t1/claims lists active cluster claims; offline degrades to advisory — local fail-closed semantics keep working. M8.3e team dashboard: teamd serves a dashboard at / (own page in packages/team/public, reusing the web package's viz.js and the docs/12 tokens) over GET /t1/state (machines with live/quiet status, cluster claims, spend today/by project/by user (chargeback)/by machine/by day, recent audit-event feed) + /t1/events SSE (change pings on ingest/claims, debounced refetch); token via ?token= → localStorage with a paste-token sign-in gate. M8.3f signed org policy: teamd keeps an ed25519 keypair (policies history table), POST /t1/policy (admin, TOML-validated) signs, GET /t1/policy serves; the forwarder fetches every 5 min, verifies against the key pinned at swarm login (TOFU on first fetch otherwise; core verifyPolicySignature is free-side) and installs ~/.swarm/policy.toml + policy.sig.json — a bad signature is reported, never installed (closes the OQ-3 signing deferral); swarm install --config-url <url> writes [team] url for fleet onboarding (the M8.5 hook). Not in scope for v1 (recorded in 14-teams): Postgres (OQ-18), org→team grouping UI (tables exist), hosted offering
M8.4 Budgets + chargeback: per-project / team / user $ ceilings → warn / ask rule / stop spawned runs; cost by team, task and ticket ID (M4.8 sources) exportable monthly; model allow-lists in policy M8.1, M8.3 ✅ 2026-08-24 team ceilings live where the cross-machine numbers live: budgets table on teamd (org / user / project scopes, daily + monthly, per-budget on_exceed), admin POST /t1/budgets, statuses vs forwarded spend (warn at 80%); each machine pulls its relevant statuses (GET /t1/budget) every flush and applies the same semantics as local [budget] — warn incident once/day, ask on Bash/Edit/Write via evaluateTool, stop halts spawned runs + dispatch (org/user scope halts every project). Chargeback: per-task daily cost forwarded as spend_task rows (task ids are the ticket ids for GH/Linear sources), GET /t1/spend/export?month=&by=detail|task&format=csv for monthly export by user/machine/model/task. Model allow-list: [models] allow = ["claude-*"] (13-config, org-lockable) — spawned runs/dispatch refuse a disallowed model, live sessions on one get a model_allowlist incident (observation, never interrupted). Team-scope ceilings (org→team grouping) wait for the grouping management UI deferred from M8.3
M8.5 Fleet operations: non-interactive swarm install --config-url, pinned versions, signed single-file binaries with SBOM/provenance, Windows; offline mode with vendored pricing table; versioned SQLite migrations + swarm doctor --migrate, backup/restore of ~/.swarm; /metrics on the team daemon; incident → Slack / Jira / PagerDuty webhooks M8.3 ✅ 2026-08-24 swarm install --config-url (M8.3f) + swarm login = the two-command fleet onboarding (pin versions by installing @ra3orblade/swarm@x.y.z); offline mode was already real — the static pricing table is vendored, SWARM_OFFLINE=1 skips the LiteLLM refresh, nothing else phones anywhere; swarm doctor --migrate applies pending versioned migrations (M8.2a) and reports the schema; swarm backup [dest] (POST /v1/backup: VACUUM INTO — consistent under WAL, zero downtime — plus config/policy/token files) and swarm restore <src> (daemon stopped, stale WAL cleared); GET /t1/metrics Prometheus text on teamd (machines/active, users, events, active claims, spend total/today; scrape with Bearer); [notify] webhook POSTs every incident.opened as Slack-compatible {text} JSON, fire-and-forget (13-config). Remaining infra items, as with M6.4: Windows code-signing needs a cert, and signed standalone single-file binaries + SBOM stay optional release work (npm publish already ships provenance)

M9 — Observatory (deep observability + graphs) ✅ 2026-08-26 ← direction set 2026-08-24

Goal: the data already in ~/.swarm/swarm.db (hooks, transcripts, ledger, gates, PRs) pays off a second time. Close the loop past the PR (did the agent's work survive?), watch behaviour live (is it stuck? colliding?), and render the fleet's relationships as graphs, not only tables. The first task is the interface itself: the flat header nav and if/else view dispatch don't scale to this many views — restructure once, then every feature lands as a registry entry.

Ordering: M9.1 (interface at scale) unblocks everything else. Observability (M9.2–M9.10) and graphs (M9.11–M9.17) then proceed independently; M9.11 (graph engine) gates the other graph views; A/B compare (M9.18) comes last — it composes dispatch (M7.5) with outcomes (M9.2). Everything reads existing state — no new collection surface in the monitored repo, ever.

ID Task Depends Status
M9.1 Interface at scale: view registry in app.js (one entry per view: id, label, icon, group, render, badge), grouped sidebar nav (Observe / Work / Insight / Guard) replacing the flat header links, collapsed sidebar becomes an icon rail, ⌘K palette (jump to any view / project / session; falls through to Search) M0.6 ✅ 2026-08-24 VIEW_DEFS drives nav, render dispatch, deep links and palette; #viewnav in the header — four group buttons (Observe / Work / Insight / Guard) that open a fancy-menus submenu of their views, badges summed per group, rebuilt only on html change, and the active group also shows the current view's own label so the header answers "where am I"; body.nosb = 46px icon rail; ⌘K/Ctrl-K palette reuses the #picker overlay (.pk.pal), substring-ranked over views/projects/sessions with a Search fall-through row
M9.2 Outcome tracking: join session → commits → PR → merged / reverted / CI in the ledger; Outcomes view with per-model/per-agent scorecard (merge rate, revert rate, time-to-merge) M4.2, M1.8 ✅ 2026-08-24 core/src/outcomes.ts outcomeReport (branch ← sessions by git branch, PR by headRef, reverted via parseReverts over git log --grep 'This reverts commit', sha prefix-matched; dominant session supplies model/agent; merge rate counts finished work only) + 7 tests; forge.merged() fetches merged PRs/MRs (gh --state merged incl. mergeCommit / glab --merged), cached 10 min; GET /v1/outcomes?project=; Outcomes view (Insight): by-model + by-agent scorecards (merge rate, median lead session-start→merge, $/merge) and a branches grid with outcome badges + PR links. CI status per PR deferred
M9.3 Loop & stall detection: same failing command N×, edit–revert oscillation, test-fail cycles, long idle — "stuck" badge on Fleet + desktop notification; heuristics in core with unit tests M0.8 ✅ 2026-08-24 core/src/stall.ts detectStall (repeat-and-failing run, all-failing streak; conservative — plain repeats like git status polling never count) + toolResponseErrored (unambiguous markers only); store.checkStalls() on the 10 s tick judges live sessions' last 12 tool.completed events, one session.stuck event per episode (kind transition), reason updates in place; SessionView.stuck → red Stuck badge on the Fleet session cell + desktop notification. Edit-oscillation and idle heuristics deferred to a follow-up
M9.4 Waiting-on-human: blocked-time per session (permission prompts, swarm_ask), notification→response latency; surfaced on Fleet and Stats M3.2, M7.7 ✅ 2026-08-25 core/src/waiting.ts pairWaits + waitingReport (three kinds: permission.requested→resolved, question.asked→answered, and session.notification closed by the session's next prompt/tool call; a wait still open when its session ended is capped at that end, never at now) + 12 tests; store.waiting(project?, days=7) assembles the samples (ordering by seq so events(session_id, seq) is used) and GET /v1/waiting; a Waiting badge on the Fleet session cell naming what blocks it, and a Waiting on you section on Stats (by kind, median/longest, and the sessions that waited most)
M9.5 Context composition: per-session window breakdown (file reads, tool results, MCP schemas, thinking, system) + waste metric; generalises the repeated-read detector M4.2 ✅ 2026-08-25 core/src/context.ts contextReport + rereadWaste + 13 tests. Tool-result volume is exact (every tool.completed stores its response); the waste metric is re-reads — a file read N times costs the window N copies and everything after the first is waste, while a file read once never is, however large. store.context() + GET /v1/context; Context view (Insight): what fills the window by tool, cache-hit rate, and the sessions that wasted most with their worst files. MCP schemas and the system prompt are not included — Swarm sees calls, never the schemas or the prompt preamble, so they are absent rather than estimated; token figures are a flat 4:1 estimate and are labelled as one. On real data: 14.7M characters returned in 7 days, 2% of it re-reads, one session at 11%
M9.6 MCP server health: per-server tool-call latency distribution, error rate, schema token overhead across all sessions M0.8 ✅ 2026-08-25 core/src/mcphealth.ts mcpHealth + serverOf/toolOf + 11 tests (an MCP server name may itself contain underscores — mcp__claude_in_chrome__navigate splits correctly), reusing percentile from M9.7 and toolResponseErrored from M9.3 rather than restating them. No duration is recorded anywhere, so latency is hook-to-hook: PreToolUse→PostToolUse paired per session with at most one open request per tool — a stack instead let an abandoned request be closed by an unrelated completion and reported a git commit as taking 7.8 hours. A call held behind a permission prompt legitimately carries that wait, so the view leads with p50/p95 and labels slowest accordingly. store.mcpHealth() + GET /v1/mcp/health; MCP view (Insight) — calls, sessions, p50/p95/slowest, total waited, unanswered, error rate, busiest tools. Schema token overhead is not implemented: Swarm never sees MCP tool schemas, only calls, so there is nothing to measure without a new collection surface — deferred rather than estimated
M9.7 Gate flakiness: executed-gate duration + pass/fail history; flaky-gate and slowest-gate views M7.4 ✅ 2026-08-25 core/src/gatehealth.ts gateHealth + percentile (flaky = the same gate returned both verdicts on the same task; a fail on a different task is the gate working, and is not counted) + 11 tests; gate wall-clock became a real column — schema v2 adds gates.duration_ms and back-fills historic rows from the rubric prose it used to hide in (… in 12.5s), agent-recorded gates staying null rather than 0; store.gateHealth(project?, days=30) + GET /v1/gates/health; new Gates view (Insight, flaky-count badge) — pass/fail strip, pass rate, flips, p50/p95/slowest and total wall-clock, ranked flaky-first then most expensive
M9.8 Machine hygiene: CPU/RAM per tracked process, orphan detection, stale-worktree/disk usage, one-click reap M1.7, M7.2 ✅ 2026-08-25 core/src/hygiene.ts classifyProcess / classifyWorktree / hygieneReport + 17 tests — processes are dead (pid gone, registry drifted), orphaned (alive after its session ended) or hungry (named, never auto-reclaimed); worktrees are stale (merged, idle, nobody in them), abandoned (unmerged and long untouched — surfaced, never called safe) or heavy. Reclaimability defers to the ledger's own canRemoveWorktree rather than re-deriving the predicate, with a test asserting the two agree, so the view can never offer a Remove that wtRemove would refuse. store.hygiene() samples one ps for every tracked pid and runs du off the request path (cached 10 min, null until the first sweep — the view says measuring… rather than a false 0); GET /v1/hygiene; new Hygiene view (Work, issue-count badge) with KPIs, both grids and the existing Stop / Remove actions inline
M9.9 Security audit: per-session egress domains, package installs, secret-read detection over transcripts; observation first, ask rule second M2.1, M0.8 ✅ 2026-08-26 core/src/security.ts securityScan + hostsIn/installsIn/secretsIn + 30 tests. Observation only — nothing here denies anything, which is the roadmap's order: learn what the fleet does, then write an ask rule about it. Reads what was requested, so a command a rule already blocked still appears. Egress over-reports on purpose: a host means an agent named it, and whether bytes left cannot be known without running the command. Secrets are matched on file names, never contents — Swarm sees the path only. Two parser bugs surfaced only against real data: installs were read anywhere in the string, so the sentence "then npm install and the rest follows" became four packages, and shell redirections became packages called 2> and >/dev/null (315 installs → 93 real ones); and stripping only a numeric port left 127.0.0.1:$PORT as its own host. store.security() + GET /v1/security; Security view (Guard). Sold as a lint, not a sandbox: an obfuscated command will not match and a comment mentioning .env will. On real data: 7,976 commands, 72 remote hosts, 93 installs, 102 credential-file opens
M9.10 Rule effectiveness: incidents/week before vs after each rule or lesson landed; recurring-incident clustering M4.3 ✅ 2026-08-26 core/src/ruleeffect.ts ruleEffect + commandSignature + 18 tests. The useful reading is clustering: a rule that fires once taught somebody something, a rule that fires forty times on the same shaped command is friction — either the habit changes or the rule does. commandSignature reduces a command to program + subcommand-or-flag, skipping a leading cd (cd repo && git add -A is a git habit, not a cd habit), so siblings collapse; concentration is how much of a rule is one shape. Before/after needed a fact that did not exist — nothing recorded when a rule landed, it simply appeared in a config file — so policyFor now writes rules.changed when the effective set differs from the last one seen, persisted in meta so a restart is not mistaken for an edit. Older databases have none and the comparison is reported unavailable rather than guessed. store.ruleEffect() + GET /v1/rules/effect; Rules view (Guard). On real data: 62 incidents over 5 rules, pattern_kill 39 of them
M9.11 Graph engine in viz.js: deterministic layered DAG layout + small static force pass, stable positions across SSE updates, shared node/edge styling — no chart lib M0.6 ✅ 2026-08-25 viz.bipartite landed with M9.12; the layered DAG engine lands here as core/src/dag.ts layoutDag + findBackEdges + 14 tests — the maths sits in core, not viz, so it can be unit-tested; viz.dag only draws. Longest-path layering, barycenter crossing reduction, every tie broken by node id so the same graph always yields the same coordinates, an optional seed so a new node does not reshuffle the picture mid-SSE, and cycle-safe by design (two agents messaging each other is a legitimate 2-cycle — the closing edge is found by an iterative DFS and drawn bowed). Still no chart library
M9.12 Live file-collision graph: running sessions × touched files (bipartite); shared file nodes highlighted as predicted conflicts + notification; optional ask rule on contested paths M9.11, M2.1 ✅ 2026-08-24 core/src/graphs.ts collisionGraph (contested = ≥2 sessions + ≥1 writer; writers via WRITE_TOOLS); store.collisions over live sessions' tool.requested events (json_extract on toolInput.file_path); GET /v1/graphs/collisions?project=; Graphs view (Observe, contested-count badge) polls while open — contested files red with tooltip, legend explains edges. Notification + ask rule on contested paths deferred to a follow-up
M9.13 Session lineage DAG: dispatch / handoff / subagent / message edges; nodes coloured by outcome once M9.2 lands M9.11, M7.5 ✅ 2026-08-25 core/src/lineage.ts lineageGraph + handoffEdges + collapseFanout + 19 tests. All four edges come from recorded state: subagent is a run of turns carrying an agent_id rolled up as its own node (subagent.started only bumps a counter, and sessions.parent_id is never written — the earlier reading of it was dead code); dispatch needed dispatch.started to record by, since the run's own session is the child, not the requester; message is sender → recipient; handoff is one task's successive claim holders. Readability came from drawing fewer things, not a faster renderer: a sibling fan over fanout collapses to one click-to-expand pill (127 nodes / 2346px / 87 hidden → 21 nodes / 408px / none hidden on a real database), edges leave after their source's label, and labels carry a halo. GET /v1/graphs/lineage; Lineage tab on Graphs
M9.14 Provenance chain: issue → task → session → worktree → commits → PR as a traversable graph (audit-friendly) M9.11, M9.2 ✅ 2026-08-25 core/src/provenance.ts provenance + breakOf + 17 tests, built on M9.2's BranchRow rather than re-deriving branch → PR → merged. Walked from both ends: forward from tasks, and back from branches — so work that landed with no task behind it is surfaced, which a task-keyed walk can never show (22 such branches on a real database, one carrying $255). Chains rank by audit severity then cost, not by raw incompleteness: ordering purely on depth buried those 22 under 93 inert roadmap rows nobody had claimed. GET /v1/provenance (shares one outcomesFor join with /v1/outcomes); Provenance view (Guard, untracked-count badge) — six link dots per row, filled to where the trail goes cold. The issue → task link carries no URL: the task source records ids and titles, not links
M9.15 Tool-transition digraph per session: weighted edges over tool sequences; high-weight cycles feed the stuck detector (M9.3) M9.11 ✅ 2026-08-26 core/src/transitions.ts transitionGraph + findLoops + detectToolLoop + 20 tests. Pairs are consecutive calls within one session — pairing across the boundary would invent a transition nobody made. Drawn as an adjacency matrix, not a layered graph: the transition graph is dense and cyclic, so nearly every edge lays out as a back-edge and the picture is a hairball; a matrix has no crossings, puts self-loops on the diagonal, and shows asymmetry as a lopsided pair of cells (viz.matrix, log-scaled — Bash → Bash at 6881 would flatten everything else linearly). The cycles do not feed the stuck detector as planned: Read → Edit → Read → Edit is the most ordinary thing an agent does, so a bare oscillation is not evidence of anything. detectToolLoop therefore requires the oscillation and failures inside it, the same bar M9.3 sets for a plain repeat. store.transitions() + GET /v1/graphs/transitions; Tools tab on Graphs, badged with the round-trip count. On real data: 10,198 transitions over 62 sessions, Bash → Bash 6881
M9.16 Agent-traversal map: file-touch heat across sessions — hot zones (context-hungry, CLAUDE.md candidates), cold zones, incident correlation M9.11 ✅ 2026-08-26 core/src/heat.ts fileHeat + dirOf + 17 tests. Re-reads are counted per session — two sessions reading a file once each have each learned something; one session reading it twice has not. A CLAUDE.md candidate is a file several sessions re-read and hardly ever write: read and written is where the work is, not a reference being re-learned. The two-session bar is empirical — on a real database only eight files were touched by more than one session at all, so a three-session bar could never fire. store.fileHeat() + GET /v1/heat; Files view (Insight). On real data: 333 files, 757 touches, 55% of every touch a re-read, 222 files touched once and never returned to. Incident correlation is not implemented: an incident.opened payload carries the rule, the action and the command — not a path — so tying a rule that fired on a shell command to a file would mean parsing paths out of command strings and guessing, and it is left out rather than estimated
M9.17 Resource-holding graph: sessions ↔ claims / ports / processes; orphaned-resource and wait-cycle detection M9.11, M1.7 ✅ 2026-08-26 core/src/resourcegraph.ts resourceGraph + findContention + 21 tests. Orphaned is the session, not the owner: an owner string outlives the run it belonged to, so asking whether the owner is still around would call every finished agent's leftovers live. Wait cycles as specified cannot occur — claims fail closed, so a second claimer is refused rather than queued and nobody blocks holding one thing while waiting on another. What does occur is contention (A holds T1 and was refused T2 while B holds T2 and was refused T1), and that needed a new fact: refusals were not recorded at all, so claim.denied is now written at the point of refusal and supplies the wanted-edges. Drawn with the existing bipartite renderer — holders left, what they hold right, faint edges for what they were refused. store.resourceHolding() + GET /v1/graphs/resources; Resources tab on Graphs, badged with the orphan count
M9.18 A/B dispatch: same task to N models/agents in parallel worktrees; compare cost, wall time, gates, diff size, outcome M7.5, M9.2 ✅ 2026-08-26 core/src/abtrial.ts scoreTrial + armTask/splitArmTask + 14 tests. The ledger is not bent to run an experiment: a claim is one holder per (project, task) and the runner refuses a second live run on a task, so an arm is its own task id <task>#<arm> with its own claim and worktree — a daemon test asserts the bare task stays claimable and the arms hold separate trees. Scoring is blunt on purpose: an arm is eligible only if it finished and passed every gate it ran, then cheapest wins with wall time and name breaking ties, so a cheap arm that failed a gate never wins. An arm's session joins by worktree cwd, not by the claim's actor — the runner claims as ab:<arm>, which the ledger reads as a person, so the actor never names the session. Diff size is git diff --shortstat against the main checkout's branch, swept off the request path and cached. store.abTrial/abTrials, GET /v1/ab (one trial or all) and POST /v1/ab to start one; Trials view (Work, badged with trials still running)

M10 — Reclaim (make hygiene actionable) ← direction set 2026-08-26

Goal: hygiene reported 50 GB held across 32 worktrees and offered nothing to do about it. Measure what is actually recoverable, and act on it.

ID Task Depends Status
M10.1 Build-output reclamation: measure node_modules / target / dist per worktree, offer to clear it, keep the branch M9.8 ✅ 2026-08-26 core/src/hygiene.ts reclaimPlan + BUILD_DIRS + 10 tests. Clearing build output is a different act from removing a worktree: it keeps the checkout, the branch and every uncommitted edit, so a dirty tree is fine — what it refuses is the main checkout, a held claim, or a live session, because those mean somebody is mid-build. Containment is checked twice, when the plan is made and again at the moment of deletion, and a nested worktree's output belongs to that worktree rather than being swept up by its parent (which would also double-count the bytes in both rows). Measured in the same off-request sweep as du, with one pruned find per worktree. store.reclaimBuild() + POST /v1/hygiene/reclaim (dry returns the plan); Hygiene gains a build output column, a total, and a per-row Clear. Verified end to end on a real worktree: 222 MB → 9 MB, 213 MB freed across 7 directories, branch and git state intact. On this machine: 31 GB of the 50 GB held is build output
M10.2 Stale threshold that fires: seven days never elapsed on a machine where work lands daily M9.8 ✅ 2026-08-26 staleDays 7 → 2. The report said 50 GB held and 0 reclaimable, which reads as broken rather than cautious; the ledger's canRemoveWorktree is still what decides whether removal is safe, so this only changes when a merged, unoccupied tree is worth mentioning. On this machine it took the offer from 0 to 8 worktrees and 1.7 GB

M11 — React dashboard (readable, reactive) ← direction set 2026-08-27

Goal: the dashboard stops being 3,551 lines of one file that repaints by replacing its own DOM. Componentised React + TypeScript, types imported from @swarm/core, Google TS style. See the OQ-6 decision for why React and why still no Vite. The dashboard must work at every commit — the shell lands first and unported views keep rendering through a legacy adapter until each is converted, so no commit ships a half-migrated app.

ID Task Depends Status
M11.1 Toolchain: multi-entry Bun.build, CSS pipeline, app tsconfig, Biome rules encoding the Google TS guide — ✅ 2026-08-27 two entrypoints (menus.js, dashboard.js + dashboard.css); Biome rules for the Google TS guide; DashboardSnapshot contract; SessionView/Worktree moved to core; core subpath exports so a browser bundle cannot pull node:crypto
M11.2 Data layer: typed endpoints over @swarm/core types; useSnapshot (poll + SSE) and view-scoped useResource replacing 21 xChanged booleans M11.1 ✅ 2026-08-27 api/client.ts (one fetch path, token attached once, typed ApiError), api/useSnapshot.ts (useSyncExternalStore; SSE nudges a re-poll; identical body does not notify), api/useResource.ts (view-scoped, keeps the last value across refetches), api/endpoints.ts (routes typed by core report types)
M11.3 Design tokens + UI primitives (Card, Badge, Empty, StatTile, Section) extracted from the 775 CSS lines in index.html M11.1 ✅ 2026-08-27 CSS extracted verbatim; ui.tsx primitives, Panel, BarList, PathCell, Mark (a real <svg>). Splitting the sheet into tokens/base/components is a follow-up, tracked in the README
M11.4 <DataGrid>: port table.js (262 lines) to typed generic columns, keeping sort / resize / reorder / filter / column menu / per-table persistence M11.3 ✅ 2026-08-27 components/DataGrid.tsx — generic over its row type; sort / resize / reorder / filter / column menu / per-table persistence kept, plus keyboard sorting and aria-sort
M11.5 Charts: port viz.js (446 lines) — sparkline, bipartite, layered DAG, bars M11.3 ✅ 2026-08-27 Sparkline, StackedColumns, Heatmap, Legend, BarList, Calendar, Line, CompositionBar, streaks, Timeline, Bipartite, Matrix, Dag — all of viz.js
M11.6 Shell: header, nav, project sidebar, router (?view= deep links), error boundary, legacy-view adapter M11.2, M11.3 ✅ 2026-08-27 Header + nav + Sidebar + view registry + error boundary; zustand ui store is the router (?view= deep links). Served at /next beside the original
M11.7 Views — Observe: Fleet, Timeline, Graphs M11.4, M11.5, M11.6 ✅ 2026-08-27 Fleet, Timeline, Graphs (all four tabs: collisions, lineage, tools, resources)
M11.8 Views — Work: Board, PRs, Trials, Hygiene M11.7 ✅ 2026-08-27 Board, PRs, Trials, Hygiene — Hygiene with its three actions wired through api/actions.ts
M11.9 Views — Insight: Outcomes, Gates, MCP, Context, Files, Spend, Stats, Search M11.7 ✅ 2026-08-27 Outcomes, Gates, MCP, Context, Files, Spend, Stats, Search
M11.10 Views — Guard: Security, Provenance, Incidents, Rules M11.7 ✅ 2026-08-27 Security, Provenance, Incidents, Rules
M11.11 Session detail + Replay, run control and the stdin box M11.7 ✅ 2026-08-27 Session detail (header, merged event/turn stream, stats sidebar, row menus); Modal portals into #picker; Replay steps tool calls with per-step payload fetch + cache; DiffDrawer (per-file patch, rendered as elements never HTML); RunDrawer + resume-where-it-died; RunControl stdin box and permission cards for spawned runs
M11.12 Retire app.js / table.js / viz.js; drop the legacy adapter; component tests M11.8–M11.11 ✅ 2026-08-27 React serves /; app.js retired and /next 308s to /. viz.js and table.js stay in web/public because the team dashboard is still a vanilla page and loads them from there. The three art-drift tests are replaced by one that fails if any component pastes a copy of the drawing
M11.13 App chrome the port dropped: ⌘K palette, theme, zoom, desktop notifications, What's New, update + star nudges, feedback, desktop window drag M11.12 ✅ 2026-08-27 The screens were ported; the chrome was not. ?chrome=inset (the macOS overlay title bar) was the worst of it — no traffic-light padding and a window that could not be dragged. #root also broke every `body > aside
M11.14 Render what agents write: everything an agent says is markdown, and the dashboard showed the source — **bold**, backticks and ``` fences in the session log, and a "What's New" panel whose build-time HTML put every source line in its own paragraph. One renderer for all of it: a parser in web/src/lib/markdown.ts and elements (never HTML) in components/Markdown.tsx. Places with room for one line strip the marks instead M11.11, M11.13 ✅ 2026-09-14 lib/markdown.ts (spans / parse / plain / firstLine — paragraphs, headings, bullet and numbered lists with nesting, fenced code, quotes, rules, pipe tables, inline code/bold/em/strike/links/bare URLs; 19 tests) and components/Markdown.tsx, which builds React nodes and checks every href, so model-written text never reaches dangerouslySetInnerHTML. Rendered in the session log (assistant and subagent turns) and in What's New, which now ships the changelog's markdown from tools/build.ts instead of generated HTML. Stripped to one line on Fleet's now column, in search snippets and for the prose event summaries (prompt, question, answer, message)

M12 — Reach (interop, surfaces and guards) ← direction proposed 2026-09-06

Goal: Swarm's data shows up where people already look (their APM, their terminal, their plan meter), its rules hold on every agent it observes, and the Observatory turns from fixed detectors into ones the user writes. Design and the survey behind it: 15-landscape. Three batches, each shippable alone; decisions needed: OQ-20 – OQ-23.

v0.14.0 cut (decided 2026-09-12): M12.2 and M12.3 ship with M13.1–M13.6 and M13.12 (see M13); M12.1 and the rest of M12 move to 0.15.

v0.15.0 cut (decided 2026-09-18): M12.5, M12.4 and M12.1 ship with M13.9, M13.7 and M13.13 (see M13 for the order and the reasoning). M12.6 sandboxed runs and M12.7 reviewer move to 0.16 — each wants its own design pass (OQ-21, OQ-22) and the sandbox depends on an external runtime; M12.8 – M12.12 are unscheduled. OQ-20 (OTLP schema) has to be decided before M12.1 starts.

v0.16.0 cut (decided 2026-10-01): M12.13, M12.6, M12.7, M12.11 and M12.12 ship with M13.8, M13.10 and M13.11 (see M13 for the order). OQ-21 and OQ-22 were decided the same day, both as proposed.

ID Task Depends Status
M12.1 OTLP export: one trace per session (gen_ai.conversation.id), a span per hook-paired tool call, blocked_on_user spans from waiting pairs, claim / incident / gate span events, cost + token metrics; [otel] endpoint — off unless set; verified against Grafana Cloud's Claude Code dashboard M9.4, M9.6 ✅ 2026-09-18 OQ-20 decided (GenAI default, compat = "claude-code"). core/src/otel.ts builds OTLP/HTTP JSON (derived trace/span ids, so a retry or a late root lands on the same trace); daemon/src/otel.ts exports every [otel] interval from two meta cursors (event seq → spans, turn rowid → metrics), each moved only after the collector accepts; backoff to 5 min; GET /v1/otel + swarm doctor. Tool spans pair tool.requested/tool.completed by tool_use_id, waits from permission.* / question.* pairs (a permission wait nests under its tool), marks = claims / incidents / gates on the session root. Validated end to end against an OpenTelemetry Collector 0.161 in Docker (both vocabularies decoded, parent ids and durations right, an outage lost nothing). Not verified against Grafana Cloud's Claude Code dashboard — no account here
M12.2 Statusline: swarm statusline over Claude Code's statusLine stdin JSON + daemon state (task, lease, budget, incidents, waiting); 400 ms, fails open, never starts the daemon; swarm install --statusline only when none is set. Verify the payload against current docs first M1.2 ✅ 2026-09-12 core/src/statusline.ts (payload type verified against the statusline reference on 2.1.269, renderStatusline, statuslineIsOurs), swarm-hook statusline (hook/src/statusline.ts: 400 ms, fails open to the local fields, never starts the daemon), POST /v1/statusline → store.statuslineFor (held task + lease by cwd, budget standing, open incidents, open questions, inbox; last payload kept per session for M12.3), swarm install --statusline / uninstall / status / doctor with ownership detection so another status line is never replaced. Plan windows render from the payload already; storing them is M12.3
M12.3 Plan quota windows: 5 h / 7 d window %, reset, burn rate, time-to-limit from the statusline payload (verify availability); quota samples; Spend + statusline; [budget] window_warn_at opens a budget incident M12.2 ✅ 2026-09-12 core/src/quota.ts (quotaSamples from the payload, quotaReport with burn rate over the newest reset period only, time-to-limit, limitBeforeReset, levels, tightestWindow; 5 tests); quota table filled by POST /v1/statusline (a row when a window moves or 10 min pass, pruned at 14 d), store.quota() in the snapshot + GET /v1/quota, checkQuota() on the tick → one budget incident per window per reset per level; Plan windows tiles on Spend; the statusline says 5h limit in 2h; [budget] window_warn_at (0.8, false = off). Statusline only, per OQ-23
M12.4 Rules on Codex, Gemini CLI, Cursor: map each agent's pre-tool hook payload onto ToolRequest; swarm install registers the shim (Gemini BeforeTool + policy tier, Cursor hooks.json, Codex hook surface — verify, else generated requirements.toml command rules); doctor coverage per agent; Rules view shows which agents each rule holds on M5.4, M2.1 ✅ 2026-09-18 hook contracts verified against each reference (Codex PreToolUse — stable since rust-v0.124.0, schema in codex-rs/hooks; Gemini BeforeTool; Cursor docs). Cursor needs no hook of its own: it imports ~/.claude/settings.json hooks (Third-Party Imports, on by default) and maps Bash→Shell, so Swarm's existing hook was already running there with a Cursor payload and silently allowing it. core/src/agenthooks.ts: detectAgent, toToolRequests (Codex apply_patch → one Write per *** … File: header), renderAgentDecision; the daemon answers non-Claude payloads on the same /v1/hook/<event> route without ingesting them (their adapters record the sessions), the shim's offline path covers them too. swarm install adds Codex ~/.codex/hooks.json PreToolUse and Gemini settings.json BeforeTool beside the user's own; doctor + GET /v1/rules/agents + a Rules-view panel show where the rules hold. None of the three can ask: ask → deny with the reason (OQ-29). Codex runs a new hook only after the user trusts it once (/hooks). Not live-tested against the three CLIs — none is on this machine
M12.5 Destructive family + secrets + tamper: destructive_fs, destructive_infra, pipe_to_shell, secrets (credential-file reads/prints, .env* / *.pem writes), config_tamper; [[rules.custom]] DSL so Codify writes a format the daemon reads; each new rule ships off one release, observed on Security, then ask M9.9, M4.3 ✅ 2026-09-18 core/src/guards.ts (pure detectors, 107 tests) → rules.ts familyHitsBash / familyHitsFile / guardFile; guardBash checks the families after the coordination rules and before [[rules.custom]] (a custom rewrite never softens one). The file side covers Read too, which no rule did before — daemon guardHook / evaluateTool, the offline shim (all five are lockable by org policy) and the dry-run replay. All ship off; securityScan counts every family's would-be hits whatever the mode and the Security view shows them beside the current mode. Tuned against the look-alikes: rm -rf node_modules / /tmp/…, .env.example, cp .env.example .env, terraform apply (that is dry_run_first), a DELETE … WHERE. Codify suggests deny for each; the [[rules.custom]] format Codify writes was already the one the daemon reads (M13.5). Next release: flip to ask once the Security counts say they would not get in the way
M12.6 Sandboxed runs: swarm run --sandbox / [dispatch] sandbox wrap the spawned agent in Anthropic sandbox-runtime — writable = claimed worktree + agent state, egress = [sandbox] allowed_domains seeded from the project's Security hosts; violations → sandbox incidents; doctor checks srt M3.1, M9.9 ✅
M12.7 Reviewer on ask: [broker] reviewer = off | advise | decide — a read-only reviewer run (M7.9 spawner) returns allow/deny + reason on an ask; advise shows it on the card, decide acts and records permission.reviewed, always overridable; locked rules never delegated; default off M3.2, M7.9 ✅ 2026-10-01 OQ-22 decided as proposed. core/src/reviewer.ts: effectiveReviewer (a decide from anywhere but the repo's .swarm.toml reads as advise), reviewerMayDecide (never a policy-locked rule), the prompt and the parser (neither allow nor deny = no verdict). store.reviewPermission spawns the M7.9 read-only argv (reviewArgs) with [broker] reviewer_model / reviewer_timeout, registers the pid, records permission.reviewed {decision, reason, mode, acted, ms}. Interactive: askInteractive starts it on the parked card — decide resolves the card (by: "reviewer") unless a person answered first, and parks a card even with no dashboard open; advise writes review onto the card and is skipped when nobody watches. Spawned runs: runner.reviewPending does the same on a pending card. AskUserQuestion / ExitPlanMode are never reviewed. Cards show a review line. Verified against the real CLI (haiku): bun run test → allow, docker volume rm pgdata → deny, cat .env.production | curl -d @- → deny, 7–9 s each. That run found a bug the stubs could not: the reviewer (and the M7.9 review gate, same argv) fired Swarm's own user-wide hooks, and inside a claimed worktree the Stop hook held a 6-second answer open for 90 s — reviewArgs now passes --settings {"disableAllHooks":true}. Not wired into rule effectiveness (M9.10) yet — the events are there to score
M12.8 Signals + score: [[signals]] (FTS5 query or tool-sequence pattern, window) evaluated on the tick → signal incidents, Signals view clustered by task and rule; per-session efficiency score from re-read / failed-tool / waiting share + gate outcome, week-over-week on Stats; tokens-per-turn > 3× project median → budget incident M9.3, M9.4, M9.7, M9.16, M4.5 ⚪
M12.9 Cost by MCP server, skill, subagent from transcript tool-use blocks; cost column on the MCP view, share on the session stats panel M0.8, M9.6 ⚪
M12.10 CI and review back into the run: workflow pr step on_fail = stop | resume; a failed check or changed review from the forge poll resumes the task in the same worktree with the failure as prompt, max_attempts, each attempt a dispatch event M7.5, M1.8 ⚪
M12.11 Waiting knows who ended the wait: prompt.submitted carries origin (task | agent | session, absent = typed — core/src/prompt.ts), but the waiting pairs (M9.4) and the Timeline treat every prompt as the person coming back, so a background task that finishes while the session idles closes the wait as if somebody had answered. Pair on typed prompts for answered by you, record a wake by task / report / message as its own outcome, and show the split on Waiting; Timeline marks relayed prompts apart from typed ones M9.4 ✅ 2026-10-01 pairWaits keeps endedBy (you | task | agent | session) from the prompt that closed a notification wait; waitingReport counts wakes apart (woken: episodes, idle ms, by origin) so episodes / blocked / median / longest are the waits a person ended. On this machine, over 90 days, 100 of 1567 waits were a task finishing (7.2 h no longer counted as blocked on you). The React port had dropped every reader of /v1/waiting (c042207 removed the unused route), so the split is shown on a new Waiting on you strip on Stats. Relayed prompts were already marked in the session log (prompt.task / .agent / .session, PR #170); the Timeline view draws turns, not prompts, so it is unchanged
M12.12 A subagent's report sits under the subagent: the session log shows sub →, sub ←, the report prompt and the task notification as four unrelated rows; join them on the agent id (agent-message from, task-id) into one expandable row — started, ran for, first line of the report, full report on expand M11.11 ✅ 2026-10-01 describePrompt now returns ref (the agent id: from on <agent-message>, <task-id> on <task-notification>) and the task's usage (tokens, tool uses, duration); ingest stores both, and sessionEvents reads them off the stored prompt for rows written before (no migration). views/session/subagentRuns.ts joins start / stop / report / notification on that id into one row at its newest member (so a run that just reported comes to the top): what it was asked, ran for, tokens, tools, first line of the report, full report fetched from /v1/events/:seq and rendered as markdown. A lone SubagentStop with nothing else under its id (Claude Code's own short-lived agents — ten or so per long review on this machine) stays a plain row
M12.13 The 0.15 guards start asking: destructive_fs, destructive_infra, pipe_to_shell, secrets and config_tamper shipped off in 0.15 and were observed on Security for a release; their default becomes ask (the M9.9 order), the Rules and Security views and the guide say so, and a repo that set a mode keeps it M12.5 ✅ 2026-10-01 Replayed the 14 days before the flip (13.4k Bash calls) through the detectors first: 158 would-be asks, most of them false — curl … | python3 -c / node -e (the download is stdin data, the code is inline), S=/tmp/x; rm -rf "$S" (assigned in the same command; a bare $VAR that is empty removes nothing — only $VAR/ is /), and cat .swarm.toml 2>/dev/null / sqlite3 … "… >= …" read as writes. pipeToShell now checks what the interpreter runs, destructiveFs resolves literal assignments, configTamperCommand collects real write targets (redirects, rm/mv/tee operands, a copy's destination, sed -i, a writing sqlite3), and words() joins "$DIR"/x into one word. 62 remain, 59 of them genuine .env reads. Defaults ask in core/src/rules.ts + config.ts; guide, docs/13 and the Security hint updated

M13 — Act (agents are steered, not only watched) ← direction set 2026-09-12

Goal: interactive sessions are how nearly everyone runs an agent, and for them Swarm has two levers — context at SessionStart and a deny / ask on PreToolUse. Spawned runs get the rest (stdin, the broker, dispatch, workflows, executed gates). M13 gives interactive sessions the same authority using response fields the hook protocol already offers and Swarm never returns, and lets Swarm hand its own findings back to the codebase. Every task is optional ~/.swarm/config.toml / .swarm.toml behaviour; nothing is required in the monitored repo, and every steering act is an event.

Hook schema verified 2026-09-12 against code.claude.com/docs/en/hooks on Claude Code 2.1.269 (the CLAUDE.md rule: never from memory). 33 events exist; Swarm installs 10 and acts on 2. The fields M13 builds on, verbatim from the reference: Stop / SubagentStop → hookSpecificOutput.decision = "block" + reason keeps the agent working; PermissionRequest → decision = "allow" | "deny" + updatedInput (exit 2 is not honoured on this event); PreToolUse → updatedInput; PostToolUse → additionalContext, decision = "block"; PostToolUseFailure, PostCompact, ConfigChange, WorktreeCreate / WorktreeRemove, PreModelSwitch (decision = "block"), TaskCreated / TaskCompleted, TeammateIdle; command hooks accept async: true and asyncRewake: true (background, exit 2 wakes Claude with stdout/stderr as a system reminder). Default hook timeout is 600 s (30 s on UserPromptSubmit and the model-switch events), so a hook that waits on a person is allowed — the shim's 400 ms budget is Swarm's choice for the hot path, not the protocol's limit. Re-verify per task; keep the upstream payload under raw.

v0.14.0 cut (decided 2026-09-12): M12.2, M12.3, M13.1–M13.6 and M13.12. M12.1 OTLP and M13.7–M13.11 go to 0.15. Order inside the release: M12.2 → M12.3 (the statusline is the quota channel), then M13.1 → M13.5 → M13.2 → M13.3 → M13.4 → M13.6 (core rule semantics first, the hook-shape work next, the repo-writing task last so it lands on a settled event vocabulary), then M13.12 last because it is the one task with a distribution decision (OQ-27) and its own package to publish.

v0.15.0 cut (decided 2026-09-18): M13.9, M13.7, M12.5, M12.4, M12.1 and M13.13 — covers every agent, and survives the long run. Six agents are observed and one is ruled, which is the first objection a stranger raises, so the rules work (M12.5 then M12.4) is the centre of the release. M13.9 and M13.7 each close a hole that today fails silently — a compacted session forgets the claim it holds; a worktree Claude Code made itself is invisible to the ledger — and both are small. M13.13 finishes what M13.12 started: hosting a team from the app still needs a clone until the package, the image and the binary exist. M12.1 was promised for 0.15 in the last cut and stands alone. Order: M13.9 → M13.7 (one re-verification of the hook reference serves both, and they install the new events the rest of the release can lean on), then M12.5 → M12.4 (the new rule families land in core first so the other agents' shims carry the whole set, not the old one), then M12.1, then M13.13 last for the same reason M13.12 was — it is the task with publishing steps outside the repo (a second npm trusted-publisher rule, the ghcr image). M13.8, M13.10 and M13.11 move to 0.16 with M12.6 and M12.7.

v0.16.0 cut (decided 2026-10-01): M12.13 → M13.8 → M12.11 → M12.12 → M12.7 → M12.6 → M13.11 → M13.10 — the guards go live, and runs can be fenced and reviewed. The 0.15 guards were observed for a release and flip to ask first, so every task after them runs against the defaults users will get. M13.8 sits next to them because it is the same family (config_tamper) caught at the event, not by doctor. M12.11 and M12.12 are small, both build on the prompt origin field, and fix things the Fleet already shows wrong. The reviewer (OQ-22) comes before the sandbox (OQ-21) because it needs no external runtime. M13.11 then M13.10 close the release: issues from findings first, because a scheduled review-prs run is the first thing that would want to file one.

ID Task Depends Status
M13.1 Repair loop: [gates] on_stop = "record" | "block" (default record, today's behaviour). With block, the Stop hook in a held worktree runs the task's required gates (M7.4 already does) and, if one fails, answers decision: "block" with the gate name and the tail of its output as reason, so the agent cannot declare done until the gates pass. Bounded: max_blocks (default 3) per session, honours stop_hook_active, never on SubagentStop, never without a held claim; each refusal is a gate.blocked event, shown on the Board row and on the session page; the third refusal opens a gate_failed incident instead of a fourth block. The "plan-gate-check as a rule" line from Later, made real — see OQ-24 M7.4, M2.2 ✅ 2026-09-12 core/src/repair.ts (repairDecision: allow / block / exhausted, the reason with the gate's output tail; 4 tests); store.stopDecision runs the executable required gates inside the Stop hook (awaited, capped by stop_timeout), writes the auto-handoff verify line, appends gate.blocked per refusal (the count survives restarts because it is the events), opens one gate_failed incident per session at max_blocks; the hook shim gives Stop a 330 s budget and the installer a 330 s timeout, every other event stays at 400 ms / 5 s; autoGate steps aside on Stop when the loop is on. Session log shows refused. OQ-24 decision recorded
M13.2 Permission card for interactive sessions: swarm install registers PermissionRequest; the shim parks the request as the same card spawned runs get (M3.2) with a desktop notification (M4.7) and long-polls the daemon up to [broker] interactive_wait seconds (default 30) — an answer from the card returns decision, a timeout returns {} and the terminal prompt proceeds untouched. Fails open, never starts the daemon; the request carries the rule that fired (if any) so the card shows why. Verify whether the terminal prompt is drawn while the hook runs — it decides the default wait (OQ-25) M3.2, M4.7 ✅ 2026-09-12 PermissionRequest joins HOOK_EVENTS (→ permission.requested, with display / reason / requestId so the M4.7 notification path serves both kinds); core/src/permissions.ts (InteractivePermission, permissionHookOutput); store.askInteractive parks the prompt with the rule that flagged it (re-evaluated without recording) and resolves on POST /v1/permissions/:id (allow / deny / terminal) or the wait, always appending permission.resolved so the M9.4 pairs close; the hook route waits [broker] interactive_wait (30 s, ≤ 120, 0 = off) only while a dashboard polled or streamed in the last 45 s, else hands the terminal the prompt at once; snapshot.permissions, InteractivePermissionCard on the session page with a countdown and Answer in terminal, Fleet marks the session asking; shim budget 130 s / installer timeout 150 s for this event alone. Verified 2026-09-12: the dialog is not drawn while the hook runs and a timeout falls through to it — OQ-25 decided
M13.3 Live collision context: on PostToolUse for Edit / Write / MultiEdit, if another live session edited the same path within [rules] collision_window minutes (default 15), answer additionalContext naming that session, its task, branch and how long ago — the collision graph (M9.12) already knows, nobody tells the agent. Also on PreToolUse for the same tools when the path is inside a worktree another session holds (today no_foreign_worktree asks; the context says who). Off with collision_context = false; one collision.warned event per pair per window M9.12, M2.1 ✅ 2026-09-12 core/src/collision-context.ts (collisionWarning: other live editors of the path within the window, newest first, with task / branch / title; 2 tests); the daemon keeps an in-memory recent-edit ledger from every write-tool PreToolUse (noteEdit, pruned at 4 h), answers PostToolUse with the heads-up as additionalContext (combined with pending answers), once per pair per window, and appends collision.warned (session log: collide); [rules] collision_context (true) / collision_window (15 min). The PreToolUse half was already there: no_foreign_worktree names the task and holder in its reason
M13.4 Real delivery: messages (M7.6) and answers (M7.7) wake the session instead of waiting for its next tool call. swarm-hook wait is registered as an asyncRewake command hook armed at SessionStart and re-armed at Stop: it long-polls the session's inbox and exits 2 with the text when something arrives, exits 0 when the session ends. [messages] wake = true (default on once verified). Verify re-arm semantics and that the reminder reaches an idle session; fall back to today's next-hook delivery when it does not (OQ-26). Fleet shows delivered vs queued M7.6, M7.7 ✅ 2026-09-12 swarm-hook wait (hook/src/wait.ts: long-polls POST /v1/wake, exits 2 with the text on delivery, 0 on replaced / active / ended / off, gives up at 55 min under the 3600 s hook timeout, fails open); installer arms it as an asyncRewake command hook on SessionStart and Stop (one waiter per event, removed with the rest); store.waitForWake keeps one waiter per session, delivers through the same answerContext the next-hook path uses (rows marked delivered), send / answer release waiters (message.delivered, session log woke), UserPromptSubmit / PreToolUse / SessionEnd cancel them; [messages] wake (true). Verified against the reference (asyncRewake wakes from idle on exit 2, timeout enforced, nothing persists across turns) and end to end at the shim level; the in-terminal wake itself is Claude Code's contract — OQ-26
M13.5 Rewrite rules: a rule may return rewrite with an updatedInput instead of ask / deny. Built-ins, all off this release and observed on Security (the M9.9 order): no_verify (drops --no-verify / --no-gpg-sign from git commit / git push), dry_run_first (the first terraform apply / kubectl delete in a session runs with its dry-run flag; the second is allowed). The [[rules.custom]] DSL (match, action = ask | deny | rewrite | off, replace) lands here rather than in M12.5, so Codify (M13.6) writes a format the daemon reads. Every rewrite is a tool.rewritten event with before / after on Incidents; the agent sees the rewrite as additionalContext M2.1, M4.3 ✅ 2026-09-12 core/src/rules.ts: GuardDecision gains rewrite (+ command, key), RewriteMode, CustomRule, rewriteNoVerify / rewriteDryRun / applyCustomRule, evaluated after the coordination rules so a deny is never softened; parseCustomRules in config (bad regex drops the rule, not the daemon); the daemon records a rewrite as an incident.opened with action: "rewrite" + rewritten (one feed, rule effectiveness unchanged) and remembers dry_run_first families per session; PreToolUse answers permissionDecision: allow + updatedInput + additionalContext (verified 2026-09-12), the runner passes updatedInput in its control_response; Incidents shows Rewritten; dry-run counts rewrites; Codify emits [[rules.custom]] and the two rule modes. Both built-ins ship off
M13.6 Codify that writes: Apply beside Copy on the Codify action (M4.3). Creates a task-less worktree (M7.2) on swarm/codify-<n>, writes the lesson to CLAUDE.md and / or the rule to .swarm.toml, commits, pushes, opens the PR through the forge (M7.3) prefilled with the incident, and removes the worktree. Never touches the main checkout; per-click consent keeps repo-agnostic intact; [codify] target = "claude-md" | "swarm-toml" | "both"; repos without a forge remote get the branch and a copyable git line M4.3, M7.2, M7.3 ✅ 2026-09-12 core/src/lessons-apply.ts (mergeLesson: one Lessons from Swarm section, a line once; mergeToml: key set in its section, missing section added, [rules.protected] ports unioned, [[rules.custom]] appended, everything else byte for byte; 3 tests); daemon/src/codify.ts applyCodify: task-less worktree on swarm/codify-<seq> (M7.2), merge, one commit (git.ts commitPaths), forge.openPR (M7.3) + pr.opened, worktree removed once upstream, else the branch + worktree stay with the git push line; codify.applied event; POST /v1/incidents/:seq/apply; [codify] target (both); the Codify card itself was missing from the React port and is restored (views/incidents/CodifyModal.tsx) with Copy and Apply → PR; daemon test on a temp repo without a remote (main untouched, branch + commit present, second apply refused)
M13.7 Claim what Claude Code creates: WorktreeCreate / WorktreeRemove hooks register Claude Code's own worktrees as claims (owner = the session) and refuse removal of a dirty or unpushed tree M1.1, M7.2 ✅ 2026-09-18 by observation, not hooks (OQ-28: a WorktreeCreate hook replaces Claude Code's creation). The first hook from inside <repo>/.claude/worktrees/<name> claims cc/<name> for session:<id> with claims.origin = "claude-code" (core/worktree.ts claudeCodeWorktree, store adoptClaudeWorktree); auto-renew keeps it, SessionEnd / a lapsed lease closes it, the next session takes over a lapsed one; release / reap / gc never remove the directory. Other sessions writing into it now hit no_foreign_worktree
M13.8 Guards at the moment: PreModelSwitch blocks a switch outside [models] allow (the M8 follow-up) with the reason; ConfigChange opens the config_tamper incident immediately and tells the agent, instead of doctor finding it later; both events are ingested for the Timeline M8.3, M9.9 ✅ 2026-10-01 Hook reference re-verified that day (33 events; PreModelSwitch reads permissionDecision + reason with from_model / to_model, ConfigChange reads top-level decision: "block" with config_source, and cannot block policy_settings). store.modelSwitch denies with modelSwitchRefusal (core/src/team.ts) and records a model_allowlist deny. store.configChange blames the agent only when a Bash / write call happened in the last 20 s with no permission dialog in that window (Claude Code writes settings.local.json itself on "don't ask again") and no config_tamper hit already on that call; deny blocks, ask tells (systemMessage + additionalContext), skills / policy_settings are only recorded. Swarm now installs 14 hooks
M13.9 Memory across compaction + failure coaching: PostCompact SessionStart with source: "compact" re-injects sessionContext (held task, lease, latest handoff, open question, rule modes) so a compacted session keeps what it holds — PostCompact has no decision control and cannot add context (hooks reference, re-verified 2026-09-18), and Swarm's matcher-less SessionStart hook was already firing on compaction, so this pins it with a test and heads the context context was compacted; PostToolUseFailure on the third failure of the same command hands back the handoff's verify command or the lesson Codify derived from a matching incident M1.3, M4.3, M4.4 ✅
M13.10 Scheduled workflows: [[schedules]] cron, workflow, task? — the daemon dispatches a workflow on a cron; a built-in review-prs step runs the review gate (M7.9) over each open PR in the PRs view and posts findings as a forge review; a gates step runs the required gates on the main checkout's branch and records them like any gate run. The nightly janitor is a config example, not a feature M7.8, M7.9, M1.8 ✅
M13.11 Findings become issues: gate flakiness (M9.7) and repeated failed-tool patterns (M9.3) can open an issue through the task source (GitHub Issues / Linear, M4.8) from the report row, labelled swarm, deduped by fingerprint; the issue lands on the Board like any task M9.3, M9.7, M4.8 ✅
M13.12 Host or join a team from the app: today the team daemon runs only from a clone (bun packages/team/src/bin.ts, private: true, unpublished), is configured by environment variables, promotes admins and publishes policy by hand-written POSTs, and joining is two CLI commands with a secret typed by hand. Ship it in the interface: the local dashboard (browser and desktop) gains a Team panel with two buttons. Host a team fetches the swarm-teamd binary for this platform from the release (into ~/.swarm/bin, the way Linux unpacks the daemon — the free bundle stays Apache-only, the FSL binary arrives on demand with its license shown), asks for a team name and a mode (shared secret, or OIDC issuer + client id), starts it as a registered process (M1.4, restarted with the daemon and on login), makes the host the admin, and shows the invite link plus a QR code; a laptop that sleeps is fine — teammates queue and drain (M8.3d). Join a team lists teams announced on the LAN over mDNS (_swarm-team._tcp) and accepts an invite link; the sign-in (secret or device code with the code shown) runs in the panel, never in a terminal. The panel then shows status: lag, what is forwarded, budget standing, members and machines, and for the admin: roles (promote / demote / remove), machine revoke, and an org-policy editor that signs and publishes the TOML. Move to a server is swarm backup → restore on the server and a new invite link; swarm-teamd setup and the published package + image (OQ-27) remain for teams that start on a server. doctor checks the whole path M8.3, M8.4, M1.4 ✅ 2026-09-12 core/src/teamsetup.ts (~/.swarm/team.toml render/parse, teamSetupEnv, inviteLink / parseInvite — an invite link, a URL, url#secret or a bare host all parse —, withTeamUrl; 6 tests); swarm-teamd setup (interactive or flags) and the daemon reading that file with the environment on top, so existing deployments are untouched; daemon/src/teamctl.ts hostTeam (write settings → spawn swarm-teamd via resolveBin, so no FSL code enters the free bundle → wait for /t1/health → point [team] url at loopback → register → invite link from the LAN address), joinTeam, leaveTeam (by pid, never by pattern), hostingStatus; GET /v1/team extended, `POST /v1/team/host
M13.13 Publish the team daemon, and finish its own dashboard: @ra3orblade/swarm-team on npm (needs an npm trusted-publisher rule for a second package) + ghcr.io/ra3orblade/swarm-teamd built by release.yml on the same v* tag + a per-platform binary on the release the app can fetch into ~/.swarm/bin (OQ-27); the team dashboard gains Settings — members and roles (promote / demote / remove), machines (last seen, revoke), an org-policy editor that signs and publishes; Join lists teams announced on the LAN over mDNS (_swarm-team._tcp) beside the pasted link M13.12 ✅ 2026-09-18 distribution (PR #181) (OQ-27 status): tools/build-team.ts → @ra3orblade/swarm-team + five compiled binaries + checksums, packages/team/Dockerfile → ghcr image, release.yml jobs team + team-release-assets, and the app's license-gated, checksum-verified download into ~/.swarm/bin; the team DB now follows SWARM_HOME like team.toml did (the image would otherwise have written it outside its volume). Bundle, compiled binary and image were each run and answered health; the Team panel's missing-binary state was checked from the npm bundle. Two steps remain for the user: the first manual npm publish + trusted-publisher rule, and making the ghcr package public. Settings + discovery (stacked PR): admin API (POST /t1/users/:subject/role, DELETE /t1/users/:subject, DELETE /t1/machines/:id; never the last admin), a Settings section on the team page with the signed-policy editor, the shared secret as the admin credential in token mode (docs/14 decision); _swarm-team._tcp announced by teamd and discovered by the local daemon (core/src/mdns.ts), verified against macOS mDNSResponder both ways; Join lists what it finds

Later (not scheduled)

Hosted (SaaS) team daemon wrapping the M8.3 binary · adapters for other agent CLIs. (plan-gate-check — ✅ unreachable without a passing gate — is now M13.1.)

(Linear/GitHub task sources shipped in M4.8 and are verified end to end — this line listed them as unscheduled long after they landed.)

Source: docs/06-roadmap.md · v0.16.0.