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