@mu-crew/mu 3.13.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/AGENTS.md +73 -35
  2. package/README.md +71 -57
  3. package/dist/chunk-5HECDBK3.js +4482 -0
  4. package/dist/chunk-5HECDBK3.js.map +1 -0
  5. package/dist/chunk-AW2NONYU.js +301 -0
  6. package/dist/chunk-AW2NONYU.js.map +1 -0
  7. package/dist/chunk-GQNHIPJN.js +4060 -0
  8. package/dist/chunk-GQNHIPJN.js.map +1 -0
  9. package/dist/{chunk-3BARPJLY.js → chunk-PZ5GOCWH.js} +16 -2
  10. package/dist/chunk-PZ5GOCWH.js.map +1 -0
  11. package/dist/chunk-S424D5EP.js +13766 -0
  12. package/dist/chunk-S424D5EP.js.map +1 -0
  13. package/dist/{cli-CKOMHBXV.js → cli-GB2CAVVW.js} +6776 -4350
  14. package/dist/cli-GB2CAVVW.js.map +1 -0
  15. package/dist/cli.js +2 -3
  16. package/dist/cli.js.map +1 -1
  17. package/dist/errors-KHOYKD7D.js +65 -0
  18. package/dist/extension/mu-pi.js +1617 -836
  19. package/dist/extension/mu-pi.js.map +1 -1
  20. package/dist/index.d.ts +4015 -2561
  21. package/dist/index.js +614 -297
  22. package/dist/index.js.map +1 -1
  23. package/dist/tui-ALPHYTA2.js +1773 -0
  24. package/dist/tui-ALPHYTA2.js.map +1 -0
  25. package/docs/ARCHITECTURE.md +117 -66
  26. package/docs/VOCABULARY.md +112 -49
  27. package/docs/guide/README.md +3 -3
  28. package/docs/guide/backends.md +8 -4
  29. package/docs/guide/cleanup.md +61 -33
  30. package/docs/guide/delegate.md +141 -66
  31. package/docs/guide/dispatch.md +148 -53
  32. package/docs/guide/getting-started.md +37 -44
  33. package/docs/guide/other-agent-clis.md +102 -0
  34. package/docs/guide/recovery.md +31 -50
  35. package/docs/guide/remote.md +115 -45
  36. package/docs/guide/sql.md +104 -24
  37. package/docs/guide/stop-a-worker.md +30 -37
  38. package/docs/guide/sync.md +11 -3
  39. package/docs/guide/upgrade.md +93 -43
  40. package/docs/reference/env.md +23 -32
  41. package/docs/reference/naming.md +12 -7
  42. package/docs/reference/surface-contract.md +153 -0
  43. package/package.json +33 -11
  44. package/prompts/mu-debug.md +3 -1
  45. package/prompts/mu-refute.md +2 -1
  46. package/prompts/mu-research.md +3 -1
  47. package/prompts/mu-review.md +2 -1
  48. package/prompts/mu-rules-audit.md +5 -4
  49. package/prompts/mu-sweep.md +2 -1
  50. package/prompts/mu-tournament.md +4 -2
  51. package/prompts/mu-until.md +3 -1
  52. package/prompts/ultrathink.md +1 -1
  53. package/skills/mu/SKILL.md +60 -291
  54. package/skills/mu/recipes/adversarial-review.md +12 -103
  55. package/skills/mu/recipes/brief.md +14 -61
  56. package/skills/mu/recipes/course-correct.md +23 -0
  57. package/skills/mu/recipes/findings.md +17 -111
  58. package/skills/mu/recipes/models.md +17 -92
  59. package/skills/mu/recipes/move.md +14 -0
  60. package/skills/mu/recipes/orchestrator-loop.md +20 -119
  61. package/skills/mu/recipes/recovery.md +8 -29
  62. package/skills/mu/recipes/remote-workers.md +16 -318
  63. package/skills/mu/recipes/tasks-or-calls.md +15 -78
  64. package/skills/mu/recipes/test-health.md +19 -77
  65. package/skills/mu/recipes/worker.md +17 -24
  66. package/dist/chunk-45KQLMEL.js +0 -2908
  67. package/dist/chunk-45KQLMEL.js.map +0 -1
  68. package/dist/chunk-G434JKV2.js +0 -641
  69. package/dist/chunk-G434JKV2.js.map +0 -1
  70. package/dist/chunk-KUGUFBWH.js +0 -12527
  71. package/dist/chunk-KUGUFBWH.js.map +0 -1
  72. package/dist/chunk-QITLNZEM.js +0 -87
  73. package/dist/chunk-QITLNZEM.js.map +0 -1
  74. package/dist/chunk-VIEYQMQA.js +0 -393
  75. package/dist/chunk-VIEYQMQA.js.map +0 -1
  76. package/dist/chunk-WI6AWCMK.js +0 -19
  77. package/dist/chunk-WI6AWCMK.js.map +0 -1
  78. package/dist/cli-CKOMHBXV.js.map +0 -1
  79. package/dist/devtools-LS4ZEPWM.js +0 -3772
  80. package/dist/devtools-LS4ZEPWM.js.map +0 -1
  81. package/dist/tui-LFJ5P4Y2.js +0 -40956
  82. package/dist/tui-LFJ5P4Y2.js.map +0 -1
  83. package/dist/tui-sync-worker.js +0 -17
  84. package/dist/tui-sync-worker.js.map +0 -1
  85. package/dist/yank-XDOONQSL.js +0 -13
  86. package/dist/yank-XDOONQSL.js.map +0 -1
  87. package/docs/ROADMAP.md +0 -123
  88. package/docs/VISION.md +0 -298
  89. package/docs/architecture/control-socket.md +0 -142
  90. package/docs/architecture/dag.md +0 -117
  91. package/docs/architecture/mux.md +0 -167
  92. package/docs/architecture/ops-log.md +0 -160
  93. package/docs/architecture/sdk.md +0 -187
  94. package/docs/architecture/sync.md +0 -159
  95. package/docs/architecture/tui.md +0 -165
  96. package/docs/guide/tui.md +0 -98
  97. package/docs/history/CHANGELOG-pre-3.md +0 -4866
  98. package/docs/img/tui-dashboard.png +0 -0
  99. package/docs/specs/2026-09-28-agent-state-from-murmur.md +0 -333
  100. package/docs/specs/2026-09-29-task-substates.md +0 -247
  101. package/docs/specs/2026-10-03-keep-driving-nudge.md +0 -163
  102. package/docs/specs/2026-10-21-fast-and-slow-gates.md +0 -229
  103. package/skills/mu/recipes/backlog-triage.md +0 -45
  104. package/skills/mu/recipes/codemode-driver.md +0 -118
  105. package/skills/mu/recipes/deep-research.md +0 -69
  106. package/skills/mu/recipes/drift-audit.md +0 -75
  107. package/skills/mu/recipes/fan-out.md +0 -42
  108. package/skills/mu/recipes/hypothesis-panel.md +0 -48
  109. package/skills/mu/recipes/long-run.md +0 -32
  110. package/skills/mu/recipes/loop-until-done.md +0 -45
  111. package/skills/mu/recipes/plan.md +0 -67
  112. package/skills/mu/recipes/refute.md +0 -55
  113. package/skills/mu/recipes/review-panel.md +0 -65
  114. package/skills/mu/recipes/rules-audit.md +0 -89
  115. package/skills/mu/recipes/tournament.md +0 -44
  116. package/skills/mu/recipes/ultrathink.md +0 -86
  117. package/skills/mu/recipes/watcher.md +0 -28
  118. package/skills/mu/recipes/waves.md +0 -21
  119. /package/dist/{chunk-3BARPJLY.js.map → errors-KHOYKD7D.js.map} +0 -0
Binary file
@@ -1,333 +0,0 @@
1
- # Agent state from murmur (mu 2.0.0, murmur 1.0.0)
2
-
3
- Status: shipped in 2.0.0; partly superseded in 3.2.0 by the control socket ([architecture/control-socket.md](../architecture/control-socket.md)). Approved 2026-09-28. Task `crew/mu_status_from_murmur`.
4
-
5
- ## Problem
6
-
7
- mu works out what an agent is doing by reading the last 20 lines of its
8
- pane (`src/detect.ts`), and stores the answer in `agents.status`. This fails
9
- in three ways:
10
-
11
- - **Remote workers.** The local pane holds an ssh client that renders a
12
- nested tmux. `skills/mu/REMOTE_WORKERS.md` records wrong answers in both
13
- directions in one session: a stall fired at 300 s on a worker that was
14
- mid-turn, and `mu agent list` showed `needs_input` for a worker that was
15
- working.
16
- - **Wrappers.** Each pi wrapper with different chrome has needed its own
17
- patch (the Braille-spinner fallback).
18
- - **Stale copies.** The stored status is only as recent as the last
19
- reconcile, and `mu agent show` needed a fix to re-detect before it printed.
20
-
21
- murmur's pi extension reports state from inside the agent on its own host,
22
- and it publishes the state on each pane (`@murmur_pane_state`) and across
23
- machines (`murmur status --json`). mu already exports the variables that
24
- murmur uses to recognise mu's workers (`MU_MANAGED_AGENT`, `MU_AGENT_NAME`,
25
- `MU_WORKSTREAM`).
26
-
27
- ## Decision
28
-
29
- **murmur is the answer for agent state.** mu owns the work: the DAG, task
30
- owners, workspaces and panes. murmur owns what an agent is doing. mu does
31
- not read pane text to decide agent state, and it does not store agent state.
32
-
33
- Sources of runtime state, in order:
34
-
35
- 1. herdr's `paneStatus()` on the herdr backend. This is herdr's own report,
36
- not scraping.
37
- 2. On tmux, murmur. The local read is the pane's `@murmur_pane_state` and
38
- `@murmur_pane_since`. The remote read is `murmur status --json`, matched
39
- on `agent_name` and `workstream`.
40
- 3. Otherwise `unknown`, and mu says why.
41
-
42
- This follows ZEN rules 2 (report state, don't scrape it), 3 (absence means
43
- absence) and 5 (standalone tools that compose). mu's core needs none of this
44
- to work: the DAG, `claim`/`close`, `task wait`, workspaces, spawn, send and
45
- read.
46
-
47
- ## Scope
48
-
49
- In:
50
-
51
- - A state resolver module that replaces every state read from
52
- `detectPiStatus`:
53
- - reconcile, including `mu agent list` and `mu state`
54
- - `mu agent show`
55
- - `mu agent wait`
56
- - stall detection in `mu task wait`
57
- - spawn readiness
58
- - `computeAgentIdle`
59
- - the TUI cards and popups
60
- - Stop writing `agents.status` after the initial insert, and stop reading it
61
- everywhere.
62
- - A new `--json` shape for agents: `state` and `source`, with no `status`.
63
- - `mu doctor` checks for murmur and its pi extension.
64
- - Updates to the README, the skill, `REMOTE_WORKERS.md`, ARCHITECTURE,
65
- DECISIONS and ROADMAP.
66
- - murmur 1.0.0: a contract section in its ARCHITECTURE, and the release.
67
-
68
- Out:
69
-
70
- - The two input-timing checks in `send`. `awaitPaneQuiescence` waits out
71
- pi's re-init modal, which eats the Enter. `isTextStranded` detects a
72
- prompt that was typed but never submitted. Both are about typing into a
73
- terminal, not about agent state, and murmur cannot see either. They keep a
74
- private helper for spinner and work-marker text. It is used only by
75
- `send`, returns no status, and is not exported.
76
- - `detectSpawnStartupError` in `src/agents/spawn.ts`. It scans a new pane
77
- for provider and auth errors (a dud spawn), which is a spawn failure, not
78
- agent state. It stays for the same reason as the `send` checks.
79
- - Dropping the `agents.status` column. mu has no migration path: v10 is both
80
- the minimum and the current schema. The drop waits for the first change
81
- that needs a v11 anyway, and ROADMAP records it.
82
- - Reading murmur's SQLite files. Reading them would couple mu to murmur's
83
- private schema.
84
- - Any feature added to murmur for mu. murmur only documents what it already
85
- publishes.
86
-
87
- ## Design
88
-
89
- ### Caller's view
90
-
91
- ```ts
92
- const states = await readAgentStates(db, agents); // one tmux read, ≤1 murmur call
93
- const s = states.get(agent); // { state: "busy", source: "murmur", since: 1790581553851 }
94
- if (s.state === "unknown") hint(s.reason); // "murmur not installed" | ...
95
- ```
96
-
97
- `mu agent list --json`:
98
-
99
- ```json
100
- { "name": "worker-1", "pane": "%4", "state": "busy", "source": "murmur",
101
- "since": "2026-09-28T07:45:53Z" }
102
- { "name": "worker-9", "pane": "%9", "state": "unknown", "source": "none",
103
- "reason": "murmur not installed" }
104
- ```
105
-
106
- ### State resolver (`src/agent-state.ts`, new)
107
-
108
- ```ts
109
- type RuntimeState = "busy" | "needs_input" | "needs_permission" | "unknown";
110
- type StateSource = "murmur" | "herdr" | "none";
111
- interface StateReading {
112
- state: RuntimeState;
113
- source: StateSource;
114
- since: number | null; // ms; when the state last changed, from the source
115
- reason?: string; // set when state is unknown
116
- }
117
- function readAgentStates(db: Db, agents: AgentRow[]): Promise<Map<AgentRow, StateReading>>;
118
- ```
119
-
120
- - It takes a batch of agents, so a whole display costs one read.
121
- - **herdr backend:** calls `paneStatus()` per pane, as today.
122
- - **tmux, local:**
123
- - One `list-panes -a -F '#{pane_id}\t#{@murmur_pane_state}\t#{@murmur_pane_since}'`
124
- call.
125
- - A pane with a value is read from murmur. A pane with no value is a
126
- candidate for the remote read.
127
- - murmur publishes `@murmur_pane_state` only for a pane its extension has
128
- claimed (fixed in murmur `3c8c1a7`).
129
- - **tmux, remote:** the agents with no local value.
130
- - One `murmur status --json` call, cached per mu process for
131
- `MURMUR_CACHE_MS` (10 s), so the 1 s poll in `mu agent wait` does not
132
- start Node every tick. murmur limits its own ssh collect to one per
133
- 30 s ± 10 s.
134
- - A row matches when `agent_name` and `workstream` are equal and `local`
135
- is false.
136
- - Zero matches gives `unknown`, with reason "murmur has no row". More
137
- than one match gives `unknown`, with reason "ambiguous: N hosts". A
138
- peer that murmur reports as stale gives `unknown`, with reason "remote
139
- snapshot stale".
140
- - **`since`:**
141
- - For a local pane, it is `@murmur_pane_since`: the time the pane entered
142
- its current state (see murmur 1.0.0 below).
143
- - For a remote row, it is the row's `updated_at`. That is an
144
- approximation, because it also moves on runtime updates. It is used
145
- only for remote idle and stall timing, where scraping had no answer at
146
- all.
147
- - Only the remote path starts `murmur`. A machine with no remote workers
148
- never runs it.
149
- - **When murmur is missing:** no `murmur` on PATH, or the pi extension not
150
- linked (`~/.pi/agent/extensions/murmur.ts` missing).
151
- - Every tmux agent gets `unknown`, with reason "murmur not installed" or
152
- "murmur pi extension not linked".
153
- - The resolver checks this once per process.
154
-
155
- ### Mapping
156
-
157
- | murmur | mu |
158
- | --- | --- |
159
- | `working` | `busy` |
160
- | `idle` | `needs_input` |
161
- | `done` | `needs_input` |
162
- | `blocked` | `needs_permission` |
163
- | `crashed` | `needs_input` |
164
-
165
- - `done` and `idle` both mean "waiting for a prompt".
166
- - `blocked` means waiting on a human answer, which is what a permission
167
- dialog means.
168
- - For `crashed`, pi is gone but the pane may still be alive. The mapping
169
- makes waits and stall detection fire. Pane death stays with the reaper.
170
-
171
- herdr's mapping does not change: `working` gives `busy`; `blocked` gives
172
- `needs_permission`; `idle`, `done` and `unknown` give `needs_input`.
173
-
174
- ### Consumers
175
-
176
- - **reconcile.** It keeps pruning dead panes and refreshing titles. It
177
- stops detecting and writing status. `report-only` mode stays read-only.
178
- - **`mu agent list`, `mu state`, `mu agent show`, TUI.** These show
179
- `state` from the resolver. An `unknown` state shows `?` and a one-line
180
- hint, printed once per invocation: "agent state needs murmur (see mu
181
- doctor)".
182
- - **`mu agent wait`.** It fires on busy → not busy, as today. `unknown`
183
- never fires. If every watched agent is `unknown` at the first tick, it
184
- prints the reason once and keeps waiting until `--timeout`.
185
- - **`mu task wait` stall detection.** The stall timer measures time in
186
- `needs_input` using `since`. `unknown` never counts as a stall. The rest
187
- of `task wait` is unchanged: it polls the DB, which is exact.
188
- - **Spawn readiness.**
189
- - With murmur present, spawn waits until the pane's `@murmur_pane_state`
190
- is set, within the existing budget. murmur publishes it right after the
191
- extension claims the pane.
192
- - Without murmur, spawn skips the wait and keeps the "pane died on spawn"
193
- check. The first `send` waits for pi through its input-timing checks.
194
- - **`computeAgentIdle`.** An agent is idle when it is `needs_input` and
195
- `since` is older than the threshold, taken from the resolver. It was
196
- `agents.updated_at`.
197
- - **`mu doctor`.** It reports murmur on PATH, the version (≥ 1.0.0), the pi
198
- extension linked, and whether the identity exists. A failure is a
199
- warning, not an error.
200
-
201
- ### `agents.status` in 2.0
202
-
203
- - It is deprecated in place.
204
- - Inserts write the fixed value `'spawning'` to satisfy `NOT NULL` and the
205
- CHECK. Nothing updates it and nothing reads it.
206
- - Mid-spawn is already marked by the `pending:` pane-id placeholder.
207
- - `free` (from `adopt`) and the "sticky until activity" overwrite rule
208
- disappear, because nothing overwrites anything.
209
- - `db.ts` marks the column deprecated.
210
- - The SDK drops `AgentStatus`, `shouldOverwriteAgentStatus`,
211
- `updateAgentStatus` and the `detectPiStatus` export. It exports
212
- `RuntimeState`, `StateReading` and `readAgentStates`.
213
-
214
- ### murmur 1.0.0
215
-
216
- 1.0.0 means the interfaces mu reads are stable. A breaking change to them
217
- needs a major version.
218
-
219
- - **New: `@murmur_pane_since`.** This is the time (ms since the epoch)
220
- the pane entered its current `@murmur_pane_state`.
221
- - It is set in the same tmux command as the state, and only when the
222
- state changes: `if -F '#{!=:#{@murmur_pane_state},<new>}' 'set -p
223
- @murmur_pane_since <now>' ; set -p @murmur_pane_state <new>`.
224
- - The compare-and-set runs inside tmux, so every writer (the extension,
225
- the collector, `clear`) agrees, and a repeated write of the same state
226
- leaves the value alone. This was checked on a scratch server:
227
- `working → working → idle` kept the first time and then moved.
228
- - It is unset together with the state. It needs no store schema change.
229
- - **The contract**, listed in the "Contract" section of its ARCHITECTURE:
230
- - `@murmur_pane_state` and its five values, and `@murmur_pane_since`
231
- - the `status --json` fields `panes[].{pane, local, agent_name,
232
- workstream, driver, activity, attention, freshness, updated_at}`
233
- - the `MU_*` variables murmur reads
234
- - **Release:** it ships main (the idle-label fix) plus
235
- `@murmur_pane_since`, with a CHANGELOG entry that says what 1.0 means.
236
- There is no 0.6.2.
237
- - **Order:** murmur 1.0.0 first, then mu 2.0.0.
238
-
239
- ## Cost
240
-
241
- Measured on the dev machine with 5 local agents:
242
-
243
- | Read | Before (scrape) | After |
244
- | --- | --- | --- |
245
- | local state, 5 agents | 5 × `capture-pane`, 13 ms | 1 × `list-panes`, 2 ms |
246
- | `agent wait`, per 1 s tick | 5 captures, plus detection in JS | 1 × `list-panes` |
247
- | local idle and stall timing | a DB timestamp that only moved on reconcile | `@murmur_pane_since`, read in the same call |
248
- | remote agents | captures that gave wrong answers | `murmur status --json`, 90 ms, cached for 10 s |
249
-
250
- `mu agent list` and `mu state` take about 0.8 s, and most of that is mu's
251
- startup (`mu --version` takes 0.30 s). This change removes work from both
252
- commands. No local path starts a second Node process.
253
-
254
- ## Testing
255
-
256
- - **Unit tests** for the resolver, using a fake tmux and a fake `murmur`
257
- runner:
258
- - the local value
259
- - a remote match
260
- - no match, an ambiguous match and a stale peer
261
- - murmur missing and the extension not linked
262
- - the herdr path
263
- - cache reuse within `MURMUR_CACHE_MS`
264
- - no `murmur` process started when every agent has a local option
265
- - **Consumer tests:**
266
- - `agent wait` never fires on `unknown`
267
- - stall detection uses `since` and ignores `unknown`
268
- - spawn readiness with murmur and without it
269
- - `computeAgentIdle`
270
- - **Removal checks:**
271
- - No state path imports the send helper. A grep test in the style of
272
- murmur's cheap-tick check enforces this.
273
- - `src/detect.ts` exports no status.
274
- - **Integration:**
275
- - With real tmux and a fake `@murmur_pane_state` set with `tmux set -p`:
276
- `mu agent list --json` shows `source: murmur`.
277
- - Without the option, it shows `unknown` with its reason.
278
- - **Live check** on this machine: spawn a worker, send it work, and check
279
- `mu agent list` for busy and then needs_input, plus `mu agent wait` and
280
- `mu task wait --on-stall exit`.
281
- - **Gates:**
282
- - mu: `npm run typecheck && npm run lint && npm run test`
283
- - murmur: `npm run check`
284
-
285
- ## Implementation checklist
286
-
287
- 1. murmur: publish `@murmur_pane_since` with a compare-and-set in
288
- `setPaneState`, with tests (set on change, kept on a repeat, unset with
289
- the state). Add the Contract section to ARCHITECTURE and the 1.0.0
290
- CHANGELOG entry. Live check, then release 1.0.0 through CI.
291
- 2. mu: add `src/agent-state.ts` (the resolver, mapping and cache) and its
292
- unit tests.
293
- 3. mu: switch reconcile, `agent list/show`, `mu state` and the TUI to the
294
- resolver. Stop writing status.
295
- 4. mu: switch `agent wait`, `task wait` stall detection and
296
- `computeAgentIdle` to the resolver.
297
- 5. mu: switch spawn readiness to the murmur option, or skip it when murmur
298
- is missing.
299
- 6. mu: move the send input-timing checks to a private helper, remove the
300
- state exports from `src/detect.ts`, and add the grep test.
301
- 7. mu: add the `--json` shape (`state`, `source`, `since`, `reason`), and
302
- make the SDK export changes.
303
- 8. mu: add the `mu doctor` murmur check.
304
- 9. mu docs:
305
- - README: "agent state needs murmur"
306
- - the skill and `REMOTE_WORKERS.md`: drop the "ask murmur by hand" step
307
- - ARCHITECTURE: rewrite § "Status detection"
308
- - DECISIONS: this decision and what it rejects
309
- - ROADMAP: the column drop, and update § murmur
310
- - VOCABULARY
311
- 10. mu: CHANGELOG 2.0.0 (breaking: `status` is gone, murmur is needed for
312
- state); do the live check, then release through CI.
313
-
314
- ## Rejected
315
-
316
- - **Scraping as a fallback.** It keeps two answers, one for each source. It
317
- also keeps the wrapper-patch cycle, and the misreads return whenever
318
- murmur is absent. `unknown` is honest.
319
- - **Scraping only for remote workers.** Remote is exactly where scraping is
320
- wrong.
321
- - **Storing runtime state in `mu.db`.** The copy goes stale. The data
322
- belongs in murmur.
323
- - **Dropping the column now.** It would make this release mu's first
324
- migration, and the only gain would be deleting an unused column.
325
- - **Reading murmur's `state.db`.** It couples mu to a private schema.
326
- - **Making murmur a hard dependency.** It pulls murmur's native
327
- `better-sqlite3` build into installs that want only the task graph.
328
- - **Removing the send checks.** They fixed measured failures, such as a
329
- prompt lost to pi's session-naming modal, and murmur cannot replace them.
330
-
331
- ## Open questions
332
-
333
- None.
@@ -1,247 +0,0 @@
1
- # Task substates: classification without stranded dependents
2
-
3
- Status: shipped in 3.0.0 (schema v11); `CLOSED/rejected` added in 3.1.0; `OPEN/triage` added in 3.4.0 (per D10, one seeded row). Implemented 2026-09-29 (approved 2026-09-29; see [Deviations during implementation](#deviations-during-implementation))
4
- Date: 2026-09-29
5
- Supersedes the notes-only convention from `9e3c47b` ("tasks: reduce lifecycle to three states").
6
-
7
- ## Problem
8
-
9
- `9e3c47b` removed `REJECTED` and `DEFERRED`. That fixed a real problem and created a new one.
10
-
11
- The real problem: each old status mixed two independent facts into one value.
12
-
13
- | old status | lifecycle phase | why | effect on dependents |
14
- |---|---|---|---|
15
- | `REJECTED` | ended | won't do | still blocked |
16
- | `DEFERRED` | not active | parked | still blocked |
17
-
18
- Neither satisfied a `blocks` edge, so rejecting or deferring a task silently stranded its dependents. The guard against that refused the transition unless `--cascade` was given. `--cascade` then stamped the whole subtree, which was too aggressive (`bug_cascade_reject_too_aggressive`).
19
-
20
- The new problem: with only `OPEN | IN_PROGRESS | CLOSED`, mu cannot tell "done" from "abandoned", and cannot keep a task out of the scheduler without closing it. The "why" lives only in free-text notes, which no view can filter on.
21
-
22
- ## Approach
23
-
24
- Split the two facts into two columns.
25
-
26
- - **`status`** is the lifecycle phase. It alone decides edge satisfaction: `CLOSED` satisfies a `blocks` edge, anything else does not. Unchanged.
27
- - **`substate`** qualifies the status. It never touches edge semantics.
28
- - **Blocked / ready** stay derived from edges in views. They are never stored.
29
-
30
- Rule: **store intent, derive graph facts.** Only what a person or agent decides is stored.
31
-
32
- | status | substates | default |
33
- |---|---|---|
34
- | `OPEN` | `todo`, `parked` | `todo` |
35
- | `IN_PROGRESS` | `active` | `active` |
36
- | `CLOSED` | `done`, `wontfix`, `duplicate`, `superseded` | `done` |
37
-
38
- `substate` is never null. Every status has one named default, so an absent value never carries meaning.
39
-
40
- Consequences:
41
-
42
- - `CLOSED/wontfix` (and any other close) unblocks dependents. The close reports which dependents it unblocked. Nothing gets stranded, so no cascade is needed.
43
- - `OPEN/parked` leaves the scheduler: it is excluded from `ready` and `next`, and `claim` refuses it without `--force`. It stays in `goals`, because tracks are built from goals and a parked goal must not make its subtree vanish; a track whose non-closed tasks are all parked is marked parked instead. Its dependents stay ordinarily blocked, and surfaces say "blocked by parked X".
44
- - A closed blocker now means "stop waiting", not "the prerequisite work exists". `mu task wait` and `close --if-ready` fire on any `CLOSED/*` blocker. This is intended, and it is why every non-`done` close prints the dependents it unblocked.
45
-
46
- ## Key decisions
47
-
48
- | # | Decision | Why |
49
- |---|---|---|
50
- | D1 | Two columns (`status` + `substate`), not more statuses. | Keeps a single rule for edge semantics; classification can grow without touching the DAG. |
51
- | D2 | Parked is an `OPEN` substate, not a status. | The dependency is still real: B still waits on A. Only scheduling changes. |
52
- | D3 | Non-`done` closes unblock dependents. | Blocking caused the stranding problem last time. The report keeps the effect visible. |
53
- | D4 | `substate` is never null; each status has a named default. | An absent value must not carry meaning. |
54
- | D5 | Valid pairs are enforced by a lookup table plus a composite foreign key `DEFERRABLE INITIALLY DEFERRED`. | Enforces the rule across both columns; tolerates sync's one-field-at-a-time writes, because the check runs at commit; adding a value is an `INSERT`, not a table rebuild. Verified in SQLite (see Appendix A). |
55
- | D6 | Drop the `status` CHECK. The lookup table is the single list. | Two lists would drift. |
56
- | D7 | Every lifecycle op writes `status` and `substate` together. | Both fields carry the same HLC, so per-field last-write-wins cannot split a pair from our own ops. |
57
- | D8 | Apply resolves the pair before writing: a status-only op, or an unknown substate from a newer peer, falls back to the status default. The original payload stays in `ops`. | The foreign key fails the whole commit. Version skew must not break sync; the foreign key is there to catch our own bugs. |
58
- | D9 | Non-`done` close and `park` require `--why`, stored as an ordinary note in the same transaction. | Keeps the rationale, the one good part of the notes-only convention. |
59
- | D10 | Start with `parked` as the only non-default `OPEN` substate. No `--until`. | YAGNI. mu has no daemon to run the automatic un-park. Adding `triage` later is one seeded row. |
60
- | D11 | One shared legacy mapping: `REJECTED → CLOSED/rejected` (3.1.0; 3.0.0 used `wontfix`), `DEFERRED → OPEN/parked`. | Replaces `normalizeTaskStatus` (currently folds both onto `OPEN`). Apply, rebuild, undo and migration all use it, so recovery happens on every path. |
61
- | D12 | Migrate v10 → v11 through `scripts/migrate.ts`, not in `openDb`. | Keeps the "production never migrates in place" rule. SQLite cannot add a foreign key to an existing table, so a new target is required anyway. |
62
-
63
- ## Schema (v11)
64
-
65
- ```sql
66
- CREATE TABLE IF NOT EXISTS task_substates (
67
- status TEXT NOT NULL,
68
- substate TEXT NOT NULL,
69
- is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)),
70
- PRIMARY KEY (status, substate)
71
- );
72
- CREATE UNIQUE INDEX IF NOT EXISTS idx_task_substates_one_default
73
- ON task_substates (status) WHERE is_default = 1;
74
-
75
- -- tasks gains:
76
- substate TEXT NOT NULL,
77
- FOREIGN KEY (status, substate) REFERENCES task_substates (status, substate)
78
- DEFERRABLE INITIALLY DEFERRED
79
- -- and loses: CHECK (status IN ('OPEN', 'IN_PROGRESS', 'CLOSED'))
80
- ```
81
-
82
- - `applySchema` seeds the pairs with `INSERT OR IGNORE`, before any task write.
83
- - `CURRENT_SCHEMA_VERSION = MIN_ACCEPTED_SCHEMA_VERSION = 11`.
84
- - `EXPECTED_TABLES` gains `task_substates` (11 entries).
85
- - Views:
86
- - `ready`: add `AND t.substate <> 'parked'`.
87
- - `blocked`: unchanged.
88
- - `goals`: unchanged (see Consequences: tracks mark parked instead).
89
- - `SchemaTooOldError.errorNextSteps` names `scripts/migrate.ts`.
90
- - New `SchemaTooNewError` (exit 4): `openDb` refuses a DB newer than `CURRENT_SCHEMA_VERSION`. v10 has no such guard, so a v10 binary opening a v11 DB fails only when it writes (NOT NULL / FK). The guard protects v12 and later; for v11 the operator must switch every binary before the swap (see Restoring destroyed workstreams).
91
- - `substate` has no column DEFAULT, so a writer that doesn't know about it fails loudly instead of guessing.
92
-
93
- `src/tasks/status.ts` mirrors the table:
94
-
95
- ```ts
96
- export const TASK_SUBSTATES = {
97
- OPEN: ["todo", "parked"],
98
- IN_PROGRESS: ["active"],
99
- CLOSED: ["done", "wontfix", "duplicate", "superseded"],
100
- } as const satisfies Record<TaskStatus, readonly string[]>;
101
- export const DEFAULT_SUBSTATE = { OPEN: "todo", IN_PROGRESS: "active", CLOSED: "done" } as const;
102
- export function resolvePair(status: string, substate: unknown): { status: TaskStatus; substate: TaskSubstate };
103
- export function mapLegacyStatus(value: string): { status: TaskStatus; substate: TaskSubstate } | null;
104
- ```
105
-
106
- The seed rows are generated from `TASK_SUBSTATES`. A test asserts that the seeded table and the map are identical.
107
-
108
- ## Sync and ops
109
-
110
- - `capture.ts` `CAPTURED_COLUMNS.tasks` and `apply.ts` `APPLIABLE_COLUMNS.tasks` gain `substate`.
111
- - Every lifecycle verb writes `{status, substate}` in one op payload.
112
- - `applyTaskPut` projects the payload in this order:
113
- 1. Map legacy status values through `mapLegacyStatus`. This can set both fields.
114
- 2. Apply per-field LWW as today.
115
- 3. Read the resulting row pair. If it is invalid, write the default substate for the status. The repair is deterministic, so every peer converges on the same row without an extra op.
116
- - `undo.ts` `restoreRow` uses the same mapping and repair.
117
- - v10 peers drop `substate` as an unknown field and keep working. Status-only ops from v10 peers are repaired by step 3.
118
-
119
- ## CLI and SDK surface
120
-
121
- | Command | Effect |
122
- |---|---|
123
- | `mu task close <id> [--as done\|wontfix\|duplicate\|superseded] [--why "..."]` | `CLOSED/<as>`; default `done`. `--why` required unless `done`. Prints unblocked dependents when `--as` is not `done`. |
124
- | `mu task park <id> --why "..."` | `OPEN/todo → OPEN/parked`. Refuses `IN_PROGRESS` (next step: `release` first) and `CLOSED` (next step: `open` first). Idempotent on `OPEN/parked`. |
125
- | `mu task unpark <id>` | `OPEN/parked → OPEN/todo`. No-op on other pairs. |
126
- | `mu task open <id>` | `→ OPEN/todo` from any pair. |
127
- | `mu task claim <id>` | `→ IN_PROGRESS/active`. Refuses `OPEN/parked` without `--force`. |
128
- | `mu task release <id>` | `IN_PROGRESS/active → OPEN/todo`. `--reopen` from `CLOSED/*` also gives `OPEN/todo`. |
129
- | `mu task list --substate <name>` | Filter. Composes with `--status`. |
130
- | `mu task show`, `list`, `tree`, `next`, `mu state` | Render `STATUS/substate` when the substate is not the default, for example `OPEN/parked` or `CLOSED/wontfix`. Show the bare status for defaults. JSON always carries both fields. |
131
-
132
- - `mu task show` blocked output names the blocker's substate: "blocked by X (OPEN/parked)".
133
- - SDK exports: `TaskSubstate`, `TASK_SUBSTATES`, `DEFAULT_SUBSTATE`, `parkTask`, `unparkTask`, and `closeTask(…, { as, why })`. `TaskRow` gains `substate`.
134
- - Tracks: a track whose non-closed tasks are all parked is marked parked and is not counted as available parallel work.
135
-
136
- ## TUI
137
-
138
- The TUI only reads data; it has no commands that change tasks. Only `recent.tsx` references `closeTask`, and only to describe it. So the TUI work is display, filtering and yank strings.
139
-
140
- | Surface | File | Change |
141
- |---|---|---|
142
- | Colours | `src/cli/format.ts` `colorStatus`, `inkColorForStatus` | Take the pair. `OPEN/parked` gray; `CLOSED/done` green; other `CLOSED/*` red/dim. `InkColor` gains `gray` and `red` again. |
143
- | Status label | `list-row.tsx`, `all-tasks.tsx:243`, `task-detail.tsx`, DAG `renderForest` | Render `STATUS/substate` for non-defaults, sharing one formatting helper with the CLI. |
144
- | Filter strip | `use-status-filter.tsx`, `keymap-spec.ts` | Keep `o/i/c`. Add `p`, which toggles parked tasks within `OPEN`, and `w`, which toggles non-`done` closes within `CLOSED`. Default: all visible. Update the `DAG_HINTS` / `ALL_TASKS_HINTS` clusters and the help pane; `tui-help-overlay.test.ts` enforces that they match. |
145
- | Ready card | `cards/ready.tsx:72` | Parked tasks drop out via the view. Empty-state text: mention parked when some exist, e.g. "(no ready tasks) 3 parked". |
146
- | Blocked card / popup | `cards/blocked.tsx`, `popups/blocked.tsx` | `stillGating` unchanged (not `CLOSED`). Annotate gating blockers that are parked: "← X (parked)". |
147
- | Tracks popup | `popups/tracks.tsx` `statusRank` | Order: `IN_PROGRESS`, `OPEN/todo`, `OPEN/parked`, `CLOSED/*`. Mark all-parked tracks. |
148
- | Recent card | `cards/recent.tsx` | Show the close substate: "closed (wontfix)". |
149
- | Yank | `popups/ready.tsx` `yankCommandForTask` | `OPEN/parked` → `mu task unpark …`. `CLOSED/*` → `mu task open …` (unchanged). |
150
- | Glyphs | `src/glyphs.ts` | Add a `parked` glyph for dense rows. The status text stays the source of truth. |
151
-
152
- ## Migration (`scripts/migrate.ts`)
153
-
154
- Extend the existing sidecar to target v11. It already accepts v7–v9; add v10. The default output suffix becomes `.v11.db`.
155
-
156
- **v10 → v11.** Use `rebuildInto` as the v9 path does: copy the full ops log, then replay it through the v11 apply path. Recovery mostly comes for free, because the legacy payloads are still in the log:
157
-
158
- 1. Ops carrying `REJECTED` / `DEFERRED` project through `mapLegacyStatus` to `CLOSED/wontfix` / `OPEN/parked`.
159
- 2. Normal LWW decides the result. If a later op wrote the status, that later write wins and nothing is recovered, which is the "later change wins" rule.
160
- When looking for the last status writer, ignore ops with intent `undo` or `migrate.substate`. An undo restore replays an older value; it is not a new decision.
161
- 3. **Fallback from `MIGRATION:` notes.** If a task has a `MIGRATION: previous status was X` note, currently projects as `OPEN/todo`, and no status-writing op is newer than the note's `created_at`, append one `migrate.substate` op setting the mapped pair.
162
- 4. Carry machine-local rows (agents, workspaces, owners) exactly as the v9 path does.
163
-
164
- **v7–v9 → v11.** Unchanged, except that the target is v11. The shared mapping now yields substates directly. Stop appending `MIGRATION:` notes: the substate carries the information.
165
-
166
- **Report.** In addition to the current output, list every recovered task with its old and new pair, and for each `CLOSED/wontfix` recovery, the dependents it unblocked.
167
-
168
- `scripts/README.md`: rename the section to "v7–v10 to v11", update the recipe paths, and document the recovery rules above.
169
-
170
- ## Restoring destroyed workstreams
171
-
172
- Run this after the v11 swap, using ordinary `mu undo`, so the undo ops sync and appear in `mu log`. `restoreRow` uses the shared mapping, so restored tasks come back as `OPEN/parked` or `CLOSED/wontfix`, not as `OPEN`.
173
-
174
- A tombstone written after the v10 migration may hold the already-folded `OPEN`, not the legacy value, so mapping alone recovers nothing. The migration's recovery pass (steps 1–3 above) is therefore a shared function, `recoverLegacySubstates(db, workstream)`. After restoring, run it on each restored workstream with `npx tsx scripts/migrate.ts --recover <db> -w <ws>`. It uses the same last-status-writer rule and writes `migrate.substate` ops.
175
-
176
- Current live DB (read-only check, 2026-09-29):
177
-
178
- - 69 tasks with legacy statuses in history, all in 11 destroyed workstreams: `feedback`, `gchatui`, `gchatui-node` (2 destroy groups), `infer-rs`, `modelbridge` (teardown), `mufeedback`, `mufeedback-v03`, `multimachine`, `roadmap-v0-2`, `surface-audit`, `tui-impl`.
179
- - 42 workstreams in total are tombstoned; only 3 are live.
180
- - Dry runs of `mu undo 6f3d12e7` and `mu undo f34e2141` on v10 both plan full restores.
181
-
182
- Scope: all 42 tombstoned workstreams, not only the 11 with legacy tasks.
183
-
184
- Recipe:
185
-
186
- 1. For each destroy/teardown group, oldest first, run `mu undo <group>` (dry run). Record conflicts. A workstream destroyed more than once (e.g. `gchatui-node`) needs its groups undone newest first.
187
- 2. Apply with `--yes`. Do not use `--force`; resolve any superseded groups by hand.
188
- 3. Run `mu doctor --deep`, then `mu task list --substate parked` and `--substate wontfix` per workstream, and compare against the migration report.
189
-
190
- ## Testing
191
-
192
- - **Schema:** fresh DB seeds pairs; invalid pair, unknown value, and deleting a pair that is in use all fail; per-field writes inside one transaction commit; one default per status.
193
- - **Map parity:** `TASK_SUBSTATES` equals the seeded rows.
194
- - **Lifecycle:** each verb's resulting pair; `park` refuses `IN_PROGRESS` and `CLOSED` with next steps; `claim` on parked refuses without `--force`; `--why` is required; the unblocked-dependents report is correct.
195
- - **Views:** parked tasks are excluded from `ready` and stay in `goals`; a dependent of a parked task is in `blocked`; a dependent of `CLOSED/wontfix` is `ready`.
196
- - **Apply:** status-only op (v10 peer) repairs to the default; unknown substate falls back; legacy payloads map; the same op sequence converges on two DBs.
197
- - **Undo:** restoring a tombstoned legacy task yields the mapped pair; undoing a park restores the prior pair.
198
- - **Migration:** v10 fixtures with (a) a legacy op and no later status op → recovered; (b) a later `task.open` → not recovered; (c) a note but truncated ops → fallback fires; (d) v9 source → v11 directly. `mu doctor --deep` reports zero drift on the target.
199
- - **TUI:** filter-key parity test; `yankCommandForTask` for parked; colour and label helper cases; ready empty-state text.
200
-
201
- ## Out of scope
202
-
203
- - Task kinds (bug / feature / chore) and free-form labels.
204
- - `--until` automatic un-parking.
205
- - `triage` / `needs-info` substates (one seeded row each, later).
206
- - Substates for `IN_PROGRESS` beyond `active`.
207
- - Blocking semantics for any closed substate.
208
-
209
- ## Resolved questions
210
-
211
- 1. **Restore scope:** restore all 42 tombstoned workstreams, then run legacy recovery on each.
212
- 2. **Park on `IN_PROGRESS`:** refuse. The owner must `release` first. One verb, one transition.
213
- 3. **TUI keys:** `p` and `w` accepted.
214
-
215
- ## Implementation checklist
216
-
217
- 1. `status.ts`: `TASK_SUBSTATES`, `DEFAULT_SUBSTATE`, `resolvePair`, `mapLegacyStatus`; delete `normalizeTaskStatus`.
218
- 2. `db.ts`: v11 schema, seed, view changes, `EXPECTED_TABLES`, `SchemaTooOldError` next steps.
219
- 3. `capture.ts` / `apply.ts` / `undo.ts`: carry `substate`, apply-time mapping and repair.
220
- 4. Lifecycle verbs and SDK: `close --as/--why`, `park`, `unpark`, claim guard, unblocked-dependents report.
221
- 5. CLI rendering, `--substate` filter, JSON fields, tracks marking.
222
- 6. TUI changes per the table above.
223
- 7. `scripts/migrate.ts`: v10 → v11 path, `recoverLegacySubstates`, `--recover` mode, report; `scripts/README.md`.
224
- 8. Docs: `VOCABULARY.md` (substate, park, resolution values), `skills/mu/SKILL.md`, `CHANGELOG.md` (Breaking: v11; Added: substates).
225
- 9. Operator step: migrate the live DB, then restore workstreams per the recipe.
226
-
227
- ## Deviations during implementation
228
-
229
- Recorded after tasks 1–7 landed. Where this section and the text above disagree, this section describes the code.
230
-
231
- 1. **Substate is resolved from the log, not by per-field LWW** (task 3). [Sync and ops](#sync-and-ops) step 2–3 described per-field LWW plus a row-only repair. That diverges by arrival order: a repair that reads only the row bakes the fallback into it, and a later status change keeps the fallback on one peer and the real substate on another. `repairTaskPair(db, rowId, pending?)` in `src/apply.ts` instead takes the substate from the newest op in the log that writes one (an explicit substate, a legacy status implying its mapped pair, or a status-only op implying the status default), then resolves it against the row's current status. The result is a pure function of the op set, so every peer converges and the repair records no op. `applyOp` wraps each task put in `db.transaction` so the deferred FK sees a complete pair. `normalizeTaskStatus` and `RETIRED_STATUSES` are deleted.
232
- 2. **A second `updated_at`-only task op may share the group** (tasks 4, 5). `close --why` and `park` insert a note, and the note's parent touch writes a separate task op carrying only `updated_at` when the millisecond clock ticks between the two writes. It sits in the same group, so undo and log rendering are unaffected. Tests count ops that carry `status` or `substate`, not all task ops.
233
- 3. **Ready empty-state text is shorter** (task 6): `(no ready tasks) N parked · the rest are blocked or closed`, truncated at the card edge. The longer text in [TUI](#tui) wrapped over the card's border. The Recent card shows the pair (`CLOSED/wontfix`) rather than "closed (wontfix)". `inkColorForStatus` is removed in favour of `inkColorForPair`; `loadFullDag` gained an `include` predicate so the root module does not import the TUI filter.
234
- 4. **Migration and drift** (task 7). `src/drift.ts` compares `substate`, so `mu doctor --deep` sees substate drift. The v7/v8/v9 paths no longer write `MIGRATION:` notes. The migration report lists a third source, `replay`, for pairs the apply path derived during replay, alongside `ops` and `note` for captured `migrate.substate` recoveries. Recovery candidates are `OPEN/todo` tasks only, and a task that already has a `migrate.substate` op is skipped, which makes `--recover` idempotent. The v10 path turned out to need captured recovery only for the undo-hidden and note-only cases; replay already maps the rest.
235
- 5. **`SchemaTooNewError`** (task 2). `openDb` refuses a DB whose `schema_version` is newer than `CURRENT_SCHEMA_VERSION` (exit 4), before `applySchema`. Without it, a v10 binary would open a v11 DB and fail at commit on its first write. `tasks.substate` has no `DEFAULT`, so an old INSERT fails loudly rather than silently choosing a substate.
236
-
237
- ## Appendix A: foreign key verification
238
-
239
- Tested in SQLite with `PRAGMA foreign_keys=ON` and the deferred composite foreign key:
240
-
241
- | test | result |
242
- |---|---|
243
- | Change `status`, then `substate`, in two `UPDATE`s inside one transaction | committed |
244
- | `CLOSED` + `parked` | rejected |
245
- | unknown substate `bogus` | rejected |
246
- | delete an allowed pair that a task uses | rejected |
247
- | `ALTER TABLE … ADD CONSTRAINT FOREIGN KEY` on an existing table | syntax error (hence D12) |