mover-os 4.7.8 → 4.7.10

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.
Files changed (43) hide show
  1. package/README.md +34 -24
  2. package/install.js +2229 -204
  3. package/package.json +3 -2
  4. package/src/dashboard/build.js +41 -3
  5. package/src/dashboard/lib/active-context-parser.js +16 -4
  6. package/src/dashboard/lib/agent-session.js +70 -49
  7. package/src/dashboard/lib/approval-registry.js +170 -0
  8. package/src/dashboard/lib/daily-note-resolver.js +20 -3
  9. package/src/dashboard/lib/date-utils.js +9 -1
  10. package/src/dashboard/lib/distribution-parser.js +69 -10
  11. package/src/dashboard/lib/drift-history.js +6 -1
  12. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  13. package/src/dashboard/lib/engine-health.js +4 -1
  14. package/src/dashboard/lib/engine-writer.js +157 -11
  15. package/src/dashboard/lib/goal-forecast.js +154 -31
  16. package/src/dashboard/lib/library-indexer-v2.js +14 -0
  17. package/src/dashboard/lib/library-search.js +290 -0
  18. package/src/dashboard/lib/log-activation.sh +0 -0
  19. package/src/dashboard/lib/memory-index.js +61 -12
  20. package/src/dashboard/lib/pid-markers.js +80 -0
  21. package/src/dashboard/lib/regenerate-manifest.js +0 -0
  22. package/src/dashboard/lib/run-registry.js +75 -15
  23. package/src/dashboard/lib/state-core/backfill.js +298 -0
  24. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  25. package/src/dashboard/lib/state-core/event-log.js +615 -0
  26. package/src/dashboard/lib/state-core/events.js +265 -0
  27. package/src/dashboard/lib/state-core/projections.js +376 -0
  28. package/src/dashboard/lib/state-core/start-close.js +162 -0
  29. package/src/dashboard/lib/state-core/trial.js +96 -0
  30. package/src/dashboard/lib/strategy-parser.js +3 -0
  31. package/src/dashboard/lib/suggested-now.js +2 -2
  32. package/src/dashboard/lib/transcript-parser.js +48 -8
  33. package/src/dashboard/server.js +422 -44
  34. package/src/dashboard/shortcut.js +0 -0
  35. package/src/dashboard/ui/dist/assets/index-BoxTW_kj.js +161 -0
  36. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  37. package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/system/counts.json +7 -0
  41. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  42. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  43. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
package/install.js CHANGED
@@ -4,8 +4,8 @@
4
4
  // Zero dependencies. Node.js 18+.
5
5
  //
6
6
  // Usage:
7
- // npx moveros
8
- // npx moveros --key YOUR_KEY --vault ~/vault
7
+ // npx mover-os
8
+ // npx mover-os --key YOUR_KEY --vault ~/vault
9
9
  // ══════════════════════════════════════════════════════════════════════════════
10
10
 
11
11
  const readline = require("readline");
@@ -13,7 +13,7 @@ const fs = require("fs");
13
13
  const path = require("path");
14
14
  const os = require("os");
15
15
  const crypto = require("crypto");
16
- const { execSync } = require("child_process");
16
+ const { execSync, spawnSync } = require("child_process");
17
17
 
18
18
  const VERSION = "4";
19
19
 
@@ -43,7 +43,12 @@ function jsonOut(command, data, ok = true) {
43
43
  }
44
44
 
45
45
  // ─── TTY detection (must be before output budget check) ──────────────────────
46
- const IS_TTY = process.stdout.isTTY && process.stdin.isTTY;
46
+ // --yes/-y forces the non-interactive default path everywhere. It also serves
47
+ // as the escape hatch for Git Bash on Windows, where isTTY can read false at a
48
+ // real terminal (npm shim indirection) — the documented flag must actually
49
+ // work (R5-A: opts.yes was parsed but never read).
50
+ const FORCE_DEFAULTS = process.argv.includes("--yes") || process.argv.includes("-y");
51
+ const IS_TTY = !FORCE_DEFAULTS && process.stdout.isTTY && process.stdin.isTTY;
47
52
 
48
53
  // ─── Output budget (30K chars = Anthropic bash tool truncation limit) ────────
49
54
  const MAX_OUTPUT_CHARS = 28000; // Leave 2K buffer below 30K limit
@@ -270,7 +275,9 @@ async function printHeader(animate = IS_TTY) {
270
275
 
271
276
  // Progress bar with label and percentage
272
277
  function progressBar(current, total, width = 30, label = "") {
273
- const pct = Math.min(1, current / total);
278
+ // terra read F6: total===0 made current/total NaN, rendered as "NaN%". A valid
279
+ // empty (no-tasks) state is 0%, not corrupt.
280
+ const pct = total > 0 ? Math.min(1, current / total) : 0;
274
281
  const filled = Math.round(pct * width);
275
282
  const empty = width - filled;
276
283
  const bar = `${S.green}${"█".repeat(filled)}${S.gray}${"░".repeat(empty)}${S.reset}`;
@@ -465,8 +472,15 @@ function textInput({ label = "", initial = "", mask = null, placeholder = "" })
465
472
  question(label);
466
473
 
467
474
  if (!IS_TTY) {
475
+ // --yes: never wait for input, take the default outright.
476
+ if (FORCE_DEFAULTS) { resolve(initial); return; }
468
477
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
469
- rl.question(`${BAR_COLOR}│${S.reset} `, (ans) => { rl.close(); resolve(ans || initial); });
478
+ let settled = false;
479
+ const settle = (ans) => { if (settled) return; settled = true; try { rl.close(); } catch {} resolve(ans || initial); };
480
+ rl.question(`${BAR_COLOR}│${S.reset} `, settle);
481
+ // A closed/empty stdin (CI, scripts) fires 'close' with no answer —
482
+ // resolve the default instead of hanging forever (R5-A).
483
+ rl.on("close", () => settle(""));
470
484
  return;
471
485
  }
472
486
 
@@ -730,6 +744,30 @@ async function validateKey(key) {
730
744
  }
731
745
  }
732
746
 
747
+ function persistActivationId(moverDir, activationId) {
748
+ if (typeof activationId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(activationId)) {
749
+ return false;
750
+ }
751
+ try {
752
+ fs.mkdirSync(moverDir, { recursive: true, mode: 0o700 });
753
+ fs.writeFileSync(path.join(moverDir, ".activation-id"), activationId, { encoding: "utf8", mode: 0o600 });
754
+ return true;
755
+ } catch {
756
+ return false;
757
+ }
758
+ }
759
+
760
+ function readActivationId(moverDir) {
761
+ try {
762
+ const activationId = fs.readFileSync(path.join(moverDir, ".activation-id"), "utf8").trim();
763
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(activationId)
764
+ ? activationId
765
+ : null;
766
+ } catch {
767
+ return null;
768
+ }
769
+ }
770
+
733
771
  async function activateKey(key) {
734
772
  if (!key) return;
735
773
  try {
@@ -743,7 +781,7 @@ async function activateKey(key) {
743
781
  let label = os.hostname();
744
782
  try { label = getMachineId(moverDir) || label; } catch {}
745
783
  const body = JSON.stringify({ key: key.trim(), organization_id: POLAR_ORG_ID, label });
746
- await new Promise((resolve, reject) => {
784
+ const result = await new Promise((resolve, reject) => {
747
785
  const req = https.request({
748
786
  hostname: "api.polar.sh",
749
787
  path: "/v1/customer-portal/license-keys/activate",
@@ -753,13 +791,20 @@ async function activateKey(key) {
753
791
  }, (res) => {
754
792
  let data = "";
755
793
  res.on("data", (chunk) => data += chunk);
756
- res.on("end", () => resolve(data));
794
+ res.on("end", () => resolve({ statusCode: res.statusCode, data }));
757
795
  });
758
796
  req.on("error", reject);
759
797
  req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
760
798
  req.write(body);
761
799
  req.end();
762
800
  });
801
+ if (result.statusCode >= 200 && result.statusCode < 300) {
802
+ try {
803
+ const data = JSON.parse(result.data);
804
+ persistActivationId(moverDir, data.id);
805
+ } catch {}
806
+ }
807
+ return result;
763
808
  } catch { /* Activation failure is non-blocking */ }
764
809
  }
765
810
 
@@ -812,6 +857,8 @@ async function downloadPayload(key) {
812
857
  headers: { "X-License-Key": key.trim(), "X-Machine-Id": machineId },
813
858
  timeout: 60000,
814
859
  }, (res) => {
860
+ const activationId = res.headers["x-mover-activation-id"];
861
+ if (typeof activationId === "string") persistActivationId(moverDir, activationId);
815
862
  if (res.statusCode === 301 || res.statusCode === 302) {
816
863
  // Follow redirect — only to trusted domains
817
864
  const redirectUrl = new URL(res.headers.location);
@@ -1083,8 +1130,12 @@ function loadCliUsage() {
1083
1130
 
1084
1131
  function recordCliUsage(cmd) {
1085
1132
  const cfgPath = path.join(os.homedir(), ".mover", "config.json");
1133
+ // Do NOT create config.json just to log a usage counter (terra P2): a user
1134
+ // with no install running a stray `moveros who` should leave zero trace.
1135
+ // Usage tracking is for actual installs, which already have this file.
1136
+ if (!fs.existsSync(cfgPath)) return;
1086
1137
  try {
1087
- const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
1138
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
1088
1139
  if (!cfg.cli_usage) cfg.cli_usage = {};
1089
1140
  const entry = cfg.cli_usage[cmd] || { count: 0, last: 0 };
1090
1141
  entry.count++;
@@ -1111,6 +1162,8 @@ const CLI_COMMANDS = {
1111
1162
  doctor: { desc: "Health check across all installed agents", alias: [] },
1112
1163
  pulse: { desc: "Browser dashboard — Suggested Now, patterns, drift (use --terminal for TUI)",alias: ["dashboard"] },
1113
1164
  open: { desc: "Open the dashboard in your browser (starts it in the background if needed)", alias: [] },
1165
+ shortcut: { desc: "Desktop launcher (double-clickable dashboard icon)", alias: [] },
1166
+ menubar: { desc: "Native macOS menu-bar status app (macOS only)", alias: [] },
1114
1167
  menu: { desc: "Classic terminal launcher (install, update, doctor…)", alias: [] },
1115
1168
  // warm removed — hooks + rules + /morning already handle session priming
1116
1169
  capture: { desc: "Quick capture — tasks, links, ideas", alias: [] },
@@ -1126,6 +1179,7 @@ const CLI_COMMANDS = {
1126
1179
  status: { desc: "Full system state (vault, agents, version)", alias: [] },
1127
1180
  help: { desc: "Interactive guide to Mover OS", alias: ["-h"] },
1128
1181
  test: { desc: "Run integration tests (dev)", alias: [], hidden: true },
1182
+ inventory: { desc: "Bundle facts as JSON (docs are generated from this)", alias: [], hidden: true },
1129
1183
  };
1130
1184
 
1131
1185
  function parseArgs() {
@@ -1394,6 +1448,157 @@ function savePristineCopy(category, srcFile, relPath) {
1394
1448
  fs.writeFileSync(destPath, content, "utf8");
1395
1449
  }
1396
1450
 
1451
+ // ── Update State Machine (journal) ──────────────────────────────────────────
1452
+ // One transactional, resumable record of an update run, shared by the CLI and
1453
+ // the /update workflow (sol part 10: CLI plus /update must be ONE state
1454
+ // machine, not a prose handoff). Stages: preflight, backup, stage, apply,
1455
+ // migrate, verify, stamp. The CLI owns the mechanical stages (through apply,
1456
+ // plus verify+stamp on --quick where there is no AI migration); the /update
1457
+ // workflow owns migrate and finishes with `moveros update --finalize`, which
1458
+ // verifies the applied files and stamps the version ONLY on a pass. Every
1459
+ // stage is idempotent: after a crash at any point, re-running `moveros update`
1460
+ // resumes (the journal keeps the ORIGINAL backup dir, so rollback always
1461
+ // points at the true pre-update state, never a mid-update snapshot).
1462
+ const UPDATE_STAGES = ["preflight", "backup", "stage", "apply", "migrate", "verify", "stamp"];
1463
+
1464
+ function updateJournalPath() {
1465
+ return path.join(os.homedir(), ".mover", "update-state.json");
1466
+ }
1467
+
1468
+ function loadUpdateJournal() {
1469
+ try { return JSON.parse(fs.readFileSync(updateJournalPath(), "utf8")); } catch { return null; }
1470
+ }
1471
+
1472
+ function saveUpdateJournal(journal) {
1473
+ const p = updateJournalPath();
1474
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1475
+ journal.updatedAt = new Date().toISOString();
1476
+ // Atomic same-dir temp+rename: a crash mid-save leaves the old journal
1477
+ // intact, never a torn one (same discipline as linkOrCopy's F6 fix).
1478
+ const tmp = p + ".tmp-" + process.pid;
1479
+ fs.writeFileSync(tmp, JSON.stringify(journal, null, 2), "utf8");
1480
+ fs.renameSync(tmp, p);
1481
+ }
1482
+
1483
+ function newUpdateJournal(mode, fromVersion, toVersion, vaultPath) {
1484
+ const stages = {};
1485
+ for (const s of UPDATE_STAGES) stages[s] = { status: "pending" };
1486
+ return {
1487
+ runId: `upd-${new Date().toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}`,
1488
+ mode,
1489
+ fromVersion: fromVersion || null,
1490
+ toVersion,
1491
+ vaultPath,
1492
+ startedAt: new Date().toISOString(),
1493
+ completedAt: null,
1494
+ stages,
1495
+ };
1496
+ }
1497
+
1498
+ function journalStage(journal, name, status, extra) {
1499
+ journal.stages[name] = { ...(journal.stages[name] || {}), ...(extra || {}), status, at: new Date().toISOString() };
1500
+ saveUpdateJournal(journal);
1501
+ }
1502
+
1503
+ // Deterministic crash injection for the update-machine tests: kills THIS
1504
+ // process at a named stage boundary so crash-at-each-stage recoverability is
1505
+ // provable, not timing-dependent. Inert unless MOVER_TEST_CRASH_STAGE is set.
1506
+ function maybeCrashForTest(stage) {
1507
+ if (process.env.MOVER_TEST_CRASH_STAGE === stage) process.kill(process.pid, "SIGKILL");
1508
+ }
1509
+
1510
+ // ── Verify stage ─────────────────────────────────────────────────────────────
1511
+ // The version stamp is a claim ("this install IS at version X"); verify makes
1512
+ // the claim earned, not assumed. Re-classify every workflow and template
1513
+ // against the saved manifest and fail on any state where upstream content is
1514
+ // missing from disk (install / safe-overwrite / legacy-overwrite) or a
1515
+ // conflict is unresolved. PASS states: "skip" (current) and "user-only-skip"
1516
+ // (a deliberate user customization, respected by design; transformed workflows
1517
+ // land here too because the pristine base is the raw source). Hooks are
1518
+ // Mover-owned and force-synced, so when a Claude hooks dir exists every bundle
1519
+ // hook must be present with matching content.
1520
+ // options.templates: "content" (full three-way check, interactive/finalize) or
1521
+ // "presence" (file exists; quick mode does not force-overwrite vault scaffold
1522
+ // files, so content drift there is not a quick-mode failure).
1523
+ function verifyUpdateApplied(bundleDir, vaultPath, options = {}) {
1524
+ const templatesMode = options.templates || "content";
1525
+ const failures = [];
1526
+ const home = os.homedir();
1527
+
1528
+ // Unresolved conflicts block the stamp outright.
1529
+ const conflictsPath = path.join(home, ".mover", "conflicts.json");
1530
+ if (fs.existsSync(conflictsPath)) {
1531
+ try {
1532
+ const data = JSON.parse(fs.readFileSync(conflictsPath, "utf8"));
1533
+ for (const f of data.files || []) failures.push({ file: f.file, reason: "unresolved-conflict" });
1534
+ } catch {
1535
+ failures.push({ file: "conflicts.json", reason: "unreadable" });
1536
+ }
1537
+ }
1538
+
1539
+ const manifest = loadUpdateManifest();
1540
+ const FAIL_ACTIONS = new Set(["install", "safe-overwrite", "legacy-overwrite", "conflict"]);
1541
+
1542
+ const wfSrcDir = path.join(bundleDir, "src", "workflows");
1543
+ const wfDestDir = path.join(home, ".claude", "commands");
1544
+ if (fs.existsSync(wfSrcDir) && fs.existsSync(wfDestDir)) {
1545
+ for (const file of fs.readdirSync(wfSrcDir).filter((f) => f.endsWith(".md"))) {
1546
+ const destFile = path.join(wfDestDir, file);
1547
+ if (file === "update.md") {
1548
+ // Force-synced with a transform on every update; presence + non-empty
1549
+ // is its contract, not hash equality.
1550
+ try {
1551
+ if (fs.statSync(destFile).size === 0) failures.push({ file: "workflows/update.md", reason: "empty" });
1552
+ } catch {
1553
+ failures.push({ file: "workflows/update.md", reason: "missing" });
1554
+ }
1555
+ continue;
1556
+ }
1557
+ const r = compareForUpdate("workflows", path.join(wfSrcDir, file), destFile, manifest);
1558
+ if (FAIL_ACTIONS.has(r.action)) failures.push({ file: `workflows/${file}`, reason: r.action });
1559
+ }
1560
+ }
1561
+
1562
+ const tmplSrcDir = path.join(bundleDir, "src", "structure");
1563
+ if (fs.existsSync(tmplSrcDir) && vaultPath) {
1564
+ const walk = (dir, rel) => {
1565
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1566
+ const srcPath = path.join(dir, entry.name);
1567
+ const entryRel = path.join(rel, entry.name);
1568
+ if (entry.isDirectory()) { walk(srcPath, entryRel); continue; }
1569
+ const relNorm = entryRel.replace(/\\/g, "/");
1570
+ // Engine files are user data; never verified against templates.
1571
+ if (relNorm.includes("02_Areas") && relNorm.includes("Engine")) continue;
1572
+ const destFile = path.join(vaultPath, entryRel);
1573
+ if (templatesMode === "presence") {
1574
+ if (!fs.existsSync(destFile)) failures.push({ file: `templates/${relNorm}`, reason: "missing" });
1575
+ continue;
1576
+ }
1577
+ const r = compareForUpdate("templates", srcPath, destFile, manifest, relNorm);
1578
+ if (FAIL_ACTIONS.has(r.action)) failures.push({ file: `templates/${relNorm}`, reason: r.action });
1579
+ }
1580
+ };
1581
+ walk(tmplSrcDir, "");
1582
+ }
1583
+
1584
+ // Hooks: force-synced, Mover-owned. Only checked when a Claude hooks dir
1585
+ // exists (a cursor-only install has no ~/.claude/hooks and owes none).
1586
+ const hooksSrcDir = path.join(bundleDir, "src", "hooks");
1587
+ const hooksDestDir = path.join(home, ".claude", "hooks");
1588
+ if (fs.existsSync(hooksSrcDir) && fs.existsSync(hooksDestDir)) {
1589
+ for (const f of fs.readdirSync(hooksSrcDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
1590
+ const destFile = path.join(hooksDestDir, f);
1591
+ if (!fs.existsSync(destFile)) {
1592
+ failures.push({ file: `hooks/${f}`, reason: "missing" });
1593
+ } else if (fileContentHash(destFile) !== fileContentHash(path.join(hooksSrcDir, f))) {
1594
+ failures.push({ file: `hooks/${f}`, reason: "stale" });
1595
+ }
1596
+ }
1597
+ }
1598
+
1599
+ return { ok: failures.length === 0, failures };
1600
+ }
1601
+
1397
1602
  function compareForUpdate(category, srcFile, destFile, manifest, relPath) {
1398
1603
  const fileName = relPath ? `${category}/${relPath}` : `${category}/${path.basename(srcFile)}`;
1399
1604
  const newHash = fileContentHash(srcFile);
@@ -1577,11 +1782,28 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
1577
1782
  const backupRoot = path.join(home, ".mover", "backups", `pre-update-${ts}`);
1578
1783
  let backed = 0;
1579
1784
 
1785
+ // Rollback manifest: every entry records the ABSOLUTE original path plus
1786
+ // whether the file existed before the update. `moveros update --rollback`
1787
+ // replays it: existedBefore -> restore bytes; created-by-update -> remove.
1788
+ // This is the npm-correct rollback target (vault + agent artifacts), never a
1789
+ // git checkout of the bundle dir, which on npm installs is a read-only
1790
+ // global package (sol part 10: rollback assumptions vs npm installs).
1791
+ const entries = [];
1792
+
1580
1793
  const backup = (src, relPath) => {
1581
1794
  if (!fs.existsSync(src)) return;
1582
1795
  const dest = path.join(backupRoot, relPath);
1583
1796
  fs.mkdirSync(path.dirname(dest), { recursive: true });
1584
- try { fs.copyFileSync(src, dest); backed++; } catch {}
1797
+ try {
1798
+ fs.copyFileSync(src, dest);
1799
+ backed++;
1800
+ entries.push({ original: src, backup: relPath, existedBefore: true });
1801
+ } catch {}
1802
+ };
1803
+ // A file the update will CREATE: nothing to copy, but rollback must remove it.
1804
+ const willCreate = (dest) => {
1805
+ if (fs.existsSync(dest)) return;
1806
+ entries.push({ original: dest, backup: null, existedBefore: false });
1585
1807
  };
1586
1808
 
1587
1809
  // Workflows
@@ -1595,12 +1817,18 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
1595
1817
  backup(path.join(dir, f.file), path.join("workflows", path.basename(dir), f.file));
1596
1818
  }
1597
1819
  }
1820
+ for (const f of changes.workflows.filter((x) => x.status === "new")) {
1821
+ for (const dir of wfDests) willCreate(path.join(dir, f.file));
1822
+ }
1598
1823
 
1599
1824
  // Hooks
1600
1825
  if (targetIds.includes("claude-code")) {
1601
1826
  for (const f of changes.hooks.filter((x) => x.status === "changed")) {
1602
1827
  backup(path.join(home, ".claude", "hooks", f.file), path.join("hooks", f.file));
1603
1828
  }
1829
+ for (const f of changes.hooks.filter((x) => x.status === "new")) {
1830
+ willCreate(path.join(home, ".claude", "hooks", f.file));
1831
+ }
1604
1832
  }
1605
1833
 
1606
1834
  // Rules
@@ -1633,17 +1861,45 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
1633
1861
  // Statusline
1634
1862
  if (changes.statusline === "changed" && targetIds.includes("claude-code")) {
1635
1863
  backup(path.join(home, ".claude", "statusline.js"), "statusline.js");
1864
+ } else if (changes.statusline === "new" && targetIds.includes("claude-code")) {
1865
+ willCreate(path.join(home, ".claude", "statusline.js"));
1636
1866
  }
1637
1867
 
1638
1868
  // Templates
1639
1869
  for (const f of changes.templates.filter((x) => x.status === "changed")) {
1640
1870
  backup(path.join(vaultPath, f.file), path.join("templates", f.file));
1641
1871
  }
1872
+ for (const f of changes.templates.filter((x) => x.status === "new")) {
1873
+ willCreate(path.join(vaultPath, f.file));
1874
+ }
1875
+
1876
+ // ~/.mover state that the apply stage rewrites: the update manifest and the
1877
+ // pristine base snapshots. Without these, rolled-back files would be
1878
+ // classified against post-update bases and mis-read as user edits or
1879
+ // conflicts on the next run. Text files only; cheap.
1880
+ try {
1881
+ const mfstPath = path.join(home, ".mover", "manifest.json");
1882
+ if (fs.existsSync(mfstPath)) backup(mfstPath, path.join("mover-state", "manifest.json"));
1883
+ const installedDir = path.join(home, ".mover", "installed");
1884
+ if (fs.existsSync(installedDir)) {
1885
+ fs.cpSync(installedDir, path.join(backupRoot, "mover-state", "installed"), { recursive: true });
1886
+ }
1887
+ } catch {}
1888
+
1889
+ // Write the rollback manifest LAST so its presence implies a complete backup.
1890
+ try {
1891
+ fs.mkdirSync(backupRoot, { recursive: true });
1892
+ fs.writeFileSync(path.join(backupRoot, "backup-manifest.json"), JSON.stringify({
1893
+ createdAt: new Date().toISOString(),
1894
+ vaultPath,
1895
+ entries,
1896
+ }, null, 2), "utf8");
1897
+ } catch {}
1642
1898
 
1643
1899
  if (backed > 0) {
1644
1900
  statusLine("ok", "Auto-backup", `${backed} files → ${dim(backupRoot)}`);
1645
1901
  }
1646
- return backed;
1902
+ return { backupRoot, backed };
1647
1903
  }
1648
1904
 
1649
1905
  function countChanges(changes) {
@@ -1750,6 +2006,19 @@ async function runUninstall(vaultPath) {
1750
2006
 
1751
2007
  const home = os.homedir();
1752
2008
 
2009
+ // terra uninstall F3: only touch VAULT-relative artifacts (AGENTS.md, SOUL.md,
2010
+ // .clinerules, .roo/skills, the PARA structure) when the vault is a REAL Mover
2011
+ // install. resolveVaultPath trusts an explicit --vault without existsSync, so a
2012
+ // typo'd/wrong/stale path could otherwise delete an unrelated project's files.
2013
+ // isInstalledVault is stricter than the old inline stat (it also rejects
2014
+ // an EMPTY marker file) — for a destructive command, stricter is safer.
2015
+ const vaultOwned = isInstalledVault(vaultPath);
2016
+
2017
+ // terra uninstall F1: uninstall is DESTRUCTIVE (it removes agent commands/hooks/
2018
+ // skills, some of which live in shared dirs). A piped/non-TTY run must NOT
2019
+ // silently auto-select and delete — require an explicit confirmation flag.
2020
+ const uninstallConfirmed = process.argv.includes("--yes") || process.argv.includes("--confirm") || process.env.MOVER_UNINSTALL_YES === "1";
2021
+
1753
2022
  // ── Build category map (only include categories that actually exist on disk) ──
1754
2023
  const categories = [];
1755
2024
 
@@ -1761,7 +2030,7 @@ async function runUninstall(vaultPath) {
1761
2030
  { label: "Codex instructions", path: path.join(home, ".codex", "instructions.md") },
1762
2031
  { label: "Windsurf rules", path: path.join(home, ".windsurfrules") },
1763
2032
  ];
1764
- if (vaultPath) {
2033
+ if (vaultOwned) {
1765
2034
  rulesPaths.push(
1766
2035
  { label: "AGENTS.md", path: path.join(vaultPath, "AGENTS.md") },
1767
2036
  { label: "SOUL.md", path: path.join(vaultPath, "SOUL.md") },
@@ -1789,30 +2058,88 @@ async function runUninstall(vaultPath) {
1789
2058
  { label: "Windsurf skills (legacy)", path: path.join(home, ".windsurf", "skills"), dir: true },
1790
2059
  { label: "Windsurf skills", path: path.join(home, ".codeium", "windsurf", "skills"), dir: true },
1791
2060
  { label: "Cline skills", path: path.join(home, ".cline", "skills"), dir: true },
1792
- { label: "Roo Code skills (vault-relative)", path: vaultPath && path.join(vaultPath, ".roo", "skills"), dir: true },
2061
+ { label: "Roo Code skills (vault-relative)", path: vaultOwned && path.join(vaultPath, ".roo", "skills"), dir: true },
1793
2062
  { label: "Cross-agent shared skills", path: path.join(home, ".agents", "skills"), dir: true },
1794
2063
  ].filter(p => p.path);
1795
2064
  const skillsExist = skillsPaths.some(p => fs.existsSync(p.path));
1796
2065
  if (skillsExist) categories.push({ id: "skills", name: "Skills", description: "61 curated skill packs", items: skillsPaths });
1797
2066
 
2067
+ // sol #2 (Installer-to-10): commands and hooks live in SHARED agent dirs that can
2068
+ // also hold the user's OWN files (a hand-written slash command, a personal hook).
2069
+ // Uninstall must remove only what Mover installed, never a co-resident user file.
2070
+ // The running installer ships its own source tree, so its basenames ARE the
2071
+ // ownership manifest — precise and always available. Union with the on-disk
2072
+ // install manifest so files renamed away from the current bundle (but still
2073
+ // installed from an older version) are also reclaimed.
2074
+ const moverOwnedNames = (kind) => {
2075
+ const names = new Set();
2076
+ try {
2077
+ const dir = path.join(__dirname, "src", kind === "commands" ? "workflows" : "hooks");
2078
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
2079
+ if (!e.isFile()) continue;
2080
+ if (kind === "commands" && !e.name.endsWith(".md")) continue;
2081
+ names.add(e.name);
2082
+ }
2083
+ } catch { /* bundle source unavailable — manifest below may still populate */ }
2084
+ try {
2085
+ const man = JSON.parse(fs.readFileSync(path.join(home, ".mover", "manifest.json"), "utf8"));
2086
+ for (const k of Object.keys((man && man.files) || {})) {
2087
+ const seg = String(k).split("/");
2088
+ const base = seg.pop();
2089
+ const prefix = seg[0] || "";
2090
+ if (!base) continue;
2091
+ // Only union keys whose prefix matches the kind, so a hook name can never
2092
+ // leak into the command owned-set (or vice versa). A flat manifest (no
2093
+ // prefix) contributes nothing here; bundle enumeration already covers it.
2094
+ if (kind === "commands" && (prefix === "workflows" || prefix === "commands") && base.endsWith(".md")) names.add(base);
2095
+ if (kind === "hooks" && prefix === "hooks") names.add(base);
2096
+ }
2097
+ } catch { /* no manifest / unreadable — bundle enumeration stands alone */ }
2098
+ return names;
2099
+ };
2100
+ const ownedCommandNames = moverOwnedNames("commands");
2101
+ const ownedHookNames = moverOwnedNames("hooks");
2102
+
1798
2103
  // Commands / Workflows
1799
2104
  const commandsPaths = [
1800
- { label: "Claude Code commands", path: path.join(home, ".claude", "commands"), dir: true },
1801
- { label: "Cursor commands", path: path.join(home, ".cursor", "commands"), dir: true },
1802
- { label: "Antigravity workflows", path: path.join(home, ".gemini", "antigravity", "global_workflows"), dir: true },
2105
+ { label: "Claude Code commands", path: path.join(home, ".claude", "commands"), dir: true, ownedNames: ownedCommandNames },
2106
+ { label: "Cursor commands", path: path.join(home, ".cursor", "commands"), dir: true, ownedNames: ownedCommandNames },
2107
+ { label: "Antigravity workflows", path: path.join(home, ".gemini", "antigravity", "global_workflows"), dir: true, ownedNames: ownedCommandNames },
1803
2108
  ];
1804
2109
  const commandsExist = commandsPaths.some(p => fs.existsSync(p.path));
1805
2110
  if (commandsExist) categories.push({ id: "commands", name: "Commands & Workflows", description: "22 slash commands / workflows", items: commandsPaths });
1806
2111
 
1807
2112
  // Hooks
1808
2113
  const hooksPaths = [
1809
- { label: "Claude Code hooks", path: path.join(home, ".claude", "hooks"), dir: true },
2114
+ { label: "Claude Code hooks", path: path.join(home, ".claude", "hooks"), dir: true, ownedNames: ownedHookNames },
1810
2115
  ];
1811
2116
  const hooksExist = hooksPaths.some(p => fs.existsSync(p.path));
1812
2117
  if (hooksExist) categories.push({ id: "hooks", name: "Hooks", description: "6 Claude Code lifecycle hooks", items: hooksPaths });
1813
2118
 
2119
+ // Desktop launcher (Mover Studio shortcut) — previously orphaned on uninstall
2120
+ // (terra 2026-07-11): the .app/symlink/.desktop/.lnk that `moveros shortcut`
2121
+ // and the onboarding checkbox create were never listed here, so they lingered
2122
+ // after uninstall. Reuse the same name logic the creator uses.
2123
+ try {
2124
+ const { studioName, fileSafeName } = require("./src/dashboard/shortcut");
2125
+ let scCfg = {};
2126
+ try { scCfg = JSON.parse(fs.readFileSync(path.join(home, ".mover", "config.json"), "utf8")); } catch {}
2127
+ const fname = fileSafeName(studioName(scCfg));
2128
+ const shortcutPaths = [
2129
+ { label: "macOS app (~/Applications)", path: path.join(home, "Applications", `${fname}.app`), dir: true },
2130
+ { label: "macOS Desktop link", path: path.join(home, "Desktop", `${fname}.app`) },
2131
+ { label: "Linux app entry", path: path.join(home, ".local", "share", "applications", "mover-studio.desktop") },
2132
+ { label: "Linux Desktop entry", path: path.join(home, "Desktop", `${fname}.desktop`) },
2133
+ { label: "Windows launcher (.bat)", path: path.join(home, ".mover", "launcher", `${fname}.bat`) },
2134
+ { label: "Windows Desktop shortcut", path: path.join(home, "Desktop", `${fname}.lnk`) },
2135
+ { label: "Windows Desktop .bat fallback", path: path.join(home, "Desktop", `${fname}.bat`) },
2136
+ ];
2137
+ const shortcutExist = shortcutPaths.some(p => { try { return fs.lstatSync(p.path) && true; } catch { return false; } });
2138
+ if (shortcutExist) categories.push({ id: "shortcut", name: "Desktop launcher", description: "Mover Studio app / desktop shortcut", items: shortcutPaths });
2139
+ } catch { /* shortcut module unavailable — skip this category */ }
2140
+
1814
2141
  // Vault structure (only if vault has Mover OS structure)
1815
- if (vaultPath) {
2142
+ if (vaultOwned) {
1816
2143
  const vaultStructurePaths = [
1817
2144
  { label: "00_Inbox/", path: path.join(vaultPath, "00_Inbox"), dir: true },
1818
2145
  { label: "01_Projects/", path: path.join(vaultPath, "01_Projects"), dir: true },
@@ -1826,7 +2153,7 @@ async function runUninstall(vaultPath) {
1826
2153
  }
1827
2154
 
1828
2155
  // Mover OS source folder (if cloned into vault)
1829
- if (vaultPath) {
2156
+ if (vaultOwned) {
1830
2157
  const moverBundlePath = path.join(vaultPath, "01_Projects", "Mover OS Bundle");
1831
2158
  if (fs.existsSync(moverBundlePath)) {
1832
2159
  categories.push({ id: "bundle", name: "Mover OS Bundle", description: "01_Projects/Mover OS Bundle/ source folder", items: [
@@ -1835,16 +2162,18 @@ async function runUninstall(vaultPath) {
1835
2162
  }
1836
2163
  }
1837
2164
 
1838
- // Config files
2165
+ // Config files. ONLY files Mover itself created: {vault}/.claude/settings.json
2166
+ // is Claude Code's (or the user's) project config — Mover never writes it,
2167
+ // so uninstall must never delete it (R5-A: default-selected category was
2168
+ // silently removing a file we don't own).
1839
2169
  const configPaths = [];
1840
2170
  if (vaultPath) {
1841
2171
  configPaths.push(
1842
2172
  { label: ".mover-version", path: path.join(vaultPath, ".mover-version") },
1843
- { label: "Claude settings", path: path.join(vaultPath, ".claude", "settings.json") },
1844
2173
  );
1845
2174
  }
1846
2175
  const configExists = configPaths.some(p => fs.existsSync(p.path));
1847
- if (configExists) categories.push({ id: "config", name: "Config Files", description: ".mover-version, settings.json", items: configPaths });
2176
+ if (configExists) categories.push({ id: "config", name: "Config Files", description: ".mover-version", items: configPaths });
1848
2177
 
1849
2178
  if (categories.length === 0) {
1850
2179
  barLn(dim("Nothing to remove — Mover OS not detected."));
@@ -1854,6 +2183,16 @@ async function runUninstall(vaultPath) {
1854
2183
  }
1855
2184
 
1856
2185
  // ── Multi-select: what to uninstall ──
2186
+ // F1: in a non-TTY run interactiveSelect returns the pre-selected defaults with
2187
+ // no human in the loop, silently deleting them (including shared agent dirs that
2188
+ // may hold USER commands/hooks/skills). Refuse unless explicitly confirmed
2189
+ // (--yes / --confirm / MOVER_UNINSTALL_YES=1).
2190
+ if (!IS_TTY && !uninstallConfirmed) {
2191
+ barLn(red(" Uninstall is destructive and this session is non-interactive."));
2192
+ barLn(dim(" Re-run in a terminal, or pass --yes to remove the detected Mover OS components."));
2193
+ outro("Uninstall cancelled.");
2194
+ return;
2195
+ }
1857
2196
  barLn(bold("What do you want to uninstall?"));
1858
2197
  barLn();
1859
2198
 
@@ -1903,15 +2242,50 @@ async function runUninstall(vaultPath) {
1903
2242
  const cat = categories.find(c => c.id === catId);
1904
2243
  if (!cat) continue;
1905
2244
  for (const item of cat.items) {
1906
- if (!fs.existsSync(item.path)) continue;
2245
+ // lstat, not existsSync: existsSync FOLLOWS symlinks, so once a target is
2246
+ // removed a dangling symlink (e.g. the Desktop .app pointing at the now-gone
2247
+ // ~/Applications app) reads as non-existent and gets orphaned. lstat sees
2248
+ // the link itself. (terra shortcut-orphan fix, 2026-07-11)
2249
+ let lst = null;
2250
+ try { lst = fs.lstatSync(item.path); } catch { lst = null; }
2251
+ if (!lst) continue;
1907
2252
  try {
1908
2253
  if (item.dir) {
1909
- if (item.keepBuiltins) {
2254
+ // terra uninstall F2: a SYMLINKED category root (e.g. ~/.claude/skills ->
2255
+ // an external repo) must never be traversed — readdirSync would follow
2256
+ // it and rmSync would delete the user's out-of-tree files. rmSync on the
2257
+ // symlink itself removes only the link (recursive:true does NOT follow a
2258
+ // final-component symlink for deletion). Skip a symlinked keepBuiltins
2259
+ // root entirely (user-configured); remove a symlinked plain dir as a link.
2260
+ if (lst.isSymbolicLink()) {
2261
+ if (item.keepBuiltins) { continue; } // do not touch a user-symlinked skills root
2262
+ fs.rmSync(item.path, { recursive: true, force: true }); // removes the link, not the target
2263
+ } else if (item.keepBuiltins) {
1910
2264
  for (const entry of fs.readdirSync(item.path, { withFileTypes: true })) {
2265
+ // Skip a symlinked child skill dir too — only remove real Mover skill dirs.
2266
+ if (entry.isSymbolicLink()) continue;
1911
2267
  if (entry.isDirectory() && fs.existsSync(path.join(item.path, entry.name, "SKILL.md"))) {
1912
2268
  fs.rmSync(path.join(item.path, entry.name), { recursive: true, force: true });
1913
2269
  }
1914
2270
  }
2271
+ } else if (item.ownedNames) {
2272
+ // sol #2: a SHARED command/hook dir — remove only Mover-installed
2273
+ // entries, never a co-resident user file. If we could not determine
2274
+ // Mover's file set, leave the dir untouched (orphaning Mover files is
2275
+ // the safe failure direction; deleting the user's files is not).
2276
+ if (item.ownedNames.size === 0) {
2277
+ barLn(`${yellow("!")} ${dim(item.label + " — left in place (could not determine Mover's file list)")}`);
2278
+ continue;
2279
+ }
2280
+ let userLeft = 0;
2281
+ for (const entry of fs.readdirSync(item.path, { withFileTypes: true })) {
2282
+ if (entry.isSymbolicLink()) { userLeft++; continue; } // never follow a link out of the dir
2283
+ if (item.ownedNames.has(entry.name)) {
2284
+ try { fs.rmSync(path.join(item.path, entry.name), { recursive: true, force: true }); }
2285
+ catch { userLeft++; }
2286
+ } else { userLeft++; }
2287
+ }
2288
+ if (userLeft === 0) { try { fs.rmdirSync(item.path); } catch {} } // dir held only Mover files
1915
2289
  } else {
1916
2290
  fs.rmSync(item.path, { recursive: true, force: true });
1917
2291
  }
@@ -1933,37 +2307,48 @@ async function runUninstall(vaultPath) {
1933
2307
  barLn(dim("Deactivating license..."));
1934
2308
  try {
1935
2309
  const https = require("https");
1936
- // v4.7.6: match activateKey's label scheme (machine_id) so deactivate
1937
- // actually frees the activation slot. v4.7.5 deactivated by hostname,
1938
- // which only worked if the user hadn't renamed their machine since
1939
- // install. Fallback: if machine_id read fails, try hostname (covers
1940
- // pre-v4.7.3 activations that stored hostname).
1941
2310
  const moverDir = path.join(os.homedir(), ".mover");
1942
- let label = os.hostname();
1943
- try { label = getMachineId(moverDir) || label; } catch {}
1944
- const body = JSON.stringify({ key: cfg.licenseKey, organization_id: POLAR_ORG_ID, label });
1945
- await new Promise((resolve, reject) => {
1946
- const req = https.request({
1947
- hostname: "api.polar.sh",
1948
- path: "/v1/customer-portal/license-keys/deactivate",
1949
- method: "POST",
1950
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
1951
- timeout: 10000,
1952
- }, (res) => {
1953
- let data = "";
1954
- res.on("data", (chunk) => data += chunk);
1955
- res.on("end", () => resolve(data));
2311
+ const activationId = readActivationId(moverDir);
2312
+ if (!activationId) {
2313
+ barLn(dim("No saved activation ID. Open your Polar customer portal to free the device slot."));
2314
+ } else {
2315
+ const body = JSON.stringify({ key: cfg.licenseKey, organization_id: POLAR_ORG_ID, activation_id: activationId });
2316
+ const result = await new Promise((resolve, reject) => {
2317
+ const req = https.request({
2318
+ hostname: "api.polar.sh",
2319
+ path: "/v1/customer-portal/license-keys/deactivate",
2320
+ method: "POST",
2321
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
2322
+ timeout: 10000,
2323
+ }, (res) => {
2324
+ let data = "";
2325
+ res.on("data", (chunk) => data += chunk);
2326
+ res.on("end", () => resolve({ statusCode: res.statusCode, data }));
2327
+ });
2328
+ req.on("error", reject);
2329
+ req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
2330
+ req.write(body);
2331
+ req.end();
1956
2332
  });
1957
- req.on("error", reject);
1958
- req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
1959
- req.write(body);
1960
- req.end();
1961
- });
1962
- barLn(`${green("\u2713")} ${dim("License deactivated — activation slot freed")}`);
2333
+ if (result.statusCode === 204) {
2334
+ try { fs.unlinkSync(path.join(moverDir, ".activation-id")); } catch {}
2335
+ barLn(`${green("\u2713")} ${dim("License deactivated - activation slot freed")}`);
2336
+ } else {
2337
+ barLn(dim(`Polar did not deactivate this device (HTTP ${result.statusCode}). Open your customer portal to free the slot.`));
2338
+ }
2339
+ }
1963
2340
  } catch {
1964
- barLn(`${dim("Could not reach Polar license not deactivated")}`);
2341
+ barLn(`${dim("Could not reach Polar - license not deactivated")}`);
1965
2342
  }
1966
2343
  }
2344
+ // terra uninstall F5: reducing the config to just the license key silently
2345
+ // discards the user's settings (review_day, osName, cli_usage history). Save
2346
+ // a full copy first so a reinstall can recover their personalization.
2347
+ try {
2348
+ const bak = configPath + ".pre-uninstall-bak";
2349
+ fs.copyFileSync(configPath, bak);
2350
+ barLn(`${green("\u2713")} ${dim("~/.mover/config.json.pre-uninstall-bak (settings saved)")}`);
2351
+ } catch {}
1967
2352
  // Remove config file but preserve license key for reinstall
1968
2353
  if (cfg.licenseKey) {
1969
2354
  fs.writeFileSync(configPath, JSON.stringify({ licenseKey: cfg.licenseKey }, null, 2), "utf8");
@@ -2008,7 +2393,9 @@ const AGENT_REGISTRY = {
2008
2393
  "claude-code": {
2009
2394
  name: "Claude Code",
2010
2395
  tier: "full",
2011
- tierDesc: "Rules, 23 commands, skills, 6 hooks",
2396
+ // No counts in tierDesc strings — they rot (this one said "23 commands, 6
2397
+ // hooks" while the product shipped 25/18). docs-generated.test.mjs guards.
2398
+ tierDesc: "Rules, commands, skills, hooks",
2012
2399
  detect: () => cmdExists("claude") || fs.existsSync(path.join(H, ".claude")),
2013
2400
  rules: { type: "link", dest: () => path.join(H, ".claude", "CLAUDE.md"), header: "# Mover OS Global Rules" },
2014
2401
  skills: { dest: () => path.join(H, ".claude", "skills") },
@@ -2033,7 +2420,7 @@ const AGENT_REGISTRY = {
2033
2420
  "cline": {
2034
2421
  name: "Cline",
2035
2422
  tier: "full",
2036
- tierDesc: "Rules, skills, hooks",
2423
+ tierDesc: "Rules, skills",
2037
2424
  detect: () => globDirExists(path.join(H, ".vscode", "extensions"), "saoudrizwan.claude-dev-*"),
2038
2425
  rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".clinerules", "mover-os.md") },
2039
2426
  skills: { dest: (vault) => path.join(vault || ".", ".cline", "skills") },
@@ -2078,7 +2465,7 @@ const AGENT_REGISTRY = {
2078
2465
  "amazon-q": {
2079
2466
  name: "Amazon Q Developer",
2080
2467
  tier: "full",
2081
- tierDesc: "Rules, JSON agent commands, hooks",
2468
+ tierDesc: "Rules, JSON agent commands",
2082
2469
  detect: () => cmdExists("q") || fs.existsSync(path.join(H, ".aws", "amazonq")),
2083
2470
  rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".amazonq", "rules", "mover-os.md") },
2084
2471
  skills: null,
@@ -2088,18 +2475,22 @@ const AGENT_REGISTRY = {
2088
2475
  "opencode": {
2089
2476
  name: "OpenCode",
2090
2477
  tier: "full",
2091
- tierDesc: "AGENTS.md, agents, commands",
2478
+ tierDesc: "AGENTS.md, skills",
2092
2479
  detect: () => cmdExists("opencode") || fs.existsSync(path.join(".", "opencode.json")),
2093
2480
  sharedRulesFile: "agents-md",
2094
2481
  rules: { type: "agents-md", dest: (vault) => path.join(vault || ".", "AGENTS.md") },
2095
2482
  skills: { dest: (vault) => path.join(vault || ".", ".opencode", "skills") },
2096
- commands: { format: "opencode-json", dest: (vault) => path.join(vault || ".", "opencode.json") },
2483
+ // The opencode-json command writer is a stub (installFromRegistry skips
2484
+ // it: "requires config file merge logic"). Declared null so the docs,
2485
+ // menus, and inventory can't sell commands that never install. Restore
2486
+ // the field WITH the merge writer, together.
2487
+ commands: null,
2097
2488
  hooks: null,
2098
2489
  },
2099
2490
  "kilo-code": {
2100
2491
  name: "Kilo Code",
2101
2492
  tier: "full",
2102
- tierDesc: "Rules, skills, commands, modes",
2493
+ tierDesc: "Rules, skills, commands",
2103
2494
  detect: () => globDirExists(path.join(H, ".vscode", "extensions"), "kilocode.kilo-code-*"),
2104
2495
  rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".kilocode", "rules", "mover-os.md") },
2105
2496
  skills: { dest: (vault) => path.join(vault || ".", ".kilocode", "skills") },
@@ -2111,7 +2502,7 @@ const AGENT_REGISTRY = {
2111
2502
  "codex": {
2112
2503
  name: "Codex",
2113
2504
  tier: "full",
2114
- tierDesc: "AGENTS.md, skills (skills = commands), 5 hooks",
2505
+ tierDesc: "AGENTS.md, skills (skills = commands), hooks",
2115
2506
  detect: () => cmdExists("codex") || fs.existsSync(path.join(H, ".codex")),
2116
2507
  rules: { type: "agents-md", dest: () => path.join(H, ".codex", "AGENTS.md") },
2117
2508
  skills: { dest: () => path.join(H, ".codex", "skills") },
@@ -2238,15 +2629,46 @@ function globDirExists(dir, pattern) {
2238
2629
  }
2239
2630
 
2240
2631
  let linkFallbackWarned = false;
2632
+ // Back up a rules/instructions file we are about to overwrite when it was NOT
2633
+ // authored by Mover (no Mover signature in the content) — a user's own
2634
+ // AGENTS.md / copilot-instructions.md / CLAUDE.md must never be silently
2635
+ // destroyed (R5-A). The .bak is written once: repeated installs never
2636
+ // overwrite the original backup with Mover's own content.
2637
+ function backupForeignFile(filePath) {
2638
+ try {
2639
+ if (!fs.existsSync(filePath)) return false;
2640
+ const content = fs.readFileSync(filePath, "utf8");
2641
+ if (!content.trim()) return false;
2642
+ const moverAuthored = /Mover OS|MOVER_OS_RULES|## My Customizations/.test(content);
2643
+ if (moverAuthored) return false;
2644
+ const bak = filePath + ".bak";
2645
+ if (!fs.existsSync(bak)) {
2646
+ fs.copyFileSync(filePath, bak);
2647
+ console.log(` ${dim(`Existing ${path.basename(filePath)} backed up to ${path.basename(bak)}`)}`);
2648
+ }
2649
+ return true;
2650
+ } catch { return false; }
2651
+ }
2652
+
2241
2653
  function linkOrCopy(src, dest) {
2654
+ // Atomic replace: stage into a same-directory temp name, then rename over the
2655
+ // destination. rename(2) within one directory is atomic, so a kill/power-loss
2656
+ // mid-operation leaves the ORIGINAL dest intact. The old unlink-then-link left
2657
+ // a window where dest did not exist and the command file went missing (terra
2658
+ // F6). The temp link shares src's inode, so the hard-link architecture holds.
2659
+ const tmp = `${dest}.mover-tmp-${process.pid}`;
2242
2660
  try {
2243
2661
  fs.mkdirSync(path.dirname(dest), { recursive: true });
2244
- if (fs.existsSync(dest)) fs.unlinkSync(dest);
2245
- fs.linkSync(src, dest);
2662
+ if (fs.existsSync(dest)) backupForeignFile(dest);
2663
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
2664
+ fs.linkSync(src, tmp);
2665
+ fs.renameSync(tmp, dest);
2246
2666
  return "linked";
2247
2667
  } catch {
2668
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
2248
2669
  try {
2249
- fs.copyFileSync(src, dest);
2670
+ fs.copyFileSync(src, tmp);
2671
+ fs.renameSync(tmp, dest);
2250
2672
  if (!linkFallbackWarned) {
2251
2673
  console.log(`\n ${dim("Note: Hard links unavailable — using copies. Edits won't auto-propagate.")}`);
2252
2674
  console.log(` ${dim("Run link.sh after editing source files to re-sync.")}\n`);
@@ -2254,6 +2676,7 @@ function linkOrCopy(src, dest) {
2254
2676
  }
2255
2677
  return "copied";
2256
2678
  } catch {
2679
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
2257
2680
  return null;
2258
2681
  }
2259
2682
  }
@@ -2518,7 +2941,14 @@ function generateCodexHooks() {
2518
2941
  {
2519
2942
  type: "command",
2520
2943
  command: `node ${adapter} codex PreToolUse ${hookDir}/engine-protection.sh"`,
2521
- timeout: 5,
2944
+ // Packet #2 rider (dev/plan.md T-PACKET2-RIDER-1): approval
2945
+ // wait budget. This is a CEILING, not a delay: the adapter
2946
+ // answers in milliseconds on every write, except during a
2947
+ // real dashboard-approval wait (MOVER_STUDIO_RUN=1 + live
2948
+ // daemon), where it blocks up to 45s for a human yes/no in
2949
+ // Mover Studio before failing closed to deny. 60s = 45s wait
2950
+ // + 4.5s bash-hook spawn + node startup + kill-precision margin.
2951
+ timeout: 60,
2522
2952
  },
2523
2953
  ],
2524
2954
  },
@@ -2643,7 +3073,13 @@ function generateGeminiHooks() {
2643
3073
  name: "mover-engine-protection",
2644
3074
  type: "command",
2645
3075
  command: `node ${adapter} gemini BeforeTool ${hookDir}/engine-protection.sh"`,
2646
- timeout: 5000,
3076
+ // Packet #2 rider (dev/plan.md T-PACKET2-RIDER-1): approval wait
3077
+ // budget, mirrors the Codex PreToolUse engine-protection entry.
3078
+ // A ceiling, not a delay: the adapter answers in milliseconds
3079
+ // except during a real dashboard-approval wait (MOVER_STUDIO_RUN=1
3080
+ // + live daemon), where it blocks up to 45s for a human yes/no
3081
+ // before failing closed to deny. Gemini timeouts are milliseconds.
3082
+ timeout: 60000,
2647
3083
  },
2648
3084
  ],
2649
3085
  },
@@ -3508,7 +3944,22 @@ function installSkillPacks(bundleDir, destDir, selectedCategories) {
3508
3944
  continue;
3509
3945
  }
3510
3946
 
3511
- if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
3947
+ // terra CLI F4: only recursively delete a same-named dir that Mover OS OWNS
3948
+ // (its `.mover-installed` stamp, or a manifest entry). A user's custom skill
3949
+ // that happens to share a bundled name must NOT be erased — park it at
3950
+ // `.user-backup` first; if it can't be moved safely, skip rather than clobber.
3951
+ if (fs.existsSync(dest)) {
3952
+ const owned = fs.existsSync(path.join(dest, ".mover-installed")) || !!manifest.skills[skill.name];
3953
+ if (owned) {
3954
+ fs.rmSync(dest, { recursive: true, force: true });
3955
+ } else {
3956
+ const bak = `${dest}.user-backup`;
3957
+ try {
3958
+ if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true });
3959
+ fs.renameSync(dest, bak);
3960
+ } catch { continue; } // could not preserve the user's skill safely — do NOT delete it
3961
+ }
3962
+ }
3512
3963
  copyDirRecursive(skill.path, dest);
3513
3964
  // v4.7.6: stamp file proves Mover OS owns this skill. Used by orphan
3514
3965
  // cleanup below — only stamped skills are deletable on update. User-
@@ -3573,6 +4024,9 @@ function installHooksForClaude(bundleDir, vaultPath) {
3573
4024
  // Read, strip \r (CRLF→LF), write — prevents "command not found" on macOS/Linux
3574
4025
  const content = fs.readFileSync(path.join(hooksSrc, file), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3575
4026
  fs.writeFileSync(dst, content, { mode: 0o755 });
4027
+ // writeFileSync's mode only applies when the file is CREATED — a hook
4028
+ // already on disk at 644 stayed 644 and was silently dead (R5-A).
4029
+ try { fs.chmodSync(dst, 0o755); } catch {}
3576
4030
  savePristineCopy("hooks", path.join(hooksSrc, file));
3577
4031
  count++;
3578
4032
  }
@@ -3853,6 +4307,9 @@ function copyMvpHooks(bundleDir, destDir) {
3853
4307
  .replace(/\r\n/g, "\n")
3854
4308
  .replace(/\r/g, "\n");
3855
4309
  fs.writeFileSync(dst, content, { mode: 0o755 });
4310
+ // mode only applies on create — chmod so a pre-existing 644 copy
4311
+ // becomes executable again (R5-A exec-bit fix).
4312
+ try { fs.chmodSync(dst, 0o755); } catch {}
3856
4313
  count++;
3857
4314
  }
3858
4315
  // Adapter (Node script — copy preserving binary mode)
@@ -3864,12 +4321,46 @@ function copyMvpHooks(bundleDir, destDir) {
3864
4321
  .replace(/\r\n/g, "\n")
3865
4322
  .replace(/\r/g, "\n");
3866
4323
  fs.writeFileSync(dst, content, { mode: 0o755 });
4324
+ try { fs.chmodSync(dst, 0o755); } catch {}
3867
4325
  count++;
3868
4326
  }
3869
4327
  return count;
3870
4328
  }
3871
4329
 
3872
- function installHooksForCodex(bundleDir) {
4330
+ function enableCodexMemoryRecall(bundleDir, vaultPath) {
4331
+ if (!vaultPath) return false;
4332
+ const memoryDir = path.join(os.homedir(), ".mover", "memory");
4333
+ const manifestPath = path.join(memoryDir, "manifest.json");
4334
+ const markerPath = path.join(memoryDir, "recall-enabled");
4335
+ const indexScript = path.join(bundleDir, "src", "dashboard", "lib", "memory-index.js");
4336
+ if (!fs.existsSync(indexScript)) return false;
4337
+
4338
+ const manifestMatchesVault = () => {
4339
+ try {
4340
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
4341
+ return !!manifest.vault && path.resolve(manifest.vault) === path.resolve(vaultPath);
4342
+ } catch { return false; }
4343
+ };
4344
+
4345
+ if (!manifestMatchesVault()) {
4346
+ const result = spawnSync(process.execPath, [indexScript, "--backfill"], {
4347
+ cwd: bundleDir,
4348
+ env: { ...process.env, MOVER_VAULT: vaultPath },
4349
+ stdio: "ignore",
4350
+ timeout: 60000,
4351
+ });
4352
+ if (result.status !== 0 || !manifestMatchesVault()) return false;
4353
+ }
4354
+
4355
+ try {
4356
+ fs.mkdirSync(memoryDir, { recursive: true, mode: 0o700 });
4357
+ fs.writeFileSync(markerPath, "enabled\n", { mode: 0o600 });
4358
+ try { fs.chmodSync(markerPath, 0o600); } catch {}
4359
+ return true;
4360
+ } catch { return false; }
4361
+ }
4362
+
4363
+ function installHooksForCodex(bundleDir, vaultPath) {
3873
4364
  const home = os.homedir();
3874
4365
  const codexDir = path.join(home, ".codex");
3875
4366
  const hooksDst = path.join(codexDir, "hooks");
@@ -3911,6 +4402,11 @@ function installHooksForCodex(bundleDir) {
3911
4402
  const configToml = path.join(codexDir, "config.toml");
3912
4403
  upsertTomlKey(configToml, "features", "codex_hooks", "true");
3913
4404
 
4405
+ // Codex delegates recall through its already-registered prompt/Stop hooks. Build the
4406
+ // selected vault's initial index before writing the opt-in marker so a fresh bundle
4407
+ // never enables a permanently silent hook.
4408
+ enableCodexMemoryRecall(bundleDir, vaultPath);
4409
+
3914
4410
  // v4.7.8: Stash a marker so the post-install summary can show the
3915
4411
  // one-time "trust hooks in Codex IDE" hint. Codex IDE prompts users to
3916
4412
  // review/trust newly installed or modified hooks (security feature —
@@ -4051,16 +4547,20 @@ function installClaudeCode(bundleDir, vaultPath, skillOpts) {
4051
4547
  if (fs.existsSync(statusSrc)) {
4052
4548
  fs.copyFileSync(statusSrc, statusDst);
4053
4549
  const globalSettings = path.join(home, ".claude", "settings.json");
4550
+ // Absolute forward-slash path, never `~` — cmd.exe on Windows does not
4551
+ // expand tildes, so `node ~/.claude/statusline.js` was silently dead
4552
+ // there (same bug class the hook commands already fixed via fwd()).
4553
+ const statusCmd = `node "${statusDst.replace(/\\/g, "/")}"`;
4054
4554
  try {
4055
4555
  const settings = fs.existsSync(globalSettings)
4056
4556
  ? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
4057
4557
  : {};
4058
- settings.statusLine = { type: "command", command: "node ~/.claude/statusline.js" };
4558
+ settings.statusLine = { type: "command", command: statusCmd };
4059
4559
  fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
4060
4560
  } catch {
4061
4561
  fs.writeFileSync(
4062
4562
  globalSettings,
4063
- JSON.stringify({ statusLine: { type: "command", command: "node ~/.claude/statusline.js" } }, null, 2),
4563
+ JSON.stringify({ statusLine: { type: "command", command: statusCmd } }, null, 2),
4064
4564
  "utf8"
4065
4565
  );
4066
4566
  }
@@ -4157,8 +4657,12 @@ function installCodex(bundleDir, vaultPath, skillOpts) {
4157
4657
 
4158
4658
  const codexDir = path.join(home, ".codex");
4159
4659
  fs.mkdirSync(codexDir, { recursive: true });
4160
- // Codex CLI uses AGENTS.md (not instructions.md, not raw Global Rules)
4161
- fs.writeFileSync(path.join(codexDir, "AGENTS.md"), generateAgentsMd(), "utf8");
4660
+ // Codex CLI uses AGENTS.md (not instructions.md, not raw Global Rules).
4661
+ // A pre-existing AGENTS.md the user wrote is backed up once before the
4662
+ // first overwrite — never silently clobbered (R5-A).
4663
+ const agentsMdPath = path.join(codexDir, "AGENTS.md");
4664
+ backupForeignFile(agentsMdPath);
4665
+ fs.writeFileSync(agentsMdPath, generateAgentsMd(), "utf8");
4162
4666
  steps.push("AGENTS.md");
4163
4667
 
4164
4668
  // V5 FIX: In Codex, skills ARE commands — install workflows as SKILL.md dirs
@@ -4173,7 +4677,7 @@ function installCodex(bundleDir, vaultPath, skillOpts) {
4173
4677
 
4174
4678
  // v4.7.5: Native Codex hook support via mover-hook-adapter.js
4175
4679
  if (!skillOpts?.skipHooks) {
4176
- const hkCount = installHooksForCodex(bundleDir);
4680
+ const hkCount = installHooksForCodex(bundleDir, vaultPath);
4177
4681
  if (hkCount > 0) steps.push(`${hkCount} hooks`);
4178
4682
  }
4179
4683
 
@@ -4369,6 +4873,9 @@ function installCopilot(bundleDir, vaultPath, skillOpts) {
4369
4873
  fs.mkdirSync(ghDir, { recursive: true });
4370
4874
  const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
4371
4875
  if (fs.existsSync(src)) {
4876
+ // copilot-instructions.md is often team-authored and git-tracked —
4877
+ // back a foreign one up before the first overwrite (R5-A).
4878
+ backupForeignFile(path.join(ghDir, "copilot-instructions.md"));
4372
4879
  fs.copyFileSync(src, path.join(ghDir, "copilot-instructions.md"));
4373
4880
  steps.push("rules");
4374
4881
  }
@@ -4420,6 +4927,7 @@ function installFromRegistry(bundleDir, vaultPath, skillOpts, writtenFiles, id)
4420
4927
  const sharedKey = reg.sharedRulesFile || id;
4421
4928
  if (!writtenFiles || !writtenFiles.has(destPath)) {
4422
4929
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
4930
+ backupForeignFile(destPath);
4423
4931
  fs.writeFileSync(destPath, generateAgentsMd(), "utf8");
4424
4932
  if (writtenFiles) writtenFiles.add(destPath);
4425
4933
  steps.push("AGENTS.md");
@@ -4436,6 +4944,7 @@ function installFromRegistry(bundleDir, vaultPath, skillOpts, writtenFiles, id)
4436
4944
  const s5idx = content.indexOf("\n## 5.");
4437
4945
  if (s5idx > 0) content = content.substring(0, s5idx);
4438
4946
  content = "# CONVENTIONS.md\n\n" + content.replace(/^# Mover OS Global Rules/m, "").trim() + "\n";
4947
+ backupForeignFile(destPath);
4439
4948
  fs.writeFileSync(destPath, content, "utf8");
4440
4949
  steps.push("CONVENTIONS.md");
4441
4950
  }
@@ -4528,6 +5037,9 @@ const CLI_HANDLERS = {
4528
5037
  pulse: async (opts) => { await cmdPulse(opts); },
4529
5038
  dashboard: async (opts) => { await cmdPulse(opts); }, // alias — opens browser dashboard
4530
5039
  open: async (opts) => { await cmdOpen(opts); },
5040
+ shortcut: async (opts) => { await cmdShortcut(opts); },
5041
+ menubar: async (opts) => { await cmdMenubar(opts); },
5042
+ inventory: async (opts) => { cmdInventory(opts); },
4531
5043
  // warm removed
4532
5044
  capture: async (opts) => { await cmdCapture(opts); },
4533
5045
  who: async (opts) => { await cmdWho(opts); },
@@ -4615,7 +5127,15 @@ async function cmdDoctor(opts) {
4615
5127
  for (const f of engineFiles) {
4616
5128
  if (fs.existsSync(path.join(engineDir, f))) engOk++;
4617
5129
  }
4618
- statusLine(engOk >= 4 ? "ok" : engOk >= 2 ? "warn" : "fail", "Engine files", `${engOk}/${engineFiles.length} present`);
5130
+ // terra read F1: `engOk >= 4` reported OK even with the two CORE sentinels
5131
+ // (Identity_Prime, Strategy) missing — a broken Engine shown as healthy. OK only
5132
+ // when all present; FAIL if a core file is missing; WARN otherwise.
5133
+ const missingCore = ["Identity_Prime.md", "Strategy.md"].filter((f) => !fs.existsSync(path.join(engineDir, f)));
5134
+ statusLine(
5135
+ engOk === engineFiles.length ? "ok" : missingCore.length ? "fail" : "warn",
5136
+ "Engine files",
5137
+ `${engOk}/${engineFiles.length} present${missingCore.length ? ` — missing core: ${missingCore.join(", ")}` : ""}`
5138
+ );
4619
5139
 
4620
5140
  // Version
4621
5141
  const vf = path.join(vault, ".mover-version");
@@ -4638,26 +5158,41 @@ async function cmdDoctor(opts) {
4638
5158
  for (const agentId of installedAgents) {
4639
5159
  const reg = AGENT_REGISTRY[agentId];
4640
5160
  if (!reg) { statusLine("warn", ` ${agentId}`, "unknown agent"); continue; }
5161
+ // terra read F2: status was inferred from ANSI STYLING of the label text
5162
+ // (styled = problem) — fragile and it certified broken installs as green in
5163
+ // non-TTY (no styling). Derive from STRUCTURED booleans instead.
4641
5164
  const checks = [];
4642
- // Rules
5165
+ let agentOk = true;
4643
5166
  if (reg.rules) {
4644
- const rp = reg.rules.dest(vault);
4645
- checks.push(fs.existsSync(rp) ? "rules" : dim("rules missing"));
5167
+ const has = fs.existsSync(reg.rules.dest(vault));
5168
+ agentOk = agentOk && has;
5169
+ checks.push(has ? "rules" : dim("rules missing"));
4646
5170
  }
4647
- // Skills
4648
5171
  if (reg.skills) {
4649
5172
  const sp = reg.skills.dest(vault);
4650
- const hasSkills = fs.existsSync(sp) && fs.readdirSync(sp).length > 0;
4651
- checks.push(hasSkills ? "skills" : dim("no skills"));
5173
+ const has = fs.existsSync(sp) && fs.readdirSync(sp).length > 0;
5174
+ agentOk = agentOk && has;
5175
+ checks.push(has ? "skills" : dim("no skills"));
4652
5176
  }
4653
- // Commands
4654
5177
  if (reg.commands) {
4655
5178
  const cp = reg.commands.dest(vault);
4656
- const hasCmds = fs.existsSync(cp) && fs.readdirSync(cp).length > 0;
4657
- checks.push(hasCmds ? "commands" : dim("no commands"));
5179
+ const has = fs.existsSync(cp) && fs.readdirSync(cp).length > 0;
5180
+ agentOk = agentOk && has;
5181
+ checks.push(has ? "commands" : dim("no commands"));
5182
+ }
5183
+ // terra read F3: hooks were NEVER inspected — a deleted hooks config read as
5184
+ // healthy. Check the agent's hook destination (settings.json for Claude, a
5185
+ // hooks.json for the others).
5186
+ if (reg.hooks) {
5187
+ const hp = reg.hooks.dest ? reg.hooks.dest(vault)
5188
+ : (reg.hooks.type === "claude-settings" ? path.join(home, ".claude", "settings.json") : null);
5189
+ if (hp) {
5190
+ const has = fs.existsSync(hp);
5191
+ agentOk = agentOk && has;
5192
+ checks.push(has ? "hooks" : dim("hooks missing"));
5193
+ }
4658
5194
  }
4659
- const allOk = checks.every((c) => c === strip(c)); // styled text = problem
4660
- statusLine(allOk ? "ok" : "warn", ` ${reg.name}`, checks.join(", "));
5195
+ statusLine(agentOk ? "ok" : "warn", ` ${reg.name}`, checks.join(", "));
4661
5196
  }
4662
5197
 
4663
5198
  // ── Claude Code skill listing budget (v4.7.7) ────────────────────────
@@ -4708,6 +5243,103 @@ async function cmdDoctor(opts) {
4708
5243
  );
4709
5244
  }
4710
5245
 
5246
+ // ── Runtime drift check (sol #7 part 6) ───────────────────────────────
5247
+ // The hook runtime is a COPY (link.sh copies hooks; workflows are hard
5248
+ // links). A copy can silently drift from the bundle after git ops — the
5249
+ // 2026-07-12 live find: runtime session-start.sh was 11 hours stale and
5250
+ // log.md 3 DAYS stale, so recent fixes simply were not running. Detect it
5251
+ // mechanically: content-hash every bundle hook against its runtime copy,
5252
+ // and check workflow hard-link integrity (a broken link is current TODAY
5253
+ // but drifts on the next bundle edit). Dev-checkout concern only: npm-
5254
+ // global installs have no src/ on disk and skip silently.
5255
+ (() => {
5256
+ let bundleDir = null;
5257
+ for (const c of [process.cwd(), path.join(vault, "01_Projects", "Mover OS Bundle"), __dirname]) {
5258
+ if (fs.existsSync(path.join(c, "src", "hooks"))) { bundleDir = c; break; }
5259
+ }
5260
+ if (!bundleDir) return;
5261
+ const sha = (p) => crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex");
5262
+ barLn();
5263
+ barLn(dim(" Runtime sync (bundle -> agent copies):"));
5264
+
5265
+ const hookSrcDir = path.join(bundleDir, "src", "hooks");
5266
+ // Every agent runtime that receives hook COPIES (sol #8 partial
5267
+ // refutation 1: only ~/.claude was inspected, and a DELETED runtime file
5268
+ // was skipped silently — absence is drift too, and Codex/Gemini have
5269
+ // their own copy directories via installHooksForCodex/Gemini).
5270
+ const runtimes = [
5271
+ { label: "Claude hooks", dir: path.join(home, ".claude", "hooks") },
5272
+ { label: "Codex hooks", dir: path.join(home, ".codex", "hooks") },
5273
+ { label: "Gemini hooks", dir: path.join(home, ".gemini", "hooks") },
5274
+ ];
5275
+ let anyHookDrift = false;
5276
+ for (const rt of runtimes) {
5277
+ if (!fs.existsSync(rt.dir)) continue; // agent not installed: nothing to drift
5278
+ const runtimeNames = new Set(fs.readdirSync(rt.dir).filter((n) => /\.(sh|js)$/.test(n)));
5279
+ const drifted = [];
5280
+ const missing = [];
5281
+ let checked = 0;
5282
+ for (const name of fs.readdirSync(hookSrcDir)) {
5283
+ if (!/\.(sh|js)$/.test(name)) continue;
5284
+ const dst = path.join(rt.dir, name);
5285
+ if (!runtimeNames.has(name)) continue; // this agent never installs it — but see the deletion check below
5286
+ checked++;
5287
+ try { if (sha(path.join(hookSrcDir, name)) !== sha(dst)) drifted.push(name); } catch { /* unreadable = skip */ }
5288
+ }
5289
+ // Deletion detection: a runtime file the agent's OWN settings still
5290
+ // reference but which no longer exists on disk. For Claude the
5291
+ // settings.json registrations are the truth; a lighter proxy that
5292
+ // needs no per-agent settings parser: mover-lib.sh is the shared
5293
+ // library every shipped hook sources — if any hooks are present but
5294
+ // the library is gone, every one of them is broken at run time.
5295
+ if (checked > 0 && !runtimeNames.has("mover-lib.sh") && fs.existsSync(path.join(hookSrcDir, "mover-lib.sh"))) {
5296
+ missing.push("mover-lib.sh");
5297
+ }
5298
+ if (drifted.length || missing.length) {
5299
+ anyHookDrift = true;
5300
+ const parts = [];
5301
+ if (drifted.length) parts.push(`${drifted.length}/${checked} DRIFTED: ${drifted.slice(0, 4).join(", ")}${drifted.length > 4 ? ", ..." : ""}`);
5302
+ if (missing.length) parts.push(`MISSING: ${missing.join(", ")}`);
5303
+ statusLine("warn", ` ${rt.label}`, parts.join("; "));
5304
+ } else if (checked > 0) {
5305
+ statusLine("ok", ` ${rt.label}`, `${checked} current`);
5306
+ }
5307
+ }
5308
+
5309
+ const wfSrcDir = path.join(bundleDir, "src", "workflows");
5310
+ const wfDstDir = path.join(home, ".claude", "commands");
5311
+ const brokenLinks = [];
5312
+ const staleContent = [];
5313
+ let wfChecked = 0;
5314
+ if (fs.existsSync(wfDstDir) && fs.existsSync(wfSrcDir)) {
5315
+ for (const name of fs.readdirSync(wfSrcDir)) {
5316
+ if (!name.endsWith(".md")) continue;
5317
+ const src = path.join(wfSrcDir, name);
5318
+ const dst = path.join(wfDstDir, name);
5319
+ if (!fs.existsSync(dst)) continue;
5320
+ wfChecked++;
5321
+ try {
5322
+ const sIno = fs.statSync(src).ino;
5323
+ const dIno = fs.statSync(dst).ino;
5324
+ if (sIno !== dIno) {
5325
+ if (sha(src) !== sha(dst)) staleContent.push(name);
5326
+ else brokenLinks.push(name); // current bytes, but the link is gone: drifts on the next edit
5327
+ }
5328
+ } catch { /* skip */ }
5329
+ }
5330
+ if (staleContent.length) {
5331
+ statusLine("warn", " Workflow links", `${staleContent.length}/${wfChecked} STALE content: ${staleContent.slice(0, 4).join(", ")}${staleContent.length > 4 ? ", ..." : ""}`);
5332
+ } else if (brokenLinks.length) {
5333
+ statusLine("warn", " Workflow links", `${brokenLinks.length}/${wfChecked} content current but hard link broken (will drift on next edit)`);
5334
+ } else {
5335
+ statusLine("ok", " Workflow links", `${wfChecked} hard-linked`);
5336
+ }
5337
+ }
5338
+ if (anyHookDrift || staleContent.length || brokenLinks.length) {
5339
+ barLn(dim(` Fix: bash "${path.join(bundleDir, "src", "install", "link.sh")}"`));
5340
+ }
5341
+ })();
5342
+
4711
5343
  // ── Codex CLI / MCP plugin conflict check (v4.7.8) ────────────────────
4712
5344
  // Unauthenticated MCP plugins in ~/.codex/config.toml cause codex exec to
4713
5345
  // hang or exit empty on complex prompts (the Vercel "claude-plugins-official"
@@ -4769,10 +5401,20 @@ async function cmdDoctor(opts) {
4769
5401
  // Default: opens browser dashboard at http://127.0.0.1:3737. Use `--terminal` for legacy TUI.
4770
5402
  async function cmdPulse(opts) {
4771
5403
  const rest = opts.rest || [];
5404
+ // `moveros pulse --bar` — a single glanceable status line for a menu-bar widget
5405
+ // (SwiftBar/xbar plugin, a native Swift app, or a Node tray all consume the same
5406
+ // line). No TUI, no browser. Kept path-independent on purpose so the menu-bar
5407
+ // shell can be chosen later without touching this.
5408
+ if (rest.includes("--bar")) { await cmdPulseBar(opts); return; }
4772
5409
  const wantTerminal = rest.includes("--terminal");
4773
5410
  if (!wantTerminal) {
4774
5411
  const vault = resolveVaultPath(opts.vault);
4775
- if (!vault) return;
5412
+ if (!vault) {
5413
+ // Never exit silently (R5-A): a fresh machine or a moved vault landed
5414
+ // here and `moveros` just... stopped, with no clue why.
5415
+ console.error(dim(" Could not find your vault. Point at it with --vault PATH, or run `moveros doctor` to diagnose."));
5416
+ return;
5417
+ }
4776
5418
  try {
4777
5419
  const dash = require("./src/dashboard");
4778
5420
  await dash.run({
@@ -4816,6 +5458,362 @@ async function cmdOpen(opts, { soft = false, route = "" } = {}) {
4816
5458
  }
4817
5459
  }
4818
5460
 
5461
+ // ─── moveros shortcut ────────────────────────────────────────────────────────
5462
+ // Create the double-clickable desktop launcher (mac .app + Desktop symlink /
5463
+ // win .lnk / linux .desktop) for an existing install. Onboarding offers this
5464
+ // as a checkbox (/api/setup/shortcut); this is the retrofit path for installs
5465
+ // that predate it and the repair path when the launcher was deleted.
5466
+ // Optional positional overrides the name: `moveros shortcut My OS`.
5467
+ // Idempotent: re-running refreshes the same artifacts in place.
5468
+ async function cmdShortcut(opts) {
5469
+ // A launcher that runs `moveros open` is a dead icon without an install.
5470
+ // A launcher without an install is a dead icon — the one invariant
5471
+ // (requireInstalledVault) covers the terra F1 typo'd --vault case.
5472
+ const vault = requireInstalledVault(opts.vault);
5473
+ if (!vault) return;
5474
+
5475
+ // Explicit arg wins, else the display name chosen at onboarding. An empty
5476
+ // string is fine: createShortcut falls back to "Mover Studio". The 80-char
5477
+ // cap matches the /api/setup/shortcut route. Unknown flags fall through
5478
+ // parseArgs into rest; drop them so `moveros shortcut --remove` can never
5479
+ // mint a launcher literally named "--remove".
5480
+ const flagged = opts.rest.filter((a) => String(a).startsWith("-"));
5481
+ if (flagged.length) statusLine("warn", "Ignoring", flagged.join(" ") + " (no such option)");
5482
+ let name = opts.rest.filter((a) => !String(a).startsWith("-")).join(" ").trim().slice(0, 80);
5483
+ if (!name) {
5484
+ try {
5485
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".mover", "config.json"), "utf8"));
5486
+ name = String(cfg.osName || cfg.os_name || "");
5487
+ } catch { /* no config yet — createShortcut handles the fallback */ }
5488
+ }
5489
+
5490
+ const { createShortcut } = require("./src/dashboard/shortcut");
5491
+ const result = createShortcut({
5492
+ name,
5493
+ nodeBin: process.execPath, // fast path only — the launcher re-resolves node at click time
5494
+ entry: __filename, // realpath of the moveros bin; npx-cache paths share server.js's exposure
5495
+ });
5496
+
5497
+ for (const p of result.created) statusLine("ok", "Created", p);
5498
+ if (result.skipped) {
5499
+ statusLine("warn", "Shortcut skipped", result.skipped);
5500
+ barLn();
5501
+ return;
5502
+ }
5503
+ barLn();
5504
+ const label =
5505
+ result.platform === "darwin" ? "Desktop icon" :
5506
+ result.platform === "win32" ? "Desktop shortcut" : "desktop entry";
5507
+ barLn(dim(` Double-click the ${label} to open your dashboard.`));
5508
+ barLn();
5509
+ }
5510
+
5511
+ // ─── moveros inventory ───────────────────────────────────────────────────────
5512
+ // Emits the bundle's derived facts as JSON by EVALUATING the same source the
5513
+ // installer runs (AGENT_REGISTRY, generateClaudeSettings/Codex/Gemini), never
5514
+ // by re-typing numbers. scripts/generate-docs.mjs consumes this to stamp
5515
+ // README.md/MANUAL.md, and test/docs-generated.test.mjs fails the suite when
5516
+ // the stamped docs drift from these facts (sol part 12: docs must be
5517
+ // generated copies, not authored ones). Needs no vault — the subject is the
5518
+ // bundle itself.
5519
+ function collectInventory() {
5520
+ const bundle = __dirname;
5521
+ const workflows = fs.readdirSync(path.join(bundle, "src", "workflows"))
5522
+ .filter((n) => n.endsWith(".md")).map((n) => n.replace(/\.md$/, "")).sort();
5523
+
5524
+ // Same production filter the installer and inventory-counts test apply.
5525
+ const DEV_SUFFIXES = ["-workspace", "-benchmark", "-sandbox"];
5526
+ const isDev = (n) => DEV_SUFFIXES.some((s) => n.endsWith(s));
5527
+ const skills = [];
5528
+ (function walk(dir) {
5529
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
5530
+ if (!e.isDirectory() || isDev(e.name)) continue;
5531
+ const full = path.join(dir, e.name);
5532
+ if (fs.existsSync(path.join(full, "SKILL.md"))) skills.push(e.name);
5533
+ else walk(full);
5534
+ }
5535
+ })(path.join(bundle, "src", "skills"));
5536
+ skills.sort();
5537
+
5538
+ const hookFiles = fs.readdirSync(path.join(bundle, "src", "hooks"))
5539
+ .filter((f) => f.endsWith(".sh")).sort();
5540
+
5541
+ // Claude Code: walk the actual generated settings for script + event + matcher.
5542
+ const claudeRegistered = [];
5543
+ const claudeSettings = JSON.parse(generateClaudeSettings());
5544
+ for (const [event, entries] of Object.entries(claudeSettings.hooks || {})) {
5545
+ for (const entry of entries) {
5546
+ for (const h of entry.hooks || []) {
5547
+ const m = String(h.command || "").match(/([a-z0-9-]+\.sh)/);
5548
+ if (m) claudeRegistered.push({ script: m[1], event, matcher: entry.matcher || "" });
5549
+ }
5550
+ }
5551
+ }
5552
+
5553
+ const scriptsIn = (jsonText) => {
5554
+ const set = new Set();
5555
+ for (const m of String(jsonText).matchAll(/([a-z0-9-]+\.sh)/g)) set.add(m[1]);
5556
+ return [...set].sort();
5557
+ };
5558
+ const codexScripts = scriptsIn(generateCodexHooks());
5559
+ const geminiScripts = scriptsIn(JSON.stringify(generateGeminiHooks()));
5560
+
5561
+ let version = `V${VERSION}`;
5562
+ try {
5563
+ version = JSON.parse(fs.readFileSync(path.join(bundle, "package.json"), "utf8")).version || version;
5564
+ } catch {}
5565
+
5566
+ // Engine-protection approval routing, read from the adapter's own constant
5567
+ // so the documented wait budget can never drift from the shipped one.
5568
+ let approvalWaitMs = null;
5569
+ try {
5570
+ const adapterSrc = fs.readFileSync(path.join(bundle, "src", "hooks", "mover-hook-adapter.js"), "utf8");
5571
+ const m = adapterSrc.match(/MOVER_APPROVAL_WAIT_MS\s*\|\|\s*"(\d+)"/);
5572
+ if (m) approvalWaitMs = parseInt(m[1], 10);
5573
+ } catch {}
5574
+
5575
+ const agents = AGENT_SELECTIONS.map((s) => {
5576
+ const reg = AGENT_REGISTRY[s.targets[0]];
5577
+ return {
5578
+ id: s.id,
5579
+ name: s.name,
5580
+ tierDesc: reg.tierDesc,
5581
+ rules: !!reg.rules,
5582
+ commands: reg.commands ? reg.commands.format : null,
5583
+ skills: !!reg.skills,
5584
+ hooks: reg.hooks ? reg.hooks.type : null,
5585
+ };
5586
+ });
5587
+
5588
+ // TYPED approval routing (sol re-score #3 part 12 residual: "approval
5589
+ // behavior is still authored prose with only its timeout derived"). Every
5590
+ // field here is computed from the same sources the product runs:
5591
+ // interactive = agents whose hooks integration is Claude's settings.json
5592
+ // (real PreToolUse prompt); dashboardRider = agents whose GENERATED hook
5593
+ // config routes engine-protection through the adapter (no native prompt,
5594
+ // the Mover Studio dialog is the approval surface); failClosed points at
5595
+ // the test file that pins ask-to-deny when the dashboard is absent.
5596
+ const approval = {
5597
+ waitMs: approvalWaitMs,
5598
+ interactive: agents.filter((a) => a.hooks === "claude-settings").map((a) => a.name),
5599
+ dashboardRider: [
5600
+ codexScripts.includes("engine-protection.sh") ? "Codex" : null,
5601
+ geminiScripts.includes("engine-protection.sh") ? "Gemini CLI" : null,
5602
+ ].filter(Boolean),
5603
+ failClosedPinnedBy: "test/hook-adapter.test.mjs",
5604
+ };
5605
+
5606
+ // User-facing CLI subcommands, evaluated from the CLI_COMMANDS registry so
5607
+ // the docs command table cannot drift from what the binary actually
5608
+ // dispatches (sol re-score #4 part 12: "more factual prose into typed
5609
+ // blocks"). Hidden commands (test, inventory) are excluded, matching the
5610
+ // menu and --help surfaces.
5611
+ const cliCommands = Object.entries(CLI_COMMANDS)
5612
+ .filter(([, meta]) => !meta.hidden)
5613
+ .map(([name, meta]) => ({ name, desc: meta.desc, alias: meta.alias || [] }));
5614
+
5615
+ return {
5616
+ version,
5617
+ workflows,
5618
+ skills,
5619
+ cliCommands,
5620
+ hooks: {
5621
+ files: hookFiles,
5622
+ claudeRegistered,
5623
+ codexScripts,
5624
+ geminiScripts,
5625
+ approvalWaitMs,
5626
+ },
5627
+ approval,
5628
+ agents,
5629
+ };
5630
+ }
5631
+
5632
+ // The public npm package is intentionally only the installer + dashboard. The
5633
+ // paid workflows, skills, and hooks arrive after license validation, so a
5634
+ // clean `npx mover-os help` cannot derive their counts from src/. Use the
5635
+ // release-generated public counts file in that environment without exposing
5636
+ // the paid payload on npm. Repository/dev runs still derive the full inventory
5637
+ // from source above, which keeps docs generation exact.
5638
+ function collectPublicCounts() {
5639
+ try {
5640
+ const inv = collectInventory();
5641
+ return {
5642
+ workflows: inv.workflows.length,
5643
+ skills: inv.skills.length,
5644
+ agents: inv.agents.length,
5645
+ };
5646
+ } catch (_) {
5647
+ try {
5648
+ const counts = JSON.parse(fs.readFileSync(path.join(__dirname, "src", "system", "counts.json"), "utf8"));
5649
+ return {
5650
+ workflows: Number(counts.workflows) || 0,
5651
+ skills: Number(counts.skills) || 0,
5652
+ agents: Number(counts.agents) || AGENT_SELECTIONS.length,
5653
+ };
5654
+ } catch (_) {
5655
+ return { workflows: 0, skills: 0, agents: AGENT_SELECTIONS.length };
5656
+ }
5657
+ }
5658
+ }
5659
+
5660
+ function cmdInventory() {
5661
+ // Pure JSON on stdout — no banner, no color — so callers can pipe it.
5662
+ process.stdout.write(JSON.stringify(collectInventory(), null, 2) + "\n");
5663
+ }
5664
+
5665
+ // ─── moveros menubar ─────────────────────────────────────────────────────────
5666
+ // Installs the native macOS menu-bar companion (src/menubar/, prebuilt at
5667
+ // src/menubar/prebuilt/MoverBar.app — zero third-party deps, no SwiftBar, no
5668
+ // brew). Owner-approved 2026-07-11 (qa/campaign-2026-07/OWNER-DECISIONS.md
5669
+ // RULINGS LANDED): "cleanest install-on-accept", same pattern as the desktop
5670
+ // shortcut (src/dashboard/shortcut.js) — a prebuilt artifact copied into
5671
+ // place on accept, not compiled on the user's machine.
5672
+ //
5673
+ // macOS-only. The .app polls ~/.mover/menubar.json every 60s and shells out
5674
+ // to the exact cmd this function writes: [node, install.js, "pulse", "--bar"]
5675
+ // — the same one-honest-line contract cmdPulseBar already guarantees, so the
5676
+ // menu bar never has to fabricate a status.
5677
+ // Idempotent: re-running replaces the installed .app in place (rsync-style —
5678
+ // remove then copy fresh) and rewrites the config.
5679
+ async function cmdMenubar(opts) {
5680
+ if (process.platform !== "darwin") {
5681
+ barLn(dim(" moveros menubar is macOS-only (native AppKit menu-bar app)."));
5682
+ return;
5683
+ }
5684
+
5685
+ const remove = (opts.rest || []).includes("--remove");
5686
+ const appDest = path.join(os.homedir(), "Applications", "MoverBar.app");
5687
+ const configPath = path.join(os.homedir(), ".mover", "menubar.json");
5688
+
5689
+ if (remove) {
5690
+ try {
5691
+ const { execFileSync } = require("child_process");
5692
+ try { execFileSync("pkill", ["-f", "MoverBar"], { stdio: "ignore" }); } catch { /* not running — fine */ }
5693
+ } catch { /* best-effort — never block removal on this */ }
5694
+ try { if (fs.lstatSync(appDest)) fs.rmSync(appDest, { recursive: true, force: true }); statusLine("ok", "Removed", appDest); } catch { /* already absent */ }
5695
+ try { if (fs.lstatSync(configPath)) fs.rmSync(configPath, { force: true }); statusLine("ok", "Removed", configPath); } catch { /* already absent */ }
5696
+ barLn();
5697
+ return;
5698
+ }
5699
+
5700
+ // A MoverBar polling `moveros pulse --bar` is a dead icon without an
5701
+ // install — the one invariant (requireInstalledVault) is the guard.
5702
+ const vault = requireInstalledVault(opts.vault);
5703
+ if (!vault) return;
5704
+
5705
+ const prebuiltSrc = path.join(path.resolve(__dirname), "src", "menubar", "prebuilt", "MoverBar.app");
5706
+ if (!fs.existsSync(prebuiltSrc)) {
5707
+ statusLine("warn", "Missing prebuilt app", `${prebuiltSrc} not found — reinstall mover-os.`);
5708
+ barLn();
5709
+ return;
5710
+ }
5711
+
5712
+ try {
5713
+ fs.mkdirSync(path.join(os.homedir(), "Applications"), { recursive: true });
5714
+ fs.rmSync(appDest, { recursive: true, force: true }); // rsync-style replace
5715
+ copyDirRecursive(prebuiltSrc, appDest);
5716
+ statusLine("ok", "Installed", appDest);
5717
+ } catch (err) {
5718
+ statusLine("warn", "Install failed", err.message);
5719
+ barLn();
5720
+ return;
5721
+ }
5722
+
5723
+ try {
5724
+ fs.mkdirSync(path.join(os.homedir(), ".mover"), { recursive: true });
5725
+ fs.writeFileSync(
5726
+ configPath,
5727
+ JSON.stringify({ cmd: [process.execPath, __filename, "pulse", "--bar"] }, null, 2),
5728
+ "utf8"
5729
+ );
5730
+ statusLine("ok", "Wrote", configPath);
5731
+ } catch (err) {
5732
+ statusLine("warn", "Config write failed", err.message);
5733
+ barLn();
5734
+ return;
5735
+ }
5736
+
5737
+ try {
5738
+ const { spawn } = require("child_process");
5739
+ spawn("open", [appDest], { stdio: "ignore", detached: true }).unref();
5740
+ statusLine("ok", "Launched", "MoverBar");
5741
+ } catch (err) {
5742
+ statusLine("warn", "Launch failed", err.message);
5743
+ }
5744
+
5745
+ barLn();
5746
+ barLn(dim(" Look for the status line in your menu bar. It refreshes every 60s."));
5747
+ barLn();
5748
+ }
5749
+
5750
+ // One glanceable status line for a menu-bar widget. Plain text, no ANSI. Always
5751
+ // exits 0 with SOME line so a bar poller never renders an error. Shape:
5752
+ // "▸ <next action> · <done>/<total> today"
5753
+ // Falls back to the standing test, then a bare label, so the bar is never blank.
5754
+ async function cmdPulseBar(opts) {
5755
+ const emit = (s) => { process.stdout.write(String(s).replace(/\s+/g, " ").trim() + "\n"); };
5756
+ try {
5757
+ const vault = resolveVaultPath(opts.vault);
5758
+ if (!vault) { emit("Mover · not set up"); return; }
5759
+ const engineDir = path.join(vault, "02_Areas", "Engine");
5760
+
5761
+ let done = 0, total = 0, next = "";
5762
+ try {
5763
+ const dailyPath = resolveDailyNotePath(engineDir);
5764
+ if (dailyPath && fs.existsSync(dailyPath)) {
5765
+ const daily = fs.readFileSync(dailyPath, "utf8");
5766
+ const sec = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---|$)/i);
5767
+ if (sec) {
5768
+ const tasks = sec[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
5769
+ total = tasks.length;
5770
+ // Only [x] is complete; [~] is unverified/in-progress (global rule S5).
5771
+ done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
5772
+ const firstOpen = tasks.find((t) => /^\s*-\s*\[[ ~]\]/.test(t));
5773
+ if (firstOpen) next = firstOpen.replace(/^\s*-\s*\[[ x~]\]\s*/, "").replace(/\[[^\]]*\]/g, "").trim();
5774
+ }
5775
+ }
5776
+ } catch (_) { /* no daily / unreadable — fall through to the standing test */ }
5777
+
5778
+ // Owner ruling (2026-07-11): the TITLE is a glance-number, never a sentence
5779
+ // (the reference bar apps show "$12,345", not prose) — and the number is
5780
+ // the MISSION (the bet: "4/15"), not the day's chore count. Contract:
5781
+ // line 1 = compact title, line 2 = the full sentence for the click-menu.
5782
+ // A one-line consumer (scripts, old pollers) still gets a meaningful line.
5783
+ let st = "";
5784
+ try {
5785
+ const man = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".mover", "pattern-manifest.json"), "utf8"));
5786
+ st = String((man && man.single_test) || "").trim();
5787
+ } catch (_) { /* no manifest */ }
5788
+
5789
+ // The bet count comes from the SAME parser the dashboard hero uses —
5790
+ // never a second implementation that can drift from what Home shows.
5791
+ let bet = null;
5792
+ if (st) {
5793
+ try {
5794
+ const { parseGoalProgress } = require("./src/dashboard/lib/goal-forecast");
5795
+ const acRaw = fs.readFileSync(path.join(engineDir, "Active_Context.md"), "utf8");
5796
+ const g = parseGoalProgress({ singleTest: st, sourceText: acRaw, targetDate: null, now: new Date() });
5797
+ // false-zero discipline: no parseable target = no fabricated "0/0".
5798
+ if (g && g.target > 0) bet = { current: g.current || 0, target: g.target };
5799
+ } catch (_) { /* unparseable bet — fall through to the day count */ }
5800
+ }
5801
+
5802
+ const parts = [];
5803
+ if (next) parts.push("▸ " + next.slice(0, 42));
5804
+ if (total > 0) parts.push(`${done}/${total} today`);
5805
+ if (bet || parts.length) {
5806
+ emit(bet ? `▸ ${bet.current}/${bet.target}` : (total > 0 ? `▸ ${done}/${total}` : "▸ Mover"));
5807
+ emit(parts.length ? parts.join(" · ") : "▸ " + st.slice(0, 48));
5808
+ return;
5809
+ }
5810
+ emit("▸ Mover");
5811
+ emit(st ? "▸ " + st.slice(0, 48) : "Mover · no tasks today");
5812
+ } catch (_) {
5813
+ emit("Mover"); // never fail a bar poll
5814
+ }
5815
+ }
5816
+
4819
5817
  async function cmdPulseTerminal(opts) {
4820
5818
  const vault = resolveVaultPath(opts.vault);
4821
5819
  if (!vault) {
@@ -4860,7 +5858,9 @@ async function cmdPulseTerminal(opts) {
4860
5858
  const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---)/i);
4861
5859
  if (taskSection) {
4862
5860
  const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
4863
- const done = tasks.filter((t) => /\[x\]|\[~\]/.test(t)).length;
5861
+ // terra read F10: only [x] is COMPLETE. [~] is unverified/in-progress — the
5862
+ // whole point of the [~] state (global rule S5) — and must not count as done.
5863
+ const done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
4864
5864
  const total = tasks.length;
4865
5865
  barLn(` ${progressBar(done, total, 25, "Tasks")}`);
4866
5866
  }
@@ -4914,9 +5914,13 @@ async function cmdPulseTerminal(opts) {
4914
5914
  if (fs.existsSync(acLogPath)) {
4915
5915
  try {
4916
5916
  const acContent = fs.readFileSync(acLogPath, "utf8");
4917
- const logMatch = acContent.match(/log_last_run:\s*(\S+)/);
4918
- if (logMatch) {
4919
- const agoMs = Date.now() - new Date(logMatch[1]).getTime();
5917
+ // terra read F5: `(\S+)` captured the closing `**` of a Markdown-wrapped key
5918
+ // (**log_last_run:** date), yielding "NaNh ago". Consume optional Markdown
5919
+ // around the key and capture a date-shaped token, then reject an invalid date.
5920
+ const logMatch = acContent.match(/\*{0,2}\s*log_last_run:\s*\*{0,2}\s*([0-9][0-9T:\-.Z+]*)/i);
5921
+ const ts = logMatch ? new Date(logMatch[1]).getTime() : NaN;
5922
+ if (logMatch && !Number.isNaN(ts)) {
5923
+ const agoMs = Date.now() - ts;
4920
5924
  const agoMin = Math.round(agoMs / 60000);
4921
5925
  const agoH = Math.round(agoMs / 3600000);
4922
5926
  const agoStr = agoMin < 60 ? `${agoMin}m ago` : `${agoH}h ago`;
@@ -4934,7 +5938,11 @@ async function cmdPulseTerminal(opts) {
4934
5938
 
4935
5939
  // ─── moveros capture ────────────────────────────────────────────────────────
4936
5940
  async function cmdCapture(opts) {
4937
- const vault = requireVault(opts.vault);
5941
+ // terra CLI F7: an explicit --vault is trusted without existsSync, so a
5942
+ // typo'd path would get a whole 00_Inbox tree minted there and the note
5943
+ // "lost" outside the real vault. The one invariant is the mutation gate.
5944
+ const vault = requireInstalledVault(opts.vault);
5945
+ if (!vault) return;
4938
5946
 
4939
5947
  const now = new Date();
4940
5948
  const ymd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
@@ -4996,11 +6004,34 @@ async function cmdCapture(opts) {
4996
6004
  else if (type === "idea") entry = `- **Idea:** ${content} *(${ts})*`;
4997
6005
  else entry = `- ${content} *(${ts})*`;
4998
6006
 
6007
+ // terra CLI F6: never follow a SYMLINKED capture file — appending through it
6008
+ // would write outside the vault inbox. A path is unsafe if it is a symlink, or
6009
+ // its real parent is not the real inbox. Fall back to a fresh file; if that is
6010
+ // also unsafe (or the same path), refuse rather than write outside the vault.
6011
+ const realInbox = (() => { try { return fs.realpathSync(inboxDir); } catch { return inboxDir; } })();
6012
+ const isUnsafe = (p) => {
6013
+ try {
6014
+ const st = fs.lstatSync(p, { throwIfNoEntry: false });
6015
+ if (!st) return false; // does not exist = safe to create
6016
+ if (st.isSymbolicLink()) return true; // never follow a link
6017
+ return path.dirname(fs.realpathSync(p)) !== realInbox;
6018
+ } catch { return false; }
6019
+ };
6020
+ let writeTarget = capturePath;
6021
+ if (isUnsafe(writeTarget)) {
6022
+ const fresh = path.join(inboxDir, `Capture - ${ymd}.md`);
6023
+ if (path.resolve(fresh) === path.resolve(writeTarget) || isUnsafe(fresh)) {
6024
+ barLn(red(" The inbox capture file is a symlink pointing outside the vault; refusing to write."));
6025
+ return;
6026
+ }
6027
+ writeTarget = fresh;
6028
+ }
6029
+
4999
6030
  // Append to capture file
5000
- if (!fs.existsSync(capturePath)) {
5001
- fs.writeFileSync(capturePath, `# Capture — ${ymd}\n\n${entry}\n`, "utf8");
6031
+ if (!fs.existsSync(writeTarget)) {
6032
+ fs.writeFileSync(writeTarget, `# Capture — ${ymd}\n\n${entry}\n`, "utf8");
5002
6033
  } else {
5003
- fs.appendFileSync(capturePath, `${entry}\n`, "utf8");
6034
+ fs.appendFileSync(writeTarget, `${entry}\n`, "utf8");
5004
6035
  }
5005
6036
 
5006
6037
  statusLine("ok", "Captured", `${type} → 00_Inbox/`);
@@ -5010,7 +6041,10 @@ async function cmdCapture(opts) {
5010
6041
 
5011
6042
  // ─── moveros who ────────────────────────────────────────────────────────────
5012
6043
  async function cmdWho(opts) {
5013
- const vault = requireVault(opts.vault);
6044
+ // Can mint entity stubs — mutating commands go through the one install
6045
+ // invariant (sol re-score #3: the invariant must cover EVERY mutating path).
6046
+ const vault = requireInstalledVault(opts.vault);
6047
+ if (!vault) return;
5014
6048
 
5015
6049
  const name = opts.rest.join(" ").trim();
5016
6050
  if (!name) { barLn(yellow("Usage: moveros who <name>")); return; }
@@ -5041,9 +6075,35 @@ async function cmdWho(opts) {
5041
6075
  if (create === "yes") {
5042
6076
  const peopleDir = path.join(entitiesDir, "People");
5043
6077
  fs.mkdirSync(peopleDir, { recursive: true });
5044
- const stub = `# ${name}\n\n**Type:** Person\n**Last Contact:** ${new Date().toISOString().split("T")[0]}\n\n## Context\n\n- \n\n## Notes\n\n- \n`;
5045
- fs.writeFileSync(path.join(peopleDir, `${name}.md`), stub, "utf8");
5046
- statusLine("ok", "Created", `${name}.md in Entities/People/`);
6078
+ // The raw name went straight into the write path, so "../../x" escaped
6079
+ // Entities entirely (terra-confirmed traversal). Strip separators and
6080
+ // dot segments, then verify the target lands PHYSICALLY inside People/
6081
+ // using realpath, since path.resolve is lexical and a symlinked People/
6082
+ // dir would otherwise let the stub escape (terra round 2).
6083
+ const safeName = name.replace(/[\/\\:*?"<>|\0]/g, "-").replace(/\.\.+/g, "-").trim();
6084
+ const stubPath = path.resolve(peopleDir, `${safeName}.md`);
6085
+ // Containment, three ways (terra P1, two rounds): lexical (blocks "../"
6086
+ // names), realpath-consistent (dirname resolves to People), AND the
6087
+ // resolved target must sit INSIDE the vault — so a People/ symlinked to
6088
+ // an outside dir can't smuggle the stub out of the vault entirely.
6089
+ let realParent, realPeople, realVault;
6090
+ try {
6091
+ realParent = fs.realpathSync(path.dirname(stubPath));
6092
+ realPeople = fs.realpathSync(peopleDir);
6093
+ realVault = fs.realpathSync(vault);
6094
+ } catch { realParent = null; realPeople = null; realVault = null; }
6095
+ const insideVault = realParent && realVault &&
6096
+ (realParent === realVault || realParent.startsWith(realVault + path.sep));
6097
+ if (!safeName ||
6098
+ !stubPath.startsWith(path.resolve(peopleDir) + path.sep) ||
6099
+ realParent !== realPeople ||
6100
+ !insideVault) {
6101
+ statusLine("warn", "Invalid location", "Entity stub would land outside the vault. Check the name and that Entities/People is inside your vault.");
6102
+ return;
6103
+ }
6104
+ const stub = `# ${safeName}\n\n**Type:** Person\n**Last Contact:** ${new Date().toISOString().split("T")[0]}\n\n## Context\n\n- \n\n## Notes\n\n- \n`;
6105
+ fs.writeFileSync(stubPath, stub, "utf8");
6106
+ statusLine("ok", "Created", `${safeName}.md in Entities/People/`);
5047
6107
  }
5048
6108
  return;
5049
6109
  }
@@ -5065,9 +6125,12 @@ async function cmdWho(opts) {
5065
6125
  // ─── moveros diff ───────────────────────────────────────────────────────────
5066
6126
  async function cmdDiff(opts) {
5067
6127
  const vault = requireVault(opts.vault);
6128
+ if (!vault) return;
5068
6129
  if (!cmdExists("git")) { barLn(red("Git required for moveros diff.")); return; }
5069
6130
 
5070
- const target = opts.rest[0] || "strategy";
6131
+ // terra read F11: the target is the first NON-FLAG arg, so `diff --days=30 strategy`
6132
+ // (flag first) doesn't try to diff a file literally named "--days=30".
6133
+ const target = opts.rest.find((a) => !a.startsWith("--")) || "strategy";
5071
6134
  const days = parseInt(opts.rest.find((a) => a.startsWith("--days="))?.split("=")[1] || "30", 10);
5072
6135
 
5073
6136
  const fileMap = {
@@ -5091,8 +6154,11 @@ async function cmdDiff(opts) {
5091
6154
  const gitCwd = isEngineFile && fs.existsSync(path.join(engineDir, ".git"))
5092
6155
  ? engineDir
5093
6156
  : vault;
6157
+ // terra read F8: path.basename dropped subdirectories, so a nested Engine file
6158
+ // (02_Areas/Engine/Samples/Foo.md) was queried at the wrong git path and its
6159
+ // history read as empty. Use the path RELATIVE to the Engine repo root.
5094
6160
  const gitRelPath = isEngineFile && gitCwd === engineDir
5095
- ? path.basename(relPath)
6161
+ ? path.relative(engineDir, fullPath)
5096
6162
  : relPath;
5097
6163
 
5098
6164
  barLn(bold(` ${path.basename(relPath)} — last ${days} days`));
@@ -5125,7 +6191,10 @@ async function cmdDiff(opts) {
5125
6191
 
5126
6192
  // ─── moveros sync ───────────────────────────────────────────────────────────
5127
6193
  async function cmdSync(opts) {
5128
- const vault = requireVault(opts.vault);
6194
+ // --apply rewrites agent artifacts from the bundle — mutating, so the one
6195
+ // install invariant gates it (sol re-score #3).
6196
+ const vault = requireInstalledVault(opts.vault);
6197
+ if (!vault) return;
5129
6198
  const apply = opts.rest.includes("--apply");
5130
6199
  const home = os.homedir();
5131
6200
 
@@ -5231,13 +6300,22 @@ async function cmdSync(opts) {
5231
6300
  // ─── moveros replay ─────────────────────────────────────────────────────────
5232
6301
  async function cmdReplay(opts) {
5233
6302
  const vault = requireVault(opts.vault);
6303
+ if (!vault) return;
5234
6304
 
5235
6305
  const engineDir = path.join(vault, "02_Areas", "Engine");
5236
6306
  let dateStr = opts.rest.find((a) => a.startsWith("--date="))?.split("=")[1];
5237
6307
  let targetDate;
5238
6308
  if (dateStr) {
5239
- const [y, m, d] = dateStr.split("-").map(Number);
5240
- targetDate = new Date(y, m - 1, d);
6309
+ // terra read F7: `new Date(2026, 1, 31)` rolls over to Mar 3, so an impossible
6310
+ // date replayed a DIFFERENT day under a false label. Validate the round-trip
6311
+ // and reject a rollover.
6312
+ const m2 = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
6313
+ const [y, mo, d] = m2 ? m2.slice(1).map(Number) : [NaN, NaN, NaN];
6314
+ targetDate = m2 ? new Date(y, mo - 1, d) : new Date("invalid");
6315
+ if (!m2 || targetDate.getFullYear() !== y || targetDate.getMonth() !== mo - 1 || targetDate.getDate() !== d) {
6316
+ barLn(red(` Invalid date: ${dateStr}. Use a real YYYY-MM-DD.`));
6317
+ return;
6318
+ }
5241
6319
  } else {
5242
6320
  targetDate = getSessionDate();
5243
6321
  const y = targetDate.getFullYear();
@@ -5283,7 +6361,8 @@ async function cmdReplay(opts) {
5283
6361
  const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##)/i);
5284
6362
  if (taskSection) {
5285
6363
  const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
5286
- const done = tasks.filter((t) => /\[x\]|\[~\]/.test(t)).length;
6364
+ // terra read F10: only [x] is complete; [~] is unverified (global rule S5).
6365
+ const done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
5287
6366
  barLn(` ${progressBar(done, tasks.length, 25, "Completion")}`);
5288
6367
  barLn();
5289
6368
  }
@@ -5293,15 +6372,38 @@ async function cmdReplay(opts) {
5293
6372
  }
5294
6373
 
5295
6374
  // ─── moveros context ────────────────────────────────────────────────────────
6375
+ // terra read F4: `reg.hooks.events` does not exist on the registry, so context
6376
+ // always showed "0 events" / "? hook events". Parse the agent's actual hook
6377
+ // config and return the event names. null = not found / unreadable.
6378
+ function agentHookEvents(reg, vault, home) {
6379
+ try {
6380
+ let p = null;
6381
+ if (reg && reg.hooks && reg.hooks.dest) p = reg.hooks.dest(vault);
6382
+ else if (reg && reg.hooks && reg.hooks.type === "claude-settings") p = path.join(home, ".claude", "settings.json");
6383
+ if (!p || !fs.existsSync(p)) return null;
6384
+ const obj = JSON.parse(fs.readFileSync(p, "utf8"));
6385
+ // Hooks live at the top level (agent hooks.json) or under `.hooks` (settings.json).
6386
+ const h = (obj && obj.hooks && typeof obj.hooks === "object") ? obj.hooks : obj;
6387
+ if (!h || typeof h !== "object") return null;
6388
+ return Object.keys(h).filter((k) => /^[A-Z][A-Za-z]/.test(k) && (Array.isArray(h[k]) || (h[k] && typeof h[k] === "object")));
6389
+ } catch { return null; }
6390
+ }
6391
+
5296
6392
  async function cmdContext(opts) {
5297
6393
  const vault = requireVault(opts.vault);
6394
+ if (!vault) return;
5298
6395
 
5299
6396
  const target = opts.rest[0];
5300
6397
  const home = os.homedir();
5301
6398
  const cfgPath = path.join(home, ".mover", "config.json");
5302
- const agents = fs.existsSync(cfgPath)
5303
- ? (JSON.parse(fs.readFileSync(cfgPath, "utf8")).agents || [])
5304
- : [];
6399
+ // terra read F9: a malformed config.json crashed `moveros context` (an unguarded
6400
+ // JSON.parse) — exactly when config corruption most needs diagnosing. Degrade to
6401
+ // an empty agent list, mirroring doctor.
6402
+ let agents = [];
6403
+ if (fs.existsSync(cfgPath)) {
6404
+ try { agents = JSON.parse(fs.readFileSync(cfgPath, "utf8")).agents || []; }
6405
+ catch { barLn(yellow(" ~/.mover/config.json is not valid JSON — showing no agents.")); }
6406
+ }
5305
6407
 
5306
6408
  if (!target) {
5307
6409
  // Overview: show what each agent actually loads
@@ -5349,8 +6451,8 @@ async function cmdContext(opts) {
5349
6451
  }
5350
6452
  }
5351
6453
 
5352
- // Hooks
5353
- if (reg.hooks) parts.push(`${reg.hooks.events?.length || "?"} hook events`);
6454
+ // Hooks (terra read F4: parse the real config)
6455
+ if (reg.hooks) { const ev = agentHookEvents(reg, vault, home); parts.push(ev ? `${ev.length} hook events` : "no hooks"); }
5354
6456
 
5355
6457
  const tokens = Math.round(totalBytes / 4);
5356
6458
  const tokenWarn = tokens > 40000 ? red("heavy") : tokens > 20000 ? yellow("moderate") : green("lean");
@@ -5435,11 +6537,12 @@ async function cmdContext(opts) {
5435
6537
  }
5436
6538
  }
5437
6539
 
5438
- // Hooks
6540
+ // Hooks (terra read F4: parse the real config, not the nonexistent reg.hooks.events)
5439
6541
  if (reg.hooks) {
5440
6542
  barLn();
5441
- statusLine("ok", "Hooks", `${reg.hooks.events?.length || 0} events`);
5442
- if (reg.hooks.events) barLn(dim(` ${reg.hooks.events.join(", ")}`));
6543
+ const ev = agentHookEvents(reg, vault, home);
6544
+ statusLine(ev && ev.length ? "ok" : "warn", "Hooks", ev ? `${ev.length} events` : "not configured");
6545
+ if (ev && ev.length) barLn(dim(` ${ev.join(", ")}`));
5443
6546
  }
5444
6547
 
5445
6548
  // Token budget summary
@@ -5747,7 +6850,10 @@ async function cmdSettings(opts) {
5747
6850
 
5748
6851
  // ─── moveros backup ─────────────────────────────────────────────────────────
5749
6852
  async function cmdBackup(opts) {
5750
- const vault = requireVault(opts.vault);
6853
+ // Writes archives derived from the vault; backing up a non-install is a
6854
+ // meaningless artifact from a possibly wrong --vault (sol re-score #3).
6855
+ const vault = requireInstalledVault(opts.vault);
6856
+ if (!vault) return;
5751
6857
  const home = os.homedir();
5752
6858
 
5753
6859
  barLn(bold(" Backup"));
@@ -5763,8 +6869,14 @@ async function cmdBackup(opts) {
5763
6869
  if (!choices || choices.length === 0) { barLn(dim(" Cancelled.")); return; }
5764
6870
 
5765
6871
  const now = new Date();
5766
- const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
6872
+ // terra CLI F5: minute precision meant two backups in the same minute reused
6873
+ // one dir and the second SILENTLY OVERWROTE the first snapshot (losing the only
6874
+ // good restore point). Add seconds + a random suffix so every backup is a
6875
+ // distinct dir, and fail closed if it somehow already exists.
6876
+ const secs = String(now.getSeconds()).padStart(2, "0");
6877
+ const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${secs}_${crypto.randomBytes(2).toString("hex")}`;
5767
6878
  const backupDir = path.join(vault, "04_Archives", `Backup_${ts}`);
6879
+ if (fs.existsSync(backupDir)) { outro(red("Backup directory collision; try again.")); return; }
5768
6880
  fs.mkdirSync(backupDir, { recursive: true });
5769
6881
 
5770
6882
  const manifest = { timestamp: now.toISOString(), type: "manual", trigger: "moveros backup", categories: choices, files: {} };
@@ -5842,9 +6954,46 @@ async function cmdBackup(opts) {
5842
6954
  barLn();
5843
6955
  }
5844
6956
 
6957
+ // terra CLI F2: a restore that copies files in a loop with no rollback left
6958
+ // earlier files overwritten if a later copy failed (corrupt mixed state). Snapshot
6959
+ // each existing destination, copy all, and on ANY failure roll every destination
6960
+ // back to its snapshot (or remove a file we created). Returns the count restored.
6961
+ function restoreFilesTransactional(pairs) {
6962
+ const snaps = [];
6963
+ try {
6964
+ for (const { src, dest } of pairs) {
6965
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
6966
+ if (fs.existsSync(dest)) {
6967
+ const bak = `${dest}.restore-bak`;
6968
+ fs.copyFileSync(dest, bak);
6969
+ snaps.push({ dest, bak, had: true });
6970
+ } else {
6971
+ snaps.push({ dest, bak: null, had: false });
6972
+ }
6973
+ fs.copyFileSync(src, dest);
6974
+ }
6975
+ for (const s of snaps) if (s.bak) { try { fs.unlinkSync(s.bak); } catch {} }
6976
+ return pairs.length;
6977
+ } catch (e) {
6978
+ for (const s of snaps) {
6979
+ try {
6980
+ if (s.had && s.bak) fs.copyFileSync(s.bak, s.dest);
6981
+ else if (!s.had) { try { fs.unlinkSync(s.dest); } catch {} }
6982
+ } catch {}
6983
+ if (s.bak) { try { fs.unlinkSync(s.bak); } catch {} }
6984
+ }
6985
+ throw e;
6986
+ }
6987
+ }
6988
+
5845
6989
  // ─── moveros restore ────────────────────────────────────────────────────────
5846
6990
  async function cmdRestore(opts) {
5847
- const vault = requireVault(opts.vault);
6991
+ // Writes INTO the vault a typo'd --vault would dump restored files into
6992
+ // an unrelated directory, so the one install invariant gates it (sol
6993
+ // re-score #3). Recovery of a vault whose marker itself was lost goes
6994
+ // through moveros install first, which recreates the marker.
6995
+ const vault = requireInstalledVault(opts.vault);
6996
+ if (!vault) return;
5848
6997
 
5849
6998
  const archivesDir = path.join(vault, "04_Archives");
5850
6999
  if (!fs.existsSync(archivesDir)) { barLn(yellow(" No archives found.")); return; }
@@ -5876,8 +7025,21 @@ async function cmdRestore(opts) {
5876
7025
  return { id: b, name: b, tier: detail || dim("legacy backup") };
5877
7026
  });
5878
7027
 
5879
- const selected = await interactiveSelect(items, { multi: false });
5880
- if (!selected) { barLn(dim(" Cancelled.")); return; }
7028
+ // terra CLI F1: a non-TTY restore must NOT silently pick the newest backup and
7029
+ // overwrite live (possibly newer) files. Require an explicit backup name AND
7030
+ // --confirm; otherwise refuse and show how.
7031
+ let selected;
7032
+ if (!IS_TTY) {
7033
+ const explicit = opts.rest.find((a) => backups.includes(a));
7034
+ if (!explicit || !opts.rest.includes("--confirm")) {
7035
+ outro(red(`Non-interactive restore needs an explicit backup and --confirm:\n moveros restore <${backups[0] || "Backup_..."}> --confirm`));
7036
+ return;
7037
+ }
7038
+ selected = explicit;
7039
+ } else {
7040
+ selected = await interactiveSelect(items, { multi: false });
7041
+ if (!selected) { barLn(dim(" Cancelled.")); return; }
7042
+ }
5881
7043
 
5882
7044
  const backupPath = path.join(archivesDir, selected);
5883
7045
 
@@ -5920,28 +7082,42 @@ async function cmdRestore(opts) {
5920
7082
  statusLine("ok", "Agents", `${agentCount} items restored`);
5921
7083
  totalRestored = agentCount;
5922
7084
  } else {
5923
- // Engine backup (Engine_Backup_ or legacy Backup_)
7085
+ // A Backup_* dir can hold engine/ (Engine backup), areas/ (Full Areas backup,
7086
+ // terra CLI F3 — the old code knew only engine/ or loose .md and silently
7087
+ // restored NOTHING for an areas/ backup), or loose .md (legacy). Build the
7088
+ // src->dest pairs, then restore transactionally (F2).
7089
+ const engineDir = path.join(vault, "02_Areas", "Engine");
7090
+ const areasSrc = path.join(backupPath, "areas");
5924
7091
  const engPath = path.join(backupPath, "engine");
5925
- if (fs.existsSync(engPath)) {
5926
- const engineDir = path.join(vault, "02_Areas", "Engine");
5927
- let restored = 0;
7092
+ const pairs = [];
7093
+ let label = "Engine";
7094
+ if (fs.existsSync(areasSrc)) {
7095
+ const areasTarget = path.join(vault, "02_Areas");
7096
+ const walk = (dir, rel) => {
7097
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
7098
+ if (e.isDirectory()) walk(path.join(dir, e.name), path.join(rel, e.name));
7099
+ else pairs.push({ src: path.join(dir, e.name), dest: path.join(areasTarget, rel, e.name) });
7100
+ }
7101
+ };
7102
+ walk(areasSrc, "");
7103
+ label = "Areas";
7104
+ } else if (fs.existsSync(engPath)) {
5928
7105
  for (const f of fs.readdirSync(engPath).filter((f) => fs.statSync(path.join(engPath, f)).isFile())) {
5929
- fs.copyFileSync(path.join(engPath, f), path.join(engineDir, f));
5930
- restored++;
7106
+ pairs.push({ src: path.join(engPath, f), dest: path.join(engineDir, f) });
5931
7107
  }
5932
- statusLine("ok", "Engine", `${restored} files restored`);
5933
- totalRestored = restored;
5934
7108
  } else {
5935
- // Legacy backup format (files directly in backup dir)
5936
- const engineDir = path.join(vault, "02_Areas", "Engine");
5937
- let restored = 0;
5938
7109
  for (const f of fs.readdirSync(backupPath).filter((f) => f.endsWith(".md") && f !== ".backup-manifest.json")) {
5939
- fs.copyFileSync(path.join(backupPath, f), path.join(engineDir, f));
5940
- restored++;
7110
+ pairs.push({ src: path.join(backupPath, f), dest: path.join(engineDir, f) });
5941
7111
  }
5942
- if (restored > 0) statusLine("ok", "Engine", `${restored} files restored`);
5943
- totalRestored = restored;
5944
7112
  }
7113
+ try {
7114
+ totalRestored = restoreFilesTransactional(pairs);
7115
+ } catch (e) {
7116
+ outro(red(`Restore failed and was rolled back (no partial state): ${e && e.message}`));
7117
+ return;
7118
+ }
7119
+ if (totalRestored > 0) statusLine("ok", label, `${totalRestored} files restored`);
7120
+ else { barLn(yellow(" This backup contained no restorable files.")); return; }
5945
7121
  }
5946
7122
 
5947
7123
  barLn();
@@ -5980,6 +7156,9 @@ async function cmdHelp(opts) {
5980
7156
  w("\n");
5981
7157
  };
5982
7158
 
7159
+ // Counts computed, never typed: this screen shipped "23 commands / 61
7160
+ // packs / 16 agents" while the bundle held 25/70/15.
7161
+ const inv = collectPublicCounts();
5983
7162
  const pages = [
5984
7163
  {
5985
7164
  title: "What is Mover OS?",
@@ -5994,10 +7173,10 @@ async function cmdHelp(opts) {
5994
7173
  "",
5995
7174
  `${dim("Three things make it work:")}`,
5996
7175
  ` ${cyan("1.")} The ${bold("Engine")} — files that store your identity, strategy, goals`,
5997
- ` ${cyan("2.")} ${bold("Workflows")} — 23 commands that run your day (plan, build, log, repeat)`,
5998
- ` ${cyan("3.")} ${bold("Skills")} — 61 packs that make your AI genuinely useful`,
7176
+ ` ${cyan("2.")} ${bold("Workflows")} — ${inv.workflows} commands that run your day (plan, build, log, repeat)`,
7177
+ ` ${cyan("3.")} ${bold("Skills")} — ${inv.skills} packs that make your AI genuinely useful`,
5999
7178
  "",
6000
- `Works across 16 agents. Claude Code, Cursor, Gemini, all of them.`,
7179
+ `Works across ${inv.agents} agents. Claude Code, Cursor, Gemini, all of them.`,
6001
7180
  `Same context, every editor. ${dim("Press → to continue.")}`,
6002
7181
  ],
6003
7182
  },
@@ -6037,7 +7216,7 @@ async function cmdHelp(opts) {
6037
7216
  ],
6038
7217
  },
6039
7218
  {
6040
- title: "23 Workflows",
7219
+ title: `${inv.workflows} Workflows`,
6041
7220
  body: [
6042
7221
  `${dim("Slash commands inside your AI agent. Type / and go.")}`,
6043
7222
  "",
@@ -6055,7 +7234,7 @@ async function cmdHelp(opts) {
6055
7234
  ],
6056
7235
  },
6057
7236
  {
6058
- title: "61 Skill Packs",
7237
+ title: `${inv.skills} Skill Packs`,
6059
7238
  body: [
6060
7239
  `${dim("Specialized knowledge your AI loads automatically.")}`,
6061
7240
  "",
@@ -6150,7 +7329,7 @@ async function cmdHelp(opts) {
6150
7329
  `${dim("Keep everything in sync without thinking about it.")}`,
6151
7330
  "",
6152
7331
  ` ${cyan("sync")} Update all agents to latest rules and skills`,
6153
- ` ${dim("One command updates 16 agents. Shows what changed.")}`,
7332
+ ` ${dim(`One command updates ${inv.agents} agents. Shows what changed.`)}`,
6154
7333
  "",
6155
7334
  ` ${cyan("backup")} Backup Engine, Areas, or agent configs`,
6156
7335
  ` ${dim("With manifest. Knows what's in each backup.")}`,
@@ -6166,7 +7345,7 @@ async function cmdHelp(opts) {
6166
7345
  ],
6167
7346
  },
6168
7347
  {
6169
- title: "16 Agents, One Brain",
7348
+ title: `${inv.agents} Agents, One Brain`,
6170
7349
  body: [
6171
7350
  `${dim("Install once. Every agent gets the same context.")}`,
6172
7351
  "",
@@ -6374,8 +7553,9 @@ async function cmdMainMenu() {
6374
7553
 
6375
7554
  // Context filter: hide irrelevant commands
6376
7555
  const contextFilter = (cmd) => {
6377
- if (!hasVault && ["pulse", "open", "replay", "diff", "sync", "capture", "who", "context", "backup", "restore", "doctor"].includes(cmd)) return false;
7556
+ if (!hasVault && ["pulse", "open", "shortcut", "menubar", "replay", "diff", "sync", "capture", "who", "context", "backup", "restore", "doctor"].includes(cmd)) return false;
6378
7557
  if (!hasPrayer && cmd === "prayer") return false;
7558
+ if (cmd === "menubar" && process.platform !== "darwin") return false;
6379
7559
  return true;
6380
7560
  };
6381
7561
 
@@ -6431,7 +7611,430 @@ async function cmdMainMenu() {
6431
7611
  }
6432
7612
 
6433
7613
  // ─── Comprehensive Update (10-step interactive) ─────────────────────────────
7614
+ // ── Update machine subcommands (local state ops: no license, no network) ────
7615
+
7616
+ // Resolve the bundle source to verify against: the journal's staged bundleDir
7617
+ // first (verify must check the SAME source the apply stage used), then this
7618
+ // CLI's own bundle, then a downloaded payload at ~/.mover/src.
7619
+ function resolveVerifyBundleDir(journal) {
7620
+ const candidates = [
7621
+ journal && journal.stages && journal.stages.stage && journal.stages.stage.bundleDir,
7622
+ __dirname,
7623
+ path.join(os.homedir(), ".mover"),
7624
+ ].filter(Boolean);
7625
+ for (const c of candidates) {
7626
+ if (fs.existsSync(path.join(c, "src", "workflows"))) return c;
7627
+ }
7628
+ return null;
7629
+ }
7630
+
7631
+ // Engine migrations DECLARED per release (src/system/engine-migrations.json in
7632
+ // the bundle). Finalize cross-checks the migration receipt against every
7633
+ // declaration in (fromVersion, toVersion]: an empty migrated list is only
7634
+ // valid when that window truly declares nothing (sol re-score #4 part 10:
7635
+ // "an empty migration list proves process completion rather than semantic
7636
+ // completeness"). With no journal, fromVersion is unknown and only the target
7637
+ // version's own declarations bind. MOVER_TEST_MIGRATIONS_FILE injects a
7638
+ // manifest for hermetic tests, same pattern as MOVER_TEST_CRASH_STAGE.
7639
+ function loadOwedEngineMigrations(journal, toVersion) {
7640
+ const candidates = [];
7641
+ if (process.env.MOVER_TEST_MIGRATIONS_FILE) candidates.push(process.env.MOVER_TEST_MIGRATIONS_FILE);
7642
+ const bundleDir = resolveVerifyBundleDir(journal);
7643
+ if (bundleDir) candidates.push(path.join(bundleDir, "src", "system", "engine-migrations.json"));
7644
+ candidates.push(path.join(__dirname, "src", "system", "engine-migrations.json"));
7645
+ for (const c of candidates) {
7646
+ let decl;
7647
+ try { decl = JSON.parse(fs.readFileSync(c, "utf8")).declarations; } catch { continue; }
7648
+ if (!decl || typeof decl !== "object" || Array.isArray(decl)) continue;
7649
+ const fromV = journal && journal.fromVersion ? String(journal.fromVersion).replace(/^[vV]/, "") : null;
7650
+ const owed = [];
7651
+ for (const [ver, entries] of Object.entries(decl)) {
7652
+ const inWindow = fromV
7653
+ ? compareVersions(ver, fromV) > 0 && compareVersions(ver, toVersion) <= 0
7654
+ : compareVersions(ver, toVersion) === 0;
7655
+ if (!inWindow || !Array.isArray(entries)) continue;
7656
+ for (const e of entries) {
7657
+ if (e && typeof e.path === "string" && e.path) owed.push({ version: ver, path: e.path, note: e.note });
7658
+ }
7659
+ }
7660
+ return { owed, source: c };
7661
+ }
7662
+ // No manifest anywhere (an older staged payload AND a stripped CLI copy):
7663
+ // the caller records this honestly in the journal instead of passing it
7664
+ // off as a checked window.
7665
+ return { owed: [], source: null };
7666
+ }
7667
+
7668
+ // `moveros update --status`: print the journal (the machine-readable handoff
7669
+ // the /update workflow reads). Honest null when no update is in flight.
7670
+ function cmdUpdateStatus() {
7671
+ const journal = loadUpdateJournal();
7672
+ if (!journal) {
7673
+ barLn(dim(" No update journal found. Nothing in progress."));
7674
+ outro("No update in progress.");
7675
+ return;
7676
+ }
7677
+ barLn(bold(" Update journal"));
7678
+ barLn();
7679
+ barLn(` Run: ${journal.runId} ${dim(`(${journal.mode})`)}`);
7680
+ barLn(` ${dim(journal.fromVersion || "?")} ${dim("->")} ${bold(journal.toVersion || "?")} ${journal.completedAt ? green(journal.rolledBack ? "rolled back" : "completed") : yellow("in progress")}`);
7681
+ barLn();
7682
+ for (const s of UPDATE_STAGES) {
7683
+ const st = (journal.stages && journal.stages[s]) || { status: "pending" };
7684
+ const icon = st.status === "done" ? "ok" : st.status === "failed" ? "fail" : "warn";
7685
+ statusLine(icon, ` ${s}`, st.status);
7686
+ }
7687
+ barLn();
7688
+ barLn(dim(` Journal: ${updateJournalPath()}`));
7689
+ outro(journal.completedAt ? "Update complete." : "Update in progress. Finish with /update, then moveros update --finalize.");
7690
+ }
7691
+
7692
+ // `moveros update --finalize`: the /update workflow calls this after Engine
7693
+ // migration. It records the migrate stage, VERIFIES the applied files against
7694
+ // the staged bundle, and stamps .mover-version ONLY on a pass. This is the
7695
+ // single place the interactive path is allowed to stamp, so the stamp always
7696
+ // means "verified", never "assumed".
7697
+ async function cmdUpdateFinalize(opts) {
7698
+ const journal = loadUpdateJournal();
7699
+ let vaultPath = journal && journal.vaultPath && fs.existsSync(journal.vaultPath) ? journal.vaultPath : resolveVaultPath(opts.vault);
7700
+ if (!vaultPath) {
7701
+ outro(red("No Mover OS vault found. Use: moveros update --finalize --vault /path"));
7702
+ process.exit(EXIT.NOT_FOUND);
7703
+ }
7704
+ let markerOk = false;
7705
+ try {
7706
+ const mp = path.join(vaultPath, ".mover-version");
7707
+ markerOk = fs.statSync(mp).isFile() && fs.readFileSync(mp, "utf8").trim().length > 0;
7708
+ } catch {}
7709
+ if (!markerOk) {
7710
+ outro(red(`No Mover OS install found at ${vaultPath}. Nothing to finalize.`));
7711
+ process.exit(EXIT.NOT_FOUND);
7712
+ }
7713
+
7714
+ const j = journal || newUpdateJournal("finalize", null, require("./package.json").version, vaultPath);
7715
+
7716
+ // MIGRATION RECEIPT (sol re-score #3 refutation: finalize marked migrate
7717
+ // done without verifying any Engine migration happened). The /update
7718
+ // workflow writes ~/.mover/update-receipt.json AFTER its Engine steps:
7719
+ // { runId, toVersion, migrated: [{path, sha256}], checked: [paths], note }
7720
+ // runId comes from the journal (~/.mover/update-state.json); null when no
7721
+ // journal existed. migrated may be EMPTY (no migration owed this release)
7722
+ // but the receipt itself is mandatory, and every migrated file must still
7723
+ // be on disk with the recorded post-migration hash - proof the work
7724
+ // happened and survived. Fail-closed: no receipt, no stamp.
7725
+ const receiptPath = path.join(os.homedir(), ".mover", "update-receipt.json");
7726
+ let receipt = null;
7727
+ try { receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); } catch {}
7728
+ const expectVersion = (j.toVersion || require("./package.json").version).replace(/^V/i, "");
7729
+ const receiptProblems = [];
7730
+ const owedMigrations = loadOwedEngineMigrations(journal, expectVersion);
7731
+ if (!receipt) {
7732
+ receiptProblems.push(`missing or unreadable: ${receiptPath}`);
7733
+ } else {
7734
+ const expectedRunId = journal ? journal.runId : null;
7735
+ if ((receipt.runId || null) !== expectedRunId) receiptProblems.push(`runId ${JSON.stringify(receipt.runId || null)} does not match this run ${JSON.stringify(expectedRunId)}`);
7736
+ if (String(receipt.toVersion || "").replace(/^V/i, "") !== expectVersion) receiptProblems.push(`toVersion ${JSON.stringify(receipt.toVersion)} does not match ${expectVersion}`);
7737
+ // Receipt paths must stay INSIDE the vault. Lexical checks (absolute,
7738
+ // ..-traversal) are not enough: a symlink WITHIN the vault pointing
7739
+ // outside would pass lexically but resolve elsewhere (sol re-score #5
7740
+ // refutation). resolveContained realpath-resolves both the vault root and
7741
+ // the entry and requires the entry to remain beneath the resolved root,
7742
+ // so a symlink escape is rejected. Returns the real absolute path or null.
7743
+ let realVaultRoot = null;
7744
+ try { realVaultRoot = fs.realpathSync(vaultPath); } catch { realVaultRoot = path.resolve(vaultPath); }
7745
+ const resolveContained = (rel) => {
7746
+ if (typeof rel !== "string" || rel.length === 0) return { err: "path is empty" };
7747
+ if (path.isAbsolute(rel) || rel.split(/[\\/]+/).includes("..")) return { err: "must be vault-relative without traversal" };
7748
+ const joined = path.join(vaultPath, rel);
7749
+ let real;
7750
+ try { real = fs.realpathSync(joined); } catch (e) { return { err: e.code === "ENOENT" ? "does not exist in the vault" : "not resolvable on disk" }; }
7751
+ const prefix = realVaultRoot.endsWith(path.sep) ? realVaultRoot : realVaultRoot + path.sep;
7752
+ if (real !== realVaultRoot && !real.startsWith(prefix)) return { err: "resolves outside the vault (symlink escape)" };
7753
+ return { abs: real };
7754
+ };
7755
+ if (!Array.isArray(receipt.migrated)) {
7756
+ receiptProblems.push("migrated must be an array ([] is valid when no migration was owed)");
7757
+ } else {
7758
+ for (const m of receipt.migrated) {
7759
+ const rel = m && m.path;
7760
+ if (typeof rel !== "string" || !rel) { receiptProblems.push("a migrated entry has no path"); continue; }
7761
+ const c = resolveContained(rel);
7762
+ if (c.err) { receiptProblems.push(`${rel}: migrated path ${c.err}`); continue; }
7763
+ try {
7764
+ const actual = require("crypto").createHash("sha256").update(fs.readFileSync(c.abs, "utf8").replace(/\r\n/g, "\n")).digest("hex");
7765
+ if (actual !== m.sha256) receiptProblems.push(`${rel}: on-disk hash does not match the receipt (file changed or migration reverted)`);
7766
+ } catch {
7767
+ receiptProblems.push(`${rel}: listed as migrated but not readable on disk`);
7768
+ }
7769
+ }
7770
+ // SEMANTIC COMPLETENESS: the receipt must cover every Engine migration
7771
+ // this release window DECLARES (src/system/engine-migrations.json). An
7772
+ // empty migrated list stops meaning "the process ran" and starts
7773
+ // meaning "this window declares nothing owed". FAIL-CLOSED when the
7774
+ // manifest cannot be found (sol re-score #5 refutation: recording
7775
+ // "unavailable" and proceeding with zero owed migrations let a stripped
7776
+ // bundle skip the check silently; a missing declaration source is a
7777
+ // refusal, not a pass).
7778
+ if (!owedMigrations.source) {
7779
+ receiptProblems.push("engine-migrations.json is unavailable in every bundle source: cannot prove the release owes no migration (fail-closed)");
7780
+ }
7781
+ const migratedPaths = new Set(receipt.migrated.map((m) => m && m.path).filter(Boolean));
7782
+ for (const d of owedMigrations.owed) {
7783
+ if (!migratedPaths.has(d.path)) {
7784
+ receiptProblems.push(`${d.path}: declared as owed by release ${d.version}${d.note ? ` (${d.note})` : ""} but absent from the receipt's migrated list`);
7785
+ }
7786
+ }
7787
+ // `checked` is the workflow's claim of what it INSPECTED - the basis for
7788
+ // "no (further) migration owed". The inspection itself cannot be
7789
+ // re-verified, but the claim can: every named file must actually exist
7790
+ // in the vault, and an empty-migration receipt must name at least one
7791
+ // inspected file (sol re-score #4: the field was counted in the journal
7792
+ // but never validated, so a fabricated receipt passed the gate).
7793
+ if (!Array.isArray(receipt.checked)) {
7794
+ receiptProblems.push("checked must be an array of vault-relative paths");
7795
+ } else {
7796
+ if (receipt.migrated.length === 0 && receipt.checked.length === 0) {
7797
+ receiptProblems.push("migrated is empty and checked is empty: name the files you inspected to conclude no migration was owed");
7798
+ }
7799
+ for (const rel of receipt.checked) {
7800
+ const c = resolveContained(rel);
7801
+ if (c.err) { receiptProblems.push(`${JSON.stringify(rel)}: checked path ${c.err}`); continue; }
7802
+ try {
7803
+ if (!fs.statSync(c.abs).isFile()) receiptProblems.push(`${rel}: listed as checked but is not a file`);
7804
+ } catch {
7805
+ receiptProblems.push(`${rel}: listed as checked but does not exist in the vault`);
7806
+ }
7807
+ }
7808
+ }
7809
+ }
7810
+ }
7811
+ if (receiptProblems.length) {
7812
+ journalStage(j, "migrate", "failed", { problems: receiptProblems.slice(0, 20) });
7813
+ barLn(red(" Migration receipt check failed:"));
7814
+ for (const p of receiptProblems.slice(0, 8)) barLn(` ${red("x")} ${p}`);
7815
+ barLn();
7816
+ barLn(dim(" /update must write ~/.mover/update-receipt.json after its Engine"));
7817
+ barLn(dim(" migration steps: { runId (from ~/.mover/update-state.json, or null),"));
7818
+ barLn(dim(" toVersion, migrated: [{path, sha256}], checked: [paths] }."));
7819
+ barLn(dim(" An empty migrated array is valid when no migration was owed,"));
7820
+ barLn(dim(" but then checked must name the real vault files you inspected."));
7821
+ outro(red("Not stamped: no verifiable migration receipt."));
7822
+ process.exit(EXIT.ERROR);
7823
+ }
7824
+ // MACHINE-DERIVED inspection evidence (sol re-score #6 part 10: "checked
7825
+ // remains a workflow-authored inspection claim; make inspection evidence
7826
+ // machine-derived"). Rather than trusting only the workflow's `checked`
7827
+ // list, finalize independently OBSERVES the Engine directory: it lists the
7828
+ // top-level Engine files (skipping Dailies and other subfolders) with a
7829
+ // content hash each, and confirms every `checked` entry that lives under
7830
+ // Engine is corroborated by that observation. The observed set is recorded
7831
+ // in the journal, so the audit trail carries machine-witnessed evidence of
7832
+ // the Engine state at stamp time, not a self-reported claim.
7833
+ const engineObserved = (() => {
7834
+ const engineDir = path.join(vaultPath, "02_Areas", "Engine");
7835
+ // Origin classification (sol #8 part 10 residual: "checked records
7836
+ // workflow-authored inspection evidence that cannot be reconstructed
7837
+ // mechanically"). The shipped-default fingerprint set (every historical
7838
+ // hash of each non-sentinel Engine default) lets the MACHINE reconstruct
7839
+ // the inspection's basis: a file either matches a shipped default
7840
+ // byte-for-byte (nothing to migrate), or is user-modified (migration
7841
+ // decisions concern it), or is outside the shipped set entirely
7842
+ // (sentinels + user-created files). Recorded per file in the journal, so
7843
+ // the audit trail carries the machine's own classification, not only the
7844
+ // workflow's word.
7845
+ let fingerprints = null;
7846
+ try { fingerprints = JSON.parse(fs.readFileSync(path.join(__dirname, "src", "dashboard", "lib", "engine-default-fingerprints.json"), "utf8")).files; } catch {}
7847
+ const fpHash = (text) => require("crypto").createHash("sha256").update(String(text).replace(/\r\n/g, "\n").trim()).digest("hex");
7848
+ try {
7849
+ const files = fs.readdirSync(engineDir, { withFileTypes: true })
7850
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
7851
+ .map((e) => {
7852
+ const rel = path.join("02_Areas", "Engine", e.name);
7853
+ let sha = null;
7854
+ let origin = "unclassified";
7855
+ try {
7856
+ const text = fs.readFileSync(path.join(engineDir, e.name), "utf8");
7857
+ sha = require("crypto").createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex").slice(0, 16);
7858
+ if (fingerprints) {
7859
+ const known = fingerprints[e.name];
7860
+ origin = Array.isArray(known)
7861
+ ? (known.includes(fpHash(text)) ? "matches-shipped-default" : "user-modified")
7862
+ : "not-a-shipped-default"; // sentinels + user-created files
7863
+ }
7864
+ } catch {}
7865
+ return { path: rel, sha, origin };
7866
+ });
7867
+ return { present: true, count: files.length, files, fingerprintSource: fingerprints ? "engine-default-fingerprints.json" : "unavailable" };
7868
+ } catch {
7869
+ return { present: false, count: 0, files: [], fingerprintSource: fingerprints ? "engine-default-fingerprints.json" : "unavailable" };
7870
+ }
7871
+ })();
7872
+ // Corroborate: any `checked` entry that claims an Engine file must appear in
7873
+ // the machine-observed set. A claim about an Engine file the machine cannot
7874
+ // see is a fabricated inspection, caught here rather than trusted.
7875
+ const observedEngine = new Set(engineObserved.files.map((f) => f.path.split(path.sep).join("/")));
7876
+ const uncorroborated = receipt.checked
7877
+ .map((r) => String(r).split(path.sep).join("/"))
7878
+ .filter((r) => r.startsWith("02_Areas/Engine/") && !r.includes("/Dailies/") && !observedEngine.has(r));
7879
+ if (uncorroborated.length && engineObserved.present) {
7880
+ journalStage(j, "migrate", "failed", { problems: uncorroborated.map((r) => `${r}: checked as an Engine file but not observed in the Engine directory (fabricated inspection)`) });
7881
+ barLn(red(" Migration receipt check failed (machine corroboration):"));
7882
+ for (const r of uncorroborated.slice(0, 8)) barLn(` ${red("x")} ${r}: not observed in the Engine directory`);
7883
+ outro(red("Not stamped: a checked Engine file was not machine-corroborated."));
7884
+ process.exit(EXIT.ERROR);
7885
+ }
7886
+ journalStage(j, "migrate", "done", {
7887
+ owner: "workflow",
7888
+ finalizedBy: "moveros update --finalize",
7889
+ receipt: { migrated: receipt.migrated.length, checked: receipt.checked.length },
7890
+ // "unavailable" = no declarations manifest found in any bundle source;
7891
+ // recorded rather than passed off as a checked window.
7892
+ migrations: { manifest: owedMigrations.source ? "checked" : "unavailable", declaredOwed: owedMigrations.owed.length },
7893
+ // Machine-witnessed Engine state at stamp time (independent of the
7894
+ // workflow's `checked` claim).
7895
+ engineObserved,
7896
+ });
7897
+ maybeCrashForTest("migrate");
7898
+ journalStage(j, "verify", "started");
7899
+ maybeCrashForTest("verify");
7900
+
7901
+ const bundleDir = resolveVerifyBundleDir(j);
7902
+ let result;
7903
+ let scope;
7904
+ if (bundleDir) {
7905
+ scope = "full";
7906
+ result = verifyUpdateApplied(bundleDir, vaultPath, { templates: "content" });
7907
+ } else {
7908
+ // No source available to verify against (e.g. an Engine-only migration
7909
+ // with no staged payload). Degrade honestly to the conflicts gate; never
7910
+ // fabricate a full verification.
7911
+ scope = "conflicts-only";
7912
+ result = { ok: true, failures: [] };
7913
+ const conflictsPath = path.join(os.homedir(), ".mover", "conflicts.json");
7914
+ if (fs.existsSync(conflictsPath)) {
7915
+ try {
7916
+ const data = JSON.parse(fs.readFileSync(conflictsPath, "utf8"));
7917
+ result.failures = (data.files || []).map((f) => ({ file: f.file, reason: "unresolved-conflict" }));
7918
+ } catch {
7919
+ result.failures = [{ file: "conflicts.json", reason: "unreadable" }];
7920
+ }
7921
+ result.ok = result.failures.length === 0;
7922
+ }
7923
+ }
7924
+
7925
+ if (!result.ok) {
7926
+ journalStage(j, "verify", "failed", { scope, failures: result.failures.slice(0, 50) });
7927
+ barLn(red(` Verification failed: ${result.failures.length} file${result.failures.length > 1 ? "s" : ""} not at the updated version.`));
7928
+ for (const f of result.failures.slice(0, 12)) barLn(` ${red("x")} ${f.file} ${dim(`(${f.reason})`)}`);
7929
+ if (result.failures.length > 12) barLn(dim(` ...and ${result.failures.length - 12} more`));
7930
+ barLn();
7931
+ barLn(dim(" The version was NOT stamped. Resolve the files above"));
7932
+ barLn(dim(" (usually: re-run moveros update, or finish /update conflict merges),"));
7933
+ barLn(dim(" then run moveros update --finalize again."));
7934
+ outro(red("Not stamped: verification failed."));
7935
+ process.exit(EXIT.ERROR);
7936
+ }
7937
+
7938
+ journalStage(j, "verify", "done", { scope, checkedAgainst: bundleDir || null });
7939
+ journalStage(j, "stamp", "started");
7940
+ maybeCrashForTest("stamp");
7941
+ const version = (j.toVersion || require("./package.json").version).replace(/^V/i, "");
7942
+ fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${version}\n`, "utf8");
7943
+ j.completedAt = new Date().toISOString();
7944
+ journalStage(j, "stamp", "done", { version });
7945
+ // Consume the receipt: a stamped run's receipt must never validate a
7946
+ // FUTURE finalize (the null-journal path has no runId to collide on).
7947
+ try { fs.renameSync(receiptPath, `${receiptPath.replace(/\.json$/, "")}.used.json`); } catch {}
7948
+ statusLine("ok", "Verified", scope === "full" ? "workflows, templates, hooks all at the updated version" : "no unresolved conflicts");
7949
+ statusLine("ok", "Stamped", `.mover-version -> ${version}`);
7950
+ outro(green(`Update finalized: verified and stamped ${version}.`));
7951
+ }
7952
+
7953
+ // `moveros update --rollback`: restore the vault + agent artifacts from the
7954
+ // backup recorded in the journal (or the newest rollback-capable backup).
7955
+ // Never touches the bundle dir: on npm installs that is a read-only global
7956
+ // package, and it is not the rollback target anyway. Engine files are never
7957
+ // in these backups, so they are never touched here either.
7958
+ async function cmdUpdateRollback(opts) {
7959
+ const home = os.homedir();
7960
+ const journal = loadUpdateJournal();
7961
+ let backupDir = journal && journal.stages && journal.stages.backup && journal.stages.backup.backupDir;
7962
+ const dirArg = opts.rest.find((a) => a.startsWith("--dir="));
7963
+ if (dirArg) backupDir = path.resolve(dirArg.slice(6));
7964
+ if (!backupDir || !fs.existsSync(path.join(backupDir, "backup-manifest.json"))) {
7965
+ const root = path.join(home, ".mover", "backups");
7966
+ const cands = fs.existsSync(root)
7967
+ ? fs.readdirSync(root).filter((d) => d.startsWith("pre-update-") && fs.existsSync(path.join(root, d, "backup-manifest.json"))).sort()
7968
+ : [];
7969
+ if (cands.length) backupDir = path.join(root, cands[cands.length - 1]);
7970
+ }
7971
+ if (!backupDir || !fs.existsSync(path.join(backupDir, "backup-manifest.json"))) {
7972
+ barLn(red(" No rollback-capable backup found (backup-manifest.json missing)."));
7973
+ barLn(dim(" Backups made before this feature can be restored by hand from ~/.mover/backups/."));
7974
+ outro(red("Rollback aborted: nothing to restore from."));
7975
+ process.exit(EXIT.NOT_FOUND);
7976
+ }
7977
+
7978
+ let bm;
7979
+ try {
7980
+ bm = JSON.parse(fs.readFileSync(path.join(backupDir, "backup-manifest.json"), "utf8"));
7981
+ } catch {
7982
+ outro(red("Rollback aborted: backup-manifest.json is unreadable."));
7983
+ process.exit(EXIT.ERROR);
7984
+ }
7985
+
7986
+ let restored = 0, removed = 0, failed = 0;
7987
+ for (const e of bm.entries || []) {
7988
+ try {
7989
+ if (e.existedBefore && e.backup) {
7990
+ const src = path.join(backupDir, e.backup);
7991
+ if (!fs.existsSync(src)) { failed++; continue; }
7992
+ fs.mkdirSync(path.dirname(e.original), { recursive: true });
7993
+ // Unlink first: the original may be a hard link into the bundle
7994
+ // source; copying through the link would write INTO the (possibly
7995
+ // read-only, npm-owned) bundle. A fresh inode never can.
7996
+ try { fs.rmSync(e.original, { force: true }); } catch {}
7997
+ fs.copyFileSync(src, e.original);
7998
+ restored++;
7999
+ } else if (!e.existedBefore) {
8000
+ if (fs.existsSync(e.original)) { fs.rmSync(e.original, { force: true }); removed++; }
8001
+ }
8002
+ } catch {
8003
+ failed++;
8004
+ }
8005
+ }
8006
+
8007
+ // Restore the pristine base snapshots captured with the backup so the next
8008
+ // update classifies the rolled-back files against the right bases.
8009
+ try {
8010
+ const snapInstalled = path.join(backupDir, "mover-state", "installed");
8011
+ if (fs.existsSync(snapInstalled)) {
8012
+ fs.cpSync(snapInstalled, path.join(home, ".mover", "installed"), { recursive: true, force: true });
8013
+ }
8014
+ } catch {}
8015
+ // Conflicts recorded by the rolled-back run are void now.
8016
+ try { fs.rmSync(path.join(home, ".mover", "conflicts.json"), { force: true }); } catch {}
8017
+
8018
+ if (journal && !journal.completedAt) {
8019
+ journal.rolledBack = true;
8020
+ journal.completedAt = new Date().toISOString();
8021
+ saveUpdateJournal(journal);
8022
+ }
8023
+
8024
+ statusLine("ok", "Rollback", `${restored} restored, ${removed} removed${failed ? `, ${red(`${failed} failed`)}` : ""}`);
8025
+ barLn(dim(` From: ${backupDir}`));
8026
+ barLn(dim(" Engine files were never in this backup and were not touched."));
8027
+ barLn(dim(" The version marker was not changed (interactive updates never stamp before verify)."));
8028
+ outro(failed ? yellow(`Rollback finished with ${failed} failure${failed > 1 ? "s" : ""}.`) : green("Rollback complete."));
8029
+ if (failed) process.exit(EXIT.ERROR);
8030
+ }
8031
+
6434
8032
  async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
8033
+ // ── State-machine subcommands: local, no license, no self-update hop ──
8034
+ if (opts.rest.includes("--status")) return cmdUpdateStatus();
8035
+ if (opts.rest.includes("--finalize")) return cmdUpdateFinalize(opts);
8036
+ if (opts.rest.includes("--rollback")) return cmdUpdateRollback(opts);
8037
+
6435
8038
  const isQuick = opts.rest.includes("--quick");
6436
8039
 
6437
8040
  // Step 1: CLI Self-Update
@@ -6455,8 +8058,10 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6455
8058
  try {
6456
8059
  try {
6457
8060
  execSync("npm i -g mover-os", { stdio: "ignore", timeout: 60000 });
6458
- } catch {
6459
- // Retry with sudo — use inherit so password prompt shows
8061
+ } catch (err) {
8062
+ // Retry with sudo — use inherit so password prompt shows.
8063
+ // sudo does not exist on Windows; surface the real npm error there.
8064
+ if (process.platform === "win32") throw err;
6460
8065
  sp.stop(dim("Needs elevated permissions..."));
6461
8066
  execSync("sudo npm i -g mover-os", { stdio: "inherit", timeout: 120000 });
6462
8067
  }
@@ -6491,6 +8096,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6491
8096
  }
6492
8097
  statusLine("ok", "Vault", path.basename(vaultPath));
6493
8098
 
8099
+ // An update must target an EXISTING install. resolveVaultPath trusts an
8100
+ // explicit --vault without existsSync (terra F5), so a typo'd or wrong path
8101
+ // would otherwise get a fresh structure minted and stamped as "current",
8102
+ // shadowing the real install. Refuse unless a real, non-empty install marker
8103
+ // is present (sol flaw #5: an empty file or a directory named .mover-version
8104
+ // must not pass the guard).
8105
+ let installMarkerOk = false;
8106
+ try {
8107
+ const mp = path.join(vaultPath, ".mover-version");
8108
+ installMarkerOk = fs.statSync(mp).isFile() && fs.readFileSync(mp, "utf8").trim().length > 0;
8109
+ } catch {}
8110
+ if (!installMarkerOk) {
8111
+ outro(red(`No Mover OS install found at ${vaultPath}. Run ${bold("moveros install")} first, or pass the correct --vault.`));
8112
+ process.exit(EXIT.NOT_FOUND);
8113
+ }
8114
+
6494
8115
  // Step 3: Key Validation
6495
8116
  let updateKey = opts.key;
6496
8117
  if (!updateKey) {
@@ -6548,9 +8169,24 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6548
8169
  newVer = pkg.version || newVer;
6549
8170
  } catch {}
6550
8171
 
6551
- // ── Stage only no direct file overwrite ──
6552
- // Files are staged at ~/.mover/src/. The /update AI workflow handles
6553
- // the actual apply with merge support for user customizations.
8172
+ // ── Update journal: ONE resumable state machine for CLI + /update ──
8173
+ // Created here, after preflight facts (install marker, license, staged
8174
+ // bundle) are established. Resumes an interrupted run of the same mode and
8175
+ // target version so the original backup dir is preserved; every stage below
8176
+ // is idempotent, so resume = re-run from the journal.
8177
+ const journalMode = isQuick ? "quick" : "interactive";
8178
+ let journal = loadUpdateJournal();
8179
+ const resuming = !!(journal && !journal.completedAt && journal.toVersion === newVer && journal.mode === journalMode && journal.vaultPath === vaultPath);
8180
+ if (!resuming) {
8181
+ journal = newUpdateJournal(journalMode, installedVer, newVer, vaultPath);
8182
+ } else {
8183
+ barLn(dim(` Resuming interrupted update (started ${journal.startedAt}).`));
8184
+ barLn();
8185
+ }
8186
+ journalStage(journal, "preflight", "done", { installMarker: true });
8187
+ maybeCrashForTest("preflight");
8188
+ journalStage(journal, "stage", "done", { bundleDir });
8189
+ maybeCrashForTest("stage");
6554
8190
 
6555
8191
  const detectedAgents = AGENTS.filter((a) => a.detect());
6556
8192
  const selectedIds = detectedAgents.map((a) => a.id);
@@ -6561,8 +8197,34 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6561
8197
  const changes = detectChanges(bundleDir, vaultPath, selectedIds);
6562
8198
  const totalChanged = countChanges(changes);
6563
8199
  displayChangeSummary(changes, installedVer, newVer);
6564
- if (totalChanged === 0) { outro(green("Already up to date.")); return; }
6565
- autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
8200
+ if (totalChanged === 0 && !resuming) {
8201
+ // Nothing to do and no interrupted run to finish: close the journal
8202
+ // honestly rather than leave a phantom in-progress run.
8203
+ for (const s of ["backup", "apply", "migrate", "verify", "stamp"]) journalStage(journal, s, "skipped", { reason: "no-changes" });
8204
+ journal.completedAt = new Date().toISOString();
8205
+ saveUpdateJournal(journal);
8206
+ outro(green("Already up to date."));
8207
+ return;
8208
+ }
8209
+ if (totalChanged === 0 && resuming) {
8210
+ // Resume after a crash PAST the apply stage: the files are already on
8211
+ // disk but verify/stamp never ran. Fall through to them; re-running
8212
+ // backup/apply on zero changes would be a no-op anyway.
8213
+ barLn(dim(" Files already applied by the interrupted run - verifying and stamping."));
8214
+ barLn();
8215
+ if (journal.stages.backup.status !== "done") journalStage(journal, "backup", "skipped", { reason: "resume-no-changes" });
8216
+ journalStage(journal, "apply", "done", { updated: 0, resumed: true });
8217
+ }
8218
+ if (totalChanged > 0) {
8219
+ journalStage(journal, "backup", "started");
8220
+ maybeCrashForTest("backup");
8221
+ const priorBackup = resuming && journal.stages.backup && journal.stages.backup.backupDir && fs.existsSync(journal.stages.backup.backupDir)
8222
+ ? { backupRoot: journal.stages.backup.backupDir, backed: journal.stages.backup.backed || 0 }
8223
+ : null;
8224
+ const bkup = priorBackup || autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
8225
+ journalStage(journal, "backup", "done", { backupDir: bkup.backupRoot, backed: bkup.backed, reused: !!priorBackup });
8226
+ journalStage(journal, "apply", "started");
8227
+ maybeCrashForTest("apply");
6566
8228
  barLn(bold("Updating..."));
6567
8229
  barLn();
6568
8230
  createVaultStructure(vaultPath);
@@ -6583,8 +8245,102 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6583
8245
  sp.stop(steps.length > 0 ? `${displayName} ${dim(steps.join(", "))}` : `${displayName} ${dim("configured")}`);
6584
8246
  }
6585
8247
  }
8248
+
8249
+ // Complete Claude hook-asset refresh (6b parity with the interactive path):
8250
+ // the .js helpers and .md prompt templates must be on disk before anything
8251
+ // records or verifies them as current.
8252
+ const quickHooksSrc = path.join(bundleDir, "src", "hooks");
8253
+ const quickClaudeDetected = detectedAgents.some((a) => a.id === "claude-code");
8254
+ if (quickClaudeDetected && fs.existsSync(quickHooksSrc)) {
8255
+ const hooksDstClaude = path.join(os.homedir(), ".claude", "hooks");
8256
+ fs.mkdirSync(hooksDstClaude, { recursive: true });
8257
+ for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".sh"))) {
8258
+ const content = fs.readFileSync(path.join(quickHooksSrc, f), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
8259
+ fs.writeFileSync(path.join(hooksDstClaude, f), content, { mode: 0o755 });
8260
+ try { fs.chmodSync(path.join(hooksDstClaude, f), 0o755); } catch {}
8261
+ }
8262
+ for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".md") || x.endsWith(".js"))) {
8263
+ fs.copyFileSync(path.join(quickHooksSrc, f), path.join(hooksDstClaude, f));
8264
+ }
8265
+ }
8266
+
8267
+ // Rebuild the manifest to match what force-apply actually put on disk.
8268
+ // record-if-current: an entry asserts "the install holds this base", so
8269
+ // record the source hash only where the destination proves it (or, for
8270
+ // workflows, where this run just force-wrote it); otherwise carry the
8271
+ // prior base forward (F4 discipline) so nothing uncopied is marked current.
8272
+ try {
8273
+ const prior = loadUpdateManifest();
8274
+ const mfst = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
8275
+ if (prior && prior.files) Object.assign(mfst.files, prior.files);
8276
+ const wfDir = path.join(bundleDir, "src", "workflows");
8277
+ if (fs.existsSync(wfDir)) {
8278
+ // Quick force-applies EVERY workflow via the agent installers (which
8279
+ // also advance the pristine snapshots), so the source hash IS the new
8280
+ // base for all of them. Transformed copies still classify as
8281
+ // user-only-skip against this base, same as fresh installs.
8282
+ for (const f of fs.readdirSync(wfDir).filter((x) => x.endsWith(".md"))) {
8283
+ mfst.files[`workflows/${f}`] = fileContentHash(path.join(wfDir, f));
8284
+ }
8285
+ }
8286
+ const rulesPath2 = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
8287
+ if (fs.existsSync(rulesPath2)) mfst.files["system/Mover_Global_Rules.md"] = fileContentHash(rulesPath2);
8288
+ if (quickClaudeDetected && fs.existsSync(quickHooksSrc)) {
8289
+ for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
8290
+ const dst = path.join(os.homedir(), ".claude", "hooks", f);
8291
+ if (fs.existsSync(dst) && fileContentHash(dst) === fileContentHash(path.join(quickHooksSrc, f))) {
8292
+ mfst.files[`hooks/${f}`] = fileContentHash(path.join(quickHooksSrc, f));
8293
+ }
8294
+ }
8295
+ }
8296
+ // Templates: quick installs NEW files but never overwrites existing vault
8297
+ // scaffold files, so only record entries the disk actually holds.
8298
+ const structDir2 = path.join(bundleDir, "src", "structure");
8299
+ if (fs.existsSync(structDir2)) {
8300
+ const walkT = (dir, rel) => {
8301
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
8302
+ const srcPath = path.join(dir, entry.name);
8303
+ const entryRel = path.join(rel, entry.name);
8304
+ if (entry.isDirectory()) { walkT(srcPath, entryRel); continue; }
8305
+ const relNorm = entryRel.replace(/\\/g, "/");
8306
+ if (relNorm.includes("02_Areas") && relNorm.includes("Engine")) continue;
8307
+ const destFile = path.join(vaultPath, entryRel);
8308
+ try {
8309
+ if (fs.existsSync(destFile) && fileContentHash(destFile) === fileContentHash(srcPath)) {
8310
+ mfst.files[`templates/${relNorm}`] = fileContentHash(srcPath);
8311
+ }
8312
+ } catch {}
8313
+ }
8314
+ };
8315
+ walkT(structDir2, "");
8316
+ }
8317
+ saveUpdateManifest(mfst);
8318
+ } catch {}
8319
+
8320
+ journalStage(journal, "apply", "done", { updated: totalChanged });
8321
+ } // end totalChanged > 0
8322
+ journalStage(journal, "migrate", "skipped", { reason: "quick mode: run /update for Engine migrations" });
8323
+
8324
+ // ── Verify BEFORE the stamp: the stamp is earned, never assumed ──
8325
+ journalStage(journal, "verify", "started");
8326
+ maybeCrashForTest("verify");
8327
+ const quickVerify = verifyUpdateApplied(bundleDir, vaultPath, { templates: "presence" });
8328
+ if (!quickVerify.ok) {
8329
+ journalStage(journal, "verify", "failed", { failures: quickVerify.failures.slice(0, 50) });
8330
+ barLn(red(` Verification failed: ${quickVerify.failures.length} file${quickVerify.failures.length > 1 ? "s" : ""} not at the updated version.`));
8331
+ for (const f of quickVerify.failures.slice(0, 12)) barLn(` ${red("x")} ${f.file} ${dim(`(${f.reason})`)}`);
8332
+ barLn(dim(" The version was NOT stamped. Re-run moveros update --quick once the cause is resolved."));
8333
+ outro(red("Update incomplete: verification failed, version not stamped."));
8334
+ process.exit(EXIT.ERROR);
8335
+ }
8336
+ journalStage(journal, "verify", "done", { scope: "quick" });
8337
+
8338
+ journalStage(journal, "stamp", "started");
8339
+ maybeCrashForTest("stamp");
6586
8340
  fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
6587
8341
  writeMoverConfig(vaultPath, selectedIds, updateKey);
8342
+ journal.completedAt = new Date().toISOString();
8343
+ journalStage(journal, "stamp", "done", { version: require("./package.json").version });
6588
8344
  barLn();
6589
8345
  if (totalChanged > 0) {
6590
8346
  barLn(dim(" Restart your AI session to load updated rules and skills."));
@@ -6622,6 +8378,11 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6622
8378
  if (totalChanged === 0) {
6623
8379
  barLn(green(" Already up to date."));
6624
8380
  barLn();
8381
+ // Mechanical stages are trivially complete; Engine migration may still be
8382
+ // owed, so migrate stays pending and /update + --finalize own the stamp.
8383
+ journalStage(journal, "backup", "skipped", { reason: "no-changes" });
8384
+ journalStage(journal, "apply", "done", { updated: 0 });
8385
+ journalStage(journal, "migrate", "pending", { owner: "workflow" });
6625
8386
  outro(green("No changes needed. Engine files may still need migration — run /update."));
6626
8387
  return;
6627
8388
  }
@@ -6636,11 +8397,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6636
8397
  { multi: false, defaultIndex: 0 }
6637
8398
  );
6638
8399
  if (!updateConfirm || updateConfirm === "no") {
8400
+ // Nothing was touched: close the journal so no phantom run lingers.
8401
+ if (!resuming) {
8402
+ journal.cancelled = true;
8403
+ journal.completedAt = new Date().toISOString();
8404
+ saveUpdateJournal(journal);
8405
+ }
6639
8406
  outro("Update cancelled.");
6640
8407
  return;
6641
8408
  }
6642
8409
  barLn();
6643
8410
 
8411
+ // ── Backup stage: full pre-apply backup of every file this run will touch ──
8412
+ // (Previously quick-only, so an interactive safe-overwrite had NO restore
8413
+ // source. The journal keeps the ORIGINAL dir across resumes so rollback
8414
+ // always targets the true pre-update state.)
8415
+ journalStage(journal, "backup", "started");
8416
+ maybeCrashForTest("backup");
8417
+ const priorBackupI = resuming && journal.stages.backup && journal.stages.backup.backupDir && fs.existsSync(journal.stages.backup.backupDir)
8418
+ ? { backupRoot: journal.stages.backup.backupDir, backed: journal.stages.backup.backed || 0 }
8419
+ : null;
8420
+ const bkupI = priorBackupI || autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
8421
+ journalStage(journal, "backup", "done", { backupDir: bkupI.backupRoot, backed: bkupI.backed, reused: !!priorBackupI });
8422
+ journalStage(journal, "apply", "started");
8423
+ maybeCrashForTest("apply");
8424
+
6644
8425
  // ── Apply safe system files directly (no user customizations in these) ──
6645
8426
  const home = os.homedir();
6646
8427
  let appliedCount = 0;
@@ -6666,14 +8447,18 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6666
8447
  if (fs.existsSync(slSrc)) {
6667
8448
  const slContent = fs.readFileSync(slSrc, "utf8").replace(/\r\n/g, "\n");
6668
8449
  fs.writeFileSync(slDest, slContent, "utf8");
6669
- // Ensure settings.json has statusLine config
8450
+ // Ensure settings.json has statusLine config. Absolute forward-slash
8451
+ // path, never `~` — cmd.exe does not expand tildes (Windows dead-hook
8452
+ // bug class). Also migrate an existing `~` form left by old installs.
6670
8453
  const globalSettings = path.join(home, ".claude", "settings.json");
8454
+ const slCmd = `node "${slDest.replace(/\\/g, "/")}"`;
6671
8455
  try {
6672
8456
  const settings = fs.existsSync(globalSettings)
6673
8457
  ? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
6674
8458
  : {};
6675
- if (!settings.statusLine) {
6676
- settings.statusLine = { type: "command", command: "node ~/.claude/statusline.js" };
8459
+ const cur = settings.statusLine && settings.statusLine.command;
8460
+ if (!settings.statusLine || /~\/\.claude\/statusline\.js/.test(String(cur || ""))) {
8461
+ settings.statusLine = { type: "command", command: slCmd };
6677
8462
  fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
6678
8463
  }
6679
8464
  } catch {}
@@ -6692,13 +8477,16 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6692
8477
  for (const dir of cmdDirs) {
6693
8478
  const dest = path.join(dir, "update.md");
6694
8479
  if (fs.existsSync(dir)) {
6695
- // Gap-1: apply the config-first vault-resolution transform here too
6696
- // update.md itself ships the bare git-rev-parse Pre-Flight, and this
6697
- // direct sync would otherwise copy the known-broken block verbatim.
6698
- // writeWorkflowTransformed returns false only for bodies that DON'T need
6699
- // the fix; update.md always does, so the copy fallback never runs for it.
8480
+ // Gap-1: route through the vault-resolution transform. For bodies that
8481
+ // don't need it (returns false), replace the dest ATOMICALLY via
8482
+ // same-dir temp + rename: a plain copyFileSync would write THROUGH the
8483
+ // install-time hard link into the bundle source, which on an npm
8484
+ // global install is read-only (EACCES; caught by the update-machine
8485
+ // read-only-bundle test) and on a repo install would corrupt src/.
6700
8486
  if (!writeWorkflowTransformed(updateSrc, dest)) {
6701
- fs.copyFileSync(updateSrc, dest);
8487
+ const tmp = dest + ".tmp-" + process.pid;
8488
+ fs.copyFileSync(updateSrc, tmp);
8489
+ fs.renameSync(tmp, dest);
6702
8490
  }
6703
8491
  }
6704
8492
  }
@@ -6717,7 +8505,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6717
8505
  // ── Smart Update: hash-based change detection + auto-merge ──
6718
8506
  const manifest = loadUpdateManifest();
6719
8507
  const newManifest = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
6720
- const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [];
8508
+ const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [], writeFailed = [];
6721
8509
  let needsUpdate = false;
6722
8510
 
6723
8511
  if (!manifest) {
@@ -6735,6 +8523,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6735
8523
  const srcFile = path.join(wfSrcDir, file);
6736
8524
  const destFile = path.join(wfDestDir, file);
6737
8525
  const result = compareForUpdate("workflows", srcFile, destFile, manifest);
8526
+ let wroteFile = true; // false = left on disk unresolved (see manifest guard below)
6738
8527
 
6739
8528
  switch (result.action) {
6740
8529
  case "install":
@@ -6744,9 +8533,16 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6744
8533
  // bare git-rev-parse Pre-Flight on update. writeWorkflowTransformed
6745
8534
  // emits a corrected copy for matched files and returns false (→ linkOrCopy)
6746
8535
  // for the rest, preserving the live-edit hard link where it's safe.
6747
- if (!writeWorkflowTransformed(srcFile, destFile)) linkOrCopy(srcFile, destFile);
6748
- savePristineCopy("workflows", srcFile);
6749
- updated.push(file);
8536
+ // sol flaw #3: linkOrCopy returns null on I/O failure. Do NOT advance
8537
+ // the pristine snapshot or manifest for a file that was not actually
8538
+ // written, or the next update treats an unapplied file as current.
8539
+ if (writeWorkflowTransformed(srcFile, destFile) || linkOrCopy(srcFile, destFile)) {
8540
+ savePristineCopy("workflows", srcFile);
8541
+ updated.push(file);
8542
+ } else {
8543
+ writeFailed.push(file);
8544
+ wroteFile = false;
8545
+ }
6750
8546
  break;
6751
8547
  case "user-only-skip":
6752
8548
  userOnly.push(file);
@@ -6758,9 +8554,13 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6758
8554
  const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
6759
8555
  fs.mkdirSync(backupDir, { recursive: true });
6760
8556
  if (fs.existsSync(destFile)) fs.copyFileSync(destFile, path.join(backupDir, file));
6761
- if (!writeWorkflowTransformed(srcFile, destFile)) linkOrCopy(srcFile, destFile);
6762
- savePristineCopy("workflows", srcFile);
6763
- updated.push(file);
8557
+ if (writeWorkflowTransformed(srcFile, destFile) || linkOrCopy(srcFile, destFile)) {
8558
+ savePristineCopy("workflows", srcFile);
8559
+ updated.push(file);
8560
+ } else {
8561
+ writeFailed.push(file); // sol flaw #3
8562
+ wroteFile = false;
8563
+ }
6764
8564
  break;
6765
8565
  }
6766
8566
  case "conflict": {
@@ -6773,12 +8573,25 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6773
8573
  savePristineCopy("workflows", srcFile);
6774
8574
  autoMerged.push(file);
6775
8575
  } else {
6776
- conflicts.push({ name: file, reason: mergeResult.reason, conflictCount: mergeResult.conflictCount });
8576
+ conflicts.push({
8577
+ name: file, category: "workflows",
8578
+ reason: mergeResult.reason, conflictCount: mergeResult.conflictCount,
8579
+ pristine: path.join(home, ".mover", "installed", "workflows", file),
8580
+ userVersion: destFile, newVersion: srcFile,
8581
+ });
8582
+ wroteFile = false;
6777
8583
  }
6778
8584
  break;
6779
8585
  }
6780
8586
  }
6781
- newManifest.files[`workflows/${file}`] = result.newHash;
8587
+ // terra F4: an unresolved conflict leaves the user's file on disk untouched.
8588
+ // Advancing the manifest to the new hash would make the NEXT update treat
8589
+ // the un-applied upstream change as already-pristine and silently skip it,
8590
+ // so a real bug/security fix never reaches a user who has a conflict. Keep
8591
+ // the prior base hash so the conflict resurfaces until /update resolves it.
8592
+ newManifest.files[`workflows/${file}`] = wroteFile
8593
+ ? result.newHash
8594
+ : (manifest?.files?.[`workflows/${file}`] ?? result.newHash);
6782
8595
  }
6783
8596
  }
6784
8597
 
@@ -6799,6 +8612,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6799
8612
 
6800
8613
  const destFile = path.join(vaultPath, entryRel);
6801
8614
  const result = compareForUpdate("templates", srcPath, destFile, manifest, relNorm);
8615
+ let wroteTmpl = true;
6802
8616
 
6803
8617
  switch (result.action) {
6804
8618
  case "install":
@@ -6835,12 +8649,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6835
8649
  savePristineCopy("templates", srcPath, relNorm);
6836
8650
  autoMerged.push(relNorm);
6837
8651
  } else {
6838
- conflicts.push({ name: relNorm, reason: mergeResult.reason, conflictCount: mergeResult.conflictCount });
8652
+ conflicts.push({
8653
+ name: relNorm, category: "templates",
8654
+ reason: mergeResult.reason, conflictCount: mergeResult.conflictCount,
8655
+ pristine: path.join(os.homedir(), ".mover", "installed", "templates", relNorm),
8656
+ userVersion: destFile, newVersion: srcPath,
8657
+ });
8658
+ wroteTmpl = false;
6839
8659
  }
6840
8660
  break;
6841
8661
  }
6842
8662
  }
6843
- newManifest.files[`templates/${relNorm}`] = result.newHash;
8663
+ // terra F4 (templates): same rule as workflows — never advance a manifest
8664
+ // entry for a file left unresolved on disk.
8665
+ newManifest.files[`templates/${relNorm}`] = wroteTmpl
8666
+ ? result.newHash
8667
+ : (manifest?.files?.[`templates/${relNorm}`] ?? result.newHash);
6844
8668
  }
6845
8669
  };
6846
8670
  walkTmplUpdate(tmplSrcDir, "");
@@ -6890,11 +8714,17 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6890
8714
 
6891
8715
  // ── Handle true conflicts → write conflicts.json for /update ──
6892
8716
  if (conflicts.length > 0) {
6893
- const conflictData = conflicts.map(({ name }) => ({
6894
- file: name,
6895
- pristine: path.join(home, ".mover", "installed", "workflows", name),
6896
- userVersion: path.join(home, ".claude", "commands", name),
6897
- newVersion: path.join(bundleDir, "src", "workflows", name),
8717
+ // terra F3: each record now carries its own category + real paths. A template
8718
+ // conflict (e.g. 00_Inbox/Quick_Capture.md, which lives in the vault) was
8719
+ // previously serialized with workflow paths (.claude/commands, src/workflows),
8720
+ // so /update read three non-existent files and could not resolve it. Fall
8721
+ // back to the workflow layout only for legacy records without paths.
8722
+ const conflictData = conflicts.map((c) => ({
8723
+ file: c.name,
8724
+ category: c.category || "workflows",
8725
+ pristine: c.pristine || path.join(home, ".mover", "installed", "workflows", c.name),
8726
+ userVersion: c.userVersion || path.join(home, ".claude", "commands", c.name),
8727
+ newVersion: c.newVersion || path.join(bundleDir, "src", "workflows", c.name),
6898
8728
  }));
6899
8729
  const conflictPath = path.join(home, ".mover", "conflicts.json");
6900
8730
  fs.writeFileSync(conflictPath, JSON.stringify({
@@ -6903,12 +8733,14 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6903
8733
  files: conflictData,
6904
8734
  }, null, 2), "utf8");
6905
8735
 
6906
- // Backup conflicted user files
8736
+ // Backup conflicted user files (from their real location — template conflicts
8737
+ // live in the vault, not ~/.claude/commands). Slashes in the relative name are
8738
+ // flattened so the backup filename is valid.
6907
8739
  const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
6908
8740
  fs.mkdirSync(backupDir, { recursive: true });
6909
- for (const { name } of conflicts) {
6910
- const dest = path.join(home, ".claude", "commands", name);
6911
- if (fs.existsSync(dest)) fs.copyFileSync(dest, path.join(backupDir, name));
8741
+ for (const c of conflicts) {
8742
+ const dest = c.userVersion || path.join(home, ".claude", "commands", c.name);
8743
+ if (fs.existsSync(dest)) fs.copyFileSync(dest, path.join(backupDir, c.name.replace(/[\/\\]/g, "__")));
6912
8744
  }
6913
8745
  needsUpdate = true;
6914
8746
  }
@@ -6932,6 +8764,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6932
8764
  }
6933
8765
  }
6934
8766
 
8767
+ // ── Refresh Claude's hook FILES before recording their hashes ──
8768
+ // 6b (terra-confirmed): the multi-agent propagation loop above SKIPS
8769
+ // claude-code, yet the manifest loop below records every hook .sh/.js/.md as
8770
+ // current. So a Claude user's hook helpers (e.g. session-history-writer.js,
8771
+ // used by pre-compact-backup.sh) and prompt templates were marked fresh in
8772
+ // the manifest but never actually copied — they stayed stale, and the next
8773
+ // update skipped them because the hashes "matched". Copy the complete Claude
8774
+ // hook asset set (same files the fresh installer writes) BEFORE recording, so
8775
+ // the manifest never claims a version the disk does not have. File copy only;
8776
+ // settings.json is already established and is not touched here.
8777
+ const hooksSrcForClaude = path.join(bundleDir, "src", "hooks");
8778
+ const claudeDetected = detectedAgentsForProp.some((a) => a.id === "claude-code");
8779
+ if (claudeDetected && fs.existsSync(hooksSrcForClaude)) {
8780
+ const hooksDstClaude = path.join(home, ".claude", "hooks");
8781
+ fs.mkdirSync(hooksDstClaude, { recursive: true });
8782
+ for (const f of fs.readdirSync(hooksSrcForClaude).filter((x) => x.endsWith(".sh"))) {
8783
+ const content = fs.readFileSync(path.join(hooksSrcForClaude, f), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
8784
+ fs.writeFileSync(path.join(hooksDstClaude, f), content, { mode: 0o755 });
8785
+ try { fs.chmodSync(path.join(hooksDstClaude, f), 0o755); } catch {}
8786
+ }
8787
+ for (const f of fs.readdirSync(hooksSrcForClaude).filter((x) => x.endsWith(".md") || x.endsWith(".js"))) {
8788
+ fs.copyFileSync(path.join(hooksSrcForClaude, f), path.join(hooksDstClaude, f));
8789
+ }
8790
+ }
8791
+
6935
8792
  // ── Save updated manifest ──
6936
8793
  // Carry forward entries for files we didn't process (hooks, statusline, /update itself)
6937
8794
  if (manifest && manifest.files) {
@@ -6942,12 +8799,36 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6942
8799
  const hooksDir = path.join(bundleDir, "src", "hooks");
6943
8800
  if (fs.existsSync(hooksDir)) {
6944
8801
  for (const f of fs.readdirSync(hooksDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
6945
- newManifest.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
8802
+ // record-if-current (sol part 10: "interactive update records uncopied
8803
+ // hook artifacts as current"): a manifest entry asserts the installed
8804
+ // artifact holds this base. Record the fresh hash ONLY when the Claude
8805
+ // hook file is actually on disk with matching content (the refresh block
8806
+ // above guarantees that when Claude is detected); otherwise keep the
8807
+ // prior base (carried forward below) so nothing uncopied is marked
8808
+ // current and the next update still sees it as stale.
8809
+ const dst = path.join(home, ".claude", "hooks", f);
8810
+ if (claudeDetected && fs.existsSync(dst) && fileContentHash(dst) === fileContentHash(path.join(hooksDir, f))) {
8811
+ newManifest.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
8812
+ }
6946
8813
  }
6947
8814
  }
6948
8815
  saveUpdateManifest(newManifest);
6949
8816
  writeMoverConfig(vaultPath, selectedIds, updateKey);
6950
8817
 
8818
+ // ── Journal handoff: mechanical stages complete; /update owns the rest ──
8819
+ // migrate (Engine migration + conflict merges) belongs to the /update
8820
+ // workflow; verify + stamp happen in `moveros update --finalize`, which the
8821
+ // workflow runs LAST. The journal is the machine-readable handoff, so the
8822
+ // CLI and the workflow are one state machine instead of a prose baton pass.
8823
+ journalStage(journal, "apply", writeFailed.length > 0 ? "failed" : "done", {
8824
+ updated: updated.length,
8825
+ autoMerged: autoMerged.length,
8826
+ userOnly: userOnly.length,
8827
+ conflicts: conflicts.length,
8828
+ writeFailed: writeFailed.length,
8829
+ });
8830
+ journalStage(journal, "migrate", "pending", { owner: "workflow", conflicts: conflicts.length });
8831
+
6951
8832
  // ── Summary ──
6952
8833
  barLn();
6953
8834
  if (updated.length > 0) statusLine("ok", "Updated", `${updated.length} files`);
@@ -6955,6 +8836,15 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6955
8836
  if (userOnly.length > 0) statusLine("ok", "Preserved", `${userOnly.length} customized files`);
6956
8837
  if (skippedFiles.length > 0) statusLine("ok", "Current", `${skippedFiles.length} files unchanged`);
6957
8838
 
8839
+ // sol flaw #3: a write that failed mid-update was silently reported as success.
8840
+ // Surface it and force a non-clean outcome so the user (and the next run) retries.
8841
+ if (writeFailed.length > 0) {
8842
+ statusLine("warn", "Write failed", `${writeFailed.length} file${writeFailed.length > 1 ? "s" : ""} could not be written (kept prior version)`);
8843
+ for (const f of writeFailed) barLn(` ${yellow("!")} ${f}`);
8844
+ barLn(dim(" Re-run moveros update once the cause is resolved (disk space / permissions)."));
8845
+ needsUpdate = true;
8846
+ }
8847
+
6958
8848
  if (conflicts.length > 0) {
6959
8849
  barLn();
6960
8850
  barLn(yellow(` ${conflicts.length} file${conflicts.length > 1 ? "s" : ""} need intelligent merge:`));
@@ -6982,10 +8872,12 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6982
8872
  barLn();
6983
8873
  }
6984
8874
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
6985
- if (needsUpdate) {
6986
- outro(`System updated. Run ${bold("/update")} to resolve ${conflicts.length} conflict${conflicts.length > 1 ? "s" : ""}. ${dim(`(${elapsed}s)`)}`);
8875
+ if (conflicts.length > 0) {
8876
+ outro(`System updated. Run ${bold("/update")} to resolve ${conflicts.length} conflict${conflicts.length > 1 ? "s" : ""} and finish (it verifies + stamps the version). ${dim(`(${elapsed}s)`)}`);
8877
+ } else if (writeFailed.length > 0) {
8878
+ outro(`${yellow("Update incomplete.")} ${writeFailed.length} file${writeFailed.length > 1 ? "s" : ""} could not be written. Re-run when resolved. ${dim(`(${elapsed}s)`)}`);
6987
8879
  } else {
6988
- outro(`${green("Done.")} All files up to date. ${dim(`(${elapsed}s)`)}`);
8880
+ outro(`${green("Done.")} Files applied. Run ${bold("/update")} to finish: Engine migration, then verify + version stamp. ${dim(`(${elapsed}s)`)}`);
6989
8881
  }
6990
8882
  }
6991
8883
 
@@ -7021,6 +8913,37 @@ function requireVault(explicitVault) {
7021
8913
  return v;
7022
8914
  }
7023
8915
 
8916
+ // THE install invariant (sol re-score part 1: "Marker checks still occur
8917
+ // command-by-command instead of through one invariant"). A real install =
8918
+ // a `.mover-version` that is a non-empty regular FILE — an empty file or a
8919
+ // directory by that name is not an install (sol flaw #5), and requireVault
8920
+ // trusts an explicit --vault without checking, so a typo'd path would
8921
+ // otherwise get artifacts minted into it (terra F1/F7). Every command that
8922
+ // MUTATES the vault or mints install artifacts goes through
8923
+ // requireInstalledVault; read-only diagnostics (doctor, context, status)
8924
+ // deliberately do not — they must be able to examine a broken install.
8925
+ function isInstalledVault(vaultPath) {
8926
+ if (!vaultPath) return false;
8927
+ try {
8928
+ const mp = path.join(vaultPath, ".mover-version");
8929
+ if (!fs.statSync(mp).isFile()) return false;
8930
+ return fs.readFileSync(mp, "utf8").trim().length > 0;
8931
+ } catch {
8932
+ return false;
8933
+ }
8934
+ }
8935
+
8936
+ function requireInstalledVault(explicitVault) {
8937
+ const v = requireVault(explicitVault);
8938
+ if (!v) return null;
8939
+ if (!isInstalledVault(v)) {
8940
+ statusLine("warn", "No install", `${v} has no Mover OS install (.mover-version missing or empty). Run moveros install first, or pass the correct --vault.`);
8941
+ barLn();
8942
+ return null;
8943
+ }
8944
+ return v;
8945
+ }
8946
+
7024
8947
  // ─── Main ───────────────────────────────────────────────────────────────────
7025
8948
  async function main() {
7026
8949
  const opts = parseArgs();
@@ -7044,13 +8967,37 @@ async function main() {
7044
8967
  opts.command = "install"; // no vault yet → first run goes straight to install
7045
8968
  }
7046
8969
 
8970
+ // `moveros pulse --bar` is polled by a menu-bar widget every few seconds — its
8971
+ // stdout must be ONLY the status line, so intercept it here, before the alt-
8972
+ // screen, logo, and update-notice chrome would pollute the output.
8973
+ if ((opts.command === "pulse" || opts.command === "dashboard") && (opts.rest || []).includes("--bar")) {
8974
+ await cmdPulseBar(opts);
8975
+ return;
8976
+ }
8977
+
8978
+ // `moveros inventory` is piped by the docs generator and CI — stdout must be
8979
+ // ONLY the JSON, so it too skips the alt-screen and logo chrome.
8980
+ if (opts.command === "inventory") {
8981
+ cmdInventory();
8982
+ return;
8983
+ }
8984
+
8985
+ // `moveros status --json` is a machine-readable state probe (scripts, other
8986
+ // agents). Its JSON must be the WHOLE stdout, so intercept it before the
8987
+ // alt-screen and logo chrome that otherwise print ahead of jsonOut (sol
8988
+ // re-score #6 part 1: --json still passed through header rendering).
8989
+ if (opts.command === "status" && opts.json) {
8990
+ await cmdStatus(opts);
8991
+ return;
8992
+ }
8993
+
7047
8994
  // ── TUI: Enter alternate screen ──
7048
8995
  enterAltScreen();
7049
8996
 
7050
8997
  // ── Intro: Logo plays once ──
7051
8998
  await printHeader();
7052
8999
 
7053
- const lightCommands = ["pulse", "dashboard", "open", "capture", "who", "diff", "sync", "replay", "context", "settings", "backup", "restore", "doctor", "prayer", "help", "uninstall", "test"];
9000
+ const lightCommands = ["pulse", "dashboard", "open", "shortcut", "menubar", "inventory", "capture", "who", "diff", "sync", "replay", "context", "status", "settings", "backup", "restore", "doctor", "prayer", "help", "uninstall", "test"];
7054
9001
 
7055
9002
  // ── `moveros menu` → classic interactive launcher (persistent loop) ──
7056
9003
  if (opts.command === "menu") {
@@ -7617,22 +9564,59 @@ async function main() {
7617
9564
 
7618
9565
  let totalSteps = 0;
7619
9566
 
7620
- // 0. Legacy cleanup (remove files from older installer versions)
9567
+ // 0. Legacy cleanup (retire files from older installer versions).
9568
+ // GUARDED, then made NON-DESTRUCTIVE (terra P0, two rounds 2026-07-11):
9569
+ // this used to rm -rf vault-root src/ unconditionally. A weak fingerprint
9570
+ // (any one hook filename / the prose "mover os") still destroyed a user's
9571
+ // src/ that merely held a session-start.sh, and a personal SOUL.md that
9572
+ // named the product. Two changes: (a) the signature is now STRONG and
9573
+ // Mover-unique, and (b) even a match is BACKED UP, never hard-deleted, so a
9574
+ // false positive can never lose data. The campaign DoD is byte-preservation
9575
+ // of unowned files; a backup rename satisfies it even under a misfire.
9576
+ const MOVER_UNIQUE_HOOKS = ["mover-lib.sh", "session-log-reminder.sh", "engine-protection.sh", "engine-auto-commit.sh"];
9577
+ const isLegacyMoverSrc = (dir) => {
9578
+ try {
9579
+ const hooksDir = path.join(dir, "hooks");
9580
+ if (!fs.existsSync(hooksDir) || !fs.statSync(hooksDir).isDirectory()) return false;
9581
+ const present = new Set(fs.readdirSync(hooksDir));
9582
+ // mover-lib.sh is a Mover-only filename; require it PLUS one more unique
9583
+ // Mover hook. A user coincidentally naming a file session-start.sh will
9584
+ // not also carry mover-lib.sh + engine-protection.sh.
9585
+ if (!present.has("mover-lib.sh")) return false;
9586
+ return MOVER_UNIQUE_HOOKS.filter((h) => present.has(h)).length >= 2;
9587
+ } catch { return false; }
9588
+ };
9589
+ const isLegacyMoverSoul = (file) => {
9590
+ // Match the OLD Mover SOUL template structure, not a prose mention. The
9591
+ // shipped legacy SOUL led with an agent-identity header and the "The
9592
+ // Architect" persona; a user writing "I tried Mover OS" prose has neither.
9593
+ try {
9594
+ const head = fs.readFileSync(file, "utf8").slice(0, 4000);
9595
+ return /^#\s*(SOUL|THE SOUL|Mover OS SOUL)\b/im.test(head) &&
9596
+ /(Architect|Agent Name|Global Rules|Identity_Prime)/.test(head);
9597
+ } catch { return false; }
9598
+ };
7621
9599
  const legacyPaths = [
7622
- path.join(vaultPath, "src"), // hooks used to install here
7623
- path.join(vaultPath, "SOUL.md"), // old soul file
9600
+ { p: path.join(vaultPath, "src"), owned: isLegacyMoverSrc }, // hooks used to install here
9601
+ { p: path.join(vaultPath, "SOUL.md"), owned: isLegacyMoverSoul }, // old soul file
7624
9602
  ];
7625
- for (const lp of legacyPaths) {
7626
- if (fs.existsSync(lp)) {
7627
- try {
7628
- const stat = fs.statSync(lp);
7629
- if (stat.isDirectory()) {
7630
- fs.rmSync(lp, { recursive: true, force: true });
7631
- } else {
7632
- fs.unlinkSync(lp);
7633
- }
7634
- } catch {}
7635
- }
9603
+ for (const { p: lp, owned } of legacyPaths) {
9604
+ if (!fs.existsSync(lp)) continue;
9605
+ try {
9606
+ if (!owned(lp)) {
9607
+ statusLine("warn", "Preserved", `${path.basename(lp)} exists but is not a Mover artifact — left untouched`);
9608
+ continue;
9609
+ }
9610
+ // Owned legacy artifact: back it up rather than delete. A stray false
9611
+ // positive is then fully recoverable; a real legacy dir is out of the way.
9612
+ const backup = `${lp}.pre-mover-backup`;
9613
+ if (!fs.existsSync(backup)) {
9614
+ fs.renameSync(lp, backup);
9615
+ statusLine("ok", "Backed up", `legacy ${path.basename(lp)} → ${path.basename(backup)}`);
9616
+ } else {
9617
+ statusLine("warn", "Left as-is", `${path.basename(lp)} (backup already exists)`);
9618
+ }
9619
+ } catch {}
7636
9620
  }
7637
9621
 
7638
9622
  // 1. Vault structure
@@ -7797,15 +9781,54 @@ async function main() {
7797
9781
 
7798
9782
  const agentNames = selectedAgents.map((a) => a.name);
7799
9783
 
9784
+ // `moveros` is only on PATH after a global npm install. An `npx mover-os`
9785
+ // user has no bin — telling them to "Run moveros" dead-ends (sol, 2026-07-10).
9786
+ // Detect the npx cache path and print the command that will actually work.
9787
+ const viaNpx = /[\\/]_npx[\\/]/.test(process.argv[1] || "") || /[\\/]_npx[\\/]/.test(process.env.npm_execpath || "");
9788
+ const cliCmd = viaNpx ? "npx mover-os" : "moveros";
9789
+
7800
9790
  // ── What you got ──
7801
9791
  ln(gray(" ─────────────────────────────────────────────"));
7802
9792
  ln();
7803
9793
  ln(` ${bold("What was installed")}`);
7804
9794
  ln();
7805
- ln(` ${green("▸")} ${bold("23")} workflows ${dim("slash commands for daily rhythm, projects, strategy")}`);
7806
- ln(` ${green("▸")} ${bold("62")} skills ${dim("curated packs for dev, marketing, CRO, design")}`);
7807
- if (selectedIds.includes("claude-code")) {
7808
- ln(` ${green("▸")} ${bold("18")} hooks ${dim("lifecycle guards (engine protection, git safety)")}`);
9795
+ // Counted from the bundle at print time, never hardcoded. Sol's product
9796
+ // review (2026-07-10) caught this banner claiming 23/62/18 while the
9797
+ // install delivered 25/70/20 — a buyer couldn't tell which product they
9798
+ // bought. Same filters as the installers: workflows = src/workflows/*.md,
9799
+ // skills = findSkills + the category gate installSkillPacks applies,
9800
+ // hooks = unique scripts in the generated Claude settings.
9801
+ // Terra verify (2026-07-10): a null selectedCategories can mean "skills
9802
+ // skipped entirely", not "all categories" — gate each line on whether that
9803
+ // artifact class was actually installed, or the banner overstates again.
9804
+ // Agents whose installers call installWorkflows/installWorkflowsConverted
9805
+ // (see installClaudeCode..installCopilot). Cline/amazon-q/etc get rules+skills only.
9806
+ const wfAgents = new Set(["claude-code", "cursor", "codex", "windsurf", "gemini-cli", "antigravity", "roo-code", "copilot"]);
9807
+ const anyWorkflowAgent = selectedIds.some((id) => wfAgents.has(id));
9808
+ let wfInstalled = 0;
9809
+ try {
9810
+ if (anyWorkflowAgent && skillOpts.workflows !== false) {
9811
+ wfInstalled = fs.readdirSync(path.join(bundleDir, "src", "workflows")).filter((n) => n.endsWith(".md")).length;
9812
+ }
9813
+ } catch {}
9814
+ let skInstalled = 0;
9815
+ try {
9816
+ if (skillOpts.install) {
9817
+ skInstalled = findSkills(bundleDir).filter(
9818
+ (s) => !selectedCategories || s.category === "tools" || selectedCategories.has(s.category)
9819
+ ).length;
9820
+ }
9821
+ } catch {}
9822
+ let hkInstalled = 0;
9823
+ try {
9824
+ const hookScripts = new Set();
9825
+ for (const m of generateClaudeSettings().matchAll(/hooks\/([a-z0-9-]+\.sh)/g)) hookScripts.add(m[1]);
9826
+ hkInstalled = hookScripts.size;
9827
+ } catch {}
9828
+ if (wfInstalled > 0) ln(` ${green("▸")} ${bold(String(wfInstalled))} workflows ${dim("slash commands for daily rhythm, projects, strategy")}`);
9829
+ if (skInstalled > 0) ln(` ${green("▸")} ${bold(String(skInstalled))} skills ${dim("curated packs for dev, marketing, CRO, design")}`);
9830
+ if (selectedIds.includes("claude-code") && hkInstalled > 0) {
9831
+ ln(` ${green("▸")} ${bold(String(hkInstalled))} hooks ${dim("lifecycle guards (engine protection, git safety)")}`);
7809
9832
  }
7810
9833
  ln(` ${green("▸")} ${bold(String(selectedAgents.length))} agent${selectedAgents.length > 1 ? "s" : ""} ${dim(agentNames.join(", "))}`);
7811
9834
  ln(` ${green("▸")} PARA vault ${dim("folders, templates, Engine scaffold")}`);
@@ -7819,7 +9842,7 @@ async function main() {
7819
9842
  await slideIn([
7820
9843
  ` ${bold("Next steps")}`,
7821
9844
  "",
7822
- ` ${cyan("1")} Run ${bold("moveros")}`,
9845
+ ` ${cyan("1")} Run ${bold(cliCmd)}`,
7823
9846
  ` ${dim("Opens your dashboard; setup starts there, a few plain questions")}`,
7824
9847
  "",
7825
9848
  ` ${cyan("2")} Open your vault in ${bold("Obsidian")} ${dim("(optional)")}`,
@@ -7843,12 +9866,12 @@ async function main() {
7843
9866
  ln();
7844
9867
  ln(` ${bold("CLI commands")} ${dim("(run from any terminal)")}`);
7845
9868
  ln();
7846
- ln(` ${green("▸")} ${bold("moveros")} ${dim("Open your dashboard")}`);
7847
- ln(` ${green("▸")} ${bold("moveros doctor")} ${dim("Health check across all agents")}`);
7848
- ln(` ${green("▸")} ${bold("moveros capture")} ${dim("Quick inbox — tasks, links, ideas")}`);
7849
- ln(` ${green("▸")} ${bold("moveros prayer")} ${dim("Set up prayer time reminders")}`);
7850
- ln(` ${green("▸")} ${bold("moveros update")} ${dim("Update agents, rules, and skills")}`);
7851
- ln(` ${green("▸")} ${bold("moveros menu")} ${dim("All commands (classic launcher)")}`);
9869
+ ln(` ${green("▸")} ${bold(cliCmd)} ${dim("Open your dashboard")}`);
9870
+ ln(` ${green("▸")} ${bold(cliCmd + " doctor")} ${dim("Health check across all agents")}`);
9871
+ ln(` ${green("▸")} ${bold(cliCmd + " capture")} ${dim("Quick inbox — tasks, links, ideas")}`);
9872
+ ln(` ${green("▸")} ${bold(cliCmd + " prayer")} ${dim("Set up prayer time reminders")}`);
9873
+ ln(` ${green("▸")} ${bold(cliCmd + " update")} ${dim("Update agents, rules, and skills")}`);
9874
+ ln(` ${green("▸")} ${bold(cliCmd + " menu")} ${dim("All commands (classic launcher)")}`);
7852
9875
  ln();
7853
9876
 
7854
9877
  // ── Land on the dashboard's onboarding route (the payoff) ──
@@ -7861,9 +9884,11 @@ async function main() {
7861
9884
  try {
7862
9885
  const dash = require("./src/dashboard");
7863
9886
  await dash.open({ vault: vaultPath, version: VERSION, rest: [], soft: false, route: "/?onboarding=1" });
7864
- ln(` ${dim("Opening onboarding in your browser. Next: run")} ${bold("/setup")} ${dim("in your AI agent.")}`);
9887
+ // Setup starts IN the browser that just opened. Pointing at /setup here
9888
+ // contradicted the dashboard-first funnel (sol, 2026-07-10).
9889
+ ln(` ${dim("Opening onboarding in your browser — setup starts there.")}`);
7865
9890
  } catch (err) {
7866
- ln(` ${dim("Your dashboard is ready — open it anytime with")} ${bold("moveros")}.`);
9891
+ ln(` ${dim("Your dashboard is ready — open it anytime with")} ${bold(cliCmd)}.`);
7867
9892
  }
7868
9893
  ln();
7869
9894
  }