mover-os 4.7.7 → 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 (119) hide show
  1. package/README.md +34 -24
  2. package/install.js +2868 -251
  3. package/package.json +15 -3
  4. package/src/dashboard/build.js +1541 -0
  5. package/src/dashboard/dashboard.js +276 -0
  6. package/src/dashboard/index.js +319 -0
  7. package/src/dashboard/lib/activation-log.js +297 -0
  8. package/src/dashboard/lib/active-context-parser.js +189 -0
  9. package/src/dashboard/lib/agent-command.js +93 -0
  10. package/src/dashboard/lib/agent-detect.js +255 -0
  11. package/src/dashboard/lib/agent-detector.js +92 -0
  12. package/src/dashboard/lib/agent-session.js +483 -0
  13. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  14. package/src/dashboard/lib/approval-registry.js +170 -0
  15. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  16. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  17. package/src/dashboard/lib/config-parser.js +109 -0
  18. package/src/dashboard/lib/connect-recommender.js +131 -0
  19. package/src/dashboard/lib/correlations-parser.js +231 -0
  20. package/src/dashboard/lib/daily-note-resolver.js +228 -0
  21. package/src/dashboard/lib/date-utils.js +43 -0
  22. package/src/dashboard/lib/distribution-parser.js +137 -0
  23. package/src/dashboard/lib/dossier-parser.js +64 -0
  24. package/src/dashboard/lib/drift-history.js +88 -0
  25. package/src/dashboard/lib/drift-score.js +119 -0
  26. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  27. package/src/dashboard/lib/engine-health.js +173 -0
  28. package/src/dashboard/lib/engine-writer.js +1831 -0
  29. package/src/dashboard/lib/execution-plan.js +125 -0
  30. package/src/dashboard/lib/experiments-parser.js +429 -0
  31. package/src/dashboard/lib/feed-parser.js +294 -0
  32. package/src/dashboard/lib/forked-future.js +60 -0
  33. package/src/dashboard/lib/goal-forecast.js +427 -0
  34. package/src/dashboard/lib/goals-parser.js +67 -0
  35. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  36. package/src/dashboard/lib/hook-activity.js +48 -0
  37. package/src/dashboard/lib/hook-indexer.js +169 -0
  38. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  39. package/src/dashboard/lib/identity-parser.js +85 -0
  40. package/src/dashboard/lib/ingestion.js +418 -0
  41. package/src/dashboard/lib/library-indexer-v2.js +226 -0
  42. package/src/dashboard/lib/library-indexer.js +105 -0
  43. package/src/dashboard/lib/library-search.js +290 -0
  44. package/src/dashboard/lib/log-activation.sh +61 -0
  45. package/src/dashboard/lib/memory-curator.js +97 -0
  46. package/src/dashboard/lib/memory-gardener.js +177 -0
  47. package/src/dashboard/lib/memory-gepa.js +102 -0
  48. package/src/dashboard/lib/memory-index.js +519 -0
  49. package/src/dashboard/lib/memory-rerank.js +72 -0
  50. package/src/dashboard/lib/memory-text.js +136 -0
  51. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  52. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  53. package/src/dashboard/lib/onboarding-forge.js +70 -0
  54. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  55. package/src/dashboard/lib/override-summary.js +73 -0
  56. package/src/dashboard/lib/paths.js +192 -0
  57. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  58. package/src/dashboard/lib/phantom-strategy.js +129 -0
  59. package/src/dashboard/lib/pid-markers.js +80 -0
  60. package/src/dashboard/lib/project-scanner.js +121 -0
  61. package/src/dashboard/lib/promise-wall.js +88 -0
  62. package/src/dashboard/lib/record-score.js +173 -0
  63. package/src/dashboard/lib/redaction.js +140 -0
  64. package/src/dashboard/lib/refusal-parser.js +44 -0
  65. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  66. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  67. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  68. package/src/dashboard/lib/run-registry.js +286 -0
  69. package/src/dashboard/lib/safe-write.js +63 -0
  70. package/src/dashboard/lib/session-log-parser.js +145 -0
  71. package/src/dashboard/lib/session-time-parser.js +158 -0
  72. package/src/dashboard/lib/skill-index.js +171 -0
  73. package/src/dashboard/lib/skill-indexer.js +118 -0
  74. package/src/dashboard/lib/skill-recommender.js +689 -0
  75. package/src/dashboard/lib/state-core/backfill.js +298 -0
  76. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  77. package/src/dashboard/lib/state-core/event-log.js +615 -0
  78. package/src/dashboard/lib/state-core/events.js +265 -0
  79. package/src/dashboard/lib/state-core/projections.js +376 -0
  80. package/src/dashboard/lib/state-core/start-close.js +162 -0
  81. package/src/dashboard/lib/state-core/trial.js +96 -0
  82. package/src/dashboard/lib/strategy-parser.js +248 -0
  83. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  84. package/src/dashboard/lib/streak-parser.js +95 -0
  85. package/src/dashboard/lib/suggested-now.js +254 -0
  86. package/src/dashboard/lib/tool-awareness.js +125 -0
  87. package/src/dashboard/lib/transcript-parser.js +371 -0
  88. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  89. package/src/dashboard/lib/view-generator.js +163 -0
  90. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  91. package/src/dashboard/lib/walkthrough-script.js +140 -0
  92. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  93. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  94. package/src/dashboard/server.js +2402 -0
  95. package/src/dashboard/shortcut.js +284 -0
  96. package/src/dashboard/static/setup-poc.html +306 -0
  97. package/src/dashboard/static/walkthrough-poc.html +580 -0
  98. package/src/dashboard/styles.css +1201 -0
  99. package/src/dashboard/templates/index.html +278 -0
  100. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  101. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  102. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  103. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  104. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  105. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  106. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  107. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  108. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  109. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  110. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  111. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  112. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  113. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  114. package/src/dashboard/ui/dist/icon.svg +4 -0
  115. package/src/dashboard/ui/dist/index.html +18 -0
  116. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  117. package/src/dashboard/ui/dist/registerSW.js +1 -0
  118. package/src/dashboard/ui/dist/sw.js +1 -0
  119. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
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);
@@ -944,8 +991,30 @@ async function downloadPayload(key) {
944
991
  // The ordering of -tvzf and -tzf output is identical (tar walks the
945
992
  // archive in the same order), so we zip them by line index for the
946
993
  // type/path correlation.
947
- const pathsListing = execSync(`tar -tzf "${tarPath}"`, { encoding: 'utf8' });
948
- const verboseListing = execSync(`tar -tvzf "${tarPath}"`, { encoding: 'utf8' });
994
+ // v4.7.9 (S4 ship-blocker): tar preflight. Both listing calls were outside
995
+ // any try/catch, so on a Windows box without tar on PATH the raw spawn threw
996
+ // `Error: spawnSync /bin/sh ENOENT` (or `'tar' is not recognized`) with no
997
+ // hint about what the user must install. Check tar is present first, then
998
+ // wrap the listings so a genuinely corrupt payload reports differently from a
999
+ // missing-tar host. cmdExists() is the same probe used elsewhere in install.
1000
+ if (!cmdExists('tar')) {
1001
+ throw new Error(
1002
+ "tar is required to install Mover OS but was not found on PATH. " +
1003
+ "Install tar and re-run (Git Bash and WSL both ship it; on bare Windows run `choco install tartool` " +
1004
+ "or use the bsdtar bundled with Windows 10+ by adding C:\\\\Windows\\\\System32 to PATH)."
1005
+ );
1006
+ }
1007
+ let pathsListing, verboseListing;
1008
+ try {
1009
+ pathsListing = execSync(`tar -tzf "${tarPath}"`, { encoding: 'utf8' });
1010
+ verboseListing = execSync(`tar -tvzf "${tarPath}"`, { encoding: 'utf8' });
1011
+ } catch (e) {
1012
+ throw new Error(
1013
+ `tar could not read the downloaded payload at ${tarPath}. ` +
1014
+ "The download is likely corrupt or incomplete — delete that file and re-run. " +
1015
+ `(tar reported: ${e.message})`
1016
+ );
1017
+ }
949
1018
  const pathLines = pathsListing.split('\n').filter(Boolean);
950
1019
  const verboseLines = verboseListing.split('\n').filter(Boolean);
951
1020
  const badPaths = [];
@@ -988,6 +1057,41 @@ async function downloadPayload(key) {
988
1057
  // Clean up tarball
989
1058
  try { fs.unlinkSync(tarPath); } catch {}
990
1059
 
1060
+ // v4.7.9 (S5 ship-blocker): post-extract assert. A silently-truncated tarball
1061
+ // can pass the listing/type checks above yet extract to nothing useful. If the
1062
+ // canonical payload root (~/.mover/src/workflows) is absent after extraction,
1063
+ // every downstream installer would copy from nonexistent dirs and the install
1064
+ // would "succeed" while shipping no workflows. Fail loudly here instead.
1065
+ const extractedWorkflows = path.join(moverDir, "src", "workflows");
1066
+ if (!fs.existsSync(extractedWorkflows)) {
1067
+ throw new Error(
1068
+ `Payload extracted but ${extractedWorkflows} is missing — the download is corrupt or incomplete. ` +
1069
+ `Delete ${path.join(moverDir, "payload.tar.gz")} and re-run.`
1070
+ );
1071
+ }
1072
+ // Existence alone is too weak: a tarball carrying just an empty src/workflows/
1073
+ // dir passes the check above, then installWorkflows() finds zero .md files and
1074
+ // returns 0 commands while the install reports success — the exact silent-empty
1075
+ // failure S5 exists to stop. Require at least one workflow .md to be present.
1076
+ let extractedWfCount = 0;
1077
+ try {
1078
+ // Require a real .md FILE — not just any entry ending in ".md". A corrupt
1079
+ // payload containing a directory literally named "x.md" would otherwise pass.
1080
+ extractedWfCount = fs.readdirSync(extractedWorkflows, { withFileTypes: true })
1081
+ .filter((d) => d.isFile() && d.name.endsWith(".md")).length;
1082
+ } catch (e) {
1083
+ throw new Error(
1084
+ `Payload extracted but ${extractedWorkflows} is unreadable (${e.message}) — ` +
1085
+ `the download is corrupt or incomplete. Delete ${path.join(moverDir, "payload.tar.gz")} and re-run.`
1086
+ );
1087
+ }
1088
+ if (extractedWfCount === 0) {
1089
+ throw new Error(
1090
+ `Payload extracted but ${extractedWorkflows} contains no workflow (.md) files — ` +
1091
+ `the download is corrupt or incomplete. Delete ${path.join(moverDir, "payload.tar.gz")} and re-run.`
1092
+ );
1093
+ }
1094
+
991
1095
  return moverDir;
992
1096
  }
993
1097
 
@@ -1026,8 +1130,12 @@ function loadCliUsage() {
1026
1130
 
1027
1131
  function recordCliUsage(cmd) {
1028
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;
1029
1137
  try {
1030
- const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, "utf8")) : {};
1138
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
1031
1139
  if (!cfg.cli_usage) cfg.cli_usage = {};
1032
1140
  const entry = cfg.cli_usage[cmd] || { count: 0, last: 0 };
1033
1141
  entry.count++;
@@ -1052,7 +1160,11 @@ const CLI_COMMANDS = {
1052
1160
  update: { desc: "Comprehensive update — agents, rules, skills", alias: ["-u"] },
1053
1161
  uninstall: { desc: "Remove Mover OS from vault and agents", alias: [] },
1054
1162
  doctor: { desc: "Health check across all installed agents", alias: [] },
1055
- pulse: { desc: "Terminal dashboard — energy, tasks, streaks",alias: [] },
1163
+ pulse: { desc: "Browser dashboard — Suggested Now, patterns, drift (use --terminal for TUI)",alias: ["dashboard"] },
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: [] },
1167
+ menu: { desc: "Classic terminal launcher (install, update, doctor…)", alias: [] },
1056
1168
  // warm removed — hooks + rules + /morning already handle session priming
1057
1169
  capture: { desc: "Quick capture — tasks, links, ideas", alias: [] },
1058
1170
  who: { desc: "Entity memory lookup", alias: [] },
@@ -1067,6 +1179,7 @@ const CLI_COMMANDS = {
1067
1179
  status: { desc: "Full system state (vault, agents, version)", alias: [] },
1068
1180
  help: { desc: "Interactive guide to Mover OS", alias: ["-h"] },
1069
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 },
1070
1183
  };
1071
1184
 
1072
1185
  function parseArgs() {
@@ -1085,6 +1198,7 @@ function parseArgs() {
1085
1198
  if (a === "--json") { opts.json = true; continue; }
1086
1199
  if (a === "--quiet" || a === "-q") { opts.quiet = true; continue; }
1087
1200
  if (a === "--dry-run") { opts.dryRun = true; continue; }
1201
+ if (a === "--no-open") { opts.noOpen = true; continue; }
1088
1202
  if (a === "--help" || a === "-h") {
1089
1203
  ln();
1090
1204
  ln(` ${bold("moveros")} ${dim("— the Mover OS companion CLI")}`);
@@ -1104,9 +1218,10 @@ function parseArgs() {
1104
1218
  ln(` --json Structured JSON output (for status command)`);
1105
1219
  ln(` --quiet, -q Minimal output, one value per line`);
1106
1220
  ln(` --dry-run Show what would change without doing it`);
1221
+ ln(` --no-open Skip the browser launch (automation / headless)`);
1107
1222
  ln(` --update, -u Quick update (backward compat)`);
1108
1223
  ln();
1109
- ln(` ${dim("Run")} moveros ${dim("with no args for interactive menu")}`);
1224
+ ln(` ${dim("Run")} moveros ${dim("with no args to open the dashboard — installs on first run")}`);
1110
1225
  ln();
1111
1226
  process.exit(0);
1112
1227
  }
@@ -1333,6 +1448,157 @@ function savePristineCopy(category, srcFile, relPath) {
1333
1448
  fs.writeFileSync(destPath, content, "utf8");
1334
1449
  }
1335
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
+
1336
1602
  function compareForUpdate(category, srcFile, destFile, manifest, relPath) {
1337
1603
  const fileName = relPath ? `${category}/${relPath}` : `${category}/${path.basename(srcFile)}`;
1338
1604
  const newHash = fileContentHash(srcFile);
@@ -1516,11 +1782,28 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
1516
1782
  const backupRoot = path.join(home, ".mover", "backups", `pre-update-${ts}`);
1517
1783
  let backed = 0;
1518
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
+
1519
1793
  const backup = (src, relPath) => {
1520
1794
  if (!fs.existsSync(src)) return;
1521
1795
  const dest = path.join(backupRoot, relPath);
1522
1796
  fs.mkdirSync(path.dirname(dest), { recursive: true });
1523
- 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 });
1524
1807
  };
1525
1808
 
1526
1809
  // Workflows
@@ -1534,12 +1817,18 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
1534
1817
  backup(path.join(dir, f.file), path.join("workflows", path.basename(dir), f.file));
1535
1818
  }
1536
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
+ }
1537
1823
 
1538
1824
  // Hooks
1539
1825
  if (targetIds.includes("claude-code")) {
1540
1826
  for (const f of changes.hooks.filter((x) => x.status === "changed")) {
1541
1827
  backup(path.join(home, ".claude", "hooks", f.file), path.join("hooks", f.file));
1542
1828
  }
1829
+ for (const f of changes.hooks.filter((x) => x.status === "new")) {
1830
+ willCreate(path.join(home, ".claude", "hooks", f.file));
1831
+ }
1543
1832
  }
1544
1833
 
1545
1834
  // Rules
@@ -1572,17 +1861,45 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
1572
1861
  // Statusline
1573
1862
  if (changes.statusline === "changed" && targetIds.includes("claude-code")) {
1574
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"));
1575
1866
  }
1576
1867
 
1577
1868
  // Templates
1578
1869
  for (const f of changes.templates.filter((x) => x.status === "changed")) {
1579
1870
  backup(path.join(vaultPath, f.file), path.join("templates", f.file));
1580
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 {}
1581
1898
 
1582
1899
  if (backed > 0) {
1583
1900
  statusLine("ok", "Auto-backup", `${backed} files → ${dim(backupRoot)}`);
1584
1901
  }
1585
- return backed;
1902
+ return { backupRoot, backed };
1586
1903
  }
1587
1904
 
1588
1905
  function countChanges(changes) {
@@ -1689,6 +2006,19 @@ async function runUninstall(vaultPath) {
1689
2006
 
1690
2007
  const home = os.homedir();
1691
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
+
1692
2022
  // ── Build category map (only include categories that actually exist on disk) ──
1693
2023
  const categories = [];
1694
2024
 
@@ -1700,7 +2030,7 @@ async function runUninstall(vaultPath) {
1700
2030
  { label: "Codex instructions", path: path.join(home, ".codex", "instructions.md") },
1701
2031
  { label: "Windsurf rules", path: path.join(home, ".windsurfrules") },
1702
2032
  ];
1703
- if (vaultPath) {
2033
+ if (vaultOwned) {
1704
2034
  rulesPaths.push(
1705
2035
  { label: "AGENTS.md", path: path.join(vaultPath, "AGENTS.md") },
1706
2036
  { label: "SOUL.md", path: path.join(vaultPath, "SOUL.md") },
@@ -1728,30 +2058,88 @@ async function runUninstall(vaultPath) {
1728
2058
  { label: "Windsurf skills (legacy)", path: path.join(home, ".windsurf", "skills"), dir: true },
1729
2059
  { label: "Windsurf skills", path: path.join(home, ".codeium", "windsurf", "skills"), dir: true },
1730
2060
  { label: "Cline skills", path: path.join(home, ".cline", "skills"), dir: true },
1731
- { 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 },
1732
2062
  { label: "Cross-agent shared skills", path: path.join(home, ".agents", "skills"), dir: true },
1733
2063
  ].filter(p => p.path);
1734
2064
  const skillsExist = skillsPaths.some(p => fs.existsSync(p.path));
1735
2065
  if (skillsExist) categories.push({ id: "skills", name: "Skills", description: "61 curated skill packs", items: skillsPaths });
1736
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
+
1737
2103
  // Commands / Workflows
1738
2104
  const commandsPaths = [
1739
- { label: "Claude Code commands", path: path.join(home, ".claude", "commands"), dir: true },
1740
- { label: "Cursor commands", path: path.join(home, ".cursor", "commands"), dir: true },
1741
- { 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 },
1742
2108
  ];
1743
2109
  const commandsExist = commandsPaths.some(p => fs.existsSync(p.path));
1744
2110
  if (commandsExist) categories.push({ id: "commands", name: "Commands & Workflows", description: "22 slash commands / workflows", items: commandsPaths });
1745
2111
 
1746
2112
  // Hooks
1747
2113
  const hooksPaths = [
1748
- { 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 },
1749
2115
  ];
1750
2116
  const hooksExist = hooksPaths.some(p => fs.existsSync(p.path));
1751
2117
  if (hooksExist) categories.push({ id: "hooks", name: "Hooks", description: "6 Claude Code lifecycle hooks", items: hooksPaths });
1752
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
+
1753
2141
  // Vault structure (only if vault has Mover OS structure)
1754
- if (vaultPath) {
2142
+ if (vaultOwned) {
1755
2143
  const vaultStructurePaths = [
1756
2144
  { label: "00_Inbox/", path: path.join(vaultPath, "00_Inbox"), dir: true },
1757
2145
  { label: "01_Projects/", path: path.join(vaultPath, "01_Projects"), dir: true },
@@ -1765,7 +2153,7 @@ async function runUninstall(vaultPath) {
1765
2153
  }
1766
2154
 
1767
2155
  // Mover OS source folder (if cloned into vault)
1768
- if (vaultPath) {
2156
+ if (vaultOwned) {
1769
2157
  const moverBundlePath = path.join(vaultPath, "01_Projects", "Mover OS Bundle");
1770
2158
  if (fs.existsSync(moverBundlePath)) {
1771
2159
  categories.push({ id: "bundle", name: "Mover OS Bundle", description: "01_Projects/Mover OS Bundle/ source folder", items: [
@@ -1774,16 +2162,18 @@ async function runUninstall(vaultPath) {
1774
2162
  }
1775
2163
  }
1776
2164
 
1777
- // 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).
1778
2169
  const configPaths = [];
1779
2170
  if (vaultPath) {
1780
2171
  configPaths.push(
1781
2172
  { label: ".mover-version", path: path.join(vaultPath, ".mover-version") },
1782
- { label: "Claude settings", path: path.join(vaultPath, ".claude", "settings.json") },
1783
2173
  );
1784
2174
  }
1785
2175
  const configExists = configPaths.some(p => fs.existsSync(p.path));
1786
- 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 });
1787
2177
 
1788
2178
  if (categories.length === 0) {
1789
2179
  barLn(dim("Nothing to remove — Mover OS not detected."));
@@ -1793,6 +2183,16 @@ async function runUninstall(vaultPath) {
1793
2183
  }
1794
2184
 
1795
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
+ }
1796
2196
  barLn(bold("What do you want to uninstall?"));
1797
2197
  barLn();
1798
2198
 
@@ -1842,15 +2242,50 @@ async function runUninstall(vaultPath) {
1842
2242
  const cat = categories.find(c => c.id === catId);
1843
2243
  if (!cat) continue;
1844
2244
  for (const item of cat.items) {
1845
- 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;
1846
2252
  try {
1847
2253
  if (item.dir) {
1848
- 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) {
1849
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;
1850
2267
  if (entry.isDirectory() && fs.existsSync(path.join(item.path, entry.name, "SKILL.md"))) {
1851
2268
  fs.rmSync(path.join(item.path, entry.name), { recursive: true, force: true });
1852
2269
  }
1853
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
1854
2289
  } else {
1855
2290
  fs.rmSync(item.path, { recursive: true, force: true });
1856
2291
  }
@@ -1872,37 +2307,48 @@ async function runUninstall(vaultPath) {
1872
2307
  barLn(dim("Deactivating license..."));
1873
2308
  try {
1874
2309
  const https = require("https");
1875
- // v4.7.6: match activateKey's label scheme (machine_id) so deactivate
1876
- // actually frees the activation slot. v4.7.5 deactivated by hostname,
1877
- // which only worked if the user hadn't renamed their machine since
1878
- // install. Fallback: if machine_id read fails, try hostname (covers
1879
- // pre-v4.7.3 activations that stored hostname).
1880
2310
  const moverDir = path.join(os.homedir(), ".mover");
1881
- let label = os.hostname();
1882
- try { label = getMachineId(moverDir) || label; } catch {}
1883
- const body = JSON.stringify({ key: cfg.licenseKey, organization_id: POLAR_ORG_ID, label });
1884
- await new Promise((resolve, reject) => {
1885
- const req = https.request({
1886
- hostname: "api.polar.sh",
1887
- path: "/v1/customer-portal/license-keys/deactivate",
1888
- method: "POST",
1889
- headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
1890
- timeout: 10000,
1891
- }, (res) => {
1892
- let data = "";
1893
- res.on("data", (chunk) => data += chunk);
1894
- 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();
1895
2332
  });
1896
- req.on("error", reject);
1897
- req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
1898
- req.write(body);
1899
- req.end();
1900
- });
1901
- 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
+ }
1902
2340
  } catch {
1903
- barLn(`${dim("Could not reach Polar license not deactivated")}`);
2341
+ barLn(`${dim("Could not reach Polar - license not deactivated")}`);
1904
2342
  }
1905
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 {}
1906
2352
  // Remove config file but preserve license key for reinstall
1907
2353
  if (cfg.licenseKey) {
1908
2354
  fs.writeFileSync(configPath, JSON.stringify({ licenseKey: cfg.licenseKey }, null, 2), "utf8");
@@ -1947,7 +2393,9 @@ const AGENT_REGISTRY = {
1947
2393
  "claude-code": {
1948
2394
  name: "Claude Code",
1949
2395
  tier: "full",
1950
- 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",
1951
2399
  detect: () => cmdExists("claude") || fs.existsSync(path.join(H, ".claude")),
1952
2400
  rules: { type: "link", dest: () => path.join(H, ".claude", "CLAUDE.md"), header: "# Mover OS Global Rules" },
1953
2401
  skills: { dest: () => path.join(H, ".claude", "skills") },
@@ -1972,7 +2420,7 @@ const AGENT_REGISTRY = {
1972
2420
  "cline": {
1973
2421
  name: "Cline",
1974
2422
  tier: "full",
1975
- tierDesc: "Rules, skills, hooks",
2423
+ tierDesc: "Rules, skills",
1976
2424
  detect: () => globDirExists(path.join(H, ".vscode", "extensions"), "saoudrizwan.claude-dev-*"),
1977
2425
  rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".clinerules", "mover-os.md") },
1978
2426
  skills: { dest: (vault) => path.join(vault || ".", ".cline", "skills") },
@@ -2017,7 +2465,7 @@ const AGENT_REGISTRY = {
2017
2465
  "amazon-q": {
2018
2466
  name: "Amazon Q Developer",
2019
2467
  tier: "full",
2020
- tierDesc: "Rules, JSON agent commands, hooks",
2468
+ tierDesc: "Rules, JSON agent commands",
2021
2469
  detect: () => cmdExists("q") || fs.existsSync(path.join(H, ".aws", "amazonq")),
2022
2470
  rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".amazonq", "rules", "mover-os.md") },
2023
2471
  skills: null,
@@ -2027,18 +2475,22 @@ const AGENT_REGISTRY = {
2027
2475
  "opencode": {
2028
2476
  name: "OpenCode",
2029
2477
  tier: "full",
2030
- tierDesc: "AGENTS.md, agents, commands",
2478
+ tierDesc: "AGENTS.md, skills",
2031
2479
  detect: () => cmdExists("opencode") || fs.existsSync(path.join(".", "opencode.json")),
2032
2480
  sharedRulesFile: "agents-md",
2033
2481
  rules: { type: "agents-md", dest: (vault) => path.join(vault || ".", "AGENTS.md") },
2034
2482
  skills: { dest: (vault) => path.join(vault || ".", ".opencode", "skills") },
2035
- 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,
2036
2488
  hooks: null,
2037
2489
  },
2038
2490
  "kilo-code": {
2039
2491
  name: "Kilo Code",
2040
2492
  tier: "full",
2041
- tierDesc: "Rules, skills, commands, modes",
2493
+ tierDesc: "Rules, skills, commands",
2042
2494
  detect: () => globDirExists(path.join(H, ".vscode", "extensions"), "kilocode.kilo-code-*"),
2043
2495
  rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".kilocode", "rules", "mover-os.md") },
2044
2496
  skills: { dest: (vault) => path.join(vault || ".", ".kilocode", "skills") },
@@ -2050,7 +2502,7 @@ const AGENT_REGISTRY = {
2050
2502
  "codex": {
2051
2503
  name: "Codex",
2052
2504
  tier: "full",
2053
- tierDesc: "AGENTS.md, skills (skills = commands), 5 hooks",
2505
+ tierDesc: "AGENTS.md, skills (skills = commands), hooks",
2054
2506
  detect: () => cmdExists("codex") || fs.existsSync(path.join(H, ".codex")),
2055
2507
  rules: { type: "agents-md", dest: () => path.join(H, ".codex", "AGENTS.md") },
2056
2508
  skills: { dest: () => path.join(H, ".codex", "skills") },
@@ -2177,15 +2629,46 @@ function globDirExists(dir, pattern) {
2177
2629
  }
2178
2630
 
2179
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
+
2180
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}`;
2181
2660
  try {
2182
2661
  fs.mkdirSync(path.dirname(dest), { recursive: true });
2183
- if (fs.existsSync(dest)) fs.unlinkSync(dest);
2184
- 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);
2185
2666
  return "linked";
2186
2667
  } catch {
2668
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
2187
2669
  try {
2188
- fs.copyFileSync(src, dest);
2670
+ fs.copyFileSync(src, tmp);
2671
+ fs.renameSync(tmp, dest);
2189
2672
  if (!linkFallbackWarned) {
2190
2673
  console.log(`\n ${dim("Note: Hard links unavailable — using copies. Edits won't auto-propagate.")}`);
2191
2674
  console.log(` ${dim("Run link.sh after editing source files to re-sync.")}\n`);
@@ -2193,6 +2676,7 @@ function linkOrCopy(src, dest) {
2193
2676
  }
2194
2677
  return "copied";
2195
2678
  } catch {
2679
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
2196
2680
  return null;
2197
2681
  }
2198
2682
  }
@@ -2381,9 +2865,11 @@ Stuck: /debug-resistance
2381
2865
  // ─── Claude Code hooks (settings.json) ──────────────────────────────────────
2382
2866
  // ─── Codex hook config generator ────────────────────────────────────────────
2383
2867
  // v4.7.5: Codex hooks invoked through mover-hook-adapter.js for schema
2384
- // translation. MVP scope: session-start, engine-protection, git-safety,
2385
- // plan-sync-reminder, dirty-tree-guard (no session-log-reminder under Codex
2386
- // — that script is Claude-transcript-specific).
2868
+ // translation.
2869
+ //
2870
+ // v4.7.8: expanded coverage was 5 hooks across 4 events, now 13 hooks across
2871
+ // 5 events. Brings Codex to Claude Code parity on every event Codex supports.
2872
+ // PermissionRequest deferred until adapter handles nested decision schema.
2387
2873
  //
2388
2874
  // IMPORTANT: Codex runs hook commands through cmd.exe on Windows, which does
2389
2875
  // NOT expand $HOME. We resolve absolute paths at install time so the same
@@ -2413,6 +2899,28 @@ function generateCodexHooks() {
2413
2899
  command: `node ${adapter} codex SessionStart ${hookDir}/session-start.sh" resume`,
2414
2900
  timeout: 15,
2415
2901
  },
2902
+ {
2903
+ // v4.7.8: Engine Health surface on session boot — matches the
2904
+ // PreCompact-equivalent hook Claude Code runs.
2905
+ type: "command",
2906
+ command: `node ${adapter} codex SessionStart ${hookDir}/engine-health.sh" full`,
2907
+ timeout: 15,
2908
+ },
2909
+ ],
2910
+ },
2911
+ ],
2912
+ // v4.7.8 NEW: per-prompt enforcement (Single Test reminder, friction floor)
2913
+ UserPromptSubmit: [
2914
+ {
2915
+ hooks: [
2916
+ {
2917
+ type: "command",
2918
+ command: `node ${adapter} codex UserPromptSubmit ${hookDir}/user-prompt-enforce.sh"`,
2919
+ // v4.7.8 audit fix: 4s undercut adapter's 4500ms fallback (mover-hook-adapter.js:65),
2920
+ // host would SIGTERM adapter before its internal timeout fired. 5s gives the
2921
+ // adapter room to surface its own timeout error cleanly.
2922
+ timeout: 5,
2923
+ },
2416
2924
  ],
2417
2925
  },
2418
2926
  ],
@@ -2433,6 +2941,32 @@ function generateCodexHooks() {
2433
2941
  {
2434
2942
  type: "command",
2435
2943
  command: `node ${adapter} codex PreToolUse ${hookDir}/engine-protection.sh"`,
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,
2952
+ },
2953
+ ],
2954
+ },
2955
+ ],
2956
+ // v4.7.8: PermissionRequest event. Schema verified against
2957
+ // https://developers.openai.com/codex/hooks — Codex expects nested
2958
+ // {decision:{behavior,message?}} shape, which engine-permission-request.sh
2959
+ // already emits. Adapter (mover-hook-adapter.js v4.7.8) recognizes the
2960
+ // shape via isPermissionBehavior and passes through with allowlisting.
2961
+ // Strategy.md and Identity_Prime.md pass through to manual dialog;
2962
+ // Daily Notes and non-Engine files auto-allow.
2963
+ PermissionRequest: [
2964
+ {
2965
+ matcher: "Edit|Write|apply_patch",
2966
+ hooks: [
2967
+ {
2968
+ type: "command",
2969
+ command: `node ${adapter} codex PermissionRequest ${hookDir}/engine-permission-request.sh"`,
2436
2970
  timeout: 5,
2437
2971
  },
2438
2972
  ],
@@ -2447,16 +2981,50 @@ function generateCodexHooks() {
2447
2981
  command: `node ${adapter} codex PostToolUse ${hookDir}/plan-sync-reminder.sh"`,
2448
2982
  timeout: 5,
2449
2983
  },
2984
+ {
2985
+ // v4.7.8: auto-commit Engine edits. Multi-agent safe via flock
2986
+ // (see engine-auto-commit.sh:62-89). Silent on non-Engine files.
2987
+ type: "command",
2988
+ command: `node ${adapter} codex PostToolUse ${hookDir}/engine-auto-commit.sh"`,
2989
+ timeout: 10,
2990
+ },
2450
2991
  ],
2451
2992
  },
2452
2993
  ],
2453
2994
  Stop: [
2454
2995
  {
2996
+ // v4.7.8: 6 sequential hooks. Order matters — cheap/important first
2997
+ // so they complete before any per-event budget exhausts.
2455
2998
  hooks: [
2456
2999
  {
2457
3000
  type: "command",
2458
3001
  command: `node ${adapter} codex Stop ${hookDir}/dirty-tree-guard.sh"`,
2459
- timeout: 10,
3002
+ timeout: 8,
3003
+ },
3004
+ {
3005
+ type: "command",
3006
+ command: `node ${adapter} codex Stop ${hookDir}/session-log-reminder.sh"`,
3007
+ timeout: 8,
3008
+ },
3009
+ {
3010
+ type: "command",
3011
+ command: `node ${adapter} codex Stop ${hookDir}/context-staleness.sh"`,
3012
+ timeout: 5,
3013
+ },
3014
+ {
3015
+ type: "command",
3016
+ command: `node ${adapter} codex Stop ${hookDir}/iteration-check.sh"`,
3017
+ timeout: 5,
3018
+ },
3019
+ {
3020
+ type: "command",
3021
+ command: `node ${adapter} codex Stop ${hookDir}/engine-health.sh" summary`,
3022
+ timeout: 8,
3023
+ },
3024
+ {
3025
+ type: "command",
3026
+ command: `node ${adapter} codex Stop ${hookDir}/clock.sh"`,
3027
+ timeout: 2,
2460
3028
  },
2461
3029
  ],
2462
3030
  },
@@ -2505,7 +3073,13 @@ function generateGeminiHooks() {
2505
3073
  name: "mover-engine-protection",
2506
3074
  type: "command",
2507
3075
  command: `node ${adapter} gemini BeforeTool ${hookDir}/engine-protection.sh"`,
2508
- 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,
2509
3083
  },
2510
3084
  ],
2511
3085
  },
@@ -2550,64 +3124,121 @@ function generateGeminiHooks() {
2550
3124
  }
2551
3125
 
2552
3126
  function generateClaudeSettings() {
3127
+ // v4.7.9 (S3 ship-blocker): resolve absolute, forward-slash hook paths at
3128
+ // install time instead of the literal `$HOME`. On Windows, Claude Code runs
3129
+ // Stop/PreToolUse hooks through cmd.exe, which does NOT expand `$HOME` — so
3130
+ // `bash "$HOME/.claude/hooks/X.sh"` resolved to a nonexistent literal path
3131
+ // and EVERY guardrail hook (engine-protection, git-safety, session-start…)
3132
+ // was silently dead on Windows. This is the SAME fix already proven in
3133
+ // generateCodexHooks/generateGeminiHooks (see comment above generateCodexHooks):
3134
+ // forward slashes work for `bash` on macOS/Linux/Windows, and an absolute
3135
+ // path removes the cmd.exe `$HOME` dependency. The settings-merge dedup
3136
+ // (basename-based, see line ~3372) is unaffected because it compares the
3137
+ // hook script basename, which is identical for `$HOME/...` and absolute paths.
3138
+ const home = os.homedir();
3139
+ const fwd = (p) => p.split(path.sep).join("/");
3140
+ const hooksRoot = fwd(path.join(home, ".claude", "hooks"));
2553
3141
  return JSON.stringify(
2554
3142
  {
2555
3143
  hooks: {
2556
- Stop: [
3144
+ // ── SessionStart ── full P0-P5 load on startup|clear|resume, volatile reload on
3145
+ // compact. engine-health runs AFTER session-start and self-dedups: on `full`,
3146
+ // session-start.sh emits the staleness summary INLINE and drops a fresh
3147
+ // `mover_health_emitted` marker, so the trailing engine-health.sh exits 0; on
3148
+ // compact/precompact there is no fresh full-marker so it emits. (See the dedup
3149
+ // guard in engine-health.sh — added with the inline emission in 95ed8b40.)
3150
+ SessionStart: [
2557
3151
  {
3152
+ matcher: "startup|clear|resume",
3153
+ hooks: [
3154
+ { type: "command", command: `bash "${hooksRoot}/session-start.sh" full`, timeout: 30 },
3155
+ { type: "command", command: `bash "${hooksRoot}/engine-health.sh" full`, timeout: 20 },
3156
+ ],
3157
+ },
3158
+ {
3159
+ matcher: "compact",
2558
3160
  hooks: [
2559
- { type: "command", command: 'bash "$HOME/.claude/hooks/session-log-reminder.sh"', timeout: 10 },
2560
- { type: "command", command: 'bash "$HOME/.claude/hooks/dirty-tree-guard.sh"', timeout: 10 },
2561
- { type: "command", command: 'bash "$HOME/.claude/hooks/context-staleness.sh"', timeout: 5 },
3161
+ { type: "command", command: `bash "${hooksRoot}/session-start.sh" volatile`, timeout: 15 },
3162
+ { type: "command", command: `bash "${hooksRoot}/engine-health.sh" full`, timeout: 20 },
2562
3163
  ],
2563
3164
  },
2564
3165
  ],
3166
+ // ── UserPromptSubmit ── per-prompt anti-drift enforcement (the [MOVER] status line).
3167
+ // memory-recall.sh / skill-recall.sh are DELIBERATELY NOT wired here: the memory
3168
+ // & recall engine ships DORMANT for new installs (opt-in). Do not "fix" this by
3169
+ // adding them — that absence is intentional, unlike user-prompt-enforce.sh which
3170
+ // was an oversight this generator missed when v4.7.8 added it (wired for Codex +
3171
+ // Gemini but not Claude). Reconciled in v4.8.0.
3172
+ UserPromptSubmit: [
3173
+ {
3174
+ hooks: [{ type: "command", command: `bash "${hooksRoot}/user-prompt-enforce.sh"`, timeout: 5 }],
3175
+ },
3176
+ ],
2565
3177
  PreToolUse: [
2566
3178
  {
2567
3179
  matcher: "Write|Edit",
2568
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/engine-protection.sh"', timeout: 5 }],
3180
+ hooks: [{ type: "command", command: `bash "${hooksRoot}/engine-protection.sh"`, timeout: 5 }],
2569
3181
  },
2570
3182
  {
2571
3183
  matcher: "Bash",
2572
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/git-safety.sh"', timeout: 5 }],
3184
+ hooks: [{ type: "command", command: `bash "${hooksRoot}/git-safety.sh"`, timeout: 5 }],
2573
3185
  },
2574
3186
  ],
2575
3187
  PostToolUse: [
2576
3188
  {
2577
3189
  matcher: "Write|Edit",
2578
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/plan-sync-reminder.sh"', timeout: 5 }],
3190
+ hooks: [
3191
+ { type: "command", command: `bash "${hooksRoot}/plan-sync-reminder.sh"`, timeout: 5 },
3192
+ { type: "command", command: `bash "${hooksRoot}/engine-auto-commit.sh"`, timeout: 10 },
3193
+ ],
2579
3194
  },
2580
3195
  ],
2581
- PreCompact: [
3196
+ Stop: [
2582
3197
  {
2583
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/pre-compact-backup.sh"', timeout: 15 }],
3198
+ hooks: [
3199
+ { type: "command", command: `bash "${hooksRoot}/session-log-reminder.sh"`, timeout: 10 },
3200
+ { type: "command", command: `bash "${hooksRoot}/dirty-tree-guard.sh"`, timeout: 10 },
3201
+ { type: "command", command: `bash "${hooksRoot}/context-staleness.sh"`, timeout: 10 },
3202
+ { type: "command", command: `bash "${hooksRoot}/iteration-check.sh"`, timeout: 10 },
3203
+ { type: "command", command: `bash "${hooksRoot}/engine-health.sh" summary`, timeout: 10 },
3204
+ { type: "command", command: `bash "${hooksRoot}/clock.sh"`, timeout: 2 },
3205
+ ],
2584
3206
  },
2585
3207
  ],
2586
- SessionEnd: [
3208
+ SubagentStart: [
2587
3209
  {
2588
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/session-end.sh"', timeout: 10 }],
3210
+ hooks: [{ type: "command", command: `bash "${hooksRoot}/subagent-context.sh"`, timeout: 5 }],
2589
3211
  },
2590
3212
  ],
2591
- PermissionRequest: [
3213
+ SubagentStop: [
2592
3214
  {
2593
- matcher: "Write|Edit",
2594
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/engine-permission-request.sh"', timeout: 5 }],
3215
+ hooks: [{ type: "command", command: `bash "${hooksRoot}/subagent-validate.sh"`, timeout: 5 }],
2595
3216
  },
2596
3217
  ],
2597
- SessionStart: [
3218
+ PreCompact: [
2598
3219
  {
2599
- matcher: "startup|clear",
2600
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/session-start.sh" full', timeout: 5 }],
3220
+ hooks: [
3221
+ { type: "command", command: `bash "${hooksRoot}/pre-compact-backup.sh"`, timeout: 30 },
3222
+ { type: "command", command: `bash "${hooksRoot}/engine-health.sh" full`, timeout: 10 },
3223
+ ],
2601
3224
  },
3225
+ ],
3226
+ // SessionEnd: memory-index-session.js DELIBERATELY omitted (dormant memory engine).
3227
+ SessionEnd: [
2602
3228
  {
2603
- matcher: "compact",
2604
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/session-start.sh" volatile', timeout: 5 }],
3229
+ hooks: [{ type: "command", command: `bash "${hooksRoot}/session-end.sh"`, timeout: 10 }],
3230
+ },
3231
+ ],
3232
+ PermissionRequest: [
3233
+ {
3234
+ matcher: "Write|Edit",
3235
+ hooks: [{ type: "command", command: `bash "${hooksRoot}/engine-permission-request.sh"`, timeout: 5 }],
2605
3236
  },
2606
3237
  ],
2607
3238
  ConfigChange: [
2608
3239
  {
2609
3240
  matcher: "user_settings",
2610
- hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/config-guard.sh"', timeout: 5 }],
3241
+ hooks: [{ type: "command", command: `bash "${hooksRoot}/config-guard.sh"`, timeout: 5 }],
2611
3242
  },
2612
3243
  ],
2613
3244
  },
@@ -2883,6 +3514,70 @@ function installTemplateFiles(bundleDir, vaultPath) {
2883
3514
  return { count, hashes };
2884
3515
  }
2885
3516
 
3517
+ // S1: install a single Markdown workflow to a Claude-style destination with the
3518
+ // config-first vault-resolution transform applied. Claude Code normally
3519
+ // hard-links workflows so editing src/ propagates live to ~/.claude/commands,
3520
+ // but the bare-git-rev-parse Pre-Flight block (see fixVaultResolution) must be
3521
+ // rewritten to a config-first resolver, and we MUST NOT mutate the shared inode.
3522
+ // So: if the body contains the bare block, write a transformed COPY (live-edit
3523
+ // hard-link is traded for a correct Pre-Flight on those files only); every other
3524
+ // workflow keeps the hard link. The transform is idempotent, so re-running is
3525
+ // safe. Shared by the fresh-install path (installWorkflows) AND the /update path
3526
+ // (direct update.md sync + smart-update loop) so updated workflows get the same
3527
+ // correct Pre-Flight as fresh installs.
3528
+ //
3529
+ // `presetContent` (optional): write/transform THIS string instead of reading
3530
+ // srcPath from disk — used by the smart-update conflict path, where the body to
3531
+ // install is an in-memory auto-merge result that may differ from the source file.
3532
+ //
3533
+ // Gap-3 fail-loud contract: if the body MATCHED the bare block (i.e. it NEEDS
3534
+ // the transform) we MUST emit the corrected copy. If reading/transforming throws
3535
+ // for a body that needs the fix, we DO NOT silently fall back to linkOrCopy
3536
+ // (which would ship the known-broken Pre-Flight) — the error propagates. Bodies
3537
+ // that don't match fall through safely: return false so the caller linkOrCopies.
3538
+ //
3539
+ // Returns true when a transformed copy was written; false when the file does not
3540
+ // need the transform and should be hard-linked/copied verbatim by the caller.
3541
+ function writeWorkflowTransformed(srcPath, destPath, presetContent) {
3542
+ let raw;
3543
+ if (presetContent !== undefined) {
3544
+ raw = presetContent;
3545
+ } else {
3546
+ // A read failure on a real source file is a hard error: we cannot prove the
3547
+ // file doesn't need the transform, so we must not silently linkOrCopy it.
3548
+ raw = fs.readFileSync(srcPath, "utf8");
3549
+ }
3550
+ let fixed;
3551
+ try {
3552
+ fixed = fixVaultResolution(raw);
3553
+ } catch (e) {
3554
+ // Transform itself threw. If the body needs the fix, fail loud; else no-op.
3555
+ if (workflowNeedsVaultFix(raw)) {
3556
+ throw new Error(`Vault-resolution transform failed for ${path.basename(destPath)}: ${e.message}`);
3557
+ }
3558
+ return false;
3559
+ }
3560
+ if (fixed === raw) {
3561
+ // No bare block (or already fixed). If preset content was supplied we still
3562
+ // own the write; otherwise the caller hard-links/copies the source verbatim.
3563
+ if (presetContent !== undefined) {
3564
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
3565
+ fs.writeFileSync(destPath, fixed, "utf8");
3566
+ return true;
3567
+ }
3568
+ return false;
3569
+ }
3570
+ // Defensive: if the transform claims to have changed the body but the bare
3571
+ // block somehow survives, refuse to ship it rather than emit a broken file.
3572
+ if (workflowNeedsVaultFix(fixed)) {
3573
+ throw new Error(`Vault-resolution transform did not remove the bare git-rev-parse block in ${path.basename(destPath)}`);
3574
+ }
3575
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
3576
+ if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
3577
+ fs.writeFileSync(destPath, fixed, "utf8");
3578
+ return true;
3579
+ }
3580
+
2886
3581
  function installWorkflows(bundleDir, destDir, selectedWorkflows) {
2887
3582
  const srcDir = path.join(bundleDir, "src", "workflows");
2888
3583
  if (!fs.existsSync(srcDir)) return 0;
@@ -2892,8 +3587,14 @@ function installWorkflows(bundleDir, destDir, selectedWorkflows) {
2892
3587
  let count = 0;
2893
3588
  for (const file of srcFiles) {
2894
3589
  if (selectedWorkflows && !selectedWorkflows.has(file)) continue;
2895
- if (linkOrCopy(path.join(srcDir, file), path.join(destDir, file))) {
2896
- savePristineCopy("workflows", path.join(srcDir, file));
3590
+ const srcPath = path.join(srcDir, file);
3591
+ const destPath = path.join(destDir, file);
3592
+ // Gap-3: writeWorkflowTransformed throws if a workflow that NEEDS the
3593
+ // Pre-Flight fix fails to transform — we surface that instead of shipping
3594
+ // the broken source via linkOrCopy. Only unmatched workflows fall through.
3595
+ const wrote = writeWorkflowTransformed(srcPath, destPath);
3596
+ if (wrote || linkOrCopy(srcPath, destPath)) {
3597
+ savePristineCopy("workflows", srcPath);
2897
3598
  count++;
2898
3599
  }
2899
3600
  }
@@ -3059,6 +3760,49 @@ function parseWorkflowMd(filePath) {
3059
3760
  return { name, description, body, filePath };
3060
3761
  }
3061
3762
 
3763
+ // v4.7.9 (S1 ship-blocker): workflow Pre-Flight blocks resolve the vault root
3764
+ // with a bare `git rev-parse --show-toplevel`. A LIVE vault is NOT a git repo,
3765
+ // so that command exits non-zero and the agent never learns VAULT_ROOT — every
3766
+ // subsequent `{VAULT_ROOT}/...` read silently fails. The canonical resolution
3767
+ // (mirrored from resolveVaultPath) reads ~/.mover/config.json `vaultPath`
3768
+ // FIRST, then falls back to git, then walks up for 02_Areas/Engine. This
3769
+ // transform rewrites the bare fenced block at INSTALL time so we never mutate
3770
+ // the shared-inode source .md (Claude path uses copy-with-transform; the
3771
+ // converted agents already write copies). Idempotent: returns body unchanged
3772
+ // if the bare block isn't present (already fixed, or a workflow that doesn't
3773
+ // resolve the vault this way).
3774
+ const VAULT_RESOLVE_SNIPPET = [
3775
+ "```bash",
3776
+ "# Resolve VAULT_ROOT — config first (a live vault is NOT a git repo).",
3777
+ 'VAULT_ROOT="$(node -e \'try{console.log(JSON.parse(require("fs").readFileSync(require("os").homedir()+"/.mover/config.json","utf8")).vaultPath||"")}catch(e){}\' 2>/dev/null)"',
3778
+ '[ -z "$VAULT_ROOT" ] && VAULT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"',
3779
+ 'if [ -z "$VAULT_ROOT" ]; then d="$PWD"; while [ "$d" != "/" ]; do [ -d "$d/02_Areas/Engine" ] && VAULT_ROOT="$d" && break; d="$(dirname "$d")"; done; fi',
3780
+ 'echo "$VAULT_ROOT"',
3781
+ "```",
3782
+ ].join("\n");
3783
+
3784
+ // Match the exact bare Pre-Flight block: ```bash\n git rev-parse --show-toplevel \n```
3785
+ // Tolerate surrounding blank lines / trailing whitespace inside the fence. Built
3786
+ // fresh on each call (no shared lastIndex) so .test() is stateless across callers.
3787
+ function bareVaultBlockRe() {
3788
+ return /```bash\s*\n[ \t]*git rev-parse --show-toplevel[ \t]*\n```/;
3789
+ }
3790
+
3791
+ // True when `body` still ships the bare git-rev-parse Pre-Flight block — i.e. it
3792
+ // NEEDS the config-first rewrite. Used by the install paths to decide between a
3793
+ // transformed copy and a verbatim hard-link, and to enforce the Gap-3 fail-loud
3794
+ // contract (a body that needs the fix must never ship untransformed).
3795
+ function workflowNeedsVaultFix(body) {
3796
+ if (typeof body !== "string") return false;
3797
+ return bareVaultBlockRe().test(body);
3798
+ }
3799
+
3800
+ function fixVaultResolution(body) {
3801
+ if (typeof body !== "string") return body;
3802
+ if (!workflowNeedsVaultFix(body)) return body;
3803
+ return body.replace(bareVaultBlockRe(), VAULT_RESOLVE_SNIPPET);
3804
+ }
3805
+
3062
3806
  // Gemini CLI: .md → .toml
3063
3807
  function mdToToml(wf) {
3064
3808
  const desc = wf.description.replace(/"/g, '\\"');
@@ -3125,6 +3869,10 @@ function installWorkflowsConverted(bundleDir, destDir, format, selectedWorkflows
3125
3869
  for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith(".md"))) {
3126
3870
  if (selectedWorkflows && !selectedWorkflows.has(file)) continue;
3127
3871
  const wf = parseWorkflowMd(path.join(srcDir, file));
3872
+ // S1: config-first vault resolution. Safe — these agents always get a
3873
+ // freshly-written copy (no shared inode), so transforming the body here
3874
+ // only changes the emitted file, never the source .md.
3875
+ wf.body = fixVaultResolution(wf.body);
3128
3876
  const converted = conv.fn(wf);
3129
3877
  if (conv.dir) {
3130
3878
  // Directory-based output (e.g., Codex: morning/SKILL.md)
@@ -3196,7 +3944,22 @@ function installSkillPacks(bundleDir, destDir, selectedCategories) {
3196
3944
  continue;
3197
3945
  }
3198
3946
 
3199
- 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
+ }
3200
3963
  copyDirRecursive(skill.path, dest);
3201
3964
  // v4.7.6: stamp file proves Mover OS owns this skill. Used by orphan
3202
3965
  // cleanup below — only stamped skills are deletable on update. User-
@@ -3261,6 +4024,9 @@ function installHooksForClaude(bundleDir, vaultPath) {
3261
4024
  // Read, strip \r (CRLF→LF), write — prevents "command not found" on macOS/Linux
3262
4025
  const content = fs.readFileSync(path.join(hooksSrc, file), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3263
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 {}
3264
4030
  savePristineCopy("hooks", path.join(hooksSrc, file));
3265
4031
  count++;
3266
4032
  }
@@ -3279,23 +4045,105 @@ function installHooksForClaude(bundleDir, vaultPath) {
3279
4045
  try {
3280
4046
  const existing = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
3281
4047
  if (!existing.hooks) existing.hooks = {};
3282
- // Merge each hook event (Stop, PreToolUse, PreCompact)
4048
+
4049
+ // Gap-3 (S3 update path): repair existing Windows-broken hook commands.
4050
+ // A pre-v4.7.9 install wrote `bash "$HOME/.claude/hooks/X.sh" [args]` (the
4051
+ // form cmd.exe can't expand). The basename-only dedup below treats that as
4052
+ // "already present" and would neither add the corrected command NOR fix the
4053
+ // old one — leaving the hook dead on Windows forever. So FIRST rewrite any
4054
+ // existing command that targets one of OUR hook scripts via a $HOME/~/
4055
+ // relative path into the exact absolute-forward-slash form generateClaudeSettings
4056
+ // now emits (matched on the script + trailing-arg tail so the two
4057
+ // session-start variants map to their correct full/volatile replacements).
4058
+ // After rewriting in place, the dedup sees the new form and won't duplicate.
4059
+ const canonicalCmds = [];
4060
+ for (const entries of Object.values(newHooks)) {
4061
+ for (const grp of entries) for (const h of grp.hooks) canonicalCmds.push(h.command);
4062
+ }
4063
+ // Normalized tail signature: `<script>.sh<rest>` with quotes stripped and
4064
+ // backslashes normalized, so a QUOTED canonical command and an UNQUOTED (or
4065
+ // backslash-pathed) old command map to the SAME key. `<rest>` keeps the
4066
+ // trailing arg (` full` / ` volatile`) so the two session-start variants
4067
+ // stay distinct. Matching on the basename (after the last slash, either
4068
+ // direction) means $HOME / %USERPROFILE% / ~ / absolute prefixes all collapse
4069
+ // to the same signature.
4070
+ const tailSig = (cmd) => {
4071
+ if (typeof cmd !== "string") return null;
4072
+ const norm = cmd.replace(/["']/g, "").replace(/\\/g, "/");
4073
+ const m = norm.match(/([^\/\s]+\.sh)(.*)$/);
4074
+ return m ? (m[1] + m[2]).trim() : null;
4075
+ };
4076
+ const canonicalByTail = new Map();
4077
+ for (const cmd of canonicalCmds) {
4078
+ const t = tailSig(cmd);
4079
+ if (t) canonicalByTail.set(t, cmd);
4080
+ }
4081
+ // An "old form" is a command pointing at a .claude/hooks/<script>.sh via a
4082
+ // HOME-relative prefix ($HOME, %USERPROFILE%, or ~), in either slash
4083
+ // direction, quoted or not. Absolute paths and non-Mover commands are NOT
4084
+ // old forms (so they're left untouched).
4085
+ const isOldHomeForm = (cmd) =>
4086
+ typeof cmd === "string" &&
4087
+ /\.claude[\\/]+hooks[\\/]+[^"\\/]*\.sh/.test(cmd) &&
4088
+ /(\$HOME|\$\{HOME\}|%USERPROFILE%|(^|["'\s])~[\\/])/.test(cmd);
4089
+ const rewriteCmd = (cmd) => {
4090
+ if (!isOldHomeForm(cmd)) return cmd;
4091
+ const t = tailSig(cmd);
4092
+ const canon = t && canonicalByTail.get(t);
4093
+ return canon || cmd; // only rewrite scripts we actually own/emit
4094
+ };
4095
+ for (const event of Object.keys(existing.hooks)) {
4096
+ const groups = existing.hooks[event];
4097
+ if (!Array.isArray(groups)) continue;
4098
+ for (const grp of groups) {
4099
+ if (!grp || !Array.isArray(grp.hooks)) continue;
4100
+ for (const h of grp.hooks) {
4101
+ if (h && typeof h.command === "string") h.command = rewriteCmd(h.command);
4102
+ }
4103
+ }
4104
+ }
4105
+
4106
+ // Merge each hook event (Stop, PreToolUse, PreCompact).
4107
+ // Dedup runs AFTER the old-form rewrite above, so a repaired command is now
4108
+ // seen as present and is not re-added.
4109
+ //
4110
+ // v4.7.9: HOOK-LEVEL dedup keyed on a signature = script basename + trailing
4111
+ // args (quotes stripped, slash direction normalized). Three fixes over the
4112
+ // old basename-only check:
4113
+ // • Dedup per individual hook, not per group: a new group is appended with
4114
+ // only the hooks the event is MISSING. The old code (and an interim
4115
+ // group-level version) re-added an entire multi-hook group — e.g. Stop's
4116
+ // 3 hooks — whenever any single hook was absent, duplicating the ones
4117
+ // already present.
4118
+ // • Include the trailing arg in the signature so `session-start.sh full`
4119
+ // and `session-start.sh volatile` are distinct (multi-group event safe).
4120
+ // • Strip ALL quotes from both sides so `…session-start.sh" full` matches
4121
+ // `session-start.sh full` (the old single-quote .replace left the needle
4122
+ // unmatchable → duplicates).
4123
+ const cmdSig = (cmd) => {
4124
+ if (typeof cmd !== "string") return "";
4125
+ const m = cmd.match(/([^\\/"]+\.sh)("?.*)$/);
4126
+ const tail = m ? m[1] + m[2] : cmd;
4127
+ return tail.replace(/["']/g, "").replace(/\\/g, "/").trim();
4128
+ };
3283
4129
  for (const [event, entries] of Object.entries(newHooks)) {
3284
4130
  if (!existing.hooks[event]) {
3285
4131
  existing.hooks[event] = entries;
3286
4132
  } else {
3287
- // Check if our hooks are already registered (by command substring).
3288
- // v4.7.6: split on both / and \\ so the basename extraction works on
3289
- // Windows where command paths use backslashes. The forward-slash-only
3290
- // split returned the entire command string on Windows, which never
3291
- // matched the substring check, leading to duplicate hooks accumulating
3292
- // on every install.
3293
- const existingCmds = JSON.stringify(existing.hooks[event]);
3294
- const alreadyHas = entries[0].hooks.every(
3295
- (h) => existingCmds.includes(h.command.split(/[\\/]/).pop().replace('"', ""))
3296
- );
3297
- if (!alreadyHas) {
3298
- existing.hooks[event].push(...entries);
4133
+ const existingSigs = new Set();
4134
+ for (const grp of existing.hooks[event]) {
4135
+ if (grp && Array.isArray(grp.hooks)) for (const h of grp.hooks) existingSigs.add(cmdSig(h.command));
4136
+ }
4137
+ for (const grp of entries) {
4138
+ // Keep only hooks whose signature isn't already registered for this event.
4139
+ const missing = (grp.hooks || []).filter((h) => !existingSigs.has(cmdSig(h.command)));
4140
+ if (missing.length === 0) continue; // every hook in this group already present
4141
+ // Append a group carrying only the missing hooks (preserve matcher if any).
4142
+ const toAdd = grp.matcher !== undefined
4143
+ ? { matcher: grp.matcher, hooks: missing }
4144
+ : { hooks: missing };
4145
+ existing.hooks[event].push(toAdd);
4146
+ for (const h of missing) existingSigs.add(cmdSig(h.command));
3299
4147
  }
3300
4148
  }
3301
4149
  }
@@ -3339,16 +4187,40 @@ function installHooksForClaude(bundleDir, vaultPath) {
3339
4187
  return count;
3340
4188
  }
3341
4189
 
3342
- // ─── Multi-agent hook installer (v4.7.5) ────────────────────────────────────
3343
- // Copies the MVP hook set (5 enforcement hooks + adapter + shared lib) to the
3344
- // agent's hook directory and writes its hook config. Used by Codex and Gemini.
4190
+ // ─── Multi-agent hook installer (v4.7.5 + v4.7.8 expansion) ─────────────────
4191
+ // Copies the MVP hook set + adapter + shared lib to the agent's hook directory
4192
+ // and writes its hook config. Used by Codex and Gemini.
4193
+ //
4194
+ // v4.7.8: expanded from 5 → 12 hook scripts to bring Codex to Claude Code
4195
+ // parity on UserPromptSubmit (per-prompt anti-drift), PostToolUse
4196
+ // engine-auto-commit, and Stop multi-script coverage (session-log-reminder,
4197
+ // context-staleness, iteration-check, engine-health, clock).
4198
+ //
4199
+ // PermissionRequest deferred: engine-permission-request.sh emits a nested
4200
+ // decision shape that mover-hook-adapter.js:isValidOutput rejects. Wiring it
4201
+ // would silently drop the decision. Adapter extension required first.
3345
4202
  const MVP_HOOK_SCRIPTS = [
4203
+ // SessionStart (2)
3346
4204
  "session-start.sh",
4205
+ "engine-health.sh", // also fires under Stop
4206
+ // UserPromptSubmit (1) — v4.7.8 NEW: per-prompt enforcement injection
4207
+ "user-prompt-enforce.sh",
4208
+ // PreToolUse (2)
3347
4209
  "engine-protection.sh",
3348
4210
  "git-safety.sh",
4211
+ // PermissionRequest (1) — v4.7.8 NEW: nested decision-shape via adapter
4212
+ "engine-permission-request.sh",
4213
+ // PostToolUse (2)
3349
4214
  "plan-sync-reminder.sh",
4215
+ "engine-auto-commit.sh", // v4.7.8 NEW: micro-commit Engine edits
4216
+ // Stop (6) — v4.7.8 NEW: session-log-reminder, context-staleness, iteration-check, clock
3350
4217
  "dirty-tree-guard.sh",
3351
- "mover-lib.sh", // sourced by the others
4218
+ "session-log-reminder.sh",
4219
+ "context-staleness.sh",
4220
+ "iteration-check.sh",
4221
+ "clock.sh",
4222
+ // Shared library (sourced by the others)
4223
+ "mover-lib.sh",
3352
4224
  ];
3353
4225
 
3354
4226
  // Section-aware TOML upsert. Sets [section] key = value, preserving comments,
@@ -3435,6 +4307,9 @@ function copyMvpHooks(bundleDir, destDir) {
3435
4307
  .replace(/\r\n/g, "\n")
3436
4308
  .replace(/\r/g, "\n");
3437
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 {}
3438
4313
  count++;
3439
4314
  }
3440
4315
  // Adapter (Node script — copy preserving binary mode)
@@ -3446,12 +4321,46 @@ function copyMvpHooks(bundleDir, destDir) {
3446
4321
  .replace(/\r\n/g, "\n")
3447
4322
  .replace(/\r/g, "\n");
3448
4323
  fs.writeFileSync(dst, content, { mode: 0o755 });
4324
+ try { fs.chmodSync(dst, 0o755); } catch {}
3449
4325
  count++;
3450
4326
  }
3451
4327
  return count;
3452
4328
  }
3453
4329
 
3454
- 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) {
3455
4364
  const home = os.homedir();
3456
4365
  const codexDir = path.join(home, ".codex");
3457
4366
  const hooksDst = path.join(codexDir, "hooks");
@@ -3493,6 +4402,26 @@ function installHooksForCodex(bundleDir) {
3493
4402
  const configToml = path.join(codexDir, "config.toml");
3494
4403
  upsertTomlKey(configToml, "features", "codex_hooks", "true");
3495
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
+
4410
+ // v4.7.8: Stash a marker so the post-install summary can show the
4411
+ // one-time "trust hooks in Codex IDE" hint. Codex IDE prompts users to
4412
+ // review/trust newly installed or modified hooks (security feature —
4413
+ // hooks run outside the sandbox). There is NO config-level bypass per
4414
+ // https://developers.openai.com/codex/hooks. CLI uses hooks.json
4415
+ // directly without the trust gate, so Mover OS works fine via CLI.
4416
+ // The trust prompt only affects users running Codex IDE.
4417
+ try {
4418
+ fs.writeFileSync(
4419
+ path.join(codexDir, ".mover-hook-install-stamp"),
4420
+ new Date().toISOString() + "\n",
4421
+ "utf8"
4422
+ );
4423
+ } catch {}
4424
+
3496
4425
  return count;
3497
4426
  }
3498
4427
 
@@ -3618,16 +4547,20 @@ function installClaudeCode(bundleDir, vaultPath, skillOpts) {
3618
4547
  if (fs.existsSync(statusSrc)) {
3619
4548
  fs.copyFileSync(statusSrc, statusDst);
3620
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, "/")}"`;
3621
4554
  try {
3622
4555
  const settings = fs.existsSync(globalSettings)
3623
4556
  ? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
3624
4557
  : {};
3625
- settings.statusLine = { type: "command", command: "node ~/.claude/statusline.js" };
4558
+ settings.statusLine = { type: "command", command: statusCmd };
3626
4559
  fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
3627
4560
  } catch {
3628
4561
  fs.writeFileSync(
3629
4562
  globalSettings,
3630
- JSON.stringify({ statusLine: { type: "command", command: "node ~/.claude/statusline.js" } }, null, 2),
4563
+ JSON.stringify({ statusLine: { type: "command", command: statusCmd } }, null, 2),
3631
4564
  "utf8"
3632
4565
  );
3633
4566
  }
@@ -3724,8 +4657,12 @@ function installCodex(bundleDir, vaultPath, skillOpts) {
3724
4657
 
3725
4658
  const codexDir = path.join(home, ".codex");
3726
4659
  fs.mkdirSync(codexDir, { recursive: true });
3727
- // Codex CLI uses AGENTS.md (not instructions.md, not raw Global Rules)
3728
- 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");
3729
4666
  steps.push("AGENTS.md");
3730
4667
 
3731
4668
  // V5 FIX: In Codex, skills ARE commands — install workflows as SKILL.md dirs
@@ -3740,7 +4677,7 @@ function installCodex(bundleDir, vaultPath, skillOpts) {
3740
4677
 
3741
4678
  // v4.7.5: Native Codex hook support via mover-hook-adapter.js
3742
4679
  if (!skillOpts?.skipHooks) {
3743
- const hkCount = installHooksForCodex(bundleDir);
4680
+ const hkCount = installHooksForCodex(bundleDir, vaultPath);
3744
4681
  if (hkCount > 0) steps.push(`${hkCount} hooks`);
3745
4682
  }
3746
4683
 
@@ -3936,6 +4873,9 @@ function installCopilot(bundleDir, vaultPath, skillOpts) {
3936
4873
  fs.mkdirSync(ghDir, { recursive: true });
3937
4874
  const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
3938
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"));
3939
4879
  fs.copyFileSync(src, path.join(ghDir, "copilot-instructions.md"));
3940
4880
  steps.push("rules");
3941
4881
  }
@@ -3987,6 +4927,7 @@ function installFromRegistry(bundleDir, vaultPath, skillOpts, writtenFiles, id)
3987
4927
  const sharedKey = reg.sharedRulesFile || id;
3988
4928
  if (!writtenFiles || !writtenFiles.has(destPath)) {
3989
4929
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
4930
+ backupForeignFile(destPath);
3990
4931
  fs.writeFileSync(destPath, generateAgentsMd(), "utf8");
3991
4932
  if (writtenFiles) writtenFiles.add(destPath);
3992
4933
  steps.push("AGENTS.md");
@@ -4003,6 +4944,7 @@ function installFromRegistry(bundleDir, vaultPath, skillOpts, writtenFiles, id)
4003
4944
  const s5idx = content.indexOf("\n## 5.");
4004
4945
  if (s5idx > 0) content = content.substring(0, s5idx);
4005
4946
  content = "# CONVENTIONS.md\n\n" + content.replace(/^# Mover OS Global Rules/m, "").trim() + "\n";
4947
+ backupForeignFile(destPath);
4006
4948
  fs.writeFileSync(destPath, content, "utf8");
4007
4949
  steps.push("CONVENTIONS.md");
4008
4950
  }
@@ -4093,6 +5035,11 @@ function preflight() {
4093
5035
  // ─── CLI Command Handlers (stubs — implemented progressively) ────────────────
4094
5036
  const CLI_HANDLERS = {
4095
5037
  pulse: async (opts) => { await cmdPulse(opts); },
5038
+ dashboard: async (opts) => { await cmdPulse(opts); }, // alias — opens browser dashboard
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); },
4096
5043
  // warm removed
4097
5044
  capture: async (opts) => { await cmdCapture(opts); },
4098
5045
  who: async (opts) => { await cmdWho(opts); },
@@ -4180,7 +5127,15 @@ async function cmdDoctor(opts) {
4180
5127
  for (const f of engineFiles) {
4181
5128
  if (fs.existsSync(path.join(engineDir, f))) engOk++;
4182
5129
  }
4183
- 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
+ );
4184
5139
 
4185
5140
  // Version
4186
5141
  const vf = path.join(vault, ".mover-version");
@@ -4203,26 +5158,41 @@ async function cmdDoctor(opts) {
4203
5158
  for (const agentId of installedAgents) {
4204
5159
  const reg = AGENT_REGISTRY[agentId];
4205
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.
4206
5164
  const checks = [];
4207
- // Rules
5165
+ let agentOk = true;
4208
5166
  if (reg.rules) {
4209
- const rp = reg.rules.dest(vault);
4210
- 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"));
4211
5170
  }
4212
- // Skills
4213
5171
  if (reg.skills) {
4214
5172
  const sp = reg.skills.dest(vault);
4215
- const hasSkills = fs.existsSync(sp) && fs.readdirSync(sp).length > 0;
4216
- 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"));
4217
5176
  }
4218
- // Commands
4219
5177
  if (reg.commands) {
4220
5178
  const cp = reg.commands.dest(vault);
4221
- const hasCmds = fs.existsSync(cp) && fs.readdirSync(cp).length > 0;
4222
- 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
+ }
4223
5194
  }
4224
- const allOk = checks.every((c) => c === strip(c)); // styled text = problem
4225
- statusLine(allOk ? "ok" : "warn", ` ${reg.name}`, checks.join(", "));
5195
+ statusLine(agentOk ? "ok" : "warn", ` ${reg.name}`, checks.join(", "));
4226
5196
  }
4227
5197
 
4228
5198
  // ── Claude Code skill listing budget (v4.7.7) ────────────────────────
@@ -4273,6 +5243,138 @@ async function cmdDoctor(opts) {
4273
5243
  );
4274
5244
  }
4275
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
+
5343
+ // ── Codex CLI / MCP plugin conflict check (v4.7.8) ────────────────────
5344
+ // Unauthenticated MCP plugins in ~/.codex/config.toml cause codex exec to
5345
+ // hang or exit empty on complex prompts (the Vercel "claude-plugins-official"
5346
+ // plugin is the most common offender — it requires Bearer auth that's never
5347
+ // set, so the MCP transport errors and blocks the LLM response path).
5348
+ // We don't auto-disable user plugins, just surface the issue.
5349
+ const codexConfig = path.join(home, ".codex", "config.toml");
5350
+ if (fs.existsSync(codexConfig)) {
5351
+ barLn();
5352
+ barLn(dim(" Codex:"));
5353
+ const codexToml = fs.readFileSync(codexConfig, "utf8");
5354
+ // Find all [plugins."NAME"] sections with enabled = true
5355
+ const pluginRe = /\[plugins\."([^"]+)"\]\s*\nenabled\s*=\s*true/g;
5356
+ const enabledPlugins = [];
5357
+ let m;
5358
+ while ((m = pluginRe.exec(codexToml)) !== null) enabledPlugins.push(m[1]);
5359
+ // Known plugins that require auth and break Codex CLI when unauthenticated.
5360
+ // Add more here as users report similar issues.
5361
+ const KNOWN_BROKEN_AUTH = new Set([
5362
+ "vercel@claude-plugins-official",
5363
+ ]);
5364
+ const broken = enabledPlugins.filter(p => KNOWN_BROKEN_AUTH.has(p));
5365
+ if (broken.length > 0) {
5366
+ statusLine("warn", " MCP plugin auth", `${broken.length} plugin(s) likely unauthenticated`);
5367
+ for (const p of broken) {
5368
+ barLn(dim(` [plugins."${p}"] enabled=true — auth not configured`));
5369
+ }
5370
+ barLn(dim(` Symptom: codex exec hangs or returns empty on complex prompts.`));
5371
+ barLn(dim(` Fix: edit ~/.codex/config.toml, flip enabled=false for the above plugins`));
5372
+ barLn(dim(` (or configure the missing auth). Mover OS does NOT auto-disable plugins.`));
5373
+ } else if (enabledPlugins.length > 0) {
5374
+ statusLine("ok", " MCP plugin auth", `${enabledPlugins.length} plugin(s), none flagged`);
5375
+ }
5376
+ }
5377
+
4276
5378
  // Engine Git
4277
5379
  barLn();
4278
5380
  const engineGit = fs.existsSync(path.join(engineDir, ".git"));
@@ -4295,8 +5397,396 @@ async function cmdDoctor(opts) {
4295
5397
  barLn(dim(" Fix any issues above, then run /mover-check in your AI agent for deeper validation."));
4296
5398
  }
4297
5399
 
4298
- // ─── moveros pulse ──────────────────────────────────────────────────────────
5400
+ // ─── moveros pulse / dashboard ───────────────────────────────────────────────
5401
+ // Default: opens browser dashboard at http://127.0.0.1:3737. Use `--terminal` for legacy TUI.
4299
5402
  async function cmdPulse(opts) {
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; }
5409
+ const wantTerminal = rest.includes("--terminal");
5410
+ if (!wantTerminal) {
5411
+ const vault = resolveVaultPath(opts.vault);
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
+ }
5418
+ try {
5419
+ const dash = require("./src/dashboard");
5420
+ await dash.run({
5421
+ vault,
5422
+ version: (() => { try { return require("./package.json").version; } catch { return "unknown"; } })(),
5423
+ rest
5424
+ });
5425
+ return;
5426
+ } catch (err) {
5427
+ console.error(dim(` dashboard module error: ${err.message}`));
5428
+ console.error(dim(" falling back to terminal pulse — fix or run with --terminal explicitly"));
5429
+ }
5430
+ }
5431
+ await cmdPulseTerminal(opts);
5432
+ }
5433
+
5434
+ // ─── moveros open ────────────────────────────────────────────────────────────
5435
+ // Ensure the dashboard is running in the background, then open it in the
5436
+ // browser and exit. Used by the desktop launcher (shortcut.js → `node
5437
+ // install.js open`) and by anyone who wants the dashboard without holding a
5438
+ // terminal open. `pulse` is the foreground equivalent.
5439
+ async function cmdOpen(opts, { soft = false, route = "" } = {}) {
5440
+ const vault = resolveVaultPath(opts.vault);
5441
+ if (!vault) {
5442
+ console.error(dim(" Could not resolve vault. Run `moveros doctor` to diagnose."));
5443
+ return;
5444
+ }
5445
+ try {
5446
+ const dash = require("./src/dashboard");
5447
+ await dash.open({
5448
+ vault,
5449
+ version: (() => { try { return require("./package.json").version; } catch { return "unknown"; } })(),
5450
+ rest: opts.rest || [],
5451
+ soft,
5452
+ noOpen: !!opts.noOpen,
5453
+ route,
5454
+ });
5455
+ } catch (err) {
5456
+ console.error(dim(` dashboard module error: ${err.message}`));
5457
+ console.error(dim(" open it manually with: moveros pulse"));
5458
+ }
5459
+ }
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
+
5789
+ async function cmdPulseTerminal(opts) {
4300
5790
  const vault = resolveVaultPath(opts.vault);
4301
5791
  if (!vault) {
4302
5792
  // handled by requireVault()
@@ -4340,7 +5830,9 @@ async function cmdPulse(opts) {
4340
5830
  const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---)/i);
4341
5831
  if (taskSection) {
4342
5832
  const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
4343
- 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;
4344
5836
  const total = tasks.length;
4345
5837
  barLn(` ${progressBar(done, total, 25, "Tasks")}`);
4346
5838
  }
@@ -4394,9 +5886,13 @@ async function cmdPulse(opts) {
4394
5886
  if (fs.existsSync(acLogPath)) {
4395
5887
  try {
4396
5888
  const acContent = fs.readFileSync(acLogPath, "utf8");
4397
- const logMatch = acContent.match(/log_last_run:\s*(\S+)/);
4398
- if (logMatch) {
4399
- 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;
4400
5896
  const agoMin = Math.round(agoMs / 60000);
4401
5897
  const agoH = Math.round(agoMs / 3600000);
4402
5898
  const agoStr = agoMin < 60 ? `${agoMin}m ago` : `${agoH}h ago`;
@@ -4414,7 +5910,11 @@ async function cmdPulse(opts) {
4414
5910
 
4415
5911
  // ─── moveros capture ────────────────────────────────────────────────────────
4416
5912
  async function cmdCapture(opts) {
4417
- 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;
4418
5918
 
4419
5919
  const now = new Date();
4420
5920
  const ymd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
@@ -4476,11 +5976,34 @@ async function cmdCapture(opts) {
4476
5976
  else if (type === "idea") entry = `- **Idea:** ${content} *(${ts})*`;
4477
5977
  else entry = `- ${content} *(${ts})*`;
4478
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
+
4479
6002
  // Append to capture file
4480
- if (!fs.existsSync(capturePath)) {
4481
- 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");
4482
6005
  } else {
4483
- fs.appendFileSync(capturePath, `${entry}\n`, "utf8");
6006
+ fs.appendFileSync(writeTarget, `${entry}\n`, "utf8");
4484
6007
  }
4485
6008
 
4486
6009
  statusLine("ok", "Captured", `${type} → 00_Inbox/`);
@@ -4490,7 +6013,10 @@ async function cmdCapture(opts) {
4490
6013
 
4491
6014
  // ─── moveros who ────────────────────────────────────────────────────────────
4492
6015
  async function cmdWho(opts) {
4493
- 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;
4494
6020
 
4495
6021
  const name = opts.rest.join(" ").trim();
4496
6022
  if (!name) { barLn(yellow("Usage: moveros who <name>")); return; }
@@ -4521,9 +6047,35 @@ async function cmdWho(opts) {
4521
6047
  if (create === "yes") {
4522
6048
  const peopleDir = path.join(entitiesDir, "People");
4523
6049
  fs.mkdirSync(peopleDir, { recursive: true });
4524
- 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`;
4525
- fs.writeFileSync(path.join(peopleDir, `${name}.md`), stub, "utf8");
4526
- 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/`);
4527
6079
  }
4528
6080
  return;
4529
6081
  }
@@ -4545,9 +6097,12 @@ async function cmdWho(opts) {
4545
6097
  // ─── moveros diff ───────────────────────────────────────────────────────────
4546
6098
  async function cmdDiff(opts) {
4547
6099
  const vault = requireVault(opts.vault);
6100
+ if (!vault) return;
4548
6101
  if (!cmdExists("git")) { barLn(red("Git required for moveros diff.")); return; }
4549
6102
 
4550
- 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";
4551
6106
  const days = parseInt(opts.rest.find((a) => a.startsWith("--days="))?.split("=")[1] || "30", 10);
4552
6107
 
4553
6108
  const fileMap = {
@@ -4571,8 +6126,11 @@ async function cmdDiff(opts) {
4571
6126
  const gitCwd = isEngineFile && fs.existsSync(path.join(engineDir, ".git"))
4572
6127
  ? engineDir
4573
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.
4574
6132
  const gitRelPath = isEngineFile && gitCwd === engineDir
4575
- ? path.basename(relPath)
6133
+ ? path.relative(engineDir, fullPath)
4576
6134
  : relPath;
4577
6135
 
4578
6136
  barLn(bold(` ${path.basename(relPath)} — last ${days} days`));
@@ -4605,7 +6163,10 @@ async function cmdDiff(opts) {
4605
6163
 
4606
6164
  // ─── moveros sync ───────────────────────────────────────────────────────────
4607
6165
  async function cmdSync(opts) {
4608
- 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;
4609
6170
  const apply = opts.rest.includes("--apply");
4610
6171
  const home = os.homedir();
4611
6172
 
@@ -4630,7 +6191,32 @@ async function cmdSync(opts) {
4630
6191
  for (const c of candidates) {
4631
6192
  if (fs.existsSync(path.join(c, "src", "workflows"))) { bundleDir = c; break; }
4632
6193
  }
4633
- if (!bundleDir) { barLn(yellow(" Can't find Mover OS source. Run from the bundle directory.")); return; }
6194
+ // v4.7.9 (S6 ship-blocker): download fallback. `moveros sync` was disk-only
6195
+ // it resolved bundleDir from cwd / vault-bundle / __dirname and bailed if none
6196
+ // had src/. npm-global installs have NO src/ on disk (the CLI is just the
6197
+ // published install.js), so every npm-global user hit "Can't find Mover OS
6198
+ // source" and could never sync. Mirror cmdUpdateComprehensive: if a license
6199
+ // key is available, download the payload to ~/.mover and use that as the
6200
+ // source. Only bail when there is no disk source AND no key to download with.
6201
+ if (!bundleDir) {
6202
+ const key = opts.key || cfg.licenseKey;
6203
+ if (key) {
6204
+ const dlSp = spinner("Downloading Mover OS");
6205
+ try {
6206
+ bundleDir = await downloadPayload(key);
6207
+ dlSp.stop(green("Downloaded"));
6208
+ barLn();
6209
+ } catch (err) {
6210
+ dlSp.stop(red("Download failed"));
6211
+ barLn(yellow(` ${err.message}`));
6212
+ return;
6213
+ }
6214
+ } else {
6215
+ barLn(yellow(" Can't find Mover OS source on disk, and no license key to download it."));
6216
+ barLn(dim(" Run from the bundle directory, or set a license key (moveros install) so sync can fetch the payload."));
6217
+ return;
6218
+ }
6219
+ }
4634
6220
 
4635
6221
  // Compare source hash vs installed for each agent
4636
6222
  const srcRulesHash = (() => {
@@ -4686,13 +6272,22 @@ async function cmdSync(opts) {
4686
6272
  // ─── moveros replay ─────────────────────────────────────────────────────────
4687
6273
  async function cmdReplay(opts) {
4688
6274
  const vault = requireVault(opts.vault);
6275
+ if (!vault) return;
4689
6276
 
4690
6277
  const engineDir = path.join(vault, "02_Areas", "Engine");
4691
6278
  let dateStr = opts.rest.find((a) => a.startsWith("--date="))?.split("=")[1];
4692
6279
  let targetDate;
4693
6280
  if (dateStr) {
4694
- const [y, m, d] = dateStr.split("-").map(Number);
4695
- 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
+ }
4696
6291
  } else {
4697
6292
  targetDate = getSessionDate();
4698
6293
  const y = targetDate.getFullYear();
@@ -4738,7 +6333,8 @@ async function cmdReplay(opts) {
4738
6333
  const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##)/i);
4739
6334
  if (taskSection) {
4740
6335
  const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
4741
- 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;
4742
6338
  barLn(` ${progressBar(done, tasks.length, 25, "Completion")}`);
4743
6339
  barLn();
4744
6340
  }
@@ -4748,15 +6344,38 @@ async function cmdReplay(opts) {
4748
6344
  }
4749
6345
 
4750
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
+
4751
6364
  async function cmdContext(opts) {
4752
6365
  const vault = requireVault(opts.vault);
6366
+ if (!vault) return;
4753
6367
 
4754
6368
  const target = opts.rest[0];
4755
6369
  const home = os.homedir();
4756
6370
  const cfgPath = path.join(home, ".mover", "config.json");
4757
- const agents = fs.existsSync(cfgPath)
4758
- ? (JSON.parse(fs.readFileSync(cfgPath, "utf8")).agents || [])
4759
- : [];
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
+ }
4760
6379
 
4761
6380
  if (!target) {
4762
6381
  // Overview: show what each agent actually loads
@@ -4804,8 +6423,8 @@ async function cmdContext(opts) {
4804
6423
  }
4805
6424
  }
4806
6425
 
4807
- // Hooks
4808
- 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"); }
4809
6428
 
4810
6429
  const tokens = Math.round(totalBytes / 4);
4811
6430
  const tokenWarn = tokens > 40000 ? red("heavy") : tokens > 20000 ? yellow("moderate") : green("lean");
@@ -4890,11 +6509,12 @@ async function cmdContext(opts) {
4890
6509
  }
4891
6510
  }
4892
6511
 
4893
- // Hooks
6512
+ // Hooks (terra read F4: parse the real config, not the nonexistent reg.hooks.events)
4894
6513
  if (reg.hooks) {
4895
6514
  barLn();
4896
- statusLine("ok", "Hooks", `${reg.hooks.events?.length || 0} events`);
4897
- 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(", ")}`));
4898
6518
  }
4899
6519
 
4900
6520
  // Token budget summary
@@ -5202,7 +6822,10 @@ async function cmdSettings(opts) {
5202
6822
 
5203
6823
  // ─── moveros backup ─────────────────────────────────────────────────────────
5204
6824
  async function cmdBackup(opts) {
5205
- 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;
5206
6829
  const home = os.homedir();
5207
6830
 
5208
6831
  barLn(bold(" Backup"));
@@ -5218,8 +6841,14 @@ async function cmdBackup(opts) {
5218
6841
  if (!choices || choices.length === 0) { barLn(dim(" Cancelled.")); return; }
5219
6842
 
5220
6843
  const now = new Date();
5221
- 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")}`;
5222
6850
  const backupDir = path.join(vault, "04_Archives", `Backup_${ts}`);
6851
+ if (fs.existsSync(backupDir)) { outro(red("Backup directory collision; try again.")); return; }
5223
6852
  fs.mkdirSync(backupDir, { recursive: true });
5224
6853
 
5225
6854
  const manifest = { timestamp: now.toISOString(), type: "manual", trigger: "moveros backup", categories: choices, files: {} };
@@ -5297,9 +6926,46 @@ async function cmdBackup(opts) {
5297
6926
  barLn();
5298
6927
  }
5299
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
+
5300
6961
  // ─── moveros restore ────────────────────────────────────────────────────────
5301
6962
  async function cmdRestore(opts) {
5302
- 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;
5303
6969
 
5304
6970
  const archivesDir = path.join(vault, "04_Archives");
5305
6971
  if (!fs.existsSync(archivesDir)) { barLn(yellow(" No archives found.")); return; }
@@ -5331,8 +6997,21 @@ async function cmdRestore(opts) {
5331
6997
  return { id: b, name: b, tier: detail || dim("legacy backup") };
5332
6998
  });
5333
6999
 
5334
- const selected = await interactiveSelect(items, { multi: false });
5335
- 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
+ }
5336
7015
 
5337
7016
  const backupPath = path.join(archivesDir, selected);
5338
7017
 
@@ -5375,28 +7054,42 @@ async function cmdRestore(opts) {
5375
7054
  statusLine("ok", "Agents", `${agentCount} items restored`);
5376
7055
  totalRestored = agentCount;
5377
7056
  } else {
5378
- // 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");
5379
7063
  const engPath = path.join(backupPath, "engine");
5380
- if (fs.existsSync(engPath)) {
5381
- const engineDir = path.join(vault, "02_Areas", "Engine");
5382
- 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)) {
5383
7077
  for (const f of fs.readdirSync(engPath).filter((f) => fs.statSync(path.join(engPath, f)).isFile())) {
5384
- fs.copyFileSync(path.join(engPath, f), path.join(engineDir, f));
5385
- restored++;
7078
+ pairs.push({ src: path.join(engPath, f), dest: path.join(engineDir, f) });
5386
7079
  }
5387
- statusLine("ok", "Engine", `${restored} files restored`);
5388
- totalRestored = restored;
5389
7080
  } else {
5390
- // Legacy backup format (files directly in backup dir)
5391
- const engineDir = path.join(vault, "02_Areas", "Engine");
5392
- let restored = 0;
5393
7081
  for (const f of fs.readdirSync(backupPath).filter((f) => f.endsWith(".md") && f !== ".backup-manifest.json")) {
5394
- fs.copyFileSync(path.join(backupPath, f), path.join(engineDir, f));
5395
- restored++;
7082
+ pairs.push({ src: path.join(backupPath, f), dest: path.join(engineDir, f) });
5396
7083
  }
5397
- if (restored > 0) statusLine("ok", "Engine", `${restored} files restored`);
5398
- totalRestored = restored;
5399
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; }
5400
7093
  }
5401
7094
 
5402
7095
  barLn();
@@ -5435,6 +7128,9 @@ async function cmdHelp(opts) {
5435
7128
  w("\n");
5436
7129
  };
5437
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();
5438
7134
  const pages = [
5439
7135
  {
5440
7136
  title: "What is Mover OS?",
@@ -5449,10 +7145,10 @@ async function cmdHelp(opts) {
5449
7145
  "",
5450
7146
  `${dim("Three things make it work:")}`,
5451
7147
  ` ${cyan("1.")} The ${bold("Engine")} — files that store your identity, strategy, goals`,
5452
- ` ${cyan("2.")} ${bold("Workflows")} — 23 commands that run your day (plan, build, log, repeat)`,
5453
- ` ${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`,
5454
7150
  "",
5455
- `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.`,
5456
7152
  `Same context, every editor. ${dim("Press → to continue.")}`,
5457
7153
  ],
5458
7154
  },
@@ -5593,7 +7289,7 @@ async function cmdHelp(opts) {
5593
7289
  ` ${dim("moveros capture --task \"Fix the login bug\"")}`,
5594
7290
  "",
5595
7291
  ` ${cyan("who")} Entity lookup — search People, Orgs, Places`,
5596
- ` ${dim("moveros who \"Ishaaq\" → everything you know about them")}`,
7292
+ ` ${dim("moveros who \"Sarah\" → everything you know about them")}`,
5597
7293
  "",
5598
7294
  ` ${cyan("prayer")} Mosque timetable — next prayer in your status line`,
5599
7295
  ` ${dim("Paste or fetch. Shows countdown. Optional.")}`,
@@ -5829,8 +7525,9 @@ async function cmdMainMenu() {
5829
7525
 
5830
7526
  // Context filter: hide irrelevant commands
5831
7527
  const contextFilter = (cmd) => {
5832
- if (!hasVault && ["pulse", "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;
5833
7529
  if (!hasPrayer && cmd === "prayer") return false;
7530
+ if (cmd === "menubar" && process.platform !== "darwin") return false;
5834
7531
  return true;
5835
7532
  };
5836
7533
 
@@ -5886,7 +7583,430 @@ async function cmdMainMenu() {
5886
7583
  }
5887
7584
 
5888
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
+
5889
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
+
5890
8010
  const isQuick = opts.rest.includes("--quick");
5891
8011
 
5892
8012
  // Step 1: CLI Self-Update
@@ -5910,8 +8030,10 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
5910
8030
  try {
5911
8031
  try {
5912
8032
  execSync("npm i -g mover-os", { stdio: "ignore", timeout: 60000 });
5913
- } catch {
5914
- // 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;
5915
8037
  sp.stop(dim("Needs elevated permissions..."));
5916
8038
  execSync("sudo npm i -g mover-os", { stdio: "inherit", timeout: 120000 });
5917
8039
  }
@@ -5946,6 +8068,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
5946
8068
  }
5947
8069
  statusLine("ok", "Vault", path.basename(vaultPath));
5948
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
+
5949
8087
  // Step 3: Key Validation
5950
8088
  let updateKey = opts.key;
5951
8089
  if (!updateKey) {
@@ -6003,9 +8141,24 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6003
8141
  newVer = pkg.version || newVer;
6004
8142
  } catch {}
6005
8143
 
6006
- // ── Stage only no direct file overwrite ──
6007
- // Files are staged at ~/.mover/src/. The /update AI workflow handles
6008
- // 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");
6009
8162
 
6010
8163
  const detectedAgents = AGENTS.filter((a) => a.detect());
6011
8164
  const selectedIds = detectedAgents.map((a) => a.id);
@@ -6016,8 +8169,34 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6016
8169
  const changes = detectChanges(bundleDir, vaultPath, selectedIds);
6017
8170
  const totalChanged = countChanges(changes);
6018
8171
  displayChangeSummary(changes, installedVer, newVer);
6019
- if (totalChanged === 0) { outro(green("Already up to date.")); return; }
6020
- 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");
6021
8200
  barLn(bold("Updating..."));
6022
8201
  barLn();
6023
8202
  createVaultStructure(vaultPath);
@@ -6038,11 +8217,118 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6038
8217
  sp.stop(steps.length > 0 ? `${displayName} ${dim(steps.join(", "))}` : `${displayName} ${dim("configured")}`);
6039
8218
  }
6040
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");
6041
8312
  fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
6042
8313
  writeMoverConfig(vaultPath, selectedIds, updateKey);
8314
+ journal.completedAt = new Date().toISOString();
8315
+ journalStage(journal, "stamp", "done", { version: require("./package.json").version });
6043
8316
  barLn();
6044
8317
  if (totalChanged > 0) {
6045
8318
  barLn(dim(" Restart your AI session to load updated rules and skills."));
8319
+ // v4.7.8: If Codex hooks were just installed/updated, point users at
8320
+ // the IDE trust step. Codex IDE shows new/modified hooks as "needs
8321
+ // review" — a security feature. CLI fires hooks fine without trust.
8322
+ try {
8323
+ const codexStamp = path.join(os.homedir(), ".codex", ".mover-hook-install-stamp");
8324
+ if (selectedIds.includes("codex") && fs.existsSync(codexStamp)) {
8325
+ const stampedAt = new Date(fs.readFileSync(codexStamp, "utf8").trim()).getTime();
8326
+ if (Date.now() - stampedAt < 5 * 60 * 1000) {
8327
+ barLn(dim(" Codex IDE: open Settings → Hooks, click Trust on the new entries."));
8328
+ barLn(dim(" (One-time per hook change. CLI fires them without the trust gate.)"));
8329
+ }
8330
+ }
8331
+ } catch {}
6046
8332
  barLn();
6047
8333
  }
6048
8334
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -6064,6 +8350,11 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6064
8350
  if (totalChanged === 0) {
6065
8351
  barLn(green(" Already up to date."));
6066
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" });
6067
8358
  outro(green("No changes needed. Engine files may still need migration — run /update."));
6068
8359
  return;
6069
8360
  }
@@ -6078,11 +8369,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6078
8369
  { multi: false, defaultIndex: 0 }
6079
8370
  );
6080
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
+ }
6081
8378
  outro("Update cancelled.");
6082
8379
  return;
6083
8380
  }
6084
8381
  barLn();
6085
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
+
6086
8397
  // ── Apply safe system files directly (no user customizations in these) ──
6087
8398
  const home = os.homedir();
6088
8399
  let appliedCount = 0;
@@ -6108,14 +8419,18 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6108
8419
  if (fs.existsSync(slSrc)) {
6109
8420
  const slContent = fs.readFileSync(slSrc, "utf8").replace(/\r\n/g, "\n");
6110
8421
  fs.writeFileSync(slDest, slContent, "utf8");
6111
- // 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.
6112
8425
  const globalSettings = path.join(home, ".claude", "settings.json");
8426
+ const slCmd = `node "${slDest.replace(/\\/g, "/")}"`;
6113
8427
  try {
6114
8428
  const settings = fs.existsSync(globalSettings)
6115
8429
  ? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
6116
8430
  : {};
6117
- if (!settings.statusLine) {
6118
- 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 };
6119
8434
  fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
6120
8435
  }
6121
8436
  } catch {}
@@ -6134,7 +8449,17 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6134
8449
  for (const dir of cmdDirs) {
6135
8450
  const dest = path.join(dir, "update.md");
6136
8451
  if (fs.existsSync(dir)) {
6137
- fs.copyFileSync(updateSrc, dest);
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/.
8458
+ if (!writeWorkflowTransformed(updateSrc, dest)) {
8459
+ const tmp = dest + ".tmp-" + process.pid;
8460
+ fs.copyFileSync(updateSrc, tmp);
8461
+ fs.renameSync(tmp, dest);
8462
+ }
6138
8463
  }
6139
8464
  }
6140
8465
  statusLine("ok", "/update", "workflow synced");
@@ -6152,7 +8477,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6152
8477
  // ── Smart Update: hash-based change detection + auto-merge ──
6153
8478
  const manifest = loadUpdateManifest();
6154
8479
  const newManifest = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
6155
- const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [];
8480
+ const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [], writeFailed = [];
6156
8481
  let needsUpdate = false;
6157
8482
 
6158
8483
  if (!manifest) {
@@ -6170,13 +8495,26 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6170
8495
  const srcFile = path.join(wfSrcDir, file);
6171
8496
  const destFile = path.join(wfDestDir, file);
6172
8497
  const result = compareForUpdate("workflows", srcFile, destFile, manifest);
8498
+ let wroteFile = true; // false = left on disk unresolved (see manifest guard below)
6173
8499
 
6174
8500
  switch (result.action) {
6175
8501
  case "install":
6176
8502
  case "safe-overwrite":
6177
- linkOrCopy(srcFile, destFile);
6178
- savePristineCopy("workflows", srcFile);
6179
- updated.push(file);
8503
+ // Gap-1: same config-first transform as the fresh-install path. The
8504
+ // smart-update loop previously linkOrCopy'd the raw source, shipping the
8505
+ // bare git-rev-parse Pre-Flight on update. writeWorkflowTransformed
8506
+ // emits a corrected copy for matched files and returns false (→ linkOrCopy)
8507
+ // for the rest, preserving the live-edit hard link where it's safe.
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
+ }
6180
8518
  break;
6181
8519
  case "user-only-skip":
6182
8520
  userOnly.push(file);
@@ -6188,24 +8526,44 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6188
8526
  const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
6189
8527
  fs.mkdirSync(backupDir, { recursive: true });
6190
8528
  if (fs.existsSync(destFile)) fs.copyFileSync(destFile, path.join(backupDir, file));
6191
- linkOrCopy(srcFile, destFile);
6192
- savePristineCopy("workflows", srcFile);
6193
- 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
+ }
6194
8536
  break;
6195
8537
  }
6196
8538
  case "conflict": {
6197
8539
  const mergeResult = tryAutoMerge("workflows", srcFile, destFile);
6198
8540
  if (mergeResult.success) {
6199
- fs.writeFileSync(destFile, mergeResult.content, "utf8");
8541
+ // Transform the MERGED body before writing: an auto-merge can carry the
8542
+ // bare block over from the user's old copy, so a verbatim write would
8543
+ // re-ship the broken Pre-Flight. presetContent feeds the in-memory merge.
8544
+ writeWorkflowTransformed(srcFile, destFile, mergeResult.content);
6200
8545
  savePristineCopy("workflows", srcFile);
6201
8546
  autoMerged.push(file);
6202
8547
  } else {
6203
- 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;
6204
8555
  }
6205
8556
  break;
6206
8557
  }
6207
8558
  }
6208
- 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);
6209
8567
  }
6210
8568
  }
6211
8569
 
@@ -6226,6 +8584,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6226
8584
 
6227
8585
  const destFile = path.join(vaultPath, entryRel);
6228
8586
  const result = compareForUpdate("templates", srcPath, destFile, manifest, relNorm);
8587
+ let wroteTmpl = true;
6229
8588
 
6230
8589
  switch (result.action) {
6231
8590
  case "install":
@@ -6262,12 +8621,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6262
8621
  savePristineCopy("templates", srcPath, relNorm);
6263
8622
  autoMerged.push(relNorm);
6264
8623
  } else {
6265
- 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;
6266
8631
  }
6267
8632
  break;
6268
8633
  }
6269
8634
  }
6270
- 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);
6271
8640
  }
6272
8641
  };
6273
8642
  walkTmplUpdate(tmplSrcDir, "");
@@ -6317,11 +8686,17 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6317
8686
 
6318
8687
  // ── Handle true conflicts → write conflicts.json for /update ──
6319
8688
  if (conflicts.length > 0) {
6320
- const conflictData = conflicts.map(({ name }) => ({
6321
- file: name,
6322
- pristine: path.join(home, ".mover", "installed", "workflows", name),
6323
- userVersion: path.join(home, ".claude", "commands", name),
6324
- 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),
6325
8700
  }));
6326
8701
  const conflictPath = path.join(home, ".mover", "conflicts.json");
6327
8702
  fs.writeFileSync(conflictPath, JSON.stringify({
@@ -6330,12 +8705,14 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6330
8705
  files: conflictData,
6331
8706
  }, null, 2), "utf8");
6332
8707
 
6333
- // 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.
6334
8711
  const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
6335
8712
  fs.mkdirSync(backupDir, { recursive: true });
6336
- for (const { name } of conflicts) {
6337
- const dest = path.join(home, ".claude", "commands", name);
6338
- 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, "__")));
6339
8716
  }
6340
8717
  needsUpdate = true;
6341
8718
  }
@@ -6359,6 +8736,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6359
8736
  }
6360
8737
  }
6361
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
+
6362
8764
  // ── Save updated manifest ──
6363
8765
  // Carry forward entries for files we didn't process (hooks, statusline, /update itself)
6364
8766
  if (manifest && manifest.files) {
@@ -6369,12 +8771,36 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6369
8771
  const hooksDir = path.join(bundleDir, "src", "hooks");
6370
8772
  if (fs.existsSync(hooksDir)) {
6371
8773
  for (const f of fs.readdirSync(hooksDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
6372
- 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
+ }
6373
8785
  }
6374
8786
  }
6375
8787
  saveUpdateManifest(newManifest);
6376
8788
  writeMoverConfig(vaultPath, selectedIds, updateKey);
6377
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
+
6378
8804
  // ── Summary ──
6379
8805
  barLn();
6380
8806
  if (updated.length > 0) statusLine("ok", "Updated", `${updated.length} files`);
@@ -6382,6 +8808,15 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6382
8808
  if (userOnly.length > 0) statusLine("ok", "Preserved", `${userOnly.length} customized files`);
6383
8809
  if (skippedFiles.length > 0) statusLine("ok", "Current", `${skippedFiles.length} files unchanged`);
6384
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
+
6385
8820
  if (conflicts.length > 0) {
6386
8821
  barLn();
6387
8822
  barLn(yellow(` ${conflicts.length} file${conflicts.length > 1 ? "s" : ""} need intelligent merge:`));
@@ -6395,13 +8830,26 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
6395
8830
  barLn();
6396
8831
  if (updated.length > 0 || autoMerged.length > 0) {
6397
8832
  barLn(dim(" Restart your AI session to load updated rules and skills."));
8833
+ // v4.7.8: Codex IDE hook-trust hint (same logic as install path above).
8834
+ try {
8835
+ const codexStamp = path.join(os.homedir(), ".codex", ".mover-hook-install-stamp");
8836
+ if (selectedIds.includes("codex") && fs.existsSync(codexStamp)) {
8837
+ const stampedAt = new Date(fs.readFileSync(codexStamp, "utf8").trim()).getTime();
8838
+ if (Date.now() - stampedAt < 5 * 60 * 1000) {
8839
+ barLn(dim(" Codex IDE: open Settings → Hooks, click Trust on the new entries."));
8840
+ barLn(dim(" (One-time per hook change. CLI fires them without the trust gate.)"));
8841
+ }
8842
+ }
8843
+ } catch {}
6398
8844
  barLn();
6399
8845
  }
6400
8846
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
6401
- if (needsUpdate) {
6402
- 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)`)}`);
6403
8851
  } else {
6404
- 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)`)}`);
6405
8853
  }
6406
8854
  }
6407
8855
 
@@ -6437,23 +8885,95 @@ function requireVault(explicitVault) {
6437
8885
  return v;
6438
8886
  }
6439
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
+
6440
8919
  // ─── Main ───────────────────────────────────────────────────────────────────
6441
8920
  async function main() {
6442
8921
  const opts = parseArgs();
6443
8922
  let bundleDir = path.resolve(__dirname);
6444
8923
  const startTime = Date.now();
6445
8924
 
8925
+ // ── Bare `moveros` — smart default (decided before the branded header so
8926
+ // launching the dashboard is instant, with no logo animation in the way).
8927
+ // Configured machine → open Mover Studio (soft: won't stack tabs on a
8928
+ // reflexive re-run). Fresh machine → fall through to the installer, which
8929
+ // lands the user on the dashboard's onboarding route when it finishes.
8930
+ // `moveros menu` restores the classic interactive launcher. ──
8931
+ if (!opts.command) {
8932
+ const configuredVault = resolveVaultPath(opts.vault);
8933
+ if (configuredVault) {
8934
+ recordCliUsage("open");
8935
+ await cmdOpen(opts, { soft: true });
8936
+ if (!opts.noOpen) ln(dim(" Tools: moveros menu · moveros doctor · moveros update"));
8937
+ return;
8938
+ }
8939
+ opts.command = "install"; // no vault yet → first run goes straight to install
8940
+ }
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
+
6446
8966
  // ── TUI: Enter alternate screen ──
6447
8967
  enterAltScreen();
6448
8968
 
6449
8969
  // ── Intro: Logo plays once ──
6450
8970
  await printHeader();
6451
8971
 
6452
- // ── Route: no command interactive menu (persistent loop) ──
6453
- const lightCommands = ["pulse", "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"];
6454
8973
 
6455
- if (!opts.command) {
6456
- // Interactive loop stay open like a real app
8974
+ // ── `moveros menu` → classic interactive launcher (persistent loop) ──
8975
+ if (opts.command === "menu") {
8976
+ opts.command = "";
6457
8977
  while (true) {
6458
8978
  clearContent();
6459
8979
  ln(compactHeader());
@@ -7016,22 +9536,59 @@ async function main() {
7016
9536
 
7017
9537
  let totalSteps = 0;
7018
9538
 
7019
- // 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
+ };
7020
9571
  const legacyPaths = [
7021
- path.join(vaultPath, "src"), // hooks used to install here
7022
- 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
7023
9574
  ];
7024
- for (const lp of legacyPaths) {
7025
- if (fs.existsSync(lp)) {
7026
- try {
7027
- const stat = fs.statSync(lp);
7028
- if (stat.isDirectory()) {
7029
- fs.rmSync(lp, { recursive: true, force: true });
7030
- } else {
7031
- fs.unlinkSync(lp);
7032
- }
7033
- } catch {}
7034
- }
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 {}
7035
9592
  }
7036
9593
 
7037
9594
  // 1. Vault structure
@@ -7196,15 +9753,54 @@ async function main() {
7196
9753
 
7197
9754
  const agentNames = selectedAgents.map((a) => a.name);
7198
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
+
7199
9762
  // ── What you got ──
7200
9763
  ln(gray(" ─────────────────────────────────────────────"));
7201
9764
  ln();
7202
9765
  ln(` ${bold("What was installed")}`);
7203
9766
  ln();
7204
- ln(` ${green("▸")} ${bold("23")} workflows ${dim("slash commands for daily rhythm, projects, strategy")}`);
7205
- ln(` ${green("▸")} ${bold("62")} skills ${dim("curated packs for dev, marketing, CRO, design")}`);
7206
- if (selectedIds.includes("claude-code")) {
7207
- 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)")}`);
7208
9804
  }
7209
9805
  ln(` ${green("▸")} ${bold(String(selectedAgents.length))} agent${selectedAgents.length > 1 ? "s" : ""} ${dim(agentNames.join(", "))}`);
7210
9806
  ln(` ${green("▸")} PARA vault ${dim("folders, templates, Engine scaffold")}`);
@@ -7213,21 +9809,23 @@ async function main() {
7213
9809
  // ── Next steps (animated slide-in) ──
7214
9810
  ln(gray(" ─────────────────────────────────────────────"));
7215
9811
  ln();
9812
+ // Funnel v2 (owner spec): the dashboard IS the front door. Lead with
9813
+ // moveros; Obsidian is the file viewer, /setup the terminal alternative.
7216
9814
  await slideIn([
7217
9815
  ` ${bold("Next steps")}`,
7218
9816
  "",
7219
- ` ${cyan("1")} Open your vault in ${bold("Obsidian")}`,
7220
- ` ${dim("This is where you view and browse your files")}`,
9817
+ ` ${cyan("1")} Run ${bold(cliCmd)}`,
9818
+ ` ${dim("Opens your dashboard; setup starts there, a few plain questions")}`,
7221
9819
  "",
7222
- ` ${cyan("2")} Open the vault folder in your AI agent`,
7223
- ` ${dim("Installed: " + agentNames.join(", "))}`,
9820
+ ` ${cyan("2")} Open your vault in ${bold("Obsidian")} ${dim("(optional)")}`,
9821
+ ` ${dim("For viewing and browsing your files. Not required to start.")}`,
7224
9822
  "",
7225
9823
  ]);
7226
- ln(` ${cyan("3")} Enable the Obsidian theme`);
9824
+ ln(` ${cyan("3")} Enable the Obsidian theme ${dim("(if using Obsidian)")}`);
7227
9825
  ln(` ${dim("Settings → Appearance → CSS snippets → minimal-theme")}`);
7228
9826
  ln();
7229
- ln(` ${cyan("4")} Run ${bold("/setup")}`);
7230
- ln(` ${dim("Builds your Identity, Strategy, and Goals")}`);
9827
+ ln(` ${cyan("4")} Prefer the terminal? Run ${bold("/setup")} in your agent instead`);
9828
+ ln(` ${dim("The same setup, full depth: Identity, Strategy, Goals, projects")}`);
7231
9829
  ln();
7232
9830
  ln(gray(" ─────────────────────────────────────────────"));
7233
9831
  ln();
@@ -7240,13 +9838,32 @@ async function main() {
7240
9838
  ln();
7241
9839
  ln(` ${bold("CLI commands")} ${dim("(run from any terminal)")}`);
7242
9840
  ln();
7243
- ln(` ${green("▸")} ${bold("moveros pulse")} ${dim("Dashboard energy, tasks, streaks")}`);
7244
- ln(` ${green("▸")} ${bold("moveros doctor")} ${dim("Health check across all agents")}`);
7245
- ln(` ${green("▸")} ${bold("moveros capture")} ${dim("Quick inbox — tasks, links, ideas")}`);
7246
- ln(` ${green("▸")} ${bold("moveros prayer")} ${dim("Set up prayer time reminders")}`);
7247
- ln(` ${green("▸")} ${bold("moveros update")} ${dim("Update agents, rules, and skills")}`);
7248
- ln(` ${green("▸")} ${bold("moveros")} ${dim("Full menu with all commands")}`);
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)")}`);
7249
9847
  ln();
9848
+
9849
+ // ── Land on the dashboard's onboarding route (the payoff) ──
9850
+ // Engine is still templates pre-/setup, so force the cold-start Onboarding
9851
+ // cascade explicitly (?onboarding=1). TTY-only + best-effort: a dashboard
9852
+ // hiccup never fails or blocks the install. `--no-open` opts out (automation).
9853
+ if (!opts.noOpen && process.stdout.isTTY) {
9854
+ ln(gray(" ─────────────────────────────────────────────"));
9855
+ ln();
9856
+ try {
9857
+ const dash = require("./src/dashboard");
9858
+ await dash.open({ vault: vaultPath, version: VERSION, rest: [], soft: false, route: "/?onboarding=1" });
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.")}`);
9862
+ } catch (err) {
9863
+ ln(` ${dim("Your dashboard is ready — open it anytime with")} ${bold(cliCmd)}.`);
9864
+ }
9865
+ ln();
9866
+ }
7250
9867
  }
7251
9868
 
7252
9869
  main().catch((err) => {