@mu-crew/mu 1.6.0

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,545 @@
1
+ # Architecture
2
+
3
+ mu is one SQLite file, a crew of agents in tmux panes, a task DAG, and one append-only op log everything else is derived from. It is layered: callers on top, a shared TypeScript core in the middle, SQLite + tmux + VCS substrates at the base. The CLI verbs and the programmatic SDK are thin facades over the same core modules.
4
+
5
+ - For canonical terms (_workstream_, _agent_, _task DAG_, _track_, _claim_, _free_, _workspace_, _substrate_, ...) see [VOCABULARY.md](VOCABULARY.md). It is the source of truth.
6
+ - For design rationale, rejected alternatives, and what's on the roadmap, see [ROADMAP.md](ROADMAP.md).
7
+ - For principles, see [VISION.md](VISION.md).
8
+
9
+ ```
10
+ ┌────────────────────────────────────────────────────────────────┐
11
+ │ Callers │
12
+ │ ┌────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐ │
13
+ │ │ Pi │ │ Bash + │ │ Pi sub- │ │ mu log │ │
14
+ │ │ shell │ │ jq │ │ agent │ │ --tail subs │ │
15
+ │ └───┬────┘ └────┬─────┘ └──────┬───────┘ └──────┬───────┘ │
16
+ │ │ │ │ │ │
17
+ └──────┼────────────┼───────────────┼──────────────────┼─────────┘
18
+ │ in-proc │ subprocess │ subprocess │ in-proc
19
+ ▼ ▼ ▼ ▼
20
+ ┌────────────────────────────────────────────────────────────────┐
21
+ │ mu core (shared TS modules) │
22
+ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
23
+ │ │ agents/ │ │ tasks/ │ │ vcs/ │ │ ops log/ │ │
24
+ │ │ tmux │ │ schema │ │ jj │ │ capture │ │
25
+ │ │ detect │ │ queries │ │ sapling │ │ apply/sync │ │
26
+ │ │ state │ │ tracks │ │ git │ │ doctor │ │
27
+ │ │ │ │ claim │ │ none │ │ │ │
28
+ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
29
+ └───────┼─────────────┼─────────────┼───────────────┼───────────┘
30
+ ▼ ▼ ▼ ▼
31
+ ┌────────────────────────────────────────────────────────────────┐
32
+ │ Substrates │
33
+ │ SQLite (~/.local/state/mu/mu.db) · tmux panes · jj/sl/git workspaces │
34
+ └────────────────────────────────────────────────────────────────┘
35
+ ```
36
+
37
+ ## The ops log (read this first)
38
+
39
+ Everything below rests on one append-only table. Read this section before the rest of the file.
40
+
41
+ **Capture is a trigger, not a call site.** Every INSERT / UPDATE / DELETE on a portable table (`workstreams`, `tasks`, `task_edges`, `task_notes`) is recorded as an **op** by a SQLite trigger, in the SAME transaction as the mutation. No SDK function decides to record history, so no call site can forget to, and the log cannot drift from the data — not even on power loss.
42
+
43
+ **The log is the record; the tables are the materialized view.** Reads hit the tables, as ordinary indexed SQL. This is not event-sourcing-on-read: nothing replays the log to answer a query. The log is what the tables are _derived from_ and what they can be rebuilt from.
44
+
45
+ ```
46
+ mu task close t3
47
+ │
48
+ ▼
49
+ UPDATE tasks SET status=... ──┬──► tables (what reads see)
50
+ └──► ops (what everything else reads)
51
+ one transaction
52
+ ```
53
+
54
+ **Four features are queries or replays over that one log:**
55
+
56
+ - **history** — `mu log` is a typed reader over `ops`.
57
+ - **undo** — `mu undo <group>` emits the inverse ops for one group.
58
+ - **archive** — an archive is one marker op pinning a point in the log; restore replays up to it.
59
+ - **sync** — a machine appends its own ops to a JSONL segment in a shared folder and applies each peer's from a watermark.
60
+
61
+ **Ops are semantic partial updates.** An UPDATE op carries only the columns that actually changed. That is what makes per-field merge free: two machines editing different fields of one task both keep their edit, with no extra bookkeeping. A full-row payload would silently regress this to row-level last-writer-wins.
62
+
63
+ **The cost, plainly:** one mechanism means one capture bug breaks history, undo, archive and sync at once, silently. That is why `mu doctor --deep` rebuilds the log into a temp DB and diffs it field-by-field against the live tables, and why the cheap tier (every live row has at least one op naming it) runs by default.
64
+
65
+ Ordering across machines comes from the **HLC** on every op — a hybrid logical clock serialized so that bytewise `ORDER BY hlc` is causal order. Machine-local tables (`agents`, `vcs_workspaces`) have no capture triggers: their contents are pane ids and absolute paths, which mean nothing on another machine. Their changes are recorded as log-only events via `emitEvent`.
66
+
67
+ Per-module detail is in [§ Modules](#modules-actual-src-layout).
68
+
69
+ ---
70
+
71
+ ## The task DAG
72
+
73
+ mu's coordination model is a **directed acyclic graph of tasks**. It is the central organizing primitive, not a sidecar feature: without it mu is just an agent runner.
74
+
75
+ ### Model
76
+
77
+ - **Tasks** are nodes with mandatory `impact (1-100)` and `effort_days`. `ROI = impact / effort` drives prioritization.
78
+ - **One edge type**: `blocks`. `A → B` means A must close before B can start. Multiple edge types create ambiguity that defeats the purpose.
79
+ - **Status lifecycle**: `OPEN → IN_PROGRESS → CLOSED`, with `REJECTED` and `DEFERRED` as terminal still-blocking outcomes.
80
+ - **Notes** are append-only per task; survive across LLM sessions and agent restarts. The fix for context loss at the _task_ level rather than the agent level.
81
+
82
+ ### Built-in queries (SQL views)
83
+
84
+ | View | Returns |
85
+ | --- | --- |
86
+ | `ready` | OPEN tasks with no unresolved blockers — work that can start _now_ |
87
+ | `blocked` | OPEN tasks waiting on something |
88
+ | `goals` | Tasks with no dependents — graph endpoints |
89
+
90
+ Agents and humans both query these views directly via `mu sql`. No separate query layer.
91
+
92
+ ### Parallel-track detection
93
+
94
+ The Tracks section in `mu state` / bare `mu` runs union-find on the graph to identify independent subtrees that can be assigned to different agents in parallel.
95
+
96
+ **Diamond patterns get merged automatically.** If two roots share a prerequisite, they collapse into one track — preventing two agents from colliding on the shared dependency:
97
+
98
+ ```
99
+ Independent (2 tracks): Diamond (1 merged track):
100
+
101
+ goal_a goal_b goal_a goal_b ← Spawn 2 agents
102
+ | | \ /
103
+ task_a task_b shared ← Spawn 1 (would
104
+ | | | collide otherwise)
105
+ leaf_a leaf_b leaf
106
+ ```
107
+
108
+ The graph algorithm decides, not the LLM.
109
+
110
+ ### Claim protocol via tmux pane title
111
+
112
+ `mu task claim <task>` reads the current pane's **pane title** (set on spawn via `select-pane -T <agent-name>`) and atomically:
113
+
114
+ 1. Sets `tasks.owner = <agent_name>`
115
+ 2. Flips `tasks.status = IN_PROGRESS`
116
+ 3. Emits a `task.claim` op carrying the resolved `actor`
117
+
118
+ Reads via `tmux display-message -p '#{pane_title}'`, **not** `#W` (window name). Window names come from the `tab:` frontmatter and may group multiple agents in one window.
119
+
120
+ Two agents can't claim the same task — atomic CAS in SQLite. The agent doesn't have to know its own name.
121
+
122
+ ### Scoped subtree views
123
+
124
+ `mu task tree <id>` and task queries show the portion of the graph reachable from a task. Subtree scoping is a `WHERE` clause, which is what makes recursive delegation work: a sub-orchestrator inspects its slice without an LLM inferring the scope.
125
+
126
+ ---
127
+
128
+ ## Tmux session topology
129
+
130
+ mu organizes agents into **one tmux session per workstream**. One mu workstream = one tmux session = one `session_id` partition in `~/.local/state/mu/mu.db`. Multiple workstreams on one machine coexist as independent tmux sessions, fully isolated.
131
+
132
+ ```
133
+ tmux session: mu-auth-refactor (one mu workstream)
134
+ ┌────────────────────────────────────────────────┐
135
+ │ Window: Backend Window: Review │
136
+ │ ┌──────────┐ ┌────────┐ ┌─────────────────────┐ │
137
+ │ │ worker-1 │ │ worker-2 │ │ reviewer-1 │ │
138
+ │ │ (pi) │ │ (pi) │ │ (pi, role=read-only) │ │
139
+ │ └──────────┘ └────────┘ └─────────────────────┘ │
140
+ │ │
141
+ │ Window: mu-orchestrator │
142
+ │ ┌────────────────────────────────────────────┐ │
143
+ │ │ pi (you, with mu extension loaded) │ │
144
+ │ └────────────────────────────────────────────┘ │
145
+ └────────────────────────────────────────────────┘
146
+
147
+ tmux session: mu-migration-2024q4 (different workstream)
148
+ ┌───────────────────────────────────────────────┐
149
+ │ ...different agents, different graph, no overlap │
150
+ └───────────────────────────────────────────────┘
151
+ ```
152
+
153
+ ### Concretely
154
+
155
+ - **First `mu agent spawn` creates the tmux session** if you're not already in one. Default name `mu-<auto>`. Override with `mu workstream init <name>` or `MU_SESSION=<name>`.
156
+ - **Subsequent operations** in the same shell (or any child shell with `MU_SESSION_ID` set) target the same session.
157
+ - **`tmux attach -t mu-<workstream>`** → attach to the whole workstream's tmux session
158
+ - **`mu agent attach <agent>`** → print the agent's scrollback plus the one-paste tmux attach command for that pane
159
+ - **`mu agent list`** is scoped to one workstream — the current one by default; `mu agent list -w <workstream>` for another
160
+ - **`session_id`** is the partition key on the `agents` table
161
+ - **`mu doctor`** warns about cross-session pollution (orphan panes, ghost rows, agents whose tmux session no longer exists)
162
+
163
+ ### Window vs pane
164
+
165
+ By default each agent gets its own **tmux window** (what most terminals call a "tab"), named after the agent's `tab:` value — default, the agent name. Agents sharing a `tab:` value share a window with multiple panes.
166
+
167
+ Claim/identity depends on the **pane title**, not the window name: every agent pane gets `select-pane -T <name>` on spawn regardless of how panes are grouped. The canonical tmux protocol is in the comment block at the top of `src/tmux.ts`.
168
+
169
+ ### Why one session per workstream
170
+
171
+ - **Visual co-location.** `tmux a -t mu-auth-refactor` shows the whole crew at once.
172
+ - **Isolation.** Kill the tmux session = kill the workstream. No leaked panes.
173
+ - **Detach and reattach freely.** The crew survives a closed laptop.
174
+ - **The claim protocol falls out.** Pane title = agent name = ownership identity, with no configuration.
175
+ - **Multiple workstreams coexist.** `session_id` partitioning keeps the auth-refactor crew out of the migration crew.
176
+
177
+ ---
178
+
179
+ ## Dual-audience CLI contract
180
+
181
+ The top-level `mu` binary serves two audiences without creating a second namespace.
182
+
183
+ - **Human entrypoint:** bare `mu` launches the read-only TUI when `process.stdout.isTTY === true`. It loads every workstream on the machine and chooses the initial active tab with the shared focus ladder (`$MU_SESSION` → tmux session name → cwd inside a workspace → cwd equal to a workspace's VCS-derived project root, with latest activity breaking project-root ties → tab 0). If no workstreams exist, it prints `mu --help` plus the one-paste `Get started: mu workstream init <name>` hint and exits 0.
184
+ - **Agent / script entrypoint:** typed verbs remain the API, with `--json` on reads and structured errors. Bare `mu` on non-TTY stdout (pipes, redirects, CI, most harnessed agent calls) prints help instead of entering Ink. `MU_NO_TUI=1` forces that same path for scripted use inside an otherwise-interactive terminal.
185
+ - **`mu state`** is the static state card; `mu state --tui` is the explicit TUI selector. The split is stdout-is-TTY plus the opt-out env var, not a second command namespace.
186
+
187
+ The TUI import stays dynamic (`await import("./cli/tui/index.js")`). No module outside `src/cli/tui/` may statically import ink/react, or the static CLI bundle pulls the TUI graph into help/version/json paths.
188
+
189
+ ---
190
+
191
+ ## TUI architecture
192
+
193
+ The TUI is a 10-card live-updating dashboard on `ink`. It is **read-only** and lives entirely under `src/cli/tui/` — CLI verbs remain the canonical mutation API. The TUI yanks `mu` commands; the operator runs them.
194
+
195
+ ### Cluster shape
196
+
197
+ `src/cli/tui/` is the only place ink/react are imported.
198
+
199
+ ```
200
+ src/cli/tui/
201
+ ├── index.ts # runTui entrypoint; alt-screen + mouse-mode lifecycle
202
+ ├── escapes.ts # pure ANSI byte sequences (alt-screen, SGR mouse mode)
203
+ ├── app.tsx # <App> root: popup state machine, global keymap, tabs
204
+ ├── state.ts # useDashboardSnapshot poll-loop hook (fast/slow tier split)
205
+ ├── keys.ts # pure dispatchGlobalKey + dispatchPopupKey + shouldSwallowGlobalKey
206
+ ├── keymap-spec.ts # canonical keymap source-of-truth (drives help overlay + dispatch)
207
+ ├── mouse.ts # vendored SGR mouse parser + double-click + useMouse hook
208
+ ├── yank.ts # clipboard probe + write (pbcopy/wl-copy/xclip/xsel/clip.exe + OSC-52)
209
+ ├── tuicr.ts # `t` shortcut: alt-screen handoff to tuicr -r <sha>
210
+ ├── layout.ts # responsive multi-column dashboard + per-card row budgets
211
+ ├── columns.ts # column-aligned row layout with protect/clip clipping
212
+ ├── wrap-ansi.ts # ANSI-aware visual-width line wrapper + SGR close-on-end
213
+ ├── glyphs.ts # superscript digit + status glyphs
214
+ ├── format-helpers.ts # shared TUI formatters (relTime, sinceClaim, ROI)
215
+ ├── titled-box.tsx # rounded border with section-header / bottomLabel inset
216
+ ├── popup-shell.tsx # popup outer chrome (cyan TitledBox)
217
+ ├── list-row.tsx # centralised non-selected row primitive
218
+ ├── padded-rows.tsx # per-card body padder
219
+ ├── status-bar.tsx # bottom status bar (mode + active ws + tick + footer flash)
220
+ ├── tab-strip.tsx # multi-workstream tab switcher (N≥2)
221
+ ├── tab-strip-layout.ts # pure window-around-active layout helper
222
+ ├── help.tsx # ?/F1 keymap overlay (scrollable on short panes)
223
+ ├── use-popup-filter.tsx # shared '/' substring filter hook + applyFilter + FilterPrompt
224
+ ├── use-status-filter.tsx # task-status toggles (o/i/c/r/d) for task-list popups
225
+ ├── use-notes-drill.ts # shared notes-drill memo (5 task popups consume it)
226
+ ├── use-popup-action-queue.ts # consume mouse PopupAction queue once per render
227
+ ├── cards/ # 10 dashboard glance cards (one slot each)
228
+ │ ├── _placeholder.tsx # shared loading/empty body wrapper
229
+ │ └── {agents,tracks,ready,log,workspaces,inprogress,blocked,recent,commits,doctor}.tsx
230
+ └── popups/ # fullscreen drill-down popups
231
+ ├── {agents,tracks,ready,log,workspaces,inprogress,blocked,recent,commits,doctor}.tsx
232
+ ├── dag.tsx # keybind-only on `g`: full task DAG forest
233
+ ├── all-tasks.tsx # keybind-only on `t`: sortable / filterable list of every task
234
+ ├── task-list-popup.tsx # shared TaskListPopup scaffold (ready/inprogress/recent/blocked config-only)
235
+ ├── drill.tsx # DrillScrollView + useDrillKeymap (shared scrollable-text leaf)
236
+ ├── task-detail.tsx # TaskDetailDrill (notes timeline; the recursion sink)
237
+ ├── cursor-row.tsx # selected-row primitive (delegated to from list-row)
238
+ ├── scroll.ts # pure applyCursor / applyScroll / clampScrollTop / isNavAction
239
+ ├── viewport.ts # popupViewport + POPUP_CHROME_ROWS + POPUP_VIEWPORT_FLOOR
240
+ └── show-loader.ts # subprocess-preserving show loader (avoids blank-flash mid-refetch)
241
+ ```
242
+
243
+ ### State machine
244
+
245
+ `<App>` is the root. It owns:
246
+
247
+ - **Popup state** — `null` (dashboard) or one of the popup ids. Single-popup invariant; `Esc` / `q` returns to dashboard.
248
+ - **Card visibility** — `Record<CardId, boolean>` toggled by `0`-`9`.
249
+ - **Tick rate** — fast tick interval (1s default; adjustable with `+` / `-` / `=` / `0`).
250
+ - **Active workstream tab** — index into the resolved workstream set; `Tab` / `Shift-Tab` cycles when N≥2.
251
+ - **Footer flash** — transient status-bar message (yank confirm, tuicr exit, etc.).
252
+
253
+ Popups own their own local state (cursor, filter query, drill mode, local modes like Workspaces' `list` / `commits` / `show`). Popups NEVER mutate App-level state — they receive a read-only props bag (`snapshot`, `db`, `workstream`, `fastTickNonce`, `slowTickNonce`, `yank`, `onClose`, `onModeChange`, `onFilterEditingChange`, `onFooter`).
254
+
255
+ ### Polling tiers (fast vs slow)
256
+
257
+ The poll loop in `state.ts` (`useDashboardSnapshot`) splits work into two intervals:
258
+
259
+ - **Fast tick** (default 1s, adjustable): SQL-only. `loadWorkstreamSnapshotFast` reads tasks, tracks, workspace registry rows, recent events, workspace orphans. Cheap (~p50 <1ms).
260
+ - **Slow tick** (10s, hardcoded `SLOW_TICK_MS`): subprocess-backed. `loadWorkstreamSnapshotSlow` runs tmux liveness, per-workspace dirty status, recent project commits, and the Doctor summary. Expensive (~p50 hundreds of ms).
261
+
262
+ The last slow result is merged into every fast render via `mergeSnapshotFastSlow`, so cards never flicker through a loading state. `r` / `F5` triggers both intervals immediately. A tab switch clears the slow cache and eager-fetches, so cards are fresh within 1s. The pure `snapshotKey` / `snapshotKeyString` guard returns the SAME `data` reference across no-op ticks so React's diffing short-circuits.
263
+
264
+ ### Render geometry
265
+
266
+ Responsive layout lives in `layout.ts`:
267
+
268
+ - **Breakpoint-driven columns**: stacked below 120 cols; 2 columns at 120; 3 at 180; 4 at 240. Stream cards (Commits, Activity log) trail; slot 0 (Commits) trails last.
269
+ - **Per-card row budgets**: each visible card gets a `min` / `max` / `chrome` budget; the allocator distributes available rows so a noisy list can't crowd siblings. Overflow surfaces as `+N more · Shift+N` inset into the card's bottom border.
270
+ - **Cull-on-tight-pane**: when even minimum budgets don't fit, cull cards by priority (Doctor → Recent → Workspaces → …) and show `+N cards hidden · resize taller` at the bottom. Outer height clip is the safety net.
271
+
272
+ Text rendering is ANSI-aware: `wrap-ansi.ts` wraps by visual width (`string-width`) and closes open SGR state on both the early-return and end-of-loop paths, so coloured fragments without a trailing `\x1b[0m` can't bleed into adjacent ink chrome. Drill bodies are space-padded to exact box width, or ink's `wrap="truncate"` ANSI miscount eats the right-border glyph.
273
+
274
+ ### Read-only invariant + the `tuicr` escape
275
+
276
+ Every popup row exposes one canonical `mu` command via `y`. `yank.ts` probes for a clipboard backend (pbcopy / wl-copy / xclip / xsel / clip.exe) and falls back to OSC-52 over stderr if none is found. The command goes to the clipboard; the operator runs it.
277
+
278
+ The one escape is `t` inside any `git show` drill: `tuicr.ts` writes `ALT_SCREEN_EXIT` + the mouse-mode disable bytes, exec's `tuicr -r <sha>` in the project root / workspace cwd as a foreground subprocess, then restores both on exit. A handoff, not an in-process mutation.
279
+
280
+ The read-only pledge is in `docs/ROADMAP.md`'s anti-feature list; a TUI gesture that wants to mutate state needs a roadmap entry first.
281
+
282
+ ### Mouse + keyboard
283
+
284
+ Mouse support is opt-in via SGR mouse mode (`escapes.ts` provides the enable/disable bytes). `mouse.ts` parses `ESC[<button;x;y;M/m` from stdin, detects double-clicks, and exposes a `useMouse()` hook.
285
+
286
+ Keyboard dispatch flows through pure helpers in `keys.ts`: `dispatchGlobalKey` (dashboard mode), `dispatchPopupKey` (popup mode), and `shouldSwallowGlobalKey` (which keys popups consume and do not bubble to the global dispatcher). The keymap source-of- truth lives in `keymap-spec.ts` so the help overlay and the dispatcher can never drift apart.
287
+
288
+ Double-click on a card emits `{kind: "setCursor", index}` followed by `{kind: "drill"}` through `use-popup-action-queue.ts`, which consumes one action per render (so the cursor update lands before the drill resolves the focused row).
289
+
290
+ ### Drill recursion
291
+
292
+ List popups drill via `Enter` into entity-specific leaves. The central primitive is `popups/drill.tsx`'s `DrillScrollView` (a scrollable text leaf shared by Workspaces' git-show, Agents' scrollback, the Activity log payload drill, and the Doctor remediation drill). Task popups drill into `popups/task-detail.tsx`'s `TaskDetailDrill` (the notes timeline); the Tracks popup chains track → task list → TaskDetailDrill via the same leaf.
293
+
294
+ `useDrillKeymap` owns the scroll state, takes an optional `resetKey` (identity change resets scroll; a tick-driven body refresh preserves it) and an optional `onScrollChange` (the DAG popup's focused-root tracking), and shares ANSI-aware wrapped body metadata so the scroll-clamp math and the painter can't desync.
295
+
296
+ Subprocess-backed drills (Workspaces git-show, Agents scrollback, Commits show) use `popups/show-loader.ts`, which preserves the prior body during a refetch — no blank flash on the slow tick.
297
+
298
+ ### Test seam
299
+
300
+ See `test/README.md`. The seam is `test/_ink-render.ts`'s `createInkInputStream` + `createInkCaptureStream` + `simulateInput` + `latestRenderedFrame`: mount a popup or `<App>` into a CaptureStream, drive keystrokes, assert against the visible frame and spy callbacks. Source-greps are for narrow structural guards only (App ↔ keys ↔ layout wiring; slot ↔ keymap glue), never behaviour.
301
+
302
+ ---
303
+
304
+ ## CLI / SDK surface
305
+
306
+ Every user-visible operation is a typed SDK function plus a thin Commander wrapper. `src/cli.ts` and the verb-namespace files under `src/cli/` are the canonical verb surface — no generated registry, no DSL, no separate operation schema. Programmatic callers import the same SDK functions from `src/index.ts`; agents and scripts compose CLI verbs with `--json`.
307
+
308
+ The boundary rule: external surfaces accept operator-facing names (workstream, task id, agent name); internal helpers resolve those to surrogate INTEGER ids once and then stay on ids. See [§ Surrogate-PK + SDK-boundary discipline](#surrogate-pk--sdk-boundary-discipline).
309
+
310
+ ---
311
+
312
+ ## Reconciliation
313
+
314
+ `mu agent list` always reconciles the registry against tmux reality before returning. Three steps, in order:
315
+
316
+ 1. **Prune ghosts.** For each `agents` row, if its `pane_id` no longer exists in tmux, delete the row.
317
+ 2. **Detect status from scrollback.** For each surviving agent, capture the pane and run the per-CLI detector. Update `agents.status` if the detected value differs from the stored one.
318
+ 3. **Surface orphans.** For each tmux pane in the workstream's session that has no matching `agents` row but whose pane title looks like an agent name, add it to the orphans list. **Do not auto-adopt** — `mu agent list` shows orphans under a separate "(orphans)" section and the user runs `mu agent adopt %15 [--name X]` to claim them.
319
+
320
+ `src/reconcile.ts` is the only implementation. Key properties:
321
+
322
+ - **Reality wins**: tmux is the source of truth for what panes exist. The DB records what we last _observed_. Reconciliation closes the gap on every `mu agent list`.
323
+ - **Pi-only status detection** (`src/detect.ts`): `busy` / `needs_input` / `idle` / `done` via a known pi marker. Other CLIs fall back to a Braille-spinner heuristic.
324
+ - **No silent adoption**: orphans are reported, never claimed.
325
+ - **`mu doctor` calls the same routine** and reports counts.
326
+
327
+ ---
328
+
329
+ ## Modules (actual src/ layout)
330
+
331
+ Mostly-flat `src/`: root `.ts` modules plus cohesive subclusters (`src/agents/`, `src/tasks/`, and `src/cli/` wrappers with their own `src/cli/tasks/` and `src/cli/tui/` sub-clusters). Cluster files import from neighbours and root substrate modules, never from the hub they're re-exported through. `src/tasks.ts`, `src/vcs.ts` and `src/workspace.ts` are pure re-export hubs, kept so external imports keep working; they hold no logic and are not listed separately below.
332
+
333
+ | Module | Responsibility |
334
+ | --- | --- |
335
+ | `src/db.ts` | Connection (better-sqlite3, WAL), **schema v9** (10 tables + 3 views), `resolveWorkstreamId`. Installs capture on every writable open. Owns `SYNCED_ENTITIES` / `PORTABLE_TABLES` / `MACHINE_LOCAL_TABLES`. Refuses a pre-v9 DB (exit 4). |
336
+ | `src/hlc.ts` | The **HLC** (VOCABULARY § HLC), serialized as sortable TEXT `<wall_ms:15>.<counter:6>.<machine_id>`. `nextHlc` / `receiveHlc` / `compareHlc` / `parseHlc` / `formatHlc`. Clock state lives in `machine_identity`. |
337
+ | `src/capture.ts` | **Op capture**: builds the triggers that record every write to a portable table as an op in the same transaction. |
338
+ | `src/apply.ts` | **The apply path** — capture's counterpart: given one op, local or from a peer, make the tables reflect it. Also owns `reprojectDeferredOps` ([§ ambient sync hook](#the-ambient-sync-hook)). |
339
+ | `src/archives*.ts` | **Archives as markers**: one `marker` op pinning a point in the log. Labels are global, append-only. `restore.ts` replays to the marker under a NEW name, re-keying every natural key; `export.ts` does the same in a scratch workstream, then rolls back. |
340
+ | `src/exporting.ts` | Bucket renderer shared by `mu workstream export` and `mu archive export`: per-task markdown + `manifest.json` (`bucketVersion: 2`), idempotent via per-file sha256, deleted-task preservation banner. Read-only artifacts, not a round-trip substrate. |
341
+ | `src/undo.ts` + `src/cli/undo.ts` | **Undo as inverse ops**: inverses for one `group_id`, derived from log provenance, refusing a superseded group (exit 4; `--force` overrides). Bare form lists undoable groups (`-n` widens), a prefix previews, `--yes` applies. |
342
+ | `src/parked.ts` | Read-only "presumed parked on another machine" heuristic behind `mu workstream list`'s `parked` column and the TUI tab strip's dim marker. **Effectively dormant**: it keys on `workstream.export`, which rarely happens. |
343
+ | `src/project-root.ts` | `detectProjectRoot` — the launch-cwd ladder bare `mu` uses to guess which workstream to focus. Pure filesystem walk; no DB. |
344
+ | `src/rebuild.ts` + `src/cli/rebuild.ts` | **Rebuild** — disaster recovery: `rebuildInto` replays the whole log into a NEW DB file via `applyOp`. The verb renders the report: counts, `--json`, an `mv`-swap `Next:` step, a warning that `agents` / `vcs_workspaces` are not reconstructible. |
345
+ | `src/drift.ts` | **Drift detection**, two tiers: `checkDrift` (`mu doctor --deep`) rebuilds into a temp DB and diffs field-by-field; `checkCheapDriftInvariant` (the default) asserts every live row has ≥1 op naming its key. Plus `driftRemediation`. |
346
+ | `src/segments.ts` | **Segments** — the transport. `flushSegment` appends this machine's unflushed ops to `<MU_SYNC_DIR>/<machine_id>.jsonl`; `ingestSegment` reads a peer segment from its watermark into `applyOp`. Peer discovery is implicit: every non-self `*.jsonl`. |
347
+ | `src/sync.ts` + `src/cli/sync.ts` | **The sync seam**: `peerStatuses`, the ambient hook (`ambientIngest` / `ambientFlush` / `ambientSyncPass`), `ingestFromDb`, `repairPeer`. The verb is a peer status report; `--from <path>`, `--repair <peer>` (unique prefix, ambiguity is exit 4). |
348
+ | `src/cli/db.ts` | `mu db backup <file>` — one scp-able file via `VACUUM INTO`. Never overwrites: it is the pre-migration copy `SchemaTooOldError` tells you to take. |
349
+ | `src/file-lock.ts` | Cross-process advisory lock via atomic `fs.mkdir`; `src/agents/spawn-lock.ts` wraps it per tmux session. Flush takes it so two local `mu` processes cannot interleave partial lines. Best-effort: a non-contention failure runs the body unlocked. |
350
+ | `src/fleet-hazards.ts` | **Mixed-fleet hazards** — three environment checks in the default doctor: `MU_DB_PATH` inside `MU_SYNC_DIR` (fail), DB on a network mount (warn), two workstream names differing only by case (warn). All no-op when `MU_SYNC_DIR` is unset. |
351
+ | `src/op-context.ts` | The **op context** seam: `withOpContext(db, {intent, actor, group}, fn)` labels every op in a scope, restored in a `finally`. Nested scopes inherit the group, which puts a cascade under one `mu undo`. `withCaptureSuppressed` is the echo guard. |
352
+ | `src/tmux.ts` | Single tmux executor wrapper, send protocol (bracketed-paste), pane validation |
353
+ | `src/detect.ts` | Pi-only status detector (`busy` / `needs_input` / `idle` / `done`) |
354
+ | `src/reconcile.ts` | Ghost prune + status detect + orphan surface; "reality wins" |
355
+ | `src/agents.ts` | Hub: CRUD + send / read / list / close / free + liveness + reaper. Re-exports `src/agents/*`; pane-title composition (`composeAgentTitle`) lives here. |
356
+ | `src/agents/*.ts` | Agent-lifecycle internals: `spawn.ts` (spawn, CLI resolution, liveness wait, pane create-or-reuse, rollback), `spawn-lock.ts` (per-session lock around topology+finalize), `wait.ts`, `adopt.ts`, `kick.ts` (signal a wedged pane's pgid), `errors.ts`. |
357
+ | `src/dag.ts` | Shared DAG read/render helpers: `loadFullDag` plus pure `renderForest` / `renderTaskTree`, reused by `mu task tree` and the TUI DAG popup. |
358
+ | `src/tasks/*.ts` | Task-graph internals: `core.ts` (row shapes, id resolution), `id.ts`, `queries.ts` (reads), `edit.ts`, `edges.ts` (+ cycle check), `status.ts` (TaskStatus), `sort.ts`, `claim.ts` (atomic CAS), `lifecycle.ts` (+ cascade), `wait.ts`, `errors.ts`. |
359
+ | `src/tracks.ts` | Parallel-tracks union-find with diamond merge |
360
+ | `src/staleness.ts` | `WORKSPACE_STALE_THRESHOLD = 10` and the pure `isWorkspaceStale` predicate, shared by static state, the TUI Workspaces card, and dispatch-time checks. |
361
+ | `src/workstream.ts` | ensureWorkstream / list / summarize / destroy / export (thin wrapper around the bucket renderer) |
362
+ | `src/logs.ts` | Typed READER over `ops` (`listLogs` / `latestSeq`), plus the write paths triggers cannot cover: `appendLog` (operator prose) and `emitEvent` (changes mutating no portable table — `agent.*`, `workspace.*`), under a typed `LocalIntent`. |
363
+ | `src/log-render.ts` | **The ONE op → prose formatter.** `renderOp` maps an intent (+ key + payload fields) to `{verb, subject, detail}`; plus `renderOpLine`, `opSubject`, `parseOpKey`. Pure and colour-free, so CLI and TUI share one phrasing. |
364
+ | `src/vcs/*.ts` | One backend per file (`git.ts`, `jj.ts`, `sl.ts`, `none.ts`) plus `types.ts` (the `VcsBackend` interface), `helpers.ts`, and `index.ts` (detection precedence `jj` → `sl` → `git` → none; `backendByName`). |
365
+ | `src/workspace/*.ts` | Per-agent VCS workspaces, a registry on top of `vcs.ts`: `core.ts` (row shapes, paths, errors), `crud.ts`, `decorate.ts` (staleness + dirty), `orphans.ts`, `recreate.ts`. |
366
+ | `src/output.ts` | NextStep type + `printNextSteps` + `errorNextSteps` plumbing for self-documenting output |
367
+ | `src/shell-quote.ts` | `shellQuote` — POSIX single-quoting for tokens interpolated into copy-pasteable next-step hints. |
368
+ | `src/state.ts` | SDK seam for `mu state`: `loadWorkstreamSnapshotFast` (pure SQL, TUI 1s tick), `loadWorkstreamSnapshotSlow` (subprocesses), `mergeSnapshotFastSlow`, `loadWorkstreamSnapshot`. Opt-in: `withDirty`, `withDoctor`, `withRecentCommits`, `withAllTasks`. |
369
+ | `src/doctor-summary.ts` | The dashboard's doctor seam: `loadDoctorSummary` (pragmas and COUNT-shape selects only — cheap enough for the poll loop), `loadDoctorChecks` for the popup, plus `yankCommandForCheck` / `remediationParagraph`. |
370
+ | `src/cli.ts` | commander entry; `buildProgram()` (re-exports `format`/`handle` symbols for back-compat with existing import sites). |
371
+ | `src/cli/*.ts` | One file per verb-namespace; thin wrappers over the SDK; `--json` on every read verb. Two non-verb cluster-mates: `format.ts` (table renderers, status colourers) and `handle.ts` (typed-error → exit-code map). |
372
+ | `src/cli/tui/*.tsx` | The interactive ink TUI, lazy-imported by `src/cli/state.ts`. **The only place ink/react are imported** — a ROADMAP pledge. Per-file roles: [§ TUI architecture](#tui-architecture). |
373
+ | `src/cli/tasks/*.ts` | Sub-cluster of the `mu task` namespace: `queries.ts` (list/next/owned-by + `mu me tasks` / `mu me next`), `lifecycle.ts` (+ cascade preview), `edit.ts`, `edges.ts`, `claim.ts`, `tree.ts`, `wire.ts` (Commander glue). |
374
+ | `src/index.ts` | SDK entrypoint (re-exports) |
375
+ | `skills/mu/SKILL.md` | Bundled skill teaching the LLM the model + verb list + jq pipelines |
376
+
377
+ ### What the rows above leave out
378
+
379
+ - **Capture triggers are TEMP triggers**, reinstalled per connection — SQLite refuses a main-schema trigger referencing the temp `_op_ctx`. Hence two oddities: DELETE keys are captured inline, with the parent stashing its natural key in `_op_dying` first (FK CASCADE fires child triggers after the parent is gone), and the HLC is minted in SQL, because a trigger cannot call into JS.
380
+ - **Merge is per entity**: notes and messages are grow-only sets, tasks and workstreams merge per field by HLC, edges are an LWW-element-set. A tombstone is an ordinary op carrying an HLC, so out-of-order arrival is a comparison and resurrection falls out. Never `json_patch` — RFC 7396 reads a null member as delete-the-key.
381
+ - **Undo and restore write through the tables**, so neither can be the write path capture misses. `restore` records then applies under the SAME HLC: `applyOp` excludes an op's own HLC from provenance, so a fresher one makes the row outrank the op and lose every field to an insert default.
382
+ - **Segments are single-writer per file** — a machine appends only to its own, so nothing is contended and any folder-syncer is adequate transport. Regenerable, so no fsync. Four layers stop at the first bad record and advance the watermark only that far: `JSON.parse` (torn write), crc32 per line (bit rot), monotonic HLC (reorder, duplicate, truncation), a `.manifest` sidecar (truncation exactly on a line boundary). Only `SYNCED_ENTITIES` and only THIS machine's ops are flushed, so pane ids and absolute paths never leave and peers never echo each other's history.
383
+ - **Sync never fails a command**: a truncated segment, a garbage segment, a sync dir that is a file, a vanished directory all warn on stderr and return.
384
+ - **Rebuild is not ingest**: ingest filters to `SYNCED_ENTITIES`, but local recovery replays everything, so log-only entities are copied verbatim or `mu log` comes back empty. `machine_identity` carries across whole — id, hostname, HLC clock — or the rebuilt DB is a different peer minting HLCs below every replayed op. Drift diffs by natural key and excludes `owner_id`: a rebuild always has NULL owners.
385
+
386
+ Why segments and not Litestream / cr-sqlite / a peer list, and why the DB must never sit in `MU_SYNC_DIR`: [ROADMAP § Rejected sync substrates](ROADMAP.md#rejected-sync-substrates).
387
+
388
+ ## Data flow
389
+
390
+ 1. **A caller invokes a verb** — the CLI subprocess, or in-proc SDK use.
391
+ 2. **CLI handler dispatches to an SDK function** in `src/agents.ts` / `src/tasks.ts` / etc.
392
+ 3. **For multi-statement writes, opens a transaction** via better-sqlite3's `db.transaction(fn)()` wrapper.
393
+ 4. **Executes the operation** — agent ops shell out to tmux (and to jj/sl/git for workspaces); task ops are pure SQL.
394
+ 5. **Reconciles with reality** — for read-paths that need accuracy (`mu agent list`, state views), queries tmux for live pane state and updates the DB (ghost prune + status detect).
395
+ 6. **Records ops** — automatically, per [§ The ops log](#the-ops-log-read-this-first); machine-local changes go through `emitEvent`. `mu log --tail` subscribers see the new ops on the next 1-second poll.
396
+ 7. **Commits or rolls back** — exception propagates after rollback so the caller sees the real error and the typed error class maps to a specific exit code in `handle()`.
397
+
398
+ ## Key seams
399
+
400
+ The extension points. A new impl of each is small.
401
+
402
+ | Seam | Add a new impl by... |
403
+ | --- | --- |
404
+ | `VcsBackend` | Implementing `detect / createWorkspace / freeWorkspace / isClean / commitsBehind / rebaseTo / commitsSinceBase / recentCommits / showCommit` (~80–150 LOC; jj/sl/git/none are working examples) |
405
+ | Per-CLI `Detector` | Adding patterns to `detectPiStatus` (vanilla pi `to interrupt)`; pi-meta + every TUI wrapper covered by Braille spinner glyph fallback `[\u2800-\u28FF]`) |
406
+ | New typed verb | An SDK function in the relevant `src/*.ts`; a `cmd<Verb>` in the matching `src/cli/<namespace>.ts`; one commander block in `buildProgram()`, wrapped in `handle()` and routed through `printNextSteps` |
407
+ | New schema migration | Bump `CURRENT_SCHEMA_VERSION` in `src/db.ts` and mirror the shape in `CURRENT_SCHEMA`. Recipe and the clean-break rules: [scripts/README.md](../scripts/README.md) |
408
+ | New syncable field | Add the column to a portable table, extend the capture trigger's changed-column comparison, confirm the apply path writes it. The UPDATE trigger MUST emit only changed columns — a full-row payload silently regresses field merge to row-level LWW |
409
+ | Cross-machine sync | Nothing: every invocation already flushes local ops and ingests each peer segment from its watermark. No daemon, no network code, no membership config |
410
+
411
+ An importer must synthesize **ops, not rows**: the tables are a projection of the log, so a direct INSERT is invisible to sync and reported as drift.
412
+
413
+ ## The ambient sync hook
414
+
415
+ Sync is **ambient, not a daemon**: no watcher, no background process, no polling loop outliving a command. It happens because you already run `mu` constantly.
416
+
417
+ **The seam is `handle()`** (`src/cli/handle.ts`), the one place every verb passes through, and the only seam that is already async — `syncPass` is async (it takes the file lock) while most verb bodies are synchronous better-sqlite3 code. One `await` before `fn(db)` and one after covers all ~60 verbs; no verb learns that sync exists.
418
+
419
+ **Order matters**: ingest BEFORE the body, so the verb reads the freshest state transport has delivered; flush AFTER, so this invocation's ops reach the segment now rather than next invocation. Flush also runs on the ERROR path — a verb may have committed ops before throwing, and those ops are already canonical, so withholding them would make this machine's history depend on the exit code.
420
+
421
+ Four carve-outs:
422
+
423
+ | Surface | Behaviour | Why |
424
+ | --- | --- | --- |
425
+ | `MU_SYNC_DIR` unset | One `if` on one env var. No promise, no filesystem touch. | The single-machine case must pay nothing. Measured indistinguishable from baseline; ~3ms with one peer. |
426
+ | `mu sql` | Opts out (`handle(..., { ambientSync: false })`). | It is the verb whose result an operator diffs against their own expectation; an ambient ingest changing a row count mid-inspection reads as a mu bug. |
427
+ | `mu sync` | Opts out and runs the pass itself. | Otherwise the report prints 'ingested 0' right after ingesting. |
428
+ | The TUI | On the SLOW tick (10s, `SLOW_TICK_MS`) only, guarded so N tabs run one pass per beat, `quiet: true`. | The fast tick can be wound down to 100ms — 10 passes/second on a shared folder. Quiet because the TUI owns the alternate screen; sync problems surface via the Doctor card. |
429
+
430
+ ### Out-of-order arrival: `reprojectDeferredOps`
431
+
432
+ An `edge` or `note` put whose task has not been ingested yet skips as `absent` — correctly. But ingest advances the watermark past it anyway, so without a second pass it never lands: a permanent silent divergence, and a coin flip per fleet, since peers are discovered by `localeCompare` over random-UUID filenames.
433
+
434
+ `reprojectDeferredOps` re-queries the log for note/edge puts that are resolvable NOW but unprojected. No retry queue — that would be a second source of truth, and it would not survive a short-lived mu process when the parent often arrives days later. It excludes ops whose parent task is gone and keys carrying a newer `del`, so a deleted edge is never resurrected and an orphan is not retried forever. On a healthy DB it is two indexed queries returning zero rows. Called once per ingest pass, never per peer: an edge in one segment may name a task in another.
435
+
436
+ ## Surrogate-PK + SDK-boundary discipline
437
+
438
+ A substrate-wide invariant; every entity table follows it.
439
+
440
+ **Schema shape — every entity table:**
441
+
442
+ ```
443
+ (
444
+ id INTEGER PRIMARY KEY AUTOINCREMENT, -- surrogate; internal
445
+ <scope_id> INTEGER NOT NULL REFERENCES <parent>(id) ON DELETE CASCADE,
446
+ <name> TEXT NOT NULL, -- operator-facing; mutable
447
+ -- ... domain attributes
448
+ UNIQUE (<scope_id>, <name>) -- per-scope unique
449
+ )
450
+ ```
451
+
452
+ FKs reference `<child>.<parent>_id` (INTEGER), never the TEXT name. The TEXT name is an operator-facing attribute — searchable, displayable, cheap to rename. The surrogate id is the identity.
453
+
454
+ **TEXT-by-design exceptions:** the workstream's own `name` (it IS a tmux session name; globally unique), `task_notes.author` / `ops.actor` (free-text actor labels — `"orchestrator"`, `"user"`, `"system"`), `ops.entity` (open enum — future entities need no migration), `agents.cli` (adding a new CLI must not require a schema change), and every column of `ops` (the ops log is FK-free by design and addresses rows by their NATURAL key, so an op outlives the row — and the workstream — it records).
455
+
456
+ **SDK boundary discipline** — same shape as REST: external API uses business identifiers, internal layer uses primary keys.
457
+
458
+ > **Public SDK functions take operator-facing names.** **Internal helpers take surrogate ids.** **Resolution happens at the public-function entry, exactly once.**
459
+
460
+ ```ts
461
+ // PUBLIC: takes operator-facing names
462
+ export function claimTask(
463
+ db: Db,
464
+ workstream: string,
465
+ localId: string,
466
+ opts?: ClaimOptions,
467
+ ): ClaimResult {
468
+ const wsId = resolveWorkstreamId(db, workstream);
469
+ const taskId = tryResolveTaskId(db, wsId, localId);
470
+ if (taskId === null) throw new TaskNotFoundError(localId);
471
+ const agentId = resolveCurrentAgentId(db, wsId);
472
+ return claimTaskById(db, taskId, agentId, opts);
473
+ }
474
+
475
+ // INTERNAL: takes surrogate ids; never re-resolves
476
+ function claimTaskById(db, taskId, agentId, opts): ClaimResult { ... }
477
+ ```
478
+
479
+ Resolving exactly once at the boundary buys three things: no double-resolution; no mid-function ambiguity (with surrogate ids in hand, internal helpers need no workstream context — the FKs make scope implicit); and one place for error mapping (`WorkstreamNotFoundError` originates at resolve-time in `src/db.ts`; `TaskNotFoundError` / `AgentNotFoundError` are raised by SDK callers wrapping the `tryResolve*` null-return, so the typed class and its exit-code 3 mapping hold whichever leg missed).
480
+
481
+ **`--json` output preserves operator-facing names.** Surrogate ids never leak into `--json`, error payloads, log lines, or markdown exports. Promoting them to the public shape would re-introduce a global namespace by the back door (anti-feature pledge).
482
+
483
+ ## State of truth
484
+
485
+ - **`~/.local/state/mu/mu.db` is canonical.** Everything else is a cache, including tmux pane titles (mu re-pushes them via `composeAgentTitle` after every state change).
486
+ - **Reads are cheap** via SQLite views (`ready`, `blocked`, `goals`).
487
+ - **Writes go through the typed SDK functions** (`src/agents.ts`, `src/tasks.ts`, …) which validate, transact, and reconcile. Op capture is not their job — the triggers handle it.
488
+ - **Workstream scoping is mandatory at the CLI boundary.** TEXT names (`tasks.local_id`, `agents.name`) are per-workstream unique — the same name may legitimately exist in two workstreams. Every public SDK function taking such a name also takes (or threads) the workstream; internal SQL filters by `(workstream_id, name)`. Test fixtures and `mu sql` read paths may omit it and fall back to first-match-by-name. The schema enforces it structurally (per-workstream UNIQUE on name + INTEGER FKs).
489
+ - **The ops log is the insurance and it IS version history** — see [§ The ops log](#the-ops-log-read-this-first). `mu undo <group>` reverts one action; `mu rebuild <file>` replays the whole log into a fresh DB.
490
+ - **In-memory state is short-lived** — the CLI's per-command connection. Gone on process exit.
491
+ - **Cross-process coordination** is via SQLite WAL — multiple `mu` processes share the file safely.
492
+
493
+ ## Errors
494
+
495
+ Curated error classes per layer; no try/catch swallowing. CLI exit codes:
496
+
497
+ | Code | Meaning |
498
+ | ---- | ----------------------------------------------------- |
499
+ | 0 | success |
500
+ | 1 | generic error |
501
+ | 2 | usage error (commander's default) |
502
+ | 3 | not found (no such agent / task / workspace) |
503
+ | 4 | conflict (name collision, double-claim, dirty tree) |
504
+ | 5 | substrate unavailable (`tmux` not running, DB locked) |
505
+
506
+ Errors carry structured context (operation name, target, attempted action) so `mu doctor` can surface them readably.
507
+
508
+ ## Testing layers
509
+
510
+ | Layer | Test approach |
511
+ | --- | --- |
512
+ | `src/db.ts` | Real SQLite in temp dir; schema/table-count assertions |
513
+ | `src/tasks.ts` | Real SQLite in temp dir; pure functions over fixture data |
514
+ | `src/tracks.ts` | Pure functions; union-find + diamond-merge properties |
515
+ | `src/agents.ts` | Mocked tmux executor via `setTmuxExecutor()`; reaper integration tests |
516
+ | `src/logs.ts` | Real SQLite; cursor semantics, AUTOINCREMENT durability, ops-outlive-workstream |
517
+ | `src/capture.ts` + `src/op-context.ts` | Real SQLite, no tmux: payloads hold ONLY changed columns (asserted by key COUNT), echo suppression, cascade grouping, FK-CASCADE tombstone keys, the null-intent fail-safe |
518
+ | `src/apply.ts` | Real SQLite, INJECTED HLCs, no sleeps: field-level convergence in both orders, same-field LWW determinism, all four put/del orderings, resurrection, grow-only notes, edge add/remove, the set-to-NULL trap, `owner_id` stripping, replay idempotence |
519
+ | `src/rebuild.ts` | Round trip asserted row-BY-ROW per portable table (a count-only check passes on a garbage rebuild), plus idempotence, tombstones, byte-identical ops, log-only entities copied, `machine_id` + clock carried. `cli-rebuild.integration` covers the verb |
520
+ | `src/drift.ts` + `src/fleet-hazards.ts` | Drift planted the way a capture bug would (`withCaptureSuppressed` + direct mutation), with equal weight on FALSE positives (cascade delete, resurrection, set-to-NULL, no-op update, claimed task). `classifyFsType` against synthetic magic numbers |
521
+ | `src/segments.ts` | TWO temp DBs and one shared dir — the real deployment shape. Round trip; two machines editing different fields both surviving; idempotence; each robustness layer; machine-local ops never reaching a file; Syncthing conflict copies |
522
+ | `src/sync.ts` + `src/cli/sync.ts` | Fast tier covers decision logic in-process (staleness arithmetic, peer-prefix ambiguity, MU_SYNC_DIR unset, the never-throw contract against four breakages, hook order). `cli-sync.integration` re-checks it THROUGH THE VERB with two DBs and one dir |
523
+ | the whole sync path, as a SESSION | `sync-session.integration` simulates days of laptop↔devserver work: 8 rounds, two machines, no coordination beyond the folder. Asserts byte-identical portable content, no `--deep` drift, and a quiet round adding exactly zero ops |
524
+ | `src/hlc.ts` | Real SQLite + an INJECTED clock (no sleeps): backwards jumps, stalled milliseconds, sort order, durability, concurrent minting |
525
+ | `src/vcs.ts` + `src/workspace.ts` | `*.integration.test.ts` files use real git in `os.tmpdir()`; jj/sl tests feature-detect (skip if binary missing) |
526
+ | `src/cli.ts` / verb integration | `*.integration.test.ts` files; real tmux server, unique session per test |
527
+ | Fast unit/dev-loop tier | `npm run test:fast`; excludes `*.integration.test.ts` / `*.smoke.test.ts`, uses mocked tmux/VCS and per-test temp DBs |
528
+ | Stress / flake audit | `npm run test:stress`; repeats the full suite with per-run logs/timeouts, optionally in parallel waves (`MU_TEST_STRESS_MODE=parallel`) |
529
+ | End-to-end | `test/acceptance.integration.test.ts` — the canonical 10-task / 3-agent demo |
530
+
531
+ Two fixture traps: a convergence fixture must share ONE creation op (two independent `task add`s for the same id make the later creation legitimately win every field), and an ordering-sensitive test must RENAME segments to pin ingest order, since peers are discovered by `localeCompare` over random-UUID filenames.
532
+
533
+ Flake lessons: treat pass-alone/fail-under-load cases as concurrency bugs first; use retrying temp-dir cleanup for VCS fixtures whose subprocesses keep files alive briefly; drive wait/reaper integration tests from poll-loop seams, not fixed timers; wait for stable Ink output instead of sleeping.
534
+
535
+ ## Distribution
536
+
537
+ Single npm package `mu` (see `package.json`):
538
+
539
+ - `dist/cli.js` — CLI entry, executable (`bin: { mu: ./dist/cli.js }`; shebang preserved by `tsup`)
540
+ - `dist/index.js` + `dist/index.d.ts` — programmatic API + types for SDK callers
541
+ - `skills/mu/SKILL.md` — bundled skill (the only non-`dist` asset shipped)
542
+
543
+ `tsup` bundles two entries (`index`, `cli`) from `src/`. No runtime build step on the user's machine. There is no pi-extension entry — pi is a peer dep — and no bundled `agents/*.md` or `prompts/*.md`: per-role agent guidance lives in the user's project repo.
544
+
545
+ The dependency list lives in `package.json`; the rule for adding new ones is the anti-feature pledge in [ROADMAP.md § Anti-feature pledges](ROADMAP.md#anti-feature-pledges).