# Changelog before 3.0

Releases 2.0.0 and older, moved verbatim from [CHANGELOG.md](../../CHANGELOG.md). Newer releases live there.

## [2.0.0] — 2026-09-28

### Changed

- **Agent state comes from murmur.** mu no longer reads pane text to decide
  what an agent is doing, and no longer stores it. On tmux it reads
  [murmur](https://github.com/mu-crew/murmur) 1.0.0 or later: one
  `tmux list-panes` call for local agents, and `murmur status --json`
  (cached for 10 s) only for agents running on other hosts. On herdr it reads
  herdr. Without murmur, agent state shows `unknown` and `mu doctor` says why.
  Remote workers now report real state instead of an ssh-rendered pane.
- `mu task wait --stuck-after` times the stall from murmur's
  `@murmur_pane_since`, the moment the agent went idle, instead of the last
  time mu happened to reconcile.
- `mu agent spawn` waits for murmur to claim the pane. Without murmur it
  returns after the liveness check; the first `mu agent send` still waits
  for pi to accept input.
- `mu doctor` has an `agent state` row (`environment.agentState` in `--json`).

### Breaking

- Agent JSON (`mu agent list/show --json`, `mu state --json`, `mu me --json`)
  drops `status` and adds `state` (`busy`, `needs_input`,
  `needs_permission` or `unknown`), `source` (`murmur`, `herdr` or `none`),
  `since` (ISO time or null) and `reason` (when `unknown`).
- `mu agent wait` and `mu task wait --stuck-after` never fire on `unknown`.
  Without murmur they wait until `--timeout`.
- The `free` and `spawning` agent statuses are gone. `mu agent adopt` no
  longer marks an agent free.
- SDK: removed `AgentStatus`, `detectPiStatus`, `extractTail`,
  `updateAgentStatus`, `agentStatusGlyph`,
  `AGENT_STATUS_GLYPH` and `STATUS_EMOJI`. Added `readAgentStates`,
  `RuntimeState`, `StateSource`, `StateReading`, `LiveAgent`,
  `agentStateGlyph` and `MuxPaneStatus`.
- The `agents.status` column is unused and always `spawning`. It is dropped
  at the next schema migration.

### Unchanged

Tasks, claims, `mu task wait` on task status, workspaces, spawn, send and read
work the same with or without murmur.

### Upgrade

```sh
npm i -g @mu-crew/mu@2 @mu-crew/murmur@^1
murmur init        # once per machine
murmur link pi     # then restart pi sessions
mu doctor          # agent state : ok agent state from murmur 1.x
```

## [1.6.0] — 2026-09-27

### Moved

- **mu is now `@mu-crew/mu`, in the [mu-crew](https://github.com/mu-crew)
  org.** Install with `npm i -g @mu-crew/mu` and the skill with
  `npx skills add mu-crew/mu`. `@martintrojer/mu` stops at 1.5.0. The skill's
  remote-worker guidance now names mule (formerly coop).

### Changed

- **Pane titles now carry durable mu context, not sampled runtime status.**
  They retain the agent name and owned task summary, while live activity stays
  on reconciled state surfaces. Mu also leaves `pane-border-format` untouched so
  the user's tmux configuration can render a continuously updated observer's
  status; it still enables and styles borders for mu-managed windows.

- **Every operator-facing `mu task wait` hint now includes `--on-stall exit`.**
  Generated `Next:` output, CLI help examples, the usage guide, and the bundled
  skill no longer leave unattended orchestrators polling after a worker needs
  attention. The wait default remains `warn` for direct invocations.

### Fixed

- **A send immediately after `/new` no longer races pi's delayed screen transition.**
  The `/new` send now waits for the pane to visibly change and then stabilize
  before returning, without matching configuration-dependent pi output. A
  following `mu agent send` therefore cannot paste into a transition that the
  old screen had not started rendering yet.

- **The running TUI now marks workstreams torn down by another process.**
  Its launch-time tab set stays stable, while a missing workstream is dimmed
  and struck through on the next fast tick. A single-workstream TUI surfaces
  its normally hidden tab when torn down. Recreating the same name restores the
  tab without restarting the dashboard.

- **A re-delivered block of ops no longer wedges a peer's ingest forever.**
  When a peer's segment contains a byte-identical repeat of ops it already
  wrote, the monotonic-hlc check (layer 3) saw a backwards clock and halted,
  as it must for genuine reordering. But those ops are already in the local
  `ops` table, identified by the same `UNIQUE (machine_id, hlc)` that makes
  ingest idempotent, so re-applying them is a no-op and skipping them cannot
  produce a state neither machine had. Halting was also unrecoverable:
  `mu sync --repair` re-read from zero straight back into the same lines, and
  the advertised repair was the only one the CLI offers. Such a line is now
  reported as a new `duplicate-op` defect and skipped, exactly as
  `entity-not-synced` already was; a non-monotonic line that is NOT already
  recorded is still real damage and still halts. Observed in the field: three
  duplicated lines at position 21,347 of a 23,192-line segment held back 1,846
  later ops and left two machines in permanent, undiagnosable drift.

- **Full-DAG and single-task tree reads no longer take a query per task or make
  SQLite compare every task pair.** A redundant same-workstream join predicate
  made the planner choose a quadratic join for `loadFullDag`, while
  `renderTaskTree` separately walked the graph with an edge query plus task
  lookup per node. Both now load the indexed workstream DAG once. On the
  698-task / 1,418-edge dogfood graph, the full DAG fell from ~77ms to ~2.6ms
  and a 584-line task tree from 838 SQL prepares / ~36ms to 3 prepares / ~5ms.

- **Opening a large track in the TUI no longer fetches its tasks one by one.**
  The track drill performed two SQL reads per task; the largest live track made
  351 lookups and took ~33ms. It now loads the workstream's tasks once and
  filters them in memory (~2ms on the same data).

## [1.5.0] — 2026-09-19

### Changed

- **Node 26 is supported and the npm dependency set is current.** The declared
  engine range is now Node 22.12 through 26. `better-sqlite3` 13.0.3 loads its
  N-API binary and executes queries on Node 26; React, React DevTools, Vitest,
  tsup, Biome, fast-check, and the Node/React type packages move to their latest
  releases. The remaining direct packages were already current.

### Fixed

- **Switching TUI workstream tabs no longer blocks for seconds on the shallow
  drift check.** The note invariant used a correlated dynamic-prefix `LIKE`, so
  SQLite scanned every note op for every live note. The TUI runs Doctor on its
  eager slow-tier load after a tab switch; 4,384 notes therefore held the Node
  event loop for ~3 seconds. The equivalent half-open key range now uses the
  existing `(entity, key)` index and takes ~4ms on that DB. No background jobs,
  cancellation plumbing, or additional state were needed.

- **Parallel-track detection no longer runs one recursive SQL query per active goal.**
  The TUI recomputes tracks on every fast tick, so a large workstream paid an
  N+1 query cost once per second. It now traverses every goal's prerequisite
  subgraph in one recursive CTE and merges goals as shared tasks are observed.
  On a 1,500-goal fixture this reduced a warm track read from ~170ms to ~8ms;
  the dogfood workstream with 698 tasks and 1,418 edges retains the same 24
  tracks.

## [1.4.0] — 2026-09-16

### Added

- **`mu doctor` has a `housekeeping` section answering "which workstreams can I
  tear down?"** `mu workstream list` prints row counts and no dates, so the
  workstream finished in June looked exactly like the one worked on an hour ago.
  On the dogfood box that meant 11 workstreams, two of them untouched for three
  months, and nothing ever torn down — teardown is reversible, so the cost of the
  missing surface was not risk, it was that the list only grew.

  Two buckets, never one number, because merging them gives actively bad advice:

  - **finished** — every task `CLOSED` and idle ≥ 14 days. Nothing is at stake;
    the remediation is the `mu workstream teardown` command.
  - **abandoned** — idle ≥ 60 days but still holding unclosed tasks. Those tasks
    are the record of what was left to do, so the remediation lists them instead
    of offering a teardown. Teardown removes the plan, not the code: no checkout
    is touched (this list excludes workstreams that have one) and `mu undo`
    restores the rows — the cost is losing sight of the outstanding work, not
    losing it.

  The thresholds differ on purpose: a fortnight away from a project is a holiday,
  not abandonment. A single "dormant" list would have invited `--empty`-style
  sweeping of 27 open tasks on the box this was written against.

  Severity is always `ok` — a tidy-up opportunity is not a fault, and `warn`
  would make doctor claim something needs attention forever on a healthy box.
  Excludes the current workstream, `scratch` (ephemeral by design), anything with
  a live agent or registered workspace, and task-less workstreams (already
  `mu workstream teardown --empty`'s job). Report-only, like the rest of the
  `disk` section: every finding names its command and mu runs none of them.

  Idle time is derived from `MAX(tasks.updated_at)`, not a new
  `workstreams.last_activity` column — no schema change, and no second source of
  truth that can disagree with the rows it summarises. `mu doctor --json` gains a
  `housekeeping.dormantWorkstreams` key with the rows structured (`kind`,
  `idleDays`, `unclosed`) so an agent can act without parsing prose.

### Fixed

- **A peer's unrecognised op entity no longer freezes its watermark
  forever.** `applyOp` rejected every entity outside `SYNCED_ENTITIES`,
  and `ingestSegment` treated that rejection as segment damage: defect
  recorded, watermark left at the offending line, loop broken. On a real
  fleet a peer wrote 27 `entity:"marker"` ops (legal on the build that
  wrote them, whose vocabulary included it), the first at line 2533 of a
  20,305-line segment, and 87% of that peer's history never applied. The
  segment was intact — sha256 matched its manifest — so nothing surfaced
  the cause, and the suggested `mu sync --repair <peer>` only resets the
  watermark, so it re-read straight back into line 2533. A permanent wall
  advertised as a stale watermark.

  Ingest now separates three cases that were one:

  - **Damage** (torn write, crc mismatch, non-monotonic HLC, manifest
    mismatch) still halts at the first bad record. A gap in an ordered
    log is indistinguishable from reordering.
  - **A KNOWN machine-local entity** (the new `MACHINE_LOCAL_ENTITIES` in
    `src/db.ts`: `agent`, `workspace`, `event`, `broadcast`) is still a
    reported `entity-not-synced` defect — a peer shipping a pane id or an
    absolute path is a real bug — but the line is now SKIPPED rather than
    halting. It projects nothing, so it leaves no hole, and the rest of
    the segment arrives.
  - **An UNRECOGNISED entity** applies as a forward-compatible no-op,
    recorded in `ops` and projected nowhere, exactly like `message`. No
    defect. A reader's vocabulary may legitimately lag a writer's in a
    mixed fleet; the op survives for a later build and for `mu rebuild`.

  `mu sync` also stops suggesting `--repair` for a defect a re-read
  cannot clear.

## [1.3.3] — 2026-09-14

### Changed

- **Semantic state markers now consistently use `src/glyphs.ts`.** The
  all-tasks popup uses `GLYPH.blocked`, and multi-task summaries use
  `GLYPH.multi`, removing the last production literals outside the shared
  vocabulary. Source comments now name meanings instead of preserving an old
  visual alphabet.

- **Pane-title state now comes last.** Titles read agent, task context, then
  state. Long titles truncate the task portion while preserving the agent name
  used by identity fallback and the final state glyph used for quick scanning.

## [1.3.2] — 2026-09-13

### Changed

- **The `needs_input` stall warning states the observation instead of guessing
  a cause** (`agent_attention_required`, renamed from
  `agent_close_discipline_gap`). The warning asserted "Worker likely committed
  but skipped `mu task close <id>`", but the predicate behind it only knows
  "owner has been in `needs_input` for >= N". That covers at least three
  situations needing opposite responses: a worker that finished without
  closing, one waiting on an answer, and one sitting at an approval prompt.
  Naming the first misdirected an operator into hunting for a commit to merge
  while the worker was waiting on a design decision. The text now reports what
  was measured and points at `mu agent read <owner>`, which is the next move in
  all three cases — the evidence is in the pane, not the task row. The pattern
  rename follows: a worker stopping to ask a question is desirable behaviour,
  and the old name presumed negligence.

- **The warning reports the worker's real age, not the threshold.** It
  interpolated `--stuck-after`, so `--stuck-after 1` said "(>= 1000ms since
  last status change)" about a worker that had been waiting five minutes.
  `StallDetectedDuringWaitError.ageSecs` was wrong the same way. Both now carry
  the measured age, formatted as `45s` / `5m` / `2h`.

- **A stuck ref's `nextSteps` reads the owner's pane.** On timeout every unmet
  ref got `mu task show <id>`, which for this case shows a healthy-looking
  IN_PROGRESS row and none of the reason it stopped. `stuck` refs with a known
  owner now get `mu agent read <owner> --lines 60` instead; ownerless refs keep
  `mu task show`. `StallDetectedDuringWaitError`'s steps were reordered to lead
  with `agent read` for the same reason — they led with "poke the worker",
  which is right for one of the three causes and actively wrong for a worker
  waiting on an answer, since a poke with no answer in it just restates the
  question.

### Fixed

- **`mu task wait --stuck-after --help` no longer hides `--on-stall`.** Its
  description ended "Wait keeps polling — the warning is observation-only",
  which was written when `--stuck-after` was the only flag and has been false
  since 0.3.0 added `--on-stall exit`. Read in order it was a dead end, and a
  reporter filed a request for a flag that had existed for ten releases after
  stopping at that sentence. The text now names itself as the TRIGGER and ends
  by pointing at the ACTION.

- **`mu agent wait --help` explains when NOT to use it.** It fires on
  `busy → needs_input`, so it looks like the answer for catching a worker that
  asked a question — but it keys on agents, so a task-DAG orchestrator would
  have to track the task→agent mapping itself and run two waits concurrently.
  The description now says so and points at `mu task wait --stuck-after`.

---

## [1.3.1] — 2026-09-13

### Changed

- **All state glyphs now come from one file, `src/glyphs.ts`, and one font
  family.** Symbols were inlined at each render site — three separate `"✓"`
  literals (Recent card, Workspaces card, doctor card), `"⚠"` in two CLI
  formatters, `"⛓"` / `"⋈"` / `"★"` / `"ⓘ"` / `"●"` each at their call site —
  which is one place per literal for the next drift to start. `AGENT_STATUS_GLYPH`
  (was `STATUS_EMOJI`, kept as an alias) and the new `GLYPH` record are keyed by
  MEANING (`GLYPH.stale`, not a clock), so re-pointing a symbol is a one-line
  edit. The TUI's `superscriptDigit` folded in from `src/cli/tui/glyphs.ts`;
  two files called `glyphs.ts` was its own trap.

  Every glyph is now a classic Nerd Font `nf-fa-*` codepoint. Mixed families
  were the original bug: a Unicode emoji like ⚙️ is two codepoints that
  `cli-table3` sizes as width 2 but the terminal draws one cell wide, so rows
  mixing families misalign. `nf-md-*` slots move between font releases and
  render as the wrong icon.

- **`busy` agent glyph is `nf-fa-play` instead of `nf-fa-cog`.** murmur's dash
  paints a running pane with the play glyph, and mu drives the same panes. A
  gear reads as "settings" in every other TUI the operator uses; play reads as
  "this is running" with no lookup.

- **`scripts/migrate.ts` accepts v7 and restores pre-1.0 archives.** The
  sidecar already covered v8/v9 → v10. Real upgrade DBs are often still
  on v7 with empty live tables and the useful history only in
  `archived_*` — which the importer used to refuse unless
  `--drop-archives` threw that history away. It now auto-detects v7,
  synthesizes ops from `archived_tasks` / edges / notes / events into
  live workstreams named after each row's `source_workstream`, and keeps
  `--drop-archives` as the explicit skip. A live/archive task-key
  collision still refuses rather than merging two histories onto one
  key. See `scripts/README.md`.

---

## [1.3.0] — 2026-09-11

### Added

- **Remote dispatches now print a bounded, one-shot wait command.** `mu task
  wait` cannot fire for a remote worker, because nothing on the host can close
  the task — so every orchestrator invented its own poll loop, and the observed
  ones were unbounded `while true` with a hardcoded baseline. Record the
  workspace and per-worker baseline in a task note (`REMOTE: <host>:<path>`
  plus `REMOTE_BASE: <agent>:<sha>`); `mu task claim --for <agent>` then adds a
  `Next:` step with the real host, path, task id, agent name and baseline
  substituted. It dispatches a 30-second-bounded coop probe, validates the
  returned sha, and closes the task with it as evidence — which is what makes
  `mu task wait` usable again. **mu stays transport-free:** it prints the
  operator-owned network command and never opens the connection itself.

### Changed

- **The skill files are 25% smaller, with nothing non-recoverable lost.**
  `SKILL.md` 3537 → 2461 words and `REMOTE_WORKERS.md` 5343 → 4334, because
  both had grown into caches of `mu --help` and `coop --help` — verb lists,
  flag tables and an exit table that one command answers and that go stale
  silently. SKILL.md loads on every invocation, so this is context returned to
  the user's actual work. The coop reference material moved back to coop, whose
  `--help` now carries it and which warns at dispatch about the traps the skill
  used to teach.

  `AGENTS.md` gained a section explaining why these files are context rather
  than documentation, and the "adding a verb" checklist no longer instructs
  contributors to grow `SKILL.md` by default.

- **Remote-worker guidance corrected on two measured points.** Polling a capped
  host with bare ssh is not merely slow, it is wrong: eight concurrent
  `rev-parse` polls returned one sha and seven *empty* results, and an empty
  result compares unequal to the baseline, so a naive wait fires on a refusal.
  Route by whether a refusal could look like success, not by duration. Also
  `git -C <path> rev-parse` fails over ssh where `cd <path> && git rev-parse`
  works — the previous fragment used the broken form.

---

## [1.2.0] — 2026-09-09

### Breaking

- **`mu workstream destroy` is now `mu workstream teardown`, with no
  alias.** "Destroy" claimed irreversibility that was never true: the
  verb writes **tombstone** ops, so `mu undo <group>` restores every row
  it removed. The wrong word had a measurable cost — it drove
  defensive pre-flight `mu db backup` runs, 12 of them (270M) in one
  operator's home directory, named `mu-pre-destroy-*` and friends. The
  `--yes` two-phase dry-run is unchanged; only the noun was wrong.

  The old verb is GONE, not aliased: a half-renamed verb is worse than
  either end state. `mu workstream destroy` now exits with commander's
  unknown-command error.

  History is unaffected. New ops carry `intent='workstream.teardown'`,
  and the ~5k existing ops spelled `workstream.destroy` stay readable:
  `mu log` renders both as "workstream teardown" (it already did), and
  `mu log --intent workstream.teardown` matches both spellings via a new
  `LEGACY_INTENT_SYNONYMS` map in `src/legacy-ops.ts`. Without that, a
  rename would silently truncate history at the release boundary and
  `mu undo` would stop finding pre-rename groups — verified it still
  reverses a 1057-op teardown from 2026-08-26.

  `--json` field renamed with it: `destroyed` → `tornDown` on
  `mu workstream teardown --empty`. SDK: `destroyWorkstream` →
  `teardownWorkstream`, `DestroyResult` → `TeardownResult`.

### Added

- **`mu state` now inventories remote workers from task notes.** An exact
  `REMOTE: <host>:<path>` line appears in a `Remote workers` section and in
  the JSON snapshot's `remoteWorkers` array, preserving the only durable
  record of a remote workspace after its agent row disappears. Prose that
  merely mentions `REMOTE:` and malformed lines are ignored; no empty text
  section is rendered.

- **`mu workstream list --torn-down`** — past teardowns, read from the
  ops log, with the **group** id to pass to `mu undo`. A teardown is
  reversible, which is useless if the group cannot be found: bare
  `mu undo` lists only recent groups, so a teardown from last month
  needed hand-written SQL over `ops` to recover. Columns are name,
  group (short, the form `mu undo` accepts), relative time, and the
  task / edge / note counts the teardown removed, so a row says what is
  at stake without a second query. The `Next:` block prints the ready-to-run
  `mu undo <group>` / `--yes` pair.

  Read straight from the log rather than a projection — the log already
  holds it, and a side table would be one more thing that can disagree
  with it. Consequences that fall out of that: one entry per teardown
  rather than per name (a name torn down twice lists twice; the group is
  the identity), teardowns predating the 1.2.0 intent rename are
  included, and an entry a later op put back is marked
  `← recreated since` instead of being hidden, since "I tore this down,
  then undid it" is history worth seeing. The suggested undo always
  names the newest entry that is still restorable, so the printed
  command is never a no-op.

### Fixed

- **The remote-worker guide is 806 words smaller.** The coop section now keeps mu-specific routing guidance and measured failure evidence while delegating coop's flags, warnings, and exit table to `coop --help`. The attach-pane rule is consolidated with the session-cap diagnosis and detached-tmux fix instead of explaining the same contention twice.

- **The bundled mu skill is 1,042 words smaller.** Removed CLI-reference caches that `mu <verb> --help` already answers, consolidated dispatch and scratch guidance, and moved remote-only stall handling to `REMOTE_WORKERS.md`. Measured failure history, recovery traps, exit semantics absent from help, and the remote-worker pointer remain.

- **The `mu task wait --json` docs promised a `firing` field that
  `--any` never sets.** `docs/USAGE_GUIDE.md`, the `--first` help text
  and the comment in `src/cli/tasks/claim.ts` all said `firing` is
  populated "on `--first` / `--any` success". Only `--first` sets it.
  `--any` exits **0** with `firing: null` on a perfectly successful
  wait, so a documented consumer reading `.firing.qualifiedId` after
  `--any` dereferences null exactly when the wait worked. Corrected in
  all four places, including `skills/mu/SKILL.md`, and pinned by tests.

  Reported as "`--first` can exit 0 with `firing: null`", which it
  cannot: `--first` implies `any: true`, and both non-timeout returns
  in `waitForTasks` are guarded by `isDone()`, which for `any: true`
  means at least one ref reached the target — so the `?? null` fallback
  is unreachable, and a clean `--first` exit always names a ref. No
  exit code changed; the false documentation was the whole defect.

- **A prose log line poisoned the sync segment, and mu blamed a torn
  write for it on every invocation.** `encodeSegmentLine` interpolated
  `ops.payload` into the JSON line raw, on the assumption that the
  column always holds JSON. It does not: `mu log "text"` writes an
  entity-`message` op whose payload is bare prose, and prose containing
  a comma or a quote produced `"payload":Added 5 tasks, ...` — a line
  that is not JSON at all. Payloads are now passed through verbatim
  when they parse as JSON and encoded as a JSON string when they do
  not, with the wrapping undone on ingest so a peer's `ops` table holds
  the origin's bytes.

  The damage was noise, not loss — the `ops` table stayed canonical and
  `mu doctor` was clean throughout — but it was **permanent** noise. The
  segment self-repair regenerates from `ops`, and regeneration re-emitted
  the same unencodable payload, so the warning fired on every single mu
  command, forever. That is the real cost: it trains you to ignore sync
  warnings, and a genuine torn write then reads as background noise.

- **A complete-but-unparsable segment line is no longer reported as a
  torn write.** Layer 1 called every `JSON.parse` failure `torn-write`,
  which sent the reader hunting for a crash-during-write that never
  happened; the line above was 765 complete bytes ending in a valid
  `crc`. A line carrying its trailing `,"crc":"…"}` framing cannot have
  been cut off in flight, so it is now reported as `malformed-shape`.
  Different cause, different remediation: refetch the tail versus go
  read the writer.

- **An incompatible herdr server looked available on herdr 0.9.0.**
  0.9.0 split `herdr status`'s single `compatible: yes|no` line into
  `endpoint_compatible:` (the stable public API generation) and
  `private_protocol_compatible:` (the internal protocol). mu's
  availability gate matched `^\s*compatible:\s*no$`, which the new
  prefixed keys can never satisfy — so a server predating endpoint
  generation 1 passed the check, `activeMux()` picked herdr, and the
  failure surfaced on the first real verb instead of degrading to tmux.
  The two lines are now read for what they mean: `endpoint_compatible:
  no` is fatal, private-protocol skew is not (since 0.9.0 it disables
  individual actions and leaves running agents alone, so a client one
  release ahead of its server still drives panes). herdr ≤0.8.x's bare
  `compatible:` line is still honoured. The predicate is now one
  exported `isHerdrStatusUsable()` that `test/_mux.ts`'s
  integration-tier gate also calls — it had its own hand-rolled copy,
  which is precisely why the rename went unnoticed on both sides.

- **`mu workstream teardown` now refuses a herdr workspace group
  instead of failing with a bare mux error.** herdr 0.9.0 rejects
  `workspace close` with `workspace_group_close_required` when worktree
  workspaces are linked to the target. mu does **not** retry with
  `--group`: those siblings were created by `herdr worktree`, host panes
  mu never spawned, and closing them to satisfy a teardown would destroy
  unsaved work with no undo. The new `HerdrWorkspaceGroupCloseError`
  exits 2 (the operator-decision lane, beside the other herdr refusals)
  and prints the `herdr worktree remove` / `herdr workspace close
  --group` commands. Teardown kills the mux session before touching the
  DB, so the workstream is left intact and the command is safe to
  re-run.

- **`mu undo` of a pre-v10 `workstream destroy` could not run at all.**
  Three independent walls, each fatal on its own, all of which real
  history presents together:

  1. Legacy `workstream.export` ops are `entity='workstream'` carrying a
     PROSE payload. SQLite raises `malformed JSON` while STEPPING a
     `json_type()`, so the query failed before any row reached JS and
     `mu undo <group>` died with `Unexpected token 'w', "workstream"...
     is not valid JSON`. `src/legacy-ops.ts` existed for exactly this
     shape and `rebuild` / `segments` / `sync` already skipped it — the
     undo and apply paths never got the check. Now a shared SQL
     predicate (`LEGACY_LOG_ONLY_SQL_EXCLUSION`) excludes them in the
     WHERE clause, which a JS-side filter cannot do.
  2. Restores replayed `DEFERRED` / `REJECTED`, retired in 1.1.0, and
     the schema CHECK clause aborted the whole transaction on the first
     one. The apply path already normalized these to `OPEN`; that logic
     now lives in `normalizeTaskStatus` and both paths share it.
  3. Note tombstones written before 1.1.1 carry `'{}'`, and a note's key
     embeds a rowid that reprojection reassigns, so neither the per-key
     fold nor the (new) self-describing tombstone could resolve them and
     notes were silently not restored — 255 tasks and 266 edges came
     back with 0 of 535 notes. A third tier now recovers content from
     the puts for the same TASK. Ordinal pairing is deliberately NOT
     used: notes are a grow-only set keyed on (task, content), so
     identical prose collapses, and one task had 5 puts against 3
     tombstones. Restoring the distinct contents reaches the same end
     state the dedupe produced.

  Verified against a real 27k-op log: undoing a 1057-op destroy from
  2026-08-26 now restores 255 tasks, 535 notes and 266 edges, and the
  notes are byte-identical to an independent pre-destroy backup.
  `mu doctor --deep` clean afterwards.

  Consequence worth stating: a destroy has always been fully recoverable
  from the ops log — this bug meant the recovery PATH was broken, not
  that data was gone.

## [1.1.1] — 2026-09-03

### Added

- **`mu doctor` reconciles the state dir against the DB, in both
  directions** (new `disk` section; `src/disk-recon.ts`). Every other
  doctor check reads the database; nothing looked at
  `<state-dir>` and compared the two, so both kinds of disagreement
  were invisible:
  - `ws-rows` — a `vcs_workspaces` row whose path is gone. This had **no
    surface at all** before: `mu workspace list` printed such a row as
    healthy, and the next send or refresh against it failed deep inside
    the VCS backend rather than at the row that lied. Usually a hand-run
    `rm -rf` where `mu workspace free` was wanted.
  - `ws-dirs` — a workspace dir with no row, counting `stranded` ones
    separately. `listAllOrphanWorkspaces` already existed but was
    reachable only from `mu workspace orphans` and the TUI card, so
    running `mu doctor` to answer "is anything wrong" said nothing about
    dirs that will fail their next spawn.
  - `db-copies` — stray `mu.db*` files that are not the live WAL triple
    (49M on the box this was written against). Hand-made copies and
    pre-upgrade saves: nothing reads them and nothing prunes them.
  - `exports` — leftovers from `mu workstream export`, which 1.0 removed
    without removing its output directory. The one finding here that is
    a defect rather than housekeeping: mu deleted the producer and left
    the output unreferenced.
  - `ws-empty` / `locks` — empty per-workstream dirs (`ok` severity;
    `workspace free` leaves the parent behind) and advisory-lock dirs
    older than an hour, which are not a deadlock but are evidence of a
    spawn or flush that died mid-critical-section.

  **Report-only by construction.** Each finding carries its own cleanup
  command in the remediation block and mu runs none of them — an orphan
  or stranded dir may hold the only copy of uncommitted work, and a
  diagnostic that deleted checkouts because a `readdir` raced a spawn
  would be a worse bug than the residue it removed.

  Findings are `FleetHazard`-shaped, so doctor's row renderer, `--json`
  (new `disk` key, with remediation lines included) and the TUI Doctor
  card need no new shape. The default tier is `readdir` + `stat` at depth
  2 (~1ms).

- **`mu doctor --disk`** adds recursive per-checkout byte accounting with
  the orphan share called out as reclaimable (758M across 4 checkouts,
  ~2s, on the box above). Separate flag because its cost scales with the
  size of your checkouts rather than with mu's state, and `mu doctor`
  has to stay cheap enough to run reflexively.

### Documentation

- **[skills/mu/REMOTE_WORKERS.md](../../skills/mu/recipes/remote-workers.md) (now `recipes/remote-workers.md`)** — running
  agents on another machine. This already worked with no mu changes (the
  pane is local, the process is remote, so send/read/status/reaper are
  unaffected), but nothing said so and the failure modes are not
  guessable. Written from a mixed local+remote crew run against a real
  devserver: cherry-pick handoff needs no shared remote, since
  `git fetch ssh://host/path HEAD` reads a remote worktree directly; the
  local `$MU_PI_COMMAND` wrapper is not what a bare `pi` gives you on the
  host, which yields a healthy-looking pane with no models; a dropped
  connection reaps the task but strands the commit, so the remote path
  belongs in a task note; and a host with `MaxSessions 1` lets the
  agent's own ssh block the orchestrator's `git fetch` behind a
  misleading `Permission denied (keyboard-interactive)`. A separate file
  rather than more `SKILL.md`, which is loaded every session — the skill
  keeps a short stub and links out.

### Fixed

- **A throwaway `MU_DB_PATH` leaked phantom peers into the user's real
  sync dir.** Overriding the DB does not contain a test or a smoke
  run: sync is ambient, so the first flush stamps the fresh DB's new
  `machine_id` onto a segment in whatever `MU_SYNC_DIR` names. Nothing
  prunes segments — absence of one is the only way a peer disappears —
  so `mu sync` then lists a machine that never existed, forever. Eight
  had accumulated in one `~/mu`, each holding a single op from a
  disposable `workstream init` (`smoke`, `smoketest`, `doccheck`, ...),
  all traceable to the documented
  `MU_DB_PATH=/tmp/mu-smoke.db mu <verb>` recipe.

  `syncDir()` now refuses a sync dir outside `tmpdir()` when running
  under a test runner — the exact sibling of the `openDb` guard that
  refuses the user's real DB (`refuseUserDbDuringTests`), and for the
  same reason: the failure was silent, permanent, and invisible until
  someone read the folder. Tests that exercise sync legitimately
  already use a per-test `mkdtemp` dir and are unaffected. Production
  never sets `VITEST`, so the shipped CLI is untouched. AGENTS.md's
  smoke-test recipe now blanks `MU_SYNC_DIR` too, since a hand-run
  `node dist/cli.js` is not under vitest and the guard cannot help it.

- **`mu undo` of a workstream destroy silently failed to restore notes
  whose rowid had shifted** (drift-641). A note's op key embeds its
  rowid (`<ws>/<task>#<id>`), which is not portable: a rebuild, a v8/v9
  reprojection, or any path that reinserts a note assigns a fresh one.
  The historical `put` then sat under the OLD key while the `del`
  emitted at destroy time used the CURRENT one, so nothing joined them.

  `planUndo` reconstructs a tombstoned row by folding the puts for that
  exact key, so such a note folded to `{}` and `restoreRow` skipped the
  insert and reported no change. Undoing a destroy therefore brought
  back every task and edge but only the notes whose rowid happened to
  be unchanged — on the box this was found, 73 of 714 — and the 641
  orphaned rows then showed up as `mu doctor --deep` drift ("present in
  live tables but the log cannot explain it"), which breaks undo, sync
  and rebuild together since all three derive from the log.

  Note tombstones are now **self-describing**: the `task_notes` BEFORE
  DELETE trigger records `OLD.author/content/created_at` instead of
  `'{}'` (new `fullOldPayload` in `src/capture.ts`), and `planUndo`
  falls back to the tombstone's own payload when the fold comes back
  empty (new `payloadFields` in `src/undo.ts`, sharing
  `NEVER_RESTORE` and the scalar guard so a fallback can never
  reintroduce a surrogate id or FK). The inverse no longer depends on
  finding a matching put. Other tombstones stay empty — the key plus
  the preceding puts already describe them, and notes are the only
  entity whose key is not stable. Costs ~2 bytes per existing note
  tombstone in the log and needs no schema change; old empty-payload
  tombstones keep working through the fold path.

## [1.1.0] — 2026-08-28

### Breaking

- **`mu workstream` fields renamed to backend-neutral mux naming**
  (`find_workstream_json_tmux_named_fields_on_herdr`). The `mu
  workstream list` / `init` / `destroy` JSON shapes and CLI prose used
  `tmuxSession` / `tmuxAlive` / `killedTmux` and "tmux session" wording
  even when the active backend was herdr, contradicting the
  backend-agnostic `activeMux()` seam both verbs already went through.
  `WorkstreamSummary.tmuxSession` → `muxSession`,
  `WorkstreamSummary.tmuxAlive` → `muxAlive`,
  `DestroyResult.killedTmux` → `killedMux`; `WorkstreamOptions.tmuxSession`
  (the session-name override on `summarizeWorkstream` /
  `destroyWorkstream`) → `muxSession`. The `mu workstream list` table
  header and the `destroy` dry-run / result lines now say "mux" /
  "mux session" instead of "tmux". No deprecated aliases are kept —
  scripts consuming the old JSON field names must update. `mu agent
  spawn` / `mu agent adopt` / `mu state`'s unrelated `tmuxSession`
  session-override option (a different interface, orthogonal to this
  rename) is unchanged. `mu workstream init --json`'s own `sessionName`
  field is likewise renamed to `muxSession` for the same consistency.

- **Three-state task lifecycle (schema v10).** `REJECTED` and `DEFERRED`
  task statuses are removed. Only `OPEN`, `IN_PROGRESS`, and `CLOSED`
  remain. `mu task reject` and `mu task defer` are deleted. Use task
  notes (`mu task note <id> "won't do: ..."`) to record rationale;
  close the task to satisfy blocked-by edges. Pre-v10 databases are
  refused at startup with `SchemaTooOldError` (exit 4); no in-process
  migration ladder is provided. The retained `scripts/migrate.ts`
  sidecar auto-detects v8/v9 and writes a fresh v10 DB. For v9 sources,
  current `REJECTED` / `DEFERRED` tasks project as `OPEN` with a durable
  migration note, while original op payloads remain unchanged. The SDK
  no longer exports `rejectTask`, `deferTask`,
  `RejectDeferOptions`, `RejectDeferResult`, or
  `TaskHasOpenDependentsError`.

### Changed

- **`mu workspace create` and `mu workspace recreate` removed
  (surface-audit).** Both standalone verbs are gone with no
  compatibility aliases. Workspace creation is not an operator verb:
  it happens inside `mu agent spawn --workspace` (with
  `--workspace-backend` / `--workspace-from` /
  `--workspace-project-root`), and cleanup on `mu agent close` is
  unchanged. The remaining namespace is `list`, `refresh`, `commits`,
  `free`, `path`, `orphans`. Between waves use `mu workspace refresh`
  (preserves the worker's commits by rebasing onto fresh main); for
  destructive cleanup use `mu workspace free`. The SDK no longer
  exports `recreateWorkspace`, `RecreateWorkspaceOptions`, or
  `RecreateWorkspaceResult`, and `src/workspace/recreate.ts` is
  deleted; the lower-level `createWorkspace` remains, since spawn
  needs it. `workspace.recreate` is removed from `LocalIntent` and the
  log-render verb table, and the private `_suppressEvent` flag on
  `createWorkspace` / `freeWorkspace` (which only existed to give
  recreate one atomic event line) is gone. Operator-facing hints that
  recommended free + create — the `mu state` stale-workspace tip and
  the `mu task wait` next-step recipe — now recommend
  `mu workspace refresh`.

- **`mu agent ensure`, `poll`, `reap-idle`, `free`, and `attach` removed
  (surface-audit).** These five secondary convenience verbs are gone with
  no compatibility aliases. The remaining lifecycle is: `spawn`, `send`,
  `read`, `show`, `list`, `wait`, `kick`, `adopt`, `close`. SDK helpers
  `ensureAgent`, `freeAgent`, `pollAgents`, `reapIdleAgents` and their
  types are removed from `src/agents.ts` and `src/index.ts`.
  `agent.free` removed from `LocalIntent` and the log-render verb table.

- **TypeScript 7 (the Go-native compiler) for typechecking.**
  `npm run typecheck` drops from ~9s to ~0.6s. The upgrade is not a
  plain version bump, because TS 7.0 ships **no programmatic compiler
  API** — `ts.createProgram` and friends are gone, the package exports
  two keys, and the API is deferred to 7.1. `tsup --dts` goes through
  `rollup-plugin-dts`, which needs that API, so `typescript@7` alone
  builds JS and then dies on declaration emit.

  Resolved with the aliasing the TS team documents for exactly this
  case: `@typescript/native` is TS 7 and provides the `tsc` binary,
  while `typescript` resolves to `@typescript/typescript6` so library
  consumers still get the JS API. The two bins do not collide (`tsc`
  vs `tsc6`). Revertible to a single dependency once 7.1 ships its API
  and `rollup-plugin-dts` adopts it.

### Changed (docs / help)

- **Docs and `--help` text realigned to the reduced surface
  (`align-small-cli-contract`).** AGENTS.md's schema-version note and
  module-layout comments now say v10 (was v9) and drop the removed
  `archives.ts` / `exporting.ts` / `cli/archive.ts` rows and the
  `workspace create`/`recreate` and `agent free`/`attach` verbs from
  the file-tree annotations. `src/cli.ts`'s header comments no longer
  hardcode a verb/namespace count that drifts every time a namespace
  is added or removed. `src/cli/agents.ts`'s `spawn` / `send` / `list`
  / `adopt` help text is backend-neutral ("mux pane" / "the active
  mux") instead of naming tmux specifically, matching the
  `activeMux()` seam those verbs already go through; `send`'s
  description now names herdr's atomic `agent prompt` alongside
  tmux's bracketed-paste. `docs/USAGE_GUIDE.md`'s three-state task
  lifecycle: the "placeholder task" cancellation example no longer
  references the deleted `mu task reject --cascade`, the TUI keymap
  table and per-status-toggle prose drop `r` / `d` (REJECTED /
  DEFERRED), the status colour line drops the two dead colours, and
  `--if-ready` / `--reopen` / `release` prose drop the two dead
  terminal statuses. The keymap reference table also gains the four
  real keys it was missing (`a` attach, `l` lazygit, `b` blocked-filter
  cycle, `c` clear footer) so `?`/`docs` and the actual dashboard
  agree. `src/tasks/lifecycle.ts`'s capture-trigger comment no longer
  lists `task.reject` / `task.defer` as live intents this file can
  still produce (they only appear in historical pre-v10 ops, rendered
  by `src/log-render.ts`, which is unchanged and correct). Finally,
  `mu workstream init --json`'s `sessionName` field is renamed to
  `muxSession`, matching `mu workstream list` / `destroy`'s naming
  from the mux-neutral-fields rename above — the one JSON field that
  rename missed.

- **Final documentation sweep against the built CLI
  (`final-doc-sweep`, surface-audit).** Docs still described
  behavior removed by the second cleanup round or never matched
  the shipped binary. `docs/USAGE_GUIDE.md`'s worked examples now
  read "mux session" / `Attach the session : tmux attach -t
  mu-<name>` instead of the pre-mux-rename "tmux session" /
  `tmux a -t mu-<name>` wording (verified against a live `mu
  workstream init`); the `mu doctor` sample output now shows
  `schema_version : 10`; `mu workstream destroy`'s sample output
  drops the `Pre-destroy export:` line left over from the removed
  auto-export. `docs/ARCHITECTURE.md` and `AGENTS.md`'s TUI
  module-tree comments drop the removed `r`/`d` (REJECTED/DEFERRED)
  toggle keys from the `use-status-filter.tsx` annotation (now
  `o/i/c`) and `AGENTS.md`'s `src/agents/` and `src/mux/` listings
  gain the `spawn-lock.ts`, `wait.ts`, and `index.ts` rows that
  existed in source but not in the tree; the TUI cluster listing
  gains `use-terminal-size.ts`, `agent-display.ts`, `lazygit.ts`,
  and `tmux-attach.ts`. `AGENTS.md`'s `tasks/lifecycle.ts` and
  `cli/tasks/lifecycle.ts` comments drop `rejectTask`/`deferTask`
  and `reject`/`defer`, which the three-state-lifecycle removal
  already deleted from those files. `skills/mu/SKILL.md` no longer
  claims herdr `agent spawn`/`send`/`read` are unimplemented (they
  shipped) and drops `free` from its agent-verb list (`mu agent
  free` was removed with the other secondary agent verbs); its
  vocabulary entry now points at `mu agent kick`'s Linux-only gap
  instead. `docs/VISION.md`'s "no dry-run on most mutations" gap
  now names `task delete` and `undo`, which gained two-phase
  dry-run/`--yes` after that line was written, and its iteration-
  speed LOC/test counts are corrected from a stale ~30k LOC /
  ~2800 tests to the current ~95k LOC / ~2900 tests (`wc -l` over
  `src/` + `test/`; `npm run test`). `docs/ROADMAP.md`'s rejected-
  substrates table drops "and archives" from a capture-bug
  consequence list — the archive namespace no longer exists to
  corrupt. Historical CHANGELOG entries, the v8/v9 migration recipe
  in `scripts/README.md`, and legacy task-status compatibility text
  are left untouched — they describe what shipped, not current
  behavior. Verification: `mu --help` / relevant `mu <verb> --help`
  cross-checked against every corrected claim, `npm run
  typecheck && npm run lint && npm run test:fast && npm run test &&
  npm run build` all green.

### Fixed

- **Removed the dead `suppressSnapshot` field from
  `DestroyWorkstreamOptions`** (`reviewfind_dead_suppresssnapshot_field`).
  It was already a documented no-op since v9 dropped the `snapshots`
  table, and no caller in the codebase ever set it — a pure
  speculative-flexibility leftover the code-reviewer's Speculative
  Generality smell flags. No behaviour change.

- **`mu sync --from <peer.db>` crashed on the same legacy
  `workstream.export` op the two sibling readers already skip.**
  `ingestFromDb()` filtered rows by `SYNCED_ENTITIES` only, so a
  peer DB carrying a pre-1.0 `workstream.export` op (prose payload,
  synced `workstream` entity) reached `applyIncomingOp` and threw
  `SyntaxError` from `JSON.parse`, aborting the whole `--from` read
  in one transaction. `flushSegment` and `rebuild.ts`'s
  `isProjectable` already call `isLegacyLogOnlyIntent`;
  `ingestFromDb` now does too, folding the skip into the existing
  `skippedLocal` counter like the segment path does.

- **`flushSegment` no longer regrows a corrupted own segment forever.**
  `readSegmentTail` could not distinguish clean EOF from a defect, so
  `flushLocked` repeatedly re-derived and appended the same tail after
  damage. It now truncates to the last verified-good line before
  regenerating from the canonical ops log, and reports the repair so
  `mu sync` and ambient sync warn instead of failing silently.

- **Removed commands no longer erase their historical op compatibility.**
  `workstream.export` ops used a synced `workstream` entity but carried a
  prose payload. The export command is still removed, but its intent now
  remains in a narrow permanent compatibility classifier used by rebuild
  and segment flush. Old databases therefore retain the row as history
  without sending it to `applyOp` or writing malformed segment JSON.

- **Log-only ops are no longer replayed, flushed, or projected
  (`workstream.export`).** `emitEvent` derives an op's entity from its
  intent prefix, so `workstream.export` lands on `entity='workstream'`
  — a synced, projectable entity — while carrying a PROSE payload
  rather than JSON. `rebuild.ts` already excluded it by intent; two
  other consumers did not, and each broke differently:

  - `mu archive restore` crashed with `Unexpected token 'w'` whenever
    the marker sat above a `workstream.export` op. Reachable on any
    archive, because `workstream destroy` auto-exports and re-pinning
    puts that op BELOW the new marker. Observed on a real DB: 8 of 10
    archived workstreams refused to restore.
  - `flushSegment` wrote a MALFORMED segment line for it, because
    `encodeSegmentLine` embeds the payload as raw JSON. Worse, it
    compounded: `readSegmentTail` stops at the first bad record to
    recover its watermark, so the watermark rewound and every later
    flush re-appended the whole tail. Observed in the wild: a 2.7 MB
    segment grown to 102 MB, 221,627 lines holding 12,978 distinct
    ops, some repeated 96 times. A corrupt segment is repaired by
    deleting it and re-running `mu sync` — the ops log is canonical,
    so the segment is rebuilt from it with no data at risk.

  `LOG_ONLY_INTENTS` moved from private to exported so all three
  consumers shared one list rather than three copies drifting apart.
  The list is now gone along with its only member — see the
  `mu workstream export` removal below.

- **`mu archive list` no longer blows past the terminal width.** One
  label accumulates markers from many workstreams (cross-workstream
  pinning is the point), so a dozen names joined into a ~150-char cell
  and pushed the table to ~206 columns. The `workstreams` column now
  takes a budget from the actual terminal width and overflows into
  `+N more`, so the hidden count — the thing that tells you whether to
  run `mu archive list <label>` — survives. `--json` still emits every
  name, and the per-label view is unchanged. Same failure and same
  remedy as the `path` column in `mu workspace list`
  (`tables_truncate_long_cols_audit`), which this table was missed by.

### Removed

- **`src/parked.ts` and the "presumed parked" heuristic deleted
  (`audit2find_dormant_parked_heuristic`, surface-audit).**
  `parkedStatus` never fired in production: it keyed on the most
  recent op having intent `workstream.export`, but nothing in-tree has
  emitted that intent since `mu workstream export` was removed (see
  below), so it returned `{ parked: false }` unconditionally. The
  module's own header called this out as a deletion candidate if the
  peer-watermark re-grounding never happened; it never did. Deleted
  with it: `mu workstream list`'s dead `parked` column (always
  rendered `—`), the TUI tab strip's unreachable `~` marker and its
  precedence-over-scratch logic, `WorkstreamSummary.parked`, and the
  SDK exports `parkedStatus`, `ParkedStatus`, and
  `WORKSTREAM_PARKED_THRESHOLD_DAYS`. The legacy `workstream.export`
  intent stays recognized by `src/legacy-ops.ts` for reading ops
  written by older versions (rebuild / segments / sync compatibility)
  — only the dead `parkedStatus` consumer surface is gone.

- **`mu workstream export` deleted, along with the markdown bucket
  renderer (surface-audit).** The read-only markdown artifact was a
  second, lossy way to get data out of mu, and it paid for itself in
  complexity rather than use: a bucket layout with its own version
  discriminator, a manifest schema with its own migration path,
  per-file sha256 idempotency, and a deleted-task preservation banner
  — all for output nothing could read back in. `src/exporting.ts`
  (~600 LOC) and `exportWorkstream` are gone, as are the
  `ExportManifest` / `ExportSource` / `ExportTaskEntry` /
  `RenderBucketInput` / `RenderBucketResult` / `ExportResult` /
  `ExportWorkstreamOptions` types and `EXPORT_MANIFEST_VERSION` from
  the SDK.

  `mu workstream destroy` no longer auto-exports before destroying, and
  `--no-export` is gone with it. Destroy is now just preview → confirm
  → destroy. The pre-destroy safety copy is `mu db backup <file>`;
  reversing a destroy is `mu undo <group> --yes` (destroy writes
  tombstone ops, so the history survives either way).

  No aliases, no deprecation window, no replacement format. The three
  surviving paths each do one job properly: **sync** moves state
  losslessly between machines, `mu db backup` takes the safety copy,
  and `mu rebuild` / `mu undo` recover from the ops log. Reading the
  graph out for review or grep is `--json` on any verb.

  `workstream.export` is removed from `LocalIntent` and the
  log-render verb table, so the intent can no longer be emitted. That
  empties `LOG_ONLY_INTENTS`, whose entire purpose was excluding this
  one intent from segment flush and rebuild projection: every
  remaining `LocalIntent` is `agent.*` or `workspace.*`, neither of
  which names a synced entity, so the entity check alone is again a
  sufficient classifier. `isProjectable` drops its `intent` parameter
  and `src/segments.ts` drops its import of it.

  `src/parked.ts` keys its dormant heuristic on `workstream.export`
  and is now dormant by construction rather than by accident — no code
  path emits the marker. It is left in place (the branch still
  classifies ops written by older versions) but documented as a
  deletion candidate if the peer-watermark re-grounding never happens.

- **`mu archive` namespace deleted** (`add`, `list`, `restore`, `export`). The
  marker machinery was a point-in-time snapshot concept that added complexity
  without paying its way: destroyed-workstream recovery is `mu log
  --intent workstream.destroy --all` then `mu undo <group> --yes`; the
  pre-destroy safety copy is `mu db backup`. The `marker` entity is removed
  from `SYNCED_ENTITIES`; `src/archives.ts`, `src/archives/`, and
  `src/cli/archive.ts` are gone. `workstream destroy --archive <label>` is
  gone. `scripts/restore-pre1.0-archives.ts` is gone.

- **`searchTasks` SDK export deleted (`reviewfind_searchtasks_dead_sdk_export`).**
  Zero production call sites (`mu state` uses `listBlocked`,
  `mu tracks` uses `listGoals`, but nothing consumed `searchTasks`
  despite the comment in `src/cli/tasks/queries.ts` claiming it did).
  `mu sql` already covers ad-hoc title/note search. `searchTasks`,
  `SearchTasksOptions`, their re-exports from `src/tasks.ts` /
  `src/index.ts`, and the stale comment claiming internal reuse are
  removed, along with the function's sole unit test.

### Added

- **One consolidated migration sidecar: `scripts/migrate.ts`.** It replaces
  the former v8-only importer, supports direct v8 or v9 → v10, opens the
  source read-only, writes only a fresh target by default, and verifies
  source SHA-256 stability. The v9 path preserves the complete ops log,
  machine identity/HLC, peer watermarks, and referentially valid local
  agents/workspaces/owners. Legacy task statuses normalize only in the
  shared apply path, so old segments and rebuilds also project safely.
  See `scripts/README.md` for the exact backup/verify/swap recipe and the
  explicit limits on validating pane ids and absolute workspace paths.

- **`scripts/restore-pre1.0-archives.ts`** — carries pre-1.0 archives
  into v9, which `scripts/migrate.ts` refuses to do. That
  refusal rests on two true claims (a v9 archive is a marker pinning
  the ops log; v8's `workstream destroy` deleted rows rather than
  writing tombstones, so the ops to pin do not exist) and one
  conclusion that does not follow: v8's `archived_tasks` /
  `archived_edges` / `archived_notes` retained enough per row to
  SYNTHESIZE those ops. The script mints them the same ops-not-rows
  way the v8 importer does, then pins a marker above them.

  Exact for a source workstream that no longer exists. For one that is
  still live, a marker necessarily pins CURRENT state, so the script
  compares archived rows against live ones field by field and REFUSES
  unless they agree (`--allow-divergent` to override). Verified
  end-to-end on a real DB: 680 tasks, 551 edges and 1376 notes
  round-tripped through `mu archive export` with zero field
  mismatches.

## [1.0.1] — 2026-08-10

**mu now supports two multiplexers.** tmux was hardwired: ~382
references across 12 modules, a pane-id regex assumed to be `%N`
everywhere, and remediation hints that spelled tmux commands at
whoever was reading. This release turns the multiplexer into a
backend, adds [herdr](https://github.com/martintrojer/herdr) as the
second one, and routes every call site through it.

**Where herdr actually stands.** Spawn, send, read and status
detection all work, alongside topology, identity and diagnostics. The
remaining differences are narrow rather than blocking: `mu agent kick`
is Linux-only on herdr (herdr reports a shell pid, not a tty, so
`paneTTY` resolves `/proc/<pid>/fd/0`), a pane on the alternate screen
cannot have scrolled-off rows recovered by `--lines`, `herdr pane list`
carries no foreground-command field, and `MU_<UPPER_CLI>_COMMAND` has
no herdr equivalent because herdr resolves the agent binary itself.
See [docs/USAGE_GUIDE.md § 20](../guide/backends.md)
for the full difference table.

Verified end to end on herdr 0.8.0, not just in tests: `mu agent spawn
worker-1 --cli pi` produced a real pi agent, `mu agent send` delivered
through the one-call atomic protocol, the agent replied, `mu agent
read` returned it, and `mu agent list` showed status resolved natively
by herdr.

### Added

- **`MuxBackend`: the multiplexer is a backend seam** (`src/mux/`),
  same shape as `src/vcs/` — `types.ts` (interface + `MuxError` /
  `PaneNotFoundError` / `NoMultiplexerError`), `detect.ts` (the ladder
  plus `activeMux()` memoization and a `setMuxForTests` seam),
  `tmux.ts` (the existing implementation, moved), `herdr.ts`, and an
  `index.ts` dispatcher. `src/mux.ts` is the public hub; `src/tmux.ts`
  survives as a back-compat re-export for genuinely tmux-only concerns
  (the `MU_TMUX_SOCKET` isolation seam, the shared `sleep` poll seam).
- **`mu agent spawn` works on the herdr backend** — as a two-step
  create-then-start, because herdr has no create-and-run form.
  `workspace create` / `tab create` / `pane split` always start a plain
  shell, and `agent start` never creates, splits or moves layout: it
  requires an already-existing pane at its interactive prompt. So mu
  creates the pane **bare**, then runs `herdr agent start <name> --kind
  <cli> --pane <id>`.

  The seam is a new **optional** `MuxBackend.startAgentInPane()`, and
  its *absence* is the default shape: tmux creates-and-runs in one call,
  so it does not implement it and its spawn path is untouched.
  `src/agents/spawn.ts` branches on the **capability**, never on
  `mux.name`.

  What collapses on herdr: `agent start` returns only once herdr has
  detected the expected agent in that pane and considers it ready for
  input (30s default, herdr's own). That is strictly stronger than
  scrollback polling, so `awaitSpawnLiveness` / `awaitSpawnReadiness` —
  and with them `MU_SPAWN_LIVENESS_MS` / `MU_SPAWN_READINESS_MS` — are
  subsumed and not consulted. The tmux polling loop was **not** ported.

  Three decisions worth recording, all of them refusals:

  - **An unknown `--cli` is refused, not shell-run.** herdr's `--kind`
    is a closed enum of 21 agent kinds. Falling back to `pane run
    <command>` would start the binary but leave herdr unable to classify
    that pane — and on this backend herdr's classification *is* mu's
    status source. A pane mu can start but never observe is the same
    family of failure as a pane with nothing running in it. mu does not
    hardcode the kind list: it forwards `--cli` and translates the
    rejection, so a herdr release adding a kind needs no mu change.
  - **`MU_<UPPER_CLI>_COMMAND` and `--command` are refused, not
    ignored.** `agent start --kind` resolves the canonical executable
    itself and has no override flag; args after `--` go to the agent,
    not to executable selection (verified: `--kind pi -- --model m`
    reports `argv:["pi"]`). Accepting an override and not honouring it
    was the one outcome ruled out, so mu refuses and names the exact
    knob to change. Both refusals exit **2** (usage) rather than 5: the
    substrate is healthy and answered precisely.
  - **Agent names pass straight through.** herdr requires
    `[a-z][a-z0-9_-]{0,31}` unique among live agents, byte-identical to
    mu's `isValidAgentName`. A test asserts the equivalence over both
    boundary probes and a small fuzz, so a future widening of either
    side fails loudly here instead of surfacing as a confusing
    herdr-side rejection after a pane already exists.

  The **anti-empty-shell property** is preserved end to end: the
  creation verbs still *refuse* a non-empty command rather than dropping
  it, and every failure in step 2 — timeout, rejected name, name already
  live, unsupported kind, refused override — routes through
  `rollbackSpawn`, which kills the bare pane and deletes the row. There
  is no path where mu records an agent for a pane with nothing in it.

  Two things found by probing a real server that would have bitten
  later:

  - **herdr pane ordinals are Crockford base32, not decimal.** Opening
    38 panes yields `p1…p9, pA…pH, pJ, pK, pM, pN, pP…pZ, p10, p11…`
    (and `wA` after `w9`). The previous `/^w\d+:p\d+$/` accepted exactly
    nine panes per workspace and then started rejecting *live* ids —
    a `TypeError` on the tenth agent. Now matched as an opaque
    `[0-9A-Z]+` run.
  - **`agent start` immediately after `pane split` loses a race**
    (`agent_pane_busy`, ~100% of the time; a 200ms gap always
    succeeded). The pane exists the moment split returns but its shell
    has not drawn a prompt. Since the condition is observable, mu
    retries on that specific code instead of pre-sleeping a guessed
    constant.

- **`TmuxError` now extends `MuxError`**, so `handle()` maps every
  backend's error family to exit 5 through one `instanceof`. Its
  classify label changed from `tmux` to `mux`, and
  `NoMultiplexerError` gets its own.

- **The herdr backend** (`src/mux/herdr.ts`), verified against herdr
  0.8.0 / protocol 19. The locked mapping is **mu workstream = herdr
  workspace labelled `mu-<workstream>`, mu window = herdr tab, mu
  agent = herdr pane**. herdr's own "session" is server-level (one
  socket) and is deliberately NOT the workstream unit — mapping to it
  would have made one herdr server hold one workstream.

  Decisions worth keeping:

  - **Addressing is by label, resolved to an id per call.** mu
    persists a session *name*; herdr addresses workspaces by opaque
    handle (`w1`). Ids are always READ from creation responses
    (`.result.workspace`, `.result.tab`, `.result.pane.pane_id`),
    never predicted — herdr does not reuse closed ids and a pane
    moved between workspaces is renumbered.
  - **Exit 2 is a bug in mu, not an outage.** Server errors arrive as
    JSON on stderr with exit 1 (→ `HerdrError`, a `MuxError`, exit 5);
    *syntax* errors exit 2 (→ `HerdrSyntaxError`, deliberately outside
    the `MuxError` family). Bucketing CLI drift as "herdr is down"
    would send operators chasing a healthy server.
  - **`--no-focus` on every mutating call**, even when the caller asks
    for an attached session. A background agent manager must never
    move the user's focus.
  - **Creation verbs refuse a command rather than dropping it.**
    Silently dropping `opts.command` would leave an empty shell that
    mu records as a live agent — the worst available failure mode.
  - **`selectLayout` and the pane-border chrome are no-ops.** herdr
    has no layout algorithm (splits are explicit) and owns its own
    pane chrome; mu-managed panes are marked by their label.

  `MU_HERDR_SESSION=<name>` routes every call through a named herdr
  server, the isolation seam mirroring `MU_TMUX_SOCKET`.
  `setHerdrExecutor` / `resetHerdrExecutor` mirror the tmux executor
  seam so the fast tier never shells out.

  Command-running spawn (a creation verb carrying `opts.command`) still
  throws `HerdrNotImplementedError` naming its owning task rather than
  guessing at a protocol.

- **herdr mux backend — IO half**: `sendToPane`, `capturePane`, and
  native pane status. Mostly a DELETION relative to tmux.

  - **The send protocol collapses from six steps to one.** tmux needs
    `awaitPaneQuiescence` before, an `MU_SEND_DELAY_MS` gap in the
    middle, and a confirm-the-Enter-took retry loop after, purely
    because a TUI rendering a modal swallows a separately-sent Enter and
    strands the pasted text while `mu agent send` reports exit 0
    (`dogfood_send_after_new_dropped`). `herdr agent prompt <pane>
    <text> --wait` submits the text *and* an encoded Enter atomically,
    honouring the pane's live bracketed-paste mode, so none of that can
    apply. No `--until`: `--wait` already defaults to the first settled
    idle / done / blocked. `readinessMs: 0` drops `--wait`; `delayMs` is
    accepted and ignored, so callers need no backend branch.
  - **An unconfirmed send warns, never throws** — the same contract as
    tmux. herdr's `agent_prompt_stalled` (a prompt from a non-working
    state that produced no lifecycle change within 5s) maps onto the
    existing `SendWarning` surface with reason `paste-vanished`.
  - **Panes with no recognized agent fall back to the pane surface.**
    `agent prompt` only addresses targets herdr has classified as an
    agent; a plain shell pane answers `agent_not_found`, and mu retries
    once through `pane run`, herdr's atomic text+Enter for raw shells.
    Any other server error propagates instead of being re-run as a
    shell command.
  - **`capturePane` uses `pane read --source recent-unwrapped`** (soft
    wraps joined; herdr's recommendation for logs and transcripts),
    with `lines: 0` mapping to `--source visible`. Known limit,
    documented in the code: a pane on the terminal's ALTERNATE screen
    does not spill rows into herdr's host scrollback, so a larger
    `--lines` cannot recover scrolled-off output.

- **`MuxBackend.paneStatus?()` — status detection is now genuinely
  backend-dependent.** An optional method returning the pane's
  lifecycle status *as the mux itself classifies it*. tmux omits it and
  mu keeps scraping scrollback with the per-CLI detector in
  `src/detect.ts`; herdr implements it from its native `agent_status`,
  so the detector is bypassed entirely — guessing from a 100-line tail
  would be strictly worse information and would misclassify every
  non-pi CLI. The mapping is `working` → `busy`, `blocked` →
  `needs_permission`, and `idle` / `done` / `unknown` → `needs_input`.
  `unknown` deliberately does **not** map to `free`: herdr documents
  that it does not prove completion, and a false `free` would make mu
  hand the worker a second task mid-run. Nothing on this path calls a
  focus or seen-marking verb, so mu's polling never clears the user's
  `done` badge.

- **Mux detection**: `MU_MUX` → `HERDR_ENV=1` → `$TMUX` / `$TMUX_PANE`
  → availability → `NoMultiplexerError`. `HERDR_ENV` sits above the
  tmux rung because herdr routinely runs a tmux server inside its
  panes, so both signals can be live at once and `HERDR_ENV=1` is the
  narrower claim ("herdr manages THIS pane"). The comparison is
  exactly `= 1`, as `herdr --skill` mandates — truthiness would
  misfire on a stale `0`. `MU_MUX=<name>` selects a backend
  explicitly and an unknown value throws rather than falling through;
  tmux still wins a pure availability tie as the incumbent.

### Changed

- **`MU_MUX` is load-bearing.** The seam shipped with tmux as its sole
  consumer, so every verb still reached tmux directly and the override
  did nothing (`MU_MUX=herdrr mu agent list` exited 0). It now exits 1.
  Migrated: `src/agents.ts`, `src/agents/{spawn,adopt,kick}.ts`,
  `src/reconcile.ts`, `src/workstream.ts`,
  `src/cli/{agents,workstream,doctor}.ts`, and
  `src/cli/tui/tmux-attach.ts`.

  Every migrated site is classified **load-bearing** or
  **best-effort**, and the distinction is the substance of the change:
  with a second backend in play, "no reachable multiplexer" stops
  being a broken-box edge case and becomes routine. Spawn, send, read,
  kill, adopt, reconcile and session create/destroy let
  `NoMultiplexerError` propagate to exit 5. Identity, pane titles,
  banners, liveness hints and workstream listings degrade instead —
  `mu workstream list` on a box with no multiplexer reports the
  registered set rather than failing. Reconcile is pointedly in the
  first group: treating an unreachable mux as "zero panes" would prune
  every agent as a ghost.

  Three sites needed judgement rather than a mechanical rewrite, and
  each grew a method on `MuxBackend` so no caller spells a
  backend-specific string:

  - `attachHint()` / `attachCommands()` — the copy-pasteable line
    printed by `mu agent attach` and `mu workstream init`, and the
    argv the TUI's `a` key executes. A herdr user must never be shown
    a tmux command.
  - `healthCheck()` — version plus the backend's own ambient env facts
    as a typed record. `mu doctor` owns all rendering; `--json` gains
    an `environment.mux` object, with the old `environment.tmux` key
    kept as an alias now reporting the active backend.
  - `PaneNotFoundError` takes the backend that raised it and borrows
    its remediation steps. Without one it offers only mu's own verbs
    instead of guessing tmux.

  Also new on the interface: `currentSessionName()`, backing the
  `mu-<name>` rung of workstream auto-detection
  (`resolveTmuxSessionWorkstreamName` →
  `resolveMuxSessionWorkstreamName`). `parseAgentNameFromTitle` moved
  to `src/mux/types.ts` — the title format is mu's, not any
  multiplexer's.

- **Agent identity is env-first**: `$MU_AGENT_NAME` (already injected
  at spawn) is consulted before the backend's pane-title lookup, which
  adopted panes still need. A worker in a pane therefore does not care
  which multiplexer it is in, and the in-pane loop documented in
  `skills/mu/SKILL.md` is unchanged.

- **Vocabulary: the multiplexer is a backend.** mu's substrate is now
  described as a **mux session** — a tmux session on the tmux backend,
  a herdr workspace on the herdr backend — with **window** and
  **pane** unchanged below it. VISION pillar 5 goes from "One
  workstream per tmux session" to "per mux session"; Key Constraint 1
  becomes "A multiplexer is required"; pillar 6 softens from "Pi-only"
  to "Pi-first", since status detection is a property of the backend
  rather than of mu.

  Two naming decisions worth recording. **"mux session", not "mux
  workspace"** — `workspace` already means a VCS-isolated checkout
  throughout mu (`mu workspace`, `vcs_workspaces`, "workspace orphan",
  "stale workspace"), and a second sense would make `mu workspace
  list` ambiguous; herdr's spelling is kept only when describing
  herdr's own CLI. And **pane-id shape belongs to the backend**, so
  validation lives on `MuxBackend` instead of a global regex or
  scheme-prefixed ids.

### Fixed

- **`killSession` and `listPanesInSession` threw when the tmux server
  outlived its last session.** With zero sessions, tmux answers
  `-t <name>` with `no current target` rather than `can't find
  session`, and neither swallow-list matched that wording — so two
  operations documented as idempotent threw instead. Reachable by any
  user whose `~/.tmux.conf` sets `exit-empty off`, not just by the test
  suite that surfaced it.

- **`mu doctor`'s environment block misaligned on herdr.** The label
  column was 17 wide against a 19-char `$HERDR_WORKSPACE_ID`, and
  `$MU_SESSION` used a hardcoded pad that bypassed the constant
  entirely. Colons now line up on both backends.

---


## [1.0.0] — 2026-08-02

**The problem.** mu kept state in one SQLite file per machine, and had
no good story for moving work between a laptop and a devserver. The
explicit export/import that existed made the operator adjudicate every
handoff — classify the drift, pick a side, park the loser in a sidecar,
replay it by hand. It worked, and it was annoying enough that people
avoided it.

**The change.** Every mutation is now an append-only **op**, recorded
by a SQLite trigger in the same transaction as the mutation itself. No
call site can forget to record history, and the log cannot drift from
the data. Sync, undo, archive and history are all queries or replays
over that one log. Machines exchange append-only JSONL segments through
any shared folder — Syncthing, rsync, scp, a USB stick — and converge
without adjudication, per FIELD: a crew closing a task on one machine
and an operator re-pricing it on another both keep their edit.

**Why this is 1.0, and why it breaks the DB.** The old schema had four
separate mechanisms for recording change: an event log, whole-DB
snapshots, a per-workstream sync cursor with divergence sidecars, and
five archive tables. Sync over that would have been a fifth. Collapsing
them into one log is what makes convergence, granular undo, and
archives-as-markers fall out of the same substrate instead of each
needing its own merge rules — and one mechanism is what makes a
stability promise credible. That is the point of the version number:
mu was 0.x throughout, so nothing was ever promised and nothing is
being broken. This is the shape we are willing to promise stability
for.

Carrying a migration path would have meant keeping the four dead
mechanisms alive in code purely to read them once. The honest cost:
the upgrade is one-way, so keep your old DB file. There is a verified
path across — `scripts/migrate.ts`, read-only on the source, run
against a real 857-task database with `mu doctor --deep` reporting no
drift.

**What you actually do differently:**

- Set `MU_SYNC_DIR` to a shared folder. Sync then happens on every
  `mu` invocation; there is no daemon and no verb to remember.
- `mu undo <group>` reverts one action, not the whole database.
- `mu sync` reports peer status — who, how far behind, how stale.
- `mu doctor --deep` is the integrity check: it rebuilds the log into
  a temp DB and diffs it field-by-field against your live tables.

### Breaking

- **`mu undo` semantics changed completely.** mu used to restore a whole-DB
  snapshot: `mu undo --yes` (optionally `--to <id>`) swapped the database
  file, reverting every workstream, and each restore took a fresh
  pre-restore snapshot. `mu undo <group>` now emits inverse ops for ONE
  group — granular, composable, and itself an op (so it syncs and is
  itself undoable). There are no snapshot files, so `--to` is gone along
  with `mu snapshot list / show / prune`. Consequences: undo no longer
  touches unrelated workstreams (the point), no longer needs a
  pre-restore snapshot (redo is just undoing the undo), and now REFUSES
  by default when a later action changed the same fields instead of
  silently winning.

- **Schema v9 — no migration from v8.** `openDb` refuses any pre-v9 DB
  with `SchemaTooOldError` (exit 4) and leaves the file untouched.
  `MIN_ACCEPTED_SCHEMA_VERSION === CURRENT_SCHEMA_VERSION === 9`, so
  the in-place forward-bump ladder (including the v6→v7 `approvals`
  DROP) is gone. Keep a copy of your old DB (a plain `cp` of the file, as
  in the recipe below) and re-import it
  through **`scripts/migrate.ts`** — a sidecar you run once, by hand,
  against a copy. See [scripts/README.md](../../scripts/README.md) for the
  exact upgrade recipe (BACK UP FIRST) and the honest list of what does
  NOT come across.

  ```bash
  cp ~/.local/state/mu/mu.db ~/mu-pre1.0-backup.db        # keep this forever
  mv ~/.local/state/mu/mu.db ~/.local/state/mu/mu.db.old
  npx tsx scripts/migrate.ts ~/.local/state/mu/mu.db.old --out /tmp/mu-new.db
  MU_DB_PATH=/tmp/mu-new.db mu doctor --deep              # must report NO drift
  mv /tmp/mu-new.db ~/.local/state/mu/mu.db
  ```

  The importer is READ-ONLY on the source (sha256 printed before and
  after) and writes a fresh v9 file. It **synthesizes ops, it does not
  insert rows**: v9's entity tables are a projection of the ops log, so
  rows written behind the log would be invisible to sync, unrecoverable
  by `mu rebuild`, and reported as drift by `mu doctor --deep`. One
  `put` op per source row, HLC-ordered by the source `created_at`, all
  under one synthetic group with intent `migrate.v8`.

  Carried: `workstreams`, `tasks`, `task_edges`, `task_notes`, and
  `agent_logs` (as log-only `event` ops with intent `migrate.v8-log`,
  droppable with `--drop-logs`). NOT carried, printed with counts on
  every run: `agents` (meaningless `pane_id`), `vcs_workspaces`
  (absolute paths), `snapshots` (table gone; the `.db` files stay on
  disk), `workstream_sync` (superseded by `sync_peers`), and task
  owners (`owner_id` FKs into machine-local `agents`). Pre-1.0 **archives
  refuse loudly** rather than half-importing: v8 stored a column subset
  rather than history, and after a v8 destroy the ops a v9 marker would
  pin do not exist — export them with mu 0.4.x first, or pass
  `--drop-archives`.

  Unlike `scripts/migrate-v4-to-v5.ts` (deleted post-landing per the
  temp-impl-artifact rule), this script is KEPT: it crosses a major
  version that users cross on their own schedule, so a deleted script
  would be useless to someone upgrading in six months.
- **Tables dropped:** `agent_logs`, `snapshots`, `workstream_sync`,
  `archives`, `archived_tasks`, `archived_edges`, `archived_notes`,
  `archived_events`. A healthy DB now has exactly 10 tables.
- **Tables added:** `ops` (the ops log — `seq`, `hlc`, `machine_id`,
  `group_id`, `actor`, `intent`, `entity`, `key`, `op`, `payload`,
  `created_at`, with `UNIQUE (machine_id, hlc)` making ingest
  idempotent) and `sync_peers` (per-peer watermarks). `ops` is
  deliberately FK-free and keys rows by their NATURAL key
  (`<workstream>/<local_id>`), so an op outlives the row — and the
  workstream — it records.
- **Verbs removed:** `mu db export` / `import` / `replay`,
  `mu snapshot list` / `show` / `prune`, `mu undo`, and the whole
  `mu archive` namespace (`create` / `list` / `show` / `add` /
  `restore` / `remove` / `delete` / `search` / `export`).
  `mu workstream destroy --archive <label>` loses its flag.
  **`mu db backup <file>` survives** as the whole `db` namespace: a
  `VACUUM INTO` one-liner for the "one file I can scp" case, which is
  the only thing `db export` was actually used for. It never
  overwrites — the copy `SchemaTooOldError` tells you to take before
  `scripts/migrate.ts` is not one to clobber on a retry.
- **Destructive verbs no longer snapshot.** `workstream destroy`,
  `task delete` / `close` / `reject` / `defer` / `release`,
  `agent close`, and `workspace free` / `recreate` used to capture a
  whole-DB snapshot first. They no longer do; rollback returns as
  inverse ops over the ops log.

### Added

- **`mu sync` + AMBIENT sync — the switch point (`src/sync.ts`,
  `src/cli/sync.ts`).** Two halves:

  **ONE verb, whose bare form is a PEER STATUS REPORT.** The flush and
  ingest are incidental, because they happen on every mu invocation
  anyway (below):

  ```
  $ mu sync
  flushed 14 ops · ingested 22 from 1 peer
  machine   last seen   behind
  devbox    2m ago      0
  desktop   3d ago      47   ← stale
  ```

  Two flags, and only two, because nothing else can express them:
  `--from <path>` ingests from a peer's `mu.db` DIRECTLY (a different
  READER: its SQLite `ops` table rather than a JSONL segment — for an
  sshfs mount or a copied file), and `--repair <peer>` resets that peer's
  **watermark** and re-reads its **segment** from zero (safe because
  ingest is idempotent via `UNIQUE (machine_id, hlc)`). `--repair`
  accepts any unique machine-id prefix; an ambiguous one is a conflict
  (exit 4), never a guess. Both flags carry `--json` and a `Next:` block.

  Deliberately NOT built: `mu peers` (folded into the bare form) and
  `--to <dir>` / `--from <dir>`. A one-off directory needs no flag —
  `MU_SYNC_DIR=/mnt/usb mu state` already ingests from the USB stick
  through the repo's existing env-var-override idiom, and a second way to
  say the same thing is a surface to keep in step for nothing.

  **AMBIENT flush + ingest on every mu invocation**, which is what makes
  the workflow no-hands: with `MU_SYNC_DIR` set, `mu task list` on the
  devserver already shows what the laptop added. AMBIENT, NOT A DAEMON —
  there is no watcher, no background process, and no polling loop that
  outlives the command. Sync happens because you already run `mu`
  constantly. The hook lives in `handle()` (`src/cli/handle.ts`), the one
  seam every verb passes through and the only one that is ALREADY async
  while verb bodies are synchronous: one `await` before the body and one
  after covers all ~63 verbs, and no verb learns that sync exists. Order
  is load-bearing — ingest BEFORE (so the verb reads the freshest state)
  and flush AFTER (so the ops this invocation just wrote are published
  now, not on the next one).

  Guarantees: `MU_SYNC_DIR` unset is a single `if` reading one env var,
  with no promise allocated and no filesystem touched (measured
  indistinguishable from baseline; ~3ms with one peer). Flushes take the
  cross-process lock in `src/file-lock.ts`, so two concurrent mu
  processes cannot interleave lines or double-ingest. `mu sql` opts OUT
  entirely — its no-surprise-mutations property is load-bearing, and an
  ambient ingest changing a row count mid-inspection would read as a mu
  bug. The TUI runs the pass on its SLOW tick (10s), never the 1s fast
  tick, and quietly (a stderr write would paint over the alternate
  screen). And it can NEVER fail a command: a truncated segment, a
  garbage segment, a sync dir that is a file, a vanished directory — all
  warn on stderr and return, so `mu task add` works when sync is broken.

  **Transport stays the operator's.** mu reads and writes FILES and
  shells out to nothing. When a peer looks stale, `mu sync` PRINTS a
  copy-pasteable `rsync` line via the ordinary `NextStep` convention. The
  `--push` / `--pull <host>` shape proposed earlier in this project was
  RETRACTED: it violates the ROADMAP pledge that the user owns transport,
  and it would drag in ssh config, jump hosts, ProxyCommand, ports,
  identity files, interactive prompts (inside a TUI tick?), and
  network-vs-auth error mapping — a remote backend wearing a small hat.

- **`mu doctor`'s two `MU_SYNC_DIR` hazard checks are live.** They
  shipped inert in `src/fleet-hazards.ts` and now fire for real. The one
  that matters is `db-vs-sync`: `MU_DB_PATH` inside `MU_SYNC_DIR` is a
  hard **fail**, because a live WAL-mode SQLite DB is three files whose
  mutual consistency IS its durability, and a file-syncer copying them
  out of order — or resurrecting a peer's stale `-wal` — produces a DB
  that opens fine and is silently corrupt.

- **Segments — the sync transport layer (`src/segments.ts`).** How ops
  leave and enter a machine: `flushSegment` appends this machine's
  not-yet-flushed ops to `<MU_SYNC_DIR>/<machine_id>.jsonl`, and
  `ingestSegment` reads a peer's segment from its **watermark** and feeds
  each op through `applyOp`. Surfaced by `mu sync`, above.

  The load-bearing property is **single-writer-per-file**: a machine
  appends only to its OWN segment and read-onlys every other, so no file
  is ever written by two machines and there is no file-level conflict,
  ever. That is what makes Syncthing, rsync, scp, git and a USB stick all
  adequate transport — it removes the one thing every file-mover is bad
  at. mu reads and writes files; it never shells out to move them, and the
  operator owns transport.

  **Two logs, only one critical.** The `ops` table is canonical: ACID,
  WAL, written in the same transaction as the mutation. A segment is
  DERIVED and regenerable (`SELECT ... FROM ops`), so losing one costs a
  re-flush. That asymmetry is what licenses plain append-only files, and
  it has one deliberate consequence that looks like an oversight: **no
  fsync on append.** Losing the tail to power loss is harmless because the
  next flush re-derives it.

  - **Four robustness layers**, following what RocksDB / Kafka / etcd /
    SQLite-WAL all do — detect the bad record, stop at it, refetch the
    tail. On damage, ingest stops at the last GOOD record and advances the
    watermark only that far, then reports it: (1) `JSON.parse` failure =
    torn write, free, and the dominant failure mode; (2) crc32 per line
    over a canonical serialization, catching bit rot that JSON.parse would
    accept; (3) monotonic `hlc` per segment, catching reordering,
    duplication and silent mid-file truncation, structurally and at zero
    extra bytes; (4) a `<machine_id>.manifest` sidecar
    (`{count, lastHlc, sha256}`) for whole-file verification, which is the
    only layer that can catch truncation exactly on a line boundary where
    every remaining line is individually valid. Because
    `UNIQUE (machine_id, hlc)` makes ingest idempotent, the universal
    repair is "re-read from zero" — so a damaged segment is recoverable,
    never fatal.
  - **Filtering is load-bearing.** Only ops whose entity is in
    `SYNCED_ENTITIES` are flushed, so machine-local ops (agent.*,
    workspace.*) never reach a file: they carry pane ids and absolute
    paths that are meaningless, and often wrong, elsewhere. "Not synced"
    is not "not logged" — they still appear in `mu log`. Flush also
    filters to THIS machine's ops, so ingested peer history is never
    re-flushed under our own name (which would grow without bound as two
    machines echoed each other). Ingest surfaces a non-synced entity from
    a peer as a bad-peer defect rather than crashing.
  - **Peer discovery is implicit**: every `*.jsonl` in the sync dir that
    is not mine. No membership list — `MU_SYNC_PEERS` was rejected as "a
    config file with extra steps that must be kept consistent across every
    machine", so dropping a segment in the folder joins the cluster.
    Syncthing conflict copies (`foo.sync-conflict-*.jsonl`) are INGESTED
    rather than ignored: they are still valid op logs, dedup makes reading
    them safe, and ignoring them would drop real ops exactly when
    something had already gone wrong.
  - **Watermarks** use the `sync_peers.last_applied_seq` column that has
    been in the v9 schema unused until now. One integer per peer suffices
    because segments are append-only and ordered; it counts LINES in that
    peer's segment, not their `ops.seq` (a local-only cursor that means
    nothing here).
  - Ingest calls `receiveHlc` per op, so the local clock advances past the
    peer's and a subsequent local edit sorts above what it just saw.
  - No daemon, no watcher, no polling loop that outlives the command.
    `syncPass` is one flush plus one ingest of each peer, and a no-op
    costing nothing when `MU_SYNC_DIR` is unset.

- **`src/file-lock.ts`** — the atomic-`mkdir` advisory lock extracted from
  `src/agents/spawn-lock.ts`, which now delegates to it and keeps its
  session-keyed wrapper and tests. Flush uses it so two concurrent local
  `mu` processes cannot interleave partial lines in the same segment. Two
  copies of a lock implementation is how they drift, so the mechanism
  lives in one place and each caller names its own resource.
- **Archives are back, as MARKERS pinning the ops log (`v2-archive-markers`).**
  R1 dropped the five `archived_*` tables. They are not coming back: an
  archive is now ONE op (`entity='marker'`, `intent='archive.add'`, key
  `<label>/<workstream>`) naming a point in the log, and everything else
  is a query or a replay. Five tables → zero.

  ```
  mu archive add v0-3 -w proj              # pin; creates the label on first use
  mu archive list [label]
  mu archive restore v0-3 --as recovered   # dry run; --yes applies
  mu archive export v0-3 --out ./out       # markdown, same renderer as workstream export
  mu workstream destroy proj --yes --archive v0-3   # pin, THEN destroy
  ```

  Every previous property survives, and each was verified against a real DB
  rather than assumed:

  - **Outlives destroy.** `workstream destroy` writes TOMBSTONES, so the
    puts below the marker remain. Demonstrated end to end: seed → archive
    → destroy → restore returns all tasks, edges, and notes, with
    `sourceDestroyed: true` in the report.
  - **Cross-workstream accumulation.** One label, many markers.
  - **Additive.** Markers are append-only *because they are ops*, so
    `lastAddedAt` is `MAX(hlc)` — no stored column to keep in sync.
  - **Lossless restore.** Replaying ops reproduces every captured column,
    strictly more faithful than the old column-subset copy. Restore stops AT
    the marker (later work is not resurrected) and honours tombstones
    below it (a task deleted before the pin stays deleted).

  **LOAD-BEARING INVARIANT, recorded now rather than discovered later:
  compaction must NEVER discard ops at or below a pinned marker's HLC.**
  Nothing compacts today; when something does, dropping ops under a
  marker silently empties the archive it promised to preserve, and the
  failure surfaces only when someone tries to restore. Written into
  `docs/VOCABULARY.md` § marker and into code as `pinnedHlcs()`.

  Verbs retired as consequences of the model, not as scope cuts:
  `create` (a label with no markers pins nothing — `add` IS the create),
  `remove` / `delete` (markers are append-only; un-pinning means
  rewriting history), `show` (folded into `list <label>`), and `search`
  (`mu log --intent archive.add` or `mu sql`). `add --destroy` inverted
  into `workstream destroy --archive <label>`, so the destructive verb
  owns the confirmation. `--empty` and `--archive` are mutually exclusive
  (exit 2): a sweep over every empty workstream cannot be described by
  one label.

  `mu archive export` writes NO second renderer. It replays to the marker
  in a scratch workstream inside a transaction, builds the `ExportSource`,
  then forces a ROLLBACK — so it mutates nothing (no rows, no ops, no
  drift) and `src/exporting.ts` produces the identical bucket layout
  `mu workstream export` does.

  The subtle part, worth recording because the wrong version passes a
  casual test: restore must RECORD an op and APPLY it **under the same
  HLC**. `applyOp` deliberately does not write to `ops` (it is built for
  ingesting ops that already exist), so applying alone leaves live rows
  the log cannot explain — 6 drift divergences. But recording under a
  *fresher* HLC is worse: `applyOp`'s provenance queries exclude the op's
  own HLC, so the row just written outranks the op being applied and
  every field collapses to an insert default — 12 divergences, with
  `title='design'` where the log says `'Design the API'`.
  `mu doctor --deep` now reports no drift after archive, destroy, and
  restore.

- **`mu undo [group]` — granular undo via inverse ops (`src/undo.ts`,
  `src/cli/undo.ts`).** The verb disappeared in v9 along with
  `src/cli/snapshot.ts` (it lived there, not in its own module); this
  re-wires it on the ops-log substrate with entirely different semantics.

  Per op in the group: a `put` that CREATED a row inverts to a `del`; a
  `put` that CHANGED fields inverts to a `put` restoring the PRIOR value
  of exactly those fields; a `del` inverts to a `put` restoring the row.
  "Did this put create the row" is answered from provenance rather than a
  flag, and "what was this field before" reuses the same backwards
  provenance query `src/apply.ts` uses for per-field LWW — a second
  implementation is how the two would drift, and drift there would mean
  undo restoring wrong values.

  - **Inverses go through the NORMAL write path.** The module mutates the
    tables inside a `withOpContext` scope and lets the capture triggers
    record the result; it never hand-writes rows into `ops` and never uses
    `applyOp` (which is capture-suppressed, being the ingest path). So an
    undo gets a fresh HLC from the same clock as any other edit, appears
    in `mu log`, flushes to this machine's segment, and is visible to the
    drift check. `mu doctor --deep` stays clean after an undo.
  - **The undo is itself an op, in its own group**, so it syncs to peers
    and is itself undoable. That is the entire implementation of redo:
    `mu undo <the-undo-group>`. No separate mechanism and no asymmetry.
  - **One UPDATE per inverse, not one per field.** Each statement fires
    the capture trigger once, so a per-field loop would emit N ops for one
    logical inverse and misreport a single action as several.
  - **FK-safe ordering.** A group can span entities (a destroy writes
    tombstones for the whole tree), and restoring a task before its
    workstream violates the constraint. Inverses are sorted by entity
    DEPTH (workstream → task → note/edge), not by reversed emission order:
    a destroy emits the workstream tombstone FIRST, so reversing would try
    to restore notes before their task.
  - **Dry run by default**, `--yes` to apply, matching `workstream
    destroy` / `db import`. `mu undo` with no argument reports what it
    WOULD undo and lists recent groups, so group ids are discoverable
    without needing `mu log`. Abbreviated ids work like git shas.

- **Undo refuses to clobber newer work.** Undoing a group whose fields
  were changed again since is the hard case: because the inverse gets a
  fresh (newest) HLC it would WIN per-field LWW and silently discard that
  newer work. Silently skipping instead is equally bad — the operator
  believes the action was undone. So `mu undo` detects supersession
  PER FIELD, exits 4 naming the conflicting field and the later group, and
  changes nothing; `--force` is the explicit override and says what it
  destroys. A later DELETE of the row counts as supersession too, since
  restoring fields on a deleted row would resurrect it.

- **Ops-log drift detection (`src/drift.ts`), wired into `mu doctor`.**
  The check that makes capture, apply and rebuild TRUSTWORTHY, and the
  reason collapsing four change-recording mechanisms into one log was
  defensible at all. Per docs/VISION.md § 2b, the cost of that
  consolidation is that a capture bug is no longer "sync is broken" — it
  is undo AND archives AND sync AND history broken, simultaneously and
  silently, because all four are projections of the same table. This
  converts that hazard into a detectable condition, so it is production
  code rather than diagnostics garnish.

  Reports WHICH table, WHICH key and WHICH field, with both values —
  "drift detected" alone is useless at 3am:

  ```
    drift            : FAIL 2 divergence(s) (12ms)
        tasks demo/a.title: live=TAMPERED log=A
        tasks demo/a.impact: live=7 log=60
  ```

  - **Two tiers, chosen by measurement.** The deep check rebuilds the log
    into a temp DB and diffs field-by-field, costing ~0.6ms per op —
    measured at 2.3s on a 1000-task / 3452-op DB, which is too slow for a
    command people run reflexively. So `mu doctor` runs a ~3ms invariant
    (every live row must have at least one op naming its key) and
    `mu doctor --deep` runs the full rebuild diff. Both exit **5** on
    drift, so CI and wrapper scripts notice.
  - **The cheap tier's blindness is deliberate, proven and documented.**
    It catches an uncaptured INSERT or DELETE but CANNOT see an
    uncaptured UPDATE, because the row's key still has ops from its
    insert. A test asserts exactly that, so the tiering cannot silently
    change, and the default output points at `--deep` rather than
    implying it has proved anything.
  - **Remediation warns AGAINST rebuilding reflexively**, which matters
    more than the detection. Drift means one side is wrong and we cannot
    know which from here: if capture missed a mutation, the LIVE tables
    hold work the log never recorded and rebuilding would discard it. The
    guidance is back up first, materialize what the log believes, compare
    the named keys, then choose deliberately — and report it, because
    drift is a bug rather than operator error.
  - Diff identity is the NATURAL key throughout. `owner_id` is excluded
    from comparison because it never syncs (apply strips it, so a rebuild
    always has NULL owners); comparing it would report drift on every
    claimed task forever. Notes diff on `(task, author, content)`, the
    same identity `applyNotePut` uses, since their surrogate id is not
    portable.

- **Mixed-fleet hazard checks (`src/fleet-hazards.ts`)**, in the default
  `mu doctor`. Cheap, and unlike drift every one is PREVENTABLE:

  - **`MU_DB_PATH` inside `MU_SYNC_DIR` → FAIL.** THE footgun of the
    whole design. A live WAL-mode SQLite DB is three files (`mu.db`,
    `-wal`, `-shm`) whose mutual consistency IS its durability; a file
    syncer copying them out of order, or resurrecting a peer's stale
    `-wal`, yields a DB that opens fine and is silently corrupt. Two
    machines writing the same synced file is worse — last writer wins on
    the whole FILE, so one machine's entire history disappears. mu ships
    append-only per-machine **segments** so the database file never has
    to travel.
  - **DB on a network mount → WARN.** WAL needs working POSIX advisory
    locks and a shared-memory file; NFS, SMB/CIFS and FUSE mounts provide
    neither dependably. Warn rather than fail because a single machine on
    an NFS home usually works and refusing would lock that operator out
    of their own tool. Detected via `statfsSync` magic numbers
    transcribed from linux/magic.h; off Linux `f_type` is an unstable
    driver index, so the probe returns `unknown` and the check says "not
    classifiable" rather than claiming a clean bill of health.
  - **Case-colliding workstream names → WARN.** `Foo` and `foo` coexist
    on ext4 but collide on macOS (APFS) and Windows (NTFS). Because a
    workstream name IS a tmux session name and seeds every workspace
    path, a Mac joining the fleet sees one session and one directory
    where Linux sees two, so the fleet reaches different states depending
    on which machine applies an op first.

  `MU_SYNC_DIR` does not exist until v2-sync; these read it if set and
  no-op otherwise, so they are live the moment sync lands.

- The TUI's Doctor card/popup gains the fleet + shallow-drift rows,
  obeying `src/doctor-summary.ts`'s per-tick cheapness rules — no rebuild
  there, since it would make the poll tick take seconds.

- **`mu rebuild <file>` — replay the ops log into a fresh DB
  (`src/rebuild.ts`).** The disaster-recovery story that replaces the
  snapshot files mu no longer keeps: given an intact `ops` table, every portable
  row is reconstructable, because the log is canonical and the tables are
  a projection of it. Also the foundation the forthcoming doctor drift
  check stands on.

  Always writes a NEW FILE and prints the `mv` swap command; never
  rebuilds in place. A rebuild that overwrote the live DB would be a
  destructive operation whose failure mode is "no database at all", so
  the operator inspects and swaps when ready. Exits 4 rather than
  overwriting an existing target (`--force` overrides) or writing onto
  the source DB, which would truncate the log being replayed.

  Projects through `applyOp`, not a second implementation of the merge
  rules — tombstone ordering, per-field LWW and grow-only sets all come
  from `src/apply.ts`, so a rebuild and a sync ingest cannot disagree.
  Replay is wrapped in `withCaptureSuppressed`: without it, applying each
  op would fire the capture triggers and mint a second op per row,
  roughly doubling the log and filling it with fresh HLCs for changes
  that never happened.

  - **Rebuild is not ingest.** Ingest filters to `SYNCED_ENTITIES`
    because only those may cross a machine boundary. A rebuild is LOCAL
    recovery, so it replays everything the log knows, including
    machine-local log entities (`event` / `broadcast`) that `applyOp`
    deliberately rejects. Those are copied verbatim rather than
    projected — there is no table to project a log line into — because
    dropping them would leave `mu log` empty after a recovery.
  - **Machine identity is carried across**, including the HLC clock. A
    fresh `openDb` seeds a new uuid, which would make the rebuilt DB a
    DIFFERENT peer: its own historical ops would look foreign and peers
    tracking watermarks against the old id would treat it as unknown. And
    a clock reset to zero would mint HLCs BELOW every replayed op, so the
    next local edit would sort as older than history and lose every LWW
    comparison against it. Monotonicity is a property of the machine, not
    of the file.
  - **What a rebuild legitimately loses is REPORTED, never silent.**
    `agents` and `vcs_workspaces` have no capture triggers, so they leave
    no ops and cannot be reconstructed. That is correct rather than a gap:
    `pane_id` names a tmux pane that no longer exists and
    `vcs_workspaces.path` is an absolute path whose working copy may be
    gone, so resurrecting either would produce rows that lie about
    reality. But an operator who does not realise their agent registry is
    empty will wonder why `mu agent list` is blank, so the summary prints
    the per-table row counts in yellow and the `Next:` block gains a
    re-spawn step.
  - `rebuildInto` takes the target path as a parameter and prints
    nothing, returning a `RebuildReport`. v2-doctor-drift rebuilds into a
    temp DB and diffs, so the SDK must stay free of human-shaped output.

- **The apply path (`src/apply.ts`)** — the read/merge counterpart to
  capture. `applyOp(db, op)` takes one op, local or from a peer, and
  makes the portable tables reflect it; `applyOps` sorts a batch into
  HLC order and applies it in one transaction. v2-sync calls these; they
  know nothing about segments or files.

  Merge rules, one per entity, chosen so convergence needs no
  coordination between machines:

  - **notes / messages — grow-only set.** Insert-if-absent, never
    updated, so two machines cannot disagree about content and there is
    nothing to resolve. Identity is `(task, author, content)`, because a
    note's surrogate id is assigned by whichever machine inserted it and
    so cannot identify it across machines.
  - **tasks / workstreams — per-field LWW by HLC**, not row-level. For
    each field in a payload, the write lands only if this op's HLC beats
    the newest HLC that previously wrote THAT field. So one op can win on
    `status` and lose on `impact` in the same call.
  - **edges — LWW-element-set.** Add and remove each carry an HLC, so a
    remove and a later re-add converge in either arrival order.
  - **machine-local entities are rejected loudly** with
    `OpEntityNotSyncedError` rather than ignored: one arriving means a
    peer disagrees about what syncs, which is a bug to report, not
    absorb.

  Per-field is the load-bearing choice. The earlier design note argued
  row-level LWW sufficed because "there are no concurrent edits to one
  workstream on two machines" — but mu runs autonomous agent crews, so a
  crew on the devserver closing a task while the operator re-prices it on
  a laptop is concurrent multi-machine writing BY CONSTRUCTION. Row-level
  would let one edit silently clobber the other. It costs nothing extra
  because capture already emits semantic partial updates, so "apply each
  op's fields in HLC order" IS per-field LWW — no column version vectors,
  none of the cr-sqlite machinery we rejected.

- **Provenance is DERIVED from the ops log, not stored.** Per-field LWW
  needs "which HLC last wrote field F of key K". There is deliberately NO
  `(entity, key, field) -> hlc` side table: `ops` already records which
  fields each HLC touched and is indexed on `(entity, key)`, so the
  answer is a query over that key's handful of ops. A side table would be
  a denormalisation that can disagree with the log, and when it did,
  every projection (undo, archive, sync) would inherit the disagreement
  with no procedure to decide which side was right. Deriving costs zero
  extra storage (a side table would cost ~1KB per task, duplicating data
  already on disk) and ~10us per key against a 16k-op log. It also makes
  idempotence and resurrection fall out for free.

- **Tombstones are ordinary ops (no tombstone table).** `op='del'` rows
  carry an HLC, so out-of-order arrival is just "compare HLCs" — the same
  comparison the update path makes, one code path with no special casing.
  A late `put` older than a seen `del` loses; a `del` older than a seen
  `put` loses. **Resurrection** — a `put` NEWER than a seen `del`
  recreating the row — is distinguishable from a stale put for free,
  because ops outlive the rows they describe, so provenance survives the
  deletion. All four orderings are tested in both arrival orders.

- Applying is **idempotent** and runs **capture-suppressed**. The echo
  guard matters: without it, writing a peer's op would fire the capture
  trigger, mint a fresh local op, flush it back, and loop forever.
  Idempotence is what lets `mu sync --repair` be nothing more than
  "re-read that peer's segment from zero", and it is asserted by counting
  ops before and after and by replaying a whole segment three times.

- **The `json_patch` trap is avoided by construction.** RFC 7396 treats a
  null member as DELETE-THIS-KEY, so
  `json_patch('{"owner_id":7}','{"owner_id":null}')` returns `{}`. Since
  capture emits exactly `{"owner_id":null}` when a claim is released,
  using `json_patch` anywhere on the apply path would silently drop every
  set-to-NULL in the system. Payload fields are applied one at a time
  with explicit binding instead, and a test pins json_patch's destructive
  behaviour so it cannot be reintroduced as a "simplification". The same
  hazard appears in the provenance presence test, which uses
  `json_type(...) IS NOT NULL` rather than `json_extract(...) IS NOT
  NULL` — json_extract returns SQL NULL for both an absent key and a
  present-but-null one, which would leave a set-to-NULL with no
  provenance and thus overwritable by any older op.

- Ownership still does not sync: `owner_id` is stripped from incoming
  payloads. It is an FK into the machine-local `agents` table, so a
  peer's value would at best name an unrelated local agent and at worst
  violate the constraint outright (verified: 'FOREIGN KEY constraint
  failed'). Likewise `local_id` / `name` are ignored, since they are
  encoded in the natural key and a payload must not be able to rename a
  row out from under its own key.

- **Op capture via SQLite triggers (`src/capture.ts`)** — the linchpin
  of the design. Every INSERT / UPDATE / DELETE on a portable table
  (`workstreams`, `tasks`, `task_edges`, `task_notes`) writes an op
  inside the SAME TRANSACTION as the mutation, in the same file, so
  capture is atomic with the change and the two cannot drift — not even
  on power loss mid-write.

  Hand-emitted ops (an `emitOp(...)` beside each mutation) were
  rejected because they can be FORGOTTEN, and undo, archives,
  sync and history are all projections of this one log, so a single
  missing op is silent corruption of all four at once. A future SDK
  function that mutates `tasks` must not be ABLE to skip capture;
  triggers make that structural rather than a convention.

  - **Payloads are semantic partial updates.** The UPDATE triggers
    compare `NEW.<col> IS NOT OLD.<col>` per column and emit only the
    columns that actually changed, so `task.close` carries
    `{"status":"CLOSED"}` and a re-price carries `{"impact":80}`. This
    is what makes per-field merge fall out of "apply in HLC order" for
    free, with no column version vectors. A whole-row payload would
    look identical on one machine and silently regress the design to
    row-level last-writer-wins, discarding one of two concurrent edits
    to different fields of the same task — which, with agent crews on
    two machines, happens by construction. `IS NOT` rather than `<>`
    so NULL transitions (releasing a claim sets `owner_id` to NULL)
    are captured rather than dropped.
  - **No-op UPDATEs produce no op**, keeping the log free of churn.
  - **Natural keys, never surrogate ids**: `demo` / `demo/fix-auth` /
    `demo/fix-auth#12` / `demo/a->demo/b`. Two machines both minting
    `tasks.id = 7` is inevitable and meaningless; `demo/fix-auth`
    denotes the same task everywhere.
  - **The triggers are TEMP triggers**, rebuilt per connection by
    `installCapture` on every `openDb`. Not a choice: SQLite refuses to
    let a main-schema trigger reference the temp schema at all (`no
    such table: main._op_ctx`, and qualifying it gives `trigger cannot
    reference objects in database temp`), and the triggers must read
    the per-connection `_op_ctx`. Upside: no trigger DDL is persisted
    in the file, so changing the trigger set needs no schema bump.
  - **FK CASCADE fires triggers** (verified empirically, and
    independently of `recursive_triggers`, which governs only
    trigger-initiated recursion). So `workstream destroy` captures a
    tombstone for every cascaded task, note and edge with no explicit
    walk. The trap is ordering: cascaded children fire after the parent
    row is gone, so a join to the parent yields nothing. Fixed with
    BEFORE DELETE triggers plus an `_op_dying` stash in which each
    parent records its natural key before deleting, which the child key
    resolvers consult when the live join misses.
  - The HLC is minted in SQL, mirroring `nextHlc` exactly (a trigger
    cannot call into JS). A test asserts SQL-minted and JS-minted HLCs
    interleave monotonically, so the two cannot drift unnoticed.

- **The op context seam (`src/op-context.ts`)** — triggers capture
  reliably but blindly, so intent / actor / grouping are recovered from
  the per-connection `_op_ctx` temp table that the SDK sets and the
  triggers read. `withOpContext(db, {intent, actor, group}, fn)` is
  scoped rather than a bare setter: it restores the previous context in
  a `finally`, so a throw cannot leak a stale intent onto later ops (a
  wrong intent is worse than a null one — `mu log` renders it as
  confident prose). Nested scopes inherit the outer group by default,
  which is exactly what makes a cascade close or a `workstream destroy`
  write all N ops under ONE `group_id` for `mu undo`; `group: "new"`
  forces a fresh group and `intentIfUnset` lets a shared internal like
  `setTaskStatus` yield its label to the outer operator verb.
  `withCaptureSuppressed(db, fn)` sets `applying = 1` — the echo-loop
  guard v2-sync wraps ingest in, so applying a peer's op does not mint
  a local op that propagates back forever.

  `_op_ctx` is seeded with a DEFAULT ROW, so a mutation occurring
  outside any SDK context is still CAPTURED, just with a null intent.
  Fail safe, never fail silent. `group_id` is never null: an ungrouped
  op is its own group of one.

- Intents are now set on the mutating task and workstream verbs
  (`task.add` / `update` / `note` / `close` / `open` / `reject` /
  `defer` / `delete` / `block` / `unblock` / `reparent` / `claim` /
  `release`, `workstream.init` / `destroy`).

- **Syncability constants in `src/db.ts`** — one place declaring which
  state crosses machines. `SYNCED_ENTITIES` (`workstream`, `task`,
  `edge`, `note`, `message`, `marker`) is a readonly tuple with a
  derived `SyncedEntity` union, so downstream sync code gets
  compile-time checking instead of raw strings. `PORTABLE_TABLES`
  (`workstreams`, `tasks`, `task_edges`, `task_notes`) and
  `MACHINE_LOCAL_TABLES` (`agents`, `vcs_workspaces`,
  `machine_identity`, `schema_version`, `sync_peers`, `ops`) sit next
  to `EXPECTED_TABLES`, the existing precedent for "the schema's shape
  as data", and are exported from `src/index.ts`.

  Syncability is a STATIC function of the entity, so it is a constant
  rather than a `local` column on `ops`: a per-row decision is a
  per-row opportunity to get it wrong, and it would cost a column plus
  a branch at every write site. `ops` is classified machine-local
  deliberately rather than omitted — the table is never
  wholesale-copied; individual op rows ship, filtered by
  `SYNCED_ENTITIES` and carried by per-machine segments.

  Falls out with no special case: `tasks.owner_id` is an FK into
  `agents`, and `agents` is machine-local, therefore **ownership does
  not sync** — the conclusion the deleted `db-sync.ts` reached via an
  `includeOwners` flag is now structural. Machine-local ops are still
  RECORDED, so `mu log` and the TUI Recent card keep showing agent
  spawn/close; "not synced" is not "not logged".

  `test/entities.test.ts` (9 fast-tier tests) guards the structure:
  the two table lists must partition `EXPECTED_TABLES` exactly, with
  no overlap and no omission, so adding an 11th table without
  classifying it fails loudly at schema-change time rather than
  silently leaking or dropping it later.
- **`src/hlc.ts` — the hybrid logical clock that orders every op.**
  `(wall_ms, counter, machine_id)` serialized to a lexicographically
  sortable TEXT: `<wall_ms:15 digits>.<counter:6 digits>.<machine_id>`
  (e.g. `001780000000123.000007.9f1c8a2e-…`). Both numeric fields are
  zero-padded to a fixed width so bytewise `ORDER BY hlc` in SQLite is
  exactly causal order, and `.` is the separator because it cannot
  appear in a uuid. API: `nextHlc` / `receiveHlc` / `compareHlc` /
  `parseHlc` / `formatHlc`, all re-exported from `src/index.ts`.
  A plain wall-clock timestamp was rejected: a laptop that sleeps and
  wakes with a clock skewed behind a peer would stamp fresh edits as
  older than stale ones and silently lose them under last-writer-wins.
  The HLC treats wall time as a hint and the counter as truth, so the
  minted value is monotonic even when the clock stalls or jumps
  backwards. Not yet wired into capture or sync (`src/logs.ts` keeps
  its placeholder hlc until v2-capture).
- **`machine_identity.last_wall` / `.last_counter`** persist that clock.
  Every mu invocation is a separate short-lived process, so an
  in-memory counter would reset and mint duplicates that
  `UNIQUE (machine_id, hlc)` would reject. `nextHlc` advances the pair
  in a single atomic `UPDATE … RETURNING`; `receiveHlc` uses
  `BEGIN IMMEDIATE` because its three-way max is a genuine
  read-then-write. Additive columns on an existing v9 table — the table
  count stays at 10 and `CURRENT_SCHEMA_VERSION` stays at 9
  (1.0 is unreleased).

### Changed

- **Voice pass over the docs corpus (`docs2-ai-writing`).** The docs
  were written almost entirely by LLM workers over one session, and it
  showed in ways the terseness pass could not reach: filler
  intensifiers (`deliberately`, `genuinely`, `truly`, `precisely`),
  promotional adjectives (`flagship`, `killer property`,
  `best-in-class`), vague endorsement (`worth knowing`,
  `worth stating plainly`), and closing sentences that restated the
  paragraph above them. Prose only — no command, flag, exit code,
  path, table name, schema version or count changed, and
  `test/docs-cli-drift.test.ts` stayed green throughout. Parallel
  bullet lists, repeated sentence shapes in reference tables and
  Oxford commas in factual enumerations were left alone: a reference
  doc legitimately repeats the structures a prose detector dislikes.

- **Documentation sweep for 1.0, plus a CI guard so the next drift is
  caught by a test rather than by a human (`v2-docs`).** Every previous
  release task updated the docs it touched; this pass swept for the
  contradictions no single task could see. Fixed: `mu undo` documented
  as a whole-DB snapshot restore with a `--to <id>` flag and a
  `mu snapshot list / show / prune / delete` family (USAGE_GUIDE § 14);
  `mu workstream destroy` described as auto-capturing a snapshot;
  `mu archive create` in the destroy recipe; `mu archive show` in the
  flag-vs-positional rule; `mu archive restore --source` in USAGE_GUIDE
  and ROADMAP (VOCABULARY's copy was fixed separately); the `mu sql`
  schema paragraph listing 16 tables including all five `archived_*`,
  `agent_logs`, `snapshots` and `workstream_sync` (now the real 10,
  split portable vs machine-local); eight live prose references to
  `agent_logs` as the place claims/actors/events are recorded (now
  `ops.actor` / the ops log); ARCHITECTURE's claim-protocol and data-flow
  steps still writing `agent_logs` rows of `kind='event'`; README's
  entire "Portability and handoff" section still describing
  `mu db export`/`import` with divergence sidecars (now ambient sync +
  per-field merge + `mu rebuild`); AGENTS.md's module tree listing
  `db-sync.ts`, `db-sync-replay.ts`, `snapshots.ts` and `cli/db.ts`
  while omitting all thirteen modules the ops-log arc added; AGENTS.md's
  "Update the schema" section still documenting v8 as current.
  ARCHITECTURE gained rows for `src/undo.ts`, `src/cli/undo.ts`,
  `src/parked.ts` and `src/project-root.ts`, which had none.

  **The guard** (`test/docs-cli-drift.test.ts` +
  `test/_doc-commands.ts`, fast tier, no new dependency): extracts every
  `mu ...` command from nine docs — 668 of them — and asserts each names
  a real verb path and passes only real flags, by walking commander's
  own command tree rather than shelling out per snippet. Historical and
  rejected-surface sections opt out with a
  `<!-- doc-cli-drift:skip-start -->` region. It self-tests against five
  planted drift shapes, including a flag inside markdown
  optional-brackets (`[--source <orig-ws>]`) — the shape that shipped
  past two reviewers, because a naive extractor treats the brackets as
  prose.

- **`mu log` renders prose from structured intents, through ONE
  formatter (`v2-log-verb`).** R7 left the read side printing op payloads
  verbatim, so a four-command session looked like this:

  ```
  #2 ... task  [demo/a]  {"local_id":"a","title":"Build auth","status":"OPEN","impact":80,...}
  #3 ... task  [demo/a]  {"impact":90,"updated_at":"2026-08-01T17:22:51.340Z"}
  #5 ... task  [demo/a]  {"updated_at":"2026-08-01T17:22:51.638Z"}
  ```

  It now looks like this:

  ```
  #2 ... system    task add a "Build auth" impact=80 effort=3   [demo]
  #3 ... system    task update a impact=90                      [demo]
  #4 ... worker-2  task note a #1 some context by worker-2       [demo]
  #6 ... worker-2  task close a → CLOSED                         [demo]
  ```

  New `src/log-render.ts` is the single formatter: `mu log`, `mu state`'s
  Recent card, the ink Activity-log card, and the log popup all render
  through it, so no surface can invent its own phrasing. It reads
  `intent` + `key` + named payload fields and **never string-matches a
  payload to decide what an op is** — that was the old failure mode.

  This finishes the job R7 started: `classifyEventVerb`,
  `EVENT_VERB_PREFIXES`, `ClassifiedEvent`, and `logRowSubject` are
  DELETED. All four existed only to recover a verb by prefix-matching
  prose, which is why `CLAIM_EVENT_PREFIX` had to be bolted on when that
  matching broke. Exhaustiveness is enforced at COMPILE time — the
  formatter's switch is its function's only exit path, so a missing
  `KnownIntent` is a type error (it caught a missing `task.delete` case
  during development) rather than a runtime fallback.

  - **`--kind` is RETAINED, not renamed.** It is the operator's channel
    tag (`mu log --kind pr-state '...'`), a different axis from what mu
    records, and the documented **log ledger** watcher pattern depends on
    its shape. Filtering on mu's own labels is the NEW `--intent
    task.close`. Renaming `--kind` would have broken a documented
    convention to save a flag.
  - **New `--group <id>`** filters to one undo group — every op of a
    single operator action. Undo discoverability, for `v2-undo`.
  - **`--json` gains a `rendered` field** alongside the structured
    columns (now including `intent`, `group`, and `op`), so a script can
    switch on `intent` and never has to re-derive prose from `payload`.
    `--tail` stays NDJSON, one object per line, per SKILL.md.
  - **Parent-row touch ops are hidden from the log.** Adding a note or an
    edge bumps its task's `updated_at`, and that UPDATE fires the capture
    trigger, producing a second op in the same group whose payload is
    only `updated_at`. A `task note` therefore appeared TWICE. Those rows
    stay in `ops` (they are real changes, and per-field merge needs them)
    but they are not log lines. The filter is applied to `listLogs` and
    `latestSeq` by one shared predicate, because those two must return
    the same row set — when they disagree, `--tail` starts past rows the
    non-tail view already showed (hit in R4, again in R7).
  - `mu state`'s heading is now "Recent activity (last N ops)"; it
    previously claimed "of kind=event", an entity that no longer exists.
  - `src/parked.ts` keys its marker on `intent = 'workstream.export'`
    instead of a `db export ` payload PREFIX. Same rule: nothing decides
    what an op is by string-matching its text. (The heuristic remains
    dormant pending `v2-sync`, as flagged in R7.)
  - The log popup's drill view shows the rendered prose first, then
    `intent` / `key` / `group` / raw payload — readable but still
    lossless. Its `y`-yank resolves the target from `intent` + `key`,
    which also fixes a latent bug in the old `logRowSubject`: it split
    ids on `-`, so `mu task show` yanked `my` for a task named `my-task`.

- **`mu log` reads the ops log itself; the duplicate prose events are
  gone (`v2-retire-log-shim`).** v2-capture left every operator action
  recorded TWICE — once as a typed op from the capture trigger
  (`intent='task.update'`, `key='demo/t1'`, JSON payload) and once as a
  prose breadcrumb from the `src/logs.ts` shim (`intent=NULL`,
  `key='demo'`, free text). A 4-command session wrote 8 ops. It now
  writes **4**, every one with a non-null intent.

  The duplication was the smaller problem. The prose copies used
  `entity='event'`, which is not in `SYNCED_ENTITIES`, so the
  human-readable narrative could never reach a peer while the typed ops
  did — and with `intent=NULL` they could not be rendered by the single
  intent-driven formatter v2-log-verb builds, only by prefix-matching
  their text, which is the brittleness the ops log exists to delete.

  Census of all 20 `emitEvent` call sites, classified by whether a
  capture trigger can see the change:

  - **13 deleted** — they mutate a portable table (`tasks`,
    `task_edges`, `task_notes`, `workstreams`), so the trigger already
    recorded them with a real intent and natural key: `workstream.init`,
    `workstream.destroy`, `task.add`, `task.note`, `task.delete`,
    `task.update`, `task.set-*`/`close`/`open`/`reject`/`defer`,
    `task.block`, `task.unblock`, `task.reparent`, `task.release`, and
    both `task.claim` paths.
  - **11 kept, now with typed intents** — they mutate no portable table,
    so no trigger will ever see them: `agent.spawn` / `close` / `free` /
    `adopt` / `kick` (`agents` is machine-local; it holds `pane_id`),
    `workspace.create` / `free` / `refresh` / `recreate`
    (`vcs_workspaces` holds absolute paths), `agent.stall` (a pure
    observation), and `workstream.export` (writes files). `emitEvent`
    now REQUIRES a `LocalIntent` — a closed union, so a typo is a
    compile error — and derives `entity` from it.
  - **1 reclassified**, which the split did not predict: `task reap`
    DOES mutate `tasks`, so a trigger sees it, but it ran outside any
    `withOpContext` and so produced `intent=NULL` typed ops. Fixed with
    an intent (`task.reap`, actor `reaper`), not a prose row.

  Consequences, each verified rather than assumed:

  - **Claim attribution is `ops.actor`.** The tab-delimited
    `task.claim<TAB><id><TAB>actor=...` payload prefix — which existed
    only because prose had to be re-parsed — is deleted, along with
    `formatClaimEvent` / `parseClaimEventActor` / `displayEventPayload`.
    `lastClaimActor` reads two indexed columns. This still works on the
    `--self` path, where `owner_id` stays NULL by design and the payload
    therefore *cannot* name the actor.
  - **`--evidence` moved to notes for every verb.** It previously lived
    in the prose payload, and only `close` also wrote a note — so
    deleting the prose would have silently dropped evidence on
    `reject` / `defer` / `open` / `release` / `claim`. `recordEvidenceNote`
    now covers all of them; notes are portable, so evidence syncs, which
    the machine-local prose event never could.
  - **`mu log` output is uglier for now, and honest.** Captured payloads
    render as raw JSON until v2-log-verb renders prose from `intent`. The
    entity filter that hid captured ops is gone, and `latestSeq` dropped
    it in lockstep — when the two disagree, `--tail` starts past rows the
    non-tail view already showed.
  - **Workstream scoping follows the natural key.** `ops.key` is `demo`
    for workstream rows but `demo/t1` for everything inside, so
    `key = 'demo'` alone hid nearly every op. Fixed in `listLogs`,
    `latestSeq`, and `src/parked.ts` — where the same bug meant local
    activity no longer superseded an export marker.
  - The TUI Recent card and the `y`-yank shortcut read ops directly;
    yank resolves its target from `intent` + `key` instead of prose.

  `EVENT_VERB_PREFIXES` shrank from 22 entries to 11 and is now only the
  TUI's verb colouring, disappearing with v2-log-verb.

  New `test/ops-no-duplicates.test.ts` (11 fast-tier tests) is the guard
  that would have caught this: no two ops in one `group_id` may describe
  the same `(entity, key, op)`; no op may use `entity='event'`; every op
  must carry a non-null intent with no exception list; a 4-command
  session must write exactly 4 ops; and `latestSeq` must equal the max
  seq `listLogs` returns.

- `src/logs.ts` reads and writes `ops` rows (`kind`→`entity`,
  `source`→`actor`, `workstream`→`key`). Superseded within the arc by
  v2-retire-log-shim above, which retired the duplicate-emit half of the
  shim and changed `emitEvent` to require a typed intent; what remains is
  a thin typed reader plus the one write path triggers cannot cover.
- Log rows survive `workstream destroy` (the ops log has no FK
  cascade); `mu doctor` reports `ops rows` instead of
  `agent_logs rows`.
- `src/logs.ts` now mints REAL HLCs via `nextHlc`. The placeholder
  `<iso>|<uuid>` is gone — `parseHlc` deliberately rejects that shape
  as a tripwire, so leaving it would have failed loudly the moment
  anything read the log in HLC order.
- The log shim briefly read only log-line entities (`message` / `event` /
  `broadcast`) so a single `task add` did not surface twice in `mu log`.
  v2-retire-log-shim removed the duplicate emitters instead, so the
  filter had nothing left to hide and is gone: `mu log` reads every op.
  `latestSeq` dropped it in lockstep — the two must return the same row
  set, or `--tail` starts past rows the non-tail view already showed.
- `mu sql --confirm-rows` counts only the OPERATOR's rows. Capture
  triggers make `total_changes()` roughly 5x the real count (the op
  INSERT plus the HLC clock UPDATEs), which would have turned the
  safety prompt and its "re-run with --confirm-rows N" hint into
  nonsense. The multi-statement path now measures a capture-suppressed
  probe, then rolls it back and re-executes with capture ON before
  committing — so the committed writes are still captured, and a
  matching count never commits uncaptured rows.

### Fixed

- **Docs promised `mu archive restore --source <orig-ws>`, which does not
  exist (`doc-archive-restore-flag-drift`).** The implemented verb takes
  the source workstream through the universal `-w/--workstream` scope
  flag, per VOCABULARY § Naming conventions ("the primary entity a verb
  acts on is positional; everything else is a flag"). The rename is
  correct; the docs and one `AgentHasWorkspaceError` next-step hint were
  stale and made a copy-pasted command fail with "unknown option".

- **An edge or note whose task arrived from a DIFFERENT peer was silently
  dropped from the live tables, permanently
  (`v2-sync-workflow-integration`).** `applyEdgePut` / `applyNotePut`
  correctly returned `skipped:"absent"` when the task they hang off had
  not been ingested yet, and their comments promised the next re-read
  would replay the task op first and land them. It did not: `ingestSegment`
  counted an `absent` skip as a successful apply and advanced the
  **watermark** past the line, so the segment was never re-read.

  Not a corner case. `flushSegment` ships only LOCAL ops, so a machine's
  segment routinely holds `edge`/`note` ops naming tasks created in
  ANOTHER machine's segment, and `discoverPeers` orders peers by
  `localeCompare` over random-UUID filenames — so whether the parent
  arrives first was a coin flip per fleet. Measured on 8 fresh fleets:
  5 dropped the edge, 3 kept it, correlating exactly with segment
  filename sort order.

  The op was always recorded in `ops`, so no data was lost and `mu doctor
  --deep` DID report the divergence (a rebuild replays the log in global
  HLC order and got the right answer while the live tables did not) —
  but nothing healed it short of `mu sync --repair`.

  Fixed with `reprojectDeferredOps` in `src/apply.ts`, run once after
  every ingest pass (not per peer — an edge in peer A's segment may name
  a task in peer B's, so only the union is enough). It asks the ops log
  which note/edge puts are resolvable now but unprojected, rather than
  keeping a retry queue: a queue would be a second source of truth that
  can disagree with `ops`, and would not survive the process, while the
  parent commonly arrives days later. Ops whose parent task is genuinely
  gone, and keys with a newer `del`, are excluded — so a deleted edge is
  not resurrected and an orphan is not retried forever. On a healthy DB
  it is two indexed queries returning zero rows.

- **`mu log --group` accepts abbreviated group ids, like `mu undo` always
  did (`bug-group-id-prefix-asymmetry`).** `mu undo` prints the most
  recent group as an 8-char prefix and accepts one, but
  `mu log --group <prefix>` compared `ops.group_id` literally and
  returned zero rows. Each verb was self-consistent, so the break only
  showed when following the documented workflow ACROSS them: `mu undo`
  (see the id) → `mu log --group <id>` (inspect it) → `mu undo <id>
  --yes` (commit).

  The failure was silent and misleading rather than an error: an empty log
  reads as "this group did nothing", which could lead an operator to skip
  inspection or undo the wrong group. `mu undo`'s own `Next:` hint printed
  the short form, so mu was suggesting a command that did not work.

  Fixed by extracting ONE `groupIdFromPrefix(db, prefix)` into
  `src/logs.ts` (next to the ops reader) that both verbs call —
  `src/undo.ts`'s `resolveGroupId` now delegates to it rather than keeping
  a second copy of the rule. An exact match always wins over prefix
  matching, so a full uuid can never be shadowed.

  Two error paths that did not previously exist:

  - An unmatched prefix is `UndoGroupNotFoundError` (exit 3) instead of an
    empty listing.
  - An ambiguous prefix is the new `GroupIdAmbiguousError` (exit 4),
    naming the candidates, in BOTH verbs. Astronomically unlikely with
    uuids, but silently picking one of two candidate groups to *undo* is
    not an acceptable failure mode. It was previously folded into
    not-found.

  `--json` keeps emitting full uuids (machine-readable); human-facing
  `Next:` hints keep the short form, which now works.

- **`mu agent send` no longer reports success for input it did not
  deliver (`dogfood-send-after-new-dropped`).** The documented
  `send '/new'; sleep 2; send '<prompt>'` pattern silently dropped the
  second prompt. Both sends exited 0, the `/new` landed, and the pane
  then sat at `needs_input` with 0.0% context on work it had never
  received — the orchestrator only found out ~300s later when
  `mu task wait --on-stall exit` returned 7. Cost ~30 minutes of
  wall-clock across two stalled workers in one session.

  Reproduced against a real pi pane before any fix: **3 of 6** attempts
  dropped at `sleep 0.3`, **1 of 5** at `sleep 2`.

  Root cause, measured rather than assumed: `/new` makes pi run an async
  "Naming session before closing…" step — an LLM call, ~1.5s warm but
  unbounded. A bracketed paste arriving during that modal is *accepted*,
  but **the Enter after it is swallowed**, so the prompt is left typed
  but unsubmitted in the new session's input box. (Confirmed by
  occurrence-counting a probe string: exactly once, in the input box,
  3/3 attempts; re-sending Enter took it to 2 and moved context
  0.0% → 2.4%.) Because the blocker is a model call, no fixed sleep can
  be correct — `sleep 2` was a coin flip, not a fix.

  Fix reuses the existing readiness machinery instead of adding a second
  mechanism, matching `awaitSpawnReadiness`: `sendToPane` now waits for
  the pane to quiesce before pasting (`MU_SEND_READINESS_MS`, default
  15000, `0` disables), and after Enter re-checks for text stranded in
  the input box, re-sending Enter to recover it. Only if it is *still*
  stranded does it warn — loudly, on stderr, with `"delivered": false`
  under `--json`. **Exit 0 now means submitted.** Not a throw, because
  by then the text is in the agent's input box and a bare Enter
  finishes the job; failing the command would break every caller for a
  recoverable condition.

  Two subtleties, each found by measurement and each load-bearing:

  - Quiescence requires **3 consecutive** calm polls. pi renders its
    modal ~200-400ms *after* the `/new` send returns, so a single poll
    reads the previous frame, declares the pane ready, and pastes
    straight into the modal that is about to appear (observed: ready
    returned true while naming was still running on 2 of 3 attempts).
  - A pane that is busy **working** is not waited on. Queuing a
    follow-up into a working agent is a documented pattern that pi
    supports, and waiting on it made every such send pay the whole
    budget — 14.5s of 15s, versus milliseconds before. Modal and
    mid-turn both render a Braille spinner, so they are told apart by a
    work marker (`to interrupt)` / `Working`), not by timing.

  `skills/mu/SKILL.md`, `docs/HANDOVER.md`, and `docs/USAGE_GUIDE.md`
  drop the `sleep` from the clear-then-send recipe.

  `docs/HANDOVER.md` § the dispatch loop now states the four-step rule
  the fix unblocks — `workspace recreate` → `/new` → brief → VERIFY
  DELIVERY — with the measured cost of skipping `/new` (workers at
  65-68% of an 800k context by wave 6; ~$5 → ~$35 per task) and the
  caveat that an orchestrator runs the INSTALLED mu, so the send fix
  may not be in the binary doing the dispatching. Verification is not
  optional until 1.0 is installed.

- **Blank (whitespace-only) list-flag fragments no longer silently
  vanish (`bug_whitespace_status_fragment`).** `parseCsvFlag` trimmed
  fragments and dropped every empty result, which conflated two
  different things. `mu task list --status "OPEN, "` quietly widened to
  *no status filter at all* and exited 0, and `--status " "` returned
  every task as though the flag had been omitted — the same
  silent-wrong-answer class as the flag-vs-positional sweep, and the
  opposite of a narrower filter, so it was under-reporting nothing and
  over-reporting everything.

  The rule, now written down in `docs/VOCABULARY.md` § Empty vs blank
  flag fragments: **an EMPTY fragment (`""` before trimming) is
  dropped; a BLANK fragment (non-empty before trimming, empty after) is
  a `UsageError` (exit 2).** Empty is a structural comma artifact
  (`"a,b,"`, `"a,,b"`) or the documented `reparent --blocked-by ''`
  clear-all sentinel — both things an operator means. Blank is a typo
  or a quoting accident that nobody means.

  Enforced once in `parseCsvFlag`, so all four list flags agree
  (`--status`, `--by`, `--blocked-by`, `-w`); all 7 call sites now pass
  their flag name so the error names the flag actually typed. A blank
  single-value `-w ' '` is rejected in `resolveWorkstream` too — an
  adjacent hole found while testing, since that shape never reaches
  `parseCsvFlag` and previously resolved to a workstream named `" "`.
  Whether zero surviving fragments is legal stays a per-verb call made
  after the rule: `--by ''` still requires a blocker, `reparent
  --blocked-by ''` still clears all.

  The property test that caught this was itself wrong in three ways and
  is rewritten as a TOTAL property that derives the expected outcome
  from the input rather than filtering the generator: besides the
  reported `" "`, it also mis-asserted that `","` (two empty fragments)
  and `"OPEN,"` (a valid `[OPEN]`) must throw. It failed ~1 in 3 runs
  in isolation, which both the orchestrator and this worker twice
  misfiled as the known load-flake; it was fast-check randomness, not
  concurrency. Now 15/15 green in isolation.

- **Three pre-existing test failures across two files (not new
  regressions).** All three reproduce on the commit the ops-log branch
  forked from, and all three are FIXTURE bugs, not production bugs.

  - `test/_fixture.ts` `freshWorkstream()` documented a 17-char suffix
    budget but did not enforce it: `Date.now().toString(36)` is 8
    chars and the pid can be 5, so prefix `"claim"` yielded a 27-char
    name. A caller appending its own suffix (`${ws}-other`) then blew
    the 32-char `/^[a-z][a-z0-9_-]{0,31}$/` workstream-name regex and
    `test/claim.integration.test.ts` failed with
    `WorkstreamNameInvalidError` instead of the `TaskNotFoundError` it
    asserts. The pid and timestamp are now truncated to their
    low-order base36 chars (3 + 6), which is where the entropy lives;
    worst case is now the documented 17.
  - `test/cli-agent-preflight.test.ts` pointed both `MU_STATE_DIR` and
    `--workspace-project-root` at the SAME temp dir. Workspaces
    materialise under `<state-dir>/workspaces/<ws>/<agent>`, so the
    `none` backend's `cp -a <projectRoot>/. <workspacePath>` copied a
    directory into itself and `cp` refused. The two roots are now
    sibling subdirs, matching real deployments (`~/.local/state/mu`
    vs. the repo).

- **Flag-vs-positional parity sweep (dogfood-\* findings).** Four
  dogfooding reports, one theme: mu named the SAME concept two
  different ways across sibling verbs, so muscle memory from one verb
  produced a help dump on the next. The rule the CLI already mostly
  followed is now written down in `docs/VOCABULARY.md` § Naming
  conventions and `docs/USAGE_GUIDE.md`: **the primary entity a verb
  acts on is positional; everything else is a flag.** Every fix below
  is ADDITIVE — no existing invocation changed.

  - `mu task block <id> --by` and `mu task unblock <id> --by` now
    accept multiple blockers in repeat / comma-separated / mixed form
    (`--by a,b`, `--by a --by b`, `--by a,b --by c`), routed through
    the same canonical `parseCsvFlag` helper `mu task add
    --blocked-by` already used. Previously `--by a,b,c` failed with
    the confusing `no such task: a,b,c`. A bad id inside the list
    still exits 3 naming only the offending id. An all-empty
    `--by ''` is now a usage error (exit 2) instead of a silent
    no-op.
  - `mu workstream destroy [name]` and `mu workstream export [name]`
    accept the workstream positionally, matching `mu workstream init
    <name>`. Previously the bare positional was rejected as "too many
    arguments" and printed help. `-w` keeps working; supplying both
    with disagreeing values is a usage error (exit 2) rather than a
    silent pick-one.
  - `mu task note <id> --text "..."` is accepted as an alias for the
    positional note body, matching `mu task add --note <text>`.
    Supplying both, or neither, is a targeted usage error (exit 2)
    that names both shapes instead of dumping help.
  - Validation for the new `--text` alias runs INSIDE the `handle()`
    callback rather than the `.action()` body, so it goes through the
    typed-error → exit-code map (and the DB-close `finally`) like
    every other verb's checks.

  Not reproduced: `dogfood-init-exit-code` reported `mu workstream
  init mu-foo` exiting 0. On this tree it exits **2** (the documented
  usage lane) across human, `--json`, piped, and PTY invocations, as
  does the installed 0.4.5 build; `WorkstreamNameInvalidError` is
  already mapped to exit 2 in `src/cli/handle.ts`. A regression test
  asserting the exit CODE (not just the message) is added so it can't
  silently regress.

---


## [0.4.5] — 2026-06-09

### Added

- **`mu task add --note <text>` for initial task context.** Task
  creation can now append the first note in one command, with
  `--note-author <name>` for explicit attribution. The add + note path
  is wrapped in one transaction, so either both land or neither does.
  `--json` includes the created `note` when present.

### Fixed

- **TUI no longer double-renders on every mount (`useTerminalSize`).**
  The resize-reactivity hook's mount-time "defensive sync" called
  `setSize()` unconditionally, forcing a second render on every mount
  even when the dimensions were unchanged — wasteful in production and,
  in ink render tests, doubling the captured frame log (it had been
  silently breaking `tui-row-budget-overflow.integration.test.ts`,
  which measured `2 × rows − 1` lines). The hook now bails with the same
  state reference when nothing changed, so React skips the no-op
  re-render; a genuine resize still re-renders. Full suite green again.

- **Parallel `mu agent spawn` no longer races and drops agents.**
  Firing several spawns at once (`for n in 1 2 3; do mu agent spawn
  scout-$n -w scratch & done; wait`) silently dropped — and sometimes
  duplicated — agents. Two cross-process races, since every `mu`
  invocation is a separate process:
  - **tmux topology:** N processes all saw the session absent and all
    ran `new-session`; losers threw and rolled back their agent row.
    Fixed with a per-session filesystem advisory lock
    (`src/agents/spawn-lock.ts`, via atomic `fs.mkdir` — no new
    dependency) around the topology check-then-act + row finalize. The
    slow liveness wait stays OUTSIDE the lock so genuine parallelism is
    preserved. Keyed on the tmux session name; spawns into different
    sessions never contend. Tunable via `MU_SPAWN_LOCK_TIMEOUT_MS`.
  - **schema init:** N processes opening the same fresh DB interleaved
    the non-transactional `DROP VIEW goals; CREATE VIEW goals` DDL and
    hit 'view goals already exists'. Fixed with `busy_timeout = 5000`
    plus an atomic `BEGIN IMMEDIATE` transaction around the schema DDL
    in `openDb`.

### Added

- **`mu agent ensure <name>` — idempotent spawn-or-reuse.**
  Watcher/scratch flows can now collapse "spawn this helper iff one is
  not already alive" into one verb. Missing agent → `spawnAgent` using
  the practical spawn flags (`--workspace`, `--role`, `--cli`, `--cwd`,
  `--tab`, workspace backend/from/project-root); existing idle/free agent
  → reuse, `changed=false`, `reused=true`, exit 0. Existing busy / still
  spawning / permission-blocked agent is reused by default with
  `busy=true` and no mutation (the safest small default: do not spawn a
  duplicate and do not interrupt the worker). `--idle-only` turns that
  busy-existing case into typed `AgentBusyError` (conflict exit 4) so
  scripts can use it as a concurrency lock. JSON success shape:
  `{agent, changed, created, reused, busy, existed, previousStatus,
  workspace, nextSteps}`. New SDK: `ensureAgent` / `EnsureAgentResult`
  in `src/agents.ts`; CLI verb `cmdEnsure` in `src/cli/agents.ts`.

- **`mu agent poll` — non-blocking, read-only snapshot of all agents.**
  The dual of `mu agent wait`: where `wait` blocks until a status
  transition, `poll` captures the current pool state exactly once and
  returns — the shape a `/watch` loop or orchestrator tick wants (poll
  each tick, diff against the previous tick). Per-agent JSON fields:
  `{name, status, idleMs, lastActivitySeq, workspaceBehind, dead}`;
  `--json` returns the `{items, count}` collection shape. It does NOT
  reconcile (no DB mutation), does NOT capture per-pane scrollback, and
  does NOT fetch from any VCS remote (`workspaceBehind` is as fresh as
  the workspace's local refs cache) — a single `list-panes` read detects
  dead panes. New SDK: `pollAgents` (with `AgentPollSnapshot` /
  `AgentPollView`) in `src/agents.ts`, reusing the `listAgents` /
  `listLogs` / `decorateWithStaleness` / `listPanesInSession` seams; CLI
  verb `cmdPoll` in `src/cli/agents.ts`.

- **`mu agent reap-idle` — one-line graveyard cleanup of idle helpers.**
  The scratch watcher pattern (one `fixer-N` per unit) leaves a
  graveyard of finished panes; this verb sweeps the workstream and
  closes the finished, idle, SAFE ones in one shot instead of
  `mu agent list | grep | xargs mu agent close`. An agent is a
  candidate when its status is `needs_input` / `needs_permission` /
  `free` (not `busy`/`spawning`) AND it has been idle `>= --idle-for`
  seconds (default `MU_IDLE_THRESHOLD_MS`, 300). Each candidate is
  closed via the existing `closeAgent` path, which auto-frees a clean
  workspace and **refuses a dirty one** — so a helper with uncommitted
  changes / commits since fork is *skipped* by default (no lossy
  surprise); `--discard-dirty` overrides (lossy). `--dry-run` previews
  without killing any pane. `--json` returns the `{items, count}`
  collection shape where `count` is the number actually CLOSED and each
  item carries `{name, action: "closed"|"skipped", status, idleMs,
  reason?, workspaceFreed?}` so the output is a full audit of the sweep.
  Works in any workstream, not just `scratch`. New SDK: `reapIdleAgents`
  (with `ReapAgentResult` / `ReapView` / `ReapIdleAgentsOptions`) in
  `src/agents.ts`, reusing `listAgents` + `getWorkspaceForAgent` +
  `isWorkspaceClean` + `closeAgent`; CLI verb `cmdReapIdle` in
  `src/cli/agents.ts`.

- **`mu agent wait <names...>` — block until agents finish working.**
  The task-less counterpart to `mu task wait`: scratch / off-the-cuff
  helpers usually own no task in the DAG, so the only "done" signal is
  the agent's own runtime status. An agent fires when it transitions
  **busy → any other state** (it must be observed busy first, so an
  already-idle agent does not fire instantly — you're waiting for *this*
  work to finish). Replaces `sleep` polling loops in orchestrator
  scripts. Mirrors `mu task wait`'s shape: `--any`/`--first` fire on the
  first agent (default: all), `--first` prints the firing ref, `--json`
  carries `nextSteps`, refs may be qualified `<workstream>/<name>`.
  Exit codes: `0` met, `5` timeout, `6` a watched agent's pane died.
  Status detection is pi-only, so a non-pi pane never goes busy and the
  wait times out. New SDK: `waitForAgents` in `src/agents/wait.ts`.

- **`scratch` reserved workstream for off-the-cuff agents.** A new
  reserved workstream name, `scratch`, lowers the activation energy of
  mu's "stand up an agent I can keep talking to" value: `mu agent
  spawn helper -w scratch` Just Works with zero setup (the workstream
  and its `mu-scratch` tmux session auto-create on first spawn, via
  the existing `ensureWorkstream`/`createOrReusePane` paths). Tasks are
  optional in `scratch` — a task-less helper is fine; the task DAG
  stays opt-in. This is a strict expansion of mu's driveable/
  observable/durable agents into low-ceremony territory, **not** a
  replacement for `pi-subagents` (which remains the right tool for
  one-shot "fire a focused task, get a result back" delegation).
  `scratch` agents are still **agents**, not "subagents".

  - `mu workstream init scratch` is rejected loud
    (`WorkstreamNameReservedError`, exit 2) with next-steps pointing at
    `mu agent spawn ... -w scratch` — the name only ever auto-creates
    on spawn, so it can't be mistaken for a durable crew workstream.
  - `mu workstream destroy scratch` keeps the standard confirmation.
  - `scratch` is special-cased for staleness: idle scratch agents are
    nudged in `mu state` and surfaced in the TUI (ephemeral tab marker
    + scratch-agent counter) so easy spawning doesn't become pane
    sprawl you forget about.
  - New SDK surface in `src/workstream.ts`: `RESERVED_WORKSTREAM_NAMES`,
    `SCRATCH_WORKSTREAM`, `isScratchWorkstream()`,
    `assertWorkstreamInitable()`, `WorkstreamNameReservedError`.

---


## [0.4.4] — 2026-05-30

### Added

- **All-tasks popup (`t`): blocked indicator + filter toggle**. Tasks
  with unsatisfied blockers now show a `⛓` chain-link glyph next to
  their status column (yellow-coloured when blocked). New `b` key
  cycles a three-state blocked filter: all → only-blocked →
  hide-blocked → all. Displayed as a `[b]locked:` strip between the
  status-filter and sort-indicator rows. Keymap help (`?`) updated.

### Fixed

- **TUI re-renders on terminal resize**. Shrinking or growing the
  tmux pane now immediately reflows the dashboard and all popups.
  Previously ink did not re-render on resize because `useStdout()`
  values were read once per render with no subscription to the
  `resize` event. New shared `useTerminalSize()` hook subscribes to
  stdout's `resize` event and forces a state update; all 17 consumers
  (app, titled-box, help overlay, viewport, and 13 popup files)
  migrated from the ad-hoc `useStdout()` pattern.

- **Agent spawn waits for CLI readiness before returning**. After the
  existing liveness check (pane alive + no startup errors),
  `spawnAgent` now polls the pane's scrollback via `detectPiStatus`
  until the CLI reaches a recognisable state (`needs_input`, `busy`,
  or `needs_permission`). Prevents orchestrators from sending
  commands to agents that haven't finished loading. Controlled by
  `MU_SPAWN_READINESS_MS` (default 10 000 ms; 0 disables). Budget
  exhaustion is not an error — the agent row is already committed and
  reconcile picks up status on the next tick.


## [0.4.3] — 2026-05-23

### Performance

- **TUI stable-frame rendering** now keeps no-op fast ticks render-silent
  instead of bumping hook-level scalar state every poll interval. The
  dashboard fast tier also stops preloading the exhaustive all-tasks
  list; the All-tasks popup reads SQLite directly while open. This
  reduces full-frame repaint pressure and large-task-list churn while
  preserving slow-tick refresh for subprocess-backed drills.


## [0.4.2] — 2026-05-17

### Changed

- **TUI task owner and workspace agent columns** now prefix known agent
  references with the live agent status glyph (for example busy,
  waiting-for-input, or permission-needed) while preserving raw names
  for anonymous or stale references. The Agents card/popup keep their
  dedicated glyph column, and all TUI status-glyph formatting now goes
  through the shared `agent-display` helper.

- **TUI dashboard / `mu state`** now reap agents whose tmux pane has
  disappeared on the next reconcile pass (slow tick in the TUI,
  immediate in CLI invocations). Previously these reads skipped
  reaping, so ghost agents could linger until `mu agent list`. When a
  tmux session disappears (server crash, manual `tmux kill-server`,
  etc.), `mu state` and the TUI now report the truth: agents removed
  from the registry, their IN_PROGRESS tasks reverted to OPEN with
  `[reaper]` notes. Closes `bug_no_recovery_after_tmux_server_crash`.

### Removed

- Internal `ReconcileMode = "status-only"` variant. The placeholder-pane
  protection that motivated it is now defensive in the `reconcile()`
  prune loop itself, independent of mode.


## [0.4.1] — 2026-05-14

Feature theme: **multi-machine sync**. A single user can now move a
mu DB between two machines for multi-day laptop ↔ devserver stretches
with typed drift detection, sharp conflict handling, and lossless
un-archive.

### Added

- **`mu db export <file>`** — write the whole SQLite DB to `<file>`
  via `VACUUM INTO`, plus a `<file>.manifest.json` sidecar containing
  `machineId`, `schemaVersion`, and per-workstream `latestSeq`.
- **`mu db import <file>`** — drift-detecting per-workstream merge
  from an exported DB. Dry-run by default; `--apply` commits. Five
  case branches: `IDENTICAL` / `FAST_FORWARD` / `LOCAL_AHEAD` /
  `CONFLICT` / `IMPORT` (source-only or clean-machine import). On
  `CONFLICT`, refuses by default; `--force-source` clobbers from the
  source but first parks the local divergent state to
  `<state-dir>/divergence/<ws>-<ts>.db` for later inspection.
- **`mu db replay <sidecar>`** — manual cherry-pick of parked
  divergent state. Dry-run by default; `--task <id>` / `--all` apply.
  Idempotent; refuses on `local_id` collision with diverged content.
- **`mu archive restore <label> --as <new-ws> [--source <orig-ws>]`**
  — lossless un-archive from `archived_*` tables directly. No bucket
  round-trip. Refuses if `--as` collides; auto-snapshots before
  writing.
- **Schema v8**: `machine_identity` (one row per
  `~/.local/state/mu` DB; uuid + hostname seeded on first `openDb`),
  `workstream_sync` (per-workstream last-seen-peer-seq map for drift
  detection).

### Changed

- Bucket exports (`mu workstream export`, `mu archive export`) are now
  **read-only** artifacts for humans / git / docs. The lossless
  un-archive path is `mu archive restore`; the cross-machine sync path
  is `mu db {export,import}`.
- Help text on `mu archive export` and `mu archive add --destroy` now
  points to `mu archive restore` as the reverse-of-record.
- Multi-line tasks/notes in the TUI DAG popup: recurrence marker
  shortened to dim `(↻)` (was wordy English).

### Removed

- **`mu workstream import`** — replaced by `mu db import`
  (cross-machine sync) and `mu archive restore` (un-archive). Removing
  the lossy bucket→DB round-trip is the whole point of the new typed
  surfaces. Removed `src/importing.ts` (~800 LOC).

### Known limitations

- `mu db import` does **not** carry task owners (`owner_id` is an FK
  into the machine-local `agents` table). The hard rule for safe
  operation: no concurrent edits to the same workstream on two
  machines. Finish or release in-flight claims before `mu db export`.
  `mu agent list -w <ws>` shows current owners.
- `mu archive restore` does not restore `agent_logs` (archives don't
  snapshot the live event log).


## [0.4.0] — 2026-05-14

Feature theme: **interactive TUI**. Bare `mu` opens an ink-based,
read-only dashboard (rounded-border cards, fullscreen popups,
multi-workstream tabs, mouse + keyboard, live-updating); `mu state`
is the static card; both stay opt-in for non-TTY callers.

### Breaking

- Removed `mu state --mission`. Bare `mu` is the human TUI entrypoint;
  agents/scripts use `mu state --json` (superset of the old shape).
- Removed `mu state -n/--lines`; use `mu state --events <n>`.
- Removed `mu agent list --all`; agent listing is workstream-scoped
  (list workstreams, then `mu agent list -w <ws>`).
- SDK: `workstreamStateDir` / `ensureWorkstreamStateDir` removed
  from `src/db.ts` + `src/index.ts` (zero in-tree consumers).

### Added

#### TUI

- **Bare `mu` launches the dashboard** when stdout is a TTY.
  `--json`, `MU_NO_TUI=1`, and non-TTY pipes still print `--help`.
  `mu state --tui` remains the explicit selector.
- **9-card responsive dashboard**: Agents, Tracks, Ready, Activity
  log, Workspaces, In-progress, Blocked, Recent, Doctor, plus a
  Commits stream. Reflows into 1/2/3/4-column layouts at
  120/180/240 cols; per-card row-budget allocator picks min/max,
  culls low-priority cards on tight panes with a hint.
- **Card section headers inset into the rounded top border** with
  yellow superscript toggle digits (btop/lazygit convention).
  Optional `Shift+N` truncation hint inset into the bottom border.
- **9 fullscreen popups** opened with `Shift+1`-`Shift+9` (US-glyph
  row); single-popup invariant; `Esc`/`q` returns to dashboard
  preserving toggles + tick rate.
- **DAG popup (`g`)**: full task DAG for the active workstream,
  ASCII subtree per root, diamond-collapse marker, `o/i/c/r/d`
  per-status toggles, yanks `mu task tree <root> -w <ws>`.
- **All-tasks popup (`t`)**: every task as a sortable
  (roi → recency → age → id) + filterable list, drills into
  TaskDetailDrill, yanks `mu task show <id>`.
- **Multi-workstream tabs** (`mu state --tui -w A,B,C` or `--all`):
  one-row tab strip with `▸` active marker, `Tab`/`Shift-Tab`
  cycles, status-bar shows `[<active-ws>]`, single-ws frame
  byte-identical to pre-multi-ws.
- **Mouse support**: double-click card → drill, scroll wheel
  navigates lists/drills, double-click row → drill into detail.
- **`/` substring filter** on every list popup; status-bar
  flips to filter mode while editing; per-status-toggle strip
  on task popups (`o/i/c/r/d`).
- **`y` yanks** the canonical `mu` command for the focused
  row to the system clipboard via
  pbcopy/wl-copy/xclip/xsel/clip.exe with OSC-52 fallback.
- **`?` help overlay** (scrollable on short panes).
- **`t` in any git-show drill** launches `tuicr -r <sha>` with
  alt-screen suspend/restore — the one user-driven escape from
  the read-only TUI pledge.
- **Drills auto-refresh on tick** (fast 1s for SQL-derived bodies,
  slow 10s for subprocess git-show / scrollback). Scroll position
  preserved across refreshes; no blank-flash mid-refetch.
- **Initial active tab** picked via `$MU_SESSION` → tmux session
  → cwd inside a workspace → cwd at any workstream's project root
  → tab 0.

#### CLI

- **`mu agent adopt <pane-id> [--name <agent>]`** — formally
  register an existing tmux pane as a managed agent (was a manual
  SQL escape).
- **`mu task close-if-ready <id>`** — close a task only when all
  its blockers are CLOSED; refuses with a typed error otherwise.
- **`mu task wait`** — block until a set of tasks reach a target
  status; `--first --any --on-stall exit --json` returns full
  next-step recipe (cherry-pick, free, recreate).
- **`mu task claim --for` and `mu agent send` warn before
  dispatching to a stale workspace** (≥10 commits behind main);
  `--strict-staleness` makes it a hard error
  (`TaskClaimStaleWorkspaceError`, exit 4).
- **`--blocked-by <a,b,c>`** on `mu task add` (replaces the older
  `--blocks` direction; reads as "this task is blocked by X").
- **`mu workstream destroy --empty --yes`** sweeps every empty
  workstream in one snapshot (was N+1 snapshots).
- **VCS backend seam**: `recentCommits(projectRoot, limit)` and
  `showCommit(projectRoot, sha)` for git, jj, sl; `none` returns
  graceful empty.

### Changed

- **TUI dashboard cards pack tighter** (`pack_dashboard_cards_tighter`):
  `CARD_CHROME_ROWS` lowered from 4 to 2 — the true cost of
  `TitledBox`'s top + bottom border. The old value over-reserved 2
  rows per card, leaving visible blank space at the bottom of every
  card and forcing premature culling on short panes. Cards now stack
  flush; the cull threshold and largest-remainder row distributor
  re-tuned to match (more cards survive at a given pane height).
- **Schema v4 → v7**: snapshots table (v4 — auto-snapshot before
  every destructive verb), surrogate-PK normalisation
  (`tasks.id INTEGER PK + (workstream_id, local_id) UNIQUE`,
  v5), cross-workstream archive tables (`archives`,
  `archived_tasks`, `archived_edges`, `archived_notes`,
  `archived_events`, v6), dropped unused `approvals` (v7).
- **Source clusters**: split `src/{tasks,vcs,workspace,archives,
  snapshots}.ts` into per-concern subdirs; root files become SDK
  re-export hubs. Each cluster has its own ARCHITECTURE.md row.
- **MU_FORCE_COLOR=0 / FORCE_COLOR=0** now opt OUT of static CLI
  colour (matches chalk semantics; was inverted).
- **Diff drills** now render in colour (red/green/cyan diff
  highlighting); ANSI-aware wrap pads each line to the popup's
  exact content width so ink can't eat the right border.
- **TUI snapshot poll** split into a fast SQL-only tick (1s) and
  a slow subprocess tick (10s); p50 cost dropped ~385ms → <1ms.
- **`mu state --tui`** no longer preloads + discards static
  snapshots before launching ink.
- **`reconcile()`** hoists `knownAgentCommands()` out of the
  orphan-pane loop (one env-var read per pass, was per-pane).
- **README revised**: TUI dashboard screenshot up top; dropped
  anti-bloat boasting; "stay out of the model's way" thesis
  sharpened; dedicated TUI section.
- **`docs/ROADMAP.md`** trimmed 561 → ~165 lines (cut shipped
  entries, long rejection essays, pi-subagents internals tables,
  speculative items). Kept promotion criteria, anti-feature
  pledges, per-CLI detector sketch, open questions.
- **`skills/mu/SKILL.md`** trimmed 590 → 356 lines (filler,
  redundant warnings, restated examples).
- **`docs/HANDOVER.md`** added: orchestrator goto reset doc —
  onboarding, the 8-phase dispatch loop, conflict-resolution
  playbook, gotchas, end-of-session checklist. Cross-linked from
  AGENTS.md so a fresh orchestrator agent finds it immediately.
- **Notes model** standardised on FILES / DECISION / VERIFIED
  conventions (already de facto; documented).
- Many smaller pure-refactor TUI consolidations: shared
  `useNotesDrill`, `useDrillKeymap` (incl. `onScrollChange` +
  `resetKey`), `useWrappedBody`, `usePopupFilter` `enabled` prop,
  `CARD_REGISTRY` / `POPUP_REGISTRY`, `CARD_CONFIGS.{name,label}`
  table-lookup, `CardPlaceholder`, `shouldSwallowGlobalKey`,
  `setCursor` PopupAction (no more synthetic key replay),
  `getInkInternalEmitter` typed seam.

### Fixed

- **Filter+Enter on a list popup** drilled into the wrong row
  (visibleTasks dropped the text filter on `mode === "drill"`,
  re-resolved cursor against the unfiltered set). Filter now
  applies uniformly; popups also capture drilled-task identity
  at Enter time as a defensive belt.
- **git-show drill right border ragged** on coloured hunk-header
  rows. Two compounding fixes:
  (a) `wrap-ansi` closes any open SGR state on early-return +
      end-of-loop trailing chunk (was leaking colour into
      adjacent chrome cells).
  (b) drill body lines ANSI-wrapped + space-padded to exact
      popup content width before ink sees them, so ink's
      `wrap="truncate"` ANSI miscount no longer eats the
      trailing space + right border.
- **Drill auto-refresh flicker**: scroll reset to 0 on every tick
  + blank-flash mid-refetch. Scroll now resets only on identity
  change; subprocess loaders preserve prior body until new body
  arrives.
- **`mu agent spawn` startup-error scanner false-positive on
  banner prose** quoting `"command not found"` / `"No such file
  or directory"`. Regex anchored to end-of-line shell-error form.
- **`mu workstream import`** now refuses ANY existing target
  workstream (was only refusing if it had tasks); prevents
  silent merges into ws with agents/workspaces but no tasks.
- **`mu workstream import`** preserves literal `"system"` note
  author across round-trip (was coerced to NULL).
- **`mu task reparent` / `mu task add --blocked-by`** silently
  dedupe duplicate blockers from comma/repeated-flag forms (was
  raw SQLite UNIQUE error). Same-set reparent is a true no-op.
- **`mu task add`** drops empty `blocked by:` line when no
  blockers were supplied.
- **`mu workstream destroy` dry-run Next** preserves operator's
  `--archive <label>` and `--no-export` flags.
- **`mu sql`** routes single-statement read vs write via
  better-sqlite3's `stmt.reader` flag, not a string-prefix
  guess; `PRAGMA table_info(...)` and comment-prefixed SELECTs
  now return rows.
- **`agents.deleteAgent`** wraps the reaper sequence in a
  transaction so a mid-loop throw can't leave the agent row
  deleted with un-released stuck tasks.
- **`src/db.ts` resolver helpers** return `null` on miss
  (`tryResolveTaskId` / `tryResolveAgentId`); SDK callers throw
  the typed error so `cli/handle.ts:classifyError` maps to the
  right exit code (was falling through to generic exit 1).
- **TUI dashboard** culling on tight panes: low-priority cards
  collapse with a `+N hidden · resize taller` hint; outer height
  clip is the safety net. No more interleaved card borders.
- **TUI dashboard 2-col layout** keeps Commits (slot 0) trailing
  the right column; rebalance to 5/5 split.
- **TUI keyboard popup-opens** (`t`, `1-9`, `Shift+0-9`) consume
  the mouse double-click replay queue once via a ref (was
  replaying stale events).
- **TUI mouse double-click hit-test** aligned with rendered card
  heights (empty cards now render at full chrome+rowBudget).
- **All-tasks popup** properly windows large lists via
  `centredVisibleSlice`; cursor stays mid-window.
- **TabStrip** no longer crashes on small panes (was conditionally
  calling `useStdout()` — react hooks rule violation).
- **`?` help overlay scrollable** on low-row panes
  (j/k/Ctrl-D/U/g/G/PgDn/PgUp + position indicator).
- **VCS backend detection** card subtitle shows active backend
  (`git` / `jj` / `sl` / `(no vcs)`).
- **mu task wait** reaper integration tests no longer rely on
  fixed 100ms timers; action runs from the wait-loop sleep seam.
- **Test-suite flake population** audited and remediated; lessons
  in AGENTS.md / ARCHITECTURE.md. Multi-agent concurrent test runs
  on shared `/tmp` were the primary driver. New `npm run test:stress`
  runs 30× back-to-back (or parallel via
  `MU_TEST_STRESS_MODE=parallel MU_TEST_STRESS_PARALLEL=2`).

### Tests

- **Test suite split into fast/full tiers**: `npm run test:fast`
  excludes `*.integration.test.ts` / `*.smoke.test.ts` for the
  dev loop; full `npm run test` is the four-greens pre-commit
  gate.
- **`tsconfig.test.json` wired into `npm run typecheck`**; 48
  long-buried test type errors fixed across 5 commits. Test-file
  TS regressions no longer escape typecheck.
- **CaptureStream + simulateInput behaviour-test seam**
  documented in `test/_ink-render.ts` + `test/README.md`. 7
  popup test files converted from readFileSync source-greps to
  mount-and-assert behaviour against rendered frames + spy
  callbacks. Each conversion verified against a deliberate
  regression. Structural greps (App ↔ keys ↔ layout wiring,
  slot ↔ keymap glue) carved out as the legitimate use of
  source-greps.
- **`<App>` first behaviour coverage** (`test/tui-app-behaviour.test.ts`):
  card toggle, help overlay, popup open/close, multi-ws Tab
  cycling, Ctrl-C unmount, tick-rate adjust.
- **`useDashboardSnapshot` source-greps replaced** with behaviour
  tests asserting Object.is reference stability and refreshNonce
  loader-fire semantics.
- **`classifyError` + `errorNextSteps`** now cover every exported
  typed error class, with an inventory drift check.
- **Git-show wrap-ansi**: regression tests for ANSI SGR close on
  early-return + end-of-loop, and for drill body padding to
  prevent right-border eating.

### Performance

- TUI snapshot poll p50 ~385ms → <1ms (fast/slow split).
- `reconcile()` orphan loop O(panes·env-reads) → O(panes).

### Removed (docs)

- `docs/test-flakes-audit.md` (one-off remediation log; lessons
  folded into AGENTS.md + ARCHITECTURE.md).
- `docs/plans/` (pre-TUI implementation scratchpads; long since
  shipped).


## [0.3.2] — 2026-05-11

Feature theme: aggressive cleanup + dogfood-driven verbs. The 0.3.1 wave
generated a fresh round of mufeedback: a wedged-worker escape hatch
(`mu agent kick`), a clean-workspace `mu agent close` shortcut, a
`mu workspace recreate` between-wave verb, scrollback-pattern detection
for provider-auth failures during spawn, `mu task notes --tail`, and
filters across `mu task add --json`. Then a sweep dropped every
same-session deprecation alias / shim that nobody depended on yet:
top-level `mu adopt`, the pre-v0.3 export-bucket detection, the legacy
`mu task wait --json` envelope fields, the `TaskRow.localId` duplicate.

### Removed

- **Pre-v0.3 ("v1", single-source) export-bucket detection** in
  `src/exporting.ts` and `src/importing.ts`. v0.3 shipped
  2026-05-10 with the new bucket layout (top-level
  README/INDEX/manifest + per-source-ws subdirs). There are no
  pre-v0.3 buckets in the wild, so the operator-facing branches
  that probed for the old shape and threw
  `LegacyExportLayoutError` / `ImportLegacyLayoutError` with a
  re-export hint are now dead weight. Dropped: both error
  classes, the `{ kind: "legacy" }` arm of `ManifestProbe`, the
  legacy detection in `readManifest`, the `if (probe.kind ===
  "legacy") throw` blocks in `renderToBucket` /
  `loadBucketLayout`, the imports + `instanceof` arms in
  `src/cli/handle.ts`'s usage-class predicate and `classifyError`,
  the SDK re-exports in `src/index.ts`, and the legacy-throw
  tests in `test/exporting.test.ts` / `test/importing.test.ts`.
  Manifests that aren't `bucketVersion: 2` now fall through to
  the existing `corrupt` lane (export: re-scaffold; import:
  `ImportBucketInvalidError` with the standard `manifest.json is
  unreadable / malformed` reason) — a single typed surface
  instead of two near-identical ones.

- **`TaskRow.localId` duplicate field dropped**
  (`drop_taskrow_localid_duplicate_of_name`). Commit 26a914a added
  `localId` alongside `name` on every TaskRow as a "compat-safe"
  duplicate so jq recipes like `.[].localId` (matching the
  agents/workstreams JSON pattern) would work. With one user and the
  rest of the codebase reading `.name` canonically across 134+ sites,
  the duplicate was dead weight. `TaskRow.localId` is gone from the
  SDK type and from every JSON read (`task list/next/show`, archive
  bucket exports, etc.). `localId` survives as a function-parameter
  NAME on `addTask` / `closeTask` / `releaseTask` and friends — that
  is internal API shape, not a JSON key. The `mu agent list` JSON
  shape (which renames the underlying SQL column to `localId` for an
  internal struct) is untouched. Operators reading task JSON from jq
  must switch `.localId` → `.name`. The corresponding regression-guard
  tests (`test/json-output.test.ts` and
  `test/output-labels-human-rename.test.ts`) flip from "emits both
  keys" to "emits `name` only".

### Breaking

- **`mu task wait --json`: dropped legacy fields from the envelope**
  (`drop_legacy_mu_task_wait_json_fields`). The previous shape spread
  the SDK `TaskWaitResult` into the JSON envelope, which leaked
  `tasks` / `allReached` / `anyReached` / `elapsedMs` and a *boolean*
  `timedOut` alongside the operator-facing `firing` / `all` /
  `timedOut` (array) / `nextSteps`. The legacy fields were kept for
  back-compat at the time — but mu has a single user, no callers
  pinned to the prior shape, and the dual-shape `timedOut`
  (overwritten boolean → array) was an accident waiting to bite.
  The canonical envelope is now exactly:

  ```
  { firing, all, timedOut, nextSteps }
  ```

  with `timedOut` always an array (`[]` on a clean exit; populated
  on actual timeout). The SDK `TaskWaitResult` shape shrinks to
  match: `{ refs, timedOut }`. Callers that need elapsed wall-clock
  time wrap `waitForTasks` with their own `Date.now()` bracket.
  `isDone()` inside `waitForTasks` now derives any/all from
  `refs.filter(r => r.reachedTarget)` directly.

### Changed

- **`mu agent close` auto-frees a clean workspace instead of requiring
  `--discard-workspace`** (`allow_mu_agent_close_without_discard`).
  Real-user pain (mufeedback gchatui): a misconfigured spawn left two
  workers whose `--workspace` dirs contained nothing but the
  backend's `.git` / `.jj` pointer file (no commits since fork, no
  uncommitted changes). `mu agent close <name>` refused both with the
  WorkspacePreservedError nag, forcing the user through the lossy
  `--discard-workspace` flag (or two extra `mu workspace free`
  invocations) just to clean up. Now: `closeAgent` calls
  `isWorkspaceClean(row)` and, if true (no uncommitted changes per
  the backend's new `isClean` probe AND zero commits since fork per
  `commitsSinceBase`), silently frees the workspace and proceeds with
  the close — the same audit trail (`workspace free` event) is
  emitted, just without the operator friction. Non-clean workspaces
  (uncommitted changes OR commits since fork) still throw
  WorkspacePreservedError and still require `--discard-workspace` for
  the lossy escape hatch. The new `VcsBackend.isClean(workspacePath)`
  method is implemented for git (empty `git status --porcelain`), jj
  (empty `jj diff -r @ --summary`), sl (empty `sl status`), and `none`
  (unconditionally true: a cp -a snapshot has nothing committed worth
  preserving). `closeAgent`'s `CloseAgentResult` gains a
  `workspaceAutoFreedClean: boolean` so the CLI can render
  "workspace auto-freed" vs "workspace discarded" accurately and JSON
  consumers get a stable signal.

- **Renamed `mu adopt` → `mu agent adopt`**
  (`mu_adopt_should_be_mu_agent_adopt_for` +
  `remove_top_level_mu_adopt_alias_now_was`). Every other
  agent-lifecycle verb (`spawn`, `send`, `read`, `show`, `list`,
  `close`, `free`, `attach`, `kick`) lives under `mu agent`;
  `adopt` was the lone holdout at the top level. `mu agent adopt`
  is now the only form — the top-level `mu adopt` alias is gone.
  Bare `mu adopt` falls through to commander's default
  unknown-command error. Internal next-step hints
  (`ClaimerNotRegisteredError`, the orphan-list footer in
  `mu agent list`, `mu undo`'s reconcile note) all use the
  canonical form.

### Added

- **`mu agent kick <name>`: signal a wedged worker pane's foreground
  process group from outside the pane**
  (`workers_commonly_attempt_unbounded_find`). Live dogfood report:
  workers running `find / -maxdepth 6 ...` or unbounded busy-wait
  loops blocked their pi event loop for tens of minutes; `mu agent
  send` queued steering messages until the tool returned, and
  `tmux send-keys C-c` did NOT propagate (the wrapping CLI catches
  it as TUI input). Recovery story was: drop out of mu, `pgrep -af
  "find /"`, `kill <pid>` — fiddly and breaks the orchestrator's
  mental model.

  `mu agent kick <name>` looks up the pane's TTY (`tmux
  display-message -p '#{pane_tty}'`), asks `ps -t <tty>` for the
  foreground process group (the row whose `stat` field contains
  `+`), and `kill -<signal> -<pgid>` signals the whole pgrp
  directly. Default `--signal SIGINT` (graceful, matches Ctrl-C);
  `--signal SIGTERM` / `--signal SIGKILL` escalate. Refuses with
  a typed `NoForegroundProcessError` when the foreground IS the
  wrapping CLI itself (`pi`/`claude`/`codex`/`bash`/...) — use
  `mu agent close` for that. Emits an `agent kick <name>
  (signal=..., pgid=..., comm=...)` event so the activity log
  records the intervention.

  SDK: `kickAgent(db, name, { workstream, signal? })` returns
  `{ agentName, paneId, tty, signaledPgid, signal, foregroundComm
  }`. New tmux helper `paneTTY(paneId)`. Process executor is
  swappable via `setKickProcessExecutor` (mirror of
  `setTmuxExecutor`) so unit tests don't touch real `ps` / `kill`.

- **`mu task notes` gains `--tail / --since / --since-claim` filters**
  (`fb_task_notes_tail`). Live mufeedback note: `mu task notes <id>`
  dumps EVERY note attached to the task, including the multi-screen
  pre-task SPEC the orchestrator drops before dispatching. Checking
  "what did the worker actually report at close?" required scrolling
  past the spec every time. Three composable filters:

  - `--tail N` (alias `--last N`): print only the last N notes.
    Must be a positive integer; commander's `parsePositiveNumber`
    rejects 0 / negatives at parse time (exit 2).
  - `--since <iso>`: print only notes with `created_at > <iso>`.
    Comparison is lexicographic on the ISO string. Unparseable
    timestamps error with `--since must be an ISO 8601 timestamp`
    (exit 2).
  - `--since-claim`: auto-resolves to the `created_at` of the most
    recent `task claim` event in `agent_logs` for this task and
    uses it as the cutoff. When no claim event exists, degrades to
    no filter (equivalent to `--since-beginning`) so the verb stays
    useful on un-claimed tasks. Mutually exclusive with `--since`
    (both define a cutoff); passing both errors with `--since and
    --since-claim are mutually exclusive` (exit 2).

  Filters compose multiplicatively: the timestamp filter is applied
  first, then `--tail` slices the last N of what survived. Default
  behaviour (no filters) is unchanged — every note, oldest-first.
  `--json` keeps the `{items, count}` collection envelope per
  `audit_json_envelope_uniformity`.

  SDK: `listNotes(db, id, ws, opts?)` gains an optional fourth
  `ListNotesOptions` argument (`{tail?, since?, sinceClaim?}`).
  All-undefined preserves the historical "return every note" shape
  so every existing caller (`cmdTaskShow`'s notes block,
  `exporting.ts`'s bucket renderer, `agents.test.ts`) keeps working
  unchanged. `lastClaimEventAt` is the new helper in `src/logs.ts`
  that resolves `--since-claim`; it mirrors `lastClaimActor`'s
  LIKE-with-escape pattern so a same-prefix id (`foo` vs `foo_2`)
  can't cross-match.

- **`mu workspace recreate <agent>`: free + create in one shot**
  (`add_mu_workspace_recreate_free_create`). Live dogfood report:
  between waves the operator was running `mu workspace free worker-N`
  + `mu workspace create worker-N -w X` for every agent in the wave;
  the `mu task wait --json` `nextSteps` already suggested `free && create`
  as one combined intent. The new verb does both atomically:
      mu workspace recreate worker-1 [-w <ws>] \
        [--backend <jj|sl|git|none>] [--from <ref>] \
        [--project-root <path>] [--force] [--json]
  Reuses the previous backend unless `--backend` overrides; bases on
  the project's current main unless `--from` overrides. Refuses on a
  dirty workspace (uncommitted changes, git/sl) the same way `free`
  does — throws `WorkspaceDirtyError` (exit 4) listing the dirty
  files, with a `--force` `nextStep` for the lossy escape. `--force`
  discards the dirty edits and rebuilds; jj is always-snapshotted so
  it never refuses; `none` has no VCS to consult so the dirty check
  is a no-op. Audit trail: ONE `workspace recreate <agent>` event
  (with both old + new `parent_ref` in the payload) instead of
  separate free + create entries; ONE pre-mutation snapshot under
  the same label. Sibling of `mu workspace refresh`: refresh
  PRESERVES the worker's local commits (rebases them onto fresh
  main); recreate THROWS THEM AWAY. Use refresh when you've already
  cherry-picked the worker's HEAD; use recreate when you want a
  pristine dir for the next dispatch. SDK: `recreateWorkspace(db,
  agent, opts) → { workspace, previousParentRef }`.

- **`mu task add --json` surfaces auto-id truncation telemetry**
  (`task_add_slugify_silently_truncates_ids`). Sibling fix to the
  human stderr hint in `slugifytitle_silently_drops_clauses`:
  scripted callers parse stdout JSON and never see the stderr
  prose, so they cannot tell when the SLUG_SOFT_CAP word-boundary
  cut dropped trailing clauses from the auto-derived id. The JSON
  envelope now adds two top-level fields (siblings of `task` /
  `blockers` / `nextSteps`, NOT inside `task`) when auto-id
  derivation actually truncated:

      truncated:    boolean   // only present when true
      originalSlug: string    // un-truncated slug, only present when truncated

  Both fields are omitted when the operator passed an explicit
  `<id>` positional (no auto-derive happened) and when
  auto-derivation produced no truncation — the omission itself is
  the false-signal, matching the singleton-envelope convention
  established by `audit_json_envelope_uniformity` (only emit
  optional fields when meaningful). SDK: `SlugifyResult` and
  `IdFromTitleResult` both gain an `originalSlug` field.

### Fixed

- **`mu agent spawn`: validate `--cli` resolves to a PATH binary BEFORE
  any side effect; surface env-var attribution in the success line**
  (`fb_agent_spawn_no_validation`). Live dogfood report: `mu agent
  spawn worker-1 --cli pi-meta` on a host where `pi-meta` wasn't on
  PATH printed `Spawned worker-1 (pi-meta)` and the pane immediately
  died with `command not found`; the existing 1.5s liveness check
  sometimes missed it (the shell stays alive past a failed exec).
  Three coordinated fixes:

  - **Pre-flight PATH check**: `spawnAgent` now resolves `--cli`
    through `MU_<UPPER_CLI>_COMMAND` and then verifies the first
    token is on PATH (via `command -v`) BEFORE `prestageWorkspace`.
    A typo throws the new typed `AgentSpawnCliNotFoundError` with
    no orphan workspace dir, no pane, no DB row.
    `errorNextSteps()` carries three remediation hints: try the
    default `--cli pi`, set the conventional env var
    (`export MU_<KEY>_COMMAND=...`), and `which pi pi-meta claude
    codex`. Hookable via `setCommandResolverForTests` so tests
    don't depend on what's installed in the test env.
  - **Extended scrollback scanner**: the post-spawn liveness scan
    added in `agent_spawn_model_auth_failure_counts_as_live` now
    also matches `/command not found/i` and
    `/No such file or directory/i` in the first ~30 lines. Catches
    the post-spawn variant that slips past the pre-flight (`--command`
    opt-out, login-shell PATH drift, …) and maps to the existing
    `AgentSpawnStartupError` (rolled back the same way).
  - **Env-var attribution in the success line**: when `--cli` was
    resolved via `$MU_<KEY>_COMMAND`, the human success line now
    reads `Spawned worker-1 (pi-meta via $MU_PI_META_COMMAND)` and
    the `--json` envelope carries `resolvedFromEnvVar:
    "MU_PI_META_COMMAND"`. Stale aliases are now obvious without
    `mu agent show`.

  SDK additions: `AgentSpawnCliNotFoundError`,
  `checkCommandResolvable`, `envVarNameForCli`,
  `resolveCliCommandWithSource`,
  `setCommandResolverForTests`/`resetCommandResolverForTests`.

- **`mu agent spawn`: detect provider-auth startup failures during the
  liveness check** (`agent_spawn_model_auth_failure_counts_as_live`).
  Live dogfood report: `pi-meta --no-solo --model sonnet:high` printed
  `Error: No API key found for amazon-bedrock` and parked at a prompt.
  The pane stayed alive (1.5s liveness check passed) but the worker
  could never do work — the orchestrator only discovered this when
  `mu task wait` stalled minutes later. Fix: after confirming
  `paneExists`, `awaitSpawnLiveness` now scans the LAST ~30 lines of
  the post-liveness pane capture for a curated list of startup-error
  patterns:
      - `/No API key found for [\w-]+/i`
      - `/Error: invalid API key/i`
      - `/Authentication failed/i`
      - `/401 Unauthorized/i`
      - `/Could not authenticate/i`
  On a match the spawn rolls back (workspace + agent row) and throws
  the new typed `AgentSpawnStartupError` with the matched line, the
  full scrollback tail, and `nextSteps` pointing at the safe pi-meta
  default (`--command "pi-meta --no-solo"`) and the
  `export ANTHROPIC_API_KEY=...` recipe. The new error is exit-code 1
  (substrate-level, same lane as `AgentDiedOnSpawnError`). The scan is
  tail-only (last 30 lines of a 50-line capture) so harmless
  prior-session text scrolled off the top of a brand-new pane can't
  trip it; the patterns must come from the spawned CLI's first ~1.5s
  of output. Disable with `MU_SPAWN_LIVENESS_MS=0` if you actually
  wanted the parked prompt (CI / scripted recovery).

- **`mu agent spawn --workspace`: rollback the workspace dir + agent
  row when tmux pane creation fails** (`agent_spawn_abort_leaves_orphan_workspace`).
  Live dogfood report: spawning a worker into a workstream whose tmux
  session didn't exist (and where tmux refused `new-session`)
  prestaged the workspace dir + placeholder agent row, then threw —
  but the existing rollback only fired on later phases (finalize,
  liveness). The orphan workspace dir survived; `mu workspace list`
  showed nothing; `mu workspace orphans` was the only way to find
  it. Fix: a single outer try wraps `createOrReusePane` +
  `setPaneTitle` + `enableMuPaneBordersForPane` + `finalizeAgentRow`
  + `awaitSpawnLiveness`; any failure runs the existing
  `rollbackSpawn` (idempotent and best-effort). When the failure
  happens after a workspace was prestaged, the thrown error is
  augmented with two orphan-cleanup `nextSteps` (`mu workspace
  orphans -w <ws>`, `mu workspace free <agent> -w <ws>`) so the
  operator gets the cleanup recipe inline. Two follow-ups left
  out of scope (filed for triage): auto-creating the missing tmux
  workstream session before spawn (operator's update note 3), and
  SIGINT handlers between prestage and the first try-block (needs
  process-global state).

---

## [0.3.1] — 2026-05-11

**First npm release.** Published as `@martintrojer/mu`. The skill
ships in the same repo and installs via the
[skills CLI](https://github.com/vercel-labs/skills):
`npx skills add martintrojer/mu`.

Feature theme: contract uniformity. Two `--json` audits make every
operator-error path and every collection-read verb structurally
identical, so a script can `jq` any verb's output / error envelope
without per-verb special-casing. Plus four small typed-verb wins
from the v0.3 dogfood feedback wave.

### Added

- **`mu task close --if-ready`: idempotent umbrella-on-wave-done
  closer** (`fb_umbrella_no_auto_close`, impact=60). Live dogfood
  report: built `wave_w3_tests` umbrella with 18 blockers; after
  every blocker reached CLOSED / DEFERRED the umbrella stayed OPEN
  and had to be hand-closed. `--if-ready` is the cheap fix — the
  bare `mu task close <id>` semantics are unchanged (still closes
  regardless), but `--if-ready` no-ops unless every direct blocker
  is in a terminal status (CLOSED / REJECTED / DEFERRED). On the
  no-op path the verb prints the still-blocking ids + a Next: hint
  pointing at `mu task wait <ids> --first --any`. JSON gains
  `skipped: "not_ready"` and `blockingIds: [...]` so an
  orchestrator can fire the closer eagerly after each pipeline
  cherry-pick. Exit code 0 either way (no-op is success).
  Option (a) auto-close on last-blocker-close was rejected because
  it changes lifecycle semantics for umbrellas with content of
  their own. SDK: `closeTask` gains `ifReady?: boolean` and a new
  `CloseSkippedResult` return shape (typed-union with
  `SetStatusResult`).

- **`mu workspace commits <agent> [--since <ref>]`**
  (`fb_workspace_commits_verb` /
  `mu_workspace_commits_print_since_fork`). Promotes the dogfood-
  painful `cd $(mu workspace path X) && git log <base>..HEAD`
  incantation into a typed verb that knows the workspace's
  recorded `parent_ref`. Default text output is `<sha> <subject>`
  per line, oldest-first. `--json` emits the full array
  `[{sha, subject, body, authorDate}]` for piping (e.g.
  `mu workspace commits worker-X --json | jq -r '.[] | select(...) |
  .sha' | xargs git cherry-pick`).
  - **git**: `git log --reverse -z --format='%H%x00%s%x00%b%x00%aI'
    <base>..HEAD` (NUL-delimited per record so subjects/bodies with
    embedded newlines survive parsing).
  - **jj / sl**: equivalent NUL-field / `\x1e`-record templates;
    `parseNulRecords()` is the shared parser.
  - **none**: throws `WorkspaceVcsRequiredError` (exit 4) — cp -a
    snapshots have no fork point.
  - SDK: `listCommitsForWorkspace(db, agent, opts)` returns
    `{ vcs, baseRef, commits, workspacePath }`.
  - VcsBackend interface gains
    `commitsSinceBase(workspacePath, baseRef): Promise<CommitSummary[]>`
    where `CommitSummary = { sha, subject, body, authorDate }`.

- **`mu workspace refresh <agent> [--from <ref>]`**
  (`fb_workspace_recycle_verb` /
  `mu_workspace_refresh_rebase_agent`). Rebases an agent's workspace
  onto a fresh base WITHOUT touching the agent or pane — the worker
  keeps its LLM context while the on-disk dir moves. Default base =
  the backend's tracked main: `origin/HEAD` for git (with a
  best-effort `git fetch` first), `trunk()` for jj / sl. `--from
  <ref>` overrides.
  - **git**: refuses on dirty WC with `WorkspaceDirtyError` carrying
    the dirty file list (exit 4) and Next: hints to commit/stash.
    On rebase conflict aborts the rebase and throws
    `WorkspaceConflictError` carrying the conflicting paths (exit 5)
    with a `cd` hint to resolve manually.
  - **jj / sl**: rebase onto `trunk()` (or `--from`), surface
    conflicts via `conflict()` revset / `sl resolve --list`. jj is
    always-snapshotted so dirty WC isn't an issue; sl pre-checks.
  - **none**: throws `WorkspaceVcsRequiredError` (exit 4) —
    refresh is meaningless for a `cp -a` snapshot.
  - JSON shape: `{ vcs, fromRef, replayed: string[], conflicts:
    string[], workspacePath }`. Card output lists each replayed
    commit subject. Replaces the dogfood-painful
    `close → free → spawn` recycle that killed worker context.
  - SDK: `refreshWorkspace(db, opts)` returns the same shape.
  - VcsBackend interface gains `rebaseTo(workspacePath, fromRef?):
    Promise<RebaseResult>`.

### Changed

- **Uniform `--json` collection envelope across every list/search/
  notes/orphans/commits verb** (`audit_json_envelope_uniformity`).
  Pre-1.0 breaking. Every collection-read verb used to emit a bare
  array (`mu task list --json` → `[{...}, {...}]`); a sibling field
  could not be added later (e.g. `baseRef`, `behindCount`,
  `totalAcrossPages`) without breaking every caller. Now uniformly
  `{items: T[], count: number}`. Affected verbs:
  `mu task list / next / owned-by / notes`, `mu workstream list`,
  `mu workstream destroy --empty` (dry-run), `mu archive list /
  search`, `mu workspace list / orphans / commits`, `mu snapshot
  list`, `mu log -n N` (read; NOT `mu log --tail` which stays NDJSON
  one-object-per-line for stream consumers).
  - `count` is `items.length` pre-computed; future siblings can
    layer on without breaking the existing two fields.
  - `mu workspace orphans` (with `-w`) was already an object
    envelope; renamed `orphans` field to `items` for uniformity
    and added `count`. The `--all` form was bare-array; now matches.
  - **Carve-outs**: `mu sql --json` keeps bare-array rows (it's the
    escape hatch; row shape is per-query, not part of the typed
    contract; envelope-wrapping is paternalism). `mu log --tail`
    keeps NDJSON (one object per line) since it's a stream, not a
    collection.
  Codified by a new `emitJsonCollection<T>(items)` helper in
  `src/cli.ts` so any future collection-read verb gets the shape
  for free.

- **Uniform validation-error contract across every operator-error
  path** (`audit_cli_validation_uniformity`). Pre-1.0 breaking on
  exit codes. Three error classes used to produce three different
  surfaces: commander mistakes (missing required option, unknown
  option/subcommand, missing positional, type-coercion failure)
  exited 1 with a help dump and ignored `--json`; handler-thrown
  `UsageError` (mutex flags, range checks) exited 2 with NO help
  and a `{error,message,nextSteps,exitCode}` JSON; typed `*Invalid*`
  domain errors (workstream-name / archive-label / task-id / prune-
  options) exited 2 with no help. Now all three:
  - print red `error: <msg>` then the failing subcommand's `--help`
    (human path), exit **2** uniformly.
  - emit `{error, message, nextSteps, exitCode: 2, usage}` to stderr
    (`--json` path) where `usage` is a structured rendition of the
    same `--help`: `{command, synopsis, description, args[], options[]}`.
    `usage.options[].mandatory` distinguishes "operator MUST pass"
    (`.requiredOption()`) from `valueRequired` ("if passed, value
    can't be omitted"); the two were conflated as one `required`
    flag in the previous JSON.
  Plumbing: every command in the tree now calls `.exitOverride()`
  recursively, the active subcommand is tracked in a module-local
  set by `handle()`, and the parseAsync catch routes commander
  errors through the same `emitError()` pipeline. `_runCli.ts` test
  helper updated to mirror the new entry-point shape. Excluded from
  the help-on-error rendering: `Import*Error` / `LegacyExportLayoutError`
  (those fault on directory contents the operator pointed at;
  `--help` wouldn't have prevented them; their typed `nextSteps`
  already carry the fix).

- **`mu task delete <id>` is now two-phase: bare = dry-run preview;
  `--yes` commits** (`fb_task_delete_no_yes`, impact=30). Pre-1.0
  breaking change. The dogfood report: typed `mu task delete X
  --yes` (mirroring `mu workstream destroy --yes`) and got
  'unknown option --yes' — the verb took no confirmation flag at
  all. Two failed deletes left long-named tasks lingering until
  noticed. Mirrors `mu workstream destroy` / `mu archive delete`
  / `mu snapshot prune`. Bare `mu task delete <id>` now prints
  the cascade preview (the task + edges that would drop + notes
  that would drop, with counts) plus a Next: hint pointing at
  `mu task delete <id> --yes`; nothing is mutated and no snapshot
  is taken on the dry-run. `--yes` keeps today's behaviour
  byte-for-byte (auto-snapshot, then DELETE; FK CASCADE drops
  task_edges + task_notes). JSON shape: dry-run carries `dryRun:
  true` + `deletedEdges` / `deletedNotes` (would-be counts) +
  `present: boolean`; commit carries `dryRun: false` + actual
  counts. SDK: `deleteTask` gains `opts: { dryRun?: boolean }`
  and the result type gains `dryRun: boolean` + `present:
  boolean` (discriminator for the missing-row case). Idempotent
  on a missing task in both phases.

---

## [0.3.0] — 2026-05-10

### Added

- **Stderr lint hint when `mu agent spawn <name>` violates the
  smallest-unused-suffix convention** (`agent_spawn_stderr_hint_when_name_does`,
  source ws task `fb_agent_naming_convention`). Names that don't match
  `^[a-z][a-z0-9]*(?:-[0-9]+)$` (e.g. `worker-tests`, `alice`,
  `db-leader`, `x-y-1`) still spawn successfully — this is a lint, not
  a rule — but mu now writes a one-line hint to stderr after the spawn:
  `hint: agent name "X" does not match the smallest-unused-suffix
  convention (<role>-<n>; e.g. worker-1, reviewer-2). Accepted; consider
  renaming if you spawn additional workers.` Suppressed under `--json`
  so script callers stay clean. Mirrors the slugify-truncation hint in
  `cmdTaskAdd` (`slugifytitle_silently_drops_clauses`): stderr-only,
  exit 0, no schema or behaviour change for scripts that ignore stderr.
  Surfaced by the dogfood report where the operator named
  `reviewer-1/2/3`, `worker-1/2/3`, then drifted to `worker-tests` and
  mu accepted it silently.

- **`mu snapshot prune` and `mu snapshot delete <id>`**
  (`snapshot_gc_caps_too_lax_no_cleanup_verb`). Two new manual
  cleanup verbs for the snapshots collection; both promote what
  used to require `rm -rf <state-dir>/snapshots/*.db` + a `mu sql
  DELETE FROM snapshots` (scary; bypasses the schema-version safety
  check that keeps `mu undo` honest).
  - **`mu snapshot prune`** — bulk policy-driven cleanup. Bare form
    runs the GC policy (count + age caps) explicitly. Flags select
    alternate modes: `--keep-last N` (top-N by id), `--older-than
    <DAYS>d` (accepts `7d`/`30d`/bare integer), `--stale-version`
    (drop rows whose `schema_version != CURRENT_SCHEMA_VERSION` —
    unrestorable; pure disk weight after a schema bump), `--all`
    (nuke everything). Two-phase: prints a dry-run summary by
    default; `--yes` commits. `--all --yes` auto-captures a
    safety-net snapshot of the live DB FIRST so a subsequent
    `mu undo --to <safety-net-id> --yes` recovers. JSON shape:
    `{deletedRows, deletedFiles, freedBytes,
    safetyNetSnapshotId?}`. SDK: `pruneSnapshots(db, opts)` returns
    a structured `{victims, freedBytes, deletedRows, deletedFiles}`
    + `safetyNetSnapshotId` when `mode='all'`.
  - **`mu snapshot delete <id>`** — surgical removal mirroring
    `mu task delete`. Drops the row + unlinks the on-disk .db file.
    Errors with `SnapshotNotFoundError` on miss. Does NOT auto-
    snapshot first (the point is to delete one stepping-stone, and
    that can't break `mu undo` — every other snapshot remains).
    SDK: `deleteSnapshot(db, id)`.

- **`mu task claim --for` accepts cross-workstream qualified refs**
  (`task_claim_for_cross_workstream`). `--for <name>` keeps today's
  same-workstream resolution; `--for <workstream>/<name>` (NEW)
  dispatches across the boundary — the agent stays in its own
  workstream, only `tasks.owner_id` crosses (FK is workstream-
  agnostic at the schema level). Cures the per-workstream-worker-pool
  friction where a free worker in A and a queued task in B forced
  closing + respawning the worker (losing LLM context) or hand-edits
  via `mu sql`. Bad qualifier surfaces typed `WorkstreamNotFoundError`
  (missing prefix) or `AgentNotFoundError` (worker not in named ws);
  nothing committed on failure. SDK `claimTask` gains an optional
  `agentWorkstream` field; default = `opts.workstream`.

- **`mu task wait --on-stall <warn|exit>`: expose the stall ACTION as
  a flag** (`task_wait_stall_action_flag`). Today's `--stuck-after`
  defines the TRIGGER (IN_PROGRESS task whose owner sat in
  `needs_input` for >= N seconds); `--on-stall` defines what to do
  when it fires. `warn` (default) = today's behaviour byte-for-byte
  (yellow STUCK to stderr + corroborating `agent stalled` event;
  wait keeps polling). `exit` = same emit + persist, then exit 7
  (`STALL_DETECTED`) so an unattended orchestrator can branch on the
  ambiguous-idle (7) vs unambiguous-dead-pane (6) distinction.
  Suppressed when `--status` is not `CLOSED` (mirrors exit-6's
  carve-out). If both reaper-flip (6) and stall (7) would fire in
  the same poll, exit 6 wins (the reaper-flip in `beforePoll`
  pre-empts the snapshot's stuck-check; once status is OPEN,
  isStuck naturally returns false). New typed
  `StallDetectedDuringWaitError` (HasNextSteps: poke worker /
  inspect scrollback / release --reopen / show task); SDK
  `waitForTasks` gains `onStall?: 'warn' | 'exit'`.

- **Derived `idle` flag on `AgentRow`: alive + assigned + no recent
  progress** (`idle_assigned_agent_detection`). Surfaces the third
  agent lifecycle state (pi crashed mid-task without crashing the
  pane: `Operation aborted`, model timeouts, transient connection
  drops). Predicate: `status === 'needs_input'` AND owns ≥1
  IN_PROGRESS task AND `(now - updated_at) >= MU_IDLE_THRESHOLD_MS`
  (default 300_000ms; matches today's `mu task wait --stuck-after`
  default). Computed at read time only — NOT a 5th status enum
  value, NOT stored in the DB. `listLiveAgents` enriches each row;
  `mu state` (full / hud / mission) prefixes a yellow ⚠ glyph and
  yellows the agent name when idle; `mu state --json` emits
  `idle: true` (omitted otherwise). `mu task wait --stuck-after`
  also persists a `kind='event'` row payload `agent stalled <name>
  owns <task-id> for <secs>s` as corroborating signal. Recovery is
  operator-driven: `mu agent send <name> '<retry>'` or `mu task
  release <id> --reopen` — mu deliberately does NOT auto-restart pi
  or auto-release the task (idle is ambiguous; the operator decides).

- **`mu --help` and every subcommand `--help` now list commands
  alphabetically** (`cli_help_alphabetical_subcommands`). Options
  list ordering inside each verb is unchanged — those are curated
  semantically; only the Commands listings are sorted.

- **`mu workstream import <bucket-dir>`** — inverse of
  `mu workstream export`. Walks a v0.3 bucket directory (markdown +
  manifest.json) and rebuilds every source-ws subdir as live tasks,
  edges, and notes. Markdown-only by design (no `.db` imports;
  cross-machine `.db` is `mu undo` + snapshots). Per-source-ws
  transactional; refuses to merge silently into an existing
  workstream (`--workstream <name>` for single-source rename, or
  destroy first). Supports `--dry-run` and `--json`. Pre-0.3 layouts
  surface a typed `ImportLegacyLayoutError`. New SDK in
  `src/importing.ts` exports `importBucket()` and the typed errors.

- **`mu workstream import` — partial bucket import** (per-source-ws
  subdir path OR `--source-ws <names...>` CSV filter on a bucket).
  Form 1 auto-detects a per-source-ws subdir via `README.md` +
  `INDEX.md` + `tasks/` and validates against the parent bucket's
  `manifest.json`; Form 2 keeps the bucket root and filters via the
  variadic flag (repeat or comma-separate; or both, per
  `cli_audit_plurality_uniformity`). `--workstream <new-name>` is
  allowed when the resolved source list is single (Form 1, or Form 2
  with one name); multi-source filters keep today's rejection. New
  typed `ImportSourceNotInBucketError` (exit 4) names the bad name +
  the valid ones.

- **`mu hud` accepts multiple workstreams via `-w/--workstream` (now
  variadic) or `--all`** (`hud_multi_workstream` + `hud_unify_workstream_flag`).
  N=1 (the common case, including legacy `mu hud -w X`) renders
  byte-for-byte unchanged — same columns, same JSON shape — so
  existing tmux status-bar pipes (`#(mu hud --json) | jq ...`) keep
  working. N≥2 grows the workstream-summary table to N rows, gains
  a leading bold-cyan `workstream` column on every section table,
  and switches the JSON envelope to `{ workstreams: [...] }`.
  Recent-events table becomes a cross-workstream timeline (DESC by
  `created_at` across the union). The variadic shape uses the
  parseCsvFlag convention from cli_audit_plurality_uniformity
  (repeat OR comma-separate OR both); the originally-shipped
  `--workstreams` companion flag was unified into `-w` before
  release (see the Changed `hud_unify_workstream_flag` entry below).

- **CLI multi-value flags now accept repeat OR comma-separated forms
  uniformly** (today's `--blocked-by a,b,c` keeps working; you can now
  also `--blocked-by a --blocked-by b`). Codified by
  `cli_audit_plurality_uniformity`: every variadic flag is post-processed
  through a single `parseCsvFlag` helper; help text uses the stock phrase
  "(repeat or comma-separate; or both)"; the `<value...>` metavar is the
  syntactic signal.

- **`src/archives.ts` SDK** (Phase 1 of the v0.3 archive feature):
  `createArchive`, `listArchives`, `getArchive`, `deleteArchive`,
  `addToArchive`, `removeFromArchive`, `listArchivedTasks`. Idempotent
  at (archive, source_workstream) granularity — re-running
  `addToArchive` against the same workstream is a no-op; adding a
  new task and re-running picks up only the delta. Typed errors:
  `ArchiveNotFoundError`, `ArchiveAlreadyExistsError`,
  `ArchiveLabelInvalidError`. Phases 2 (CLI), 3 (destroy hook), and
  4 (export renderer) follow.

- **`mu archive search <pattern>` — LIKE-search archived titles
  AND archived note content** (Phase 4b). `--label <l>` scopes to one
  archive (throws `ArchiveNotFoundError` on miss); `--limit N`
  defaults to 50; `--json` emits the `ArchiveSearchHit[]` array.
  The pattern is bound as a SQL parameter (never concatenated), so
  `mu archive search "'); DROP TABLE archives; --"` is just an
  empty result. Title matches win over note matches when the same
  task hits both.

- **`mu archive create / list / show / add / remove / delete` —
  feature complete (6 verbs + tests + docs).** Phase 2 of the v0.3
  archive feature: thin commander glue (`src/cli/archive.ts`) over
  the Phase 1 SDK. `mu archive add <label> -w <ws> [--destroy]` is
  the headline workflow — preserve a workstream's task graph in an
  operator-named bucket, optionally cascading to `mu workstream
  destroy --yes`. The bucket is additive: re-add new workstreams
  under the same label as new releases finish. `mu archive delete`
  is two-phase (dry-run by default, `--yes` captures a snapshot
  first). Typed errors map to exit codes: `ArchiveNotFoundError`
  → 3, `ArchiveAlreadyExistsError` → 4, `ArchiveLabelInvalidError`
  → 2. `--json` on every verb.

- **Unified bucket renderer + `mu archive export <label> --out <dir>`**
  (Phase 4 of the archive feature; `archive_phase4_export_renderer_unified`).
  The renderer factored out of `src/workstream.ts` into a new
  `src/exporting.ts` module that takes N source workstreams (each
  with its tasks/edges/notes) and writes a `bucketVersion: 2`
  bucket on disk. Both `mu workstream export` (one source) and the
  new `mu archive export` (every source-ws in an archive) delegate
  to the same renderer, producing byte-identical disk shapes.
  Bucket exports are additive across calls (sha256 short-circuit
  per task; sibling source-ws subdirs are never touched by an
  unrelated re-export). `mu workstream destroy --yes`'s pre-destroy
  auto-export uses the new shape automatically. Pre-0.3 export
  directories are no longer accepted in place — see Breaking above.

- **`mu workstream destroy --archive <label>`** (Phase 3 of the v0.3
  archive feature): atomic snapshot-then-destroy. The label must
  already exist (anti-feature: no auto-create — run `mu archive
  create <label>` first). Archive add runs BEFORE destroy; if it
  fails, the destroy is aborted. Dry-run mode (no `--yes`) reports
  "would archive N tasks to <label>" alongside the existing
  pre-destroy summary.

- **`mu workstream destroy --empty`** sweeps every empty workstream
  (zero tasks, agents, vcs_workspaces, approvals) in one call;
  replaces the per-name `jq` incantation over `mu workstream list
  --json`. Tmux session presence and audit-only `agent_logs` do NOT
  disqualify. Mutually exclusive with `-w` and `--archive`. Dry-run
  lists candidates as a table (or array via `--json`); `--yes`
  captures ONE whole-DB snapshot for the batch, then best-effort
  destroys each (a per-workstream failure is collected into
  `failed[]` and the sweep continues). Closes
  `workstream_destroy_empty_sweep`.

### Removed

- **Phantom `mu workspace adopt` hint dropped from `cmdWorkspaceOrphans`
  nextSteps** (`nextsteps_audit_workspace_orphans_phantom_verb`). The
  hint pointed operators at a verb that does not exist (and at a
  ROADMAP entry that also does not exist), violating the "nextSteps
  must not point at non-existent verbs" framing. Workspace adoption
  is theoretical; the remaining `git worktree remove` / `rm -rf`
  hint is the one workable path.

- **`docs/OUTPUT_LABELS_AUDIT.md` removed.** v0.2 single-purpose
  audit; output-label rename work shipped; no live readers.

- **`docs/VERB_AUDIT.md` removed** (`remove_or_shrink_verb_audit_md`).
  v0.2-vintage 1122-LOC verb-by-verb audit (typed-vs-`mu sql`,
  atomicity / side-effect / error-mapping / nextstep scoring); the
  promotion decisions it informed have shipped (`mu hud` merged into
  `mu state --hud`; `mu approve *` removed; `whoami` / `my-tasks` /
  `my-next` merged into `mu me`; `mu task search/blocked/goals`
  removed; `mu adopt` re-wired). The audit was a one-shot exercise,
  not a living spec; doc_stale_verb_audit_v01's drift was too large
  (every audit row references the v0.2 verb surface) for in-place
  fixes to be worthwhile. No live readers (only links: this
  CHANGELOG entry + a README pointer); both updated.
- **`scripts/` directory + CI grep guards removed.**
  `grep-v4-references`: job done (v4 migration code removed; remaining
  v4 mentions are intentional history). `grep-name-without-workstream`:
  invariant now structurally enforced by the v5 surrogate-id schema
  (per-workstream UNIQUE on name + INTEGER FKs). `lint` becomes
  biome-only.

- **`mu hud` removed; behavior moved to `mu state --hud`**
  (`merge_state_into_hud_render_mode`). The verb was a render-strategy
  variant of `mu state` (same data set; different presentation), so
  it collapses to a flag on the canonical card. Update tmux configs
  accordingly: `tmux display-popup -E 'mu hud -w X'` becomes
  `tmux display-popup -E 'mu state --hud -w X'`. Pre-1.0; no
  deprecation shim.

- **`mu approve` verbs + `approvals` schema table — REMOVED.** Zero
  usage across the v0.2 + v0.3 dogfood waves (200+ tasks). Anti-
  anticipatory pruning per VISION.md "no traits with zero
  implementors". 706 LOC of SDK + CLI gone (`src/approvals.ts` +
  `src/cli/approve.ts`); `mu approve add/list/grant/deny/wait` are
  no longer recognised verbs. The `approvals` table, its indexes,
  and the `approval add/granted/denied/timeout` event prefixes are
  all gone too. v6→v7 schema migration drops the table in-place
  via `applySchema` (DROP TABLE IF EXISTS approvals on any pre-v7
  DB; gated on the detected pre-bump version so it's a one-shot).
  The pre-v5 refusal floor in `openDb` stays at v5. May return in
  v0.4+ when a real second implementor surfaces (e.g., an unattended
  pi-orchestrator running mu). If you have approvals rows you want
  to preserve, snapshot first via `mu undo` (or copy them out via
  `mu sql`) before upgrading.

### Changed

- **`src/cli.ts` split below the 800-LOC refactor signal**
  (`review_cli_ts_past_refactor_signal`). cli.ts had drifted to 1339
  LOC — well past AGENTS.md's 800-LOC refactor signal — hosting
  ~600 LOC of pure rendering helpers (table renderers, status
  colourers, `truncate` / `relTime`) and ~150 LOC of typed-error →
  exit-code mapping (`classifyError` / `emitError` / `handle` /
  `UsageError` / `NameAmbiguousError`) on top of its actual job
  (workstream resolution + commander wiring). Extracted to two
  cluster-mates inside `src/cli/`: `format.ts` (418 LOC; pure
  rendering, no I/O beyond `printLogRow`'s single `console.log`) and
  `handle.ts` (247 LOC; typed-error catalogue + the wrapping helper).
  cli.ts shrinks to 718 LOC and re-exports every moved symbol so the
  ~30 import sites in `src/cli/*.ts` and `test/` keep working without
  churn. No behaviour change; ARCHITECTURE.md cluster-table updated.

- **`mu task release` auto-flips `IN_PROGRESS` → `OPEN` by default;
  `--reopen` re-scoped to the un-close escape hatch**
  (`review_release_open_in_progress_inconsistency`). Bare `mu task
  release <id>` against an `IN_PROGRESS` task used to clear `owner`
  but leave `status = IN_PROGRESS` — a structurally stranded state
  (no owner to drive the task forward; `mu task next` skipped it
  because it wasn't `OPEN`; `mu task wait` blocked indefinitely; `mu
  state`'s in-progress section listed it with an empty owner column).
  The reaper's dead-pane recovery already does the right thing
  (clears owner AND flips to `OPEN`); release now matches. New
  default semantics:
    * `IN_PROGRESS` → owner cleared + status flipped to `OPEN` (the
      "give it back to the pool" workflow operators already describe).
    * `OPEN` → owner cleared, status preserved (today's behaviour).
    * `CLOSED` / `REJECTED` / `DEFERRED` → owner cleared, status
      preserved (release is not an un-decide).
  `--reopen` is now the explicit force-OPEN escape hatch — useful
  when un-closing a `CLOSED` owned task in one verb (previously the
  only thing `--reopen` did beyond bare release; on an `IN_PROGRESS`
  task `--reopen` is now a no-op vs. bare release). Pre-1.0 breaking
  change in the `releaseTask` SDK return shape (`status` may now be
  `OPEN` where it would have been `IN_PROGRESS`); CLI exit code and
  flag surface unchanged. Snapshot + agent_logs event behaviour
  unchanged.

- **`mu state` gains `--hud` and `--mission` render flags. Bare `mu`
  (no verb) is now an alias for `mu state --mission`** (today's
  stripped 5-col glance card; `merge_state_into_hud_render_mode`).
  One verb, three render modes:
    * default    — full top-to-bottom card (today's `mu state`)
    * `--hud`    — dynamic-fit budget renderer (today's `mu hud`)
    * `--mission` — stripped 5-column glance card (today's bare `mu`)
  `--hud` and `--mission` are mutually exclusive. The flag toggles
  rendering ONLY — the data set is identical across modes. JSON shape
  follows the renderer: default + `--hud` emit the unified flat shape
  `{ workstreamName, agents, orphans, tracks, ready, blocked,
  inProgress, recentClosed, workspaces, recent }`; `--mission` emits
  the stripped subset `{ workstreamName, agents, orphans, tracks,
  ready }`. Bare `mu --json` matches `--mission --json`. Net `-570`
  LOC src/ (entire `src/cli/hud.ts` lifted into `src/cli/state.ts`
  as render helpers).

- **`mu task wait` accepts cross-workstream qualified refs and gains
  `--first` (alias of `--any` that prints WHICH ref closed)**
  (`task_wait_cross_workstream`). Each `<ref>` is now bare
  (resolves via `-w` / `$MU_SESSION` / tmux session) or qualified
  `<workstream>/<name>` — `-w` is dropped when every ref is
  qualified; mixed lists are allowed. The per-poll reconcile loops
  over every workstream in the wait set (so reaper-flip exit 6
  fires across the whole watched surface, NOT just `-w`); a
  reaper-flip on an UNWATCHED workstream does not bleed into the
  exit code. `--first` adds a `firing: { workstreamName, name,
  qualifiedId, status, owner }` field to `--json` and prints the
  qualified id to stdout, so the dispatch-pipeline loop reduces to
  `closed=$(mu task wait <refs> --first --json | jq -r .firing.qualifiedId);
  cherry-pick; verify; free; recreate; repeat`. `--json` shape on
  the default `--all` path: `{ firing: null, all: [<ref reaching
  status>...], timedOut: [<unmet refs>...], nextSteps }`. SDK
  `waitForTasks` now accepts `TaskWaitRef[]` (each carrying its own
  `workstreamName`) in addition to the legacy `string[] + opts.workstream`
  shape; new exported `TaskWaitRef` type; `TaskWaitTaskState` gains
  a `workstreamName` field.

- **`mu task wait` now reconciles the workstream each poll and fails
  fast on a dead worker pane** (`task_wait_reconcile_dead_panes`).
  Per-poll `reconcile(mode: "full")` runs the reaper, which flips an
  IN_PROGRESS task whose owning pane is gone back to OPEN. With the
  default `--status CLOSED` the wait then exits with new code `6`
  (REAPER_DETECTED) and a stderr message naming the dead task + prior
  owner — cures the silent multi-minute stall after a tmux server
  restart kills worker panes. Suppressed when `--status` is not
  CLOSED (a reaper-flip TO open IS the success when `--status OPEN`).
  New `ReaperDetectedDuringWaitError`; `TaskWaitTaskState` gains an
  `owner` field; SDK gains a `beforePoll` hook on `waitForTasks`.

- **`mu workstream destroy --empty` now also surfaces unregistered
  `mu-*` tmux sessions** (`destroy_empty_match_tmux_only`). Test
  litter and partial-destroy remnants (DB row gone, tmux session
  survived) are now matched by the same sweep verb. Predicate is
  narrow on the `mu-` prefix; arbitrary tmux sessions are never
  touched. Synthetic `WorkstreamSummary` for tmux-only entries has
  `registered=false`, all counts 0, `tmuxAlive=true`; the dry-run
  table renders an em-dash for the missing `created_at`.

- **`--status` accepts multi (union) on `mu task list`, `mu task next`,
  and `mu approve list`** (`task_list_multi_status_union`). Same
  dual-form as every other multi-value flag (`--status OPEN,CLOSED`,
  `--status OPEN --status CLOSED`, or any mix), case-insensitive,
  deduped. Missing `--status` keeps today's no-filter shape (no auto-
  default to `OPEN ∨ IN_PROGRESS`). Single value is byte-identical to
  today's behaviour. `mu task wait --status` stays single (the verb is
  semantically "wait until reaches THIS status"). New shared helper
  `parseStatusesOption` in `src/cli.ts`; SDK `listTasks` /
  `listReady` / `listApprovals` accept `status?: T | readonly T[]`.

- **`mu hud`: `-w/--workstream` is now variadic; `--workstreams` removed**
  (`hud_unify_workstream_flag`). One flag does single + multi via
  parseCsvFlag (repeat OR comma-separate OR mix). `--all` kept as
  orthogonal sugar (mutually exclusive with `-w`). Pre-1.0, no
  back-compat shim: the only consumer was the orchestrator's own
  dispatch. hud is the one verb where `-w` accepts multi; every other
  verb keeps `WORKSTREAM_OPT` (single-valued).

### Fixed

- **`mu workspace orphans` no longer hides dirs from destroyed
  workstreams; `--all` flag added; `-w <unknown>` now errors**
  (`workspace_orphans_misses_destroyed_workstreams`). Three failure
  modes were folded into one nit: the verb required `-w <ws>`, had
  no scan-everything mode, and silently returned "no orphans" when
  the workstream itself didn't exist (so a typo, OR a workstream
  that had been destroyed without its on-disk dir cleared, hid
  permanent garbage). Fix is two-part:
  (1) New `mu workspace orphans --all` enumerates every workstream
  subdir under `<state-dir>/workspaces/`, recurses one level, and
  reports orphans across ALL workstreams INCLUDING workstreams
  whose row is gone. Each entry carries `stranded: boolean` — true
  when the parent workstream has no DB row, surfacing the destroyed-
  workstream case. JSON output is a flat array of
  `{workstreamName, agentName, path, stranded}`. `--all` overrides
  `-w` (a typo'd `-w` with `--all` is ignored, not an error).
  (2) `mu workspace orphans -w <unknown>` now throws
  `WorkstreamNotFoundError` (exit 3) via the same path the mutating
  verbs use, instead of silently happy-pathing to "(no orphan
  workspace dirs in <typo>)". The single-ws path now resolves the
  workstream tightly via `tryResolveWorkstreamId` before scanning.
  SDK side: new `listAllOrphanWorkspaces(db)` in `src/workspace.ts`
  and a new `StrandedWorkspaceOrphan` row shape, both re-exported
  from `src/index.ts`. No env vars, no new layout assumptions, no
  prune flag (the existing rm/`git worktree remove --force` recipe
  in Next: hints stays). Regression tests in
  `test/workspace-sdk.test.ts` cover the SDK aggregation, the
  destroyed-workstream stranded marker, the typo-`-w` exit-3 path,
  and the `--all` overrides-`-w` documented choice.

- **Snapshot GC was AND-of-caps, leaking 458 rows / 731MB after
  one day's dogfood; flipped to OR**
  (`snapshot_gc_caps_too_lax_no_cleanup_verb`). `gcSnapshots()`'s
  WHERE clause was `(created_at < cutoff) AND (id NOT IN top-100)`
  — "delete only if BOTH old AND past the count cap". Under bursty
  use every row was younger than the 14-day age cap, so the date
  filter spared everything regardless of row count and the 100-row
  cap NEVER fired. Operator-facing intent is the union ("keep at
  most 100 OR things <14 days old"); the impl was the intersection.
  Fix is one-line: WHERE flips to `(id NOT IN top-N) OR
  (created_at < cutoff)` — "delete if past the count cap OR past
  the age cap, whichever fires first." Matches the docstring.
  Regression test in `test/snapshots.test.ts` creates >GC_MAX_COUNT
  snapshots all <GC_MAX_AGE_DAYS old and asserts the count cap now
  fires. The defaults (100 rows / 14 days) are unchanged — with
  the OR fix they behave correctly.

- **`mu snapshot list` now shows `schema_version`; stale rows render
  dimmed** (`snapshot_gc_caps_too_lax_no_cleanup_verb`). New `ver`
  column between `id` and `label`, rendered as `v<N>` (e.g. `v7`).
  When a row's `schema_version != CURRENT_SCHEMA_VERSION` the entire
  row renders dimmed via `pc.dim` (mirroring the satisfied-blockers
  bucket in `mu task show`) so operators can see at a glance which
  snapshots are stepping-stones to nowhere (restore raises
  `SnapshotVersionMismatchError`). When stale rows are present the
  Next: block grows a one-paste `mu snapshot prune --stale-version
  --yes` suggestion. `--json` already exposed `schemaVersion` — no
  shape change.

- **GC caps are now env-tunable: `MU_SNAPSHOT_KEEP_LAST` (default
  100) and `MU_SNAPSHOT_MAX_AGE_DAYS` (default 14)**
  (`snapshot_gc_caps_too_lax_no_cleanup_verb`). Mirrors the
  `MU_SPAWN_LIVENESS_MS` / `MU_IDLE_THRESHOLD_MS` precedent in
  `src/agents.ts`: typed reader fns (`gcMaxCount()` /
  `gcMaxAgeDays()`) that fall back to the default on bad input
  rather than throwing — a typo'd env var must not crash auto-GC
  in a destructive verb's hot path. Replaces the prior
  `const GC_MAX_*` declarations.

- **`AgentDiedOnSpawnError.errorNextSteps()` now leads with a per-spawn
  `--command` recipe** (`agent_spawn_liveness_check_trips_on`). The
  prior Next: block jumped straight to `export MU_<UPPER_CLI>_COMMAND`,
  which is overkill for a one-off spawn (e.g. a single read-only scout
  hitting a wrapper CLI's per-project solo lock) and silently leaks
  into every subsequent spawn in the shell. The per-spawn recipe
  (`mu agent spawn <name> --command "<cli> <bypass-flag>"`, e.g.
  `pi-meta --no-solo`) already worked but was undocumented in the
  error path. Step order is now scrollback / per-spawn / global / disable
  liveness / doctor — smallest-blast-radius first. Error message body
  unchanged. Regression test in `test/error-nextsteps.test.ts` pins
  the per-spawn step's existence, position before the env-var step,
  and agent-name interpolation.

- **Task JSON now exposes `localId` alongside `name`**
  (`task_list_show_json_omits_localid_only`). Prior to this fix,
  `mu task list/next/show --json` only carried the per-workstream
  identifier as `name`, so the natural inference
  `jq -r '.[].localId'` (matching agents/workstreams JSON, and the
  literal recipe in `skills/mu/SKILL.md` "Pick the highest-ROI"
  block) returned `null`. `TaskRow` now carries both keys, set to
  the same value; `name` is preserved for compat. Regression test
  pins both keys across all three verbs in `test/json-output.test.ts`.

- **`tasks.updated_at` now bumps on every write that mutates the task
  row OR its child rows** (`task_updatedat_not_bumped_by_reparent`).
  Status changes (close/open/reject/defer) and field updates
  (--title/--impact/--effort-days) already bumped it; note inserts,
  edge inserts/deletes (block/unblock/reparent), and claim/release
  did not (claim/release in fact already updated `updated_at`; the
  three child-row writes did not). `mu task list --sort recency`
  uses `updated_at` and was silently demoting tasks that had just
  had a note appended or their blockers reshuffled. Fix is
  SDK-side: a single shared `touchTask(db, id)` helper called from
  `addNote`, `addBlockEdge`, `removeBlockEdge`, `reparentTask` in
  the same transaction as the child-row mutation. Idempotent no-ops
  (block-already-exists, unblock-already-gone, reparent to the same
  empty set) skip the bump so `--sort recency` stays honest about
  what was actually written.

- **`mu task add` now warns to stderr when the auto-id derivation
  truncates the title's slug**
  (`slugifytitle_silently_drops_clauses`). The `SLUG_SOFT_CAP=40`
  word-boundary cut keeps ids tidy in tables, but it silently dropped
  trailing clauses — dogfood-observed twice in one session, with one
  cut producing an id (`task_list_show_json_omits_localid_only`)
  whose meaning was the *opposite* of the original title. Now: when
  the slugify pass dropped real characters, `mu task add` writes a
  one-line stderr hint (`hint: id 'foo_bar' truncated from a longer
  slug; pass <id> positional to override...`) before the usual
  `Added task` line. Stderr-only, exit 0, suppressed under `--json`,
  no slug-algorithm change — zero behaviour change for scripts; the
  hint is the entire UX. New SDK helpers `slugifyTitleVerbose()` /
  `idFromTitleVerbose()` return the same string the plain forms do
  plus a `truncated: boolean`. `mu task add --help` now documents the
  word-boundary cap so the operator hears about it before getting
  bitten.

- **`mu task show` now groups blockers/dependents by status and dims
  the satisfied bucket** (`task_show_blocked_by_renders_closed`).
  Prior rendering printed every blocker in one comma-joined list
  regardless of status, so a reader could not tell from `mu task
  show` alone which prereqs still gated work vs which were already
  CLOSED-and-stale. The new layout under `Edges`:

  ```
    blocked by : sil_virtual_static_class_dispatch [OPEN]
    satisfied  : code_declenv_typed_keys [CLOSED],
                 parity_latent_reporting_detail [CLOSED]   (dimmed)
    blocks     : downstream_a [OPEN]
    no longer  : downstream_b [CLOSED]                     (dimmed)
  ```

  Each entry carries `[<STATUS>]` colour-coded the same way the
  task-list table colours statuses (`src/cli/format.ts`
  `colorStatus`). REJECTED + DEFERRED stay in the still-gating
  bucket because they continue to gate downstream work per
  `src/tasks/status.ts`. Empty `satisfied` / `no longer` lines are
  omitted (no clutter); empty `blocked by` / `blocks` keep the `—`
  back-compat marker. New SDK helper `getTaskEdgesWithStatus()`
  exposes `{name, status}` per edge so the renderer doesn't N+1.
  `--json` shape extended: `blockers` and `dependents` are now
  `Array<{name, status}>` (was `string[]`). NO new flag
  (`--all-blockers`, `--hide-closed`) was added — the grouping IS
  the fix; CLOSED entries are kept visible-but-recessive so DAG
  history stays readable.

- **SKILL.md `Next:` invariant now matches the empirical truth: it's
  emitted on MUTATING verbs only**
  (`nextsteps_audit_read_verbs_emit_no_nextsteps`). The skill claimed
  "Every successful verb also prints a `Next:` block" but ~17
  read-only verbs (`mu task list/next/owned-by/tree/show/notes`, `mu
  state` all 3 modes, `mu doctor`, `mu log read`, `mu workspace
  list/path`, `mu agent show/list/read/attach`, `mu me`, `mu archive
  show` happy path) have always omitted it on the read happy path —
  the table itself is the answer and the operator already chose to
  look. Doc-side fix (per fix-sketch on the task note: code-side
  would be ~50 LOC across 17 verbs to add hints of dubious value to
  idempotent verbs the operator just typed). VOCABULARY.md needed no
  change — it never asserted the universal form.

- **`TaskNotFoundError` next-step recipe no longer references the
  removed `tasks.workstream` column**
  (`nextsteps_audit_task_not_found_workstream_col`). The hint a
  user sees first on a missed-task lookup was a `mu sql "SELECT
  workstream, ..."` recipe; v5 dropped TEXT `tasks.workstream` for
  FK `tasks.workstream_id`, so the recipe failed at runtime with
  `no such column: workstream`. Replaced with a `JOIN workstreams`
  pattern matching the v5 `AgentExistsError` fix at
  `src/agents/errors.ts:35`. Added a regression test in
  `test/error-nextsteps.test.ts` that prepares every SELECT recipe
  in `TaskNotFoundError.errorNextSteps()` against a freshly-opened
  v-current DB — catches future stale-column drift before users do.

- **`CrossWorkstreamEdgeError.errorNextSteps()` now emits v5-shaped
  recipes** (`nextsteps_audit_cross_workstream_edge_v4_columns`). The
  "move the blocker" hint printed `UPDATE tasks SET workstream='…'
  WHERE local_id='…'` — v4 schema. Post-v5 there is no
  `tasks.workstream` column (it's `workstream_id` INT FK to
  `workstreams.id`) and `local_id` is unique only per
  `workstream_id`, so the v4 recipe both errored at runtime ("no
  such column: workstream") and was ambiguous across workstreams.
  Replaced with a v5-correct form that scopes the WHERE by the
  blocker's workstream_id and resolves the destination via
  subselect. Also dropped the "rename one workstream to the other"
  hint: it silently moves *every* task in the source workstream and
  fails outright when the destination name already exists (UNIQUE
  violation) — almost never what the operator wants. Duplicate-the-
  blocker hint kept since that's the safest fallback.

- **`mu workspace create <missing-agent>` now throws a typed
  `AgentNotFoundError` (exit 3) instead of leaking SQLite's bare
  `NOT NULL constraint failed: vcs_workspaces.agent_id`**
  (`workspace_create_typed_no_agent_error`). The error message
  includes the agent name and workstream context so the operator
  knows which scope was searched. Surfaced during the parallel-
  fan-out spawn dogfood when an agent name was passed against the
  wrong workstream.

- **`WorkstreamNameInvalidError` now uses a direct next-step intent
  for the `mu-` prefix branch** (`workstream_init_name_rejected_mu`,
  feedback ws). Pre: the only loud action line on `mu workstream
  init mu-foo` was "Try a sanitized name (best guess) : mu workstream
  init foo" — the prefix-rejection rationale lived only in the red
  error message above. Dogfooding showed agents skipped the rationale
  and read the hedge as a hint, not a fix. Post: when the failure is
  the unambiguous `mu-` prefix case, the intent reads "Retry without
  the 'mu-' prefix". For the regex/dot/colon branch the hedge stays
  honest (the sanitiser really is guessing). Code path + message body
  unchanged; only the intent label branches. ~10 LOC + regression
  test in `test/error-nextsteps.test.ts`.

### Schema

- **Schema v7: drops the `approvals` table.** Destructive in-place
  migration via `applySchema` (DROP INDEX + DROP TABLE IF EXISTS,
  gated on the detected pre-bump version so it runs once on a v6
  DB and is a no-op on a fresh v7). The pre-v5 refusal floor in
  `openDb` stays in place; v5 DBs still get the v5→v6 archive
  tables added before the v6→v7 approvals drop. See the Removed
  entry above for the rationale.

- **Schema v6: 5 new `archive_*` tables; additive only.** Backs the
  in-progress `mu archive` verb (cross-workstream preservation of
  task graphs before destroy). Tables: `archives`, `archived_tasks`,
  `archived_edges`, `archived_notes`, `archived_events`. v5 DBs are
  forward-bumped to v6 in place by `applySchema` (no migration
  script needed; the v5 → v6 transition touches no existing column,
  FK, or view). The pre-v5 refusal floor stays in place.

### Breaking

- **Bucket export layout (`bucketVersion: 2`); old single-workstream
  layout no longer supported.** `mu workstream export` and the new
  `mu archive export` both write a multi-source bucket: top-level
  `<bucket>/{README.md,INDEX.md,manifest.json}` plus one
  `<bucket>/<source-ws>/{README.md,INDEX.md,tasks/<id>.md}`
  subdirectory per source workstream. Re-exporting `-w X` into a
  bucket containing `-w Y` appends `X/` without touching `Y/`.
  Pre-0.3 export directories (top-level `tasks/`, no `bucketVersion`
  in `manifest.json`) are NOT migrated in place; the export refuses
  with a `LegacyExportLayoutError` (exit 2) and asks the operator
  to `rm -rf <dir>` and re-run. The per-source-ws subdir layout
  preserves task `.md` paths byte-identically across export → archive
  → re-export, so `git`'s rename detector tracks history through
  the migration (verified on the in-repo `exports/mu/` migration
  commit; ~150 task files renamed cleanly, no new add/delete pairs).

## [0.2.0] — 2026-05-09

### Breaking

- **`--json` shape rewritten end-to-end** (`output_json_keys_rename_v5`).
  Every entity row's keys realigned to the v5 name-vs-surrogate-id split:
  `localId` → `name`; `slug` → `name`; `workstream` → `workstreamName`;
  `owner` → `ownerName`; `agent` → `agentName`; counts on
  `WorkstreamSummary` gain a `*Count` suffix; composite-verb wrappers
  rename `task:` / `agent:` / `workstream:` → `taskName` / `agentName`
  / `workstreamName`; `TaskNoteRow` drops `id` + `taskId`. CLI text,
  exit codes, and column rendering unchanged. No `--json-shape v4`
  flag, no dual-emit. `jq` migration recipes inline in the matching
  task notes.

- **Schema bumped to v5 — surrogate INTEGER PKs everywhere
  (`schema_surrogate_pks_for_global_uniqueness`).** Every entity table
  gets `id INTEGER PRIMARY KEY AUTOINCREMENT` + `UNIQUE (<scope_id>,
  <name>)`; FKs become INTEGER. `tasks.local_id` and `agents.name` are
  now per-workstream unique (the same name in two workstreams is
  legal). Pre-v5 DBs are rejected at `openDb` with
  `SchemaTooOldError`; the operator runs a one-shot
  `scripts/migrate-v4-to-v5.ts` (loud, not auto-applied). See
  [docs/architecture/sdk.md § State of truth](../architecture/sdk.md#state-of-truth)
  and the deleted `docs/SCHEMA_v5_DESIGN.md` (in git history).

- **SDK signatures rewired for v5 (`schema_v5_sdk_signatures`).**
  Every public function that took an entity name now takes
  `workstream` first; the v4 nullable-workstream fall-back branches
  are gone (`v5_prune_v4_fallback_branches`, ≈ −160 LOC). External
  SDK consumers must re-thread `workstream`. CLI behaviour unchanged.
  CI guard `scripts/grep-name-without-workstream.sh` (wired into
  `npm run lint`) bans unscoped name lookups under `src/`.

- **`addApproval` requires a non-null workstream.** v5's
  `approvals.workstream_id` is `NOT NULL`; the v4 nullable contract
  is gone. The runtime check is replaced by the type system.

- **`mu hud` mode flags removed** (`--line` / `--small` / `--mid`
  / `--full`). The HUD now renders one shape — a dynamic table
  layout that fills the available pane height + width — by default.
  `--json` is preserved unchanged. Status-bar callers should use the
  one-line first row of the default render or `mu hud --json | jq`.

- **`mu agent close` no longer touches the workspace** (pre-v0.2;
  retained for migration clarity). Closing an agent kills the pane
  and removes the registry row only; run `mu workspace free <agent>`
  explicitly. The `--keep-workspace` / `--commit-workspace` flags are
  gone. Migration: scripts that did `mu agent close X` should add
  `mu workspace free X` after.

### Added

- **Cross-workstream verb args via `<workstream>/<name>`
  qualified form** (`verb_arg_qualified_workstream_name`). Every verb
  taking a task / agent / approval / workspace name accepts either
  bare `<name>` (resolved via `-w` / `$MU_SESSION` / current tmux
  session) or `<workstream>/<name>` (skips `-w` resolution; from any
  shell). Mixing qualified ref with non-matching `-w` errors out
  (`UsageError`, exit 2). Bare name with no `-w` and ≥2 candidate
  workstreams raises `NameAmbiguousError` (exit 4) with a one-paste
  qualified-form hint per candidate. SDK signatures unchanged — the
  qualifier lives entirely above `src/cli.ts`.

- **`mu workstream export -w <ws> [--out <dir>]` writes the
  workstream's task graph + notes as a directory of plain markdown.**
  Closes `export_tasks_to_md_folder`. One `.md` per task with
  frontmatter (status / impact / effort / ROI / owner / timestamps /
  blocked_by / blocks) + body (title + chronological notes, fenced
  with a backtick-run long enough to escape literal triple-fences),
  plus `INDEX.md` (per-status table), `README.md` (counts), and
  `manifest.json` (per-file sha256 + `latestSeq` cursor). Idempotent
  re-export (sha256 short-circuit); deleted-from-DB tasks are
  preserved with a one-time banner. `mu workstream destroy --yes`
  now auto-exports to `<state-dir>/exports/<ws>-<ts>/` first; opt
  out with `--no-export`.

- **`mu task wait --stuck-after <seconds>` warns when a worker
  committed but skipped `mu task close`.** Closes
  `agent_close_discipline_gap` Phase 1. `waitForTasks` accepts
  optional `stuckAfterMs` (default 300_000 = 5 min); on every poll
  it checks IN_PROGRESS tasks owned by an agent in `needs_input`
  whose `agents.updated_at` is older than the threshold and emits
  one yellow line to stderr per stuck task per call (Set-deduped).
  `TaskWaitResult.tasks[i]` gains `stuck: boolean`. Wait keeps
  polling — the warning is observational; force-close /
  re-prompt / escalate is the operator's call. Phase 2 adds a
  matching SKILL.md bullet.

- **`--sort` for `mu task list / next` (recency / age / id /
  roi).** Closes `nit_task_list_sort_by_recency`. Two new shapes
  formerly stuck behind `mu sql`: "what did I touch most
  recently?" (`--sort recency` = `updated_at` DESC) and "what's
  gone stale?" (`--sort age` = `created_at` ASC). Unknown keys exit
  2. Time-based sorts add a relative-time column (`12s` / `5m` /
  `3h` / `2d` / `2w`); other sorts keep the historical narrow
  table. JSON is reordered, never reshaped.

- **Workspace staleness signal in `mu state` and `mu workspace
  list`.** Closes `bug_workspace_stale_parent_silent_drift`
  (Option 2 only — warn-only). Each `vcs_workspaces` row gets an
  optional `commitsBehindMain` populated by
  `decorateWithStaleness` (per-backend `commitsBehind(path,
  ref)`). Rendered as a colour-coded `behind` column (≤2 green,
  3–9 yellow, ≥10 red). `mu state` prefixes the Workspaces header
  with `⚠ (N stale ≥10 commits behind)` when any row qualifies, and
  appends a `mu workspace free + create` remediation tip. Pure
  observation: no auto-fetch. Backends that can't resolve the
  default branch return `null` (renders `—`).

- **`mu workspace create` refuses outright when projectRoot is
  `$HOME`** and cleans up partial dirs on failure. New typed
  `HomeDirAsProjectRootError` (exit 4) catches `cd $HOME && mu
  workspace create`, `--project-root ~/`, etc. Direct children of
  `$HOME` are deliberately not blocked. `createWorkspace` now wraps
  `backend.createWorkspace` in a try/catch: on throw, the partial
  workspace path is removed via `rm -rf` before the original error
  re-throws.

- **`mu undo` / `mu snapshot list` / `mu snapshot show` — the
  user-facing recovery verbs.** Closes `snap_undo_verb`. Default
  restores the latest snapshot; `--to N` picks one. Confirmation
  gate mirrors `mu workstream destroy --yes`: dry-run prints
  summary + the explicit "tmux NOT rolled back" warning; `--yes`
  commits. Post-restore reconcile reports ghost-pruned /
  orphan-surfaced counts. No `mu redo`: each restore captures a
  pre-restore snapshot, so re-running `mu undo` rolls forward.
  Typed errors map to exit 3 / 4 / 5.

- **Snapshots + auto-capture before destructive verbs (schema v4).**
  Closes `snap_schema`. Every destructive verb (workstream destroy,
  agent close, task close/reject/defer/release/delete, workspace
  free, approve grant/deny/timeout) captures a whole-DB snapshot
  via `VACUUM INTO`. Files land in `<dirname(db-path)>/snapshots/`,
  indexed by a `snapshots` sidecar table (no FK on workstream — the
  snapshot must outlive its workstream). Capture happens at the
  verb wrapper, not inside `setTaskStatus`, so `--cascade reject`
  produces ONE snapshot per invocation. GC: keep <14 days OR <100
  rows.

- **`mu workstream destroy` advertises `mu undo` in its `Next:`
  block.** Closes `snap_destroy_safety`. Dry-run output names the
  pre-destroy snapshot and the explicit "tmux NOT rolled back"
  caveat; `--yes` output adds an `Undo` next-step.

- **`mu task reject --cascade` / `mu task defer --cascade` are now
  dry-run by default; require `--yes` to commit.** Closes
  `bug_cascade_reject_too_aggressive`. `RejectDeferOptions` gains
  `yes?: boolean`; `RejectDeferResult` gains `dryRun` +
  `affectedIds`. Single-task case (no open dependents) skips the
  preview. `--yes` without `--cascade` errors with `UsageError`.

- **`mu hud` rewritten as a dynamic table layout.** Closes
  `nit_hud_render_tables`. Greedy top-down by priority: header line
  → agents → ready tasks → in-progress → tracks → recent events.
  Each section is a width-aware cli-table3; truncated sections show
  an `… +N more (<verb>)` footer. Pane size resolved via
  `MU_HUD_FORCE_SIZE` → `process.stdout` TTY → `tmux
  display-message` → 120×30 fallback. `--json` shape unchanged.

- **`mu hud` verb (initial form, superseded above).** Print-once
  HUD card; the operator-side complement to the agent pane border.
  Composes via `watch -n 5 mu hud -w X`, `tmux display-popup -E`,
  status-bar `#()` injection.

- **Pane border + composed pane title carry mu's interpreted
  state.** Closes `hud_visual_cue_design` + `_impl`.
  `enableMuPaneBorders` sets `pane-border-status=top` +
  `pane-border-format=' [mu] #{pane_title} '` + heavy box-drawing
  on all four sides (`pane-border-lines=heavy`,
  active=`fg=cyan,bold`, inactive=`fg=brightblack`). Pane title is
  composed from current DB state and refreshed after every
  state-touching verb + on every reconcile (`<name> · <emoji> ·
  <task-id>`); `parseAgentNameFromTitle` keeps the agent name as
  the first ` · ` token so the claim-protocol fallback still works.
  Opt-out: `MU_BANNER_QUIET=1`.

- **Spawned agent panes inherit identifying env vars**
  (`MU_MANAGED_AGENT=1`, `MU_AGENT_NAME=<name>`,
  `MU_WORKSTREAM=<name>`). Closes `pass_mu_env_to_panes`. Tmux
  3.0+ `-e KEY=VALUE` is set in the new pane's environment only;
  no global server pollution. Pane-creating helpers in
  `src/tmux.ts` gain an optional `env` arg.

- **`mu task wait <ids...>` blocks until tasks reach a status.**
  Closes `nit_no_mu_task_wait`. `--status` (default `CLOSED`),
  `--any`, `--timeout` (default 600s, 0 = forever). Exit 0
  (condition met) / 3 (TaskNotFoundError pre-flight) / 5
  (timeout). 1s poll. Replaces the hand-rolled bash+awk
  multi-task wait; the awk tail-pattern remains valid for
  one-event ad hoc.

- **`mu agent close` refuses by default if the agent has a
  workspace.** Closes `bug_workspace_orphaned_after_agent_close`.
  Throws `WorkspacePreservedError` (exit 4) with three actionable
  resolutions; `--discard-workspace` (and SDK
  `closeAgent(db, name, { discardWorkspace: true })`) frees the
  workspace BEFORE deleting the agent.

- **`WorkspacePathNotEmptyError` typed-error + defensive `git
  worktree prune` on create.** Closes
  `agent_spawn_workspace_fails_when_prior` +
  `workspace_free_cleanup_leaves_git`. Replaces bare backend
  errors when an on-disk dir is occupied with no DB row;
  `errorNextSteps()` lists the three concrete recoveries.
  `gitBackend.createWorkspace` runs `git worktree prune`
  defensively before `add` (cheap, idempotent).

- **Status detector recognises Braille spinner glyphs as busy.**
  Closes `bug_status_detector_pi_solo_misclassifies`. Fallback
  regex `/[\u2800-\u28FF]/` after the existing permission +
  `to interrupt)` patterns; covers pi-meta and every TUI spinner
  library. Order of precedence preserved: permission > busy
  literal > braille fallback > needs_input.

- **Task states gain `REJECTED` and `DEFERRED`; new verbs
  `mu task reject` / `mu task defer`.** Schema v3. `goals` view
  excludes both; `ready` / `blocked` views unchanged (only
  CLOSED satisfies a `--blocked-by` edge — REJECTED + DEFERRED
  still BLOCK downstream by design). Stranded-dependent guard
  surfaces `TaskHasOpenDependentsError` (exit 4) with three
  resolutions; `--cascade` walk PRUNES at CLOSED / REJECTED /
  DEFERRED nodes.

- **`mu workstream destroy` now actually cleans workspaces.**
  Closes `workstream_destroy_yes_leaves_workspace`. Calls each
  `vcs_workspaces` row's backend `freeWorkspace()` before the FK
  CASCADE; `DestroyResult` gains `freedWorkspaces` /
  `failedWorkspaces`. Empty `<state>/workspaces/<ws>/` parent dir
  is reaped (best-effort `rmdir`). Bare-registry workstreams are
  no longer treated as "nothing to destroy".

- **Agent identity propagates to task notes; spawn output
  surfaces `--command` overrides.** Closes
  `nit_agent_note_author_identity` + `nit_spawn_custom_command_display`.
  `mu task note` author resolves via `resolveActorIdentity()`
  (`$MU_AGENT_NAME` > pane title > `$USER` > `'orchestrator'`); pass
  `--author` to override. `mu agent spawn` output reads
  `Spawned X (pi (cmd: pi-meta --no-solo))` when the resolved
  command differs from the cli value; JSON gains `resolvedCommand`
  + `commandOverridden`.

- **`mu sql` accepts multi-statement scripts** (BEGIN/COMMIT
  blocks, semicolon-separated batches). Closes
  `nit_sql_multi_statement`. Probes via `db.prepare`; on
  `'more than one statement'` throw, falls back to `db.exec`
  with a hand-rolled `countTopLevelStatements()` for the report.

- **Auto-generated task IDs trim at a 40-char word boundary.**
  Closes `nit_long_auto_slug`. `slugifyTitle` cuts at the last
  `_` at-or-before the soft cap; collision-loop respects the
  64-char hard ceiling.

- **Self-documenting verb output: `Next:` hints + structured JSON
  errors + universal `--json`.** Closes the `selfdoc_*` track
  (infra, errors, verbs_round2, json_universal, skill_cleanup).
  Every successful write verb prints follow-up commands; every
  typed error class implements `errorNextSteps()` with actionable
  resolutions; every verb (one allow-listed exception, `mu agent
  attach`) accepts `--json`. Errors emit
  `{ error, message, nextSteps, exitCode }` to stderr;
  `nextSteps` carry the same structured shape in human + JSON
  output. `mu doctor --json` returns a fully structured
  `{ environment, db, workstream, state }` report. SKILL.md
  trimmed 771 → 574 LOC over two passes.

- **`mu task claim --self`, `mu adopt <pane-or-title>`,
  `mu task list --status <S>`** — three smaller v0.2 additions
  for the orchestrator pattern: `--self` records the actor in
  `agent_logs` while leaving `tasks.owner` NULL; `mu adopt`
  registers an existing tmux pane as a managed agent (idempotent;
  scope-checked); `--status` filter on `mu task list`
  (case-insensitive `OPEN | IN_PROGRESS | CLOSED`).

### Changed

- **`mu task ready` merged into `mu task next -n 0`** — closes
  `audit_merge_task_ready_into_next`. `cmdTaskNext` treats `-n 0`
  as unlimited (the historical `task ready` shape); default
  `-n 1` keeps "what should I do right now?". The `ready` SQL
  view stays (consumed by `mu state` / `mu hud`); the verb +
  Commander wiring + `cmdTaskReady` (~25 LOC) are gone.

- **`mu whoami` / `mu my-tasks` / `mu my-next` merged into
  `mu me [tasks|next]`** — closes
  `audit_merge_self_verbs_into_mu_me`. `mu me` (default = former
  `whoami`); `mu me tasks` (former `my-tasks`); `mu me next [-n
  K]` (former `my-next`, with `-n 0` extended to "all ready"). No
  back-compat aliases.

- **CLI output labels: `name`/`<entityType>Name`.** Closes
  `output_id_vs_name_audit` (audit) +
  `output_labels_human_rename` (Phase 2, non-breaking). Every
  cli-table3 first column renamed `id` / `slug` → `name`;
  surrogate ids stay strictly internal. `mu undo --to <id>` and
  `mu log --since SEQ` keep their integer surrogate column names
  (operator-facing by design). Phase 3 (`<workstream>/<name>`
  qualified refs) and the breaking JSON rewrite both ship in
  separate entries above.

- **CLI boundary discipline: `WorkstreamNotFoundError` maps to
  exit 3** (`schema_v5_cli_boundary`). Registers the missing
  class next to `AgentNotFoundError` / `TaskNotFoundError` and
  exports `classifyError` for unit-testing the full map.

- **`reconcile()` `dryRun: boolean` replaced with `mode: "full"
  | "status-only" | "report-only"`.** Closes
  `reconcile_split_dryrun_into_status_only_mode` +
  `bug_pane_title_glyph_stuck_at_needs_input`. Splits
  prune-suppression from status-suppression. `mu state` / `mu
  hud` use `"status-only"` (refresh status + pane title; no
  prune); `mu doctor` / `mu undo` use `"report-only"` (no
  mutation); `mu agent list` defaults to `"full"`. **Breaking**
  for SDK consumers of `ReconcileOptions` / `ReconcileReport` /
  `ListLiveAgentsOptions`: `dryRun?: boolean` → `mode?:
  ReconcileMode`. CLI verb behaviour is strictly better.

- **Read-only verbs no longer race in-flight `--workspace`
  spawns.** Closes (re-opened) `bug_agent_spawn_workspace_fk_failure`.
  Pre-fix: `watch -n 5 mu hud` could prune the placeholder agent
  row mid-spawn, FK-failing the subsequent `vcs_workspaces`
  insert. `ListLiveAgentsOptions` gains `dryRun?: boolean`;
  `cmdHud` / `cmdState` / `cmdMission` / `cmdAttach` / `cmdDoctor`
  set it. `cmdList` keeps the mutating behaviour (the documented
  escape hatch).

- **`mu undo` no longer silently drops recovered agent rows
  whose panes are dead.** Closes
  `snap_undo_reconcile_destroys_recovered_agents`. Post-restore
  reconcile runs in `"report-only"` mode so the snapshot's
  agents + workspaces survive the restore.

- **`mu task claim <task> -w <wsA> --for <agent>` rejects when
  `<agent>` lives in a different workstream.** Closes
  `cross_workstream_claim_for`. Pre-FK check throws
  `AgentNotInWorkstreamError` (exit 4). The `--self` path is
  untouched.

- **HUD colors survive `watch` and other non-TTY pipes.** Closes
  `hud_colors_stripped_under_watch_and`. New `colorEnabled()`
  helper returns true if any of `picocolors.isColorSupported`,
  `MU_FORCE_COLOR`, `FORCE_COLOR`, or `process.env.TMUX` is set;
  `NO_COLOR` trumps. Every `picocolors` import re-exports from
  `src/output.ts` so every colour-using verb picks up the fix
  uniformly.

- **`mu task add` invalid id throws typed `TaskIdInvalidError`
  (exit 4)** instead of bare `TypeError`. Closes
  `nit_invalid_id_typeerror`. `errorNextSteps()` returns the
  drop-`--id` recipe + a sanitised candidate.

- **`docs/VERB_AUDIT.md`: typed-vs-`mu sql` audit of every
  verb.** Closes `audit_verbs_typed_vs_sql`. 51 KEEP, 3 REMOVE
  (`mu task search/blocked/goals`), 4 MERGE (`task ready` into
  `task next -n 0`; `whoami`/`my-tasks`/`my-next` into `mu me`).
  Each disposition filed as a follow-up; the operator decides
  which ship.

- **`docs/SCHEMA_v5_DESIGN.md` design + amendments.** Closes
  `schema_surrogate_pks_for_global_uniqueness` (design) +
  `schema_v5_design_amendments` (review fixes: pinned 10-step
  migration ordering, SDK consumer impact, real-DB fixture,
  snapshot interaction). Doc removed in the post-landing
  cleanup; load-bearing patterns (boundary discipline, surrogate-
  PK pattern) absorbed into [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md).

- **`src/cli/tasks.ts` split: 1234 → 29 LOC re-export hub.**
  Closes `review_code_cli_tasks_oversize`. Five sibling files in
  `src/cli/tasks/` (`wire.ts`, `edit.ts`, `claim.ts`, `edges.ts`,
  `tree.ts`); every file < 500 LOC, median 200. Re-export hub
  surfaces only `wireTaskCommands` / `cmdMyTasks` / `cmdMyNext` /
  `unescapeNoteText` (the only outside-cluster imports).

- **`muTable()` helper bakes in HUD truncation safety belt
  (`wordWrap: false` + per-column `colWidths`).** Closes
  `tables_truncate_long_cols_audit`. Surfaces eight existing call
  sites; per-site truncation budgets target user-data columns
  (`path` 40 cols front-truncated, `name` 40, `label` 50,
  `reason` 60, `window`/`role` 32/14). `mu sql` divides terminal
  width evenly with a 12-char floor.

- **`mu task note` Next: hints + --help teach single-quote
  discipline.** Closes `nit_task_note_shell_metachar_hint`.
  Backticks / `$VAR` / `$(...)` expand in the operator's shell
  before mu sees the note; double-quoted hints in
  `cmdTaskAdd` / `cmdClaim` / `mu task note --help` now show the
  single-quote form.

- **De-duplicated SDK + CLI patterns.** Closes
  `review_code_should_overwrite_status_dup`,
  `_raw_task_state_duplicate`, `_views_recreated_thrice`,
  `_assert_in_workstream_smell`, `_resolveselfnameoruser_dup_resolveself`,
  `_banner_quiet_env_repeated`, `_cli_tasks_re_export_indirection`,
  `_taskerrors_sanitise_lives_in_errors`. Net ≈ −80 LOC across
  status-overwrite predicate, `RawTaskRowForState`+`rawTaskRowToTask`
  CLI→SDK consolidation, `READY/BLOCKED/GOALS_VIEW_SQL` constants,
  `assertEntityInWorkstream` collapse, `resolveSelfOptional`
  layering, `MU_BANNER_QUIET` self-checking border helpers,
  re-export hub cleanup, `sanitiseTaskId` migration to `tasks.ts`.

- **`spawnAgent` workspace pre-stage extracted into named
  helpers** (`prestageWorkspace` / `finalizeAgentRow` /
  `rollbackSpawn`); the placeholder pane-id (`%pending-<name>`)
  becomes the named `PENDING_PANE_PREFIX` constant. Closes
  `review_code_spawn_workspace_dance_too_clever`. The 18-line
  rejected-designs narration is gone.

- **`mu task show --self` actor lookup is no longer brittle.**
  Closes `review_code_last_claim_actor_brittle`. Claim events
  carry a tab-delimited structured prefix
  (`task.claim<TAB><id><TAB>actor=<x><TAB>self=<0|1><TAB>`);
  consumer does an indexed `LIKE` with no recent-window cap.
  Display layer strips the prefix via `displayEventPayload`.

- **`mu adopt <pane>` is wired again.** Closes
  `bug_adopt_verb_unwired`. The f42e86d `wireXxxCommands`
  refactor dropped the top-level `program.command("adopt
  <pane-or-title>")` registration; restored. Two new regression
  cases pin the wiring in `test/verbs.test.ts`.

- **Per-workstream name lookups no longer silently misroute.**
  Closes `bug_v5_name_clash_silent_misroute` (Phase 1). Every
  public SDK function that takes a TEXT name now also takes (or
  threads) the workstream; internal SQL filters by
  `(workstream_id, name)`. CI guard
  `scripts/grep-name-without-workstream.sh` enforces. 26 new
  cases in `test/v5-name-clash.test.ts`. Phase 2
  (`NameAmbiguousError` for unscoped SDK consumers) shipped under
  `verb_arg_qualified_workstream_name` above.

- **Test-suite repair (v5).** SDK callsites threaded through
  `workstream`; helpers (`insertTask` / `insertEdge` / `insertNote`
  in `test/db.test.ts`; `insertVcsWorkspaceRow` in
  `test/workstream.test.ts` + `test/snapshots.test.ts`) translate
  operator-facing names to surrogate ids on insert. The 9
  v1→v2 / framework-rollback migration tests are
  `describe.skip(...)` (substrate no longer reachable).

- **Doc staleness sweep — 9 files updated, 3 obsolete sections
  removed, 12 duplicated paragraphs collapsed.** Closes
  `docs_staleness_review_capstone`. README compressed
  600+ → ≤ 250 LOC; CHANGELOG `[Unreleased]` compressed
  ~3300 → ~400 LOC; SKILL.md trimmed; `docs/SCHEMA_v5_DESIGN.md`
  load-bearing patterns absorbed into ARCHITECTURE.md before the
  doc was deleted; broken links fixed.

### Removed

- **Three audit-flagged read-only verbs deleted: `mu task
  blocked`, `mu task goals`, `mu task search`.** Closes
  `audit_remove_task_*`. All scored 1/4 in the verb audit
  (since-removed `docs/VERB_AUDIT.md`); the underlying
  abstractions (the two SQL views + case-insensitive `LIKE`)
  are one-liners against `mu sql`. SDK helpers (`listBlocked`
  / `listGoals` / `searchTasks`) survive as reusable surface
  consumed by `mu state` / `src/tracks.ts`. SQL recipes published
  in `docs/USAGE_GUIDE.md` "What's NOT in 0.2.0".

- **Four schema-v5-defunct workarounds deleted
  (`schema_v5_cleanups`; net ≈ −40 LOC).** The `mu_`
  reserved-prefix gymnastics, the `idFromTitle`
  collision-loop hard-cap defensive truncation, the
  `cross_workstream_claim_for` pre-check residue, and the brittle
  `lastClaimActor` CLI-side wrapper. Each existed because v4 had
  a global TEXT namespace; v5's per-workstream UNIQUE makes them
  moot.

- **Every "preserves the v4 contract" fall-back branch in `src/`
  deleted (≈ −160 LOC).** Closes `v5_prune_v4_fallback_branches`.
  Tightened ~30 SDK signatures (workstream now required, not
  optional). Helper `lookupTaskAnyWorkstream(db, localId)` is the
  one legitimate cross-workstream task lookup, used by `addTask`
  + `reparentTask` blocker resolvers so a same-name blocker in a
  different workstream surfaces `CrossWorkstreamEdgeError`. CI
  guard `scripts/grep-v4-references.sh` (wired into
  `npm run lint`) bans `v4` / `backward-compat` in `src/`.

- **`src/migrations.ts` deleted (≈ −450 LOC src+test).** Closes
  `schema_v5_drop_migrations_ts`. The v1→v2 / v2→v3 / v3→v4
  in-process migrators are dead code post-v5: the loud-fail hook
  in `openDb` rejects every pre-v5 DB before any migration would
  run, and v4→v5 is a one-shot out-of-process script.

- **`src/cli/tasks.ts` no longer re-exports the
  lifecycle/queries cluster's `cmd*` functions.** Closes
  `review_code_cli_tasks_re_export_indirection`. No outside-cluster
  caller went through the re-exports; deleted the 24 lines of
  ceremony.

- **`docs/SCHEMA_v5_DESIGN.md` + `scripts/migrate-v4-to-v5.ts`
  + `test/migrate-v4-to-v5.integration.test.ts` deleted
  (capstone, separate commit).** Per the temp-impl-artifact
  cleanup rule (`docs_staleness_review_capstone`): files named
  for a SPECIFIC OPERATION (`migrate-vN-to-vM`,
  `decision-doc-for-X`) are temporary by construction. Operator's
  DBs migrated; the loud-fail hook in `openDb` stays as the
  safety belt; restore from git history if needed.

### Fixed

- **`destroyWorkstream` no longer double-counts already-gone
  workspaces as freed.** Closes
  `review_code_destroy_freed_workspaces_double_count`.
  `DestroyResult` gains `alreadyGoneWorkspaces: number`; the CLI
  appends `(N already gone on disk)` only when non-zero. The
  `workstream destroy` log event gains `already_gone=N`.

- **`waitForTasks` returns within `timeoutMs` even when `pollMs >
  timeoutMs`.** Closes `review_test_waitfortasks_polling_unverified`.
  Sleep clamped to `min(pollMs, deadline - now)`; `timeoutMs=0`
  still uses the full poll cadence.

- **`mu task note` escape translation no longer relies on an
  in-band sentinel string.** Closes
  `review_code_unescape_note_text_placeholder_brittle`.
  Single-pass regex `/\\([\\ntr])/g`.

- **`mu hud` recent-events tail colours every emitter verb.**
  Closes `review_code_hud_event_color_regex_drift`. Verb prefix
  list extracted to single source of truth `EVENT_VERB_PREFIXES`
  in `src/logs.ts`; two-sided regression tests scan every
  `emitEvent(...)` callsite.

- **`mu log`'s `resolveLogContext` `??` consistency + pane-branch
  asymmetry comment.** Closes
  `review_code_resolve_log_workstream_branch_dup`.

- **`decorateWithStaleness` no longer fans out N concurrent VCS
  shellouts.** Closes
  `review_code_decorate_with_staleness_n_plus_one`.
  Concurrency cap of 4 (inline `mapWithConcurrency`) +
  per-invocation memoization keyed by `(backend, parentRef)`.

- **`colorEnabled()` is synchronously testable.** Closes
  `review_test_color_enabled_no_color_module_load_caveat`.
  Reimplemented from scratch reading every signal at call time;
  picocolors is the renderer, the decision is ours. Two new
  cases pin `TERM=dumb` and `NO_COLOR=""` semantics.

- **Long task titles no longer blow out the terminal** (pre-v0.2;
  retained for migration clarity). Table views compute a
  title-column budget from `process.stdout.columns`; the `id`
  column is never truncated.

- **Task JSON output now includes `roi`** (impact ÷ effortDays).
  Tasks with `effortDays === 0` omit the field.

- **`mu workstream init <name>` validates the name.** Names with
  `.`, `:`, `/`, uppercase, leading digit/hyphen, or > 32 chars
  are rejected with `WorkstreamNameInvalidError` (exit 2). The
  same regex applies to `ensureWorkstream`.

- **Workstream names with the `mu-` prefix are rejected at init
  time.** Caught the `mu-mu-foo` double-prefix case.

- **`mu task claim` from an unregistered pane gives an actionable
  error** (`ClaimerNotRegisteredError`, exit 4). Pre-check
  throws before the atomic CAS UPDATE. Three actionable hints in
  `errorNextSteps()`: `--self`, `--for`, `mu adopt %<pane>`.

### Test-suite repair (non-v5 follow-ups)

- **`destroyWorkstream` `failedWorkspaces` accumulation path now
  has direct test coverage** (new `WorkstreamOptions.resolveBackend`
  injection seam). Closes
  `review_test_destroy_failed_workspaces_uncovered`.
- **`TaskIdInvalidError` test assertions relaxed off the exact
  sanitised-command suffix.** Closes
  `review_test_invalid_id_overspecs_sanitised_command`.
- **`workspace list` "behind" column anchored structurally**
  (JSON pin + cli-table3 `│`-separator regex). Closes
  `review_test_workspace_staleness_behind_value_unanchored`.
- **`createWorkspace` `opts.backend` accepts a `VcsBackend` object
  for cleanup-on-throw test injection** (drops the
  monkey-patched singleton). Closes
  `review_test_workspace_cleanup_throws_monkeypatch_smell`.
- **`STATUS_EMOJI` round-trip tests now interpolate every entry,
  not three.** Closes
  `review_test_status_emoji_drift_only_three_glyphs`.
- **`printNextStepsTo('stderr')` routes to `console.error`** is
  now pinned. Closes
  `review_test_print_next_steps_stderr_branch_uncovered`.
- **`claim.integration.test.ts` regains end-to-end coverage of
  the cross-workstream guard.** Closes
  `review_test_claim_integration_xws_rewrite`.
- **`listTasksByOwner` cross-workstream test exercises the read
  codepath honestly.** Closes
  `review_test_listtasksbyowner_xws_owner_state_unreachable`.
- **`tasks.test.ts` `--self` identity tests strip
  `MU_AGENT_NAME`** alongside `TMUX_PANE` / `USER` (extracted
  `withCleanIdentityEnv` to `test/_env.ts`). Closes
  `review_test_tasks_mu_agent_name_env_pollution`.

### Schema

- **Schema bumped to v5** — see Breaking above.
- **`schema_version` table + migration framework** (v1 → v2;
  later removed once v5 landed). The framework existed for the
  ON-UPDATE-CASCADE migration and the v3 `REJECTED`/`DEFERRED`
  states; the file is gone post-v5.
- **All 10 foreign keys gain `ON UPDATE CASCADE`** (v1 → v2,
  pre-v5). Renaming a workstream / task / agent name now leaves
  no dangling children. Recovery recipes in
  [USAGE_GUIDE § 14](../guide/recovery.md#rename-a-workstream).

## [0.1.0] — Initial release

First public release. Mu is a CLI that manages a persistent crew
of pi agents in tmux panes, coordinated through a built-in task
DAG and per-agent VCS workspaces. State lives in one SQLite file
at `<XDG_STATE_HOME or ~/.local/state>/mu/mu.db`.

This release packages a body of work developed against real
multi-day investigations. The version number resets at the
public boundary; see git history for the per-step evolution.

### What's in 0.1.0

**~50 typed verbs across 6 namespaces, plus `mu`, `mu state`,
`mu sql`, `mu doctor`.** Every read verb supports `--json`.

| Area                     | Verbs                                                                 |
| ------------------------ | --------------------------------------------------------------------- |
| **workstream** (3)       | `init`, `list`, `destroy`                                             |
| **agent** (8)            | `spawn` (with `--workspace*`), `send`, `read`, `show`, `list`, `close`, `free`, `attach` |
| **task** (22)            | `add` (id auto-derived from title), `list`, `show`, `notes`, `note`, `tree`, `next`, `ready`, `blocked`, `goals`, `owned-by`, `search`, `claim` (`--evidence`), `release` (`--evidence`), `close` (`--evidence`), `open` (`--evidence`), `block`, `unblock`, `update`, `delete`, `reparent` |
| **workspace** (4)        | `create`, `list`, `free` (`--commit`), `path`                         |
| **log** (1, overloaded)  | write, read, `--tail` subscription; auto-emits on every state change  |
| **approve** (5)          | `add`, `list`, `grant`, `deny`, `wait` (exit 0/4/5 = granted/denied/timeout) |
| **self-id** (3)          | `whoami`, `my-tasks`, `my-next` (resolves agent via `$TMUX_PANE`)     |
| **utilities** (4)        | bare `mu` (human dashboard), `mu state` (canonical state card), `sql`, `doctor` |

### Pillars (what makes mu mu)

- **One workstream = one tmux session.** All agents live as
  panes/windows inside it. Detach and reattach freely; the crew
  survives.
- **The CLI is the product.** Anything mu can do, you can do from
  a shell. No daemon, no config file, no extension required.
- **One DB is canonical.** SQLite WAL at `~/.local/state/mu/mu.db`.
  Multiple processes share it safely.
- **Reality wins reconciliation.** Every list-style verb queries
  tmux, prunes ghost agents, and surfaces orphan panes.
- **Agents are dumb workers; the task DAG is the brain.** Tasks
  have mandatory `impact` and `effort_days`; edges are `blocks`
  relationships; the parallel-tracks union-find with diamond-merge
  guarantees two agents never collide on a shared dependency.
- **Per-agent VCS workspaces.** `--workspace` auto-creates
  isolated jj workspaces / sl shares / git worktrees / `cp -a`
  snapshots; auto-freed on `mu agent close`.
- **Async coordination via `mu log`.** Every state-changing verb
  auto-emits a `kind='event'` row; subscribers `mu log --tail`
  instead of polling.
- **Human-in-the-loop approvals.** `mu approve add/wait` lets
  agent scripts gate destructive actions on operator sign-off.
- **Audit trail with grounding.** `--evidence` on lifecycle verbs
  records what the caller observed. First inch of "observed vs
  claimed state" discipline.
- **Crash recovery.** Reconciliation prunes ghost agents; the
  reaper reverts their IN_PROGRESS tasks to OPEN with an
  explanatory note; no manual cleanup.
- **Get out of the model's way.** Mu owns no model selection,
  effort tier, prompt engineering, or tool routing. Pi already
  has those abstractions; mu doesn't recreate them.

### Schema (8 tables)

- `workstreams` — top-level partition; one tmux session each.
- `agents` — pane registry; identity is `(workstream, name)`.
- `tasks` — the work graph nodes. Mandatory `impact` (1–100) +
  `effort_days`.
- `task_edges` — `blocks` relationships; cycles rejected at write
  time.
- `task_notes` — append-only per-task notes. FILES / DECISION /
  VERIFIED conventions documented in SKILL.md.
- `vcs_workspaces` — per-agent isolated working copies.
- `agent_logs` — append-only timeline. Manual broadcasts, auto
  state-change events, and external `--as` writes share one table
  via the `kind` column. `seq` is AUTOINCREMENT for tail cursors.
- `approvals` — human-in-the-loop gate state. FK CASCADE on
  workstreams; CHECK constraint on status enum.

Built-in views: `ready`, `blocked`, `goals` (in `tasks` schema).

### Environment variables

| Variable                     | Purpose                                                |
|------------------------------|--------------------------------------------------------|
| `MU_DB_PATH`                 | Override the SQLite file path                          |
| `MU_STATE_DIR`               | Override the state directory (`<dir>/mu.db`)           |
| `XDG_STATE_HOME`             | Standard XDG fallback                                  |
| `MU_SESSION`                 | Override active workstream name                        |
| `MU_<UPPER_CLI>_COMMAND`     | Pick the executable for `--cli <cli>` (e.g. `MU_PI_COMMAND="pi-alt --some-flag"`) |
| `MU_SEND_DELAY_MS`           | Bracketed-paste → Enter delay (default 500)            |
| `MU_SPAWN_LIVENESS_MS`       | Spawn liveness window (default 1500; 0 disables)      |
| `MU_TMUX_SOCKET`             | Override tmux socket (`-L <name>`); default uses `$TMUX` |

### Known limits in 0.1.0

- **Pi-only status detection.** Other CLIs (claude, codex) can be
  spawned via `--cli <name>` + `MU_<UPPER_CLI>_COMMAND` but always
  show `needs_input`. See [docs/ROADMAP.md](../../docs/ROADMAP.md).
- **Polling-based subscriptions.** `mu log --tail` and `mu approve
  wait` poll SQLite once per second. Real subscription mechanisms
  (SQLite update hooks, fs.watch on the WAL) are deferred.
- **No `mu undo`.** Snapshots / undo are deferred. `mu workstream
  destroy --yes` is irreversible; recovery is restoring `mu.db`
  from a backup.
- **No capability enforcement.** The `role` field on agents
  (`full-access` / `read-only`) is stored but not enforced. The
  flag is operator discipline, not a guard.
- **Local-only state.** No cross-machine sync. Layer something
  like syncthing on top if you want it.
- **Pi extension not yet shipped.** Mu is CLI-only in 0.1.0; a
  pi extension is on the roadmap.

### Inspirations

- **[pi-subagents](https://github.com/nicobailon/pi-subagents)** by
  Nico Bailon — the pi-native delegation pattern. mu reuses its
  frontmatter format and borrows operational machinery (worktrees,
  mutation guards, model fallback, doctor).
- A prior internal multi-agent runtime (Rust) — the "tmux as
  universal substrate + per-CLI status detection + reality-wins
  reconciliation + parallel-track union-find with diamond-merge"
  patterns originated there. Mu adopts the patterns; not the
  deps.
- An internal critique of that prior runtime — sharpened the case
  for the anti-feature pledges (no DSL, no plugins, no daemon, no
  config file, no web UI) and motivated several of the verbs in
  this release (state cards, approvals, observed-vs-claimed
  evidence on lifecycle verbs).
