mover-os 4.5.6 → 4.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/install.js +494 -39
  2. package/package.json +1 -1
package/install.js CHANGED
@@ -1093,6 +1093,207 @@ function detectChanges(bundleDir, vaultPath, selectedAgentIds) {
1093
1093
  return result;
1094
1094
  }
1095
1095
 
1096
+ // ── Pristine Manifest Helpers (dpkg-style hash detection) ──────────────────
1097
+
1098
+ function fileContentHash(filePath) {
1099
+ const content = fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n");
1100
+ return crypto.createHash("sha256").update(content).digest("hex");
1101
+ }
1102
+
1103
+ function loadUpdateManifest() {
1104
+ const p = path.join(os.homedir(), ".mover", "manifest.json");
1105
+ if (!fs.existsSync(p)) return null;
1106
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return null; }
1107
+ }
1108
+
1109
+ function saveUpdateManifest(manifest) {
1110
+ const p = path.join(os.homedir(), ".mover", "manifest.json");
1111
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1112
+ fs.writeFileSync(p, JSON.stringify(manifest, null, 2), "utf8");
1113
+ }
1114
+
1115
+ function savePristineCopy(category, srcFile) {
1116
+ const pristineDir = path.join(os.homedir(), ".mover", "installed", category);
1117
+ fs.mkdirSync(pristineDir, { recursive: true });
1118
+ const content = fs.readFileSync(srcFile, "utf8").replace(/\r\n/g, "\n");
1119
+ fs.writeFileSync(path.join(pristineDir, path.basename(srcFile)), content, "utf8");
1120
+ }
1121
+
1122
+ function compareForUpdate(category, srcFile, destFile, manifest) {
1123
+ const fileName = `${category}/${path.basename(srcFile)}`;
1124
+ const newHash = fileContentHash(srcFile);
1125
+ const pristineHash = manifest?.files?.[fileName];
1126
+
1127
+ if (!fs.existsSync(destFile)) return { action: "install", newHash };
1128
+
1129
+ if (!pristineHash) {
1130
+ const destHash = fileContentHash(destFile);
1131
+ return destHash === newHash
1132
+ ? { action: "skip", newHash }
1133
+ : { action: "legacy-overwrite", newHash };
1134
+ }
1135
+
1136
+ const destHash = fileContentHash(destFile);
1137
+ const userChanged = destHash !== pristineHash;
1138
+ const upstreamChanged = newHash !== pristineHash;
1139
+
1140
+ if (!userChanged && !upstreamChanged) return { action: "skip", newHash };
1141
+ if (!userChanged && upstreamChanged) return { action: "safe-overwrite", newHash };
1142
+ if (userChanged && !upstreamChanged) return { action: "user-only-skip", newHash };
1143
+ return { action: "conflict", newHash };
1144
+ }
1145
+
1146
+ // ── Three-Way Merge (inlined from node-diff3, MIT license) ─────────────────
1147
+ // bhousel/node-diff3 v3.2.0 — Hunt-McIlroy LCS + diff3 merge
1148
+ // Only the functions needed for three-way file merge are included.
1149
+
1150
+ function _lcs(buffer1, buffer2) {
1151
+ let eq = {};
1152
+ for (let j = 0; j < buffer2.length; j++) {
1153
+ const item = buffer2[j];
1154
+ if (eq[item]) eq[item].push(j); else eq[item] = [j];
1155
+ }
1156
+ const NULL = { buffer1index: -1, buffer2index: -1, chain: null };
1157
+ let cands = [NULL];
1158
+ for (let i = 0; i < buffer1.length; i++) {
1159
+ const b2 = eq[buffer1[i]] || [];
1160
+ let r = 0, c = cands[0];
1161
+ for (let jx = 0; jx < b2.length; jx++) {
1162
+ const j = b2[jx];
1163
+ let s;
1164
+ for (s = r; s < cands.length; s++) {
1165
+ if (cands[s].buffer2index < j && (s === cands.length - 1 || cands[s + 1].buffer2index > j)) break;
1166
+ }
1167
+ if (s < cands.length) {
1168
+ const nc = { buffer1index: i, buffer2index: j, chain: cands[s] };
1169
+ if (r === cands.length) cands.push(c); else cands[r] = c;
1170
+ r = s + 1; c = nc;
1171
+ if (r === cands.length) break;
1172
+ }
1173
+ }
1174
+ cands[r] = c;
1175
+ }
1176
+ return cands[cands.length - 1];
1177
+ }
1178
+
1179
+ function _diffIndices(buffer1, buffer2) {
1180
+ const lcs = _lcs(buffer1, buffer2);
1181
+ let result = [], tail1 = buffer1.length, tail2 = buffer2.length;
1182
+ for (let c = lcs; c !== null; c = c.chain) {
1183
+ const m1 = tail1 - c.buffer1index - 1, m2 = tail2 - c.buffer2index - 1;
1184
+ tail1 = c.buffer1index; tail2 = c.buffer2index;
1185
+ if (m1 || m2) {
1186
+ result.push({
1187
+ buffer1: [tail1 + 1, m1], buffer1Content: buffer1.slice(tail1 + 1, tail1 + 1 + m1),
1188
+ buffer2: [tail2 + 1, m2], buffer2Content: buffer2.slice(tail2 + 1, tail2 + 1 + m2)
1189
+ });
1190
+ }
1191
+ }
1192
+ result.reverse();
1193
+ return result;
1194
+ }
1195
+
1196
+ function _diff3MergeRegions(a, o, b) {
1197
+ let hunks = [];
1198
+ function addHunk(h, ab) {
1199
+ hunks.push({ ab, oStart: h.buffer1[0], oLength: h.buffer1[1], abStart: h.buffer2[0], abLength: h.buffer2[1] });
1200
+ }
1201
+ _diffIndices(o, a).forEach(i => addHunk(i, "a"));
1202
+ _diffIndices(o, b).forEach(i => addHunk(i, "b"));
1203
+ hunks.sort((x, y) => x.oStart - y.oStart);
1204
+
1205
+ let results = [], curr = 0;
1206
+ function advanceTo(end) {
1207
+ if (end > curr) {
1208
+ results.push({ stable: true, buffer: "o", bufferStart: curr, bufferLength: end - curr, bufferContent: o.slice(curr, end) });
1209
+ curr = end;
1210
+ }
1211
+ }
1212
+
1213
+ while (hunks.length) {
1214
+ let hunk = hunks.shift();
1215
+ let rStart = hunk.oStart, rEnd = hunk.oStart + hunk.oLength;
1216
+ let rHunks = [hunk];
1217
+ advanceTo(rStart);
1218
+ while (hunks.length && hunks[0].oStart <= rEnd) {
1219
+ rEnd = Math.max(rEnd, hunks[0].oStart + hunks[0].oLength);
1220
+ rHunks.push(hunks.shift());
1221
+ }
1222
+ if (rHunks.length === 1) {
1223
+ if (hunk.abLength > 0) {
1224
+ const buf = hunk.ab === "a" ? a : b;
1225
+ results.push({ stable: true, buffer: hunk.ab, bufferStart: hunk.abStart, bufferLength: hunk.abLength, bufferContent: buf.slice(hunk.abStart, hunk.abStart + hunk.abLength) });
1226
+ }
1227
+ } else {
1228
+ let bounds = { a: [a.length, -1, o.length, -1], b: [b.length, -1, o.length, -1] };
1229
+ while (rHunks.length) {
1230
+ hunk = rHunks.shift();
1231
+ let bd = bounds[hunk.ab];
1232
+ bd[0] = Math.min(hunk.abStart, bd[0]);
1233
+ bd[1] = Math.max(hunk.abStart + hunk.abLength, bd[1]);
1234
+ bd[2] = Math.min(hunk.oStart, bd[2]);
1235
+ bd[3] = Math.max(hunk.oStart + hunk.oLength, bd[3]);
1236
+ }
1237
+ results.push({
1238
+ stable: false,
1239
+ aStart: bounds.a[0] + (rStart - bounds.a[2]), aLength: (bounds.a[1] + (rEnd - bounds.a[3])) - (bounds.a[0] + (rStart - bounds.a[2])),
1240
+ aContent: a.slice(bounds.a[0] + (rStart - bounds.a[2]), bounds.a[1] + (rEnd - bounds.a[3])),
1241
+ oStart: rStart, oLength: rEnd - rStart, oContent: o.slice(rStart, rEnd),
1242
+ bStart: bounds.b[0] + (rStart - bounds.b[2]), bLength: (bounds.b[1] + (rEnd - bounds.b[3])) - (bounds.b[0] + (rStart - bounds.b[2])),
1243
+ bContent: b.slice(bounds.b[0] + (rStart - bounds.b[2]), bounds.b[1] + (rEnd - bounds.b[3]))
1244
+ });
1245
+ }
1246
+ curr = rEnd;
1247
+ }
1248
+ advanceTo(o.length);
1249
+ return results;
1250
+ }
1251
+
1252
+ function diff3Merge(a, o, b) {
1253
+ let results = [], okBuf = [];
1254
+ function flushOk() { if (okBuf.length) { results.push({ ok: okBuf }); okBuf = []; } }
1255
+ function isFalse(x, y) {
1256
+ if (x.length !== y.length) return false;
1257
+ for (let i = 0; i < x.length; i++) { if (x[i] !== y[i]) return false; }
1258
+ return true;
1259
+ }
1260
+ _diff3MergeRegions(a, o, b).forEach(r => {
1261
+ if (r.stable) { okBuf.push(...r.bufferContent); }
1262
+ else if (isFalse(r.aContent, r.bContent)) { okBuf.push(...r.aContent); }
1263
+ else { flushOk(); results.push({ conflict: { a: r.aContent, o: r.oContent, b: r.bContent } }); }
1264
+ });
1265
+ flushOk();
1266
+ return results;
1267
+ }
1268
+
1269
+ // ── Auto-Merge (uses diff3 + pristine snapshots) ──────────────────────────
1270
+
1271
+ function tryAutoMerge(category, srcFile, destFile) {
1272
+ const pristineDir = path.join(os.homedir(), ".mover", "installed", category);
1273
+ const pristineFile = path.join(pristineDir, path.basename(srcFile));
1274
+ if (!fs.existsSync(pristineFile)) return { success: false, reason: "no-pristine" };
1275
+
1276
+ const base = fs.readFileSync(pristineFile, "utf8").replace(/\r\n/g, "\n");
1277
+ const theirs = fs.readFileSync(srcFile, "utf8").replace(/\r\n/g, "\n");
1278
+ const yours = fs.readFileSync(destFile, "utf8").replace(/\r\n/g, "\n");
1279
+
1280
+ const result = diff3Merge(yours.split("\n"), base.split("\n"), theirs.split("\n"));
1281
+ const hasConflicts = result.some(block => block.conflict);
1282
+
1283
+ if (!hasConflicts) {
1284
+ const merged = result.flatMap(block => block.ok || []);
1285
+ return { success: true, content: merged.join("\n") };
1286
+ }
1287
+
1288
+ return {
1289
+ success: false,
1290
+ reason: "conflict",
1291
+ conflictCount: result.filter(block => block.conflict).length
1292
+ };
1293
+ }
1294
+
1295
+ // ── End Three-Way Merge ────────────────────────────────────────────────────
1296
+
1096
1297
  function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
1097
1298
  const home = os.homedir();
1098
1299
  const targetIds = expandTargetIds(selectedAgentIds);
@@ -1469,9 +1670,14 @@ async function runUninstall(vaultPath) {
1469
1670
  barLn(`${dim("Could not reach Polar — license not deactivated")}`);
1470
1671
  }
1471
1672
  }
1472
- // Remove config file
1473
- fs.unlinkSync(configPath);
1474
- barLn(`${green("\u2713")} ${dim("~/.mover/config.json")}`);
1673
+ // Remove config file but preserve license key for reinstall
1674
+ if (cfg.licenseKey) {
1675
+ fs.writeFileSync(configPath, JSON.stringify({ licenseKey: cfg.licenseKey }, null, 2), "utf8");
1676
+ barLn(`${green("\u2713")} ${dim("~/.mover/config.json (license key preserved for reinstall)")}`);
1677
+ } else {
1678
+ fs.unlinkSync(configPath);
1679
+ barLn(`${green("\u2713")} ${dim("~/.mover/config.json")}`);
1680
+ }
1475
1681
  removed++;
1476
1682
  } catch {}
1477
1683
  }
@@ -1679,13 +1885,14 @@ const AGENT_REGISTRY = {
1679
1885
  },
1680
1886
  };
1681
1887
 
1682
- // User-selectable agents (14 selections). Each maps to 1+ install targets.
1888
+ // User-selectable agents (15 selections). Each maps to 1+ install targets.
1683
1889
  const AGENT_SELECTIONS = [
1684
1890
  { id: "claude-code", targets: ["claude-code"], name: "Claude Code" },
1685
1891
  { id: "cursor", targets: ["cursor"], name: "Cursor" },
1686
1892
  { id: "cline", targets: ["cline"], name: "Cline" },
1687
1893
  { id: "windsurf", targets: ["windsurf"], name: "Windsurf" },
1688
- { id: "gemini-cli", targets: ["gemini-cli", "antigravity"], name: "Gemini CLI + Antigravity" },
1894
+ { id: "gemini-cli", targets: ["gemini-cli"], name: "Gemini CLI" },
1895
+ { id: "antigravity", targets: ["antigravity"], name: "Antigravity" },
1689
1896
  { id: "copilot", targets: ["copilot"], name: "GitHub Copilot" },
1690
1897
  { id: "codex", targets: ["codex"], name: "Codex" },
1691
1898
  { id: "amazon-q", targets: ["amazon-q"], name: "Amazon Q Developer" },
@@ -2256,7 +2463,10 @@ function installWorkflows(bundleDir, destDir, selectedWorkflows) {
2256
2463
  let count = 0;
2257
2464
  for (const file of srcFiles) {
2258
2465
  if (selectedWorkflows && !selectedWorkflows.has(file)) continue;
2259
- if (linkOrCopy(path.join(srcDir, file), path.join(destDir, file))) count++;
2466
+ if (linkOrCopy(path.join(srcDir, file), path.join(destDir, file))) {
2467
+ savePristineCopy("workflows", path.join(srcDir, file));
2468
+ count++;
2469
+ }
2260
2470
  }
2261
2471
 
2262
2472
  // Clean orphaned workflows (renamed/removed in updates)
@@ -2311,6 +2521,9 @@ function installRules(bundleDir, destPath, agentId) {
2311
2521
  const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
2312
2522
  if (!fs.existsSync(src)) return false;
2313
2523
 
2524
+ // Save pristine copy of source rules (pre-customization) for future hash comparison
2525
+ savePristineCopy("system", src);
2526
+
2314
2527
  // Extract any user customizations from existing file before overwriting
2315
2528
  const customizations = extractCustomizations(destPath);
2316
2529
 
@@ -2588,12 +2801,14 @@ function installHooksForClaude(bundleDir, vaultPath) {
2588
2801
  // Read, strip \r (CRLF→LF), write — prevents "command not found" on macOS/Linux
2589
2802
  const content = fs.readFileSync(path.join(hooksSrc, file), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2590
2803
  fs.writeFileSync(dst, content, { mode: 0o755 });
2804
+ savePristineCopy("hooks", path.join(hooksSrc, file));
2591
2805
  count++;
2592
2806
  }
2593
2807
  // Copy hook template files (.md) and scripts (.js)
2594
2808
  for (const file of fs.readdirSync(hooksSrc).filter((f) => f.endsWith(".md") || f.endsWith(".js"))) {
2595
2809
  const dst = path.join(hooksDst, file);
2596
2810
  fs.copyFileSync(path.join(hooksSrc, file), dst);
2811
+ savePristineCopy("hooks", path.join(hooksSrc, file));
2597
2812
  count++;
2598
2813
  }
2599
2814
 
@@ -4884,6 +5099,21 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4884
5099
  return;
4885
5100
  }
4886
5101
 
5102
+ // ── Confirmation gate ──
5103
+ barLn();
5104
+ const updateConfirm = await interactiveSelect(
5105
+ [
5106
+ { id: "yes", name: "Apply updates", tier: `${totalChanged} file${totalChanged > 1 ? "s" : ""} will be updated` },
5107
+ { id: "no", name: "Cancel", tier: "No changes will be made" },
5108
+ ],
5109
+ { multi: false, defaultIndex: 0 }
5110
+ );
5111
+ if (!updateConfirm || updateConfirm === "no") {
5112
+ outro("Update cancelled.");
5113
+ return;
5114
+ }
5115
+ barLn();
5116
+
4887
5117
  // ── Apply safe system files directly (no user customizations in these) ──
4888
5118
  const home = os.homedir();
4889
5119
  let appliedCount = 0;
@@ -4894,7 +5124,9 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4894
5124
  const hooksDest = path.join(home, ".claude", "hooks");
4895
5125
  fs.mkdirSync(hooksDest, { recursive: true });
4896
5126
  for (const file of fs.readdirSync(hooksSrc).filter((f) => f.endsWith(".sh"))) {
4897
- fs.copyFileSync(path.join(hooksSrc, file), path.join(hooksDest, file));
5127
+ const hookContent = fs.readFileSync(path.join(hooksSrc, file), "utf8")
5128
+ .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
5129
+ fs.writeFileSync(path.join(hooksDest, file), hookContent, { mode: 0o755 });
4898
5130
  try { fs.chmodSync(path.join(hooksDest, file), 0o755); } catch {}
4899
5131
  appliedCount++;
4900
5132
  }
@@ -4905,7 +5137,8 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4905
5137
  const slSrc = path.join(bundleDir, "src", "hooks", "statusline.js");
4906
5138
  const slDest = path.join(home, ".claude", "statusline.js");
4907
5139
  if (fs.existsSync(slSrc)) {
4908
- fs.copyFileSync(slSrc, slDest);
5140
+ const slContent = fs.readFileSync(slSrc, "utf8").replace(/\r\n/g, "\n");
5141
+ fs.writeFileSync(slDest, slContent, "utf8");
4909
5142
  // Ensure settings.json has statusLine config
4910
5143
  const globalSettings = path.join(home, ".claude", "settings.json");
4911
5144
  try {
@@ -4945,26 +5178,189 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4945
5178
  // Update version marker
4946
5179
  fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
4947
5180
 
4948
- // Stage confirmation for user-customizable files
4949
- const userFileChanges = changes.workflows.filter((f) => f.status !== "unchanged").length
4950
- + (changes.rules === "changed" ? 1 : 0)
4951
- + (changes.skills || []).filter((f) => f.status !== "unchanged").length;
5181
+ // ── Smart Update: hash-based change detection + auto-merge ──
5182
+ const manifest = loadUpdateManifest();
5183
+ const newManifest = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
5184
+ const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [];
5185
+ let needsUpdate = false;
4952
5186
 
5187
+ if (!manifest) {
5188
+ barLn(dim(" First update with smart change detection — setting up file tracking."));
5189
+ barLn(dim(" Future updates will preserve your customizations automatically."));
5190
+ barLn();
5191
+ }
5192
+
5193
+ // ── Workflows: smart merge per file ──
5194
+ const wfSrcDir = path.join(bundleDir, "src", "workflows");
5195
+ const wfDestDir = path.join(home, ".claude", "commands");
5196
+ if (fs.existsSync(wfSrcDir)) {
5197
+ for (const file of fs.readdirSync(wfSrcDir).filter((f) => f.endsWith(".md"))) {
5198
+ if (file === "update.md") continue; // already synced above
5199
+ const srcFile = path.join(wfSrcDir, file);
5200
+ const destFile = path.join(wfDestDir, file);
5201
+ const result = compareForUpdate("workflows", srcFile, destFile, manifest);
5202
+
5203
+ switch (result.action) {
5204
+ case "install":
5205
+ case "safe-overwrite":
5206
+ linkOrCopy(srcFile, destFile);
5207
+ savePristineCopy("workflows", srcFile);
5208
+ updated.push(file);
5209
+ break;
5210
+ case "user-only-skip":
5211
+ userOnly.push(file);
5212
+ break;
5213
+ case "skip":
5214
+ skippedFiles.push(file);
5215
+ break;
5216
+ case "legacy-overwrite": {
5217
+ const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
5218
+ fs.mkdirSync(backupDir, { recursive: true });
5219
+ if (fs.existsSync(destFile)) fs.copyFileSync(destFile, path.join(backupDir, file));
5220
+ linkOrCopy(srcFile, destFile);
5221
+ savePristineCopy("workflows", srcFile);
5222
+ updated.push(file);
5223
+ break;
5224
+ }
5225
+ case "conflict": {
5226
+ const mergeResult = tryAutoMerge("workflows", srcFile, destFile);
5227
+ if (mergeResult.success) {
5228
+ fs.writeFileSync(destFile, mergeResult.content, "utf8");
5229
+ savePristineCopy("workflows", srcFile);
5230
+ autoMerged.push(file);
5231
+ } else {
5232
+ conflicts.push({ name: file, reason: mergeResult.reason, conflictCount: mergeResult.conflictCount });
5233
+ }
5234
+ break;
5235
+ }
5236
+ }
5237
+ newManifest.files[`workflows/${file}`] = result.newHash;
5238
+ }
5239
+ }
5240
+
5241
+ // ── Rules: keep sentinel merge + track pristine ──
5242
+ if (changes.rules === "changed") {
5243
+ const rulesSrc = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
5244
+ // installRules already handles sentinel merge + pristine save
5245
+ const detectedAgents = AGENTS.filter((a) => a.detect());
5246
+ for (const agent of detectedAgents) {
5247
+ const sel = AGENT_SELECTIONS.find((s) => s.id === agent.id);
5248
+ const targets = sel ? sel.targets : [agent.id];
5249
+ for (const tid of targets) {
5250
+ const reg = AGENT_REGISTRY[tid];
5251
+ if (reg && reg.rules) {
5252
+ const destPath = typeof reg.rules === "function" ? reg.rules() : reg.rules;
5253
+ installRules(bundleDir, destPath, tid);
5254
+ }
5255
+ }
5256
+ }
5257
+ if (fs.existsSync(rulesSrc)) newManifest.files["system/Mover_Global_Rules.md"] = fileContentHash(rulesSrc);
5258
+ updated.push("Global Rules");
5259
+ }
5260
+
5261
+ // ── Skills: use existing hash-based install (already has .skill-manifest.json) ──
5262
+ const skillChanges = (changes.skills || []).filter((f) => f.status !== "unchanged");
5263
+ if (skillChanges.length > 0) {
5264
+ const detectedAgents = AGENTS.filter((a) => a.detect());
5265
+ const writtenFiles = new Set();
5266
+ for (const agent of detectedAgents) {
5267
+ const sel = AGENT_SELECTIONS.find((s) => s.id === agent.id);
5268
+ const targets = sel ? sel.targets : [agent.id];
5269
+ for (const tid of targets) {
5270
+ const reg = AGENT_REGISTRY[tid];
5271
+ if (reg && reg.skills) {
5272
+ const destDir = typeof reg.skills === "function" ? reg.skills() : reg.skills;
5273
+ if (destDir && !writtenFiles.has(destDir)) {
5274
+ installSkills(bundleDir, destDir, null, null);
5275
+ writtenFiles.add(destDir);
5276
+ }
5277
+ }
5278
+ }
5279
+ }
5280
+ updated.push(`${skillChanges.length} skills`);
5281
+ }
5282
+
5283
+ // ── Handle true conflicts → write conflicts.json for /update ──
5284
+ if (conflicts.length > 0) {
5285
+ const conflictData = conflicts.map(({ name }) => ({
5286
+ file: name,
5287
+ pristine: path.join(home, ".mover", "installed", "workflows", name),
5288
+ userVersion: path.join(home, ".claude", "commands", name),
5289
+ newVersion: path.join(bundleDir, "src", "workflows", name),
5290
+ }));
5291
+ const conflictPath = path.join(home, ".mover", "conflicts.json");
5292
+ fs.writeFileSync(conflictPath, JSON.stringify({
5293
+ version: require("./package.json").version,
5294
+ timestamp: new Date().toISOString(),
5295
+ files: conflictData,
5296
+ }, null, 2), "utf8");
5297
+
5298
+ // Backup conflicted user files
5299
+ const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
5300
+ fs.mkdirSync(backupDir, { recursive: true });
5301
+ for (const { name } of conflicts) {
5302
+ const dest = path.join(home, ".claude", "commands", name);
5303
+ if (fs.existsSync(dest)) fs.copyFileSync(dest, path.join(backupDir, name));
5304
+ }
5305
+ needsUpdate = true;
5306
+ }
5307
+
5308
+ // ── Multi-agent propagation (mirrors --quick path) ──
5309
+ const detectedAgentsForProp = AGENTS.filter((a) => a.detect());
5310
+ const propWritten = new Set();
5311
+ for (const agent of detectedAgentsForProp) {
5312
+ if (agent.id === "claude-code") continue;
5313
+ const sel = AGENT_SELECTIONS.find((s) => s.id === agent.id);
5314
+ const targets = sel ? sel.targets : [agent.id];
5315
+ for (const tid of targets) {
5316
+ const fn = AGENT_INSTALLERS[tid];
5317
+ if (!fn || propWritten.has(tid)) continue;
5318
+ try {
5319
+ const sp = spinner(AGENT_REGISTRY[tid]?.name || agent.name);
5320
+ const steps = fn(bundleDir, vaultPath, { install: true, categories: null, workflows: null, statusLine: true }, propWritten, tid);
5321
+ sp.stop(`${AGENT_REGISTRY[tid]?.name || agent.name} ${dim(steps.join(", "))}`);
5322
+ } catch {}
5323
+ propWritten.add(tid);
5324
+ }
5325
+ }
5326
+
5327
+ // ── Save updated manifest ──
5328
+ // Carry forward entries for files we didn't process (hooks, statusline, /update itself)
5329
+ if (manifest && manifest.files) {
5330
+ for (const [key, hash] of Object.entries(manifest.files)) {
5331
+ if (!newManifest.files[key]) newManifest.files[key] = hash;
5332
+ }
5333
+ }
5334
+ const hooksDir = path.join(bundleDir, "src", "hooks");
5335
+ if (fs.existsSync(hooksDir)) {
5336
+ for (const f of fs.readdirSync(hooksDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
5337
+ newManifest.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
5338
+ }
5339
+ }
5340
+ saveUpdateManifest(newManifest);
5341
+ writeMoverConfig(vaultPath, selectedIds);
5342
+
5343
+ // ── Summary ──
4953
5344
  barLn();
4954
- if (userFileChanges > 0) {
4955
- statusLine("ok", "Staged", `${userFileChanges} workflow/rule/skill updates at ${dim("~/.mover/src/")}`);
5345
+ if (updated.length > 0) statusLine("ok", "Updated", `${updated.length} files`);
5346
+ if (autoMerged.length > 0) statusLine("ok", "Auto-merged", `${autoMerged.length} files (your changes + our updates combined)`);
5347
+ if (userOnly.length > 0) statusLine("ok", "Preserved", `${userOnly.length} customized files`);
5348
+ if (skippedFiles.length > 0) statusLine("ok", "Current", `${skippedFiles.length} files unchanged`);
5349
+
5350
+ if (conflicts.length > 0) {
4956
5351
  barLn();
4957
- barLn(bold(" Next step:"));
4958
- barLn(` Run ${bold("/update")} in your AI agent to apply workflow & rule changes.`);
4959
- barLn(dim(" Your customizations will be preserved — new version is the base,"));
4960
- barLn(dim(" your additions get carried forward."));
4961
- } else {
4962
- statusLine("ok", "System files", "all updated");
5352
+ barLn(yellow(` ${conflicts.length} file${conflicts.length > 1 ? "s" : ""} need intelligent merge:`));
5353
+ for (const { name } of conflicts) barLn(` ${yellow("!")} ${name}`);
5354
+ barLn();
5355
+ barLn(dim(" You and we both changed the same sections."));
5356
+ barLn(dim(" Run /update — the AI will merge your changes with ours."));
5357
+ barLn(dim(" Your current versions are backed up."));
4963
5358
  }
5359
+
4964
5360
  barLn();
4965
5361
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
4966
- if (userFileChanges > 0) {
4967
- outro(`System updated. Run ${bold("/update")} for workflows & rules. ${dim(`(${elapsed}s)`)}`);
5362
+ if (needsUpdate) {
5363
+ outro(`System updated. Run ${bold("/update")} to resolve ${conflicts.length} conflict${conflicts.length > 1 ? "s" : ""}. ${dim(`(${elapsed}s)`)}`);
4968
5364
  } else {
4969
5365
  outro(`${green("Done.")} All files up to date. ${dim(`(${elapsed}s)`)}`);
4970
5366
  }
@@ -5302,8 +5698,9 @@ async function main() {
5302
5698
  if (selectedIds.includes("claude-code")) {
5303
5699
  barLn();
5304
5700
  question("Install Claude Code status line?");
5305
- barLn(dim(" Shows model, project, context usage, and session cost in your terminal."));
5306
- barLn(dim(" Example: [Opus 4.6] my-project | Context: 42% | $3.50"));
5701
+ barLn(dim(" Live status bar with model, context %, project, session cost, and Mover OS data."));
5702
+ barLn(dim(" Example: Opus 4.6 · 24% · my-project (main) · 2h14m · $12.50"));
5703
+ barLn(dim(" ▸ next task · 2/5 done · Sleep by 22:00 · logged 30m ago"));
5307
5704
  barLn();
5308
5705
 
5309
5706
  const slChoice = await interactiveSelect(
@@ -5430,30 +5827,33 @@ async function main() {
5430
5827
  }
5431
5828
  }
5432
5829
 
5433
- // ── Settings step — let user configure before install ──
5830
+ // ── Settings step — let user configure before install (loops until esc) ──
5434
5831
  {
5435
5832
  barLn();
5436
- question("Configure settings " + dim("(esc to use defaults)"));
5833
+ question("Configure settings " + dim("(esc to continue with defaults)"));
5437
5834
  barLn();
5438
- const settingsItems = [
5439
- { id: "review_day", name: "review_day Sunday Weekly review day" },
5440
- { id: "track_food", name: "track_food on Track food in daily notes" },
5441
- { id: "track_sleep", name: "track_sleep on Track sleep in daily notes" },
5442
- { id: "friction_level", name: "friction_level 3 Max friction level (1-4)" },
5443
- ];
5444
- const settingsPick = await interactiveSelect(settingsItems);
5445
- if (settingsPick) {
5835
+ let editingSettings = true;
5836
+ while (editingSettings) {
5837
+ const cfgPath = path.join(os.homedir(), ".mover", "config.json");
5838
+ let cfg = {};
5839
+ if (fs.existsSync(cfgPath)) { try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {} }
5840
+ if (!cfg.settings) cfg.settings = {};
5841
+
5842
+ const settingsItems = [
5843
+ { id: "review_day", name: `review_day ${(cfg.settings.review_day || "Sunday").toString().padEnd(12)}Weekly review day` },
5844
+ { id: "track_food", name: `track_food ${(cfg.settings.track_food !== undefined ? (cfg.settings.track_food ? "on" : "off") : "on").padEnd(12)}Track food in daily notes` },
5845
+ { id: "track_sleep", name: `track_sleep ${(cfg.settings.track_sleep !== undefined ? (cfg.settings.track_sleep ? "on" : "off") : "on").padEnd(12)}Track sleep in daily notes` },
5846
+ { id: "friction_level", name: `friction_level ${(cfg.settings.friction_level || 3).toString().padEnd(12)}Max friction level (1-4)` },
5847
+ ];
5848
+ const settingsPick = await interactiveSelect(settingsItems);
5849
+ if (!settingsPick) { editingSettings = false; break; }
5446
5850
  const meta = KNOWN_SETTINGS[settingsPick];
5447
5851
  if (meta) {
5448
- const cfgPath = path.join(os.homedir(), ".mover", "config.json");
5449
- let cfg = {};
5450
- if (fs.existsSync(cfgPath)) { try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {} }
5451
- if (!cfg.settings) cfg.settings = {};
5452
5852
  if (meta.type === "boolean") {
5453
5853
  cfg.settings[settingsPick] = !(cfg.settings[settingsPick] !== undefined ? cfg.settings[settingsPick] : meta.defaults);
5454
5854
  statusLine("ok", settingsPick, cfg.settings[settingsPick] ? "on" : "off");
5455
5855
  } else {
5456
- const answer = await textInput({ label: settingsPick, initial: String(meta.defaults) });
5856
+ const answer = await textInput({ label: settingsPick, initial: String(cfg.settings[settingsPick] !== undefined ? cfg.settings[settingsPick] : meta.defaults) });
5457
5857
  if (answer !== null && answer.trim() !== "") {
5458
5858
  cfg.settings[settingsPick] = meta.type === "number" ? parseInt(answer.trim(), 10) : answer.trim();
5459
5859
  statusLine("ok", settingsPick, JSON.stringify(cfg.settings[settingsPick]));
@@ -5465,6 +5865,39 @@ async function main() {
5465
5865
  }
5466
5866
  }
5467
5867
 
5868
+ // ── Confirmation gate — show what will be installed ──
5869
+ {
5870
+ barLn();
5871
+ question(bold("Review your selections:"));
5872
+ barLn();
5873
+ barLn(` ${bold("Vault:")} ${vaultPath}`);
5874
+ barLn(` ${bold("Agents:")} ${selectedAgents.map((a) => a.name).join(", ")}`);
5875
+ if (installSkills && selectedCategories) {
5876
+ barLn(` ${bold("Skills:")} ${[...selectedCategories].join(", ")}`);
5877
+ } else if (installSkills) {
5878
+ barLn(` ${bold("Skills:")} all categories`);
5879
+ } else {
5880
+ barLn(` ${bold("Skills:")} none`);
5881
+ }
5882
+ if (selectedIds.includes("claude-code")) {
5883
+ barLn(` ${bold("Status line:")} ${installStatusLine ? "yes" : "no"}`);
5884
+ }
5885
+ barLn(` ${bold("Prayer:")} ${prayerSetup ? "yes" : "no"}`);
5886
+ barLn();
5887
+
5888
+ const confirmChoice = await interactiveSelect(
5889
+ [
5890
+ { id: "yes", name: "Install", tier: "Proceed with the selections above" },
5891
+ { id: "no", name: "Cancel", tier: "Go back and start over" },
5892
+ ],
5893
+ { multi: false, defaultIndex: 0 }
5894
+ );
5895
+ if (!confirmChoice || confirmChoice === "no") {
5896
+ outro("Cancelled.");
5897
+ return;
5898
+ }
5899
+ }
5900
+
5468
5901
  // ── Install with animated spinners ──
5469
5902
  barLn();
5470
5903
  question(bold("Installing..."));
@@ -5605,6 +6038,28 @@ async function main() {
5605
6038
  // 9. Write ~/.mover/config.json (both fresh + update)
5606
6039
  writeMoverConfig(vaultPath, selectedIds, key, { prayerSetup });
5607
6040
 
6041
+ // 10. Build update manifest (pristine hashes for smart future updates)
6042
+ try {
6043
+ const mfst = { version: VERSION, installedAt: new Date().toISOString(), files: {} };
6044
+ const wfDir = path.join(bundleDir, "src", "workflows");
6045
+ if (fs.existsSync(wfDir)) {
6046
+ for (const f of fs.readdirSync(wfDir).filter((x) => x.endsWith(".md"))) {
6047
+ mfst.files[`workflows/${f}`] = fileContentHash(path.join(wfDir, f));
6048
+ }
6049
+ }
6050
+ const rulesPath = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
6051
+ if (fs.existsSync(rulesPath)) {
6052
+ mfst.files["system/Mover_Global_Rules.md"] = fileContentHash(rulesPath);
6053
+ }
6054
+ const hooksDir = path.join(bundleDir, "src", "hooks");
6055
+ if (fs.existsSync(hooksDir)) {
6056
+ for (const f of fs.readdirSync(hooksDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
6057
+ mfst.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
6058
+ }
6059
+ }
6060
+ saveUpdateManifest(mfst);
6061
+ } catch {}
6062
+
5608
6063
  barLn();
5609
6064
 
5610
6065
  // ── Done ──
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mover-os",
3
- "version": "4.5.6",
3
+ "version": "4.6.1",
4
4
  "description": "The self-improving OS for AI agents. Turns Obsidian into an execution engine.",
5
5
  "bin": {
6
6
  "moveros": "install.js"