@martintrojer/mu 0.4.0 → 0.4.1

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,13 +593,13 @@ 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).
@@ -2236,6 +2264,10 @@ var WorkspacePreservedError = class extends Error {
2236
2264
  intent: "Or close + discard the workspace in one shot (lossy)",
2237
2265
  command: `mu agent close ${this.agentName} --discard-workspace`
2238
2266
  },
2267
+ {
2268
+ intent: "If the workstream was archived, restore task memory under a fresh name",
2269
+ command: "mu archive restore <label> --as <new-workstream> --source <workstream>"
2270
+ },
2239
2271
  {
2240
2272
  intent: "Or just inspect what's in the workspace",
2241
2273
  command: `cd ${this.workspacePath}`
@@ -4568,6 +4600,106 @@ function searchArchives(db, opts) {
4568
4600
  return hits.slice(0, limit);
4569
4601
  }
4570
4602
 
4603
+ // src/archives/restore.ts
4604
+ var ArchiveSourceAmbiguousError = class extends Error {
4605
+ constructor(label, sources) {
4606
+ super(
4607
+ sources.length === 0 ? `archive ${label} contains no source workstreams` : `archive ${label} requires --source <orig-ws-name>. Available: ${sources.join(", ")}`
4608
+ );
4609
+ this.label = label;
4610
+ this.sources = sources;
4611
+ }
4612
+ label;
4613
+ sources;
4614
+ name = "ArchiveSourceAmbiguousError";
4615
+ errorNextSteps() {
4616
+ return [
4617
+ { intent: "Inspect archive sources", command: `mu archive show ${this.label}` },
4618
+ ...this.sources.map((source) => ({
4619
+ intent: `Restore source workstream ${source}`,
4620
+ command: `mu archive restore ${this.label} --source ${source} --as <new-workstream>`
4621
+ }))
4622
+ ];
4623
+ }
4624
+ };
4625
+ function restoreArchive(db, label, asWorkstream, opts = {}) {
4626
+ const archiveId = resolveArchiveId(db, label);
4627
+ const sources = listSources(db, archiveId);
4628
+ if (!isValidWorkstreamName(asWorkstream)) throw new WorkstreamNameInvalidError(asWorkstream);
4629
+ const sourceWorkstream = opts.sourceWorkstream ?? sources[0];
4630
+ if (sourceWorkstream === void 0) throw new ArchiveSourceAmbiguousError(label, sources);
4631
+ if (opts.sourceWorkstream === void 0 && sources.length > 1) {
4632
+ throw new ArchiveSourceAmbiguousError(label, sources);
4633
+ }
4634
+ if (opts.sourceWorkstream !== void 0 && !sources.includes(opts.sourceWorkstream)) {
4635
+ throw new ArchiveSourceAmbiguousError(label, sources);
4636
+ }
4637
+ if (tryResolveWorkstreamId(db, asWorkstream) !== null) {
4638
+ throw new WorkstreamExistsError(asWorkstream);
4639
+ }
4640
+ captureSnapshot(db, `archive restore ${label} as ${asWorkstream}`, null);
4641
+ return db.transaction(() => {
4642
+ ensureWorkstream(db, asWorkstream);
4643
+ const wsId = resolveWorkstreamId(db, asWorkstream);
4644
+ const restoredTasks = db.prepare(
4645
+ `INSERT INTO tasks
4646
+ (workstream_id, local_id, title, status, impact, effort_days, owner_id, created_at, updated_at)
4647
+ SELECT ?, original_local_id, title, status, impact, effort_days, NULL,
4648
+ original_created_at, original_updated_at
4649
+ FROM archived_tasks
4650
+ WHERE archive_id = ? AND source_workstream = ?
4651
+ ORDER BY id`
4652
+ ).run(wsId, archiveId, sourceWorkstream).changes;
4653
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4654
+ const restoredEdges = db.prepare(
4655
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
4656
+ SELECT live_from.id, live_to.id, ?
4657
+ FROM archived_edges e
4658
+ JOIN archived_tasks arch_from ON arch_from.id = e.from_archived_id
4659
+ JOIN archived_tasks arch_to ON arch_to.id = e.to_archived_id
4660
+ JOIN tasks live_from ON live_from.workstream_id = ?
4661
+ AND live_from.local_id = arch_from.original_local_id
4662
+ JOIN tasks live_to ON live_to.workstream_id = ?
4663
+ AND live_to.local_id = arch_to.original_local_id
4664
+ WHERE e.archive_id = ?
4665
+ AND arch_from.source_workstream = ?
4666
+ AND arch_to.source_workstream = ?`
4667
+ ).run(now, wsId, wsId, archiveId, sourceWorkstream, sourceWorkstream).changes;
4668
+ const restoredNotes = db.prepare(
4669
+ `INSERT INTO task_notes (task_id, author, content, created_at)
4670
+ SELECT live.id, n.author, n.content, n.created_at
4671
+ FROM archived_notes n
4672
+ JOIN archived_tasks arch ON arch.id = n.archived_task_id
4673
+ JOIN tasks live ON live.workstream_id = ?
4674
+ AND live.local_id = arch.original_local_id
4675
+ WHERE n.archive_id = ? AND arch.source_workstream = ?
4676
+ ORDER BY n.id`
4677
+ ).run(wsId, archiveId, sourceWorkstream).changes;
4678
+ emitEvent(
4679
+ db,
4680
+ asWorkstream,
4681
+ `archive restore ${label} source=${sourceWorkstream} as ${asWorkstream} (tasks=${restoredTasks}, edges=${restoredEdges}, notes=${restoredNotes})`
4682
+ );
4683
+ return {
4684
+ archiveLabel: label,
4685
+ sourceWorkstream,
4686
+ workstreamName: asWorkstream,
4687
+ restoredTasks,
4688
+ restoredEdges,
4689
+ restoredNotes
4690
+ };
4691
+ })();
4692
+ }
4693
+ function listSources(db, archiveId) {
4694
+ return db.prepare(
4695
+ `SELECT source_workstream AS name
4696
+ FROM archived_tasks
4697
+ WHERE archive_id = ?
4698
+ GROUP BY source_workstream
4699
+ ORDER BY source_workstream`
4700
+ ).all(archiveId).map((row) => row.name);
4701
+ }
4702
+
4571
4703
  // src/tasks/status.ts
4572
4704
  var TASK_STATUSES = [
4573
4705
  "OPEN",
@@ -5145,6 +5277,27 @@ function isValidWorkstreamName(name) {
5145
5277
  if (name.startsWith(RESERVED_WORKSTREAM_PREFIX)) return false;
5146
5278
  return true;
5147
5279
  }
5280
+ var WorkstreamExistsError = class extends Error {
5281
+ constructor(workstream) {
5282
+ super(`workstream already exists: ${workstream}`);
5283
+ this.workstream = workstream;
5284
+ }
5285
+ workstream;
5286
+ name = "WorkstreamExistsError";
5287
+ errorNextSteps() {
5288
+ return [
5289
+ {
5290
+ intent: "Pick a different workstream name",
5291
+ command: "mu archive restore <label> --as <new-name>"
5292
+ },
5293
+ { intent: "List existing workstreams", command: "mu workstream list" },
5294
+ {
5295
+ intent: "Destroy the existing workstream first",
5296
+ command: `mu workstream destroy -w ${this.workstream} --yes`
5297
+ }
5298
+ ];
5299
+ }
5300
+ };
5148
5301
  var WorkstreamNameInvalidError = class extends Error {
5149
5302
  constructor(attempted) {
5150
5303
  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.`;
@@ -6389,6 +6542,8 @@ async function listLiveAgents(db, opts) {
6389
6542
  }
6390
6543
 
6391
6544
  // src/dag.ts
6545
+ import pc2 from "picocolors";
6546
+ var RECURRENCE_MARKER = ` ${pc2.dim("(\u21BB)")}`;
6392
6547
  function loadFullDag(db, workstream, opts = {}) {
6393
6548
  const tasks = listTasks(db, workstream).filter(
6394
6549
  (t) => opts.statuses === void 0 || opts.statuses.has(t.status)
@@ -6427,7 +6582,7 @@ function renderForest(roots, edges, statusFn, tasksByName, opts = {}) {
6427
6582
  if (!byName.has(root.name)) byName.set(root.name, root);
6428
6583
  const lines = [formatTreeNodeLabel(root, statusFn, opts)];
6429
6584
  if (seen.has(root.name)) {
6430
- lines[0] = `${lines[0]} (\u21BB already shown above)`;
6585
+ lines[0] = `${lines[0]}${RECURRENCE_MARKER}`;
6431
6586
  } else {
6432
6587
  seen.add(root.name);
6433
6588
  renderForestChildren(root.name, "", edges, byName, statusFn, seen, lines, opts);
@@ -6486,7 +6641,7 @@ function renderForestChildren(taskName, prefix, edges, byName, statusFn, seen, l
6486
6641
  }
6487
6642
  if (seen.has(childName)) {
6488
6643
  lines.push(
6489
- `${prefix}${branch}${formatTreeNodeLabel(child, statusFn, opts)} (\u21BB already shown above)`
6644
+ `${prefix}${branch}${formatTreeNodeLabel(child, statusFn, opts)}${RECURRENCE_MARKER}`
6490
6645
  );
6491
6646
  continue;
6492
6647
  }
@@ -6501,6 +6656,811 @@ function formatTreeNodeLabel(t, statusFn, opts = {}) {
6501
6656
  return `${base} ${t.title}`;
6502
6657
  }
6503
6658
 
6659
+ // src/db-sync.ts
6660
+ import { randomUUID as randomUUID2 } from "crypto";
6661
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4, readFileSync as readFileSync2, unlinkSync as unlinkSync4, writeFileSync as writeFileSync2 } from "fs";
6662
+ import { hostname as hostname2 } from "os";
6663
+ import { dirname as dirname5, join as join8 } from "path";
6664
+ import { fileURLToPath as fileURLToPath2 } from "url";
6665
+
6666
+ // src/db-sync-replay.ts
6667
+ import { createHash as createHash2 } from "crypto";
6668
+ var DbReplayWorkstreamMissingError = class extends Error {
6669
+ constructor(workstream) {
6670
+ super(
6671
+ `replay sidecar is for workstream "${workstream}", which does not exist locally; restore it first via mu db import or mu archive restore`
6672
+ );
6673
+ this.workstream = workstream;
6674
+ }
6675
+ workstream;
6676
+ name = "DbReplayWorkstreamMissingError";
6677
+ errorNextSteps() {
6678
+ return [
6679
+ {
6680
+ intent: "Restore this workstream from a DB export",
6681
+ command: "mu db import <file> --apply"
6682
+ },
6683
+ {
6684
+ intent: "Or restore it from an archive",
6685
+ command: `mu archive restore <label> --as ${this.workstream}`
6686
+ }
6687
+ ];
6688
+ }
6689
+ };
6690
+ var DbReplayLocalIdConflictError = class extends Error {
6691
+ constructor(workstream, conflicts) {
6692
+ super(
6693
+ `sidecar task id collides with different local content in ${workstream}: ${conflicts.map(
6694
+ (c) => `${c.localId} (local: ${c.local.status} ${JSON.stringify(c.local.title)}; sidecar: ${c.sidecar.status} ${JSON.stringify(c.sidecar.title)})`
6695
+ ).join(", ")}`
6696
+ );
6697
+ this.workstream = workstream;
6698
+ this.conflicts = conflicts;
6699
+ }
6700
+ workstream;
6701
+ conflicts;
6702
+ name = "DbReplayLocalIdConflictError";
6703
+ errorNextSteps() {
6704
+ const first = this.conflicts[0];
6705
+ return [
6706
+ {
6707
+ intent: "Create a renamed local task manually, then replay notes if desired",
6708
+ 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>`
6709
+ },
6710
+ {
6711
+ intent: "Skip the colliding id and replay another task",
6712
+ command: "mu db replay <sidecar> --task <other-id> --apply"
6713
+ }
6714
+ ];
6715
+ }
6716
+ };
6717
+ function replayDb(db, file, opts = {}) {
6718
+ const sidecarDb = openDb({ path: file, readonly: true });
6719
+ try {
6720
+ const plan = buildReplayPlan(db, sidecarDb, file);
6721
+ const taskFilter = new Set(opts.tasks ?? []);
6722
+ const noteFilter = new Set(opts.notes ?? []);
6723
+ const selectedTaskIds = opts.all === true ? new Set(plan.tasks.map((t) => t.localId)) : taskFilter;
6724
+ const selectedNoteIds = opts.all === true ? new Set(plan.notes.map((n) => n.taskLocalId)) : noteFilter;
6725
+ const hasSelectors = opts.all === true || selectedTaskIds.size > 0 || selectedNoteIds.size > 0;
6726
+ const noteTaskIds = /* @__PURE__ */ new Set([...selectedNoteIds, ...selectedTaskIds]);
6727
+ const hasWrites = plan.tasks.some((t) => selectedTaskIds.has(t.localId)) || plan.notes.some((n) => noteTaskIds.has(n.taskLocalId)) || plan.edges.some(
6728
+ (e) => opts.all === true || selectedTaskIds.has(e.fromLocalId) || selectedTaskIds.has(e.toLocalId)
6729
+ );
6730
+ const relevantConflicts = opts.all === true ? plan.conflicts : plan.conflicts.filter((c) => selectedTaskIds.has(c.localId));
6731
+ if (relevantConflicts.length > 0) {
6732
+ throw new DbReplayLocalIdConflictError(plan.workstream, relevantConflicts);
6733
+ }
6734
+ if (opts.apply !== true || !hasSelectors) return replayResult(plan, true, false);
6735
+ if (!hasWrites) return replayResult(plan, false, true);
6736
+ const snapshot = captureSnapshot(db, `db replay ${file}`, null);
6737
+ const applied = applyReplayPlan(db, plan, selectedTaskIds, selectedNoteIds, opts.all === true);
6738
+ return { ...replayResult(plan, false, true), snapshotId: snapshot.id, ...applied };
6739
+ } finally {
6740
+ sidecarDb.close();
6741
+ }
6742
+ }
6743
+ function buildReplayPlan(localDb, sidecarDb, sourceFile) {
6744
+ const sidecarWorkstreams = listLocalWorkstreams(sidecarDb);
6745
+ const sidecarWs = sidecarWorkstreams[0];
6746
+ if (sidecarWorkstreams.length !== 1 || !sidecarWs) {
6747
+ throw new Error(
6748
+ `replay sidecar must contain exactly one workstream; found ${sidecarWorkstreams.length}`
6749
+ );
6750
+ }
6751
+ const localWs = listLocalWorkstreams(localDb).find((w) => w.name === sidecarWs.name);
6752
+ if (!localWs) throw new DbReplayWorkstreamMissingError(sidecarWs.name);
6753
+ const localTasks = new Map(
6754
+ localDb.prepare("SELECT local_id, title, status FROM tasks WHERE workstream_id = ?").all(localWs.id).map((t) => [t.local_id, t])
6755
+ );
6756
+ const tasks = [];
6757
+ const conflicts = [];
6758
+ for (const task of listReplayTasks(sidecarDb, sidecarWs.id)) {
6759
+ const local = localTasks.get(task.localId);
6760
+ if (!local) tasks.push(task);
6761
+ else if (local.title !== task.title || local.status !== task.status) {
6762
+ conflicts.push({
6763
+ localId: task.localId,
6764
+ local: { title: local.title, status: local.status },
6765
+ sidecar: { title: task.title, status: task.status }
6766
+ });
6767
+ }
6768
+ }
6769
+ const localNoteHashes = new Set(listReplayNotes(localDb, localWs.id).map((n) => n.hash));
6770
+ const localEdges = new Set(listReplayEdges(localDb, localWs.id).map(edgeKey));
6771
+ return {
6772
+ sourceFile,
6773
+ workstream: sidecarWs.name,
6774
+ tasks,
6775
+ notes: listReplayNotes(sidecarDb, sidecarWs.id).filter((n) => !localNoteHashes.has(n.hash)),
6776
+ edges: listReplayEdges(sidecarDb, sidecarWs.id).filter((e) => !localEdges.has(edgeKey(e))),
6777
+ conflicts
6778
+ };
6779
+ }
6780
+ function applyReplayPlan(db, plan, selectedTaskIds, selectedNoteIds, replayAllEdges) {
6781
+ const warnings = [];
6782
+ const added = db.transaction(() => {
6783
+ const wsId = db.prepare("SELECT id FROM workstreams WHERE name = ?").get(plan.workstream)?.id;
6784
+ if (wsId === void 0) throw new DbReplayWorkstreamMissingError(plan.workstream);
6785
+ const taskIds = new Set(selectedTaskIds);
6786
+ const noteTaskIds = /* @__PURE__ */ new Set([...selectedNoteIds, ...taskIds]);
6787
+ let tasks = 0;
6788
+ let notes = 0;
6789
+ let edges = 0;
6790
+ const insertTask = db.prepare(
6791
+ `INSERT OR IGNORE INTO tasks (workstream_id, local_id, title, status, impact, effort_days, created_at, updated_at)
6792
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
6793
+ );
6794
+ for (const task of plan.tasks) {
6795
+ if (!taskIds.has(task.localId)) continue;
6796
+ const result = insertTask.run(
6797
+ wsId,
6798
+ task.localId,
6799
+ task.title,
6800
+ task.status,
6801
+ task.impact,
6802
+ task.effortDays,
6803
+ task.createdAt,
6804
+ task.updatedAt
6805
+ );
6806
+ if (result.changes > 0) tasks += 1;
6807
+ }
6808
+ const existingNoteHashes = new Set(listReplayNotes(db, wsId).map((n) => n.hash));
6809
+ const insertNote = db.prepare(
6810
+ `INSERT INTO task_notes (task_id, author, content, created_at)
6811
+ SELECT id, ?, ?, ? FROM tasks WHERE workstream_id = ? AND local_id = ?`
6812
+ );
6813
+ for (const note of plan.notes) {
6814
+ if (!noteTaskIds.has(note.taskLocalId) || existingNoteHashes.has(note.hash)) continue;
6815
+ const result = insertNote.run(
6816
+ note.author,
6817
+ note.content,
6818
+ note.createdAt,
6819
+ wsId,
6820
+ note.taskLocalId
6821
+ );
6822
+ if (result.changes > 0) {
6823
+ notes += 1;
6824
+ existingNoteHashes.add(note.hash);
6825
+ }
6826
+ }
6827
+ const insertEdge = db.prepare(
6828
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
6829
+ SELECT f.id, t.id, ?
6830
+ FROM tasks f, tasks t
6831
+ WHERE f.workstream_id = ? AND f.local_id = ?
6832
+ AND t.workstream_id = ? AND t.local_id = ?`
6833
+ );
6834
+ for (const edge of plan.edges) {
6835
+ if (!replayAllEdges && !taskIds.has(edge.fromLocalId) && !taskIds.has(edge.toLocalId)) {
6836
+ continue;
6837
+ }
6838
+ if (!hasTask(db, wsId, edge.fromLocalId) || !hasTask(db, wsId, edge.toLocalId)) {
6839
+ warnings.push(
6840
+ `skipped edge ${edge.fromLocalId} -> ${edge.toLocalId}: one endpoint is missing locally`
6841
+ );
6842
+ continue;
6843
+ }
6844
+ const result = insertEdge.run(edge.createdAt, wsId, edge.fromLocalId, wsId, edge.toLocalId);
6845
+ if (result.changes > 0) edges += 1;
6846
+ }
6847
+ return { tasks, notes, edges };
6848
+ })();
6849
+ return { added, warnings };
6850
+ }
6851
+ function replayResult(plan, dryRun, applied) {
6852
+ return { ...plan, dryRun, applied, added: { tasks: 0, notes: 0, edges: 0 }, warnings: [] };
6853
+ }
6854
+ function listLocalWorkstreams(db) {
6855
+ return db.prepare("SELECT id, name FROM workstreams ORDER BY name").all();
6856
+ }
6857
+ function listReplayTasks(db, wsId) {
6858
+ return db.prepare(
6859
+ `SELECT local_id, title, status, impact, effort_days, created_at, updated_at
6860
+ FROM tasks
6861
+ WHERE workstream_id = ?
6862
+ ORDER BY local_id`
6863
+ ).all(wsId).map((row) => ({
6864
+ localId: row.local_id,
6865
+ title: row.title,
6866
+ status: row.status,
6867
+ impact: row.impact,
6868
+ effortDays: row.effort_days,
6869
+ createdAt: row.created_at,
6870
+ updatedAt: row.updated_at
6871
+ }));
6872
+ }
6873
+ function listReplayNotes(db, wsId) {
6874
+ const rows = db.prepare(
6875
+ `SELECT t.local_id AS taskLocalId, n.author, n.content, n.created_at AS createdAt
6876
+ FROM task_notes n
6877
+ JOIN tasks t ON t.id = n.task_id
6878
+ WHERE t.workstream_id = ?
6879
+ ORDER BY n.created_at, n.id`
6880
+ ).all(wsId);
6881
+ return rows.map((row) => ({ ...row, hash: noteHash(row) }));
6882
+ }
6883
+ function listReplayEdges(db, wsId) {
6884
+ return db.prepare(
6885
+ `SELECT f.local_id AS fromLocalId, t.local_id AS toLocalId, e.created_at AS createdAt
6886
+ FROM task_edges e
6887
+ JOIN tasks f ON f.id = e.from_task_id
6888
+ JOIN tasks t ON t.id = e.to_task_id
6889
+ WHERE f.workstream_id = ? AND t.workstream_id = ?
6890
+ ORDER BY f.local_id, t.local_id`
6891
+ ).all(wsId, wsId);
6892
+ }
6893
+ function noteHash(note) {
6894
+ return createHash2("sha256").update(`${note.taskLocalId}\0${note.content}\0${note.createdAt}`).digest("hex");
6895
+ }
6896
+ function edgeKey(edge) {
6897
+ return `${edge.fromLocalId}\0${edge.toLocalId}`;
6898
+ }
6899
+ function hasTask(db, wsId, localId) {
6900
+ return db.prepare("SELECT 1 FROM tasks WHERE workstream_id = ? AND local_id = ?").get(wsId, localId) !== void 0;
6901
+ }
6902
+ function shellQuote2(s) {
6903
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
6904
+ }
6905
+
6906
+ // src/db-sync.ts
6907
+ var DbExportTargetExistsError = class extends Error {
6908
+ constructor(file) {
6909
+ super(`DB export target already exists: ${file}`);
6910
+ this.file = file;
6911
+ }
6912
+ file;
6913
+ name = "DbExportTargetExistsError";
6914
+ errorNextSteps() {
6915
+ return [
6916
+ { intent: "Choose a different target", command: "mu db export <new-file>" },
6917
+ { intent: "Overwrite this target", command: `mu db export ${shellQuote3(this.file)} --force` }
6918
+ ];
6919
+ }
6920
+ };
6921
+ var DbImportManifestMissingError = class extends Error {
6922
+ constructor(manifestPath) {
6923
+ super(`DB import manifest not found: ${manifestPath}`);
6924
+ this.manifestPath = manifestPath;
6925
+ }
6926
+ manifestPath;
6927
+ name = "DbImportManifestMissingError";
6928
+ errorNextSteps() {
6929
+ return [
6930
+ { intent: "Export the DB with its sidecar", command: "mu db export /tmp/mu.db --force" },
6931
+ { intent: "Copy the sidecar too", command: `scp <host>:${shellQuote3(this.manifestPath)} .` }
6932
+ ];
6933
+ }
6934
+ };
6935
+ var DbImportSchemaTooOldError = class extends Error {
6936
+ constructor(sourceVersion) {
6937
+ super(
6938
+ `source DB schema v${sourceVersion} is older than local mu requires (v${CURRENT_SCHEMA_VERSION})`
6939
+ );
6940
+ this.sourceVersion = sourceVersion;
6941
+ }
6942
+ sourceVersion;
6943
+ name = "DbImportSchemaTooOldError";
6944
+ errorNextSteps() {
6945
+ return [
6946
+ {
6947
+ intent: "Upgrade mu on the source machine",
6948
+ command: "npm run build && mu db export <file> --force"
6949
+ },
6950
+ { intent: "Then retry this import", command: "mu db import <file> --apply" }
6951
+ ];
6952
+ }
6953
+ };
6954
+ var DbImportSchemaTooNewError = class extends Error {
6955
+ constructor(sourceVersion) {
6956
+ super(
6957
+ `source DB schema v${sourceVersion} is newer than this mu supports (v${CURRENT_SCHEMA_VERSION}); upgrade local mu`
6958
+ );
6959
+ this.sourceVersion = sourceVersion;
6960
+ }
6961
+ sourceVersion;
6962
+ name = "DbImportSchemaTooNewError";
6963
+ errorNextSteps() {
6964
+ return [
6965
+ { intent: "Upgrade local mu", command: "git pull && npm install && npm run build" },
6966
+ { intent: "Then retry this import", command: "mu db import <file> --apply" }
6967
+ ];
6968
+ }
6969
+ };
6970
+ var DbImportSourceStaleError = class extends Error {
6971
+ constructor(workstreams) {
6972
+ super(`source DB is stale for local-ahead workstream(s): ${workstreams.join(", ")}`);
6973
+ this.workstreams = workstreams;
6974
+ }
6975
+ workstreams;
6976
+ name = "DbImportSourceStaleError";
6977
+ errorNextSteps() {
6978
+ return [
6979
+ { intent: "Re-export from this machine", command: "mu db export /tmp/mu-fresh.db --force" },
6980
+ { intent: "Dry-run the incoming file first", command: "mu db import <file>" }
6981
+ ];
6982
+ }
6983
+ };
6984
+ var DbImportConflictError = class extends Error {
6985
+ constructor(workstreams) {
6986
+ super(`source and local both advanced for workstream(s): ${workstreams.join(", ")}`);
6987
+ this.workstreams = workstreams;
6988
+ }
6989
+ workstreams;
6990
+ name = "DbImportConflictError";
6991
+ errorNextSteps() {
6992
+ return [
6993
+ { intent: "Preview the conflicting workstreams", command: "mu db import <file> --json" },
6994
+ {
6995
+ intent: "Clobber from source after parking local divergence",
6996
+ command: "mu db import <file> --apply --force-source"
6997
+ }
6998
+ ];
6999
+ }
7000
+ };
7001
+ function exportDb(db, file, opts = {}) {
7002
+ const target = file;
7003
+ const manifestPath = `${target}.manifest.json`;
7004
+ const targetExists = existsSync12(target);
7005
+ if (targetExists && opts.force !== true) throw new DbExportTargetExistsError(target);
7006
+ const manifest = buildExportManifest(db);
7007
+ mkdirSync4(dirname5(target), { recursive: true });
7008
+ try {
7009
+ if (targetExists) unlinkSync4(target);
7010
+ db.exec(`VACUUM INTO ${quoteSqlString2(target)}`);
7011
+ writeFileSync2(manifestPath, `${JSON.stringify(manifest, null, 2)}
7012
+ `, "utf8");
7013
+ } catch (err) {
7014
+ try {
7015
+ if (existsSync12(target)) unlinkSync4(target);
7016
+ } catch {
7017
+ }
7018
+ throw err;
7019
+ }
7020
+ return { file: target, manifestPath, manifest, overwritten: targetExists };
7021
+ }
7022
+ function importDb(db, file, opts = {}) {
7023
+ const manifest = readImportManifest(file);
7024
+ assertImportSchemaCompatible(manifest.schemaVersion);
7025
+ const sourceDb = openDb({ path: file, readonly: true });
7026
+ try {
7027
+ const summary = buildImportPlan(db, manifest, file, opts.onlyWorkstreams);
7028
+ if (opts.apply !== true) {
7029
+ return {
7030
+ machineId: manifest.machineId,
7031
+ sourceFile: file,
7032
+ dryRun: true,
7033
+ applied: false,
7034
+ summary
7035
+ };
7036
+ }
7037
+ const stale = summary.filter((s) => s.decision === "LOCAL_AHEAD").map((s) => s.workstream);
7038
+ if (stale.length > 0) throw new DbImportSourceStaleError(stale);
7039
+ const conflicts = summary.filter((s) => s.decision === "CONFLICT").map((s) => s.workstream);
7040
+ if (conflicts.length > 0 && opts.forceSource !== true)
7041
+ throw new DbImportConflictError(conflicts);
7042
+ const mutating = summary.some((s) => shouldReplace(s.decision, opts.forceSource === true));
7043
+ const snapshot = mutating ? captureSnapshot(db, `db import ${file}`, null) : void 0;
7044
+ for (const item of summary) {
7045
+ if (!shouldReplace(item.decision, opts.forceSource === true)) continue;
7046
+ if (item.decision === "CONFLICT") {
7047
+ item.parkPath = parkLocalWorkstream(db, item.workstream);
7048
+ }
7049
+ const sourceWs = manifest.workstreams.find((w) => w.name === item.workstream);
7050
+ const sourceSeq = sourceWs?.latestSeq ?? 0;
7051
+ replaceWorkstreamFromSource(db, sourceDb, item.workstream, manifest.machineId, sourceSeq);
7052
+ }
7053
+ return {
7054
+ machineId: manifest.machineId,
7055
+ sourceFile: file,
7056
+ dryRun: false,
7057
+ applied: true,
7058
+ ...snapshot ? { snapshotId: snapshot.id } : {},
7059
+ summary
7060
+ };
7061
+ } finally {
7062
+ sourceDb.close();
7063
+ }
7064
+ }
7065
+ function buildImportPlan(localDb, manifest, sourceFile, onlyWorkstreams) {
7066
+ const sourceByName = new Map(manifest.workstreams.map((w) => [w.name, w]));
7067
+ const localByName = new Map(listLocalWorkstreams2(localDb).map((w) => [w.name, w]));
7068
+ const localMachineId = getMachineIdentity(localDb)?.machine_id ?? "";
7069
+ const only = normaliseOnlyWorkstreams(onlyWorkstreams);
7070
+ const names = Array.from(/* @__PURE__ */ new Set([...sourceByName.keys(), ...localByName.keys()])).filter((name) => only.size === 0 || only.has(name)).sort();
7071
+ return names.map((name) => {
7072
+ const source = sourceByName.get(name);
7073
+ const local = localByName.get(name);
7074
+ const sourceSeq = source?.latestSeq ?? 0;
7075
+ const localSeq = local ? latestSeq(localDb, local.id) : 0;
7076
+ 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 };
7077
+ const decision = classifyWorkstream({
7078
+ hasSource: source !== void 0,
7079
+ hasLocal: local !== void 0,
7080
+ sourceSeq,
7081
+ localSeq,
7082
+ syncedSourceSeq: synced.sourceSeq,
7083
+ syncedLocalSeq: synced.localSeq
7084
+ });
7085
+ return {
7086
+ workstream: name,
7087
+ decision,
7088
+ delta: {
7089
+ sourceFile,
7090
+ sourceSeq,
7091
+ localSeq,
7092
+ lastSynced: synced.sourceSeq,
7093
+ localSynced: synced.localSeq,
7094
+ source: source ? countsFromManifest(source) : null,
7095
+ local: local ? countWorkstream(localDb, local.id) : null
7096
+ },
7097
+ ...decision === "LOCAL_AHEAD" ? { needs: "re-export from this machine" } : {},
7098
+ ...decision === "CONFLICT" ? { needs: "--force-source" } : {}
7099
+ };
7100
+ });
7101
+ }
7102
+ function classifyWorkstream(opts) {
7103
+ if (opts.hasSource && !opts.hasLocal) return "IMPORT";
7104
+ if (!opts.hasSource && opts.hasLocal)
7105
+ return opts.syncedSourceSeq > 0 || opts.syncedLocalSeq > 0 ? "LOCAL_AHEAD" : "LEAVE_ALONE";
7106
+ if (!opts.hasSource && !opts.hasLocal) return "IDENTICAL";
7107
+ const sourceAdvanced = opts.sourceSeq > opts.syncedSourceSeq;
7108
+ const localAdvanced = opts.localSeq > opts.syncedLocalSeq;
7109
+ if (!sourceAdvanced && !localAdvanced) return "IDENTICAL";
7110
+ if (sourceAdvanced && !localAdvanced) return "FAST_FORWARD";
7111
+ if (!sourceAdvanced && localAdvanced) return "LOCAL_AHEAD";
7112
+ return "CONFLICT";
7113
+ }
7114
+ function shouldReplace(decision, forceSource) {
7115
+ return decision === "FAST_FORWARD" || decision === "IMPORT" || decision === "CONFLICT" && forceSource;
7116
+ }
7117
+ function replaceWorkstreamFromSource(localDb, sourceDb, workstream, sourceMachineId, sourceSeq) {
7118
+ localDb.transaction(() => {
7119
+ const existing = localDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream);
7120
+ if (existing) {
7121
+ localDb.prepare("DELETE FROM vcs_workspaces WHERE workstream_id = ?").run(existing.id);
7122
+ localDb.prepare("DELETE FROM agents WHERE workstream_id = ?").run(existing.id);
7123
+ localDb.prepare("DELETE FROM workstreams WHERE id = ?").run(existing.id);
7124
+ }
7125
+ copyWorkstreamRows(sourceDb, localDb, workstream, {
7126
+ includeMachineLocalRows: false,
7127
+ preserveLogSeq: false,
7128
+ includeSync: false
7129
+ });
7130
+ const wsId = localDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream)?.id;
7131
+ if (wsId === void 0) throw new Error(`importDb: failed to import workstream ${workstream}`);
7132
+ writeSyncState(localDb, wsId, sourceMachineId, sourceSeq);
7133
+ })();
7134
+ }
7135
+ function parkLocalWorkstream(db, workstream) {
7136
+ const dir = join8(defaultStateDir(), "divergence");
7137
+ mkdirSync4(dir, { recursive: true });
7138
+ const path = join8(
7139
+ dir,
7140
+ `${workstream}-${(/* @__PURE__ */ new Date()).toISOString()}-${randomUUID2().slice(0, 8)}.db`
7141
+ );
7142
+ const parkDb = openDb({ path });
7143
+ try {
7144
+ const identity = getMachineIdentity(db);
7145
+ if (identity) {
7146
+ parkDb.prepare(
7147
+ `UPDATE machine_identity
7148
+ SET machine_id = ?, hostname = ?, created_at = ?
7149
+ WHERE id = 1`
7150
+ ).run(
7151
+ identity.machine_id,
7152
+ identity.hostname,
7153
+ identity.created_at ?? (/* @__PURE__ */ new Date()).toISOString()
7154
+ );
7155
+ }
7156
+ copyWorkstreamRows(db, parkDb, workstream, {
7157
+ includeMachineLocalRows: true,
7158
+ preserveLogSeq: true,
7159
+ includeSync: true
7160
+ });
7161
+ } catch (err) {
7162
+ try {
7163
+ parkDb.close();
7164
+ } catch {
7165
+ }
7166
+ try {
7167
+ if (existsSync12(path)) unlinkSync4(path);
7168
+ } catch {
7169
+ }
7170
+ throw err;
7171
+ }
7172
+ parkDb.close();
7173
+ return path;
7174
+ }
7175
+ function copyWorkstreamRows(sourceDb, targetDb, workstream, opts) {
7176
+ const sourceWs = sourceDb.prepare("SELECT id, name, created_at FROM workstreams WHERE name = ?").get(workstream);
7177
+ if (!sourceWs) throw new Error(`copyWorkstreamRows: no such workstream ${workstream}`);
7178
+ targetDb.prepare("INSERT INTO workstreams (name, created_at) VALUES (?, ?)").run(sourceWs.name, sourceWs.created_at);
7179
+ const targetWsId = targetDb.prepare("SELECT id FROM workstreams WHERE name = ?").get(workstream).id;
7180
+ if (opts.includeMachineLocalRows) copyAgents(sourceDb, targetDb, sourceWs.id, targetWsId);
7181
+ copyTasks(sourceDb, targetDb, sourceWs.id, targetWsId, opts.includeMachineLocalRows);
7182
+ copyEdges(sourceDb, targetDb, sourceWs.id, targetWsId);
7183
+ copyNotes(sourceDb, targetDb, sourceWs.id, targetWsId);
7184
+ copyLogs(sourceDb, targetDb, sourceWs.id, targetWsId, opts.preserveLogSeq);
7185
+ if (opts.includeMachineLocalRows) copyWorkspaces(sourceDb, targetDb, sourceWs.id, targetWsId);
7186
+ if (opts.includeSync) copySync(sourceDb, targetDb, sourceWs.id, targetWsId);
7187
+ }
7188
+ function copyAgents(sourceDb, targetDb, sourceWsId, targetWsId) {
7189
+ const rows = sourceDb.prepare(
7190
+ `SELECT name, cli, pane_id, status, role, tab, created_at, updated_at
7191
+ FROM agents
7192
+ WHERE workstream_id = ?
7193
+ ORDER BY id`
7194
+ ).all(sourceWsId);
7195
+ const insert = targetDb.prepare(
7196
+ `INSERT INTO agents (workstream_id, name, cli, pane_id, status, role, tab, created_at, updated_at)
7197
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7198
+ );
7199
+ for (const row of rows) {
7200
+ insert.run(
7201
+ targetWsId,
7202
+ row.name,
7203
+ row.cli,
7204
+ row.pane_id,
7205
+ row.status,
7206
+ row.role,
7207
+ row.tab,
7208
+ row.created_at,
7209
+ row.updated_at
7210
+ );
7211
+ }
7212
+ }
7213
+ function copyTasks(sourceDb, targetDb, sourceWsId, targetWsId, includeOwners) {
7214
+ const rows = sourceDb.prepare(
7215
+ `SELECT t.local_id, t.title, t.status, t.impact, t.effort_days, a.name AS owner_name,
7216
+ t.created_at, t.updated_at
7217
+ FROM tasks t
7218
+ LEFT JOIN agents a ON a.id = t.owner_id
7219
+ WHERE t.workstream_id = ?
7220
+ ORDER BY t.id`
7221
+ ).all(sourceWsId);
7222
+ const ownerLookup = targetDb.prepare(
7223
+ "SELECT id FROM agents WHERE workstream_id = ? AND name = ?"
7224
+ );
7225
+ const insert = targetDb.prepare(
7226
+ `INSERT INTO tasks (workstream_id, local_id, title, status, impact, effort_days, owner_id, created_at, updated_at)
7227
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
7228
+ );
7229
+ for (const row of rows) {
7230
+ const ownerId = includeOwners && row.owner_name !== null ? ownerLookup.get(targetWsId, row.owner_name)?.id ?? null : null;
7231
+ insert.run(
7232
+ targetWsId,
7233
+ row.local_id,
7234
+ row.title,
7235
+ row.status,
7236
+ row.impact,
7237
+ row.effort_days,
7238
+ ownerId,
7239
+ row.created_at,
7240
+ row.updated_at
7241
+ );
7242
+ }
7243
+ }
7244
+ function copyEdges(sourceDb, targetDb, sourceWsId, targetWsId) {
7245
+ const rows = sourceDb.prepare(
7246
+ `SELECT f.local_id AS from_local_id, t.local_id AS to_local_id, e.created_at
7247
+ FROM task_edges e
7248
+ JOIN tasks f ON f.id = e.from_task_id
7249
+ JOIN tasks t ON t.id = e.to_task_id
7250
+ WHERE f.workstream_id = ? AND t.workstream_id = ?
7251
+ ORDER BY e.created_at, f.local_id, t.local_id`
7252
+ ).all(sourceWsId, sourceWsId);
7253
+ const insert = targetDb.prepare(
7254
+ `INSERT OR IGNORE INTO task_edges (from_task_id, to_task_id, created_at)
7255
+ SELECT f.id, t.id, ?
7256
+ FROM tasks f, tasks t
7257
+ WHERE f.workstream_id = ? AND f.local_id = ?
7258
+ AND t.workstream_id = ? AND t.local_id = ?`
7259
+ );
7260
+ for (const row of rows) {
7261
+ insert.run(row.created_at, targetWsId, row.from_local_id, targetWsId, row.to_local_id);
7262
+ }
7263
+ }
7264
+ function copyNotes(sourceDb, targetDb, sourceWsId, targetWsId) {
7265
+ const rows = sourceDb.prepare(
7266
+ `SELECT t.local_id AS task_local_id, n.author, n.content, n.created_at
7267
+ FROM task_notes n
7268
+ JOIN tasks t ON t.id = n.task_id
7269
+ WHERE t.workstream_id = ?
7270
+ ORDER BY n.id`
7271
+ ).all(sourceWsId);
7272
+ const insert = targetDb.prepare(
7273
+ `INSERT INTO task_notes (task_id, author, content, created_at)
7274
+ SELECT id, ?, ?, ? FROM tasks WHERE workstream_id = ? AND local_id = ?`
7275
+ );
7276
+ for (const row of rows) {
7277
+ insert.run(row.author, row.content, row.created_at, targetWsId, row.task_local_id);
7278
+ }
7279
+ }
7280
+ function copyLogs(sourceDb, targetDb, sourceWsId, targetWsId, preserveSeq) {
7281
+ const rows = sourceDb.prepare(
7282
+ `SELECT seq, source, kind, payload, created_at
7283
+ FROM agent_logs
7284
+ WHERE workstream_id = ?
7285
+ ORDER BY seq`
7286
+ ).all(sourceWsId);
7287
+ const insertPreserve = targetDb.prepare(
7288
+ "INSERT INTO agent_logs (seq, workstream_id, source, kind, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)"
7289
+ );
7290
+ const insertRenumber = targetDb.prepare(
7291
+ "INSERT INTO agent_logs (workstream_id, source, kind, payload, created_at) VALUES (?, ?, ?, ?, ?)"
7292
+ );
7293
+ for (const row of rows) {
7294
+ if (preserveSeq) {
7295
+ insertPreserve.run(row.seq, targetWsId, row.source, row.kind, row.payload, row.created_at);
7296
+ } else {
7297
+ insertRenumber.run(targetWsId, row.source, row.kind, row.payload, row.created_at);
7298
+ }
7299
+ }
7300
+ }
7301
+ function copyWorkspaces(sourceDb, targetDb, sourceWsId, targetWsId) {
7302
+ const rows = sourceDb.prepare(
7303
+ `SELECT a.name AS agent_name, v.backend, v.path, v.parent_ref, v.created_at
7304
+ FROM vcs_workspaces v
7305
+ JOIN agents a ON a.id = v.agent_id
7306
+ WHERE v.workstream_id = ?
7307
+ ORDER BY v.id`
7308
+ ).all(sourceWsId);
7309
+ const agentLookup = targetDb.prepare(
7310
+ "SELECT id FROM agents WHERE workstream_id = ? AND name = ?"
7311
+ );
7312
+ const insert = targetDb.prepare(
7313
+ `INSERT INTO vcs_workspaces (agent_id, workstream_id, backend, path, parent_ref, created_at)
7314
+ VALUES (?, ?, ?, ?, ?, ?)`
7315
+ );
7316
+ for (const row of rows) {
7317
+ const agentId = agentLookup.get(targetWsId, row.agent_name)?.id;
7318
+ if (agentId === void 0) continue;
7319
+ insert.run(agentId, targetWsId, row.backend, row.path, row.parent_ref, row.created_at);
7320
+ }
7321
+ }
7322
+ function copySync(sourceDb, targetDb, sourceWsId, targetWsId) {
7323
+ const row = sourceDb.prepare("SELECT last_known_peer_seqs FROM workstream_sync WHERE workstream_id = ?").get(sourceWsId);
7324
+ if (!row) return;
7325
+ targetDb.prepare("INSERT INTO workstream_sync (workstream_id, last_known_peer_seqs) VALUES (?, ?)").run(targetWsId, row.last_known_peer_seqs);
7326
+ }
7327
+ function writeSyncState(db, workstreamId, sourceMachineId, sourceSeq) {
7328
+ const localSeq = latestSeq(db, workstreamId);
7329
+ const peers = {
7330
+ [sourceMachineId]: sourceSeq,
7331
+ [localSeqKey(sourceMachineId)]: localSeq
7332
+ };
7333
+ db.prepare(
7334
+ `INSERT OR REPLACE INTO workstream_sync (workstream_id, last_known_peer_seqs)
7335
+ VALUES (?, ?)`
7336
+ ).run(workstreamId, JSON.stringify(peers));
7337
+ }
7338
+ function lastKnownPeerSync(db, workstreamId, machineId) {
7339
+ const row = db.prepare("SELECT last_known_peer_seqs FROM workstream_sync WHERE workstream_id = ?").get(workstreamId);
7340
+ if (!row) return { sourceSeq: 0, localSeq: 0 };
7341
+ const parsed = parsePeerSeqs(row.last_known_peer_seqs);
7342
+ const sourceSeq = parsed[machineId] ?? 0;
7343
+ return { sourceSeq, localSeq: parsed[localSeqKey(machineId)] ?? sourceSeq };
7344
+ }
7345
+ function localSeqKey(machineId) {
7346
+ return `${machineId}:local`;
7347
+ }
7348
+ function parsePeerSeqs(raw) {
7349
+ try {
7350
+ const parsed = JSON.parse(raw);
7351
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
7352
+ const result = {};
7353
+ for (const [key, value] of Object.entries(parsed)) {
7354
+ if (typeof value === "number" && Number.isFinite(value)) result[key] = value;
7355
+ }
7356
+ return result;
7357
+ } catch {
7358
+ return {};
7359
+ }
7360
+ }
7361
+ function readImportManifest(file) {
7362
+ const manifestPath = `${file}.manifest.json`;
7363
+ if (!existsSync12(manifestPath)) throw new DbImportManifestMissingError(manifestPath);
7364
+ return JSON.parse(readFileSync2(manifestPath, "utf8"));
7365
+ }
7366
+ function assertImportSchemaCompatible(sourceVersion) {
7367
+ if (sourceVersion < CURRENT_SCHEMA_VERSION) throw new DbImportSchemaTooOldError(sourceVersion);
7368
+ if (sourceVersion > CURRENT_SCHEMA_VERSION) throw new DbImportSchemaTooNewError(sourceVersion);
7369
+ }
7370
+ function buildExportManifest(db) {
7371
+ const identity = getMachineIdentity(db);
7372
+ const schemaRow = db.prepare("SELECT version FROM schema_version WHERE id = 1").get();
7373
+ const workstreams = listLocalWorkstreams2(db);
7374
+ return {
7375
+ muVersion: readPackageVersion(),
7376
+ schemaVersion: schemaRow?.version ?? CURRENT_SCHEMA_VERSION,
7377
+ machineId: identity?.machine_id ?? "",
7378
+ hostname: identity?.hostname ?? hostname2(),
7379
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
7380
+ workstreams: workstreams.map((ws) => ({
7381
+ name: ws.name,
7382
+ tasks: count(db, "SELECT COUNT(*) AS n FROM tasks WHERE workstream_id = ?", ws.id),
7383
+ edges: count(
7384
+ db,
7385
+ `SELECT COUNT(*) AS n
7386
+ FROM task_edges e
7387
+ JOIN tasks f ON f.id = e.from_task_id
7388
+ JOIN tasks t ON t.id = e.to_task_id
7389
+ WHERE f.workstream_id = ? AND t.workstream_id = ?`,
7390
+ ws.id,
7391
+ ws.id
7392
+ ),
7393
+ notes: count(
7394
+ db,
7395
+ `SELECT COUNT(*) AS n
7396
+ FROM task_notes n
7397
+ JOIN tasks t ON t.id = n.task_id
7398
+ WHERE t.workstream_id = ?`,
7399
+ ws.id
7400
+ ),
7401
+ latestSeq: latestSeq(db, ws.id)
7402
+ }))
7403
+ };
7404
+ }
7405
+ function listLocalWorkstreams2(db) {
7406
+ return db.prepare("SELECT id, name FROM workstreams ORDER BY name").all();
7407
+ }
7408
+ function getMachineIdentity(db) {
7409
+ return db.prepare("SELECT machine_id, hostname, created_at FROM machine_identity WHERE id = 1").get();
7410
+ }
7411
+ function countWorkstream(db, wsId) {
7412
+ return {
7413
+ tasks: count(db, "SELECT COUNT(*) AS n FROM tasks WHERE workstream_id = ?", wsId),
7414
+ edges: count(
7415
+ db,
7416
+ `SELECT COUNT(*) AS n
7417
+ FROM task_edges e
7418
+ JOIN tasks f ON f.id = e.from_task_id
7419
+ JOIN tasks t ON t.id = e.to_task_id
7420
+ WHERE f.workstream_id = ? AND t.workstream_id = ?`,
7421
+ wsId,
7422
+ wsId
7423
+ ),
7424
+ notes: count(
7425
+ db,
7426
+ `SELECT COUNT(*) AS n
7427
+ FROM task_notes n
7428
+ JOIN tasks t ON t.id = n.task_id
7429
+ WHERE t.workstream_id = ?`,
7430
+ wsId
7431
+ )
7432
+ };
7433
+ }
7434
+ function countsFromManifest(ws) {
7435
+ return { tasks: ws.tasks, edges: ws.edges, notes: ws.notes };
7436
+ }
7437
+ function normaliseOnlyWorkstreams(input) {
7438
+ if (!input || input.length === 0) return /* @__PURE__ */ new Set();
7439
+ return new Set(
7440
+ input.flatMap((v) => v.split(",")).map((v) => v.trim()).filter((v) => v.length > 0)
7441
+ );
7442
+ }
7443
+ function count(db, sql, ...params) {
7444
+ const row = db.prepare(sql).get(...params);
7445
+ return row?.n ?? 0;
7446
+ }
7447
+ function quoteSqlString2(s) {
7448
+ return `'${s.replace(/'/g, "''")}'`;
7449
+ }
7450
+ function readPackageVersion() {
7451
+ try {
7452
+ const here = dirname5(fileURLToPath2(import.meta.url));
7453
+ const raw = readFileSync2(join8(here, "..", "package.json"), "utf8");
7454
+ const parsed = JSON.parse(raw);
7455
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
7456
+ } catch {
7457
+ return "unknown";
7458
+ }
7459
+ }
7460
+ function shellQuote3(s) {
7461
+ return `'${s.replace(/'/g, `'"'"'`)}'`;
7462
+ }
7463
+
6504
7464
  // src/tracks.ts
6505
7465
  function getParallelTracks(db, workstream) {
6506
7466
  const goals = listGoals(db, workstream).filter(
@@ -6603,544 +7563,6 @@ var UnionFind = class {
6603
7563
  }
6604
7564
  };
6605
7565
 
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
7566
  // src/doctor-summary.ts
7145
7567
  function loadDoctorSummary(db, snapshot) {
7146
7568
  const checks = [];
@@ -7476,14 +7898,14 @@ function agentStatusHistogram(agents) {
7476
7898
  return out;
7477
7899
  }
7478
7900
  function summarizeOwnedTasks(owned) {
7479
- const count = owned.length;
7480
- if (count === 0) return { bit: "\u2014", count: 0 };
7481
- if (count === 1) {
7901
+ const count2 = owned.length;
7902
+ if (count2 === 0) return { bit: "\u2014", count: 0 };
7903
+ if (count2 === 1) {
7482
7904
  const only = owned[0];
7483
7905
  if (!only) return { bit: "\u2014", count: 0 };
7484
7906
  return { bit: only.name, count: 1, onlyTaskId: only.name };
7485
7907
  }
7486
- return { bit: `\u2295${count}`, count };
7908
+ return { bit: `\u2295${count2}`, count: count2 };
7487
7909
  }
7488
7910
  export {
7489
7911
  AgentDiedOnSpawnError,
@@ -7495,16 +7917,22 @@ export {
7495
7917
  ArchiveAlreadyExistsError,
7496
7918
  ArchiveLabelInvalidError,
7497
7919
  ArchiveNotFoundError,
7920
+ ArchiveSourceAmbiguousError,
7498
7921
  CURRENT_SCHEMA_VERSION,
7499
7922
  ClaimerNotRegisteredError,
7500
7923
  CrossWorkstreamEdgeError,
7501
7924
  CycleError,
7925
+ DbExportTargetExistsError,
7926
+ DbImportConflictError,
7927
+ DbImportManifestMissingError,
7928
+ DbImportSchemaTooNewError,
7929
+ DbImportSchemaTooOldError,
7930
+ DbImportSourceStaleError,
7931
+ DbReplayLocalIdConflictError,
7932
+ DbReplayWorkstreamMissingError,
7502
7933
  EVENT_VERB_PREFIXES,
7503
7934
  EXPECTED_TABLES,
7504
7935
  HomeDirAsProjectRootError,
7505
- ImportBucketInvalidError,
7506
- ImportEdgeRefMissingError,
7507
- ImportFrontmatterParseError,
7508
7936
  NoForegroundProcessError,
7509
7937
  PANE_ID_RE,
7510
7938
  PaneNotFoundError,
@@ -7528,7 +7956,7 @@ export {
7528
7956
  WorkspaceNotFoundError,
7529
7957
  WorkspacePathNotEmptyError,
7530
7958
  WorkspacePreservedError,
7531
- WorkstreamAlreadyExistsError,
7959
+ WorkstreamExistsError,
7532
7960
  WorkstreamNameInvalidError,
7533
7961
  addBlockEdge,
7534
7962
  addNote,
@@ -7539,6 +7967,8 @@ export {
7539
7967
  appendLog,
7540
7968
  assertValidPaneId,
7541
7969
  backendByName,
7970
+ buildImportPlan,
7971
+ buildReplayPlan,
7542
7972
  capturePane,
7543
7973
  captureSnapshot,
7544
7974
  checkCommandResolvable,
@@ -7570,6 +8000,7 @@ export {
7570
8000
  ensureWorkstream,
7571
8001
  envVarNameForCli,
7572
8002
  exportArchive,
8003
+ exportDb,
7573
8004
  exportSourceForWorkstream,
7574
8005
  exportSourcesForArchive,
7575
8006
  exportWorkstream,
@@ -7594,7 +8025,7 @@ export {
7594
8025
  gitBackend,
7595
8026
  idFromTitle,
7596
8027
  idFromTitleVerbose,
7597
- importBucket,
8028
+ importDb,
7598
8029
  insertAgent,
7599
8030
  isKickSignal,
7600
8031
  isStaleVersion,
@@ -7663,6 +8094,7 @@ export {
7663
8094
  renderTaskTree,
7664
8095
  renderToBucket,
7665
8096
  reparentTask,
8097
+ replayDb,
7666
8098
  resetCommandResolverForTests,
7667
8099
  resetKickProcessExecutor,
7668
8100
  resetSleep,
@@ -7671,6 +8103,7 @@ export {
7671
8103
  resolveActorIdentity,
7672
8104
  resolveCliCommand,
7673
8105
  resolveCliCommandWithSource,
8106
+ restoreArchive,
7674
8107
  restoreSnapshot,
7675
8108
  roiBucket,
7676
8109
  searchArchives,