@mu-crew/mu 2.0.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.
@@ -111,8 +111,38 @@ it mu is just an agent runner.
111
111
  `ROI = impact / effort` drives prioritization.
112
112
  - **One edge type**: `blocks`. `A → B` means A must close before B can
113
113
  start. Multiple edge types create ambiguity that defeats the purpose.
114
- - **Status lifecycle**: `OPEN → IN_PROGRESS → CLOSED`. Postponed or
115
- won't-do rationale belongs in notes; `CLOSED` alone satisfies blockers.
114
+ - **Status lifecycle**: `OPEN → IN_PROGRESS → CLOSED`. Status alone
115
+ decides edge satisfaction: any `CLOSED` task satisfies its blockers.
116
+ - **Substate** qualifies status and never touches edges:
117
+ `OPEN/todo|parked`, `IN_PROGRESS/active`,
118
+ `CLOSED/done|wontfix|duplicate|superseded`. Rule: **store intent,
119
+ derive graph facts** — ready and blocked stay derived from edges.
120
+ `OPEN/parked` is excluded from `ready` but stays in `goals`.
121
+
122
+ #### Substate integrity: deferred FK + apply-time pair repair
123
+
124
+ The legal pairs live in the `task_substates` lookup table, seeded from
125
+ code on every open (machine-local, never synced). `tasks` has a
126
+ composite FK `(status, substate) → task_substates`,
127
+ `DEFERRABLE INITIALLY DEFERRED`, which is the only guard on the pair.
128
+ It is checked at COMMIT, so apply's one-field-at-a-time UPDATEs pass as
129
+ long as the pair is legal when the transaction ends. That is why
130
+ `applyOp` wraps each task put in `db.transaction` (and undo already runs
131
+ in one): in autocommit the first per-field UPDATE would fail the FK.
132
+
133
+ Every lifecycle write sets `status` and `substate` in one UPDATE, so
134
+ one op carries both under one HLC. A pair can still split across ops:
135
+ a status-only op from a v10 peer, an unknown substate from a newer
136
+ peer, or a concurrent close and park on two machines. Before commit,
137
+ `repairTaskPair` (`src/apply.ts`) sets the substate to the one implied
138
+ by the newest op in the LOG that writes a substate, resolved against
139
+ the row's current status, and falls back to the status default when
140
+ that pair is illegal. Substate is therefore NOT per-field LWW.
141
+ Reading the log instead of the row makes the result a pure function of
142
+ the op set plus the row's status, so every peer converges on the same
143
+ row whatever the arrival order. For the same reason the repair records
144
+ **no op**: each peer computes the identical repair from the identical
145
+ log. Recording one would only add a second writer racing the first.
116
146
  - **Notes** are append-only per task; survive across LLM sessions and
117
147
  agent restarts. The fix for context loss at the *task* level rather
118
148
  than the agent level.
@@ -121,7 +151,7 @@ it mu is just an agent runner.
121
151
 
122
152
  | View | Returns |
123
153
  | --------- | ---------------------------------------------------------------------- |
124
- | `ready` | OPEN tasks with no unresolved blockers — work that can start *now* |
154
+ | `ready` | `OPEN/todo` tasks with no unresolved blockers — work that can start *now* |
125
155
  | `blocked` | OPEN tasks waiting on something |
126
156
  | `goals` | Tasks with no dependents — graph endpoints |
127
157
 
@@ -406,7 +436,7 @@ src/cli/tui/
406
436
  ├── tab-strip-layout.ts # pure window-around-active layout helper
407
437
  ├── help.tsx # ?/F1 keymap overlay (scrollable on short panes)
408
438
  ├── use-popup-filter.tsx # shared '/' substring filter hook + applyFilter + FilterPrompt
409
- ├── use-status-filter.tsx # task-status toggles (o/i/c) for task-list popups
439
+ ├── use-status-filter.tsx # status toggles (o/i/c) + substate toggles (p/w) for task-list popups
410
440
  ├── use-notes-drill.ts # shared notes-drill memo (5 task popups consume it)
411
441
  ├── use-popup-action-queue.ts # consume mouse PopupAction queue once per render
412
442
  ├── cards/ # 10 dashboard glance cards (one slot each)
@@ -606,7 +636,7 @@ returning. Two steps, in order:
606
636
  installed`, `murmur pi extension not linked`, `murmur has no row`,
607
637
  `ambiguous: N hosts`, `remote snapshot stale`, and `pane gone`.
608
638
  - **`agents.status` is deprecated.** Inserts write `spawning` to satisfy
609
- the v10 schema. No code updates or reads the column as runtime state.
639
+ the schema. No code updates or reads the column as runtime state.
610
640
  - **No silent adoption**: orphans are reported, never claimed.
611
641
  - **`mu doctor` calls the same routine** and reports counts.
612
642
 
@@ -625,7 +655,7 @@ separately below.
625
655
 
626
656
  | Module | Responsibility |
627
657
  | --------------------- | ----------------------------------------------------------------------------------------- |
628
- | `src/db.ts` | Connection (better-sqlite3, WAL), **schema v10** (10 tables + 3 views), `resolveWorkstreamId`. Installs capture on every writable open. Owns `SYNCED_ENTITIES` / `MACHINE_LOCAL_ENTITIES` / `PORTABLE_TABLES` / `MACHINE_LOCAL_TABLES`. Refuses a pre-v10 DB (exit 4). |
658
+ | `src/db.ts` | Connection (better-sqlite3, WAL), **schema v11** (11 tables incl. the `task_substates` lookup + 3 views), `resolveWorkstreamId`. Installs capture on every writable open. Owns `SYNCED_ENTITIES` / `MACHINE_LOCAL_ENTITIES` / `PORTABLE_TABLES` / `MACHINE_LOCAL_TABLES`. Refuses a pre-v11 DB (`SchemaTooOldError`) or a newer one (`SchemaTooNewError`), both exit 4. |
629
659
  | `src/hlc.ts` | The **HLC** (VOCABULARY § HLC), serialized as sortable TEXT `<wall_ms:15>.<counter:6>.<machine_id>`. `nextHlc` / `receiveHlc` / `compareHlc` / `parseHlc` / `formatHlc`. Clock state lives in `machine_identity`. |
630
660
  | `src/capture.ts` | **Op capture**: builds the triggers that record every write to a portable table as an op in the same transaction. |
631
661
  | `src/apply.ts` | **The apply path** — capture's counterpart: given one op, local or from a peer, make the tables reflect it. Also owns `reprojectDeferredOps` ([§ ambient sync hook](#the-ambient-sync-hook)). |
@@ -8,7 +8,7 @@ A practical, copy-pasteable tour of mu. Terms are canonical — see
8
8
  > **Status:** 1.0 (pre-release). Typed verbs span 7 namespaces
9
9
  > (`workstream`, `agent`, `task`, `workspace`, `log`, `me`, `db`) plus
10
10
  > bare top-level verbs (`state`, `doctor`, `sql`, `undo`, `sync`,
11
- > `rebuild`). Every verb accepts `--json`. Schema v10. See
11
+ > `rebuild`). Every verb accepts `--json`. Schema v11. See
12
12
  > [CHANGELOG.md](../CHANGELOG.md).
13
13
 
14
14
  **In a hurry? Start at [§ 0. Common scenarios](#0-common-scenarios).**
@@ -386,11 +386,12 @@ table/key/field is the reproduction. See
386
386
 
387
387
  ### 0.6 Upgrading an older DB
388
388
 
389
- `mu` refuses to open a pre-v10 DB (`SchemaTooOldError`, exit 4) and
390
- leaves the file alone. The retained `scripts/migrate.ts` sidecar
391
- auto-detects v7, v8, or v9 and writes a fresh v10 DB. It never migrates
392
- in place. Legacy `REJECTED` / `DEFERRED` tasks become `OPEN` with a
393
- migration note while their original op payloads remain unchanged.
389
+ `mu` refuses to open a pre-v11 DB (`SchemaTooOldError`, exit 4) or a DB
390
+ newer than it understands (`SchemaTooNewError`, exit 4) and leaves the
391
+ file alone. The retained `scripts/migrate.ts` sidecar auto-detects v7,
392
+ v8, v9, or v10 and writes a fresh v11 DB. It never migrates in place.
393
+ Legacy `REJECTED` tasks become `CLOSED/wontfix` and `DEFERRED` tasks
394
+ become `OPEN/parked`; their original op payloads remain unchanged.
394
395
  Pre-1.0 archives restore as live workstreams under their original
395
396
  `source_workstream` names unless you pass `--drop-archives`.
396
397
 
@@ -1208,6 +1209,7 @@ for the newly active workstream.
1208
1209
  | filter | `Esc` | cancel (clear query) |
1209
1210
  | filter | `Enter` | commit (keep filter, return to nav) |
1210
1211
  | task popup| `o` / `i` / `c` | toggle OPEN / IN_PROGRESS / CLOSED |
1212
+ | task popup| `p` / `w` | toggle OPEN/parked / closed-not-done (DAG + All-tasks) |
1211
1213
  | All-tasks | `b` | blocked filter cycle (all → only → hide) |
1212
1214
  | All-tasks | `s` | cycle sort key (roi → recency → age → id) |
1213
1215
  | git-show | `t` | launch `tuicr -r <sha>` (alt-screen handoff) |
@@ -1616,13 +1618,30 @@ enforce these — they're for the agents reading them.
1616
1618
  ## 12. Close out a task
1617
1619
 
1618
1620
  ```bash
1619
- mu task close design # OPEN/IN_PROGRESS → CLOSED
1621
+ mu task close design # OPEN/IN_PROGRESS → CLOSED/done
1620
1622
  mu task close umbrella --if-ready # close ONLY if every blocker
1621
1623
  # is CLOSED; else no-op + list
1622
1624
  # the still-blocking ids
1625
+ mu task close spike --as wontfix --why "superseded by the v2 design"
1623
1626
  mu task open design # CLOSED → OPEN (e.g. closed by mistake)
1627
+ mu task park polish --why "after the release" # OPEN → OPEN/parked
1628
+ mu task unpark polish # OPEN/parked → OPEN/todo
1624
1629
  ```
1625
1630
 
1631
+ Every task carries a **substate** next to its status, rendered as a pair.
1632
+ `OPEN` is `todo` or `parked`; `IN_PROGRESS` is `active`; `CLOSED` is
1633
+ `done`, `wontfix`, `duplicate`, or `superseded`. `--as` picks the closed
1634
+ substate. Anything but `done` requires `--why`, stored as a note in the
1635
+ same transaction. Any `CLOSED/*` satisfies a blocker, so a non-`done`
1636
+ close prints the dependents it unblocked.
1637
+
1638
+ To keep dependents waiting, park the task instead of closing it. A
1639
+ parked task leaves `mu task next` and the ready set, and `mu task claim`
1640
+ refuses it without `--force`. It stays in goals, and a track whose non-closed
1641
+ tasks are all parked is marked `(parked)`. Park refuses `IN_PROGRESS`
1642
+ (release first) and `CLOSED` (open first). Filter with
1643
+ `mu task list --substate parked`.
1644
+
1626
1645
  Both are idempotent (closing an already-CLOSED task prints a no-op and
1627
1646
  exits 0). Owner is left intact — use `mu task release <id>` to clear
1628
1647
  ownership when an agent bails mid-flight. `IN_PROGRESS` flips back to
@@ -2366,26 +2385,29 @@ winner, and the newer HLC takes it.
2366
2385
 
2367
2386
  ---
2368
2387
 
2369
- ## 15.7 Upgrading a v7, v8, or v9 DB
2388
+ ## 15.7 Upgrading a v7, v8, v9, or v10 DB
2370
2389
 
2371
- `mu` refuses to write a pre-v10 DB and leaves it untouched. Use the
2390
+ `mu` refuses to write a pre-v11 DB and leaves it untouched. Use the
2372
2391
  single retained sidecar against a backup:
2373
2392
 
2374
2393
  ```bash
2375
2394
  DB=${MU_DB_PATH:-$HOME/.local/state/mu/mu.db}
2376
2395
  BACKUP="$HOME/mu-old-backup-$(date +%Y%m%d-%H%M%S).db"
2377
2396
  sqlite3 "$DB" ".backup '$BACKUP'"
2378
- npx tsx scripts/migrate.ts "$BACKUP" --out "${DB}.v10"
2379
- MU_DB_PATH="${DB}.v10" mu doctor --deep
2380
- mv "$DB" "${DB}.old-kept" && mv "${DB}.v10" "$DB"
2397
+ npx tsx scripts/migrate.ts "$BACKUP" --out "${DB}.v11"
2398
+ MU_DB_PATH="${DB}.v11" mu doctor --deep
2399
+ mv "$DB" "${DB}.old-kept" && mv "${DB}.v11" "$DB"
2381
2400
  mu doctor
2382
2401
  ```
2383
2402
 
2384
- The script auto-detects v7, v8, or v9, opens it read-only, compares its
2385
- SHA-256 before and after, and never overwrites a target unless `--force`
2386
- is explicit. v9 history is retained unchanged; legacy statuses normalize
2387
- only while projecting into v10, with one durable migration note per
2388
- currently affected task. Valid v9 agents, workspaces, owners, peer
2403
+ The script auto-detects v7, v8, v9, or v10, opens it read-only, compares
2404
+ its SHA-256 before and after, and never overwrites a target unless
2405
+ `--force` is explicit. v9/v10 history is retained unchanged; legacy
2406
+ statuses map onto substates (`REJECTED` → `CLOSED/wontfix`, `DEFERRED` →
2407
+ `OPEN/parked`) while projecting into v11, and no migration notes are
2408
+ written. Where an undo or a note hid the legacy status, the script
2409
+ records a `migrate.substate` op; `--recover <db>` reruns that recovery
2410
+ on an already-migrated DB. Valid v9 agents, workspaces, owners, peer
2389
2411
  watermarks, and machine identity carry across, but pane ids and absolute
2390
2412
  workspace paths cannot be proven live until `mu doctor` reconciles them.
2391
2413
  The v7/v8 path keeps the older conservative omissions for agents and
@@ -28,13 +28,16 @@ defined here, fix the doc. If you need a new term, add it here first.
28
28
  | **remote worker** | A worker whose process and workspace are on another machine while its pane and orchestrator DB remain local. The durable inventory record is a task-note line in the exact `REMOTE: <host>:<path>` shape; pair it with `REMOTE_BASE: <agent>:<sha>` so `mu task claim --for <agent>` can print the one-shot poll-and-close step. `mu state` lists `REMOTE:` lines and `mu state --json` exposes them as `remoteWorkers`. | "remote agent record", "host registry" |
29
29
  | **actor** | The party that *caused* a state change. May or may not be a registered worker. Recorded in `ops.actor` for every op. The orchestrator running mu from a top-level shell is an actor but not a worker. | "caller", "author" (only on notes) |
30
30
  | **crew** | *Informal* collective noun for the agents in a workstream | (no API surface; prose only) |
31
- | **task** | A node in the DAG. Has mandatory `impact` and `effort_days`. Status one of `OPEN`, `IN_PROGRESS`, `CLOSED` (see **task status** below). | "issue", "ticket", "item" |
32
- | **task status** | One of 3 states. **OPEN** = ready to be claimed; **IN_PROGRESS** = claimed and active; **CLOSED** = finished and the only state that satisfies a `--blocked-by` edge. Record postponed or won't-do rationale in a **note**, then close the task. | "state" |
31
+ | **task** | A node in the DAG. Has mandatory `impact` and `effort_days`. Has a **task status** and a **substate**, shown as a pair (`OPEN/parked`, `CLOSED/wontfix`). | "issue", "ticket", "item" |
32
+ | **task status** | The lifecycle phase, one of 3: **OPEN** = not started; **IN_PROGRESS** = claimed and active; **CLOSED** = ended. Status alone decides edge satisfaction: any `CLOSED/*` satisfies a `--blocked-by` edge. | "state" |
33
+ | **substate** | What the **task status** means for this task. Never null; each status has a default. `OPEN`: `todo` (default), `parked`. `IN_PROGRESS`: `active`. `CLOSED`: `done` (default), `wontfix`, `duplicate`, `superseded`. Rendered as a pair `STATUS/substate`; a default substate renders as the bare status. Valid pairs live in the `task_substates` table. Never affects edges. Refer to a non-`done` close as a *closed substate*. | "resolution", "reason", "sub-status" |
34
+ | **park** | Verb: set an `OPEN` task to `OPEN/parked` with a required `--why` note. A parked task leaves **ready** and `next`, `claim` refuses it without `--force`, and it stays in **goals**. Its dependents stay blocked. Refuses `IN_PROGRESS` (release first) and `CLOSED` (open first). | "defer", "snooze", "shelve" |
35
+ | **unpark** | Verb: return an `OPEN/parked` task to `OPEN/todo`. | "resume", "reactivate" |
33
36
  | **task DAG** / **graph** | The directed acyclic graph of tasks. | "task list", "todo", "tree" (it's a DAG, not a tree) |
34
37
  | **edge** | A `blocks` relationship between two tasks. The single edge type. `A blocks B` = A must close before B can start. | "dependency" (use only in prose) |
35
38
  | **track** | An independent subtree of the DAG identified by parallel-track detection | "branch", "lane" |
36
39
  | **diamond merge** | When two tracks share a prerequisite, parallel-track detection collapses them into one track to prevent two agents from colliding on the shared dependency. | "join", "converge" |
37
- | **ready** | An OPEN task with no unresolved blockers. Exposed as the `ready` SQL view. | "unblocked", "available" |
40
+ | **ready** | An `OPEN/todo` task with no unresolved blockers (parked tasks are excluded). Exposed as the `ready` SQL view. | "unblocked", "available" |
38
41
  | **goals** | Tasks with no outgoing blocks-edges (graph endpoints). Exposed as the `goals` SQL view. | "leaves", "targets" |
39
42
  | **sort key** | Argument to `mu task list / next / ready --sort <key>`. One of `roi` (impact / effort_days, default for `next` / `ready`), `recency` (`updated_at` DESC — "what did I touch most recently"), `age` (`created_at` ASC — "what's gone stale"), `id` (`local_id` ASC, default for `task list`). The two time-based keys also render an `updated`/`created` relative-time column in the table view. | "order by", "sort by" |
40
43
  | **subtree** / **scope** | The set of tasks reachable from a root via blocks-edges | "subgraph" (only for technical descriptions) |
@@ -198,8 +201,11 @@ The retained `agents.status` column is deprecated and always contains
198
201
  | Verb | Effect |
199
202
  | ------------------------------------- | ----------------------------------------------------- |
200
203
  | `mu task add <id> ...` | Creates a new OPEN task. `--note <text>` appends an initial note in the same transaction; `--note-author <name>` overrides the note author. |
201
- | `mu task close/open <id>` | Lifecycle transition |
202
- | `mu task claim <task> [--for <agent>]` | Atomic: sets `owner`, flips status to `IN_PROGRESS` |
204
+ | `mu task close <id> [--as <substate> --why <text>]` | Sets `CLOSED` (default `CLOSED/done`). A non-`done` `--as` requires `--why` and prints the dependents it unblocked. |
205
+ | `mu task open <id>` | Reopens a task to `OPEN/todo`. |
206
+ | `mu task park <id> --why <text>` | `OPEN` → `OPEN/parked`. Leaves ready/next; stays in goals. |
207
+ | `mu task unpark <id>` | `OPEN/parked` → `OPEN/todo`. |
208
+ | `mu task claim <task> [--for <agent>] [--force]` | Atomic: sets `owner`, flips status to `IN_PROGRESS/active`. Refuses a parked task unless `--force`. |
203
209
  | `mu task release <task>` | Clears `owner`. Auto-flips `IN_PROGRESS` → `OPEN` (so the task re-enters the ready set); `CLOSED` is preserved. `--reopen` forces `OPEN` from `CLOSED`. |
204
210
  | `mu task note <task> "..."` | Appends to `task_notes`. Never edits prior notes. |
205
211
  | `mu task notes <task> [--tail N \| --since <iso> \| --since-claim]` | List notes (oldest first). `--tail N` (alias `--last N`) prints last N; `--since <iso>` filters by `created_at`; `--since-claim` auto-resolves to the most recent `task claim` event timestamp. `--since` and `--since-claim` are mutually exclusive. |
Binary file
@@ -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": "2.0.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"
@@ -35,9 +35,9 @@ both. `--json` exists on every verb:
35
35
  Spawn, send, and read work on both. Agent state comes from murmur
36
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
@@ -174,6 +174,7 @@ Every turn:
174
174
  6. `mu task wait ... --first --on-stall exit --json`.
175
175
  7. Cherry-pick the closed worker's **new** commit(s), verify the MERGE
176
176
  (see below), return control. Do not barrier or loop in shell.
177
+ Only `CLOSED/done` ships; other closes: read the reason note.
177
178
  8. Repeat from `mu state`.
178
179
 
179
180
  ## Dispatch rules that prevent real failures
@@ -232,6 +233,10 @@ Every turn:
232
233
  5 timeout, 6 pane died.
233
234
  - **`task close --if-ready`** no-ops until every blocker is CLOSED; bare
234
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.
235
240
  - **For waits use `task wait`, not `log --tail`.** `--kind` is the operator's
236
241
  log-ledger channel; `--intent` is what mu recorded.
237
242
  - **`mu undo`** bare lists undoable actions with group ids; `<group>` previews;
@@ -332,7 +337,8 @@ mu task note <id> "FILES: ...\nDECISION: ...\nVERIFIED: ..."
332
337
  mu task close <id> --evidence "tests pass: ..." # LAST action
333
338
  ```
334
339
 
335
- 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 "..."`.
336
342
 
337
343
  ## Follow-on prompts
338
344