@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/index.js CHANGED
@@ -5,8 +5,9 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // src/db.ts
8
+ import { randomUUID } from "crypto";
8
9
  import { mkdirSync } from "fs";
9
- import { homedir } from "os";
10
+ import { homedir, hostname } from "os";
10
11
  import { dirname, join, resolve } from "path";
11
12
  import Database from "better-sqlite3";
12
13
  function defaultStateDir() {
@@ -35,6 +36,7 @@ function openDb(options = {}) {
35
36
  throw new SchemaTooOldError(detectedVersion, MIN_ACCEPTED_SCHEMA_VERSION);
36
37
  }
37
38
  applySchema(db);
39
+ seedMachineIdentity(db);
38
40
  } else {
39
41
  db.pragma("foreign_keys = ON");
40
42
  }
@@ -120,6 +122,14 @@ function detectExistingSchemaVersion(db) {
120
122
  if (hasWorkstreams) return 1;
121
123
  return null;
122
124
  }
125
+ function seedMachineIdentity(db) {
126
+ const row = db.prepare("SELECT COUNT(*) AS count FROM machine_identity").get();
127
+ if (row.count !== 0) return;
128
+ db.prepare(
129
+ `INSERT OR IGNORE INTO machine_identity (id, machine_id, hostname, created_at)
130
+ VALUES (1, ?, ?, ?)`
131
+ ).run(randomUUID(), hostname(), (/* @__PURE__ */ new Date()).toISOString());
132
+ }
123
133
  function applySchema(db) {
124
134
  const preBumpVersion = detectExistingSchemaVersion(db);
125
135
  db.exec(CURRENT_SCHEMA);
@@ -136,7 +146,7 @@ function applySchema(db) {
136
146
  CURRENT_SCHEMA_VERSION
137
147
  );
138
148
  }
139
- var CURRENT_SCHEMA_VERSION = 7;
149
+ var CURRENT_SCHEMA_VERSION = 8;
140
150
  var MIN_ACCEPTED_SCHEMA_VERSION = 5;
141
151
  var EXPECTED_TABLES = [
142
152
  "agent_logs",
@@ -146,12 +156,14 @@ var EXPECTED_TABLES = [
146
156
  "archived_notes",
147
157
  "archived_tasks",
148
158
  "archives",
159
+ "machine_identity",
149
160
  "schema_version",
150
161
  "snapshots",
151
162
  "task_edges",
152
163
  "task_notes",
153
164
  "tasks",
154
165
  "vcs_workspaces",
166
+ "workstream_sync",
155
167
  "workstreams"
156
168
  ];
157
169
  var READY_VIEW_SQL = `
@@ -203,6 +215,15 @@ CREATE TABLE IF NOT EXISTS schema_version (
203
215
  version INTEGER NOT NULL
204
216
  );
205
217
 
218
+ -- machine_identity: one durable identity per DB/machine, seeded by
219
+ -- openDb after schema creation. hostname is advisory only.
220
+ CREATE TABLE IF NOT EXISTS machine_identity (
221
+ id INTEGER PRIMARY KEY CHECK (id = 1),
222
+ machine_id TEXT NOT NULL,
223
+ hostname TEXT,
224
+ created_at TEXT NOT NULL
225
+ );
226
+
206
227
  -- \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
207
228
 
208
229
  -- workstreams: top of the hierarchy. name stays globally unique
@@ -214,6 +235,13 @@ CREATE TABLE IF NOT EXISTS workstreams (
214
235
  created_at TEXT NOT NULL -- ISO 8601
215
236
  );
216
237
 
238
+ -- workstream_sync: per-workstream cross-machine drift state. Rows are
239
+ -- created on demand by db import/export code, not pre-seeded.
240
+ CREATE TABLE IF NOT EXISTS workstream_sync (
241
+ workstream_id INTEGER PRIMARY KEY REFERENCES workstreams (id) ON DELETE CASCADE,
242
+ last_known_peer_seqs TEXT NOT NULL DEFAULT '{}'
243
+ );
244
+
217
245
  -- agents: one row per managed pane. Per-workstream unique on name.
218
246
  CREATE TABLE IF NOT EXISTS agents (
219
247
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -503,8 +531,8 @@ function listLogs(db, opts = {}) {
503
531
  const rows = db.prepare(sql).all(...params);
504
532
  return rows.map(rowFromDb);
505
533
  }
506
- function latestSeq(db) {
507
- const row = db.prepare("SELECT MAX(seq) AS s FROM agent_logs").get();
534
+ function latestSeq(db, workstreamId) {
535
+ const row = 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);
508
536
  return row.s ?? 0;
509
537
  }
510
538
  function emitEvent(db, workstream, payload, source = "system") {
@@ -565,17 +593,22 @@ var EVENT_VERB_PREFIXES = [
565
593
  "workstream init",
566
594
  "workstream destroy",
567
595
  "workstream export",
568
- "workstream import",
569
596
  // src/archives.ts — v6 archive verbs. Machine-wide events
570
597
  // (workstream=null) because archives outlive workstreams.
571
598
  "archive create",
572
599
  "archive delete",
573
600
  "archive add",
574
601
  "archive remove",
602
+ "archive restore",
575
603
  // src/exporting.ts — archive export emits the bucket-render summary
576
604
  // as a machine-wide event (workstream=null; the export spans every
577
605
  // source-ws in the archive).
578
- "archive export"
606
+ "archive export",
607
+ // src/db-sync.ts — emitted per-workstream after a successful
608
+ // `mu db export`. Used as the marker for src/parked.ts (the
609
+ // "presumed parked on another machine" heuristic for `mu workstream
610
+ // list` / TUI tab strip).
611
+ "db export"
579
612
  ];
580
613
  function classifyEventVerb(payload) {
581
614
  for (const verb of EVENT_VERB_PREFIXES) {
@@ -1093,17 +1126,19 @@ async function reconcile(db, opts) {
1093
1126
  let statusChanges = 0;
1094
1127
  const orphans = [];
1095
1128
  const survivors = [];
1129
+ const pendingSurvivors = [];
1096
1130
  for (const agent of dbAgents) {
1097
- if (tmuxByPaneId.has(agent.paneId)) {
1131
+ if (isPendingPaneId(agent.paneId)) {
1132
+ pendingSurvivors.push(agent);
1133
+ } else if (tmuxByPaneId.has(agent.paneId)) {
1098
1134
  survivors.push(agent);
1099
1135
  } else {
1100
1136
  if (mode === "full") deleteAgent(db, agent.name, agent.workstreamName);
1101
1137
  prunedGhosts++;
1102
1138
  }
1103
1139
  }
1104
- if (mode !== "report-only") {
1140
+ if (mode === "full") {
1105
1141
  for (const agent of survivors) {
1106
- if (isPendingPaneId(agent.paneId)) continue;
1107
1142
  const scrollback = await capturePane(agent.paneId, { lines: 100 });
1108
1143
  const detected = detectPiStatus(scrollback);
1109
1144
  if (shouldOverwriteAgentStatus(agent.status, detected) && detected !== agent.status) {
@@ -2236,6 +2271,10 @@ var WorkspacePreservedError = class extends Error {
2236
2271
  intent: "Or close + discard the workspace in one shot (lossy)",
2237
2272
  command: `mu agent close ${this.agentName} --discard-workspace`
2238
2273
  },
2274
+ {
2275
+ intent: "If the workstream was archived, restore task memory under a fresh name",
2276
+ command: "mu archive restore <label> --as <new-workstream> --source <workstream>"
2277
+ },
2239
2278
  {
2240
2279
  intent: "Or just inspect what's in the workspace",
2241
2280
  command: `cd ${this.workspacePath}`
@@ -4568,6 +4607,106 @@ function searchArchives(db, opts) {
4568
4607
  return hits.slice(0, limit);
4569
4608
  }
4570
4609
 
4610
+ // src/archives/restore.ts
4611
+ var ArchiveSourceAmbiguousError = class extends Error {
4612
+ constructor(label, sources) {
4613
+ super(
4614
+ sources.length === 0 ? `archive ${label} contains no source workstreams` : `archive ${label} requires --source <orig-ws-name>. Available: ${sources.join(", ")}`
4615
+ );
4616
+ this.label = label;
4617
+ this.sources = sources;
4618
+ }
4619
+ label;
4620
+ sources;
4621
+ name = "ArchiveSourceAmbiguousError";
4622
+ errorNextSteps() {
4623
+ return [
4624
+ { intent: "Inspect archive sources", command: `mu archive show ${this.label}` },
4625
+ ...this.sources.map((source) => ({
4626
+ intent: `Restore source workstream ${source}`,
4627
+ command: `mu archive restore ${this.label} --source ${source} --as <new-workstream>`
4628
+ }))
4629
+ ];
4630
+ }
4631
+ };
4632
+ function restoreArchive(db, label, asWorkstream, opts = {}) {
4633
+ const archiveId = resolveArchiveId(db, label);
4634
+ const sources = listSources(db, archiveId);
4635
+ if (!isValidWorkstreamName(asWorkstream)) throw new WorkstreamNameInvalidError(asWorkstream);
4636
+ const sourceWorkstream = opts.sourceWorkstream ?? sources[0];
4637
+ if (sourceWorkstream === void 0) throw new ArchiveSourceAmbiguousError(label, sources);
4638
+ if (opts.sourceWorkstream === void 0 && sources.length > 1) {
4639
+ throw new ArchiveSourceAmbiguousError(label, sources);
4640
+ }
4641
+ if (opts.sourceWorkstream !== void 0 && !sources.includes(opts.sourceWorkstream)) {
4642
+ throw new ArchiveSourceAmbiguousError(label, sources);
4643
+ }
4644
+ if (tryResolveWorkstreamId(db, asWorkstream) !== null) {
4645
+ throw new WorkstreamExistsError(asWorkstream);
4646
+ }
4647
+ captureSnapshot(db, `archive restore ${label} as ${asWorkstream}`, null);
4648
+ return db.transaction(() => {
4649
+ ensureWorkstream(db, asWorkstream);
4650
+ const wsId = resolveWorkstreamId(db, asWorkstream);
4651
+ const restoredTasks = db.prepare(
4652
+ `INSERT INTO tasks
4653
+ (workstream_id, local_id, title, status, impact, effort_days, owner_id, created_at, updated_at)
4654
+ SELECT ?, original_local_id, title, status, impact, effort_days, NULL,
4655
+ original_created_at, original_updated_at
4656
+ FROM archived_tasks
4657
+ WHERE archive_id = ? AND source_workstream = ?
4658
+ ORDER BY id`
4659
+ ).run(wsId, archiveId, sourceWorkstream).changes;
4660
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4661
+ const restoredEdges = db.prepare(
4662
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
4663
+ SELECT live_from.id, live_to.id, ?
4664
+ FROM archived_edges e
4665
+ JOIN archived_tasks arch_from ON arch_from.id = e.from_archived_id
4666
+ JOIN archived_tasks arch_to ON arch_to.id = e.to_archived_id
4667
+ JOIN tasks live_from ON live_from.workstream_id = ?
4668
+ AND live_from.local_id = arch_from.original_local_id
4669
+ JOIN tasks live_to ON live_to.workstream_id = ?
4670
+ AND live_to.local_id = arch_to.original_local_id
4671
+ WHERE e.archive_id = ?
4672
+ AND arch_from.source_workstream = ?
4673
+ AND arch_to.source_workstream = ?`
4674
+ ).run(now, wsId, wsId, archiveId, sourceWorkstream, sourceWorkstream).changes;
4675
+ const restoredNotes = db.prepare(
4676
+ `INSERT INTO task_notes (task_id, author, content, created_at)
4677
+ SELECT live.id, n.author, n.content, n.created_at
4678
+ FROM archived_notes n
4679
+ JOIN archived_tasks arch ON arch.id = n.archived_task_id
4680
+ JOIN tasks live ON live.workstream_id = ?
4681
+ AND live.local_id = arch.original_local_id
4682
+ WHERE n.archive_id = ? AND arch.source_workstream = ?
4683
+ ORDER BY n.id`
4684
+ ).run(wsId, archiveId, sourceWorkstream).changes;
4685
+ emitEvent(
4686
+ db,
4687
+ asWorkstream,
4688
+ `archive restore ${label} source=${sourceWorkstream} as ${asWorkstream} (tasks=${restoredTasks}, edges=${restoredEdges}, notes=${restoredNotes})`
4689
+ );
4690
+ return {
4691
+ archiveLabel: label,
4692
+ sourceWorkstream,
4693
+ workstreamName: asWorkstream,
4694
+ restoredTasks,
4695
+ restoredEdges,
4696
+ restoredNotes
4697
+ };
4698
+ })();
4699
+ }
4700
+ function listSources(db, archiveId) {
4701
+ return db.prepare(
4702
+ `SELECT source_workstream AS name
4703
+ FROM archived_tasks
4704
+ WHERE archive_id = ?
4705
+ GROUP BY source_workstream
4706
+ ORDER BY source_workstream`
4707
+ ).all(archiveId).map((row) => row.name);
4708
+ }
4709
+
4571
4710
  // src/tasks/status.ts
4572
4711
  var TASK_STATUSES = [
4573
4712
  "OPEN",
@@ -5137,6 +5276,31 @@ function exportArchive(db, opts) {
5137
5276
  return { ...result, archiveLabel: opts.label, sourceCount: sources.length };
5138
5277
  }
5139
5278
 
5279
+ // src/parked.ts
5280
+ var WORKSTREAM_PARKED_THRESHOLD_DAYS = 1;
5281
+ function parkedStatus(db, workstream, opts = {}) {
5282
+ const wsRow = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream);
5283
+ if (wsRow === void 0) return { parked: false };
5284
+ const latest = db.prepare(
5285
+ "SELECT kind, payload, created_at FROM agent_logs WHERE workstream_id = ? ORDER BY seq DESC LIMIT 1"
5286
+ ).get(wsRow.id);
5287
+ if (latest === void 0) return { parked: false };
5288
+ if (latest.kind !== "event") return { parked: false };
5289
+ if (!latest.payload.startsWith("db export ")) return { parked: false };
5290
+ const aliveAgent = db.prepare("SELECT 1 AS x FROM agents WHERE workstream_id = ? AND status != 'closed' LIMIT 1").get(wsRow.id);
5291
+ if (aliveAgent !== void 0) return { parked: false };
5292
+ const inProgress = db.prepare("SELECT 1 AS x FROM tasks WHERE workstream_id = ? AND status = 'IN_PROGRESS' LIMIT 1").get(wsRow.id);
5293
+ if (inProgress !== void 0) return { parked: false };
5294
+ const threshold = Math.max(0, opts.thresholdDays ?? WORKSTREAM_PARKED_THRESHOLD_DAYS);
5295
+ const exportedAt = Date.parse(latest.created_at);
5296
+ if (Number.isNaN(exportedAt)) return { parked: false };
5297
+ const now = (opts.now ?? /* @__PURE__ */ new Date()).getTime();
5298
+ const deltaMs = now - exportedAt;
5299
+ const deltaDays = Math.floor(deltaMs / (24 * 60 * 60 * 1e3));
5300
+ if (deltaDays < threshold) return { parked: false };
5301
+ return { parked: true, sinceDays: deltaDays };
5302
+ }
5303
+
5140
5304
  // src/workstream.ts
5141
5305
  var WORKSTREAM_NAME_RE = /^[a-z][a-z0-9_-]{0,31}$/;
5142
5306
  var RESERVED_WORKSTREAM_PREFIX = "mu-";
@@ -5145,6 +5309,27 @@ function isValidWorkstreamName(name) {
5145
5309
  if (name.startsWith(RESERVED_WORKSTREAM_PREFIX)) return false;
5146
5310
  return true;
5147
5311
  }
5312
+ var WorkstreamExistsError = class extends Error {
5313
+ constructor(workstream) {
5314
+ super(`workstream already exists: ${workstream}`);
5315
+ this.workstream = workstream;
5316
+ }
5317
+ workstream;
5318
+ name = "WorkstreamExistsError";
5319
+ errorNextSteps() {
5320
+ return [
5321
+ {
5322
+ intent: "Pick a different workstream name",
5323
+ command: "mu archive restore <label> --as <new-name>"
5324
+ },
5325
+ { intent: "List existing workstreams", command: "mu workstream list" },
5326
+ {
5327
+ intent: "Destroy the existing workstream first",
5328
+ command: `mu workstream destroy -w ${this.workstream} --yes`
5329
+ }
5330
+ ];
5331
+ }
5332
+ };
5148
5333
  var WorkstreamNameInvalidError = class extends Error {
5149
5334
  constructor(attempted) {
5150
5335
  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.`;
@@ -5187,6 +5372,7 @@ async function listWorkstreams(db) {
5187
5372
  }
5188
5373
  async function summarizeWorkstream(db, opts) {
5189
5374
  const tmuxSession = opts.tmuxSession ?? `mu-${opts.workstream}`;
5375
+ const parked = parkedStatus(db, opts.workstream);
5190
5376
  return {
5191
5377
  name: opts.workstream,
5192
5378
  tmuxSession,
@@ -5196,7 +5382,8 @@ async function summarizeWorkstream(db, opts) {
5196
5382
  noteCount: countNotes(db, opts.workstream),
5197
5383
  edgeCount: countEdges(db, opts.workstream),
5198
5384
  workspaceCount: listWorkspaces(db, opts.workstream).length,
5199
- registered: isRegistered(db, opts.workstream)
5385
+ registered: isRegistered(db, opts.workstream),
5386
+ ...parked.parked ? { parked: { sinceDays: parked.sinceDays ?? 0 } } : {}
5200
5387
  };
5201
5388
  }
5202
5389
  function isRegistered(db, workstream) {
@@ -6244,6 +6431,9 @@ var STATUS_EMOJI = {
6244
6431
  terminated: "\uF057"
6245
6432
  // nf-fa-times_circle
6246
6433
  };
6434
+ function agentStatusGlyph(status) {
6435
+ return STATUS_EMOJI[status] ?? "?";
6436
+ }
6247
6437
  var MAX_TITLE_LEN = 64;
6248
6438
  var PENDING_PANE_PREFIX = "%pending-";
6249
6439
  function pendingPaneIdFor(agentName) {
@@ -6257,7 +6447,7 @@ function composeAgentTitle(db, agent) {
6257
6447
  const tasks = listTasksByOwner(db, agent.workstreamName, agent.name);
6258
6448
  let title = agent.name;
6259
6449
  if (showStatus) {
6260
- title += ` \xB7 ${STATUS_EMOJI[agent.status]}`;
6450
+ title += ` \xB7 ${agentStatusGlyph(agent.status)}`;
6261
6451
  }
6262
6452
  if (tasks.length === 1) {
6263
6453
  title += ` \xB7 ${tasks[0]?.name}`;
@@ -6389,6 +6579,8 @@ async function listLiveAgents(db, opts) {
6389
6579
  }
6390
6580
 
6391
6581
  // src/dag.ts
6582
+ import pc2 from "picocolors";
6583
+ var RECURRENCE_MARKER = ` ${pc2.dim("(\u21BB)")}`;
6392
6584
  function loadFullDag(db, workstream, opts = {}) {
6393
6585
  const tasks = listTasks(db, workstream).filter(
6394
6586
  (t) => opts.statuses === void 0 || opts.statuses.has(t.status)
@@ -6427,7 +6619,7 @@ function renderForest(roots, edges, statusFn, tasksByName, opts = {}) {
6427
6619
  if (!byName.has(root.name)) byName.set(root.name, root);
6428
6620
  const lines = [formatTreeNodeLabel(root, statusFn, opts)];
6429
6621
  if (seen.has(root.name)) {
6430
- lines[0] = `${lines[0]} (\u21BB already shown above)`;
6622
+ lines[0] = `${lines[0]}${RECURRENCE_MARKER}`;
6431
6623
  } else {
6432
6624
  seen.add(root.name);
6433
6625
  renderForestChildren(root.name, "", edges, byName, statusFn, seen, lines, opts);
@@ -6486,7 +6678,7 @@ function renderForestChildren(taskName, prefix, edges, byName, statusFn, seen, l
6486
6678
  }
6487
6679
  if (seen.has(childName)) {
6488
6680
  lines.push(
6489
- `${prefix}${branch}${formatTreeNodeLabel(child, statusFn, opts)} (\u21BB already shown above)`
6681
+ `${prefix}${branch}${formatTreeNodeLabel(child, statusFn, opts)}${RECURRENCE_MARKER}`
6490
6682
  );
6491
6683
  continue;
6492
6684
  }
@@ -6501,6 +6693,815 @@ function formatTreeNodeLabel(t, statusFn, opts = {}) {
6501
6693
  return `${base} ${t.title}`;
6502
6694
  }
6503
6695
 
6696
+ // src/db-sync.ts
6697
+ import { randomUUID as randomUUID2 } from "crypto";
6698
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4, readFileSync as readFileSync2, unlinkSync as unlinkSync4, writeFileSync as writeFileSync2 } from "fs";
6699
+ import { hostname as hostname2 } from "os";
6700
+ import { dirname as dirname5, join as join8 } from "path";
6701
+ import { fileURLToPath as fileURLToPath2 } from "url";
6702
+
6703
+ // src/db-sync-replay.ts
6704
+ import { createHash as createHash2 } from "crypto";
6705
+ var DbReplayWorkstreamMissingError = class extends Error {
6706
+ constructor(workstream) {
6707
+ super(
6708
+ `replay sidecar is for workstream "${workstream}", which does not exist locally; restore it first via mu db import or mu archive restore`
6709
+ );
6710
+ this.workstream = workstream;
6711
+ }
6712
+ workstream;
6713
+ name = "DbReplayWorkstreamMissingError";
6714
+ errorNextSteps() {
6715
+ return [
6716
+ {
6717
+ intent: "Restore this workstream from a DB export",
6718
+ command: "mu db import <file> --apply"
6719
+ },
6720
+ {
6721
+ intent: "Or restore it from an archive",
6722
+ command: `mu archive restore <label> --as ${this.workstream}`
6723
+ }
6724
+ ];
6725
+ }
6726
+ };
6727
+ var DbReplayLocalIdConflictError = class extends Error {
6728
+ constructor(workstream, conflicts) {
6729
+ super(
6730
+ `sidecar task id collides with different local content in ${workstream}: ${conflicts.map(
6731
+ (c) => `${c.localId} (local: ${c.local.status} ${JSON.stringify(c.local.title)}; sidecar: ${c.sidecar.status} ${JSON.stringify(c.sidecar.title)})`
6732
+ ).join(", ")}`
6733
+ );
6734
+ this.workstream = workstream;
6735
+ this.conflicts = conflicts;
6736
+ }
6737
+ workstream;
6738
+ conflicts;
6739
+ name = "DbReplayLocalIdConflictError";
6740
+ errorNextSteps() {
6741
+ const first = this.conflicts[0];
6742
+ return [
6743
+ {
6744
+ intent: "Create a renamed local task manually, then replay notes if desired",
6745
+ 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>`
6746
+ },
6747
+ {
6748
+ intent: "Skip the colliding id and replay another task",
6749
+ command: "mu db replay <sidecar> --task <other-id> --apply"
6750
+ }
6751
+ ];
6752
+ }
6753
+ };
6754
+ function replayDb(db, file, opts = {}) {
6755
+ const sidecarDb = openDb({ path: file, readonly: true });
6756
+ try {
6757
+ const plan = buildReplayPlan(db, sidecarDb, file);
6758
+ const taskFilter = new Set(opts.tasks ?? []);
6759
+ const noteFilter = new Set(opts.notes ?? []);
6760
+ const selectedTaskIds = opts.all === true ? new Set(plan.tasks.map((t) => t.localId)) : taskFilter;
6761
+ const selectedNoteIds = opts.all === true ? new Set(plan.notes.map((n) => n.taskLocalId)) : noteFilter;
6762
+ const hasSelectors = opts.all === true || selectedTaskIds.size > 0 || selectedNoteIds.size > 0;
6763
+ const noteTaskIds = /* @__PURE__ */ new Set([...selectedNoteIds, ...selectedTaskIds]);
6764
+ const hasWrites = plan.tasks.some((t) => selectedTaskIds.has(t.localId)) || plan.notes.some((n) => noteTaskIds.has(n.taskLocalId)) || plan.edges.some(
6765
+ (e) => opts.all === true || selectedTaskIds.has(e.fromLocalId) || selectedTaskIds.has(e.toLocalId)
6766
+ );
6767
+ const relevantConflicts = opts.all === true ? plan.conflicts : plan.conflicts.filter((c) => selectedTaskIds.has(c.localId));
6768
+ if (relevantConflicts.length > 0) {
6769
+ throw new DbReplayLocalIdConflictError(plan.workstream, relevantConflicts);
6770
+ }
6771
+ if (opts.apply !== true || !hasSelectors) return replayResult(plan, true, false);
6772
+ if (!hasWrites) return replayResult(plan, false, true);
6773
+ const snapshot = captureSnapshot(db, `db replay ${file}`, null);
6774
+ const applied = applyReplayPlan(db, plan, selectedTaskIds, selectedNoteIds, opts.all === true);
6775
+ return { ...replayResult(plan, false, true), snapshotId: snapshot.id, ...applied };
6776
+ } finally {
6777
+ sidecarDb.close();
6778
+ }
6779
+ }
6780
+ function buildReplayPlan(localDb, sidecarDb, sourceFile) {
6781
+ const sidecarWorkstreams = listLocalWorkstreams(sidecarDb);
6782
+ const sidecarWs = sidecarWorkstreams[0];
6783
+ if (sidecarWorkstreams.length !== 1 || !sidecarWs) {
6784
+ throw new Error(
6785
+ `replay sidecar must contain exactly one workstream; found ${sidecarWorkstreams.length}`
6786
+ );
6787
+ }
6788
+ const localWs = listLocalWorkstreams(localDb).find((w) => w.name === sidecarWs.name);
6789
+ if (!localWs) throw new DbReplayWorkstreamMissingError(sidecarWs.name);
6790
+ const localTasks = new Map(
6791
+ localDb.prepare("SELECT local_id, title, status FROM tasks WHERE workstream_id = ?").all(localWs.id).map((t) => [t.local_id, t])
6792
+ );
6793
+ const tasks = [];
6794
+ const conflicts = [];
6795
+ for (const task of listReplayTasks(sidecarDb, sidecarWs.id)) {
6796
+ const local = localTasks.get(task.localId);
6797
+ if (!local) tasks.push(task);
6798
+ else if (local.title !== task.title || local.status !== task.status) {
6799
+ conflicts.push({
6800
+ localId: task.localId,
6801
+ local: { title: local.title, status: local.status },
6802
+ sidecar: { title: task.title, status: task.status }
6803
+ });
6804
+ }
6805
+ }
6806
+ const localNoteHashes = new Set(listReplayNotes(localDb, localWs.id).map((n) => n.hash));
6807
+ const localEdges = new Set(listReplayEdges(localDb, localWs.id).map(edgeKey));
6808
+ return {
6809
+ sourceFile,
6810
+ workstream: sidecarWs.name,
6811
+ tasks,
6812
+ notes: listReplayNotes(sidecarDb, sidecarWs.id).filter((n) => !localNoteHashes.has(n.hash)),
6813
+ edges: listReplayEdges(sidecarDb, sidecarWs.id).filter((e) => !localEdges.has(edgeKey(e))),
6814
+ conflicts
6815
+ };
6816
+ }
6817
+ function applyReplayPlan(db, plan, selectedTaskIds, selectedNoteIds, replayAllEdges) {
6818
+ const warnings = [];
6819
+ const added = db.transaction(() => {
6820
+ const wsId = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(plan.workstream)?.id;
6821
+ if (wsId === void 0) throw new DbReplayWorkstreamMissingError(plan.workstream);
6822
+ const taskIds = new Set(selectedTaskIds);
6823
+ const noteTaskIds = /* @__PURE__ */ new Set([...selectedNoteIds, ...taskIds]);
6824
+ let tasks = 0;
6825
+ let notes = 0;
6826
+ let edges = 0;
6827
+ const insertTask = db.prepare(
6828
+ `INSERT OR IGNORE INTO tasks (workstream_id, local_id, title, status, impact, effort_days, created_at, updated_at)
6829
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
6830
+ );
6831
+ for (const task of plan.tasks) {
6832
+ if (!taskIds.has(task.localId)) continue;
6833
+ const result = insertTask.run(
6834
+ wsId,
6835
+ task.localId,
6836
+ task.title,
6837
+ task.status,
6838
+ task.impact,
6839
+ task.effortDays,
6840
+ task.createdAt,
6841
+ task.updatedAt
6842
+ );
6843
+ if (result.changes > 0) tasks += 1;
6844
+ }
6845
+ const existingNoteHashes = new Set(listReplayNotes(db, wsId).map((n) => n.hash));
6846
+ const insertNote = db.prepare(
6847
+ `INSERT INTO task_notes (task_id, author, content, created_at)
6848
+ SELECT id, ?, ?, ? FROM tasks WHERE workstream_id = ? AND local_id = ?`
6849
+ );
6850
+ for (const note of plan.notes) {
6851
+ if (!noteTaskIds.has(note.taskLocalId) || existingNoteHashes.has(note.hash)) continue;
6852
+ const result = insertNote.run(
6853
+ note.author,
6854
+ note.content,
6855
+ note.createdAt,
6856
+ wsId,
6857
+ note.taskLocalId
6858
+ );
6859
+ if (result.changes > 0) {
6860
+ notes += 1;
6861
+ existingNoteHashes.add(note.hash);
6862
+ }
6863
+ }
6864
+ const insertEdge = db.prepare(
6865
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
6866
+ SELECT f.id, t.id, ?
6867
+ FROM tasks f, tasks t
6868
+ WHERE f.workstream_id = ? AND f.local_id = ?
6869
+ AND t.workstream_id = ? AND t.local_id = ?`
6870
+ );
6871
+ for (const edge of plan.edges) {
6872
+ if (!replayAllEdges && !taskIds.has(edge.fromLocalId) && !taskIds.has(edge.toLocalId)) {
6873
+ continue;
6874
+ }
6875
+ if (!hasTask(db, wsId, edge.fromLocalId) || !hasTask(db, wsId, edge.toLocalId)) {
6876
+ warnings.push(
6877
+ `skipped edge ${edge.fromLocalId} -> ${edge.toLocalId}: one endpoint is missing locally`
6878
+ );
6879
+ continue;
6880
+ }
6881
+ const result = insertEdge.run(edge.createdAt, wsId, edge.fromLocalId, wsId, edge.toLocalId);
6882
+ if (result.changes > 0) edges += 1;
6883
+ }
6884
+ return { tasks, notes, edges };
6885
+ })();
6886
+ return { added, warnings };
6887
+ }
6888
+ function replayResult(plan, dryRun, applied) {
6889
+ return { ...plan, dryRun, applied, added: { tasks: 0, notes: 0, edges: 0 }, warnings: [] };
6890
+ }
6891
+ function listLocalWorkstreams(db) {
6892
+ return db.prepare("SELECT id, name FROM workstreams ORDER BY name").all();
6893
+ }
6894
+ function listReplayTasks(db, wsId) {
6895
+ return db.prepare(
6896
+ `SELECT local_id, title, status, impact, effort_days, created_at, updated_at
6897
+ FROM tasks
6898
+ WHERE workstream_id = ?
6899
+ ORDER BY local_id`
6900
+ ).all(wsId).map((row) => ({
6901
+ localId: row.local_id,
6902
+ title: row.title,
6903
+ status: row.status,
6904
+ impact: row.impact,
6905
+ effortDays: row.effort_days,
6906
+ createdAt: row.created_at,
6907
+ updatedAt: row.updated_at
6908
+ }));
6909
+ }
6910
+ function listReplayNotes(db, wsId) {
6911
+ const rows = db.prepare(
6912
+ `SELECT t.local_id AS taskLocalId, n.author, n.content, n.created_at AS createdAt
6913
+ FROM task_notes n
6914
+ JOIN tasks t ON t.id = n.task_id
6915
+ WHERE t.workstream_id = ?
6916
+ ORDER BY n.created_at, n.id`
6917
+ ).all(wsId);
6918
+ return rows.map((row) => ({ ...row, hash: noteHash(row) }));
6919
+ }
6920
+ function listReplayEdges(db, wsId) {
6921
+ return db.prepare(
6922
+ `SELECT f.local_id AS fromLocalId, t.local_id AS toLocalId, e.created_at AS createdAt
6923
+ FROM task_edges e
6924
+ JOIN tasks f ON f.id = e.from_task_id
6925
+ JOIN tasks t ON t.id = e.to_task_id
6926
+ WHERE f.workstream_id = ? AND t.workstream_id = ?
6927
+ ORDER BY f.local_id, t.local_id`
6928
+ ).all(wsId, wsId);
6929
+ }
6930
+ function noteHash(note) {
6931
+ return createHash2("sha256").update(`${note.taskLocalId}\0${note.content}\0${note.createdAt}`).digest("hex");
6932
+ }
6933
+ function edgeKey(edge) {
6934
+ return `${edge.fromLocalId}\0${edge.toLocalId}`;
6935
+ }
6936
+ function hasTask(db, wsId, localId) {
6937
+ return db.prepare("SELECT 1 FROM tasks WHERE workstream_id = ? AND local_id = ?").get(wsId, localId) !== void 0;
6938
+ }
6939
+ function shellQuote2(s) {
6940
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
6941
+ }
6942
+
6943
+ // src/db-sync.ts
6944
+ var DbExportTargetExistsError = class extends Error {
6945
+ constructor(file) {
6946
+ super(`DB export target already exists: ${file}`);
6947
+ this.file = file;
6948
+ }
6949
+ file;
6950
+ name = "DbExportTargetExistsError";
6951
+ errorNextSteps() {
6952
+ return [
6953
+ { intent: "Choose a different target", command: "mu db export <new-file>" },
6954
+ { intent: "Overwrite this target", command: `mu db export ${shellQuote3(this.file)} --force` }
6955
+ ];
6956
+ }
6957
+ };
6958
+ var DbImportManifestMissingError = class extends Error {
6959
+ constructor(manifestPath) {
6960
+ super(`DB import manifest not found: ${manifestPath}`);
6961
+ this.manifestPath = manifestPath;
6962
+ }
6963
+ manifestPath;
6964
+ name = "DbImportManifestMissingError";
6965
+ errorNextSteps() {
6966
+ return [
6967
+ { intent: "Export the DB with its sidecar", command: "mu db export /tmp/mu.db --force" },
6968
+ { intent: "Copy the sidecar too", command: `scp <host>:${shellQuote3(this.manifestPath)} .` }
6969
+ ];
6970
+ }
6971
+ };
6972
+ var DbImportSchemaTooOldError = class extends Error {
6973
+ constructor(sourceVersion) {
6974
+ super(
6975
+ `source DB schema v${sourceVersion} is older than local mu requires (v${CURRENT_SCHEMA_VERSION})`
6976
+ );
6977
+ this.sourceVersion = sourceVersion;
6978
+ }
6979
+ sourceVersion;
6980
+ name = "DbImportSchemaTooOldError";
6981
+ errorNextSteps() {
6982
+ return [
6983
+ {
6984
+ intent: "Upgrade mu on the source machine",
6985
+ command: "npm run build && mu db export <file> --force"
6986
+ },
6987
+ { intent: "Then retry this import", command: "mu db import <file> --apply" }
6988
+ ];
6989
+ }
6990
+ };
6991
+ var DbImportSchemaTooNewError = class extends Error {
6992
+ constructor(sourceVersion) {
6993
+ super(
6994
+ `source DB schema v${sourceVersion} is newer than this mu supports (v${CURRENT_SCHEMA_VERSION}); upgrade local mu`
6995
+ );
6996
+ this.sourceVersion = sourceVersion;
6997
+ }
6998
+ sourceVersion;
6999
+ name = "DbImportSchemaTooNewError";
7000
+ errorNextSteps() {
7001
+ return [
7002
+ { intent: "Upgrade local mu", command: "git pull && npm install && npm run build" },
7003
+ { intent: "Then retry this import", command: "mu db import <file> --apply" }
7004
+ ];
7005
+ }
7006
+ };
7007
+ var DbImportSourceStaleError = class extends Error {
7008
+ constructor(workstreams) {
7009
+ super(`source DB is stale for local-ahead workstream(s): ${workstreams.join(", ")}`);
7010
+ this.workstreams = workstreams;
7011
+ }
7012
+ workstreams;
7013
+ name = "DbImportSourceStaleError";
7014
+ errorNextSteps() {
7015
+ return [
7016
+ { intent: "Re-export from this machine", command: "mu db export /tmp/mu-fresh.db --force" },
7017
+ { intent: "Dry-run the incoming file first", command: "mu db import <file>" }
7018
+ ];
7019
+ }
7020
+ };
7021
+ var DbImportConflictError = class extends Error {
7022
+ constructor(workstreams) {
7023
+ super(`source and local both advanced for workstream(s): ${workstreams.join(", ")}`);
7024
+ this.workstreams = workstreams;
7025
+ }
7026
+ workstreams;
7027
+ name = "DbImportConflictError";
7028
+ errorNextSteps() {
7029
+ return [
7030
+ { intent: "Preview the conflicting workstreams", command: "mu db import <file> --json" },
7031
+ {
7032
+ intent: "Clobber from source after parking local divergence",
7033
+ command: "mu db import <file> --apply --force-source"
7034
+ }
7035
+ ];
7036
+ }
7037
+ };
7038
+ function exportDb(db, file, opts = {}) {
7039
+ const target = file;
7040
+ const manifestPath = `${target}.manifest.json`;
7041
+ const targetExists = existsSync12(target);
7042
+ if (targetExists && opts.force !== true) throw new DbExportTargetExistsError(target);
7043
+ const preEventManifest = buildExportManifest(db);
7044
+ for (const ws of preEventManifest.workstreams) {
7045
+ emitEvent(db, ws.name, `db export ${ws.name} seq=${ws.latestSeq}`);
7046
+ }
7047
+ const manifest = buildExportManifest(db);
7048
+ mkdirSync4(dirname5(target), { recursive: true });
7049
+ try {
7050
+ if (targetExists) unlinkSync4(target);
7051
+ db.exec(`VACUUM INTO ${quoteSqlString2(target)}`);
7052
+ writeFileSync2(manifestPath, `${JSON.stringify(manifest, null, 2)}
7053
+ `, "utf8");
7054
+ } catch (err) {
7055
+ try {
7056
+ if (existsSync12(target)) unlinkSync4(target);
7057
+ } catch {
7058
+ }
7059
+ throw err;
7060
+ }
7061
+ return { file: target, manifestPath, manifest, overwritten: targetExists };
7062
+ }
7063
+ function importDb(db, file, opts = {}) {
7064
+ const manifest = readImportManifest(file);
7065
+ assertImportSchemaCompatible(manifest.schemaVersion);
7066
+ const sourceDb = openDb({ path: file, readonly: true });
7067
+ try {
7068
+ const summary = buildImportPlan(db, manifest, file, opts.onlyWorkstreams);
7069
+ if (opts.apply !== true) {
7070
+ return {
7071
+ machineId: manifest.machineId,
7072
+ sourceFile: file,
7073
+ dryRun: true,
7074
+ applied: false,
7075
+ summary
7076
+ };
7077
+ }
7078
+ const stale = summary.filter((s) => s.decision === "LOCAL_AHEAD").map((s) => s.workstream);
7079
+ if (stale.length > 0) throw new DbImportSourceStaleError(stale);
7080
+ const conflicts = summary.filter((s) => s.decision === "CONFLICT").map((s) => s.workstream);
7081
+ if (conflicts.length > 0 && opts.forceSource !== true)
7082
+ throw new DbImportConflictError(conflicts);
7083
+ const mutating = summary.some((s) => shouldReplace(s.decision, opts.forceSource === true));
7084
+ const snapshot = mutating ? captureSnapshot(db, `db import ${file}`, null) : void 0;
7085
+ for (const item of summary) {
7086
+ if (!shouldReplace(item.decision, opts.forceSource === true)) continue;
7087
+ if (item.decision === "CONFLICT") {
7088
+ item.parkPath = parkLocalWorkstream(db, item.workstream);
7089
+ }
7090
+ const sourceWs = manifest.workstreams.find((w) => w.name === item.workstream);
7091
+ const sourceSeq = sourceWs?.latestSeq ?? 0;
7092
+ replaceWorkstreamFromSource(db, sourceDb, item.workstream, manifest.machineId, sourceSeq);
7093
+ }
7094
+ return {
7095
+ machineId: manifest.machineId,
7096
+ sourceFile: file,
7097
+ dryRun: false,
7098
+ applied: true,
7099
+ ...snapshot ? { snapshotId: snapshot.id } : {},
7100
+ summary
7101
+ };
7102
+ } finally {
7103
+ sourceDb.close();
7104
+ }
7105
+ }
7106
+ function buildImportPlan(localDb, manifest, sourceFile, onlyWorkstreams) {
7107
+ const sourceByName = new Map(manifest.workstreams.map((w) => [w.name, w]));
7108
+ const localByName = new Map(listLocalWorkstreams2(localDb).map((w) => [w.name, w]));
7109
+ const localMachineId = getMachineIdentity(localDb)?.machine_id ?? "";
7110
+ const only = normaliseOnlyWorkstreams(onlyWorkstreams);
7111
+ const names = Array.from(/* @__PURE__ */ new Set([...sourceByName.keys(), ...localByName.keys()])).filter((name) => only.size === 0 || only.has(name)).sort();
7112
+ return names.map((name) => {
7113
+ const source = sourceByName.get(name);
7114
+ const local = localByName.get(name);
7115
+ const sourceSeq = source?.latestSeq ?? 0;
7116
+ const localSeq = local ? latestSeq(localDb, local.id) : 0;
7117
+ 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 };
7118
+ const decision = classifyWorkstream({
7119
+ hasSource: source !== void 0,
7120
+ hasLocal: local !== void 0,
7121
+ sourceSeq,
7122
+ localSeq,
7123
+ syncedSourceSeq: synced.sourceSeq,
7124
+ syncedLocalSeq: synced.localSeq
7125
+ });
7126
+ return {
7127
+ workstream: name,
7128
+ decision,
7129
+ delta: {
7130
+ sourceFile,
7131
+ sourceSeq,
7132
+ localSeq,
7133
+ lastSynced: synced.sourceSeq,
7134
+ localSynced: synced.localSeq,
7135
+ source: source ? countsFromManifest(source) : null,
7136
+ local: local ? countWorkstream(localDb, local.id) : null
7137
+ },
7138
+ ...decision === "LOCAL_AHEAD" ? { needs: "re-export from this machine" } : {},
7139
+ ...decision === "CONFLICT" ? { needs: "--force-source" } : {}
7140
+ };
7141
+ });
7142
+ }
7143
+ function classifyWorkstream(opts) {
7144
+ if (opts.hasSource && !opts.hasLocal) return "IMPORT";
7145
+ if (!opts.hasSource && opts.hasLocal)
7146
+ return opts.syncedSourceSeq > 0 || opts.syncedLocalSeq > 0 ? "LOCAL_AHEAD" : "LEAVE_ALONE";
7147
+ if (!opts.hasSource && !opts.hasLocal) return "IDENTICAL";
7148
+ const sourceAdvanced = opts.sourceSeq > opts.syncedSourceSeq;
7149
+ const localAdvanced = opts.localSeq > opts.syncedLocalSeq;
7150
+ if (!sourceAdvanced && !localAdvanced) return "IDENTICAL";
7151
+ if (sourceAdvanced && !localAdvanced) return "FAST_FORWARD";
7152
+ if (!sourceAdvanced && localAdvanced) return "LOCAL_AHEAD";
7153
+ return "CONFLICT";
7154
+ }
7155
+ function shouldReplace(decision, forceSource) {
7156
+ return decision === "FAST_FORWARD" || decision === "IMPORT" || decision === "CONFLICT" && forceSource;
7157
+ }
7158
+ function replaceWorkstreamFromSource(localDb, sourceDb, workstream, sourceMachineId, sourceSeq) {
7159
+ localDb.transaction(() => {
7160
+ const existing = localDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream);
7161
+ if (existing) {
7162
+ localDb.prepare("DELETE FROM vcs_workspaces WHERE workstream_id = ?").run(existing.id);
7163
+ localDb.prepare("DELETE FROM agents WHERE workstream_id = ?").run(existing.id);
7164
+ localDb.prepare("DELETE FROM workstreams WHERE id = ?").run(existing.id);
7165
+ }
7166
+ copyWorkstreamRows(sourceDb, localDb, workstream, {
7167
+ includeMachineLocalRows: false,
7168
+ preserveLogSeq: false,
7169
+ includeSync: false
7170
+ });
7171
+ const wsId = localDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream)?.id;
7172
+ if (wsId === void 0) throw new Error(`importDb: failed to import workstream ${workstream}`);
7173
+ writeSyncState(localDb, wsId, sourceMachineId, sourceSeq);
7174
+ })();
7175
+ }
7176
+ function parkLocalWorkstream(db, workstream) {
7177
+ const dir = join8(defaultStateDir(), "divergence");
7178
+ mkdirSync4(dir, { recursive: true });
7179
+ const path = join8(
7180
+ dir,
7181
+ `${workstream}-${(/* @__PURE__ */ new Date()).toISOString()}-${randomUUID2().slice(0, 8)}.db`
7182
+ );
7183
+ const parkDb = openDb({ path });
7184
+ try {
7185
+ const identity = getMachineIdentity(db);
7186
+ if (identity) {
7187
+ parkDb.prepare(
7188
+ `UPDATE machine_identity
7189
+ SET machine_id = ?, hostname = ?, created_at = ?
7190
+ WHERE id = 1`
7191
+ ).run(
7192
+ identity.machine_id,
7193
+ identity.hostname,
7194
+ identity.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
7195
+ );
7196
+ }
7197
+ copyWorkstreamRows(db, parkDb, workstream, {
7198
+ includeMachineLocalRows: true,
7199
+ preserveLogSeq: true,
7200
+ includeSync: true
7201
+ });
7202
+ } catch (err) {
7203
+ try {
7204
+ parkDb.close();
7205
+ } catch {
7206
+ }
7207
+ try {
7208
+ if (existsSync12(path)) unlinkSync4(path);
7209
+ } catch {
7210
+ }
7211
+ throw err;
7212
+ }
7213
+ parkDb.close();
7214
+ return path;
7215
+ }
7216
+ function copyWorkstreamRows(sourceDb, targetDb, workstream, opts) {
7217
+ const sourceWs = sourceDb.prepare("SELECT id, name, created_at FROM workstreams WHERE name = ?").get(workstream);
7218
+ if (!sourceWs) throw new Error(`copyWorkstreamRows: no such workstream ${workstream}`);
7219
+ targetDb.prepare("INSERT INTO workstreams (name, created_at) VALUES (?, ?)").run(sourceWs.name, sourceWs.created_at);
7220
+ const targetWsId = targetDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream).id;
7221
+ if (opts.includeMachineLocalRows) copyAgents(sourceDb, targetDb, sourceWs.id, targetWsId);
7222
+ copyTasks(sourceDb, targetDb, sourceWs.id, targetWsId, opts.includeMachineLocalRows);
7223
+ copyEdges(sourceDb, targetDb, sourceWs.id, targetWsId);
7224
+ copyNotes(sourceDb, targetDb, sourceWs.id, targetWsId);
7225
+ copyLogs(sourceDb, targetDb, sourceWs.id, targetWsId, opts.preserveLogSeq);
7226
+ if (opts.includeMachineLocalRows) copyWorkspaces(sourceDb, targetDb, sourceWs.id, targetWsId);
7227
+ if (opts.includeSync) copySync(sourceDb, targetDb, sourceWs.id, targetWsId);
7228
+ }
7229
+ function copyAgents(sourceDb, targetDb, sourceWsId, targetWsId) {
7230
+ const rows = sourceDb.prepare(
7231
+ `SELECT name, cli, pane_id, status, role, tab, created_at, updated_at
7232
+ FROM agents
7233
+ WHERE workstream_id = ?
7234
+ ORDER BY id`
7235
+ ).all(sourceWsId);
7236
+ const insert = targetDb.prepare(
7237
+ `INSERT INTO agents (workstream_id, name, cli, pane_id, status, role, tab, created_at, updated_at)
7238
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7239
+ );
7240
+ for (const row of rows) {
7241
+ insert.run(
7242
+ targetWsId,
7243
+ row.name,
7244
+ row.cli,
7245
+ row.pane_id,
7246
+ row.status,
7247
+ row.role,
7248
+ row.tab,
7249
+ row.created_at,
7250
+ row.updated_at
7251
+ );
7252
+ }
7253
+ }
7254
+ function copyTasks(sourceDb, targetDb, sourceWsId, targetWsId, includeOwners) {
7255
+ const rows = sourceDb.prepare(
7256
+ `SELECT t.local_id, t.title, t.status, t.impact, t.effort_days, a.name AS owner_name,
7257
+ t.created_at, t.updated_at
7258
+ FROM tasks t
7259
+ LEFT JOIN agents a ON a.id = t.owner_id
7260
+ WHERE t.workstream_id = ?
7261
+ ORDER BY t.id`
7262
+ ).all(sourceWsId);
7263
+ const ownerLookup = targetDb.prepare(
7264
+ "SELECT id FROM agents WHERE workstream_id = ? AND name = ?"
7265
+ );
7266
+ const insert = targetDb.prepare(
7267
+ `INSERT INTO tasks (workstream_id, local_id, title, status, impact, effort_days, owner_id, created_at, updated_at)
7268
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7269
+ );
7270
+ for (const row of rows) {
7271
+ const ownerId = includeOwners && row.owner_name !== null ? ownerLookup.get(targetWsId, row.owner_name)?.id ?? null : null;
7272
+ insert.run(
7273
+ targetWsId,
7274
+ row.local_id,
7275
+ row.title,
7276
+ row.status,
7277
+ row.impact,
7278
+ row.effort_days,
7279
+ ownerId,
7280
+ row.created_at,
7281
+ row.updated_at
7282
+ );
7283
+ }
7284
+ }
7285
+ function copyEdges(sourceDb, targetDb, sourceWsId, targetWsId) {
7286
+ const rows = sourceDb.prepare(
7287
+ `SELECT f.local_id AS from_local_id, t.local_id AS to_local_id, e.created_at
7288
+ FROM task_edges e
7289
+ JOIN tasks f ON f.id = e.from_task_id
7290
+ JOIN tasks t ON t.id = e.to_task_id
7291
+ WHERE f.workstream_id = ? AND t.workstream_id = ?
7292
+ ORDER BY e.created_at, f.local_id, t.local_id`
7293
+ ).all(sourceWsId, sourceWsId);
7294
+ const insert = targetDb.prepare(
7295
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
7296
+ SELECT f.id, t.id, ?
7297
+ FROM tasks f, tasks t
7298
+ WHERE f.workstream_id = ? AND f.local_id = ?
7299
+ AND t.workstream_id = ? AND t.local_id = ?`
7300
+ );
7301
+ for (const row of rows) {
7302
+ insert.run(row.created_at, targetWsId, row.from_local_id, targetWsId, row.to_local_id);
7303
+ }
7304
+ }
7305
+ function copyNotes(sourceDb, targetDb, sourceWsId, targetWsId) {
7306
+ const rows = sourceDb.prepare(
7307
+ `SELECT t.local_id AS task_local_id, n.author, n.content, n.created_at
7308
+ FROM task_notes n
7309
+ JOIN tasks t ON t.id = n.task_id
7310
+ WHERE t.workstream_id = ?
7311
+ ORDER BY n.id`
7312
+ ).all(sourceWsId);
7313
+ const insert = targetDb.prepare(
7314
+ `INSERT INTO task_notes (task_id, author, content, created_at)
7315
+ SELECT id, ?, ?, ? FROM tasks WHERE workstream_id = ? AND local_id = ?`
7316
+ );
7317
+ for (const row of rows) {
7318
+ insert.run(row.author, row.content, row.created_at, targetWsId, row.task_local_id);
7319
+ }
7320
+ }
7321
+ function copyLogs(sourceDb, targetDb, sourceWsId, targetWsId, preserveSeq) {
7322
+ const rows = sourceDb.prepare(
7323
+ `SELECT seq, source, kind, payload, created_at
7324
+ FROM agent_logs
7325
+ WHERE workstream_id = ?
7326
+ ORDER BY seq`
7327
+ ).all(sourceWsId);
7328
+ const insertPreserve = targetDb.prepare(
7329
+ "INSERT INTO agent_logs (seq, workstream_id, source, kind, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)"
7330
+ );
7331
+ const insertRenumber = targetDb.prepare(
7332
+ "INSERT INTO agent_logs (workstream_id, source, kind, payload, created_at) VALUES (?, ?, ?, ?, ?)"
7333
+ );
7334
+ for (const row of rows) {
7335
+ if (preserveSeq) {
7336
+ insertPreserve.run(row.seq, targetWsId, row.source, row.kind, row.payload, row.created_at);
7337
+ } else {
7338
+ insertRenumber.run(targetWsId, row.source, row.kind, row.payload, row.created_at);
7339
+ }
7340
+ }
7341
+ }
7342
+ function copyWorkspaces(sourceDb, targetDb, sourceWsId, targetWsId) {
7343
+ const rows = sourceDb.prepare(
7344
+ `SELECT a.name AS agent_name, v.backend, v.path, v.parent_ref, v.created_at
7345
+ FROM vcs_workspaces v
7346
+ JOIN agents a ON a.id = v.agent_id
7347
+ WHERE v.workstream_id = ?
7348
+ ORDER BY v.id`
7349
+ ).all(sourceWsId);
7350
+ const agentLookup = targetDb.prepare(
7351
+ "SELECT id FROM agents WHERE workstream_id = ? AND name = ?"
7352
+ );
7353
+ const insert = targetDb.prepare(
7354
+ `INSERT INTO vcs_workspaces (agent_id, workstream_id, backend, path, parent_ref, created_at)
7355
+ VALUES (?, ?, ?, ?, ?, ?)`
7356
+ );
7357
+ for (const row of rows) {
7358
+ const agentId = agentLookup.get(targetWsId, row.agent_name)?.id;
7359
+ if (agentId === void 0) continue;
7360
+ insert.run(agentId, targetWsId, row.backend, row.path, row.parent_ref, row.created_at);
7361
+ }
7362
+ }
7363
+ function copySync(sourceDb, targetDb, sourceWsId, targetWsId) {
7364
+ const row = sourceDb.prepare("SELECT last_known_peer_seqs FROM workstream_sync WHERE workstream_id = ?").get(sourceWsId);
7365
+ if (!row) return;
7366
+ targetDb.prepare("INSERT INTO workstream_sync (workstream_id, last_known_peer_seqs) VALUES (?, ?)").run(targetWsId, row.last_known_peer_seqs);
7367
+ }
7368
+ function writeSyncState(db, workstreamId, sourceMachineId, sourceSeq) {
7369
+ const localSeq = latestSeq(db, workstreamId);
7370
+ const peers = {
7371
+ [sourceMachineId]: sourceSeq,
7372
+ [localSeqKey(sourceMachineId)]: localSeq
7373
+ };
7374
+ db.prepare(
7375
+ `INSERT OR REPLACE INTO workstream_sync (workstream_id, last_known_peer_seqs)
7376
+ VALUES (?, ?)`
7377
+ ).run(workstreamId, JSON.stringify(peers));
7378
+ }
7379
+ function lastKnownPeerSync(db, workstreamId, machineId) {
7380
+ const row = db.prepare("SELECT last_known_peer_seqs FROM workstream_sync WHERE workstream_id = ?").get(workstreamId);
7381
+ if (!row) return { sourceSeq: 0, localSeq: 0 };
7382
+ const parsed = parsePeerSeqs(row.last_known_peer_seqs);
7383
+ const sourceSeq = parsed[machineId] ?? 0;
7384
+ return { sourceSeq, localSeq: parsed[localSeqKey(machineId)] ?? sourceSeq };
7385
+ }
7386
+ function localSeqKey(machineId) {
7387
+ return `${machineId}:local`;
7388
+ }
7389
+ function parsePeerSeqs(raw) {
7390
+ try {
7391
+ const parsed = JSON.parse(raw);
7392
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
7393
+ const result = {};
7394
+ for (const [key, value] of Object.entries(parsed)) {
7395
+ if (typeof value === "number" && Number.isFinite(value)) result[key] = value;
7396
+ }
7397
+ return result;
7398
+ } catch {
7399
+ return {};
7400
+ }
7401
+ }
7402
+ function readImportManifest(file) {
7403
+ const manifestPath = `${file}.manifest.json`;
7404
+ if (!existsSync12(manifestPath)) throw new DbImportManifestMissingError(manifestPath);
7405
+ return JSON.parse(readFileSync2(manifestPath, "utf8"));
7406
+ }
7407
+ function assertImportSchemaCompatible(sourceVersion) {
7408
+ if (sourceVersion < CURRENT_SCHEMA_VERSION) throw new DbImportSchemaTooOldError(sourceVersion);
7409
+ if (sourceVersion > CURRENT_SCHEMA_VERSION) throw new DbImportSchemaTooNewError(sourceVersion);
7410
+ }
7411
+ function buildExportManifest(db) {
7412
+ const identity = getMachineIdentity(db);
7413
+ const schemaRow = db.prepare("SELECT version FROM schema_version WHERE id = 1").get();
7414
+ const workstreams = listLocalWorkstreams2(db);
7415
+ return {
7416
+ muVersion: readPackageVersion(),
7417
+ schemaVersion: schemaRow?.version ?? CURRENT_SCHEMA_VERSION,
7418
+ machineId: identity?.machine_id ?? "",
7419
+ hostname: identity?.hostname ?? hostname2(),
7420
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
7421
+ workstreams: workstreams.map((ws) => ({
7422
+ name: ws.name,
7423
+ tasks: count(db, "SELECT COUNT(*) AS n FROM tasks WHERE workstream_id = ?", ws.id),
7424
+ edges: count(
7425
+ db,
7426
+ `SELECT COUNT(*) AS n
7427
+ FROM task_edges e
7428
+ JOIN tasks f ON f.id = e.from_task_id
7429
+ JOIN tasks t ON t.id = e.to_task_id
7430
+ WHERE f.workstream_id = ? AND t.workstream_id = ?`,
7431
+ ws.id,
7432
+ ws.id
7433
+ ),
7434
+ notes: count(
7435
+ db,
7436
+ `SELECT COUNT(*) AS n
7437
+ FROM task_notes n
7438
+ JOIN tasks t ON t.id = n.task_id
7439
+ WHERE t.workstream_id = ?`,
7440
+ ws.id
7441
+ ),
7442
+ latestSeq: latestSeq(db, ws.id)
7443
+ }))
7444
+ };
7445
+ }
7446
+ function listLocalWorkstreams2(db) {
7447
+ return db.prepare("SELECT id, name FROM workstreams ORDER BY name").all();
7448
+ }
7449
+ function getMachineIdentity(db) {
7450
+ return db.prepare("SELECT machine_id, hostname, created_at FROM machine_identity WHERE id = 1").get();
7451
+ }
7452
+ function countWorkstream(db, wsId) {
7453
+ return {
7454
+ tasks: count(db, "SELECT COUNT(*) AS n FROM tasks WHERE workstream_id = ?", wsId),
7455
+ edges: count(
7456
+ db,
7457
+ `SELECT COUNT(*) AS n
7458
+ FROM task_edges e
7459
+ JOIN tasks f ON f.id = e.from_task_id
7460
+ JOIN tasks t ON t.id = e.to_task_id
7461
+ WHERE f.workstream_id = ? AND t.workstream_id = ?`,
7462
+ wsId,
7463
+ wsId
7464
+ ),
7465
+ notes: count(
7466
+ db,
7467
+ `SELECT COUNT(*) AS n
7468
+ FROM task_notes n
7469
+ JOIN tasks t ON t.id = n.task_id
7470
+ WHERE t.workstream_id = ?`,
7471
+ wsId
7472
+ )
7473
+ };
7474
+ }
7475
+ function countsFromManifest(ws) {
7476
+ return { tasks: ws.tasks, edges: ws.edges, notes: ws.notes };
7477
+ }
7478
+ function normaliseOnlyWorkstreams(input) {
7479
+ if (!input || input.length === 0) return /* @__PURE__ */ new Set();
7480
+ return new Set(
7481
+ input.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0)
7482
+ );
7483
+ }
7484
+ function count(db, sql, ...params) {
7485
+ const row = db.prepare(sql).get(...params);
7486
+ return row?.n ?? 0;
7487
+ }
7488
+ function quoteSqlString2(s) {
7489
+ return `'${s.replace(/'/g, "''")}'`;
7490
+ }
7491
+ function readPackageVersion() {
7492
+ try {
7493
+ const here = dirname5(fileURLToPath2(import.meta.url));
7494
+ const raw = readFileSync2(join8(here, "..", "package.json"), "utf8");
7495
+ const parsed = JSON.parse(raw);
7496
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
7497
+ } catch {
7498
+ return "unknown";
7499
+ }
7500
+ }
7501
+ function shellQuote3(s) {
7502
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
7503
+ }
7504
+
6504
7505
  // src/tracks.ts
6505
7506
  function getParallelTracks(db, workstream) {
6506
7507
  const goals = listGoals(db, workstream).filter(
@@ -6603,544 +7604,6 @@ var UnionFind = class {
6603
7604
  }
6604
7605
  };
6605
7606
 
6606
- // src/importing.ts
6607
- import { existsSync as existsSync12, readFileSync as readFileSync2, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
6608
- import { basename as basename2, dirname as dirname5, join as join8 } from "path";
6609
- var ImportBucketInvalidError = class extends Error {
6610
- constructor(bucketDir, reason) {
6611
- super(`not a valid mu bucket export at ${bucketDir}: ${reason}`);
6612
- this.bucketDir = bucketDir;
6613
- this.reason = reason;
6614
- }
6615
- bucketDir;
6616
- reason;
6617
- name = "ImportBucketInvalidError";
6618
- errorNextSteps() {
6619
- return [
6620
- { intent: "List the directory's contents", command: `ls ${this.bucketDir}` },
6621
- {
6622
- intent: "Inspect the manifest (must be bucketVersion 2)",
6623
- command: `cat ${this.bucketDir}/manifest.json`
6624
- }
6625
- ];
6626
- }
6627
- };
6628
- var ImportSourceNotInBucketError = class extends Error {
6629
- constructor(bucketDir, badName, validNames) {
6630
- super(
6631
- `--source-ws "${badName}" is not a source-ws in bucket ${bucketDir}; valid: ${validNames.length === 0 ? "<none>" : validNames.join(", ")}`
6632
- );
6633
- this.bucketDir = bucketDir;
6634
- this.badName = badName;
6635
- this.validNames = validNames;
6636
- }
6637
- bucketDir;
6638
- badName;
6639
- validNames;
6640
- name = "ImportSourceNotInBucketError";
6641
- errorNextSteps() {
6642
- return [
6643
- { intent: "List the bucket's source-ws subdirs", command: `ls ${this.bucketDir}` },
6644
- {
6645
- intent: "Inspect the bucket manifest's sources map",
6646
- command: `cat ${this.bucketDir}/manifest.json | head -40`
6647
- }
6648
- ];
6649
- }
6650
- };
6651
- var WorkstreamAlreadyExistsError = class extends Error {
6652
- constructor(workstream) {
6653
- super(
6654
- `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.`
6655
- );
6656
- this.workstream = workstream;
6657
- }
6658
- workstream;
6659
- name = "WorkstreamAlreadyExistsError";
6660
- errorNextSteps() {
6661
- return [
6662
- {
6663
- intent: "Import under a new name (single-source bucket only)",
6664
- command: "mu workstream import <bucket> --workstream <new-name>"
6665
- },
6666
- {
6667
- intent: "Or destroy the existing workstream first",
6668
- command: `mu workstream destroy -w ${this.workstream} --yes`
6669
- }
6670
- ];
6671
- }
6672
- };
6673
- var ImportFrontmatterParseError = class extends Error {
6674
- constructor(path, line, raw) {
6675
- super(`failed to parse frontmatter at ${path}:${line}: ${raw}`);
6676
- this.path = path;
6677
- this.line = line;
6678
- this.raw = raw;
6679
- }
6680
- path;
6681
- line;
6682
- raw;
6683
- name = "ImportFrontmatterParseError";
6684
- errorNextSteps() {
6685
- return [{ intent: "Inspect the offending file", command: `sed -n 1,30p ${this.path}` }];
6686
- }
6687
- };
6688
- var ImportEdgeRefMissingError = class extends Error {
6689
- constructor(fromTask, toTask, direction) {
6690
- super(
6691
- `task "${fromTask}" references "${toTask}" via ${direction}, but no task with that id was found in the import`
6692
- );
6693
- this.fromTask = fromTask;
6694
- this.toTask = toTask;
6695
- this.direction = direction;
6696
- }
6697
- fromTask;
6698
- toTask;
6699
- direction;
6700
- name = "ImportEdgeRefMissingError";
6701
- errorNextSteps() {
6702
- return [
6703
- {
6704
- intent: "Inspect the offending task file in the bucket",
6705
- command: `grep -l 'id: "${this.fromTask}"' <bucket>/*/tasks/`
6706
- }
6707
- ];
6708
- }
6709
- };
6710
- function unquote(raw) {
6711
- const trimmed = raw.trim();
6712
- if (trimmed === "null") return null;
6713
- if (trimmed.length < 2 || trimmed[0] !== '"' || trimmed[trimmed.length - 1] !== '"') {
6714
- return raw.trim();
6715
- }
6716
- const inner = trimmed.slice(1, -1);
6717
- let out = "";
6718
- for (let i = 0; i < inner.length; i++) {
6719
- const ch = inner[i];
6720
- if (ch === "\\" && i + 1 < inner.length) {
6721
- const next = inner[i + 1];
6722
- if (next === '"' || next === "\\") {
6723
- out += next;
6724
- i += 1;
6725
- continue;
6726
- }
6727
- }
6728
- if (ch !== void 0) out += ch;
6729
- }
6730
- return out;
6731
- }
6732
- function parseStringArray(raw) {
6733
- const trimmed = raw.trim();
6734
- if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
6735
- throw new Error(`expected [..] array, got ${JSON.stringify(raw)}`);
6736
- }
6737
- const inner = trimmed.slice(1, -1).trim();
6738
- if (inner === "") return [];
6739
- const out = [];
6740
- let i = 0;
6741
- while (i < inner.length) {
6742
- while (i < inner.length && (inner[i] === " " || inner[i] === ",")) i += 1;
6743
- if (i >= inner.length) break;
6744
- if (inner[i] !== '"') {
6745
- throw new Error(`expected quoted string in array, got ${JSON.stringify(inner.slice(i))}`);
6746
- }
6747
- let j = i + 1;
6748
- while (j < inner.length) {
6749
- if (inner[j] === "\\" && j + 1 < inner.length) {
6750
- j += 2;
6751
- continue;
6752
- }
6753
- if (inner[j] === '"') break;
6754
- j += 1;
6755
- }
6756
- if (j >= inner.length) {
6757
- throw new Error("unterminated quoted string in array");
6758
- }
6759
- const scalar = unquote(inner.slice(i, j + 1));
6760
- if (scalar === null) {
6761
- throw new Error("null is not a legal array element");
6762
- }
6763
- out.push(scalar);
6764
- i = j + 1;
6765
- }
6766
- return out;
6767
- }
6768
- function parseTaskMarkdown(path) {
6769
- const raw = readFileSync2(path, "utf8");
6770
- const lines = raw.split("\n");
6771
- if (lines[0] !== "---") {
6772
- throw new ImportFrontmatterParseError(path, 1, lines[0] ?? "");
6773
- }
6774
- let end = -1;
6775
- for (let i2 = 1; i2 < lines.length; i2++) {
6776
- if (lines[i2] === "---") {
6777
- end = i2;
6778
- break;
6779
- }
6780
- }
6781
- if (end === -1) {
6782
- throw new ImportFrontmatterParseError(path, 1, "missing closing '---' for frontmatter");
6783
- }
6784
- const fields = {};
6785
- for (let i2 = 1; i2 < end; i2++) {
6786
- const line = lines[i2] ?? "";
6787
- const colon = line.indexOf(":");
6788
- if (colon === -1) {
6789
- throw new ImportFrontmatterParseError(path, i2 + 1, line);
6790
- }
6791
- const key = line.slice(0, colon).trim();
6792
- const value = line.slice(colon + 1).trim();
6793
- fields[key] = value;
6794
- }
6795
- function require2(key) {
6796
- const v = fields[key];
6797
- if (v === void 0) {
6798
- throw new ImportFrontmatterParseError(path, 1, `missing frontmatter key: ${key}`);
6799
- }
6800
- return v;
6801
- }
6802
- const id = unquote(require2("id"));
6803
- if (id === null || id === "") {
6804
- throw new ImportFrontmatterParseError(path, 1, "id must be a non-empty string");
6805
- }
6806
- const workstream = unquote(require2("workstream"));
6807
- if (workstream === null || workstream === "") {
6808
- throw new ImportFrontmatterParseError(path, 1, "workstream must be a non-empty string");
6809
- }
6810
- const statusRaw = require2("status");
6811
- if (!isTaskStatus(statusRaw)) {
6812
- throw new ImportFrontmatterParseError(path, 1, `unknown status: ${statusRaw}`);
6813
- }
6814
- const impact = Number(require2("impact"));
6815
- const effortDays = Number(require2("effort_days"));
6816
- if (!Number.isFinite(impact) || !Number.isFinite(effortDays)) {
6817
- throw new ImportFrontmatterParseError(path, 1, "impact / effort_days must be numeric");
6818
- }
6819
- const ownerName = unquote(require2("owner"));
6820
- const createdAt = unquote(require2("created_at"));
6821
- const updatedAt = unquote(require2("updated_at"));
6822
- if (createdAt === null || updatedAt === null) {
6823
- throw new ImportFrontmatterParseError(path, 1, "created_at / updated_at cannot be null");
6824
- }
6825
- let blockedBy;
6826
- let blocks;
6827
- try {
6828
- blockedBy = parseStringArray(require2("blocked_by"));
6829
- blocks = parseStringArray(require2("blocks"));
6830
- } catch (err) {
6831
- throw new ImportFrontmatterParseError(
6832
- path,
6833
- 1,
6834
- err instanceof Error ? err.message : String(err)
6835
- );
6836
- }
6837
- let bodyIdx = end + 1;
6838
- while (bodyIdx < lines.length && lines[bodyIdx] === "") bodyIdx += 1;
6839
- const titleLine = lines[bodyIdx] ?? "";
6840
- if (!titleLine.startsWith("# ")) {
6841
- throw new ImportFrontmatterParseError(
6842
- path,
6843
- bodyIdx + 1,
6844
- "expected '# <title>' after frontmatter"
6845
- );
6846
- }
6847
- const title = titleLine.slice(2).trim();
6848
- const notes = [];
6849
- let i = bodyIdx + 1;
6850
- while (i < lines.length && !(lines[i] ?? "").startsWith("## Notes (")) i += 1;
6851
- if (i < lines.length) {
6852
- i += 1;
6853
- while (i < lines.length) {
6854
- const line = lines[i] ?? "";
6855
- if (!line.startsWith("### #")) {
6856
- i += 1;
6857
- continue;
6858
- }
6859
- const headerRest = line.slice(line.indexOf(" by ") + 4);
6860
- const lastComma = headerRest.lastIndexOf(", ");
6861
- if (lastComma === -1) {
6862
- throw new ImportFrontmatterParseError(path, i + 1, line);
6863
- }
6864
- const authorRaw = headerRest.slice(0, lastComma);
6865
- const author = unquote(authorRaw);
6866
- const createdAtNote = headerRest.slice(lastComma + 2);
6867
- i += 1;
6868
- while (i < lines.length && lines[i] === "") i += 1;
6869
- const openFence = lines[i] ?? "";
6870
- if (!/^`{3,}$/.test(openFence)) {
6871
- throw new ImportFrontmatterParseError(path, i + 1, openFence);
6872
- }
6873
- const fence = openFence;
6874
- i += 1;
6875
- const contentLines = [];
6876
- while (i < lines.length && lines[i] !== fence) {
6877
- contentLines.push(lines[i] ?? "");
6878
- i += 1;
6879
- }
6880
- if (i >= lines.length) {
6881
- throw new ImportFrontmatterParseError(path, i + 1, `unterminated note fence ${fence}`);
6882
- }
6883
- i += 1;
6884
- notes.push({ author, createdAt: createdAtNote, content: contentLines.join("\n") });
6885
- }
6886
- }
6887
- return {
6888
- id,
6889
- workstream,
6890
- status: statusRaw,
6891
- impact,
6892
- effortDays,
6893
- ownerName,
6894
- createdAt,
6895
- updatedAt,
6896
- blockedBy,
6897
- blocks,
6898
- title,
6899
- notes
6900
- };
6901
- }
6902
- function walkBucket(bucketDir) {
6903
- if (!existsSync12(bucketDir)) {
6904
- throw new ImportBucketInvalidError(bucketDir, "directory does not exist");
6905
- }
6906
- if (!statSync4(bucketDir).isDirectory()) {
6907
- throw new ImportBucketInvalidError(bucketDir, "not a directory");
6908
- }
6909
- const manifestPath = join8(bucketDir, "manifest.json");
6910
- const probe = readManifest(manifestPath);
6911
- if (probe.kind === "v2") {
6912
- const manifest = probe.manifest;
6913
- const sources = [];
6914
- for (const entry of readdirSync3(bucketDir, { withFileTypes: true })) {
6915
- if (!entry.isDirectory()) continue;
6916
- const sourceDir = join8(bucketDir, entry.name);
6917
- const tasksDir2 = join8(sourceDir, "tasks");
6918
- if (!existsSync12(tasksDir2) || !statSync4(tasksDir2).isDirectory()) continue;
6919
- const taskFiles2 = [];
6920
- for (const f of readdirSync3(tasksDir2, { withFileTypes: true })) {
6921
- if (f.isFile() && f.name.endsWith(".md")) {
6922
- taskFiles2.push(join8(tasksDir2, f.name));
6923
- }
6924
- }
6925
- taskFiles2.sort();
6926
- sources.push({ diskName: entry.name, taskFiles: taskFiles2 });
6927
- }
6928
- sources.sort((a, b) => a.diskName.localeCompare(b.diskName));
6929
- return { manifest, sources, shape: "bucket" };
6930
- }
6931
- if (probe.kind === "corrupt") {
6932
- throw new ImportBucketInvalidError(bucketDir, "manifest.json is unreadable / malformed");
6933
- }
6934
- const tasksDir = join8(bucketDir, "tasks");
6935
- const looksLikeSourceWs = existsSync12(join8(bucketDir, "README.md")) && existsSync12(join8(bucketDir, "INDEX.md")) && existsSync12(tasksDir) && statSync4(tasksDir).isDirectory();
6936
- if (!looksLikeSourceWs) {
6937
- throw new ImportBucketInvalidError(bucketDir, "manifest.json missing");
6938
- }
6939
- const parentDir = dirname5(bucketDir);
6940
- const baseName = basename2(bucketDir);
6941
- const parentProbe = readManifest(join8(parentDir, "manifest.json"));
6942
- if (parentProbe.kind !== "v2") {
6943
- throw new ImportBucketInvalidError(
6944
- bucketDir,
6945
- `${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.`
6946
- );
6947
- }
6948
- const parentManifest = parentProbe.manifest;
6949
- if (!Object.prototype.hasOwnProperty.call(parentManifest.sources ?? {}, baseName)) {
6950
- throw new ImportBucketInvalidError(
6951
- bucketDir,
6952
- `${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.`
6953
- );
6954
- }
6955
- const taskFiles = [];
6956
- for (const f of readdirSync3(tasksDir, { withFileTypes: true })) {
6957
- if (f.isFile() && f.name.endsWith(".md")) {
6958
- taskFiles.push(join8(tasksDir, f.name));
6959
- }
6960
- }
6961
- taskFiles.sort();
6962
- return {
6963
- manifest: parentManifest,
6964
- sources: [{ diskName: baseName, taskFiles }],
6965
- shape: "sourceWsSubdir"
6966
- };
6967
- }
6968
- function importOneSource(db, targetWorkstream, parsed, options) {
6969
- const idSet = new Set(parsed.map((p) => p.id));
6970
- for (const t of parsed) {
6971
- for (const blocker of t.blockedBy) {
6972
- if (!idSet.has(blocker)) {
6973
- throw new ImportEdgeRefMissingError(t.id, blocker, "blocked_by");
6974
- }
6975
- }
6976
- for (const dep of t.blocks) {
6977
- if (!idSet.has(dep)) {
6978
- throw new ImportEdgeRefMissingError(t.id, dep, "blocks");
6979
- }
6980
- }
6981
- }
6982
- if (options.dryRun) {
6983
- const edgePairs = /* @__PURE__ */ new Set();
6984
- for (const t of parsed) {
6985
- for (const blocker of t.blockedBy) edgePairs.add(`${blocker}\0${t.id}`);
6986
- for (const dep of t.blocks) edgePairs.add(`${t.id}\0${dep}`);
6987
- }
6988
- const noteCount = parsed.reduce((acc, p) => acc + p.notes.length, 0);
6989
- return {
6990
- tasksImported: parsed.length,
6991
- edgesImported: edgePairs.size,
6992
- notesImported: noteCount
6993
- };
6994
- }
6995
- return db.transaction(() => {
6996
- ensureWorkstream(db, targetWorkstream);
6997
- const wsId = resolveWorkstreamId(db, targetWorkstream);
6998
- const insertTask = db.prepare(
6999
- `INSERT INTO tasks
7000
- (workstream_id, local_id, title, status, impact, effort_days, owner_id, created_at, updated_at)
7001
- VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?)`
7002
- );
7003
- const taskIdByLocalId = /* @__PURE__ */ new Map();
7004
- for (const t of parsed) {
7005
- const r = insertTask.run(
7006
- wsId,
7007
- t.id,
7008
- t.title,
7009
- t.status,
7010
- t.impact,
7011
- t.effortDays,
7012
- t.createdAt,
7013
- t.updatedAt
7014
- );
7015
- taskIdByLocalId.set(t.id, Number(r.lastInsertRowid));
7016
- }
7017
- const insertEdge = db.prepare(
7018
- "INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at) VALUES (?, ?, ?)"
7019
- );
7020
- const seenEdges = /* @__PURE__ */ new Set();
7021
- let edgesImported = 0;
7022
- const now = (/* @__PURE__ */ new Date()).toISOString();
7023
- const recordEdge = (fromLocal, toLocal) => {
7024
- const key = `${fromLocal}\0${toLocal}`;
7025
- if (seenEdges.has(key)) return;
7026
- seenEdges.add(key);
7027
- const fromId = taskIdByLocalId.get(fromLocal);
7028
- const toId = taskIdByLocalId.get(toLocal);
7029
- if (fromId === void 0 || toId === void 0) return;
7030
- const r = insertEdge.run(fromId, toId, now);
7031
- if (r.changes > 0) edgesImported += 1;
7032
- };
7033
- for (const t of parsed) {
7034
- for (const blocker of t.blockedBy) recordEdge(blocker, t.id);
7035
- for (const dep of t.blocks) recordEdge(t.id, dep);
7036
- }
7037
- const insertNote = db.prepare(
7038
- "INSERT INTO task_notes (task_id, author, content, created_at) VALUES (?, ?, ?, ?)"
7039
- );
7040
- let notesImported = 0;
7041
- for (const t of parsed) {
7042
- const taskId = taskIdByLocalId.get(t.id);
7043
- if (taskId === void 0) continue;
7044
- for (const note of t.notes) {
7045
- insertNote.run(taskId, note.author, note.content, note.createdAt);
7046
- notesImported += 1;
7047
- }
7048
- }
7049
- emitEvent(
7050
- db,
7051
- targetWorkstream,
7052
- `workstream import ${targetWorkstream} (tasks=${parsed.length}, edges=${edgesImported}, notes=${notesImported})`
7053
- );
7054
- return {
7055
- tasksImported: parsed.length,
7056
- edgesImported,
7057
- notesImported
7058
- };
7059
- })();
7060
- }
7061
- function importBucket(db, opts) {
7062
- const { manifest, sources, shape } = walkBucket(opts.bucketDir);
7063
- if (shape === "sourceWsSubdir" && opts.sourceWs !== void 0 && opts.sourceWs.length > 0) {
7064
- throw new ImportBucketInvalidError(
7065
- opts.bucketDir,
7066
- `cannot pass --source-ws when ${opts.bucketDir} is itself a source-ws subdir; drop the flag`
7067
- );
7068
- }
7069
- let filteredSources = sources;
7070
- if (opts.sourceWs !== void 0 && opts.sourceWs.length > 0) {
7071
- const validNames = Object.keys(manifest.sources ?? {}).sort();
7072
- const validSet = new Set(validNames);
7073
- for (const wanted of opts.sourceWs) {
7074
- if (!validSet.has(wanted)) {
7075
- throw new ImportSourceNotInBucketError(opts.bucketDir, wanted, validNames);
7076
- }
7077
- }
7078
- const wantedSet = new Set(opts.sourceWs);
7079
- filteredSources = sources.filter((s) => wantedSet.has(s.diskName));
7080
- }
7081
- if (opts.workstreamOverride !== void 0 && filteredSources.length !== 1) {
7082
- throw new ImportBucketInvalidError(
7083
- opts.bucketDir,
7084
- `--workstream override requires a single source-ws subdir; this bucket has ${filteredSources.length}`
7085
- );
7086
- }
7087
- const dryRun = opts.dryRun === true;
7088
- const results = [];
7089
- for (const source of filteredSources) {
7090
- const targetName = opts.workstreamOverride ?? source.diskName;
7091
- const result = {
7092
- workstreamName: targetName,
7093
- tasksImported: 0,
7094
- edgesImported: 0,
7095
- notesImported: 0,
7096
- tombstonesSkipped: 0,
7097
- errors: []
7098
- };
7099
- const liveFiles = [];
7100
- for (const file of source.taskFiles) {
7101
- const head = readFileSync2(file, "utf8").slice(0, DELETED_BANNER_PREFIX.length);
7102
- if (head.startsWith(DELETED_BANNER_PREFIX)) {
7103
- result.tombstonesSkipped += 1;
7104
- continue;
7105
- }
7106
- liveFiles.push(file);
7107
- }
7108
- if (!dryRun) {
7109
- const existing = db.prepare("SELECT 1 AS x FROM workstreams WHERE name = ?").get(targetName);
7110
- if (existing !== void 0) {
7111
- const err = new WorkstreamAlreadyExistsError(targetName);
7112
- result.errors.push(err.message);
7113
- results.push(result);
7114
- throw err;
7115
- }
7116
- }
7117
- let parsed;
7118
- try {
7119
- parsed = liveFiles.map(parseTaskMarkdown);
7120
- } catch (err) {
7121
- result.errors.push(err instanceof Error ? err.message : String(err));
7122
- results.push(result);
7123
- throw err;
7124
- }
7125
- try {
7126
- const counts = importOneSource(db, targetName, parsed, { dryRun });
7127
- result.tasksImported = counts.tasksImported;
7128
- result.edgesImported = counts.edgesImported;
7129
- result.notesImported = counts.notesImported;
7130
- } catch (err) {
7131
- result.errors.push(err instanceof Error ? err.message : String(err));
7132
- results.push(result);
7133
- throw err;
7134
- }
7135
- results.push(result);
7136
- }
7137
- return {
7138
- bucketLabel: manifest.bucketLabel,
7139
- bucketVersion: manifest.bucketVersion,
7140
- sources: results
7141
- };
7142
- }
7143
-
7144
7607
  // src/doctor-summary.ts
7145
7608
  function loadDoctorSummary(db, snapshot) {
7146
7609
  const checks = [];
@@ -7244,7 +7707,7 @@ function loadDoctorSummary(db, snapshot) {
7244
7707
  checks.push({
7245
7708
  name: "agents",
7246
7709
  status: "warn",
7247
- detail: `${ghosts} ghost pane${ghosts === 1 ? "" : "s"}; run \`mu agent list\``
7710
+ detail: `${ghosts} ghost pane${ghosts === 1 ? "" : "s"}; run \`mu state\` or \`mu agent list\` to reap`
7248
7711
  });
7249
7712
  }
7250
7713
  const orphanPanes = snapshot.view.orphans.length;
@@ -7281,7 +7744,7 @@ function loadDoctorChecks(db, snapshot) {
7281
7744
  function yankCommandForCheck(check) {
7282
7745
  switch (check.name) {
7283
7746
  case "agents":
7284
- return "mu agent list";
7747
+ return "mu state";
7285
7748
  case "panes":
7286
7749
  return "mu agent adopt";
7287
7750
  case "workspaces":
@@ -7299,10 +7762,10 @@ function remediationParagraph(check) {
7299
7762
  switch (check.name) {
7300
7763
  case "agents":
7301
7764
  return [
7302
- "A 'ghost pane' is a tmux pane that mu's reconcile pass would",
7303
- "prune on the next mutation. Run `mu agent list` to see the",
7304
- "current state, then `mu agent close <name>` if the agent is",
7305
- "stale. The TUI is read-only \u2014 no auto-prune."
7765
+ "A 'ghost pane' is a registered agent whose tmux pane is gone.",
7766
+ "Doctor only reports the count. Run `mu state` or `mu agent list`",
7767
+ "to reap ghost agents and return their IN_PROGRESS tasks to OPEN.",
7768
+ "The TUI is read-only, but its slow tick uses the same state reap."
7306
7769
  ];
7307
7770
  case "panes":
7308
7771
  return [
@@ -7371,7 +7834,7 @@ async function loadWorkstreamSnapshotFast(db, workstream, opts = {}) {
7371
7834
  };
7372
7835
  }
7373
7836
  async function loadWorkstreamSnapshotSlow(db, workstream, opts = {}, baseSnapshot) {
7374
- const view = await listLiveAgents(db, { workstream, mode: "status-only" });
7837
+ const view = await listLiveAgents(db, { workstream });
7375
7838
  let workspaces = listWorkspaces(db, workstream);
7376
7839
  if (opts.withDirty === true) workspaces = await decorateWithDirty(workspaces);
7377
7840
  const commits = await loadRecentCommits(opts.withRecentCommits);
@@ -7414,7 +7877,7 @@ function emptyLiveAgentsView() {
7414
7877
  return {
7415
7878
  agents: [],
7416
7879
  orphans: [],
7417
- report: { prunedGhosts: 0, statusChanges: 0, orphans: [], mode: "status-only" }
7880
+ report: { prunedGhosts: 0, statusChanges: 0, orphans: [], mode: "full" }
7418
7881
  };
7419
7882
  }
7420
7883
  function minimalSnapshot(workstream) {
@@ -7476,14 +7939,14 @@ function agentStatusHistogram(agents) {
7476
7939
  return out;
7477
7940
  }
7478
7941
  function summarizeOwnedTasks(owned) {
7479
- const count = owned.length;
7480
- if (count === 0) return { bit: "\u2014", count: 0 };
7481
- if (count === 1) {
7942
+ const count2 = owned.length;
7943
+ if (count2 === 0) return { bit: "\u2014", count: 0 };
7944
+ if (count2 === 1) {
7482
7945
  const only = owned[0];
7483
7946
  if (!only) return { bit: "\u2014", count: 0 };
7484
7947
  return { bit: only.name, count: 1, onlyTaskId: only.name };
7485
7948
  }
7486
- return { bit: `\u2295${count}`, count };
7949
+ return { bit: `\u2295${count2}`, count: count2 };
7487
7950
  }
7488
7951
  export {
7489
7952
  AgentDiedOnSpawnError,
@@ -7495,16 +7958,22 @@ export {
7495
7958
  ArchiveAlreadyExistsError,
7496
7959
  ArchiveLabelInvalidError,
7497
7960
  ArchiveNotFoundError,
7961
+ ArchiveSourceAmbiguousError,
7498
7962
  CURRENT_SCHEMA_VERSION,
7499
7963
  ClaimerNotRegisteredError,
7500
7964
  CrossWorkstreamEdgeError,
7501
7965
  CycleError,
7966
+ DbExportTargetExistsError,
7967
+ DbImportConflictError,
7968
+ DbImportManifestMissingError,
7969
+ DbImportSchemaTooNewError,
7970
+ DbImportSchemaTooOldError,
7971
+ DbImportSourceStaleError,
7972
+ DbReplayLocalIdConflictError,
7973
+ DbReplayWorkstreamMissingError,
7502
7974
  EVENT_VERB_PREFIXES,
7503
7975
  EXPECTED_TABLES,
7504
7976
  HomeDirAsProjectRootError,
7505
- ImportBucketInvalidError,
7506
- ImportEdgeRefMissingError,
7507
- ImportFrontmatterParseError,
7508
7977
  NoForegroundProcessError,
7509
7978
  PANE_ID_RE,
7510
7979
  PaneNotFoundError,
@@ -7524,21 +7993,25 @@ export {
7524
7993
  TaskNotInWorkstreamError,
7525
7994
  TmuxError,
7526
7995
  WORKSPACE_STALE_THRESHOLD,
7996
+ WORKSTREAM_PARKED_THRESHOLD_DAYS,
7527
7997
  WorkspaceExistsError,
7528
7998
  WorkspaceNotFoundError,
7529
7999
  WorkspacePathNotEmptyError,
7530
8000
  WorkspacePreservedError,
7531
- WorkstreamAlreadyExistsError,
8001
+ WorkstreamExistsError,
7532
8002
  WorkstreamNameInvalidError,
7533
8003
  addBlockEdge,
7534
8004
  addNote,
7535
8005
  addTask,
7536
8006
  addToArchive,
7537
8007
  adoptAgent,
8008
+ agentStatusGlyph,
7538
8009
  agentStatusHistogram,
7539
8010
  appendLog,
7540
8011
  assertValidPaneId,
7541
8012
  backendByName,
8013
+ buildImportPlan,
8014
+ buildReplayPlan,
7542
8015
  capturePane,
7543
8016
  captureSnapshot,
7544
8017
  checkCommandResolvable,
@@ -7570,6 +8043,7 @@ export {
7570
8043
  ensureWorkstream,
7571
8044
  envVarNameForCli,
7572
8045
  exportArchive,
8046
+ exportDb,
7573
8047
  exportSourceForWorkstream,
7574
8048
  exportSourcesForArchive,
7575
8049
  exportWorkstream,
@@ -7594,7 +8068,7 @@ export {
7594
8068
  gitBackend,
7595
8069
  idFromTitle,
7596
8070
  idFromTitleVerbose,
7597
- importBucket,
8071
+ importDb,
7598
8072
  insertAgent,
7599
8073
  isKickSignal,
7600
8074
  isStaleVersion,
@@ -7647,6 +8121,7 @@ export {
7647
8121
  openTask,
7648
8122
  paneExists,
7649
8123
  paneTTY,
8124
+ parkedStatus,
7650
8125
  parseAgentNameFromTitle,
7651
8126
  parsePsTtyOutput,
7652
8127
  pruneSnapshots,
@@ -7663,6 +8138,7 @@ export {
7663
8138
  renderTaskTree,
7664
8139
  renderToBucket,
7665
8140
  reparentTask,
8141
+ replayDb,
7666
8142
  resetCommandResolverForTests,
7667
8143
  resetKickProcessExecutor,
7668
8144
  resetSleep,
@@ -7671,6 +8147,7 @@ export {
7671
8147
  resolveActorIdentity,
7672
8148
  resolveCliCommand,
7673
8149
  resolveCliCommandWithSource,
8150
+ restoreArchive,
7674
8151
  restoreSnapshot,
7675
8152
  roiBucket,
7676
8153
  searchArchives,