@mu-crew/mu 3.2.0 → 3.2.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.
Files changed (40) hide show
  1. package/AGENTS.md +203 -599
  2. package/README.md +76 -327
  3. package/dist/cli.js.map +1 -1
  4. package/dist/extension/mu-pi.js +178 -45
  5. package/dist/extension/mu-pi.js.map +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/docs/ARCHITECTURE.md +175 -1072
  8. package/docs/ROADMAP.md +88 -169
  9. package/docs/VISION.md +205 -415
  10. package/docs/VOCABULARY.md +202 -466
  11. package/docs/architecture/control-socket.md +119 -0
  12. package/docs/architecture/dag.md +113 -0
  13. package/docs/architecture/mux.md +163 -0
  14. package/docs/architecture/ops-log.md +140 -0
  15. package/docs/architecture/sdk.md +185 -0
  16. package/docs/architecture/sync.md +129 -0
  17. package/docs/architecture/tui.md +146 -0
  18. package/docs/guide/README.md +25 -0
  19. package/docs/guide/backends.md +61 -0
  20. package/docs/guide/cleanup.md +74 -0
  21. package/docs/guide/delegate.md +87 -0
  22. package/docs/guide/dispatch.md +137 -0
  23. package/docs/guide/getting-started.md +177 -0
  24. package/docs/guide/recovery.md +133 -0
  25. package/docs/guide/remote.md +74 -0
  26. package/docs/guide/sql.md +76 -0
  27. package/docs/guide/stop-a-worker.md +64 -0
  28. package/docs/guide/sync.md +78 -0
  29. package/docs/guide/tui.md +98 -0
  30. package/docs/guide/upgrade.md +76 -0
  31. package/docs/history/CHANGELOG-pre-3.md +4866 -0
  32. package/docs/reference/env.md +87 -0
  33. package/docs/reference/naming.md +82 -0
  34. package/docs/specs/2026-09-28-agent-state-from-murmur.md +1 -1
  35. package/docs/specs/2026-09-29-task-substates.md +1 -1
  36. package/package.json +1 -1
  37. package/skills/mu/REMOTE_WORKERS.md +179 -504
  38. package/skills/mu/SKILL.md +116 -185
  39. package/docs/HANDOVER.md +0 -486
  40. package/docs/USAGE_GUIDE.md +0 -2738
@@ -1,1090 +1,193 @@
1
1
  # Architecture
2
2
 
3
3
  mu is one SQLite file, a crew of agents in multiplexer panes, a task
4
- DAG, and one append-only op log everything else is derived from. It is
5
- layered: callers on top, a shared TypeScript core in the middle,
6
- SQLite + mux + VCS substrates at the base. The CLI verbs and the
7
- programmatic SDK are thin facades over the same core modules.
4
+ DAG, and one append-only op log that everything else derives from.
5
+ Callers sit on top, a shared TypeScript core in the middle, and SQLite,
6
+ the multiplexer and the VCS at the base. The CLI verbs and the SDK are
7
+ thin facades over the same core.
8
8
 
9
- - For canonical terms (*workstream*, *agent*, *task DAG*, *track*,
10
- *claim*, *free*, *workspace*, *substrate*, ...) see
11
- [VOCABULARY.md](VOCABULARY.md). It is the source of truth.
12
- - For design rationale, rejected alternatives, and what's on the
13
- roadmap, see [ROADMAP.md](ROADMAP.md).
14
- - For principles, see [VISION.md](VISION.md).
9
+ Terms: [VOCABULARY.md](VOCABULARY.md) (source of truth). Principles:
10
+ [VISION.md](VISION.md). Rationale, rejected alternatives and pledges:
11
+ [ROADMAP.md](ROADMAP.md).
15
12
 
16
13
  ```
17
14
  ┌─────────────────────────────────────────────────────────────────┐
18
- │ Callers │
19
- │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌─────────────┐ │
20
- │ │ Pi │ │ Bash + │ │ Pi sub- │ │ mu log │ │
21
- │ │ shell │ │ jq │ │ agent │ │ --tail subs │ │
22
- │ └────┬─────┘ └────┬─────┘ └──────┬───────┘ └──────┬──────┘ │
23
- │ │ │ │ │ │
24
- └───────┼─────────────┼───────────────┼─────────────────┼─────────┘
25
- │ in-proc │ subprocess │ subprocess │ in-proc
26
- ▼ ▼ ▼ ▼
15
+ │ Callers: pi shell · bash + jq · pi sub-agent · mu log --tail │
16
+ └───────────────┬─────────────────────────────────┬───────────────┘
17
+ │ subprocess (CLI) │ in-proc (SDK)
18
+ ▼ ▼
27
19
  ┌─────────────────────────────────────────────────────────────────┐
28
20
  │ mu core (shared TS modules) │
29
- │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
30
- │ │ agents/ │ │ tasks/ │ │ vcs/ │ │ ops log/ │ │
31
- │ │ mux/ │ │ schema │ │ jj │ │ capture │ │
32
- │ │ tmux │ │ queries │ │ sapling │ │ apply/sync │ │
33
- │ │ herdr │ │ tracks │ │ git │ │ doctor │ │
34
- │ │ state │ │ claim │ │ none │ │ │ │
35
- │ │ sources │ │ │ │ │ │ │ │
36
- │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────────┬────────┘ │
37
- └───────┼─────────────┼─────────────┼─────────────────┼───────────┘
38
- ▼ ▼ ▼ ▼
39
- ┌─────────────────────────────────────────────────────────────────┐
40
- │ Substrates │
41
- │ SQLite (~/.local/state/mu/mu.db) · tmux/herdr panes · jj/sl/git│
42
- └─────────────────────────────────────────────────────────────────┘
43
- ```
44
-
45
- ## The ops log (read this first)
46
-
47
- Everything below rests on one append-only table. Read this section
48
- before the rest of the file.
49
-
50
- **Capture is a trigger, not a call site.** Every INSERT / UPDATE /
51
- DELETE on a portable table (`workstreams`, `tasks`, `task_edges`,
52
- `task_notes`) is recorded as an **op** by a SQLite trigger, in the
53
- SAME transaction as the mutation. No SDK function decides to record
54
- history, so no call site can forget to, and the log cannot drift from
55
- the data — not even on power loss.
56
-
57
- **The log is the record; the tables are the materialized view.**
58
- Reads hit the tables, as ordinary indexed SQL. This is not
59
- event-sourcing-on-read: nothing replays the log to answer a query.
60
- The log is what the tables are *derived from* and what they can be
61
- rebuilt from.
62
-
63
- ```
64
- mu task close t3
65
- │
66
- ▼
67
- UPDATE tasks SET status=... ──┬──► tables (what reads see)
68
- └──► ops (what everything else reads)
69
- one transaction
70
- ```
71
-
72
- **Four features are queries or replays over that one log:**
73
-
74
- - **history** — `mu log` is a typed reader over `ops`.
75
- - **undo** — `mu undo <group>` emits the inverse ops for one group.
76
- - **sync** — a machine appends its own ops to a JSONL segment in a
77
- shared folder and applies each peer's from a watermark.
78
-
79
- **Ops are semantic partial updates.** An UPDATE op carries only the
80
- columns that actually changed. That is what makes per-field merge
81
- free: two machines editing different fields of one task both keep
82
- their edit, with no extra bookkeeping. A full-row payload would
83
- silently regress this to row-level last-writer-wins.
84
-
85
- **The cost, plainly:** one mechanism means one capture bug breaks
86
- history, undo and sync at once, silently. That is why
87
- `mu doctor --deep` rebuilds the log into a temp DB and diffs it
88
- field-by-field against the live tables, and why the cheap tier
89
- (every live row has at least one op naming it) runs by default.
90
-
91
- Ordering across machines comes from the **HLC** on every op — a
92
- hybrid logical clock serialized so that bytewise `ORDER BY hlc` is
93
- causal order. Machine-local tables (`agents`, `vcs_workspaces`) have
94
- no capture triggers: their contents are pane ids and absolute paths,
95
- which mean nothing on another machine. Their changes are recorded as
96
- log-only events via `emitEvent`.
97
-
98
- Per-module detail is in [§ Modules](#modules-actual-src-layout).
99
-
100
- ---
101
-
102
- ## The task DAG
103
-
104
- mu's coordination model is a **directed acyclic graph of tasks**. It
105
- is the central organizing primitive, not a sidecar feature: without
106
- it mu is just an agent runner.
107
-
108
- ### Model
109
-
110
- - **Tasks** are nodes with mandatory `impact (1-100)` and `effort_days`.
111
- `ROI = impact / effort` drives prioritization.
112
- - **One edge type**: `blocks`. `A → B` means A must close before B can
113
- start. Multiple edge types create ambiguity that defeats the purpose.
114
- - **Status lifecycle**: `OPEN → IN_PROGRESS → CLOSED`. Status alone
115
- decides edge satisfaction: any `CLOSED` task satisfies its blockers.
116
- - **Substate** qualifies status and never touches edges:
117
- `OPEN/todo|parked`, `IN_PROGRESS/active`,
118
- `CLOSED/done|rejected|wontfix|duplicate|superseded`. Rule: **store intent,
119
- derive graph facts** — ready and blocked stay derived from edges.
120
- `OPEN/parked` is excluded from `ready` but stays in `goals`.
121
-
122
- #### Substate integrity: deferred FK + apply-time pair repair
123
-
124
- The legal pairs live in the `task_substates` lookup table, seeded from
125
- code on every open (machine-local, never synced). `tasks` has a
126
- composite FK `(status, substate) → task_substates`,
127
- `DEFERRABLE INITIALLY DEFERRED`, which is the only guard on the pair.
128
- It is checked at COMMIT, so apply's one-field-at-a-time UPDATEs pass as
129
- long as the pair is legal when the transaction ends. That is why
130
- `applyOp` wraps each task put in `db.transaction` (and undo already runs
131
- in one): in autocommit the first per-field UPDATE would fail the FK.
132
-
133
- Every lifecycle write sets `status` and `substate` in one UPDATE, so
134
- one op carries both under one HLC. A pair can still split across ops:
135
- a status-only op from a v10 peer, an unknown substate from a newer
136
- peer, or a concurrent close and park on two machines. Before commit,
137
- `repairTaskPair` (`src/apply.ts`) sets the substate to the one implied
138
- by the newest op in the LOG that writes a substate, resolved against
139
- the row's current status, and falls back to the status default when
140
- that pair is illegal. Substate is therefore NOT per-field LWW.
141
- Reading the log instead of the row makes the result a pure function of
142
- the op set plus the row's status, so every peer converges on the same
143
- row whatever the arrival order. For the same reason the repair records
144
- **no op**: each peer computes the identical repair from the identical
145
- log. Recording one would only add a second writer racing the first.
146
- - **Notes** are append-only per task; survive across LLM sessions and
147
- agent restarts. The fix for context loss at the *task* level rather
148
- than the agent level.
149
-
150
- ### Built-in queries (SQL views)
151
-
152
- | View | Returns |
153
- | --------- | ---------------------------------------------------------------------- |
154
- | `ready` | `OPEN/todo` tasks with no unresolved blockers — work that can start *now* |
155
- | `blocked` | OPEN tasks waiting on something |
156
- | `goals` | Tasks with no dependents — graph endpoints |
157
-
158
- Agents and humans both query these views directly via `mu sql`. No
159
- separate query layer.
160
-
161
- ### Parallel-track detection
162
-
163
- The Tracks section in `mu state` / bare `mu` runs union-find on the
164
- graph to identify independent subtrees that can be assigned to
165
- different agents in parallel.
166
-
167
- **Diamond patterns get merged automatically.** If two roots share a
168
- prerequisite, they collapse into one track — preventing two agents
169
- from colliding on the shared dependency:
170
-
171
- ```
172
- Independent (2 tracks): Diamond (1 merged track):
173
-
174
- goal_a goal_b goal_a goal_b ← Spawn 2 agents
175
- | | \ /
176
- task_a task_b shared ← Spawn 1 (would
177
- | | | collide otherwise)
178
- leaf_a leaf_b leaf
179
- ```
180
-
181
- The graph algorithm decides, not the LLM.
182
-
183
- ### Claim protocol via ambient identity
184
-
185
- `mu task claim <task>` resolves who is claiming, then atomically:
186
-
187
- 1. Sets `tasks.owner = <agent_name>`
188
- 2. Flips `tasks.status = IN_PROGRESS`
189
- 3. Emits a `task.claim` op carrying the resolved `actor`
190
-
191
- Identity resolution is a two-rung ladder:
192
-
193
- 1. **`$MU_AGENT_NAME`** — injected into the pane's environment at
194
- spawn. Backend-independent, and more reliable than scraping even on
195
- tmux.
196
- 2. **`backend.currentAgentName()`** — the mux backend's fallback. On
197
- tmux that is `display-message -p '#{pane_title}'`; on herdr the
198
- agent name is registered with the mux and looked up by pane id.
199
-
200
- The fallback earns its place because **adopted** panes
201
- (`mu agent adopt`) predate the env injection and carry only a title.
202
- Because it lives behind `MuxBackend`, the call site has no idea which
203
- multiplexer it is running under.
204
-
205
- When the tmux fallback runs, it reads pane title, **not** `#W` (window
206
- name). Window names come from the `tab:` frontmatter and may group
207
- multiple agents in one window.
208
-
209
- Two agents can't claim the same task — atomic CAS in SQLite. The
210
- agent doesn't have to know its own name.
211
-
212
- ### Scoped subtree views
213
-
214
- `mu task tree <id>` and task queries show the portion of the graph
215
- reachable from a task. Subtree scoping is a `WHERE` clause, which is
216
- what makes recursive delegation work: a sub-orchestrator inspects its
217
- slice without an LLM inferring the scope.
218
-
219
- ---
220
-
221
- ## Mux session topology
222
-
223
- mu organizes agents into **one mux session per workstream**. One mu
224
- workstream = one mux session = one `session_id` partition in
225
- `~/.local/state/mu/mu.db`. Multiple workstreams on one machine coexist as
226
- independent mux sessions, fully isolated.
227
-
228
- A **mux session** is a tmux session on the tmux backend and a herdr
229
- *workspace* on the herdr backend (herdr's own "session" is
230
- server-level — one socket — and is the wrong granularity). The
231
- three-level shape is identical either way:
232
-
233
- | mu term | tmux | herdr |
234
- | ---------------- | ------------ | ----------------- |
235
- | **mux session** | session | workspace |
236
- | **window** | window | tab |
237
- | **pane** | pane (`%15`) | pane (`w1:p1`) |
238
-
239
- The diagram below uses the tmux spelling.
240
-
241
- ```
242
- tmux session: mu-auth-refactor (one mu workstream)
243
- ┌───────────────────────────────────────────────────────────┐
244
- │ Window: Backend Window: Review │
245
- │ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │
246
- │ │ worker-1 │ │ worker-2 │ │ reviewer-1 │ │
247
- │ │ (pi) │ │ (pi) │ │ (pi, role=read-only) │ │
248
- │ └──────────┘ └──────────┘ └────────────────────────┘ │
249
- │ │
250
- │ Window: mu-orchestrator │
251
- │ ┌────────────────────────────────────────────────────┐ │
252
- │ │ pi (you, with mu extension loaded) │ │
253
- │ └────────────────────────────────────────────────────┘ │
254
- └───────────────────────────────────────────────────────────┘
255
-
256
- tmux session: mu-migration-2024q4 (different workstream)
257
- ┌───────────────────────────────────────────────────────────┐
258
- │ ...different agents, different graph, no overlap │
259
- └───────────────────────────────────────────────────────────┘
260
- ```
261
-
262
- ### Concretely
263
-
264
- - **First `mu agent spawn` creates the tmux session** if you're not already
265
- in one. Default name `mu-<auto>`. Override with `mu workstream init <name>` or
266
- `MU_SESSION=<name>`.
267
- - **Subsequent operations** in the same shell (or any child shell with
268
- `MU_SESSION_ID` set) target the same session.
269
- - **`tmux attach -t mu-<workstream>`** → attach to the whole
270
- workstream's mux session (herdr: `herdr workspace focus <id>`)
271
- - **`mu agent list`** is scoped to one workstream — the current one by
272
- default; `mu agent list -w <workstream>` for another
273
- - **`session_id`** is the partition key on the `agents` table
274
- - **`mu doctor`** warns about cross-session pollution (orphan panes,
275
- ghost rows, agents whose mux session no longer exists)
276
-
277
- ### The `activeMux()` seam
278
-
279
- Every call site reaches its multiplexer through
280
- `(await activeMux()).<method>()`. Nothing outside `src/mux/` names a
281
- backend, and `MU_MUX` is therefore load-bearing: an unknown value
282
- fails the invocation rather than quietly running on tmux.
283
-
284
- Call sites are classified, and the classification is the seam's real
285
- content. With a second backend in play, "no reachable multiplexer"
286
- stops being a broken-box edge case and becomes routine, so each site
287
- has to have already decided what it means:
288
-
289
- | | Behaviour on `NoMultiplexerError` | Examples |
290
- | --- | --- | --- |
291
- | **Load-bearing** | propagate; `handle()` maps it to exit 5 | spawn, send, read, kill, adopt, kick, reconcile, session create/destroy |
292
- | **Best-effort** | `try`/`catch`, degrade | actor identity, pane titles, pane borders, workstream listings, liveness polls, TUI attach |
293
-
294
- `resolveWorkerIdentity()` in `src/tasks/claim.ts` is the canonical
295
- best-effort shape. `reconcile()` is pointedly load-bearing: treating
296
- an unreachable mux as "zero panes exist" would prune every registered
297
- agent as a ghost and reap its in-progress tasks.
298
-
299
- Three things that look like caller concerns belong to the backend,
300
- because each would otherwise hardcode a tmux string in a place a herdr
301
- user can see: `attachHint()` / `attachCommands()` (the printed recipe
302
- and the TUI's executed argv), `healthCheck()` (version + ambient env
303
- facts as DATA — `mu doctor` owns rendering), and
304
- `paneNotFoundNextSteps()` (borrowed by `PaneNotFoundError` from the
305
- backend that raised it).
306
-
307
- `src/tmux.ts` survives as a re-export for genuinely tmux-only
308
- concerns: the `MU_TMUX_SOCKET` test-isolation seam and the shared
309
- `sleep` / `setSleepForTests` poll seam.
310
-
311
- ### The spawn seam: create-and-run vs create-then-start
312
-
313
- Backends disagree on something structural: tmux creates a pane **and**
314
- runs a command in one atomic call, while herdr has no create-and-run
315
- form at all. Its creation verbs always start a plain shell, and `agent
316
- start` requires an already-existing pane at its interactive prompt.
317
-
318
- That difference is expressed as a **capability**, not a name check:
319
-
320
- | | tmux | herdr |
321
- | --- | --- | --- |
322
- | `NewWindowOptions.command` etc. | carries the command | **refused** if non-empty |
323
- | `startAgentInPane()` | not implemented | implemented |
324
- | liveness / readiness wait | mu checks pane life, startup errors, then murmur readiness when available | the mux blocks until ready |
325
-
326
- `spawnAgent` branches on `mux.startAgentInPane !== undefined`. When it
327
- is absent (tmux), the command rides along on the creation verb. The
328
- liveness check verifies that the pane survived and scans for startup
329
- errors; when murmur is available, readiness also waits for the pane's
330
- `@murmur_pane_state`. Without murmur, readiness is skipped and the first
331
- send uses its input-timing checks. When `startAgentInPane` is present,
332
- mu creates the pane bare, calls it, and skips the tmux checks because
333
- the backend returns only after the agent is ready.
334
-
335
- The creation verbs **refuse** a non-empty command on a backend that
336
- cannot honour it rather than dropping it silently: a dropped command
337
- leaves an empty shell mu believes hosts an agent, which is the worst
338
- available failure mode. Both the refusal and every step-2 failure route
339
- through `rollbackSpawn`, so no path records an agent row for a pane with
340
- nothing running in it.
341
-
342
- Both the pane-creation call and the agent start sit **outside** the
343
- per-session spawn lock's slow half, so a parallel fan-out still
344
- parallelises.
345
-
346
- ### Window vs pane
347
-
348
- By default each agent gets its own **window** (a tmux window, a herdr
349
- tab — what most terminals call a "tab"), named after the agent's
350
- `tab:` value; default, the agent name. Agents sharing a `tab:` value
351
- share a window with multiple panes.
352
-
353
- Claim/identity depends on the agent's name, not the window name:
354
- every agent pane gets `$MU_AGENT_NAME` in its environment on spawn,
355
- plus a **pane title** as fallback, regardless of how panes are grouped.
356
- The title carries durable mu context only (agent identity plus owned tasks),
357
- not sampled runtime status. Mu enables the top border but leaves
358
- `pane-border-format` to the user's tmux configuration. The canonical tmux
359
- protocol is in the comment block at the top of `src/mux/tmux.ts`.
360
-
361
- ### Why one session per workstream
362
-
363
- - **Visual co-location.** `tmux a -t mu-auth-refactor` shows the whole
364
- crew at once.
365
- - **Isolation.** Kill the tmux session = kill the workstream. No
366
- leaked panes.
367
- - **Detach and reattach freely.** The crew survives a closed laptop.
368
- - **The claim protocol falls out.** Pane title = agent name =
369
- ownership identity, with no configuration.
370
- - **Multiple workstreams coexist.** `session_id` partitioning keeps
371
- the auth-refactor crew out of the migration crew.
372
-
373
- ---
374
-
375
- ## Dual-audience CLI contract
376
-
377
- The top-level `mu` binary serves two audiences without creating a
378
- second namespace.
379
-
380
- - **Human entrypoint:** bare `mu` launches the read-only TUI when
381
- `process.stdout.isTTY === true`. It loads every workstream on the
382
- machine and chooses the initial active tab with the shared focus
383
- ladder (`$MU_SESSION` → tmux session name → cwd inside a workspace
384
- → cwd equal to a workspace's VCS-derived project root, with latest
385
- activity breaking project-root ties → tab 0). If no
386
- workstreams exist, it prints `mu --help` plus the one-paste
387
- `Get started: mu workstream init <name>` hint and exits 0.
388
- - **Agent / script entrypoint:** typed verbs remain the API, with
389
- `--json` on reads and structured errors. Bare `mu` on non-TTY
390
- stdout (pipes, redirects, CI, most harnessed agent calls) prints
391
- help instead of entering Ink. `MU_NO_TUI=1` forces that same path
392
- for scripted use inside an otherwise-interactive terminal.
393
- - **`mu state`** is the static state card; `mu state --tui` is the
394
- explicit TUI selector. The split is stdout-is-TTY plus the opt-out
395
- env var, not a second command namespace.
396
-
397
- The TUI import stays dynamic (`await import("./cli/tui/index.js")`).
398
- No module outside `src/cli/tui/` may statically import ink/react, or
399
- the static CLI bundle pulls the TUI graph into help/version/json
400
- paths.
401
-
402
- ---
403
-
404
- ## TUI architecture
405
-
406
- The TUI is a 10-card live-updating dashboard on `ink`. It is
407
- **read-only** and lives entirely under `src/cli/tui/` — CLI verbs
408
- remain the canonical mutation API. The TUI yanks `mu` commands; the
409
- operator runs them.
410
-
411
- ### Cluster shape
412
-
413
- `src/cli/tui/` is the only place ink/react are imported.
414
-
415
- ```
416
- src/cli/tui/
417
- ├── index.ts # runTui entrypoint; alt-screen + mouse-mode lifecycle
418
- ├── escapes.ts # pure ANSI byte sequences (alt-screen, SGR mouse mode)
419
- ├── app.tsx # <App> root: popup state machine, global keymap, tabs
420
- ├── state.ts # useDashboardSnapshot poll-loop hook (fast/slow tier split)
421
- ├── keys.ts # pure dispatchGlobalKey + dispatchPopupKey + shouldSwallowGlobalKey
422
- ├── keymap-spec.ts # canonical keymap source-of-truth (drives help overlay + dispatch)
423
- ├── mouse.ts # vendored SGR mouse parser + double-click + useMouse hook
424
- ├── yank.ts # clipboard probe + write (pbcopy/wl-copy/xclip/xsel/clip.exe + OSC-52)
425
- ├── tuicr.ts # `t` shortcut: alt-screen handoff to tuicr -r <sha>
426
- ├── layout.ts # responsive multi-column dashboard + per-card row budgets
427
- ├── columns.ts # column-aligned row layout with protect/clip clipping
428
- ├── wrap-ansi.ts # ANSI-aware visual-width line wrapper + SGR close-on-end
429
- ├── format-helpers.ts # shared TUI formatters (relTime, sinceClaim, ROI)
430
- ├── titled-box.tsx # rounded border with section-header / bottomLabel inset
431
- ├── popup-shell.tsx # popup outer chrome (cyan TitledBox)
432
- ├── list-row.tsx # centralised non-selected row primitive
433
- ├── padded-rows.tsx # per-card body padder
434
- ├── status-bar.tsx # bottom status bar (mode + active ws + tick + footer flash)
435
- ├── tab-strip.tsx # multi-workstream tab switcher (N≥2)
436
- ├── tab-strip-layout.ts # pure window-around-active layout helper
437
- ├── help.tsx # ?/F1 keymap overlay (scrollable on short panes)
438
- ├── use-popup-filter.tsx # shared '/' substring filter hook + applyFilter + FilterPrompt
439
- ├── use-status-filter.tsx # status toggles (o/i/c) + substate toggles (p/w) for task-list popups
440
- ├── use-notes-drill.ts # shared notes-drill memo (5 task popups consume it)
441
- ├── use-popup-action-queue.ts # consume mouse PopupAction queue once per render
442
- ├── cards/ # 10 dashboard glance cards (one slot each)
443
- │ ├── _placeholder.tsx # shared loading/empty body wrapper
444
- │ └── {agents,tracks,ready,log,workspaces,inprogress,blocked,recent,commits,doctor}.tsx
445
- └── popups/ # fullscreen drill-down popups
446
- ├── {agents,tracks,ready,log,workspaces,inprogress,blocked,recent,commits,doctor}.tsx
447
- ├── dag.tsx # keybind-only on `g`: full task DAG forest
448
- ├── all-tasks.tsx # keybind-only on `t`: sortable / filterable list of every task
449
- ├── task-list-popup.tsx # shared TaskListPopup scaffold (ready/inprogress/recent/blocked config-only)
450
- ├── drill.tsx # DrillScrollView + useDrillKeymap (shared scrollable-text leaf)
451
- ├── task-detail.tsx # TaskDetailDrill (notes timeline; the recursion sink)
452
- ├── cursor-row.tsx # selected-row primitive (delegated to from list-row)
453
- ├── scroll.ts # pure applyCursor / applyScroll / clampScrollTop / isNavAction
454
- ├── viewport.ts # popupViewport + POPUP_CHROME_ROWS + POPUP_VIEWPORT_FLOOR
455
- └── show-loader.ts # subprocess-preserving show loader (avoids blank-flash mid-refetch)
21
+ │ agents/ mux/ ctl/ · tasks/ tracks · vcs/ workspace/ · ops log │
22
+ │ (capture, apply, undo, sync, drift) │
23
+ └───────────────┬──────────────────┬──────────────────┬───────────┘
24
+ ▼ ▼ ▼
25
+ SQLite (~/.local/state/mu/mu.db) · tmux/herdr panes · jj/sl/git
26
+ pi agents: control socket per agent
456
27
  ```
457
28
 
458
- ### State machine
459
-
460
- `<App>` is the root. It owns:
461
-
462
- - **Popup state** — `null` (dashboard) or one of the popup ids.
463
- Single-popup invariant; `Esc` / `q` returns to dashboard.
464
- - **Card visibility** — `Record<CardId, boolean>` toggled by `0`-`9`.
465
- - **Tick rate** — fast tick interval (1s default; adjustable with
466
- `+` / `-` / `=` / `0`).
467
- - **Active workstream tab** — index into the launch-time workstream
468
- set; `Tab` / `Shift-Tab` cycles when N≥2. The fast tick checks that
469
- each row still exists. A torn-down workstream keeps its stable tab
470
- position and renders dimmed with strikethrough until the same name is
471
- recreated. A torn-down single workstream also renders its normally hidden
472
- tab so the state remains visible.
473
- - **Footer flash** — transient status-bar message (yank confirm,
474
- tuicr exit, etc.).
475
-
476
- Popups own their own local state (cursor, filter query, drill mode,
477
- local modes like Workspaces' `list` / `commits` / `show`). Popups
478
- NEVER mutate App-level state — they receive a read-only props bag
479
- (`snapshot`, `db`, `workstream`, `fastTickNonce`, `slowTickNonce`,
480
- `yank`, `onClose`, `onModeChange`, `onFilterEditingChange`,
481
- `onFooter`).
482
-
483
- ### Polling tiers (fast vs slow)
484
-
485
- The poll loop in `state.ts` (`useDashboardSnapshot`) splits work
486
- into two intervals:
487
-
488
- - **Fast tick** (default 1s, adjustable): SQL-only. `loadWorkstreamSnapshotFast`
489
- reads tasks, tracks, workspace registry rows, recent events,
490
- workspace orphans. Cheap (~p50 <1ms).
491
- - **Slow tick** (10s, hardcoded `SLOW_TICK_MS`): subprocess-backed.
492
- `loadWorkstreamSnapshotSlow` runs tmux liveness, per-workspace
493
- dirty status, recent project commits, and the Doctor summary.
494
- Expensive (~p50 hundreds of ms).
495
-
496
- The last slow result is merged into every fast render via
497
- `mergeSnapshotFastSlow`, so cards never flicker through a loading
498
- state. `r` / `F5` triggers both intervals immediately. A tab switch
499
- clears the slow cache and eager-fetches, so cards are fresh within
500
- 1s. The pure `snapshotKey` / `snapshotKeyString` guard returns the
501
- SAME `data` reference across no-op ticks so React's diffing
502
- short-circuits.
503
-
504
- ### Render geometry
505
-
506
- Responsive layout lives in `layout.ts`:
507
-
508
- - **Breakpoint-driven columns**: stacked below 120 cols; 2 columns
509
- at 120; 3 at 180; 4 at 240. Stream cards (Commits, Activity log)
510
- trail; slot 0 (Commits) trails last.
511
- - **Per-card row budgets**: each visible card gets a `min` /
512
- `max` / `chrome` budget; the allocator distributes available
513
- rows so a noisy list can't crowd siblings. Overflow surfaces as
514
- `+N more · Shift+N` inset into the card's bottom border.
515
- - **Cull-on-tight-pane**: when even minimum budgets don't fit,
516
- cull cards by priority (Doctor → Recent → Workspaces → …) and
517
- show `+N cards hidden · resize taller` at the bottom. Outer
518
- height clip is the safety net.
519
-
520
- Text rendering is ANSI-aware: `wrap-ansi.ts` wraps by visual width
521
- (`string-width`) and closes open SGR state on both the early-return
522
- and end-of-loop paths, so coloured fragments without a trailing
523
- `\x1b[0m` can't bleed into adjacent ink chrome. Drill bodies are
524
- space-padded to exact box width, or ink's `wrap="truncate"` ANSI
525
- miscount eats the right-border glyph.
526
-
527
- ### Read-only invariant + the `tuicr` escape
528
-
529
- Every popup row exposes one canonical `mu` command via `y`. `yank.ts`
530
- probes for a clipboard backend (pbcopy / wl-copy / xclip / xsel /
531
- clip.exe) and falls back to OSC-52 over stderr if none is found.
532
- The command goes to the clipboard; the operator runs it.
533
-
534
- The one escape is `t` inside any `git show` drill: `tuicr.ts` writes
535
- `ALT_SCREEN_EXIT` + the mouse-mode disable bytes, exec's
536
- `tuicr -r <sha>` in the project root / workspace cwd as a foreground
537
- subprocess, then restores both on exit. A handoff, not an in-process
538
- mutation.
539
-
540
- The read-only pledge is in `docs/ROADMAP.md`'s anti-feature list; a
541
- TUI gesture that wants to mutate state needs a roadmap entry first.
542
-
543
- ### Mouse + keyboard
544
-
545
- Mouse support is opt-in via SGR mouse mode (`escapes.ts` provides
546
- the enable/disable bytes). `mouse.ts` parses `ESC[<button;x;y;M/m`
547
- from stdin, detects double-clicks, and exposes a `useMouse()` hook.
548
-
549
- Keyboard dispatch flows through pure helpers in `keys.ts`:
550
- `dispatchGlobalKey` (dashboard mode), `dispatchPopupKey` (popup
551
- mode), and `shouldSwallowGlobalKey` (which keys popups consume
552
- and do not bubble to the global dispatcher). The keymap source-of-
553
- truth lives in `keymap-spec.ts` so the help overlay and the
554
- dispatcher can never drift apart.
555
-
556
- Double-click on a card emits `{kind: "setCursor", index}` followed
557
- by `{kind: "drill"}` through `use-popup-action-queue.ts`, which
558
- consumes one action per render (so the cursor update lands before
559
- the drill resolves the focused row).
560
-
561
- ### Drill recursion
562
-
563
- List popups drill via `Enter` into entity-specific leaves. The
564
- central primitive is `popups/drill.tsx`'s `DrillScrollView` (a
565
- scrollable text leaf shared by Workspaces' git-show, Agents'
566
- scrollback, the Activity log payload drill, and the Doctor
567
- remediation drill). Task popups drill into
568
- `popups/task-detail.tsx`'s `TaskDetailDrill` (the notes timeline);
569
- the Tracks popup chains track → task list → TaskDetailDrill via
570
- the same leaf.
571
-
572
- `useDrillKeymap` owns the scroll state, takes an optional `resetKey`
573
- (identity change resets scroll; a tick-driven body refresh preserves
574
- it) and an optional `onScrollChange` (the DAG popup's focused-root
575
- tracking), and shares ANSI-aware wrapped body metadata so the
576
- scroll-clamp math and the painter can't desync.
577
-
578
- Subprocess-backed drills (Workspaces git-show, Agents scrollback,
579
- Commits show) use `popups/show-loader.ts`, which preserves the prior
580
- body during a refetch — no blank flash on the slow tick.
581
-
582
- ### Test seam
583
-
584
- See `test/README.md`. The seam is `test/_ink-render.ts`'s
585
- `createInkInputStream` + `createInkCaptureStream` + `simulateInput` +
586
- `latestRenderedFrame`: mount a popup or `<App>` into a CaptureStream,
587
- drive keystrokes, assert against the visible frame and spy callbacks.
588
- Source-greps are for narrow structural guards only (App ↔ keys ↔
589
- layout wiring; slot ↔ keymap glue), never behaviour.
590
-
591
- ---
592
-
593
- ## CLI / SDK surface
594
-
595
- Every user-visible operation is a typed SDK function plus a thin
596
- Commander wrapper. `src/cli.ts` and the verb-namespace files under
597
- `src/cli/` are the canonical verb surface — no generated registry, no
598
- DSL, no separate operation schema. Programmatic callers import the
599
- same SDK functions from `src/index.ts`; agents and scripts compose
600
- CLI verbs with `--json`.
601
-
602
- The boundary rule: external surfaces accept operator-facing names
603
- (workstream, task id, agent name); internal helpers resolve those to
604
- surrogate INTEGER ids once and then stay on ids. See
605
- [§ Surrogate-PK + SDK-boundary discipline](#surrogate-pk--sdk-boundary-discipline).
606
-
607
- ---
608
-
609
- ## Reconciliation
610
-
611
- `mu agent list` always reconciles the registry against mux reality before
612
- returning. Two steps, in order:
613
-
614
- 1. **Prune ghosts.** For each `agents` row, if its `pane_id` no longer
615
- exists in the mux, delete the row.
616
- 2. **Surface orphans.** For each pane in the workstream's mux session
617
- that has no matching `agents` row but whose pane title looks like
618
- an agent name, add it to the orphans list. **Do not auto-adopt** —
619
- `mu agent list` shows orphans under a separate "(orphans)" section and
620
- the user runs `mu agent adopt %15 [--name X]` to claim them.
621
-
622
- `src/reconcile.ts` is the only implementation. Key properties:
623
-
624
- - **Reality wins**: the mux is the source of truth for what panes
625
- exist. Reconciliation closes the gap on every `mu agent list`.
626
- - **Agent state is reported, not scraped or persisted.** `src/agent-state.ts`
627
- probes a pi agent's control socket ([§ Control socket](#control-socket)),
628
- in parallel for all pi agents. For other CLIs it reads herdr's
629
- `paneStatus()` on herdr. On tmux, one `list-panes -a`
630
- reads `@murmur_pane_state` and `@murmur_pane_since` for every local
631
- agent. Only agents without a local option use `murmur status --json`;
632
- that result is cached for 10 seconds. See murmur's
633
- [stable contract](https://github.com/mu-crew/murmur/blob/main/ARCHITECTURE.md#contract).
634
- - **Mapping:** murmur `working` → `busy`, `blocked` →
635
- `needs_permission`, and `idle` / `done` / `crashed` → `needs_input`.
636
- herdr supplies mu's runtime states directly.
637
- - **Absence is `unknown`.** Readings carry reasons such as `murmur not
638
- installed`, `murmur pi extension not linked`, `murmur has no row`,
639
- `ambiguous: N hosts`, `remote snapshot stale`, and `pane gone`.
640
- - **`agents.status` is deprecated.** Inserts write `spawning` to satisfy
641
- the schema. No code updates or reads the column as runtime state.
642
- - **No silent adoption**: orphans are reported, never claimed.
643
- - **`mu doctor` calls the same routine** and reports counts.
644
-
645
- ---
646
-
647
- ## Control socket
648
-
649
- mu drives a pi agent through a unix socket served by mu's own pi
650
- extension inside the agent's interactive pi. The pane stays pi's TUI;
651
- a human attached to it shares the same session. No `pi --mode rpc`, no
652
- screen scraping, no daemon: the server lives and dies with the pi
653
- process.
654
-
655
- **Path derivation** (`src/ctl/path.ts`). `ctlSocketPath(ws, agent)` is
656
- `<state dir>/sock/<ws>/<agent>.sock`, where the state dir is
657
- `dirname($MU_DB_PATH)` or the default. Past 103 bytes (macOS
658
- `sun_path`) it becomes `<state dir>/sock/h/<sha1(ws/agent)[:16]>.sock`.
659
- Nothing is stored: spawn injects the path as `MU_CTL_SOCK`, and every
660
- later verb derives it again.
661
-
662
- **Protocol v1** (`src/ctl/protocol.ts`, imported standalone by the
663
- extension). JSON lines, one request per connection. Every reply carries
664
- `v: 1`; the client throws `CtlVersionError` on anything else.
665
-
666
- | op | does |
29
+ ## Subsystems
30
+
31
+ **The ops log.** Every write to a portable table (`workstreams`,
32
+ `tasks`, `task_edges`, `task_notes`) is recorded as an op by a SQLite
33
+ trigger in the same transaction. The tables are the materialized view
34
+ reads hit; the log is the record. History (`mu log`), undo, sync,
35
+ rebuild and drift checks are all queries or replays over it. Ops carry
36
+ only changed columns and an HLC, which makes per-field merge across
37
+ machines free. Read this first:
38
+ [architecture/ops-log.md](architecture/ops-log.md).
39
+
40
+ **The task DAG.** Tasks are nodes with impact and effort; the one edge
41
+ type is `blocks`. Status decides edge satisfaction, and substate
42
+ qualifies status without touching edges. The `ready`, `blocked` and
43
+ `goals` views, union-find tracks with diamond merge, and an atomic
44
+ claim keyed on `$MU_AGENT_NAME` coordinate the crew:
45
+ [architecture/dag.md](architecture/dag.md).
46
+
47
+ **Multiplexer and reconciliation.** One workstream is one mux session
48
+ (a tmux session or a herdr workspace). Call sites reach the backend
49
+ only through `activeMux()` and branch on capabilities, never on the
50
+ backend name. `mu agent list` prunes ghost rows and surfaces orphan
51
+ panes against mux reality. Runtime agent state is read on demand, never
52
+ stored: the control socket for pi agents, herdr's native status on
53
+ herdr, and murmur for other CLIs on tmux:
54
+ [architecture/mux.md](architecture/mux.md).
55
+
56
+ **The control socket.** mu's pi extension serves a unix socket inside
57
+ each spawned pi. Send, `--fresh`, wait, abort and state for pi agents
58
+ go through it, exactly, with no screen reading and no fallback to
59
+ pasting. Remote agents use an ssh socket forward printed by
60
+ `mu agent remote-env`. The tmux bracketed-paste path serves the
61
+ [cases listed there](architecture/control-socket.md#no-silent-fallback).
62
+
63
+ **Sync.** With `MU_SYNC_DIR` set, every `mu` invocation ingests peer
64
+ JSONL segments before the verb and flushes its own ops after it,
65
+ through `handle()`. There is no daemon and no network code:
66
+ [architecture/sync.md](architecture/sync.md).
67
+
68
+ **The TUI.** Bare `mu` on a TTY opens a read-only ink dashboard of 10
69
+ cards with drill-down popups. It polls SQL every second and
70
+ subprocesses every 10 seconds, yanks `mu` commands instead of running
71
+ them, and is the only code that imports ink:
72
+ [architecture/tui.md](architecture/tui.md).
73
+
74
+ **CLI and SDK surface.** Each operation is a typed SDK function plus a
75
+ thin Commander wrapper. Public functions take operator-facing names and
76
+ resolve them to surrogate ids exactly once. Typed errors map to exit
77
+ codes 0-5. The same page covers testing layers and packaging:
78
+ [architecture/sdk.md](architecture/sdk.md).
79
+
80
+ ## Module map
81
+
82
+ `src/` is flat at the root, with one level of cohesive clusters.
83
+ Cluster files import from neighbours and root modules, never from the
84
+ hub that re-exports them. `src/tasks.ts`, `src/agents.ts`, `src/mux.ts`,
85
+ `src/vcs.ts` and `src/workspace.ts` are re-export hubs.
86
+
87
+ ### Storage and the ops log
88
+
89
+ | Module | Responsibility |
667
90
  | --- | --- |
668
- | `hello` | identity plus `ops` (served ops) and `extVersion` (mu version the extension was built from) |
669
- | `status` | `state` (`busy` / `idle` / `needs_input`), `since`, `runs`, `pending` |
670
- | `send` | `pi.sendUserMessage`; when busy, `mode` `steer` or `followUp` (default) |
671
- | `wait` | resolves on pi's `agent_settled` after `afterRuns`, with the run's `lastText` |
672
- | `abort` | `ctx.abort()` (pi's Esc) |
673
- | `fresh` | new session + prompt as one operation via the internal `/mu-fresh` command; replies once the new run starts; refused with `busy` unless `force` |
674
-
675
- An op the extension does not serve returns `unknown op: <op>` with its
676
- `ops`; mu raises `AgentExtensionOutdatedError` (exit 4, next steps
677
- `/reload` via mux or respawn), and `mu doctor` flags an `extVersion`
678
- older than the installed mu.
679
-
680
- **Extension lifecycle** (`extension/mu-pi.ts`). Inert unless
681
- `MU_CTL_SOCK` is set. pi re-creates extension runtimes per session, so
682
- the server, run counters and an in-flight `fresh` live in a
683
- process-global map (`Symbol.for("mu.pi.ctl")`). Session replacement
684
- (`new` / `resume` / `fork` / `reload`) keeps the socket; quit closes it.
685
- Binding guards against stealing: if the path already answers (a nested
686
- `pi -p` inherited `MU_CTL_SOCK`), the extension leaves it alone. It
687
- listens on a private name and hard-links it into place, records the
688
- inode, and unlinks the public path only if that inode is still its own.
689
- On `session_start` a server whose path was deleted or replaced closes
690
- and rebinds, which is how `/reload` recovers a lost file.
691
-
692
- **No silent fallback** (`src/agents/transport.ts`). A pi agent
693
- (`expectsCtl`) whose socket does not answer is `AgentCtlUnreachableError`
694
- for send and abort, and `unknown` with reason `ctl missing` /
695
- `ctl refused` for state. mu never pastes into it or asks murmur instead.
696
- The mux paste path serves non-pi CLIs, adopted panes without ctl, slash
697
- commands (`sendUserMessage` does not run them), and explicit `--via mux`.
698
-
699
- **Remote agents.** The user's `ssh` command carries
700
- `-L <local derived path>:<remote path>` plus `MU_CTL_SOCK=<remote path>`
701
- in the remote env; `mu agent remote-env` prints both and runs nothing.
702
- mu always connects to the local path, so local and remote share one
703
- transport. ssh leaves the local socket file behind when it dies; it
704
- probes `refused`, and close or the reaper deletes it.
705
-
706
- ---
707
-
708
- ## Modules (actual src/ layout)
709
-
710
- Mostly-flat `src/`: root `.ts` modules plus cohesive subclusters
711
- (`src/agents/`, `src/tasks/`, `src/mux/`, and `src/cli/` wrappers with
712
- their own `src/cli/tasks/` and `src/cli/tui/` sub-clusters). Cluster
713
- files import from neighbours and root substrate modules, never from
714
- the hub they're re-exported through. `src/tasks.ts`, `src/vcs.ts`,
715
- `src/mux.ts` and `src/workspace.ts` are pure re-export hubs, kept so
716
- external imports keep working; they hold no logic and are not listed
717
- separately below.
718
-
719
- | Module | Responsibility |
720
- | --------------------- | ----------------------------------------------------------------------------------------- |
721
- | `src/db.ts` | Connection (better-sqlite3, WAL), **schema v11** (11 tables incl. the `task_substates` lookup + 3 views), `resolveWorkstreamId`. Installs capture on every writable open. Owns `SYNCED_ENTITIES` / `MACHINE_LOCAL_ENTITIES` / `PORTABLE_TABLES` / `MACHINE_LOCAL_TABLES`. Refuses a pre-v11 DB (`SchemaTooOldError`) or a newer one (`SchemaTooNewError`), both exit 4. |
722
- | `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`. |
723
- | `src/capture.ts` | **Op capture**: builds the triggers that record every write to a portable table as an op in the same transaction. |
724
- | `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)). |
725
- | `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. |
726
- | `src/project-root.ts` | `detectProjectRoot` — the launch-cwd ladder bare `mu` uses to guess which workstream to focus. Pure filesystem walk; no DB. |
727
- | `extension/mu-pi.ts` + `extension/delegate.ts` | **The mu pi extension**, built to `dist/extension/mu-pi.js`. Child side (`serveCtl`): serves `$MU_CTL_SOCK` in a pi mu spawned. Parent side (`registerDelegate`, hidden when `MU_MANAGED_AGENT` is set or `MU_DELEGATE=0`): the `mu_delegate` / `mu_delegate_cancel` tools, which shell out to the `mu` CLI (spawn, send, `wait --json`, read, abort, close) and only format the CLI's `outcome` into a follow-up message. Imports nothing from pi; only the protocol from mu. |
728
- | `src/ctl/*.ts` | **Control socket** client side ([§ Control socket](#control-socket)): `path.ts` (`ctlSocketPath`, `remoteCtlSocketPath`, `MU_CTL_SOCK`), `protocol.ts` (v1 request/reply types, `CTL_OPS`, line framing; imports nothing from mu so the extension can load it), `client.ts` (`ctlRequest`, `ctlProbe`, typed version / unknown-op errors). |
729
- | `src/link.ts` + `src/cli/link.ts` | **Link** — `mu link pi`: `linkPi` writes the extension shim (or `--copy`), `linkSkill` symlinks the skill, `inspectLinks` reports `ok` / `missing` / `stale-copy` / `foreign` / `dangling` for doctor. Filesystem only: the verb runs outside `handle()` (no DB). |
730
- | `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. |
731
- | `src/legacy-ops.ts` | Permanent compatibility classifier for historical log-only intents whose entity looks projectable. Rebuild copies them without projection; segment flush never emits them. |
732
- | `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`. |
733
- | `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`. |
734
- | `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). |
735
- | `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. |
736
- | `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. |
737
- | `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. |
738
- | `src/dormant.ts` | **Dormant workstreams** — the `housekeeping` doctor section: which workstreams are **teardown** candidates. Two buckets with different remediation (`finished` = all closed + idle ≥14d, safe; `abandoned` = idle ≥60d with unclosed tasks, look first), because one merged list would invite sweeping away open work. Emits `FleetHazard` at `ok` severity, so it never makes doctor claim a fault. One indexed `GROUP BY` over `tasks`; idle is DERIVED from `MAX(tasks.updated_at)` rather than a new `workstreams.last_activity` column, which would be a second source of truth that can disagree with the rows it summarises. |
739
- | `src/disk-recon.ts` | **Disk↔DB reconciliation** — the only module that reads the STATE DIR and compares it to the DB, in both directions: a `vcs_workspaces` row whose path is gone (`ws-rows`), a dir with no row (`ws-dirs`, reusing `listAllOrphanWorkspaces`), plus residue nothing references (`ws-empty` / `db-copies` / `exports` / `locks`). Emits `FleetHazard` so doctor's renderer, `--json` and the TUI card need no new shape. Default tier is `readdir` + `stat` at depth 2 (~1ms); `measureWorkspaceUsage` recurses for bytes and is `--disk` only, because its cost scales with the checkouts. **Report-only by construction** — every finding carries its cleanup command and the module runs none of them. |
740
- | `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. |
741
- | `src/mux.ts` | **Mux backend hub** — re-exports `src/mux/*`, same shape as `src/vcs.ts`. The public `MuxBackend` surface every call site imports. |
742
- | `src/mux/*.ts` | **Multiplexer backends**: `types.ts` (the `MuxBackend` interface + `MuxError` / `PaneNotFoundError`), `detect.ts` (`MU_MUX` → `HERDR_ENV` → `$TMUX` → `PATH` ladder + test seam), `tmux.ts`, `herdr.ts`, and `input-timing.ts`. The backend owns topology, capture, **pane id** validation (tmux `%15` vs herdr `w1:p1`), identity fallback, and optional native agent state (`paneStatus?()`) and agent start (`startAgentInPane?()`). `input-timing.ts` reads pane text only to decide when tmux input can be submitted; it does not report agent state. |
743
- | `src/tmux.ts` | Re-export of the tmux backend, kept for genuinely tmux-only concerns: the `MU_TMUX_SOCKET` test seam and the shared `sleep` / `setSleepForTests` poll seam. Everything else imports `src/mux.ts` and goes through `activeMux()`. |
744
- | `src/agent-state.ts` | Runtime agent-state resolver. Reads herdr's native state or murmur's local pane options and remote JSON; maps source states, returns `unknown` with a reason, and caches remote murmur reads for 10 seconds. |
745
- | `src/reconcile.ts` | Ghost prune + orphan surface; "reality wins" |
746
- | `src/agents.ts` | Hub: CRUD + send / read / list / close + liveness + reaper. Re-exports `src/agents/*`; pane-title composition (`composeAgentTitle`) lives here. |
747
- | `src/agents/*.ts` | Agent-lifecycle internals: `spawn.ts` (spawn, CLI resolution, liveness wait *or* backend `startAgentInPane`, 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), `abort.ts` (stop a pi turn via the control socket), `transport.ts` (send routing: ctl for pi agents, mux paste otherwise, no fallback), `delegate.ts` (`delegateOutcome`: one wait result → `done` / `empty` / `died` / `timeout` / `pending`, surfaced by `mu agent wait --json`), `errors.ts`. |
748
- | `src/dag.ts` | Shared DAG read/render helpers: `loadFullDag` plus pure `renderForest` / `renderTaskTree`, reused by `mu task tree` and the TUI DAG popup. |
749
- | `src/tasks/*.ts` | Task-graph internals: `core.ts` (row shapes, id resolution), `id.ts`, `queries.ts` (reads), `edit.ts` (+ delete cascade preview), `edges.ts` (+ cycle check), `status.ts` (TaskStatus), `sort.ts`, `claim.ts` (atomic CAS), `lifecycle.ts` (close/open), `wait.ts`, `errors.ts`. |
750
- | `src/tracks.ts` | Parallel-tracks union-find with diamond merge |
751
- | `src/staleness.ts` | `WORKSPACE_STALE_THRESHOLD = 10` and the pure `isWorkspaceStale` predicate, shared by static state, the TUI Workspaces card, and dispatch-time checks. |
752
- | `src/workstream.ts` | ensureWorkstream / list / summarize / destroy |
753
- | `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`. |
754
- | `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. |
755
- | `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`). |
756
- | `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`. |
757
- | `src/glyphs.ts` | **The ONE glyph vocabulary.** `AGENT_STATE_GLYPH` + `agentStateGlyph()` for runtime agent state, the `GLYPH` record for every other state symbol (blocked, dirty, stale, ok/warn/fail, filter toggles), and `superscriptDigit` for TUI card-header keys. All classic Nerd Font `nf-fa-*` — single-codepoint and one cell wide, so `cli-table3` columns line up. |
758
- | `src/output.ts` | NextStep type + `printNextSteps` + `errorNextSteps` plumbing for self-documenting output |
759
- | `src/shell-quote.ts` | `shellQuote` — POSIX single-quoting for tokens interpolated into copy-pasteable next-step hints. |
760
- | `src/state.ts` | SDK seam for `mu state`: `loadWorkstreamSnapshotFast` (pure SQL, TUI 1s tick), `loadWorkstreamSnapshotSlow` (subprocesses), `mergeSnapshotFastSlow`, `loadWorkstreamSnapshot`. Also parses exact `REMOTE:` task-note inventory and `REMOTE_BASE:` dispatch metadata. Opt-in: `withDirty`, `withDoctor`, `withRecentCommits`, `withAllTasks`. |
761
- | `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`. |
762
- | `src/cli.ts` | commander entry; `buildProgram()` (re-exports `format`/`handle` symbols for back-compat with existing import sites). |
763
- | `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). |
764
- | `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). |
765
- | `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` (close/open), `edit.ts`, `edges.ts` (+ delete cascade preview), `claim.ts`, `tree.ts`, `wire.ts` (Commander glue). |
766
- | `src/index.ts` | SDK entrypoint (re-exports) |
767
- | `skills/mu/SKILL.md` | Bundled skill teaching the LLM the orchestration model and operational traps; `mu --help` owns the verb reference. |
768
-
769
- ### What the rows above leave out
770
-
771
- - **Capture triggers are TEMP triggers**, reinstalled per connection —
772
- SQLite refuses a main-schema trigger referencing the temp `_op_ctx`.
773
- Hence two oddities: DELETE keys are captured inline, with the parent
774
- stashing its natural key in `_op_dying` first (FK CASCADE fires child
775
- triggers after the parent is gone), and the HLC is minted in SQL,
776
- because a trigger cannot call into JS.
777
- - **Merge is per entity**: notes and messages are grow-only sets, tasks
778
- and workstreams merge per field by HLC, edges are an LWW-element-set.
779
- A tombstone is an ordinary op carrying an HLC, so out-of-order
780
- arrival is a comparison and resurrection falls out. Never
781
- `json_patch` — RFC 7396 reads a null member as delete-the-key.
782
- - **Note tombstones are the one SELF-DESCRIBING tombstone.** Every
783
- other `del` carries `'{}'`, because its key plus the puts before it
784
- describe the row. A note's key embeds its rowid
785
- (`<ws>/<task>#<id>`), which is NOT stable — a rebuild or
786
- reprojection reassigns it, leaving the puts under the old key and a
787
- later `del` under the new one. Undo folds the puts for a key to
788
- reconstruct a tombstoned row, so such a note folded to nothing and
789
- was silently never restored (drift-641). The `task_notes` delete
790
- trigger therefore records `OLD.*`, and `planUndo` falls back to the
791
- tombstone's payload when the fold is empty. This is also why
792
- `src/drift.ts` matches notes on the task-key PREFIX rather than the
793
- exact key.
794
- - **Undo and restore write through the tables**, so neither can be the
795
- write path capture misses. `restore` records then applies under the
796
- SAME HLC: `applyOp` excludes an op's own HLC from provenance, so a
797
- fresher one makes the row outrank the op and lose every field to an
798
- insert default.
799
- - **Segments are single-writer per file** — a machine appends only to
800
- its own, so nothing is contended and any folder-syncer is adequate
801
- transport. Regenerable, so no fsync. Four layers stop at the first
802
- bad record and advance the watermark only that far: `JSON.parse`
803
- (torn write when the line lacks its trailing `,"crc"…}` framing,
804
- malformed-shape when it has it — the same failure, but a transfer cut
805
- short and a writer bug have different remediations), crc32 per line
806
- (bit rot), monotonic HLC (reorder, duplicate, truncation), a
807
- `.manifest` sidecar (truncation exactly on a line boundary).
808
- `ops.payload` is TEXT that is usually but not always JSON (`mu log
809
- "text"` writes prose), so the encoder passes valid JSON through
810
- verbatim — the crc covers those exact bytes — and JSON-encodes
811
- anything else, unwrapping on ingest. Only `SYNCED_ENTITIES` and only THIS machine's ops
812
- are flushed, so pane ids and absolute paths never leave and peers
813
- never echo each other's history.
814
- - **Sync never fails a command**: a truncated segment, a garbage
815
- segment, a sync dir that is a file, a vanished directory all warn on
816
- stderr and return.
817
- - **Damage halts; refusal skips.** Stopping at the first bad record is
818
- right for DAMAGE, where a gap is indistinguishable from reordering.
819
- It is wrong for a well-formed line mu merely declines to project: a
820
- refusal lands nothing, so it leaves no hole, and halting made the
821
- watermark unrecoverable by any means the CLI offers. One
822
- `entity:"marker"` line at position 2533 of a 20,305-line peer segment
823
- froze that peer's watermark permanently, and `mu sync --repair` (which
824
- only resets the watermark) re-read straight back into the same line.
825
- So ingest now distinguishes three cases: damage halts, a KNOWN
826
- machine-local entity is a reported defect that is SKIPPED, and an
827
- UNRECOGNISED entity applies as a no-op with no defect at all —
828
- reader-behind-writer vocabulary is a fact of a mixed fleet, not
829
- corruption. `--repair` is only suggested for defects a re-read can
830
- clear.
831
- - **Rebuild is not ingest**: ingest filters to `SYNCED_ENTITIES`, but
832
- local recovery replays everything, so log-only entities are copied
833
- verbatim or `mu log` comes back empty. `machine_identity` carries
834
- across whole — id, hostname, HLC clock — or the rebuilt DB is a
835
- different peer minting HLCs below every replayed op. Drift diffs by
836
- natural key and excludes `owner_id`: a rebuild always has NULL
837
- owners.
838
-
839
- Why segments and not Litestream / cr-sqlite / a peer list, and why the
840
- DB must never sit in `MU_SYNC_DIR`:
841
- [ROADMAP § Rejected sync substrates](ROADMAP.md#rejected-sync-substrates).
91
+ | `src/db.ts` | better-sqlite3 connection (WAL); schema v11 (11 tables, 3 views) in `applySchema`; installs capture; owns `SYNCED_ENTITIES`, `PORTABLE_TABLES`, `MACHINE_LOCAL_TABLES`; refuses older (`SchemaTooOldError`) or newer (`SchemaTooNewError`) DBs, exit 4 |
92
+ | `src/hlc.ts` | hybrid logical clock, serialized as sortable TEXT |
93
+ | `src/capture.ts` | TEMP triggers recording each portable write as an op |
94
+ | `src/op-context.ts` | `withOpContext` (intent, actor, group); `withCaptureSuppressed` echo guard |
95
+ | `src/apply.ts` | apply one op: per-field LWW, tombstones, substate pair repair, `reprojectDeferredOps` |
96
+ | `src/undo.ts` | inverse ops for one group; refuses a superseded group |
97
+ | `src/rebuild.ts` | replay the whole log into a new DB file |
98
+ | `src/legacy-ops.ts` | classifier for historical log-only intents; rebuild copies them unprojected |
99
+ | `src/drift.ts` | cheap invariant (every row has an op) and `--deep` rebuild-diff |
100
+ | `src/logs.ts` | typed reader over `ops`; `appendLog` and `emitEvent`, the writes triggers cannot cover |
101
+ | `src/log-render.ts` | the one op-to-prose formatter (`renderOp`), shared by CLI and TUI |
102
+
103
+ ### Sync and doctor checks
104
+
105
+ | Module | Responsibility |
106
+ | --- | --- |
107
+ | `src/segments.ts` | JSONL segments: flush own ops, ingest peers from a watermark |
108
+ | `src/sync.ts` | peer status, the ambient hook, `--from`, `--repair` |
109
+ | `src/file-lock.ts` | cross-process advisory lock through atomic `fs.mkdir` |
110
+ | `src/fleet-hazards.ts` | mixed-fleet checks: DB inside `MU_SYNC_DIR`, network mount, case collisions |
111
+ | `src/dormant.ts` | doctor housekeeping: `finished` (all closed, idle 14 days) vs `abandoned` (idle 60 days, open tasks) workstreams; idle derived from `MAX(tasks.updated_at)` |
112
+ | `src/disk-recon.ts` | report-only state-dir vs DB reconciliation (rows without dirs, dirs without rows, residue); `--disk` measures bytes |
113
+ | `src/doctor-summary.ts` | cheap doctor slice for the TUI; ctl, extension and skill checks; remediation text |
842
114
 
843
- ## Data flow
115
+ ### Agents, multiplexer and control socket
844
116
 
845
- 1. **A caller invokes a verb** — the CLI subprocess, or in-proc SDK
846
- use.
847
- 2. **CLI handler dispatches to an SDK function** in `src/agents.ts`
848
- / `src/tasks.ts` / etc.
849
- 3. **For multi-statement writes, opens a transaction** via
850
- better-sqlite3's `db.transaction(fn)()` wrapper.
851
- 4. **Executes the operation** — agent ops shell out to tmux (and to
852
- jj/sl/git for workspaces); task ops are pure SQL.
853
- 5. **Reconciles with reality** — read paths prune ghosts and surface
854
- orphans; runtime agent state is read from the control socket (pi), murmur or herdr without a DB write.
855
- 6. **Records ops** — automatically, per [§ The ops log](#the-ops-log-read-this-first);
856
- machine-local changes go through `emitEvent`. `mu log --tail`
857
- subscribers see the new ops on the next 1-second poll.
858
- 7. **Commits or rolls back** — exception propagates after rollback
859
- so the caller sees the real error and the typed error class
860
- maps to a specific exit code in `handle()`.
117
+ | Module | Responsibility |
118
+ | --- | --- |
119
+ | `src/agents.ts` | CRUD, send, read, list, close, liveness, reaper; `composeAgentTitle` |
120
+ | `src/agents/spawn.ts` | spawn: CLI resolution, pane create or reuse, liveness, readiness, ctl handshake, rollback |
121
+ | `src/agents/spawn-lock.ts` | per-session lock around topology and row insert |
122
+ | `src/agents/transport.ts` | send routing: ctl for pi agents, mux paste otherwise, no fallback; `expectsCtl` |
123
+ | `src/agents/abort.ts` | stop a pi turn through the control socket |
124
+ | `src/agents/delegate.ts` | `delegateOutcome`: one wait result to `done` / `empty` / `died` / `timeout` / `pending` |
125
+ | `src/agents/wait.ts` | block until task-less agents leave `busy` |
126
+ | `src/agents/adopt.ts` | register an existing pane as an agent |
127
+ | `src/agents/kick.ts` | `mu agent kick`: signal a wedged pane's foreground process group |
128
+ | `src/agents/errors.ts` | typed agent errors |
129
+ | `src/agent-state.ts` | runtime state from ctl, herdr or murmur; `unknown` with a reason |
130
+ | `src/reconcile.ts` | ghost prune and orphan surfacing |
131
+ | `src/mux/types.ts` | `MuxBackend` interface; `MuxError`, `PaneNotFoundError`, `NoMultiplexerError` |
132
+ | `src/mux/detect.ts` | `MU_MUX` → `HERDR_ENV` → `$TMUX` → `PATH` ladder; `activeMux()` |
133
+ | `src/mux/tmux.ts` | tmux backend: the `tmux()` wrapper, bracketed-paste send, pane validation |
134
+ | `src/mux/herdr.ts` | herdr backend: JSON socket API, atomic send, native pane status, `startAgentInPane` |
135
+ | `src/mux/input-timing.ts` | when tmux paste input can be submitted; not a state source |
136
+ | `src/tmux.ts` | tmux-only re-exports: `MU_TMUX_SOCKET` seam, `sleep` / `setSleepForTests` |
137
+ | `src/ctl/path.ts` | `ctlSocketPath`, `remoteCtlSocketPath`, `MU_CTL_SOCK` |
138
+ | `src/ctl/protocol.ts` | protocol v1 types, `CTL_OPS`, line framing; imports nothing from mu |
139
+ | `src/ctl/client.ts` | `ctlRequest`, `ctlProbe`, version and unknown-op errors |
140
+ | `extension/mu-pi.ts` | pi extension, child side: serves `$MU_CTL_SOCK`; built to `dist/extension/mu-pi.js` |
141
+ | `extension/delegate.ts` | pi extension, parent side: `mu_delegate` / `mu_delegate_cancel`, which shell out to `mu` |
142
+ | `src/link.ts` | `mu link pi`: extension shim, skill symlink, `inspectLinks` for doctor |
143
+
144
+ ### Tasks, workspaces and workstreams
145
+
146
+ | Module | Responsibility |
147
+ | --- | --- |
148
+ | `src/tasks/core.ts`, `id.ts` | row shapes, id resolution, qualified ids |
149
+ | `src/tasks/queries.ts`, `sort.ts` | list, next, owned-by; ROI, recency, age and id sorts |
150
+ | `src/tasks/edit.ts`, `edges.ts` | add and edit; block, unblock, reparent, delete with cycle check |
151
+ | `src/tasks/claim.ts` | atomic claim and release; `resolveWorkerIdentity` |
152
+ | `src/tasks/lifecycle.ts`, `status.ts` | close, open and cascade; `TaskStatus` |
153
+ | `src/tasks/wait.ts`, `errors.ts` | `waitForTasks`; typed task errors |
154
+ | `src/tracks.ts` | union-find tracks with diamond merge |
155
+ | `src/dag.ts` | `loadFullDag`, `renderForest`, `renderTaskTree` for `mu task tree` and the DAG popup |
156
+ | `src/workstream.ts` | ensure, list, summarize, destroy |
157
+ | `src/vcs/*.ts` | `VcsBackend` with git, jj, sl and none; detection `jj` → `sl` → `git` → none |
158
+ | `src/workspace/*.ts` | per-agent workspace registry: CRUD, staleness and dirty decoration, orphans |
159
+ | `src/staleness.ts` | `WORKSPACE_STALE_THRESHOLD` and `isWorkspaceStale` |
160
+ | `src/project-root.ts` | `detectProjectRoot` for the TUI launch ladder |
161
+ | `src/state.ts` | `mu state` seam: fast SQL tier, slow subprocess tier, merge; parses `REMOTE:` notes |
162
+
163
+ ### CLI, output and packaging
164
+
165
+ | Module | Responsibility |
166
+ | --- | --- |
167
+ | `src/cli.ts` | Commander wiring (`buildProgram`) |
168
+ | `src/cli/*.ts` | one file per verb namespace, thin wrappers over the SDK; `format.ts` renders tables, `handle.ts` maps errors to exit codes and runs ambient sync |
169
+ | `src/cli/tasks/*.ts` | the `mu task` namespace, including `mu me tasks` / `mu me next` |
170
+ | `src/cli/dispatch-hints.ts` | dispatch-time `Next:` hints for pi agents (`--fresh`, `--steer`, abort) |
171
+ | `src/cli/agents-remote.ts` | `mu agent remote-env`: prints the ssh forward and env, runs nothing |
172
+ | `src/cli/tui-launch-focus.ts` | initial-tab focus ladder for bare `mu` and `mu state --tui` |
173
+ | `src/cli/tui/` | the ink TUI; the only place ink and react are imported |
174
+ | `src/output.ts`, `src/shell-quote.ts` | `printNextSteps` / `errorNextSteps`; POSIX quoting for hints |
175
+ | `src/glyphs.ts` | the one glyph vocabulary (single-cell Nerd Font codepoints) |
176
+ | `src/index.ts` | SDK entry point |
177
+ | `skills/mu/` | the skill loaded into an agent's context; `mu --help` owns the verb reference |
178
+ | `scripts/migrate.ts` | retained migration sidecar ([scripts/README.md](../scripts/README.md)) |
179
+ | `scripts/check-doc-links.mjs` | relative-link and anchor checker for every markdown file, run by `test/doc-links.test.ts` |
180
+ | `test/` | vitest suites; `*.integration.test.ts` touch real tmux, herdr or VCS ([sdk.md § Testing layers](architecture/sdk.md#testing-layers)) |
861
181
 
862
182
  ## Key seams
863
183
 
864
- The extension points. A new impl of each is small.
865
-
866
- | Seam | Add a new impl by... |
867
- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
868
- | `VcsBackend` | Implementing `detect / createWorkspace / freeWorkspace / isClean / commitsBehind / rebaseTo / commitsSinceBase / recentCommits / showCommit` (~80–150 LOC; jj/sl/git/none are working examples) |
869
- | 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` |
870
- | New schema migration| Bump `CURRENT_SCHEMA_VERSION` in `src/db.ts` and mirror the shape in `CURRENT_SCHEMA`. Keep startup migration-free; extend the retained fresh-target sidecar only when an old released schema needs a bridge. Recipe: [scripts/README.md](../scripts/README.md) |
871
- | 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 |
872
- | 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 |
873
-
874
- An importer must synthesize **ops, not rows**: the tables are a
875
- projection of the log, so a direct INSERT is invisible to sync and
876
- reported as drift.
877
-
878
- ## The ambient sync hook
879
-
880
- Sync is **ambient, not a daemon**: no watcher, no background process,
881
- no polling loop outliving a command. It happens because you already
882
- run `mu` constantly.
883
-
884
- **The seam is `handle()`** (`src/cli/handle.ts`), the one place every
885
- verb passes through, and the only seam that is already async —
886
- `syncPass` is async (it takes the file lock) while most verb bodies
887
- are synchronous better-sqlite3 code. One `await` before `fn(db)` and
888
- one after covers all ~60 verbs; no verb learns that sync exists.
889
-
890
- **Order matters**: ingest BEFORE the body, so the verb reads the
891
- freshest state transport has delivered; flush AFTER, so this
892
- invocation's ops reach the segment now rather than next invocation.
893
- Flush also runs on the ERROR path — a verb may have committed ops
894
- before throwing, and those ops are already canonical, so withholding
895
- them would make this machine's history depend on the exit code.
896
-
897
- Four carve-outs:
898
-
899
- | Surface | Behaviour | Why |
900
- | ------- | --------- | --- |
901
- | `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. |
902
- | `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. |
903
- | `mu sync` | Opts out and runs the pass itself. | Otherwise the report prints 'ingested 0' right after ingesting. |
904
- | 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. |
905
-
906
- ### Out-of-order arrival: `reprojectDeferredOps`
907
-
908
- An `edge` or `note` put whose task has not been ingested yet skips as
909
- `absent` — correctly. But ingest advances the watermark past it
910
- anyway, so without a second pass it never lands: a permanent silent
911
- divergence, and a coin flip per fleet, since peers are discovered by
912
- `localeCompare` over random-UUID filenames.
913
-
914
- `reprojectDeferredOps` re-queries the log for note/edge puts that are
915
- resolvable NOW but unprojected. No retry queue — that would be a
916
- second source of truth, and it would not survive a short-lived mu
917
- process when the parent often arrives days later. It excludes ops
918
- whose parent task is gone and keys carrying a newer `del`,
919
- so a deleted edge is never resurrected and an orphan is not retried
920
- forever. On a healthy DB it is two indexed queries returning zero
921
- rows. Called once per ingest pass, never per peer: an edge in one
922
- segment may name a task in another.
923
-
924
- ## Surrogate-PK + SDK-boundary discipline
925
-
926
- A substrate-wide invariant; every entity table follows it.
927
-
928
- **Schema shape — every entity table:**
929
-
930
- ```
931
- (
932
- id INTEGER PRIMARY KEY AUTOINCREMENT, -- surrogate; internal
933
- <scope_id> INTEGER NOT NULL REFERENCES <parent>(id) ON DELETE CASCADE,
934
- <name> TEXT NOT NULL, -- operator-facing; mutable
935
- -- ... domain attributes
936
- UNIQUE (<scope_id>, <name>) -- per-scope unique
937
- )
938
- ```
939
-
940
- FKs reference `<child>.<parent>_id` (INTEGER), never the TEXT name.
941
- The TEXT name is an operator-facing attribute — searchable,
942
- displayable, cheap to rename. The surrogate id is the identity.
184
+ A new implementation of each is small.
943
185
 
944
- **TEXT-by-design exceptions:** the
945
- workstream's own `name` (it IS a tmux session name; globally
946
- unique), `task_notes.author` / `ops.actor` (free-text actor
947
- labels — `"orchestrator"`, `"user"`, `"system"`), `ops.entity`
948
- (open enum — future entities need no migration), `agents.cli`
949
- (adding a new CLI must not require a schema change), and every
950
- column of `ops` (the ops log is FK-free by design and addresses
951
- rows by their NATURAL key, so an op outlives the row — and the
952
- workstream — it records).
953
-
954
- **SDK boundary discipline** — same shape as REST: external API
955
- uses business identifiers, internal layer uses primary keys.
956
-
957
- > **Public SDK functions take operator-facing names.**
958
- > **Internal helpers take surrogate ids.**
959
- > **Resolution happens at the public-function entry, exactly once.**
960
-
961
- ```ts
962
- // PUBLIC: takes operator-facing names
963
- export function claimTask(
964
- db: Db,
965
- workstream: string,
966
- localId: string,
967
- opts?: ClaimOptions,
968
- ): ClaimResult {
969
- const wsId = resolveWorkstreamId(db, workstream);
970
- const taskId = tryResolveTaskId(db, wsId, localId);
971
- if (taskId === null) throw new TaskNotFoundError(localId);
972
- const agentId = resolveCurrentAgentId(db, wsId);
973
- return claimTaskById(db, taskId, agentId, opts);
974
- }
975
-
976
- // INTERNAL: takes surrogate ids; never re-resolves
977
- function claimTaskById(db, taskId, agentId, opts): ClaimResult { ... }
978
- ```
979
-
980
- Resolving exactly once at the boundary buys three things: no
981
- double-resolution; no mid-function ambiguity (with surrogate ids in
982
- hand, internal helpers need no workstream context — the FKs make
983
- scope implicit); and one place for error mapping
984
- (`WorkstreamNotFoundError` originates at resolve-time in
985
- `src/db.ts`; `TaskNotFoundError` / `AgentNotFoundError` are raised by
986
- SDK callers wrapping the `tryResolve*` null-return, so the typed
987
- class and its exit-code 3 mapping hold whichever leg missed).
988
-
989
- **`--json` output preserves operator-facing names.** Surrogate ids
990
- never leak into `--json`, error payloads, log lines, or markdown
991
- exports. Promoting them to the public shape would re-introduce a
992
- global namespace by the back door (anti-feature pledge).
993
-
994
- ## State of truth
995
-
996
- - **`~/.local/state/mu/mu.db` is canonical.** Everything else is a
997
- cache, including tmux pane titles (mu re-pushes their durable agent/task
998
- context via `composeAgentTitle` after every relevant state change).
999
- - **Reads are cheap** via SQLite views (`ready`, `blocked`, `goals`).
1000
- - **Writes go through the typed SDK functions** (`src/agents.ts`,
1001
- `src/tasks.ts`, …) which validate, transact, and reconcile. Op
1002
- capture is not their job — the triggers handle it.
1003
- - **Workstream scoping is mandatory at the CLI boundary.** TEXT names
1004
- (`tasks.local_id`, `agents.name`) are per-workstream unique — the
1005
- same name may legitimately exist in two workstreams. Every public
1006
- SDK function taking such a name also takes (or threads) the
1007
- workstream; internal SQL filters by `(workstream_id, name)`. Test
1008
- fixtures and `mu sql` read paths may omit it and fall back to
1009
- first-match-by-name. The schema enforces it structurally
1010
- (per-workstream UNIQUE on name + INTEGER FKs).
1011
- - **The ops log is the insurance and it IS version history** — see
1012
- [§ The ops log](#the-ops-log-read-this-first). `mu undo <group>`
1013
- reverts one action; `mu rebuild <file>` replays the whole log into
1014
- a fresh DB.
1015
- - **In-memory state is short-lived** — the CLI's per-command
1016
- connection. Gone on process exit.
1017
- - **Cross-process coordination** is via SQLite WAL — multiple `mu`
1018
- processes share the file safely.
1019
-
1020
- ## Errors
1021
-
1022
- Curated error classes per layer; no try/catch swallowing. CLI exit
1023
- codes:
1024
-
1025
- | Code | Meaning |
1026
- | ---- | -------------------------------------------------------- |
1027
- | 0 | success |
1028
- | 1 | generic error |
1029
- | 2 | usage error (commander's default) |
1030
- | 3 | not found (no such agent / task / workspace) |
1031
- | 4 | conflict (name collision, double-claim, dirty tree) |
1032
- | 5 | substrate unavailable (`tmux` not running, DB locked) |
1033
-
1034
- Errors carry structured context (operation name, target, attempted
1035
- action) so `mu doctor` can surface them readably.
1036
-
1037
- ## Testing layers
1038
-
1039
- | Layer | Test approach |
1040
- | ---------------------------------- | ------------------------------------------------------------------------------ |
1041
- | `src/db.ts` | Real SQLite in temp dir; schema/table-count assertions |
1042
- | `src/tasks.ts` | Real SQLite in temp dir; pure functions over fixture data |
1043
- | `src/tracks.ts` | Pure functions; union-find + diamond-merge properties |
1044
- | `src/agents.ts` | Mocked tmux executor via `setTmuxExecutor()`; reaper integration tests |
1045
- | `src/logs.ts` | Real SQLite; cursor semantics, AUTOINCREMENT durability, ops-outlive-workstream |
1046
- | `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 |
1047
- | `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 |
1048
- | `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 |
1049
- | `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 |
1050
- | `src/dormant.ts` | Task `updated_at` written directly in SQL (the verbs stamp `datetime('now')`, and the age IS the input under test). Weighted toward the BOUNDARY between the two buckets rather than detection: at an age between the thresholds, a closed-out workstream is reported and one with open tasks is not |
1051
- | `src/disk-recon.ts` | Per-test `MU_STATE_DIR` temp dir with the on-disk shape built by hand — the disagreements it detects cannot be produced through the normal verbs, since those keep disk and DB in step. Both directions covered (row without dir, dir without row), plus the live-WAL-triple exclusion and `stranded` marking |
1052
- | `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 |
1053
- | `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 |
1054
- | 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 |
1055
- | `src/hlc.ts` | Real SQLite + an INJECTED clock (no sleeps): backwards jumps, stalled milliseconds, sort order, durability, concurrent minting |
1056
- | `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) |
1057
- | `src/cli.ts` / verb integration | `*.integration.test.ts` files; real tmux server, unique session per test |
1058
- | 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 |
1059
- | 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`) |
1060
- | End-to-end | `test/acceptance.integration.test.ts` — the canonical 10-task / 3-agent demo |
1061
-
1062
- Two fixture traps: a convergence fixture must share ONE creation op
1063
- (two independent `task add`s for the same id make the later creation
1064
- legitimately win every field), and an ordering-sensitive test must
1065
- RENAME segments to pin ingest order, since peers are discovered by
1066
- `localeCompare` over random-UUID filenames.
1067
-
1068
- Flake lessons: treat pass-alone/fail-under-load cases as
1069
- concurrency bugs first; use retrying temp-dir cleanup for VCS fixtures
1070
- whose subprocesses keep files alive briefly; drive wait/reaper
1071
- integration tests from poll-loop seams, not fixed timers; wait for
1072
- stable Ink output instead of sleeping.
1073
-
1074
- ## Distribution
1075
-
1076
- Single npm package `mu` (see `package.json`):
1077
-
1078
- - `dist/cli.js` — CLI entry, executable (`bin: { mu: ./dist/cli.js }`; shebang preserved by `tsup`)
1079
- - `dist/index.js` + `dist/index.d.ts` — programmatic API + types for SDK callers
1080
- - `skills/mu/SKILL.md` — bundled skill (the only non-`dist` asset shipped)
1081
-
1082
- `tsup` bundles three entries: `index` and `cli` from `src/`, and
1083
- `extension/mu-pi` (the pi extension `mu link pi` points at). No runtime
1084
- build step on the user's machine. pi stays a peer dep — the extension
1085
- types pi's API structurally — and there is no bundled `agents/*.md` or `prompts/*.md`:
1086
- per-role agent guidance lives in the user's project repo.
1087
-
1088
- The dependency list lives in `package.json`; the rule for adding
1089
- new ones is the anti-feature pledge in
1090
- [ROADMAP.md § Anti-feature pledges](ROADMAP.md#anti-feature-pledges).
186
+ | Seam | Add one by |
187
+ | --- | --- |
188
+ | `MuxBackend` | implementing the interface in `src/mux/`; a backend without an equivalent no-ops, and a backend-only feature is an optional method callers test for |
189
+ | `VcsBackend` | implementing `detect`, `createWorkspace`, `freeWorkspace`, `isClean`, `commitsBehind`, `rebaseTo`, `commitsSinceBase`, `recentCommits`, `showCommit` (about 80-150 LOC) |
190
+ | typed verb | an SDK function; a `cmd<Verb>` in `src/cli/<namespace>.ts`; one Commander block in `buildProgram()` wrapped in `handle()` and routed through `printNextSteps` |
191
+ | schema change | bump `CURRENT_SCHEMA_VERSION` in `src/db.ts` and mirror `CURRENT_SCHEMA`; startup stays migration-free ([scripts/README.md](../scripts/README.md)) |
192
+ | syncable field | add the column to a portable table, extend the UPDATE trigger's changed-column comparison, confirm apply writes it; a full-row payload regresses merge to row-level LWW |
193
+ | cross-machine sync | nothing: every invocation already flushes and ingests |