mover-os 4.7.8 → 4.7.9

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 (42) hide show
  1. package/README.md +34 -24
  2. package/install.js +2197 -200
  3. package/package.json +1 -1
  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-ByVKPvLf.js +34 -0
  36. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  37. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  41. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  42. 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,334 @@ 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
+ function cmdInventory() {
5633
+ // Pure JSON on stdout — no banner, no color — so callers can pipe it.
5634
+ process.stdout.write(JSON.stringify(collectInventory(), null, 2) + "\n");
5635
+ }
5636
+
5637
+ // ─── moveros menubar ─────────────────────────────────────────────────────────
5638
+ // Installs the native macOS menu-bar companion (src/menubar/, prebuilt at
5639
+ // src/menubar/prebuilt/MoverBar.app — zero third-party deps, no SwiftBar, no
5640
+ // brew). Owner-approved 2026-07-11 (qa/campaign-2026-07/OWNER-DECISIONS.md
5641
+ // RULINGS LANDED): "cleanest install-on-accept", same pattern as the desktop
5642
+ // shortcut (src/dashboard/shortcut.js) — a prebuilt artifact copied into
5643
+ // place on accept, not compiled on the user's machine.
5644
+ //
5645
+ // macOS-only. The .app polls ~/.mover/menubar.json every 60s and shells out
5646
+ // to the exact cmd this function writes: [node, install.js, "pulse", "--bar"]
5647
+ // — the same one-honest-line contract cmdPulseBar already guarantees, so the
5648
+ // menu bar never has to fabricate a status.
5649
+ // Idempotent: re-running replaces the installed .app in place (rsync-style —
5650
+ // remove then copy fresh) and rewrites the config.
5651
+ async function cmdMenubar(opts) {
5652
+ if (process.platform !== "darwin") {
5653
+ barLn(dim(" moveros menubar is macOS-only (native AppKit menu-bar app)."));
5654
+ return;
5655
+ }
5656
+
5657
+ const remove = (opts.rest || []).includes("--remove");
5658
+ const appDest = path.join(os.homedir(), "Applications", "MoverBar.app");
5659
+ const configPath = path.join(os.homedir(), ".mover", "menubar.json");
5660
+
5661
+ if (remove) {
5662
+ try {
5663
+ const { execFileSync } = require("child_process");
5664
+ try { execFileSync("pkill", ["-f", "MoverBar"], { stdio: "ignore" }); } catch { /* not running — fine */ }
5665
+ } catch { /* best-effort — never block removal on this */ }
5666
+ try { if (fs.lstatSync(appDest)) fs.rmSync(appDest, { recursive: true, force: true }); statusLine("ok", "Removed", appDest); } catch { /* already absent */ }
5667
+ try { if (fs.lstatSync(configPath)) fs.rmSync(configPath, { force: true }); statusLine("ok", "Removed", configPath); } catch { /* already absent */ }
5668
+ barLn();
5669
+ return;
5670
+ }
5671
+
5672
+ // A MoverBar polling `moveros pulse --bar` is a dead icon without an
5673
+ // install — the one invariant (requireInstalledVault) is the guard.
5674
+ const vault = requireInstalledVault(opts.vault);
5675
+ if (!vault) return;
5676
+
5677
+ const prebuiltSrc = path.join(path.resolve(__dirname), "src", "menubar", "prebuilt", "MoverBar.app");
5678
+ if (!fs.existsSync(prebuiltSrc)) {
5679
+ statusLine("warn", "Missing prebuilt app", `${prebuiltSrc} not found — reinstall mover-os.`);
5680
+ barLn();
5681
+ return;
5682
+ }
5683
+
5684
+ try {
5685
+ fs.mkdirSync(path.join(os.homedir(), "Applications"), { recursive: true });
5686
+ fs.rmSync(appDest, { recursive: true, force: true }); // rsync-style replace
5687
+ copyDirRecursive(prebuiltSrc, appDest);
5688
+ statusLine("ok", "Installed", appDest);
5689
+ } catch (err) {
5690
+ statusLine("warn", "Install failed", err.message);
5691
+ barLn();
5692
+ return;
5693
+ }
5694
+
5695
+ try {
5696
+ fs.mkdirSync(path.join(os.homedir(), ".mover"), { recursive: true });
5697
+ fs.writeFileSync(
5698
+ configPath,
5699
+ JSON.stringify({ cmd: [process.execPath, __filename, "pulse", "--bar"] }, null, 2),
5700
+ "utf8"
5701
+ );
5702
+ statusLine("ok", "Wrote", configPath);
5703
+ } catch (err) {
5704
+ statusLine("warn", "Config write failed", err.message);
5705
+ barLn();
5706
+ return;
5707
+ }
5708
+
5709
+ try {
5710
+ const { spawn } = require("child_process");
5711
+ spawn("open", [appDest], { stdio: "ignore", detached: true }).unref();
5712
+ statusLine("ok", "Launched", "MoverBar");
5713
+ } catch (err) {
5714
+ statusLine("warn", "Launch failed", err.message);
5715
+ }
5716
+
5717
+ barLn();
5718
+ barLn(dim(" Look for the status line in your menu bar. It refreshes every 60s."));
5719
+ barLn();
5720
+ }
5721
+
5722
+ // One glanceable status line for a menu-bar widget. Plain text, no ANSI. Always
5723
+ // exits 0 with SOME line so a bar poller never renders an error. Shape:
5724
+ // "▸ <next action> · <done>/<total> today"
5725
+ // Falls back to the standing test, then a bare label, so the bar is never blank.
5726
+ async function cmdPulseBar(opts) {
5727
+ const emit = (s) => { process.stdout.write(String(s).replace(/\s+/g, " ").trim() + "\n"); };
5728
+ try {
5729
+ const vault = resolveVaultPath(opts.vault);
5730
+ if (!vault) { emit("Mover · not set up"); return; }
5731
+ const engineDir = path.join(vault, "02_Areas", "Engine");
5732
+
5733
+ let done = 0, total = 0, next = "";
5734
+ try {
5735
+ const dailyPath = resolveDailyNotePath(engineDir);
5736
+ if (dailyPath && fs.existsSync(dailyPath)) {
5737
+ const daily = fs.readFileSync(dailyPath, "utf8");
5738
+ const sec = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---|$)/i);
5739
+ if (sec) {
5740
+ const tasks = sec[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
5741
+ total = tasks.length;
5742
+ // Only [x] is complete; [~] is unverified/in-progress (global rule S5).
5743
+ done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
5744
+ const firstOpen = tasks.find((t) => /^\s*-\s*\[[ ~]\]/.test(t));
5745
+ if (firstOpen) next = firstOpen.replace(/^\s*-\s*\[[ x~]\]\s*/, "").replace(/\[[^\]]*\]/g, "").trim();
5746
+ }
5747
+ }
5748
+ } catch (_) { /* no daily / unreadable — fall through to the standing test */ }
5749
+
5750
+ // Owner ruling (2026-07-11): the TITLE is a glance-number, never a sentence
5751
+ // (the reference bar apps show "$12,345", not prose) — and the number is
5752
+ // the MISSION (the bet: "4/15"), not the day's chore count. Contract:
5753
+ // line 1 = compact title, line 2 = the full sentence for the click-menu.
5754
+ // A one-line consumer (scripts, old pollers) still gets a meaningful line.
5755
+ let st = "";
5756
+ try {
5757
+ const man = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".mover", "pattern-manifest.json"), "utf8"));
5758
+ st = String((man && man.single_test) || "").trim();
5759
+ } catch (_) { /* no manifest */ }
5760
+
5761
+ // The bet count comes from the SAME parser the dashboard hero uses —
5762
+ // never a second implementation that can drift from what Home shows.
5763
+ let bet = null;
5764
+ if (st) {
5765
+ try {
5766
+ const { parseGoalProgress } = require("./src/dashboard/lib/goal-forecast");
5767
+ const acRaw = fs.readFileSync(path.join(engineDir, "Active_Context.md"), "utf8");
5768
+ const g = parseGoalProgress({ singleTest: st, sourceText: acRaw, targetDate: null, now: new Date() });
5769
+ // false-zero discipline: no parseable target = no fabricated "0/0".
5770
+ if (g && g.target > 0) bet = { current: g.current || 0, target: g.target };
5771
+ } catch (_) { /* unparseable bet — fall through to the day count */ }
5772
+ }
5773
+
5774
+ const parts = [];
5775
+ if (next) parts.push("▸ " + next.slice(0, 42));
5776
+ if (total > 0) parts.push(`${done}/${total} today`);
5777
+ if (bet || parts.length) {
5778
+ emit(bet ? `▸ ${bet.current}/${bet.target}` : (total > 0 ? `▸ ${done}/${total}` : "▸ Mover"));
5779
+ emit(parts.length ? parts.join(" · ") : "▸ " + st.slice(0, 48));
5780
+ return;
5781
+ }
5782
+ emit("▸ Mover");
5783
+ emit(st ? "▸ " + st.slice(0, 48) : "Mover · no tasks today");
5784
+ } catch (_) {
5785
+ emit("Mover"); // never fail a bar poll
5786
+ }
5787
+ }
5788
+
4819
5789
  async function cmdPulseTerminal(opts) {
4820
5790
  const vault = resolveVaultPath(opts.vault);
4821
5791
  if (!vault) {
@@ -4860,7 +5830,9 @@ async function cmdPulseTerminal(opts) {
4860
5830
  const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---)/i);
4861
5831
  if (taskSection) {
4862
5832
  const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
4863
- const done = tasks.filter((t) => /\[x\]|\[~\]/.test(t)).length;
5833
+ // terra read F10: only [x] is COMPLETE. [~] is unverified/in-progress — the
5834
+ // whole point of the [~] state (global rule S5) — and must not count as done.
5835
+ const done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
4864
5836
  const total = tasks.length;
4865
5837
  barLn(` ${progressBar(done, total, 25, "Tasks")}`);
4866
5838
  }
@@ -4914,9 +5886,13 @@ async function cmdPulseTerminal(opts) {
4914
5886
  if (fs.existsSync(acLogPath)) {
4915
5887
  try {
4916
5888
  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();
5889
+ // terra read F5: `(\S+)` captured the closing `**` of a Markdown-wrapped key
5890
+ // (**log_last_run:** date), yielding "NaNh ago". Consume optional Markdown
5891
+ // around the key and capture a date-shaped token, then reject an invalid date.
5892
+ const logMatch = acContent.match(/\*{0,2}\s*log_last_run:\s*\*{0,2}\s*([0-9][0-9T:\-.Z+]*)/i);
5893
+ const ts = logMatch ? new Date(logMatch[1]).getTime() : NaN;
5894
+ if (logMatch && !Number.isNaN(ts)) {
5895
+ const agoMs = Date.now() - ts;
4920
5896
  const agoMin = Math.round(agoMs / 60000);
4921
5897
  const agoH = Math.round(agoMs / 3600000);
4922
5898
  const agoStr = agoMin < 60 ? `${agoMin}m ago` : `${agoH}h ago`;
@@ -4934,7 +5910,11 @@ async function cmdPulseTerminal(opts) {
4934
5910
 
4935
5911
  // ─── moveros capture ────────────────────────────────────────────────────────
4936
5912
  async function cmdCapture(opts) {
4937
- const vault = requireVault(opts.vault);
5913
+ // terra CLI F7: an explicit --vault is trusted without existsSync, so a
5914
+ // typo'd path would get a whole 00_Inbox tree minted there and the note
5915
+ // "lost" outside the real vault. The one invariant is the mutation gate.
5916
+ const vault = requireInstalledVault(opts.vault);
5917
+ if (!vault) return;
4938
5918
 
4939
5919
  const now = new Date();
4940
5920
  const ymd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
@@ -4996,11 +5976,34 @@ async function cmdCapture(opts) {
4996
5976
  else if (type === "idea") entry = `- **Idea:** ${content} *(${ts})*`;
4997
5977
  else entry = `- ${content} *(${ts})*`;
4998
5978
 
5979
+ // terra CLI F6: never follow a SYMLINKED capture file — appending through it
5980
+ // would write outside the vault inbox. A path is unsafe if it is a symlink, or
5981
+ // its real parent is not the real inbox. Fall back to a fresh file; if that is
5982
+ // also unsafe (or the same path), refuse rather than write outside the vault.
5983
+ const realInbox = (() => { try { return fs.realpathSync(inboxDir); } catch { return inboxDir; } })();
5984
+ const isUnsafe = (p) => {
5985
+ try {
5986
+ const st = fs.lstatSync(p, { throwIfNoEntry: false });
5987
+ if (!st) return false; // does not exist = safe to create
5988
+ if (st.isSymbolicLink()) return true; // never follow a link
5989
+ return path.dirname(fs.realpathSync(p)) !== realInbox;
5990
+ } catch { return false; }
5991
+ };
5992
+ let writeTarget = capturePath;
5993
+ if (isUnsafe(writeTarget)) {
5994
+ const fresh = path.join(inboxDir, `Capture - ${ymd}.md`);
5995
+ if (path.resolve(fresh) === path.resolve(writeTarget) || isUnsafe(fresh)) {
5996
+ barLn(red(" The inbox capture file is a symlink pointing outside the vault; refusing to write."));
5997
+ return;
5998
+ }
5999
+ writeTarget = fresh;
6000
+ }
6001
+
4999
6002
  // Append to capture file
5000
- if (!fs.existsSync(capturePath)) {
5001
- fs.writeFileSync(capturePath, `# Capture — ${ymd}\n\n${entry}\n`, "utf8");
6003
+ if (!fs.existsSync(writeTarget)) {
6004
+ fs.writeFileSync(writeTarget, `# Capture — ${ymd}\n\n${entry}\n`, "utf8");
5002
6005
  } else {
5003
- fs.appendFileSync(capturePath, `${entry}\n`, "utf8");
6006
+ fs.appendFileSync(writeTarget, `${entry}\n`, "utf8");
5004
6007
  }
5005
6008
 
5006
6009
  statusLine("ok", "Captured", `${type} → 00_Inbox/`);
@@ -5010,7 +6013,10 @@ async function cmdCapture(opts) {
5010
6013
 
5011
6014
  // ─── moveros who ────────────────────────────────────────────────────────────
5012
6015
  async function cmdWho(opts) {
5013
- const vault = requireVault(opts.vault);
6016
+ // Can mint entity stubs — mutating commands go through the one install
6017
+ // invariant (sol re-score #3: the invariant must cover EVERY mutating path).
6018
+ const vault = requireInstalledVault(opts.vault);
6019
+ if (!vault) return;
5014
6020
 
5015
6021
  const name = opts.rest.join(" ").trim();
5016
6022
  if (!name) { barLn(yellow("Usage: moveros who <name>")); return; }
@@ -5041,9 +6047,35 @@ async function cmdWho(opts) {
5041
6047
  if (create === "yes") {
5042
6048
  const peopleDir = path.join(entitiesDir, "People");
5043
6049
  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/`);
6050
+ // The raw name went straight into the write path, so "../../x" escaped
6051
+ // Entities entirely (terra-confirmed traversal). Strip separators and
6052
+ // dot segments, then verify the target lands PHYSICALLY inside People/
6053
+ // using realpath, since path.resolve is lexical and a symlinked People/
6054
+ // dir would otherwise let the stub escape (terra round 2).
6055
+ const safeName = name.replace(/[\/\\:*?"<>|\0]/g, "-").replace(/\.\.+/g, "-").trim();
6056
+ const stubPath = path.resolve(peopleDir, `${safeName}.md`);
6057
+ // Containment, three ways (terra P1, two rounds): lexical (blocks "../"
6058
+ // names), realpath-consistent (dirname resolves to People), AND the
6059
+ // resolved target must sit INSIDE the vault — so a People/ symlinked to
6060
+ // an outside dir can't smuggle the stub out of the vault entirely.
6061
+ let realParent, realPeople, realVault;
6062
+ try {
6063
+ realParent = fs.realpathSync(path.dirname(stubPath));
6064
+ realPeople = fs.realpathSync(peopleDir);
6065
+ realVault = fs.realpathSync(vault);
6066
+ } catch { realParent = null; realPeople = null; realVault = null; }
6067
+ const insideVault = realParent && realVault &&
6068
+ (realParent === realVault || realParent.startsWith(realVault + path.sep));
6069
+ if (!safeName ||
6070
+ !stubPath.startsWith(path.resolve(peopleDir) + path.sep) ||
6071
+ realParent !== realPeople ||
6072
+ !insideVault) {
6073
+ statusLine("warn", "Invalid location", "Entity stub would land outside the vault. Check the name and that Entities/People is inside your vault.");
6074
+ return;
6075
+ }
6076
+ 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`;
6077
+ fs.writeFileSync(stubPath, stub, "utf8");
6078
+ statusLine("ok", "Created", `${safeName}.md in Entities/People/`);
5047
6079
  }
5048
6080
  return;
5049
6081
  }
@@ -5065,9 +6097,12 @@ async function cmdWho(opts) {
5065
6097
  // ─── moveros diff ───────────────────────────────────────────────────────────
5066
6098
  async function cmdDiff(opts) {
5067
6099
  const vault = requireVault(opts.vault);
6100
+ if (!vault) return;
5068
6101
  if (!cmdExists("git")) { barLn(red("Git required for moveros diff.")); return; }
5069
6102
 
5070
- const target = opts.rest[0] || "strategy";
6103
+ // terra read F11: the target is the first NON-FLAG arg, so `diff --days=30 strategy`
6104
+ // (flag first) doesn't try to diff a file literally named "--days=30".
6105
+ const target = opts.rest.find((a) => !a.startsWith("--")) || "strategy";
5071
6106
  const days = parseInt(opts.rest.find((a) => a.startsWith("--days="))?.split("=")[1] || "30", 10);
5072
6107
 
5073
6108
  const fileMap = {
@@ -5091,8 +6126,11 @@ async function cmdDiff(opts) {
5091
6126
  const gitCwd = isEngineFile && fs.existsSync(path.join(engineDir, ".git"))
5092
6127
  ? engineDir
5093
6128
  : vault;
6129
+ // terra read F8: path.basename dropped subdirectories, so a nested Engine file
6130
+ // (02_Areas/Engine/Samples/Foo.md) was queried at the wrong git path and its
6131
+ // history read as empty. Use the path RELATIVE to the Engine repo root.
5094
6132
  const gitRelPath = isEngineFile && gitCwd === engineDir
5095
- ? path.basename(relPath)
6133
+ ? path.relative(engineDir, fullPath)
5096
6134
  : relPath;
5097
6135
 
5098
6136
  barLn(bold(` ${path.basename(relPath)} — last ${days} days`));
@@ -5125,7 +6163,10 @@ async function cmdDiff(opts) {
5125
6163
 
5126
6164
  // ─── moveros sync ───────────────────────────────────────────────────────────
5127
6165
  async function cmdSync(opts) {
5128
- const vault = requireVault(opts.vault);
6166
+ // --apply rewrites agent artifacts from the bundle — mutating, so the one
6167
+ // install invariant gates it (sol re-score #3).
6168
+ const vault = requireInstalledVault(opts.vault);
6169
+ if (!vault) return;
5129
6170
  const apply = opts.rest.includes("--apply");
5130
6171
  const home = os.homedir();
5131
6172
 
@@ -5231,13 +6272,22 @@ async function cmdSync(opts) {
5231
6272
  // ─── moveros replay ─────────────────────────────────────────────────────────
5232
6273
  async function cmdReplay(opts) {
5233
6274
  const vault = requireVault(opts.vault);
6275
+ if (!vault) return;
5234
6276
 
5235
6277
  const engineDir = path.join(vault, "02_Areas", "Engine");
5236
6278
  let dateStr = opts.rest.find((a) => a.startsWith("--date="))?.split("=")[1];
5237
6279
  let targetDate;
5238
6280
  if (dateStr) {
5239
- const [y, m, d] = dateStr.split("-").map(Number);
5240
- targetDate = new Date(y, m - 1, d);
6281
+ // terra read F7: `new Date(2026, 1, 31)` rolls over to Mar 3, so an impossible
6282
+ // date replayed a DIFFERENT day under a false label. Validate the round-trip
6283
+ // and reject a rollover.
6284
+ const m2 = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
6285
+ const [y, mo, d] = m2 ? m2.slice(1).map(Number) : [NaN, NaN, NaN];
6286
+ targetDate = m2 ? new Date(y, mo - 1, d) : new Date("invalid");
6287
+ if (!m2 || targetDate.getFullYear() !== y || targetDate.getMonth() !== mo - 1 || targetDate.getDate() !== d) {
6288
+ barLn(red(` Invalid date: ${dateStr}. Use a real YYYY-MM-DD.`));
6289
+ return;
6290
+ }
5241
6291
  } else {
5242
6292
  targetDate = getSessionDate();
5243
6293
  const y = targetDate.getFullYear();
@@ -5283,7 +6333,8 @@ async function cmdReplay(opts) {
5283
6333
  const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##)/i);
5284
6334
  if (taskSection) {
5285
6335
  const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
5286
- const done = tasks.filter((t) => /\[x\]|\[~\]/.test(t)).length;
6336
+ // terra read F10: only [x] is complete; [~] is unverified (global rule S5).
6337
+ const done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
5287
6338
  barLn(` ${progressBar(done, tasks.length, 25, "Completion")}`);
5288
6339
  barLn();
5289
6340
  }
@@ -5293,15 +6344,38 @@ async function cmdReplay(opts) {
5293
6344
  }
5294
6345
 
5295
6346
  // ─── moveros context ────────────────────────────────────────────────────────
6347
+ // terra read F4: `reg.hooks.events` does not exist on the registry, so context
6348
+ // always showed "0 events" / "? hook events". Parse the agent's actual hook
6349
+ // config and return the event names. null = not found / unreadable.
6350
+ function agentHookEvents(reg, vault, home) {
6351
+ try {
6352
+ let p = null;
6353
+ if (reg && reg.hooks && reg.hooks.dest) p = reg.hooks.dest(vault);
6354
+ else if (reg && reg.hooks && reg.hooks.type === "claude-settings") p = path.join(home, ".claude", "settings.json");
6355
+ if (!p || !fs.existsSync(p)) return null;
6356
+ const obj = JSON.parse(fs.readFileSync(p, "utf8"));
6357
+ // Hooks live at the top level (agent hooks.json) or under `.hooks` (settings.json).
6358
+ const h = (obj && obj.hooks && typeof obj.hooks === "object") ? obj.hooks : obj;
6359
+ if (!h || typeof h !== "object") return null;
6360
+ return Object.keys(h).filter((k) => /^[A-Z][A-Za-z]/.test(k) && (Array.isArray(h[k]) || (h[k] && typeof h[k] === "object")));
6361
+ } catch { return null; }
6362
+ }
6363
+
5296
6364
  async function cmdContext(opts) {
5297
6365
  const vault = requireVault(opts.vault);
6366
+ if (!vault) return;
5298
6367
 
5299
6368
  const target = opts.rest[0];
5300
6369
  const home = os.homedir();
5301
6370
  const cfgPath = path.join(home, ".mover", "config.json");
5302
- const agents = fs.existsSync(cfgPath)
5303
- ? (JSON.parse(fs.readFileSync(cfgPath, "utf8")).agents || [])
5304
- : [];
6371
+ // terra read F9: a malformed config.json crashed `moveros context` (an unguarded
6372
+ // JSON.parse) — exactly when config corruption most needs diagnosing. Degrade to
6373
+ // an empty agent list, mirroring doctor.
6374
+ let agents = [];
6375
+ if (fs.existsSync(cfgPath)) {
6376
+ try { agents = JSON.parse(fs.readFileSync(cfgPath, "utf8")).agents || []; }
6377
+ catch { barLn(yellow(" ~/.mover/config.json is not valid JSON — showing no agents.")); }
6378
+ }
5305
6379
 
5306
6380
  if (!target) {
5307
6381
  // Overview: show what each agent actually loads
@@ -5349,8 +6423,8 @@ async function cmdContext(opts) {
5349
6423
  }
5350
6424
  }
5351
6425
 
5352
- // Hooks
5353
- if (reg.hooks) parts.push(`${reg.hooks.events?.length || "?"} hook events`);
6426
+ // Hooks (terra read F4: parse the real config)
6427
+ if (reg.hooks) { const ev = agentHookEvents(reg, vault, home); parts.push(ev ? `${ev.length} hook events` : "no hooks"); }
5354
6428
 
5355
6429
  const tokens = Math.round(totalBytes / 4);
5356
6430
  const tokenWarn = tokens > 40000 ? red("heavy") : tokens > 20000 ? yellow("moderate") : green("lean");
@@ -5435,11 +6509,12 @@ async function cmdContext(opts) {
5435
6509
  }
5436
6510
  }
5437
6511
 
5438
- // Hooks
6512
+ // Hooks (terra read F4: parse the real config, not the nonexistent reg.hooks.events)
5439
6513
  if (reg.hooks) {
5440
6514
  barLn();
5441
- statusLine("ok", "Hooks", `${reg.hooks.events?.length || 0} events`);
5442
- if (reg.hooks.events) barLn(dim(` ${reg.hooks.events.join(", ")}`));
6515
+ const ev = agentHookEvents(reg, vault, home);
6516
+ statusLine(ev && ev.length ? "ok" : "warn", "Hooks", ev ? `${ev.length} events` : "not configured");
6517
+ if (ev && ev.length) barLn(dim(` ${ev.join(", ")}`));
5443
6518
  }
5444
6519
 
5445
6520
  // Token budget summary
@@ -5747,7 +6822,10 @@ async function cmdSettings(opts) {
5747
6822
 
5748
6823
  // ─── moveros backup ─────────────────────────────────────────────────────────
5749
6824
  async function cmdBackup(opts) {
5750
- const vault = requireVault(opts.vault);
6825
+ // Writes archives derived from the vault; backing up a non-install is a
6826
+ // meaningless artifact from a possibly wrong --vault (sol re-score #3).
6827
+ const vault = requireInstalledVault(opts.vault);
6828
+ if (!vault) return;
5751
6829
  const home = os.homedir();
5752
6830
 
5753
6831
  barLn(bold(" Backup"));
@@ -5763,8 +6841,14 @@ async function cmdBackup(opts) {
5763
6841
  if (!choices || choices.length === 0) { barLn(dim(" Cancelled.")); return; }
5764
6842
 
5765
6843
  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")}`;
6844
+ // terra CLI F5: minute precision meant two backups in the same minute reused
6845
+ // one dir and the second SILENTLY OVERWROTE the first snapshot (losing the only
6846
+ // good restore point). Add seconds + a random suffix so every backup is a
6847
+ // distinct dir, and fail closed if it somehow already exists.
6848
+ const secs = String(now.getSeconds()).padStart(2, "0");
6849
+ 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
6850
  const backupDir = path.join(vault, "04_Archives", `Backup_${ts}`);
6851
+ if (fs.existsSync(backupDir)) { outro(red("Backup directory collision; try again.")); return; }
5768
6852
  fs.mkdirSync(backupDir, { recursive: true });
5769
6853
 
5770
6854
  const manifest = { timestamp: now.toISOString(), type: "manual", trigger: "moveros backup", categories: choices, files: {} };
@@ -5842,9 +6926,46 @@ async function cmdBackup(opts) {
5842
6926
  barLn();
5843
6927
  }
5844
6928
 
6929
+ // terra CLI F2: a restore that copies files in a loop with no rollback left
6930
+ // earlier files overwritten if a later copy failed (corrupt mixed state). Snapshot
6931
+ // each existing destination, copy all, and on ANY failure roll every destination
6932
+ // back to its snapshot (or remove a file we created). Returns the count restored.
6933
+ function restoreFilesTransactional(pairs) {
6934
+ const snaps = [];
6935
+ try {
6936
+ for (const { src, dest } of pairs) {
6937
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
6938
+ if (fs.existsSync(dest)) {
6939
+ const bak = `${dest}.restore-bak`;
6940
+ fs.copyFileSync(dest, bak);
6941
+ snaps.push({ dest, bak, had: true });
6942
+ } else {
6943
+ snaps.push({ dest, bak: null, had: false });
6944
+ }
6945
+ fs.copyFileSync(src, dest);
6946
+ }
6947
+ for (const s of snaps) if (s.bak) { try { fs.unlinkSync(s.bak); } catch {} }
6948
+ return pairs.length;
6949
+ } catch (e) {
6950
+ for (const s of snaps) {
6951
+ try {
6952
+ if (s.had && s.bak) fs.copyFileSync(s.bak, s.dest);
6953
+ else if (!s.had) { try { fs.unlinkSync(s.dest); } catch {} }
6954
+ } catch {}
6955
+ if (s.bak) { try { fs.unlinkSync(s.bak); } catch {} }
6956
+ }
6957
+ throw e;
6958
+ }
6959
+ }
6960
+
5845
6961
  // ─── moveros restore ────────────────────────────────────────────────────────
5846
6962
  async function cmdRestore(opts) {
5847
- const vault = requireVault(opts.vault);
6963
+ // Writes INTO the vault a typo'd --vault would dump restored files into
6964
+ // an unrelated directory, so the one install invariant gates it (sol
6965
+ // re-score #3). Recovery of a vault whose marker itself was lost goes
6966
+ // through moveros install first, which recreates the marker.
6967
+ const vault = requireInstalledVault(opts.vault);
6968
+ if (!vault) return;
5848
6969
 
5849
6970
  const archivesDir = path.join(vault, "04_Archives");
5850
6971
  if (!fs.existsSync(archivesDir)) { barLn(yellow(" No archives found.")); return; }
@@ -5876,8 +6997,21 @@ async function cmdRestore(opts) {
5876
6997
  return { id: b, name: b, tier: detail || dim("legacy backup") };
5877
6998
  });
5878
6999
 
5879
- const selected = await interactiveSelect(items, { multi: false });
5880
- if (!selected) { barLn(dim(" Cancelled.")); return; }
7000
+ // terra CLI F1: a non-TTY restore must NOT silently pick the newest backup and
7001
+ // overwrite live (possibly newer) files. Require an explicit backup name AND
7002
+ // --confirm; otherwise refuse and show how.
7003
+ let selected;
7004
+ if (!IS_TTY) {
7005
+ const explicit = opts.rest.find((a) => backups.includes(a));
7006
+ if (!explicit || !opts.rest.includes("--confirm")) {
7007
+ outro(red(`Non-interactive restore needs an explicit backup and --confirm:\n moveros restore <${backups[0] || "Backup_..."}> --confirm`));
7008
+ return;
7009
+ }
7010
+ selected = explicit;
7011
+ } else {
7012
+ selected = await interactiveSelect(items, { multi: false });
7013
+ if (!selected) { barLn(dim(" Cancelled.")); return; }
7014
+ }
5881
7015
 
5882
7016
  const backupPath = path.join(archivesDir, selected);
5883
7017
 
@@ -5920,28 +7054,42 @@ async function cmdRestore(opts) {
5920
7054
  statusLine("ok", "Agents", `${agentCount} items restored`);
5921
7055
  totalRestored = agentCount;
5922
7056
  } else {
5923
- // Engine backup (Engine_Backup_ or legacy Backup_)
7057
+ // A Backup_* dir can hold engine/ (Engine backup), areas/ (Full Areas backup,
7058
+ // terra CLI F3 — the old code knew only engine/ or loose .md and silently
7059
+ // restored NOTHING for an areas/ backup), or loose .md (legacy). Build the
7060
+ // src->dest pairs, then restore transactionally (F2).
7061
+ const engineDir = path.join(vault, "02_Areas", "Engine");
7062
+ const areasSrc = path.join(backupPath, "areas");
5924
7063
  const engPath = path.join(backupPath, "engine");
5925
- if (fs.existsSync(engPath)) {
5926
- const engineDir = path.join(vault, "02_Areas", "Engine");
5927
- let restored = 0;
7064
+ const pairs = [];
7065
+ let label = "Engine";
7066
+ if (fs.existsSync(areasSrc)) {
7067
+ const areasTarget = path.join(vault, "02_Areas");
7068
+ const walk = (dir, rel) => {
7069
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
7070
+ if (e.isDirectory()) walk(path.join(dir, e.name), path.join(rel, e.name));
7071
+ else pairs.push({ src: path.join(dir, e.name), dest: path.join(areasTarget, rel, e.name) });
7072
+ }
7073
+ };
7074
+ walk(areasSrc, "");
7075
+ label = "Areas";
7076
+ } else if (fs.existsSync(engPath)) {
5928
7077
  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++;
7078
+ pairs.push({ src: path.join(engPath, f), dest: path.join(engineDir, f) });
5931
7079
  }
5932
- statusLine("ok", "Engine", `${restored} files restored`);
5933
- totalRestored = restored;
5934
7080
  } else {
5935
- // Legacy backup format (files directly in backup dir)
5936
- const engineDir = path.join(vault, "02_Areas", "Engine");
5937
- let restored = 0;
5938
7081
  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++;
7082
+ pairs.push({ src: path.join(backupPath, f), dest: path.join(engineDir, f) });
5941
7083
  }
5942
- if (restored > 0) statusLine("ok", "Engine", `${restored} files restored`);
5943
- totalRestored = restored;
5944
7084
  }
7085
+ try {
7086
+ totalRestored = restoreFilesTransactional(pairs);
7087
+ } catch (e) {
7088
+ outro(red(`Restore failed and was rolled back (no partial state): ${e && e.message}`));
7089
+ return;
7090
+ }
7091
+ if (totalRestored > 0) statusLine("ok", label, `${totalRestored} files restored`);
7092
+ else { barLn(yellow(" This backup contained no restorable files.")); return; }
5945
7093
  }
5946
7094
 
5947
7095
  barLn();
@@ -5980,6 +7128,9 @@ async function cmdHelp(opts) {
5980
7128
  w("\n");
5981
7129
  };
5982
7130
 
7131
+ // Counts computed, never typed: this screen shipped "23 commands / 61
7132
+ // packs / 16 agents" while the bundle held 25/70/15.
7133
+ const inv = collectInventory();
5983
7134
  const pages = [
5984
7135
  {
5985
7136
  title: "What is Mover OS?",
@@ -5994,10 +7145,10 @@ async function cmdHelp(opts) {
5994
7145
  "",
5995
7146
  `${dim("Three things make it work:")}`,
5996
7147
  ` ${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`,
7148
+ ` ${cyan("2.")} ${bold("Workflows")} — ${inv.workflows.length} commands that run your day (plan, build, log, repeat)`,
7149
+ ` ${cyan("3.")} ${bold("Skills")} — ${inv.skills.length} packs that make your AI genuinely useful`,
5999
7150
  "",
6000
- `Works across 16 agents. Claude Code, Cursor, Gemini, all of them.`,
7151
+ `Works across ${inv.agents.length} agents. Claude Code, Cursor, Gemini, all of them.`,
6001
7152
  `Same context, every editor. ${dim("Press → to continue.")}`,
6002
7153
  ],
6003
7154
  },
@@ -6374,8 +7525,9 @@ async function cmdMainMenu() {
6374
7525
 
6375
7526
  // Context filter: hide irrelevant commands
6376
7527
  const contextFilter = (cmd) => {
6377
- if (!hasVault && ["pulse", "open", "replay", "diff", "sync", "capture", "who", "context", "backup", "restore", "doctor"].includes(cmd)) return false;
7528
+ if (!hasVault && ["pulse", "open", "shortcut", "menubar", "replay", "diff", "sync", "capture", "who", "context", "backup", "restore", "doctor"].includes(cmd)) return false;
6378
7529
  if (!hasPrayer && cmd === "prayer") return false;
7530
+ if (cmd === "menubar" && process.platform !== "darwin") return false;
6379
7531
  return true;
6380
7532
  };
6381
7533
 
@@ -6431,7 +7583,430 @@ async function cmdMainMenu() {
6431
7583
  }
6432
7584
 
6433
7585
  // ─── Comprehensive Update (10-step interactive) ─────────────────────────────
7586
+ // ── Update machine subcommands (local state ops: no license, no network) ────
7587
+
7588
+ // Resolve the bundle source to verify against: the journal's staged bundleDir
7589
+ // first (verify must check the SAME source the apply stage used), then this
7590
+ // CLI's own bundle, then a downloaded payload at ~/.mover/src.
7591
+ function resolveVerifyBundleDir(journal) {
7592
+ const candidates = [
7593
+ journal && journal.stages && journal.stages.stage && journal.stages.stage.bundleDir,
7594
+ __dirname,
7595
+ path.join(os.homedir(), ".mover"),
7596
+ ].filter(Boolean);
7597
+ for (const c of candidates) {
7598
+ if (fs.existsSync(path.join(c, "src", "workflows"))) return c;
7599
+ }
7600
+ return null;
7601
+ }
7602
+
7603
+ // Engine migrations DECLARED per release (src/system/engine-migrations.json in
7604
+ // the bundle). Finalize cross-checks the migration receipt against every
7605
+ // declaration in (fromVersion, toVersion]: an empty migrated list is only
7606
+ // valid when that window truly declares nothing (sol re-score #4 part 10:
7607
+ // "an empty migration list proves process completion rather than semantic
7608
+ // completeness"). With no journal, fromVersion is unknown and only the target
7609
+ // version's own declarations bind. MOVER_TEST_MIGRATIONS_FILE injects a
7610
+ // manifest for hermetic tests, same pattern as MOVER_TEST_CRASH_STAGE.
7611
+ function loadOwedEngineMigrations(journal, toVersion) {
7612
+ const candidates = [];
7613
+ if (process.env.MOVER_TEST_MIGRATIONS_FILE) candidates.push(process.env.MOVER_TEST_MIGRATIONS_FILE);
7614
+ const bundleDir = resolveVerifyBundleDir(journal);
7615
+ if (bundleDir) candidates.push(path.join(bundleDir, "src", "system", "engine-migrations.json"));
7616
+ candidates.push(path.join(__dirname, "src", "system", "engine-migrations.json"));
7617
+ for (const c of candidates) {
7618
+ let decl;
7619
+ try { decl = JSON.parse(fs.readFileSync(c, "utf8")).declarations; } catch { continue; }
7620
+ if (!decl || typeof decl !== "object" || Array.isArray(decl)) continue;
7621
+ const fromV = journal && journal.fromVersion ? String(journal.fromVersion).replace(/^[vV]/, "") : null;
7622
+ const owed = [];
7623
+ for (const [ver, entries] of Object.entries(decl)) {
7624
+ const inWindow = fromV
7625
+ ? compareVersions(ver, fromV) > 0 && compareVersions(ver, toVersion) <= 0
7626
+ : compareVersions(ver, toVersion) === 0;
7627
+ if (!inWindow || !Array.isArray(entries)) continue;
7628
+ for (const e of entries) {
7629
+ if (e && typeof e.path === "string" && e.path) owed.push({ version: ver, path: e.path, note: e.note });
7630
+ }
7631
+ }
7632
+ return { owed, source: c };
7633
+ }
7634
+ // No manifest anywhere (an older staged payload AND a stripped CLI copy):
7635
+ // the caller records this honestly in the journal instead of passing it
7636
+ // off as a checked window.
7637
+ return { owed: [], source: null };
7638
+ }
7639
+
7640
+ // `moveros update --status`: print the journal (the machine-readable handoff
7641
+ // the /update workflow reads). Honest null when no update is in flight.
7642
+ function cmdUpdateStatus() {
7643
+ const journal = loadUpdateJournal();
7644
+ if (!journal) {
7645
+ barLn(dim(" No update journal found. Nothing in progress."));
7646
+ outro("No update in progress.");
7647
+ return;
7648
+ }
7649
+ barLn(bold(" Update journal"));
7650
+ barLn();
7651
+ barLn(` Run: ${journal.runId} ${dim(`(${journal.mode})`)}`);
7652
+ barLn(` ${dim(journal.fromVersion || "?")} ${dim("->")} ${bold(journal.toVersion || "?")} ${journal.completedAt ? green(journal.rolledBack ? "rolled back" : "completed") : yellow("in progress")}`);
7653
+ barLn();
7654
+ for (const s of UPDATE_STAGES) {
7655
+ const st = (journal.stages && journal.stages[s]) || { status: "pending" };
7656
+ const icon = st.status === "done" ? "ok" : st.status === "failed" ? "fail" : "warn";
7657
+ statusLine(icon, ` ${s}`, st.status);
7658
+ }
7659
+ barLn();
7660
+ barLn(dim(` Journal: ${updateJournalPath()}`));
7661
+ outro(journal.completedAt ? "Update complete." : "Update in progress. Finish with /update, then moveros update --finalize.");
7662
+ }
7663
+
7664
+ // `moveros update --finalize`: the /update workflow calls this after Engine
7665
+ // migration. It records the migrate stage, VERIFIES the applied files against
7666
+ // the staged bundle, and stamps .mover-version ONLY on a pass. This is the
7667
+ // single place the interactive path is allowed to stamp, so the stamp always
7668
+ // means "verified", never "assumed".
7669
+ async function cmdUpdateFinalize(opts) {
7670
+ const journal = loadUpdateJournal();
7671
+ let vaultPath = journal && journal.vaultPath && fs.existsSync(journal.vaultPath) ? journal.vaultPath : resolveVaultPath(opts.vault);
7672
+ if (!vaultPath) {
7673
+ outro(red("No Mover OS vault found. Use: moveros update --finalize --vault /path"));
7674
+ process.exit(EXIT.NOT_FOUND);
7675
+ }
7676
+ let markerOk = false;
7677
+ try {
7678
+ const mp = path.join(vaultPath, ".mover-version");
7679
+ markerOk = fs.statSync(mp).isFile() && fs.readFileSync(mp, "utf8").trim().length > 0;
7680
+ } catch {}
7681
+ if (!markerOk) {
7682
+ outro(red(`No Mover OS install found at ${vaultPath}. Nothing to finalize.`));
7683
+ process.exit(EXIT.NOT_FOUND);
7684
+ }
7685
+
7686
+ const j = journal || newUpdateJournal("finalize", null, require("./package.json").version, vaultPath);
7687
+
7688
+ // MIGRATION RECEIPT (sol re-score #3 refutation: finalize marked migrate
7689
+ // done without verifying any Engine migration happened). The /update
7690
+ // workflow writes ~/.mover/update-receipt.json AFTER its Engine steps:
7691
+ // { runId, toVersion, migrated: [{path, sha256}], checked: [paths], note }
7692
+ // runId comes from the journal (~/.mover/update-state.json); null when no
7693
+ // journal existed. migrated may be EMPTY (no migration owed this release)
7694
+ // but the receipt itself is mandatory, and every migrated file must still
7695
+ // be on disk with the recorded post-migration hash - proof the work
7696
+ // happened and survived. Fail-closed: no receipt, no stamp.
7697
+ const receiptPath = path.join(os.homedir(), ".mover", "update-receipt.json");
7698
+ let receipt = null;
7699
+ try { receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); } catch {}
7700
+ const expectVersion = (j.toVersion || require("./package.json").version).replace(/^V/i, "");
7701
+ const receiptProblems = [];
7702
+ const owedMigrations = loadOwedEngineMigrations(journal, expectVersion);
7703
+ if (!receipt) {
7704
+ receiptProblems.push(`missing or unreadable: ${receiptPath}`);
7705
+ } else {
7706
+ const expectedRunId = journal ? journal.runId : null;
7707
+ if ((receipt.runId || null) !== expectedRunId) receiptProblems.push(`runId ${JSON.stringify(receipt.runId || null)} does not match this run ${JSON.stringify(expectedRunId)}`);
7708
+ if (String(receipt.toVersion || "").replace(/^V/i, "") !== expectVersion) receiptProblems.push(`toVersion ${JSON.stringify(receipt.toVersion)} does not match ${expectVersion}`);
7709
+ // Receipt paths must stay INSIDE the vault. Lexical checks (absolute,
7710
+ // ..-traversal) are not enough: a symlink WITHIN the vault pointing
7711
+ // outside would pass lexically but resolve elsewhere (sol re-score #5
7712
+ // refutation). resolveContained realpath-resolves both the vault root and
7713
+ // the entry and requires the entry to remain beneath the resolved root,
7714
+ // so a symlink escape is rejected. Returns the real absolute path or null.
7715
+ let realVaultRoot = null;
7716
+ try { realVaultRoot = fs.realpathSync(vaultPath); } catch { realVaultRoot = path.resolve(vaultPath); }
7717
+ const resolveContained = (rel) => {
7718
+ if (typeof rel !== "string" || rel.length === 0) return { err: "path is empty" };
7719
+ if (path.isAbsolute(rel) || rel.split(/[\\/]+/).includes("..")) return { err: "must be vault-relative without traversal" };
7720
+ const joined = path.join(vaultPath, rel);
7721
+ let real;
7722
+ try { real = fs.realpathSync(joined); } catch (e) { return { err: e.code === "ENOENT" ? "does not exist in the vault" : "not resolvable on disk" }; }
7723
+ const prefix = realVaultRoot.endsWith(path.sep) ? realVaultRoot : realVaultRoot + path.sep;
7724
+ if (real !== realVaultRoot && !real.startsWith(prefix)) return { err: "resolves outside the vault (symlink escape)" };
7725
+ return { abs: real };
7726
+ };
7727
+ if (!Array.isArray(receipt.migrated)) {
7728
+ receiptProblems.push("migrated must be an array ([] is valid when no migration was owed)");
7729
+ } else {
7730
+ for (const m of receipt.migrated) {
7731
+ const rel = m && m.path;
7732
+ if (typeof rel !== "string" || !rel) { receiptProblems.push("a migrated entry has no path"); continue; }
7733
+ const c = resolveContained(rel);
7734
+ if (c.err) { receiptProblems.push(`${rel}: migrated path ${c.err}`); continue; }
7735
+ try {
7736
+ const actual = require("crypto").createHash("sha256").update(fs.readFileSync(c.abs, "utf8").replace(/\r\n/g, "\n")).digest("hex");
7737
+ if (actual !== m.sha256) receiptProblems.push(`${rel}: on-disk hash does not match the receipt (file changed or migration reverted)`);
7738
+ } catch {
7739
+ receiptProblems.push(`${rel}: listed as migrated but not readable on disk`);
7740
+ }
7741
+ }
7742
+ // SEMANTIC COMPLETENESS: the receipt must cover every Engine migration
7743
+ // this release window DECLARES (src/system/engine-migrations.json). An
7744
+ // empty migrated list stops meaning "the process ran" and starts
7745
+ // meaning "this window declares nothing owed". FAIL-CLOSED when the
7746
+ // manifest cannot be found (sol re-score #5 refutation: recording
7747
+ // "unavailable" and proceeding with zero owed migrations let a stripped
7748
+ // bundle skip the check silently; a missing declaration source is a
7749
+ // refusal, not a pass).
7750
+ if (!owedMigrations.source) {
7751
+ receiptProblems.push("engine-migrations.json is unavailable in every bundle source: cannot prove the release owes no migration (fail-closed)");
7752
+ }
7753
+ const migratedPaths = new Set(receipt.migrated.map((m) => m && m.path).filter(Boolean));
7754
+ for (const d of owedMigrations.owed) {
7755
+ if (!migratedPaths.has(d.path)) {
7756
+ receiptProblems.push(`${d.path}: declared as owed by release ${d.version}${d.note ? ` (${d.note})` : ""} but absent from the receipt's migrated list`);
7757
+ }
7758
+ }
7759
+ // `checked` is the workflow's claim of what it INSPECTED - the basis for
7760
+ // "no (further) migration owed". The inspection itself cannot be
7761
+ // re-verified, but the claim can: every named file must actually exist
7762
+ // in the vault, and an empty-migration receipt must name at least one
7763
+ // inspected file (sol re-score #4: the field was counted in the journal
7764
+ // but never validated, so a fabricated receipt passed the gate).
7765
+ if (!Array.isArray(receipt.checked)) {
7766
+ receiptProblems.push("checked must be an array of vault-relative paths");
7767
+ } else {
7768
+ if (receipt.migrated.length === 0 && receipt.checked.length === 0) {
7769
+ receiptProblems.push("migrated is empty and checked is empty: name the files you inspected to conclude no migration was owed");
7770
+ }
7771
+ for (const rel of receipt.checked) {
7772
+ const c = resolveContained(rel);
7773
+ if (c.err) { receiptProblems.push(`${JSON.stringify(rel)}: checked path ${c.err}`); continue; }
7774
+ try {
7775
+ if (!fs.statSync(c.abs).isFile()) receiptProblems.push(`${rel}: listed as checked but is not a file`);
7776
+ } catch {
7777
+ receiptProblems.push(`${rel}: listed as checked but does not exist in the vault`);
7778
+ }
7779
+ }
7780
+ }
7781
+ }
7782
+ }
7783
+ if (receiptProblems.length) {
7784
+ journalStage(j, "migrate", "failed", { problems: receiptProblems.slice(0, 20) });
7785
+ barLn(red(" Migration receipt check failed:"));
7786
+ for (const p of receiptProblems.slice(0, 8)) barLn(` ${red("x")} ${p}`);
7787
+ barLn();
7788
+ barLn(dim(" /update must write ~/.mover/update-receipt.json after its Engine"));
7789
+ barLn(dim(" migration steps: { runId (from ~/.mover/update-state.json, or null),"));
7790
+ barLn(dim(" toVersion, migrated: [{path, sha256}], checked: [paths] }."));
7791
+ barLn(dim(" An empty migrated array is valid when no migration was owed,"));
7792
+ barLn(dim(" but then checked must name the real vault files you inspected."));
7793
+ outro(red("Not stamped: no verifiable migration receipt."));
7794
+ process.exit(EXIT.ERROR);
7795
+ }
7796
+ // MACHINE-DERIVED inspection evidence (sol re-score #6 part 10: "checked
7797
+ // remains a workflow-authored inspection claim; make inspection evidence
7798
+ // machine-derived"). Rather than trusting only the workflow's `checked`
7799
+ // list, finalize independently OBSERVES the Engine directory: it lists the
7800
+ // top-level Engine files (skipping Dailies and other subfolders) with a
7801
+ // content hash each, and confirms every `checked` entry that lives under
7802
+ // Engine is corroborated by that observation. The observed set is recorded
7803
+ // in the journal, so the audit trail carries machine-witnessed evidence of
7804
+ // the Engine state at stamp time, not a self-reported claim.
7805
+ const engineObserved = (() => {
7806
+ const engineDir = path.join(vaultPath, "02_Areas", "Engine");
7807
+ // Origin classification (sol #8 part 10 residual: "checked records
7808
+ // workflow-authored inspection evidence that cannot be reconstructed
7809
+ // mechanically"). The shipped-default fingerprint set (every historical
7810
+ // hash of each non-sentinel Engine default) lets the MACHINE reconstruct
7811
+ // the inspection's basis: a file either matches a shipped default
7812
+ // byte-for-byte (nothing to migrate), or is user-modified (migration
7813
+ // decisions concern it), or is outside the shipped set entirely
7814
+ // (sentinels + user-created files). Recorded per file in the journal, so
7815
+ // the audit trail carries the machine's own classification, not only the
7816
+ // workflow's word.
7817
+ let fingerprints = null;
7818
+ try { fingerprints = JSON.parse(fs.readFileSync(path.join(__dirname, "src", "dashboard", "lib", "engine-default-fingerprints.json"), "utf8")).files; } catch {}
7819
+ const fpHash = (text) => require("crypto").createHash("sha256").update(String(text).replace(/\r\n/g, "\n").trim()).digest("hex");
7820
+ try {
7821
+ const files = fs.readdirSync(engineDir, { withFileTypes: true })
7822
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
7823
+ .map((e) => {
7824
+ const rel = path.join("02_Areas", "Engine", e.name);
7825
+ let sha = null;
7826
+ let origin = "unclassified";
7827
+ try {
7828
+ const text = fs.readFileSync(path.join(engineDir, e.name), "utf8");
7829
+ sha = require("crypto").createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex").slice(0, 16);
7830
+ if (fingerprints) {
7831
+ const known = fingerprints[e.name];
7832
+ origin = Array.isArray(known)
7833
+ ? (known.includes(fpHash(text)) ? "matches-shipped-default" : "user-modified")
7834
+ : "not-a-shipped-default"; // sentinels + user-created files
7835
+ }
7836
+ } catch {}
7837
+ return { path: rel, sha, origin };
7838
+ });
7839
+ return { present: true, count: files.length, files, fingerprintSource: fingerprints ? "engine-default-fingerprints.json" : "unavailable" };
7840
+ } catch {
7841
+ return { present: false, count: 0, files: [], fingerprintSource: fingerprints ? "engine-default-fingerprints.json" : "unavailable" };
7842
+ }
7843
+ })();
7844
+ // Corroborate: any `checked` entry that claims an Engine file must appear in
7845
+ // the machine-observed set. A claim about an Engine file the machine cannot
7846
+ // see is a fabricated inspection, caught here rather than trusted.
7847
+ const observedEngine = new Set(engineObserved.files.map((f) => f.path.split(path.sep).join("/")));
7848
+ const uncorroborated = receipt.checked
7849
+ .map((r) => String(r).split(path.sep).join("/"))
7850
+ .filter((r) => r.startsWith("02_Areas/Engine/") && !r.includes("/Dailies/") && !observedEngine.has(r));
7851
+ if (uncorroborated.length && engineObserved.present) {
7852
+ journalStage(j, "migrate", "failed", { problems: uncorroborated.map((r) => `${r}: checked as an Engine file but not observed in the Engine directory (fabricated inspection)`) });
7853
+ barLn(red(" Migration receipt check failed (machine corroboration):"));
7854
+ for (const r of uncorroborated.slice(0, 8)) barLn(` ${red("x")} ${r}: not observed in the Engine directory`);
7855
+ outro(red("Not stamped: a checked Engine file was not machine-corroborated."));
7856
+ process.exit(EXIT.ERROR);
7857
+ }
7858
+ journalStage(j, "migrate", "done", {
7859
+ owner: "workflow",
7860
+ finalizedBy: "moveros update --finalize",
7861
+ receipt: { migrated: receipt.migrated.length, checked: receipt.checked.length },
7862
+ // "unavailable" = no declarations manifest found in any bundle source;
7863
+ // recorded rather than passed off as a checked window.
7864
+ migrations: { manifest: owedMigrations.source ? "checked" : "unavailable", declaredOwed: owedMigrations.owed.length },
7865
+ // Machine-witnessed Engine state at stamp time (independent of the
7866
+ // workflow's `checked` claim).
7867
+ engineObserved,
7868
+ });
7869
+ maybeCrashForTest("migrate");
7870
+ journalStage(j, "verify", "started");
7871
+ maybeCrashForTest("verify");
7872
+
7873
+ const bundleDir = resolveVerifyBundleDir(j);
7874
+ let result;
7875
+ let scope;
7876
+ if (bundleDir) {
7877
+ scope = "full";
7878
+ result = verifyUpdateApplied(bundleDir, vaultPath, { templates: "content" });
7879
+ } else {
7880
+ // No source available to verify against (e.g. an Engine-only migration
7881
+ // with no staged payload). Degrade honestly to the conflicts gate; never
7882
+ // fabricate a full verification.
7883
+ scope = "conflicts-only";
7884
+ result = { ok: true, failures: [] };
7885
+ const conflictsPath = path.join(os.homedir(), ".mover", "conflicts.json");
7886
+ if (fs.existsSync(conflictsPath)) {
7887
+ try {
7888
+ const data = JSON.parse(fs.readFileSync(conflictsPath, "utf8"));
7889
+ result.failures = (data.files || []).map((f) => ({ file: f.file, reason: "unresolved-conflict" }));
7890
+ } catch {
7891
+ result.failures = [{ file: "conflicts.json", reason: "unreadable" }];
7892
+ }
7893
+ result.ok = result.failures.length === 0;
7894
+ }
7895
+ }
7896
+
7897
+ if (!result.ok) {
7898
+ journalStage(j, "verify", "failed", { scope, failures: result.failures.slice(0, 50) });
7899
+ barLn(red(` Verification failed: ${result.failures.length} file${result.failures.length > 1 ? "s" : ""} not at the updated version.`));
7900
+ for (const f of result.failures.slice(0, 12)) barLn(` ${red("x")} ${f.file} ${dim(`(${f.reason})`)}`);
7901
+ if (result.failures.length > 12) barLn(dim(` ...and ${result.failures.length - 12} more`));
7902
+ barLn();
7903
+ barLn(dim(" The version was NOT stamped. Resolve the files above"));
7904
+ barLn(dim(" (usually: re-run moveros update, or finish /update conflict merges),"));
7905
+ barLn(dim(" then run moveros update --finalize again."));
7906
+ outro(red("Not stamped: verification failed."));
7907
+ process.exit(EXIT.ERROR);
7908
+ }
7909
+
7910
+ journalStage(j, "verify", "done", { scope, checkedAgainst: bundleDir || null });
7911
+ journalStage(j, "stamp", "started");
7912
+ maybeCrashForTest("stamp");
7913
+ const version = (j.toVersion || require("./package.json").version).replace(/^V/i, "");
7914
+ fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${version}\n`, "utf8");
7915
+ j.completedAt = new Date().toISOString();
7916
+ journalStage(j, "stamp", "done", { version });
7917
+ // Consume the receipt: a stamped run's receipt must never validate a
7918
+ // FUTURE finalize (the null-journal path has no runId to collide on).
7919
+ try { fs.renameSync(receiptPath, `${receiptPath.replace(/\.json$/, "")}.used.json`); } catch {}
7920
+ statusLine("ok", "Verified", scope === "full" ? "workflows, templates, hooks all at the updated version" : "no unresolved conflicts");
7921
+ statusLine("ok", "Stamped", `.mover-version -> ${version}`);
7922
+ outro(green(`Update finalized: verified and stamped ${version}.`));
7923
+ }
7924
+
7925
+ // `moveros update --rollback`: restore the vault + agent artifacts from the
7926
+ // backup recorded in the journal (or the newest rollback-capable backup).
7927
+ // Never touches the bundle dir: on npm installs that is a read-only global
7928
+ // package, and it is not the rollback target anyway. Engine files are never
7929
+ // in these backups, so they are never touched here either.
7930
+ async function cmdUpdateRollback(opts) {
7931
+ const home = os.homedir();
7932
+ const journal = loadUpdateJournal();
7933
+ let backupDir = journal && journal.stages && journal.stages.backup && journal.stages.backup.backupDir;
7934
+ const dirArg = opts.rest.find((a) => a.startsWith("--dir="));
7935
+ if (dirArg) backupDir = path.resolve(dirArg.slice(6));
7936
+ if (!backupDir || !fs.existsSync(path.join(backupDir, "backup-manifest.json"))) {
7937
+ const root = path.join(home, ".mover", "backups");
7938
+ const cands = fs.existsSync(root)
7939
+ ? fs.readdirSync(root).filter((d) => d.startsWith("pre-update-") && fs.existsSync(path.join(root, d, "backup-manifest.json"))).sort()
7940
+ : [];
7941
+ if (cands.length) backupDir = path.join(root, cands[cands.length - 1]);
7942
+ }
7943
+ if (!backupDir || !fs.existsSync(path.join(backupDir, "backup-manifest.json"))) {
7944
+ barLn(red(" No rollback-capable backup found (backup-manifest.json missing)."));
7945
+ barLn(dim(" Backups made before this feature can be restored by hand from ~/.mover/backups/."));
7946
+ outro(red("Rollback aborted: nothing to restore from."));
7947
+ process.exit(EXIT.NOT_FOUND);
7948
+ }
7949
+
7950
+ let bm;
7951
+ try {
7952
+ bm = JSON.parse(fs.readFileSync(path.join(backupDir, "backup-manifest.json"), "utf8"));
7953
+ } catch {
7954
+ outro(red("Rollback aborted: backup-manifest.json is unreadable."));
7955
+ process.exit(EXIT.ERROR);
7956
+ }
7957
+
7958
+ let restored = 0, removed = 0, failed = 0;
7959
+ for (const e of bm.entries || []) {
7960
+ try {
7961
+ if (e.existedBefore && e.backup) {
7962
+ const src = path.join(backupDir, e.backup);
7963
+ if (!fs.existsSync(src)) { failed++; continue; }
7964
+ fs.mkdirSync(path.dirname(e.original), { recursive: true });
7965
+ // Unlink first: the original may be a hard link into the bundle
7966
+ // source; copying through the link would write INTO the (possibly
7967
+ // read-only, npm-owned) bundle. A fresh inode never can.
7968
+ try { fs.rmSync(e.original, { force: true }); } catch {}
7969
+ fs.copyFileSync(src, e.original);
7970
+ restored++;
7971
+ } else if (!e.existedBefore) {
7972
+ if (fs.existsSync(e.original)) { fs.rmSync(e.original, { force: true }); removed++; }
7973
+ }
7974
+ } catch {
7975
+ failed++;
7976
+ }
7977
+ }
7978
+
7979
+ // Restore the pristine base snapshots captured with the backup so the next
7980
+ // update classifies the rolled-back files against the right bases.
7981
+ try {
7982
+ const snapInstalled = path.join(backupDir, "mover-state", "installed");
7983
+ if (fs.existsSync(snapInstalled)) {
7984
+ fs.cpSync(snapInstalled, path.join(home, ".mover", "installed"), { recursive: true, force: true });
7985
+ }
7986
+ } catch {}
7987
+ // Conflicts recorded by the rolled-back run are void now.
7988
+ try { fs.rmSync(path.join(home, ".mover", "conflicts.json"), { force: true }); } catch {}
7989
+
7990
+ if (journal && !journal.completedAt) {
7991
+ journal.rolledBack = true;
7992
+ journal.completedAt = new Date().toISOString();
7993
+ saveUpdateJournal(journal);
7994
+ }
7995
+
7996
+ statusLine("ok", "Rollback", `${restored} restored, ${removed} removed${failed ? `, ${red(`${failed} failed`)}` : ""}`);
7997
+ barLn(dim(` From: ${backupDir}`));
7998
+ barLn(dim(" Engine files were never in this backup and were not touched."));
7999
+ barLn(dim(" The version marker was not changed (interactive updates never stamp before verify)."));
8000
+ outro(failed ? yellow(`Rollback finished with ${failed} failure${failed > 1 ? "s" : ""}.`) : green("Rollback complete."));
8001
+ if (failed) process.exit(EXIT.ERROR);
8002
+ }
8003
+
6434
8004
  async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
8005
+ // ── State-machine subcommands: local, no license, no self-update hop ──
8006
+ if (opts.rest.includes("--status")) return cmdUpdateStatus();
8007
+ if (opts.rest.includes("--finalize")) return cmdUpdateFinalize(opts);
8008
+ if (opts.rest.includes("--rollback")) return cmdUpdateRollback(opts);
8009
+
6435
8010
  const isQuick = opts.rest.includes("--quick");
6436
8011
 
6437
8012
  // Step 1: CLI Self-Update
@@ -6455,8 +8030,10 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6455
8030
  try {
6456
8031
  try {
6457
8032
  execSync("npm i -g mover-os", { stdio: "ignore", timeout: 60000 });
6458
- } catch {
6459
- // Retry with sudo — use inherit so password prompt shows
8033
+ } catch (err) {
8034
+ // Retry with sudo — use inherit so password prompt shows.
8035
+ // sudo does not exist on Windows; surface the real npm error there.
8036
+ if (process.platform === "win32") throw err;
6460
8037
  sp.stop(dim("Needs elevated permissions..."));
6461
8038
  execSync("sudo npm i -g mover-os", { stdio: "inherit", timeout: 120000 });
6462
8039
  }
@@ -6491,6 +8068,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6491
8068
  }
6492
8069
  statusLine("ok", "Vault", path.basename(vaultPath));
6493
8070
 
8071
+ // An update must target an EXISTING install. resolveVaultPath trusts an
8072
+ // explicit --vault without existsSync (terra F5), so a typo'd or wrong path
8073
+ // would otherwise get a fresh structure minted and stamped as "current",
8074
+ // shadowing the real install. Refuse unless a real, non-empty install marker
8075
+ // is present (sol flaw #5: an empty file or a directory named .mover-version
8076
+ // must not pass the guard).
8077
+ let installMarkerOk = false;
8078
+ try {
8079
+ const mp = path.join(vaultPath, ".mover-version");
8080
+ installMarkerOk = fs.statSync(mp).isFile() && fs.readFileSync(mp, "utf8").trim().length > 0;
8081
+ } catch {}
8082
+ if (!installMarkerOk) {
8083
+ outro(red(`No Mover OS install found at ${vaultPath}. Run ${bold("moveros install")} first, or pass the correct --vault.`));
8084
+ process.exit(EXIT.NOT_FOUND);
8085
+ }
8086
+
6494
8087
  // Step 3: Key Validation
6495
8088
  let updateKey = opts.key;
6496
8089
  if (!updateKey) {
@@ -6548,9 +8141,24 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6548
8141
  newVer = pkg.version || newVer;
6549
8142
  } catch {}
6550
8143
 
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.
8144
+ // ── Update journal: ONE resumable state machine for CLI + /update ──
8145
+ // Created here, after preflight facts (install marker, license, staged
8146
+ // bundle) are established. Resumes an interrupted run of the same mode and
8147
+ // target version so the original backup dir is preserved; every stage below
8148
+ // is idempotent, so resume = re-run from the journal.
8149
+ const journalMode = isQuick ? "quick" : "interactive";
8150
+ let journal = loadUpdateJournal();
8151
+ const resuming = !!(journal && !journal.completedAt && journal.toVersion === newVer && journal.mode === journalMode && journal.vaultPath === vaultPath);
8152
+ if (!resuming) {
8153
+ journal = newUpdateJournal(journalMode, installedVer, newVer, vaultPath);
8154
+ } else {
8155
+ barLn(dim(` Resuming interrupted update (started ${journal.startedAt}).`));
8156
+ barLn();
8157
+ }
8158
+ journalStage(journal, "preflight", "done", { installMarker: true });
8159
+ maybeCrashForTest("preflight");
8160
+ journalStage(journal, "stage", "done", { bundleDir });
8161
+ maybeCrashForTest("stage");
6554
8162
 
6555
8163
  const detectedAgents = AGENTS.filter((a) => a.detect());
6556
8164
  const selectedIds = detectedAgents.map((a) => a.id);
@@ -6561,8 +8169,34 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6561
8169
  const changes = detectChanges(bundleDir, vaultPath, selectedIds);
6562
8170
  const totalChanged = countChanges(changes);
6563
8171
  displayChangeSummary(changes, installedVer, newVer);
6564
- if (totalChanged === 0) { outro(green("Already up to date.")); return; }
6565
- autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
8172
+ if (totalChanged === 0 && !resuming) {
8173
+ // Nothing to do and no interrupted run to finish: close the journal
8174
+ // honestly rather than leave a phantom in-progress run.
8175
+ for (const s of ["backup", "apply", "migrate", "verify", "stamp"]) journalStage(journal, s, "skipped", { reason: "no-changes" });
8176
+ journal.completedAt = new Date().toISOString();
8177
+ saveUpdateJournal(journal);
8178
+ outro(green("Already up to date."));
8179
+ return;
8180
+ }
8181
+ if (totalChanged === 0 && resuming) {
8182
+ // Resume after a crash PAST the apply stage: the files are already on
8183
+ // disk but verify/stamp never ran. Fall through to them; re-running
8184
+ // backup/apply on zero changes would be a no-op anyway.
8185
+ barLn(dim(" Files already applied by the interrupted run - verifying and stamping."));
8186
+ barLn();
8187
+ if (journal.stages.backup.status !== "done") journalStage(journal, "backup", "skipped", { reason: "resume-no-changes" });
8188
+ journalStage(journal, "apply", "done", { updated: 0, resumed: true });
8189
+ }
8190
+ if (totalChanged > 0) {
8191
+ journalStage(journal, "backup", "started");
8192
+ maybeCrashForTest("backup");
8193
+ const priorBackup = resuming && journal.stages.backup && journal.stages.backup.backupDir && fs.existsSync(journal.stages.backup.backupDir)
8194
+ ? { backupRoot: journal.stages.backup.backupDir, backed: journal.stages.backup.backed || 0 }
8195
+ : null;
8196
+ const bkup = priorBackup || autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
8197
+ journalStage(journal, "backup", "done", { backupDir: bkup.backupRoot, backed: bkup.backed, reused: !!priorBackup });
8198
+ journalStage(journal, "apply", "started");
8199
+ maybeCrashForTest("apply");
6566
8200
  barLn(bold("Updating..."));
6567
8201
  barLn();
6568
8202
  createVaultStructure(vaultPath);
@@ -6583,8 +8217,102 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6583
8217
  sp.stop(steps.length > 0 ? `${displayName} ${dim(steps.join(", "))}` : `${displayName} ${dim("configured")}`);
6584
8218
  }
6585
8219
  }
8220
+
8221
+ // Complete Claude hook-asset refresh (6b parity with the interactive path):
8222
+ // the .js helpers and .md prompt templates must be on disk before anything
8223
+ // records or verifies them as current.
8224
+ const quickHooksSrc = path.join(bundleDir, "src", "hooks");
8225
+ const quickClaudeDetected = detectedAgents.some((a) => a.id === "claude-code");
8226
+ if (quickClaudeDetected && fs.existsSync(quickHooksSrc)) {
8227
+ const hooksDstClaude = path.join(os.homedir(), ".claude", "hooks");
8228
+ fs.mkdirSync(hooksDstClaude, { recursive: true });
8229
+ for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".sh"))) {
8230
+ const content = fs.readFileSync(path.join(quickHooksSrc, f), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
8231
+ fs.writeFileSync(path.join(hooksDstClaude, f), content, { mode: 0o755 });
8232
+ try { fs.chmodSync(path.join(hooksDstClaude, f), 0o755); } catch {}
8233
+ }
8234
+ for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".md") || x.endsWith(".js"))) {
8235
+ fs.copyFileSync(path.join(quickHooksSrc, f), path.join(hooksDstClaude, f));
8236
+ }
8237
+ }
8238
+
8239
+ // Rebuild the manifest to match what force-apply actually put on disk.
8240
+ // record-if-current: an entry asserts "the install holds this base", so
8241
+ // record the source hash only where the destination proves it (or, for
8242
+ // workflows, where this run just force-wrote it); otherwise carry the
8243
+ // prior base forward (F4 discipline) so nothing uncopied is marked current.
8244
+ try {
8245
+ const prior = loadUpdateManifest();
8246
+ const mfst = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
8247
+ if (prior && prior.files) Object.assign(mfst.files, prior.files);
8248
+ const wfDir = path.join(bundleDir, "src", "workflows");
8249
+ if (fs.existsSync(wfDir)) {
8250
+ // Quick force-applies EVERY workflow via the agent installers (which
8251
+ // also advance the pristine snapshots), so the source hash IS the new
8252
+ // base for all of them. Transformed copies still classify as
8253
+ // user-only-skip against this base, same as fresh installs.
8254
+ for (const f of fs.readdirSync(wfDir).filter((x) => x.endsWith(".md"))) {
8255
+ mfst.files[`workflows/${f}`] = fileContentHash(path.join(wfDir, f));
8256
+ }
8257
+ }
8258
+ const rulesPath2 = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
8259
+ if (fs.existsSync(rulesPath2)) mfst.files["system/Mover_Global_Rules.md"] = fileContentHash(rulesPath2);
8260
+ if (quickClaudeDetected && fs.existsSync(quickHooksSrc)) {
8261
+ for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
8262
+ const dst = path.join(os.homedir(), ".claude", "hooks", f);
8263
+ if (fs.existsSync(dst) && fileContentHash(dst) === fileContentHash(path.join(quickHooksSrc, f))) {
8264
+ mfst.files[`hooks/${f}`] = fileContentHash(path.join(quickHooksSrc, f));
8265
+ }
8266
+ }
8267
+ }
8268
+ // Templates: quick installs NEW files but never overwrites existing vault
8269
+ // scaffold files, so only record entries the disk actually holds.
8270
+ const structDir2 = path.join(bundleDir, "src", "structure");
8271
+ if (fs.existsSync(structDir2)) {
8272
+ const walkT = (dir, rel) => {
8273
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
8274
+ const srcPath = path.join(dir, entry.name);
8275
+ const entryRel = path.join(rel, entry.name);
8276
+ if (entry.isDirectory()) { walkT(srcPath, entryRel); continue; }
8277
+ const relNorm = entryRel.replace(/\\/g, "/");
8278
+ if (relNorm.includes("02_Areas") && relNorm.includes("Engine")) continue;
8279
+ const destFile = path.join(vaultPath, entryRel);
8280
+ try {
8281
+ if (fs.existsSync(destFile) && fileContentHash(destFile) === fileContentHash(srcPath)) {
8282
+ mfst.files[`templates/${relNorm}`] = fileContentHash(srcPath);
8283
+ }
8284
+ } catch {}
8285
+ }
8286
+ };
8287
+ walkT(structDir2, "");
8288
+ }
8289
+ saveUpdateManifest(mfst);
8290
+ } catch {}
8291
+
8292
+ journalStage(journal, "apply", "done", { updated: totalChanged });
8293
+ } // end totalChanged > 0
8294
+ journalStage(journal, "migrate", "skipped", { reason: "quick mode: run /update for Engine migrations" });
8295
+
8296
+ // ── Verify BEFORE the stamp: the stamp is earned, never assumed ──
8297
+ journalStage(journal, "verify", "started");
8298
+ maybeCrashForTest("verify");
8299
+ const quickVerify = verifyUpdateApplied(bundleDir, vaultPath, { templates: "presence" });
8300
+ if (!quickVerify.ok) {
8301
+ journalStage(journal, "verify", "failed", { failures: quickVerify.failures.slice(0, 50) });
8302
+ barLn(red(` Verification failed: ${quickVerify.failures.length} file${quickVerify.failures.length > 1 ? "s" : ""} not at the updated version.`));
8303
+ for (const f of quickVerify.failures.slice(0, 12)) barLn(` ${red("x")} ${f.file} ${dim(`(${f.reason})`)}`);
8304
+ barLn(dim(" The version was NOT stamped. Re-run moveros update --quick once the cause is resolved."));
8305
+ outro(red("Update incomplete: verification failed, version not stamped."));
8306
+ process.exit(EXIT.ERROR);
8307
+ }
8308
+ journalStage(journal, "verify", "done", { scope: "quick" });
8309
+
8310
+ journalStage(journal, "stamp", "started");
8311
+ maybeCrashForTest("stamp");
6586
8312
  fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
6587
8313
  writeMoverConfig(vaultPath, selectedIds, updateKey);
8314
+ journal.completedAt = new Date().toISOString();
8315
+ journalStage(journal, "stamp", "done", { version: require("./package.json").version });
6588
8316
  barLn();
6589
8317
  if (totalChanged > 0) {
6590
8318
  barLn(dim(" Restart your AI session to load updated rules and skills."));
@@ -6622,6 +8350,11 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6622
8350
  if (totalChanged === 0) {
6623
8351
  barLn(green(" Already up to date."));
6624
8352
  barLn();
8353
+ // Mechanical stages are trivially complete; Engine migration may still be
8354
+ // owed, so migrate stays pending and /update + --finalize own the stamp.
8355
+ journalStage(journal, "backup", "skipped", { reason: "no-changes" });
8356
+ journalStage(journal, "apply", "done", { updated: 0 });
8357
+ journalStage(journal, "migrate", "pending", { owner: "workflow" });
6625
8358
  outro(green("No changes needed. Engine files may still need migration — run /update."));
6626
8359
  return;
6627
8360
  }
@@ -6636,11 +8369,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6636
8369
  { multi: false, defaultIndex: 0 }
6637
8370
  );
6638
8371
  if (!updateConfirm || updateConfirm === "no") {
8372
+ // Nothing was touched: close the journal so no phantom run lingers.
8373
+ if (!resuming) {
8374
+ journal.cancelled = true;
8375
+ journal.completedAt = new Date().toISOString();
8376
+ saveUpdateJournal(journal);
8377
+ }
6639
8378
  outro("Update cancelled.");
6640
8379
  return;
6641
8380
  }
6642
8381
  barLn();
6643
8382
 
8383
+ // ── Backup stage: full pre-apply backup of every file this run will touch ──
8384
+ // (Previously quick-only, so an interactive safe-overwrite had NO restore
8385
+ // source. The journal keeps the ORIGINAL dir across resumes so rollback
8386
+ // always targets the true pre-update state.)
8387
+ journalStage(journal, "backup", "started");
8388
+ maybeCrashForTest("backup");
8389
+ const priorBackupI = resuming && journal.stages.backup && journal.stages.backup.backupDir && fs.existsSync(journal.stages.backup.backupDir)
8390
+ ? { backupRoot: journal.stages.backup.backupDir, backed: journal.stages.backup.backed || 0 }
8391
+ : null;
8392
+ const bkupI = priorBackupI || autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
8393
+ journalStage(journal, "backup", "done", { backupDir: bkupI.backupRoot, backed: bkupI.backed, reused: !!priorBackupI });
8394
+ journalStage(journal, "apply", "started");
8395
+ maybeCrashForTest("apply");
8396
+
6644
8397
  // ── Apply safe system files directly (no user customizations in these) ──
6645
8398
  const home = os.homedir();
6646
8399
  let appliedCount = 0;
@@ -6666,14 +8419,18 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6666
8419
  if (fs.existsSync(slSrc)) {
6667
8420
  const slContent = fs.readFileSync(slSrc, "utf8").replace(/\r\n/g, "\n");
6668
8421
  fs.writeFileSync(slDest, slContent, "utf8");
6669
- // Ensure settings.json has statusLine config
8422
+ // Ensure settings.json has statusLine config. Absolute forward-slash
8423
+ // path, never `~` — cmd.exe does not expand tildes (Windows dead-hook
8424
+ // bug class). Also migrate an existing `~` form left by old installs.
6670
8425
  const globalSettings = path.join(home, ".claude", "settings.json");
8426
+ const slCmd = `node "${slDest.replace(/\\/g, "/")}"`;
6671
8427
  try {
6672
8428
  const settings = fs.existsSync(globalSettings)
6673
8429
  ? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
6674
8430
  : {};
6675
- if (!settings.statusLine) {
6676
- settings.statusLine = { type: "command", command: "node ~/.claude/statusline.js" };
8431
+ const cur = settings.statusLine && settings.statusLine.command;
8432
+ if (!settings.statusLine || /~\/\.claude\/statusline\.js/.test(String(cur || ""))) {
8433
+ settings.statusLine = { type: "command", command: slCmd };
6677
8434
  fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
6678
8435
  }
6679
8436
  } catch {}
@@ -6692,13 +8449,16 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6692
8449
  for (const dir of cmdDirs) {
6693
8450
  const dest = path.join(dir, "update.md");
6694
8451
  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.
8452
+ // Gap-1: route through the vault-resolution transform. For bodies that
8453
+ // don't need it (returns false), replace the dest ATOMICALLY via
8454
+ // same-dir temp + rename: a plain copyFileSync would write THROUGH the
8455
+ // install-time hard link into the bundle source, which on an npm
8456
+ // global install is read-only (EACCES; caught by the update-machine
8457
+ // read-only-bundle test) and on a repo install would corrupt src/.
6700
8458
  if (!writeWorkflowTransformed(updateSrc, dest)) {
6701
- fs.copyFileSync(updateSrc, dest);
8459
+ const tmp = dest + ".tmp-" + process.pid;
8460
+ fs.copyFileSync(updateSrc, tmp);
8461
+ fs.renameSync(tmp, dest);
6702
8462
  }
6703
8463
  }
6704
8464
  }
@@ -6717,7 +8477,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6717
8477
  // ── Smart Update: hash-based change detection + auto-merge ──
6718
8478
  const manifest = loadUpdateManifest();
6719
8479
  const newManifest = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
6720
- const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [];
8480
+ const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [], writeFailed = [];
6721
8481
  let needsUpdate = false;
6722
8482
 
6723
8483
  if (!manifest) {
@@ -6735,6 +8495,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6735
8495
  const srcFile = path.join(wfSrcDir, file);
6736
8496
  const destFile = path.join(wfDestDir, file);
6737
8497
  const result = compareForUpdate("workflows", srcFile, destFile, manifest);
8498
+ let wroteFile = true; // false = left on disk unresolved (see manifest guard below)
6738
8499
 
6739
8500
  switch (result.action) {
6740
8501
  case "install":
@@ -6744,9 +8505,16 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6744
8505
  // bare git-rev-parse Pre-Flight on update. writeWorkflowTransformed
6745
8506
  // emits a corrected copy for matched files and returns false (→ linkOrCopy)
6746
8507
  // 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);
8508
+ // sol flaw #3: linkOrCopy returns null on I/O failure. Do NOT advance
8509
+ // the pristine snapshot or manifest for a file that was not actually
8510
+ // written, or the next update treats an unapplied file as current.
8511
+ if (writeWorkflowTransformed(srcFile, destFile) || linkOrCopy(srcFile, destFile)) {
8512
+ savePristineCopy("workflows", srcFile);
8513
+ updated.push(file);
8514
+ } else {
8515
+ writeFailed.push(file);
8516
+ wroteFile = false;
8517
+ }
6750
8518
  break;
6751
8519
  case "user-only-skip":
6752
8520
  userOnly.push(file);
@@ -6758,9 +8526,13 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6758
8526
  const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
6759
8527
  fs.mkdirSync(backupDir, { recursive: true });
6760
8528
  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);
8529
+ if (writeWorkflowTransformed(srcFile, destFile) || linkOrCopy(srcFile, destFile)) {
8530
+ savePristineCopy("workflows", srcFile);
8531
+ updated.push(file);
8532
+ } else {
8533
+ writeFailed.push(file); // sol flaw #3
8534
+ wroteFile = false;
8535
+ }
6764
8536
  break;
6765
8537
  }
6766
8538
  case "conflict": {
@@ -6773,12 +8545,25 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6773
8545
  savePristineCopy("workflows", srcFile);
6774
8546
  autoMerged.push(file);
6775
8547
  } else {
6776
- conflicts.push({ name: file, reason: mergeResult.reason, conflictCount: mergeResult.conflictCount });
8548
+ conflicts.push({
8549
+ name: file, category: "workflows",
8550
+ reason: mergeResult.reason, conflictCount: mergeResult.conflictCount,
8551
+ pristine: path.join(home, ".mover", "installed", "workflows", file),
8552
+ userVersion: destFile, newVersion: srcFile,
8553
+ });
8554
+ wroteFile = false;
6777
8555
  }
6778
8556
  break;
6779
8557
  }
6780
8558
  }
6781
- newManifest.files[`workflows/${file}`] = result.newHash;
8559
+ // terra F4: an unresolved conflict leaves the user's file on disk untouched.
8560
+ // Advancing the manifest to the new hash would make the NEXT update treat
8561
+ // the un-applied upstream change as already-pristine and silently skip it,
8562
+ // so a real bug/security fix never reaches a user who has a conflict. Keep
8563
+ // the prior base hash so the conflict resurfaces until /update resolves it.
8564
+ newManifest.files[`workflows/${file}`] = wroteFile
8565
+ ? result.newHash
8566
+ : (manifest?.files?.[`workflows/${file}`] ?? result.newHash);
6782
8567
  }
6783
8568
  }
6784
8569
 
@@ -6799,6 +8584,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6799
8584
 
6800
8585
  const destFile = path.join(vaultPath, entryRel);
6801
8586
  const result = compareForUpdate("templates", srcPath, destFile, manifest, relNorm);
8587
+ let wroteTmpl = true;
6802
8588
 
6803
8589
  switch (result.action) {
6804
8590
  case "install":
@@ -6835,12 +8621,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6835
8621
  savePristineCopy("templates", srcPath, relNorm);
6836
8622
  autoMerged.push(relNorm);
6837
8623
  } else {
6838
- conflicts.push({ name: relNorm, reason: mergeResult.reason, conflictCount: mergeResult.conflictCount });
8624
+ conflicts.push({
8625
+ name: relNorm, category: "templates",
8626
+ reason: mergeResult.reason, conflictCount: mergeResult.conflictCount,
8627
+ pristine: path.join(os.homedir(), ".mover", "installed", "templates", relNorm),
8628
+ userVersion: destFile, newVersion: srcPath,
8629
+ });
8630
+ wroteTmpl = false;
6839
8631
  }
6840
8632
  break;
6841
8633
  }
6842
8634
  }
6843
- newManifest.files[`templates/${relNorm}`] = result.newHash;
8635
+ // terra F4 (templates): same rule as workflows — never advance a manifest
8636
+ // entry for a file left unresolved on disk.
8637
+ newManifest.files[`templates/${relNorm}`] = wroteTmpl
8638
+ ? result.newHash
8639
+ : (manifest?.files?.[`templates/${relNorm}`] ?? result.newHash);
6844
8640
  }
6845
8641
  };
6846
8642
  walkTmplUpdate(tmplSrcDir, "");
@@ -6890,11 +8686,17 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6890
8686
 
6891
8687
  // ── Handle true conflicts → write conflicts.json for /update ──
6892
8688
  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),
8689
+ // terra F3: each record now carries its own category + real paths. A template
8690
+ // conflict (e.g. 00_Inbox/Quick_Capture.md, which lives in the vault) was
8691
+ // previously serialized with workflow paths (.claude/commands, src/workflows),
8692
+ // so /update read three non-existent files and could not resolve it. Fall
8693
+ // back to the workflow layout only for legacy records without paths.
8694
+ const conflictData = conflicts.map((c) => ({
8695
+ file: c.name,
8696
+ category: c.category || "workflows",
8697
+ pristine: c.pristine || path.join(home, ".mover", "installed", "workflows", c.name),
8698
+ userVersion: c.userVersion || path.join(home, ".claude", "commands", c.name),
8699
+ newVersion: c.newVersion || path.join(bundleDir, "src", "workflows", c.name),
6898
8700
  }));
6899
8701
  const conflictPath = path.join(home, ".mover", "conflicts.json");
6900
8702
  fs.writeFileSync(conflictPath, JSON.stringify({
@@ -6903,12 +8705,14 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6903
8705
  files: conflictData,
6904
8706
  }, null, 2), "utf8");
6905
8707
 
6906
- // Backup conflicted user files
8708
+ // Backup conflicted user files (from their real location — template conflicts
8709
+ // live in the vault, not ~/.claude/commands). Slashes in the relative name are
8710
+ // flattened so the backup filename is valid.
6907
8711
  const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
6908
8712
  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));
8713
+ for (const c of conflicts) {
8714
+ const dest = c.userVersion || path.join(home, ".claude", "commands", c.name);
8715
+ if (fs.existsSync(dest)) fs.copyFileSync(dest, path.join(backupDir, c.name.replace(/[\/\\]/g, "__")));
6912
8716
  }
6913
8717
  needsUpdate = true;
6914
8718
  }
@@ -6932,6 +8736,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6932
8736
  }
6933
8737
  }
6934
8738
 
8739
+ // ── Refresh Claude's hook FILES before recording their hashes ──
8740
+ // 6b (terra-confirmed): the multi-agent propagation loop above SKIPS
8741
+ // claude-code, yet the manifest loop below records every hook .sh/.js/.md as
8742
+ // current. So a Claude user's hook helpers (e.g. session-history-writer.js,
8743
+ // used by pre-compact-backup.sh) and prompt templates were marked fresh in
8744
+ // the manifest but never actually copied — they stayed stale, and the next
8745
+ // update skipped them because the hashes "matched". Copy the complete Claude
8746
+ // hook asset set (same files the fresh installer writes) BEFORE recording, so
8747
+ // the manifest never claims a version the disk does not have. File copy only;
8748
+ // settings.json is already established and is not touched here.
8749
+ const hooksSrcForClaude = path.join(bundleDir, "src", "hooks");
8750
+ const claudeDetected = detectedAgentsForProp.some((a) => a.id === "claude-code");
8751
+ if (claudeDetected && fs.existsSync(hooksSrcForClaude)) {
8752
+ const hooksDstClaude = path.join(home, ".claude", "hooks");
8753
+ fs.mkdirSync(hooksDstClaude, { recursive: true });
8754
+ for (const f of fs.readdirSync(hooksSrcForClaude).filter((x) => x.endsWith(".sh"))) {
8755
+ const content = fs.readFileSync(path.join(hooksSrcForClaude, f), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
8756
+ fs.writeFileSync(path.join(hooksDstClaude, f), content, { mode: 0o755 });
8757
+ try { fs.chmodSync(path.join(hooksDstClaude, f), 0o755); } catch {}
8758
+ }
8759
+ for (const f of fs.readdirSync(hooksSrcForClaude).filter((x) => x.endsWith(".md") || x.endsWith(".js"))) {
8760
+ fs.copyFileSync(path.join(hooksSrcForClaude, f), path.join(hooksDstClaude, f));
8761
+ }
8762
+ }
8763
+
6935
8764
  // ── Save updated manifest ──
6936
8765
  // Carry forward entries for files we didn't process (hooks, statusline, /update itself)
6937
8766
  if (manifest && manifest.files) {
@@ -6942,12 +8771,36 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6942
8771
  const hooksDir = path.join(bundleDir, "src", "hooks");
6943
8772
  if (fs.existsSync(hooksDir)) {
6944
8773
  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));
8774
+ // record-if-current (sol part 10: "interactive update records uncopied
8775
+ // hook artifacts as current"): a manifest entry asserts the installed
8776
+ // artifact holds this base. Record the fresh hash ONLY when the Claude
8777
+ // hook file is actually on disk with matching content (the refresh block
8778
+ // above guarantees that when Claude is detected); otherwise keep the
8779
+ // prior base (carried forward below) so nothing uncopied is marked
8780
+ // current and the next update still sees it as stale.
8781
+ const dst = path.join(home, ".claude", "hooks", f);
8782
+ if (claudeDetected && fs.existsSync(dst) && fileContentHash(dst) === fileContentHash(path.join(hooksDir, f))) {
8783
+ newManifest.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
8784
+ }
6946
8785
  }
6947
8786
  }
6948
8787
  saveUpdateManifest(newManifest);
6949
8788
  writeMoverConfig(vaultPath, selectedIds, updateKey);
6950
8789
 
8790
+ // ── Journal handoff: mechanical stages complete; /update owns the rest ──
8791
+ // migrate (Engine migration + conflict merges) belongs to the /update
8792
+ // workflow; verify + stamp happen in `moveros update --finalize`, which the
8793
+ // workflow runs LAST. The journal is the machine-readable handoff, so the
8794
+ // CLI and the workflow are one state machine instead of a prose baton pass.
8795
+ journalStage(journal, "apply", writeFailed.length > 0 ? "failed" : "done", {
8796
+ updated: updated.length,
8797
+ autoMerged: autoMerged.length,
8798
+ userOnly: userOnly.length,
8799
+ conflicts: conflicts.length,
8800
+ writeFailed: writeFailed.length,
8801
+ });
8802
+ journalStage(journal, "migrate", "pending", { owner: "workflow", conflicts: conflicts.length });
8803
+
6951
8804
  // ── Summary ──
6952
8805
  barLn();
6953
8806
  if (updated.length > 0) statusLine("ok", "Updated", `${updated.length} files`);
@@ -6955,6 +8808,15 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6955
8808
  if (userOnly.length > 0) statusLine("ok", "Preserved", `${userOnly.length} customized files`);
6956
8809
  if (skippedFiles.length > 0) statusLine("ok", "Current", `${skippedFiles.length} files unchanged`);
6957
8810
 
8811
+ // sol flaw #3: a write that failed mid-update was silently reported as success.
8812
+ // Surface it and force a non-clean outcome so the user (and the next run) retries.
8813
+ if (writeFailed.length > 0) {
8814
+ statusLine("warn", "Write failed", `${writeFailed.length} file${writeFailed.length > 1 ? "s" : ""} could not be written (kept prior version)`);
8815
+ for (const f of writeFailed) barLn(` ${yellow("!")} ${f}`);
8816
+ barLn(dim(" Re-run moveros update once the cause is resolved (disk space / permissions)."));
8817
+ needsUpdate = true;
8818
+ }
8819
+
6958
8820
  if (conflicts.length > 0) {
6959
8821
  barLn();
6960
8822
  barLn(yellow(` ${conflicts.length} file${conflicts.length > 1 ? "s" : ""} need intelligent merge:`));
@@ -6982,10 +8844,12 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6982
8844
  barLn();
6983
8845
  }
6984
8846
  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)`)}`);
8847
+ if (conflicts.length > 0) {
8848
+ 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)`)}`);
8849
+ } else if (writeFailed.length > 0) {
8850
+ outro(`${yellow("Update incomplete.")} ${writeFailed.length} file${writeFailed.length > 1 ? "s" : ""} could not be written. Re-run when resolved. ${dim(`(${elapsed}s)`)}`);
6987
8851
  } else {
6988
- outro(`${green("Done.")} All files up to date. ${dim(`(${elapsed}s)`)}`);
8852
+ outro(`${green("Done.")} Files applied. Run ${bold("/update")} to finish: Engine migration, then verify + version stamp. ${dim(`(${elapsed}s)`)}`);
6989
8853
  }
6990
8854
  }
6991
8855
 
@@ -7021,6 +8885,37 @@ function requireVault(explicitVault) {
7021
8885
  return v;
7022
8886
  }
7023
8887
 
8888
+ // THE install invariant (sol re-score part 1: "Marker checks still occur
8889
+ // command-by-command instead of through one invariant"). A real install =
8890
+ // a `.mover-version` that is a non-empty regular FILE — an empty file or a
8891
+ // directory by that name is not an install (sol flaw #5), and requireVault
8892
+ // trusts an explicit --vault without checking, so a typo'd path would
8893
+ // otherwise get artifacts minted into it (terra F1/F7). Every command that
8894
+ // MUTATES the vault or mints install artifacts goes through
8895
+ // requireInstalledVault; read-only diagnostics (doctor, context, status)
8896
+ // deliberately do not — they must be able to examine a broken install.
8897
+ function isInstalledVault(vaultPath) {
8898
+ if (!vaultPath) return false;
8899
+ try {
8900
+ const mp = path.join(vaultPath, ".mover-version");
8901
+ if (!fs.statSync(mp).isFile()) return false;
8902
+ return fs.readFileSync(mp, "utf8").trim().length > 0;
8903
+ } catch {
8904
+ return false;
8905
+ }
8906
+ }
8907
+
8908
+ function requireInstalledVault(explicitVault) {
8909
+ const v = requireVault(explicitVault);
8910
+ if (!v) return null;
8911
+ if (!isInstalledVault(v)) {
8912
+ statusLine("warn", "No install", `${v} has no Mover OS install (.mover-version missing or empty). Run moveros install first, or pass the correct --vault.`);
8913
+ barLn();
8914
+ return null;
8915
+ }
8916
+ return v;
8917
+ }
8918
+
7024
8919
  // ─── Main ───────────────────────────────────────────────────────────────────
7025
8920
  async function main() {
7026
8921
  const opts = parseArgs();
@@ -7044,13 +8939,37 @@ async function main() {
7044
8939
  opts.command = "install"; // no vault yet → first run goes straight to install
7045
8940
  }
7046
8941
 
8942
+ // `moveros pulse --bar` is polled by a menu-bar widget every few seconds — its
8943
+ // stdout must be ONLY the status line, so intercept it here, before the alt-
8944
+ // screen, logo, and update-notice chrome would pollute the output.
8945
+ if ((opts.command === "pulse" || opts.command === "dashboard") && (opts.rest || []).includes("--bar")) {
8946
+ await cmdPulseBar(opts);
8947
+ return;
8948
+ }
8949
+
8950
+ // `moveros inventory` is piped by the docs generator and CI — stdout must be
8951
+ // ONLY the JSON, so it too skips the alt-screen and logo chrome.
8952
+ if (opts.command === "inventory") {
8953
+ cmdInventory();
8954
+ return;
8955
+ }
8956
+
8957
+ // `moveros status --json` is a machine-readable state probe (scripts, other
8958
+ // agents). Its JSON must be the WHOLE stdout, so intercept it before the
8959
+ // alt-screen and logo chrome that otherwise print ahead of jsonOut (sol
8960
+ // re-score #6 part 1: --json still passed through header rendering).
8961
+ if (opts.command === "status" && opts.json) {
8962
+ await cmdStatus(opts);
8963
+ return;
8964
+ }
8965
+
7047
8966
  // ── TUI: Enter alternate screen ──
7048
8967
  enterAltScreen();
7049
8968
 
7050
8969
  // ── Intro: Logo plays once ──
7051
8970
  await printHeader();
7052
8971
 
7053
- const lightCommands = ["pulse", "dashboard", "open", "capture", "who", "diff", "sync", "replay", "context", "settings", "backup", "restore", "doctor", "prayer", "help", "uninstall", "test"];
8972
+ const lightCommands = ["pulse", "dashboard", "open", "shortcut", "menubar", "inventory", "capture", "who", "diff", "sync", "replay", "context", "status", "settings", "backup", "restore", "doctor", "prayer", "help", "uninstall", "test"];
7054
8973
 
7055
8974
  // ── `moveros menu` → classic interactive launcher (persistent loop) ──
7056
8975
  if (opts.command === "menu") {
@@ -7617,22 +9536,59 @@ async function main() {
7617
9536
 
7618
9537
  let totalSteps = 0;
7619
9538
 
7620
- // 0. Legacy cleanup (remove files from older installer versions)
9539
+ // 0. Legacy cleanup (retire files from older installer versions).
9540
+ // GUARDED, then made NON-DESTRUCTIVE (terra P0, two rounds 2026-07-11):
9541
+ // this used to rm -rf vault-root src/ unconditionally. A weak fingerprint
9542
+ // (any one hook filename / the prose "mover os") still destroyed a user's
9543
+ // src/ that merely held a session-start.sh, and a personal SOUL.md that
9544
+ // named the product. Two changes: (a) the signature is now STRONG and
9545
+ // Mover-unique, and (b) even a match is BACKED UP, never hard-deleted, so a
9546
+ // false positive can never lose data. The campaign DoD is byte-preservation
9547
+ // of unowned files; a backup rename satisfies it even under a misfire.
9548
+ const MOVER_UNIQUE_HOOKS = ["mover-lib.sh", "session-log-reminder.sh", "engine-protection.sh", "engine-auto-commit.sh"];
9549
+ const isLegacyMoverSrc = (dir) => {
9550
+ try {
9551
+ const hooksDir = path.join(dir, "hooks");
9552
+ if (!fs.existsSync(hooksDir) || !fs.statSync(hooksDir).isDirectory()) return false;
9553
+ const present = new Set(fs.readdirSync(hooksDir));
9554
+ // mover-lib.sh is a Mover-only filename; require it PLUS one more unique
9555
+ // Mover hook. A user coincidentally naming a file session-start.sh will
9556
+ // not also carry mover-lib.sh + engine-protection.sh.
9557
+ if (!present.has("mover-lib.sh")) return false;
9558
+ return MOVER_UNIQUE_HOOKS.filter((h) => present.has(h)).length >= 2;
9559
+ } catch { return false; }
9560
+ };
9561
+ const isLegacyMoverSoul = (file) => {
9562
+ // Match the OLD Mover SOUL template structure, not a prose mention. The
9563
+ // shipped legacy SOUL led with an agent-identity header and the "The
9564
+ // Architect" persona; a user writing "I tried Mover OS" prose has neither.
9565
+ try {
9566
+ const head = fs.readFileSync(file, "utf8").slice(0, 4000);
9567
+ return /^#\s*(SOUL|THE SOUL|Mover OS SOUL)\b/im.test(head) &&
9568
+ /(Architect|Agent Name|Global Rules|Identity_Prime)/.test(head);
9569
+ } catch { return false; }
9570
+ };
7621
9571
  const legacyPaths = [
7622
- path.join(vaultPath, "src"), // hooks used to install here
7623
- path.join(vaultPath, "SOUL.md"), // old soul file
9572
+ { p: path.join(vaultPath, "src"), owned: isLegacyMoverSrc }, // hooks used to install here
9573
+ { p: path.join(vaultPath, "SOUL.md"), owned: isLegacyMoverSoul }, // old soul file
7624
9574
  ];
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
- }
9575
+ for (const { p: lp, owned } of legacyPaths) {
9576
+ if (!fs.existsSync(lp)) continue;
9577
+ try {
9578
+ if (!owned(lp)) {
9579
+ statusLine("warn", "Preserved", `${path.basename(lp)} exists but is not a Mover artifact — left untouched`);
9580
+ continue;
9581
+ }
9582
+ // Owned legacy artifact: back it up rather than delete. A stray false
9583
+ // positive is then fully recoverable; a real legacy dir is out of the way.
9584
+ const backup = `${lp}.pre-mover-backup`;
9585
+ if (!fs.existsSync(backup)) {
9586
+ fs.renameSync(lp, backup);
9587
+ statusLine("ok", "Backed up", `legacy ${path.basename(lp)} → ${path.basename(backup)}`);
9588
+ } else {
9589
+ statusLine("warn", "Left as-is", `${path.basename(lp)} (backup already exists)`);
9590
+ }
9591
+ } catch {}
7636
9592
  }
7637
9593
 
7638
9594
  // 1. Vault structure
@@ -7797,15 +9753,54 @@ async function main() {
7797
9753
 
7798
9754
  const agentNames = selectedAgents.map((a) => a.name);
7799
9755
 
9756
+ // `moveros` is only on PATH after a global npm install. An `npx mover-os`
9757
+ // user has no bin — telling them to "Run moveros" dead-ends (sol, 2026-07-10).
9758
+ // Detect the npx cache path and print the command that will actually work.
9759
+ const viaNpx = /[\\/]_npx[\\/]/.test(process.argv[1] || "") || /[\\/]_npx[\\/]/.test(process.env.npm_execpath || "");
9760
+ const cliCmd = viaNpx ? "npx mover-os" : "moveros";
9761
+
7800
9762
  // ── What you got ──
7801
9763
  ln(gray(" ─────────────────────────────────────────────"));
7802
9764
  ln();
7803
9765
  ln(` ${bold("What was installed")}`);
7804
9766
  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)")}`);
9767
+ // Counted from the bundle at print time, never hardcoded. Sol's product
9768
+ // review (2026-07-10) caught this banner claiming 23/62/18 while the
9769
+ // install delivered 25/70/20 — a buyer couldn't tell which product they
9770
+ // bought. Same filters as the installers: workflows = src/workflows/*.md,
9771
+ // skills = findSkills + the category gate installSkillPacks applies,
9772
+ // hooks = unique scripts in the generated Claude settings.
9773
+ // Terra verify (2026-07-10): a null selectedCategories can mean "skills
9774
+ // skipped entirely", not "all categories" — gate each line on whether that
9775
+ // artifact class was actually installed, or the banner overstates again.
9776
+ // Agents whose installers call installWorkflows/installWorkflowsConverted
9777
+ // (see installClaudeCode..installCopilot). Cline/amazon-q/etc get rules+skills only.
9778
+ const wfAgents = new Set(["claude-code", "cursor", "codex", "windsurf", "gemini-cli", "antigravity", "roo-code", "copilot"]);
9779
+ const anyWorkflowAgent = selectedIds.some((id) => wfAgents.has(id));
9780
+ let wfInstalled = 0;
9781
+ try {
9782
+ if (anyWorkflowAgent && skillOpts.workflows !== false) {
9783
+ wfInstalled = fs.readdirSync(path.join(bundleDir, "src", "workflows")).filter((n) => n.endsWith(".md")).length;
9784
+ }
9785
+ } catch {}
9786
+ let skInstalled = 0;
9787
+ try {
9788
+ if (skillOpts.install) {
9789
+ skInstalled = findSkills(bundleDir).filter(
9790
+ (s) => !selectedCategories || s.category === "tools" || selectedCategories.has(s.category)
9791
+ ).length;
9792
+ }
9793
+ } catch {}
9794
+ let hkInstalled = 0;
9795
+ try {
9796
+ const hookScripts = new Set();
9797
+ for (const m of generateClaudeSettings().matchAll(/hooks\/([a-z0-9-]+\.sh)/g)) hookScripts.add(m[1]);
9798
+ hkInstalled = hookScripts.size;
9799
+ } catch {}
9800
+ if (wfInstalled > 0) ln(` ${green("▸")} ${bold(String(wfInstalled))} workflows ${dim("slash commands for daily rhythm, projects, strategy")}`);
9801
+ if (skInstalled > 0) ln(` ${green("▸")} ${bold(String(skInstalled))} skills ${dim("curated packs for dev, marketing, CRO, design")}`);
9802
+ if (selectedIds.includes("claude-code") && hkInstalled > 0) {
9803
+ ln(` ${green("▸")} ${bold(String(hkInstalled))} hooks ${dim("lifecycle guards (engine protection, git safety)")}`);
7809
9804
  }
7810
9805
  ln(` ${green("▸")} ${bold(String(selectedAgents.length))} agent${selectedAgents.length > 1 ? "s" : ""} ${dim(agentNames.join(", "))}`);
7811
9806
  ln(` ${green("▸")} PARA vault ${dim("folders, templates, Engine scaffold")}`);
@@ -7819,7 +9814,7 @@ async function main() {
7819
9814
  await slideIn([
7820
9815
  ` ${bold("Next steps")}`,
7821
9816
  "",
7822
- ` ${cyan("1")} Run ${bold("moveros")}`,
9817
+ ` ${cyan("1")} Run ${bold(cliCmd)}`,
7823
9818
  ` ${dim("Opens your dashboard; setup starts there, a few plain questions")}`,
7824
9819
  "",
7825
9820
  ` ${cyan("2")} Open your vault in ${bold("Obsidian")} ${dim("(optional)")}`,
@@ -7843,12 +9838,12 @@ async function main() {
7843
9838
  ln();
7844
9839
  ln(` ${bold("CLI commands")} ${dim("(run from any terminal)")}`);
7845
9840
  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)")}`);
9841
+ ln(` ${green("▸")} ${bold(cliCmd)} ${dim("Open your dashboard")}`);
9842
+ ln(` ${green("▸")} ${bold(cliCmd + " doctor")} ${dim("Health check across all agents")}`);
9843
+ ln(` ${green("▸")} ${bold(cliCmd + " capture")} ${dim("Quick inbox — tasks, links, ideas")}`);
9844
+ ln(` ${green("▸")} ${bold(cliCmd + " prayer")} ${dim("Set up prayer time reminders")}`);
9845
+ ln(` ${green("▸")} ${bold(cliCmd + " update")} ${dim("Update agents, rules, and skills")}`);
9846
+ ln(` ${green("▸")} ${bold(cliCmd + " menu")} ${dim("All commands (classic launcher)")}`);
7852
9847
  ln();
7853
9848
 
7854
9849
  // ── Land on the dashboard's onboarding route (the payoff) ──
@@ -7861,9 +9856,11 @@ async function main() {
7861
9856
  try {
7862
9857
  const dash = require("./src/dashboard");
7863
9858
  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.")}`);
9859
+ // Setup starts IN the browser that just opened. Pointing at /setup here
9860
+ // contradicted the dashboard-first funnel (sol, 2026-07-10).
9861
+ ln(` ${dim("Opening onboarding in your browser — setup starts there.")}`);
7865
9862
  } catch (err) {
7866
- ln(` ${dim("Your dashboard is ready — open it anytime with")} ${bold("moveros")}.`);
9863
+ ln(` ${dim("Your dashboard is ready — open it anytime with")} ${bold(cliCmd)}.`);
7867
9864
  }
7868
9865
  ln();
7869
9866
  }