mover-os 4.5.6 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/install.js +414 -17
  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);
@@ -2256,7 +2457,10 @@ function installWorkflows(bundleDir, destDir, selectedWorkflows) {
2256
2457
  let count = 0;
2257
2458
  for (const file of srcFiles) {
2258
2459
  if (selectedWorkflows && !selectedWorkflows.has(file)) continue;
2259
- if (linkOrCopy(path.join(srcDir, file), path.join(destDir, file))) count++;
2460
+ if (linkOrCopy(path.join(srcDir, file), path.join(destDir, file))) {
2461
+ savePristineCopy("workflows", path.join(srcDir, file));
2462
+ count++;
2463
+ }
2260
2464
  }
2261
2465
 
2262
2466
  // Clean orphaned workflows (renamed/removed in updates)
@@ -2311,6 +2515,9 @@ function installRules(bundleDir, destPath, agentId) {
2311
2515
  const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
2312
2516
  if (!fs.existsSync(src)) return false;
2313
2517
 
2518
+ // Save pristine copy of source rules (pre-customization) for future hash comparison
2519
+ savePristineCopy("system", src);
2520
+
2314
2521
  // Extract any user customizations from existing file before overwriting
2315
2522
  const customizations = extractCustomizations(destPath);
2316
2523
 
@@ -2588,12 +2795,14 @@ function installHooksForClaude(bundleDir, vaultPath) {
2588
2795
  // Read, strip \r (CRLF→LF), write — prevents "command not found" on macOS/Linux
2589
2796
  const content = fs.readFileSync(path.join(hooksSrc, file), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2590
2797
  fs.writeFileSync(dst, content, { mode: 0o755 });
2798
+ savePristineCopy("hooks", path.join(hooksSrc, file));
2591
2799
  count++;
2592
2800
  }
2593
2801
  // Copy hook template files (.md) and scripts (.js)
2594
2802
  for (const file of fs.readdirSync(hooksSrc).filter((f) => f.endsWith(".md") || f.endsWith(".js"))) {
2595
2803
  const dst = path.join(hooksDst, file);
2596
2804
  fs.copyFileSync(path.join(hooksSrc, file), dst);
2805
+ savePristineCopy("hooks", path.join(hooksSrc, file));
2597
2806
  count++;
2598
2807
  }
2599
2808
 
@@ -4894,7 +5103,9 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4894
5103
  const hooksDest = path.join(home, ".claude", "hooks");
4895
5104
  fs.mkdirSync(hooksDest, { recursive: true });
4896
5105
  for (const file of fs.readdirSync(hooksSrc).filter((f) => f.endsWith(".sh"))) {
4897
- fs.copyFileSync(path.join(hooksSrc, file), path.join(hooksDest, file));
5106
+ const hookContent = fs.readFileSync(path.join(hooksSrc, file), "utf8")
5107
+ .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
5108
+ fs.writeFileSync(path.join(hooksDest, file), hookContent, { mode: 0o755 });
4898
5109
  try { fs.chmodSync(path.join(hooksDest, file), 0o755); } catch {}
4899
5110
  appliedCount++;
4900
5111
  }
@@ -4905,7 +5116,8 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4905
5116
  const slSrc = path.join(bundleDir, "src", "hooks", "statusline.js");
4906
5117
  const slDest = path.join(home, ".claude", "statusline.js");
4907
5118
  if (fs.existsSync(slSrc)) {
4908
- fs.copyFileSync(slSrc, slDest);
5119
+ const slContent = fs.readFileSync(slSrc, "utf8").replace(/\r\n/g, "\n");
5120
+ fs.writeFileSync(slDest, slContent, "utf8");
4909
5121
  // Ensure settings.json has statusLine config
4910
5122
  const globalSettings = path.join(home, ".claude", "settings.json");
4911
5123
  try {
@@ -4945,26 +5157,189 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
4945
5157
  // Update version marker
4946
5158
  fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
4947
5159
 
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;
5160
+ // ── Smart Update: hash-based change detection + auto-merge ──
5161
+ const manifest = loadUpdateManifest();
5162
+ const newManifest = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
5163
+ const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [];
5164
+ let needsUpdate = false;
5165
+
5166
+ if (!manifest) {
5167
+ barLn(dim(" First update with smart change detection — setting up file tracking."));
5168
+ barLn(dim(" Future updates will preserve your customizations automatically."));
5169
+ barLn();
5170
+ }
5171
+
5172
+ // ── Workflows: smart merge per file ──
5173
+ const wfSrcDir = path.join(bundleDir, "src", "workflows");
5174
+ const wfDestDir = path.join(home, ".claude", "commands");
5175
+ if (fs.existsSync(wfSrcDir)) {
5176
+ for (const file of fs.readdirSync(wfSrcDir).filter((f) => f.endsWith(".md"))) {
5177
+ if (file === "update.md") continue; // already synced above
5178
+ const srcFile = path.join(wfSrcDir, file);
5179
+ const destFile = path.join(wfDestDir, file);
5180
+ const result = compareForUpdate("workflows", srcFile, destFile, manifest);
5181
+
5182
+ switch (result.action) {
5183
+ case "install":
5184
+ case "safe-overwrite":
5185
+ linkOrCopy(srcFile, destFile);
5186
+ savePristineCopy("workflows", srcFile);
5187
+ updated.push(file);
5188
+ break;
5189
+ case "user-only-skip":
5190
+ userOnly.push(file);
5191
+ break;
5192
+ case "skip":
5193
+ skippedFiles.push(file);
5194
+ break;
5195
+ case "legacy-overwrite": {
5196
+ const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
5197
+ fs.mkdirSync(backupDir, { recursive: true });
5198
+ if (fs.existsSync(destFile)) fs.copyFileSync(destFile, path.join(backupDir, file));
5199
+ linkOrCopy(srcFile, destFile);
5200
+ savePristineCopy("workflows", srcFile);
5201
+ updated.push(file);
5202
+ break;
5203
+ }
5204
+ case "conflict": {
5205
+ const mergeResult = tryAutoMerge("workflows", srcFile, destFile);
5206
+ if (mergeResult.success) {
5207
+ fs.writeFileSync(destFile, mergeResult.content, "utf8");
5208
+ savePristineCopy("workflows", srcFile);
5209
+ autoMerged.push(file);
5210
+ } else {
5211
+ conflicts.push({ name: file, reason: mergeResult.reason, conflictCount: mergeResult.conflictCount });
5212
+ }
5213
+ break;
5214
+ }
5215
+ }
5216
+ newManifest.files[`workflows/${file}`] = result.newHash;
5217
+ }
5218
+ }
5219
+
5220
+ // ── Rules: keep sentinel merge + track pristine ──
5221
+ if (changes.rules === "changed") {
5222
+ const rulesSrc = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
5223
+ // installRules already handles sentinel merge + pristine save
5224
+ const detectedAgents = AGENTS.filter((a) => a.detect());
5225
+ for (const agent of detectedAgents) {
5226
+ const sel = AGENT_SELECTIONS.find((s) => s.id === agent.id);
5227
+ const targets = sel ? sel.targets : [agent.id];
5228
+ for (const tid of targets) {
5229
+ const reg = AGENT_REGISTRY[tid];
5230
+ if (reg && reg.rules) {
5231
+ const destPath = typeof reg.rules === "function" ? reg.rules() : reg.rules;
5232
+ installRules(bundleDir, destPath, tid);
5233
+ }
5234
+ }
5235
+ }
5236
+ if (fs.existsSync(rulesSrc)) newManifest.files["system/Mover_Global_Rules.md"] = fileContentHash(rulesSrc);
5237
+ updated.push("Global Rules");
5238
+ }
4952
5239
 
5240
+ // ── Skills: use existing hash-based install (already has .skill-manifest.json) ──
5241
+ const skillChanges = (changes.skills || []).filter((f) => f.status !== "unchanged");
5242
+ if (skillChanges.length > 0) {
5243
+ const detectedAgents = AGENTS.filter((a) => a.detect());
5244
+ const writtenFiles = new Set();
5245
+ for (const agent of detectedAgents) {
5246
+ const sel = AGENT_SELECTIONS.find((s) => s.id === agent.id);
5247
+ const targets = sel ? sel.targets : [agent.id];
5248
+ for (const tid of targets) {
5249
+ const reg = AGENT_REGISTRY[tid];
5250
+ if (reg && reg.skills) {
5251
+ const destDir = typeof reg.skills === "function" ? reg.skills() : reg.skills;
5252
+ if (destDir && !writtenFiles.has(destDir)) {
5253
+ installSkills(bundleDir, destDir, null, null);
5254
+ writtenFiles.add(destDir);
5255
+ }
5256
+ }
5257
+ }
5258
+ }
5259
+ updated.push(`${skillChanges.length} skills`);
5260
+ }
5261
+
5262
+ // ── Handle true conflicts → write conflicts.json for /update ──
5263
+ if (conflicts.length > 0) {
5264
+ const conflictData = conflicts.map(({ name }) => ({
5265
+ file: name,
5266
+ pristine: path.join(home, ".mover", "installed", "workflows", name),
5267
+ userVersion: path.join(home, ".claude", "commands", name),
5268
+ newVersion: path.join(bundleDir, "src", "workflows", name),
5269
+ }));
5270
+ const conflictPath = path.join(home, ".mover", "conflicts.json");
5271
+ fs.writeFileSync(conflictPath, JSON.stringify({
5272
+ version: require("./package.json").version,
5273
+ timestamp: new Date().toISOString(),
5274
+ files: conflictData,
5275
+ }, null, 2), "utf8");
5276
+
5277
+ // Backup conflicted user files
5278
+ const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
5279
+ fs.mkdirSync(backupDir, { recursive: true });
5280
+ for (const { name } of conflicts) {
5281
+ const dest = path.join(home, ".claude", "commands", name);
5282
+ if (fs.existsSync(dest)) fs.copyFileSync(dest, path.join(backupDir, name));
5283
+ }
5284
+ needsUpdate = true;
5285
+ }
5286
+
5287
+ // ── Multi-agent propagation (mirrors --quick path) ──
5288
+ const detectedAgentsForProp = AGENTS.filter((a) => a.detect());
5289
+ const propWritten = new Set();
5290
+ for (const agent of detectedAgentsForProp) {
5291
+ if (agent.id === "claude-code") continue;
5292
+ const sel = AGENT_SELECTIONS.find((s) => s.id === agent.id);
5293
+ const targets = sel ? sel.targets : [agent.id];
5294
+ for (const tid of targets) {
5295
+ const fn = AGENT_INSTALLERS[tid];
5296
+ if (!fn || propWritten.has(tid)) continue;
5297
+ try {
5298
+ const sp = spinner(AGENT_REGISTRY[tid]?.name || agent.name);
5299
+ const steps = fn(bundleDir, vaultPath, { install: true, categories: null, workflows: null, statusLine: true }, propWritten, tid);
5300
+ sp.stop(`${AGENT_REGISTRY[tid]?.name || agent.name} ${dim(steps.join(", "))}`);
5301
+ } catch {}
5302
+ propWritten.add(tid);
5303
+ }
5304
+ }
5305
+
5306
+ // ── Save updated manifest ──
5307
+ // Carry forward entries for files we didn't process (hooks, statusline, /update itself)
5308
+ if (manifest && manifest.files) {
5309
+ for (const [key, hash] of Object.entries(manifest.files)) {
5310
+ if (!newManifest.files[key]) newManifest.files[key] = hash;
5311
+ }
5312
+ }
5313
+ const hooksDir = path.join(bundleDir, "src", "hooks");
5314
+ if (fs.existsSync(hooksDir)) {
5315
+ for (const f of fs.readdirSync(hooksDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
5316
+ newManifest.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
5317
+ }
5318
+ }
5319
+ saveUpdateManifest(newManifest);
5320
+ writeMoverConfig(vaultPath, selectedIds);
5321
+
5322
+ // ── Summary ──
4953
5323
  barLn();
4954
- if (userFileChanges > 0) {
4955
- statusLine("ok", "Staged", `${userFileChanges} workflow/rule/skill updates at ${dim("~/.mover/src/")}`);
5324
+ if (updated.length > 0) statusLine("ok", "Updated", `${updated.length} files`);
5325
+ if (autoMerged.length > 0) statusLine("ok", "Auto-merged", `${autoMerged.length} files (your changes + our updates combined)`);
5326
+ if (userOnly.length > 0) statusLine("ok", "Preserved", `${userOnly.length} customized files`);
5327
+ if (skippedFiles.length > 0) statusLine("ok", "Current", `${skippedFiles.length} files unchanged`);
5328
+
5329
+ if (conflicts.length > 0) {
4956
5330
  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");
5331
+ barLn(yellow(` ${conflicts.length} file${conflicts.length > 1 ? "s" : ""} need intelligent merge:`));
5332
+ for (const { name } of conflicts) barLn(` ${yellow("!")} ${name}`);
5333
+ barLn();
5334
+ barLn(dim(" You and we both changed the same sections."));
5335
+ barLn(dim(" Run /update — the AI will merge your changes with ours."));
5336
+ barLn(dim(" Your current versions are backed up."));
4963
5337
  }
5338
+
4964
5339
  barLn();
4965
5340
  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)`)}`);
5341
+ if (needsUpdate) {
5342
+ outro(`System updated. Run ${bold("/update")} to resolve ${conflicts.length} conflict${conflicts.length > 1 ? "s" : ""}. ${dim(`(${elapsed}s)`)}`);
4968
5343
  } else {
4969
5344
  outro(`${green("Done.")} All files up to date. ${dim(`(${elapsed}s)`)}`);
4970
5345
  }
@@ -5605,6 +5980,28 @@ async function main() {
5605
5980
  // 9. Write ~/.mover/config.json (both fresh + update)
5606
5981
  writeMoverConfig(vaultPath, selectedIds, key, { prayerSetup });
5607
5982
 
5983
+ // 10. Build update manifest (pristine hashes for smart future updates)
5984
+ try {
5985
+ const mfst = { version: VERSION, installedAt: new Date().toISOString(), files: {} };
5986
+ const wfDir = path.join(bundleDir, "src", "workflows");
5987
+ if (fs.existsSync(wfDir)) {
5988
+ for (const f of fs.readdirSync(wfDir).filter((x) => x.endsWith(".md"))) {
5989
+ mfst.files[`workflows/${f}`] = fileContentHash(path.join(wfDir, f));
5990
+ }
5991
+ }
5992
+ const rulesPath = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
5993
+ if (fs.existsSync(rulesPath)) {
5994
+ mfst.files["system/Mover_Global_Rules.md"] = fileContentHash(rulesPath);
5995
+ }
5996
+ const hooksDir = path.join(bundleDir, "src", "hooks");
5997
+ if (fs.existsSync(hooksDir)) {
5998
+ for (const f of fs.readdirSync(hooksDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
5999
+ mfst.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
6000
+ }
6001
+ }
6002
+ saveUpdateManifest(mfst);
6003
+ } catch {}
6004
+
5608
6005
  barLn();
5609
6006
 
5610
6007
  // ── 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.0",
4
4
  "description": "The self-improving OS for AI agents. Turns Obsidian into an execution engine.",
5
5
  "bin": {
6
6
  "moveros": "install.js"