@mu-crew/mu 3.2.0 → 3.3.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.
Files changed (71) hide show
  1. package/AGENTS.md +208 -599
  2. package/README.md +125 -329
  3. package/dist/cli.js +108 -78
  4. package/dist/cli.js.map +1 -1
  5. package/dist/extension/mu-pi.js +421 -56
  6. package/dist/extension/mu-pi.js.map +1 -1
  7. package/dist/index.d.ts +36 -10
  8. package/dist/index.js +89 -67
  9. package/dist/index.js.map +1 -1
  10. package/docs/ARCHITECTURE.md +176 -1072
  11. package/docs/ROADMAP.md +88 -169
  12. package/docs/VISION.md +205 -415
  13. package/docs/VOCABULARY.md +204 -466
  14. package/docs/architecture/control-socket.md +133 -0
  15. package/docs/architecture/dag.md +113 -0
  16. package/docs/architecture/mux.md +162 -0
  17. package/docs/architecture/ops-log.md +140 -0
  18. package/docs/architecture/sdk.md +185 -0
  19. package/docs/architecture/sync.md +129 -0
  20. package/docs/architecture/tui.md +146 -0
  21. package/docs/guide/README.md +30 -0
  22. package/docs/guide/backends.md +61 -0
  23. package/docs/guide/cleanup.md +74 -0
  24. package/docs/guide/delegate.md +87 -0
  25. package/docs/guide/dispatch.md +144 -0
  26. package/docs/guide/getting-started.md +180 -0
  27. package/docs/guide/recovery.md +135 -0
  28. package/docs/guide/remote.md +74 -0
  29. package/docs/guide/sql.md +76 -0
  30. package/docs/guide/stop-a-worker.md +64 -0
  31. package/docs/guide/sync.md +78 -0
  32. package/docs/guide/tui.md +98 -0
  33. package/docs/guide/upgrade.md +76 -0
  34. package/docs/history/CHANGELOG-pre-3.md +4866 -0
  35. package/docs/reference/env.md +87 -0
  36. package/docs/reference/naming.md +82 -0
  37. package/docs/specs/2026-09-28-agent-state-from-murmur.md +1 -1
  38. package/docs/specs/2026-09-29-task-substates.md +1 -1
  39. package/docs/specs/2026-10-03-keep-driving-nudge.md +142 -0
  40. package/package.json +2 -1
  41. package/prompts/mu-refute.md +8 -0
  42. package/prompts/mu-research.md +8 -0
  43. package/prompts/mu-review.md +8 -0
  44. package/prompts/mu-rules-audit.md +8 -0
  45. package/prompts/mu-tournament.md +9 -0
  46. package/prompts/ultrathink.md +9 -0
  47. package/skills/mu/SKILL.md +126 -255
  48. package/skills/mu/recipes/adversarial-review.md +83 -0
  49. package/skills/mu/recipes/brief.md +53 -0
  50. package/skills/mu/recipes/codemode-driver.md +54 -0
  51. package/skills/mu/recipes/deep-research.md +52 -0
  52. package/skills/mu/recipes/fan-out.md +36 -0
  53. package/skills/mu/recipes/hypothesis-panel.md +40 -0
  54. package/skills/mu/recipes/long-run.md +20 -0
  55. package/skills/mu/recipes/loop-until-done.md +38 -0
  56. package/skills/mu/recipes/orchestrator-loop.md +87 -0
  57. package/skills/mu/recipes/plan.md +54 -0
  58. package/skills/mu/recipes/recovery.md +26 -0
  59. package/skills/mu/recipes/refute.md +49 -0
  60. package/skills/mu/recipes/remote-workers.md +292 -0
  61. package/skills/mu/recipes/review-panel.md +57 -0
  62. package/skills/mu/recipes/rules-audit.md +70 -0
  63. package/skills/mu/recipes/tournament.md +37 -0
  64. package/skills/mu/recipes/triage.md +38 -0
  65. package/skills/mu/recipes/ultrathink.md +73 -0
  66. package/skills/mu/recipes/watcher.md +16 -0
  67. package/skills/mu/recipes/waves.md +19 -0
  68. package/skills/mu/recipes/worker.md +24 -0
  69. package/docs/HANDOVER.md +0 -486
  70. package/docs/USAGE_GUIDE.md +0 -2738
  71. package/skills/mu/REMOTE_WORKERS.md +0 -617
@@ -0,0 +1,133 @@
1
+ # Control socket
2
+
3
+ mu drives a pi agent through a unix socket served by mu's own pi
4
+ extension inside the agent's interactive pi. The pane stays pi's TUI,
5
+ so a human attached to it shares the same session. There is no
6
+ `pi --mode rpc`, no screen scraping and no daemon: the server lives and
7
+ dies with the pi process. Overview: [ARCHITECTURE.md](../ARCHITECTURE.md).
8
+
9
+ ## Path derivation
10
+
11
+ `ctlSocketPath(ws, agent)` (`src/ctl/path.ts`) is
12
+ `<state dir>/sock/<ws>/<agent>.sock`. The state dir is
13
+ `dirname($MU_DB_PATH)` or the default. If the path exceeds 103 bytes
14
+ (the macOS `sun_path` limit), it becomes
15
+ `<state dir>/sock/h/<sha1(ws/agent)[:16]>.sock`.
16
+
17
+ Nothing is stored. Spawn creates the directory (mode 0700), removes any
18
+ stale socket file at the path, and injects the path as `MU_CTL_SOCK`
19
+ into every pane. Every later verb derives the path again.
20
+
21
+ ## Protocol v1
22
+
23
+ `src/ctl/protocol.ts` imports nothing from mu, so the extension loads
24
+ it standalone. Framing is JSON lines, one request per connection. Every
25
+ reply carries `v: 1`; the client (`src/ctl/client.ts`) throws
26
+ `CtlVersionError` on anything else.
27
+
28
+ | op | does |
29
+ | --- | --- |
30
+ | `hello` | identity plus `ops` (served ops) and `extVersion` (the mu version the extension was built from) |
31
+ | `status` | `state` (`busy` / `idle` / `needs_input`), `since`, `runs`, `pending` |
32
+ | `send` | `pi.sendUserMessage`; when busy, `mode` is `steer` or `followUp` (default) |
33
+ | `wait` | resolves on pi's `agent_settled` after `afterRuns`, with the run's `lastText` |
34
+ | `abort` | `ctx.abort()` (pi's Esc) |
35
+ | `fresh` | new session plus prompt as one operation, through the internal `/mu-fresh` command; replies once the new run starts; refused with `busy` unless `force` |
36
+ | `command` | `name` `new`, `reload` or `compact` (with optional `instructions`), through the internal `/mu-new`, `/mu-reload`, `/mu-compact` commands, which call `ctx.newSession()`, `ctx.reload()`, `ctx.compact()`. `new` replies once the session is replaced, `reload` after the `session_start` it causes, `compact` when compaction starts or with pi's error (`Nothing to compact ...`). Refused with `busy` unless `force` |
37
+
38
+ `pi.sendUserMessage` dispatches only extension commands, not pi's
39
+ built-in `/new`, `/reload`, `/compact`. Each internal command is an
40
+ extension command, triggered with `expandPromptTemplates: true`, and
41
+ its handler gets the command context that can replace or reload the
42
+ session.
43
+
44
+ These back `mu agent send` (`--fresh`, `--steer`, session commands), `mu agent wait`,
45
+ `mu agent abort`, the agent state reading, and `mu_delegate`.
46
+
47
+ ## Version skew
48
+
49
+ An op the extension does not serve returns `unknown op: <op>` with its
50
+ `ops` list. mu raises `AgentExtensionOutdatedError` (exit 4), whose
51
+ next steps are `/reload` through the mux (an extension that lacks the
52
+ `command` op cannot run it over ctl) or a respawn. `mu doctor`
53
+ probes every pi agent's socket (the `ctl` row) and flags an
54
+ `extVersion` older than the installed mu or a missing op. Its extension
55
+ row reports whether `mu link pi` installed the extension.
56
+
57
+ ## Extension lifecycle
58
+
59
+ `extension/mu-pi.ts` is inert unless `MU_CTL_SOCK` is set.
60
+
61
+ - pi re-creates extension runtimes per session. The server, the run
62
+ counters and an in-flight `fresh` therefore live in a process-global
63
+ map (`Symbol.for("mu.pi.ctl")`).
64
+ - Session replacement (`new`, `resume`, `fork`, `reload`) keeps the
65
+ socket and its connections. Quit closes it.
66
+
67
+ **Binding never steals a live socket.**
68
+
69
+ 1. If the path already answers, another pi serves it (a nested `pi -p`
70
+ inherited `MU_CTL_SOCK`). The extension leaves it alone and says so
71
+ on stderr.
72
+ 2. Otherwise it listens on a private name in the same directory, sets
73
+ mode 0600, and hard-links that name onto the public path. libuv
74
+ unlinks the listened-on name at close, so the public path must never
75
+ be that name. `link()` fails on an existing path, so a racing pi
76
+ keeps its socket.
77
+ 3. It records the inode of the public path.
78
+
79
+ On quit it unlinks the public path only if the inode is still its own,
80
+ because another pi may own the path by then. On `session_start`, a
81
+ server whose file was deleted or replaced closes and rebinds. That is
82
+ how `/reload` recovers a lost socket file.
83
+
84
+ ## No silent fallback
85
+
86
+ `src/agents/transport.ts` routes sends. `expectsCtl` is true when the
87
+ cli key resolves to pi (`pi` or `pi-meta` as argv0), or when a socket
88
+ file exists at the agent's derived path. The file case covers
89
+ `--cli helper --command "pi-meta ..."`, whose command is not stored.
90
+
91
+ A pi agent whose socket does not answer raises
92
+ `AgentCtlUnreachableError` for send and abort, and reads as `unknown`
93
+ with reason `ctl missing` or `ctl refused` for state. mu never pastes
94
+ into it or asks murmur instead.
95
+
96
+ The mux paste path (bracketed paste, `MU_SEND_DELAY_MS`,
97
+ `MU_SEND_READINESS_MS`) serves only:
98
+
99
+ - non-pi CLIs;
100
+ - adopted panes without ctl;
101
+ - an explicit `--via mux`.
102
+
103
+ A pi agent's `/new`, `/reload` and `/compact [instructions]` go
104
+ through the `command` op. Any other slash command to a pi agent raises
105
+ `AgentSlashCommandUnsupportedError` (exit 2), whose next step is the
106
+ same text with `--via mux`. Text such as `/tmp/x.log ...` is a plain
107
+ prompt, not a slash command. A pi agent therefore never reaches
108
+ `capturePane` or the paste timing in `src/mux/input-timing.ts`.
109
+
110
+ ## Remote agents
111
+
112
+ The user's `ssh` command carries `-L <local derived path>:<remote path>`
113
+ and puts `MU_CTL_SOCK=<remote path>` in the remote env.
114
+ `mu agent remote-env` prints both and runs nothing
115
+ (`src/cli/agents-remote.ts`). mu always connects to the local path, so
116
+ local and remote agents share one transport, and state, send, wait and
117
+ abort are exact without murmur.
118
+
119
+ ssh does not create the local directory, which is why spawn makes it
120
+ first. When ssh dies it leaves the local socket file behind. That file
121
+ probes `refused`, and `mu agent close` or the reaper deletes it.
122
+
123
+ ## Parent side: `mu_delegate`
124
+
125
+ `extension/delegate.ts` registers the `mu_delegate` and
126
+ `mu_delegate_cancel` tools in the operator's own pi. They are hidden
127
+ when `MU_MANAGED_AGENT` is set (inside a mu-spawned pane) or
128
+ `MU_DELEGATE=0`. The tools shell out to the `mu` CLI (spawn, send,
129
+ `wait --json`, read, abort, close) and only format the CLI's
130
+ `outcome` into a follow-up message. `delegateOutcome`
131
+ (`src/agents/delegate.ts`) maps one wait result to `done`, `empty`,
132
+ `died`, `timeout` or `pending`. The extension imports nothing from pi;
133
+ it types pi's API structurally.
@@ -0,0 +1,113 @@
1
+ # The task DAG
2
+
3
+ mu coordinates work through a directed acyclic graph of tasks. Without
4
+ it mu is only an agent runner. Overview:
5
+ [ARCHITECTURE.md](../ARCHITECTURE.md). Terms:
6
+ [VOCABULARY.md](../VOCABULARY.md).
7
+
8
+ ## Model
9
+
10
+ - **Tasks** are nodes with mandatory `impact` (1-100) and
11
+ `effort_days`. `ROI = impact / effort` drives prioritization.
12
+ - **One edge type: `blocks`.** `A → B` means A must close before B can
13
+ start. More edge types would make "can this start?" ambiguous.
14
+ - **Status lifecycle:** `OPEN → IN_PROGRESS → CLOSED`. Status alone
15
+ decides edge satisfaction: any `CLOSED` task satisfies its blockers.
16
+ - **Substate** qualifies status and never touches edges:
17
+ `OPEN/todo|parked`, `IN_PROGRESS/active`,
18
+ `CLOSED/done|rejected|wontfix|duplicate|superseded`. The rule is
19
+ "store intent, derive graph facts": ready and blocked stay derived
20
+ from edges. `OPEN/parked` is excluded from `ready` but stays in
21
+ `goals`.
22
+ - **Notes** are append-only per task. They survive LLM context loss
23
+ and agent restarts, which fixes context loss at the task level
24
+ rather than the agent level.
25
+
26
+ ## Substate integrity
27
+
28
+ The legal pairs live in the `task_substates` lookup table, seeded from
29
+ code on every open (machine-local, never synced). `tasks` has a
30
+ composite FK `(status, substate) → task_substates`,
31
+ `DEFERRABLE INITIALLY DEFERRED`. That FK is the only guard on the pair.
32
+
33
+ SQLite checks it at COMMIT, so apply's one-field-at-a-time UPDATEs
34
+ pass as long as the pair is legal when the transaction ends. That is
35
+ why `applyOp` wraps each task put in `db.transaction`. In autocommit
36
+ the first per-field UPDATE would fail the FK.
37
+
38
+ Every lifecycle write sets `status` and `substate` in one UPDATE, so
39
+ one op carries both under one HLC. A pair can still split across ops:
40
+ a status-only op from a v10 peer, an unknown substate from a newer
41
+ peer, or a concurrent close and park on two machines. Before commit,
42
+ `repairTaskPair` (`src/apply.ts`) sets the substate to the one implied
43
+ by the newest op in the log that writes a substate, resolved against
44
+ the row's current status. If that pair is illegal, it falls back to
45
+ the status default.
46
+
47
+ Substate is therefore not per-field LWW. Reading the log instead of
48
+ the row makes the result a pure function of the op set plus the row's
49
+ status, so every peer converges whatever the arrival order. For the
50
+ same reason the repair records no op: each peer computes the identical
51
+ repair from the identical log.
52
+
53
+ ## Built-in views
54
+
55
+ | View | Returns |
56
+ | --- | --- |
57
+ | `ready` | `OPEN/todo` tasks with no unresolved blockers: work that can start now |
58
+ | `blocked` | OPEN tasks waiting on something |
59
+ | `goals` | tasks with no dependents: graph endpoints |
60
+
61
+ Agents and humans query these views directly through `mu sql`. There
62
+ is no separate query layer.
63
+
64
+ ## Parallel tracks
65
+
66
+ The Tracks section of `mu state` and bare `mu` runs union-find
67
+ (`src/tracks.ts`) to find independent subtrees that different agents
68
+ can work in parallel.
69
+
70
+ Diamonds merge. If two roots share a prerequisite, they collapse into
71
+ one track, so two agents never collide on the shared dependency:
72
+
73
+ ```
74
+ Independent (2 tracks): Diamond (1 merged track):
75
+
76
+ goal_a goal_b goal_a goal_b ← Spawn 2 agents
77
+ | | \ /
78
+ task_a task_b shared ← Spawn 1 (would
79
+ | | | collide otherwise)
80
+ leaf_a leaf_b leaf
81
+ ```
82
+
83
+ The graph algorithm decides, not the LLM.
84
+
85
+ ## Claim protocol
86
+
87
+ `mu task claim <task>` resolves the claimer, then atomically sets
88
+ `tasks.owner`, flips `status` to `IN_PROGRESS`, and records a
89
+ `task.claim` op carrying the resolved `actor`. The claim is a CAS in
90
+ SQLite, so two agents cannot claim the same task.
91
+
92
+ Identity resolution (`resolveWorkerIdentity`, `src/tasks/claim.ts`) is
93
+ a two-rung ladder:
94
+
95
+ 1. **`$MU_AGENT_NAME`**, injected into the pane's environment at spawn.
96
+ It is backend-independent.
97
+ 2. **`backend.currentAgentName()`**, the mux fallback. On tmux it reads
98
+ the pane title (`display-message -p '#{pane_title}'`), never the
99
+ window name, because one window can hold several agents. On herdr
100
+ the name is registered with the mux and looked up by pane id.
101
+
102
+ The fallback exists for **adopted** panes (`mu agent adopt`), which
103
+ predate the env injection and carry only a title. The agent does not
104
+ need to know its own name.
105
+
106
+ ## Scoped subtree views
107
+
108
+ `mu task tree <id>` and the task queries show the part of the graph
109
+ reachable from a task. Scoping is a `WHERE` clause, which makes
110
+ recursive delegation work: a sub-orchestrator inspects its slice
111
+ without an LLM inferring the scope. `src/dag.ts` (`loadFullDag`,
112
+ `renderForest`, `renderTaskTree`) serves both `mu task tree` and the
113
+ TUI DAG popup.
@@ -0,0 +1,162 @@
1
+ # Mux topology, backends and reconciliation
2
+
3
+ mu runs agents in multiplexer panes, tmux or herdr, behind one
4
+ `MuxBackend` interface. Overview: [ARCHITECTURE.md](../ARCHITECTURE.md).
5
+
6
+ ## One mux session per workstream
7
+
8
+ One workstream is one mux session and one `session_id` partition in
9
+ `~/.local/state/mu/mu.db`. Workstreams on one machine are independent
10
+ mux sessions.
11
+
12
+ | mu term | tmux | herdr |
13
+ | --- | --- | --- |
14
+ | **mux session** | session | workspace |
15
+ | **window** | window | tab |
16
+ | **pane** | pane (`%15`) | pane (`w1:p1`) |
17
+
18
+ herdr's own "session" is server-level (one socket), which is the wrong
19
+ granularity, so a herdr workspace plays the mux-session role.
20
+
21
+ ```
22
+ tmux session: mu-auth-refactor (one mu workstream)
23
+ ┌───────────────────────────────────────────────────────────┐
24
+ │ Window: Backend Window: Review │
25
+ │ ┌──────────┐ ┌──────────┐ ┌────────────────────────┐ │
26
+ │ │ worker-1 │ │ worker-2 │ │ reviewer-1 │ │
27
+ │ │ (pi) │ │ (pi) │ │ (pi, role=read-only) │ │
28
+ │ └──────────┘ └──────────┘ └────────────────────────┘ │
29
+ │ Window: mu-orchestrator │
30
+ │ ┌────────────────────────────────────────────────────┐ │
31
+ │ │ pi (you, with mu extension loaded) │ │
32
+ │ └────────────────────────────────────────────────────┘ │
33
+ └───────────────────────────────────────────────────────────┘
34
+ ```
35
+
36
+ - The first `mu agent spawn` creates the session if needed. Name it
37
+ with `mu workstream init <name>` or `MU_SESSION=<name>`.
38
+ - `tmux attach -t mu-<workstream>` shows the whole crew (herdr:
39
+ `herdr workspace focus <id>`). Killing the session kills the
40
+ workstream, with no leaked panes, and the crew survives a closed
41
+ laptop.
42
+ - `mu agent list` is scoped to one workstream; `-w` picks another.
43
+ - `mu doctor` warns about orphan panes, ghost rows, and agents whose
44
+ mux session is gone.
45
+
46
+ **Window vs pane.** Each agent gets its own window, named by `--tab`
47
+ (default: the agent name). Agents sharing a `--tab` value share a
48
+ window. Identity never depends on the window: each pane gets
49
+ `$MU_AGENT_NAME` plus a **pane title** fallback. The title carries
50
+ durable context only (agent name and owned tasks), re-pushed by
51
+ `composeAgentTitle` after each relevant change. mu enables the top
52
+ border and leaves `pane-border-format` to the user's tmux config. The
53
+ canonical tmux protocol is the comment block at the top of
54
+ `src/mux/tmux.ts`.
55
+
56
+ ## The `activeMux()` seam
57
+
58
+ Every call site reaches its multiplexer through
59
+ `(await activeMux()).<method>()`. Nothing outside `src/mux/` names a
60
+ backend. `src/mux/detect.ts` picks one: `MU_MUX` → `HERDR_ENV` →
61
+ `$TMUX` → `PATH`. An unknown `MU_MUX` value fails the invocation
62
+ instead of falling back to tmux.
63
+
64
+ Each call site has decided what "no reachable multiplexer" means:
65
+
66
+ | | On `NoMultiplexerError` | Examples |
67
+ | --- | --- | --- |
68
+ | **Load-bearing** | propagate; `handle()` maps it to exit 5 | spawn, send, read, kill, adopt, kick, reconcile, session create and destroy |
69
+ | **Best-effort** | `try`/`catch`, degrade | actor identity, pane titles, pane borders, workstream listings, liveness polls, TUI attach |
70
+
71
+ `resolveWorkerIdentity()` in `src/tasks/claim.ts` is the canonical
72
+ best-effort shape. `reconcile()` is load-bearing on purpose: treating
73
+ an unreachable mux as "zero panes" would prune every agent as a ghost
74
+ and reap its in-progress tasks.
75
+
76
+ Three concerns belong to the backend, because each would otherwise
77
+ hardcode a tmux string a herdr user sees:
78
+
79
+ - `attachHint()` and `attachCommands()`: the printed recipe and the
80
+ TUI's executed argv.
81
+ - `healthCheck()`: version and env facts as data; `mu doctor` renders.
82
+ - `paneNotFoundNextSteps()`: borrowed by `PaneNotFoundError` from the
83
+ backend that raised it.
84
+
85
+ `src/tmux.ts` survives as a re-export for tmux-only concerns: the
86
+ `MU_TMUX_SOCKET` test-isolation seam and the shared `sleep` /
87
+ `setSleepForTests` poll seam.
88
+
89
+ ## Spawn: create-and-run vs create-then-start
90
+
91
+ tmux creates a pane and runs a command in one call. herdr has no
92
+ create-and-run form: its creation verbs start a plain shell, and
93
+ `agent start` needs an existing pane at its prompt. mu expresses this
94
+ as a capability, not a name check:
95
+
96
+ | | tmux | herdr |
97
+ | --- | --- | --- |
98
+ | `NewWindowOptions.command` | carries the command | refused if non-empty |
99
+ | `startAgentInPane()` | absent | implemented |
100
+ | after start | mu checks pane life and startup errors | the mux returns once the agent is ready |
101
+
102
+ `spawnAgent` branches on `mux.startAgentInPane !== undefined`.
103
+
104
+ - **tmux.** After `MU_SPAWN_LIVENESS_MS` (default 1500, `0` skips) mu
105
+ verifies the pane survived and scans its tail for provider or auth
106
+ startup errors. mu does not wait for murmur to claim the pane: state
107
+ reads before the first claim report `unknown`.
108
+ - **herdr.** mu creates a bare pane and calls `startAgentInPane`, which
109
+ subsumes both checks.
110
+ - **pi, either backend.** After the agent starts, mu polls its control
111
+ socket until the extension answers (`MU_SPAWN_CTL_MS`, default 30s;
112
+ `--no-ctl` or `0` skips). A timeout is reported but not rolled back:
113
+ the pane and pi are fine, only control is missing. See
114
+ [control-socket.md](control-socket.md).
115
+
116
+ A creation verb refuses a command it cannot honour. A dropped command
117
+ would leave an empty shell that mu believes hosts an agent. The
118
+ refusal and every failure before the handshake route through
119
+ `rollbackSpawn`, so no agent row survives for an empty pane.
120
+
121
+ Spawns serialize per mux session (`src/agents/spawn-lock.ts`, over
122
+ `src/file-lock.ts`) only around the topology check-then-act and the
123
+ row insert. Agent start and the readiness waits sit outside the lock,
124
+ so a parallel fan-out still runs in parallel.
125
+
126
+ ## Reconciliation
127
+
128
+ `mu agent list` reconciles the registry against the mux before
129
+ returning. `src/reconcile.ts` is the only implementation, and
130
+ `mu doctor` calls the same routine.
131
+
132
+ 1. **Prune ghosts.** Delete each `agents` row whose `pane_id` no longer
133
+ exists.
134
+ 2. **Surface orphans.** List panes in the workstream's session that
135
+ have no row but whose title looks like an agent name. mu never
136
+ adopts them; the user runs `mu agent adopt <pane> [--name X]`.
137
+
138
+ The mux is the source of truth for which panes exist.
139
+
140
+ ## Agent state
141
+
142
+ Runtime state is read on demand and never persisted.
143
+ `src/agent-state.ts` picks one source per agent:
144
+
145
+ | Agent | Source |
146
+ | --- | --- |
147
+ | pi (`expectsCtl`) | its control socket, probed in parallel for all pi agents |
148
+ | other CLI on herdr | herdr's native `paneStatus()` |
149
+ | other CLI on tmux | murmur: one `list-panes -a` reads `@murmur_pane_state` / `@murmur_pane_since`; agents without a local option fall back to `murmur status --json`, cached 10s |
150
+
151
+ - ctl `idle` maps to `needs_input`. murmur maps `working` → `busy`,
152
+ `blocked` → `needs_permission`, and `idle` / `done` / `crashed` →
153
+ `needs_input`. herdr supplies mu's states directly. See murmur's
154
+ [stable contract](https://github.com/mu-crew/murmur/blob/main/ARCHITECTURE.md#contract).
155
+ - An absent reading is `unknown` with a reason: `ctl missing`,
156
+ `ctl refused`, `murmur not installed`, `murmur pi extension not
157
+ linked`, `murmur has no row`, `remote snapshot stale`, `herdr reports
158
+ no state`, or `pane gone`.
159
+ - `src/mux/input-timing.ts` reads pane text only to decide when tmux
160
+ input can be submitted on the paste path. It does not report state.
161
+ - `agents.status` is deprecated. Inserts write `spawning` to satisfy the
162
+ schema; nothing reads it as runtime state.
@@ -0,0 +1,140 @@
1
+ # The ops log
2
+
3
+ mu keeps one append-only table, `ops`. History, undo, sync, rebuild and
4
+ drift detection are all queries or replays over it. Overview:
5
+ [ARCHITECTURE.md](../ARCHITECTURE.md).
6
+
7
+ ## Capture is a trigger
8
+
9
+ Every INSERT, UPDATE and DELETE on a portable table (`workstreams`,
10
+ `tasks`, `task_edges`, `task_notes`) is recorded as an **op** by a
11
+ SQLite trigger, in the same transaction as the mutation. No SDK
12
+ function decides to record history, so no call site can forget to, and
13
+ the log cannot drift from the data, even on power loss.
14
+
15
+ The tables are the materialized view; the log is the record. Reads hit
16
+ the tables as ordinary indexed SQL. Nothing replays the log to answer
17
+ a query. The log is what the tables are derived from and can be
18
+ rebuilt from.
19
+
20
+ ```
21
+ mu task close t3
22
+ │
23
+ ▼
24
+ UPDATE tasks SET status=... ──┬──► tables (what reads see)
25
+ └──► ops (what everything else reads)
26
+ one transaction
27
+ ```
28
+
29
+ | Feature | Over the log |
30
+ | --- | --- |
31
+ | history | `mu log` is a typed reader over `ops` (`src/logs.ts`) |
32
+ | undo | `mu undo <group>` emits the inverse ops for one group |
33
+ | sync | each machine appends its own ops to a JSONL segment and applies each peer's from a watermark ([sync.md](sync.md)) |
34
+ | rebuild | `mu rebuild <file>` replays the whole log into a new DB |
35
+ | drift | `mu doctor --deep` rebuilds into a temp DB and diffs it against the live tables |
36
+
37
+ **Ops are semantic partial updates.** An UPDATE op carries only the
38
+ columns that changed. That makes per-field merge free: two machines
39
+ editing different fields of one task both keep their edit. A full-row
40
+ payload would silently regress this to row-level last-writer-wins.
41
+
42
+ **The cost:** one capture bug breaks history, undo and sync at once,
43
+ silently. Hence the two drift tiers below.
44
+
45
+ **Ordering** across machines comes from the **HLC** (`src/hlc.ts`) on
46
+ every op: a hybrid logical clock serialized as sortable TEXT
47
+ `<wall_ms:15>.<counter:6>.<machine_id>`, so bytewise `ORDER BY hlc` is
48
+ causal order. Clock state lives in `machine_identity`.
49
+
50
+ **Machine-local tables** (`agents`, `vcs_workspaces`) have no capture
51
+ triggers. Their contents are pane ids and absolute paths, which mean
52
+ nothing on another machine. Their changes are recorded as log-only
53
+ events through `emitEvent`. `appendLog` writes operator prose
54
+ (`mu log "text"`).
55
+
56
+ ## Trigger mechanics
57
+
58
+ - **Capture triggers are TEMP triggers**, reinstalled per connection
59
+ by `src/capture.ts`. SQLite refuses a main-schema trigger that
60
+ references the temp `_op_ctx` table. Two consequences follow:
61
+ - DELETE keys are captured inline, with the parent stashing its
62
+ natural key in `_op_dying` first. FK CASCADE fires child triggers
63
+ after the parent is gone.
64
+ - The HLC is minted in SQL, because a trigger cannot call into JS.
65
+ - **Op context** (`src/op-context.ts`): `withOpContext(db, {intent,
66
+ actor, group}, fn)` labels every op in a scope and restores in a
67
+ `finally`. Nested scopes inherit the group, which puts a cascade
68
+ under one `mu undo`. `withCaptureSuppressed` is the echo guard used
69
+ by the apply path.
70
+ - An importer must synthesize **ops, not rows**. A direct INSERT is
71
+ invisible to sync and reported as drift.
72
+
73
+ ## Apply and merge
74
+
75
+ `src/apply.ts` is capture's counterpart: given one op, local or from a
76
+ peer, it makes the tables reflect it.
77
+
78
+ - **Merge is per entity.** Notes are grow-only sets. Tasks and
79
+ workstreams merge per field by HLC. Edges are an LWW-element-set.
80
+ - A **tombstone** is an ordinary op with an HLC, so out-of-order
81
+ arrival is a comparison and resurrection falls out.
82
+ - Never use `json_patch`: RFC 7396 reads a null member as
83
+ delete-the-key.
84
+ - Task `status` and `substate` are repaired as a pair, not merged per
85
+ field. See [dag.md](dag.md#substate-integrity).
86
+
87
+ ## Undo
88
+
89
+ `src/undo.ts` derives the inverse ops for one `group_id` from log
90
+ provenance. It refuses a superseded group (exit 4; `--force`
91
+ overrides). Bare `mu undo` lists undoable groups (`-n` widens), a group
92
+ prefix previews, and `--yes` applies.
93
+
94
+ - **Undo and restore write through the tables**, so capture records
95
+ them like any other write.
96
+ - `restore` records and applies under the **same** HLC. `applyOp`
97
+ excludes an op's own HLC from provenance. A fresher HLC would make
98
+ the row outrank the op and lose every field to an insert default.
99
+ - **Note tombstones are self-describing.** Every other `del` carries
100
+ `'{}'`, because its key plus earlier puts describe the row. A note's
101
+ key embeds its rowid (`<ws>/<task>#<id>`), and a rebuild or
102
+ reprojection reassigns rowids. The `task_notes` delete trigger
103
+ therefore records `OLD.*`, and `planUndo` falls back to the
104
+ tombstone payload when folding the puts finds nothing. For the same
105
+ reason `src/drift.ts` matches notes on the task-key prefix.
106
+
107
+ ## Rebuild
108
+
109
+ `src/rebuild.ts` (`rebuildInto`) replays the whole log into a new DB
110
+ file through `applyOp`. The verb prints counts, supports `--json`,
111
+ suggests an `mv` swap, and warns that `agents` and `vcs_workspaces`
112
+ cannot be reconstructed.
113
+
114
+ Rebuild is not ingest:
115
+
116
+ - Ingest filters to `SYNCED_ENTITIES`. Rebuild replays everything, so
117
+ log-only entities are copied verbatim, or `mu log` comes back empty.
118
+ - `src/legacy-ops.ts` classifies historical log-only intents whose
119
+ entity looks projectable. Rebuild copies them without projection;
120
+ flush never emits them.
121
+ - `machine_identity` carries across whole: id, hostname and HLC clock.
122
+ Otherwise the rebuilt DB is a different peer minting HLCs below
123
+ every replayed op.
124
+
125
+ ## Drift
126
+
127
+ `src/drift.ts` has two tiers:
128
+
129
+ | Tier | Runs | Checks |
130
+ | --- | --- | --- |
131
+ | cheap (`checkCheapDriftInvariant`) | default `mu doctor` | every live row has at least one op naming its key |
132
+ | deep (`checkDrift`) | `mu doctor --deep` | rebuilds into a temp DB and diffs field by field |
133
+
134
+ The deep diff matches rows by natural key and excludes `owner_id`,
135
+ because a rebuild always has NULL owners. `driftRemediation` prints the
136
+ fix.
137
+
138
+ For disaster recovery, `mu db backup <file>` writes one scp-able file
139
+ through `VACUUM INTO`. It never overwrites; it is the copy
140
+ `SchemaTooOldError` tells you to take.