@martintrojer/mu 0.3.1 → 0.4.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.
package/dist/index.d.ts CHANGED
@@ -49,15 +49,6 @@ declare function defaultStateDir(): string;
49
49
  * MU_DB_PATH > <state-dir>/mu.db
50
50
  */
51
51
  declare function defaultDbPath(): string;
52
- /**
53
- * Per-workstream artifact directory: <state-dir>/workstreams/<workstream>/
54
- *
55
- * Created lazily by callers. 0.1.0 doesn't write to it yet — reserved
56
- * for future snapshots / tracing logs / forensic pane captures. The DB
57
- * stays canonical and shared; this directory is only for things that
58
- * naturally don't need cross-workstream queries.
59
- */
60
- declare function workstreamStateDir(workstream: string): string;
61
52
  /**
62
53
  * Open the mu database. Creates the parent directory and applies the schema
63
54
  * idempotently on every open. Safe to call from many short-lived processes
@@ -71,8 +62,6 @@ declare class SchemaTooOldError extends Error implements HasNextSteps {
71
62
  constructor(detectedVersion: number, requiredVersion: number);
72
63
  errorNextSteps(): NextStep[];
73
64
  }
74
- /** Test seam: ensure a workstream's artifact dir exists. Unused today. */
75
- declare function ensureWorkstreamStateDir(workstream: string): string;
76
65
  /** The schema version a fresh DB starts at. v7 drops the
77
66
  * `approvals` table on top of v6 (which added 5 archive_* tables
78
67
  * on top of v5's surrogate-PK substrate; docs/ARCHITECTURE.md §
@@ -221,7 +210,19 @@ interface NewSessionWithPaneOptions {
221
210
  * session left behind by a failed spawn.
222
211
  */
223
212
  declare function newSessionWithPane(name: string, opts: NewSessionWithPaneOptions): Promise<string>;
224
- /** Idempotent: succeeds even if the session is already gone. */
213
+ /**
214
+ * Idempotent: succeeds even if the session is already gone.
215
+ *
216
+ * Three swallowed shapes:
217
+ * - "can't find session: <name>" — session never existed.
218
+ * - "session not found" — alternate phrasing on some tmux builds.
219
+ * - "no server running on <path>" — the tmux server itself has exited
220
+ * (typical when the test suite runs against a private `tmux -L
221
+ * <socket>` server and the just-killed session was its last; tmux
222
+ * quietly shuts the server down). Without this, killSession would
223
+ * throw on the very next idempotent call — only visible under
224
+ * Layer 3 of bug_test_suite_flake_leaks_isolation.
225
+ */
225
226
  declare function killSession(name: string): Promise<void>;
226
227
  declare function listWindows(session?: string): Promise<TmuxWindow[]>;
227
228
  interface NewWindowOptions {
@@ -338,10 +339,22 @@ declare function enableMuPaneBordersForPane(paneId: string): Promise<void>;
338
339
  * The border is tmux chrome, not pane content: it doesn't scroll, it
339
340
  * survives copy-mode, and the inner CLI never sees it.
340
341
  *
341
- * Designed in roadmap-v0-2 hud_visual_cue_design (note #283); shipped
342
- * in hud_visual_cue_impl.
342
+ * Designed as the pane-border visual cue for mu-managed panes.
343
343
  */
344
344
  declare function enableMuPaneBorders(target: string): Promise<void>;
345
+ /**
346
+ * Look up the TTY device path for a pane (e.g. `/dev/ttys012` on macOS,
347
+ * `/dev/pts/3` on Linux). Used by `mu agent kick` to find the
348
+ * foreground process group on the pane's TTY so it can be signalled
349
+ * directly — `tmux send-keys C-c` does NOT propagate to wrapped
350
+ * subprocesses inside a CLI like pi/claude/codex (the CLI catches it
351
+ * itself and treats it as a UI input). The escape hatch is signalling
352
+ * the foreground pgid of the underlying TTY from outside the pane.
353
+ *
354
+ * Throws `PaneNotFoundError` when the pane id is invalid or the pane
355
+ * has vanished. Throws `TmuxError` on any other tmux failure.
356
+ */
357
+ declare function paneTTY(paneId: string): Promise<string>;
345
358
  declare function getPaneTitle(paneId: string): Promise<string | undefined>;
346
359
  /**
347
360
  * Read the title of the *current* pane (the one whose shell is running this
@@ -350,18 +363,6 @@ declare function getPaneTitle(paneId: string): Promise<string | undefined>;
350
363
  * protocol's zero-config identity step.
351
364
  */
352
365
  declare function currentPaneTitle(): Promise<string | undefined>;
353
- /**
354
- * Read the *current* pane's interior size (`pane_width` x `pane_height`)
355
- * via $TMUX_PANE. Returns undefined when not inside tmux or when the
356
- * tmux call fails. Used by `mu hud` to size its tables when stdout
357
- * isn't a TTY (e.g. when running under `watch -n 5 mu hud -w X` or
358
- * `tmux display-popup -E 'mu hud -w X'`, both of which strip TTY-ness
359
- * but still run inside a tmux pane whose dimensions matter).
360
- */
361
- declare function currentPaneSize(): Promise<{
362
- width: number;
363
- height: number;
364
- } | undefined>;
365
366
  /**
366
367
  * Extract the agent-name token from a (possibly composed) pane title.
367
368
  * mu's composeAgentTitle renders titles as `name · <glyph> · task_id`,
@@ -432,7 +433,6 @@ type tmux$1_TmuxWindow = TmuxWindow;
432
433
  declare const tmux$1_assertValidPaneId: typeof assertValidPaneId;
433
434
  declare const tmux$1_capturePane: typeof capturePane;
434
435
  declare const tmux$1_currentAgentName: typeof currentAgentName;
435
- declare const tmux$1_currentPaneSize: typeof currentPaneSize;
436
436
  declare const tmux$1_currentPaneTitle: typeof currentPaneTitle;
437
437
  declare const tmux$1_defaultSendDelayMs: typeof defaultSendDelayMs;
438
438
  declare const tmux$1_enableMuPaneBorders: typeof enableMuPaneBorders;
@@ -451,6 +451,7 @@ declare const tmux$1_newSession: typeof newSession;
451
451
  declare const tmux$1_newSessionWithPane: typeof newSessionWithPane;
452
452
  declare const tmux$1_newWindow: typeof newWindow;
453
453
  declare const tmux$1_paneExists: typeof paneExists;
454
+ declare const tmux$1_paneTTY: typeof paneTTY;
454
455
  declare const tmux$1_parseAgentNameFromTitle: typeof parseAgentNameFromTitle;
455
456
  declare const tmux$1_resetSleep: typeof resetSleep;
456
457
  declare const tmux$1_resetTmuxExecutor: typeof resetTmuxExecutor;
@@ -464,7 +465,7 @@ declare const tmux$1_sleep: typeof sleep;
464
465
  declare const tmux$1_splitWindow: typeof splitWindow;
465
466
  declare const tmux$1_tmux: typeof tmux;
466
467
  declare namespace tmux$1 {
467
- export { type tmux$1_CaptureOptions as CaptureOptions, type tmux$1_NewSessionOptions as NewSessionOptions, type tmux$1_NewSessionWithPaneOptions as NewSessionWithPaneOptions, type tmux$1_NewWindowOptions as NewWindowOptions, tmux$1_PANE_ID_RE as PANE_ID_RE, tmux$1_PaneNotFoundError as PaneNotFoundError, type tmux$1_SendOptions as SendOptions, type tmux$1_SplitWindowOptions as SplitWindowOptions, tmux$1_TmuxError as TmuxError, type tmux$1_TmuxExecResult as TmuxExecResult, type tmux$1_TmuxExecutor as TmuxExecutor, type tmux$1_TmuxPane as TmuxPane, type tmux$1_TmuxSession as TmuxSession, type tmux$1_TmuxWindow as TmuxWindow, tmux$1_assertValidPaneId as assertValidPaneId, tmux$1_capturePane as capturePane, tmux$1_currentAgentName as currentAgentName, tmux$1_currentPaneSize as currentPaneSize, tmux$1_currentPaneTitle as currentPaneTitle, tmux$1_defaultSendDelayMs as defaultSendDelayMs, tmux$1_enableMuPaneBorders as enableMuPaneBorders, tmux$1_enableMuPaneBordersForPane as enableMuPaneBordersForPane, tmux$1_enableMuPaneBordersForSession as enableMuPaneBordersForSession, tmux$1_getPaneTitle as getPaneTitle, tmux$1_getWindowIdForPane as getWindowIdForPane, tmux$1_isValidPaneId as isValidPaneId, tmux$1_killPane as killPane, tmux$1_killSession as killSession, tmux$1_listPanes as listPanes, tmux$1_listPanesInSession as listPanesInSession, tmux$1_listSessions as listSessions, tmux$1_listWindows as listWindows, tmux$1_newSession as newSession, tmux$1_newSessionWithPane as newSessionWithPane, tmux$1_newWindow as newWindow, tmux$1_paneExists as paneExists, tmux$1_parseAgentNameFromTitle as parseAgentNameFromTitle, tmux$1_resetSleep as resetSleep, tmux$1_resetTmuxExecutor as resetTmuxExecutor, tmux$1_selectLayout as selectLayout, tmux$1_sendToPane as sendToPane, tmux$1_sessionExists as sessionExists, tmux$1_setPaneTitle as setPaneTitle, tmux$1_setSleepForTests as setSleepForTests, tmux$1_setTmuxExecutor as setTmuxExecutor, tmux$1_sleep as sleep, tmux$1_splitWindow as splitWindow, tmux$1_tmux as tmux };
468
+ export { type tmux$1_CaptureOptions as CaptureOptions, type tmux$1_NewSessionOptions as NewSessionOptions, type tmux$1_NewSessionWithPaneOptions as NewSessionWithPaneOptions, type tmux$1_NewWindowOptions as NewWindowOptions, tmux$1_PANE_ID_RE as PANE_ID_RE, tmux$1_PaneNotFoundError as PaneNotFoundError, type tmux$1_SendOptions as SendOptions, type tmux$1_SplitWindowOptions as SplitWindowOptions, tmux$1_TmuxError as TmuxError, type tmux$1_TmuxExecResult as TmuxExecResult, type tmux$1_TmuxExecutor as TmuxExecutor, type tmux$1_TmuxPane as TmuxPane, type tmux$1_TmuxSession as TmuxSession, type tmux$1_TmuxWindow as TmuxWindow, tmux$1_assertValidPaneId as assertValidPaneId, tmux$1_capturePane as capturePane, tmux$1_currentAgentName as currentAgentName, tmux$1_currentPaneTitle as currentPaneTitle, tmux$1_defaultSendDelayMs as defaultSendDelayMs, tmux$1_enableMuPaneBorders as enableMuPaneBorders, tmux$1_enableMuPaneBordersForPane as enableMuPaneBordersForPane, tmux$1_enableMuPaneBordersForSession as enableMuPaneBordersForSession, tmux$1_getPaneTitle as getPaneTitle, tmux$1_getWindowIdForPane as getWindowIdForPane, tmux$1_isValidPaneId as isValidPaneId, tmux$1_killPane as killPane, tmux$1_killSession as killSession, tmux$1_listPanes as listPanes, tmux$1_listPanesInSession as listPanesInSession, tmux$1_listSessions as listSessions, tmux$1_listWindows as listWindows, tmux$1_newSession as newSession, tmux$1_newSessionWithPane as newSessionWithPane, tmux$1_newWindow as newWindow, tmux$1_paneExists as paneExists, tmux$1_paneTTY as paneTTY, tmux$1_parseAgentNameFromTitle as parseAgentNameFromTitle, tmux$1_resetSleep as resetSleep, tmux$1_resetTmuxExecutor as resetTmuxExecutor, tmux$1_selectLayout as selectLayout, tmux$1_sendToPane as sendToPane, tmux$1_sessionExists as sessionExists, tmux$1_setPaneTitle as setPaneTitle, tmux$1_setSleepForTests as setSleepForTests, tmux$1_setTmuxExecutor as setTmuxExecutor, tmux$1_sleep as sleep, tmux$1_splitWindow as splitWindow, tmux$1_tmux as tmux };
468
469
  }
469
470
 
470
471
  /**
@@ -481,9 +482,9 @@ declare namespace tmux$1 {
481
482
  * pane title — desired side-effects of a refresh) and
482
483
  * orphan surface. Does NOT prune (so a dead pane's
483
484
  * row stays visible until a real `mu agent list`) and
484
- * does NOT reap. Used by `mu state`, `mu hud`, bare
485
- * `mu`, and `mu agent attach` — the verbs an operator
486
- * polls to answer "is worker-X busy or idle right
485
+ * does NOT reap. Used by `mu state` and
486
+ * `mu agent attach` — the verbs an operator polls to
487
+ * answer "is worker-X busy or idle right
487
488
  * now?". Status detection skips placeholder agents
488
489
  * whose pane id starts with `%pending-` (mid-spawn,
489
490
  * no usable scrollback yet).
@@ -499,7 +500,7 @@ declare namespace tmux$1 {
499
500
  *
500
501
  * Surfaced live by bug_pane_title_glyph_stuck_at_needs_input: the
501
502
  * old `dryRun: boolean` flag conflated "don't prune" with "don't
502
- * detect status", so `mu state` / `mu hud` showed stale status
503
+ * detect status", so state-card pollers showed stale status
503
504
  * indefinitely. Splitting prune-suppression from status-suppression
504
505
  * is the fix.
505
506
  */
@@ -542,6 +543,52 @@ interface ReconcileReport {
542
543
  }
543
544
  declare function reconcile(db: Db, opts: ReconcileOptions): Promise<ReconcileReport>;
544
545
 
546
+ /**
547
+ * Pre-flight failure: the command mu would have spawned in the new
548
+ * pane doesn't resolve to a binary on PATH (and isn't an absolute /
549
+ * relative path that exists + is executable). Thrown by `spawnAgent`
550
+ * BEFORE `prestageWorkspace` so a typo in `--cli` never leaves an
551
+ * orphan workspace dir behind.
552
+ *
553
+ * Source: feedback ws task `fb_agent_spawn_no_validation`. Live
554
+ * dogfood report: `mu agent spawn worker-1 --cli pi-meta` on a host
555
+ * where the `pi-meta` binary wasn't on PATH printed `Spawned worker-1
556
+ * (pi-meta)` and the pane immediately died with `command not found`;
557
+ * the existing 1.5s liveness check sometimes missed it (the shell
558
+ * stays alive after the failed exec). Pre-flighting the PATH lookup
559
+ * surfaces the typo before any side effects (workspace, pane, DB row).
560
+ *
561
+ * Distinct from `AgentSpawnStartupError` (pane alive but parked at an
562
+ * error prompt) and `AgentDiedOnSpawnError` (pane vanished within the
563
+ * liveness window). All three carry different remediation hints, so
564
+ * they're separate types.
565
+ */
566
+ declare class AgentSpawnCliNotFoundError extends Error implements HasNextSteps {
567
+ readonly cli: string;
568
+ /** First whitespace-separated token of the resolved command — the
569
+ * thing actually missing on PATH. Surfaced verbatim in the
570
+ * message so the operator sees what mu searched for (which may
571
+ * differ from `cli` when `$MU_<UPPER_CLI>_COMMAND` rewrites it). */
572
+ readonly binary: string;
573
+ /** Name of the env var that mu consulted before falling back to
574
+ * the bare `cli` value (e.g. `MU_PI_META_COMMAND`). Always set
575
+ * to the conventional name so the nextSteps hint can recommend
576
+ * exporting it. */
577
+ readonly envVarChecked: string;
578
+ readonly name = "AgentSpawnCliNotFoundError";
579
+ constructor(cli: string,
580
+ /** First whitespace-separated token of the resolved command — the
581
+ * thing actually missing on PATH. Surfaced verbatim in the
582
+ * message so the operator sees what mu searched for (which may
583
+ * differ from `cli` when `$MU_<UPPER_CLI>_COMMAND` rewrites it). */
584
+ binary: string,
585
+ /** Name of the env var that mu consulted before falling back to
586
+ * the bare `cli` value (e.g. `MU_PI_META_COMMAND`). Always set
587
+ * to the conventional name so the nextSteps hint can recommend
588
+ * exporting it. */
589
+ envVarChecked: string);
590
+ errorNextSteps(): NextStep[];
591
+ }
545
592
  declare class AgentExistsError extends Error implements HasNextSteps {
546
593
  readonly agentName: string;
547
594
  readonly name = "AgentExistsError";
@@ -600,6 +647,52 @@ declare class AgentDiedOnSpawnError extends Error implements HasNextSteps {
600
647
  constructor(agentName: string, paneId: string, scrollback: string | undefined);
601
648
  errorNextSteps(): NextStep[];
602
649
  }
650
+ /**
651
+ * Thrown when an agent's pane is alive AND staying alive after the
652
+ * liveness window, but its first burst of output matches a known
653
+ * provider-startup-failure pattern (missing API key, auth rejected, …).
654
+ * Source: feedback ws task `agent_spawn_model_auth_failure_counts_as_live`.
655
+ * Live dogfood report: `pi-meta --no-solo --model sonnet:high` printed
656
+ * `Error: No API key found for amazon-bedrock` and parked at a prompt.
657
+ * The pane stayed alive (1.5s liveness check passed) but the worker
658
+ * could never do work — the orchestrator only discovered this when
659
+ * `mu task wait` stalled minutes later.
660
+ *
661
+ * Distinct from `AgentDiedOnSpawnError`:
662
+ * - `AgentDiedOnSpawnError` → pane vanished within the liveness window
663
+ * (CLI exited fast).
664
+ * - `AgentSpawnStartupError` → pane alive, but the captured scrollback
665
+ * tail contains a curated provider-auth-failure pattern.
666
+ * The two carry different remediation hints (CLI override vs. fix the
667
+ * env var), so they're separate types instead of one with a flag.
668
+ *
669
+ * The pattern list is curated and short to keep false-positive risk low
670
+ * — the scan only looks at the last ~30 lines of the 50-line capture
671
+ * taken right after the liveness sleep, so matches naturally come from
672
+ * the CLI's first ~1.5s of output (not arbitrary later prompts the
673
+ * agent might type into).
674
+ */
675
+ declare class AgentSpawnStartupError extends Error implements HasNextSteps {
676
+ readonly agentName: string;
677
+ readonly paneId: string;
678
+ /** The single scrollback line that matched a known startup-error
679
+ * pattern. Surfaced verbatim in the message so the operator sees
680
+ * what mu saw. */
681
+ readonly matchedLine: string;
682
+ /** Full captured scrollback (tail-trimmed already by
683
+ * awaitSpawnLiveness). Attached to the message for context. */
684
+ readonly scrollback: string;
685
+ readonly name = "AgentSpawnStartupError";
686
+ constructor(agentName: string, paneId: string,
687
+ /** The single scrollback line that matched a known startup-error
688
+ * pattern. Surfaced verbatim in the message so the operator sees
689
+ * what mu saw. */
690
+ matchedLine: string,
691
+ /** Full captured scrollback (tail-trimmed already by
692
+ * awaitSpawnLiveness). Attached to the message for context. */
693
+ scrollback: string);
694
+ errorNextSteps(): NextStep[];
695
+ }
603
696
  /**
604
697
  * Thrown when `closeAgent` is called on an agent that has an associated
605
698
  * workspace AND the caller didn't explicitly opt into discarding it.
@@ -651,8 +744,20 @@ interface CommitSummary {
651
744
  subject: string;
652
745
  /** Remainder of the commit message (may be empty). */
653
746
  body: string;
747
+ /** Author display name, when the backend exposes one. */
748
+ author: string;
654
749
  /** ISO-8601 author / commit timestamp. */
655
750
  authorDate: string;
751
+ /** Compact relative author time (e.g. "3m", "2d"). */
752
+ relTime: string;
753
+ }
754
+ interface ShowCommitResult {
755
+ /** Captured VCS show output (possibly truncated). Empty string on error. */
756
+ text: string;
757
+ /** True when stdout exceeded SHOW_COMMIT_MAX_CHARS and was clipped. */
758
+ truncated: boolean;
759
+ /** Human-readable error message; omitted on success. */
760
+ error?: string;
656
761
  }
657
762
  interface CreateWorkspaceOptions$1 {
658
763
  /** The repository being branched from. Absolute path. */
@@ -678,7 +783,7 @@ interface FreeWorkspaceOptions$1 {
678
783
  * needs an explicit commit on the worktree, sl needs `sl commit`,
679
784
  * none has nothing to commit. If pending changes exist and `commit`
680
785
  * is false, the on-disk directory still gets removed and changes are
681
- * lost \u2014 the verb prints a clear warning. */
786
+ * lost the verb prints a clear warning. */
682
787
  commit: boolean;
683
788
  }
684
789
  interface FreeWorkspaceResult$1 {
@@ -740,6 +845,31 @@ interface VcsBackend {
740
845
  * the on-disk dir without touching the agent or pane.
741
846
  */
742
847
  rebaseTo(workspacePath: string, fromRef?: string): Promise<RebaseResult>;
848
+ /**
849
+ * Cheap "is the working copy clean?" probe used by close-auto-free
850
+ * (allow_mu_agent_close_without_discard). Definition: ZERO uncommitted
851
+ * changes (no working-tree modifications, no staged changes, no
852
+ * untracked-not-ignored files). Pure observation; no fetch, no commit.
853
+ *
854
+ * Backend-specific:
855
+ * - git: empty `git status --porcelain` output.
856
+ * - jj: jj is auto-snapshotted, so the @ commit IS the WC; clean
857
+ * here means @ has no diff from its parent (empty `jj diff
858
+ * -r @ --summary`). A description-only difference still
859
+ * counts as clean.
860
+ * - sl: empty `sl status` output.
861
+ * - none: meaningless (cp -a snapshot has no notion of
862
+ * "committed" vs "uncommitted"); always returns true so the
863
+ * close-auto-free path treats every none-workspace as
864
+ * eligible for silent free (no commits can be lost; the only
865
+ * loss is local file edits, which the operator implicitly
866
+ * accepts by closing the agent).
867
+ *
868
+ * Returns false on any backend command failure — be conservative
869
+ * (we'd rather refuse a close than auto-free a workspace whose
870
+ * cleanliness we couldn't verify).
871
+ */
872
+ isClean(workspacePath: string): Promise<boolean>;
743
873
  /**
744
874
  * List commits the workspace has on top of `baseRef`, oldest-first.
745
875
  * Used by `mu workspace commits` (fb_workspace_commits_verb) to
@@ -754,11 +884,44 @@ interface VcsBackend {
754
884
  * on backend command failure (unknown ref, missing repo).
755
885
  */
756
886
  commitsSinceBase(workspacePath: string, baseRef: string): Promise<CommitSummary[]>;
887
+ /** Last N commits on the project root, newest-first. Used by the
888
+ * TUI Commits card / popup. Unlike commitsSinceBase, this is NOT
889
+ * a per-workspace since-fork query. */
890
+ recentCommits(projectRoot: string, limit: number): Promise<CommitSummary[]>;
891
+ /** Show one commit / change from the project root, capped for TUI
892
+ * rendering. Backend-specific equivalent of `git show <sha>`. */
893
+ showCommit(projectRoot: string, sha: string): Promise<ShowCommitResult>;
894
+ /**
895
+ * Return the list of dirty (uncommitted / unstaged / untracked-not-
896
+ * ignored) paths in the workspace. Empty array = clean.
897
+ *
898
+ * Used by `mu workspace recreate` to refuse a free+create cycle on
899
+ * a dirty workspace unless the operator passes `--force` (the lossy
900
+ * escape hatch). Mirrors the dirty-check `rebaseTo` does internally.
901
+ *
902
+ * Backend semantics:
903
+ * - git: `git status --porcelain` (working-tree + staged +
904
+ * untracked-not-ignored, mirroring the rebaseTo path).
905
+ * - sl: `sl status` parsed for non-empty output.
906
+ * - jj: always-snapshotted, so no concept of "dirty" — returns [].
907
+ * - none: cp -a snapshots have no VCS, so we can't decide "dirty";
908
+ * returns [] so the caller doesn't refuse for an unanswerable
909
+ * question.
910
+ *
911
+ * Throws on backend command failure (the operator should see a
912
+ * real error, not a silent "clean").
913
+ */
914
+ listDirtyFiles(workspacePath: string): Promise<string[]>;
757
915
  }
758
- declare const noneBackend: VcsBackend;
916
+
759
917
  declare const gitBackend: VcsBackend;
918
+
760
919
  declare const jjBackend: VcsBackend;
920
+
921
+ declare const noneBackend: VcsBackend;
922
+
761
923
  declare const slBackend: VcsBackend;
924
+
762
925
  /** Return the backend that should handle projectRoot. Walks BACKENDS
763
926
  * in precedence order; never returns undefined because noneBackend
764
927
  * always claims. */
@@ -779,6 +942,48 @@ declare function backendByName(name: VcsBackendName): VcsBackend;
779
942
  * are still recognised as agents.
780
943
  */
781
944
  declare function resolveCliCommand(cli: string): string;
945
+ /**
946
+ * Compute the `MU_<UPPER_CLI>_COMMAND` env var name mu consults when
947
+ * resolving `--cli <key>`. Hyphens in the cli key become underscores
948
+ * (env var names can't contain `-`); this matches the operator-aliases
949
+ * convention documented in the mu skill (e.g. `--cli pi-meta` →
950
+ * `MU_PI_META_COMMAND`).
951
+ */
952
+ declare function envVarNameForCli(cli: string): string;
953
+ /**
954
+ * Resolve `--cli <key>` to its actual command string AND tell the
955
+ * caller whether the resolution came from a `MU_<UPPER_CLI>_COMMAND`
956
+ * env var or fell through to the bare cli name. The CLI uses this to
957
+ * surface env-var attribution in the spawn-success line so config
958
+ * issues are visible without `mu agent show`
959
+ * (fb_agent_spawn_no_validation, part C).
960
+ */
961
+ declare function resolveCliCommandWithSource(cli: string): {
962
+ command: string;
963
+ envVar: string;
964
+ resolvedFromEnv: boolean;
965
+ };
966
+ interface CommandResolutionResult {
967
+ ok: boolean;
968
+ /** First whitespace-separated token of the command — the binary
969
+ * whose presence on PATH we checked. */
970
+ binary: string;
971
+ /** Absolute path of the resolved binary on PATH, when ok=true. */
972
+ resolvedPath?: string;
973
+ }
974
+ type CommandResolver = (command: string) => Promise<CommandResolutionResult>;
975
+ /** Override the PATH resolver. Tests use this to simulate "binary
976
+ * absent" / "binary present" without depending on what's actually
977
+ * installed. Production callers should never touch this. */
978
+ declare function setCommandResolverForTests(resolver: CommandResolver): void;
979
+ /** Restore the default PATH resolver. */
980
+ declare function resetCommandResolverForTests(): void;
981
+ /**
982
+ * Verify the first token of `command` resolves to a binary on PATH.
983
+ * Public so tests can call it directly; spawnAgent calls it before
984
+ * prestageWorkspace so a bad --cli never creates an orphan workspace.
985
+ */
986
+ declare function checkCommandResolvable(command: string): Promise<CommandResolutionResult>;
782
987
  interface SpawnAgentOptions {
783
988
  name: string;
784
989
  workstream: string;
@@ -899,6 +1104,119 @@ interface AdoptAgentResult {
899
1104
  */
900
1105
  declare function adoptAgent(db: Db, opts: AdoptAgentOptions): Promise<AdoptAgentResult>;
901
1106
 
1107
+ /** The signal set kick supports. SIGINT is graceful (matches Ctrl-C
1108
+ * semantics — what the operator probably wanted in the first place);
1109
+ * SIGTERM is the polite escalation; SIGKILL is the unblockable
1110
+ * hammer. We deliberately don't expose arbitrary signals — the
1111
+ * three above are the actionable ones for "interrupt a wedged
1112
+ * foreground tool subprocess." */
1113
+ type KickSignal = "SIGINT" | "SIGTERM" | "SIGKILL";
1114
+ declare function isKickSignal(s: string): s is KickSignal;
1115
+ /**
1116
+ * Thrown when the foreground pgid lookup on a pane's TTY yields
1117
+ * either no rows at all (the pane is sitting at an idle shell with
1118
+ * no foreground job) or only the wrapping shell itself (the LLM CLI
1119
+ * — pi/claude/codex — is the foreground; signalling it would close
1120
+ * the agent, which is what `mu agent close` is for).
1121
+ *
1122
+ * Maps to the generic exit code 1 in handle.ts (this is a
1123
+ * runtime-state condition, not a typed not-found / conflict).
1124
+ */
1125
+ declare class NoForegroundProcessError extends Error implements HasNextSteps {
1126
+ readonly agentName: string;
1127
+ readonly tty: string;
1128
+ readonly reason: "no-foreground" | "shell-only";
1129
+ readonly name = "NoForegroundProcessError";
1130
+ constructor(agentName: string, tty: string, reason: "no-foreground" | "shell-only");
1131
+ errorNextSteps(): NextStep[];
1132
+ }
1133
+ interface KickProcessExecResult {
1134
+ stdout: string;
1135
+ stderr: string;
1136
+ exitCode: number | null;
1137
+ }
1138
+ type KickProcessExecutor = (cmd: string, args: readonly string[]) => Promise<KickProcessExecResult>;
1139
+ /** Install a custom executor (for tests). Returns the previous one so
1140
+ * tests can restore cleanly. */
1141
+ declare function setKickProcessExecutor(executor: KickProcessExecutor): KickProcessExecutor;
1142
+ /** Restore the real executor. */
1143
+ declare function resetKickProcessExecutor(): void;
1144
+ interface PsRow {
1145
+ pid: number;
1146
+ pgid: number;
1147
+ /** ps's `stat` (or `state`) field. The presence of `+` means
1148
+ * "foreground process group on its controlling tty". */
1149
+ stat: string;
1150
+ /** Process command (just the comm; truncated, used for diagnostics). */
1151
+ comm: string;
1152
+ }
1153
+ /**
1154
+ * Parse `ps -t <tty> -o pid=,pgid=,stat=,comm=` output. Each non-blank
1155
+ * line is one process: four whitespace-separated fields. Defensive
1156
+ * about leading whitespace and command names with embedded spaces
1157
+ * (the comm is the LAST field — join the tail).
1158
+ */
1159
+ declare function parsePsTtyOutput(output: string): PsRow[];
1160
+ /**
1161
+ * Resolve the foreground process group id for a TTY device path. The
1162
+ * canonical signal `ps`'s `stat` field uses is `+` (BSD/Darwin AND
1163
+ * Linux procps). We pick the first row whose stat contains `+`; its
1164
+ * `pgid` is the foreground pgid of that controlling terminal.
1165
+ *
1166
+ * Returns:
1167
+ * - `{ kind: "ok", pgid, fgRow }` on success
1168
+ * - `{ kind: "no-foreground" }` when no row carries `+` AND there
1169
+ * are no candidate rows at all
1170
+ * - `{ kind: "shell-only", pgid, fgRow }` when the foreground pgid
1171
+ * resolves to a shell whose comm is the agent's wrapping CLI
1172
+ * (caller decides whether to refuse — kick refuses)
1173
+ *
1174
+ * The wrapping-CLI guard is intentionally narrow: we only refuse
1175
+ * when the foreground process command matches one of the known
1176
+ * pi/claude/codex/zsh/bash shapes. Anything else (a `find`, a
1177
+ * `cargo build`, a `python script.py`) is exactly what we want to
1178
+ * signal — that's the unbounded-tool case the verb was built for.
1179
+ */
1180
+ interface ForegroundLookup {
1181
+ kind: "ok" | "shell-only" | "no-foreground";
1182
+ pgid?: number;
1183
+ fgRow?: PsRow;
1184
+ /** All rows ps returned for the tty, for diagnostics / tests. */
1185
+ rows: PsRow[];
1186
+ }
1187
+ declare function foregroundPgid(tty: string): Promise<ForegroundLookup>;
1188
+ interface KickAgentOptions {
1189
+ workstream: string;
1190
+ /** Defaults to SIGINT (matches Ctrl-C semantics). */
1191
+ signal?: KickSignal;
1192
+ }
1193
+ interface KickAgentResult {
1194
+ agentName: string;
1195
+ paneId: string;
1196
+ /** TTY device path the foreground pgid was resolved against. */
1197
+ tty: string;
1198
+ /** The pgid we signalled. */
1199
+ signaledPgid: number;
1200
+ signal: KickSignal;
1201
+ /** The comm of the foreground process at the time of signal — useful
1202
+ * diagnostic in the event log ("we kicked a `find`, not a `cargo`"). */
1203
+ foregroundComm: string;
1204
+ }
1205
+ /**
1206
+ * Send `signal` to the foreground process group of an agent's pane
1207
+ * TTY. Default signal is SIGINT.
1208
+ *
1209
+ * Errors:
1210
+ * - `AgentNotFoundError` — the agent doesn't exist in this workstream.
1211
+ * - `PaneNotFoundError` (from paneTTY) — the agent's pane has vanished.
1212
+ * - `NoForegroundProcessError` — pane has no foreground job, OR the
1213
+ * foreground is the wrapping CLI itself (refuse; use `mu agent close`).
1214
+ *
1215
+ * Emits an `agent kick <name> (signal=..., pgid=..., comm=...)` event
1216
+ * on success.
1217
+ */
1218
+ declare function kickAgent(db: Db, name: string, opts: KickAgentOptions): Promise<KickAgentResult>;
1219
+
902
1220
  interface AgentRow {
903
1221
  name: string;
904
1222
  /** Foreign-name reference to the owning workstream. */
@@ -1025,14 +1343,17 @@ interface FreeAgentResult {
1025
1343
  declare function freeAgent(db: Db, name: string, workstream: string): FreeAgentResult;
1026
1344
  interface CloseAgentOptions {
1027
1345
  /**
1028
- * When true, free the agent's workspace BEFORE deleting the agent
1029
- * (so we control the order rather than relying on FK cascade, which
1030
- * leaves the on-disk dir orphaned). Lossy: any pending changes in
1031
- * the workspace are gone unless the caller frees with `--commit`
1032
- * separately first.
1346
+ * Lossy override: when true, free the agent's workspace BEFORE
1347
+ * deleting the agent regardless of whether it's clean. (We control
1348
+ * the order rather than relying on FK cascade, which leaves the
1349
+ * on-disk dir orphaned.) Any pending changes / commits since fork
1350
+ * are gone unless the caller frees with `--commit` separately first.
1033
1351
  *
1034
- * When false (default) and a workspace exists, throws
1035
- * WorkspacePreservedError so the caller has to decide explicitly.
1352
+ * When false (default), behaviour depends on workspace state:
1353
+ * - clean (no uncommitted changes AND no commits since fork):
1354
+ * silently auto-free. allow_mu_agent_close_without_discard.
1355
+ * - dirty (uncommitted changes OR commits since fork): throw
1356
+ * WorkspacePreservedError so the caller decides explicitly.
1036
1357
  * Surfaced as a real bug in the multi-agent dogfood teardown.
1037
1358
  */
1038
1359
  discardWorkspace?: boolean;
@@ -1040,11 +1361,20 @@ interface CloseAgentOptions {
1040
1361
  interface CloseAgentResult {
1041
1362
  killedPane: boolean;
1042
1363
  deletedRow: boolean;
1043
- /** True iff the agent had an associated workspace AND the caller
1044
- * passed `discardWorkspace: true` so we proactively freed it.
1045
- * False on the no-workspace path (nothing to free) and on the
1046
- * refused path (we threw before doing anything). */
1364
+ /** True iff the agent had an associated workspace AND we proactively
1365
+ * freed it either because the caller passed `discardWorkspace:
1366
+ * true` (lossy) or because the workspace was clean and we
1367
+ * auto-freed (allow_mu_agent_close_without_discard). False on the
1368
+ * no-workspace path (nothing to free) and on the refused path (we
1369
+ * threw before doing anything). */
1047
1370
  workspaceFreed: boolean;
1371
+ /** True iff `workspaceFreed` was triggered by the clean-workspace
1372
+ * auto-free path (no uncommitted changes AND no commits since
1373
+ * fork) rather than the explicit `discardWorkspace: true` override.
1374
+ * Lets the CLI render an accurate message ("auto-freed (clean)"
1375
+ * vs "workspace discarded") and gives JSON consumers a stable
1376
+ * signal. False on every other path. */
1377
+ workspaceAutoFreedClean: boolean;
1048
1378
  }
1049
1379
  /**
1050
1380
  * Close an agent: kill its tmux pane and remove its DB row. Idempotent:
@@ -1052,15 +1382,22 @@ interface CloseAgentResult {
1052
1382
  * - if the tmux pane is already gone, killPane swallows the error
1053
1383
  *
1054
1384
  * Workspace handling: closing an agent and freeing its workspace are
1055
- * separate concerns (agent lifecycle vs disk artifacts), so by default
1056
- * `closeAgent` REFUSES if the agent has a workspace — you'd otherwise
1057
- * orphan the on-disk dir (the FK cascade drops the registry row but
1058
- * not the directory). Two ways to proceed:
1059
- *
1060
- * 1. `freeWorkspace(db, name)` first, then `closeAgent(db, name)`.
1061
- * Preserves the option to `--commit` pending changes.
1062
- * 2. `closeAgent(db, name, { discardWorkspace: true })`. One-shot;
1063
- * lossy.
1385
+ * separate concerns (agent lifecycle vs disk artifacts). Three cases:
1386
+ *
1387
+ * - No workspace: close proceeds normally.
1388
+ * - Workspace exists AND is CLEAN (no uncommitted changes, no
1389
+ * commits since fork): silently auto-free (so a workspace that
1390
+ * contains nothing worth preserving doesn't make the operator
1391
+ * type --discard-workspace just to clean it up). Surfaced by
1392
+ * allow_mu_agent_close_without_discard a misconfigured-spawn
1393
+ * teardown was needlessly forced through the lossy flag.
1394
+ * - Workspace exists AND has either uncommitted changes OR commits
1395
+ * since fork: REFUSE with WorkspacePreservedError so the operator
1396
+ * decides explicitly. Two resolutions:
1397
+ * 1. `freeWorkspace(db, name)` first, then `closeAgent(db, name)`.
1398
+ * Preserves the option to `--commit` pending changes.
1399
+ * 2. `closeAgent(db, name, { discardWorkspace: true })`.
1400
+ * One-shot; lossy.
1064
1401
  *
1065
1402
  * The CLI surfaces these as the two actionable nextSteps on the
1066
1403
  * `WorkspacePreservedError` thrown by the refuse path.
@@ -1077,7 +1414,7 @@ interface ListLiveAgentsOptions {
1077
1414
  * documented mutating behaviour `mu agent list` has always had).
1078
1415
  *
1079
1416
  * Read-only callers split two ways:
1080
- * - `mu hud`, `mu state`, bare `mu`, `mu agent attach` →
1417
+ * - `mu state`, `mu agent attach` →
1081
1418
  * `"status-only"`: refresh status + title (writes to DB),
1082
1419
  * skip prune + reap. The operator's primary signal
1083
1420
  * (busy/needs_input) stays fresh without a periodic poll
@@ -1109,10 +1446,10 @@ interface LiveAgentsView {
1109
1446
  /**
1110
1447
  * Return the live, reality-reconciled view of agents in a workstream.
1111
1448
  * `mu agent list` calls this with `mode: "full"` (mutating); status
1112
- * pollers (`mu hud`, `mu state`, bare `mu`, `mu agent attach`) call
1113
- * it with `mode: "status-only"` to refresh status without pruning;
1114
- * read-only diagnostic / restore paths (`mu doctor`, `mu undo`)
1115
- * call it with `mode: "report-only"` to mutate nothing at all.
1449
+ * pollers (`mu state`, `mu agent attach`) call it with
1450
+ * `mode: "status-only"` to refresh status without pruning; read-only
1451
+ * diagnostic / restore paths (`mu doctor`, `mu undo`) call it with
1452
+ * `mode: "report-only"` to mutate nothing at all.
1116
1453
  */
1117
1454
  declare function listLiveAgents(db: Db, opts: ListLiveAgentsOptions): Promise<LiveAgentsView>;
1118
1455
 
@@ -1197,14 +1534,9 @@ interface AddToArchiveResult {
1197
1534
  skippedTasks: number;
1198
1535
  /** Number of new archived_edges rows actually inserted. */
1199
1536
  addedEdges: number;
1200
- /** Number of new archived_notes rows inserted. (Notes have no
1201
- * natural unique key, so this matches the count of notes attached
1202
- * to NEW archived_tasks rows; existing rows' notes are not
1203
- * duplicated because note copy is gated on at-least-one new task
1204
- * for the (archive, source_workstream) pair.) */
1537
+ /** Number of new archived_notes rows inserted. */
1205
1538
  addedNotes: number;
1206
- /** Number of new archived_events rows inserted (one per kind='event'
1207
- * agent_logs row in the source workstream). */
1539
+ /** Number of new archived_events rows inserted. */
1208
1540
  addedEvents: number;
1209
1541
  }
1210
1542
  interface RemoveFromArchiveResult {
@@ -1217,26 +1549,25 @@ interface RemoveFromArchiveResult {
1217
1549
  /** Number of archived_events rows directly deleted. */
1218
1550
  removedEvents: number;
1219
1551
  }
1552
+
1220
1553
  /**
1221
- * Create a new archive bucket. Throws `ArchiveAlreadyExistsError` if
1222
- * the label is already in use; throws `ArchiveLabelInvalidError` for
1223
- * malformed labels.
1554
+ * Add every task in `workstream` to the archive identified by `label`.
1224
1555
  *
1225
- * The archive starts EMPTY: created_at and last_added_at both equal
1226
- * now(). Use `addToArchive(label, workstream)` to populate it.
1227
- */
1228
- declare function createArchive(db: Db, label: string, description?: string): Archive;
1229
- /**
1230
- * List every archive on this machine, summarised with per-source-
1231
- * workstream counts. Sorted by label ascending. Pure read; safe to
1232
- * call against an empty DB (returns []).
1556
+ * Idempotency invariant: re-running with the same (label, workstream)
1557
+ * pair is a no-op for tasks already present. The
1558
+ * (archive_id, source_workstream, original_local_id) UNIQUE on
1559
+ * archived_tasks is the lever; we INSERT OR IGNORE and skip notes /
1560
+ * events for the (archive, source_workstream) pair entirely when the
1561
+ * task copy added zero new rows.
1233
1562
  */
1234
- declare function listArchives(db: Db): ArchiveSummary[];
1563
+ declare function addToArchive(db: Db, label: string, workstream: string): AddToArchiveResult;
1235
1564
  /**
1236
- * Look up a single archive by label. Throws `ArchiveNotFoundError`
1237
- * on miss.
1565
+ * Remove every row contributed by `sourceWorkstream` from the named
1566
+ * archive. Other source workstreams' contributions are untouched
1567
+ * (additive accumulation invariant).
1238
1568
  */
1239
- declare function getArchive(db: Db, label: string): ArchiveSummary;
1569
+ declare function removeFromArchive(db: Db, label: string, sourceWorkstream: string): RemoveFromArchiveResult;
1570
+
1240
1571
  /**
1241
1572
  * Delete an archive and every row that references it. The FK
1242
1573
  * CASCADE chain (archives → archived_tasks → archived_edges /
@@ -1245,57 +1576,35 @@ declare function getArchive(db: Db, label: string): ArchiveSummary;
1245
1576
  *
1246
1577
  * Idempotent: throws `ArchiveNotFoundError` rather than silently
1247
1578
  * succeeding on a missing label (operator confusion safeguard).
1248
- *
1249
- * Mirror of `destroyWorkstream`'s safety story but cheaper: archives
1250
- * have no on-disk artifacts (no tmux session, no workspaces). The
1251
- * pre-delete snapshot is the operator's recovery path if they run
1252
- * this verb by mistake (handled in the CLI wrapper, Phase 2).
1253
1579
  */
1254
1580
  declare function deleteArchive(db: Db, label: string): void;
1581
+
1255
1582
  /**
1256
- * Add every task in `workstream` to the archive identified by `label`.
1257
- *
1258
- * Idempotency invariant: re-running with the same (label, workstream)
1259
- * pair is a no-op for tasks already present. The
1260
- * (archive_id, source_workstream, original_local_id) UNIQUE on
1261
- * archived_tasks is the lever; we INSERT OR IGNORE and skip notes /
1262
- * events for the (archive, source_workstream) pair entirely when the
1263
- * task copy added zero new rows. This makes addToArchive
1264
- * coarse-grained idempotent: the only way to get duplicate notes is
1265
- * to add a NEW task to the source workstream and re-run, which
1266
- * legitimately copies the new task's notes.
1267
- *
1268
- * Throws:
1269
- * - `ArchiveNotFoundError` if the label doesn't exist (call
1270
- * `createArchive` first).
1271
- * - `WorkstreamNotFoundError` if the source workstream is gone
1272
- * (you must archive BEFORE destroy).
1583
+ * Create a new archive bucket. Throws `ArchiveAlreadyExistsError` if
1584
+ * the label is already in use; throws `ArchiveLabelInvalidError` for
1585
+ * malformed labels.
1273
1586
  *
1274
- * The whole operation runs in a transaction so a partial failure
1275
- * leaves the archive untouched.
1587
+ * The archive starts EMPTY: created_at and last_added_at both equal
1588
+ * now(). Use `addToArchive(label, workstream)` to populate it.
1276
1589
  */
1277
- declare function addToArchive(db: Db, label: string, workstream: string): AddToArchiveResult;
1590
+ declare function createArchive(db: Db, label: string, description?: string): Archive;
1278
1591
  /**
1279
- * Remove every row contributed by `sourceWorkstream` from the named
1280
- * archive. Other source workstreams' contributions are untouched
1281
- * (additive accumulation invariant). Throws `ArchiveNotFoundError`
1282
- * if the label doesn't exist; returns all-zero counts (no error)
1283
- * when the source workstream never contributed to this archive.
1592
+ * List every archive on this machine, summarised with per-source-
1593
+ * workstream counts. Sorted by label ascending. Pure read; safe to
1594
+ * call against an empty DB (returns []).
1284
1595
  */
1285
- declare function removeFromArchive(db: Db, label: string, sourceWorkstream: string): RemoveFromArchiveResult;
1596
+ declare function listArchives(db: Db): ArchiveSummary[];
1597
+ /**
1598
+ * Look up a single archive by label. Throws `ArchiveNotFoundError`
1599
+ * on miss.
1600
+ */
1601
+ declare function getArchive(db: Db, label: string): ArchiveSummary;
1286
1602
  interface ListArchivedTasksOptions {
1287
1603
  /** Filter by source workstream. Omit to return every source's
1288
1604
  * contribution, sorted by (source_workstream, original_local_id). */
1289
1605
  sourceWorkstream?: string;
1290
1606
  }
1291
- /**
1292
- * List archived task rows in a single archive. Throws
1293
- * `ArchiveNotFoundError` on missing label.
1294
- *
1295
- * Default order: source_workstream ASC, then original_local_id ASC,
1296
- * so the output is deterministic and groups each workstream's
1297
- * contribution together.
1298
- */
1607
+ declare function listArchivedTasks(db: Db, label: string, opts?: ListArchivedTasksOptions): ArchivedTaskRow[];
1299
1608
  interface ArchiveSearchHit {
1300
1609
  /** Operator-facing label of the parent archive. */
1301
1610
  archiveLabel: string;
@@ -1315,44 +1624,17 @@ interface ArchiveSearchHit {
1315
1624
  matchSnippet: string;
1316
1625
  }
1317
1626
  interface SearchArchivesOptions {
1318
- /** LIKE-style needle. Wrapped in `%…%` automatically; `_` and `%`
1319
- * inside the pattern are still SQL LIKE wildcards (matches the
1320
- * `searchTasks` convention in src/tasks.ts). Empty / whitespace-
1321
- * only patterns throw — the CLI is the canonical caller and
1322
- * enforces it via UsageError before we get here, but the SDK
1323
- * guards it too so direct programmatic callers don't accidentally
1324
- * match every row. */
1627
+ /** LIKE-style needle. Wrapped in `%…%` automatically. */
1325
1628
  pattern: string;
1326
1629
  /** Restrict to one archive label; undefined = search every
1327
1630
  * archive. Throws ArchiveNotFoundError on miss. */
1328
1631
  label?: string;
1329
1632
  /** Cap on hits returned. Default 50; values below 1 fall back to
1330
- * the default. There is no `--all` escape hatch — for unbounded
1331
- * exports use `mu sql`. */
1633
+ * the default. */
1332
1634
  limit?: number;
1333
1635
  }
1334
- /**
1335
- * LIKE-search archived task titles AND archived note content. The
1336
- * pattern is bound as a SQL parameter (never concatenated): an
1337
- * archive label like `'); DROP TABLE archives; --` round-trips
1338
- * through `?` without touching the DDL surface.
1339
- *
1340
- * Behaviour:
1341
- * - One row per (archive, source_workstream, original_local_id)
1342
- * pair. When a task matches via BOTH title and note, the title
1343
- * row wins (matchKind='title'); only note matches stand on
1344
- * their own as matchKind='note'.
1345
- * - With `opts.label`, restricts to that archive. Resolves the
1346
- * label up-front via the helper; throws ArchiveNotFoundError
1347
- * on miss.
1348
- * - Results sorted by (archive label, source workstream,
1349
- * original_local_id) — the same order `mu archive show` uses,
1350
- * so a search hit lines up with the show output.
1351
- * - `limit` defaults to 50 and caps the result set. There is no
1352
- * unbounded mode (use `mu sql` for raw extracts).
1353
- */
1636
+ /** LIKE-search archived task titles AND archived note content. */
1354
1637
  declare function searchArchives(db: Db, opts: SearchArchivesOptions): ArchiveSearchHit[];
1355
- declare function listArchivedTasks(db: Db, label: string, opts?: ListArchivedTasksOptions): ArchivedTaskRow[];
1356
1638
 
1357
1639
  type TaskStatus = "OPEN" | "IN_PROGRESS" | "CLOSED" | "REJECTED" | "DEFERRED";
1358
1640
  /** Every legal task status, in canonical order (matches the schema
@@ -1376,878 +1658,1207 @@ declare function isTaskStatus(s: string): s is TaskStatus;
1376
1658
  * doesn't leave stale lists rotting in the CLI surface. */
1377
1659
  declare const TASK_STATUS_LIST: string;
1378
1660
 
1379
- declare class TaskNotFoundError extends Error implements HasNextSteps {
1380
- readonly taskId: string;
1381
- readonly name = "TaskNotFoundError";
1382
- constructor(taskId: string);
1383
- errorNextSteps(): NextStep[];
1661
+ interface TaskRow {
1662
+ /** Per-workstream-unique TEXT name. The operator-facing identifier. */
1663
+ name: string;
1664
+ /** Foreign-name reference to the owning workstream. */
1665
+ workstreamName: string;
1666
+ title: string;
1667
+ status: TaskStatus;
1668
+ impact: number;
1669
+ effortDays: number;
1670
+ /** Foreign-name reference to the owning agent (NULL when unowned). */
1671
+ ownerName: string | null;
1672
+ createdAt: string;
1673
+ updatedAt: string;
1384
1674
  }
1385
- declare class TaskExistsError extends Error implements HasNextSteps {
1386
- readonly taskId: string;
1387
- readonly name = "TaskExistsError";
1388
- constructor(taskId: string);
1389
- errorNextSteps(): NextStep[];
1675
+ interface TaskNoteRow {
1676
+ author: string | null;
1677
+ content: string;
1678
+ createdAt: string;
1390
1679
  }
1391
- /**
1392
- * Thrown when a verb is invoked with `-w/--workstream <name>` but the
1393
- * named task lives in a different workstream. Distinguishes "the user
1394
- * typo'd the workstream" from "the task doesn't exist anywhere"
1395
- * (which surfaces as `TaskNotFoundError`). Maps to exit code 4
1396
- * (conflict / wrong scope).
1397
- */
1398
- declare class TaskNotInWorkstreamError extends Error implements HasNextSteps {
1399
- readonly taskId: string;
1400
- readonly expectedWorkstream: string;
1401
- readonly actualWorkstream: string;
1402
- readonly name = "TaskNotInWorkstreamError";
1403
- constructor(taskId: string, expectedWorkstream: string, actualWorkstream: string);
1404
- errorNextSteps(): NextStep[];
1680
+
1681
+ interface TaskEdges {
1682
+ /** Tasks that must close before this one can start (blockers). */
1683
+ blockers: string[];
1684
+ /** Tasks that this one blocks (dependents). */
1685
+ dependents: string[];
1405
1686
  }
1406
- declare class TaskAlreadyOwnedError extends Error implements HasNextSteps {
1407
- readonly taskId: string;
1408
- readonly currentOwner: string;
1409
- readonly name = "TaskAlreadyOwnedError";
1410
- constructor(taskId: string, currentOwner: string);
1411
- errorNextSteps(): NextStep[];
1687
+ /** One end of an edge with the neighbour's current status attached.
1688
+ * Used by `mu task show` to group blockers/dependents into
1689
+ * "still gating" vs "satisfied" buckets without making the renderer
1690
+ * do a second round-trip to the DB per neighbour. */
1691
+ interface TaskEdgeWithStatus {
1692
+ name: string;
1693
+ status: TaskStatus;
1694
+ }
1695
+ interface TaskEdgesWithStatus {
1696
+ /** Tasks that must close before this one can start (blockers),
1697
+ * carrying each blocker's current status. */
1698
+ blockers: TaskEdgeWithStatus[];
1699
+ /** Tasks that this one blocks (dependents), carrying each
1700
+ * dependent's current status. */
1701
+ dependents: TaskEdgeWithStatus[];
1412
1702
  }
1413
1703
  /**
1414
- * Thrown by `rejectTask` / `deferTask` when the target task has
1415
- * dependents that are still OPEN or IN_PROGRESS. Rejecting or
1416
- * deferring such a task would silently strand the dependents (they'd
1417
- * remain blocked by a prereq that's never going to satisfy the edge),
1418
- * so we refuse and force an explicit decision: pass `--cascade` to
1419
- * apply the same status to every transitive dependent, drop the
1420
- * blocking edge first with `mu task unblock`, or address the
1421
- * dependents individually. Maps to exit code 4.
1704
+ * Direct (one-hop) edges for a task. For transitive prerequisites, use
1705
+ * `getPrerequisites()`; this helper is the immediate-neighbour view used
1706
+ * by `mu task show`.
1422
1707
  */
1423
- declare class TaskHasOpenDependentsError extends Error implements HasNextSteps {
1424
- readonly taskId: string;
1425
- readonly verb: "reject" | "defer";
1426
- readonly dependents: readonly string[];
1427
- readonly name = "TaskHasOpenDependentsError";
1428
- constructor(taskId: string, verb: "reject" | "defer", dependents: readonly string[]);
1429
- errorNextSteps(): NextStep[];
1708
+ declare function getTaskEdges(db: Db, taskLocalId: string, workstream: string): TaskEdges;
1709
+ /**
1710
+ * Same one-hop edge view as `getTaskEdges`, but each neighbour is
1711
+ * returned as `{ name, status }` so callers can group / colour by
1712
+ * status without an N+1 round-trip. Used by `mu task show` to split
1713
+ * "blocked by" (still-gating) from "satisfied" (already-CLOSED)
1714
+ * blockers, and the symmetric split on the dependents side
1715
+ * (task_show_blocked_by_renders_closed). The status is the neighbour's
1716
+ * full TaskStatus, not just OPEN/CLOSED — REJECTED/DEFERRED still
1717
+ * gate downstream work, so the renderer keeps them in the
1718
+ * still-gating bucket.
1719
+ */
1720
+ declare function getTaskEdgesWithStatus(db: Db, taskLocalId: string, workstream: string): TaskEdgesWithStatus;
1721
+ /**
1722
+ * All tasks transitively reachable from `taskId` via reverse-edge
1723
+ * traversal (i.e. the set of tasks that block this one), including the
1724
+ * task itself.
1725
+ */
1726
+ declare function getPrerequisites(db: Db, taskLocalId: string, workstream: string): Set<string>;
1727
+ interface BlockEdgeResult {
1728
+ /** True iff a row was actually inserted (vs. already present). */
1729
+ added: boolean;
1430
1730
  }
1431
1731
  /**
1432
- * Thrown when `mu task claim` resolves a claimer agent name (from the
1433
- * pane title or --for) that has no matching row in the agents table.
1434
- *
1435
- * The FK on `tasks.owner` references `agents.name`; without this guard
1436
- * the claim attempt would fail with the unhelpful 'FOREIGN KEY constraint
1437
- * failed' from SQLite. This typed error gives the user actionable next
1438
- * steps (run `mu adopt <pane-id>` to register, or use --for to pick a
1439
- * different agent).
1732
+ * Add the edge `blocker blocked` ('blocker blocks blocked').
1733
+ * Idempotent (existing edge `added: false`). Validates:
1440
1734
  *
1441
- * Maps to exit code 4 (conflict) via the cli.ts handler.
1735
+ * - both tasks exist
1736
+ * - same workstream (cross-workstream edges forbidden)
1737
+ * - no cycle (the new edge wouldn't form a path blocked → ... → blocker)
1738
+ * - blocker ≠ blocked (no self-reference)
1442
1739
  */
1443
- declare class ClaimerNotRegisteredError extends Error implements HasNextSteps {
1444
- readonly agentName: string;
1445
- readonly paneId: string | null;
1446
- readonly name = "ClaimerNotRegisteredError";
1447
- constructor(agentName: string, paneId: string | null);
1448
- /**
1449
- * Three actionable resolutions in expected-frequency order:
1450
- * 1. --self : orchestrator pattern (working directly)
1451
- * 2. --for : dispatcher pattern (assigning to a worker)
1452
- * 3. mu adopt: registration pattern (promote pane to worker)
1453
- */
1454
- errorNextSteps(): NextStep[];
1455
- }
1456
- declare class CycleError extends Error implements HasNextSteps {
1457
- readonly from: string;
1458
- readonly to: string;
1459
- readonly name = "CycleError";
1460
- constructor(from: string, to: string);
1461
- errorNextSteps(): NextStep[];
1740
+ declare function addBlockEdge(db: Db, workstream: string, blocked: string, blocker: string): BlockEdgeResult;
1741
+ interface RemoveBlockEdgeResult {
1742
+ /** True iff a row was actually deleted (vs. no such edge). */
1743
+ removed: boolean;
1462
1744
  }
1463
- declare class CrossWorkstreamEdgeError extends Error implements HasNextSteps {
1464
- readonly blocker: string;
1465
- readonly blockerWorkstream: string;
1466
- readonly dependent: string;
1467
- readonly dependentWorkstream: string;
1468
- readonly name = "CrossWorkstreamEdgeError";
1469
- constructor(blocker: string, blockerWorkstream: string, dependent: string, dependentWorkstream: string);
1470
- errorNextSteps(): NextStep[];
1745
+ /**
1746
+ * Remove the edge `blocker → blocked`. Idempotent (no edge →
1747
+ * `removed: false`). Does NOT validate task existence — if the
1748
+ * edge is gone there's nothing to do, regardless of whether the
1749
+ * tasks are gone too.
1750
+ */
1751
+ declare function removeBlockEdge(db: Db, workstream: string, blocked: string, blocker: string): RemoveBlockEdgeResult;
1752
+ interface ReparentTaskResult {
1753
+ /** Edges removed (i.e. all incoming `to_task = taskId` edges). */
1754
+ removedEdges: number;
1755
+ /** Edges added (after duplicate blockers are canonicalised). */
1756
+ addedEdges: number;
1471
1757
  }
1758
+ /**
1759
+ * Atomically replace every incoming edge of `taskId` with new ones
1760
+ * `blocker[i] → taskId`. Pass an empty `blockers` array to clear all
1761
+ * incoming edges (the task becomes ready iff its status allows).
1762
+ *
1763
+ * Validates ALL new blockers up-front (existence + same workstream +
1764
+ * cycle check); if any fails, no DELETE happens — the call is fully
1765
+ * atomic via a single transaction.
1766
+ *
1767
+ * Cycle reasoning: removing the existing incoming edges to `taskId`
1768
+ * doesn't change `taskId`'s OUTGOING reachability, so
1769
+ * `wouldCreateCycle(db, blocker, taskId)` evaluated against the
1770
+ * pre-state gives the right answer for each new edge.
1771
+ */
1772
+ declare function reparentTask(db: Db, taskLocalId: string, blockers: readonly string[], scope: {
1773
+ workstream: string;
1774
+ }): ReparentTaskResult;
1472
1775
 
1473
- declare function setWaitSleepForTests(impl: ((ms: number) => Promise<void>) | undefined): (ms: number) => Promise<void>;
1474
- /** Test seam: swap the stderr writer used by the stuck-task warning so
1475
- * unit tests can capture warnings without spying on process.stderr. */
1476
- declare function setWaitStuckWarnForTests(impl: ((msg: string) => void) | undefined): (msg: string) => void;
1477
- /** Total number of polls performed across all `waitForTasks` calls in this
1478
- * process. Tests typically reset before exercising and read after. */
1479
- declare function getWaitPollCount(): number;
1480
- declare function resetWaitPollCount(): void;
1481
- /** A single task ref the wait verb is watching. Cross-workstream
1482
- * waits arrive as a heterogeneous list of (workstream, name) pairs;
1483
- * the legacy single-workstream call passes the same workstream on
1484
- * every ref. task_wait_cross_workstream. */
1485
- interface TaskWaitRef {
1486
- /** The workstream the task lives in. Each ref carries its own so
1487
- * the SDK doesn't need a single "the workstream" — cross-ws waits
1488
- * pass refs from multiple workstreams in one call. */
1489
- workstreamName: string;
1490
- /** The task's per-workstream-unique local id. */
1491
- name: string;
1776
+ interface AddTaskOptions {
1777
+ localId: string;
1778
+ workstream: string;
1779
+ title: string;
1780
+ /** 1..100; enforced by schema CHECK. */
1781
+ impact: number;
1782
+ /** > 0; enforced by schema CHECK. */
1783
+ effortDays: number;
1784
+ /**
1785
+ * Tasks that block this one. Edges inserted as `blocker -> newTask`.
1786
+ * Each blocker must already exist AND share this task's workstream
1787
+ * (cross-workstream edges are forbidden); cycle check guards each
1788
+ * edge. The CLI surfaces this as `--blocked-by`; the SDK key matches.
1789
+ */
1790
+ blockedBy?: string[];
1492
1791
  }
1493
- interface TaskWaitOptions {
1494
- /** Target status. Default 'CLOSED'. */
1495
- status?: TaskStatus;
1496
- /** When true, succeed as soon as ONE listed task reaches the target.
1497
- * Default false: every listed task must reach the target. */
1498
- any?: boolean;
1499
- /** Maximum time to wait, in milliseconds. Default 600_000 (10 min).
1500
- * Pass 0 to wait forever. */
1501
- timeoutMs?: number;
1502
- /** Polling interval. Default 1000ms; overridable for tests. */
1503
- pollMs?: number;
1504
- /** Workstream context applied to bare-string ids. Required when the
1505
- * caller passes `string[]`; ignored when the caller passes
1506
- * `TaskWaitRef[]` (each ref carries its own ws). The legacy
1507
- * single-ws SDK call site keeps its today's shape; the cross-ws
1508
- * callers (CLI verb) pass `TaskWaitRef[]` and omit `workstream`.
1509
- * task_wait_cross_workstream. */
1510
- workstream?: string;
1511
- /** Emit a yellow STUCK warning to stderr (once per task per wait call)
1512
- * when an IN_PROGRESS task's owner has been in `needs_input` for at
1513
- * least this many milliseconds since the agent row's last update.
1514
- * Default 300_000 (5 min). Pass 0 to disable.
1515
- *
1516
- * Surfaced by agent_close_discipline_gap in mufeedback: workers
1517
- * occasionally finish + commit + go idle without running
1518
- * `mu task close <id>`, leaving wait blocked indefinitely. The
1519
- * warning is observation-only — wait keeps polling so the operator
1520
- * (or a wrapping policy) decides whether to force-close, re-prompt,
1521
- * or escalate. */
1522
- stuckAfterMs?: number;
1523
- /** What to do when the `--stuck-after` predicate fires on a watched
1524
- * task. `'warn'` (default) = today's behaviour: yellow STUCK line
1525
- * to stderr (deduped per task per wait call) + corroborating
1526
- * `kind='event'` agent_logs row; wait keeps polling. `'exit'` =
1527
- * same emit + persist, but THEN throw `StallDetectedDuringWaitError`
1528
- * so the CLI wrapper exits 7 (STALL_DETECTED). The exit-action is
1529
- * the unattended-orchestrator escape: a wrapping policy can branch
1530
- * on 7 (idle, ambiguous — operator decides poke vs release) vs 6
1531
- * (dead pane, unambiguous — re-dispatch).
1532
- *
1533
- * Carve-out (lives at the call site, not here): the CLI passes
1534
- * `'exit'` only when the wait target is CLOSED — mirrors exit-6's
1535
- * reaper-flip suppression. With `--status OPEN` the worker reaching
1536
- * needs_input might BE the success path. See
1537
- * task_wait_stall_action_flag. */
1538
- onStall?: "warn" | "exit";
1539
- /** Optional async hook run BEFORE every snapshot (initial + each
1540
- * poll iteration). The CLI uses this to reconcile the workstream
1541
- * each tick (reaper flips IN_PROGRESS → OPEN for dead-pane
1542
- * workers) and to throw a typed error when a reaper-flip on a
1543
- * watched task should abandon the wait — see
1544
- * task_wait_reconcile_dead_panes. Throwing from `beforePoll`
1545
- * propagates out of `waitForTasks` unchanged.
1546
- *
1547
- * Kept as a generic seam (not a `--reconcile`-shaped option) so
1548
- * the SDK module stays free of tmux/reconcile imports — that
1549
- * layering belongs above the SDK in the CLI wrapper. */
1550
- beforePoll?: () => Promise<void>;
1792
+ /**
1793
+ * Atomically create a task and (optionally) its incoming blocked-by
1794
+ * edges.
1795
+ *
1796
+ * The task insert + every edge insert + cycle check happen inside one
1797
+ * SQLite transaction. If any blocker is missing or any edge would
1798
+ * create a cycle, the entire add rolls back.
1799
+ *
1800
+ * Cycle check for `addTask` is structurally trivial (a fresh task has
1801
+ * no outgoing edges, so `to -> ... -> from` is impossible). It's still
1802
+ * called here so the same primitive is exercised by tests.
1803
+ */
1804
+ declare function addTask(db: Db, opts: AddTaskOptions): TaskRow;
1805
+ interface AddNoteOptions {
1806
+ /** Free-form author label. Convention: agent name, "user", or "orchestrator". */
1807
+ author?: string;
1808
+ /** Workstream context (operator-facing name). v5: tasks.local_id is
1809
+ * per-workstream unique, so this is required to disambiguate. */
1810
+ workstream: string;
1551
1811
  }
1552
- interface TaskWaitTaskState {
1553
- /** The workstream this task lives in. Cross-workstream waits
1554
- * return a mixed list; the workstream is part of identity.
1555
- * task_wait_cross_workstream. */
1556
- workstreamName: string;
1557
- /** The task's per-workstream-unique name. */
1558
- name: string;
1559
- /** Current status (at the moment we exit). */
1560
- status: TaskStatus;
1561
- /** Owner at exit time (NULL when unowned, after release, or after
1562
- * the reaper flipped IN_PROGRESS OPEN due to a dead pane). */
1563
- owner: string | null;
1564
- /** True when this task's status equals the target. */
1565
- reachedTarget: boolean;
1566
- /** True when the task is IN_PROGRESS, owned by a registered agent
1567
- * whose detected status is `needs_input` for >= `stuckAfterMs`.
1568
- * Surfaces the agent_close_discipline_gap pattern: worker finished +
1569
- * committed but skipped `mu task close <id>`. Backwards-compatible
1570
- * signal callers ignoring it see no behaviour change. */
1571
- stuck: boolean;
1812
+ declare function addNote(db: Db, taskLocalId: string, content: string, opts: AddNoteOptions): {
1813
+ author: string | null;
1814
+ content: string;
1815
+ createdAt: string;
1816
+ };
1817
+ interface DeleteTaskResult {
1818
+ /** True iff the row existed and was deleted. False on a dry-run
1819
+ * (preview) AND on the idempotent missing-row case. */
1820
+ deleted: boolean;
1821
+ /** Number of `task_edges` rows cascaded out (informational). On a
1822
+ * dry-run, this is the would-be count. */
1823
+ deletedEdges: number;
1824
+ /** Number of `task_notes` rows cascaded out (informational). On a
1825
+ * dry-run, this is the would-be count. */
1826
+ deletedNotes: number;
1827
+ /** True iff this was a dry-run (`opts.dryRun: true`). On a
1828
+ * dry-run `deleted` is false and the counts are the would-be
1829
+ * counts; the DB is unchanged. Always false on a commit / on a
1830
+ * missing-row idempotent no-op. */
1831
+ dryRun: boolean;
1832
+ /** True iff a matching task row was found at the time of the
1833
+ * call. Discriminator for the CLI: a dry-run that found nothing
1834
+ * (`present: false`) renders differently from a dry-run that
1835
+ * found an existing task with zero edges and zero notes
1836
+ * (`present: true, deletedEdges: 0, deletedNotes: 0`). */
1837
+ present: boolean;
1572
1838
  }
1573
- interface TaskWaitResult {
1574
- /** Per-task state at exit time. Same length and order as the input list. */
1575
- tasks: TaskWaitTaskState[];
1576
- /** True when EVERY task reached the target (the --all condition). */
1577
- allReached: boolean;
1578
- /** True when AT LEAST ONE task reached the target (the --any condition). */
1579
- anyReached: boolean;
1580
- /** Wall-clock time spent waiting, in ms (always >= 0). */
1581
- elapsedMs: number;
1582
- /** True when we exited because of the timeout, not because the wait
1583
- * condition was met. allReached / anyReached can still be true on
1584
- * partial progress when timedOut is true. */
1585
- timedOut: boolean;
1839
+ interface DeleteTaskOptions {
1840
+ /** When true, return the cascade preview (would-be edge / note
1841
+ * counts) without mutating and without snapshotting. The CLI uses
1842
+ * this to power the bare `mu task delete <id>` two-phase pattern
1843
+ * (mirrors `mu workstream destroy` / `mu archive delete` /
1844
+ * `mu snapshot prune`). Surfaced by feedback ws task
1845
+ * fb_task_delete_no_yes (impact=30): a dogfood report typed
1846
+ * `mu task delete X --yes` (mirroring workstream destroy) and got
1847
+ * 'unknown option --yes' — the verb took no confirmation flag at
1848
+ * all. Two failed deletes left long-named tasks lingering. */
1849
+ dryRun?: boolean;
1586
1850
  }
1587
1851
  /**
1588
- * Block until a set of tasks reaches `opts.status` (default CLOSED).
1589
- * Returns a result describing the final state the caller decides
1590
- * whether to treat partial-progress timeouts as success or failure
1591
- * (the CLI maps a clean exit to 0, a timeout to 5).
1852
+ * Delete a task. FK CASCADE on `task_edges` (from + to) and
1853
+ * `task_notes` cleans the joined rows automatically. Idempotent on
1854
+ * a missing task (returns `deleted: false`).
1592
1855
  *
1593
- * Pre-flight: every task in `localIds` MUST exist; missing ones throw
1594
- * TaskNotFoundError before any waiting begins. This is loud-fail by
1595
- * design — a typo'd id silently waiting forever is the worst-case UX.
1856
+ * Pre-counts the cascade victims for reporting because SQLite's
1857
+ * `changes()` only reports rows directly affected by the DELETE.
1858
+ *
1859
+ * With `opts.dryRun: true`, returns the would-be counts without
1860
+ * touching the DB and without taking a snapshot (no mutation = no
1861
+ * snapshot — same reasoning that gates the closeTask snap on the
1862
+ * idempotent no-op path). The CLI bare `mu task delete <id>` form
1863
+ * uses this; `--yes` calls through with `dryRun: false`.
1596
1864
  */
1597
- declare function waitForTasks(db: Db, input: readonly TaskWaitRef[] | readonly string[], opts: TaskWaitOptions): Promise<TaskWaitResult>;
1598
-
1599
- interface SetStatusResult {
1600
- /** Status before the call. */
1601
- previousStatus: TaskStatus;
1602
- /** Status after the call (== requested status). */
1603
- status: TaskStatus;
1604
- /** True iff the row actually changed. False on idempotent no-op. */
1605
- changed: boolean;
1865
+ declare function deleteTask(db: Db, localId: string, workstream: string, opts?: DeleteTaskOptions): DeleteTaskResult;
1866
+ interface UpdateTaskOptions {
1867
+ title?: string;
1868
+ /** 1..100; enforced by schema CHECK. */
1869
+ impact?: number;
1870
+ /** > 0; enforced by schema CHECK. */
1871
+ effortDays?: number;
1872
+ }
1873
+ interface UpdateTaskResult {
1874
+ /** True iff at least one field actually changed. */
1875
+ updated: boolean;
1876
+ /** The fields whose values differ post-update (in `UpdateTaskOptions`'s
1877
+ * camelCase shape). Empty when `updated: false`. */
1878
+ changedFields: string[];
1606
1879
  }
1607
1880
  /**
1608
- * Optional evidence string carried on lifecycle verbs (close / open /
1609
- * claim / release). Lands in the auto-emitted `kind='event'` payload
1610
- * verbatim, prefixed with `evidence=`. The first inch of distinguishing
1611
- * "observed" from "claimed" state per an internal critique: the
1612
- * verb still trusts the caller (it's not a verifier), but the audit
1613
- * trail records what the caller said it relied on.
1881
+ * Update scalar fields on a task. Each option is independently optional;
1882
+ * passing none is a typed no-op (returns `updated: false, changedFields: []`).
1883
+ * Fields whose new value equals the current value are skipped (no row change).
1884
+ *
1885
+ * NOT for status (use `closeTask` / `openTask` / `setTaskStatus`), owner
1886
+ * (use `claimTask` / `releaseTask`), local_id (rename is deferred), or
1887
+ * workstream (cross-workstream moves are deferred).
1614
1888
  */
1615
- interface EvidenceOption {
1616
- evidence?: string;
1889
+ interface UpdateTaskScopeOption {
1890
+ workstream: string;
1617
1891
  }
1892
+ declare function updateTask(db: Db, localId: string, opts: UpdateTaskOptions, scope: UpdateTaskScopeOption): UpdateTaskResult;
1893
+
1894
+ declare function isValidTaskId(id: string): boolean;
1618
1895
  /**
1619
- * Flip a task's status to any of OPEN / IN_PROGRESS / CLOSED.
1620
- * Idempotent: setting a task to its current status is a no-op (returns
1621
- * `changed: false`) rather than throwing. Owner is unchanged.
1896
+ * Lowercase title; collapse non-alnum runs into single `_`; trim
1897
+ * leading/trailing `_`; prefix `t_` if the result starts with a digit
1898
+ * (schema requires first char letter); apply the soft cap with
1899
+ * word-boundary trim (cut at the last `_` at-or-before SLUG_SOFT_CAP
1900
+ * when one exists, else hard-truncate). Mirrors `tg`'s `id_from_title`
1901
+ * but adds the soft cap.
1902
+ *
1903
+ * Throws if `title` yields an empty slug after stripping.
1622
1904
  */
1623
- declare function setTaskStatus(db: Db, localId: string, status: TaskStatus, opts: EvidenceOption & {
1624
- workstream: string;
1625
- }): SetStatusResult;
1626
- /** Result of `closeTask` when called with `ifReady: true` and the
1627
- * task is NOT yet ready to close (still has at least one OPEN /
1628
- * IN_PROGRESS blocker). Distinguished from a regular `SetStatusResult`
1629
- * by the literal `skipped` field; the CLI keys on it to switch
1630
- * between the "closed" and "waiting" rendering paths.
1905
+ declare function slugifyTitle(title: string): string;
1906
+ /**
1907
+ * Result of `slugifyTitleVerbose`: the slug plus enough metadata for
1908
+ * the CLI to decide whether to warn the user that meaning was lost.
1631
1909
  *
1632
- * Surfaced in `fb_umbrella_no_auto_close` (impact=60): a wave umbrella
1633
- * with N blockers stayed OPEN after every blocker reached a terminal
1634
- * status. `--if-ready` is the cheap fix: bare `mu task close` is
1635
- * unchanged (closes regardless), `--if-ready` is a no-op unless every
1636
- * blocker is in a terminal status (CLOSED / REJECTED / DEFERRED).
1637
- * Reject and defer satisfy the predicate too because `--if-ready`'s
1638
- * job is to fire when the umbrella has nothing left to wait for, and
1639
- * a rejected/deferred blocker is no longer being waited on. */
1640
- interface CloseSkippedResult {
1641
- /** Always 'not_ready' when set; future cause-codes can extend this
1642
- * without reshaping the JSON payload (the literal-union narrows
1643
- * safely in the CLI rendering path). */
1644
- skipped: "not_ready";
1645
- /** Status before the call (always the current status, no change). */
1646
- previousStatus: TaskStatus;
1647
- /** Status after the call (== previousStatus, since we no-op). */
1648
- status: TaskStatus;
1649
- /** Always false on a skip (no row mutated). */
1650
- changed: false;
1651
- /** Local ids of every blocker still in OPEN or IN_PROGRESS, sorted
1652
- * alphabetically for deterministic rendering. Empty list is
1653
- * impossible on this branch the no-op only fires when ≥1
1654
- * blocker is non-terminal. */
1655
- blockingIds: string[];
1656
- }
1657
- interface CloseTaskOptions extends EvidenceOption {
1658
- workstream: string;
1659
- /** When true, no-op the close unless every blocker is in a terminal
1660
- * status (CLOSED / REJECTED / DEFERRED). Returns a
1661
- * `CloseSkippedResult` carrying the still-blocking ids; the CLI
1662
- * renders the skip with a Next: hint pointing at `mu task wait`.
1663
- * When false / omitted, behaves as bare `closeTask` (closes
1664
- * regardless of blocker status). */
1665
- ifReady?: boolean;
1910
+ * slug — the same string `slugifyTitle` returns.
1911
+ * strippedLength length of the post-strip pre-cap slug. When this
1912
+ * exceeds the SLUG_SOFT_CAP the verbose form had to
1913
+ * cut at a word boundary (or hard-truncate); the
1914
+ * cut clauses are gone with no in-band signal.
1915
+ * originalSlug — what the slug WOULD have been without the
1916
+ * SLUG_SOFT_CAP cut: full stripped slug with the
1917
+ * same `t_` digit-prefix correction and the same
1918
+ * SLUG_HARD_CAP ceiling, but no word-boundary
1919
+ * truncation. Equal to `slug` when nothing was
1920
+ * cut. The CLI surfaces this in `mu task add
1921
+ * --json` so scripted callers can detect the
1922
+ * truncation without grepping stderr.
1923
+ * truncated — true iff `slug.length < strippedLength` AFTER the
1924
+ * `t_` digit-prefix correction, i.e. real bytes were
1925
+ * dropped. False for any title that fits under the
1926
+ * soft cap or whose only diff vs the stripped slug
1927
+ * is the `t_` prefix.
1928
+ *
1929
+ * The CLI's `mu task add` uses `truncated` to print a one-line stderr
1930
+ * hint pointing at the `<id>` positional override and (under --json)
1931
+ * to surface `originalSlug` alongside `truncated:true`
1932
+ * (slugifytitle_silently_drops_clauses; task_add_slugify_silently_truncates_ids).
1933
+ */
1934
+ interface SlugifyResult {
1935
+ slug: string;
1936
+ strippedLength: number;
1937
+ originalSlug: string;
1938
+ truncated: boolean;
1666
1939
  }
1667
- /** Convenience: setTaskStatus(db, id, "CLOSED"). Accepts evidence.
1668
- * Pre-snapshots the DB (snap_design §CAPTURE STRATEGY > WHEN). Skipped
1669
- * for the idempotent no-op (already CLOSED) so we don't accumulate
1670
- * empty-delta snapshots on retry loops.
1940
+ /**
1941
+ * Verbose sibling of `slugifyTitle`: returns the slug AND a
1942
+ * `truncated` flag so the CLI can hint to the user when the soft cap
1943
+ * dropped clauses (the meaning-shift hazard documented in
1944
+ * slugifytitle_silently_drops_clauses).
1671
1945
  *
1672
- * With `ifReady: true`, returns a `CloseSkippedResult` (no mutation,
1673
- * no snapshot) when any blocker is still OPEN / IN_PROGRESS. Used by
1674
- * `mu task close --if-ready` so an orchestrator can fire-and-forget
1675
- * the umbrella close after every blocker resolves without first
1676
- * re-querying the graph. */
1677
- declare function closeTask(db: Db, localId: string, opts: CloseTaskOptions): SetStatusResult | CloseSkippedResult;
1678
- /** Convenience: setTaskStatus(db, id, "OPEN"). Owner intentionally NOT
1679
- * cleared use `releaseTask` for that. Accepts evidence. */
1680
- declare function openTask(db: Db, localId: string, opts: EvidenceOption & {
1681
- workstream: string;
1682
- }): SetStatusResult;
1683
- interface RejectDeferOptions extends EvidenceOption {
1684
- /** Workstream context for the root task. All internal task lookups
1685
- * (including the dependent walk) scope to this workstream. */
1686
- workstream: string;
1687
- /** If true, walk the transitive dependent closure and (with `yes`)
1688
- * apply the same status to every dependent, atomically. Without
1689
- * `yes`, runs as a dry-run: returns the list of tasks that WOULD
1690
- * be swept (changedIds) with `dryRun: true` and changes nothing.
1691
- * Logs one event per task (via setTaskStatus) on commit. */
1692
- cascade?: boolean;
1693
- /** Required to actually commit a `cascade` operation. Without it,
1694
- * cascade is dry-run only prints the affected dependents so the
1695
- * caller can verify before sweeping. Mirrors `mu workstream destroy
1696
- * --yes`. Surfaced in mufeedback bug_cascade_reject_too_aggressive
1697
- * when an accidentally-cascaded reject swept hud_dogfood (which had
1698
- * independent merit and needed reopening). */
1699
- yes?: boolean;
1946
+ * Algorithm is byte-for-byte identical to `slugifyTitle`; this just
1947
+ * surfaces the metadata that the plain form throws away.
1948
+ */
1949
+ declare function slugifyTitleVerbose(title: string): SlugifyResult;
1950
+ /**
1951
+ * Generate a unique task id from a title. v5: tasks.local_id is
1952
+ * per-workstream unique, so the collision check scopes to one
1953
+ * workstream. On collision, appends `_2`, `_3`, until unique.
1954
+ */
1955
+ declare function idFromTitle(db: Db, workstream: string, title: string): string;
1956
+ /**
1957
+ * Result of `idFromTitleVerbose`: the unique-in-workstream id plus the
1958
+ * truncated flag from the underlying slugify pass. Used by `mu task
1959
+ * add` to decide whether to surface the stderr hint about lost clauses
1960
+ * (slugifytitle_silently_drops_clauses) and to surface the un-truncated
1961
+ * slug in `--json` (task_add_slugify_silently_truncates_ids).
1962
+ *
1963
+ * id — the unique-in-workstream task id.
1964
+ * truncated true iff the underlying slugify pass cut real
1965
+ * characters (collision-suffixing does NOT flip
1966
+ * this).
1967
+ * originalSlug — what the slug would have been without the
1968
+ * SLUG_SOFT_CAP cut. Equal to `id` when nothing was
1969
+ * cut AND no collision suffix was appended; for
1970
+ * the truncation-detection use case the only thing
1971
+ * the CLI cares about is the lossy-vs-not
1972
+ * comparison surfaced via `truncated`.
1973
+ */
1974
+ interface IdFromTitleResult {
1975
+ id: string;
1976
+ truncated: boolean;
1977
+ originalSlug: string;
1700
1978
  }
1701
- interface RejectDeferResult {
1702
- /** Tasks that actually changed status, in cascade order (root first). */
1703
- changedIds: string[];
1704
- /** The status now stamped on every changedId. */
1705
- status: TaskStatus;
1706
- /** True iff anything changed. False on a clean idempotent no-op
1707
- * (root task already in target status, no dependents). */
1708
- changed: boolean;
1709
- /** True iff this was a `cascade` dry-run (cascade requested without
1710
- * `yes`). In that case `changedIds` lists tasks that WOULD be
1711
- * swept; the DB is unchanged. */
1712
- dryRun: boolean;
1713
- /** Tasks that would be touched by a cascade. Same as `changedIds`
1714
- * on a dry-run; populated even on a commit so the caller can
1715
- * report what was swept. */
1716
- affectedIds: string[];
1979
+ /**
1980
+ * Verbose sibling of `idFromTitle`: returns the unique id, the
1981
+ * `truncated` flag from the slugify pass, and the un-truncated
1982
+ * `originalSlug` for `--json` consumers. Collision-suffixing (`_2`,
1983
+ * `_3`, …) does not flip `truncated` — the underlying slug's lossiness
1984
+ * is what the CLI hint cares about.
1985
+ */
1986
+ declare function idFromTitleVerbose(db: Db, workstream: string, title: string): IdFromTitleResult;
1987
+
1988
+ declare function getTask(db: Db, localId: string, workstream: string): TaskRow | undefined;
1989
+ /**
1990
+ * List tasks. With no `workstream` arg returns every row — used by `mu sql`
1991
+ * and by tests; CLI surfaces always pass a workstream so users only see
1992
+ * their own.
1993
+ */
1994
+ interface ListTasksOptions {
1995
+ /** Filter to one or more lifecycle statuses. Omitted = all statuses. */
1996
+ status?: TaskStatus | readonly TaskStatus[];
1717
1997
  }
1718
- /** Reject a task: terminal 'won't do' (out of scope, duplicate, wontfix).
1719
- * Refuses if dependents are open unless `--cascade`.
1720
- * Pre-snapshots once at the verb level so a cascade onto N children
1721
- * produces a single snapshot, not N. Skipped for the idempotent no-op. */
1722
- declare function rejectTask(db: Db, localId: string, opts: RejectDeferOptions): RejectDeferResult;
1723
- /** Defer a task: parked, may revisit. Same dependent-stranding semantics
1724
- * as reject (DEFERRED also doesn't satisfy a `--blocked-by` edge).
1725
- * Pre-snapshots once at the verb level. Skipped for the idempotent no-op. */
1726
- declare function deferTask(db: Db, localId: string, opts: RejectDeferOptions): RejectDeferResult;
1998
+ declare function listTasks(db: Db, workstream?: string, opts?: ListTasksOptions): TaskRow[];
1999
+ /** Options for listReady. The optional `statuses` filter composes
2000
+ * on top of the `ready` view (which itself constrains to
2001
+ * `status='OPEN'`); passing only OPEN is identical to today's no-
2002
+ * filter shape, passing only non-OPEN values returns []. Exists so
2003
+ * `mu task next --status` can mirror the multi-status flag shape
2004
+ * shipped on `mu task list` (task_list_multi_status_union). */
2005
+ interface ListReadyOptions {
2006
+ status?: TaskStatus | readonly TaskStatus[];
2007
+ }
2008
+ declare function listReady(db: Db, workstream: string, opts?: ListReadyOptions): TaskRow[];
2009
+ declare function listBlocked(db: Db, workstream: string): TaskRow[];
2010
+ declare function listGoals(db: Db, workstream: string): TaskRow[];
2011
+ /** All IN_PROGRESS tasks in a workstream, most-recently-touched first.
2012
+ * Used by `mu state` to populate its in-progress slice; exposed as a
2013
+ * named SDK helper so CLI renderers don't re-derive the row-shape
2014
+ * conversion (review_code_raw_task_state_duplicate). */
2015
+ declare function listInProgress(db: Db, workstream: string): TaskRow[];
2016
+ /** Most-recently-closed tasks in a workstream, newest first, capped at
2017
+ * `limit` (default 5). Used by `mu state` for its 'recent closed'
2018
+ * slice; exposed as a named SDK helper so the CLI no longer needs the
2019
+ * raw-row type that was duplicating RawTaskRow
2020
+ * (review_code_raw_task_state_duplicate). */
2021
+ declare function listRecentClosed(db: Db, workstream: string, limit?: number): TaskRow[];
2022
+ /** Optional filter knobs for `listNotes`. Default-everything-undefined
2023
+ * preserves the historical "return every note, oldest-first" shape so
2024
+ * every existing caller (cmdTaskShow's notes block, exporting.ts's
2025
+ * bucket renderer, agents.test.ts) keeps working unchanged.
2026
+ *
2027
+ * Filters compose multiplicatively when both apply (`since` AND
2028
+ * `tail`): the timestamp filter is applied first, then `tail` slices
2029
+ * the last N of what survived. The CLI surface (`mu task notes
2030
+ * --tail / --since / --since-claim`) lives in src/cli/tasks/edit.ts;
2031
+ * the mutex between `--since` and `--since-claim` is a CLI concern,
2032
+ * not enforced here — if both arrive at the SDK, `since` wins (it's
2033
+ * the explicit one) and `sinceClaim` is ignored. The auto-resolve
2034
+ * for `sinceClaim` (look up the most recent `task claim` event in
2035
+ * agent_logs) happens here so the SDK is self-contained for scripted
2036
+ * callers. */
2037
+ interface ListNotesOptions {
2038
+ /** Print only the last N notes (after any timestamp filter). Must
2039
+ * be a positive integer; a value of 0 returns no rows but is not
2040
+ * an error here — CLI-side validation rejects `--tail 0`. */
2041
+ tail?: number;
2042
+ /** ISO-8601 cutoff: only notes with `created_at > since` survive.
2043
+ * Comparison is lexicographic on the ISO string (matches the way
2044
+ * the rest of the codebase compares ISO timestamps). */
2045
+ since?: string;
2046
+ /** When true and `since` is unset, look up the `created_at` of the
2047
+ * most recent `task claim` event for this task and use it as the
2048
+ * cutoff. Falls back to no filter when no claim event exists
2049
+ * (equivalent to `--since-beginning`). */
2050
+ sinceClaim?: boolean;
2051
+ }
2052
+ /** List notes for a task. Operator-facing local_id; resolves to the
2053
+ * surrogate task id via taskIdFor (with optional workstream scope).
2054
+ *
2055
+ * Optional filters: see {@link ListNotesOptions}. Default behaviour
2056
+ * (no opts) is unchanged — every note, oldest-first. */
2057
+ declare function listNotes(db: Db, taskLocalId: string, workstream: string, opts?: ListNotesOptions): TaskNoteRow[];
2058
+ /**
2059
+ * All tasks currently owned by `agent` in a given workstream
2060
+ * (v5: agents.name is per-workstream unique). Sorted by local_id.
2061
+ *
2062
+ * Defaults to **excluding CLOSED** since the verb's purpose is "what
2063
+ * is X currently working on?" and a closed task is no longer being
2064
+ * worked on. closeTask intentionally preserves `owner` as a
2065
+ * historical record (so audit/notes can attribute decisions); pass
2066
+ * `{ includeClosed: true }` to surface that history.
2067
+ */
2068
+ declare function listTasksByOwner(db: Db, workstream: string, owner: string, opts?: {
2069
+ includeClosed?: boolean;
2070
+ }): TaskRow[];
2071
+ interface SearchTasksOptions {
2072
+ /** Restrict to one workstream; undefined = search across all. */
2073
+ workstream?: string;
2074
+ /** Also search `task_notes.content` (default false: titles + ids only). */
2075
+ includeNotes?: boolean;
2076
+ }
2077
+ /**
2078
+ * Substring search on task `title` and `local_id`, case-insensitive.
2079
+ * With `includeNotes: true` also searches `task_notes.content`. The
2080
+ * pattern is wrapped in `%...%` automatically so callers don't need
2081
+ * SQL LIKE knowledge — for explicit globs (or regex), use `mu sql`.
2082
+ */
2083
+ declare function searchTasks(db: Db, pattern: string, opts?: SearchTasksOptions): TaskRow[];
1727
2084
 
1728
- interface ReleaseResult {
1729
- /** The previous owner (null if the task was already unowned). */
1730
- previousOwnerName: string | null;
1731
- /** Status before the release. */
1732
- previousStatus: TaskStatus;
1733
- /** Status after the release. */
1734
- status: TaskStatus;
1735
- /** True iff owner OR status actually changed. */
1736
- changed: boolean;
2085
+ declare const WORKSPACE_STALE_THRESHOLD = 10;
2086
+ declare function isWorkspaceStale(behind: number | null | undefined): boolean;
2087
+
2088
+ interface WorkspaceRow {
2089
+ agentName: string;
2090
+ workstreamName: string;
2091
+ backend: VcsBackendName;
2092
+ path: string;
2093
+ parentRef: string | null;
2094
+ createdAt: string;
2095
+ /** How many commits the workspace's parent_ref is behind the project's
2096
+ * default branch HEAD, as of the last time the workspace's local refs
2097
+ * cache was updated. Undefined when not yet computed (the listWorkspaces
2098
+ * fast path leaves it unset; call decorateWithStaleness to populate).
2099
+ * Null when staleness was queried but cannot be computed (no main found,
2100
+ * none-backend, missing parent_ref, command failure). */
2101
+ commitsBehindMain?: number | null;
2102
+ /** True when the workspace has uncommitted / unstaged / untracked-not-
2103
+ * ignored files, as observed by the backend's `listDirtyFiles`.
2104
+ * Undefined when not yet computed (the listWorkspaces fast path leaves
2105
+ * it unset; call decorateWithDirty to populate). Null when the dirty
2106
+ * check could not be performed (backend command failure). For jj /
2107
+ * none backends — which have no operator-visible "dirty" concept —
2108
+ * this is always false (their listDirtyFiles returns []). */
2109
+ dirty?: boolean | null;
1737
2110
  }
1738
- interface ReleaseTaskOptions extends EvidenceOption {
1739
- /** Workstream context for the task (v5: tasks.local_id is
1740
- * per-workstream unique). */
1741
- workstream: string;
1742
- /** Force `status = OPEN` regardless of the current status. Without
1743
- * this flag, `IN_PROGRESS` is also flipped to `OPEN` automatically
1744
- * (so a released task isn't left structurally stranded with
1745
- * `owner=NULL, status=IN_PROGRESS`); CLOSED / REJECTED / DEFERRED
1746
- * are preserved. `--reopen` is the override for the rarer "un-
1747
- * close and hand back to the pool" workflow. */
1748
- reopen?: boolean;
2111
+ declare class WorkspaceExistsError extends Error implements HasNextSteps {
2112
+ readonly agent: string;
2113
+ readonly name = "WorkspaceExistsError";
2114
+ constructor(agent: string);
2115
+ errorNextSteps(): NextStep[];
2116
+ }
2117
+ declare class WorkspaceNotFoundError extends Error implements HasNextSteps {
2118
+ readonly agent: string;
2119
+ readonly name = "WorkspaceNotFoundError";
2120
+ constructor(agent: string);
2121
+ errorNextSteps(): NextStep[];
1749
2122
  }
1750
2123
  /**
1751
- * Release a task: clear `tasks.owner`.
2124
+ * Thrown by createWorkspace when the on-disk path it would create is
2125
+ * already occupied. Distinct from WorkspaceExistsError (which is about
2126
+ * the DB row) so the recovery is clear: the dir is orphaned (no DB
2127
+ * row points at it) and needs cleanup.
1752
2128
  *
1753
- * Status side-effects (review_release_open_in_progress_inconsistency):
1754
- * - IN_PROGRESS → OPEN automatically (without it, the task is
1755
- * stranded: no owner to drive it forward, but `mu task next`
1756
- * skips it because it's not OPEN).
1757
- * - OPEN / CLOSED / REJECTED / DEFERRED preserved.
1758
- * - `--reopen` forces OPEN regardless of current status — the
1759
- * escape hatch for un-closing a CLOSED owned task in one verb.
2129
+ * Maps to exit code 4 (conflict).
2130
+ */
2131
+ declare class WorkspacePathNotEmptyError extends Error implements HasNextSteps {
2132
+ readonly agent: string;
2133
+ readonly workstream: string;
2134
+ readonly workspacePath: string;
2135
+ readonly name = "WorkspacePathNotEmptyError";
2136
+ constructor(agent: string, workstream: string, workspacePath: string);
2137
+ errorNextSteps(): NextStep[];
2138
+ }
2139
+ /**
2140
+ * Thrown by createWorkspace when the resolved projectRoot is the
2141
+ * user's $HOME.
1760
2142
  *
1761
- * Idempotent: releasing an already-unowned task with no `--reopen` and
1762
- * no IN_PROGRESS status is a no-op (returns `changed: false`).
1763
- * Throws TaskNotFoundError on missing.
2143
+ * Maps to exit code 4 (conflict).
1764
2144
  */
1765
- declare function releaseTask(db: Db, localId: string, opts: ReleaseTaskOptions): ReleaseResult;
1766
- interface ClaimTaskOptions extends EvidenceOption {
1767
- /** Workstream context for both the task and the claiming agent.
1768
- * v5: agents.name and tasks.local_id are per-workstream unique;
1769
- * the task lookup AND the agent FK lookup scope to this
1770
- * workstream so a same-named task or worker elsewhere can't be
1771
- * silently picked. The CLI always passes this from the resolved
1772
- * -w / $MU_SESSION. */
1773
- workstream: string;
1774
- /**
1775
- * Override the agent name. If omitted, derived from the current pane's
1776
- * title via `tmux display-message -t $TMUX_PANE -p '#{pane_title}'`.
1777
- *
1778
- * Mutually exclusive with `self: true`.
1779
- */
1780
- agentName?: string;
1781
- /**
1782
- * Workstream that the claimer agent lives in. When omitted, defaults
1783
- * to `opts.workstream` (today's same-workstream behaviour). Set by
1784
- * the CLI when `mu task claim X -w A --for B/worker-1` qualifies the
1785
- * `--for` ref with a different workstream prefix
1786
- * (`task_claim_for_cross_workstream`).
1787
- *
1788
- * Cross-workstream ownership is structurally allowed by the schema:
1789
- * `tasks.owner_id` is an INTEGER FK to `agents.id` with no
1790
- * workstream qualifier on the agent side. The per-workstream UNIQUE
1791
- * on `agents(workstream_id, name)` is what previously made the
1792
- * SDK's name → id lookup scope to one workstream; this option
1793
- * widens that lookup to a different workstream when the operator
1794
- * dispatches across a workstream boundary. The agent's own
1795
- * workstream remains unchanged — only the task's `owner_id` points
1796
- * out-of-workstream.
1797
- */
1798
- agentWorkstream?: string;
1799
- /**
1800
- * Anonymous claim: write `owner = NULL` instead of resolving an agent
1801
- * name and checking the FK. Use when the actor is the orchestrator
1802
- * (or a script, or a human) doing direct work in a workstream they
1803
- * aren't a registered worker in.
1804
- *
1805
- * The actor name is still recorded — it ends up in `agent_logs.source`
1806
- * for the auto-emitted `task claim` event — so provenance is preserved.
1807
- * Just not in the FK column.
1808
- *
1809
- * Resolution order for the actor name (used as the log source):
1810
- * 1. `actor` if explicitly passed.
1811
- * 2. Current pane title (when `$TMUX_PANE` is set).
1812
- * 3. `$USER`.
1813
- * 4. The literal string 'unknown'.
1814
- *
1815
- * Mutually exclusive with `agentName` (the two are alternative
1816
- * answers to "who's the actor for this claim?"). Passing both is a
1817
- * usage error.
1818
- */
1819
- self?: boolean;
1820
- /**
1821
- * Override the actor name used for the log source when `self: true`.
1822
- * Ignored when `self: false`. Useful when the orchestrator wants to
1823
- * attribute the work to a meaningful name rather than the pane
1824
- * title (e.g. "deploy-bot" rather than "pi-mu").
1825
- */
1826
- actor?: string;
2145
+ declare class HomeDirAsProjectRootError extends Error implements HasNextSteps {
2146
+ readonly agent: string;
2147
+ readonly workstream: string;
2148
+ readonly homeDir: string;
2149
+ readonly name = "HomeDirAsProjectRootError";
2150
+ constructor(agent: string, workstream: string, homeDir: string);
2151
+ errorNextSteps(): NextStep[];
1827
2152
  }
1828
- interface ClaimResult {
1829
- /** The agent now owning the task, or null when the claim was anonymous (--self). */
1830
- ownerName: string | null;
1831
- /** The actor recorded in the agent_logs event the agent name for a
1832
- * registered-worker claim, or the resolved actor for --self. */
1833
- actorName: string;
1834
- /** The previous owner (null if it was unowned). */
1835
- previousOwnerName: string | null;
1836
- /** The status BEFORE the claim; post-claim is IN_PROGRESS unless was CLOSED. */
1837
- previousStatus: TaskStatus;
1838
- /** The status AFTER the claim. */
1839
- status: TaskStatus;
2153
+ /**
2154
+ * Compose the canonical on-disk path for an agent's workspace. Used by
2155
+ * createWorkspace and reachable from `mu workspace path` so the user
2156
+ * can `cd $(mu workspace path foo)` even before the directory exists.
2157
+ */
2158
+ declare function workspacePath(workstream: string, agent: string): string;
2159
+ /** Root dir for a workstream's workspaces the parent of all
2160
+ * per-agent workspace dirs. Used by listWorkspaceOrphans to scan
2161
+ * the filesystem. */
2162
+ declare function workspacesRoot(workstream: string): string;
2163
+ interface WorkspaceStaleness {
2164
+ agentName: string;
2165
+ workstreamName: string;
2166
+ commitsBehindMain: number | null;
2167
+ isStale: boolean;
2168
+ }
2169
+
2170
+ interface CreateWorkspaceOptions {
2171
+ agent: string;
2172
+ workstream: string;
2173
+ /** Project root to branch from. Defaults to the current working
2174
+ * directory (the `mu` invocation site, which is normally what the
2175
+ * user wants). */
2176
+ projectRoot?: string;
2177
+ /** Override backend detection. Default: walk `detectBackend`.
2178
+ * Accepts either a name ("jj" / "sl" / "git" / "none") OR a
2179
+ * pre-built `VcsBackend` object — the object form lets tests inject
2180
+ * a fresh fake backend without mutating the exported singletons. */
2181
+ backend?: VcsBackendName | VcsBackend;
2182
+ /** Optional ref to base the workspace on. Backend-specific. */
2183
+ parentRef?: string;
2184
+ /** INTERNAL. When false, suppress the `workspace create` system
2185
+ * event. Used by `recreateWorkspace` so the audit trail records
2186
+ * ONE atomic `workspace recreate` line instead of separate
2187
+ * free + create entries. Defaults to true. */
2188
+ _suppressEvent?: boolean;
1840
2189
  }
1841
2190
  /**
1842
- * Claim a task. Two modes:
1843
- *
1844
- * Worker claim (default):
1845
- * Resolve an agent name from `opts.agentName` or from $TMUX_PANE's
1846
- * pane title. The name MUST exist in the agents table (FK on
1847
- * tasks.owner). Sets `owner = <name>`. This is what mu-spawned
1848
- * workers do, and what `mu task claim --for <worker>` does for
1849
- * orchestrator dispatch.
1850
- *
1851
- * Anonymous claim (--self):
1852
- * Skip the name -> agents FK lookup entirely. Sets `owner = NULL`.
1853
- * Records the actor in `agent_logs.source` instead. This is the
1854
- * orchestrator-doing-direct-work path — the actor is logged but
1855
- * not registered as a worker pane.
1856
- *
1857
- * Status side-effect: OPEN -> IN_PROGRESS; IN_PROGRESS / CLOSED unchanged.
1858
- *
1859
- * Concurrency: the worker-claim path uses a single-statement CAS UPDATE
1860
- * with `WHERE owner IS NULL OR owner = ?` so two workers racing to
1861
- * claim the same task can't both win. The anonymous path uses
1862
- * `WHERE owner IS NULL` (anonymous claims don't 'own' the task in any
1863
- * exclusive sense; if it's already owned by anyone, the anonymous claim
1864
- * is a TaskAlreadyOwnedError just like a worker claim would be).
2191
+ * Create a fresh workspace for an agent. Allocates the on-disk
2192
+ * directory, records the row, emits a system event. Idempotent ONLY
2193
+ * to the extent that the row check is up-front; if the row exists
2194
+ * we throw `WorkspaceExistsError` rather than silently re-using a
2195
+ * possibly-stale on-disk state. Callers should `freeWorkspace` first.
1865
2196
  */
1866
- declare function claimTask(db: Db, localId: string, opts: ClaimTaskOptions): Promise<ClaimResult>;
2197
+ declare function createWorkspace(db: Db, opts: CreateWorkspaceOptions): Promise<WorkspaceRow>;
2198
+ declare function getWorkspaceForAgent(db: Db, agent: string, workstream: string): WorkspaceRow | undefined;
2199
+ declare function listWorkspaces(db: Db, workstream?: string): WorkspaceRow[];
2200
+ interface FreeWorkspaceOptions {
2201
+ /** If true, attempt to commit pending changes before tearing down.
2202
+ * Backend-specific; see VcsBackend.freeWorkspace. */
2203
+ commit?: boolean;
2204
+ /** INTERNAL. When false, suppress the `workspace free` system
2205
+ * event AND skip the pre-mutation snapshot capture. Used by
2206
+ * `recreateWorkspace` so the audit trail records ONE atomic
2207
+ * `workspace recreate` line and one snapshot for the whole
2208
+ * free+create cycle. Defaults to true. */
2209
+ _suppressEvent?: boolean;
2210
+ }
2211
+ interface FreeWorkspaceResult {
2212
+ /** The committed ref, when `commit` was true and there was something
2213
+ * to commit. */
2214
+ committedRef?: string;
2215
+ /** True iff the on-disk path was actually removed. */
2216
+ removed: boolean;
2217
+ /** True iff the DB row was actually deleted. */
2218
+ rowDeleted: boolean;
2219
+ }
1867
2220
  /**
1868
- * Resolve the current actor's identity for attribution in task notes,
1869
- * --self claims, and any other write that wants 'who did this?'.
1870
- *
1871
- * Resolution order:
1872
- * 1. $MU_AGENT_NAME env var (set by mu spawnAgent on every managed
1873
- * pane; surfaced from the f3d4bdd commit). Authoritative when
1874
- * present — you're inside a mu-spawned worker, no ambiguity.
1875
- * 2. tmux pane title (the pane-title identity step). Works
1876
- * when running inside any pane mu manages OR adopted.
1877
- * 3. $USER (when running outside tmux entirely).
1878
- * 4. The literal 'orchestrator' as a last-resort default.
2221
+ * Tear down an agent's workspace. Calls the backend to remove the
2222
+ * on-disk directory (with optional auto-commit), then DELETEs the row.
2223
+ * Idempotent on a missing workspace (returns all-false).
2224
+ */
2225
+ declare function freeWorkspace(db: Db, agent: string, opts: FreeWorkspaceOptions & {
2226
+ workstream: string;
2227
+ }): Promise<FreeWorkspaceResult>;
2228
+
2229
+ declare function getWorkspaceStaleness(db: Db, agentName: string, workstreamName: string): Promise<WorkspaceStaleness | null>;
2230
+ /**
2231
+ * Decorate each row with `commitsBehindMain` by asking the row's backend
2232
+ * how far the parent_ref is behind the project's default branch HEAD.
2233
+ * Cheap, pure observation: NO automatic `git fetch` / `jj git fetch` /
2234
+ * `sl pull`. The number is as fresh as the workspace's local refs cache.
1879
2235
  *
1880
- * Why prefer env over pane title: pane titles are a tmux-server-wide
1881
- * resource that anything can rewrite. The env var is set per-pane at
1882
- * spawn time and is unforgeable from outside without explicit
1883
- * `--actor` override. Pane title is the only identity available for
1884
- * adopted panes that didn't go through mu's spawn path.
2236
+ * Returns a NEW array; does not mutate the input. Rows whose parent_ref
2237
+ * is missing, or whose backend's commitsBehind throws / returns null,
2238
+ * get `commitsBehindMain: null`.
1885
2239
  */
1886
- declare function resolveActorIdentity(): Promise<string>;
2240
+ declare function decorateWithStaleness(rows: readonly WorkspaceRow[]): Promise<WorkspaceRow[]>;
2241
+ /**
2242
+ * Decorate every row with a `dirty` marker — true when the backend's
2243
+ * `listDirtyFiles` reports any uncommitted / unstaged / untracked-not-
2244
+ * ignored files; false when clean; null on backend-command failure.
2245
+ *
2246
+ * Returns a NEW array; does not mutate the input.
2247
+ */
2248
+ declare function decorateWithDirty(rows: readonly WorkspaceRow[]): Promise<WorkspaceRow[]>;
1887
2249
 
1888
- interface TaskRow {
1889
- /** Per-workstream-unique TEXT name. The operator-facing identifier. */
1890
- name: string;
1891
- /** Alias for `name` — the per-workstream-unique TEXT id. Emitted alongside
1892
- * `name` so JSON consumers can dot-access the canonical field name without
1893
- * having to know that, for tasks specifically, `name` plays the localId
1894
- * role. Always equal to `name`. */
1895
- localId: string;
1896
- /** Foreign-name reference to the owning workstream. */
2250
+ interface WorkspaceOrphan {
2251
+ /** The on-disk dir name (the agent name it WOULD be for, if mu had
2252
+ * registered it). */
2253
+ agentName: string;
2254
+ /** Workstream the dir is filed under. */
1897
2255
  workstreamName: string;
1898
- title: string;
1899
- status: TaskStatus;
1900
- impact: number;
1901
- effortDays: number;
1902
- /** Foreign-name reference to the owning agent (NULL when unowned). */
1903
- ownerName: string | null;
1904
- createdAt: string;
1905
- updatedAt: string;
2256
+ /** Absolute path to the orphan dir. */
2257
+ path: string;
1906
2258
  }
1907
- interface TaskNoteRow {
1908
- author: string | null;
1909
- content: string;
1910
- createdAt: string;
2259
+ /**
2260
+ * Like WorkspaceOrphan but additionally flags whether the parent
2261
+ * workstream itself is gone (no row in `workstreams`). Returned by
2262
+ * listAllOrphanWorkspaces; the per-workstream listWorkspaceOrphans
2263
+ * doesn't carry this since by construction it only runs against an
2264
+ * existing workstream.
2265
+ */
2266
+ interface StrandedWorkspaceOrphan extends WorkspaceOrphan {
2267
+ /** True iff the parent workstream has no DB row (the dir was left
2268
+ * behind by a `mu workstream destroy` or a manual DELETE). */
2269
+ stranded: boolean;
1911
2270
  }
1912
- declare function isValidTaskId(id: string): boolean;
1913
2271
  /**
1914
- * Lowercase title; collapse non-alnum runs into single `_`; trim
1915
- * leading/trailing `_`; prefix `t_` if the result starts with a digit
1916
- * (schema requires first char letter); apply the soft cap with
1917
- * word-boundary trim (cut at the last `_` at-or-before SLUG_SOFT_CAP
1918
- * when one exists, else hard-truncate). Mirrors `tg`'s `id_from_title`
1919
- * but adds the soft cap.
2272
+ * Scan `<state-dir>/workspaces/<workstream>/` for directories that
2273
+ * have no row in `vcs_workspaces`.
1920
2274
  *
1921
- * Throws if `title` yields an empty slug after stripping.
2275
+ * Returns `[]` when the workstream's workspaces dir doesn't exist,
2276
+ * or when every dir on disk has a corresponding DB row. Filesystem
2277
+ * read is best-effort: a missing/inaccessible dir returns `[]`.
1922
2278
  */
1923
- declare function slugifyTitle(title: string): string;
2279
+ declare function listWorkspaceOrphans(db: Db, workstream: string): WorkspaceOrphan[];
1924
2280
  /**
1925
- * Result of `slugifyTitleVerbose`: the slug plus enough metadata for
1926
- * the CLI to decide whether to warn the user that meaning was lost.
1927
- *
1928
- * slug — the same string `slugifyTitle` returns.
1929
- * strippedLength — length of the post-strip pre-cap slug. When this
1930
- * exceeds the SLUG_SOFT_CAP the verbose form had to
1931
- * cut at a word boundary (or hard-truncate); the
1932
- * cut clauses are gone with no in-band signal.
1933
- * truncated — true iff `slug.length < strippedLength` AFTER the
1934
- * `t_` digit-prefix correction, i.e. real bytes were
1935
- * dropped. False for any title that fits under the
1936
- * soft cap or whose only diff vs the stripped slug
1937
- * is the `t_` prefix.
1938
- *
1939
- * The CLI's `mu task add` uses `truncated` to print a one-line stderr
1940
- * hint pointing at the `<id>` positional override
1941
- * (slugifytitle_silently_drops_clauses).
2281
+ * Cross-workstream variant of listWorkspaceOrphans. Reads
2282
+ * `<state-dir>/workspaces/`, recurses one level (per-ws subdir
2283
+ * per-agent subdir), and surfaces every dir with no row in
2284
+ * `vcs_workspaces`.
1942
2285
  */
1943
- interface SlugifyResult {
1944
- slug: string;
1945
- strippedLength: number;
1946
- truncated: boolean;
2286
+ declare function listAllOrphanWorkspaces(db: Db): StrandedWorkspaceOrphan[];
2287
+
2288
+ interface RecreateWorkspaceOptions {
2289
+ /** Same as createWorkspace; defaults to cwd. */
2290
+ projectRoot?: string;
2291
+ /** Same as createWorkspace; if undefined the previous backend is
2292
+ * reused (auto-detection re-runs only when --backend was passed). */
2293
+ backend?: VcsBackendName | VcsBackend;
2294
+ /** Same as createWorkspace; if undefined the new workspace bases on
2295
+ * the backend's current head (for git/jj/sl: the project's main),
2296
+ * which is the whole point of the verb. */
2297
+ parentRef?: string;
2298
+ /** When true, skip the dirty-check refusal and discard any
2299
+ * uncommitted changes in the existing workspace. The lossy escape
2300
+ * hatch — mirrors the implicit semantics of `mu workspace free`
2301
+ * without --commit. */
2302
+ force?: boolean;
2303
+ }
2304
+ interface RecreateWorkspaceResult {
2305
+ /** The freshly-created workspace row (the previous row is already
2306
+ * gone by the time we return). */
2307
+ workspace: WorkspaceRow;
2308
+ /** parent_ref of the WORKSPACE BEFORE recreate, so callers (and the
2309
+ * CLI's success message) can show "bumped from <old> -> <new>". */
2310
+ previousParentRef: string | null;
2311
+ }
2312
+ /**
2313
+ * Free + create in one atomic-ish verb. Between waves the operator
2314
+ * wants the SAME agent name with a fresh workspace pinned to current
2315
+ * main; doing `free` then `create` manually was the dogfood-painful
2316
+ * pattern.
2317
+ */
2318
+ declare function recreateWorkspace(db: Db, agent: string, opts: RecreateWorkspaceOptions & {
2319
+ workstream: string;
2320
+ }): Promise<RecreateWorkspaceResult>;
2321
+
2322
+ declare class TaskNotFoundError extends Error implements HasNextSteps {
2323
+ readonly taskId: string;
2324
+ readonly name = "TaskNotFoundError";
2325
+ constructor(taskId: string);
2326
+ errorNextSteps(): NextStep[];
2327
+ }
2328
+ declare class TaskExistsError extends Error implements HasNextSteps {
2329
+ readonly taskId: string;
2330
+ readonly name = "TaskExistsError";
2331
+ constructor(taskId: string);
2332
+ errorNextSteps(): NextStep[];
1947
2333
  }
1948
2334
  /**
1949
- * Verbose sibling of `slugifyTitle`: returns the slug AND a
1950
- * `truncated` flag so the CLI can hint to the user when the soft cap
1951
- * dropped clauses (the meaning-shift hazard documented in
1952
- * slugifytitle_silently_drops_clauses).
1953
- *
1954
- * Algorithm is byte-for-byte identical to `slugifyTitle`; this just
1955
- * surfaces the metadata that the plain form throws away.
2335
+ * Thrown when a verb is invoked with `-w/--workstream <name>` but the
2336
+ * named task lives in a different workstream. Distinguishes "the user
2337
+ * typo'd the workstream" from "the task doesn't exist anywhere"
2338
+ * (which surfaces as `TaskNotFoundError`). Maps to exit code 4
2339
+ * (conflict / wrong scope).
1956
2340
  */
1957
- declare function slugifyTitleVerbose(title: string): SlugifyResult;
2341
+ declare class TaskNotInWorkstreamError extends Error implements HasNextSteps {
2342
+ readonly taskId: string;
2343
+ readonly expectedWorkstream: string;
2344
+ readonly actualWorkstream: string;
2345
+ readonly name = "TaskNotInWorkstreamError";
2346
+ constructor(taskId: string, expectedWorkstream: string, actualWorkstream: string);
2347
+ errorNextSteps(): NextStep[];
2348
+ }
2349
+ declare class TaskAlreadyOwnedError extends Error implements HasNextSteps {
2350
+ readonly taskId: string;
2351
+ readonly currentOwner: string;
2352
+ readonly name = "TaskAlreadyOwnedError";
2353
+ constructor(taskId: string, currentOwner: string);
2354
+ errorNextSteps(): NextStep[];
2355
+ }
1958
2356
  /**
1959
- * Generate a unique task id from a title. v5: tasks.local_id is
1960
- * per-workstream unique, so the collision check scopes to one
1961
- * workstream. On collision, appends `_2`, `_3`, until unique.
2357
+ * Thrown by `rejectTask` / `deferTask` when the target task has
2358
+ * dependents that are still OPEN or IN_PROGRESS. Rejecting or
2359
+ * deferring such a task would silently strand the dependents (they'd
2360
+ * remain blocked by a prereq that's never going to satisfy the edge),
2361
+ * so we refuse and force an explicit decision: pass `--cascade` to
2362
+ * apply the same status to every transitive dependent, drop the
2363
+ * blocking edge first with `mu task unblock`, or address the
2364
+ * dependents individually. Maps to exit code 4.
1962
2365
  */
1963
- declare function idFromTitle(db: Db, workstream: string, title: string): string;
2366
+ declare class TaskHasOpenDependentsError extends Error implements HasNextSteps {
2367
+ readonly taskId: string;
2368
+ readonly verb: "reject" | "defer";
2369
+ readonly dependents: readonly string[];
2370
+ readonly name = "TaskHasOpenDependentsError";
2371
+ constructor(taskId: string, verb: "reject" | "defer", dependents: readonly string[]);
2372
+ errorNextSteps(): NextStep[];
2373
+ }
1964
2374
  /**
1965
- * Result of `idFromTitleVerbose`: the unique-in-workstream id plus the
1966
- * truncated flag from the underlying slugify pass. Used by `mu task
1967
- * add` to decide whether to surface the stderr hint about lost clauses
1968
- * (slugifytitle_silently_drops_clauses).
2375
+ * Thrown when `mu task claim` resolves a claimer agent name (from the
2376
+ * pane title or --for) that has no matching row in the agents table.
2377
+ *
2378
+ * The FK on `tasks.owner` references `agents.name`; without this guard
2379
+ * the claim attempt would fail with the unhelpful 'FOREIGN KEY constraint
2380
+ * failed' from SQLite. This typed error gives the user actionable next
2381
+ * steps (run `mu agent adopt <pane-id>` to register, or use --for to pick a
2382
+ * different agent).
2383
+ *
2384
+ * Maps to exit code 4 (conflict) via the cli.ts handler.
1969
2385
  */
1970
- interface IdFromTitleResult {
1971
- id: string;
1972
- truncated: boolean;
2386
+ declare class ClaimerNotRegisteredError extends Error implements HasNextSteps {
2387
+ readonly agentName: string;
2388
+ readonly paneId: string | null;
2389
+ readonly name = "ClaimerNotRegisteredError";
2390
+ constructor(agentName: string, paneId: string | null);
2391
+ /**
2392
+ * Three actionable resolutions in expected-frequency order:
2393
+ * 1. --self : orchestrator pattern (working directly)
2394
+ * 2. --for : dispatcher pattern (assigning to a worker)
2395
+ * 3. mu agent adopt: registration pattern (promote pane to worker)
2396
+ */
2397
+ errorNextSteps(): NextStep[];
2398
+ }
2399
+ declare class CycleError extends Error implements HasNextSteps {
2400
+ readonly from: string;
2401
+ readonly to: string;
2402
+ readonly name = "CycleError";
2403
+ constructor(from: string, to: string);
2404
+ errorNextSteps(): NextStep[];
2405
+ }
2406
+ declare class CrossWorkstreamEdgeError extends Error implements HasNextSteps {
2407
+ readonly blocker: string;
2408
+ readonly blockerWorkstream: string;
2409
+ readonly dependent: string;
2410
+ readonly dependentWorkstream: string;
2411
+ readonly name = "CrossWorkstreamEdgeError";
2412
+ constructor(blocker: string, blockerWorkstream: string, dependent: string, dependentWorkstream: string);
2413
+ errorNextSteps(): NextStep[];
2414
+ }
2415
+
2416
+ interface SetStatusResult {
2417
+ /** Status before the call. */
2418
+ previousStatus: TaskStatus;
2419
+ /** Status after the call (== requested status). */
2420
+ status: TaskStatus;
2421
+ /** True iff the row actually changed. False on idempotent no-op. */
2422
+ changed: boolean;
1973
2423
  }
1974
2424
  /**
1975
- * Verbose sibling of `idFromTitle`: returns both the unique id and the
1976
- * `truncated` flag from the slugify pass. Collision-suffixing (`_2`,
1977
- * `_3`, …) does not flip `truncated` the underlying slug's lossiness
1978
- * is what the CLI hint cares about.
2425
+ * Optional evidence string carried on lifecycle verbs (close / open /
2426
+ * claim / release). Lands in the auto-emitted `kind='event'` payload
2427
+ * verbatim, prefixed with `evidence=`. The first inch of distinguishing
2428
+ * "observed" from "claimed" state per an internal critique: the
2429
+ * verb still trusts the caller (it's not a verifier), but the audit
2430
+ * trail records what the caller said it relied on.
1979
2431
  */
1980
- declare function idFromTitleVerbose(db: Db, workstream: string, title: string): IdFromTitleResult;
1981
- declare function getTask(db: Db, localId: string, workstream: string): TaskRow | undefined;
2432
+ interface EvidenceOption {
2433
+ evidence?: string;
2434
+ }
1982
2435
  /**
1983
- * List tasks. With no `workstream` arg returns every row used by `mu sql`
1984
- * and by tests; CLI surfaces always pass a workstream so users only see
1985
- * their own.
2436
+ * Flip a task's status to any of OPEN / IN_PROGRESS / CLOSED.
2437
+ * Idempotent: setting a task to its current status is a no-op (returns
2438
+ * `changed: false`) rather than throwing. Owner is unchanged.
1986
2439
  */
1987
- interface ListTasksOptions {
1988
- /** Filter to one or more lifecycle statuses. Omitted = all statuses. */
1989
- status?: TaskStatus | readonly TaskStatus[];
2440
+ declare function setTaskStatus(db: Db, localId: string, status: TaskStatus, opts: EvidenceOption & {
2441
+ workstream: string;
2442
+ }): SetStatusResult;
2443
+ /** Result of `closeTask` when called with `ifReady: true` and the
2444
+ * task is NOT yet ready to close (still has at least one OPEN /
2445
+ * IN_PROGRESS blocker). Distinguished from a regular `SetStatusResult`
2446
+ * by the literal `skipped` field; the CLI keys on it to switch
2447
+ * between the "closed" and "waiting" rendering paths.
2448
+ *
2449
+ * Surfaced in `fb_umbrella_no_auto_close` (impact=60): a wave umbrella
2450
+ * with N blockers stayed OPEN after every blocker reached a terminal
2451
+ * status. `--if-ready` is the cheap fix: bare `mu task close` is
2452
+ * unchanged (closes regardless), `--if-ready` is a no-op unless every
2453
+ * blocker is in a terminal status (CLOSED / REJECTED / DEFERRED).
2454
+ * Reject and defer satisfy the predicate too because `--if-ready`'s
2455
+ * job is to fire when the umbrella has nothing left to wait for, and
2456
+ * a rejected/deferred blocker is no longer being waited on. */
2457
+ interface CloseSkippedResult {
2458
+ /** Always 'not_ready' when set; future cause-codes can extend this
2459
+ * without reshaping the JSON payload (the literal-union narrows
2460
+ * safely in the CLI rendering path). */
2461
+ skipped: "not_ready";
2462
+ /** Status before the call (always the current status, no change). */
2463
+ previousStatus: TaskStatus;
2464
+ /** Status after the call (== previousStatus, since we no-op). */
2465
+ status: TaskStatus;
2466
+ /** Always false on a skip (no row mutated). */
2467
+ changed: false;
2468
+ /** Local ids of every blocker still in OPEN or IN_PROGRESS, sorted
2469
+ * alphabetically for deterministic rendering. Empty list is
2470
+ * impossible on this branch — the no-op only fires when ≥1
2471
+ * blocker is non-terminal. */
2472
+ blockingIds: string[];
1990
2473
  }
1991
- declare function listTasks(db: Db, workstream?: string, opts?: ListTasksOptions): TaskRow[];
1992
- /** Options for listReady. The optional `statuses` filter composes
1993
- * on top of the `ready` view (which itself constrains to
1994
- * `status='OPEN'`); passing only OPEN is identical to today's no-
1995
- * filter shape, passing only non-OPEN values returns []. Exists so
1996
- * `mu task next --status` can mirror the multi-status flag shape
1997
- * shipped on `mu task list` (task_list_multi_status_union). */
1998
- interface ListReadyOptions {
1999
- status?: TaskStatus | readonly TaskStatus[];
2474
+ interface CloseTaskOptions extends EvidenceOption {
2475
+ workstream: string;
2476
+ /** When true, no-op the close unless every blocker is in a terminal
2477
+ * status (CLOSED / REJECTED / DEFERRED). Returns a
2478
+ * `CloseSkippedResult` carrying the still-blocking ids; the CLI
2479
+ * renders the skip with a Next: hint pointing at `mu task wait`.
2480
+ * When false / omitted, behaves as bare `closeTask` (closes
2481
+ * regardless of blocker status). */
2482
+ ifReady?: boolean;
2483
+ /** Optional actor identity attributed to the synthetic `CLOSE: …`
2484
+ * note auto-inserted when `evidence` is non-empty (see closeTask
2485
+ * body). The CLI resolves this via `resolveActorIdentity()` so the
2486
+ * note carries the closing worker's name; SDK callers (tests,
2487
+ * internal use) may omit it (the note then carries no author, same
2488
+ * as a bare `addNote` without `--author`). Surfaced in mufeedback
2489
+ * task_close_evidence_does_not_append_the. */
2490
+ author?: string;
2000
2491
  }
2001
- declare function listReady(db: Db, workstream: string, opts?: ListReadyOptions): TaskRow[];
2002
- declare function listBlocked(db: Db, workstream: string): TaskRow[];
2003
- declare function listGoals(db: Db, workstream: string): TaskRow[];
2004
- /** All IN_PROGRESS tasks in a workstream, most-recently-touched first.
2005
- * Used by `mu state` and `mu hud` to populate their in-progress slice;
2006
- * exposed as a named SDK helper so those CLI verbs don't re-derive
2007
- * the row-shape conversion (review_code_raw_task_state_duplicate). */
2008
- declare function listInProgress(db: Db, workstream: string): TaskRow[];
2009
- /** Most-recently-closed tasks in a workstream, newest first, capped at
2010
- * `limit` (default 5). Used by `mu state` for its 'recent closed'
2011
- * slice; exposed as a named SDK helper so the CLI no longer needs the
2012
- * raw-row type that was duplicating RawTaskRow
2013
- * (review_code_raw_task_state_duplicate). */
2014
- declare function listRecentClosed(db: Db, workstream: string, limit?: number): TaskRow[];
2015
- /** List notes for a task. Operator-facing local_id; resolves to the
2016
- * surrogate task id via taskIdFor (with optional workstream scope). */
2017
- declare function listNotes(db: Db, taskLocalId: string, workstream: string): TaskNoteRow[];
2018
- /**
2019
- * All tasks currently owned by `agent` in a given workstream
2020
- * (v5: agents.name is per-workstream unique). Sorted by local_id.
2492
+ /** Convenience: setTaskStatus(db, id, "CLOSED"). Accepts evidence.
2493
+ * Pre-snapshots the DB (snap_design §CAPTURE STRATEGY > WHEN). Skipped
2494
+ * for the idempotent no-op (already CLOSED) so we don't accumulate
2495
+ * empty-delta snapshots on retry loops.
2021
2496
  *
2022
- * Defaults to **excluding CLOSED** since the verb's purpose is "what
2023
- * is X currently working on?" and a closed task is no longer being
2024
- * worked on. closeTask intentionally preserves `owner` as a
2025
- * historical record (so audit/notes can attribute decisions); pass
2026
- * `{ includeClosed: true }` to surface that history.
2027
- */
2028
- declare function listTasksByOwner(db: Db, workstream: string, owner: string, opts?: {
2029
- includeClosed?: boolean;
2030
- }): TaskRow[];
2031
- interface SearchTasksOptions {
2032
- /** Restrict to one workstream; undefined = search across all. */
2033
- workstream?: string;
2034
- /** Also search `task_notes.content` (default false: titles + ids only). */
2035
- includeNotes?: boolean;
2497
+ * With `ifReady: true`, returns a `CloseSkippedResult` (no mutation,
2498
+ * no snapshot) when any blocker is still OPEN / IN_PROGRESS. Used by
2499
+ * `mu task close --if-ready` so an orchestrator can fire-and-forget
2500
+ * the umbrella close after every blocker resolves without first
2501
+ * re-querying the graph. */
2502
+ declare function closeTask(db: Db, localId: string, opts: CloseTaskOptions): SetStatusResult | CloseSkippedResult;
2503
+ /** Convenience: setTaskStatus(db, id, "OPEN"). Owner intentionally NOT
2504
+ * cleared — use `releaseTask` for that. Accepts evidence. */
2505
+ declare function openTask(db: Db, localId: string, opts: EvidenceOption & {
2506
+ workstream: string;
2507
+ }): SetStatusResult;
2508
+ interface RejectDeferOptions extends EvidenceOption {
2509
+ /** Workstream context for the root task. All internal task lookups
2510
+ * (including the dependent walk) scope to this workstream. */
2511
+ workstream: string;
2512
+ /** If true, walk the transitive dependent closure and (with `yes`)
2513
+ * apply the same status to every dependent, atomically. Without
2514
+ * `yes`, runs as a dry-run: returns the list of tasks that WOULD
2515
+ * be swept (changedIds) with `dryRun: true` and changes nothing.
2516
+ * Logs one event per task (via setTaskStatus) on commit. */
2517
+ cascade?: boolean;
2518
+ /** Required to actually commit a `cascade` operation. Without it,
2519
+ * cascade is dry-run only — prints the affected dependents so the
2520
+ * caller can verify before sweeping. Mirrors `mu workstream destroy
2521
+ * --yes`. Surfaced in mufeedback bug_cascade_reject_too_aggressive
2522
+ * when an accidentally-cascaded reject swept hud_dogfood (which had
2523
+ * independent merit and needed reopening). */
2524
+ yes?: boolean;
2036
2525
  }
2037
- /**
2038
- * Substring search on task `title` and `local_id`, case-insensitive.
2039
- * With `includeNotes: true` also searches `task_notes.content`. The
2040
- * pattern is wrapped in `%...%` automatically so callers don't need
2041
- * SQL LIKE knowledge — for explicit globs (or regex), use `mu sql`.
2042
- */
2043
- declare function searchTasks(db: Db, pattern: string, opts?: SearchTasksOptions): TaskRow[];
2044
- interface TaskEdges {
2045
- /** Tasks that must close before this one can start (blockers). */
2046
- blockers: string[];
2047
- /** Tasks that this one blocks (dependents). */
2048
- dependents: string[];
2526
+ interface RejectDeferResult {
2527
+ /** Tasks that actually changed status, in cascade order (root first). */
2528
+ changedIds: string[];
2529
+ /** The status now stamped on every changedId. */
2530
+ status: TaskStatus;
2531
+ /** True iff anything changed. False on a clean idempotent no-op
2532
+ * (root task already in target status, no dependents). */
2533
+ changed: boolean;
2534
+ /** True iff this was a `cascade` dry-run (cascade requested without
2535
+ * `yes`). In that case `changedIds` lists tasks that WOULD be
2536
+ * swept; the DB is unchanged. */
2537
+ dryRun: boolean;
2538
+ /** Tasks that would be touched by a cascade. Same as `changedIds`
2539
+ * on a dry-run; populated even on a commit so the caller can
2540
+ * report what was swept. */
2541
+ affectedIds: string[];
2049
2542
  }
2050
- /** One end of an edge with the neighbour's current status attached.
2051
- * Used by `mu task show` to group blockers/dependents into
2052
- * "still gating" vs "satisfied" buckets without making the renderer
2053
- * do a second round-trip to the DB per neighbour. */
2054
- interface TaskEdgeWithStatus {
2055
- name: string;
2543
+ /** Reject a task: terminal 'won't do' (out of scope, duplicate, wontfix).
2544
+ * Refuses if dependents are open unless `--cascade`.
2545
+ * Pre-snapshots once at the verb level so a cascade onto N children
2546
+ * produces a single snapshot, not N. Skipped for the idempotent no-op. */
2547
+ declare function rejectTask(db: Db, localId: string, opts: RejectDeferOptions): RejectDeferResult;
2548
+ /** Defer a task: parked, may revisit. Same dependent-stranding semantics
2549
+ * as reject (DEFERRED also doesn't satisfy a `--blocked-by` edge).
2550
+ * Pre-snapshots once at the verb level. Skipped for the idempotent no-op. */
2551
+ declare function deferTask(db: Db, localId: string, opts: RejectDeferOptions): RejectDeferResult;
2552
+
2553
+ interface ReleaseResult {
2554
+ /** The previous owner (null if the task was already unowned). */
2555
+ previousOwnerName: string | null;
2556
+ /** Status before the release. */
2557
+ previousStatus: TaskStatus;
2558
+ /** Status after the release. */
2056
2559
  status: TaskStatus;
2560
+ /** True iff owner OR status actually changed. */
2561
+ changed: boolean;
2057
2562
  }
2058
- interface TaskEdgesWithStatus {
2059
- /** Tasks that must close before this one can start (blockers),
2060
- * carrying each blocker's current status. */
2061
- blockers: TaskEdgeWithStatus[];
2062
- /** Tasks that this one blocks (dependents), carrying each
2063
- * dependent's current status. */
2064
- dependents: TaskEdgeWithStatus[];
2563
+ interface ReleaseTaskOptions extends EvidenceOption {
2564
+ /** Workstream context for the task (v5: tasks.local_id is
2565
+ * per-workstream unique). */
2566
+ workstream: string;
2567
+ /** Force `status = OPEN` regardless of the current status. Without
2568
+ * this flag, `IN_PROGRESS` is also flipped to `OPEN` automatically
2569
+ * (so a released task isn't left structurally stranded with
2570
+ * `owner=NULL, status=IN_PROGRESS`); CLOSED / REJECTED / DEFERRED
2571
+ * are preserved. `--reopen` is the override for the rarer "un-
2572
+ * close and hand back to the pool" workflow. */
2573
+ reopen?: boolean;
2065
2574
  }
2066
2575
  /**
2067
- * Direct (one-hop) edges for a task. For transitive prerequisites, use
2068
- * `getPrerequisites()`; this helper is the immediate-neighbour view used
2069
- * by `mu task show`.
2070
- */
2071
- declare function getTaskEdges(db: Db, taskLocalId: string, workstream: string): TaskEdges;
2072
- /**
2073
- * Same one-hop edge view as `getTaskEdges`, but each neighbour is
2074
- * returned as `{ name, status }` so callers can group / colour by
2075
- * status without an N+1 round-trip. Used by `mu task show` to split
2076
- * "blocked by" (still-gating) from "satisfied" (already-CLOSED)
2077
- * blockers, and the symmetric split on the dependents side
2078
- * (task_show_blocked_by_renders_closed). The status is the neighbour's
2079
- * full TaskStatus, not just OPEN/CLOSED — REJECTED/DEFERRED still
2080
- * gate downstream work, so the renderer keeps them in the
2081
- * still-gating bucket.
2082
- */
2083
- declare function getTaskEdgesWithStatus(db: Db, taskLocalId: string, workstream: string): TaskEdgesWithStatus;
2084
- /**
2085
- * All tasks transitively reachable from `taskId` via reverse-edge
2086
- * traversal (i.e. the set of tasks that block this one), including the
2087
- * task itself.
2576
+ * Release a task: clear `tasks.owner`.
2577
+ *
2578
+ * Status side-effects (review_release_open_in_progress_inconsistency):
2579
+ * - IN_PROGRESS → OPEN automatically (without it, the task is
2580
+ * stranded: no owner to drive it forward, but `mu task next`
2581
+ * skips it because it's not OPEN).
2582
+ * - OPEN / CLOSED / REJECTED / DEFERRED preserved.
2583
+ * - `--reopen` forces OPEN regardless of current status the
2584
+ * escape hatch for un-closing a CLOSED owned task in one verb.
2585
+ *
2586
+ * Idempotent: releasing an already-unowned task with no `--reopen` and
2587
+ * no IN_PROGRESS status is a no-op (returns `changed: false`).
2588
+ * Throws TaskNotFoundError on missing.
2088
2589
  */
2089
- declare function getPrerequisites(db: Db, taskLocalId: string, workstream: string): Set<string>;
2090
- interface AddTaskOptions {
2091
- localId: string;
2590
+ declare function releaseTask(db: Db, localId: string, opts: ReleaseTaskOptions): ReleaseResult;
2591
+ interface ClaimTaskOptions extends EvidenceOption {
2592
+ /** Workstream context for both the task and the claiming agent.
2593
+ * v5: agents.name and tasks.local_id are per-workstream unique;
2594
+ * the task lookup AND the agent FK lookup scope to this
2595
+ * workstream so a same-named task or worker elsewhere can't be
2596
+ * silently picked. The CLI always passes this from the resolved
2597
+ * -w / $MU_SESSION. */
2092
2598
  workstream: string;
2093
- title: string;
2094
- /** 1..100; enforced by schema CHECK. */
2095
- impact: number;
2096
- /** > 0; enforced by schema CHECK. */
2097
- effortDays: number;
2098
2599
  /**
2099
- * Tasks that block this one. Edges inserted as `blocker -> newTask`.
2100
- * Each blocker must already exist AND share this task's workstream
2101
- * (cross-workstream edges are forbidden); cycle check guards each
2102
- * edge. The CLI surfaces this as `--blocked-by`; the SDK key matches.
2600
+ * Override the agent name. If omitted, derived from the current pane's
2601
+ * title via `tmux display-message -t $TMUX_PANE -p '#{pane_title}'`.
2602
+ *
2603
+ * Mutually exclusive with `self: true`.
2604
+ */
2605
+ agentName?: string;
2606
+ /**
2607
+ * Workstream that the claimer agent lives in. When omitted, defaults
2608
+ * to `opts.workstream` (today's same-workstream behaviour). Set by
2609
+ * the CLI when `mu task claim X -w A --for B/worker-1` qualifies the
2610
+ * `--for` ref with a different workstream prefix
2611
+ * (`task_claim_for_cross_workstream`).
2612
+ *
2613
+ * Cross-workstream ownership is structurally allowed by the schema:
2614
+ * `tasks.owner_id` is an INTEGER FK to `agents.id` with no
2615
+ * workstream qualifier on the agent side. The per-workstream UNIQUE
2616
+ * on `agents(workstream_id, name)` is what previously made the
2617
+ * SDK's name → id lookup scope to one workstream; this option
2618
+ * widens that lookup to a different workstream when the operator
2619
+ * dispatches across a workstream boundary. The agent's own
2620
+ * workstream remains unchanged — only the task's `owner_id` points
2621
+ * out-of-workstream.
2622
+ */
2623
+ agentWorkstream?: string;
2624
+ /**
2625
+ * Anonymous claim: write `owner = NULL` instead of resolving an agent
2626
+ * name and checking the FK. Use when the actor is the orchestrator
2627
+ * (or a script, or a human) doing direct work in a workstream they
2628
+ * aren't a registered worker in.
2629
+ *
2630
+ * The actor name is still recorded — it ends up in `agent_logs.source`
2631
+ * for the auto-emitted `task claim` event — so provenance is preserved.
2632
+ * Just not in the FK column.
2633
+ *
2634
+ * Resolution order for the actor name (used as the log source):
2635
+ * 1. `actor` if explicitly passed.
2636
+ * 2. Current pane title (when `$TMUX_PANE` is set).
2637
+ * 3. `$USER`.
2638
+ * 4. The literal string 'unknown'.
2639
+ *
2640
+ * Mutually exclusive with `agentName` (the two are alternative
2641
+ * answers to "who's the actor for this claim?"). Passing both is a
2642
+ * usage error.
2643
+ */
2644
+ self?: boolean;
2645
+ /**
2646
+ * Override the actor name used for the log source when `self: true`.
2647
+ * Ignored when `self: false`. Useful when the orchestrator wants to
2648
+ * attribute the work to a meaningful name rather than the pane
2649
+ * title (e.g. "deploy-bot" rather than "pi-mu").
2103
2650
  */
2104
- blockedBy?: string[];
2651
+ actor?: string;
2652
+ }
2653
+ interface ClaimResult {
2654
+ /** The agent now owning the task, or null when the claim was anonymous (--self). */
2655
+ ownerName: string | null;
2656
+ /** The actor recorded in the agent_logs event — the agent name for a
2657
+ * registered-worker claim, or the resolved actor for --self. */
2658
+ actorName: string;
2659
+ /** The previous owner (null if it was unowned). */
2660
+ previousOwnerName: string | null;
2661
+ /** The status BEFORE the claim; post-claim is IN_PROGRESS unless was CLOSED. */
2662
+ previousStatus: TaskStatus;
2663
+ /** The status AFTER the claim. */
2664
+ status: TaskStatus;
2105
2665
  }
2106
2666
  /**
2107
- * Atomically create a task and (optionally) its incoming blocked-by
2108
- * edges.
2667
+ * Claim a task. Two modes:
2109
2668
  *
2110
- * The task insert + every edge insert + cycle check happen inside one
2111
- * SQLite transaction. If any blocker is missing or any edge would
2112
- * create a cycle, the entire add rolls back.
2669
+ * Worker claim (default):
2670
+ * Resolve an agent name from `opts.agentName` or from $TMUX_PANE's
2671
+ * pane title. The name MUST exist in the agents table (FK on
2672
+ * tasks.owner). Sets `owner = <name>`. This is what mu-spawned
2673
+ * workers do, and what `mu task claim --for <worker>` does for
2674
+ * orchestrator dispatch.
2113
2675
  *
2114
- * Cycle check for `addTask` is structurally trivial (a fresh task has
2115
- * no outgoing edges, so `to -> ... -> from` is impossible). It's still
2116
- * called here so the same primitive is exercised by tests.
2117
- */
2118
- declare function addTask(db: Db, opts: AddTaskOptions): TaskRow;
2119
- interface AddNoteOptions {
2120
- /** Free-form author label. Convention: agent name, "user", or "orchestrator". */
2121
- author?: string;
2122
- /** Workstream context (operator-facing name). v5: tasks.local_id is
2123
- * per-workstream unique, so this is required to disambiguate. */
2124
- workstream: string;
2125
- }
2126
- declare function addNote(db: Db, taskLocalId: string, content: string, opts: AddNoteOptions): TaskNoteRow;
2127
- interface BlockEdgeResult {
2128
- /** True iff a row was actually inserted (vs. already present). */
2129
- added: boolean;
2130
- }
2131
- /**
2132
- * Add the edge `blocker → blocked` ('blocker blocks blocked').
2133
- * Idempotent (existing edge → `added: false`). Validates:
2676
+ * Anonymous claim (--self):
2677
+ * Skip the name -> agents FK lookup entirely. Sets `owner = NULL`.
2678
+ * Records the actor in `agent_logs.source` instead. This is the
2679
+ * orchestrator-doing-direct-work path — the actor is logged but
2680
+ * not registered as a worker pane.
2134
2681
  *
2135
- * - both tasks exist
2136
- * - same workstream (cross-workstream edges forbidden)
2137
- * - no cycle (the new edge wouldn't form a path blocked → ... → blocker)
2138
- * - blocker blocked (no self-reference)
2139
- */
2140
- declare function addBlockEdge(db: Db, workstream: string, blocked: string, blocker: string): BlockEdgeResult;
2141
- interface RemoveBlockEdgeResult {
2142
- /** True iff a row was actually deleted (vs. no such edge). */
2143
- removed: boolean;
2144
- }
2145
- /**
2146
- * Remove the edge `blocker → blocked`. Idempotent (no edge →
2147
- * `removed: false`). Does NOT validate task existence — if the
2148
- * edge is gone there's nothing to do, regardless of whether the
2149
- * tasks are gone too.
2682
+ * Status side-effect: OPEN -> IN_PROGRESS; IN_PROGRESS / CLOSED unchanged.
2683
+ *
2684
+ * Concurrency: the worker-claim path uses a single-statement CAS UPDATE
2685
+ * with `WHERE owner IS NULL OR owner = ?` so two workers racing to
2686
+ * claim the same task can't both win. The anonymous path uses
2687
+ * `WHERE owner IS NULL` (anonymous claims don't 'own' the task in any
2688
+ * exclusive sense; if it's already owned by anyone, the anonymous claim
2689
+ * is a TaskAlreadyOwnedError just like a worker claim would be).
2150
2690
  */
2151
- declare function removeBlockEdge(db: Db, workstream: string, blocked: string, blocker: string): RemoveBlockEdgeResult;
2152
- interface DeleteTaskResult {
2153
- /** True iff the row existed and was deleted. False on a dry-run
2154
- * (preview) AND on the idempotent missing-row case. */
2155
- deleted: boolean;
2156
- /** Number of `task_edges` rows cascaded out (informational). On a
2157
- * dry-run, this is the would-be count. */
2158
- deletedEdges: number;
2159
- /** Number of `task_notes` rows cascaded out (informational). On a
2160
- * dry-run, this is the would-be count. */
2161
- deletedNotes: number;
2162
- /** True iff this was a dry-run (`opts.dryRun: true`). On a
2163
- * dry-run `deleted` is false and the counts are the would-be
2164
- * counts; the DB is unchanged. Always false on a commit / on a
2165
- * missing-row idempotent no-op. */
2166
- dryRun: boolean;
2167
- /** True iff a matching task row was found at the time of the
2168
- * call. Discriminator for the CLI: a dry-run that found nothing
2169
- * (`present: false`) renders differently from a dry-run that
2170
- * found an existing task with zero edges and zero notes
2171
- * (`present: true, deletedEdges: 0, deletedNotes: 0`). */
2172
- present: boolean;
2173
- }
2174
- interface DeleteTaskOptions {
2175
- /** When true, return the cascade preview (would-be edge / note
2176
- * counts) without mutating and without snapshotting. The CLI uses
2177
- * this to power the bare `mu task delete <id>` two-phase pattern
2178
- * (mirrors `mu workstream destroy` / `mu archive delete` /
2179
- * `mu snapshot prune`). Surfaced by feedback ws task
2180
- * fb_task_delete_no_yes (impact=30): a dogfood report typed
2181
- * `mu task delete X --yes` (mirroring workstream destroy) and got
2182
- * 'unknown option --yes' — the verb took no confirmation flag at
2183
- * all. Two failed deletes left long-named tasks lingering. */
2184
- dryRun?: boolean;
2185
- }
2691
+ declare function claimTask(db: Db, localId: string, opts: ClaimTaskOptions): Promise<ClaimResult>;
2186
2692
  /**
2187
- * Delete a task. FK CASCADE on `task_edges` (from + to) and
2188
- * `task_notes` cleans the joined rows automatically. Idempotent on
2189
- * a missing task (returns `deleted: false`).
2693
+ * Resolve the current actor's identity for attribution in task notes,
2694
+ * --self claims, and any other write that wants 'who did this?'.
2190
2695
  *
2191
- * Pre-counts the cascade victims for reporting because SQLite's
2192
- * `changes()` only reports rows directly affected by the DELETE.
2696
+ * Resolution order:
2697
+ * 1. $MU_AGENT_NAME env var (set by mu spawnAgent on every managed
2698
+ * pane; surfaced from the f3d4bdd commit). Authoritative when
2699
+ * present — you're inside a mu-spawned worker, no ambiguity.
2700
+ * 2. tmux pane title (the pane-title identity step). Works
2701
+ * when running inside any pane mu manages OR adopted.
2702
+ * 3. $USER (when running outside tmux entirely).
2703
+ * 4. The literal 'orchestrator' as a last-resort default.
2193
2704
  *
2194
- * With `opts.dryRun: true`, returns the would-be counts without
2195
- * touching the DB and without taking a snapshot (no mutation = no
2196
- * snapshot same reasoning that gates the closeTask snap on the
2197
- * idempotent no-op path). The CLI bare `mu task delete <id>` form
2198
- * uses this; `--yes` calls through with `dryRun: false`.
2705
+ * Why prefer env over pane title: pane titles are a tmux-server-wide
2706
+ * resource that anything can rewrite. The env var is set per-pane at
2707
+ * spawn time and is unforgeable from outside without explicit
2708
+ * `--actor` override. Pane title is the only identity available for
2709
+ * adopted panes that didn't go through mu's spawn path.
2199
2710
  */
2200
- declare function deleteTask(db: Db, localId: string, workstream: string, opts?: DeleteTaskOptions): DeleteTaskResult;
2201
- interface UpdateTaskOptions {
2202
- title?: string;
2203
- /** 1..100; enforced by schema CHECK. */
2204
- impact?: number;
2205
- /** > 0; enforced by schema CHECK. */
2206
- effortDays?: number;
2711
+ declare function resolveActorIdentity(): Promise<string>;
2712
+
2713
+ declare function setWaitSleepForTests(impl: ((ms: number) => Promise<void>) | undefined): (ms: number) => Promise<void>;
2714
+ /** Test seam: swap the stderr writer used by the stuck-task warning so
2715
+ * unit tests can capture warnings without spying on process.stderr. */
2716
+ declare function setWaitStuckWarnForTests(impl: ((msg: string) => void) | undefined): (msg: string) => void;
2717
+ /** Total number of polls performed across all `waitForTasks` calls in this
2718
+ * process. Tests typically reset before exercising and read after. */
2719
+ declare function getWaitPollCount(): number;
2720
+ declare function resetWaitPollCount(): void;
2721
+ /** A single task ref the wait verb is watching. Cross-workstream
2722
+ * waits arrive as a heterogeneous list of (workstream, name) pairs;
2723
+ * the legacy single-workstream call passes the same workstream on
2724
+ * every ref. task_wait_cross_workstream. */
2725
+ interface TaskWaitRef {
2726
+ /** The workstream the task lives in. Each ref carries its own so
2727
+ * the SDK doesn't need a single "the workstream" — cross-ws waits
2728
+ * pass refs from multiple workstreams in one call. */
2729
+ workstreamName: string;
2730
+ /** The task's per-workstream-unique local id. */
2731
+ name: string;
2207
2732
  }
2208
- interface UpdateTaskResult {
2209
- /** True iff at least one field actually changed. */
2210
- updated: boolean;
2211
- /** The fields whose values differ post-update (in `UpdateTaskOptions`'s
2212
- * camelCase shape). Empty when `updated: false`. */
2213
- changedFields: string[];
2733
+ interface TaskWaitOptions {
2734
+ /** Target status. Default 'CLOSED'. */
2735
+ status?: TaskStatus;
2736
+ /** When true, succeed as soon as ONE listed task reaches the target.
2737
+ * Default false: every listed task must reach the target. */
2738
+ any?: boolean;
2739
+ /** Maximum time to wait, in milliseconds. Default 600_000 (10 min).
2740
+ * Pass 0 to wait forever. */
2741
+ timeoutMs?: number;
2742
+ /** Polling interval. Default 1000ms; overridable for tests. */
2743
+ pollMs?: number;
2744
+ /** Workstream context applied to bare-string ids. Required when the
2745
+ * caller passes `string[]`; ignored when the caller passes
2746
+ * `TaskWaitRef[]` (each ref carries its own ws). The legacy
2747
+ * single-ws SDK call site keeps its today's shape; the cross-ws
2748
+ * callers (CLI verb) pass `TaskWaitRef[]` and omit `workstream`.
2749
+ * task_wait_cross_workstream. */
2750
+ workstream?: string;
2751
+ /** Emit a yellow STUCK warning to stderr (once per task per wait call)
2752
+ * when an IN_PROGRESS task's owner has been in `needs_input` for at
2753
+ * least this many milliseconds since the agent row's last update.
2754
+ * Default 300_000 (5 min). Pass 0 to disable.
2755
+ *
2756
+ * Surfaced by agent_close_discipline_gap in mufeedback: workers
2757
+ * occasionally finish + commit + go idle without running
2758
+ * `mu task close <id>`, leaving wait blocked indefinitely. The
2759
+ * warning is observation-only — wait keeps polling so the operator
2760
+ * (or a wrapping policy) decides whether to force-close, re-prompt,
2761
+ * or escalate. */
2762
+ stuckAfterMs?: number;
2763
+ /** What to do when the `--stuck-after` predicate fires on a watched
2764
+ * task. `'warn'` (default) = today's behaviour: yellow STUCK line
2765
+ * to stderr (deduped per task per wait call) + corroborating
2766
+ * `kind='event'` agent_logs row; wait keeps polling. `'exit'` =
2767
+ * same emit + persist, but THEN throw `StallDetectedDuringWaitError`
2768
+ * so the CLI wrapper exits 7 (STALL_DETECTED). The exit-action is
2769
+ * the unattended-orchestrator escape: a wrapping policy can branch
2770
+ * on 7 (idle, ambiguous — operator decides poke vs release) vs 6
2771
+ * (dead pane, unambiguous — re-dispatch).
2772
+ *
2773
+ * Carve-out (lives at the call site, not here): the CLI passes
2774
+ * `'exit'` only when the wait target is CLOSED — mirrors exit-6's
2775
+ * reaper-flip suppression. With `--status OPEN` the worker reaching
2776
+ * needs_input might BE the success path. See
2777
+ * task_wait_stall_action_flag. */
2778
+ onStall?: "warn" | "exit";
2779
+ /** Optional async hook run BEFORE every snapshot (initial + each
2780
+ * poll iteration). The CLI uses this to reconcile the workstream
2781
+ * each tick (reaper flips IN_PROGRESS → OPEN for dead-pane
2782
+ * workers) and to throw a typed error when a reaper-flip on a
2783
+ * watched task should abandon the wait — see
2784
+ * task_wait_reconcile_dead_panes. Throwing from `beforePoll`
2785
+ * propagates out of `waitForTasks` unchanged.
2786
+ *
2787
+ * Kept as a generic seam (not a `--reconcile`-shaped option) so
2788
+ * the SDK module stays free of tmux/reconcile imports — that
2789
+ * layering belongs above the SDK in the CLI wrapper. */
2790
+ beforePoll?: () => Promise<void>;
2791
+ }
2792
+ interface TaskWaitTaskState {
2793
+ /** The workstream this task lives in. Cross-workstream waits
2794
+ * return a mixed list; the workstream is part of identity.
2795
+ * task_wait_cross_workstream. */
2796
+ workstreamName: string;
2797
+ /** The task's per-workstream-unique name. */
2798
+ name: string;
2799
+ /** Current status (at the moment we exit). */
2800
+ status: TaskStatus;
2801
+ /** Owner at exit time (NULL when unowned, after release, or after
2802
+ * the reaper flipped IN_PROGRESS → OPEN due to a dead pane). */
2803
+ owner: string | null;
2804
+ /** True when this task's status equals the target. */
2805
+ reachedTarget: boolean;
2806
+ /** True when the task is IN_PROGRESS, owned by a registered agent
2807
+ * whose detected status is `needs_input` for >= `stuckAfterMs`.
2808
+ * Surfaces the agent_close_discipline_gap pattern: worker finished +
2809
+ * committed but skipped `mu task close <id>`. Backwards-compatible
2810
+ * signal — callers ignoring it see no behaviour change. */
2811
+ stuck: boolean;
2812
+ }
2813
+ interface TaskWaitResult {
2814
+ /** Per-task state at exit time. Same length and order as the input
2815
+ * list. The caller derives all-reached / any-reached / elapsed
2816
+ * from this list (count `r.reachedTarget`) and from its own
2817
+ * startedAt clock — keeping the SDK return minimal. */
2818
+ refs: TaskWaitTaskState[];
2819
+ /** True when we exited because of the timeout, not because the wait
2820
+ * condition was met. Refs that did reach the target are still
2821
+ * reflected in `refs[i].reachedTarget` on partial-progress timeout. */
2822
+ timedOut: boolean;
2214
2823
  }
2215
2824
  /**
2216
- * Update scalar fields on a task. Each option is independently optional;
2217
- * passing none is a typed no-op (returns `updated: false, changedFields: []`).
2218
- * Fields whose new value equals the current value are skipped (no row change).
2825
+ * Block until a set of tasks reaches `opts.status` (default CLOSED).
2826
+ * Returns a result describing the final state the caller decides
2827
+ * whether to treat partial-progress timeouts as success or failure
2828
+ * (the CLI maps a clean exit to 0, a timeout to 5).
2219
2829
  *
2220
- * NOT for status (use `closeTask` / `openTask` / `setTaskStatus`), owner
2221
- * (use `claimTask` / `releaseTask`), local_id (rename is deferred), or
2222
- * workstream (cross-workstream moves are deferred).
2830
+ * Pre-flight: every task in `localIds` MUST exist; missing ones throw
2831
+ * TaskNotFoundError before any waiting begins. This is loud-fail by
2832
+ * design a typo'd id silently waiting forever is the worst-case UX.
2223
2833
  */
2224
- interface UpdateTaskScopeOption {
2225
- workstream: string;
2834
+ declare function waitForTasks(db: Db, input: readonly TaskWaitRef[] | readonly string[], opts: TaskWaitOptions): Promise<TaskWaitResult>;
2835
+
2836
+ interface FullDag {
2837
+ /** Root tasks: no incoming `blocks` edge (no blockers). */
2838
+ roots: TaskRow[];
2839
+ /** Edges map parent task name → child task names (what parent blocks). */
2840
+ edges: Map<string, string[]>;
2841
+ /** All tasks in the workstream, keyed by operator-facing name. */
2842
+ tasks: Map<string, TaskRow>;
2226
2843
  }
2227
- declare function updateTask(db: Db, localId: string, opts: UpdateTaskOptions, scope: UpdateTaskScopeOption): UpdateTaskResult;
2228
- interface ReparentTaskResult {
2229
- /** Edges removed (i.e. all incoming `to_task = taskId` edges). */
2230
- removedEdges: number;
2231
- /** Edges added (== blockers.length on success). */
2232
- addedEdges: number;
2844
+ type TaskStatusLabelFn = (task: TaskRow) => string;
2845
+ interface RenderTreeOptions {
2846
+ /** Include the task title after the name + status label. Default: true. */
2847
+ includeTitle?: boolean;
2848
+ }
2849
+ interface LoadFullDagOptions {
2850
+ /** Optional visible-status filter. Omitted = every task status. */
2851
+ statuses?: ReadonlySet<TaskStatus>;
2233
2852
  }
2853
+ declare function loadFullDag(db: Db, workstream: string, opts?: LoadFullDagOptions): FullDag;
2234
2854
  /**
2235
- * Atomically replace every incoming edge of `taskId` with new ones
2236
- * `blocker[i] taskId`. Pass an empty `blockers` array to clear all
2237
- * incoming edges (the task becomes ready iff its status allows).
2238
- *
2239
- * Validates ALL new blockers up-front (existence + same workstream +
2240
- * cycle check); if any fails, no DELETE happens — the call is fully
2241
- * atomic via a single transaction.
2242
- *
2243
- * Cycle reasoning: removing the existing incoming edges to `taskId`
2244
- * doesn't change `taskId`'s OUTGOING reachability, so
2245
- * `wouldCreateCycle(db, blocker, taskId)` evaluated against the
2246
- * pre-state gives the right answer for each new edge.
2855
+ * Render a DAG forest in the same ASCII shape as `mu task tree --down`:
2856
+ * each root is printed as a header node, dependents are below it, and
2857
+ * DAG diamonds collapse after the first full subtree render with a
2858
+ * one-line recurrence marker.
2247
2859
  */
2248
- declare function reparentTask(db: Db, taskLocalId: string, blockers: readonly string[], scope: {
2249
- workstream: string;
2250
- }): ReparentTaskResult;
2860
+ declare function renderForest(roots: readonly TaskRow[], edges: ReadonlyMap<string, readonly string[]>, statusFn: TaskStatusLabelFn, tasksByName?: ReadonlyMap<string, TaskRow>, opts?: RenderTreeOptions): string;
2861
+ declare function renderTaskTree(db: Db, workstream: string, root: TaskRow, direction: "blockers" | "dependents", statusFn: TaskStatusLabelFn, opts?: RenderTreeOptions): string;
2251
2862
 
2252
2863
  interface Track {
2253
2864
  /** Goal tasks (no outgoing edges) belonging to this track. */
@@ -2268,10 +2879,18 @@ interface Track {
2268
2879
  */
2269
2880
  declare function getParallelTracks(db: Db, workstream: string): Track[];
2270
2881
 
2882
+ declare const EXPORT_MANIFEST_VERSION = 2;
2271
2883
  /** One per-task summary inside a per-source-ws section of the manifest. */
2272
2884
  interface ExportTaskEntry {
2273
- /** Task local_id == filename stem (`<id>.md`). */
2885
+ /** Task local_id == filename stem (`<id>.md`). Kept for v1 manifest compatibility. */
2274
2886
  id: string;
2887
+ /** Task local_id, duplicated under the operator-facing SDK name so bucket INDEX can render from manifest alone. */
2888
+ name: string;
2889
+ /** Compact summary fields needed for bucket-level INDEX.md without re-reading the DB. */
2890
+ title: string;
2891
+ status: TaskRow["status"];
2892
+ impact: number;
2893
+ effortDays: number;
2275
2894
  /** Path relative to the bucket root (e.g. `auth/tasks/design.md`). */
2276
2895
  path: string;
2277
2896
  /** sha256 of the markdown body bytes; idempotency key. */
@@ -2294,12 +2913,16 @@ interface ExportSourceManifest {
2294
2913
  /** Per-task entries; sorted by id for stable diffs. */
2295
2914
  tasks: ExportTaskEntry[];
2296
2915
  }
2297
- /** Top-level bucket manifest. `bucketVersion: 2` — the v0.3 shape.
2298
- * v1 (bucketVersion absent + top-level `workstream` field) is the
2299
- * legacy single-source shape and is rejected at write time. */
2916
+ /** Top-level bucket manifest. `bucketVersion: 2` — the v0.3 disk layout.
2917
+ * `manifest_version` is the schema of the manifest JSON payload itself:
2918
+ * v1 lacked task summaries, v2 stores enough per-task data to render
2919
+ * bucket INDEX.md from `manifest.sources` alone. Manifests without
2920
+ * `bucketVersion: 2` fall through to the `corrupt` lane in `readManifest`. */
2300
2921
  interface ExportManifest {
2301
- /** Schema discriminator. Always 2 in this codebase. */
2922
+ /** Disk-layout discriminator. Always 2 in this codebase. */
2302
2923
  bucketVersion: 2;
2924
+ /** Manifest-payload discriminator. Always 2 when written by this codebase. */
2925
+ manifest_version: typeof EXPORT_MANIFEST_VERSION;
2303
2926
  /** Operator-chosen bucket label (an archive label, or null for a
2304
2927
  * one-shot `mu workstream export`). Surfaced in README only. */
2305
2928
  bucketLabel: string | null;
@@ -2345,17 +2968,6 @@ interface RenderBucketResult {
2345
2968
  manifestPath: string;
2346
2969
  manifest: ExportManifest;
2347
2970
  }
2348
- /** Thrown when the operator points an export at a directory whose
2349
- * existing manifest predates bucket layout (v1, single-source). The
2350
- * fix is destructive (remove and re-export) so we refuse to touch
2351
- * it in-place — the legacy directory may be checked into git and
2352
- * the operator should choose between rebuilding it and picking a
2353
- * new --out. */
2354
- declare class LegacyExportLayoutError extends Error {
2355
- readonly outDir: string;
2356
- readonly name = "LegacyExportLayoutError";
2357
- constructor(outDir: string);
2358
- }
2359
2971
  /**
2360
2972
  * Render `input.sources` to disk under `input.outDir` in the v0.3
2361
2973
  * bucket layout. Idempotent + additive:
@@ -2364,8 +2976,6 @@ declare class LegacyExportLayoutError extends Error {
2364
2976
  * `input.sources` either appends (new) or refreshes (existing)
2365
2977
  * its subdirectory; sources NOT in `input.sources` are left
2366
2978
  * untouched.
2367
- * - If it exists but with a legacy (v1) manifest, throw
2368
- * `LegacyExportLayoutError`.
2369
2979
  *
2370
2980
  * Per-task idempotency is sha256-keyed: a re-export of the same
2371
2981
  * source against an unchanged DB rewrites zero task files. Tasks
@@ -2495,6 +3105,14 @@ interface WorkstreamOptions {
2495
3105
  * unset. */
2496
3106
  resolveBackend?: (name: VcsBackendName) => VcsBackend;
2497
3107
  }
3108
+ interface DestroyWorkstreamOptions extends WorkstreamOptions {
3109
+ /** Skip the per-workstream pre-mutation snapshot because the caller
3110
+ * already captured a broader snapshot for the whole destructive
3111
+ * operation. Used by `mu workstream destroy --empty` after its
3112
+ * sweep-level safety snapshot; direct destroy callers leave this
3113
+ * false so the default safety net is unchanged. */
3114
+ suppressSnapshot?: boolean;
3115
+ }
2498
3116
  /**
2499
3117
  * Discover every workstream visible on this machine. The union of:
2500
3118
  * - rows in the `workstreams` table (canonical DB source; populated by
@@ -2518,7 +3136,7 @@ declare function summarizeWorkstream(db: Db, opts: WorkstreamOptions): Promise<W
2518
3136
  * to call repeatedly. Returns counts so the caller can print a useful
2519
3137
  * summary.
2520
3138
  */
2521
- declare function destroyWorkstream(db: Db, opts: WorkstreamOptions): Promise<DestroyResult>;
3139
+ declare function destroyWorkstream(db: Db, opts: DestroyWorkstreamOptions): Promise<DestroyResult>;
2522
3140
  interface ExportWorkstreamOptions {
2523
3141
  workstream: string;
2524
3142
  /** Output directory (the bucket). Defaults to `./<workstream>/`
@@ -2547,9 +3165,6 @@ interface ExportResult {
2547
3165
  * exporting a different workstream into the same bucket appends a
2548
3166
  * sibling subdir.
2549
3167
  *
2550
- * Throws:
2551
- * - `LegacyExportLayoutError` if `outDir` already contains a
2552
- * pre-0.3 (single-source) manifest.json.
2553
3168
  */
2554
3169
  declare function exportWorkstream(db: Db, opts: ExportWorkstreamOptions): ExportResult;
2555
3170
 
@@ -2560,12 +3175,6 @@ declare class ImportBucketInvalidError extends Error implements HasNextSteps {
2560
3175
  constructor(bucketDir: string, reason: string);
2561
3176
  errorNextSteps(): NextStep[];
2562
3177
  }
2563
- declare class ImportLegacyLayoutError extends Error implements HasNextSteps {
2564
- readonly bucketDir: string;
2565
- readonly name = "ImportLegacyLayoutError";
2566
- constructor(bucketDir: string);
2567
- errorNextSteps(): NextStep[];
2568
- }
2569
3178
  declare class WorkstreamAlreadyExistsError extends Error implements HasNextSteps {
2570
3179
  readonly workstream: string;
2571
3180
  readonly name = "WorkstreamAlreadyExistsError";
@@ -2627,204 +3236,14 @@ interface ImportBucketResult {
2627
3236
  * Per source-ws transactional: a failure in source A rolls back A
2628
3237
  * but leaves source B's import committed.
2629
3238
  *
2630
- * Throws on unrecoverable bucket-level errors (no manifest, legacy
2631
- * layout, --workstream override against multi-source). Per-source
3239
+ * Throws on unrecoverable bucket-level errors (no manifest,
3240
+ * --workstream override against multi-source). Per-source
2632
3241
  * errors (frontmatter parse, edge ref, target name collision) leave
2633
3242
  * the failing source's `errors` array populated and that source's
2634
3243
  * counts at zero; siblings still attempt their own import.
2635
3244
  */
2636
3245
  declare function importBucket(db: Db, opts: ImportBucketOptions): ImportBucketResult;
2637
3246
 
2638
- interface WorkspaceRow {
2639
- agentName: string;
2640
- workstreamName: string;
2641
- backend: VcsBackendName;
2642
- path: string;
2643
- parentRef: string | null;
2644
- createdAt: string;
2645
- /** How many commits the workspace's parent_ref is behind the project's
2646
- * default branch HEAD, as of the last time the workspace's local refs
2647
- * cache was updated. Undefined when not yet computed (the listWorkspaces
2648
- * fast path leaves it unset; call decorateWithStaleness to populate).
2649
- * Null when staleness was queried but cannot be computed (no main found,
2650
- * none-backend, missing parent_ref, command failure). */
2651
- commitsBehindMain?: number | null;
2652
- }
2653
- declare class WorkspaceExistsError extends Error implements HasNextSteps {
2654
- readonly agent: string;
2655
- readonly name = "WorkspaceExistsError";
2656
- constructor(agent: string);
2657
- errorNextSteps(): NextStep[];
2658
- }
2659
- declare class WorkspaceNotFoundError extends Error implements HasNextSteps {
2660
- readonly agent: string;
2661
- readonly name = "WorkspaceNotFoundError";
2662
- constructor(agent: string);
2663
- errorNextSteps(): NextStep[];
2664
- }
2665
- /**
2666
- * Thrown by createWorkspace when the on-disk path it would create is
2667
- * already occupied. Distinct from WorkspaceExistsError (which is about
2668
- * the DB row) so the recovery is clear: the dir is orphaned (no DB
2669
- * row points at it) and needs cleanup.
2670
- *
2671
- * Surfaced as a real bug from the multi-agent dogfood (mufeedback note
2672
- * #143): users hit a bare 'vcs git: workspacePath already exists' from
2673
- * the backend, with no nextSteps. After the cccba88 fix (close-refuses-
2674
- * with-workspace), this case only fires when an orphan from a previous
2675
- * mu version persists OR when the dir was manually rm-rf'd while a
2676
- * stale registration remains (the git-worktree case).
2677
- *
2678
- * Maps to exit code 4 (conflict).
2679
- */
2680
- declare class WorkspacePathNotEmptyError extends Error implements HasNextSteps {
2681
- readonly agent: string;
2682
- readonly workstream: string;
2683
- readonly workspacePath: string;
2684
- readonly name = "WorkspacePathNotEmptyError";
2685
- constructor(agent: string, workstream: string, workspacePath: string);
2686
- errorNextSteps(): NextStep[];
2687
- }
2688
- /**
2689
- * Thrown by createWorkspace when the resolved projectRoot is the
2690
- * user's $HOME. Surfaced by snap_dogfood Finding 4: a `mu workspace
2691
- * create` invoked from cwd=$HOME with no --project-root began a
2692
- * recursive `cp -a` of $HOME (~/Music, ~/.config, ...) into the
2693
- * workspace dir, stalled on DRM-protected files, and on ctrl-C left
2694
- * a partial dir behind with no DB row.
2695
- *
2696
- * The guard's whole point is to make the user pick a real project
2697
- * deliberately — there's no --force escape hatch on purpose. The
2698
- * resolution is `--project-root <real-path>` (or `cd` into a real
2699
- * project first).
2700
- *
2701
- * Maps to exit code 4 (conflict).
2702
- */
2703
- declare class HomeDirAsProjectRootError extends Error implements HasNextSteps {
2704
- readonly agent: string;
2705
- readonly workstream: string;
2706
- readonly homeDir: string;
2707
- readonly name = "HomeDirAsProjectRootError";
2708
- constructor(agent: string, workstream: string, homeDir: string);
2709
- errorNextSteps(): NextStep[];
2710
- }
2711
- /**
2712
- * Compose the canonical on-disk path for an agent's workspace. Used by
2713
- * createWorkspace and reachable from `mu workspace path` so the user
2714
- * can `cd $(mu workspace path foo)` even before the directory exists.
2715
- */
2716
- declare function workspacePath(workstream: string, agent: string): string;
2717
- /** Root dir for a workstream's workspaces — the parent of all
2718
- * per-agent workspace dirs. Used by listWorkspaceOrphans to scan
2719
- * the filesystem. */
2720
- declare function workspacesRoot(workstream: string): string;
2721
- interface WorkspaceOrphan {
2722
- /** The on-disk dir name (the agent name it WOULD be for, if mu had
2723
- * registered it). */
2724
- agentName: string;
2725
- /** Workstream the dir is filed under. */
2726
- workstreamName: string;
2727
- /** Absolute path to the orphan dir. */
2728
- path: string;
2729
- }
2730
- /**
2731
- * Like WorkspaceOrphan but additionally flags whether the parent
2732
- * workstream itself is gone (no row in `workstreams`). Returned by
2733
- * listAllOrphanWorkspaces; the per-workstream listWorkspaceOrphans
2734
- * doesn't carry this since by construction it only runs against an
2735
- * existing workstream.
2736
- */
2737
- interface StrandedWorkspaceOrphan extends WorkspaceOrphan {
2738
- /** True iff the parent workstream has no DB row (the dir was left
2739
- * behind by a `mu workstream destroy` or a manual DELETE). */
2740
- stranded: boolean;
2741
- }
2742
- /**
2743
- * Scan `<state-dir>/workspaces/<workstream>/` for directories that
2744
- * have no row in `vcs_workspaces`. These are the result of:
2745
- * - pre-cccba88 agents closed without --discard-workspace
2746
- * - failed spawn rollbacks (pre-bug_agent_spawn_workspace_fk_failure fix)
2747
- * - manual cleanup that left the dir but not the row
2748
- * - any case where the operator manually rm-rf'd vcs_workspaces rows
2749
- *
2750
- * Returns `[]` when the workstream's workspaces dir doesn't exist,
2751
- * or when every dir on disk has a corresponding DB row. Filesystem
2752
- * read is best-effort: a missing/inaccessible dir returns `[]`
2753
- * (caller doesn't have to check existsSync first).
2754
- *
2755
- * Surfaced by bug_workspace_orphan_not_in_state: orphan dirs were
2756
- * invisible to `mu state` and `mu workspace list`, but blocked
2757
- * subsequent `--workspace` spawns with WorkspacePathNotEmptyError.
2758
- */
2759
- declare function listWorkspaceOrphans(db: Db, workstream: string): WorkspaceOrphan[];
2760
- /**
2761
- * Cross-workstream variant of listWorkspaceOrphans. Reads
2762
- * `<state-dir>/workspaces/`, recurses one level (per-ws subdir →
2763
- * per-agent subdir), and surfaces every dir with no row in
2764
- * `vcs_workspaces`.
2765
- *
2766
- * Each entry is additionally tagged with `stranded: boolean`: true
2767
- * when the parent workstream has no row in `workstreams`. Stranded
2768
- * orphans are the failure mode this verb was added for — workstreams
2769
- * destroyed before the close-refuses-with-workspace fix landed (or
2770
- * via `mu sql DELETE FROM workstreams ...`) would leave their entire
2771
- * workspace subtree invisible to `mu workspace orphans -w <ws>`,
2772
- * because the user couldn't know to ask for the right name.
2773
- *
2774
- * Surfaced by workspace_orphans_misses_destroyed_workstreams. Returns
2775
- * `[]` when the workspaces root itself doesn't exist; otherwise scans
2776
- * best-effort and skips any subdir that fails to read.
2777
- */
2778
- declare function listAllOrphanWorkspaces(db: Db): StrandedWorkspaceOrphan[];
2779
- interface CreateWorkspaceOptions {
2780
- agent: string;
2781
- workstream: string;
2782
- /** Project root to branch from. Defaults to the current working
2783
- * directory (the `mu` invocation site, which is normally what the
2784
- * user wants). */
2785
- projectRoot?: string;
2786
- /** Override backend detection. Default: walk `detectBackend`.
2787
- * Accepts either a name ("jj" / "sl" / "git" / "none") OR a
2788
- * pre-built `VcsBackend` object — the object form lets tests inject
2789
- * a fresh fake backend without mutating the exported singletons. */
2790
- backend?: VcsBackendName | VcsBackend;
2791
- /** Optional ref to base the workspace on. Backend-specific. */
2792
- parentRef?: string;
2793
- }
2794
- /**
2795
- * Create a fresh workspace for an agent. Allocates the on-disk
2796
- * directory, records the row, emits a system event. Idempotent ONLY
2797
- * to the extent that the row check is up-front; if the row exists
2798
- * we throw `WorkspaceExistsError` rather than silently re-using a
2799
- * possibly-stale on-disk state. Callers should `freeWorkspace` first.
2800
- */
2801
- declare function createWorkspace(db: Db, opts: CreateWorkspaceOptions): Promise<WorkspaceRow>;
2802
- declare function getWorkspaceForAgent(db: Db, agent: string, workstream: string): WorkspaceRow | undefined;
2803
- declare function listWorkspaces(db: Db, workstream?: string): WorkspaceRow[];
2804
- declare function decorateWithStaleness(rows: readonly WorkspaceRow[]): Promise<WorkspaceRow[]>;
2805
- interface FreeWorkspaceOptions {
2806
- /** If true, attempt to commit pending changes before tearing down.
2807
- * Backend-specific; see VcsBackend.freeWorkspace. */
2808
- commit?: boolean;
2809
- }
2810
- interface FreeWorkspaceResult {
2811
- /** The committed ref, when `commit` was true and there was something
2812
- * to commit. */
2813
- committedRef?: string;
2814
- /** True iff the on-disk path was actually removed. */
2815
- removed: boolean;
2816
- /** True iff the DB row was actually deleted. */
2817
- rowDeleted: boolean;
2818
- }
2819
- /**
2820
- * Tear down an agent's workspace. Calls the backend to remove the
2821
- * on-disk directory (with optional auto-commit), then DELETEs the row.
2822
- * Idempotent on a missing workspace (returns all-false).
2823
- */
2824
- declare function freeWorkspace(db: Db, agent: string, opts: FreeWorkspaceOptions & {
2825
- workstream: string;
2826
- }): Promise<FreeWorkspaceResult>;
2827
-
2828
3247
  type LogKind = "message" | "event" | "broadcast" | string;
2829
3248
  interface LogRow {
2830
3249
  /** Monotonic AUTOINCREMENT id. Use as the cursor for `--since`. */
@@ -2892,6 +3311,213 @@ declare function latestSeq(db: Db): number;
2892
3311
  * by callers like `claimTask` (source = the claiming agent).
2893
3312
  */
2894
3313
  declare function emitEvent(db: Db, workstream: string | null, payload: string, source?: string): void;
3314
+ /**
3315
+ * Canonical list of two-token verb prefixes that `emitEvent` callers
3316
+ * use as the leading words of a payload. Single source of truth for
3317
+ * event renderers so they can never drift away from the actual emitter
3318
+ * sites.
3319
+ *
3320
+ * Maintenance contract: when you add an `emitEvent(...)` call whose
3321
+ * payload starts with a new two-word verb, add the verb here. A
3322
+ * regression test walks every entry and asserts the classifier
3323
+ * recognises it; the test fails if you add an emitter without adding
3324
+ * its verb here.
3325
+ *
3326
+ * Audit (2026-05): every `emitEvent` callsite under src/ produces a
3327
+ * payload that starts with one of these. Verified by
3328
+ * `grep -rn emitEvent src/ | grep -v import`.
3329
+ */
3330
+ declare const EVENT_VERB_PREFIXES: readonly string[];
3331
+ interface ClassifiedEvent {
3332
+ /** One of EVENT_VERB_PREFIXES. */
3333
+ verb: string;
3334
+ /** Payload past the verb token; preserves leading separator (" " or "\t"). */
3335
+ rest: string;
3336
+ }
3337
+ /**
3338
+ * Match `payload` against EVENT_VERB_PREFIXES. Returns {verb, rest} on
3339
+ * match; null otherwise. The verb-boundary check is `next is space, tab,
3340
+ * or end-of-string` so we don't false-match e.g. `task addnote`.
3341
+ *
3342
+ * Pure parser. Consumers (the static state card, the ink Activity-log
3343
+ * card) apply their own colour to `verb` after matching.
3344
+ */
3345
+ declare function classifyEventVerb(payload: string): ClassifiedEvent | null;
3346
+
3347
+ type DoctorStatus = "ok" | "warn" | "fail";
3348
+ interface DoctorCheck {
3349
+ /** Short, stable identifier — used as the row label. Lowercase
3350
+ * one-word tokens so the column-aligned card layout looks tidy. */
3351
+ name: string;
3352
+ status: DoctorStatus;
3353
+ /** Free-form prose for the row's right-hand column. Kept short so
3354
+ * the card's CLIP column doesn't truncate it on common widths. */
3355
+ detail: string;
3356
+ }
3357
+ interface DoctorSummary {
3358
+ /** Every check that ran, in stable display order. The card filters
3359
+ * to non-OK rows for its body but keeps the OK rows so the popup
3360
+ * (when it ships under feat_more_cards_umbrella) can render the
3361
+ * full list. */
3362
+ checks: readonly DoctorCheck[];
3363
+ /** Convenience: how many rows are warn or fail. Card subtitle
3364
+ * reads this directly. Pure derivation from `checks`. */
3365
+ problemCount: number;
3366
+ }
3367
+ /**
3368
+ * Compute the doctor summary for a workstream. Pure-ish: runs cheap
3369
+ * synchronous DB queries + reads from the supplied snapshot. Callers
3370
+ * that don't want to compute a snapshot first (or are running inside
3371
+ * `loadWorkstreamSnapshot` mid-build) can omit `snapshot` — the
3372
+ * snapshot-derived checks (ghosts / orphans / workspace-orphans) are
3373
+ * skipped in that case.
3374
+ */
3375
+ declare function loadDoctorSummary(db: Db, snapshot: WorkstreamSnapshot | null): DoctorSummary;
3376
+ /** Count of warn + fail rows. Pure; exported for unit tests. */
3377
+ declare function countProblems(checks: readonly DoctorCheck[]): number;
3378
+ /**
3379
+ * Return the full check array (OK + warn + fail) in stable display
3380
+ * order. Used by the TUI's slot-9 Doctor popup
3381
+ * (feat_popup_9_doctor, workstream `tui-impl`) which renders every
3382
+ * row — not just the non-OK subset Card 9 surfaces.
3383
+ *
3384
+ * Thin wrapper over `loadDoctorSummary` so the SDK seam stays
3385
+ * single: `loadDoctorSummary` is the source of truth for the
3386
+ * check vocabulary, and the popup's `loadDoctorChecks` view is
3387
+ * just `.checks`. Pure-ish (same cheap synchronous DB reads as
3388
+ * `loadDoctorSummary`).
3389
+ */
3390
+ declare function loadDoctorChecks(db: Db, snapshot: WorkstreamSnapshot | null): readonly DoctorCheck[];
3391
+ /**
3392
+ * Map a check row to the most useful informational command the
3393
+ * operator might paste. Read-only by construction: `mu agent list`,
3394
+ * `mu workspace orphans`, `mu doctor` are all SELECT-shape verbs;
3395
+ * `# ...` lines are visibly inert. Per the slot-9 popup spec KEY
3396
+ * MAP block this is INFORMATIONAL, never a mutating recipe — so
3397
+ * even when the check is `fail`, we yank the diagnostic verb the
3398
+ * operator should RUN MANUALLY, not a fix command.
3399
+ *
3400
+ * Pure; exported for unit tests + SDK reuse.
3401
+ */
3402
+ declare function yankCommandForCheck(check: Pick<DoctorCheck, "name" | "status">): string;
3403
+ /**
3404
+ * A short paragraph (one paragraph per check name) explaining the
3405
+ * shape of the failure / warning. Returned as a `readonly string[]`
3406
+ * so the popup's drill body can interleave the lines with other
3407
+ * content; CLI consumers can `.join("\n")` themselves.
3408
+ *
3409
+ * Pure; exported for unit tests + SDK reuse.
3410
+ */
3411
+ declare function remediationParagraph(check: DoctorCheck): readonly string[];
3412
+
3413
+ interface WorkstreamSnapshot {
3414
+ workstreamName: string;
3415
+ view: LiveAgentsView;
3416
+ tracks: Track[];
3417
+ ready: TaskRow[];
3418
+ inProgress: TaskRow[];
3419
+ blocked: TaskRow[];
3420
+ recentClosed: TaskRow[];
3421
+ /** Populated only when callers explicitly pass `withAllTasks: true`.
3422
+ * The TUI dashboard fast tick leaves this empty and the all-tasks
3423
+ * popup reads its exhaustive list directly from SQLite while open. */
3424
+ allTasks: TaskRow[];
3425
+ workspaces: WorkspaceRow[];
3426
+ workspaceOrphans: WorkspaceOrphan[];
3427
+ recent: LogRow[];
3428
+ /** Last N commits from the project root (process.cwd()), populated
3429
+ * when `loadWorkstreamSnapshot` is called with withRecentCommits.
3430
+ * This is intentionally NOT a per-agent workspace log. */
3431
+ recentCommits: CommitSummary[];
3432
+ /** Backend that produced recentCommits. Null when recent commits were
3433
+ * not requested or no VCS backend was detected. */
3434
+ commitsBackend?: VcsBackendName | null;
3435
+ /** Populated when `loadWorkstreamSnapshot` is called with
3436
+ * `withDoctor: true`. Used by the TUI's slot-9 Doctor card to
3437
+ * render a glanceable health badge on the dashboard
3438
+ * (feat_card_9_doctor, workstream `tui-impl`). The static `mu
3439
+ * state` card and `mu doctor` itself don't consume it — they
3440
+ * read the textual doctor card directly. Null when not requested. */
3441
+ doctor: DoctorSummary | null;
3442
+ }
3443
+ interface LoadWorkstreamSnapshotOptions {
3444
+ /** Recent-events cap (default 200). */
3445
+ eventLimit?: number;
3446
+ /** When true, slow snapshot loading also populates `WorkspaceRow.dirty`
3447
+ * via decorateWithDirty (one `git status --porcelain` shellout per row,
3448
+ * capped at DECORATE_CONCURRENCY). The TUI caches this slow-tier value
3449
+ * and merges it into every fast SQL tick. */
3450
+ withDirty?: boolean;
3451
+ /** When true, slow snapshot loading also populates
3452
+ * `WorkstreamSnapshot.doctor` via `loadDoctorSummary`. The summary is
3453
+ * cheap SQL, but it reports tmux/workspace drift from slow-tier fields,
3454
+ * so the TUI refreshes it with the subprocess tier. */
3455
+ withDoctor?: boolean;
3456
+ /** Optional full task list for the TUI all-tasks popup. */
3457
+ withAllTasks?: true;
3458
+ /** Optional recent-project-commits slice for the TUI Commits card /
3459
+ * popup. Uses process.cwd() as the project root on purpose: the TUI
3460
+ * is launched from the project checkout, while worker workspaces live
3461
+ * elsewhere under the mu state dir. */
3462
+ withRecentCommits?: {
3463
+ limit: number;
3464
+ };
3465
+ }
3466
+ interface WorkstreamSnapshotSlowFields {
3467
+ view: LiveAgentsView;
3468
+ /** Workspace rows decorated with slow-tier VCS observations
3469
+ * (`commitsBehindMain`, and `dirty` when requested). */
3470
+ workspaces: WorkspaceRow[];
3471
+ recentCommits: CommitSummary[];
3472
+ commitsBackend?: VcsBackendName | null;
3473
+ doctor: DoctorSummary | null;
3474
+ }
3475
+ /**
3476
+ * Fast TUI/state snapshot tier: pure SQLite reads only. Subprocess-backed
3477
+ * fields are intentionally empty placeholders so callers can merge the last
3478
+ * slow-tier values without blocking a 1s render tick on tmux or VCS probes.
3479
+ */
3480
+ declare function loadWorkstreamSnapshotFast(db: Db, workstream: string, opts?: LoadWorkstreamSnapshotOptions): Promise<WorkstreamSnapshot>;
3481
+ /**
3482
+ * Slow snapshot tier: fields backed by tmux / VCS subprocess probes (plus
3483
+ * doctor, which reports over those slow-tier observations). Returns only the
3484
+ * fields the fast snapshot deliberately leaves empty or undecorated.
3485
+ *
3486
+ * status-only refresh: don't prune mid-spawn placeholders or reap
3487
+ * unreachable agents — every render-mode is a polling read surface.
3488
+ */
3489
+ declare function loadWorkstreamSnapshotSlow(db: Db, workstream: string, opts?: LoadWorkstreamSnapshotOptions, baseSnapshot?: WorkstreamSnapshot): Promise<WorkstreamSnapshotSlowFields>;
3490
+ /** Merge the latest slow-tier subprocess observations into a fresh fast tier. */
3491
+ declare function mergeSnapshotFastSlow(fast: WorkstreamSnapshot, slow: WorkstreamSnapshotSlowFields | null): WorkstreamSnapshot;
3492
+ /**
3493
+ * Back-compat wrapper for non-TUI callers: return the historical union shape
3494
+ * by composing the new fast SQL tier with one slow subprocess tier.
3495
+ */
3496
+ declare function loadWorkstreamSnapshot(db: Db, workstream: string, opts?: LoadWorkstreamSnapshotOptions): Promise<WorkstreamSnapshot>;
3497
+ /**
3498
+ * ROI tiers used to colour task rows. Pure: returns the bucket name; the
3499
+ * consumer maps bucket → picocolors function (or ink text colour).
3500
+ * Magic numbers (≥100 high, ≥50 mid) lifted from the previous HUD impl.
3501
+ */
3502
+ type RoiBucket = "high" | "mid" | "low" | "infinite";
3503
+ declare function roiBucket(impact: number, effortDays: number): RoiBucket;
3504
+ /** Histogram of agents by status. Pure derivation (no colour render). */
3505
+ declare function agentStatusHistogram(agents: readonly AgentRow[]): ReadonlyMap<AgentStatus, number>;
3506
+ interface OwnedTasksSummary {
3507
+ /** Display token: "—" (none) | "<task_id>" (one) | "⊕<N>" (many). */
3508
+ bit: string;
3509
+ /** Underlying count for callers that want their own format. */
3510
+ count: number;
3511
+ /** The single owned task's local id, when count===1. */
3512
+ onlyTaskId?: string;
3513
+ }
3514
+ /**
3515
+ * Per-agent task summary: condensed display token + raw count. Used by
3516
+ * both the static Agents table and the ink Agents card. Pure on the
3517
+ * input rows — caller (e.g. loadWorkstreamSnapshot consumer) does the
3518
+ * listTasksByOwner query upstream and feeds the rows in.
3519
+ */
3520
+ declare function summarizeOwnedTasks(owned: readonly TaskRow[]): OwnedTasksSummary;
2895
3521
 
2896
3522
  interface SnapshotRow {
2897
3523
  /** Operator-facing snapshot id. EXCEPTION to the no-surrogate-ids rule:
@@ -2935,13 +3561,6 @@ declare class SnapshotNotFoundError extends Error implements HasNextSteps {
2935
3561
  constructor(snapshotId: number);
2936
3562
  errorNextSteps(): NextStep[];
2937
3563
  }
2938
- /**
2939
- * Thrown by restoreSnapshot when the snapshot's schema_version doesn't
2940
- * match the live DB's CURRENT_SCHEMA_VERSION. Maps to exit code 4
2941
- * (conflict). Auto-migration of snapshot files was deliberately rejected
2942
- * in snap_design note #293 (mutates forensic data; migrations are
2943
- * forward-only).
2944
- */
2945
3564
  declare class SnapshotVersionMismatchError extends Error implements HasNextSteps {
2946
3565
  readonly snapshotId: number;
2947
3566
  readonly snapshotVersion: number;
@@ -2950,11 +3569,6 @@ declare class SnapshotVersionMismatchError extends Error implements HasNextSteps
2950
3569
  constructor(snapshotId: number, snapshotVersion: number, currentVersion: number);
2951
3570
  errorNextSteps(): NextStep[];
2952
3571
  }
2953
- /**
2954
- * Thrown when the snapshot's .db file has been removed from disk (manual
2955
- * cleanup, fs corruption) but the row still exists. Maps to exit code 3
2956
- * (not found).
2957
- */
2958
3572
  declare class SnapshotFileMissingError extends Error implements HasNextSteps {
2959
3573
  readonly snapshotId: number;
2960
3574
  readonly dbPath: string;
@@ -2962,169 +3576,47 @@ declare class SnapshotFileMissingError extends Error implements HasNextSteps {
2962
3576
  constructor(snapshotId: number, dbPath: string);
2963
3577
  errorNextSteps(): NextStep[];
2964
3578
  }
2965
- /** Read the operator-tunable count cap (`MU_SNAPSHOT_KEEP_LAST`). */
2966
3579
  declare function gcMaxCount(): number;
2967
- /** Read the operator-tunable age cap (`MU_SNAPSHOT_MAX_AGE_DAYS`). */
2968
3580
  declare function gcMaxAgeDays(): number;
2969
- /**
2970
- * Resolve the snapshots directory.
2971
- *
2972
- * If a live `Db` handle is supplied, snapshots land under
2973
- * `<dirname(db-path)>/snapshots/` — colocated with the DB they back.
2974
- * This keeps snapshots discoverable for non-default DB paths
2975
- * (`MU_DB_PATH=/some/place/foo.db` users) AND keeps tests that use
2976
- * temp-dir DBs from polluting the user's `~/.local/state/mu/`.
2977
- *
2978
- * Without a Db handle, falls back to `<state-dir>/snapshots/` (the
2979
- * canonical default per snap_design §WHERE).
2980
- *
2981
- * Flat (not per-workstream) by design: workstream-destroy snapshots
2982
- * span every workstream so subdirs would lie about scope.
2983
- */
2984
3581
  declare function snapshotsDir(db?: Db): string;
2985
- /**
2986
- * Take a whole-DB snapshot before a destructive verb mutates state.
2987
- *
2988
- * Steps:
2989
- * 1. INSERT a row to claim an id.
2990
- * 2. VACUUM INTO <state-dir>/snapshots/<id>.db. Synchronous; runs
2991
- * page-level on the live DB without extra locks beyond SQLite's
2992
- * existing busy_timeout.
2993
- * 3. UPDATE the row with the canonical db_path (we couldn't know it
2994
- * before step 1 because id is AUTOINCREMENT).
2995
- * 4. Run opportunistic GC.
2996
- *
2997
- * If VACUUM INTO fails (disk full, perms, race), the row is rolled back
2998
- * so the DB never points at a non-existent file. The original verb's
2999
- * exception path still surfaces the underlying error.
3000
- *
3001
- * Idempotent on a same-instant double-call (each call gets its own id).
3002
- */
3582
+ declare function isStaleVersion(row: {
3583
+ schemaVersion: number;
3584
+ }): boolean;
3585
+ declare function snapshotFileSize(snapshot: SnapshotRow): number | null;
3586
+
3003
3587
  declare function captureSnapshot(db: Db, label: string, workstream?: string | null): CaptureSnapshotResult;
3004
- /**
3005
- * List snapshots, newest first. When `workstream` is set, returns rows
3006
- * for that workstream PLUS rows with workstream = NULL (workstream-
3007
- * destroy snapshots span every workstream so excluding them would hide
3008
- * the most-recent restorable point during recovery).
3009
- */
3010
3588
  declare function listSnapshots(db: Db, opts?: ListSnapshotsOptions): SnapshotRow[];
3011
- /**
3012
- * Restore a snapshot by file-swapping its .db onto the live DB path.
3013
- *
3014
- * Caller contract: pass the live `Db` handle so we can read the live DB
3015
- * path, the snapshot row, and emit a pre-restore self-snapshot for the
3016
- * "undo of undo" case (snap_design §EDGE CASES > snapshot-of-snapshot).
3017
- *
3018
- * The caller is expected to be a short-lived `mu undo` process: this
3019
- * function CLOSES `db` after taking the pre-restore snapshot, then
3020
- * fs.copyFileSync's the snapshot file onto the live DB path and unlinks
3021
- * any -wal / -shm sidecars. Any other live mu process holding the DB
3022
- * will see SQLITE_BUSY / disk-image-malformed on next write and exit
3023
- * cleanly (snap_design recommends gating the verb behind --yes for
3024
- * exactly this reason; that's snap_undo_verb's surface, not ours).
3025
- */
3026
- declare function restoreSnapshot(db: Db, snapshotId: number): RestoreSnapshotResult;
3027
- /**
3028
- * Drop snapshots that are EITHER past the count cap OR past the age
3029
- * cap — "whichever cap is more permissive wins" (snap_design §GC).
3030
- * Concretely: keep the N most recent AND keep everything <D days old;
3031
- * delete the rest (and their on-disk .db files).
3032
- *
3033
- * The caps come from `gcMaxCount()` / `gcMaxAgeDays()` (env-tunable
3034
- * via `MU_SNAPSHOT_KEEP_LAST` / `MU_SNAPSHOT_MAX_AGE_DAYS`).
3035
- *
3036
- * NOTE: prior to snapshot_gc_caps_too_lax_no_cleanup_verb the WHERE
3037
- * was `created_at < cutoff AND id NOT IN protected`, i.e. "delete
3038
- * only if BOTH old AND past the count cap". That made the count cap
3039
- * effectively dead under bursty use (every row was younger than the
3040
- * 14-day age cap, so the date filter spared everything regardless of
3041
- * row count). The fix flips AND→OR.
3042
- *
3043
- * Best-effort on file unlink: if a file is already gone, the row goes
3044
- * anyway (the user's intent — "this snapshot is gone" — is satisfied).
3045
- */
3046
3589
  declare function gcSnapshots(db: Db): {
3047
3590
  deletedRows: number;
3048
3591
  deletedFiles: number;
3049
3592
  };
3593
+
3050
3594
  type PruneMode = "gc" | "keep-last" | "older-than" | "stale-version" | "all";
3051
3595
  interface PruneOptions {
3052
3596
  mode: PruneMode;
3053
- /** For mode='keep-last'. Required by the CLI. */
3054
3597
  keepLast?: number;
3055
- /** For mode='older-than'. Days; required by the CLI. */
3056
3598
  olderThanDays?: number;
3057
- /** When true, return the would-delete shape but don't touch the DB
3058
- * or the on-disk .db files. */
3059
3599
  dryRun?: boolean;
3060
3600
  }
3061
3601
  interface PruneResult {
3062
- /** Rows that would be / were deleted. Always populated, even on
3063
- * dry-run (the CLI's summary uses it). */
3064
3602
  victims: SnapshotRow[];
3065
- /** Total bytes that would be / were freed (sum of victim file
3066
- * sizes; missing files contribute 0). */
3067
3603
  freedBytes: number;
3068
- /** Number of `snapshots` rows actually deleted. 0 on dry-run. */
3069
3604
  deletedRows: number;
3070
- /** Number of on-disk .db files actually unlinked. 0 on dry-run. */
3071
3605
  deletedFiles: number;
3072
- /** Set when mode='all' and dryRun=false: id of the safety-net
3073
- * snapshot captured BEFORE the wipe. (Survives the wipe.) */
3074
3606
  safetyNetSnapshotId?: number;
3075
3607
  }
3076
3608
  declare class PruneOptionsInvalidError extends Error implements HasNextSteps {
3077
3609
  readonly name = "PruneOptionsInvalidError";
3078
3610
  errorNextSteps(): NextStep[];
3079
3611
  }
3080
- /** True if a snapshot row's schema_version doesn't match the live DB's
3081
- * CURRENT_SCHEMA_VERSION. Stale snapshots are unrestorable (restore
3082
- * raises SnapshotVersionMismatchError) — surfaced dimmed in
3083
- * `mu snapshot list` and as the target set of `prune --stale-version`. */
3084
- declare function isStaleVersion(row: {
3085
- schemaVersion: number;
3086
- }): boolean;
3087
- /**
3088
- * Bulk policy-driven cleanup. The CLI's `mu snapshot prune` verb is
3089
- * a thin wrapper. Modes:
3090
- *
3091
- * gc — apply the auto-GC policy explicitly (same as the
3092
- * opportunistic call inside captureSnapshot).
3093
- * keep-last — keep only the N newest rows.
3094
- * older-than — drop rows whose created_at is older than D days.
3095
- * stale-version — drop rows whose schema_version != current.
3096
- * all — drop EVERY row. dryRun=false additionally captures
3097
- * a safety-net snapshot of the live DB FIRST, so a
3098
- * subsequent `mu undo` can recover; the safety-net
3099
- * row survives the wipe.
3100
- *
3101
- * On dryRun=true: returns the victim set + freed-bytes total without
3102
- * touching the DB or the filesystem.
3103
- */
3104
3612
  declare function pruneSnapshots(db: Db, opts: PruneOptions): PruneResult;
3105
3613
  interface DeleteSnapshotResult {
3106
- /** Always true on success. (Misses raise SnapshotNotFoundError; the
3107
- * shape mirrors `deleteTask`'s structured-result style.) */
3108
3614
  deleted: true;
3109
- /** 1 if the .db file was on disk + unlinked; 0 if it was already
3110
- * gone (orphaned row). */
3111
3615
  deletedFiles: 0 | 1;
3112
- /** Bytes freed by unlinking the .db file. 0 when the file was
3113
- * already gone. */
3114
3616
  freedBytes: number;
3115
3617
  }
3116
- /**
3117
- * Surgical removal of one snapshot: drop the `snapshots` row + unlink
3118
- * the on-disk .db file. Mirrors `mu task delete`. Errors with
3119
- * `SnapshotNotFoundError` on miss.
3120
- *
3121
- * No auto-snapshot before the delete: the point IS to delete one row,
3122
- * and removing one stepping-stone can't break `mu undo` (it still has
3123
- * every other snapshot). Auto-snapshotting here would be circular.
3124
- */
3125
3618
  declare function deleteSnapshot(db: Db, snapshotId: number): DeleteSnapshotResult;
3126
- /** Return the on-disk size of the snapshot file in bytes, or null if
3127
- * the file is missing. Useful for `mu snapshot list --json` output. */
3128
- declare function snapshotFileSize(snapshot: SnapshotRow): number | null;
3129
3619
 
3130
- export { type AddNoteOptions, type AddTaskOptions, type AddToArchiveResult, type AdoptAgentOptions, type AdoptAgentResult, AgentDiedOnSpawnError, AgentExistsError, AgentNotFoundError, AgentNotInWorkstreamError, type AgentRow, type AgentStatus, type AppendLogOptions, type Archive, ArchiveAlreadyExistsError, ArchiveLabelInvalidError, ArchiveNotFoundError, type ArchiveSearchHit, type ArchiveSourceSummary, type ArchiveSummary, type ArchivedTaskRow, type BlockEdgeResult, CURRENT_SCHEMA_VERSION, type CaptureOptions, type CaptureSnapshotResult, type ClaimResult, type ClaimTaskOptions, ClaimerNotRegisteredError, type CloseAgentOptions, type CloseAgentResult, CrossWorkstreamEdgeError, CycleError, type Db, type DeleteSnapshotResult, type DeleteTaskResult, type DestroyResult, type DetectedStatus, EXPECTED_TABLES, type EvidenceOption, type ExportArchiveOptions, type ExportArchiveResult, type ExportManifest, type ExportResult, type ExportSource, type ExportSourceManifest, type ExportTaskEntry, type ExportWorkstreamOptions, type FreeAgentResult, HomeDirAsProjectRootError, type IdFromTitleResult, ImportBucketInvalidError, type ImportBucketOptions, type ImportBucketResult, ImportEdgeRefMissingError, ImportFrontmatterParseError, ImportLegacyLayoutError, type ImportSourceResult, type InsertAgentInput, LegacyExportLayoutError, type ListArchivedTasksOptions, type ListLiveAgentsOptions, type ListLogsOptions, type ListReadyOptions, type ListSnapshotsOptions, type ListTasksOptions, type LiveAgentsView, type LogKind, type LogRow, type NewSessionOptions, type NewWindowOptions, type OpenDbOptions, PANE_ID_RE, PaneNotFoundError, type PruneMode, type PruneOptions, PruneOptionsInvalidError, type PruneResult, type ReconcileMode, type ReconcileOptions, type ReconcileReport, type RejectDeferOptions, type RejectDeferResult, type ReleaseResult, type ReleaseTaskOptions, type RemoveBlockEdgeResult, type RemoveFromArchiveResult, type RenderBucketInput, type RenderBucketResult, type ReparentTaskResult, type RestoreSnapshotResult, STATUSES_TERMINAL_OR_PARKED, STATUS_EMOJI, SchemaTooOldError, type SearchArchivesOptions, type SearchTasksOptions, type SendOptions, type SetStatusResult, type SlugifyResult, SnapshotFileMissingError, SnapshotNotFoundError, type SnapshotRow, SnapshotVersionMismatchError, type SpawnAgentOptions, type SplitWindowOptions, type StrandedWorkspaceOrphan, TASK_STATUSES, TASK_STATUS_LIST, TaskAlreadyOwnedError, type TaskEdgeWithStatus, type TaskEdges, type TaskEdgesWithStatus, TaskExistsError, TaskHasOpenDependentsError, TaskNotFoundError, TaskNotInWorkstreamError, type TaskNoteRow, type TaskRow, type TaskStatus, type TaskWaitOptions, type TaskWaitRef, type TaskWaitResult, type TaskWaitTaskState, TmuxError, type TmuxExecResult, type TmuxExecutor, type TmuxPane, type TmuxSession, type TmuxWindow, type Track, type UpdateTaskOptions, type UpdateTaskResult, type VcsBackend, type VcsBackendName, type CreateWorkspaceOptions$1 as VcsCreateWorkspaceOptions, type CreateWorkspaceResult as VcsCreateWorkspaceResult, type FreeWorkspaceOptions$1 as VcsFreeWorkspaceOptions, type FreeWorkspaceResult$1 as VcsFreeWorkspaceResult, type CreateWorkspaceOptions as WorkspaceCreateOptions, WorkspaceExistsError, type WorkspaceFailure, type FreeWorkspaceOptions as WorkspaceFreeOptions, type FreeWorkspaceResult as WorkspaceFreeResult, WorkspaceNotFoundError, type WorkspaceOrphan, WorkspacePathNotEmptyError, WorkspacePreservedError, type WorkspaceRow, WorkstreamAlreadyExistsError, WorkstreamNameInvalidError, type WorkstreamOptions, type WorkstreamSummary, addBlockEdge, addNote, addTask, addToArchive, adoptAgent, appendLog, assertValidPaneId, backendByName, capturePane, captureSnapshot, claimTask, closeAgent, closeTask, composeAgentTitle, createArchive, createWorkspace, currentAgentName, currentPaneTitle, decorateWithStaleness, defaultDbPath, defaultSendDelayMs, defaultSpawnLivenessMs, defaultStateDir, deferTask, deleteAgent, deleteArchive, deleteSnapshot, deleteTask, destroyWorkstream, detectBackend, detectPiStatus, emitEvent, ensureWorkstream, ensureWorkstreamStateDir, exportArchive, exportSourceForWorkstream, exportSourcesForArchive, exportWorkstream, extractTail, freeAgent, freeWorkspace, gcMaxAgeDays, gcMaxCount, gcSnapshots, getAgent, getAgentByPane, getArchive, getParallelTracks, getPrerequisites, getTask, getTaskEdges, getTaskEdgesWithStatus, getWaitPollCount, getWorkspaceForAgent, gitBackend, idFromTitle, idFromTitleVerbose, importBucket, insertAgent, isStaleVersion, isTaskStatus, isValidAgentName, isValidArchiveLabel, isValidPaneId, isValidTaskId, isValidWorkstreamName, jjBackend, killPane, killSession, latestSeq, listAgents, listAllOrphanWorkspaces, listArchivedTasks, listArchives, listBlocked, listGoals, listInProgress, listLiveAgents, listLogs, listNotes, listPanes, listPanesInSession, listReady, listRecentClosed, listSessions, listSnapshots, listTasks, listTasksByOwner, listWindows, listWorkspaceOrphans, listWorkspaces, listWorkstreams, newSession, newSessionWithPane, newWindow, noneBackend, openDb, openTask, paneExists, parseAgentNameFromTitle, pruneSnapshots, readAgent, reconcile, refreshAgentTitle, rejectTask, releaseTask, removeBlockEdge, removeFromArchive, renderToBucket, reparentTask, resetSleep, resetTmuxExecutor, resetWaitPollCount, resolveActorIdentity, resolveCliCommand, restoreSnapshot, searchArchives, searchTasks, selectLayout, sendToAgent, sendToPane, sessionExists, setPaneTitle, setSleepForTests, setTaskStatus, setTmuxExecutor, setWaitSleepForTests, setWaitStuckWarnForTests, slBackend, sleep, slugifyTitle, slugifyTitleVerbose, snapshotFileSize, snapshotsDir, spawnAgent, splitWindow, summarizeWorkstream, tmux$1 as tmux, updateAgentStatus, updateTask, waitForTasks, workspacePath, workspacesRoot, workstreamStateDir };
3620
+ declare function restoreSnapshot(db: Db, snapshotId: number): RestoreSnapshotResult;
3621
+
3622
+ export { type AddNoteOptions, type AddTaskOptions, type AddToArchiveResult, type AdoptAgentOptions, type AdoptAgentResult, AgentDiedOnSpawnError, AgentExistsError, AgentNotFoundError, AgentNotInWorkstreamError, type AgentRow, AgentSpawnCliNotFoundError, AgentSpawnStartupError, type AgentStatus, type AppendLogOptions, type Archive, ArchiveAlreadyExistsError, ArchiveLabelInvalidError, ArchiveNotFoundError, type ArchiveSearchHit, type ArchiveSourceSummary, type ArchiveSummary, type ArchivedTaskRow, type BlockEdgeResult, CURRENT_SCHEMA_VERSION, type CaptureOptions, type CaptureSnapshotResult, type ClaimResult, type ClaimTaskOptions, ClaimerNotRegisteredError, type ClassifiedEvent, type CloseAgentOptions, type CloseAgentResult, type CommandResolutionResult, type CommandResolver, CrossWorkstreamEdgeError, CycleError, type Db, type DeleteSnapshotResult, type DeleteTaskResult, type DestroyResult, type DetectedStatus, type DoctorCheck, type DoctorStatus, type DoctorSummary, EVENT_VERB_PREFIXES, EXPECTED_TABLES, type EvidenceOption, type ExportArchiveOptions, type ExportArchiveResult, type ExportManifest, type ExportResult, type ExportSource, type ExportSourceManifest, type ExportTaskEntry, type ExportWorkstreamOptions, type FreeAgentResult, type FullDag, HomeDirAsProjectRootError, type IdFromTitleResult, ImportBucketInvalidError, type ImportBucketOptions, type ImportBucketResult, ImportEdgeRefMissingError, ImportFrontmatterParseError, type ImportSourceResult, type InsertAgentInput, type KickAgentOptions, type KickAgentResult, type KickProcessExecutor, type KickSignal, type ListArchivedTasksOptions, type ListLiveAgentsOptions, type ListLogsOptions, type ListNotesOptions, type ListReadyOptions, type ListSnapshotsOptions, type ListTasksOptions, type LiveAgentsView, type LoadFullDagOptions, type LoadWorkstreamSnapshotOptions, type LogKind, type LogRow, type NewSessionOptions, type NewWindowOptions, NoForegroundProcessError, type OpenDbOptions, type OwnedTasksSummary, PANE_ID_RE, PaneNotFoundError, type PruneMode, type PruneOptions, PruneOptionsInvalidError, type PruneResult, type ReconcileMode, type ReconcileOptions, type ReconcileReport, type RejectDeferOptions, type RejectDeferResult, type ReleaseResult, type ReleaseTaskOptions, type RemoveBlockEdgeResult, type RemoveFromArchiveResult, type RenderBucketInput, type RenderBucketResult, type ReparentTaskResult, type RestoreSnapshotResult, type RoiBucket, STATUSES_TERMINAL_OR_PARKED, STATUS_EMOJI, SchemaTooOldError, type SearchArchivesOptions, type SearchTasksOptions, type SendOptions, type SetStatusResult, type SlugifyResult, SnapshotFileMissingError, SnapshotNotFoundError, type SnapshotRow, SnapshotVersionMismatchError, type SpawnAgentOptions, type SplitWindowOptions, type StrandedWorkspaceOrphan, TASK_STATUSES, TASK_STATUS_LIST, TaskAlreadyOwnedError, type TaskEdgeWithStatus, type TaskEdges, type TaskEdgesWithStatus, TaskExistsError, TaskHasOpenDependentsError, TaskNotFoundError, TaskNotInWorkstreamError, type TaskNoteRow, type TaskRow, type TaskStatus, type TaskWaitOptions, type TaskWaitRef, type TaskWaitResult, type TaskWaitTaskState, TmuxError, type TmuxExecResult, type TmuxExecutor, type TmuxPane, type TmuxSession, type TmuxWindow, type Track, type UpdateTaskOptions, type UpdateTaskResult, type VcsBackend, type VcsBackendName, type CreateWorkspaceOptions$1 as VcsCreateWorkspaceOptions, type CreateWorkspaceResult as VcsCreateWorkspaceResult, type FreeWorkspaceOptions$1 as VcsFreeWorkspaceOptions, type FreeWorkspaceResult$1 as VcsFreeWorkspaceResult, WORKSPACE_STALE_THRESHOLD, type CreateWorkspaceOptions as WorkspaceCreateOptions, WorkspaceExistsError, type WorkspaceFailure, type FreeWorkspaceOptions as WorkspaceFreeOptions, type FreeWorkspaceResult as WorkspaceFreeResult, WorkspaceNotFoundError, type WorkspaceOrphan, WorkspacePathNotEmptyError, WorkspacePreservedError, type RecreateWorkspaceOptions as WorkspaceRecreateOptions, type RecreateWorkspaceResult as WorkspaceRecreateResult, type WorkspaceRow, type WorkspaceStaleness, WorkstreamAlreadyExistsError, WorkstreamNameInvalidError, type WorkstreamOptions, type WorkstreamSnapshot, type WorkstreamSnapshotSlowFields, type WorkstreamSummary, addBlockEdge, addNote, addTask, addToArchive, adoptAgent, agentStatusHistogram, appendLog, assertValidPaneId, backendByName, capturePane, captureSnapshot, checkCommandResolvable, claimTask, classifyEventVerb, closeAgent, closeTask, composeAgentTitle, countProblems, createArchive, createWorkspace, currentAgentName, currentPaneTitle, decorateWithDirty, decorateWithStaleness, defaultDbPath, defaultSendDelayMs, defaultSpawnLivenessMs, defaultStateDir, deferTask, deleteAgent, deleteArchive, deleteSnapshot, deleteTask, destroyWorkstream, detectBackend, detectPiStatus, emitEvent, ensureWorkstream, envVarNameForCli, exportArchive, exportSourceForWorkstream, exportSourcesForArchive, exportWorkstream, extractTail, foregroundPgid, freeAgent, freeWorkspace, gcMaxAgeDays, gcMaxCount, gcSnapshots, getAgent, getAgentByPane, getArchive, getParallelTracks, getPrerequisites, getTask, getTaskEdges, getTaskEdgesWithStatus, getWaitPollCount, getWorkspaceForAgent, getWorkspaceStaleness, gitBackend, idFromTitle, idFromTitleVerbose, importBucket, insertAgent, isKickSignal, isStaleVersion, isTaskStatus, isValidAgentName, isValidArchiveLabel, isValidPaneId, isValidTaskId, isValidWorkstreamName, isWorkspaceStale, jjBackend, kickAgent, killPane, killSession, latestSeq, listAgents, listAllOrphanWorkspaces, listArchivedTasks, listArchives, listBlocked, listGoals, listInProgress, listLiveAgents, listLogs, listNotes, listPanes, listPanesInSession, listReady, listRecentClosed, listSessions, listSnapshots, listTasks, listTasksByOwner, listWindows, listWorkspaceOrphans, listWorkspaces, listWorkstreams, loadDoctorChecks, loadDoctorSummary, loadFullDag, loadWorkstreamSnapshot, loadWorkstreamSnapshotFast, loadWorkstreamSnapshotSlow, mergeSnapshotFastSlow, newSession, newSessionWithPane, newWindow, noneBackend, openDb, openTask, paneExists, paneTTY, parseAgentNameFromTitle, parsePsTtyOutput, pruneSnapshots, readAgent, reconcile, recreateWorkspace, refreshAgentTitle, rejectTask, releaseTask, remediationParagraph, removeBlockEdge, removeFromArchive, renderForest, renderTaskTree, renderToBucket, reparentTask, resetCommandResolverForTests, resetKickProcessExecutor, resetSleep, resetTmuxExecutor, resetWaitPollCount, resolveActorIdentity, resolveCliCommand, resolveCliCommandWithSource, restoreSnapshot, roiBucket, searchArchives, searchTasks, selectLayout, sendToAgent, sendToPane, sessionExists, setCommandResolverForTests, setKickProcessExecutor, setPaneTitle, setSleepForTests, setTaskStatus, setTmuxExecutor, setWaitSleepForTests, setWaitStuckWarnForTests, slBackend, sleep, slugifyTitle, slugifyTitleVerbose, snapshotFileSize, snapshotsDir, spawnAgent, splitWindow, summarizeOwnedTasks, summarizeWorkstream, tmux$1 as tmux, updateAgentStatus, updateTask, waitForTasks, workspacePath, workspacesRoot, yankCommandForCheck };