@martintrojer/mu 0.4.0 → 0.4.2

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/cli.js CHANGED
@@ -10,8 +10,9 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // src/db.ts
13
+ import { randomUUID } from "crypto";
13
14
  import { mkdirSync } from "fs";
14
- import { homedir } from "os";
15
+ import { homedir, hostname } from "os";
15
16
  import { dirname, join, resolve } from "path";
16
17
  import Database from "better-sqlite3";
17
18
  function defaultStateDir() {
@@ -40,6 +41,7 @@ function openDb(options = {}) {
40
41
  throw new SchemaTooOldError(detectedVersion, MIN_ACCEPTED_SCHEMA_VERSION);
41
42
  }
42
43
  applySchema(db);
44
+ seedMachineIdentity(db);
43
45
  } else {
44
46
  db.pragma("foreign_keys = ON");
45
47
  }
@@ -76,6 +78,14 @@ function detectExistingSchemaVersion(db) {
76
78
  if (hasWorkstreams) return 1;
77
79
  return null;
78
80
  }
81
+ function seedMachineIdentity(db) {
82
+ const row2 = db.prepare("SELECT COUNT(*) AS count FROM machine_identity").get();
83
+ if (row2.count !== 0) return;
84
+ db.prepare(
85
+ `INSERT OR IGNORE INTO machine_identity (id, machine_id, hostname, created_at)
86
+ VALUES (1, ?, ?, ?)`
87
+ ).run(randomUUID(), hostname(), (/* @__PURE__ */ new Date()).toISOString());
88
+ }
79
89
  function applySchema(db) {
80
90
  const preBumpVersion = detectExistingSchemaVersion(db);
81
91
  db.exec(CURRENT_SCHEMA);
@@ -145,7 +155,7 @@ var init_db = __esm({
145
155
  ];
146
156
  }
147
157
  };
148
- CURRENT_SCHEMA_VERSION = 7;
158
+ CURRENT_SCHEMA_VERSION = 8;
149
159
  MIN_ACCEPTED_SCHEMA_VERSION = 5;
150
160
  EXPECTED_TABLES = [
151
161
  "agent_logs",
@@ -155,12 +165,14 @@ var init_db = __esm({
155
165
  "archived_notes",
156
166
  "archived_tasks",
157
167
  "archives",
168
+ "machine_identity",
158
169
  "schema_version",
159
170
  "snapshots",
160
171
  "task_edges",
161
172
  "task_notes",
162
173
  "tasks",
163
174
  "vcs_workspaces",
175
+ "workstream_sync",
164
176
  "workstreams"
165
177
  ];
166
178
  READY_VIEW_SQL = `
@@ -212,6 +224,15 @@ CREATE TABLE IF NOT EXISTS schema_version (
212
224
  version INTEGER NOT NULL
213
225
  );
214
226
 
227
+ -- machine_identity: one durable identity per DB/machine, seeded by
228
+ -- openDb after schema creation. hostname is advisory only.
229
+ CREATE TABLE IF NOT EXISTS machine_identity (
230
+ id INTEGER PRIMARY KEY CHECK (id = 1),
231
+ machine_id TEXT NOT NULL,
232
+ hostname TEXT,
233
+ created_at TEXT NOT NULL
234
+ );
235
+
215
236
  -- \u2500\u2500\u2500 Tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
216
237
 
217
238
  -- workstreams: top of the hierarchy. name stays globally unique
@@ -223,6 +244,13 @@ CREATE TABLE IF NOT EXISTS workstreams (
223
244
  created_at TEXT NOT NULL -- ISO 8601
224
245
  );
225
246
 
247
+ -- workstream_sync: per-workstream cross-machine drift state. Rows are
248
+ -- created on demand by db import/export code, not pre-seeded.
249
+ CREATE TABLE IF NOT EXISTS workstream_sync (
250
+ workstream_id INTEGER PRIMARY KEY REFERENCES workstreams (id) ON DELETE CASCADE,
251
+ last_known_peer_seqs TEXT NOT NULL DEFAULT '{}'
252
+ );
253
+
226
254
  -- agents: one row per managed pane. Per-workstream unique on name.
227
255
  CREATE TABLE IF NOT EXISTS agents (
228
256
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -505,8 +533,8 @@ function listLogs(db, opts = {}) {
505
533
  const rows = db.prepare(sql).all(...params);
506
534
  return rows.map(rowFromDb);
507
535
  }
508
- function latestSeq(db) {
509
- const row2 = db.prepare("SELECT MAX(seq) AS s FROM agent_logs").get();
536
+ function latestSeq(db, workstreamId) {
537
+ const row2 = workstreamId === void 0 ? db.prepare("SELECT MAX(seq) AS s FROM agent_logs").get() : db.prepare("SELECT MAX(seq) AS s FROM agent_logs WHERE workstream_id = ?").get(workstreamId);
510
538
  return row2.s ?? 0;
511
539
  }
512
540
  function emitEvent(db, workstream, payload, source = "system") {
@@ -607,17 +635,22 @@ var init_logs = __esm({
607
635
  "workstream init",
608
636
  "workstream destroy",
609
637
  "workstream export",
610
- "workstream import",
611
638
  // src/archives.ts — v6 archive verbs. Machine-wide events
612
639
  // (workstream=null) because archives outlive workstreams.
613
640
  "archive create",
614
641
  "archive delete",
615
642
  "archive add",
616
643
  "archive remove",
644
+ "archive restore",
617
645
  // src/exporting.ts — archive export emits the bucket-render summary
618
646
  // as a machine-wide event (workstream=null; the export spans every
619
647
  // source-ws in the archive).
620
- "archive export"
648
+ "archive export",
649
+ // src/db-sync.ts — emitted per-workstream after a successful
650
+ // `mu db export`. Used as the marker for src/parked.ts (the
651
+ // "presumed parked on another machine" heuristic for `mu workstream
652
+ // list` / TUI tab strip).
653
+ "db export"
621
654
  ];
622
655
  }
623
656
  });
@@ -1050,17 +1083,19 @@ async function reconcile(db, opts) {
1050
1083
  let statusChanges = 0;
1051
1084
  const orphans = [];
1052
1085
  const survivors = [];
1086
+ const pendingSurvivors = [];
1053
1087
  for (const agent of dbAgents) {
1054
- if (tmuxByPaneId.has(agent.paneId)) {
1088
+ if (isPendingPaneId(agent.paneId)) {
1089
+ pendingSurvivors.push(agent);
1090
+ } else if (tmuxByPaneId.has(agent.paneId)) {
1055
1091
  survivors.push(agent);
1056
1092
  } else {
1057
1093
  if (mode === "full") deleteAgent(db, agent.name, agent.workstreamName);
1058
1094
  prunedGhosts++;
1059
1095
  }
1060
1096
  }
1061
- if (mode !== "report-only") {
1097
+ if (mode === "full") {
1062
1098
  for (const agent of survivors) {
1063
- if (isPendingPaneId(agent.paneId)) continue;
1064
1099
  const scrollback = await capturePane(agent.paneId, { lines: 100 });
1065
1100
  const detected = detectPiStatus(scrollback);
1066
1101
  if (shouldOverwriteAgentStatus(agent.status, detected) && detected !== agent.status) {
@@ -2329,6 +2364,10 @@ ${scrollback.trim()}
2329
2364
  intent: "Or close + discard the workspace in one shot (lossy)",
2330
2365
  command: `mu agent close ${this.agentName} --discard-workspace`
2331
2366
  },
2367
+ {
2368
+ intent: "If the workstream was archived, restore task memory under a fresh name",
2369
+ command: "mu archive restore <label> --as <new-workstream> --source <workstream>"
2370
+ },
2332
2371
  {
2333
2372
  intent: "Or just inspect what's in the workspace",
2334
2373
  command: `cd ${this.workspacePath}`
@@ -4889,6 +4928,117 @@ var init_query = __esm({
4889
4928
  }
4890
4929
  });
4891
4930
 
4931
+ // src/archives/restore.ts
4932
+ function restoreArchive(db, label2, asWorkstream, opts = {}) {
4933
+ const archiveId = resolveArchiveId(db, label2);
4934
+ const sources = listSources(db, archiveId);
4935
+ if (!isValidWorkstreamName(asWorkstream)) throw new WorkstreamNameInvalidError(asWorkstream);
4936
+ const sourceWorkstream = opts.sourceWorkstream ?? sources[0];
4937
+ if (sourceWorkstream === void 0) throw new ArchiveSourceAmbiguousError(label2, sources);
4938
+ if (opts.sourceWorkstream === void 0 && sources.length > 1) {
4939
+ throw new ArchiveSourceAmbiguousError(label2, sources);
4940
+ }
4941
+ if (opts.sourceWorkstream !== void 0 && !sources.includes(opts.sourceWorkstream)) {
4942
+ throw new ArchiveSourceAmbiguousError(label2, sources);
4943
+ }
4944
+ if (tryResolveWorkstreamId(db, asWorkstream) !== null) {
4945
+ throw new WorkstreamExistsError(asWorkstream);
4946
+ }
4947
+ captureSnapshot(db, `archive restore ${label2} as ${asWorkstream}`, null);
4948
+ return db.transaction(() => {
4949
+ ensureWorkstream(db, asWorkstream);
4950
+ const wsId = resolveWorkstreamId(db, asWorkstream);
4951
+ const restoredTasks = db.prepare(
4952
+ `INSERT INTO tasks
4953
+ (workstream_id, local_id, title, status, impact, effort_days, owner_id, created_at, updated_at)
4954
+ SELECT ?, original_local_id, title, status, impact, effort_days, NULL,
4955
+ original_created_at, original_updated_at
4956
+ FROM archived_tasks
4957
+ WHERE archive_id = ? AND source_workstream = ?
4958
+ ORDER BY id`
4959
+ ).run(wsId, archiveId, sourceWorkstream).changes;
4960
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4961
+ const restoredEdges = db.prepare(
4962
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
4963
+ SELECT live_from.id, live_to.id, ?
4964
+ FROM archived_edges e
4965
+ JOIN archived_tasks arch_from ON arch_from.id = e.from_archived_id
4966
+ JOIN archived_tasks arch_to ON arch_to.id = e.to_archived_id
4967
+ JOIN tasks live_from ON live_from.workstream_id = ?
4968
+ AND live_from.local_id = arch_from.original_local_id
4969
+ JOIN tasks live_to ON live_to.workstream_id = ?
4970
+ AND live_to.local_id = arch_to.original_local_id
4971
+ WHERE e.archive_id = ?
4972
+ AND arch_from.source_workstream = ?
4973
+ AND arch_to.source_workstream = ?`
4974
+ ).run(now, wsId, wsId, archiveId, sourceWorkstream, sourceWorkstream).changes;
4975
+ const restoredNotes = db.prepare(
4976
+ `INSERT INTO task_notes (task_id, author, content, created_at)
4977
+ SELECT live.id, n.author, n.content, n.created_at
4978
+ FROM archived_notes n
4979
+ JOIN archived_tasks arch ON arch.id = n.archived_task_id
4980
+ JOIN tasks live ON live.workstream_id = ?
4981
+ AND live.local_id = arch.original_local_id
4982
+ WHERE n.archive_id = ? AND arch.source_workstream = ?
4983
+ ORDER BY n.id`
4984
+ ).run(wsId, archiveId, sourceWorkstream).changes;
4985
+ emitEvent(
4986
+ db,
4987
+ asWorkstream,
4988
+ `archive restore ${label2} source=${sourceWorkstream} as ${asWorkstream} (tasks=${restoredTasks}, edges=${restoredEdges}, notes=${restoredNotes})`
4989
+ );
4990
+ return {
4991
+ archiveLabel: label2,
4992
+ sourceWorkstream,
4993
+ workstreamName: asWorkstream,
4994
+ restoredTasks,
4995
+ restoredEdges,
4996
+ restoredNotes
4997
+ };
4998
+ })();
4999
+ }
5000
+ function listSources(db, archiveId) {
5001
+ return db.prepare(
5002
+ `SELECT source_workstream AS name
5003
+ FROM archived_tasks
5004
+ WHERE archive_id = ?
5005
+ GROUP BY source_workstream
5006
+ ORDER BY source_workstream`
5007
+ ).all(archiveId).map((row2) => row2.name);
5008
+ }
5009
+ var ArchiveSourceAmbiguousError;
5010
+ var init_restore2 = __esm({
5011
+ "src/archives/restore.ts"() {
5012
+ "use strict";
5013
+ init_db();
5014
+ init_logs();
5015
+ init_snapshots();
5016
+ init_workstream();
5017
+ init_core4();
5018
+ ArchiveSourceAmbiguousError = class extends Error {
5019
+ constructor(label2, sources) {
5020
+ super(
5021
+ sources.length === 0 ? `archive ${label2} contains no source workstreams` : `archive ${label2} requires --source <orig-ws-name>. Available: ${sources.join(", ")}`
5022
+ );
5023
+ this.label = label2;
5024
+ this.sources = sources;
5025
+ }
5026
+ label;
5027
+ sources;
5028
+ name = "ArchiveSourceAmbiguousError";
5029
+ errorNextSteps() {
5030
+ return [
5031
+ { intent: "Inspect archive sources", command: `mu archive show ${this.label}` },
5032
+ ...this.sources.map((source) => ({
5033
+ intent: `Restore source workstream ${source}`,
5034
+ command: `mu archive restore ${this.label} --source ${source} --as <new-workstream>`
5035
+ }))
5036
+ ];
5037
+ }
5038
+ };
5039
+ }
5040
+ });
5041
+
4892
5042
  // src/archives.ts
4893
5043
  var init_archives = __esm({
4894
5044
  "src/archives.ts"() {
@@ -4897,6 +5047,7 @@ var init_archives = __esm({
4897
5047
  init_core4();
4898
5048
  init_delete();
4899
5049
  init_query();
5050
+ init_restore2();
4900
5051
  }
4901
5052
  });
4902
5053
 
@@ -5489,6 +5640,37 @@ var init_exporting = __esm({
5489
5640
  }
5490
5641
  });
5491
5642
 
5643
+ // src/parked.ts
5644
+ function parkedStatus(db, workstream, opts = {}) {
5645
+ const wsRow = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream);
5646
+ if (wsRow === void 0) return { parked: false };
5647
+ const latest = db.prepare(
5648
+ "SELECT kind, payload, created_at FROM agent_logs WHERE workstream_id = ? ORDER BY seq DESC LIMIT 1"
5649
+ ).get(wsRow.id);
5650
+ if (latest === void 0) return { parked: false };
5651
+ if (latest.kind !== "event") return { parked: false };
5652
+ if (!latest.payload.startsWith("db export ")) return { parked: false };
5653
+ const aliveAgent = db.prepare("SELECT 1 AS x FROM agents WHERE workstream_id = ? AND status != 'closed' LIMIT 1").get(wsRow.id);
5654
+ if (aliveAgent !== void 0) return { parked: false };
5655
+ const inProgress = db.prepare("SELECT 1 AS x FROM tasks WHERE workstream_id = ? AND status = 'IN_PROGRESS' LIMIT 1").get(wsRow.id);
5656
+ if (inProgress !== void 0) return { parked: false };
5657
+ const threshold = Math.max(0, opts.thresholdDays ?? WORKSTREAM_PARKED_THRESHOLD_DAYS);
5658
+ const exportedAt = Date.parse(latest.created_at);
5659
+ if (Number.isNaN(exportedAt)) return { parked: false };
5660
+ const now = (opts.now ?? /* @__PURE__ */ new Date()).getTime();
5661
+ const deltaMs = now - exportedAt;
5662
+ const deltaDays = Math.floor(deltaMs / (24 * 60 * 60 * 1e3));
5663
+ if (deltaDays < threshold) return { parked: false };
5664
+ return { parked: true, sinceDays: deltaDays };
5665
+ }
5666
+ var WORKSTREAM_PARKED_THRESHOLD_DAYS;
5667
+ var init_parked = __esm({
5668
+ "src/parked.ts"() {
5669
+ "use strict";
5670
+ WORKSTREAM_PARKED_THRESHOLD_DAYS = 1;
5671
+ }
5672
+ });
5673
+
5492
5674
  // src/workstream.ts
5493
5675
  import { existsSync as existsSync11, readdirSync as readdirSync2, rmdirSync } from "fs";
5494
5676
  import { join as join7, resolve as resolve4 } from "path";
@@ -5571,6 +5753,7 @@ async function listEmptyWorkstreams(db) {
5571
5753
  }
5572
5754
  async function summarizeWorkstream(db, opts) {
5573
5755
  const tmuxSession = opts.tmuxSession ?? `mu-${opts.workstream}`;
5756
+ const parked = parkedStatus(db, opts.workstream);
5574
5757
  return {
5575
5758
  name: opts.workstream,
5576
5759
  tmuxSession,
@@ -5580,7 +5763,8 @@ async function summarizeWorkstream(db, opts) {
5580
5763
  noteCount: countNotes(db, opts.workstream),
5581
5764
  edgeCount: countEdges(db, opts.workstream),
5582
5765
  workspaceCount: listWorkspaces(db, opts.workstream).length,
5583
- registered: isRegistered(db, opts.workstream)
5766
+ registered: isRegistered(db, opts.workstream),
5767
+ ...parked.parked ? { parked: { sinceDays: parked.sinceDays ?? 0 } } : {}
5584
5768
  };
5585
5769
  }
5586
5770
  function isRegistered(db, workstream) {
@@ -5717,19 +5901,41 @@ function exportWorkstream(db, opts) {
5717
5901
  source: sourceManifest
5718
5902
  };
5719
5903
  }
5720
- var WORKSTREAM_NAME_RE, RESERVED_WORKSTREAM_PREFIX, WorkstreamNameInvalidError;
5904
+ var WORKSTREAM_NAME_RE, RESERVED_WORKSTREAM_PREFIX, WorkstreamExistsError, WorkstreamNameInvalidError;
5721
5905
  var init_workstream = __esm({
5722
5906
  "src/workstream.ts"() {
5723
5907
  "use strict";
5724
5908
  init_db();
5725
5909
  init_exporting();
5726
5910
  init_logs();
5911
+ init_parked();
5727
5912
  init_snapshots();
5728
5913
  init_tmux();
5729
5914
  init_vcs2();
5730
5915
  init_workspace();
5731
5916
  WORKSTREAM_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
5732
5917
  RESERVED_WORKSTREAM_PREFIX = "mu-";
5918
+ WorkstreamExistsError = class extends Error {
5919
+ constructor(workstream) {
5920
+ super(`workstream already exists: ${workstream}`);
5921
+ this.workstream = workstream;
5922
+ }
5923
+ workstream;
5924
+ name = "WorkstreamExistsError";
5925
+ errorNextSteps() {
5926
+ return [
5927
+ {
5928
+ intent: "Pick a different workstream name",
5929
+ command: "mu archive restore <label> --as <new-name>"
5930
+ },
5931
+ { intent: "List existing workstreams", command: "mu workstream list" },
5932
+ {
5933
+ intent: "Destroy the existing workstream first",
5934
+ command: `mu workstream destroy -w ${this.workstream} --yes`
5935
+ }
5936
+ ];
5937
+ }
5938
+ };
5733
5939
  WorkstreamNameInvalidError = class extends Error {
5734
5940
  constructor(attempted) {
5735
5941
  const reason = attempted.startsWith(RESERVED_WORKSTREAM_PREFIX) ? `the 'mu-' prefix is reserved (mu auto-prepends 'mu-' to derive the tmux session name; '${attempted}' would produce session 'mu-${attempted}', which is double-prefixed and almost never what you want). Drop the 'mu-' from the workstream name.` : `must match /^[a-z][a-z0-9_-]{0,31}$/. tmux silently rewrites '.' to '_' and reserves ':' as a target separator, so workstream names containing those characters would create tmux sessions mu couldn't look up afterwards. Use letters, digits, '_', and '-' only.`;
@@ -6689,6 +6895,9 @@ function shouldOverwriteAgentStatus(current, detected) {
6689
6895
  }
6690
6896
  return true;
6691
6897
  }
6898
+ function agentStatusGlyph(status) {
6899
+ return STATUS_EMOJI[status] ?? "?";
6900
+ }
6692
6901
  function pendingPaneIdFor(agentName) {
6693
6902
  return `${PENDING_PANE_PREFIX}${agentName}`;
6694
6903
  }
@@ -6700,7 +6909,7 @@ function composeAgentTitle(db, agent) {
6700
6909
  const tasks = listTasksByOwner(db, agent.workstreamName, agent.name);
6701
6910
  let title = agent.name;
6702
6911
  if (showStatus) {
6703
- title += ` \xB7 ${STATUS_EMOJI[agent.status]}`;
6912
+ title += ` \xB7 ${agentStatusGlyph(agent.status)}`;
6704
6913
  }
6705
6914
  if (tasks.length === 1) {
6706
6915
  title += ` \xB7 ${tasks[0]?.name}`;
@@ -6882,6 +7091,7 @@ var init_agents = __esm({
6882
7091
  });
6883
7092
 
6884
7093
  // src/dag.ts
7094
+ import pc2 from "picocolors";
6885
7095
  function loadFullDag(db, workstream, opts = {}) {
6886
7096
  const tasks = listTasks(db, workstream).filter(
6887
7097
  (t) => opts.statuses === void 0 || opts.statuses.has(t.status)
@@ -6920,7 +7130,7 @@ function renderForest(roots, edges, statusFn, tasksByName, opts = {}) {
6920
7130
  if (!byName.has(root.name)) byName.set(root.name, root);
6921
7131
  const lines = [formatTreeNodeLabel(root, statusFn, opts)];
6922
7132
  if (seen.has(root.name)) {
6923
- lines[0] = `${lines[0]} (\u21BB already shown above)`;
7133
+ lines[0] = `${lines[0]}${RECURRENCE_MARKER}`;
6924
7134
  } else {
6925
7135
  seen.add(root.name);
6926
7136
  renderForestChildren(root.name, "", edges, byName, statusFn, seen, lines, opts);
@@ -6979,7 +7189,7 @@ function renderForestChildren(taskName, prefix, edges, byName, statusFn, seen, l
6979
7189
  }
6980
7190
  if (seen.has(childName)) {
6981
7191
  lines.push(
6982
- `${prefix}${branch}${formatTreeNodeLabel(child, statusFn, opts)} (\u21BB already shown above)`
7192
+ `${prefix}${branch}${formatTreeNodeLabel(child, statusFn, opts)}${RECURRENCE_MARKER}`
6983
7193
  );
6984
7194
  continue;
6985
7195
  }
@@ -6993,10 +7203,12 @@ function formatTreeNodeLabel(t, statusFn, opts = {}) {
6993
7203
  if (opts.includeTitle === false) return base;
6994
7204
  return `${base} ${t.title}`;
6995
7205
  }
7206
+ var RECURRENCE_MARKER;
6996
7207
  var init_dag = __esm({
6997
7208
  "src/dag.ts"() {
6998
7209
  "use strict";
6999
7210
  init_tasks();
7211
+ RECURRENCE_MARKER = ` ${pc2.dim("(\u21BB)")}`;
7000
7212
  }
7001
7213
  });
7002
7214
 
@@ -7103,7 +7315,7 @@ function loadDoctorSummary(db, snapshot) {
7103
7315
  checks.push({
7104
7316
  name: "agents",
7105
7317
  status: "warn",
7106
- detail: `${ghosts} ghost pane${ghosts === 1 ? "" : "s"}; run \`mu agent list\``
7318
+ detail: `${ghosts} ghost pane${ghosts === 1 ? "" : "s"}; run \`mu state\` or \`mu agent list\` to reap`
7107
7319
  });
7108
7320
  }
7109
7321
  const orphanPanes = snapshot.view.orphans.length;
@@ -7140,7 +7352,7 @@ function loadDoctorChecks(db, snapshot) {
7140
7352
  function yankCommandForCheck(check) {
7141
7353
  switch (check.name) {
7142
7354
  case "agents":
7143
- return "mu agent list";
7355
+ return "mu state";
7144
7356
  case "panes":
7145
7357
  return "mu agent adopt";
7146
7358
  case "workspaces":
@@ -7158,10 +7370,10 @@ function remediationParagraph(check) {
7158
7370
  switch (check.name) {
7159
7371
  case "agents":
7160
7372
  return [
7161
- "A 'ghost pane' is a tmux pane that mu's reconcile pass would",
7162
- "prune on the next mutation. Run `mu agent list` to see the",
7163
- "current state, then `mu agent close <name>` if the agent is",
7164
- "stale. The TUI is read-only \u2014 no auto-prune."
7373
+ "A 'ghost pane' is a registered agent whose tmux pane is gone.",
7374
+ "Doctor only reports the count. Run `mu state` or `mu agent list`",
7375
+ "to reap ghost agents and return their IN_PROGRESS tasks to OPEN.",
7376
+ "The TUI is read-only, but its slow tick uses the same state reap."
7165
7377
  ];
7166
7378
  case "panes":
7167
7379
  return [
@@ -7345,7 +7557,7 @@ async function loadWorkstreamSnapshotFast(db, workstream, opts = {}) {
7345
7557
  };
7346
7558
  }
7347
7559
  async function loadWorkstreamSnapshotSlow(db, workstream, opts = {}, baseSnapshot) {
7348
- const view = await listLiveAgents(db, { workstream, mode: "status-only" });
7560
+ const view = await listLiveAgents(db, { workstream });
7349
7561
  let workspaces = listWorkspaces(db, workstream);
7350
7562
  if (opts.withDirty === true) workspaces = await decorateWithDirty(workspaces);
7351
7563
  const commits = await loadRecentCommits(opts.withRecentCommits);
@@ -7388,7 +7600,7 @@ function emptyLiveAgentsView() {
7388
7600
  return {
7389
7601
  agents: [],
7390
7602
  orphans: [],
7391
- report: { prunedGhosts: 0, statusChanges: 0, orphans: [], mode: "status-only" }
7603
+ report: { prunedGhosts: 0, statusChanges: 0, orphans: [], mode: "full" }
7392
7604
  };
7393
7605
  }
7394
7606
  function minimalSnapshot(workstream) {
@@ -7450,14 +7662,14 @@ function agentStatusHistogram(agents) {
7450
7662
  return out;
7451
7663
  }
7452
7664
  function summarizeOwnedTasks(owned) {
7453
- const count = owned.length;
7454
- if (count === 0) return { bit: "\u2014", count: 0 };
7455
- if (count === 1) {
7665
+ const count2 = owned.length;
7666
+ if (count2 === 0) return { bit: "\u2014", count: 0 };
7667
+ if (count2 === 1) {
7456
7668
  const only = owned[0];
7457
7669
  if (!only) return { bit: "\u2014", count: 0 };
7458
7670
  return { bit: only.name, count: 1, onlyTaskId: only.name };
7459
7671
  }
7460
- return { bit: `\u2295${count}`, count };
7672
+ return { bit: `\u2295${count2}`, count: count2 };
7461
7673
  }
7462
7674
  var init_state = __esm({
7463
7675
  "src/state.ts"() {
@@ -7472,9 +7684,30 @@ var init_state = __esm({
7472
7684
  }
7473
7685
  });
7474
7686
 
7687
+ // src/cli/tui/agent-display.ts
7688
+ function agentByName(snapshot) {
7689
+ const agents = snapshot?.view?.agents ?? [];
7690
+ return new Map(agents.map((a) => [a.name, a]));
7691
+ }
7692
+ function formatKnownAgentDisplayName(agent) {
7693
+ return `${agentStatusGlyph(agent.status)} ${agent.name}`;
7694
+ }
7695
+ function formatAgentRefDisplayName(agentName, agents) {
7696
+ if (agentName === null) return "\u2014";
7697
+ const agent = agents.get(agentName);
7698
+ if (agent === void 0) return agentName;
7699
+ return formatKnownAgentDisplayName(agent);
7700
+ }
7701
+ var init_agent_display = __esm({
7702
+ "src/cli/tui/agent-display.ts"() {
7703
+ "use strict";
7704
+ init_agents();
7705
+ }
7706
+ });
7707
+
7475
7708
  // src/cli/format.ts
7476
7709
  function statusIcon(status) {
7477
- return STATUS_COLORS[status](STATUS_EMOJI[status]);
7710
+ return STATUS_COLORS[status](agentStatusGlyph(status));
7478
7711
  }
7479
7712
  function colorStatus(status) {
7480
7713
  switch (status) {
@@ -7655,17 +7888,19 @@ function printLogRow(row2) {
7655
7888
  }
7656
7889
  function formatWorkstreamsTable(rows) {
7657
7890
  const table = muTable({
7658
- head: ["name", "tmux", "agents", "tasks", "edges", "notes"].map((h) => pc.bold(h)),
7659
- colWidths: [40, null, null, null, null, null]
7891
+ head: ["name", "tmux", "agents", "tasks", "edges", "notes", "parked"].map((h) => pc.bold(h)),
7892
+ colWidths: [40, null, null, null, null, null, null]
7660
7893
  });
7661
7894
  for (const r of rows) {
7895
+ const parkedCell = r.parked ? pc.yellow(`${r.parked.sinceDays}d`) : pc.dim("\u2014");
7662
7896
  table.push([
7663
7897
  r.name,
7664
7898
  r.tmuxAlive ? pc.green("alive") : pc.dim("\u2014"),
7665
7899
  String(r.agentCount),
7666
7900
  String(r.taskCount),
7667
7901
  String(r.edgeCount),
7668
- String(r.noteCount)
7902
+ String(r.noteCount),
7903
+ parkedCell
7669
7904
  ]);
7670
7905
  }
7671
7906
  return table.toString();
@@ -7957,6 +8192,58 @@ function cullCardsForRows(visibleCardIds2, availableRows) {
7957
8192
  }
7958
8193
  return { cards: cards.filter((id) => remaining.has(id)), hidden };
7959
8194
  }
8195
+ function balanceColumns(assignments, dataCountFn) {
8196
+ if (assignments.length < 2) return assignments.map((a) => ({ cards: [...a.cards] }));
8197
+ const totalCards = assignments.reduce((sum, a) => sum + a.cards.length, 0);
8198
+ if (totalCards >= TOTAL_CARD_COUNT) return assignments.map((a) => ({ cards: [...a.cards] }));
8199
+ const cols = assignments.map((a) => [...a.cards]);
8200
+ const heightOf = (id) => {
8201
+ const config = CARD_CONFIGS[id];
8202
+ const data = Math.max(0, Math.floor(dataCountFn(id)));
8203
+ return config.chrome + clamp(data, config.minRows, config.maxRows);
8204
+ };
8205
+ const isAnchored = (id) => id === 0 || id === 3;
8206
+ const heightsOf = (lanes) => lanes.map((lane) => lane.reduce((sum, id) => sum + heightOf(id), 0));
8207
+ const safetyMax = cols.length * TOTAL_CARD_COUNT;
8208
+ for (let iter = 0; iter < safetyMax; iter++) {
8209
+ const heights = heightsOf(cols);
8210
+ const startSpread = Math.max(...heights) - Math.min(...heights);
8211
+ if (startSpread <= 0) break;
8212
+ let best = null;
8213
+ for (let donor = 0; donor < cols.length; donor++) {
8214
+ const donorCards2 = cols[donor];
8215
+ const donorH = heights[donor];
8216
+ if (donorCards2 === void 0 || donorH === void 0) continue;
8217
+ if (donorCards2.length <= 1) continue;
8218
+ for (let cardIndex = 0; cardIndex < donorCards2.length; cardIndex++) {
8219
+ const card = donorCards2[cardIndex];
8220
+ if (card === void 0 || isAnchored(card)) continue;
8221
+ const cardH = heightOf(card);
8222
+ for (let receiver = 0; receiver < cols.length; receiver++) {
8223
+ if (receiver === donor) continue;
8224
+ const receiverH = heights[receiver];
8225
+ if (receiverH === void 0) continue;
8226
+ const newHeights = heights.slice();
8227
+ newHeights[donor] = donorH - cardH;
8228
+ newHeights[receiver] = receiverH + cardH;
8229
+ const newSpread = Math.max(...newHeights) - Math.min(...newHeights);
8230
+ if (newSpread < startSpread && (best === null || newSpread < best.spread)) {
8231
+ best = { donor, receiver, cardIndex, spread: newSpread };
8232
+ }
8233
+ }
8234
+ }
8235
+ }
8236
+ if (best === null) break;
8237
+ const donorCards = cols[best.donor];
8238
+ const receiverCards = cols[best.receiver];
8239
+ if (donorCards === void 0 || receiverCards === void 0) break;
8240
+ const moved = donorCards.splice(best.cardIndex, 1)[0];
8241
+ if (moved === void 0) break;
8242
+ receiverCards.push(moved);
8243
+ receiverCards.sort(compareSlot);
8244
+ }
8245
+ return cols.map((cards) => ({ cards }));
8246
+ }
7960
8247
  function allocateRowBudgets(availableRows, cards) {
7961
8248
  const entries = cards.map((card) => {
7962
8249
  const config = card.config ?? CARD_CONFIGS[card.id];
@@ -8070,7 +8357,7 @@ function tallestMinStackRows(ids) {
8070
8357
  function clamp(value, min, max) {
8071
8358
  return Math.max(min, Math.min(max, value));
8072
8359
  }
8073
- var CARD_CHROME_ROWS, CARD_CONFIGS, CARD_CULL_PRIORITY, CARD_CULL_LAYOUT_COLS;
8360
+ var CARD_CHROME_ROWS, CARD_CONFIGS, CARD_CULL_PRIORITY, CARD_CULL_LAYOUT_COLS, TOTAL_CARD_COUNT;
8074
8361
  var init_layout = __esm({
8075
8362
  "src/cli/tui/layout.ts"() {
8076
8363
  "use strict";
@@ -8159,6 +8446,7 @@ var init_layout = __esm({
8159
8446
  };
8160
8447
  CARD_CULL_PRIORITY = [9, 8, 5, 2, 7, 6, 4, 0, 1, 3];
8161
8448
  CARD_CULL_LAYOUT_COLS = 140;
8449
+ TOTAL_CARD_COUNT = 10;
8162
8450
  }
8163
8451
  });
8164
8452
 
@@ -8420,7 +8708,7 @@ function AgentsCard({ snapshot, rowBudget, cols }) {
8420
8708
  const owned = snapshot.inProgress.filter((t) => t.ownerName === a.name);
8421
8709
  const taskBit = summarizeOwnedTasks(owned).bit;
8422
8710
  const idle = a.idle ? "\u26A0 idle" : "";
8423
- return [STATUS_EMOJI[a.status] ?? "?", a.name, taskBit, idle];
8711
+ return [agentStatusGlyph(a.status), a.name, taskBit, idle];
8424
8712
  });
8425
8713
  const widths = layoutColumns(rows, COLUMN_SPECS, contentWidth);
8426
8714
  return /* @__PURE__ */ jsx6(
@@ -8462,8 +8750,8 @@ var cardConfig, COLUMN_SPECS;
8462
8750
  var init_agents2 = __esm({
8463
8751
  "src/cli/tui/cards/agents.tsx"() {
8464
8752
  "use strict";
8465
- init_agents();
8466
8753
  init_state();
8754
+ init_agent_display();
8467
8755
  init_columns();
8468
8756
  init_layout();
8469
8757
  init_list_row();
@@ -8909,11 +9197,12 @@ function InProgressCard({ snapshot, rowBudget, cols }) {
8909
9197
  const shown = inProgress.slice(0, rowBudget ?? cardConfig5.maxRows);
8910
9198
  const more = inProgress.length - shown.length;
8911
9199
  const bottomLabel = more > 0 ? `+${more} more \xB7 Shift+6` : void 0;
9200
+ const agentLookup = agentByName(snapshot);
8912
9201
  const rows = shown.map((t, i) => [
8913
9202
  GLYPH2,
8914
9203
  t.name,
8915
9204
  t.status,
8916
- t.ownerName ?? "\u2014",
9205
+ formatAgentRefDisplayName(t.ownerName, agentLookup),
8917
9206
  formatSinceClaim(ages[i] ?? null),
8918
9207
  t.title
8919
9208
  ]);
@@ -8964,8 +9253,8 @@ var cardConfig5, GLYPH2, STALE_CLAIM_THRESHOLD_MS, COLUMN_SPECS5, glyphFor3;
8964
9253
  var init_inprogress = __esm({
8965
9254
  "src/cli/tui/cards/inprogress.tsx"() {
8966
9255
  "use strict";
8967
- init_agents();
8968
9256
  init_format();
9257
+ init_agent_display();
8969
9258
  init_columns();
8970
9259
  init_format_helpers();
8971
9260
  init_layout();
@@ -8973,7 +9262,7 @@ var init_inprogress = __esm({
8973
9262
  init_titled_box();
8974
9263
  init_placeholder();
8975
9264
  cardConfig5 = CARD_CONFIGS[6];
8976
- GLYPH2 = STATUS_EMOJI.busy ?? "\u2699";
9265
+ GLYPH2 = agentStatusGlyph("busy");
8977
9266
  STALE_CLAIM_THRESHOLD_MS = 3e5;
8978
9267
  COLUMN_SPECS5 = [
8979
9268
  { kind: "protect" },
@@ -9114,12 +9403,13 @@ function ReadyCard({ snapshot, rowBudget, cols }) {
9114
9403
  const roiText = formatRoi(t.impact, t.effortDays);
9115
9404
  return { bucket, roiText };
9116
9405
  });
9406
+ const agentLookup = agentByName(snapshot);
9117
9407
  const rows = shown.map((t, i) => [
9118
9408
  t.name,
9119
9409
  t.status,
9120
9410
  `ROI ${meta[i]?.roiText ?? ""}`,
9121
9411
  t.title,
9122
- t.ownerName ?? "\u2014"
9412
+ formatAgentRefDisplayName(t.ownerName, agentLookup)
9123
9413
  ]);
9124
9414
  const widths = layoutColumns(rows, COLUMN_SPECS7, contentWidth);
9125
9415
  return /* @__PURE__ */ jsx12(
@@ -9159,6 +9449,7 @@ var init_ready = __esm({
9159
9449
  "use strict";
9160
9450
  init_state();
9161
9451
  init_format();
9452
+ init_agent_display();
9162
9453
  init_columns();
9163
9454
  init_format_helpers();
9164
9455
  init_layout();
@@ -9409,9 +9700,10 @@ function WorkspacesCard({ snapshot, rowBudget, cols }) {
9409
9700
  const shown = workspaces.slice(0, rowBudget ?? cardConfig10.maxRows);
9410
9701
  const more = workspaces.length - shown.length;
9411
9702
  const bottomLabel = more > 0 ? `+${more} more \xB7 Shift+5` : void 0;
9703
+ const agentLookup = agentByName(snapshot);
9412
9704
  const rows = shown.map((w) => [
9413
9705
  glyphFor5(w),
9414
- w.agentName,
9706
+ formatAgentRefDisplayName(w.agentName, agentLookup),
9415
9707
  w.backend,
9416
9708
  formatBehind2(w.commitsBehindMain),
9417
9709
  w.parentRef ? w.parentRef.slice(0, 12) : "\u2014"
@@ -9481,6 +9773,7 @@ var init_workspaces = __esm({
9481
9773
  "src/cli/tui/cards/workspaces.tsx"() {
9482
9774
  "use strict";
9483
9775
  init_workspace();
9776
+ init_agent_display();
9484
9777
  init_columns();
9485
9778
  init_layout();
9486
9779
  init_list_row();
@@ -9622,6 +9915,12 @@ var init_keymap_spec = __esm({
9622
9915
  row("/", "filter/search rows", ["/"]),
9623
9916
  row("Enter", "drill into focused row", ["Enter"]),
9624
9917
  row("y", "yank action for focused row", ["y"]),
9918
+ row("a", "attach to focused agent's tmux pane (Agents popup only; user-driven TUI escape)", [
9919
+ "a"
9920
+ ]),
9921
+ row("l", "launch lazygit in the project root (Commits popup only; user-driven TUI escape)", [
9922
+ "l"
9923
+ ]),
9625
9924
  row("Shift 0-9", "switch numbered popup", ["Shift 0-9"]),
9626
9925
  row("Esc/q", "back to dashboard", ["Esc", "q"]),
9627
9926
  row("?", "toggle this overlay", ["?"])
@@ -10116,6 +10415,65 @@ var init_popup_shell = __esm({
10116
10415
  }
10117
10416
  });
10118
10417
 
10418
+ // src/cli/tui/tmux-attach.ts
10419
+ import { spawnSync } from "child_process";
10420
+ function runTmuxAttachInteractive(opts, deps = {}) {
10421
+ const run3 = deps.spawn ?? spawnSync;
10422
+ const write = deps.write ?? ((text2) => process.stdout.write(text2));
10423
+ const env = deps.env ?? process.env;
10424
+ const target = `${opts.session}:${opts.window}`;
10425
+ const insideTmux = typeof env.TMUX === "string" && env.TMUX.length > 0;
10426
+ let result = { ok: true };
10427
+ try {
10428
+ write(ALT_SCREEN_EXIT);
10429
+ if (insideTmux) {
10430
+ const r = run3("tmux", ["switch-client", "-t", target], {
10431
+ stdio: "inherit",
10432
+ env
10433
+ });
10434
+ if (r.error !== void 0) {
10435
+ result = { ok: false, error: tmuxAttachErrorMessage(r.error) };
10436
+ } else if (typeof r.status === "number" && r.status !== 0) {
10437
+ result = { ok: false, error: `tmux switch-client exited ${r.status}` };
10438
+ }
10439
+ } else {
10440
+ const attach = run3("tmux", ["attach-session", "-t", opts.session], {
10441
+ stdio: "inherit",
10442
+ env
10443
+ });
10444
+ if (attach.error !== void 0) {
10445
+ result = { ok: false, error: tmuxAttachErrorMessage(attach.error) };
10446
+ } else if (typeof attach.status === "number" && attach.status !== 0) {
10447
+ result = { ok: false, error: `tmux attach-session exited ${attach.status}` };
10448
+ } else {
10449
+ run3("tmux", ["select-window", "-t", target], { stdio: "inherit", env });
10450
+ }
10451
+ }
10452
+ } catch (err) {
10453
+ result = { ok: false, error: tmuxAttachErrorMessage(err) };
10454
+ } finally {
10455
+ try {
10456
+ write(ALT_SCREEN_ENTER);
10457
+ } catch {
10458
+ }
10459
+ }
10460
+ return result;
10461
+ }
10462
+ function tmuxAttachErrorMessage(err) {
10463
+ if (err instanceof Error) {
10464
+ const code = err.code;
10465
+ if (code === "ENOENT") return "tmux not found \xB7 install tmux";
10466
+ return err.message.length > 0 ? err.message : String(err);
10467
+ }
10468
+ return String(err);
10469
+ }
10470
+ var init_tmux_attach = __esm({
10471
+ "src/cli/tui/tmux-attach.ts"() {
10472
+ "use strict";
10473
+ init_escapes();
10474
+ }
10475
+ });
10476
+
10119
10477
  // src/cli/tui/use-popup-action-queue.ts
10120
10478
  import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
10121
10479
  function usePopupActionQueue(actions, dispatch) {
@@ -10482,6 +10840,7 @@ function AgentsPopup({
10482
10840
  onModeChange,
10483
10841
  onFilterEditingChange,
10484
10842
  popupActions,
10843
+ onFooter,
10485
10844
  db,
10486
10845
  workstream
10487
10846
  }) {
@@ -10600,6 +10959,14 @@ function AgentsPopup({
10600
10959
  void yank2(`mu agent close ${a.name} -w ${ws}`);
10601
10960
  return;
10602
10961
  }
10962
+ if (action.key === "a") {
10963
+ const session = `mu-${ws}`;
10964
+ const window = a.tab ?? a.name;
10965
+ const r = runTmuxAttachInteractive({ session, window });
10966
+ if (!r.ok) onFooter?.(r.error ?? "tmux attach failed", false, "error");
10967
+ else onFooter?.(`tmux switch-client \u2192 ${session}:${window}`, true, "info");
10968
+ return;
10969
+ }
10603
10970
  return;
10604
10971
  }
10605
10972
  }
@@ -10640,13 +11007,13 @@ function AgentsPopup({
10640
11007
  ) }) });
10641
11008
  }
10642
11009
  const { start, visible } = centredVisibleSlice(agents, safeCursor, viewport);
10643
- const rows = visible.map((a) => [STATUS_EMOJI[a.status] ?? "?", a.name, a.status, a.role]);
11010
+ const rows = visible.map((a) => [agentStatusGlyph(a.status), a.name, a.status, a.role]);
10644
11011
  const widths = layoutColumns(rows, COLUMN_SPECS11, contentWidth);
10645
11012
  return /* @__PURE__ */ jsxs10(
10646
11013
  PopupShell,
10647
11014
  {
10648
11015
  title: `Agents \xB7 popup (${safeCursor + 1}/${agents.length})`,
10649
- hint: "f free \xB7 x close \xB7 y yanks `mu agent send`",
11016
+ hint: "a attach \xB7 f free \xB7 x close \xB7 y yanks `mu agent send`",
10650
11017
  children: [
10651
11018
  /* @__PURE__ */ jsx20(Box7, { flexDirection: "column", flexGrow: 1, children: visible.map((a, i) => {
10652
11019
  const sel = start + i === safeCursor;
@@ -10674,10 +11041,12 @@ var init_agents3 = __esm({
10674
11041
  "src/cli/tui/popups/agents.tsx"() {
10675
11042
  "use strict";
10676
11043
  init_agents();
11044
+ init_agent_display();
10677
11045
  init_columns();
10678
11046
  init_keys();
10679
11047
  init_list_row();
10680
11048
  init_popup_shell();
11049
+ init_tmux_attach();
10681
11050
  init_use_popup_action_queue();
10682
11051
  init_use_popup_filter();
10683
11052
  init_drill();
@@ -11030,10 +11399,11 @@ function AllTasksPopup({
11030
11399
  ) }) });
11031
11400
  }
11032
11401
  const { start, visible: windowed } = centredVisibleSlice(visibleTasks, safeCursor, viewport);
11402
+ const agentLookup = agentByName(snapshot);
11033
11403
  const rows = windowed.map((t) => [
11034
11404
  t.name,
11035
11405
  t.status,
11036
- t.ownerName ?? "\u2014",
11406
+ formatAgentRefDisplayName(t.ownerName, agentLookup),
11037
11407
  formatRoi(t.impact, t.effortDays),
11038
11408
  t.title
11039
11409
  ]);
@@ -11126,6 +11496,7 @@ var init_all_tasks = __esm({
11126
11496
  init_tasks();
11127
11497
  init_sort();
11128
11498
  init_format();
11499
+ init_agent_display();
11129
11500
  init_columns();
11130
11501
  init_format_helpers();
11131
11502
  init_keys();
@@ -11362,10 +11733,56 @@ var init_blocked2 = __esm({
11362
11733
  }
11363
11734
  });
11364
11735
 
11736
+ // src/cli/tui/lazygit.ts
11737
+ import { spawnSync as spawnSync2 } from "child_process";
11738
+ function runLazygitInteractive(opts, deps = {}) {
11739
+ const run3 = deps.spawn ?? spawnSync2;
11740
+ const write = deps.write ?? ((text2) => process.stdout.write(text2));
11741
+ const env = deps.env ?? process.env;
11742
+ let result = { ok: true };
11743
+ try {
11744
+ write(ALT_SCREEN_EXIT);
11745
+ const r = run3("lazygit", [], {
11746
+ cwd: opts.cwd,
11747
+ stdio: "inherit",
11748
+ env
11749
+ });
11750
+ if (r.error !== void 0) {
11751
+ result = { ok: false, error: lazygitErrorMessage(r.error) };
11752
+ } else if (typeof r.status === "number" && r.status !== 0) {
11753
+ result = { ok: false, error: `lazygit exited ${r.status}` };
11754
+ }
11755
+ } catch (err) {
11756
+ result = { ok: false, error: lazygitErrorMessage(err) };
11757
+ } finally {
11758
+ try {
11759
+ write(ALT_SCREEN_ENTER);
11760
+ } catch {
11761
+ }
11762
+ }
11763
+ return result;
11764
+ }
11765
+ function lazygitErrorMessage(err) {
11766
+ if (err instanceof Error) {
11767
+ const code = err.code;
11768
+ if (code === "ENOENT") {
11769
+ return "lazygit not found \xB7 install from https://github.com/jesseduffield/lazygit";
11770
+ }
11771
+ return err.message.length > 0 ? err.message : String(err);
11772
+ }
11773
+ return String(err);
11774
+ }
11775
+ var init_lazygit = __esm({
11776
+ "src/cli/tui/lazygit.ts"() {
11777
+ "use strict";
11778
+ init_escapes();
11779
+ }
11780
+ });
11781
+
11365
11782
  // src/cli/tui/tuicr.ts
11366
- import { spawnSync } from "child_process";
11783
+ import { spawnSync as spawnSync3 } from "child_process";
11367
11784
  function runTuicrInteractive(opts, deps = {}) {
11368
- const run3 = deps.spawn ?? spawnSync;
11785
+ const run3 = deps.spawn ?? spawnSync3;
11369
11786
  const write = deps.write ?? ((text2) => process.stdout.write(text2));
11370
11787
  const env = deps.env ?? process.env;
11371
11788
  let result = { ok: true };
@@ -11544,6 +11961,13 @@ function CommitsPopup({
11544
11961
  void yank2(showCommandForBackend(backendName, c.sha));
11545
11962
  return;
11546
11963
  }
11964
+ case "verb":
11965
+ if (action.key === "l") {
11966
+ const r = runLazygitInteractive({ cwd: projectRoot });
11967
+ if (!r.ok) onFooter?.(r.error ?? "lazygit failed", false, "error");
11968
+ else onFooter?.("lazygit", true, "info");
11969
+ }
11970
+ return;
11547
11971
  }
11548
11972
  };
11549
11973
  usePopupActionQueue(popupActions, dispatchListAction);
@@ -11589,7 +12013,7 @@ function CommitsPopup({
11589
12013
  PopupShell,
11590
12014
  {
11591
12015
  title: `Commits \xB7 ${formatBackend2(backendName)} (${safeCursor + 1}/${commits.length})`,
11592
- hint: "y yanks VCS show command",
12016
+ hint: "y yanks VCS show command \xB7 l lazygit",
11593
12017
  children: [
11594
12018
  /* @__PURE__ */ jsx25(Box11, { flexDirection: "column", flexGrow: 1, children: visible.map((c, i) => {
11595
12019
  const row2 = rows[i];
@@ -11646,6 +12070,7 @@ var init_commits2 = __esm({
11646
12070
  init_vcs2();
11647
12071
  init_columns();
11648
12072
  init_keys();
12073
+ init_lazygit();
11649
12074
  init_list_row();
11650
12075
  init_popup_shell();
11651
12076
  init_tuicr();
@@ -12081,13 +12506,14 @@ function InProgressPopup({
12081
12506
  const now = Date.now();
12082
12507
  const ages = tasks.map((t) => ageMs(t, now));
12083
12508
  const { start, visible } = centredVisibleSlice(tasks, safeCursor, viewport);
12509
+ const agentLookup = agentByName(snapshot);
12084
12510
  const rows = visible.map((t, i) => {
12085
12511
  const absoluteIndex = start + i;
12086
12512
  return [
12087
12513
  glyphFor3(),
12088
12514
  t.name,
12089
12515
  t.status,
12090
- t.ownerName ?? "\u2014",
12516
+ formatAgentRefDisplayName(t.ownerName, agentLookup),
12091
12517
  formatSinceClaim(ages[absoluteIndex] ?? null),
12092
12518
  formatRoi(t.impact, t.effortDays),
12093
12519
  t.title
@@ -12147,6 +12573,7 @@ var init_inprogress2 = __esm({
12147
12573
  "src/cli/tui/popups/inprogress.tsx"() {
12148
12574
  "use strict";
12149
12575
  init_format();
12576
+ init_agent_display();
12150
12577
  init_inprogress();
12151
12578
  init_columns();
12152
12579
  init_format_helpers();
@@ -12492,7 +12919,13 @@ function ReadyPopup({
12492
12919
  ) }) });
12493
12920
  }
12494
12921
  const { start, visible } = centredVisibleSlice(tasks, safeCursor, viewport);
12495
- const rows = visible.map((t) => [t.name, t.status, t.ownerName ?? "\u2014", t.title]);
12922
+ const agentLookup = agentByName(snapshot);
12923
+ const rows = visible.map((t) => [
12924
+ t.name,
12925
+ t.status,
12926
+ formatAgentRefDisplayName(t.ownerName, agentLookup),
12927
+ t.title
12928
+ ]);
12496
12929
  const widths = layoutColumns(rows, COLUMN_SPECS18, contentWidth);
12497
12930
  return /* @__PURE__ */ jsxs19(
12498
12931
  PopupShell,
@@ -12550,6 +12983,7 @@ var init_ready2 = __esm({
12550
12983
  "src/cli/tui/popups/ready.tsx"() {
12551
12984
  "use strict";
12552
12985
  init_format();
12986
+ init_agent_display();
12553
12987
  init_columns();
12554
12988
  init_keys();
12555
12989
  init_list_row();
@@ -13362,9 +13796,10 @@ function WorkspacesPopup({
13362
13796
  ] });
13363
13797
  }
13364
13798
  const { start, visible } = centredVisibleSlice(workspaces, safeCursor, viewport);
13799
+ const agentLookup = agentByName(snapshot);
13365
13800
  const rows = visible.map((w) => [
13366
13801
  glyphFor5(w),
13367
- w.agentName,
13802
+ formatAgentRefDisplayName(w.agentName, agentLookup),
13368
13803
  w.backend,
13369
13804
  formatBehind2(w.commitsBehindMain),
13370
13805
  formatDirty(w.dirty),
@@ -13505,6 +13940,7 @@ var init_workspaces2 = __esm({
13505
13940
  "use strict";
13506
13941
  init_vcs2();
13507
13942
  init_workspace();
13943
+ init_agent_display();
13508
13944
  init_workspaces();
13509
13945
  init_columns();
13510
13946
  init_keys();
@@ -13928,8 +14364,8 @@ function rowWidth(workstreams, active, indexes, leftHidden, rightHidden, showHin
13928
14364
  function chromeWidth(leftHidden, rightHidden, showHint) {
13929
14365
  return stringWidth4(PREFIX) + counterWidth("\u2039", leftHidden) + counterWidth("\u203A", rightHidden) + (showHint ? stringWidth4(leftHidden > 0 || rightHidden > 0 ? NARROW_HINT : WIDE_HINT) : 0);
13930
14366
  }
13931
- function counterWidth(prefix, count) {
13932
- return count > 0 ? stringWidth4(`${prefix}${count} `) : 0;
14367
+ function counterWidth(prefix, count2) {
14368
+ return count2 > 0 ? stringWidth4(`${prefix}${count2} `) : 0;
13933
14369
  }
13934
14370
  function truncateToWidth(text2, width) {
13935
14371
  if (width <= 0) return "";
@@ -13964,22 +14400,25 @@ import { jsx as jsx35, jsxs as jsxs24 } from "react/jsx-runtime";
13964
14400
  function TabStrip({
13965
14401
  workstreams,
13966
14402
  active,
13967
- terminalColumns
14403
+ terminalColumns,
14404
+ parked
13968
14405
  }) {
13969
14406
  if (workstreams.length <= 1) return null;
13970
14407
  const layout = layoutTabStrip(workstreams, active, terminalColumns);
13971
14408
  if (layout === null) return null;
14409
+ const isParked = (name) => parked?.has(name) ?? false;
14410
+ const decorate = (name) => isParked(name) ? `~${name}` : name;
13972
14411
  const tabs = [];
13973
14412
  for (let i = 0; i < layout.visible.length; i++) {
13974
14413
  const tab = layout.visible[i];
13975
14414
  if (tab === void 0) continue;
13976
14415
  if (tab.isActive) {
13977
14416
  tabs.push(
13978
- /* @__PURE__ */ jsx35(Text28, { bold: true, color: "cyan", children: `\u25B8 ${tab.name}` }, `t-${i}`)
14417
+ /* @__PURE__ */ jsx35(Text28, { bold: true, color: "cyan", children: `\u25B8 ${decorate(tab.name)}` }, `t-${i}`)
13979
14418
  );
13980
14419
  } else {
13981
14420
  tabs.push(
13982
- /* @__PURE__ */ jsx35(Text28, { dimColor: true, children: tab.name }, `t-${i}`)
14421
+ /* @__PURE__ */ jsx35(Text28, { dimColor: true, children: decorate(tab.name) }, `t-${i}`)
13983
14422
  );
13984
14423
  }
13985
14424
  if (i < layout.visible.length - 1) {
@@ -14095,7 +14534,7 @@ var init_yank = __esm({
14095
14534
 
14096
14535
  // src/cli/tui/app.tsx
14097
14536
  import { Box as Box22, Text as Text29, useApp, useInput as useInput14, useStdin, useStdout as useStdout16 } from "ink";
14098
- import { useCallback as useCallback7, useEffect as useEffect10, useRef as useRef8, useState as useState18 } from "react";
14537
+ import { useCallback as useCallback7, useEffect as useEffect10, useMemo as useMemo9, useRef as useRef8, useState as useState18 } from "react";
14099
14538
  import { jsx as jsx36, jsxs as jsxs25 } from "react/jsx-runtime";
14100
14539
  function dashboardAvailableRows(rows, opts) {
14101
14540
  const tabRows = opts.hasTabStrip ? 1 : 0;
@@ -14154,6 +14593,14 @@ function App({ db, workstreams, initialActive = 0 }) {
14154
14593
  const safeActive = Math.max(0, Math.min(activeWs, workstreams.length - 1));
14155
14594
  const workstream = workstreams[safeActive] ?? "";
14156
14595
  const snap = useDashboardSnapshot(db, workstream, tickMs, true, refreshNonce);
14596
+ const parkedSet = useMemo9(() => {
14597
+ void snap.slowTickNonce;
14598
+ const set = /* @__PURE__ */ new Set();
14599
+ for (const ws of workstreams) {
14600
+ if (parkedStatus(db, ws).parked) set.add(ws);
14601
+ }
14602
+ return set;
14603
+ }, [db, workstreams, snap.slowTickNonce]);
14157
14604
  const { stdout } = useStdout16();
14158
14605
  const cols = stdout.columns ?? 80;
14159
14606
  const rows = stdout.rows ?? 24;
@@ -14333,7 +14780,15 @@ function App({ db, workstreams, initialActive = 0 }) {
14333
14780
  ] });
14334
14781
  }
14335
14782
  return /* @__PURE__ */ jsxs25(Box22, { flexDirection: "column", height: rows, overflow: "hidden", children: [
14336
- /* @__PURE__ */ jsx36(TabStrip, { workstreams, active: safeActive, terminalColumns: cols }),
14783
+ /* @__PURE__ */ jsx36(
14784
+ TabStrip,
14785
+ {
14786
+ workstreams,
14787
+ active: safeActive,
14788
+ terminalColumns: cols,
14789
+ parked: parkedSet
14790
+ }
14791
+ ),
14337
14792
  hasSnapshotError && /* @__PURE__ */ jsx36(Box22, { borderStyle: "round", borderColor: "red", paddingX: 1, children: /* @__PURE__ */ jsxs25(Text29, { color: "red", children: [
14338
14793
  "snapshot error: ",
14339
14794
  snap.error
@@ -14390,7 +14845,8 @@ function buildDashboardLayoutModel(cols, rows, visibility, snapshot) {
14390
14845
  const cullBudget = firstCull.hidden.length > 0 ? Math.max(1, rows - 1) : rows;
14391
14846
  const culled = cullBudget === rows ? firstCull : cullCardsForRows(visible, cullBudget);
14392
14847
  const cardsRows = culled.hidden.length > 0 ? Math.max(1, rows - 1) : rows;
14393
- const assignments = layoutColumns2(cols, culled.cards);
14848
+ const packed = layoutColumns2(cols, culled.cards);
14849
+ const assignments = balanceColumns(packed, (id) => dataCountForCard(id, snapshot));
14394
14850
  const widths = columnWidths(cols, assignments.length);
14395
14851
  const budgetsByColumn = assignments.map(
14396
14852
  (assignment) => capRowBudgetsForColumn(
@@ -14468,6 +14924,7 @@ var CARD_REGISTRY, POPUP_REGISTRY, DASHBOARD_MIN_ROWS, POPUP_CHROME_TOP;
14468
14924
  var init_app = __esm({
14469
14925
  "src/cli/tui/app.tsx"() {
14470
14926
  "use strict";
14927
+ init_parked();
14471
14928
  init_agents2();
14472
14929
  init_blocked();
14473
14930
  init_commits();
@@ -14564,7 +15021,7 @@ var init_tui = __esm({
14564
15021
  init_agents();
14565
15022
  import { readFileSync as readFileSync3, realpathSync as realpathSync2 } from "fs";
14566
15023
  import { dirname as dirname7, join as join10 } from "path";
14567
- import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
15024
+ import { fileURLToPath as fileURLToPath3, pathToFileURL } from "url";
14568
15025
  import { Command, InvalidArgumentError as InvalidArgumentError2 } from "commander";
14569
15026
 
14570
15027
  // src/cli/agents.ts
@@ -16169,10 +16626,10 @@ async function cmdAttach(db, rawName, opts) {
16169
16626
  const { name } = await resolveEntityRef(db, rawName, opts, "agent");
16170
16627
  const workstream = await resolveWorkstream(opts.workstream);
16171
16628
  const sessionName = `mu-${workstream}`;
16629
+ const view = await listLiveAgents(db, { workstream });
16172
16630
  if (!await sessionExists(sessionName)) {
16173
16631
  throw new UsageError(`workstream "${workstream}" has no tmux session yet`);
16174
16632
  }
16175
- const view = await listLiveAgents(db, { workstream, mode: "status-only" });
16176
16633
  const agent = view.agents.find((a) => a.name === name);
16177
16634
  if (!agent) {
16178
16635
  throw new AgentNotFoundError(name);
@@ -16443,9 +16900,15 @@ async function cmdArchiveAdd(db, label2, opts) {
16443
16900
  command: `mu archive add ${label2} -w ${workstream}`
16444
16901
  },
16445
16902
  {
16446
- intent: opts.destroy ? "Undo the destroy (DB only; tmux NOT rolled back)" : "Destroy the source workstream now that its memory is preserved",
16447
- command: opts.destroy ? "mu undo --yes" : `mu archive add ${label2} -w ${workstream} --destroy`
16448
- }
16903
+ intent: opts.destroy ? "Restore the archived workstream under a fresh name" : "Destroy the source workstream now that its memory is preserved",
16904
+ command: opts.destroy ? `mu archive restore ${label2} --as <new-workstream> --source ${workstream}` : `mu archive add ${label2} -w ${workstream} --destroy`
16905
+ },
16906
+ ...opts.destroy ? [
16907
+ {
16908
+ intent: "Undo the destroy (DB only; tmux NOT rolled back)",
16909
+ command: "mu undo --yes"
16910
+ }
16911
+ ] : []
16449
16912
  ]);
16450
16913
  }
16451
16914
  async function cmdArchiveRemove(db, label2, opts) {
@@ -16471,6 +16934,35 @@ async function cmdArchiveRemove(db, label2, opts) {
16471
16934
  }
16472
16935
  ]);
16473
16936
  }
16937
+ async function cmdArchiveRestore(db, label2, opts = {}) {
16938
+ if (!opts.as || opts.as.trim().length === 0) {
16939
+ throw new UsageError("--as <new-ws-name> is required for `mu archive restore`");
16940
+ }
16941
+ const result = restoreArchive(db, label2, opts.as, {
16942
+ sourceWorkstream: opts.source
16943
+ });
16944
+ const nextSteps = [
16945
+ { intent: "Inspect restored tasks", command: `mu task list -w ${result.workstreamName}` },
16946
+ { intent: "Undo (a snapshot was taken before the restore)", command: "mu undo --yes" }
16947
+ ];
16948
+ if (opts.json) {
16949
+ emitJson({ ...result, nextSteps });
16950
+ return;
16951
+ }
16952
+ console.log(
16953
+ `Restored archive ${pc.bold(label2)} source ${pc.bold(result.sourceWorkstream)} as workstream ${pc.bold(
16954
+ result.workstreamName
16955
+ )} ${pc.dim(
16956
+ `(tasks=${result.restoredTasks}, edges=${result.restoredEdges}, notes=${result.restoredNotes})`
16957
+ )}`
16958
+ );
16959
+ console.log(
16960
+ pc.dim(
16961
+ "agents, workspace_path, and agent_logs are not restored (archives preserve task graph rows, not live panes or the live event log)."
16962
+ )
16963
+ );
16964
+ printNextSteps(nextSteps);
16965
+ }
16474
16966
  async function cmdArchiveDelete(db, label2, opts = {}) {
16475
16967
  const summary = getArchive(db, label2);
16476
16968
  if (!opts.yes) {
@@ -16484,6 +16976,10 @@ async function cmdArchiveDelete(db, label2, opts = {}) {
16484
16976
  {
16485
16977
  intent: "Confirm and actually delete (a snapshot is taken first)",
16486
16978
  command: `mu archive delete ${label2} --yes`
16979
+ },
16980
+ {
16981
+ intent: "Recover a source before deleting the archive",
16982
+ command: `mu archive restore ${label2} --as <new-workstream> --source <workstream>`
16487
16983
  }
16488
16984
  ]
16489
16985
  });
@@ -16507,6 +17003,10 @@ async function cmdArchiveDelete(db, label2, opts = {}) {
16507
17003
  intent: "Confirm and actually delete",
16508
17004
  command: `mu archive delete ${label2} --yes`
16509
17005
  },
17006
+ {
17007
+ intent: "Recover a source before deleting the archive",
17008
+ command: `mu archive restore ${label2} --as <new-workstream> --source <workstream>`
17009
+ },
16510
17010
  {
16511
17011
  intent: "Surgically remove a single source workstream instead",
16512
17012
  command: `mu archive remove ${label2} -w <workstream>`
@@ -16524,7 +17024,7 @@ async function cmdArchiveDelete(db, label2, opts = {}) {
16524
17024
  removedTasks: summary.totalTasks,
16525
17025
  nextSteps: [
16526
17026
  {
16527
- intent: "Undo (a snapshot was taken before the delete)",
17027
+ intent: "Recover the deleted archive (a snapshot was taken before the delete)",
16528
17028
  command: "mu undo --yes"
16529
17029
  }
16530
17030
  ]
@@ -16538,7 +17038,7 @@ async function cmdArchiveDelete(db, label2, opts = {}) {
16538
17038
  );
16539
17039
  printNextSteps([
16540
17040
  {
16541
- intent: "Undo (a snapshot was taken before the delete)",
17041
+ intent: "Recover the deleted archive (a snapshot was taken before the delete)",
16542
17042
  command: "mu undo --yes"
16543
17043
  }
16544
17044
  ]);
@@ -16605,7 +17105,11 @@ async function cmdArchiveExport(db, label2, opts = {}) {
16605
17105
  0
16606
17106
  );
16607
17107
  const nextSteps = [
16608
- { intent: "Browse the bucket", command: `ls ${result.outDir}` },
17108
+ { intent: "Browse the read-only human/git/docs bucket", command: `ls ${result.outDir}` },
17109
+ {
17110
+ intent: "Restore losslessly from the archive (not from this bucket)",
17111
+ command: `mu archive restore ${label2} --as <new-workstream> --source <workstream>`
17112
+ },
16609
17113
  {
16610
17114
  intent: "Re-export to refresh (additive; existing source-ws subdirs untouched)",
16611
17115
  command: `mu archive export ${label2} --out ${result.outDir}`
@@ -16635,6 +17139,11 @@ async function cmdArchiveExport(db, label2, opts = {}) {
16635
17139
  `(sources=${result.sourceCount}, tasks=${totalTasks}, written=${result.written}, unchanged=${result.unchanged}, preserved=${result.preserved})`
16636
17140
  )}`
16637
17141
  );
17142
+ console.log(
17143
+ pc.dim(
17144
+ "This bucket is a read-only artifact for humans/git/docs; use `mu archive restore` for lossless un-archive."
17145
+ )
17146
+ );
16638
17147
  printNextSteps(nextSteps);
16639
17148
  }
16640
17149
  function wireArchiveCommands(program) {
@@ -16658,14 +17167,23 @@ function wireArchiveCommands(program) {
16658
17167
  return handle((db) => cmdArchiveShow(db, label2, opts), this)();
16659
17168
  });
16660
17169
  archive.command("add <label>").description(
16661
- "Snapshot a workstream's task graph (tasks + edges + notes + events) into an existing archive. Idempotent at (archive, source_workstream) granularity. With --destroy, cascades to `mu workstream destroy --yes` after the archive succeeds."
17170
+ "Snapshot a workstream's task graph (tasks + edges + notes + events) into an existing archive. Idempotent at (archive, source_workstream) granularity. With --destroy, cascades to `mu workstream destroy --yes` after the archive succeeds; reverse with `mu archive restore <label> --as <new>`."
16662
17171
  ).option(...WORKSTREAM_OPT).option(
16663
17172
  "--destroy",
16664
- "After a successful archive, also destroy the source workstream (kills tmux + frees workspaces + cascade-deletes DB rows)."
17173
+ "After a successful archive, also destroy the source workstream (kills tmux + frees workspaces + cascade-deletes DB rows). Recover later with `mu archive restore <label> --as <new>`."
16665
17174
  ).option(...JSON_OPT).action(function(label2) {
16666
17175
  const opts = this.optsWithGlobals();
16667
17176
  return handle((db) => cmdArchiveAdd(db, label2, opts), this)();
16668
17177
  });
17178
+ archive.command("restore <label>").description(
17179
+ "Restore one archived source workstream into a fresh workstream directly from archived_* tables. This is the lossless un-archive path; does not restore agents, workspace_path, or agent_logs (archives do not snapshot live panes or the live event log)."
17180
+ ).requiredOption("--as <new-ws-name>", "fresh workstream name to create; refuses collisions").option(
17181
+ "--source <orig-ws-name>",
17182
+ "required when the archive contains multiple source workstreams"
17183
+ ).option(...JSON_OPT).action(function(label2) {
17184
+ const opts = this.opts();
17185
+ return handle((db) => cmdArchiveRestore(db, label2, opts), this)();
17186
+ });
16669
17187
  archive.command("remove <label>").description(
16670
17188
  "Surgically remove a single source workstream's contribution from an archive (rare; recovery). Other source workstreams' rows are untouched."
16671
17189
  ).option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(label2) {
@@ -16679,7 +17197,7 @@ function wireArchiveCommands(program) {
16679
17197
  return handle((db) => cmdArchiveSearch(db, pattern, opts), this)();
16680
17198
  });
16681
17199
  archive.command("export <label>").description(
16682
- "Render every source workstream in an archive to a bucket directory of markdown (one subdir per source-ws + bucket-level README/INDEX/manifest). Idempotent + additive: re-running refreshes only changed task files."
17200
+ "Render every source workstream in an archive to a READ-ONLY bucket directory of markdown for humans/git/docs. Idempotent + additive: re-running refreshes only changed task files. For lossless un-archive, use `mu archive restore`."
16683
17201
  ).option("--out <dir>", "output directory (the bucket); required").option(...JSON_OPT).action(function(label2) {
16684
17202
  const opts = this.opts();
16685
17203
  return handle((db) => cmdArchiveExport(db, label2, opts), this)();
@@ -16692,28 +17210,1049 @@ function wireArchiveCommands(program) {
16692
17210
  });
16693
17211
  }
16694
17212
 
16695
- // src/cli/doctor.ts
16696
- init_agents();
17213
+ // src/db-sync.ts
16697
17214
  init_db();
16698
- init_output();
16699
- init_tmux();
16700
- init_workstream();
16701
- async function cmdDoctor(db, opts = {}) {
16702
- if (opts.json) {
16703
- return cmdDoctorJson(db);
17215
+ init_logs();
17216
+ init_snapshots();
17217
+ import { randomUUID as randomUUID2 } from "crypto";
17218
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4, readFileSync as readFileSync2, unlinkSync as unlinkSync4, writeFileSync as writeFileSync2 } from "fs";
17219
+ import { hostname as hostname2 } from "os";
17220
+ import { dirname as dirname5, join as join8 } from "path";
17221
+ import { fileURLToPath as fileURLToPath2 } from "url";
17222
+
17223
+ // src/db-sync-replay.ts
17224
+ init_db();
17225
+ init_snapshots();
17226
+ import { createHash as createHash2 } from "crypto";
17227
+ var DbReplayWorkstreamMissingError = class extends Error {
17228
+ constructor(workstream) {
17229
+ super(
17230
+ `replay sidecar is for workstream "${workstream}", which does not exist locally; restore it first via mu db import or mu archive restore`
17231
+ );
17232
+ this.workstream = workstream;
16704
17233
  }
16705
- console.log(pc.bold("mu doctor"));
16706
- console.log(pc.bold("\nenvironment"));
17234
+ workstream;
17235
+ name = "DbReplayWorkstreamMissingError";
17236
+ errorNextSteps() {
17237
+ return [
17238
+ {
17239
+ intent: "Restore this workstream from a DB export",
17240
+ command: "mu db import <file> --apply"
17241
+ },
17242
+ {
17243
+ intent: "Or restore it from an archive",
17244
+ command: `mu archive restore <label> --as ${this.workstream}`
17245
+ }
17246
+ ];
17247
+ }
17248
+ };
17249
+ var DbReplayLocalIdConflictError = class extends Error {
17250
+ constructor(workstream, conflicts) {
17251
+ super(
17252
+ `sidecar task id collides with different local content in ${workstream}: ${conflicts.map(
17253
+ (c) => `${c.localId} (local: ${c.local.status} ${JSON.stringify(c.local.title)}; sidecar: ${c.sidecar.status} ${JSON.stringify(c.sidecar.title)})`
17254
+ ).join(", ")}`
17255
+ );
17256
+ this.workstream = workstream;
17257
+ this.conflicts = conflicts;
17258
+ }
17259
+ workstream;
17260
+ conflicts;
17261
+ name = "DbReplayLocalIdConflictError";
17262
+ errorNextSteps() {
17263
+ const first = this.conflicts[0];
17264
+ return [
17265
+ {
17266
+ intent: "Create a renamed local task manually, then replay notes if desired",
17267
+ command: first ? `mu task add ${first.localId}-replay -w ${this.workstream} -t ${shellQuote2(first.sidecar.title)} -i <impact> -e <effort>` : `mu task add <renamed-id> -w ${this.workstream} -t <title> -i <impact> -e <effort>`
17268
+ },
17269
+ {
17270
+ intent: "Skip the colliding id and replay another task",
17271
+ command: "mu db replay <sidecar> --task <other-id> --apply"
17272
+ }
17273
+ ];
17274
+ }
17275
+ };
17276
+ function replayDb(db, file, opts = {}) {
17277
+ const sidecarDb = openDb({ path: file, readonly: true });
16707
17278
  try {
16708
- const version = (await tmux(["-V"])).trim();
16709
- console.log(` tmux : ${pc.green("ok")} (${version})`);
16710
- } catch {
16711
- console.log(` tmux : ${pc.red("NOT FOUND")} \u2014 install tmux \u2265 3.0`);
17279
+ const plan = buildReplayPlan(db, sidecarDb, file);
17280
+ const taskFilter = new Set(opts.tasks ?? []);
17281
+ const noteFilter = new Set(opts.notes ?? []);
17282
+ const selectedTaskIds = opts.all === true ? new Set(plan.tasks.map((t) => t.localId)) : taskFilter;
17283
+ const selectedNoteIds = opts.all === true ? new Set(plan.notes.map((n) => n.taskLocalId)) : noteFilter;
17284
+ const hasSelectors = opts.all === true || selectedTaskIds.size > 0 || selectedNoteIds.size > 0;
17285
+ const noteTaskIds = /* @__PURE__ */ new Set([...selectedNoteIds, ...selectedTaskIds]);
17286
+ const hasWrites = plan.tasks.some((t) => selectedTaskIds.has(t.localId)) || plan.notes.some((n) => noteTaskIds.has(n.taskLocalId)) || plan.edges.some(
17287
+ (e) => opts.all === true || selectedTaskIds.has(e.fromLocalId) || selectedTaskIds.has(e.toLocalId)
17288
+ );
17289
+ const relevantConflicts = opts.all === true ? plan.conflicts : plan.conflicts.filter((c) => selectedTaskIds.has(c.localId));
17290
+ if (relevantConflicts.length > 0) {
17291
+ throw new DbReplayLocalIdConflictError(plan.workstream, relevantConflicts);
17292
+ }
17293
+ if (opts.apply !== true || !hasSelectors) return replayResult(plan, true, false);
17294
+ if (!hasWrites) return replayResult(plan, false, true);
17295
+ const snapshot = captureSnapshot(db, `db replay ${file}`, null);
17296
+ const applied = applyReplayPlan(db, plan, selectedTaskIds, selectedNoteIds, opts.all === true);
17297
+ return { ...replayResult(plan, false, true), snapshotId: snapshot.id, ...applied };
17298
+ } finally {
17299
+ sidecarDb.close();
16712
17300
  }
16713
- console.log(` $TMUX : ${process.env.TMUX ? pc.green("set") : pc.yellow("not set")}`);
16714
- console.log(
16715
- ` $TMUX_PANE : ${process.env.TMUX_PANE ? pc.green(process.env.TMUX_PANE) : pc.dim("not set")}`
16716
- );
17301
+ }
17302
+ function buildReplayPlan(localDb, sidecarDb, sourceFile) {
17303
+ const sidecarWorkstreams = listLocalWorkstreams(sidecarDb);
17304
+ const sidecarWs = sidecarWorkstreams[0];
17305
+ if (sidecarWorkstreams.length !== 1 || !sidecarWs) {
17306
+ throw new Error(
17307
+ `replay sidecar must contain exactly one workstream; found ${sidecarWorkstreams.length}`
17308
+ );
17309
+ }
17310
+ const localWs = listLocalWorkstreams(localDb).find((w) => w.name === sidecarWs.name);
17311
+ if (!localWs) throw new DbReplayWorkstreamMissingError(sidecarWs.name);
17312
+ const localTasks = new Map(
17313
+ localDb.prepare("SELECT local_id, title, status FROM tasks WHERE workstream_id = ?").all(localWs.id).map((t) => [t.local_id, t])
17314
+ );
17315
+ const tasks = [];
17316
+ const conflicts = [];
17317
+ for (const task of listReplayTasks(sidecarDb, sidecarWs.id)) {
17318
+ const local = localTasks.get(task.localId);
17319
+ if (!local) tasks.push(task);
17320
+ else if (local.title !== task.title || local.status !== task.status) {
17321
+ conflicts.push({
17322
+ localId: task.localId,
17323
+ local: { title: local.title, status: local.status },
17324
+ sidecar: { title: task.title, status: task.status }
17325
+ });
17326
+ }
17327
+ }
17328
+ const localNoteHashes = new Set(listReplayNotes(localDb, localWs.id).map((n) => n.hash));
17329
+ const localEdges = new Set(listReplayEdges(localDb, localWs.id).map(edgeKey));
17330
+ return {
17331
+ sourceFile,
17332
+ workstream: sidecarWs.name,
17333
+ tasks,
17334
+ notes: listReplayNotes(sidecarDb, sidecarWs.id).filter((n) => !localNoteHashes.has(n.hash)),
17335
+ edges: listReplayEdges(sidecarDb, sidecarWs.id).filter((e) => !localEdges.has(edgeKey(e))),
17336
+ conflicts
17337
+ };
17338
+ }
17339
+ function applyReplayPlan(db, plan, selectedTaskIds, selectedNoteIds, replayAllEdges) {
17340
+ const warnings = [];
17341
+ const added = db.transaction(() => {
17342
+ const wsId = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(plan.workstream)?.id;
17343
+ if (wsId === void 0) throw new DbReplayWorkstreamMissingError(plan.workstream);
17344
+ const taskIds = new Set(selectedTaskIds);
17345
+ const noteTaskIds = /* @__PURE__ */ new Set([...selectedNoteIds, ...taskIds]);
17346
+ let tasks = 0;
17347
+ let notes = 0;
17348
+ let edges = 0;
17349
+ const insertTask = db.prepare(
17350
+ `INSERT OR IGNORE INTO tasks (workstream_id, local_id, title, status, impact, effort_days, created_at, updated_at)
17351
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
17352
+ );
17353
+ for (const task of plan.tasks) {
17354
+ if (!taskIds.has(task.localId)) continue;
17355
+ const result = insertTask.run(
17356
+ wsId,
17357
+ task.localId,
17358
+ task.title,
17359
+ task.status,
17360
+ task.impact,
17361
+ task.effortDays,
17362
+ task.createdAt,
17363
+ task.updatedAt
17364
+ );
17365
+ if (result.changes > 0) tasks += 1;
17366
+ }
17367
+ const existingNoteHashes = new Set(listReplayNotes(db, wsId).map((n) => n.hash));
17368
+ const insertNote = db.prepare(
17369
+ `INSERT INTO task_notes (task_id, author, content, created_at)
17370
+ SELECT id, ?, ?, ? FROM tasks WHERE workstream_id = ? AND local_id = ?`
17371
+ );
17372
+ for (const note of plan.notes) {
17373
+ if (!noteTaskIds.has(note.taskLocalId) || existingNoteHashes.has(note.hash)) continue;
17374
+ const result = insertNote.run(
17375
+ note.author,
17376
+ note.content,
17377
+ note.createdAt,
17378
+ wsId,
17379
+ note.taskLocalId
17380
+ );
17381
+ if (result.changes > 0) {
17382
+ notes += 1;
17383
+ existingNoteHashes.add(note.hash);
17384
+ }
17385
+ }
17386
+ const insertEdge = db.prepare(
17387
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
17388
+ SELECT f.id, t.id, ?
17389
+ FROM tasks f, tasks t
17390
+ WHERE f.workstream_id = ? AND f.local_id = ?
17391
+ AND t.workstream_id = ? AND t.local_id = ?`
17392
+ );
17393
+ for (const edge of plan.edges) {
17394
+ if (!replayAllEdges && !taskIds.has(edge.fromLocalId) && !taskIds.has(edge.toLocalId)) {
17395
+ continue;
17396
+ }
17397
+ if (!hasTask(db, wsId, edge.fromLocalId) || !hasTask(db, wsId, edge.toLocalId)) {
17398
+ warnings.push(
17399
+ `skipped edge ${edge.fromLocalId} -> ${edge.toLocalId}: one endpoint is missing locally`
17400
+ );
17401
+ continue;
17402
+ }
17403
+ const result = insertEdge.run(edge.createdAt, wsId, edge.fromLocalId, wsId, edge.toLocalId);
17404
+ if (result.changes > 0) edges += 1;
17405
+ }
17406
+ return { tasks, notes, edges };
17407
+ })();
17408
+ return { added, warnings };
17409
+ }
17410
+ function replayResult(plan, dryRun, applied) {
17411
+ return { ...plan, dryRun, applied, added: { tasks: 0, notes: 0, edges: 0 }, warnings: [] };
17412
+ }
17413
+ function listLocalWorkstreams(db) {
17414
+ return db.prepare("SELECT id, name FROM workstreams ORDER BY name").all();
17415
+ }
17416
+ function listReplayTasks(db, wsId) {
17417
+ return db.prepare(
17418
+ `SELECT local_id, title, status, impact, effort_days, created_at, updated_at
17419
+ FROM tasks
17420
+ WHERE workstream_id = ?
17421
+ ORDER BY local_id`
17422
+ ).all(wsId).map((row2) => ({
17423
+ localId: row2.local_id,
17424
+ title: row2.title,
17425
+ status: row2.status,
17426
+ impact: row2.impact,
17427
+ effortDays: row2.effort_days,
17428
+ createdAt: row2.created_at,
17429
+ updatedAt: row2.updated_at
17430
+ }));
17431
+ }
17432
+ function listReplayNotes(db, wsId) {
17433
+ const rows = db.prepare(
17434
+ `SELECT t.local_id AS taskLocalId, n.author, n.content, n.created_at AS createdAt
17435
+ FROM task_notes n
17436
+ JOIN tasks t ON t.id = n.task_id
17437
+ WHERE t.workstream_id = ?
17438
+ ORDER BY n.created_at, n.id`
17439
+ ).all(wsId);
17440
+ return rows.map((row2) => ({ ...row2, hash: noteHash(row2) }));
17441
+ }
17442
+ function listReplayEdges(db, wsId) {
17443
+ return db.prepare(
17444
+ `SELECT f.local_id AS fromLocalId, t.local_id AS toLocalId, e.created_at AS createdAt
17445
+ FROM task_edges e
17446
+ JOIN tasks f ON f.id = e.from_task_id
17447
+ JOIN tasks t ON t.id = e.to_task_id
17448
+ WHERE f.workstream_id = ? AND t.workstream_id = ?
17449
+ ORDER BY f.local_id, t.local_id`
17450
+ ).all(wsId, wsId);
17451
+ }
17452
+ function noteHash(note) {
17453
+ return createHash2("sha256").update(`${note.taskLocalId}\0${note.content}\0${note.createdAt}`).digest("hex");
17454
+ }
17455
+ function edgeKey(edge) {
17456
+ return `${edge.fromLocalId}\0${edge.toLocalId}`;
17457
+ }
17458
+ function hasTask(db, wsId, localId) {
17459
+ return db.prepare("SELECT 1 FROM tasks WHERE workstream_id = ? AND local_id = ?").get(wsId, localId) !== void 0;
17460
+ }
17461
+ function shellQuote2(s) {
17462
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
17463
+ }
17464
+
17465
+ // src/db-sync.ts
17466
+ var DbExportTargetExistsError = class extends Error {
17467
+ constructor(file) {
17468
+ super(`DB export target already exists: ${file}`);
17469
+ this.file = file;
17470
+ }
17471
+ file;
17472
+ name = "DbExportTargetExistsError";
17473
+ errorNextSteps() {
17474
+ return [
17475
+ { intent: "Choose a different target", command: "mu db export <new-file>" },
17476
+ { intent: "Overwrite this target", command: `mu db export ${shellQuote3(this.file)} --force` }
17477
+ ];
17478
+ }
17479
+ };
17480
+ var DbImportManifestMissingError = class extends Error {
17481
+ constructor(manifestPath) {
17482
+ super(`DB import manifest not found: ${manifestPath}`);
17483
+ this.manifestPath = manifestPath;
17484
+ }
17485
+ manifestPath;
17486
+ name = "DbImportManifestMissingError";
17487
+ errorNextSteps() {
17488
+ return [
17489
+ { intent: "Export the DB with its sidecar", command: "mu db export /tmp/mu.db --force" },
17490
+ { intent: "Copy the sidecar too", command: `scp <host>:${shellQuote3(this.manifestPath)} .` }
17491
+ ];
17492
+ }
17493
+ };
17494
+ var DbImportSchemaTooOldError = class extends Error {
17495
+ constructor(sourceVersion) {
17496
+ super(
17497
+ `source DB schema v${sourceVersion} is older than local mu requires (v${CURRENT_SCHEMA_VERSION})`
17498
+ );
17499
+ this.sourceVersion = sourceVersion;
17500
+ }
17501
+ sourceVersion;
17502
+ name = "DbImportSchemaTooOldError";
17503
+ errorNextSteps() {
17504
+ return [
17505
+ {
17506
+ intent: "Upgrade mu on the source machine",
17507
+ command: "npm run build && mu db export <file> --force"
17508
+ },
17509
+ { intent: "Then retry this import", command: "mu db import <file> --apply" }
17510
+ ];
17511
+ }
17512
+ };
17513
+ var DbImportSchemaTooNewError = class extends Error {
17514
+ constructor(sourceVersion) {
17515
+ super(
17516
+ `source DB schema v${sourceVersion} is newer than this mu supports (v${CURRENT_SCHEMA_VERSION}); upgrade local mu`
17517
+ );
17518
+ this.sourceVersion = sourceVersion;
17519
+ }
17520
+ sourceVersion;
17521
+ name = "DbImportSchemaTooNewError";
17522
+ errorNextSteps() {
17523
+ return [
17524
+ { intent: "Upgrade local mu", command: "git pull && npm install && npm run build" },
17525
+ { intent: "Then retry this import", command: "mu db import <file> --apply" }
17526
+ ];
17527
+ }
17528
+ };
17529
+ var DbImportSourceStaleError = class extends Error {
17530
+ constructor(workstreams) {
17531
+ super(`source DB is stale for local-ahead workstream(s): ${workstreams.join(", ")}`);
17532
+ this.workstreams = workstreams;
17533
+ }
17534
+ workstreams;
17535
+ name = "DbImportSourceStaleError";
17536
+ errorNextSteps() {
17537
+ return [
17538
+ { intent: "Re-export from this machine", command: "mu db export /tmp/mu-fresh.db --force" },
17539
+ { intent: "Dry-run the incoming file first", command: "mu db import <file>" }
17540
+ ];
17541
+ }
17542
+ };
17543
+ var DbImportConflictError = class extends Error {
17544
+ constructor(workstreams) {
17545
+ super(`source and local both advanced for workstream(s): ${workstreams.join(", ")}`);
17546
+ this.workstreams = workstreams;
17547
+ }
17548
+ workstreams;
17549
+ name = "DbImportConflictError";
17550
+ errorNextSteps() {
17551
+ return [
17552
+ { intent: "Preview the conflicting workstreams", command: "mu db import <file> --json" },
17553
+ {
17554
+ intent: "Clobber from source after parking local divergence",
17555
+ command: "mu db import <file> --apply --force-source"
17556
+ }
17557
+ ];
17558
+ }
17559
+ };
17560
+ function exportDb(db, file, opts = {}) {
17561
+ const target = file;
17562
+ const manifestPath = `${target}.manifest.json`;
17563
+ const targetExists = existsSync12(target);
17564
+ if (targetExists && opts.force !== true) throw new DbExportTargetExistsError(target);
17565
+ const preEventManifest = buildExportManifest(db);
17566
+ for (const ws of preEventManifest.workstreams) {
17567
+ emitEvent(db, ws.name, `db export ${ws.name} seq=${ws.latestSeq}`);
17568
+ }
17569
+ const manifest = buildExportManifest(db);
17570
+ mkdirSync4(dirname5(target), { recursive: true });
17571
+ try {
17572
+ if (targetExists) unlinkSync4(target);
17573
+ db.exec(`VACUUM INTO ${quoteSqlString2(target)}`);
17574
+ writeFileSync2(manifestPath, `${JSON.stringify(manifest, null, 2)}
17575
+ `, "utf8");
17576
+ } catch (err) {
17577
+ try {
17578
+ if (existsSync12(target)) unlinkSync4(target);
17579
+ } catch {
17580
+ }
17581
+ throw err;
17582
+ }
17583
+ return { file: target, manifestPath, manifest, overwritten: targetExists };
17584
+ }
17585
+ function importDb(db, file, opts = {}) {
17586
+ const manifest = readImportManifest(file);
17587
+ assertImportSchemaCompatible(manifest.schemaVersion);
17588
+ const sourceDb = openDb({ path: file, readonly: true });
17589
+ try {
17590
+ const summary = buildImportPlan(db, manifest, file, opts.onlyWorkstreams);
17591
+ if (opts.apply !== true) {
17592
+ return {
17593
+ machineId: manifest.machineId,
17594
+ sourceFile: file,
17595
+ dryRun: true,
17596
+ applied: false,
17597
+ summary
17598
+ };
17599
+ }
17600
+ const stale = summary.filter((s) => s.decision === "LOCAL_AHEAD").map((s) => s.workstream);
17601
+ if (stale.length > 0) throw new DbImportSourceStaleError(stale);
17602
+ const conflicts = summary.filter((s) => s.decision === "CONFLICT").map((s) => s.workstream);
17603
+ if (conflicts.length > 0 && opts.forceSource !== true)
17604
+ throw new DbImportConflictError(conflicts);
17605
+ const mutating = summary.some((s) => shouldReplace(s.decision, opts.forceSource === true));
17606
+ const snapshot = mutating ? captureSnapshot(db, `db import ${file}`, null) : void 0;
17607
+ for (const item of summary) {
17608
+ if (!shouldReplace(item.decision, opts.forceSource === true)) continue;
17609
+ if (item.decision === "CONFLICT") {
17610
+ item.parkPath = parkLocalWorkstream(db, item.workstream);
17611
+ }
17612
+ const sourceWs = manifest.workstreams.find((w) => w.name === item.workstream);
17613
+ const sourceSeq = sourceWs?.latestSeq ?? 0;
17614
+ replaceWorkstreamFromSource(db, sourceDb, item.workstream, manifest.machineId, sourceSeq);
17615
+ }
17616
+ return {
17617
+ machineId: manifest.machineId,
17618
+ sourceFile: file,
17619
+ dryRun: false,
17620
+ applied: true,
17621
+ ...snapshot ? { snapshotId: snapshot.id } : {},
17622
+ summary
17623
+ };
17624
+ } finally {
17625
+ sourceDb.close();
17626
+ }
17627
+ }
17628
+ function buildImportPlan(localDb, manifest, sourceFile, onlyWorkstreams) {
17629
+ const sourceByName = new Map(manifest.workstreams.map((w) => [w.name, w]));
17630
+ const localByName = new Map(listLocalWorkstreams2(localDb).map((w) => [w.name, w]));
17631
+ const localMachineId = getMachineIdentity(localDb)?.machine_id ?? "";
17632
+ const only = normaliseOnlyWorkstreams(onlyWorkstreams);
17633
+ const names = Array.from(/* @__PURE__ */ new Set([...sourceByName.keys(), ...localByName.keys()])).filter((name) => only.size === 0 || only.has(name)).sort();
17634
+ return names.map((name) => {
17635
+ const source = sourceByName.get(name);
17636
+ const local = localByName.get(name);
17637
+ const sourceSeq = source?.latestSeq ?? 0;
17638
+ const localSeq = local ? latestSeq(localDb, local.id) : 0;
17639
+ const synced = source !== void 0 && local !== void 0 && manifest.machineId === localMachineId ? { sourceSeq: Math.min(sourceSeq, localSeq), localSeq: Math.min(sourceSeq, localSeq) } : local ? lastKnownPeerSync(localDb, local.id, manifest.machineId) : { sourceSeq: 0, localSeq: 0 };
17640
+ const decision = classifyWorkstream({
17641
+ hasSource: source !== void 0,
17642
+ hasLocal: local !== void 0,
17643
+ sourceSeq,
17644
+ localSeq,
17645
+ syncedSourceSeq: synced.sourceSeq,
17646
+ syncedLocalSeq: synced.localSeq
17647
+ });
17648
+ return {
17649
+ workstream: name,
17650
+ decision,
17651
+ delta: {
17652
+ sourceFile,
17653
+ sourceSeq,
17654
+ localSeq,
17655
+ lastSynced: synced.sourceSeq,
17656
+ localSynced: synced.localSeq,
17657
+ source: source ? countsFromManifest(source) : null,
17658
+ local: local ? countWorkstream(localDb, local.id) : null
17659
+ },
17660
+ ...decision === "LOCAL_AHEAD" ? { needs: "re-export from this machine" } : {},
17661
+ ...decision === "CONFLICT" ? { needs: "--force-source" } : {}
17662
+ };
17663
+ });
17664
+ }
17665
+ function classifyWorkstream(opts) {
17666
+ if (opts.hasSource && !opts.hasLocal) return "IMPORT";
17667
+ if (!opts.hasSource && opts.hasLocal)
17668
+ return opts.syncedSourceSeq > 0 || opts.syncedLocalSeq > 0 ? "LOCAL_AHEAD" : "LEAVE_ALONE";
17669
+ if (!opts.hasSource && !opts.hasLocal) return "IDENTICAL";
17670
+ const sourceAdvanced = opts.sourceSeq > opts.syncedSourceSeq;
17671
+ const localAdvanced = opts.localSeq > opts.syncedLocalSeq;
17672
+ if (!sourceAdvanced && !localAdvanced) return "IDENTICAL";
17673
+ if (sourceAdvanced && !localAdvanced) return "FAST_FORWARD";
17674
+ if (!sourceAdvanced && localAdvanced) return "LOCAL_AHEAD";
17675
+ return "CONFLICT";
17676
+ }
17677
+ function shouldReplace(decision, forceSource) {
17678
+ return decision === "FAST_FORWARD" || decision === "IMPORT" || decision === "CONFLICT" && forceSource;
17679
+ }
17680
+ function replaceWorkstreamFromSource(localDb, sourceDb, workstream, sourceMachineId, sourceSeq) {
17681
+ localDb.transaction(() => {
17682
+ const existing = localDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream);
17683
+ if (existing) {
17684
+ localDb.prepare("DELETE FROM vcs_workspaces WHERE workstream_id = ?").run(existing.id);
17685
+ localDb.prepare("DELETE FROM agents WHERE workstream_id = ?").run(existing.id);
17686
+ localDb.prepare("DELETE FROM workstreams WHERE id = ?").run(existing.id);
17687
+ }
17688
+ copyWorkstreamRows(sourceDb, localDb, workstream, {
17689
+ includeMachineLocalRows: false,
17690
+ preserveLogSeq: false,
17691
+ includeSync: false
17692
+ });
17693
+ const wsId = localDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream)?.id;
17694
+ if (wsId === void 0) throw new Error(`importDb: failed to import workstream ${workstream}`);
17695
+ writeSyncState(localDb, wsId, sourceMachineId, sourceSeq);
17696
+ })();
17697
+ }
17698
+ function parkLocalWorkstream(db, workstream) {
17699
+ const dir = join8(defaultStateDir(), "divergence");
17700
+ mkdirSync4(dir, { recursive: true });
17701
+ const path2 = join8(
17702
+ dir,
17703
+ `${workstream}-${(/* @__PURE__ */ new Date()).toISOString()}-${randomUUID2().slice(0, 8)}.db`
17704
+ );
17705
+ const parkDb = openDb({ path: path2 });
17706
+ try {
17707
+ const identity = getMachineIdentity(db);
17708
+ if (identity) {
17709
+ parkDb.prepare(
17710
+ `UPDATE machine_identity
17711
+ SET machine_id = ?, hostname = ?, created_at = ?
17712
+ WHERE id = 1`
17713
+ ).run(
17714
+ identity.machine_id,
17715
+ identity.hostname,
17716
+ identity.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
17717
+ );
17718
+ }
17719
+ copyWorkstreamRows(db, parkDb, workstream, {
17720
+ includeMachineLocalRows: true,
17721
+ preserveLogSeq: true,
17722
+ includeSync: true
17723
+ });
17724
+ } catch (err) {
17725
+ try {
17726
+ parkDb.close();
17727
+ } catch {
17728
+ }
17729
+ try {
17730
+ if (existsSync12(path2)) unlinkSync4(path2);
17731
+ } catch {
17732
+ }
17733
+ throw err;
17734
+ }
17735
+ parkDb.close();
17736
+ return path2;
17737
+ }
17738
+ function copyWorkstreamRows(sourceDb, targetDb, workstream, opts) {
17739
+ const sourceWs = sourceDb.prepare("SELECT id, name, created_at FROM workstreams WHERE name = ?").get(workstream);
17740
+ if (!sourceWs) throw new Error(`copyWorkstreamRows: no such workstream ${workstream}`);
17741
+ targetDb.prepare("INSERT INTO workstreams (name, created_at) VALUES (?, ?)").run(sourceWs.name, sourceWs.created_at);
17742
+ const targetWsId = targetDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream).id;
17743
+ if (opts.includeMachineLocalRows) copyAgents(sourceDb, targetDb, sourceWs.id, targetWsId);
17744
+ copyTasks(sourceDb, targetDb, sourceWs.id, targetWsId, opts.includeMachineLocalRows);
17745
+ copyEdges(sourceDb, targetDb, sourceWs.id, targetWsId);
17746
+ copyNotes(sourceDb, targetDb, sourceWs.id, targetWsId);
17747
+ copyLogs(sourceDb, targetDb, sourceWs.id, targetWsId, opts.preserveLogSeq);
17748
+ if (opts.includeMachineLocalRows) copyWorkspaces(sourceDb, targetDb, sourceWs.id, targetWsId);
17749
+ if (opts.includeSync) copySync(sourceDb, targetDb, sourceWs.id, targetWsId);
17750
+ }
17751
+ function copyAgents(sourceDb, targetDb, sourceWsId, targetWsId) {
17752
+ const rows = sourceDb.prepare(
17753
+ `SELECT name, cli, pane_id, status, role, tab, created_at, updated_at
17754
+ FROM agents
17755
+ WHERE workstream_id = ?
17756
+ ORDER BY id`
17757
+ ).all(sourceWsId);
17758
+ const insert = targetDb.prepare(
17759
+ `INSERT INTO agents (workstream_id, name, cli, pane_id, status, role, tab, created_at, updated_at)
17760
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
17761
+ );
17762
+ for (const row2 of rows) {
17763
+ insert.run(
17764
+ targetWsId,
17765
+ row2.name,
17766
+ row2.cli,
17767
+ row2.pane_id,
17768
+ row2.status,
17769
+ row2.role,
17770
+ row2.tab,
17771
+ row2.created_at,
17772
+ row2.updated_at
17773
+ );
17774
+ }
17775
+ }
17776
+ function copyTasks(sourceDb, targetDb, sourceWsId, targetWsId, includeOwners) {
17777
+ const rows = sourceDb.prepare(
17778
+ `SELECT t.local_id, t.title, t.status, t.impact, t.effort_days, a.name AS owner_name,
17779
+ t.created_at, t.updated_at
17780
+ FROM tasks t
17781
+ LEFT JOIN agents a ON a.id = t.owner_id
17782
+ WHERE t.workstream_id = ?
17783
+ ORDER BY t.id`
17784
+ ).all(sourceWsId);
17785
+ const ownerLookup = targetDb.prepare(
17786
+ "SELECT id FROM agents WHERE workstream_id = ? AND name = ?"
17787
+ );
17788
+ const insert = targetDb.prepare(
17789
+ `INSERT INTO tasks (workstream_id, local_id, title, status, impact, effort_days, owner_id, created_at, updated_at)
17790
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
17791
+ );
17792
+ for (const row2 of rows) {
17793
+ const ownerId = includeOwners && row2.owner_name !== null ? ownerLookup.get(targetWsId, row2.owner_name)?.id ?? null : null;
17794
+ insert.run(
17795
+ targetWsId,
17796
+ row2.local_id,
17797
+ row2.title,
17798
+ row2.status,
17799
+ row2.impact,
17800
+ row2.effort_days,
17801
+ ownerId,
17802
+ row2.created_at,
17803
+ row2.updated_at
17804
+ );
17805
+ }
17806
+ }
17807
+ function copyEdges(sourceDb, targetDb, sourceWsId, targetWsId) {
17808
+ const rows = sourceDb.prepare(
17809
+ `SELECT f.local_id AS from_local_id, t.local_id AS to_local_id, e.created_at
17810
+ FROM task_edges e
17811
+ JOIN tasks f ON f.id = e.from_task_id
17812
+ JOIN tasks t ON t.id = e.to_task_id
17813
+ WHERE f.workstream_id = ? AND t.workstream_id = ?
17814
+ ORDER BY e.created_at, f.local_id, t.local_id`
17815
+ ).all(sourceWsId, sourceWsId);
17816
+ const insert = targetDb.prepare(
17817
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
17818
+ SELECT f.id, t.id, ?
17819
+ FROM tasks f, tasks t
17820
+ WHERE f.workstream_id = ? AND f.local_id = ?
17821
+ AND t.workstream_id = ? AND t.local_id = ?`
17822
+ );
17823
+ for (const row2 of rows) {
17824
+ insert.run(row2.created_at, targetWsId, row2.from_local_id, targetWsId, row2.to_local_id);
17825
+ }
17826
+ }
17827
+ function copyNotes(sourceDb, targetDb, sourceWsId, targetWsId) {
17828
+ const rows = sourceDb.prepare(
17829
+ `SELECT t.local_id AS task_local_id, n.author, n.content, n.created_at
17830
+ FROM task_notes n
17831
+ JOIN tasks t ON t.id = n.task_id
17832
+ WHERE t.workstream_id = ?
17833
+ ORDER BY n.id`
17834
+ ).all(sourceWsId);
17835
+ const insert = targetDb.prepare(
17836
+ `INSERT INTO task_notes (task_id, author, content, created_at)
17837
+ SELECT id, ?, ?, ? FROM tasks WHERE workstream_id = ? AND local_id = ?`
17838
+ );
17839
+ for (const row2 of rows) {
17840
+ insert.run(row2.author, row2.content, row2.created_at, targetWsId, row2.task_local_id);
17841
+ }
17842
+ }
17843
+ function copyLogs(sourceDb, targetDb, sourceWsId, targetWsId, preserveSeq) {
17844
+ const rows = sourceDb.prepare(
17845
+ `SELECT seq, source, kind, payload, created_at
17846
+ FROM agent_logs
17847
+ WHERE workstream_id = ?
17848
+ ORDER BY seq`
17849
+ ).all(sourceWsId);
17850
+ const insertPreserve = targetDb.prepare(
17851
+ "INSERT INTO agent_logs (seq, workstream_id, source, kind, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)"
17852
+ );
17853
+ const insertRenumber = targetDb.prepare(
17854
+ "INSERT INTO agent_logs (workstream_id, source, kind, payload, created_at) VALUES (?, ?, ?, ?, ?)"
17855
+ );
17856
+ for (const row2 of rows) {
17857
+ if (preserveSeq) {
17858
+ insertPreserve.run(row2.seq, targetWsId, row2.source, row2.kind, row2.payload, row2.created_at);
17859
+ } else {
17860
+ insertRenumber.run(targetWsId, row2.source, row2.kind, row2.payload, row2.created_at);
17861
+ }
17862
+ }
17863
+ }
17864
+ function copyWorkspaces(sourceDb, targetDb, sourceWsId, targetWsId) {
17865
+ const rows = sourceDb.prepare(
17866
+ `SELECT a.name AS agent_name, v.backend, v.path, v.parent_ref, v.created_at
17867
+ FROM vcs_workspaces v
17868
+ JOIN agents a ON a.id = v.agent_id
17869
+ WHERE v.workstream_id = ?
17870
+ ORDER BY v.id`
17871
+ ).all(sourceWsId);
17872
+ const agentLookup = targetDb.prepare(
17873
+ "SELECT id FROM agents WHERE workstream_id = ? AND name = ?"
17874
+ );
17875
+ const insert = targetDb.prepare(
17876
+ `INSERT INTO vcs_workspaces (agent_id, workstream_id, backend, path, parent_ref, created_at)
17877
+ VALUES (?, ?, ?, ?, ?, ?)`
17878
+ );
17879
+ for (const row2 of rows) {
17880
+ const agentId = agentLookup.get(targetWsId, row2.agent_name)?.id;
17881
+ if (agentId === void 0) continue;
17882
+ insert.run(agentId, targetWsId, row2.backend, row2.path, row2.parent_ref, row2.created_at);
17883
+ }
17884
+ }
17885
+ function copySync(sourceDb, targetDb, sourceWsId, targetWsId) {
17886
+ const row2 = sourceDb.prepare("SELECT last_known_peer_seqs FROM workstream_sync WHERE workstream_id = ?").get(sourceWsId);
17887
+ if (!row2) return;
17888
+ targetDb.prepare("INSERT INTO workstream_sync (workstream_id, last_known_peer_seqs) VALUES (?, ?)").run(targetWsId, row2.last_known_peer_seqs);
17889
+ }
17890
+ function writeSyncState(db, workstreamId, sourceMachineId, sourceSeq) {
17891
+ const localSeq = latestSeq(db, workstreamId);
17892
+ const peers = {
17893
+ [sourceMachineId]: sourceSeq,
17894
+ [localSeqKey(sourceMachineId)]: localSeq
17895
+ };
17896
+ db.prepare(
17897
+ `INSERT OR REPLACE INTO workstream_sync (workstream_id, last_known_peer_seqs)
17898
+ VALUES (?, ?)`
17899
+ ).run(workstreamId, JSON.stringify(peers));
17900
+ }
17901
+ function lastKnownPeerSync(db, workstreamId, machineId) {
17902
+ const row2 = db.prepare("SELECT last_known_peer_seqs FROM workstream_sync WHERE workstream_id = ?").get(workstreamId);
17903
+ if (!row2) return { sourceSeq: 0, localSeq: 0 };
17904
+ const parsed = parsePeerSeqs(row2.last_known_peer_seqs);
17905
+ const sourceSeq = parsed[machineId] ?? 0;
17906
+ return { sourceSeq, localSeq: parsed[localSeqKey(machineId)] ?? sourceSeq };
17907
+ }
17908
+ function localSeqKey(machineId) {
17909
+ return `${machineId}:local`;
17910
+ }
17911
+ function parsePeerSeqs(raw) {
17912
+ try {
17913
+ const parsed = JSON.parse(raw);
17914
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
17915
+ const result = {};
17916
+ for (const [key, value] of Object.entries(parsed)) {
17917
+ if (typeof value === "number" && Number.isFinite(value)) result[key] = value;
17918
+ }
17919
+ return result;
17920
+ } catch {
17921
+ return {};
17922
+ }
17923
+ }
17924
+ function readImportManifest(file) {
17925
+ const manifestPath = `${file}.manifest.json`;
17926
+ if (!existsSync12(manifestPath)) throw new DbImportManifestMissingError(manifestPath);
17927
+ return JSON.parse(readFileSync2(manifestPath, "utf8"));
17928
+ }
17929
+ function assertImportSchemaCompatible(sourceVersion) {
17930
+ if (sourceVersion < CURRENT_SCHEMA_VERSION) throw new DbImportSchemaTooOldError(sourceVersion);
17931
+ if (sourceVersion > CURRENT_SCHEMA_VERSION) throw new DbImportSchemaTooNewError(sourceVersion);
17932
+ }
17933
+ function buildExportManifest(db) {
17934
+ const identity = getMachineIdentity(db);
17935
+ const schemaRow = db.prepare("SELECT version FROM schema_version WHERE id = 1").get();
17936
+ const workstreams = listLocalWorkstreams2(db);
17937
+ return {
17938
+ muVersion: readPackageVersion(),
17939
+ schemaVersion: schemaRow?.version ?? CURRENT_SCHEMA_VERSION,
17940
+ machineId: identity?.machine_id ?? "",
17941
+ hostname: identity?.hostname ?? hostname2(),
17942
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
17943
+ workstreams: workstreams.map((ws) => ({
17944
+ name: ws.name,
17945
+ tasks: count(db, "SELECT COUNT(*) AS n FROM tasks WHERE workstream_id = ?", ws.id),
17946
+ edges: count(
17947
+ db,
17948
+ `SELECT COUNT(*) AS n
17949
+ FROM task_edges e
17950
+ JOIN tasks f ON f.id = e.from_task_id
17951
+ JOIN tasks t ON t.id = e.to_task_id
17952
+ WHERE f.workstream_id = ? AND t.workstream_id = ?`,
17953
+ ws.id,
17954
+ ws.id
17955
+ ),
17956
+ notes: count(
17957
+ db,
17958
+ `SELECT COUNT(*) AS n
17959
+ FROM task_notes n
17960
+ JOIN tasks t ON t.id = n.task_id
17961
+ WHERE t.workstream_id = ?`,
17962
+ ws.id
17963
+ ),
17964
+ latestSeq: latestSeq(db, ws.id)
17965
+ }))
17966
+ };
17967
+ }
17968
+ function listLocalWorkstreams2(db) {
17969
+ return db.prepare("SELECT id, name FROM workstreams ORDER BY name").all();
17970
+ }
17971
+ function getMachineIdentity(db) {
17972
+ return db.prepare("SELECT machine_id, hostname, created_at FROM machine_identity WHERE id = 1").get();
17973
+ }
17974
+ function countWorkstream(db, wsId) {
17975
+ return {
17976
+ tasks: count(db, "SELECT COUNT(*) AS n FROM tasks WHERE workstream_id = ?", wsId),
17977
+ edges: count(
17978
+ db,
17979
+ `SELECT COUNT(*) AS n
17980
+ FROM task_edges e
17981
+ JOIN tasks f ON f.id = e.from_task_id
17982
+ JOIN tasks t ON t.id = e.to_task_id
17983
+ WHERE f.workstream_id = ? AND t.workstream_id = ?`,
17984
+ wsId,
17985
+ wsId
17986
+ ),
17987
+ notes: count(
17988
+ db,
17989
+ `SELECT COUNT(*) AS n
17990
+ FROM task_notes n
17991
+ JOIN tasks t ON t.id = n.task_id
17992
+ WHERE t.workstream_id = ?`,
17993
+ wsId
17994
+ )
17995
+ };
17996
+ }
17997
+ function countsFromManifest(ws) {
17998
+ return { tasks: ws.tasks, edges: ws.edges, notes: ws.notes };
17999
+ }
18000
+ function normaliseOnlyWorkstreams(input) {
18001
+ if (!input || input.length === 0) return /* @__PURE__ */ new Set();
18002
+ return new Set(
18003
+ input.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0)
18004
+ );
18005
+ }
18006
+ function count(db, sql, ...params) {
18007
+ const row2 = db.prepare(sql).get(...params);
18008
+ return row2?.n ?? 0;
18009
+ }
18010
+ function quoteSqlString2(s) {
18011
+ return `'${s.replace(/'/g, "''")}'`;
18012
+ }
18013
+ function readPackageVersion() {
18014
+ try {
18015
+ const here = dirname5(fileURLToPath2(import.meta.url));
18016
+ const raw = readFileSync2(join8(here, "..", "package.json"), "utf8");
18017
+ const parsed = JSON.parse(raw);
18018
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
18019
+ } catch {
18020
+ return "unknown";
18021
+ }
18022
+ }
18023
+ function shellQuote3(s) {
18024
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
18025
+ }
18026
+
18027
+ // src/cli/db.ts
18028
+ init_output();
18029
+ function exportNextSteps(file) {
18030
+ return [
18031
+ { intent: "Ship the DB copy", command: `scp ${file} <other-machine>:/tmp/mu.db` },
18032
+ { intent: "Ship the manifest too", command: `scp ${file}.manifest.json <other-machine>:/tmp/` },
18033
+ { intent: "Import on the other side", command: "mu db import /tmp/mu.db" }
18034
+ ];
18035
+ }
18036
+ function importNextSteps(result) {
18037
+ if (result.dryRun) {
18038
+ const hasConflict = result.summary.some((s) => s.decision === "CONFLICT");
18039
+ const hasStale = result.summary.some((s) => s.decision === "LOCAL_AHEAD");
18040
+ return [
18041
+ ...hasStale ? [{ intent: "Source is stale", command: "re-export from this machine before importing" }] : [],
18042
+ ...hasConflict ? [
18043
+ {
18044
+ intent: "Clobber source after parking local",
18045
+ command: `mu db import ${result.sourceFile} --apply --force-source`
18046
+ }
18047
+ ] : [],
18048
+ ...!hasConflict && !hasStale ? [{ intent: "Apply this plan", command: `mu db import ${result.sourceFile} --apply` }] : []
18049
+ ];
18050
+ }
18051
+ return [
18052
+ { intent: "Undo if needed", command: "mu undo --yes" },
18053
+ { intent: "Inspect workstreams", command: "mu state --json" }
18054
+ ];
18055
+ }
18056
+ function replayNextSteps(result) {
18057
+ if (result.dryRun) {
18058
+ const firstTask = result.tasks[0]?.localId;
18059
+ return [
18060
+ ...firstTask ? [
18061
+ {
18062
+ intent: "Replay one parked task",
18063
+ command: `mu db replay ${result.sourceFile} --task ${firstTask} --apply`
18064
+ }
18065
+ ] : [],
18066
+ {
18067
+ intent: "Replay all parked rows",
18068
+ command: `mu db replay ${result.sourceFile} --all --apply`
18069
+ }
18070
+ ];
18071
+ }
18072
+ return [
18073
+ { intent: "Undo if needed", command: "mu undo --yes" },
18074
+ { intent: "Inspect the workstream", command: `mu task list -w ${result.workstream}` }
18075
+ ];
18076
+ }
18077
+ async function cmdDbExport(db, file, opts = {}) {
18078
+ const result = exportDb(db, file, opts);
18079
+ const nextSteps = exportNextSteps(result.file);
18080
+ if (opts.json) {
18081
+ emitJson({ ...result, nextSteps });
18082
+ return;
18083
+ }
18084
+ console.log(
18085
+ `Exported whole mu DB \u2192 ${pc.bold(result.file)} ${pc.dim(
18086
+ `(schema=v${result.manifest.schemaVersion}, workstreams=${result.manifest.workstreams.length}, manifest=${result.manifestPath})`
18087
+ )}`
18088
+ );
18089
+ if (result.overwritten) console.log(pc.dim("Overwrote existing target due to --force."));
18090
+ printNextSteps(nextSteps);
18091
+ }
18092
+ async function cmdDbImport(db, file, opts = {}) {
18093
+ const result = importDb(db, file, {
18094
+ apply: opts.apply,
18095
+ forceSource: opts.forceSource,
18096
+ onlyWorkstreams: opts.onlyWs
18097
+ });
18098
+ const nextSteps = importNextSteps(result);
18099
+ if (opts.json) {
18100
+ emitJson({ ...result, nextSteps });
18101
+ return;
18102
+ }
18103
+ console.log(
18104
+ `${result.dryRun ? "Dry-run" : "Applied"} DB import from ${pc.bold(result.sourceFile)} ${pc.dim(
18105
+ `(source machine=${result.machineId})`
18106
+ )}`
18107
+ );
18108
+ if (result.snapshotId !== void 0) {
18109
+ console.log(pc.dim(`Safety snapshot #${result.snapshotId} captured before import.`));
18110
+ }
18111
+ console.log(renderImportSummary(result.summary));
18112
+ printNextSteps(nextSteps);
18113
+ }
18114
+ function renderImportSummary(summary) {
18115
+ const table = muTable({
18116
+ head: ["workstream", "decision", "source_seq", "local_seq", "last_synced", "needs"]
18117
+ });
18118
+ for (const item of summary) {
18119
+ const delta = item.delta;
18120
+ table.push([
18121
+ item.workstream,
18122
+ item.decision,
18123
+ String(delta.sourceSeq ?? ""),
18124
+ String(delta.localSeq ?? ""),
18125
+ String(delta.lastSynced ?? ""),
18126
+ item.parkPath ?? item.needs ?? ""
18127
+ ]);
18128
+ }
18129
+ return table.toString();
18130
+ }
18131
+ async function cmdDbReplay(db, file, opts = {}) {
18132
+ const result = replayDb(db, file, {
18133
+ apply: opts.apply,
18134
+ tasks: opts.task,
18135
+ notes: opts.note,
18136
+ all: opts.all
18137
+ });
18138
+ const nextSteps = replayNextSteps(result);
18139
+ if (opts.json) {
18140
+ emitJson({ ...result, nextSteps });
18141
+ return;
18142
+ }
18143
+ console.log(
18144
+ `${result.dryRun ? "Dry-run" : "Applied"} DB replay from ${pc.bold(result.sourceFile)} ${pc.dim(
18145
+ `(workstream=${result.workstream})`
18146
+ )}`
18147
+ );
18148
+ if (result.snapshotId !== void 0) {
18149
+ console.log(pc.dim(`Safety snapshot #${result.snapshotId} captured before replay.`));
18150
+ }
18151
+ console.log(renderReplaySummary(result));
18152
+ for (const warning of result.warnings) console.warn(pc.yellow(`warning: ${warning}`));
18153
+ printNextSteps(nextSteps);
18154
+ }
18155
+ function renderReplaySummary(result) {
18156
+ const table = muTable({ head: ["kind", "count", "details"] });
18157
+ table.push([
18158
+ "tasks",
18159
+ String(result.tasks.length),
18160
+ result.tasks.map((t) => `${t.localId} (${t.status})`).join(", ")
18161
+ ]);
18162
+ table.push([
18163
+ "notes",
18164
+ String(result.notes.length),
18165
+ result.notes.map((n) => `${n.taskLocalId}@${n.createdAt}`).join(", ")
18166
+ ]);
18167
+ table.push([
18168
+ "edges",
18169
+ String(result.edges.length),
18170
+ result.edges.map((e) => `${e.fromLocalId}->${e.toLocalId}`).join(", ")
18171
+ ]);
18172
+ table.push([
18173
+ "conflicts",
18174
+ String(result.conflicts.length),
18175
+ result.conflicts.map(
18176
+ (c) => `${c.localId}: local=${c.local.status}/${c.local.title}; sidecar=${c.sidecar.status}/${c.sidecar.title}`
18177
+ ).join(", ")
18178
+ ]);
18179
+ if (!result.dryRun) {
18180
+ table.push([
18181
+ "added",
18182
+ String(result.added.tasks + result.added.notes + result.added.edges),
18183
+ `tasks=${result.added.tasks}, notes=${result.added.notes}, edges=${result.added.edges}`
18184
+ ]);
18185
+ }
18186
+ return table.toString();
18187
+ }
18188
+ function collectOnlyWs(value, previous = []) {
18189
+ return collectRepeatedCsv(value, previous);
18190
+ }
18191
+ function collectRepeatedCsv(value, previous = []) {
18192
+ return [
18193
+ ...previous,
18194
+ ...value.split(",").map((v) => v.trim()).filter((v) => v.length > 0)
18195
+ ];
18196
+ }
18197
+ function wireDbCommands(program) {
18198
+ const db = program.command("db").description("Whole-machine DB sync commands");
18199
+ db.command("export <file>").description(
18200
+ "Export the entire mu SQLite DB to <file> via VACUUM INTO and write <file>.manifest.json. Whole-machine by design; no --workstream flag."
18201
+ ).option("--force", "overwrite an existing target file").option(...JSON_OPT).action(function(file) {
18202
+ const opts = this.opts();
18203
+ return handle((dbHandle) => cmdDbExport(dbHandle, file, opts), this)();
18204
+ });
18205
+ db.command("import <file>").description(
18206
+ "Import an exported mu DB with per-workstream drift detection. Dry-run by default; pass --apply to commit. Use --force-source to clobber conflicts after parking local divergence."
18207
+ ).option("--apply", "actually apply the import plan (default is dry-run)").option(
18208
+ "--only-ws <names>",
18209
+ "restrict to workstream names; repeat or comma-separate",
18210
+ collectOnlyWs,
18211
+ []
18212
+ ).option("--force-source", "on conflict, park local divergence then replace from source").option(...JSON_OPT).action(function(file) {
18213
+ const opts = this.opts();
18214
+ return handle((dbHandle) => cmdDbImport(dbHandle, file, opts), this)();
18215
+ });
18216
+ db.command("replay <sidecar-file>").description(
18217
+ "Manually cherry-pick tasks, notes, and eligible edges from a divergence sidecar parked by mu db import --force-source. Dry-run by default; pass --apply to write."
18218
+ ).option("--apply", "actually apply the replay selection (default is dry-run)").option(
18219
+ "--task <id>",
18220
+ "replay a missing task plus its notes and eligible edges; repeat or comma-separate",
18221
+ collectRepeatedCsv,
18222
+ []
18223
+ ).option(
18224
+ "--note <task-id>",
18225
+ "replay missing notes for a task; repeat or comma-separate",
18226
+ collectRepeatedCsv,
18227
+ []
18228
+ ).option("--all", "replay every missing local-only item from the sidecar").option(...JSON_OPT).action(function(file) {
18229
+ const opts = this.opts();
18230
+ return handle((dbHandle) => cmdDbReplay(dbHandle, file, opts), this)();
18231
+ });
18232
+ }
18233
+
18234
+ // src/cli/doctor.ts
18235
+ init_agents();
18236
+ init_db();
18237
+ init_output();
18238
+ init_tmux();
18239
+ init_workstream();
18240
+ async function cmdDoctor(db, opts = {}) {
18241
+ if (opts.json) {
18242
+ return cmdDoctorJson(db);
18243
+ }
18244
+ console.log(pc.bold("mu doctor"));
18245
+ console.log(pc.bold("\nenvironment"));
18246
+ try {
18247
+ const version = (await tmux(["-V"])).trim();
18248
+ console.log(` tmux : ${pc.green("ok")} (${version})`);
18249
+ } catch {
18250
+ console.log(` tmux : ${pc.red("NOT FOUND")} \u2014 install tmux \u2265 3.0`);
18251
+ }
18252
+ console.log(` $TMUX : ${process.env.TMUX ? pc.green("set") : pc.yellow("not set")}`);
18253
+ console.log(
18254
+ ` $TMUX_PANE : ${process.env.TMUX_PANE ? pc.green(process.env.TMUX_PANE) : pc.dim("not set")}`
18255
+ );
16717
18256
  console.log(
16718
18257
  ` $MU_SESSION : ${process.env.MU_SESSION ? pc.green(process.env.MU_SESSION) : pc.dim("not set")}`
16719
18258
  );
@@ -16793,7 +18332,10 @@ state (workstream=${ws})`));
16793
18332
  console.log(` agent_logs rows : ${counts.logs}`);
16794
18333
  try {
16795
18334
  const view = await listLiveAgents(db, { workstream: ws, mode: "report-only" });
16796
- const ghostNote = view.report.prunedGhosts > 0 ? pc.yellow(`pruned ${view.report.prunedGhosts} during this check`) : pc.green("none");
18335
+ const ghosts = view.report.prunedGhosts;
18336
+ const ghostNote = ghosts > 0 ? pc.yellow(
18337
+ `${ghosts} ghost pane${ghosts === 1 ? "" : "s"} would be reaped by \`mu state\` or \`mu agent list\``
18338
+ ) : pc.green("none");
16797
18339
  console.log(` ghosts : ${ghostNote}`);
16798
18340
  const orphanColor = view.orphans.length > 0 ? pc.yellow : pc.green;
16799
18341
  console.log(
@@ -16933,553 +18475,8 @@ function wireDoctorCommand(program) {
16933
18475
  // src/cli/handle.ts
16934
18476
  init_agents();
16935
18477
  init_archives();
16936
- init_db();
16937
18478
  import { CommanderError } from "commander";
16938
-
16939
- // src/importing.ts
16940
18479
  init_db();
16941
- init_exporting();
16942
- init_logs();
16943
- init_status();
16944
- init_workstream();
16945
- import { existsSync as existsSync12, readFileSync as readFileSync2, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
16946
- import { basename as basename2, dirname as dirname5, join as join8 } from "path";
16947
- var ImportBucketInvalidError = class extends Error {
16948
- constructor(bucketDir, reason) {
16949
- super(`not a valid mu bucket export at ${bucketDir}: ${reason}`);
16950
- this.bucketDir = bucketDir;
16951
- this.reason = reason;
16952
- }
16953
- bucketDir;
16954
- reason;
16955
- name = "ImportBucketInvalidError";
16956
- errorNextSteps() {
16957
- return [
16958
- { intent: "List the directory's contents", command: `ls ${this.bucketDir}` },
16959
- {
16960
- intent: "Inspect the manifest (must be bucketVersion 2)",
16961
- command: `cat ${this.bucketDir}/manifest.json`
16962
- }
16963
- ];
16964
- }
16965
- };
16966
- var ImportSourceNotInBucketError = class extends Error {
16967
- constructor(bucketDir, badName, validNames) {
16968
- super(
16969
- `--source-ws "${badName}" is not a source-ws in bucket ${bucketDir}; valid: ${validNames.length === 0 ? "<none>" : validNames.join(", ")}`
16970
- );
16971
- this.bucketDir = bucketDir;
16972
- this.badName = badName;
16973
- this.validNames = validNames;
16974
- }
16975
- bucketDir;
16976
- badName;
16977
- validNames;
16978
- name = "ImportSourceNotInBucketError";
16979
- errorNextSteps() {
16980
- return [
16981
- { intent: "List the bucket's source-ws subdirs", command: `ls ${this.bucketDir}` },
16982
- {
16983
- intent: "Inspect the bucket manifest's sources map",
16984
- command: `cat ${this.bucketDir}/manifest.json | head -40`
16985
- }
16986
- ];
16987
- }
16988
- };
16989
- var WorkstreamAlreadyExistsError = class extends Error {
16990
- constructor(workstream) {
16991
- super(
16992
- `workstream "${workstream}" already exists in the DB; mu workstream import refuses to merge silently. Pass --workstream <new-name> to import under a different name (single-source buckets only), or destroy the existing workstream first.`
16993
- );
16994
- this.workstream = workstream;
16995
- }
16996
- workstream;
16997
- name = "WorkstreamAlreadyExistsError";
16998
- errorNextSteps() {
16999
- return [
17000
- {
17001
- intent: "Import under a new name (single-source bucket only)",
17002
- command: "mu workstream import <bucket> --workstream <new-name>"
17003
- },
17004
- {
17005
- intent: "Or destroy the existing workstream first",
17006
- command: `mu workstream destroy -w ${this.workstream} --yes`
17007
- }
17008
- ];
17009
- }
17010
- };
17011
- var ImportFrontmatterParseError = class extends Error {
17012
- constructor(path2, line, raw) {
17013
- super(`failed to parse frontmatter at ${path2}:${line}: ${raw}`);
17014
- this.path = path2;
17015
- this.line = line;
17016
- this.raw = raw;
17017
- }
17018
- path;
17019
- line;
17020
- raw;
17021
- name = "ImportFrontmatterParseError";
17022
- errorNextSteps() {
17023
- return [{ intent: "Inspect the offending file", command: `sed -n 1,30p ${this.path}` }];
17024
- }
17025
- };
17026
- var ImportEdgeRefMissingError = class extends Error {
17027
- constructor(fromTask, toTask, direction) {
17028
- super(
17029
- `task "${fromTask}" references "${toTask}" via ${direction}, but no task with that id was found in the import`
17030
- );
17031
- this.fromTask = fromTask;
17032
- this.toTask = toTask;
17033
- this.direction = direction;
17034
- }
17035
- fromTask;
17036
- toTask;
17037
- direction;
17038
- name = "ImportEdgeRefMissingError";
17039
- errorNextSteps() {
17040
- return [
17041
- {
17042
- intent: "Inspect the offending task file in the bucket",
17043
- command: `grep -l 'id: "${this.fromTask}"' <bucket>/*/tasks/`
17044
- }
17045
- ];
17046
- }
17047
- };
17048
- function unquote(raw) {
17049
- const trimmed = raw.trim();
17050
- if (trimmed === "null") return null;
17051
- if (trimmed.length < 2 || trimmed[0] !== '"' || trimmed[trimmed.length - 1] !== '"') {
17052
- return raw.trim();
17053
- }
17054
- const inner = trimmed.slice(1, -1);
17055
- let out = "";
17056
- for (let i = 0; i < inner.length; i++) {
17057
- const ch = inner[i];
17058
- if (ch === "\\" && i + 1 < inner.length) {
17059
- const next = inner[i + 1];
17060
- if (next === '"' || next === "\\") {
17061
- out += next;
17062
- i += 1;
17063
- continue;
17064
- }
17065
- }
17066
- if (ch !== void 0) out += ch;
17067
- }
17068
- return out;
17069
- }
17070
- function parseStringArray(raw) {
17071
- const trimmed = raw.trim();
17072
- if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
17073
- throw new Error(`expected [..] array, got ${JSON.stringify(raw)}`);
17074
- }
17075
- const inner = trimmed.slice(1, -1).trim();
17076
- if (inner === "") return [];
17077
- const out = [];
17078
- let i = 0;
17079
- while (i < inner.length) {
17080
- while (i < inner.length && (inner[i] === " " || inner[i] === ",")) i += 1;
17081
- if (i >= inner.length) break;
17082
- if (inner[i] !== '"') {
17083
- throw new Error(`expected quoted string in array, got ${JSON.stringify(inner.slice(i))}`);
17084
- }
17085
- let j = i + 1;
17086
- while (j < inner.length) {
17087
- if (inner[j] === "\\" && j + 1 < inner.length) {
17088
- j += 2;
17089
- continue;
17090
- }
17091
- if (inner[j] === '"') break;
17092
- j += 1;
17093
- }
17094
- if (j >= inner.length) {
17095
- throw new Error("unterminated quoted string in array");
17096
- }
17097
- const scalar = unquote(inner.slice(i, j + 1));
17098
- if (scalar === null) {
17099
- throw new Error("null is not a legal array element");
17100
- }
17101
- out.push(scalar);
17102
- i = j + 1;
17103
- }
17104
- return out;
17105
- }
17106
- function parseTaskMarkdown(path2) {
17107
- const raw = readFileSync2(path2, "utf8");
17108
- const lines = raw.split("\n");
17109
- if (lines[0] !== "---") {
17110
- throw new ImportFrontmatterParseError(path2, 1, lines[0] ?? "");
17111
- }
17112
- let end = -1;
17113
- for (let i2 = 1; i2 < lines.length; i2++) {
17114
- if (lines[i2] === "---") {
17115
- end = i2;
17116
- break;
17117
- }
17118
- }
17119
- if (end === -1) {
17120
- throw new ImportFrontmatterParseError(path2, 1, "missing closing '---' for frontmatter");
17121
- }
17122
- const fields = {};
17123
- for (let i2 = 1; i2 < end; i2++) {
17124
- const line = lines[i2] ?? "";
17125
- const colon = line.indexOf(":");
17126
- if (colon === -1) {
17127
- throw new ImportFrontmatterParseError(path2, i2 + 1, line);
17128
- }
17129
- const key = line.slice(0, colon).trim();
17130
- const value = line.slice(colon + 1).trim();
17131
- fields[key] = value;
17132
- }
17133
- function require2(key) {
17134
- const v = fields[key];
17135
- if (v === void 0) {
17136
- throw new ImportFrontmatterParseError(path2, 1, `missing frontmatter key: ${key}`);
17137
- }
17138
- return v;
17139
- }
17140
- const id = unquote(require2("id"));
17141
- if (id === null || id === "") {
17142
- throw new ImportFrontmatterParseError(path2, 1, "id must be a non-empty string");
17143
- }
17144
- const workstream = unquote(require2("workstream"));
17145
- if (workstream === null || workstream === "") {
17146
- throw new ImportFrontmatterParseError(path2, 1, "workstream must be a non-empty string");
17147
- }
17148
- const statusRaw = require2("status");
17149
- if (!isTaskStatus(statusRaw)) {
17150
- throw new ImportFrontmatterParseError(path2, 1, `unknown status: ${statusRaw}`);
17151
- }
17152
- const impact = Number(require2("impact"));
17153
- const effortDays = Number(require2("effort_days"));
17154
- if (!Number.isFinite(impact) || !Number.isFinite(effortDays)) {
17155
- throw new ImportFrontmatterParseError(path2, 1, "impact / effort_days must be numeric");
17156
- }
17157
- const ownerName = unquote(require2("owner"));
17158
- const createdAt = unquote(require2("created_at"));
17159
- const updatedAt = unquote(require2("updated_at"));
17160
- if (createdAt === null || updatedAt === null) {
17161
- throw new ImportFrontmatterParseError(path2, 1, "created_at / updated_at cannot be null");
17162
- }
17163
- let blockedBy;
17164
- let blocks;
17165
- try {
17166
- blockedBy = parseStringArray(require2("blocked_by"));
17167
- blocks = parseStringArray(require2("blocks"));
17168
- } catch (err) {
17169
- throw new ImportFrontmatterParseError(
17170
- path2,
17171
- 1,
17172
- err instanceof Error ? err.message : String(err)
17173
- );
17174
- }
17175
- let bodyIdx = end + 1;
17176
- while (bodyIdx < lines.length && lines[bodyIdx] === "") bodyIdx += 1;
17177
- const titleLine = lines[bodyIdx] ?? "";
17178
- if (!titleLine.startsWith("# ")) {
17179
- throw new ImportFrontmatterParseError(
17180
- path2,
17181
- bodyIdx + 1,
17182
- "expected '# <title>' after frontmatter"
17183
- );
17184
- }
17185
- const title = titleLine.slice(2).trim();
17186
- const notes = [];
17187
- let i = bodyIdx + 1;
17188
- while (i < lines.length && !(lines[i] ?? "").startsWith("## Notes (")) i += 1;
17189
- if (i < lines.length) {
17190
- i += 1;
17191
- while (i < lines.length) {
17192
- const line = lines[i] ?? "";
17193
- if (!line.startsWith("### #")) {
17194
- i += 1;
17195
- continue;
17196
- }
17197
- const headerRest = line.slice(line.indexOf(" by ") + 4);
17198
- const lastComma = headerRest.lastIndexOf(", ");
17199
- if (lastComma === -1) {
17200
- throw new ImportFrontmatterParseError(path2, i + 1, line);
17201
- }
17202
- const authorRaw = headerRest.slice(0, lastComma);
17203
- const author = unquote(authorRaw);
17204
- const createdAtNote = headerRest.slice(lastComma + 2);
17205
- i += 1;
17206
- while (i < lines.length && lines[i] === "") i += 1;
17207
- const openFence = lines[i] ?? "";
17208
- if (!/^`{3,}$/.test(openFence)) {
17209
- throw new ImportFrontmatterParseError(path2, i + 1, openFence);
17210
- }
17211
- const fence = openFence;
17212
- i += 1;
17213
- const contentLines = [];
17214
- while (i < lines.length && lines[i] !== fence) {
17215
- contentLines.push(lines[i] ?? "");
17216
- i += 1;
17217
- }
17218
- if (i >= lines.length) {
17219
- throw new ImportFrontmatterParseError(path2, i + 1, `unterminated note fence ${fence}`);
17220
- }
17221
- i += 1;
17222
- notes.push({ author, createdAt: createdAtNote, content: contentLines.join("\n") });
17223
- }
17224
- }
17225
- return {
17226
- id,
17227
- workstream,
17228
- status: statusRaw,
17229
- impact,
17230
- effortDays,
17231
- ownerName,
17232
- createdAt,
17233
- updatedAt,
17234
- blockedBy,
17235
- blocks,
17236
- title,
17237
- notes
17238
- };
17239
- }
17240
- function walkBucket(bucketDir) {
17241
- if (!existsSync12(bucketDir)) {
17242
- throw new ImportBucketInvalidError(bucketDir, "directory does not exist");
17243
- }
17244
- if (!statSync4(bucketDir).isDirectory()) {
17245
- throw new ImportBucketInvalidError(bucketDir, "not a directory");
17246
- }
17247
- const manifestPath = join8(bucketDir, "manifest.json");
17248
- const probe = readManifest(manifestPath);
17249
- if (probe.kind === "v2") {
17250
- const manifest = probe.manifest;
17251
- const sources = [];
17252
- for (const entry of readdirSync3(bucketDir, { withFileTypes: true })) {
17253
- if (!entry.isDirectory()) continue;
17254
- const sourceDir = join8(bucketDir, entry.name);
17255
- const tasksDir2 = join8(sourceDir, "tasks");
17256
- if (!existsSync12(tasksDir2) || !statSync4(tasksDir2).isDirectory()) continue;
17257
- const taskFiles2 = [];
17258
- for (const f of readdirSync3(tasksDir2, { withFileTypes: true })) {
17259
- if (f.isFile() && f.name.endsWith(".md")) {
17260
- taskFiles2.push(join8(tasksDir2, f.name));
17261
- }
17262
- }
17263
- taskFiles2.sort();
17264
- sources.push({ diskName: entry.name, taskFiles: taskFiles2 });
17265
- }
17266
- sources.sort((a, b) => a.diskName.localeCompare(b.diskName));
17267
- return { manifest, sources, shape: "bucket" };
17268
- }
17269
- if (probe.kind === "corrupt") {
17270
- throw new ImportBucketInvalidError(bucketDir, "manifest.json is unreadable / malformed");
17271
- }
17272
- const tasksDir = join8(bucketDir, "tasks");
17273
- const looksLikeSourceWs = existsSync12(join8(bucketDir, "README.md")) && existsSync12(join8(bucketDir, "INDEX.md")) && existsSync12(tasksDir) && statSync4(tasksDir).isDirectory();
17274
- if (!looksLikeSourceWs) {
17275
- throw new ImportBucketInvalidError(bucketDir, "manifest.json missing");
17276
- }
17277
- const parentDir = dirname5(bucketDir);
17278
- const baseName = basename2(bucketDir);
17279
- const parentProbe = readManifest(join8(parentDir, "manifest.json"));
17280
- if (parentProbe.kind !== "v2") {
17281
- throw new ImportBucketInvalidError(
17282
- bucketDir,
17283
- `${bucketDir} looks like a per-source-ws subdir (README.md + INDEX.md + tasks/), but ${parentDir}/manifest.json is missing or not a bucketVersion 2 manifest. Pass the parent bucket directory instead, or re-export.`
17284
- );
17285
- }
17286
- const parentManifest = parentProbe.manifest;
17287
- if (!Object.prototype.hasOwnProperty.call(parentManifest.sources ?? {}, baseName)) {
17288
- throw new ImportBucketInvalidError(
17289
- bucketDir,
17290
- `${bucketDir} looks like a per-source-ws subdir but "${baseName}" is not in the parent bucket manifest's sources (${parentDir}/manifest.json). Re-export to refresh the bucket manifest.`
17291
- );
17292
- }
17293
- const taskFiles = [];
17294
- for (const f of readdirSync3(tasksDir, { withFileTypes: true })) {
17295
- if (f.isFile() && f.name.endsWith(".md")) {
17296
- taskFiles.push(join8(tasksDir, f.name));
17297
- }
17298
- }
17299
- taskFiles.sort();
17300
- return {
17301
- manifest: parentManifest,
17302
- sources: [{ diskName: baseName, taskFiles }],
17303
- shape: "sourceWsSubdir"
17304
- };
17305
- }
17306
- function importOneSource(db, targetWorkstream, parsed, options) {
17307
- const idSet = new Set(parsed.map((p) => p.id));
17308
- for (const t of parsed) {
17309
- for (const blocker of t.blockedBy) {
17310
- if (!idSet.has(blocker)) {
17311
- throw new ImportEdgeRefMissingError(t.id, blocker, "blocked_by");
17312
- }
17313
- }
17314
- for (const dep of t.blocks) {
17315
- if (!idSet.has(dep)) {
17316
- throw new ImportEdgeRefMissingError(t.id, dep, "blocks");
17317
- }
17318
- }
17319
- }
17320
- if (options.dryRun) {
17321
- const edgePairs = /* @__PURE__ */ new Set();
17322
- for (const t of parsed) {
17323
- for (const blocker of t.blockedBy) edgePairs.add(`${blocker}\0${t.id}`);
17324
- for (const dep of t.blocks) edgePairs.add(`${t.id}\0${dep}`);
17325
- }
17326
- const noteCount = parsed.reduce((acc, p) => acc + p.notes.length, 0);
17327
- return {
17328
- tasksImported: parsed.length,
17329
- edgesImported: edgePairs.size,
17330
- notesImported: noteCount
17331
- };
17332
- }
17333
- return db.transaction(() => {
17334
- ensureWorkstream(db, targetWorkstream);
17335
- const wsId = resolveWorkstreamId(db, targetWorkstream);
17336
- const insertTask = db.prepare(
17337
- `INSERT INTO tasks
17338
- (workstream_id, local_id, title, status, impact, effort_days, owner_id, created_at, updated_at)
17339
- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)`
17340
- );
17341
- const taskIdByLocalId = /* @__PURE__ */ new Map();
17342
- for (const t of parsed) {
17343
- const r = insertTask.run(
17344
- wsId,
17345
- t.id,
17346
- t.title,
17347
- t.status,
17348
- t.impact,
17349
- t.effortDays,
17350
- t.createdAt,
17351
- t.updatedAt
17352
- );
17353
- taskIdByLocalId.set(t.id, Number(r.lastInsertRowid));
17354
- }
17355
- const insertEdge = db.prepare(
17356
- "INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at) VALUES (?, ?, ?)"
17357
- );
17358
- const seenEdges = /* @__PURE__ */ new Set();
17359
- let edgesImported = 0;
17360
- const now = (/* @__PURE__ */ new Date()).toISOString();
17361
- const recordEdge = (fromLocal, toLocal) => {
17362
- const key = `${fromLocal}\0${toLocal}`;
17363
- if (seenEdges.has(key)) return;
17364
- seenEdges.add(key);
17365
- const fromId = taskIdByLocalId.get(fromLocal);
17366
- const toId = taskIdByLocalId.get(toLocal);
17367
- if (fromId === void 0 || toId === void 0) return;
17368
- const r = insertEdge.run(fromId, toId, now);
17369
- if (r.changes > 0) edgesImported += 1;
17370
- };
17371
- for (const t of parsed) {
17372
- for (const blocker of t.blockedBy) recordEdge(blocker, t.id);
17373
- for (const dep of t.blocks) recordEdge(t.id, dep);
17374
- }
17375
- const insertNote = db.prepare(
17376
- "INSERT INTO task_notes (task_id, author, content, created_at) VALUES (?, ?, ?, ?)"
17377
- );
17378
- let notesImported = 0;
17379
- for (const t of parsed) {
17380
- const taskId = taskIdByLocalId.get(t.id);
17381
- if (taskId === void 0) continue;
17382
- for (const note of t.notes) {
17383
- insertNote.run(taskId, note.author, note.content, note.createdAt);
17384
- notesImported += 1;
17385
- }
17386
- }
17387
- emitEvent(
17388
- db,
17389
- targetWorkstream,
17390
- `workstream import ${targetWorkstream} (tasks=${parsed.length}, edges=${edgesImported}, notes=${notesImported})`
17391
- );
17392
- return {
17393
- tasksImported: parsed.length,
17394
- edgesImported,
17395
- notesImported
17396
- };
17397
- })();
17398
- }
17399
- function importBucket(db, opts) {
17400
- const { manifest, sources, shape } = walkBucket(opts.bucketDir);
17401
- if (shape === "sourceWsSubdir" && opts.sourceWs !== void 0 && opts.sourceWs.length > 0) {
17402
- throw new ImportBucketInvalidError(
17403
- opts.bucketDir,
17404
- `cannot pass --source-ws when ${opts.bucketDir} is itself a source-ws subdir; drop the flag`
17405
- );
17406
- }
17407
- let filteredSources = sources;
17408
- if (opts.sourceWs !== void 0 && opts.sourceWs.length > 0) {
17409
- const validNames = Object.keys(manifest.sources ?? {}).sort();
17410
- const validSet = new Set(validNames);
17411
- for (const wanted of opts.sourceWs) {
17412
- if (!validSet.has(wanted)) {
17413
- throw new ImportSourceNotInBucketError(opts.bucketDir, wanted, validNames);
17414
- }
17415
- }
17416
- const wantedSet = new Set(opts.sourceWs);
17417
- filteredSources = sources.filter((s) => wantedSet.has(s.diskName));
17418
- }
17419
- if (opts.workstreamOverride !== void 0 && filteredSources.length !== 1) {
17420
- throw new ImportBucketInvalidError(
17421
- opts.bucketDir,
17422
- `--workstream override requires a single source-ws subdir; this bucket has ${filteredSources.length}`
17423
- );
17424
- }
17425
- const dryRun = opts.dryRun === true;
17426
- const results = [];
17427
- for (const source of filteredSources) {
17428
- const targetName = opts.workstreamOverride ?? source.diskName;
17429
- const result = {
17430
- workstreamName: targetName,
17431
- tasksImported: 0,
17432
- edgesImported: 0,
17433
- notesImported: 0,
17434
- tombstonesSkipped: 0,
17435
- errors: []
17436
- };
17437
- const liveFiles = [];
17438
- for (const file of source.taskFiles) {
17439
- const head = readFileSync2(file, "utf8").slice(0, DELETED_BANNER_PREFIX.length);
17440
- if (head.startsWith(DELETED_BANNER_PREFIX)) {
17441
- result.tombstonesSkipped += 1;
17442
- continue;
17443
- }
17444
- liveFiles.push(file);
17445
- }
17446
- if (!dryRun) {
17447
- const existing = db.prepare("SELECT 1 AS x FROM workstreams WHERE name = ?").get(targetName);
17448
- if (existing !== void 0) {
17449
- const err = new WorkstreamAlreadyExistsError(targetName);
17450
- result.errors.push(err.message);
17451
- results.push(result);
17452
- throw err;
17453
- }
17454
- }
17455
- let parsed;
17456
- try {
17457
- parsed = liveFiles.map(parseTaskMarkdown);
17458
- } catch (err) {
17459
- result.errors.push(err instanceof Error ? err.message : String(err));
17460
- results.push(result);
17461
- throw err;
17462
- }
17463
- try {
17464
- const counts = importOneSource(db, targetName, parsed, { dryRun });
17465
- result.tasksImported = counts.tasksImported;
17466
- result.edgesImported = counts.edgesImported;
17467
- result.notesImported = counts.notesImported;
17468
- } catch (err) {
17469
- result.errors.push(err instanceof Error ? err.message : String(err));
17470
- results.push(result);
17471
- throw err;
17472
- }
17473
- results.push(result);
17474
- }
17475
- return {
17476
- bucketLabel: manifest.bucketLabel,
17477
- bucketVersion: manifest.bucketVersion,
17478
- sources: results
17479
- };
17480
- }
17481
-
17482
- // src/cli/handle.ts
17483
18480
  init_output();
17484
18481
  init_snapshots();
17485
18482
  init_tasks();
@@ -17534,13 +18531,34 @@ var NameAmbiguousError = class extends Error {
17534
18531
  }
17535
18532
  };
17536
18533
  function classifyError(err) {
17537
- if (err instanceof UsageError || err instanceof WorkstreamNameInvalidError || err instanceof ArchiveLabelInvalidError || err instanceof ImportBucketInvalidError || err instanceof ImportFrontmatterParseError || err instanceof ImportEdgeRefMissingError || err instanceof PruneOptionsInvalidError) {
18534
+ if (err instanceof UsageError || err instanceof WorkstreamNameInvalidError || err instanceof ArchiveLabelInvalidError || err instanceof PruneOptionsInvalidError) {
17538
18535
  return { label: "error", exitCode: 2 };
17539
18536
  }
17540
18537
  if (err instanceof AgentNotFoundError || err instanceof TaskNotFoundError || err instanceof WorkstreamNotFoundError || err instanceof WorkspaceNotFoundError || err instanceof SnapshotNotFoundError || err instanceof ArchiveNotFoundError) {
17541
18538
  return { label: "not found", exitCode: 3 };
17542
18539
  }
17543
- if (err instanceof NameAmbiguousError || err instanceof AgentExistsError || err instanceof TaskExistsError || err instanceof TaskAlreadyOwnedError || err instanceof TaskClaimStaleWorkspaceError || err instanceof TaskNotInWorkstreamError || err instanceof AgentNotInWorkstreamError || err instanceof CycleError || err instanceof TaskHasOpenDependentsError || err instanceof CrossWorkstreamEdgeError || err instanceof WorkspaceExistsError || err instanceof WorkspacePathNotEmptyError || err instanceof WorkspacePreservedError || err instanceof HomeDirAsProjectRootError || err instanceof WorkspaceVcsRequiredError || err instanceof WorkspaceDirtyError || err instanceof ClaimerNotRegisteredError || err instanceof SnapshotVersionMismatchError || err instanceof SchemaTooOldError || err instanceof TaskIdInvalidError || err instanceof ArchiveAlreadyExistsError || err instanceof ImportSourceNotInBucketError || err instanceof WorkstreamAlreadyExistsError) {
18540
+ if (err instanceof DbImportManifestMissingError) {
18541
+ return { label: "db import manifest missing", exitCode: 8 };
18542
+ }
18543
+ if (err instanceof DbImportSchemaTooOldError) {
18544
+ return { label: "db import schema too old", exitCode: 9 };
18545
+ }
18546
+ if (err instanceof DbImportSchemaTooNewError) {
18547
+ return { label: "db import schema too new", exitCode: 10 };
18548
+ }
18549
+ if (err instanceof DbImportSourceStaleError) {
18550
+ return { label: "db import source stale", exitCode: 11 };
18551
+ }
18552
+ if (err instanceof DbImportConflictError) {
18553
+ return { label: "db import conflict", exitCode: 12 };
18554
+ }
18555
+ if (err instanceof DbReplayWorkstreamMissingError) {
18556
+ return { label: "db replay workstream missing", exitCode: 13 };
18557
+ }
18558
+ if (err instanceof DbReplayLocalIdConflictError) {
18559
+ return { label: "db replay local-id conflict", exitCode: 14 };
18560
+ }
18561
+ if (err instanceof NameAmbiguousError || err instanceof AgentExistsError || err instanceof TaskExistsError || err instanceof TaskAlreadyOwnedError || err instanceof TaskClaimStaleWorkspaceError || err instanceof TaskNotInWorkstreamError || err instanceof AgentNotInWorkstreamError || err instanceof CycleError || err instanceof TaskHasOpenDependentsError || err instanceof CrossWorkstreamEdgeError || err instanceof WorkspaceExistsError || err instanceof WorkspacePathNotEmptyError || err instanceof WorkspacePreservedError || err instanceof HomeDirAsProjectRootError || err instanceof WorkspaceVcsRequiredError || err instanceof WorkspaceDirtyError || err instanceof ClaimerNotRegisteredError || err instanceof SnapshotVersionMismatchError || err instanceof SchemaTooOldError || err instanceof TaskIdInvalidError || err instanceof ArchiveAlreadyExistsError || err instanceof ArchiveSourceAmbiguousError || err instanceof DbExportTargetExistsError || err instanceof WorkstreamExistsError) {
17544
18562
  return { label: "conflict", exitCode: 4 };
17545
18563
  }
17546
18564
  if (err instanceof AgentSpawnCliNotFoundError) {
@@ -17923,9 +18941,9 @@ async function cmdUndo(db, opts = {}) {
17923
18941
  wouldBePrunedGhosts: totalGhostsWouldBePruned,
17924
18942
  orphansSurfaced: totalOrphans,
17925
18943
  // Reconcile mode: "report-only" preserves the snapshot's
17926
- // restored rows verbatim. (Was `dryRun: true` before the
17927
- // status-only/report-only split — BREAKING for SDK consumers
17928
- // reading this field; see CHANGELOG.)
18944
+ // restored rows verbatim. (Was `dryRun: true` before named
18945
+ // modes — BREAKING for SDK consumers reading this field; see
18946
+ // CHANGELOG.)
17929
18947
  mode: "report-only",
17930
18948
  perWorkstream: reconcilePerWorkstream
17931
18949
  },
@@ -18394,7 +19412,7 @@ async function cmdSql(db, query, opts = {}) {
18394
19412
  }
18395
19413
  }
18396
19414
  function countTopLevelStatements(sql) {
18397
- let count = 0;
19415
+ let count2 = 0;
18398
19416
  let inSingle = false;
18399
19417
  let inDouble = false;
18400
19418
  let inLineComment = false;
@@ -18450,15 +19468,15 @@ function countTopLevelStatements(sql) {
18450
19468
  }
18451
19469
  if (c === ";") {
18452
19470
  if (sawNonWs) {
18453
- count++;
19471
+ count2++;
18454
19472
  sawNonWs = false;
18455
19473
  }
18456
19474
  continue;
18457
19475
  }
18458
19476
  if (c !== void 0 && /\S/.test(c)) sawNonWs = true;
18459
19477
  }
18460
- if (sawNonWs) count++;
18461
- return count;
19478
+ if (sawNonWs) count2++;
19479
+ return count2;
18462
19480
  }
18463
19481
  function formatCell(v) {
18464
19482
  if (v === null || v === void 0) return pc.dim("null");
@@ -19202,75 +20220,6 @@ async function cmdWorkstreamExport(db, opts) {
19202
20220
  );
19203
20221
  printNextSteps(nextSteps);
19204
20222
  }
19205
- async function cmdWorkstreamImport(db, bucketDir, opts) {
19206
- let sourceWs;
19207
- if (opts.sourceWs !== void 0) {
19208
- const canonical = parseCsvFlag(opts.sourceWs);
19209
- if (canonical.length === 0) {
19210
- throw new UsageError(
19211
- "--source-ws was passed but resolved to zero names (empty strings / commas only); pass at least one source-ws name or drop the flag"
19212
- );
19213
- }
19214
- sourceWs = canonical;
19215
- }
19216
- const result = importBucket(db, {
19217
- bucketDir,
19218
- workstreamOverride: opts.workstream,
19219
- sourceWs,
19220
- dryRun: opts.dryRun
19221
- });
19222
- const totalTasks = result.sources.reduce((acc, s) => acc + s.tasksImported, 0);
19223
- const totalEdges = result.sources.reduce((acc, s) => acc + s.edgesImported, 0);
19224
- const totalNotes = result.sources.reduce((acc, s) => acc + s.notesImported, 0);
19225
- const totalTombstones = result.sources.reduce((acc, s) => acc + s.tombstonesSkipped, 0);
19226
- const importedNames = result.sources.map((s) => s.workstreamName);
19227
- const nextSteps = [];
19228
- if (!opts.dryRun) {
19229
- for (const name of importedNames) {
19230
- nextSteps.push({
19231
- intent: `Inspect ${name}`,
19232
- command: `mu task tree -w ${name}`
19233
- });
19234
- }
19235
- nextSteps.push({
19236
- intent: "Re-export to verify the round trip is byte-stable",
19237
- command: `mu workstream export -w ${importedNames[0] ?? "<ws>"} --out <new-dir>`
19238
- });
19239
- } else {
19240
- const sourceWsFlag = sourceWs !== void 0 && sourceWs.length > 0 ? ` --source-ws ${sourceWs.join(",")}` : "";
19241
- nextSteps.push({
19242
- intent: "Run the import for real",
19243
- command: `mu workstream import ${bucketDir}${opts.workstream ? ` --workstream ${opts.workstream}` : ""}${sourceWsFlag}`
19244
- });
19245
- }
19246
- if (opts.json) {
19247
- emitJson({
19248
- ...result,
19249
- bucketDir,
19250
- dryRun: opts.dryRun === true,
19251
- totals: {
19252
- tasks: totalTasks,
19253
- edges: totalEdges,
19254
- notes: totalNotes,
19255
- tombstones: totalTombstones
19256
- },
19257
- nextSteps
19258
- });
19259
- return;
19260
- }
19261
- const verb = opts.dryRun ? "Would import" : "Imported";
19262
- console.log(
19263
- `${verb} ${pc.bold(String(result.sources.length))} source-ws from ${pc.bold(bucketDir)} ${pc.dim(
19264
- `(bucketVersion=${result.bucketVersion}${result.bucketLabel ? `, label=${result.bucketLabel}` : ""}; tasks=${totalTasks}, edges=${totalEdges}, notes=${totalNotes}, tombstones_skipped=${totalTombstones})`
19265
- )}`
19266
- );
19267
- for (const s of result.sources) {
19268
- console.log(
19269
- ` ${pc.bold(s.workstreamName)}: tasks=${s.tasksImported}, edges=${s.edgesImported}, notes=${s.notesImported}, tombstones=${s.tombstonesSkipped}`
19270
- );
19271
- }
19272
- printNextSteps(nextSteps);
19273
- }
19274
20223
  function autoExportDir(workstream) {
19275
20224
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
19276
20225
  return join9(defaultStateDir(), "exports", `${workstream}-${ts}`);
@@ -19595,18 +20544,6 @@ function wireWorkstreamCommands(program) {
19595
20544
  const opts = this.opts();
19596
20545
  return handle((db) => cmdDestroy(db, opts), this)();
19597
20546
  });
19598
- workstream.command("import <bucket-dir>").description(
19599
- "Rebuild a workstream (or a multi-source bucket of workstreams) from a v0.3 markdown export. Accepts EITHER a bucket directory (top-level manifest.json + per-source-ws subdirs) OR a single per-source-ws subdir (auto-detected via README.md + INDEX.md + tasks/, validated against the parent bucket's manifest). Per source-ws transactional: each source-ws is imported in its own SQLite transaction; siblings are unaffected by a sibling's failure. Refuses to merge silently into an existing workstream \u2014 pass --workstream <name> (single-source after any --source-ws filter only) or destroy the existing one first."
19600
- ).option(
19601
- "--workstream <name>",
19602
- "override the imported workstream's name (single-source after any --source-ws filter only)"
19603
- ).option(
19604
- "--source-ws <names...>",
19605
- "restrict the import to a subset of source-ws subdirs (repeat or comma-separate; or both)"
19606
- ).option("--dry-run", "walk + parse + validate; report what WOULD be created; no DB writes").option(...JSON_OPT).action(function(bucketDir) {
19607
- const opts = this.opts();
19608
- return handle((db) => cmdWorkstreamImport(db, bucketDir, opts), this)();
19609
- });
19610
20547
  workstream.command("export").description(
19611
20548
  "Render a workstream's task graph + notes to a bucket directory of markdown. Bucket layout: <out>/README.md + INDEX.md + manifest.json (bucketVersion 2) + <ws>/{README.md,INDEX.md,tasks/<id>.md}. Idempotent + additive: re-export refreshes only changed task files; passing -w with a different workstream into the same --out appends a sibling source-ws subdir; deleted tasks are preserved with a banner. Pre-0.3 export dirs are not migrated in place."
19612
20549
  ).option(...WORKSTREAM_OPT).option("--out <dir>", "output directory (the bucket; defaults to ./<workstream>/)").option(...JSON_OPT).action(function() {
@@ -19857,9 +20794,9 @@ async function cmdBareTui(db, program, requestedWorkstreams) {
19857
20794
  throw err;
19858
20795
  }
19859
20796
  }
19860
- function readPackageVersion() {
20797
+ function readPackageVersion2() {
19861
20798
  try {
19862
- const here = dirname7(fileURLToPath2(import.meta.url));
20799
+ const here = dirname7(fileURLToPath3(import.meta.url));
19863
20800
  const pkgPath = join10(here, "..", "package.json");
19864
20801
  const raw = readFileSync3(pkgPath, "utf8");
19865
20802
  const parsed = JSON.parse(raw);
@@ -19872,7 +20809,7 @@ function buildProgram() {
19872
20809
  const program = new Command();
19873
20810
  program.name("mu").description(
19874
20811
  "Persistent crew of AI agents in tmux panes coordinated through a built-in task DAG."
19875
- ).version(readPackageVersion()).helpOption("-h, --help").configureHelp({ sortSubcommands: true }).enablePositionalOptions().option(
20812
+ ).version(readPackageVersion2()).helpOption("-h, --help").configureHelp({ sortSubcommands: true }).enablePositionalOptions().option(
19876
20813
  "-w, --workstream <names...>",
19877
20814
  "workstream(s) to render (repeat or comma-separate; or both; defaults to $MU_SESSION or current tmux session)"
19878
20815
  ).option(...JSON_OPT).action(function() {
@@ -19894,13 +20831,15 @@ function buildProgram() {
19894
20831
  wireStateCommands(program);
19895
20832
  wireSqlCommand(program);
19896
20833
  wireSnapshotCommands(program);
20834
+ wireDbCommands(program);
19897
20835
  wireDoctorCommand(program);
19898
20836
  applyAlphabeticalHelpSort(program);
19899
20837
  applyExitOverride(program);
19900
20838
  return program;
19901
20839
  }
19902
20840
  function applyAlphabeticalHelpSort(cmd) {
19903
- cmd.configureHelp({ ...cmd.configureHelp(), sortSubcommands: true });
20841
+ const keepSemanticOrder = cmd.name() === "archive";
20842
+ cmd.configureHelp({ ...cmd.configureHelp(), sortSubcommands: !keepSemanticOrder });
19904
20843
  for (const sub of cmd.commands) {
19905
20844
  applyAlphabeticalHelpSort(sub);
19906
20845
  }