@martintrojer/mu 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,481 @@
1
+ # Architecture
2
+
3
+ mu is layered: callers on top, a shared TypeScript core in the middle,
4
+ SQLite + tmux + VCS substrates at the base. The CLI and the pi
5
+ extension are thin facades over the same core modules.
6
+
7
+ - For canonical terms (*workstream*, *agent*, *task DAG*, *track*,
8
+ *claim*, *free*, *workspace*, *substrate*, ...) see
9
+ [VOCABULARY.md](VOCABULARY.md). It is the source of truth.
10
+ - For design rationale, rejected alternatives, and what's on the
11
+ roadmap, see [ROADMAP.md](ROADMAP.md).
12
+ - For principles, see [VISION.md](VISION.md).
13
+
14
+ ```
15
+ ┌────────────────────────────────────────────────────────────────┐
16
+ │ Callers │
17
+ │ ┌────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
18
+ │ │ Pi │ │ Bash + │ │ Pi sub- │ │ mu log │ │
19
+ │ │ shell │ │ jq │ │ agent │ │ --tail subs │ │
20
+ │ └───┬────┘ └────┬─────┘ └──────┬───────┘ └──────┬───────┘ │
21
+ │ │ │ │ │ │
22
+ └──────┼────────────┼───────────────┼──────────────────┼─────────┘
23
+ │ in-proc │ subprocess │ subprocess │ in-proc
24
+ ▼ ▼ ▼ ▼
25
+ ┌────────────────────────────────────────────────────────────────┐
26
+ │ mu core (shared TS modules) │
27
+ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
28
+ │ │ agents/ │ │ tasks/ │ │ vcs/ │ │ registry/ │ │
29
+ │ │ tmux │ │ schema │ │ jj │ │ snapshot │ │
30
+ │ │ detect │ │ queries │ │ sapling │ │ logs │ │
31
+ │ │ state │ │ tracks │ │ git │ │ doctor │ │
32
+ │ │ │ │ claim │ │ none │ │ │ │
33
+ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
34
+ └───────┼─────────────┼─────────────┼───────────────┼───────────┘
35
+ ▼ ▼ ▼ ▼
36
+ ┌────────────────────────────────────────────────────────────────┐
37
+ │ Substrates │
38
+ │ SQLite (~/.local/state/mu/mu.db) · tmux panes · jj/sl/git workspaces │
39
+ └────────────────────────────────────────────────────────────────┘
40
+ ```
41
+
42
+ ## The task DAG
43
+
44
+ mu's coordination model is built around a **directed acyclic graph of
45
+ tasks** (cloned from a prior internal task-graph crate). This is not
46
+ a sidecar feature — it's the central organizing primitive that makes
47
+ deterministic multi-agent orchestration possible. Without it, mu is
48
+ just a fancier agent runner.
49
+
50
+ ### Model
51
+
52
+ - **Tasks** are nodes with mandatory `impact (1-100)` and `effort_days`.
53
+ `ROI = impact / effort` drives prioritization.
54
+ - **One edge type**: `blocks`. `A → B` means A must close before B can
55
+ start. Multiple edge types create ambiguity that defeats the purpose.
56
+ - **Status lifecycle**: `OPEN → IN_PROGRESS → CLOSED/RESOLVED`.
57
+ - **Notes** are append-only per task; survive across LLM sessions and
58
+ agent restarts. The fix for context loss at the *task* level rather
59
+ than the agent level.
60
+
61
+ ### Built-in queries (SQL views)
62
+
63
+ | View | Returns |
64
+ | --------- | ---------------------------------------------------------------------- |
65
+ | `ready` | OPEN tasks with no unresolved blockers — work that can start *now* |
66
+ | `blocked` | OPEN tasks waiting on something |
67
+ | `goals` | Tasks with no dependents — graph endpoints |
68
+
69
+ Agents and humans both query these views directly via `mu sql`. No
70
+ separate query layer.
71
+
72
+ ### Parallel-track detection (the killer feature)
73
+
74
+ `mu task tracks` runs union-find on the graph to identify independent
75
+ subtrees that can be assigned to different agents in parallel.
76
+
77
+ **Diamond patterns get merged automatically.** If two roots share a
78
+ prerequisite, they collapse into one track — preventing two agents
79
+ from colliding on the shared dependency:
80
+
81
+ ```
82
+ Independent (2 tracks): Diamond (1 merged track):
83
+
84
+ goal_a goal_b goal_a goal_b ← Spawn 2 agents
85
+ | | \ /
86
+ task_a task_b shared ← Spawn 1 (would
87
+ | | | collide otherwise)
88
+ leaf_a leaf_b leaf
89
+ ```
90
+
91
+ This is **deterministic** — not "the LLM decides whether to
92
+ parallelize." The graph algorithm gives the right answer; the LLM
93
+ follows it.
94
+
95
+ ### Claim protocol via tmux pane title
96
+
97
+ `mu task claim <task>` reads the current pane's **pane title** (set on
98
+ spawn via `select-pane -T <agent-name>`) and atomically:
99
+
100
+ 1. Sets `tasks.owner = <agent_name>`
101
+ 2. Flips `tasks.status = IN_PROGRESS`
102
+ 3. Records an `agent_logs` row of kind `claim`
103
+
104
+ Reads via `tmux display-message -p '#{pane_title}'`, **not** `#W`
105
+ (window name). Window names come from the `tab:` frontmatter and may
106
+ group multiple agents in one window.
107
+
108
+ Two agents can't claim the same task — atomic CAS in SQLite. Zero-
109
+ config identity: the agent doesn't have to know its own name.
110
+
111
+ ### Scoped subtree views
112
+
113
+ `mu task <id>` shows mission-control output filtered to that task's
114
+ subtree. Enables recursive delegation: a sub-orchestrator agent runs
115
+ `mu --scope feature_a` and sees only its slice of the graph.
116
+
117
+ ### Why this is in the core
118
+
119
+ - "What should this agent do next?" becomes a SQL query, not an LLM call
120
+ - Parallelization correctness is structural (union-find + diamond-merge),
121
+ not a prompt
122
+ - Notes give every task a durable knowledge container that outlives any
123
+ LLM session
124
+ - Recursion works because subtree-scoping is just a `WHERE` clause
125
+
126
+ ---
127
+
128
+ ## Tmux session topology
129
+
130
+ mu organizes agents into **one tmux session per workstream**. One mu
131
+ workstream = one tmux session = one `session_id` partition in
132
+ `~/.local/state/mu/mu.db`. Multiple workstreams on one machine coexist as
133
+ independent tmux sessions, fully isolated.
134
+
135
+ ```
136
+ tmux session: mu-auth-refactor (one mu workstream)
137
+ ┌────────────────────────────────────────────────┐
138
+ │ Window: Backend Window: Review │
139
+ │ ┌──────────┐ ┌────────┐ ┌─────────────────────┐ │
140
+ │ │ worker-1 │ │ worker-2 │ │ reviewer-1 │ │
141
+ │ │ (pi) │ │ (pi) │ │ (pi, role=read-only) │ │
142
+ │ └──────────┘ └────────┘ └─────────────────────┘ │
143
+ │ │
144
+ │ Window: mu-orchestrator │
145
+ │ ┌────────────────────────────────────────────┐ │
146
+ │ │ pi (you, with mu extension loaded) │ │
147
+ │ └────────────────────────────────────────────┘ │
148
+ └────────────────────────────────────────────────┘
149
+
150
+ tmux session: mu-migration-2024q4 (different workstream)
151
+ ┌───────────────────────────────────────────────┐
152
+ │ ...different agents, different graph, no overlap │
153
+ └───────────────────────────────────────────────┘
154
+ ```
155
+
156
+ ### Concretely
157
+
158
+ - **First `mu agent spawn` creates the tmux session** if you're not already
159
+ in one. Default name `mu-<auto>`. Override with `mu workstream init <name>` or
160
+ `MU_SESSION=<name>`.
161
+ - **Subsequent operations** in the same shell (or any child shell with
162
+ `MU_SESSION_ID` set) target the same session.
163
+ - **`mu agent attach`** → attach to the whole workstream's tmux session
164
+ - **`mu agent attach <agent>`** → attach and focus that agent's window/pane
165
+ - **`mu agent list`** shows only the current workstream's agents by default
166
+ - **`mu agent list --all`** shows agents across all workstreams on the box
167
+ - **`session_id`** is the partition key on the `agents` table; queries
168
+ filter to the active session unless `--all` is set
169
+ - **`mu doctor`** warns about cross-session pollution (orphan panes,
170
+ ghost rows, agents whose tmux session no longer exists)
171
+
172
+ ### Window vs pane
173
+
174
+ By default each agent gets its own **tmux window** (tmux's term for
175
+ what most terminals call a "tab"), with the window name set to the
176
+ agent's `tab:` value (default: the agent name itself, so a single
177
+ agent's window is named after them). Agents that share a `tab:` value
178
+ share a window with multiple panes inside it.
179
+
180
+ The claim/identity logic depends on the **pane title**, not the
181
+ window name — every agent pane has its title set to the agent's name
182
+ via `select-pane -T <name>` on spawn, regardless of how panes are
183
+ grouped into windows. (See [VOCABULARY.md](VOCABULARY.md) and
184
+ the comment block at the top of `src/tmux.ts` for the canonical
185
+ tmux protocol.)
186
+
187
+ ### Why one session per workstream
188
+
189
+ - **Visual co-location.** `tmux a -t mu-auth-refactor` shows the whole
190
+ crew at once. No session-switching.
191
+ - **Trivial isolation.** Kill the tmux session = kill the workstream.
192
+ No leaked panes.
193
+ - **Detach and reattach freely.** Close your laptop, open it later,
194
+ `tmux a -t mu-auth-refactor`, the crew is still there.
195
+ - **The claim protocol falls out naturally.** Pane title = agent name
196
+ = ownership identity. Zero-config.
197
+ - **Multiple workstreams coexist.** session_id partitioning (a
198
+ pattern borrowed from a prior internal multi-agent runtime)
199
+ prevents the auth-refactor crew from polluting the migration crew.
200
+
201
+ ---
202
+
203
+ ## Operations registry
204
+
205
+ Every mu action is defined exactly once via `defineOperation(...)`.
206
+ The registry is collected at module import time (no codegen step) and
207
+ from one source produces six surfaces:
208
+
209
+ ```
210
+ ┌─────────────────────────────┐
211
+ │ defineOperation(...) │
212
+ │ name, category, │
213
+ │ caps[], params, │
214
+ │ handler │
215
+ └─────────────┬──────────────┘
216
+
217
+ ┌────────────┬──────┼──────┬───────────┐
218
+ ▼ ▼ ▼ ▼ ▼
219
+ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────┐ ┌────────┐
220
+ │ CLI verb│ │ Pi tool │ │ mu.d.ts │ │ skill │ │ doctor │
221
+ └─────────┘ └─────────┘ └─────────┘ └───────┘ └────────┘
222
+ ```
223
+
224
+ No operation may exist outside the registry. CLI verbs that are not
225
+ operations (e.g., `mu workstream init`, `mu agent attach`, `mu doctor`) are exceptions
226
+ listed explicitly in the CLI module and motivated.
227
+
228
+ (A capability-tag system on operations was considered and dropped
229
+ as an abstraction with no current consumer; see
230
+ [ROADMAP.md § Open questions](ROADMAP.md#open-questions).)
231
+
232
+ ---
233
+
234
+ ## Reconciliation
235
+
236
+ `mu agent list` always reconciles the registry against tmux reality before
237
+ returning. Three steps, in order:
238
+
239
+ 1. **Prune ghosts.** For each `agents` row, if its `pane_id` no longer
240
+ exists in tmux, delete the row.
241
+ 2. **Detect status from scrollback.** For each surviving agent, capture
242
+ the pane and run the per-CLI detector. Update `agents.status` if
243
+ the detected value differs from the stored one.
244
+ 3. **Surface orphans.** For each tmux pane in the workstream's session
245
+ that has no matching `agents` row but whose pane title looks like
246
+ an agent name, add it to the orphans list. **Do not auto-adopt** —
247
+ `mu agent list` shows orphans under a separate "(orphans)" section and
248
+ the user runs `mu adopt %15 [--name X]` to formally claim them.
249
+
250
+ Full algorithm lives in `src/reconcile.ts` (the canonical
251
+ implementation).
252
+
253
+ Key properties:
254
+
255
+ - **Reality wins**: tmux is the source of truth for what panes exist.
256
+ The DB records what we last *observed*. Reconciliation closes the
257
+ gap on every `mu agent list`.
258
+ - **Pi-only status detection** (`src/detect.ts`): the `busy` /
259
+ `needs_input` / `idle` / `done` classification works for pi via
260
+ a known marker. Other CLIs would need their own detectors; none
261
+ are built today and none are currently planned.
262
+ - **No silent adoption**: orphans are reported, never claimed without
263
+ user consent. Avoids surprising the user with random panes.
264
+ - **`mu doctor` calls the same routine** and reports counts. The
265
+ algorithm has no other implementation.
266
+
267
+ ---
268
+
269
+ ## Modules (actual src/ layout)
270
+
271
+ Mostly-flat `src/`: 18 root `.ts` files plus two cohesive
272
+ subclusters (`src/agents/`, `src/tasks/`) and the `src/cli/` verb
273
+ wrappers (with their own `src/cli/tasks/` sub-cluster). No
274
+ `core/` subdirectory; no anticipatory layering. Subclusters obey
275
+ the AGENTS.md rule: imports flow cluster → root, never upward.
276
+ Each module is concrete and consumed today.
277
+
278
+ | Module | Responsibility |
279
+ | --------------------- | ----------------------------------------------------------------------------------------- |
280
+ | `src/db.ts` | SQLite (better-sqlite3) connection, WAL mode, schema (14 tables + 3 views, **schema v7** — v5 surrogate-INTEGER-PK substrate, plus v6's 5 additive `archive_*` tables, minus v7's drop of `approvals`), default paths, `resolveWorkstreamId` (the SDK boundary's first leg). Pre-current DBs are upgraded in place on `openDb`: v5 → v6 was additive (CREATE-TABLE-IF-NOT-EXISTS), v6 → v7 is destructive-but-idempotent (`DROP TABLE IF EXISTS approvals` runs before `applySchema`); both happen with no migration script. |
281
+ | `src/tmux.ts` | Single tmux executor wrapper, send protocol (bracketed-paste), pane validation |
282
+ | `src/detect.ts` | Pi-only status detector (`busy` / `needs_input` / `idle` / `done`) |
283
+ | `src/reconcile.ts` | Ghost prune + status detect + orphan surface; "reality wins" |
284
+ | `src/agents.ts` | Hub: CRUD + send / read / list / close / free + liveness + reaper. Re-exports `src/agents/*` (spawn, adopt, errors); pane-title composition (`composeAgentTitle`) lives here. |
285
+ | `src/agents/*.ts` | Cohesive cluster of agent-lifecycle internals: `spawn.ts` (spawnAgent + resolveCliCommand / awaitSpawnLiveness / pane create-or-reuse / prestage / rollback), `adopt.ts` (register an existing tmux pane as a managed agent), `errors.ts` (typed agent error classes — `AgentNotFoundError`, `AgentDiedOnSpawnError`, …). |
286
+ | `src/tasks.ts` | Hub: every read/write verb on the DAG (edit / edges / queries) + cycle check + auto-event emission. Re-exports `src/tasks/*` (status, claim, lifecycle, wait, errors). |
287
+ | `src/tasks/*.ts` | Cohesive cluster of task-graph internals: `status.ts` (TaskStatus enum + helpers — single source of truth), `claim.ts` (claim/release + `resolveActorIdentity`, atomic CAS), `lifecycle.ts` (setTaskStatus / closeTask / openTask / rejectTask / deferTask + cascade), `wait.ts` (waitForTasks: block until tasks reach a target status), `errors.ts` (typed task error classes — `TaskAlreadyOwnedError`, `CycleError`, …). |
288
+ | `src/tracks.ts` | Parallel-tracks union-find with diamond merge |
289
+ | `src/workstream.ts` | ensureWorkstream / list / summarize / destroy / export (thin wrapper around the bucket renderer) |
290
+ | `src/exporting.ts` | Unified bucket renderer for `mu workstream export` and `mu archive export`: per-task markdown + manifest.json (`bucketVersion: 2`); idempotent via per-file sha256; deleted-task preservation banner; refuses pre-0.3 single-source layouts |
291
+ | `src/importing.ts` | Inverse of `src/exporting.ts`: parses a v0.3 bucket directory and rebuilds every source-ws as live tasks + edges + notes. Markdown-only (never reads .db); per-source-ws transactional; refuses silent merges into existing workstreams |
292
+ | `src/archives.ts` | Cross-workstream **archives** — feature complete (SDK + 6 CLI verbs: `mu archive create / list / show / add / remove / delete`, plus `search` and `export` via the unified bucket renderer): `createArchive` / `listArchives` / `getArchive` / `deleteArchive` / `addToArchive` (idempotent at `(archive, source_workstream)`) / `removeFromArchive` / `listArchivedTasks`. Backed by the v6 `archives` + `archived_tasks` + `archived_edges` + `archived_notes` + `archived_events` tables; archives outlive workstreams (TEXT `source_workstream` columns, no FK). |
293
+ | `src/logs.ts` | `agent_logs` SDK: appendLog / listLogs / latestSeq / emitEvent |
294
+ | `src/vcs.ts` | `VcsBackend` interface + jj / sl / git / none impls; detection precedence; `commitsBehind(workspacePath, ref)` for staleness signal (no auto-fetch; pure observation) |
295
+ | `src/workspace.ts` | Per-agent VCS workspaces (registry layer on top of vcs.ts); CRUD + cascade; orphan-dir detection (`listWorkspaceOrphans`); staleness decoration (`decorateWithStaleness` populates `commitsBehindMain` per row) |
296
+ | `src/snapshots.ts` | Whole-DB snapshots (`VACUUM INTO`); auto-captured before destructive verbs; SDK for `mu undo`. The `snapshots` table is schema v4 (carried forward unchanged through v5/v6/v7). |
297
+ | `src/output.ts` | NextStep type + `printNextSteps` + `errorNextSteps` plumbing for self-documenting output |
298
+ | `src/cli.ts` | commander entry; `buildProgram()` (re-exports `format`/`handle` symbols for back-compat with existing import sites). |
299
+ | `src/cli/*.ts` | one file per verb-namespace; thin wrappers over the SDK; `--json` rendering for every read verb. Currently: `workstream.ts`, `agents.ts`, `tasks.ts`, `workspace.ts`, `log.ts`, `archive.ts`, `state.ts` (canonical state card + bare `mu` mission-control / hud render mode), `snapshot.ts`, `sql.ts`, `doctor.ts`. Two non-verb cluster-mates carry the rendering + error-handling primitives that every verb wrapper imports: `format.ts` (table renderers, status colourers, `truncate`/`relTime`) and `handle.ts` (typed-error → exit-code map + the `handle()` wrapper). Imports flow cluster → root (never the other way). |
300
+ | `src/cli/tasks/*.ts` | sub-cluster of the `mu task` namespace; `tasks.ts` at the root re-exports only what callers outside the cluster import (`wireTaskCommands`, `cmdMyNext`/`cmdMyTasks`, `unescapeNoteText`). One file per concern: `queries.ts` (list/next/owned-by + the `cmdMyTasks` / `cmdMyNext` helpers that back `mu me tasks` / `mu me next`), `lifecycle.ts` (close/open/reject/defer + cascade preview), `edit.ts` (add/show/notes/note/update + helpers), `edges.ts` (block/unblock/reparent/delete), `claim.ts` (claim/release/wait), `tree.ts` (tree rendering), `wire.ts` (Commander glue). Each file < 600 LOC; the hub is < 35. |
301
+ | `src/index.ts` | SDK entrypoint (re-exports) |
302
+ | `skills/mu/SKILL.md` | Bundled skill teaching the LLM the model + verb list + jq pipelines |
303
+
304
+ ## Data flow
305
+
306
+ 1. **A caller invokes a verb** — the CLI subprocess, or in-proc SDK
307
+ use.
308
+ 2. **CLI handler dispatches to an SDK function** in `src/agents.ts`
309
+ / `src/tasks.ts` / etc.
310
+ 3. **For multi-statement writes, opens a transaction** via
311
+ better-sqlite3's `db.transaction(fn)()` wrapper.
312
+ 4. **Executes the operation** — agent ops shell out to tmux (and to
313
+ jj/sl/git for workspaces); task ops are pure SQL.
314
+ 5. **Reconciles with reality** — for read-paths that need accuracy
315
+ (`mu agent list`, mission control), queries tmux for live pane
316
+ state and updates the DB (ghost prune + status detect).
317
+ 6. **Auto-emits a `kind='event'` row** to `agent_logs` for any
318
+ state-changing verb, conditional on actual change. `mu log
319
+ --tail` subscribers see it on the next 1-second poll.
320
+ 7. **Commits or rolls back** — exception propagates after rollback
321
+ so the caller sees the real error and the typed error class
322
+ maps to a specific exit code in `handle()`.
323
+
324
+ ## Key seams
325
+
326
+ These are the abstraction points designed for extension. New impls of
327
+ each are deliberately small.
328
+
329
+ | Seam | Add a new impl by... |
330
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
331
+ | `VcsBackend` | Implementing `detect / createWorkspace / freeWorkspace / commitsBehind` (~80–150 LOC; jj/sl/git/none are working examples) |
332
+ | Per-CLI `Detector` | Adding patterns to `detectPiStatus` (vanilla pi `to interrupt)`; pi-meta + every TUI wrapper covered by Braille spinner glyph fallback `[\u2800-\u28FF]`) |
333
+ | New typed verb | Add an SDK function in the relevant `src/*.ts`; add a `cmd<Verb>` to the matching `src/cli/<namespace>.ts` (or create a new namespace if the verb doesn't fit existing ones); wire one commander block in `src/cli.ts`'s `buildProgram()` (use `handle()` for the exit-code map; route through `printNextSteps` for self-documenting output) |
334
+ | New schema migration| Bump `CURRENT_SCHEMA_VERSION` in `src/db.ts`; mirror the new shape in `CURRENT_SCHEMA`. Two of the three post-v5 bumps were script-free: v5 → v6 was purely additive (the existing CREATE-TABLE-IF-NOT-EXISTS pass picked up the new `archive_*` tables), and v6 → v7 was a destructive-but-idempotent in-place migration (a `DROP TABLE IF EXISTS approvals` block in `applySchema`). Reach for a one-shot migration script only when the change can't be expressed that way (the v4 → v5 surrogate-PK substrate switch was the canonical example; restore from git history if you need to see the shape). The loud-fail hook in `openDb` rejects pre-current DBs with `SchemaTooOldError` (exit code 4) and a migration instruction. |
335
+ | Snapshot hook | Add `await captureSnapshot(db, 'verb-name', workstream)` at the top of any new destructive verb (one-liner; GC + restore behaviour automatic) |
336
+
337
+ ## Surrogate-PK + SDK-boundary discipline (load-bearing)
338
+
339
+ This is the load-bearing pattern v5 turned into a substrate-wide
340
+ invariant; every entity table follows it.
341
+
342
+ **Schema shape — every entity table:**
343
+
344
+ ```
345
+ (
346
+ id INTEGER PRIMARY KEY AUTOINCREMENT, -- surrogate; internal
347
+ <scope_id> INTEGER NOT NULL REFERENCES <parent>(id) ON DELETE CASCADE,
348
+ <name> TEXT NOT NULL, -- operator-facing; mutable
349
+ -- ... domain attributes
350
+ UNIQUE (<scope_id>, <name>) -- per-scope unique
351
+ )
352
+ ```
353
+
354
+ FKs reference `<child>.<parent>_id` (INTEGER), never the TEXT name.
355
+ The TEXT name is JUST an operator-facing attribute — searchable,
356
+ displayable, renamable cheaply. The surrogate id is the identity.
357
+
358
+ **TEXT-by-design exceptions** (each one a justified skip): the
359
+ workstream's own `name` (it IS a tmux session name; globally
360
+ unique), `task_notes.author` / `agent_logs.source` (free-text actor
361
+ labels — `"orchestrator"`, `"user"`, `"system"`), `agent_logs.kind`
362
+ (open enum — future kinds need no migration), `agents.cli`
363
+ (adding a new CLI must not require a schema change), and the
364
+ `snapshots.workstream` text column (intentionally NOT an FK so
365
+ the snapshot outlives its workstream).
366
+
367
+ **SDK boundary discipline** — same shape as REST: external API
368
+ uses business identifiers, internal layer uses primary keys.
369
+
370
+ > **Public SDK functions take operator-facing names.**
371
+ > **Internal helpers take surrogate ids.**
372
+ > **Resolution happens at the public-function entry, exactly once.**
373
+
374
+ ```ts
375
+ // PUBLIC: takes operator-facing names
376
+ export function claimTask(
377
+ db: Db,
378
+ workstream: string,
379
+ localId: string,
380
+ opts?: ClaimOptions,
381
+ ): ClaimResult {
382
+ const wsId = resolveWorkstreamId(db, workstream);
383
+ const taskId = resolveTaskId(db, wsId, localId);
384
+ const agentId = resolveCurrentAgentId(db, wsId);
385
+ return claimTaskById(db, taskId, agentId, opts);
386
+ }
387
+
388
+ // INTERNAL: takes surrogate ids; never re-resolves
389
+ function claimTaskById(db, taskId, agentId, opts): ClaimResult { ... }
390
+ ```
391
+
392
+ Why exactly once at the boundary: no double-resolution; no
393
+ mid-function ambiguity (once surrogate ids exist, internal helpers
394
+ don't need to thread workstream context — the FKs make scope
395
+ implicit); one place to do error mapping
396
+ (`WorkstreamNotFoundError` / `TaskNotFoundError` /
397
+ `AgentNotFoundError` all originate at resolve-time, with the
398
+ operator's input string in the error payload).
399
+
400
+ **`--json` output preserves operator-facing names.** Surrogate ids
401
+ stay strictly internal — they never leak into `--json`, error
402
+ payloads, log lines, or markdown exports. Promoting them to the
403
+ public shape would re-introduce a global namespace through the
404
+ back door (anti-feature pledge).
405
+
406
+ ## State of truth
407
+
408
+ - **`~/.local/state/mu/mu.db` is canonical.** Everything else is a
409
+ cache, including tmux pane titles (mu re-pushes them via
410
+ `composeAgentTitle` after every state change).
411
+ - **Reads are cheap** via SQLite views (`ready`, `blocked`, `goals`).
412
+ - **Writes go through the typed SDK functions** (`src/agents.ts`,
413
+ `src/tasks.ts`, etc.) which validate, transact, snapshot (for
414
+ destructive verbs), and reconcile.
415
+ - **Workstream scoping is mandatory at the CLI boundary.** Post-v5,
416
+ TEXT names (`tasks.local_id`, `agents.name`) are
417
+ per-workstream unique — the same name may legitimately exist in two
418
+ workstreams. Every public SDK function that takes such a name also
419
+ takes (or threads from a parent context) the workstream; internal
420
+ SQL filters by `(workstream_id, name)`. Test fixtures and `mu sql`
421
+ read paths can omit the workstream and fall back to the v4
422
+ first-match-by-name contract. The invariant is now structurally
423
+ enforced by the surrogate-id schema (per-workstream UNIQUE on
424
+ name + INTEGER FKs); the previous CI grep guard was retired.
425
+ - **Snapshots are insurance, not version history.** Captured only
426
+ before destructive verbs (workstream destroy, agent close, task
427
+ close/reject/defer/release/delete, workspace free). Status flips and additive ops do NOT snapshot.
428
+ - **In-memory state is short-lived** — the CLI's per-command
429
+ connection. Gone on process exit.
430
+ - **Cross-process coordination** is via SQLite WAL — multiple `mu`
431
+ processes share the file safely.
432
+
433
+ ## Errors
434
+
435
+ Curated error classes per layer; no try/catch swallowing. CLI exit
436
+ codes:
437
+
438
+ | Code | Meaning |
439
+ | ---- | -------------------------------------------------------- |
440
+ | 0 | success |
441
+ | 1 | generic error |
442
+ | 2 | usage error (commander's default) |
443
+ | 3 | not found (no such agent / task / workspace) |
444
+ | 4 | conflict (name collision, double-claim, dirty tree) |
445
+ | 5 | substrate unavailable (`tmux` not running, DB locked) |
446
+
447
+ Errors carry structured context (operation name, target, attempted
448
+ action) so `mu doctor` can surface them readably.
449
+
450
+ ## Testing layers
451
+
452
+ | Layer | Test approach |
453
+ | ---------------------------------- | ------------------------------------------------------------------------------ |
454
+ | `src/db.ts` | Real SQLite in temp dir; schema/table-count assertions |
455
+ | `src/tasks.ts` | Real SQLite in temp dir; pure functions over fixture data |
456
+ | `src/tracks.ts` | Pure functions; union-find + diamond-merge properties |
457
+ | `src/agents.ts` | Mocked tmux executor via `setTmuxExecutor()`; reaper integration tests |
458
+ | `src/logs.ts` | Real SQLite; cursor semantics, AUTOINCREMENT durability, FK CASCADE |
459
+ | `src/vcs.ts` + `src/workspace.ts` | Real git in `os.tmpdir()`; jj/sl tests feature-detect (skip if binary missing) |
460
+ | `src/cli.ts` / verb integration | `*.integration.test.ts` files; real tmux server, unique session per test |
461
+ | End-to-end | `test/acceptance.test.ts` — the canonical 10-task / 3-agent demo |
462
+
463
+ ## Distribution
464
+
465
+ Single npm package `mu` (see `package.json`):
466
+
467
+ - `dist/cli.js` — CLI entry, executable (`bin: { mu: ./dist/cli.js }`; shebang preserved by `tsup`)
468
+ - `dist/index.js` + `dist/index.d.ts` — programmatic API + types for SDK callers
469
+ - `skills/mu/SKILL.md` — bundled skill (the only non-`dist` asset shipped)
470
+
471
+ `tsup` bundles two entries (`index`, `cli`) from `src/`. No
472
+ runtime build step on the user's machine; `npm install` just
473
+ unpacks. There is no pi-extension entry today — pi is a peer dep,
474
+ and the anti-feature pledge in ROADMAP.md keeps it that way.
475
+ Likewise no bundled `agents/*.md` or `prompts/*.md` directory
476
+ exists; per-role agent guidance lives in the user's project repo,
477
+ not in the mu package.
478
+
479
+ The dependency list lives in `package.json`; the rule for adding
480
+ new ones is the anti-feature pledge in
481
+ [ROADMAP.md § Anti-feature pledges](ROADMAP.md#anti-feature-pledges-still-in-force-reinforced-by-an-internal-critique).