@mu-crew/mu 3.4.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -175,6 +175,11 @@ function fullOldPayload(cols) {
175
175
  const pairs = cols.map((c) => `'${c}', OLD.${c}`).join(", ");
176
176
  return `json_object(${pairs})`;
177
177
  }
178
+ function noteTombstonePayload(keyExpr, cols) {
179
+ return `CASE WHEN EXISTS (
180
+ SELECT 1 FROM ops WHERE entity = 'note' AND key = ${keyExpr} AND op = 'put'
181
+ ) THEN '{}' ELSE ${fullOldPayload(cols)} END`;
182
+ }
178
183
  function anyChanged(cols) {
179
184
  return cols.map((c) => `NEW.${c} IS NOT OLD.${c}`).join("\n OR ");
180
185
  }
@@ -258,7 +263,7 @@ END;
258
263
  CREATE TEMP TRIGGER IF NOT EXISTS _cap_task_notes_del
259
264
  BEFORE DELETE ON task_notes WHEN ${NOT_APPLYING}
260
265
  BEGIN
261
- ${emitOp("note", "del", `${taskKey("OLD.task_id")} || '#' || OLD.id`, fullOldPayload(noteCols))}
266
+ ${emitOp("note", "del", `${taskKey("OLD.task_id")} || '#' || OLD.id`, noteTombstonePayload(`${taskKey("OLD.task_id")} || '#' || OLD.id`, noteCols))}
262
267
  END;
263
268
 
264
269
  -- \u2500\u2500\u2500 task_edges \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
@@ -7340,7 +7345,7 @@ async function waitForTasks(db, input, opts) {
7340
7345
  const wantAny = opts.any === true;
7341
7346
  const timeoutMs = opts.timeoutMs ?? 6e5;
7342
7347
  const pollMs = opts.pollMs ?? 1e3;
7343
- const stuckAfterMs = opts.stuckAfterMs ?? 3e5;
7348
+ const stuckAfterMs = opts.stuckAfterMs ?? DEFAULT_STUCK_AFTER_MS;
7344
7349
  const onStall = opts.onStall ?? "warn";
7345
7350
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : Number.POSITIVE_INFINITY;
7346
7351
  for (const ref of refs) {
@@ -7445,7 +7450,7 @@ async function waitForTasks(db, input, opts) {
7445
7450
  if (isDone(snap)) return snap;
7446
7451
  }
7447
7452
  }
7448
- var currentWaitSleep, pollCount, defaultStuckWarn, currentStuckWarn;
7453
+ var currentWaitSleep, pollCount, DEFAULT_STUCK_AFTER_MS, defaultStuckWarn, currentStuckWarn;
7449
7454
  var init_wait = __esm({
7450
7455
  "src/tasks/wait.ts"() {
7451
7456
  "use strict";
@@ -7455,6 +7460,7 @@ var init_wait = __esm({
7455
7460
  init_status();
7456
7461
  currentWaitSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
7457
7462
  pollCount = 0;
7463
+ DEFAULT_STUCK_AFTER_MS = 12e4;
7458
7464
  defaultStuckWarn = (msg) => {
7459
7465
  process.stderr.write(msg);
7460
7466
  };
@@ -7620,7 +7626,10 @@ var init_adopt = __esm({
7620
7626
  // src/agents/delegate.ts
7621
7627
  function delegateOutcome(agent, timedOut) {
7622
7628
  if (agent.dead) return "died";
7623
- if (agent.fired) return agent.lastText ? "done" : "empty";
7629
+ if (agent.fired) {
7630
+ if (agent.lastError !== void 0) return "error";
7631
+ return agent.lastText ? "done" : "empty";
7632
+ }
7624
7633
  return timedOut ? "timeout" : "pending";
7625
7634
  }
7626
7635
  var init_delegate = __esm({
@@ -7833,6 +7842,7 @@ async function waitForAgents(db, input, opts) {
7833
7842
  st.wasBusy = true;
7834
7843
  st.fired = true;
7835
7844
  if (snap.lastText !== void 0) st.lastText = snap.lastText;
7845
+ if (snap.lastError !== void 0) st.lastError = snap.lastError;
7836
7846
  }
7837
7847
  wake();
7838
7848
  },
@@ -10400,13 +10410,13 @@ import {
10400
10410
  appendFileSync,
10401
10411
  existsSync as existsSync14,
10402
10412
  mkdirSync as mkdirSync5,
10403
- readdirSync as readdirSync3,
10413
+ readdirSync as readdirSync4,
10404
10414
  readFileSync as readFileSync3,
10405
- statSync as statSync2,
10415
+ statSync as statSync3,
10406
10416
  writeFileSync as writeFileSync2
10407
10417
  } from "fs";
10408
10418
  import { tmpdir as tmpdir2 } from "os";
10409
- import { join as join12, resolve as resolve6, sep as sep2 } from "path";
10419
+ import { join as join13, resolve as resolve6, sep as sep2 } from "path";
10410
10420
  import { crc32 } from "zlib";
10411
10421
  function syncDir() {
10412
10422
  const dir = process.env.MU_SYNC_DIR;
@@ -10430,7 +10440,7 @@ function localMachineId(db) {
10430
10440
  return row2.machine_id;
10431
10441
  }
10432
10442
  function segmentPath(dir, machineId2) {
10433
- return join12(dir, `${machineId2}${SEGMENT_EXT}`);
10443
+ return join13(dir, `${machineId2}${SEGMENT_EXT}`);
10434
10444
  }
10435
10445
  function manifestPath(segment) {
10436
10446
  return segment.replace(new RegExp(`${SEGMENT_EXT}$`), MANIFEST_EXT);
@@ -10574,7 +10584,7 @@ async function flushSegment(db, dir = syncDir()) {
10574
10584
  const path2 = segmentPath(dir, machineId2);
10575
10585
  const lockName = createHash2("sha256").update(`${dir}${machineId2}`).digest("hex").slice(0, 16);
10576
10586
  return withFileLock(
10577
- join12(locksDir(), `segment-${lockName}.lock`),
10587
+ join13(locksDir(), `segment-${lockName}.lock`),
10578
10588
  `segment:${machineId2}`,
10579
10589
  () => Promise.resolve(flushLocked(db, path2, machineId2))
10580
10590
  );
@@ -10696,16 +10706,16 @@ function verifyAgainstManifest(segment) {
10696
10706
  function discoverPeers(dir, selfMachineId) {
10697
10707
  if (!existsSync14(dir)) return [];
10698
10708
  const peers = [];
10699
- for (const name of readdirSync3(dir)) {
10709
+ for (const name of readdirSync4(dir)) {
10700
10710
  if (!name.endsWith(SEGMENT_EXT)) continue;
10701
10711
  const stem = name.slice(0, -SEGMENT_EXT.length);
10702
10712
  const conflictAt = stem.indexOf(".sync-conflict-");
10703
10713
  const conflictCopy = conflictAt > 0;
10704
10714
  const machineId2 = conflictCopy ? stem.slice(0, conflictAt) : stem;
10705
10715
  if (machineId2 === selfMachineId && !conflictCopy) continue;
10706
- const path2 = join12(dir, name);
10716
+ const path2 = join13(dir, name);
10707
10717
  try {
10708
- if (!statSync2(path2).isFile()) continue;
10718
+ if (!statSync3(path2).isFile()) continue;
10709
10719
  } catch {
10710
10720
  continue;
10711
10721
  }
@@ -10910,7 +10920,7 @@ var init_segments = __esm({
10910
10920
  });
10911
10921
 
10912
10922
  // src/sync.ts
10913
- import { existsSync as existsSync15, statSync as statSync3 } from "fs";
10923
+ import { existsSync as existsSync15, statSync as statSync4 } from "fs";
10914
10924
  import Database2 from "better-sqlite3";
10915
10925
  function peerStatuses(db, dir) {
10916
10926
  const self = localMachineId(db);
@@ -10918,7 +10928,7 @@ function peerStatuses(db, dir) {
10918
10928
  const rows = discoverPeers(dir, self).map((peer) => {
10919
10929
  let lastSeenMs = null;
10920
10930
  try {
10921
- lastSeenMs = statSync3(peer.path).mtimeMs;
10931
+ lastSeenMs = statSync4(peer.path).mtimeMs;
10922
10932
  } catch {
10923
10933
  lastSeenMs = null;
10924
10934
  }
@@ -19393,7 +19403,7 @@ async function cmdTaskWait(db, ids, opts) {
19393
19403
  if (ids.length === 0) {
19394
19404
  throw new UsageError("mu task wait: at least one task id is required");
19395
19405
  }
19396
- const onStallRaw = opts.onStall ?? "warn";
19406
+ const onStallRaw = opts.onStall ?? "exit";
19397
19407
  if (onStallRaw !== "warn" && onStallRaw !== "exit") {
19398
19408
  throw new UsageError(`--on-stall: expected 'warn' or 'exit', got '${onStallRaw}'`);
19399
19409
  }
@@ -19406,7 +19416,7 @@ async function cmdTaskWait(db, ids, opts) {
19406
19416
  }
19407
19417
  const workstreamSet = new Set(refs.map((r) => r.workstreamName));
19408
19418
  const timeoutMs = opts.timeout !== void 0 ? opts.timeout * 1e3 : 6e5;
19409
- const stuckAfterMs = opts.stuckAfter !== void 0 ? opts.stuckAfter * 1e3 : 3e5;
19419
+ const stuckAfterMs = opts.stuckAfter !== void 0 ? opts.stuckAfter * 1e3 : DEFAULT_STUCK_AFTER_MS;
19410
19420
  const sdkOpts = { timeoutMs, stuckAfterMs };
19411
19421
  if (statusOpt !== void 0) sdkOpts.status = statusOpt;
19412
19422
  if (wantAny) sdkOpts.any = true;
@@ -20161,7 +20171,7 @@ function wireTaskCommands(program) {
20161
20171
  return handle((db) => cmdTaskReparent(db, id, opts), this)();
20162
20172
  });
20163
20173
  task.command("wait <ids...>").description(
20164
- "Block until the listed tasks reach --status (default CLOSED). Each <id> may be bare (resolves via -w / $MU_SESSION / tmux) or qualified `<workstream>/<name>` (cross-workstream waits don't need -w). Default: every task must reach the target (--all). --any / --first exit on the first one that does; --first additionally prints the firing ref's qualified id to stdout. For unattended waits, pass --on-stall exit so a worker needing attention terminates the wait with exit 7. Exit 0 = condition met; 5 = timeout; 6 = a watched task was reaper-flipped IN_PROGRESS\u2192OPEN (target=CLOSED only); 7 = a worker needs attention (--on-stall exit)."
20174
+ "Block until the listed tasks reach --status (default CLOSED). Each <id> may be bare (resolves via -w / $MU_SESSION / tmux) or qualified `<workstream>/<name>` (cross-workstream waits don't need -w). Default: every task must reach the target (--all). --any / --first exit on the first one that does; --first additionally prints the firing ref's qualified id to stdout. A worker needing attention terminates the wait with exit 7 (--on-stall exit, the default). Exit 0 = condition met; 5 = timeout; 6 = a watched task was reaper-flipped IN_PROGRESS\u2192OPEN (target=CLOSED only); 7 = a worker needs attention (target=CLOSED, unless --on-stall warn)."
20165
20175
  ).option(
20166
20176
  "--status <status>",
20167
20177
  `target status (${TASK_STATUS_LIST}, case-insensitive); default CLOSED`
@@ -20170,14 +20180,14 @@ function wireTaskCommands(program) {
20170
20180
  "succeed as soon as ONE listed task reaches the target (default: all must). Reports THAT one fired, not which: --json leaves `firing` null even on success, so use --first if you need to identify the ref."
20171
20181
  ).option(
20172
20182
  "--first",
20173
- "alias for --any that ALSO prints the firing ref's qualified id to stdout and populates `firing` in --json (which --any leaves null). Use to drive a single-shot dispatch loop: `closed=$(mu task wait a b --first --on-stall exit --json | jq -r .firing.qualifiedId)`."
20183
+ "alias for --any that ALSO prints the firing ref's qualified id to stdout and populates `firing` in --json (which --any leaves null). Use to drive a single-shot dispatch loop: `closed=$(mu task wait a b --first --json | jq -r .firing.qualifiedId)`."
20174
20184
  ).option("--timeout <seconds>", "max seconds to wait (0 = forever, default 600)", parseLines).option(
20175
20185
  "--stuck-after <seconds>",
20176
- "the TRIGGER: mark an IN_PROGRESS task as needing attention when its owner has been in needs_input for >= N seconds since their last status change (0 = disable, default 300). needs_input has several causes \u2014 the worker may have finished without closing, be waiting on an answer, or be sitting at a prompt \u2014 so the warning names the observation and points at `mu agent read <owner>`, which is the next move in every case. The default ACTION is `warn` (keep polling); see --on-stall exit to terminate the wait instead.",
20186
+ "the TRIGGER: mark an IN_PROGRESS task as needing attention when its owner has been in needs_input for >= N seconds since their last status change (0 = disable, default 120). needs_input has several causes \u2014 the worker may have finished without closing, be waiting on an answer, or be sitting at a prompt \u2014 so the warning names the observation and points at `mu agent read <owner>`, which is the next move in every case. The default ACTION is `exit` (exit 7); see --on-stall warn to keep polling instead.",
20177
20187
  parseLines
20178
20188
  ).option(
20179
20189
  "--on-stall <action>",
20180
- "the ACTION when --stuck-after fires: 'warn' (default; yellow attention warning naming the owner + age, plus a corroborating agent_logs event; wait keeps polling) or 'exit' (same emit + persist, then exit 7 = STALL_DETECTED so an unattended orchestrator can branch on the idle-vs-dead distinction). Suppressed when --status is anything other than CLOSED (mirrors exit-6's carve-out). If exit-6 (dead pane) and exit-7 (stall) would fire in the same poll, exit 6 wins (dead pane is unambiguous; stall is ambiguous)."
20190
+ "the ACTION when --stuck-after fires: 'exit' (default; yellow attention warning naming the owner + age, plus a corroborating agent_logs event, then exit 7 = STALL_DETECTED so an orchestrator can branch on the idle-vs-dead distinction) or 'warn' (same emit + persist; wait keeps polling). Suppressed when --status is anything other than CLOSED (mirrors exit-6's carve-out). If exit-6 (dead pane) and exit-7 (stall) would fire in the same poll, exit 6 wins (dead pane is unambiguous; stall is ambiguous)."
20181
20191
  ).option(...WORKSTREAM_OPT).option(...JSON_OPT).action(function(ids) {
20182
20192
  const opts = this.opts();
20183
20193
  return handle((db) => cmdTaskWait(db, ids, opts), this)();
@@ -20677,7 +20687,8 @@ async function cmdAgentWait(db, rawNames, opts) {
20677
20687
  const settled = ctlRequest(sock, { op: "wait", afterRuns: probe.status.runs }, { signal }).then(
20678
20688
  (reply) => reply.ok && reply.state !== void 0 ? {
20679
20689
  status: ctlRuntimeState(reply.state),
20680
- ...reply.lastText !== void 0 ? { lastText: reply.lastText } : {}
20690
+ ...reply.lastText !== void 0 ? { lastText: reply.lastText } : {},
20691
+ ...reply.lastError !== void 0 ? { lastError: reply.lastError } : {}
20681
20692
  } : { status: "unknown", unknownReason: reply.ok ? "ctl wait: no state" : reply.error },
20682
20693
  // The connection dropped. A socket that still answers (pi's /new
20683
20694
  // restarts the session) hands back to polling; one that does not
@@ -20886,8 +20897,308 @@ function wireSelfCommands(program) {
20886
20897
  }
20887
20898
 
20888
20899
  // src/cli/db.ts
20889
- import { existsSync as existsSync16, mkdirSync as mkdirSync6 } from "fs";
20900
+ import { existsSync as existsSync16, mkdirSync as mkdirSync6, statSync as statSync5 } from "fs";
20890
20901
  import { dirname as dirname13, resolve as resolve7 } from "path";
20902
+
20903
+ // src/compact.ts
20904
+ init_legacy_ops();
20905
+ var REDUNDANT_TOMBSTONES_SQL = `
20906
+ SELECT d.seq AS seq, length(d.payload) AS bytes
20907
+ FROM ops d
20908
+ WHERE d.entity = 'note' AND d.op = 'del' AND d.payload <> '{}'
20909
+ AND EXISTS (SELECT 1 FROM ops p
20910
+ WHERE p.entity = 'note' AND p.key = d.key AND p.op = 'put')`;
20911
+ function planCompact(db) {
20912
+ const r = db.prepare(
20913
+ `SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b FROM (${REDUNDANT_TOMBSTONES_SQL})`
20914
+ ).get();
20915
+ return { tombstones: r.n, bytes: r.b };
20916
+ }
20917
+ function compact(db) {
20918
+ const plan = planCompact(db);
20919
+ db.transaction(() => {
20920
+ db.prepare(
20921
+ `UPDATE ops SET payload = '{}' WHERE seq IN (SELECT seq FROM (${REDUNDANT_TOMBSTONES_SQL}))`
20922
+ ).run();
20923
+ })();
20924
+ return plan;
20925
+ }
20926
+ var WORKSTREAM_OPS_WHERE = `(key = @ws OR substr(key, 1, length(@ws) + 1) = @ws || '/')`;
20927
+ function listForgetCandidates(db) {
20928
+ const spellings = intentSpellings("workstream.teardown");
20929
+ const names = db.prepare(
20930
+ `SELECT key AS name, MAX(created_at) AS at
20931
+ FROM ops
20932
+ WHERE entity = 'workstream' AND op = 'del'
20933
+ AND intent IN (${spellings.map(() => "?").join(", ")})
20934
+ AND key NOT IN (SELECT name FROM workstreams)
20935
+ GROUP BY key`
20936
+ ).all(...spellings);
20937
+ const cost = db.prepare(
20938
+ `SELECT COUNT(*) AS n, COALESCE(SUM(length(payload)), 0) AS b FROM ops WHERE ${WORKSTREAM_OPS_WHERE}`
20939
+ );
20940
+ return names.map((r) => {
20941
+ const c = cost.get({ ws: r.name });
20942
+ return { name: r.name, tornDownAt: r.at, ops: c.n, bytes: c.b };
20943
+ }).sort((a, b) => b.bytes - a.bytes);
20944
+ }
20945
+ function planForget(db, names) {
20946
+ const all = new Map(listForgetCandidates(db).map((c) => [c.name, c]));
20947
+ const live = new Set(
20948
+ db.prepare("SELECT name FROM workstreams").all().map((r) => r.name)
20949
+ );
20950
+ const plan = { candidates: [], refused: [] };
20951
+ for (const name of new Set(names)) {
20952
+ const c = all.get(name);
20953
+ if (c) plan.candidates.push(c);
20954
+ else plan.refused.push({ name, why: live.has(name) ? "live" : "never-torn-down" });
20955
+ }
20956
+ return plan;
20957
+ }
20958
+ function forget(db, plan) {
20959
+ const del = db.prepare(`DELETE FROM ops WHERE ${WORKSTREAM_OPS_WHERE}`);
20960
+ let ops = 0;
20961
+ db.transaction(() => {
20962
+ for (const c of plan.candidates) ops += del.run({ ws: c.name }).changes;
20963
+ })();
20964
+ return { ops };
20965
+ }
20966
+
20967
+ // src/disk-recon.ts
20968
+ init_db();
20969
+ init_workspace();
20970
+ import { readdirSync as readdirSync3, statSync as statSync2 } from "fs";
20971
+ import { join as join12 } from "path";
20972
+ var STALE_LOCK_REPORT_MS = 60 * 60 * 1e3;
20973
+ function formatBytes(bytes) {
20974
+ if (bytes < 1024) return `${bytes}B`;
20975
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
20976
+ if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}M`;
20977
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`;
20978
+ }
20979
+ function dirNames(path2) {
20980
+ try {
20981
+ return readdirSync3(path2, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
20982
+ } catch {
20983
+ return [];
20984
+ }
20985
+ }
20986
+ function exists(path2) {
20987
+ try {
20988
+ statSync2(path2);
20989
+ return true;
20990
+ } catch {
20991
+ return false;
20992
+ }
20993
+ }
20994
+ function sizeOf(path2) {
20995
+ try {
20996
+ return statSync2(path2).size;
20997
+ } catch {
20998
+ return 0;
20999
+ }
21000
+ }
21001
+ function findMissingWorkspaceDirs(db) {
21002
+ return listWorkspaces2(db).filter((row2) => !exists(row2.path)).map((row2) => ({
21003
+ agentName: row2.agentName,
21004
+ workstreamName: row2.workstreamName,
21005
+ path: row2.path
21006
+ }));
21007
+ }
21008
+ function checkMissingWorkspaceDirs(db) {
21009
+ const missing = findMissingWorkspaceDirs(db);
21010
+ if (missing.length === 0) {
21011
+ return { name: "ws-rows", severity: "ok", detail: "every workspace row has its dir" };
21012
+ }
21013
+ return {
21014
+ name: "ws-rows",
21015
+ severity: "warn",
21016
+ detail: `${missing.length} workspace row(s) point at a path that is gone`,
21017
+ remediation: [
21018
+ "These vcs_workspaces rows name a directory that no longer exists:",
21019
+ ...missing.map((m) => ` ${m.workstreamName}/${m.agentName} ${m.path}`),
21020
+ "",
21021
+ "mu reports such a row as a healthy workspace on every read surface, and",
21022
+ "the next send or refresh against it fails inside the VCS backend rather",
21023
+ "than at the row that lied. Unregister them:",
21024
+ ...missing.map((m) => ` mu workspace free ${m.agentName} -w ${m.workstreamName}`)
21025
+ ]
21026
+ };
21027
+ }
21028
+ function checkWorkspaceOrphanDirs(db) {
21029
+ const orphans = listAllOrphanWorkspaces(db);
21030
+ if (orphans.length === 0) {
21031
+ return { name: "ws-dirs", severity: "ok", detail: "no orphan workspace dirs" };
21032
+ }
21033
+ const stranded = orphans.filter((o) => o.stranded);
21034
+ const strandedNote = stranded.length > 0 ? `, ${stranded.length} stranded` : "";
21035
+ return {
21036
+ name: "ws-dirs",
21037
+ severity: "warn",
21038
+ detail: `${orphans.length} workspace dir(s) with no DB row${strandedNote}`,
21039
+ remediation: [
21040
+ "These directories have no row in vcs_workspaces:",
21041
+ ...orphans.map((o) => ` ${o.path}${o.stranded ? " (workstream row also gone)" : ""}`),
21042
+ "",
21043
+ "Each one blocks the next `--workspace` spawn for that agent name. mu will",
21044
+ "not remove them: they may hold uncommitted work, and a stranded dir is the",
21045
+ "only remaining copy of it. Inspect, then remove by hand:",
21046
+ " mu workspace orphans --all",
21047
+ ...orphans.slice(0, 3).map((o) => ` rm -rf ${o.path}`)
21048
+ ]
21049
+ };
21050
+ }
21051
+ function checkEmptyWorkstreamDirs() {
21052
+ const root = join12(defaultStateDir(), "workspaces");
21053
+ const empty = dirNames(root).filter((name) => dirNames(join12(root, name)).length === 0);
21054
+ if (empty.length === 0) {
21055
+ return { name: "ws-empty", severity: "ok", detail: "no empty workstream dirs" };
21056
+ }
21057
+ return {
21058
+ name: "ws-empty",
21059
+ severity: "ok",
21060
+ detail: `${empty.length} empty workstream dir(s): ${empty.slice(0, 4).join(", ")}`,
21061
+ remediation: [
21062
+ "These per-workstream workspace dirs hold no checkouts:",
21063
+ ...empty.map((name) => ` ${join12(root, name)}`),
21064
+ "",
21065
+ "`mu workspace free` removes an agent's dir and its row but not the parent,",
21066
+ "so this is normal residue rather than a fault. Remove if you want the",
21067
+ "state dir to read cleanly:",
21068
+ ...empty.slice(0, 3).map((name) => ` rmdir ${join12(root, name)}`)
21069
+ ]
21070
+ };
21071
+ }
21072
+ function findStrayDbFiles(stateDir = defaultStateDir()) {
21073
+ const live = /* @__PURE__ */ new Set(["mu.db", "mu.db-wal", "mu.db-shm"]);
21074
+ let entries;
21075
+ try {
21076
+ entries = readdirSync3(stateDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name);
21077
+ } catch {
21078
+ return [];
21079
+ }
21080
+ return entries.filter((name) => name.startsWith("mu.db") && !live.has(name)).map((name) => ({ name, path: join12(stateDir, name), bytes: sizeOf(join12(stateDir, name)) })).sort((a, b) => b.bytes - a.bytes);
21081
+ }
21082
+ function checkStrayDbFiles() {
21083
+ const stateDir = defaultStateDir();
21084
+ const stray = findStrayDbFiles(stateDir);
21085
+ if (stray.length === 0) {
21086
+ return { name: "db-copies", severity: "ok", detail: "no stray DB copies" };
21087
+ }
21088
+ const total = stray.reduce((n, f) => n + f.bytes, 0);
21089
+ return {
21090
+ name: "db-copies",
21091
+ severity: "warn",
21092
+ detail: `${stray.length} stray DB file(s), ${formatBytes(total)} \u2014 nothing reads these`,
21093
+ remediation: [
21094
+ `Files matching mu.db* in ${stateDir} that are not the live WAL triple:`,
21095
+ ...stray.map((f) => ` ${formatBytes(f.bytes).padStart(6)} ${f.name}`),
21096
+ "",
21097
+ `Live DB: ${defaultDbPath()}`,
21098
+ "",
21099
+ "These are hand-made copies and pre-upgrade saves. No mu code path reads",
21100
+ "them and none prunes them. Real disaster recovery is the ops log, so a",
21101
+ "copy is only ever a convenience:",
21102
+ " mu rebuild <file> # DR from the ops log, not from a copy",
21103
+ " mu db backup <file> # a fresh copy, if that is what you wanted"
21104
+ ]
21105
+ };
21106
+ }
21107
+ function checkRemovedExportsDir() {
21108
+ const root = join12(defaultStateDir(), "exports");
21109
+ const entries = dirNames(root);
21110
+ if (entries.length === 0) {
21111
+ return { name: "exports", severity: "ok", detail: "no leftover export dirs" };
21112
+ }
21113
+ return {
21114
+ name: "exports",
21115
+ severity: "warn",
21116
+ detail: `${entries.length} export dir(s) from the removed \`mu workstream export\``,
21117
+ remediation: [
21118
+ `${root} holds ${entries.length} directory/ies.`,
21119
+ "",
21120
+ "`mu workstream export` and its markdown bucket were removed in 1.0; this",
21121
+ "is output from a version that still had it. Nothing in mu reads or prunes",
21122
+ "it. Keep anything you still want, then:",
21123
+ ` rm -rf ${root}`
21124
+ ]
21125
+ };
21126
+ }
21127
+ function checkStaleLocks(now = Date.now()) {
21128
+ const root = join12(defaultStateDir(), "locks");
21129
+ const stale = [];
21130
+ for (const name of dirNames(root)) {
21131
+ try {
21132
+ const ageMs2 = now - statSync2(join12(root, name)).mtimeMs;
21133
+ if (ageMs2 > STALE_LOCK_REPORT_MS) stale.push({ name, ageMs: ageMs2 });
21134
+ } catch {
21135
+ }
21136
+ }
21137
+ if (stale.length === 0) {
21138
+ return { name: "locks", severity: "ok", detail: "no stale lock dirs" };
21139
+ }
21140
+ return {
21141
+ name: "locks",
21142
+ severity: "warn",
21143
+ detail: `${stale.length} lock dir(s) older than 1h`,
21144
+ remediation: [
21145
+ "These advisory-lock dirs outlived the process that took them:",
21146
+ ...stale.map((s) => ` ${s.name} (${Math.round(s.ageMs / 6e4)}m old)`),
21147
+ "",
21148
+ "Not a deadlock: withFileLock steals a lock older than 30s. But it only",
21149
+ "steals under contention, so these will sit here forever. They are evidence",
21150
+ "of a spawn or segment flush that died mid-critical-section.",
21151
+ ` rm -rf ${root}/<name>`
21152
+ ]
21153
+ };
21154
+ }
21155
+ function walkBytes(path2) {
21156
+ let total = 0;
21157
+ let entries;
21158
+ try {
21159
+ entries = readdirSync3(path2, { withFileTypes: true });
21160
+ } catch {
21161
+ return 0;
21162
+ }
21163
+ for (const entry of entries) {
21164
+ const child = join12(path2, entry.name);
21165
+ if (entry.isDirectory()) total += walkBytes(child);
21166
+ else if (entry.isFile()) total += sizeOf(child);
21167
+ }
21168
+ return total;
21169
+ }
21170
+ function measureWorkspaceUsage(db) {
21171
+ const root = join12(defaultStateDir(), "workspaces");
21172
+ const registered = new Set(listWorkspaces2(db).map((w) => w.path));
21173
+ const usage = [];
21174
+ for (const wsName of dirNames(root)) {
21175
+ const wsRoot = join12(root, wsName);
21176
+ for (const agentName of dirNames(wsRoot)) {
21177
+ const path2 = join12(wsRoot, agentName);
21178
+ usage.push({
21179
+ workstreamName: wsName,
21180
+ agentName,
21181
+ path: path2,
21182
+ bytes: walkBytes(path2),
21183
+ orphan: !registered.has(path2)
21184
+ });
21185
+ }
21186
+ }
21187
+ return usage.sort((a, b) => b.bytes - a.bytes);
21188
+ }
21189
+ function checkDiskRecon(db) {
21190
+ return [
21191
+ checkMissingWorkspaceDirs(db),
21192
+ checkWorkspaceOrphanDirs(db),
21193
+ checkEmptyWorkstreamDirs(),
21194
+ checkStrayDbFiles(),
21195
+ checkRemovedExportsDir(),
21196
+ checkStaleLocks()
21197
+ ];
21198
+ }
21199
+
21200
+ // src/cli/db.ts
21201
+ init_drift();
20891
21202
  init_output();
20892
21203
 
20893
21204
  // src/cli/handle.ts
@@ -21695,6 +22006,110 @@ function cmdDbBackup(db, file, opts = {}) {
21695
22006
  console.log(`Backed up to ${pc.bold(target)}`);
21696
22007
  printNextSteps(nextSteps3);
21697
22008
  }
22009
+ function backupBeside(db, verb) {
22010
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
22011
+ const target = `${db.name}.pre-${verb}-${ts}`;
22012
+ db.prepare("VACUUM INTO ?").run(target);
22013
+ return target;
22014
+ }
22015
+ function vacuumAndCheck(db, backup) {
22016
+ const before = statSync5(db.name).size;
22017
+ db.exec("VACUUM");
22018
+ const after = statSync5(db.name).size;
22019
+ const report = checkDrift(db);
22020
+ if (!report.clean) {
22021
+ console.error(
22022
+ pc.red(`drift after the rewrite (${report.totalDrift}); the backup is ${backup}`)
22023
+ );
22024
+ throw new DriftDetectedError(report.totalDrift, report.records);
22025
+ }
22026
+ return { before, after };
22027
+ }
22028
+ function cmdDbCompact(db, opts = {}) {
22029
+ const plan = planCompact(db);
22030
+ if (opts.yes !== true || plan.tombstones === 0) {
22031
+ const nextSteps3 = plan.tombstones === 0 ? [] : [{ intent: "Apply it", command: "mu db compact --yes" }];
22032
+ if (opts.json === true) {
22033
+ emitJson({ dryRun: true, ...plan, nextSteps: nextSteps3 });
22034
+ return;
22035
+ }
22036
+ console.log(
22037
+ plan.tombstones === 0 ? pc.dim("nothing to compact: no redundant note tombstones") : `${plan.tombstones} note tombstones repeat a put already in the log (${formatBytes(plan.bytes)}). Dry run; nothing changed.`
22038
+ );
22039
+ printNextSteps(nextSteps3);
22040
+ return;
22041
+ }
22042
+ const backup = backupBeside(db, "compact");
22043
+ compact(db);
22044
+ const size = vacuumAndCheck(db, backup);
22045
+ if (opts.json === true) {
22046
+ emitJson({ dryRun: false, ...plan, backup, ...size });
22047
+ return;
22048
+ }
22049
+ console.log(
22050
+ `Compacted ${plan.tombstones} note tombstones: ${formatBytes(size.before)} \u2192 ${formatBytes(size.after)}. Drift check clean.`
22051
+ );
22052
+ console.log(pc.dim(`backup: ${backup}`));
22053
+ }
22054
+ function printForgetTable(plan) {
22055
+ const t = muTable({ head: ["workstream", "torn down", "ops", "size"].map((h) => pc.bold(h)) });
22056
+ for (const c of plan.candidates)
22057
+ t.push([c.name, c.tornDownAt.slice(0, 10), String(c.ops), formatBytes(c.bytes)]);
22058
+ console.log(t.toString());
22059
+ }
22060
+ function cmdDbForget(db, names, opts = {}) {
22061
+ const plan = planForget(db, names);
22062
+ if (plan.refused.length > 0) {
22063
+ const why = plan.refused.map((r2) => `${r2.name} (${r2.why === "live" ? "is live" : "was never torn down"})`).join(", ");
22064
+ throw new UsageError(`cannot forget: ${why}. Only torn-down workstreams can be forgotten.`);
22065
+ }
22066
+ const ops = plan.candidates.reduce((n, c) => n + c.ops, 0);
22067
+ const bytes = plan.candidates.reduce((n, c) => n + c.bytes, 0);
22068
+ if (opts.yes !== true) {
22069
+ const nextSteps3 = [
22070
+ { intent: "Forget them (NOT undoable)", command: `mu db forget ${names.join(" ")} --yes` }
22071
+ ];
22072
+ if (opts.json === true) {
22073
+ emitJson({ dryRun: true, candidates: plan.candidates, ops, bytes, nextSteps: nextSteps3 });
22074
+ return;
22075
+ }
22076
+ printForgetTable(plan);
22077
+ console.log(
22078
+ `${ops} ops (${formatBytes(bytes)} of payload). Forgetting deletes their history: mu undo can no longer restore these teardowns. Dry run; nothing changed.`
22079
+ );
22080
+ printNextSteps(nextSteps3);
22081
+ return;
22082
+ }
22083
+ const backup = backupBeside(db, "forget");
22084
+ const r = forget(db, plan);
22085
+ const size = vacuumAndCheck(db, backup);
22086
+ if (opts.json === true) {
22087
+ emitJson({
22088
+ dryRun: false,
22089
+ forgotten: plan.candidates.map((c) => c.name),
22090
+ ops: r.ops,
22091
+ backup,
22092
+ ...size
22093
+ });
22094
+ return;
22095
+ }
22096
+ console.log(
22097
+ `Forgot ${plan.candidates.length} workstream(s), ${r.ops} ops: ${formatBytes(size.before)} \u2192 ${formatBytes(size.after)}. Drift check clean.`
22098
+ );
22099
+ console.log(pc.dim(`backup (the only way back): ${backup}`));
22100
+ }
22101
+ function forgetHint(db) {
22102
+ const all = listForgetCandidates(db);
22103
+ const bytes = all.reduce((n, c) => n + c.bytes, 0);
22104
+ if (bytes < FORGET_HINT_MIN_BYTES) return void 0;
22105
+ return {
22106
+ total: all.length,
22107
+ ops: all.reduce((n, c) => n + c.ops, 0),
22108
+ bytes,
22109
+ top: all.slice(0, 3).map((c) => c.name)
22110
+ };
22111
+ }
22112
+ var FORGET_HINT_MIN_BYTES = 5 * 1024 * 1024;
21698
22113
  function wireDbCommands(program) {
21699
22114
  const dbCmd = program.command("db").description("Whole-DB file commands");
21700
22115
  dbCmd.command("backup <file>").description(
@@ -21703,247 +22118,24 @@ function wireDbCommands(program) {
21703
22118
  const opts = this.opts();
21704
22119
  return handle(async (db) => cmdDbBackup(db, file, opts), this)();
21705
22120
  });
22121
+ dbCmd.command("compact").description(
22122
+ "Shrink mu.db: blank note tombstones that repeat a put already in the log, then VACUUM. Dry run without --yes; --yes backs up beside the DB first and runs the drift check after. Local only; sync segments are untouched."
22123
+ ).option("--yes", "apply (default: dry run)").option(...JSON_OPT).action(function() {
22124
+ const opts = this.opts();
22125
+ return handle(async (db) => cmdDbCompact(db, opts), this)();
22126
+ });
22127
+ dbCmd.command("forget <workstreams...>").description(
22128
+ "Delete every op of torn-down workstreams to shrink mu.db. NOT undoable: mu undo can no longer restore them. Dry run without --yes; --yes backs up beside the DB first, VACUUMs, and runs the drift check. List candidates: mu workstream list --torn-down."
22129
+ ).option("--yes", "apply (default: dry run)").option(...JSON_OPT).action(function(names) {
22130
+ const opts = this.opts();
22131
+ return handle(async (db) => cmdDbForget(db, names, opts), this)();
22132
+ });
21706
22133
  }
21707
22134
 
21708
22135
  // src/cli/doctor.ts
21709
22136
  init_agents();
21710
22137
  import { dirname as dirname14 } from "path";
21711
22138
  init_db();
21712
-
21713
- // src/disk-recon.ts
21714
- init_db();
21715
- init_workspace();
21716
- import { readdirSync as readdirSync4, statSync as statSync4 } from "fs";
21717
- import { join as join13 } from "path";
21718
- var STALE_LOCK_REPORT_MS = 60 * 60 * 1e3;
21719
- function formatBytes(bytes) {
21720
- if (bytes < 1024) return `${bytes}B`;
21721
- if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
21722
- if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}M`;
21723
- return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}G`;
21724
- }
21725
- function dirNames(path2) {
21726
- try {
21727
- return readdirSync4(path2, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
21728
- } catch {
21729
- return [];
21730
- }
21731
- }
21732
- function exists(path2) {
21733
- try {
21734
- statSync4(path2);
21735
- return true;
21736
- } catch {
21737
- return false;
21738
- }
21739
- }
21740
- function sizeOf(path2) {
21741
- try {
21742
- return statSync4(path2).size;
21743
- } catch {
21744
- return 0;
21745
- }
21746
- }
21747
- function findMissingWorkspaceDirs(db) {
21748
- return listWorkspaces2(db).filter((row2) => !exists(row2.path)).map((row2) => ({
21749
- agentName: row2.agentName,
21750
- workstreamName: row2.workstreamName,
21751
- path: row2.path
21752
- }));
21753
- }
21754
- function checkMissingWorkspaceDirs(db) {
21755
- const missing = findMissingWorkspaceDirs(db);
21756
- if (missing.length === 0) {
21757
- return { name: "ws-rows", severity: "ok", detail: "every workspace row has its dir" };
21758
- }
21759
- return {
21760
- name: "ws-rows",
21761
- severity: "warn",
21762
- detail: `${missing.length} workspace row(s) point at a path that is gone`,
21763
- remediation: [
21764
- "These vcs_workspaces rows name a directory that no longer exists:",
21765
- ...missing.map((m) => ` ${m.workstreamName}/${m.agentName} ${m.path}`),
21766
- "",
21767
- "mu reports such a row as a healthy workspace on every read surface, and",
21768
- "the next send or refresh against it fails inside the VCS backend rather",
21769
- "than at the row that lied. Unregister them:",
21770
- ...missing.map((m) => ` mu workspace free ${m.agentName} -w ${m.workstreamName}`)
21771
- ]
21772
- };
21773
- }
21774
- function checkWorkspaceOrphanDirs(db) {
21775
- const orphans = listAllOrphanWorkspaces(db);
21776
- if (orphans.length === 0) {
21777
- return { name: "ws-dirs", severity: "ok", detail: "no orphan workspace dirs" };
21778
- }
21779
- const stranded = orphans.filter((o) => o.stranded);
21780
- const strandedNote = stranded.length > 0 ? `, ${stranded.length} stranded` : "";
21781
- return {
21782
- name: "ws-dirs",
21783
- severity: "warn",
21784
- detail: `${orphans.length} workspace dir(s) with no DB row${strandedNote}`,
21785
- remediation: [
21786
- "These directories have no row in vcs_workspaces:",
21787
- ...orphans.map((o) => ` ${o.path}${o.stranded ? " (workstream row also gone)" : ""}`),
21788
- "",
21789
- "Each one blocks the next `--workspace` spawn for that agent name. mu will",
21790
- "not remove them: they may hold uncommitted work, and a stranded dir is the",
21791
- "only remaining copy of it. Inspect, then remove by hand:",
21792
- " mu workspace orphans --all",
21793
- ...orphans.slice(0, 3).map((o) => ` rm -rf ${o.path}`)
21794
- ]
21795
- };
21796
- }
21797
- function checkEmptyWorkstreamDirs() {
21798
- const root = join13(defaultStateDir(), "workspaces");
21799
- const empty = dirNames(root).filter((name) => dirNames(join13(root, name)).length === 0);
21800
- if (empty.length === 0) {
21801
- return { name: "ws-empty", severity: "ok", detail: "no empty workstream dirs" };
21802
- }
21803
- return {
21804
- name: "ws-empty",
21805
- severity: "ok",
21806
- detail: `${empty.length} empty workstream dir(s): ${empty.slice(0, 4).join(", ")}`,
21807
- remediation: [
21808
- "These per-workstream workspace dirs hold no checkouts:",
21809
- ...empty.map((name) => ` ${join13(root, name)}`),
21810
- "",
21811
- "`mu workspace free` removes an agent's dir and its row but not the parent,",
21812
- "so this is normal residue rather than a fault. Remove if you want the",
21813
- "state dir to read cleanly:",
21814
- ...empty.slice(0, 3).map((name) => ` rmdir ${join13(root, name)}`)
21815
- ]
21816
- };
21817
- }
21818
- function findStrayDbFiles(stateDir = defaultStateDir()) {
21819
- const live = /* @__PURE__ */ new Set(["mu.db", "mu.db-wal", "mu.db-shm"]);
21820
- let entries;
21821
- try {
21822
- entries = readdirSync4(stateDir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name);
21823
- } catch {
21824
- return [];
21825
- }
21826
- return entries.filter((name) => name.startsWith("mu.db") && !live.has(name)).map((name) => ({ name, path: join13(stateDir, name), bytes: sizeOf(join13(stateDir, name)) })).sort((a, b) => b.bytes - a.bytes);
21827
- }
21828
- function checkStrayDbFiles() {
21829
- const stateDir = defaultStateDir();
21830
- const stray = findStrayDbFiles(stateDir);
21831
- if (stray.length === 0) {
21832
- return { name: "db-copies", severity: "ok", detail: "no stray DB copies" };
21833
- }
21834
- const total = stray.reduce((n, f) => n + f.bytes, 0);
21835
- return {
21836
- name: "db-copies",
21837
- severity: "warn",
21838
- detail: `${stray.length} stray DB file(s), ${formatBytes(total)} \u2014 nothing reads these`,
21839
- remediation: [
21840
- `Files matching mu.db* in ${stateDir} that are not the live WAL triple:`,
21841
- ...stray.map((f) => ` ${formatBytes(f.bytes).padStart(6)} ${f.name}`),
21842
- "",
21843
- `Live DB: ${defaultDbPath()}`,
21844
- "",
21845
- "These are hand-made copies and pre-upgrade saves. No mu code path reads",
21846
- "them and none prunes them. Real disaster recovery is the ops log, so a",
21847
- "copy is only ever a convenience:",
21848
- " mu rebuild <file> # DR from the ops log, not from a copy",
21849
- " mu db backup <file> # a fresh copy, if that is what you wanted"
21850
- ]
21851
- };
21852
- }
21853
- function checkRemovedExportsDir() {
21854
- const root = join13(defaultStateDir(), "exports");
21855
- const entries = dirNames(root);
21856
- if (entries.length === 0) {
21857
- return { name: "exports", severity: "ok", detail: "no leftover export dirs" };
21858
- }
21859
- return {
21860
- name: "exports",
21861
- severity: "warn",
21862
- detail: `${entries.length} export dir(s) from the removed \`mu workstream export\``,
21863
- remediation: [
21864
- `${root} holds ${entries.length} directory/ies.`,
21865
- "",
21866
- "`mu workstream export` and its markdown bucket were removed in 1.0; this",
21867
- "is output from a version that still had it. Nothing in mu reads or prunes",
21868
- "it. Keep anything you still want, then:",
21869
- ` rm -rf ${root}`
21870
- ]
21871
- };
21872
- }
21873
- function checkStaleLocks(now = Date.now()) {
21874
- const root = join13(defaultStateDir(), "locks");
21875
- const stale = [];
21876
- for (const name of dirNames(root)) {
21877
- try {
21878
- const ageMs2 = now - statSync4(join13(root, name)).mtimeMs;
21879
- if (ageMs2 > STALE_LOCK_REPORT_MS) stale.push({ name, ageMs: ageMs2 });
21880
- } catch {
21881
- }
21882
- }
21883
- if (stale.length === 0) {
21884
- return { name: "locks", severity: "ok", detail: "no stale lock dirs" };
21885
- }
21886
- return {
21887
- name: "locks",
21888
- severity: "warn",
21889
- detail: `${stale.length} lock dir(s) older than 1h`,
21890
- remediation: [
21891
- "These advisory-lock dirs outlived the process that took them:",
21892
- ...stale.map((s) => ` ${s.name} (${Math.round(s.ageMs / 6e4)}m old)`),
21893
- "",
21894
- "Not a deadlock: withFileLock steals a lock older than 30s. But it only",
21895
- "steals under contention, so these will sit here forever. They are evidence",
21896
- "of a spawn or segment flush that died mid-critical-section.",
21897
- ` rm -rf ${root}/<name>`
21898
- ]
21899
- };
21900
- }
21901
- function walkBytes(path2) {
21902
- let total = 0;
21903
- let entries;
21904
- try {
21905
- entries = readdirSync4(path2, { withFileTypes: true });
21906
- } catch {
21907
- return 0;
21908
- }
21909
- for (const entry of entries) {
21910
- const child = join13(path2, entry.name);
21911
- if (entry.isDirectory()) total += walkBytes(child);
21912
- else if (entry.isFile()) total += sizeOf(child);
21913
- }
21914
- return total;
21915
- }
21916
- function measureWorkspaceUsage(db) {
21917
- const root = join13(defaultStateDir(), "workspaces");
21918
- const registered = new Set(listWorkspaces2(db).map((w) => w.path));
21919
- const usage = [];
21920
- for (const wsName of dirNames(root)) {
21921
- const wsRoot = join13(root, wsName);
21922
- for (const agentName of dirNames(wsRoot)) {
21923
- const path2 = join13(wsRoot, agentName);
21924
- usage.push({
21925
- workstreamName: wsName,
21926
- agentName,
21927
- path: path2,
21928
- bytes: walkBytes(path2),
21929
- orphan: !registered.has(path2)
21930
- });
21931
- }
21932
- }
21933
- return usage.sort((a, b) => b.bytes - a.bytes);
21934
- }
21935
- function checkDiskRecon(db) {
21936
- return [
21937
- checkMissingWorkspaceDirs(db),
21938
- checkWorkspaceOrphanDirs(db),
21939
- checkEmptyWorkstreamDirs(),
21940
- checkStrayDbFiles(),
21941
- checkRemovedExportsDir(),
21942
- checkStaleLocks()
21943
- ];
21944
- }
21945
-
21946
- // src/cli/doctor.ts
21947
22139
  init_doctor_summary();
21948
22140
 
21949
22141
  // src/dormant.ts
@@ -22272,12 +22464,37 @@ state (workstream=${ws})`));
22272
22464
  throw new DriftDetectedError(cheap.totalUnexplained, []);
22273
22465
  }
22274
22466
  }
22467
+ const size = opsSizeHint(db);
22468
+ if (size) {
22469
+ console.log(` size : ${pc.yellow("hint")} ${size.line}`);
22470
+ for (const c of size.commands) console.log(pc.dim(` ${c}`));
22471
+ }
22275
22472
  if (sawHazard) {
22276
22473
  console.log(
22277
22474
  pc.dim("\nSee the fleet / disk sections above: at least one finding needs attention.")
22278
22475
  );
22279
22476
  }
22280
22477
  }
22478
+ function opsSizeHint(db) {
22479
+ const forgettable = forgetHint(db);
22480
+ const tomb = planCompact(db);
22481
+ const parts = [];
22482
+ const commands = [];
22483
+ if (forgettable) {
22484
+ parts.push(
22485
+ `${forgettable.total} torn-down workstreams hold ${forgettable.ops} ops (${formatBytes(forgettable.bytes)}); largest: ${forgettable.top.join(", ")}`
22486
+ );
22487
+ commands.push(
22488
+ "see them: mu workstream list --torn-down",
22489
+ `forget some: mu db forget ${forgettable.top.join(" ")}`
22490
+ );
22491
+ }
22492
+ if (tomb.bytes >= 1024 * 1024) {
22493
+ parts.push(`${tomb.tombstones} redundant note tombstones (${formatBytes(tomb.bytes)})`);
22494
+ commands.push("compact: mu db compact");
22495
+ }
22496
+ return parts.length === 0 ? void 0 : { line: parts.join("; "), commands };
22497
+ }
22281
22498
  async function cmdDoctorJson(db, opts = {}) {
22282
22499
  const health = await muxHealth();
22283
22500
  const muExt = ctlExtensionDoctorCheck();
@@ -22419,6 +22636,7 @@ async function cmdDoctorJson(db, opts = {}) {
22419
22636
  disk,
22420
22637
  workspaceUsage,
22421
22638
  drift,
22639
+ size: opsSizeHint(db) ?? null,
22422
22640
  remediation: drift.ok === true ? [] : driftRemediation()
22423
22641
  });
22424
22642
  if (driftFailure !== null) throw driftFailure;