@mu-crew/mu 1.6.0 → 3.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.
@@ -0,0 +1,247 @@
1
+ # Task substates: classification without stranded dependents
2
+
3
+ Status: 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/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) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mu-crew/mu",
3
- "version": "1.6.0",
3
+ "version": "3.0.0",
4
4
  "description": "A persistent, observable crew of pi agents running in one tmux or herdr session per workstream, coordinated through a built-in task DAG.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -10,7 +10,8 @@ the traps below it are the part that costs time.
10
10
  **If you read one thing:** on a session-capped host, never leave an
11
11
  attach pane open — it holds the only ssh channel and silently breaks
12
12
  `git fetch`, `murmur collect` and every other ssh. Attach to look, then
13
- `mu agent close`. See § Never leave an attach pane open.
13
+ `mu agent close`. See § Never leave an attach pane open. This guidance
14
+ concerns interactive attachment, not mu's automatic agent-state reads.
14
15
 
15
16
  ---
16
17
 
@@ -19,8 +20,8 @@ attach pane open — it holds the only ssh channel and silently breaks
19
20
  **The pane is local, the process is remote.** `mu agent spawn
20
21
  --command 'ssh <host> -t "..."'` starts an ordinary tmux pane whose
21
22
  foreground process happens to be ssh. Everything mu does is
22
- pane-shaped, so `mu agent send`, `mu agent read`, status detection and
23
- the reaper all work unchanged, across the network, with no mu changes.
23
+ pane-shaped, so `mu agent send`, `mu agent read`, and the reaper work
24
+ unchanged across the network. murmur reports the remote process's agent state.
24
25
 
25
26
  **One orchestrator DB; panes may be remote.** All state stays on your
26
27
  machine. Do not run a second mu on the host to "coordinate": ownership
@@ -65,8 +66,8 @@ the only ssh channel**. While it is open, `git fetch`, `git push`,
65
66
  `Permission denied (keyboard-interactive)` error.
66
67
 
67
68
  **Attach to look, then close immediately.** `mu agent close <name>`
68
- detaches without stopping a detached-tmux agent. Poll with `murmur
69
- collect` + `murmur status`; use mule for orchestrator commands because
69
+ detaches without stopping a detached-tmux agent. mu reads agent state
70
+ through murmur automatically; use mule for orchestrator commands because
70
71
  its separate ControlPath cannot contend with the attach pane. See
71
72
  § When the host limits concurrent sessions for diagnosis and the
72
73
  remote-agent setup.
@@ -140,39 +141,30 @@ Local and remote agents mix freely in one workstream. The DAG, tracks,
140
141
  — task status is a row in YOUR database, written by you, so a wait on
141
142
  it is exact.
142
143
 
143
- **Agent STATUS is different, and this is the one place "nothing
144
- changes" is false.** `busy` / `needs_input` / `idle` come from reading
145
- the local pane's scrollback, which for a remote worker is a nested tmux
146
- rendered over ssh. What you get is redraw lag and quiet periods that
147
- look like idleness, so anything derived from status is unreliable here:
144
+ **Agent state comes from murmur.** Its extension runs inside pi on the
145
+ host and reports state without reading the nested tmux screen. mu reads
146
+ murmur's remote rows and caches them for 10 seconds. Remote rows can lag
147
+ by murmur's collect floor (30 seconds ± 10 seconds) plus that cache;
148
+ their `since` value is the row's `updated_at`, so idle and stall age is
149
+ approximate.
148
150
 
149
151
  | waiting on | remote? |
150
152
  | --- | --- |
151
153
  | `mu task wait` (task status) | exact — a DB poll, once something closes the task |
152
154
  | the reaper | fires, but see below |
153
- | stall detection | unreliable |
154
- | `mu agent wait --first` | same, it is status-based |
155
+ | stall detection | exact state from murmur; approximate age |
156
+ | `mu agent wait --first` | exact state from murmur |
155
157
 
156
- Measured both directions in one session: a stall fired at 300s against
157
- a worker that was visibly mid-turn, and `mu agent list` showed
158
- `needs_input` for another that was working. Give remote waits a generous
159
- `--timeout`; see `mu task wait --help` for stall controls.
158
+ Without murmur, remote agent state is `unknown`. `mu agent wait` does not
159
+ fire on `unknown`, and `unknown` never counts as a task stall. Run
160
+ `mu doctor` for the missing-source reason.
160
161
 
161
- The reaper is right for a DIRECT spawn — the connection dying really
162
+ The reaper is right for a direct spawn — the connection dying really
162
163
  does kill that agent — and wrong for a detached-tmux one, where the
163
164
  agent outlives the ssh but mu reaps the task anyway. See § A dropped
164
165
  connection reaps the task but NOT the commit.
165
166
 
166
- **If you need trustworthy remote status, ask murmur, not mu.** Its
167
- extension runs inside pi ON THE HOST and pushes state, so nothing is
168
- scraped and no ssh hop distorts it:
169
-
170
- ```bash
171
- murmur collect && murmur status --json # activity is host-reported
172
- ```
173
-
174
- That is the division worth remembering: mu owns the work, murmur owns
175
- what the agent is doing.
167
+ The division is: mu owns the work; murmur reports what the agent is doing.
176
168
 
177
169
  ### On step 2 — the note is load-bearing
178
170
 
@@ -361,17 +353,18 @@ respawn.
361
353
 
362
354
  ## mu and murmur, and what you lose without it
363
355
 
364
- [murmur](https://github.com/mu-crew/murmur) is optional and
365
- **strictly additive**. Nothing in mu needs it: the DAG, claim/close/
366
- wait, workspaces, spawn/send/read, the reaper, `mu state`, this whole
367
- remote recipe and `git fetch` collection all work with murmur absent.
368
- What you lose is the *view* — cross-machine agent state, the tmux
369
- status pills, `prefix+a`, the attachment hint, and `murmur peer list`
370
- for host reachability. Guard on it (`if it is installed`) rather than
371
- assuming it.
372
-
373
- The division: **mu owns the work, murmur owns what an agent is doing.**
374
- murmur never places work.
356
+ [murmur](https://github.com/mu-crew/murmur) provides agent state for
357
+ mu's tmux backend. Without it, mu reports agent state as `unknown`.
358
+ The DAG, claims, task completion waits, workspaces, spawn, send, read,
359
+ the reaper, this remote recipe, and `git fetch` collection still work.
360
+ Agent-state consumers such as `mu agent wait`, idle flags, and task-stall
361
+ detection wait rather than treating `unknown` as completion or a stall.
362
+
363
+ murmur also provides the tmux status pills, `prefix+a`, attachment hints,
364
+ and `murmur peer list` for host reachability. The division is: **mu owns
365
+ the work; murmur reports what an agent is doing.** murmur never places
366
+ work. Its interfaces used by mu are in murmur's
367
+ [stable contract](https://github.com/mu-crew/murmur/blob/main/ARCHITECTURE.md#contract).
375
368
 
376
369
  | question | ask |
377
370
  | --- | --- |
@@ -32,12 +32,12 @@ both. `--json` exists on every verb:
32
32
  - **agent** — named worker in a pane (you may be one).
33
33
  - **mux** — the multiplexer mu drives: tmux, or herdr. One per
34
34
  invocation. `mu doctor` names the active one; `MU_MUX` forces it.
35
- Spawn, send, read, and status detection work on both; `mu agent
36
- kick` is herdr's remaining gap (Linux-only there).
35
+ Spawn, send, and read work on both. Agent state comes from murmur
36
+ on tmux and from herdr on herdr; `mu agent kick` is Linux-only on herdr.
37
37
  - **task** — DAG node with mandatory `impact` (1–100) and
38
- `effort_days`. Status: `OPEN`, `IN_PROGRESS`, `CLOSED`
39
- (satisfies `--blocked-by`). Record postponed/wont-do rationale as
40
- task notes; close the task to satisfy blockers.
38
+ `effort_days`. Shown as status/substate: `OPEN/todo|parked`,
39
+ `IN_PROGRESS/active`, `CLOSED/done|wontfix|duplicate|superseded`.
40
+ Any `CLOSED/*` satisfies `--blocked-by`.
41
41
  - **claim / release** — atomic take/clear of `tasks.owner`.
42
42
  - **note** — append-only task context; survives sessions.
43
43
  - **track** — independent DAG subtree; don't spawn more agents than
@@ -98,8 +98,9 @@ that a refusal with `--strict-staleness`.
98
98
  ### Remote agents
99
99
 
100
100
  Agents can run on another machine: the PANE is local, the PROCESS is
101
- remote (`--command 'ssh <host> -t "..."'`), so `send`, `read`, status
102
- detection and the reaper all keep working unchanged. **One orchestrator
101
+ remote (`--command 'ssh <host> -t "..."'`), so `send`, `read`, and the
102
+ reaper keep working unchanged. murmur reports the remote agent's state.
103
+ **One orchestrator
103
104
  DB; panes may be remote** — never run a second mu on the host, since
104
105
  `tasks.owner_id` is an FK into the machine-local `agents` table and a
105
106
  remote mu could not claim your tasks anyway. You create the remote
@@ -158,7 +159,7 @@ Every turn:
158
159
  `mu task claim <id> -w <ws> --for <agent> --evidence "..."`.
159
160
  If no task exists, `mu task add` first; include initial context with
160
161
  `--note 'REPRO: ...\nSCOPE: ...'` when the title alone is not
161
- enough. Agent status is noisy; task ownership is durable and
162
+ enough. Agent state is runtime observation; task ownership is durable and
162
163
  waitable.
163
164
  4. Send terse instructions: task id, files/notes to read, workspace
164
165
  path, validation command, scope guards, task note contract.
@@ -173,6 +174,7 @@ Every turn:
173
174
  6. `mu task wait ... --first --on-stall exit --json`.
174
175
  7. Cherry-pick the closed worker's **new** commit(s), verify the MERGE
175
176
  (see below), return control. Do not barrier or loop in shell.
177
+ Only `CLOSED/done` ships; other closes: read the reason note.
176
178
  8. Repeat from `mu state`.
177
179
 
178
180
  ## Dispatch rules that prevent real failures
@@ -210,8 +212,8 @@ Every turn:
210
212
  conflict. Refresh workspaces between waves.
211
213
  - Cross-workstream wait/claim uses qualified refs. The owner stays in its own
212
214
  workstream; only task ownership crosses.
213
- - For an idle worker, inspect scrollback, then send a retry or release its task.
214
- `MU_IDLE_THRESHOLD_MS` defaults to 5m.
215
+ - For an idle worker, read the pane to learn why it stopped, then answer,
216
+ retry, or release its task. `MU_IDLE_THRESHOLD_MS` defaults to 5m.
215
217
  - `mu agent kick` targets a wedged foreground subprocess. It refuses to signal
216
218
  the wrapping CLI; close the agent if that is what must stop.
217
219
  - Use `mu agent send`, not raw mux input: mu preserves literal text and confirms
@@ -231,6 +233,10 @@ Every turn:
231
233
  5 timeout, 6 pane died.
232
234
  - **`task close --if-ready`** no-ops until every blocker is CLOSED; bare
233
235
  `task release` reopens IN_PROGRESS.
236
+ - **`task close --as wontfix --why ...`** unblocks dependents (listed in
237
+ the output). To keep dependents waiting, `task park --why` instead: parked
238
+ leaves `next`, and `claim` refuses it without `--force`. Park refuses
239
+ IN_PROGRESS — `task release` first.
234
240
  - **For waits use `task wait`, not `log --tail`.** `--kind` is the operator's
235
241
  log-ledger channel; `--intent` is what mu recorded.
236
242
  - **`mu undo`** bare lists undoable actions with group ids; `<group>` previews;
@@ -291,24 +297,18 @@ Convention: `pi_mini` / `pi` / `pi_big`. Use mini for probing,
291
297
  modest for build/edit/refactor, big for design/review/incidents.
292
298
  Discover model strings with `pi --list-models [fuzzy-search]`.
293
299
 
294
- ## Reaper and status limits
300
+ ## Reaper and agent state
295
301
 
296
302
  If an agent pane dies, or `mu agent close` kills it mid-task, owned
297
303
  IN_PROGRESS tasks revert to OPEN with a `[reaper]` note and `task
298
304
  reap` op. No manual release after crashes.
299
305
 
300
- Status detection is heuristic and can lag behind custom `--command`
301
- wrappers. It is weakest for a remote worker, where the scrollback is a
302
- nested tmux rendered over ssh.
303
-
304
- **[murmur](https://github.com/mu-crew/murmur), if installed, is
305
- authoritative there** — its extension pushes state from inside the agent
306
- on the host rather than scraping a pane, and it answers across machines.
307
- Optional and strictly additive: nothing here needs it, and the seam is
308
- the env vars `mu agent spawn` already injects (`MU_MANAGED_AGENT`,
309
- `MU_AGENT_NAME`, `MU_WORKSTREAM`), which murmur reads to mark a pane as
310
- crew. **mu owns the work; murmur owns what an agent is doing.** See
311
- [REMOTE_WORKERS.md](REMOTE_WORKERS.md) § mu and murmur.
306
+ Agent state comes from [murmur](https://github.com/mu-crew/murmur) on
307
+ tmux and from herdr on herdr. `unknown` means no state source; run
308
+ `mu doctor` for the reason. mu needs murmur only for agent state:
309
+ tasks, claims, workspaces, spawn, send, read, and task completion waits
310
+ work without it. **mu owns the work; murmur reports what an agent is
311
+ doing.** See [REMOTE_WORKERS.md](REMOTE_WORKERS.md) § mu and murmur.
312
312
 
313
313
  For high-stakes decisions:
314
314
 
@@ -337,7 +337,8 @@ mu task note <id> "FILES: ...\nDECISION: ...\nVERIFIED: ..."
337
337
  mu task close <id> --evidence "tests pass: ..." # LAST action
338
338
  ```
339
339
 
340
- Skipping close makes the orchestrator's wait hang.
340
+ Skipping close makes the orchestrator's wait hang. Won't do it:
341
+ `close --as wontfix --why "..."`.
341
342
 
342
343
  ## Follow-on prompts
343
344
 
@@ -359,7 +360,7 @@ Sending to a BUSY agent is not delayed — that input queues normally.
359
360
 
360
361
  ## Guardrails
361
362
 
362
- Task ownership outranks scraped status. Coordinate through task notes and the
363
+ Task ownership outranks runtime agent state. Coordinate through task notes and the
363
364
  activity log. Keep edges within one workstream, role-name agents, and reserve
364
365
  the `mu_` task-id prefix. Give workers bounded paths and commands; use `mu
365
366
  agent kick` if a subprocess wedges.