mover-os 4.7.8 → 4.7.10
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 +2229 -204
- package/package.json +3 -2
- 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-BoxTW_kj.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
- package/src/dashboard/ui/dist/index.html +2 -2
- package/src/dashboard/ui/dist/sw.js +1 -1
- package/src/system/counts.json +7 -0
- 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,362 @@ 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
|
+
// The public npm package is intentionally only the installer + dashboard. The
|
|
5633
|
+
// paid workflows, skills, and hooks arrive after license validation, so a
|
|
5634
|
+
// clean `npx mover-os help` cannot derive their counts from src/. Use the
|
|
5635
|
+
// release-generated public counts file in that environment without exposing
|
|
5636
|
+
// the paid payload on npm. Repository/dev runs still derive the full inventory
|
|
5637
|
+
// from source above, which keeps docs generation exact.
|
|
5638
|
+
function collectPublicCounts() {
|
|
5639
|
+
try {
|
|
5640
|
+
const inv = collectInventory();
|
|
5641
|
+
return {
|
|
5642
|
+
workflows: inv.workflows.length,
|
|
5643
|
+
skills: inv.skills.length,
|
|
5644
|
+
agents: inv.agents.length,
|
|
5645
|
+
};
|
|
5646
|
+
} catch (_) {
|
|
5647
|
+
try {
|
|
5648
|
+
const counts = JSON.parse(fs.readFileSync(path.join(__dirname, "src", "system", "counts.json"), "utf8"));
|
|
5649
|
+
return {
|
|
5650
|
+
workflows: Number(counts.workflows) || 0,
|
|
5651
|
+
skills: Number(counts.skills) || 0,
|
|
5652
|
+
agents: Number(counts.agents) || AGENT_SELECTIONS.length,
|
|
5653
|
+
};
|
|
5654
|
+
} catch (_) {
|
|
5655
|
+
return { workflows: 0, skills: 0, agents: AGENT_SELECTIONS.length };
|
|
5656
|
+
}
|
|
5657
|
+
}
|
|
5658
|
+
}
|
|
5659
|
+
|
|
5660
|
+
function cmdInventory() {
|
|
5661
|
+
// Pure JSON on stdout — no banner, no color — so callers can pipe it.
|
|
5662
|
+
process.stdout.write(JSON.stringify(collectInventory(), null, 2) + "\n");
|
|
5663
|
+
}
|
|
5664
|
+
|
|
5665
|
+
// ─── moveros menubar ─────────────────────────────────────────────────────────
|
|
5666
|
+
// Installs the native macOS menu-bar companion (src/menubar/, prebuilt at
|
|
5667
|
+
// src/menubar/prebuilt/MoverBar.app — zero third-party deps, no SwiftBar, no
|
|
5668
|
+
// brew). Owner-approved 2026-07-11 (qa/campaign-2026-07/OWNER-DECISIONS.md
|
|
5669
|
+
// RULINGS LANDED): "cleanest install-on-accept", same pattern as the desktop
|
|
5670
|
+
// shortcut (src/dashboard/shortcut.js) — a prebuilt artifact copied into
|
|
5671
|
+
// place on accept, not compiled on the user's machine.
|
|
5672
|
+
//
|
|
5673
|
+
// macOS-only. The .app polls ~/.mover/menubar.json every 60s and shells out
|
|
5674
|
+
// to the exact cmd this function writes: [node, install.js, "pulse", "--bar"]
|
|
5675
|
+
// — the same one-honest-line contract cmdPulseBar already guarantees, so the
|
|
5676
|
+
// menu bar never has to fabricate a status.
|
|
5677
|
+
// Idempotent: re-running replaces the installed .app in place (rsync-style —
|
|
5678
|
+
// remove then copy fresh) and rewrites the config.
|
|
5679
|
+
async function cmdMenubar(opts) {
|
|
5680
|
+
if (process.platform !== "darwin") {
|
|
5681
|
+
barLn(dim(" moveros menubar is macOS-only (native AppKit menu-bar app)."));
|
|
5682
|
+
return;
|
|
5683
|
+
}
|
|
5684
|
+
|
|
5685
|
+
const remove = (opts.rest || []).includes("--remove");
|
|
5686
|
+
const appDest = path.join(os.homedir(), "Applications", "MoverBar.app");
|
|
5687
|
+
const configPath = path.join(os.homedir(), ".mover", "menubar.json");
|
|
5688
|
+
|
|
5689
|
+
if (remove) {
|
|
5690
|
+
try {
|
|
5691
|
+
const { execFileSync } = require("child_process");
|
|
5692
|
+
try { execFileSync("pkill", ["-f", "MoverBar"], { stdio: "ignore" }); } catch { /* not running — fine */ }
|
|
5693
|
+
} catch { /* best-effort — never block removal on this */ }
|
|
5694
|
+
try { if (fs.lstatSync(appDest)) fs.rmSync(appDest, { recursive: true, force: true }); statusLine("ok", "Removed", appDest); } catch { /* already absent */ }
|
|
5695
|
+
try { if (fs.lstatSync(configPath)) fs.rmSync(configPath, { force: true }); statusLine("ok", "Removed", configPath); } catch { /* already absent */ }
|
|
5696
|
+
barLn();
|
|
5697
|
+
return;
|
|
5698
|
+
}
|
|
5699
|
+
|
|
5700
|
+
// A MoverBar polling `moveros pulse --bar` is a dead icon without an
|
|
5701
|
+
// install — the one invariant (requireInstalledVault) is the guard.
|
|
5702
|
+
const vault = requireInstalledVault(opts.vault);
|
|
5703
|
+
if (!vault) return;
|
|
5704
|
+
|
|
5705
|
+
const prebuiltSrc = path.join(path.resolve(__dirname), "src", "menubar", "prebuilt", "MoverBar.app");
|
|
5706
|
+
if (!fs.existsSync(prebuiltSrc)) {
|
|
5707
|
+
statusLine("warn", "Missing prebuilt app", `${prebuiltSrc} not found — reinstall mover-os.`);
|
|
5708
|
+
barLn();
|
|
5709
|
+
return;
|
|
5710
|
+
}
|
|
5711
|
+
|
|
5712
|
+
try {
|
|
5713
|
+
fs.mkdirSync(path.join(os.homedir(), "Applications"), { recursive: true });
|
|
5714
|
+
fs.rmSync(appDest, { recursive: true, force: true }); // rsync-style replace
|
|
5715
|
+
copyDirRecursive(prebuiltSrc, appDest);
|
|
5716
|
+
statusLine("ok", "Installed", appDest);
|
|
5717
|
+
} catch (err) {
|
|
5718
|
+
statusLine("warn", "Install failed", err.message);
|
|
5719
|
+
barLn();
|
|
5720
|
+
return;
|
|
5721
|
+
}
|
|
5722
|
+
|
|
5723
|
+
try {
|
|
5724
|
+
fs.mkdirSync(path.join(os.homedir(), ".mover"), { recursive: true });
|
|
5725
|
+
fs.writeFileSync(
|
|
5726
|
+
configPath,
|
|
5727
|
+
JSON.stringify({ cmd: [process.execPath, __filename, "pulse", "--bar"] }, null, 2),
|
|
5728
|
+
"utf8"
|
|
5729
|
+
);
|
|
5730
|
+
statusLine("ok", "Wrote", configPath);
|
|
5731
|
+
} catch (err) {
|
|
5732
|
+
statusLine("warn", "Config write failed", err.message);
|
|
5733
|
+
barLn();
|
|
5734
|
+
return;
|
|
5735
|
+
}
|
|
5736
|
+
|
|
5737
|
+
try {
|
|
5738
|
+
const { spawn } = require("child_process");
|
|
5739
|
+
spawn("open", [appDest], { stdio: "ignore", detached: true }).unref();
|
|
5740
|
+
statusLine("ok", "Launched", "MoverBar");
|
|
5741
|
+
} catch (err) {
|
|
5742
|
+
statusLine("warn", "Launch failed", err.message);
|
|
5743
|
+
}
|
|
5744
|
+
|
|
5745
|
+
barLn();
|
|
5746
|
+
barLn(dim(" Look for the status line in your menu bar. It refreshes every 60s."));
|
|
5747
|
+
barLn();
|
|
5748
|
+
}
|
|
5749
|
+
|
|
5750
|
+
// One glanceable status line for a menu-bar widget. Plain text, no ANSI. Always
|
|
5751
|
+
// exits 0 with SOME line so a bar poller never renders an error. Shape:
|
|
5752
|
+
// "▸ <next action> · <done>/<total> today"
|
|
5753
|
+
// Falls back to the standing test, then a bare label, so the bar is never blank.
|
|
5754
|
+
async function cmdPulseBar(opts) {
|
|
5755
|
+
const emit = (s) => { process.stdout.write(String(s).replace(/\s+/g, " ").trim() + "\n"); };
|
|
5756
|
+
try {
|
|
5757
|
+
const vault = resolveVaultPath(opts.vault);
|
|
5758
|
+
if (!vault) { emit("Mover · not set up"); return; }
|
|
5759
|
+
const engineDir = path.join(vault, "02_Areas", "Engine");
|
|
5760
|
+
|
|
5761
|
+
let done = 0, total = 0, next = "";
|
|
5762
|
+
try {
|
|
5763
|
+
const dailyPath = resolveDailyNotePath(engineDir);
|
|
5764
|
+
if (dailyPath && fs.existsSync(dailyPath)) {
|
|
5765
|
+
const daily = fs.readFileSync(dailyPath, "utf8");
|
|
5766
|
+
const sec = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---|$)/i);
|
|
5767
|
+
if (sec) {
|
|
5768
|
+
const tasks = sec[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
|
|
5769
|
+
total = tasks.length;
|
|
5770
|
+
// Only [x] is complete; [~] is unverified/in-progress (global rule S5).
|
|
5771
|
+
done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
|
|
5772
|
+
const firstOpen = tasks.find((t) => /^\s*-\s*\[[ ~]\]/.test(t));
|
|
5773
|
+
if (firstOpen) next = firstOpen.replace(/^\s*-\s*\[[ x~]\]\s*/, "").replace(/\[[^\]]*\]/g, "").trim();
|
|
5774
|
+
}
|
|
5775
|
+
}
|
|
5776
|
+
} catch (_) { /* no daily / unreadable — fall through to the standing test */ }
|
|
5777
|
+
|
|
5778
|
+
// Owner ruling (2026-07-11): the TITLE is a glance-number, never a sentence
|
|
5779
|
+
// (the reference bar apps show "$12,345", not prose) — and the number is
|
|
5780
|
+
// the MISSION (the bet: "4/15"), not the day's chore count. Contract:
|
|
5781
|
+
// line 1 = compact title, line 2 = the full sentence for the click-menu.
|
|
5782
|
+
// A one-line consumer (scripts, old pollers) still gets a meaningful line.
|
|
5783
|
+
let st = "";
|
|
5784
|
+
try {
|
|
5785
|
+
const man = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".mover", "pattern-manifest.json"), "utf8"));
|
|
5786
|
+
st = String((man && man.single_test) || "").trim();
|
|
5787
|
+
} catch (_) { /* no manifest */ }
|
|
5788
|
+
|
|
5789
|
+
// The bet count comes from the SAME parser the dashboard hero uses —
|
|
5790
|
+
// never a second implementation that can drift from what Home shows.
|
|
5791
|
+
let bet = null;
|
|
5792
|
+
if (st) {
|
|
5793
|
+
try {
|
|
5794
|
+
const { parseGoalProgress } = require("./src/dashboard/lib/goal-forecast");
|
|
5795
|
+
const acRaw = fs.readFileSync(path.join(engineDir, "Active_Context.md"), "utf8");
|
|
5796
|
+
const g = parseGoalProgress({ singleTest: st, sourceText: acRaw, targetDate: null, now: new Date() });
|
|
5797
|
+
// false-zero discipline: no parseable target = no fabricated "0/0".
|
|
5798
|
+
if (g && g.target > 0) bet = { current: g.current || 0, target: g.target };
|
|
5799
|
+
} catch (_) { /* unparseable bet — fall through to the day count */ }
|
|
5800
|
+
}
|
|
5801
|
+
|
|
5802
|
+
const parts = [];
|
|
5803
|
+
if (next) parts.push("▸ " + next.slice(0, 42));
|
|
5804
|
+
if (total > 0) parts.push(`${done}/${total} today`);
|
|
5805
|
+
if (bet || parts.length) {
|
|
5806
|
+
emit(bet ? `▸ ${bet.current}/${bet.target}` : (total > 0 ? `▸ ${done}/${total}` : "▸ Mover"));
|
|
5807
|
+
emit(parts.length ? parts.join(" · ") : "▸ " + st.slice(0, 48));
|
|
5808
|
+
return;
|
|
5809
|
+
}
|
|
5810
|
+
emit("▸ Mover");
|
|
5811
|
+
emit(st ? "▸ " + st.slice(0, 48) : "Mover · no tasks today");
|
|
5812
|
+
} catch (_) {
|
|
5813
|
+
emit("Mover"); // never fail a bar poll
|
|
5814
|
+
}
|
|
5815
|
+
}
|
|
5816
|
+
|
|
4819
5817
|
async function cmdPulseTerminal(opts) {
|
|
4820
5818
|
const vault = resolveVaultPath(opts.vault);
|
|
4821
5819
|
if (!vault) {
|
|
@@ -4860,7 +5858,9 @@ async function cmdPulseTerminal(opts) {
|
|
|
4860
5858
|
const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---)/i);
|
|
4861
5859
|
if (taskSection) {
|
|
4862
5860
|
const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
|
|
4863
|
-
|
|
5861
|
+
// terra read F10: only [x] is COMPLETE. [~] is unverified/in-progress — the
|
|
5862
|
+
// whole point of the [~] state (global rule S5) — and must not count as done.
|
|
5863
|
+
const done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
|
|
4864
5864
|
const total = tasks.length;
|
|
4865
5865
|
barLn(` ${progressBar(done, total, 25, "Tasks")}`);
|
|
4866
5866
|
}
|
|
@@ -4914,9 +5914,13 @@ async function cmdPulseTerminal(opts) {
|
|
|
4914
5914
|
if (fs.existsSync(acLogPath)) {
|
|
4915
5915
|
try {
|
|
4916
5916
|
const acContent = fs.readFileSync(acLogPath, "utf8");
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
5917
|
+
// terra read F5: `(\S+)` captured the closing `**` of a Markdown-wrapped key
|
|
5918
|
+
// (**log_last_run:** date), yielding "NaNh ago". Consume optional Markdown
|
|
5919
|
+
// around the key and capture a date-shaped token, then reject an invalid date.
|
|
5920
|
+
const logMatch = acContent.match(/\*{0,2}\s*log_last_run:\s*\*{0,2}\s*([0-9][0-9T:\-.Z+]*)/i);
|
|
5921
|
+
const ts = logMatch ? new Date(logMatch[1]).getTime() : NaN;
|
|
5922
|
+
if (logMatch && !Number.isNaN(ts)) {
|
|
5923
|
+
const agoMs = Date.now() - ts;
|
|
4920
5924
|
const agoMin = Math.round(agoMs / 60000);
|
|
4921
5925
|
const agoH = Math.round(agoMs / 3600000);
|
|
4922
5926
|
const agoStr = agoMin < 60 ? `${agoMin}m ago` : `${agoH}h ago`;
|
|
@@ -4934,7 +5938,11 @@ async function cmdPulseTerminal(opts) {
|
|
|
4934
5938
|
|
|
4935
5939
|
// ─── moveros capture ────────────────────────────────────────────────────────
|
|
4936
5940
|
async function cmdCapture(opts) {
|
|
4937
|
-
|
|
5941
|
+
// terra CLI F7: an explicit --vault is trusted without existsSync, so a
|
|
5942
|
+
// typo'd path would get a whole 00_Inbox tree minted there and the note
|
|
5943
|
+
// "lost" outside the real vault. The one invariant is the mutation gate.
|
|
5944
|
+
const vault = requireInstalledVault(opts.vault);
|
|
5945
|
+
if (!vault) return;
|
|
4938
5946
|
|
|
4939
5947
|
const now = new Date();
|
|
4940
5948
|
const ymd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
@@ -4996,11 +6004,34 @@ async function cmdCapture(opts) {
|
|
|
4996
6004
|
else if (type === "idea") entry = `- **Idea:** ${content} *(${ts})*`;
|
|
4997
6005
|
else entry = `- ${content} *(${ts})*`;
|
|
4998
6006
|
|
|
6007
|
+
// terra CLI F6: never follow a SYMLINKED capture file — appending through it
|
|
6008
|
+
// would write outside the vault inbox. A path is unsafe if it is a symlink, or
|
|
6009
|
+
// its real parent is not the real inbox. Fall back to a fresh file; if that is
|
|
6010
|
+
// also unsafe (or the same path), refuse rather than write outside the vault.
|
|
6011
|
+
const realInbox = (() => { try { return fs.realpathSync(inboxDir); } catch { return inboxDir; } })();
|
|
6012
|
+
const isUnsafe = (p) => {
|
|
6013
|
+
try {
|
|
6014
|
+
const st = fs.lstatSync(p, { throwIfNoEntry: false });
|
|
6015
|
+
if (!st) return false; // does not exist = safe to create
|
|
6016
|
+
if (st.isSymbolicLink()) return true; // never follow a link
|
|
6017
|
+
return path.dirname(fs.realpathSync(p)) !== realInbox;
|
|
6018
|
+
} catch { return false; }
|
|
6019
|
+
};
|
|
6020
|
+
let writeTarget = capturePath;
|
|
6021
|
+
if (isUnsafe(writeTarget)) {
|
|
6022
|
+
const fresh = path.join(inboxDir, `Capture - ${ymd}.md`);
|
|
6023
|
+
if (path.resolve(fresh) === path.resolve(writeTarget) || isUnsafe(fresh)) {
|
|
6024
|
+
barLn(red(" The inbox capture file is a symlink pointing outside the vault; refusing to write."));
|
|
6025
|
+
return;
|
|
6026
|
+
}
|
|
6027
|
+
writeTarget = fresh;
|
|
6028
|
+
}
|
|
6029
|
+
|
|
4999
6030
|
// Append to capture file
|
|
5000
|
-
if (!fs.existsSync(
|
|
5001
|
-
fs.writeFileSync(
|
|
6031
|
+
if (!fs.existsSync(writeTarget)) {
|
|
6032
|
+
fs.writeFileSync(writeTarget, `# Capture — ${ymd}\n\n${entry}\n`, "utf8");
|
|
5002
6033
|
} else {
|
|
5003
|
-
fs.appendFileSync(
|
|
6034
|
+
fs.appendFileSync(writeTarget, `${entry}\n`, "utf8");
|
|
5004
6035
|
}
|
|
5005
6036
|
|
|
5006
6037
|
statusLine("ok", "Captured", `${type} → 00_Inbox/`);
|
|
@@ -5010,7 +6041,10 @@ async function cmdCapture(opts) {
|
|
|
5010
6041
|
|
|
5011
6042
|
// ─── moveros who ────────────────────────────────────────────────────────────
|
|
5012
6043
|
async function cmdWho(opts) {
|
|
5013
|
-
|
|
6044
|
+
// Can mint entity stubs — mutating commands go through the one install
|
|
6045
|
+
// invariant (sol re-score #3: the invariant must cover EVERY mutating path).
|
|
6046
|
+
const vault = requireInstalledVault(opts.vault);
|
|
6047
|
+
if (!vault) return;
|
|
5014
6048
|
|
|
5015
6049
|
const name = opts.rest.join(" ").trim();
|
|
5016
6050
|
if (!name) { barLn(yellow("Usage: moveros who <name>")); return; }
|
|
@@ -5041,9 +6075,35 @@ async function cmdWho(opts) {
|
|
|
5041
6075
|
if (create === "yes") {
|
|
5042
6076
|
const peopleDir = path.join(entitiesDir, "People");
|
|
5043
6077
|
fs.mkdirSync(peopleDir, { recursive: true });
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
6078
|
+
// The raw name went straight into the write path, so "../../x" escaped
|
|
6079
|
+
// Entities entirely (terra-confirmed traversal). Strip separators and
|
|
6080
|
+
// dot segments, then verify the target lands PHYSICALLY inside People/ —
|
|
6081
|
+
// using realpath, since path.resolve is lexical and a symlinked People/
|
|
6082
|
+
// dir would otherwise let the stub escape (terra round 2).
|
|
6083
|
+
const safeName = name.replace(/[\/\\:*?"<>|\0]/g, "-").replace(/\.\.+/g, "-").trim();
|
|
6084
|
+
const stubPath = path.resolve(peopleDir, `${safeName}.md`);
|
|
6085
|
+
// Containment, three ways (terra P1, two rounds): lexical (blocks "../"
|
|
6086
|
+
// names), realpath-consistent (dirname resolves to People), AND the
|
|
6087
|
+
// resolved target must sit INSIDE the vault — so a People/ symlinked to
|
|
6088
|
+
// an outside dir can't smuggle the stub out of the vault entirely.
|
|
6089
|
+
let realParent, realPeople, realVault;
|
|
6090
|
+
try {
|
|
6091
|
+
realParent = fs.realpathSync(path.dirname(stubPath));
|
|
6092
|
+
realPeople = fs.realpathSync(peopleDir);
|
|
6093
|
+
realVault = fs.realpathSync(vault);
|
|
6094
|
+
} catch { realParent = null; realPeople = null; realVault = null; }
|
|
6095
|
+
const insideVault = realParent && realVault &&
|
|
6096
|
+
(realParent === realVault || realParent.startsWith(realVault + path.sep));
|
|
6097
|
+
if (!safeName ||
|
|
6098
|
+
!stubPath.startsWith(path.resolve(peopleDir) + path.sep) ||
|
|
6099
|
+
realParent !== realPeople ||
|
|
6100
|
+
!insideVault) {
|
|
6101
|
+
statusLine("warn", "Invalid location", "Entity stub would land outside the vault. Check the name and that Entities/People is inside your vault.");
|
|
6102
|
+
return;
|
|
6103
|
+
}
|
|
6104
|
+
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`;
|
|
6105
|
+
fs.writeFileSync(stubPath, stub, "utf8");
|
|
6106
|
+
statusLine("ok", "Created", `${safeName}.md in Entities/People/`);
|
|
5047
6107
|
}
|
|
5048
6108
|
return;
|
|
5049
6109
|
}
|
|
@@ -5065,9 +6125,12 @@ async function cmdWho(opts) {
|
|
|
5065
6125
|
// ─── moveros diff ───────────────────────────────────────────────────────────
|
|
5066
6126
|
async function cmdDiff(opts) {
|
|
5067
6127
|
const vault = requireVault(opts.vault);
|
|
6128
|
+
if (!vault) return;
|
|
5068
6129
|
if (!cmdExists("git")) { barLn(red("Git required for moveros diff.")); return; }
|
|
5069
6130
|
|
|
5070
|
-
|
|
6131
|
+
// terra read F11: the target is the first NON-FLAG arg, so `diff --days=30 strategy`
|
|
6132
|
+
// (flag first) doesn't try to diff a file literally named "--days=30".
|
|
6133
|
+
const target = opts.rest.find((a) => !a.startsWith("--")) || "strategy";
|
|
5071
6134
|
const days = parseInt(opts.rest.find((a) => a.startsWith("--days="))?.split("=")[1] || "30", 10);
|
|
5072
6135
|
|
|
5073
6136
|
const fileMap = {
|
|
@@ -5091,8 +6154,11 @@ async function cmdDiff(opts) {
|
|
|
5091
6154
|
const gitCwd = isEngineFile && fs.existsSync(path.join(engineDir, ".git"))
|
|
5092
6155
|
? engineDir
|
|
5093
6156
|
: vault;
|
|
6157
|
+
// terra read F8: path.basename dropped subdirectories, so a nested Engine file
|
|
6158
|
+
// (02_Areas/Engine/Samples/Foo.md) was queried at the wrong git path and its
|
|
6159
|
+
// history read as empty. Use the path RELATIVE to the Engine repo root.
|
|
5094
6160
|
const gitRelPath = isEngineFile && gitCwd === engineDir
|
|
5095
|
-
? path.
|
|
6161
|
+
? path.relative(engineDir, fullPath)
|
|
5096
6162
|
: relPath;
|
|
5097
6163
|
|
|
5098
6164
|
barLn(bold(` ${path.basename(relPath)} — last ${days} days`));
|
|
@@ -5125,7 +6191,10 @@ async function cmdDiff(opts) {
|
|
|
5125
6191
|
|
|
5126
6192
|
// ─── moveros sync ───────────────────────────────────────────────────────────
|
|
5127
6193
|
async function cmdSync(opts) {
|
|
5128
|
-
|
|
6194
|
+
// --apply rewrites agent artifacts from the bundle — mutating, so the one
|
|
6195
|
+
// install invariant gates it (sol re-score #3).
|
|
6196
|
+
const vault = requireInstalledVault(opts.vault);
|
|
6197
|
+
if (!vault) return;
|
|
5129
6198
|
const apply = opts.rest.includes("--apply");
|
|
5130
6199
|
const home = os.homedir();
|
|
5131
6200
|
|
|
@@ -5231,13 +6300,22 @@ async function cmdSync(opts) {
|
|
|
5231
6300
|
// ─── moveros replay ─────────────────────────────────────────────────────────
|
|
5232
6301
|
async function cmdReplay(opts) {
|
|
5233
6302
|
const vault = requireVault(opts.vault);
|
|
6303
|
+
if (!vault) return;
|
|
5234
6304
|
|
|
5235
6305
|
const engineDir = path.join(vault, "02_Areas", "Engine");
|
|
5236
6306
|
let dateStr = opts.rest.find((a) => a.startsWith("--date="))?.split("=")[1];
|
|
5237
6307
|
let targetDate;
|
|
5238
6308
|
if (dateStr) {
|
|
5239
|
-
|
|
5240
|
-
|
|
6309
|
+
// terra read F7: `new Date(2026, 1, 31)` rolls over to Mar 3, so an impossible
|
|
6310
|
+
// date replayed a DIFFERENT day under a false label. Validate the round-trip
|
|
6311
|
+
// and reject a rollover.
|
|
6312
|
+
const m2 = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
|
6313
|
+
const [y, mo, d] = m2 ? m2.slice(1).map(Number) : [NaN, NaN, NaN];
|
|
6314
|
+
targetDate = m2 ? new Date(y, mo - 1, d) : new Date("invalid");
|
|
6315
|
+
if (!m2 || targetDate.getFullYear() !== y || targetDate.getMonth() !== mo - 1 || targetDate.getDate() !== d) {
|
|
6316
|
+
barLn(red(` Invalid date: ${dateStr}. Use a real YYYY-MM-DD.`));
|
|
6317
|
+
return;
|
|
6318
|
+
}
|
|
5241
6319
|
} else {
|
|
5242
6320
|
targetDate = getSessionDate();
|
|
5243
6321
|
const y = targetDate.getFullYear();
|
|
@@ -5283,7 +6361,8 @@ async function cmdReplay(opts) {
|
|
|
5283
6361
|
const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##)/i);
|
|
5284
6362
|
if (taskSection) {
|
|
5285
6363
|
const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
|
|
5286
|
-
|
|
6364
|
+
// terra read F10: only [x] is complete; [~] is unverified (global rule S5).
|
|
6365
|
+
const done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
|
|
5287
6366
|
barLn(` ${progressBar(done, tasks.length, 25, "Completion")}`);
|
|
5288
6367
|
barLn();
|
|
5289
6368
|
}
|
|
@@ -5293,15 +6372,38 @@ async function cmdReplay(opts) {
|
|
|
5293
6372
|
}
|
|
5294
6373
|
|
|
5295
6374
|
// ─── moveros context ────────────────────────────────────────────────────────
|
|
6375
|
+
// terra read F4: `reg.hooks.events` does not exist on the registry, so context
|
|
6376
|
+
// always showed "0 events" / "? hook events". Parse the agent's actual hook
|
|
6377
|
+
// config and return the event names. null = not found / unreadable.
|
|
6378
|
+
function agentHookEvents(reg, vault, home) {
|
|
6379
|
+
try {
|
|
6380
|
+
let p = null;
|
|
6381
|
+
if (reg && reg.hooks && reg.hooks.dest) p = reg.hooks.dest(vault);
|
|
6382
|
+
else if (reg && reg.hooks && reg.hooks.type === "claude-settings") p = path.join(home, ".claude", "settings.json");
|
|
6383
|
+
if (!p || !fs.existsSync(p)) return null;
|
|
6384
|
+
const obj = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
6385
|
+
// Hooks live at the top level (agent hooks.json) or under `.hooks` (settings.json).
|
|
6386
|
+
const h = (obj && obj.hooks && typeof obj.hooks === "object") ? obj.hooks : obj;
|
|
6387
|
+
if (!h || typeof h !== "object") return null;
|
|
6388
|
+
return Object.keys(h).filter((k) => /^[A-Z][A-Za-z]/.test(k) && (Array.isArray(h[k]) || (h[k] && typeof h[k] === "object")));
|
|
6389
|
+
} catch { return null; }
|
|
6390
|
+
}
|
|
6391
|
+
|
|
5296
6392
|
async function cmdContext(opts) {
|
|
5297
6393
|
const vault = requireVault(opts.vault);
|
|
6394
|
+
if (!vault) return;
|
|
5298
6395
|
|
|
5299
6396
|
const target = opts.rest[0];
|
|
5300
6397
|
const home = os.homedir();
|
|
5301
6398
|
const cfgPath = path.join(home, ".mover", "config.json");
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
6399
|
+
// terra read F9: a malformed config.json crashed `moveros context` (an unguarded
|
|
6400
|
+
// JSON.parse) — exactly when config corruption most needs diagnosing. Degrade to
|
|
6401
|
+
// an empty agent list, mirroring doctor.
|
|
6402
|
+
let agents = [];
|
|
6403
|
+
if (fs.existsSync(cfgPath)) {
|
|
6404
|
+
try { agents = JSON.parse(fs.readFileSync(cfgPath, "utf8")).agents || []; }
|
|
6405
|
+
catch { barLn(yellow(" ~/.mover/config.json is not valid JSON — showing no agents.")); }
|
|
6406
|
+
}
|
|
5305
6407
|
|
|
5306
6408
|
if (!target) {
|
|
5307
6409
|
// Overview: show what each agent actually loads
|
|
@@ -5349,8 +6451,8 @@ async function cmdContext(opts) {
|
|
|
5349
6451
|
}
|
|
5350
6452
|
}
|
|
5351
6453
|
|
|
5352
|
-
// Hooks
|
|
5353
|
-
if (reg.hooks) parts.push(`${
|
|
6454
|
+
// Hooks (terra read F4: parse the real config)
|
|
6455
|
+
if (reg.hooks) { const ev = agentHookEvents(reg, vault, home); parts.push(ev ? `${ev.length} hook events` : "no hooks"); }
|
|
5354
6456
|
|
|
5355
6457
|
const tokens = Math.round(totalBytes / 4);
|
|
5356
6458
|
const tokenWarn = tokens > 40000 ? red("heavy") : tokens > 20000 ? yellow("moderate") : green("lean");
|
|
@@ -5435,11 +6537,12 @@ async function cmdContext(opts) {
|
|
|
5435
6537
|
}
|
|
5436
6538
|
}
|
|
5437
6539
|
|
|
5438
|
-
// Hooks
|
|
6540
|
+
// Hooks (terra read F4: parse the real config, not the nonexistent reg.hooks.events)
|
|
5439
6541
|
if (reg.hooks) {
|
|
5440
6542
|
barLn();
|
|
5441
|
-
|
|
5442
|
-
|
|
6543
|
+
const ev = agentHookEvents(reg, vault, home);
|
|
6544
|
+
statusLine(ev && ev.length ? "ok" : "warn", "Hooks", ev ? `${ev.length} events` : "not configured");
|
|
6545
|
+
if (ev && ev.length) barLn(dim(` ${ev.join(", ")}`));
|
|
5443
6546
|
}
|
|
5444
6547
|
|
|
5445
6548
|
// Token budget summary
|
|
@@ -5747,7 +6850,10 @@ async function cmdSettings(opts) {
|
|
|
5747
6850
|
|
|
5748
6851
|
// ─── moveros backup ─────────────────────────────────────────────────────────
|
|
5749
6852
|
async function cmdBackup(opts) {
|
|
5750
|
-
|
|
6853
|
+
// Writes archives derived from the vault; backing up a non-install is a
|
|
6854
|
+
// meaningless artifact from a possibly wrong --vault (sol re-score #3).
|
|
6855
|
+
const vault = requireInstalledVault(opts.vault);
|
|
6856
|
+
if (!vault) return;
|
|
5751
6857
|
const home = os.homedir();
|
|
5752
6858
|
|
|
5753
6859
|
barLn(bold(" Backup"));
|
|
@@ -5763,8 +6869,14 @@ async function cmdBackup(opts) {
|
|
|
5763
6869
|
if (!choices || choices.length === 0) { barLn(dim(" Cancelled.")); return; }
|
|
5764
6870
|
|
|
5765
6871
|
const now = new Date();
|
|
5766
|
-
|
|
6872
|
+
// terra CLI F5: minute precision meant two backups in the same minute reused
|
|
6873
|
+
// one dir and the second SILENTLY OVERWROTE the first snapshot (losing the only
|
|
6874
|
+
// good restore point). Add seconds + a random suffix so every backup is a
|
|
6875
|
+
// distinct dir, and fail closed if it somehow already exists.
|
|
6876
|
+
const secs = String(now.getSeconds()).padStart(2, "0");
|
|
6877
|
+
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
6878
|
const backupDir = path.join(vault, "04_Archives", `Backup_${ts}`);
|
|
6879
|
+
if (fs.existsSync(backupDir)) { outro(red("Backup directory collision; try again.")); return; }
|
|
5768
6880
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
5769
6881
|
|
|
5770
6882
|
const manifest = { timestamp: now.toISOString(), type: "manual", trigger: "moveros backup", categories: choices, files: {} };
|
|
@@ -5842,9 +6954,46 @@ async function cmdBackup(opts) {
|
|
|
5842
6954
|
barLn();
|
|
5843
6955
|
}
|
|
5844
6956
|
|
|
6957
|
+
// terra CLI F2: a restore that copies files in a loop with no rollback left
|
|
6958
|
+
// earlier files overwritten if a later copy failed (corrupt mixed state). Snapshot
|
|
6959
|
+
// each existing destination, copy all, and on ANY failure roll every destination
|
|
6960
|
+
// back to its snapshot (or remove a file we created). Returns the count restored.
|
|
6961
|
+
function restoreFilesTransactional(pairs) {
|
|
6962
|
+
const snaps = [];
|
|
6963
|
+
try {
|
|
6964
|
+
for (const { src, dest } of pairs) {
|
|
6965
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
6966
|
+
if (fs.existsSync(dest)) {
|
|
6967
|
+
const bak = `${dest}.restore-bak`;
|
|
6968
|
+
fs.copyFileSync(dest, bak);
|
|
6969
|
+
snaps.push({ dest, bak, had: true });
|
|
6970
|
+
} else {
|
|
6971
|
+
snaps.push({ dest, bak: null, had: false });
|
|
6972
|
+
}
|
|
6973
|
+
fs.copyFileSync(src, dest);
|
|
6974
|
+
}
|
|
6975
|
+
for (const s of snaps) if (s.bak) { try { fs.unlinkSync(s.bak); } catch {} }
|
|
6976
|
+
return pairs.length;
|
|
6977
|
+
} catch (e) {
|
|
6978
|
+
for (const s of snaps) {
|
|
6979
|
+
try {
|
|
6980
|
+
if (s.had && s.bak) fs.copyFileSync(s.bak, s.dest);
|
|
6981
|
+
else if (!s.had) { try { fs.unlinkSync(s.dest); } catch {} }
|
|
6982
|
+
} catch {}
|
|
6983
|
+
if (s.bak) { try { fs.unlinkSync(s.bak); } catch {} }
|
|
6984
|
+
}
|
|
6985
|
+
throw e;
|
|
6986
|
+
}
|
|
6987
|
+
}
|
|
6988
|
+
|
|
5845
6989
|
// ─── moveros restore ────────────────────────────────────────────────────────
|
|
5846
6990
|
async function cmdRestore(opts) {
|
|
5847
|
-
|
|
6991
|
+
// Writes INTO the vault — a typo'd --vault would dump restored files into
|
|
6992
|
+
// an unrelated directory, so the one install invariant gates it (sol
|
|
6993
|
+
// re-score #3). Recovery of a vault whose marker itself was lost goes
|
|
6994
|
+
// through moveros install first, which recreates the marker.
|
|
6995
|
+
const vault = requireInstalledVault(opts.vault);
|
|
6996
|
+
if (!vault) return;
|
|
5848
6997
|
|
|
5849
6998
|
const archivesDir = path.join(vault, "04_Archives");
|
|
5850
6999
|
if (!fs.existsSync(archivesDir)) { barLn(yellow(" No archives found.")); return; }
|
|
@@ -5876,8 +7025,21 @@ async function cmdRestore(opts) {
|
|
|
5876
7025
|
return { id: b, name: b, tier: detail || dim("legacy backup") };
|
|
5877
7026
|
});
|
|
5878
7027
|
|
|
5879
|
-
|
|
5880
|
-
|
|
7028
|
+
// terra CLI F1: a non-TTY restore must NOT silently pick the newest backup and
|
|
7029
|
+
// overwrite live (possibly newer) files. Require an explicit backup name AND
|
|
7030
|
+
// --confirm; otherwise refuse and show how.
|
|
7031
|
+
let selected;
|
|
7032
|
+
if (!IS_TTY) {
|
|
7033
|
+
const explicit = opts.rest.find((a) => backups.includes(a));
|
|
7034
|
+
if (!explicit || !opts.rest.includes("--confirm")) {
|
|
7035
|
+
outro(red(`Non-interactive restore needs an explicit backup and --confirm:\n moveros restore <${backups[0] || "Backup_..."}> --confirm`));
|
|
7036
|
+
return;
|
|
7037
|
+
}
|
|
7038
|
+
selected = explicit;
|
|
7039
|
+
} else {
|
|
7040
|
+
selected = await interactiveSelect(items, { multi: false });
|
|
7041
|
+
if (!selected) { barLn(dim(" Cancelled.")); return; }
|
|
7042
|
+
}
|
|
5881
7043
|
|
|
5882
7044
|
const backupPath = path.join(archivesDir, selected);
|
|
5883
7045
|
|
|
@@ -5920,28 +7082,42 @@ async function cmdRestore(opts) {
|
|
|
5920
7082
|
statusLine("ok", "Agents", `${agentCount} items restored`);
|
|
5921
7083
|
totalRestored = agentCount;
|
|
5922
7084
|
} else {
|
|
5923
|
-
// Engine backup (
|
|
7085
|
+
// A Backup_* dir can hold engine/ (Engine backup), areas/ (Full Areas backup,
|
|
7086
|
+
// terra CLI F3 — the old code knew only engine/ or loose .md and silently
|
|
7087
|
+
// restored NOTHING for an areas/ backup), or loose .md (legacy). Build the
|
|
7088
|
+
// src->dest pairs, then restore transactionally (F2).
|
|
7089
|
+
const engineDir = path.join(vault, "02_Areas", "Engine");
|
|
7090
|
+
const areasSrc = path.join(backupPath, "areas");
|
|
5924
7091
|
const engPath = path.join(backupPath, "engine");
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
7092
|
+
const pairs = [];
|
|
7093
|
+
let label = "Engine";
|
|
7094
|
+
if (fs.existsSync(areasSrc)) {
|
|
7095
|
+
const areasTarget = path.join(vault, "02_Areas");
|
|
7096
|
+
const walk = (dir, rel) => {
|
|
7097
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
7098
|
+
if (e.isDirectory()) walk(path.join(dir, e.name), path.join(rel, e.name));
|
|
7099
|
+
else pairs.push({ src: path.join(dir, e.name), dest: path.join(areasTarget, rel, e.name) });
|
|
7100
|
+
}
|
|
7101
|
+
};
|
|
7102
|
+
walk(areasSrc, "");
|
|
7103
|
+
label = "Areas";
|
|
7104
|
+
} else if (fs.existsSync(engPath)) {
|
|
5928
7105
|
for (const f of fs.readdirSync(engPath).filter((f) => fs.statSync(path.join(engPath, f)).isFile())) {
|
|
5929
|
-
|
|
5930
|
-
restored++;
|
|
7106
|
+
pairs.push({ src: path.join(engPath, f), dest: path.join(engineDir, f) });
|
|
5931
7107
|
}
|
|
5932
|
-
statusLine("ok", "Engine", `${restored} files restored`);
|
|
5933
|
-
totalRestored = restored;
|
|
5934
7108
|
} else {
|
|
5935
|
-
// Legacy backup format (files directly in backup dir)
|
|
5936
|
-
const engineDir = path.join(vault, "02_Areas", "Engine");
|
|
5937
|
-
let restored = 0;
|
|
5938
7109
|
for (const f of fs.readdirSync(backupPath).filter((f) => f.endsWith(".md") && f !== ".backup-manifest.json")) {
|
|
5939
|
-
|
|
5940
|
-
restored++;
|
|
7110
|
+
pairs.push({ src: path.join(backupPath, f), dest: path.join(engineDir, f) });
|
|
5941
7111
|
}
|
|
5942
|
-
if (restored > 0) statusLine("ok", "Engine", `${restored} files restored`);
|
|
5943
|
-
totalRestored = restored;
|
|
5944
7112
|
}
|
|
7113
|
+
try {
|
|
7114
|
+
totalRestored = restoreFilesTransactional(pairs);
|
|
7115
|
+
} catch (e) {
|
|
7116
|
+
outro(red(`Restore failed and was rolled back (no partial state): ${e && e.message}`));
|
|
7117
|
+
return;
|
|
7118
|
+
}
|
|
7119
|
+
if (totalRestored > 0) statusLine("ok", label, `${totalRestored} files restored`);
|
|
7120
|
+
else { barLn(yellow(" This backup contained no restorable files.")); return; }
|
|
5945
7121
|
}
|
|
5946
7122
|
|
|
5947
7123
|
barLn();
|
|
@@ -5980,6 +7156,9 @@ async function cmdHelp(opts) {
|
|
|
5980
7156
|
w("\n");
|
|
5981
7157
|
};
|
|
5982
7158
|
|
|
7159
|
+
// Counts computed, never typed: this screen shipped "23 commands / 61
|
|
7160
|
+
// packs / 16 agents" while the bundle held 25/70/15.
|
|
7161
|
+
const inv = collectPublicCounts();
|
|
5983
7162
|
const pages = [
|
|
5984
7163
|
{
|
|
5985
7164
|
title: "What is Mover OS?",
|
|
@@ -5994,10 +7173,10 @@ async function cmdHelp(opts) {
|
|
|
5994
7173
|
"",
|
|
5995
7174
|
`${dim("Three things make it work:")}`,
|
|
5996
7175
|
` ${cyan("1.")} The ${bold("Engine")} — files that store your identity, strategy, goals`,
|
|
5997
|
-
` ${cyan("2.")} ${bold("Workflows")} —
|
|
5998
|
-
` ${cyan("3.")} ${bold("Skills")} —
|
|
7176
|
+
` ${cyan("2.")} ${bold("Workflows")} — ${inv.workflows} commands that run your day (plan, build, log, repeat)`,
|
|
7177
|
+
` ${cyan("3.")} ${bold("Skills")} — ${inv.skills} packs that make your AI genuinely useful`,
|
|
5999
7178
|
"",
|
|
6000
|
-
`Works across
|
|
7179
|
+
`Works across ${inv.agents} agents. Claude Code, Cursor, Gemini, all of them.`,
|
|
6001
7180
|
`Same context, every editor. ${dim("Press → to continue.")}`,
|
|
6002
7181
|
],
|
|
6003
7182
|
},
|
|
@@ -6037,7 +7216,7 @@ async function cmdHelp(opts) {
|
|
|
6037
7216
|
],
|
|
6038
7217
|
},
|
|
6039
7218
|
{
|
|
6040
|
-
title:
|
|
7219
|
+
title: `${inv.workflows} Workflows`,
|
|
6041
7220
|
body: [
|
|
6042
7221
|
`${dim("Slash commands inside your AI agent. Type / and go.")}`,
|
|
6043
7222
|
"",
|
|
@@ -6055,7 +7234,7 @@ async function cmdHelp(opts) {
|
|
|
6055
7234
|
],
|
|
6056
7235
|
},
|
|
6057
7236
|
{
|
|
6058
|
-
title:
|
|
7237
|
+
title: `${inv.skills} Skill Packs`,
|
|
6059
7238
|
body: [
|
|
6060
7239
|
`${dim("Specialized knowledge your AI loads automatically.")}`,
|
|
6061
7240
|
"",
|
|
@@ -6150,7 +7329,7 @@ async function cmdHelp(opts) {
|
|
|
6150
7329
|
`${dim("Keep everything in sync without thinking about it.")}`,
|
|
6151
7330
|
"",
|
|
6152
7331
|
` ${cyan("sync")} Update all agents to latest rules and skills`,
|
|
6153
|
-
` ${dim(
|
|
7332
|
+
` ${dim(`One command updates ${inv.agents} agents. Shows what changed.`)}`,
|
|
6154
7333
|
"",
|
|
6155
7334
|
` ${cyan("backup")} Backup Engine, Areas, or agent configs`,
|
|
6156
7335
|
` ${dim("With manifest. Knows what's in each backup.")}`,
|
|
@@ -6166,7 +7345,7 @@ async function cmdHelp(opts) {
|
|
|
6166
7345
|
],
|
|
6167
7346
|
},
|
|
6168
7347
|
{
|
|
6169
|
-
title:
|
|
7348
|
+
title: `${inv.agents} Agents, One Brain`,
|
|
6170
7349
|
body: [
|
|
6171
7350
|
`${dim("Install once. Every agent gets the same context.")}`,
|
|
6172
7351
|
"",
|
|
@@ -6374,8 +7553,9 @@ async function cmdMainMenu() {
|
|
|
6374
7553
|
|
|
6375
7554
|
// Context filter: hide irrelevant commands
|
|
6376
7555
|
const contextFilter = (cmd) => {
|
|
6377
|
-
if (!hasVault && ["pulse", "open", "replay", "diff", "sync", "capture", "who", "context", "backup", "restore", "doctor"].includes(cmd)) return false;
|
|
7556
|
+
if (!hasVault && ["pulse", "open", "shortcut", "menubar", "replay", "diff", "sync", "capture", "who", "context", "backup", "restore", "doctor"].includes(cmd)) return false;
|
|
6378
7557
|
if (!hasPrayer && cmd === "prayer") return false;
|
|
7558
|
+
if (cmd === "menubar" && process.platform !== "darwin") return false;
|
|
6379
7559
|
return true;
|
|
6380
7560
|
};
|
|
6381
7561
|
|
|
@@ -6431,7 +7611,430 @@ async function cmdMainMenu() {
|
|
|
6431
7611
|
}
|
|
6432
7612
|
|
|
6433
7613
|
// ─── Comprehensive Update (10-step interactive) ─────────────────────────────
|
|
7614
|
+
// ── Update machine subcommands (local state ops: no license, no network) ────
|
|
7615
|
+
|
|
7616
|
+
// Resolve the bundle source to verify against: the journal's staged bundleDir
|
|
7617
|
+
// first (verify must check the SAME source the apply stage used), then this
|
|
7618
|
+
// CLI's own bundle, then a downloaded payload at ~/.mover/src.
|
|
7619
|
+
function resolveVerifyBundleDir(journal) {
|
|
7620
|
+
const candidates = [
|
|
7621
|
+
journal && journal.stages && journal.stages.stage && journal.stages.stage.bundleDir,
|
|
7622
|
+
__dirname,
|
|
7623
|
+
path.join(os.homedir(), ".mover"),
|
|
7624
|
+
].filter(Boolean);
|
|
7625
|
+
for (const c of candidates) {
|
|
7626
|
+
if (fs.existsSync(path.join(c, "src", "workflows"))) return c;
|
|
7627
|
+
}
|
|
7628
|
+
return null;
|
|
7629
|
+
}
|
|
7630
|
+
|
|
7631
|
+
// Engine migrations DECLARED per release (src/system/engine-migrations.json in
|
|
7632
|
+
// the bundle). Finalize cross-checks the migration receipt against every
|
|
7633
|
+
// declaration in (fromVersion, toVersion]: an empty migrated list is only
|
|
7634
|
+
// valid when that window truly declares nothing (sol re-score #4 part 10:
|
|
7635
|
+
// "an empty migration list proves process completion rather than semantic
|
|
7636
|
+
// completeness"). With no journal, fromVersion is unknown and only the target
|
|
7637
|
+
// version's own declarations bind. MOVER_TEST_MIGRATIONS_FILE injects a
|
|
7638
|
+
// manifest for hermetic tests, same pattern as MOVER_TEST_CRASH_STAGE.
|
|
7639
|
+
function loadOwedEngineMigrations(journal, toVersion) {
|
|
7640
|
+
const candidates = [];
|
|
7641
|
+
if (process.env.MOVER_TEST_MIGRATIONS_FILE) candidates.push(process.env.MOVER_TEST_MIGRATIONS_FILE);
|
|
7642
|
+
const bundleDir = resolveVerifyBundleDir(journal);
|
|
7643
|
+
if (bundleDir) candidates.push(path.join(bundleDir, "src", "system", "engine-migrations.json"));
|
|
7644
|
+
candidates.push(path.join(__dirname, "src", "system", "engine-migrations.json"));
|
|
7645
|
+
for (const c of candidates) {
|
|
7646
|
+
let decl;
|
|
7647
|
+
try { decl = JSON.parse(fs.readFileSync(c, "utf8")).declarations; } catch { continue; }
|
|
7648
|
+
if (!decl || typeof decl !== "object" || Array.isArray(decl)) continue;
|
|
7649
|
+
const fromV = journal && journal.fromVersion ? String(journal.fromVersion).replace(/^[vV]/, "") : null;
|
|
7650
|
+
const owed = [];
|
|
7651
|
+
for (const [ver, entries] of Object.entries(decl)) {
|
|
7652
|
+
const inWindow = fromV
|
|
7653
|
+
? compareVersions(ver, fromV) > 0 && compareVersions(ver, toVersion) <= 0
|
|
7654
|
+
: compareVersions(ver, toVersion) === 0;
|
|
7655
|
+
if (!inWindow || !Array.isArray(entries)) continue;
|
|
7656
|
+
for (const e of entries) {
|
|
7657
|
+
if (e && typeof e.path === "string" && e.path) owed.push({ version: ver, path: e.path, note: e.note });
|
|
7658
|
+
}
|
|
7659
|
+
}
|
|
7660
|
+
return { owed, source: c };
|
|
7661
|
+
}
|
|
7662
|
+
// No manifest anywhere (an older staged payload AND a stripped CLI copy):
|
|
7663
|
+
// the caller records this honestly in the journal instead of passing it
|
|
7664
|
+
// off as a checked window.
|
|
7665
|
+
return { owed: [], source: null };
|
|
7666
|
+
}
|
|
7667
|
+
|
|
7668
|
+
// `moveros update --status`: print the journal (the machine-readable handoff
|
|
7669
|
+
// the /update workflow reads). Honest null when no update is in flight.
|
|
7670
|
+
function cmdUpdateStatus() {
|
|
7671
|
+
const journal = loadUpdateJournal();
|
|
7672
|
+
if (!journal) {
|
|
7673
|
+
barLn(dim(" No update journal found. Nothing in progress."));
|
|
7674
|
+
outro("No update in progress.");
|
|
7675
|
+
return;
|
|
7676
|
+
}
|
|
7677
|
+
barLn(bold(" Update journal"));
|
|
7678
|
+
barLn();
|
|
7679
|
+
barLn(` Run: ${journal.runId} ${dim(`(${journal.mode})`)}`);
|
|
7680
|
+
barLn(` ${dim(journal.fromVersion || "?")} ${dim("->")} ${bold(journal.toVersion || "?")} ${journal.completedAt ? green(journal.rolledBack ? "rolled back" : "completed") : yellow("in progress")}`);
|
|
7681
|
+
barLn();
|
|
7682
|
+
for (const s of UPDATE_STAGES) {
|
|
7683
|
+
const st = (journal.stages && journal.stages[s]) || { status: "pending" };
|
|
7684
|
+
const icon = st.status === "done" ? "ok" : st.status === "failed" ? "fail" : "warn";
|
|
7685
|
+
statusLine(icon, ` ${s}`, st.status);
|
|
7686
|
+
}
|
|
7687
|
+
barLn();
|
|
7688
|
+
barLn(dim(` Journal: ${updateJournalPath()}`));
|
|
7689
|
+
outro(journal.completedAt ? "Update complete." : "Update in progress. Finish with /update, then moveros update --finalize.");
|
|
7690
|
+
}
|
|
7691
|
+
|
|
7692
|
+
// `moveros update --finalize`: the /update workflow calls this after Engine
|
|
7693
|
+
// migration. It records the migrate stage, VERIFIES the applied files against
|
|
7694
|
+
// the staged bundle, and stamps .mover-version ONLY on a pass. This is the
|
|
7695
|
+
// single place the interactive path is allowed to stamp, so the stamp always
|
|
7696
|
+
// means "verified", never "assumed".
|
|
7697
|
+
async function cmdUpdateFinalize(opts) {
|
|
7698
|
+
const journal = loadUpdateJournal();
|
|
7699
|
+
let vaultPath = journal && journal.vaultPath && fs.existsSync(journal.vaultPath) ? journal.vaultPath : resolveVaultPath(opts.vault);
|
|
7700
|
+
if (!vaultPath) {
|
|
7701
|
+
outro(red("No Mover OS vault found. Use: moveros update --finalize --vault /path"));
|
|
7702
|
+
process.exit(EXIT.NOT_FOUND);
|
|
7703
|
+
}
|
|
7704
|
+
let markerOk = false;
|
|
7705
|
+
try {
|
|
7706
|
+
const mp = path.join(vaultPath, ".mover-version");
|
|
7707
|
+
markerOk = fs.statSync(mp).isFile() && fs.readFileSync(mp, "utf8").trim().length > 0;
|
|
7708
|
+
} catch {}
|
|
7709
|
+
if (!markerOk) {
|
|
7710
|
+
outro(red(`No Mover OS install found at ${vaultPath}. Nothing to finalize.`));
|
|
7711
|
+
process.exit(EXIT.NOT_FOUND);
|
|
7712
|
+
}
|
|
7713
|
+
|
|
7714
|
+
const j = journal || newUpdateJournal("finalize", null, require("./package.json").version, vaultPath);
|
|
7715
|
+
|
|
7716
|
+
// MIGRATION RECEIPT (sol re-score #3 refutation: finalize marked migrate
|
|
7717
|
+
// done without verifying any Engine migration happened). The /update
|
|
7718
|
+
// workflow writes ~/.mover/update-receipt.json AFTER its Engine steps:
|
|
7719
|
+
// { runId, toVersion, migrated: [{path, sha256}], checked: [paths], note }
|
|
7720
|
+
// runId comes from the journal (~/.mover/update-state.json); null when no
|
|
7721
|
+
// journal existed. migrated may be EMPTY (no migration owed this release)
|
|
7722
|
+
// but the receipt itself is mandatory, and every migrated file must still
|
|
7723
|
+
// be on disk with the recorded post-migration hash - proof the work
|
|
7724
|
+
// happened and survived. Fail-closed: no receipt, no stamp.
|
|
7725
|
+
const receiptPath = path.join(os.homedir(), ".mover", "update-receipt.json");
|
|
7726
|
+
let receipt = null;
|
|
7727
|
+
try { receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); } catch {}
|
|
7728
|
+
const expectVersion = (j.toVersion || require("./package.json").version).replace(/^V/i, "");
|
|
7729
|
+
const receiptProblems = [];
|
|
7730
|
+
const owedMigrations = loadOwedEngineMigrations(journal, expectVersion);
|
|
7731
|
+
if (!receipt) {
|
|
7732
|
+
receiptProblems.push(`missing or unreadable: ${receiptPath}`);
|
|
7733
|
+
} else {
|
|
7734
|
+
const expectedRunId = journal ? journal.runId : null;
|
|
7735
|
+
if ((receipt.runId || null) !== expectedRunId) receiptProblems.push(`runId ${JSON.stringify(receipt.runId || null)} does not match this run ${JSON.stringify(expectedRunId)}`);
|
|
7736
|
+
if (String(receipt.toVersion || "").replace(/^V/i, "") !== expectVersion) receiptProblems.push(`toVersion ${JSON.stringify(receipt.toVersion)} does not match ${expectVersion}`);
|
|
7737
|
+
// Receipt paths must stay INSIDE the vault. Lexical checks (absolute,
|
|
7738
|
+
// ..-traversal) are not enough: a symlink WITHIN the vault pointing
|
|
7739
|
+
// outside would pass lexically but resolve elsewhere (sol re-score #5
|
|
7740
|
+
// refutation). resolveContained realpath-resolves both the vault root and
|
|
7741
|
+
// the entry and requires the entry to remain beneath the resolved root,
|
|
7742
|
+
// so a symlink escape is rejected. Returns the real absolute path or null.
|
|
7743
|
+
let realVaultRoot = null;
|
|
7744
|
+
try { realVaultRoot = fs.realpathSync(vaultPath); } catch { realVaultRoot = path.resolve(vaultPath); }
|
|
7745
|
+
const resolveContained = (rel) => {
|
|
7746
|
+
if (typeof rel !== "string" || rel.length === 0) return { err: "path is empty" };
|
|
7747
|
+
if (path.isAbsolute(rel) || rel.split(/[\\/]+/).includes("..")) return { err: "must be vault-relative without traversal" };
|
|
7748
|
+
const joined = path.join(vaultPath, rel);
|
|
7749
|
+
let real;
|
|
7750
|
+
try { real = fs.realpathSync(joined); } catch (e) { return { err: e.code === "ENOENT" ? "does not exist in the vault" : "not resolvable on disk" }; }
|
|
7751
|
+
const prefix = realVaultRoot.endsWith(path.sep) ? realVaultRoot : realVaultRoot + path.sep;
|
|
7752
|
+
if (real !== realVaultRoot && !real.startsWith(prefix)) return { err: "resolves outside the vault (symlink escape)" };
|
|
7753
|
+
return { abs: real };
|
|
7754
|
+
};
|
|
7755
|
+
if (!Array.isArray(receipt.migrated)) {
|
|
7756
|
+
receiptProblems.push("migrated must be an array ([] is valid when no migration was owed)");
|
|
7757
|
+
} else {
|
|
7758
|
+
for (const m of receipt.migrated) {
|
|
7759
|
+
const rel = m && m.path;
|
|
7760
|
+
if (typeof rel !== "string" || !rel) { receiptProblems.push("a migrated entry has no path"); continue; }
|
|
7761
|
+
const c = resolveContained(rel);
|
|
7762
|
+
if (c.err) { receiptProblems.push(`${rel}: migrated path ${c.err}`); continue; }
|
|
7763
|
+
try {
|
|
7764
|
+
const actual = require("crypto").createHash("sha256").update(fs.readFileSync(c.abs, "utf8").replace(/\r\n/g, "\n")).digest("hex");
|
|
7765
|
+
if (actual !== m.sha256) receiptProblems.push(`${rel}: on-disk hash does not match the receipt (file changed or migration reverted)`);
|
|
7766
|
+
} catch {
|
|
7767
|
+
receiptProblems.push(`${rel}: listed as migrated but not readable on disk`);
|
|
7768
|
+
}
|
|
7769
|
+
}
|
|
7770
|
+
// SEMANTIC COMPLETENESS: the receipt must cover every Engine migration
|
|
7771
|
+
// this release window DECLARES (src/system/engine-migrations.json). An
|
|
7772
|
+
// empty migrated list stops meaning "the process ran" and starts
|
|
7773
|
+
// meaning "this window declares nothing owed". FAIL-CLOSED when the
|
|
7774
|
+
// manifest cannot be found (sol re-score #5 refutation: recording
|
|
7775
|
+
// "unavailable" and proceeding with zero owed migrations let a stripped
|
|
7776
|
+
// bundle skip the check silently; a missing declaration source is a
|
|
7777
|
+
// refusal, not a pass).
|
|
7778
|
+
if (!owedMigrations.source) {
|
|
7779
|
+
receiptProblems.push("engine-migrations.json is unavailable in every bundle source: cannot prove the release owes no migration (fail-closed)");
|
|
7780
|
+
}
|
|
7781
|
+
const migratedPaths = new Set(receipt.migrated.map((m) => m && m.path).filter(Boolean));
|
|
7782
|
+
for (const d of owedMigrations.owed) {
|
|
7783
|
+
if (!migratedPaths.has(d.path)) {
|
|
7784
|
+
receiptProblems.push(`${d.path}: declared as owed by release ${d.version}${d.note ? ` (${d.note})` : ""} but absent from the receipt's migrated list`);
|
|
7785
|
+
}
|
|
7786
|
+
}
|
|
7787
|
+
// `checked` is the workflow's claim of what it INSPECTED - the basis for
|
|
7788
|
+
// "no (further) migration owed". The inspection itself cannot be
|
|
7789
|
+
// re-verified, but the claim can: every named file must actually exist
|
|
7790
|
+
// in the vault, and an empty-migration receipt must name at least one
|
|
7791
|
+
// inspected file (sol re-score #4: the field was counted in the journal
|
|
7792
|
+
// but never validated, so a fabricated receipt passed the gate).
|
|
7793
|
+
if (!Array.isArray(receipt.checked)) {
|
|
7794
|
+
receiptProblems.push("checked must be an array of vault-relative paths");
|
|
7795
|
+
} else {
|
|
7796
|
+
if (receipt.migrated.length === 0 && receipt.checked.length === 0) {
|
|
7797
|
+
receiptProblems.push("migrated is empty and checked is empty: name the files you inspected to conclude no migration was owed");
|
|
7798
|
+
}
|
|
7799
|
+
for (const rel of receipt.checked) {
|
|
7800
|
+
const c = resolveContained(rel);
|
|
7801
|
+
if (c.err) { receiptProblems.push(`${JSON.stringify(rel)}: checked path ${c.err}`); continue; }
|
|
7802
|
+
try {
|
|
7803
|
+
if (!fs.statSync(c.abs).isFile()) receiptProblems.push(`${rel}: listed as checked but is not a file`);
|
|
7804
|
+
} catch {
|
|
7805
|
+
receiptProblems.push(`${rel}: listed as checked but does not exist in the vault`);
|
|
7806
|
+
}
|
|
7807
|
+
}
|
|
7808
|
+
}
|
|
7809
|
+
}
|
|
7810
|
+
}
|
|
7811
|
+
if (receiptProblems.length) {
|
|
7812
|
+
journalStage(j, "migrate", "failed", { problems: receiptProblems.slice(0, 20) });
|
|
7813
|
+
barLn(red(" Migration receipt check failed:"));
|
|
7814
|
+
for (const p of receiptProblems.slice(0, 8)) barLn(` ${red("x")} ${p}`);
|
|
7815
|
+
barLn();
|
|
7816
|
+
barLn(dim(" /update must write ~/.mover/update-receipt.json after its Engine"));
|
|
7817
|
+
barLn(dim(" migration steps: { runId (from ~/.mover/update-state.json, or null),"));
|
|
7818
|
+
barLn(dim(" toVersion, migrated: [{path, sha256}], checked: [paths] }."));
|
|
7819
|
+
barLn(dim(" An empty migrated array is valid when no migration was owed,"));
|
|
7820
|
+
barLn(dim(" but then checked must name the real vault files you inspected."));
|
|
7821
|
+
outro(red("Not stamped: no verifiable migration receipt."));
|
|
7822
|
+
process.exit(EXIT.ERROR);
|
|
7823
|
+
}
|
|
7824
|
+
// MACHINE-DERIVED inspection evidence (sol re-score #6 part 10: "checked
|
|
7825
|
+
// remains a workflow-authored inspection claim; make inspection evidence
|
|
7826
|
+
// machine-derived"). Rather than trusting only the workflow's `checked`
|
|
7827
|
+
// list, finalize independently OBSERVES the Engine directory: it lists the
|
|
7828
|
+
// top-level Engine files (skipping Dailies and other subfolders) with a
|
|
7829
|
+
// content hash each, and confirms every `checked` entry that lives under
|
|
7830
|
+
// Engine is corroborated by that observation. The observed set is recorded
|
|
7831
|
+
// in the journal, so the audit trail carries machine-witnessed evidence of
|
|
7832
|
+
// the Engine state at stamp time, not a self-reported claim.
|
|
7833
|
+
const engineObserved = (() => {
|
|
7834
|
+
const engineDir = path.join(vaultPath, "02_Areas", "Engine");
|
|
7835
|
+
// Origin classification (sol #8 part 10 residual: "checked records
|
|
7836
|
+
// workflow-authored inspection evidence that cannot be reconstructed
|
|
7837
|
+
// mechanically"). The shipped-default fingerprint set (every historical
|
|
7838
|
+
// hash of each non-sentinel Engine default) lets the MACHINE reconstruct
|
|
7839
|
+
// the inspection's basis: a file either matches a shipped default
|
|
7840
|
+
// byte-for-byte (nothing to migrate), or is user-modified (migration
|
|
7841
|
+
// decisions concern it), or is outside the shipped set entirely
|
|
7842
|
+
// (sentinels + user-created files). Recorded per file in the journal, so
|
|
7843
|
+
// the audit trail carries the machine's own classification, not only the
|
|
7844
|
+
// workflow's word.
|
|
7845
|
+
let fingerprints = null;
|
|
7846
|
+
try { fingerprints = JSON.parse(fs.readFileSync(path.join(__dirname, "src", "dashboard", "lib", "engine-default-fingerprints.json"), "utf8")).files; } catch {}
|
|
7847
|
+
const fpHash = (text) => require("crypto").createHash("sha256").update(String(text).replace(/\r\n/g, "\n").trim()).digest("hex");
|
|
7848
|
+
try {
|
|
7849
|
+
const files = fs.readdirSync(engineDir, { withFileTypes: true })
|
|
7850
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
7851
|
+
.map((e) => {
|
|
7852
|
+
const rel = path.join("02_Areas", "Engine", e.name);
|
|
7853
|
+
let sha = null;
|
|
7854
|
+
let origin = "unclassified";
|
|
7855
|
+
try {
|
|
7856
|
+
const text = fs.readFileSync(path.join(engineDir, e.name), "utf8");
|
|
7857
|
+
sha = require("crypto").createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex").slice(0, 16);
|
|
7858
|
+
if (fingerprints) {
|
|
7859
|
+
const known = fingerprints[e.name];
|
|
7860
|
+
origin = Array.isArray(known)
|
|
7861
|
+
? (known.includes(fpHash(text)) ? "matches-shipped-default" : "user-modified")
|
|
7862
|
+
: "not-a-shipped-default"; // sentinels + user-created files
|
|
7863
|
+
}
|
|
7864
|
+
} catch {}
|
|
7865
|
+
return { path: rel, sha, origin };
|
|
7866
|
+
});
|
|
7867
|
+
return { present: true, count: files.length, files, fingerprintSource: fingerprints ? "engine-default-fingerprints.json" : "unavailable" };
|
|
7868
|
+
} catch {
|
|
7869
|
+
return { present: false, count: 0, files: [], fingerprintSource: fingerprints ? "engine-default-fingerprints.json" : "unavailable" };
|
|
7870
|
+
}
|
|
7871
|
+
})();
|
|
7872
|
+
// Corroborate: any `checked` entry that claims an Engine file must appear in
|
|
7873
|
+
// the machine-observed set. A claim about an Engine file the machine cannot
|
|
7874
|
+
// see is a fabricated inspection, caught here rather than trusted.
|
|
7875
|
+
const observedEngine = new Set(engineObserved.files.map((f) => f.path.split(path.sep).join("/")));
|
|
7876
|
+
const uncorroborated = receipt.checked
|
|
7877
|
+
.map((r) => String(r).split(path.sep).join("/"))
|
|
7878
|
+
.filter((r) => r.startsWith("02_Areas/Engine/") && !r.includes("/Dailies/") && !observedEngine.has(r));
|
|
7879
|
+
if (uncorroborated.length && engineObserved.present) {
|
|
7880
|
+
journalStage(j, "migrate", "failed", { problems: uncorroborated.map((r) => `${r}: checked as an Engine file but not observed in the Engine directory (fabricated inspection)`) });
|
|
7881
|
+
barLn(red(" Migration receipt check failed (machine corroboration):"));
|
|
7882
|
+
for (const r of uncorroborated.slice(0, 8)) barLn(` ${red("x")} ${r}: not observed in the Engine directory`);
|
|
7883
|
+
outro(red("Not stamped: a checked Engine file was not machine-corroborated."));
|
|
7884
|
+
process.exit(EXIT.ERROR);
|
|
7885
|
+
}
|
|
7886
|
+
journalStage(j, "migrate", "done", {
|
|
7887
|
+
owner: "workflow",
|
|
7888
|
+
finalizedBy: "moveros update --finalize",
|
|
7889
|
+
receipt: { migrated: receipt.migrated.length, checked: receipt.checked.length },
|
|
7890
|
+
// "unavailable" = no declarations manifest found in any bundle source;
|
|
7891
|
+
// recorded rather than passed off as a checked window.
|
|
7892
|
+
migrations: { manifest: owedMigrations.source ? "checked" : "unavailable", declaredOwed: owedMigrations.owed.length },
|
|
7893
|
+
// Machine-witnessed Engine state at stamp time (independent of the
|
|
7894
|
+
// workflow's `checked` claim).
|
|
7895
|
+
engineObserved,
|
|
7896
|
+
});
|
|
7897
|
+
maybeCrashForTest("migrate");
|
|
7898
|
+
journalStage(j, "verify", "started");
|
|
7899
|
+
maybeCrashForTest("verify");
|
|
7900
|
+
|
|
7901
|
+
const bundleDir = resolveVerifyBundleDir(j);
|
|
7902
|
+
let result;
|
|
7903
|
+
let scope;
|
|
7904
|
+
if (bundleDir) {
|
|
7905
|
+
scope = "full";
|
|
7906
|
+
result = verifyUpdateApplied(bundleDir, vaultPath, { templates: "content" });
|
|
7907
|
+
} else {
|
|
7908
|
+
// No source available to verify against (e.g. an Engine-only migration
|
|
7909
|
+
// with no staged payload). Degrade honestly to the conflicts gate; never
|
|
7910
|
+
// fabricate a full verification.
|
|
7911
|
+
scope = "conflicts-only";
|
|
7912
|
+
result = { ok: true, failures: [] };
|
|
7913
|
+
const conflictsPath = path.join(os.homedir(), ".mover", "conflicts.json");
|
|
7914
|
+
if (fs.existsSync(conflictsPath)) {
|
|
7915
|
+
try {
|
|
7916
|
+
const data = JSON.parse(fs.readFileSync(conflictsPath, "utf8"));
|
|
7917
|
+
result.failures = (data.files || []).map((f) => ({ file: f.file, reason: "unresolved-conflict" }));
|
|
7918
|
+
} catch {
|
|
7919
|
+
result.failures = [{ file: "conflicts.json", reason: "unreadable" }];
|
|
7920
|
+
}
|
|
7921
|
+
result.ok = result.failures.length === 0;
|
|
7922
|
+
}
|
|
7923
|
+
}
|
|
7924
|
+
|
|
7925
|
+
if (!result.ok) {
|
|
7926
|
+
journalStage(j, "verify", "failed", { scope, failures: result.failures.slice(0, 50) });
|
|
7927
|
+
barLn(red(` Verification failed: ${result.failures.length} file${result.failures.length > 1 ? "s" : ""} not at the updated version.`));
|
|
7928
|
+
for (const f of result.failures.slice(0, 12)) barLn(` ${red("x")} ${f.file} ${dim(`(${f.reason})`)}`);
|
|
7929
|
+
if (result.failures.length > 12) barLn(dim(` ...and ${result.failures.length - 12} more`));
|
|
7930
|
+
barLn();
|
|
7931
|
+
barLn(dim(" The version was NOT stamped. Resolve the files above"));
|
|
7932
|
+
barLn(dim(" (usually: re-run moveros update, or finish /update conflict merges),"));
|
|
7933
|
+
barLn(dim(" then run moveros update --finalize again."));
|
|
7934
|
+
outro(red("Not stamped: verification failed."));
|
|
7935
|
+
process.exit(EXIT.ERROR);
|
|
7936
|
+
}
|
|
7937
|
+
|
|
7938
|
+
journalStage(j, "verify", "done", { scope, checkedAgainst: bundleDir || null });
|
|
7939
|
+
journalStage(j, "stamp", "started");
|
|
7940
|
+
maybeCrashForTest("stamp");
|
|
7941
|
+
const version = (j.toVersion || require("./package.json").version).replace(/^V/i, "");
|
|
7942
|
+
fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${version}\n`, "utf8");
|
|
7943
|
+
j.completedAt = new Date().toISOString();
|
|
7944
|
+
journalStage(j, "stamp", "done", { version });
|
|
7945
|
+
// Consume the receipt: a stamped run's receipt must never validate a
|
|
7946
|
+
// FUTURE finalize (the null-journal path has no runId to collide on).
|
|
7947
|
+
try { fs.renameSync(receiptPath, `${receiptPath.replace(/\.json$/, "")}.used.json`); } catch {}
|
|
7948
|
+
statusLine("ok", "Verified", scope === "full" ? "workflows, templates, hooks all at the updated version" : "no unresolved conflicts");
|
|
7949
|
+
statusLine("ok", "Stamped", `.mover-version -> ${version}`);
|
|
7950
|
+
outro(green(`Update finalized: verified and stamped ${version}.`));
|
|
7951
|
+
}
|
|
7952
|
+
|
|
7953
|
+
// `moveros update --rollback`: restore the vault + agent artifacts from the
|
|
7954
|
+
// backup recorded in the journal (or the newest rollback-capable backup).
|
|
7955
|
+
// Never touches the bundle dir: on npm installs that is a read-only global
|
|
7956
|
+
// package, and it is not the rollback target anyway. Engine files are never
|
|
7957
|
+
// in these backups, so they are never touched here either.
|
|
7958
|
+
async function cmdUpdateRollback(opts) {
|
|
7959
|
+
const home = os.homedir();
|
|
7960
|
+
const journal = loadUpdateJournal();
|
|
7961
|
+
let backupDir = journal && journal.stages && journal.stages.backup && journal.stages.backup.backupDir;
|
|
7962
|
+
const dirArg = opts.rest.find((a) => a.startsWith("--dir="));
|
|
7963
|
+
if (dirArg) backupDir = path.resolve(dirArg.slice(6));
|
|
7964
|
+
if (!backupDir || !fs.existsSync(path.join(backupDir, "backup-manifest.json"))) {
|
|
7965
|
+
const root = path.join(home, ".mover", "backups");
|
|
7966
|
+
const cands = fs.existsSync(root)
|
|
7967
|
+
? fs.readdirSync(root).filter((d) => d.startsWith("pre-update-") && fs.existsSync(path.join(root, d, "backup-manifest.json"))).sort()
|
|
7968
|
+
: [];
|
|
7969
|
+
if (cands.length) backupDir = path.join(root, cands[cands.length - 1]);
|
|
7970
|
+
}
|
|
7971
|
+
if (!backupDir || !fs.existsSync(path.join(backupDir, "backup-manifest.json"))) {
|
|
7972
|
+
barLn(red(" No rollback-capable backup found (backup-manifest.json missing)."));
|
|
7973
|
+
barLn(dim(" Backups made before this feature can be restored by hand from ~/.mover/backups/."));
|
|
7974
|
+
outro(red("Rollback aborted: nothing to restore from."));
|
|
7975
|
+
process.exit(EXIT.NOT_FOUND);
|
|
7976
|
+
}
|
|
7977
|
+
|
|
7978
|
+
let bm;
|
|
7979
|
+
try {
|
|
7980
|
+
bm = JSON.parse(fs.readFileSync(path.join(backupDir, "backup-manifest.json"), "utf8"));
|
|
7981
|
+
} catch {
|
|
7982
|
+
outro(red("Rollback aborted: backup-manifest.json is unreadable."));
|
|
7983
|
+
process.exit(EXIT.ERROR);
|
|
7984
|
+
}
|
|
7985
|
+
|
|
7986
|
+
let restored = 0, removed = 0, failed = 0;
|
|
7987
|
+
for (const e of bm.entries || []) {
|
|
7988
|
+
try {
|
|
7989
|
+
if (e.existedBefore && e.backup) {
|
|
7990
|
+
const src = path.join(backupDir, e.backup);
|
|
7991
|
+
if (!fs.existsSync(src)) { failed++; continue; }
|
|
7992
|
+
fs.mkdirSync(path.dirname(e.original), { recursive: true });
|
|
7993
|
+
// Unlink first: the original may be a hard link into the bundle
|
|
7994
|
+
// source; copying through the link would write INTO the (possibly
|
|
7995
|
+
// read-only, npm-owned) bundle. A fresh inode never can.
|
|
7996
|
+
try { fs.rmSync(e.original, { force: true }); } catch {}
|
|
7997
|
+
fs.copyFileSync(src, e.original);
|
|
7998
|
+
restored++;
|
|
7999
|
+
} else if (!e.existedBefore) {
|
|
8000
|
+
if (fs.existsSync(e.original)) { fs.rmSync(e.original, { force: true }); removed++; }
|
|
8001
|
+
}
|
|
8002
|
+
} catch {
|
|
8003
|
+
failed++;
|
|
8004
|
+
}
|
|
8005
|
+
}
|
|
8006
|
+
|
|
8007
|
+
// Restore the pristine base snapshots captured with the backup so the next
|
|
8008
|
+
// update classifies the rolled-back files against the right bases.
|
|
8009
|
+
try {
|
|
8010
|
+
const snapInstalled = path.join(backupDir, "mover-state", "installed");
|
|
8011
|
+
if (fs.existsSync(snapInstalled)) {
|
|
8012
|
+
fs.cpSync(snapInstalled, path.join(home, ".mover", "installed"), { recursive: true, force: true });
|
|
8013
|
+
}
|
|
8014
|
+
} catch {}
|
|
8015
|
+
// Conflicts recorded by the rolled-back run are void now.
|
|
8016
|
+
try { fs.rmSync(path.join(home, ".mover", "conflicts.json"), { force: true }); } catch {}
|
|
8017
|
+
|
|
8018
|
+
if (journal && !journal.completedAt) {
|
|
8019
|
+
journal.rolledBack = true;
|
|
8020
|
+
journal.completedAt = new Date().toISOString();
|
|
8021
|
+
saveUpdateJournal(journal);
|
|
8022
|
+
}
|
|
8023
|
+
|
|
8024
|
+
statusLine("ok", "Rollback", `${restored} restored, ${removed} removed${failed ? `, ${red(`${failed} failed`)}` : ""}`);
|
|
8025
|
+
barLn(dim(` From: ${backupDir}`));
|
|
8026
|
+
barLn(dim(" Engine files were never in this backup and were not touched."));
|
|
8027
|
+
barLn(dim(" The version marker was not changed (interactive updates never stamp before verify)."));
|
|
8028
|
+
outro(failed ? yellow(`Rollback finished with ${failed} failure${failed > 1 ? "s" : ""}.`) : green("Rollback complete."));
|
|
8029
|
+
if (failed) process.exit(EXIT.ERROR);
|
|
8030
|
+
}
|
|
8031
|
+
|
|
6434
8032
|
async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
8033
|
+
// ── State-machine subcommands: local, no license, no self-update hop ──
|
|
8034
|
+
if (opts.rest.includes("--status")) return cmdUpdateStatus();
|
|
8035
|
+
if (opts.rest.includes("--finalize")) return cmdUpdateFinalize(opts);
|
|
8036
|
+
if (opts.rest.includes("--rollback")) return cmdUpdateRollback(opts);
|
|
8037
|
+
|
|
6435
8038
|
const isQuick = opts.rest.includes("--quick");
|
|
6436
8039
|
|
|
6437
8040
|
// Step 1: CLI Self-Update
|
|
@@ -6455,8 +8058,10 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6455
8058
|
try {
|
|
6456
8059
|
try {
|
|
6457
8060
|
execSync("npm i -g mover-os", { stdio: "ignore", timeout: 60000 });
|
|
6458
|
-
} catch {
|
|
6459
|
-
// Retry with sudo — use inherit so password prompt shows
|
|
8061
|
+
} catch (err) {
|
|
8062
|
+
// Retry with sudo — use inherit so password prompt shows.
|
|
8063
|
+
// sudo does not exist on Windows; surface the real npm error there.
|
|
8064
|
+
if (process.platform === "win32") throw err;
|
|
6460
8065
|
sp.stop(dim("Needs elevated permissions..."));
|
|
6461
8066
|
execSync("sudo npm i -g mover-os", { stdio: "inherit", timeout: 120000 });
|
|
6462
8067
|
}
|
|
@@ -6491,6 +8096,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6491
8096
|
}
|
|
6492
8097
|
statusLine("ok", "Vault", path.basename(vaultPath));
|
|
6493
8098
|
|
|
8099
|
+
// An update must target an EXISTING install. resolveVaultPath trusts an
|
|
8100
|
+
// explicit --vault without existsSync (terra F5), so a typo'd or wrong path
|
|
8101
|
+
// would otherwise get a fresh structure minted and stamped as "current",
|
|
8102
|
+
// shadowing the real install. Refuse unless a real, non-empty install marker
|
|
8103
|
+
// is present (sol flaw #5: an empty file or a directory named .mover-version
|
|
8104
|
+
// must not pass the guard).
|
|
8105
|
+
let installMarkerOk = false;
|
|
8106
|
+
try {
|
|
8107
|
+
const mp = path.join(vaultPath, ".mover-version");
|
|
8108
|
+
installMarkerOk = fs.statSync(mp).isFile() && fs.readFileSync(mp, "utf8").trim().length > 0;
|
|
8109
|
+
} catch {}
|
|
8110
|
+
if (!installMarkerOk) {
|
|
8111
|
+
outro(red(`No Mover OS install found at ${vaultPath}. Run ${bold("moveros install")} first, or pass the correct --vault.`));
|
|
8112
|
+
process.exit(EXIT.NOT_FOUND);
|
|
8113
|
+
}
|
|
8114
|
+
|
|
6494
8115
|
// Step 3: Key Validation
|
|
6495
8116
|
let updateKey = opts.key;
|
|
6496
8117
|
if (!updateKey) {
|
|
@@ -6548,9 +8169,24 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6548
8169
|
newVer = pkg.version || newVer;
|
|
6549
8170
|
} catch {}
|
|
6550
8171
|
|
|
6551
|
-
// ──
|
|
6552
|
-
//
|
|
6553
|
-
//
|
|
8172
|
+
// ── Update journal: ONE resumable state machine for CLI + /update ──
|
|
8173
|
+
// Created here, after preflight facts (install marker, license, staged
|
|
8174
|
+
// bundle) are established. Resumes an interrupted run of the same mode and
|
|
8175
|
+
// target version so the original backup dir is preserved; every stage below
|
|
8176
|
+
// is idempotent, so resume = re-run from the journal.
|
|
8177
|
+
const journalMode = isQuick ? "quick" : "interactive";
|
|
8178
|
+
let journal = loadUpdateJournal();
|
|
8179
|
+
const resuming = !!(journal && !journal.completedAt && journal.toVersion === newVer && journal.mode === journalMode && journal.vaultPath === vaultPath);
|
|
8180
|
+
if (!resuming) {
|
|
8181
|
+
journal = newUpdateJournal(journalMode, installedVer, newVer, vaultPath);
|
|
8182
|
+
} else {
|
|
8183
|
+
barLn(dim(` Resuming interrupted update (started ${journal.startedAt}).`));
|
|
8184
|
+
barLn();
|
|
8185
|
+
}
|
|
8186
|
+
journalStage(journal, "preflight", "done", { installMarker: true });
|
|
8187
|
+
maybeCrashForTest("preflight");
|
|
8188
|
+
journalStage(journal, "stage", "done", { bundleDir });
|
|
8189
|
+
maybeCrashForTest("stage");
|
|
6554
8190
|
|
|
6555
8191
|
const detectedAgents = AGENTS.filter((a) => a.detect());
|
|
6556
8192
|
const selectedIds = detectedAgents.map((a) => a.id);
|
|
@@ -6561,8 +8197,34 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6561
8197
|
const changes = detectChanges(bundleDir, vaultPath, selectedIds);
|
|
6562
8198
|
const totalChanged = countChanges(changes);
|
|
6563
8199
|
displayChangeSummary(changes, installedVer, newVer);
|
|
6564
|
-
if (totalChanged === 0
|
|
6565
|
-
|
|
8200
|
+
if (totalChanged === 0 && !resuming) {
|
|
8201
|
+
// Nothing to do and no interrupted run to finish: close the journal
|
|
8202
|
+
// honestly rather than leave a phantom in-progress run.
|
|
8203
|
+
for (const s of ["backup", "apply", "migrate", "verify", "stamp"]) journalStage(journal, s, "skipped", { reason: "no-changes" });
|
|
8204
|
+
journal.completedAt = new Date().toISOString();
|
|
8205
|
+
saveUpdateJournal(journal);
|
|
8206
|
+
outro(green("Already up to date."));
|
|
8207
|
+
return;
|
|
8208
|
+
}
|
|
8209
|
+
if (totalChanged === 0 && resuming) {
|
|
8210
|
+
// Resume after a crash PAST the apply stage: the files are already on
|
|
8211
|
+
// disk but verify/stamp never ran. Fall through to them; re-running
|
|
8212
|
+
// backup/apply on zero changes would be a no-op anyway.
|
|
8213
|
+
barLn(dim(" Files already applied by the interrupted run - verifying and stamping."));
|
|
8214
|
+
barLn();
|
|
8215
|
+
if (journal.stages.backup.status !== "done") journalStage(journal, "backup", "skipped", { reason: "resume-no-changes" });
|
|
8216
|
+
journalStage(journal, "apply", "done", { updated: 0, resumed: true });
|
|
8217
|
+
}
|
|
8218
|
+
if (totalChanged > 0) {
|
|
8219
|
+
journalStage(journal, "backup", "started");
|
|
8220
|
+
maybeCrashForTest("backup");
|
|
8221
|
+
const priorBackup = resuming && journal.stages.backup && journal.stages.backup.backupDir && fs.existsSync(journal.stages.backup.backupDir)
|
|
8222
|
+
? { backupRoot: journal.stages.backup.backupDir, backed: journal.stages.backup.backed || 0 }
|
|
8223
|
+
: null;
|
|
8224
|
+
const bkup = priorBackup || autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
|
|
8225
|
+
journalStage(journal, "backup", "done", { backupDir: bkup.backupRoot, backed: bkup.backed, reused: !!priorBackup });
|
|
8226
|
+
journalStage(journal, "apply", "started");
|
|
8227
|
+
maybeCrashForTest("apply");
|
|
6566
8228
|
barLn(bold("Updating..."));
|
|
6567
8229
|
barLn();
|
|
6568
8230
|
createVaultStructure(vaultPath);
|
|
@@ -6583,8 +8245,102 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6583
8245
|
sp.stop(steps.length > 0 ? `${displayName} ${dim(steps.join(", "))}` : `${displayName} ${dim("configured")}`);
|
|
6584
8246
|
}
|
|
6585
8247
|
}
|
|
8248
|
+
|
|
8249
|
+
// Complete Claude hook-asset refresh (6b parity with the interactive path):
|
|
8250
|
+
// the .js helpers and .md prompt templates must be on disk before anything
|
|
8251
|
+
// records or verifies them as current.
|
|
8252
|
+
const quickHooksSrc = path.join(bundleDir, "src", "hooks");
|
|
8253
|
+
const quickClaudeDetected = detectedAgents.some((a) => a.id === "claude-code");
|
|
8254
|
+
if (quickClaudeDetected && fs.existsSync(quickHooksSrc)) {
|
|
8255
|
+
const hooksDstClaude = path.join(os.homedir(), ".claude", "hooks");
|
|
8256
|
+
fs.mkdirSync(hooksDstClaude, { recursive: true });
|
|
8257
|
+
for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".sh"))) {
|
|
8258
|
+
const content = fs.readFileSync(path.join(quickHooksSrc, f), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
8259
|
+
fs.writeFileSync(path.join(hooksDstClaude, f), content, { mode: 0o755 });
|
|
8260
|
+
try { fs.chmodSync(path.join(hooksDstClaude, f), 0o755); } catch {}
|
|
8261
|
+
}
|
|
8262
|
+
for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".md") || x.endsWith(".js"))) {
|
|
8263
|
+
fs.copyFileSync(path.join(quickHooksSrc, f), path.join(hooksDstClaude, f));
|
|
8264
|
+
}
|
|
8265
|
+
}
|
|
8266
|
+
|
|
8267
|
+
// Rebuild the manifest to match what force-apply actually put on disk.
|
|
8268
|
+
// record-if-current: an entry asserts "the install holds this base", so
|
|
8269
|
+
// record the source hash only where the destination proves it (or, for
|
|
8270
|
+
// workflows, where this run just force-wrote it); otherwise carry the
|
|
8271
|
+
// prior base forward (F4 discipline) so nothing uncopied is marked current.
|
|
8272
|
+
try {
|
|
8273
|
+
const prior = loadUpdateManifest();
|
|
8274
|
+
const mfst = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
|
|
8275
|
+
if (prior && prior.files) Object.assign(mfst.files, prior.files);
|
|
8276
|
+
const wfDir = path.join(bundleDir, "src", "workflows");
|
|
8277
|
+
if (fs.existsSync(wfDir)) {
|
|
8278
|
+
// Quick force-applies EVERY workflow via the agent installers (which
|
|
8279
|
+
// also advance the pristine snapshots), so the source hash IS the new
|
|
8280
|
+
// base for all of them. Transformed copies still classify as
|
|
8281
|
+
// user-only-skip against this base, same as fresh installs.
|
|
8282
|
+
for (const f of fs.readdirSync(wfDir).filter((x) => x.endsWith(".md"))) {
|
|
8283
|
+
mfst.files[`workflows/${f}`] = fileContentHash(path.join(wfDir, f));
|
|
8284
|
+
}
|
|
8285
|
+
}
|
|
8286
|
+
const rulesPath2 = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
|
|
8287
|
+
if (fs.existsSync(rulesPath2)) mfst.files["system/Mover_Global_Rules.md"] = fileContentHash(rulesPath2);
|
|
8288
|
+
if (quickClaudeDetected && fs.existsSync(quickHooksSrc)) {
|
|
8289
|
+
for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
|
|
8290
|
+
const dst = path.join(os.homedir(), ".claude", "hooks", f);
|
|
8291
|
+
if (fs.existsSync(dst) && fileContentHash(dst) === fileContentHash(path.join(quickHooksSrc, f))) {
|
|
8292
|
+
mfst.files[`hooks/${f}`] = fileContentHash(path.join(quickHooksSrc, f));
|
|
8293
|
+
}
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
8296
|
+
// Templates: quick installs NEW files but never overwrites existing vault
|
|
8297
|
+
// scaffold files, so only record entries the disk actually holds.
|
|
8298
|
+
const structDir2 = path.join(bundleDir, "src", "structure");
|
|
8299
|
+
if (fs.existsSync(structDir2)) {
|
|
8300
|
+
const walkT = (dir, rel) => {
|
|
8301
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
8302
|
+
const srcPath = path.join(dir, entry.name);
|
|
8303
|
+
const entryRel = path.join(rel, entry.name);
|
|
8304
|
+
if (entry.isDirectory()) { walkT(srcPath, entryRel); continue; }
|
|
8305
|
+
const relNorm = entryRel.replace(/\\/g, "/");
|
|
8306
|
+
if (relNorm.includes("02_Areas") && relNorm.includes("Engine")) continue;
|
|
8307
|
+
const destFile = path.join(vaultPath, entryRel);
|
|
8308
|
+
try {
|
|
8309
|
+
if (fs.existsSync(destFile) && fileContentHash(destFile) === fileContentHash(srcPath)) {
|
|
8310
|
+
mfst.files[`templates/${relNorm}`] = fileContentHash(srcPath);
|
|
8311
|
+
}
|
|
8312
|
+
} catch {}
|
|
8313
|
+
}
|
|
8314
|
+
};
|
|
8315
|
+
walkT(structDir2, "");
|
|
8316
|
+
}
|
|
8317
|
+
saveUpdateManifest(mfst);
|
|
8318
|
+
} catch {}
|
|
8319
|
+
|
|
8320
|
+
journalStage(journal, "apply", "done", { updated: totalChanged });
|
|
8321
|
+
} // end totalChanged > 0
|
|
8322
|
+
journalStage(journal, "migrate", "skipped", { reason: "quick mode: run /update for Engine migrations" });
|
|
8323
|
+
|
|
8324
|
+
// ── Verify BEFORE the stamp: the stamp is earned, never assumed ──
|
|
8325
|
+
journalStage(journal, "verify", "started");
|
|
8326
|
+
maybeCrashForTest("verify");
|
|
8327
|
+
const quickVerify = verifyUpdateApplied(bundleDir, vaultPath, { templates: "presence" });
|
|
8328
|
+
if (!quickVerify.ok) {
|
|
8329
|
+
journalStage(journal, "verify", "failed", { failures: quickVerify.failures.slice(0, 50) });
|
|
8330
|
+
barLn(red(` Verification failed: ${quickVerify.failures.length} file${quickVerify.failures.length > 1 ? "s" : ""} not at the updated version.`));
|
|
8331
|
+
for (const f of quickVerify.failures.slice(0, 12)) barLn(` ${red("x")} ${f.file} ${dim(`(${f.reason})`)}`);
|
|
8332
|
+
barLn(dim(" The version was NOT stamped. Re-run moveros update --quick once the cause is resolved."));
|
|
8333
|
+
outro(red("Update incomplete: verification failed, version not stamped."));
|
|
8334
|
+
process.exit(EXIT.ERROR);
|
|
8335
|
+
}
|
|
8336
|
+
journalStage(journal, "verify", "done", { scope: "quick" });
|
|
8337
|
+
|
|
8338
|
+
journalStage(journal, "stamp", "started");
|
|
8339
|
+
maybeCrashForTest("stamp");
|
|
6586
8340
|
fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
|
|
6587
8341
|
writeMoverConfig(vaultPath, selectedIds, updateKey);
|
|
8342
|
+
journal.completedAt = new Date().toISOString();
|
|
8343
|
+
journalStage(journal, "stamp", "done", { version: require("./package.json").version });
|
|
6588
8344
|
barLn();
|
|
6589
8345
|
if (totalChanged > 0) {
|
|
6590
8346
|
barLn(dim(" Restart your AI session to load updated rules and skills."));
|
|
@@ -6622,6 +8378,11 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6622
8378
|
if (totalChanged === 0) {
|
|
6623
8379
|
barLn(green(" Already up to date."));
|
|
6624
8380
|
barLn();
|
|
8381
|
+
// Mechanical stages are trivially complete; Engine migration may still be
|
|
8382
|
+
// owed, so migrate stays pending and /update + --finalize own the stamp.
|
|
8383
|
+
journalStage(journal, "backup", "skipped", { reason: "no-changes" });
|
|
8384
|
+
journalStage(journal, "apply", "done", { updated: 0 });
|
|
8385
|
+
journalStage(journal, "migrate", "pending", { owner: "workflow" });
|
|
6625
8386
|
outro(green("No changes needed. Engine files may still need migration — run /update."));
|
|
6626
8387
|
return;
|
|
6627
8388
|
}
|
|
@@ -6636,11 +8397,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6636
8397
|
{ multi: false, defaultIndex: 0 }
|
|
6637
8398
|
);
|
|
6638
8399
|
if (!updateConfirm || updateConfirm === "no") {
|
|
8400
|
+
// Nothing was touched: close the journal so no phantom run lingers.
|
|
8401
|
+
if (!resuming) {
|
|
8402
|
+
journal.cancelled = true;
|
|
8403
|
+
journal.completedAt = new Date().toISOString();
|
|
8404
|
+
saveUpdateJournal(journal);
|
|
8405
|
+
}
|
|
6639
8406
|
outro("Update cancelled.");
|
|
6640
8407
|
return;
|
|
6641
8408
|
}
|
|
6642
8409
|
barLn();
|
|
6643
8410
|
|
|
8411
|
+
// ── Backup stage: full pre-apply backup of every file this run will touch ──
|
|
8412
|
+
// (Previously quick-only, so an interactive safe-overwrite had NO restore
|
|
8413
|
+
// source. The journal keeps the ORIGINAL dir across resumes so rollback
|
|
8414
|
+
// always targets the true pre-update state.)
|
|
8415
|
+
journalStage(journal, "backup", "started");
|
|
8416
|
+
maybeCrashForTest("backup");
|
|
8417
|
+
const priorBackupI = resuming && journal.stages.backup && journal.stages.backup.backupDir && fs.existsSync(journal.stages.backup.backupDir)
|
|
8418
|
+
? { backupRoot: journal.stages.backup.backupDir, backed: journal.stages.backup.backed || 0 }
|
|
8419
|
+
: null;
|
|
8420
|
+
const bkupI = priorBackupI || autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
|
|
8421
|
+
journalStage(journal, "backup", "done", { backupDir: bkupI.backupRoot, backed: bkupI.backed, reused: !!priorBackupI });
|
|
8422
|
+
journalStage(journal, "apply", "started");
|
|
8423
|
+
maybeCrashForTest("apply");
|
|
8424
|
+
|
|
6644
8425
|
// ── Apply safe system files directly (no user customizations in these) ──
|
|
6645
8426
|
const home = os.homedir();
|
|
6646
8427
|
let appliedCount = 0;
|
|
@@ -6666,14 +8447,18 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6666
8447
|
if (fs.existsSync(slSrc)) {
|
|
6667
8448
|
const slContent = fs.readFileSync(slSrc, "utf8").replace(/\r\n/g, "\n");
|
|
6668
8449
|
fs.writeFileSync(slDest, slContent, "utf8");
|
|
6669
|
-
// Ensure settings.json has statusLine config
|
|
8450
|
+
// Ensure settings.json has statusLine config. Absolute forward-slash
|
|
8451
|
+
// path, never `~` — cmd.exe does not expand tildes (Windows dead-hook
|
|
8452
|
+
// bug class). Also migrate an existing `~` form left by old installs.
|
|
6670
8453
|
const globalSettings = path.join(home, ".claude", "settings.json");
|
|
8454
|
+
const slCmd = `node "${slDest.replace(/\\/g, "/")}"`;
|
|
6671
8455
|
try {
|
|
6672
8456
|
const settings = fs.existsSync(globalSettings)
|
|
6673
8457
|
? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
|
|
6674
8458
|
: {};
|
|
6675
|
-
|
|
6676
|
-
|
|
8459
|
+
const cur = settings.statusLine && settings.statusLine.command;
|
|
8460
|
+
if (!settings.statusLine || /~\/\.claude\/statusline\.js/.test(String(cur || ""))) {
|
|
8461
|
+
settings.statusLine = { type: "command", command: slCmd };
|
|
6677
8462
|
fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
|
|
6678
8463
|
}
|
|
6679
8464
|
} catch {}
|
|
@@ -6692,13 +8477,16 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6692
8477
|
for (const dir of cmdDirs) {
|
|
6693
8478
|
const dest = path.join(dir, "update.md");
|
|
6694
8479
|
if (fs.existsSync(dir)) {
|
|
6695
|
-
// Gap-1:
|
|
6696
|
-
//
|
|
6697
|
-
//
|
|
6698
|
-
//
|
|
6699
|
-
//
|
|
8480
|
+
// Gap-1: route through the vault-resolution transform. For bodies that
|
|
8481
|
+
// don't need it (returns false), replace the dest ATOMICALLY via
|
|
8482
|
+
// same-dir temp + rename: a plain copyFileSync would write THROUGH the
|
|
8483
|
+
// install-time hard link into the bundle source, which on an npm
|
|
8484
|
+
// global install is read-only (EACCES; caught by the update-machine
|
|
8485
|
+
// read-only-bundle test) and on a repo install would corrupt src/.
|
|
6700
8486
|
if (!writeWorkflowTransformed(updateSrc, dest)) {
|
|
6701
|
-
|
|
8487
|
+
const tmp = dest + ".tmp-" + process.pid;
|
|
8488
|
+
fs.copyFileSync(updateSrc, tmp);
|
|
8489
|
+
fs.renameSync(tmp, dest);
|
|
6702
8490
|
}
|
|
6703
8491
|
}
|
|
6704
8492
|
}
|
|
@@ -6717,7 +8505,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6717
8505
|
// ── Smart Update: hash-based change detection + auto-merge ──
|
|
6718
8506
|
const manifest = loadUpdateManifest();
|
|
6719
8507
|
const newManifest = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
|
|
6720
|
-
const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [];
|
|
8508
|
+
const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [], writeFailed = [];
|
|
6721
8509
|
let needsUpdate = false;
|
|
6722
8510
|
|
|
6723
8511
|
if (!manifest) {
|
|
@@ -6735,6 +8523,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6735
8523
|
const srcFile = path.join(wfSrcDir, file);
|
|
6736
8524
|
const destFile = path.join(wfDestDir, file);
|
|
6737
8525
|
const result = compareForUpdate("workflows", srcFile, destFile, manifest);
|
|
8526
|
+
let wroteFile = true; // false = left on disk unresolved (see manifest guard below)
|
|
6738
8527
|
|
|
6739
8528
|
switch (result.action) {
|
|
6740
8529
|
case "install":
|
|
@@ -6744,9 +8533,16 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6744
8533
|
// bare git-rev-parse Pre-Flight on update. writeWorkflowTransformed
|
|
6745
8534
|
// emits a corrected copy for matched files and returns false (→ linkOrCopy)
|
|
6746
8535
|
// for the rest, preserving the live-edit hard link where it's safe.
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
8536
|
+
// sol flaw #3: linkOrCopy returns null on I/O failure. Do NOT advance
|
|
8537
|
+
// the pristine snapshot or manifest for a file that was not actually
|
|
8538
|
+
// written, or the next update treats an unapplied file as current.
|
|
8539
|
+
if (writeWorkflowTransformed(srcFile, destFile) || linkOrCopy(srcFile, destFile)) {
|
|
8540
|
+
savePristineCopy("workflows", srcFile);
|
|
8541
|
+
updated.push(file);
|
|
8542
|
+
} else {
|
|
8543
|
+
writeFailed.push(file);
|
|
8544
|
+
wroteFile = false;
|
|
8545
|
+
}
|
|
6750
8546
|
break;
|
|
6751
8547
|
case "user-only-skip":
|
|
6752
8548
|
userOnly.push(file);
|
|
@@ -6758,9 +8554,13 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6758
8554
|
const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
|
|
6759
8555
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
6760
8556
|
if (fs.existsSync(destFile)) fs.copyFileSync(destFile, path.join(backupDir, file));
|
|
6761
|
-
if (
|
|
6762
|
-
|
|
6763
|
-
|
|
8557
|
+
if (writeWorkflowTransformed(srcFile, destFile) || linkOrCopy(srcFile, destFile)) {
|
|
8558
|
+
savePristineCopy("workflows", srcFile);
|
|
8559
|
+
updated.push(file);
|
|
8560
|
+
} else {
|
|
8561
|
+
writeFailed.push(file); // sol flaw #3
|
|
8562
|
+
wroteFile = false;
|
|
8563
|
+
}
|
|
6764
8564
|
break;
|
|
6765
8565
|
}
|
|
6766
8566
|
case "conflict": {
|
|
@@ -6773,12 +8573,25 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6773
8573
|
savePristineCopy("workflows", srcFile);
|
|
6774
8574
|
autoMerged.push(file);
|
|
6775
8575
|
} else {
|
|
6776
|
-
conflicts.push({
|
|
8576
|
+
conflicts.push({
|
|
8577
|
+
name: file, category: "workflows",
|
|
8578
|
+
reason: mergeResult.reason, conflictCount: mergeResult.conflictCount,
|
|
8579
|
+
pristine: path.join(home, ".mover", "installed", "workflows", file),
|
|
8580
|
+
userVersion: destFile, newVersion: srcFile,
|
|
8581
|
+
});
|
|
8582
|
+
wroteFile = false;
|
|
6777
8583
|
}
|
|
6778
8584
|
break;
|
|
6779
8585
|
}
|
|
6780
8586
|
}
|
|
6781
|
-
|
|
8587
|
+
// terra F4: an unresolved conflict leaves the user's file on disk untouched.
|
|
8588
|
+
// Advancing the manifest to the new hash would make the NEXT update treat
|
|
8589
|
+
// the un-applied upstream change as already-pristine and silently skip it,
|
|
8590
|
+
// so a real bug/security fix never reaches a user who has a conflict. Keep
|
|
8591
|
+
// the prior base hash so the conflict resurfaces until /update resolves it.
|
|
8592
|
+
newManifest.files[`workflows/${file}`] = wroteFile
|
|
8593
|
+
? result.newHash
|
|
8594
|
+
: (manifest?.files?.[`workflows/${file}`] ?? result.newHash);
|
|
6782
8595
|
}
|
|
6783
8596
|
}
|
|
6784
8597
|
|
|
@@ -6799,6 +8612,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6799
8612
|
|
|
6800
8613
|
const destFile = path.join(vaultPath, entryRel);
|
|
6801
8614
|
const result = compareForUpdate("templates", srcPath, destFile, manifest, relNorm);
|
|
8615
|
+
let wroteTmpl = true;
|
|
6802
8616
|
|
|
6803
8617
|
switch (result.action) {
|
|
6804
8618
|
case "install":
|
|
@@ -6835,12 +8649,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6835
8649
|
savePristineCopy("templates", srcPath, relNorm);
|
|
6836
8650
|
autoMerged.push(relNorm);
|
|
6837
8651
|
} else {
|
|
6838
|
-
conflicts.push({
|
|
8652
|
+
conflicts.push({
|
|
8653
|
+
name: relNorm, category: "templates",
|
|
8654
|
+
reason: mergeResult.reason, conflictCount: mergeResult.conflictCount,
|
|
8655
|
+
pristine: path.join(os.homedir(), ".mover", "installed", "templates", relNorm),
|
|
8656
|
+
userVersion: destFile, newVersion: srcPath,
|
|
8657
|
+
});
|
|
8658
|
+
wroteTmpl = false;
|
|
6839
8659
|
}
|
|
6840
8660
|
break;
|
|
6841
8661
|
}
|
|
6842
8662
|
}
|
|
6843
|
-
|
|
8663
|
+
// terra F4 (templates): same rule as workflows — never advance a manifest
|
|
8664
|
+
// entry for a file left unresolved on disk.
|
|
8665
|
+
newManifest.files[`templates/${relNorm}`] = wroteTmpl
|
|
8666
|
+
? result.newHash
|
|
8667
|
+
: (manifest?.files?.[`templates/${relNorm}`] ?? result.newHash);
|
|
6844
8668
|
}
|
|
6845
8669
|
};
|
|
6846
8670
|
walkTmplUpdate(tmplSrcDir, "");
|
|
@@ -6890,11 +8714,17 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6890
8714
|
|
|
6891
8715
|
// ── Handle true conflicts → write conflicts.json for /update ──
|
|
6892
8716
|
if (conflicts.length > 0) {
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
8717
|
+
// terra F3: each record now carries its own category + real paths. A template
|
|
8718
|
+
// conflict (e.g. 00_Inbox/Quick_Capture.md, which lives in the vault) was
|
|
8719
|
+
// previously serialized with workflow paths (.claude/commands, src/workflows),
|
|
8720
|
+
// so /update read three non-existent files and could not resolve it. Fall
|
|
8721
|
+
// back to the workflow layout only for legacy records without paths.
|
|
8722
|
+
const conflictData = conflicts.map((c) => ({
|
|
8723
|
+
file: c.name,
|
|
8724
|
+
category: c.category || "workflows",
|
|
8725
|
+
pristine: c.pristine || path.join(home, ".mover", "installed", "workflows", c.name),
|
|
8726
|
+
userVersion: c.userVersion || path.join(home, ".claude", "commands", c.name),
|
|
8727
|
+
newVersion: c.newVersion || path.join(bundleDir, "src", "workflows", c.name),
|
|
6898
8728
|
}));
|
|
6899
8729
|
const conflictPath = path.join(home, ".mover", "conflicts.json");
|
|
6900
8730
|
fs.writeFileSync(conflictPath, JSON.stringify({
|
|
@@ -6903,12 +8733,14 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6903
8733
|
files: conflictData,
|
|
6904
8734
|
}, null, 2), "utf8");
|
|
6905
8735
|
|
|
6906
|
-
// Backup conflicted user files
|
|
8736
|
+
// Backup conflicted user files (from their real location — template conflicts
|
|
8737
|
+
// live in the vault, not ~/.claude/commands). Slashes in the relative name are
|
|
8738
|
+
// flattened so the backup filename is valid.
|
|
6907
8739
|
const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
|
|
6908
8740
|
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));
|
|
8741
|
+
for (const c of conflicts) {
|
|
8742
|
+
const dest = c.userVersion || path.join(home, ".claude", "commands", c.name);
|
|
8743
|
+
if (fs.existsSync(dest)) fs.copyFileSync(dest, path.join(backupDir, c.name.replace(/[\/\\]/g, "__")));
|
|
6912
8744
|
}
|
|
6913
8745
|
needsUpdate = true;
|
|
6914
8746
|
}
|
|
@@ -6932,6 +8764,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6932
8764
|
}
|
|
6933
8765
|
}
|
|
6934
8766
|
|
|
8767
|
+
// ── Refresh Claude's hook FILES before recording their hashes ──
|
|
8768
|
+
// 6b (terra-confirmed): the multi-agent propagation loop above SKIPS
|
|
8769
|
+
// claude-code, yet the manifest loop below records every hook .sh/.js/.md as
|
|
8770
|
+
// current. So a Claude user's hook helpers (e.g. session-history-writer.js,
|
|
8771
|
+
// used by pre-compact-backup.sh) and prompt templates were marked fresh in
|
|
8772
|
+
// the manifest but never actually copied — they stayed stale, and the next
|
|
8773
|
+
// update skipped them because the hashes "matched". Copy the complete Claude
|
|
8774
|
+
// hook asset set (same files the fresh installer writes) BEFORE recording, so
|
|
8775
|
+
// the manifest never claims a version the disk does not have. File copy only;
|
|
8776
|
+
// settings.json is already established and is not touched here.
|
|
8777
|
+
const hooksSrcForClaude = path.join(bundleDir, "src", "hooks");
|
|
8778
|
+
const claudeDetected = detectedAgentsForProp.some((a) => a.id === "claude-code");
|
|
8779
|
+
if (claudeDetected && fs.existsSync(hooksSrcForClaude)) {
|
|
8780
|
+
const hooksDstClaude = path.join(home, ".claude", "hooks");
|
|
8781
|
+
fs.mkdirSync(hooksDstClaude, { recursive: true });
|
|
8782
|
+
for (const f of fs.readdirSync(hooksSrcForClaude).filter((x) => x.endsWith(".sh"))) {
|
|
8783
|
+
const content = fs.readFileSync(path.join(hooksSrcForClaude, f), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
8784
|
+
fs.writeFileSync(path.join(hooksDstClaude, f), content, { mode: 0o755 });
|
|
8785
|
+
try { fs.chmodSync(path.join(hooksDstClaude, f), 0o755); } catch {}
|
|
8786
|
+
}
|
|
8787
|
+
for (const f of fs.readdirSync(hooksSrcForClaude).filter((x) => x.endsWith(".md") || x.endsWith(".js"))) {
|
|
8788
|
+
fs.copyFileSync(path.join(hooksSrcForClaude, f), path.join(hooksDstClaude, f));
|
|
8789
|
+
}
|
|
8790
|
+
}
|
|
8791
|
+
|
|
6935
8792
|
// ── Save updated manifest ──
|
|
6936
8793
|
// Carry forward entries for files we didn't process (hooks, statusline, /update itself)
|
|
6937
8794
|
if (manifest && manifest.files) {
|
|
@@ -6942,12 +8799,36 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6942
8799
|
const hooksDir = path.join(bundleDir, "src", "hooks");
|
|
6943
8800
|
if (fs.existsSync(hooksDir)) {
|
|
6944
8801
|
for (const f of fs.readdirSync(hooksDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
|
|
6945
|
-
|
|
8802
|
+
// record-if-current (sol part 10: "interactive update records uncopied
|
|
8803
|
+
// hook artifacts as current"): a manifest entry asserts the installed
|
|
8804
|
+
// artifact holds this base. Record the fresh hash ONLY when the Claude
|
|
8805
|
+
// hook file is actually on disk with matching content (the refresh block
|
|
8806
|
+
// above guarantees that when Claude is detected); otherwise keep the
|
|
8807
|
+
// prior base (carried forward below) so nothing uncopied is marked
|
|
8808
|
+
// current and the next update still sees it as stale.
|
|
8809
|
+
const dst = path.join(home, ".claude", "hooks", f);
|
|
8810
|
+
if (claudeDetected && fs.existsSync(dst) && fileContentHash(dst) === fileContentHash(path.join(hooksDir, f))) {
|
|
8811
|
+
newManifest.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
|
|
8812
|
+
}
|
|
6946
8813
|
}
|
|
6947
8814
|
}
|
|
6948
8815
|
saveUpdateManifest(newManifest);
|
|
6949
8816
|
writeMoverConfig(vaultPath, selectedIds, updateKey);
|
|
6950
8817
|
|
|
8818
|
+
// ── Journal handoff: mechanical stages complete; /update owns the rest ──
|
|
8819
|
+
// migrate (Engine migration + conflict merges) belongs to the /update
|
|
8820
|
+
// workflow; verify + stamp happen in `moveros update --finalize`, which the
|
|
8821
|
+
// workflow runs LAST. The journal is the machine-readable handoff, so the
|
|
8822
|
+
// CLI and the workflow are one state machine instead of a prose baton pass.
|
|
8823
|
+
journalStage(journal, "apply", writeFailed.length > 0 ? "failed" : "done", {
|
|
8824
|
+
updated: updated.length,
|
|
8825
|
+
autoMerged: autoMerged.length,
|
|
8826
|
+
userOnly: userOnly.length,
|
|
8827
|
+
conflicts: conflicts.length,
|
|
8828
|
+
writeFailed: writeFailed.length,
|
|
8829
|
+
});
|
|
8830
|
+
journalStage(journal, "migrate", "pending", { owner: "workflow", conflicts: conflicts.length });
|
|
8831
|
+
|
|
6951
8832
|
// ── Summary ──
|
|
6952
8833
|
barLn();
|
|
6953
8834
|
if (updated.length > 0) statusLine("ok", "Updated", `${updated.length} files`);
|
|
@@ -6955,6 +8836,15 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6955
8836
|
if (userOnly.length > 0) statusLine("ok", "Preserved", `${userOnly.length} customized files`);
|
|
6956
8837
|
if (skippedFiles.length > 0) statusLine("ok", "Current", `${skippedFiles.length} files unchanged`);
|
|
6957
8838
|
|
|
8839
|
+
// sol flaw #3: a write that failed mid-update was silently reported as success.
|
|
8840
|
+
// Surface it and force a non-clean outcome so the user (and the next run) retries.
|
|
8841
|
+
if (writeFailed.length > 0) {
|
|
8842
|
+
statusLine("warn", "Write failed", `${writeFailed.length} file${writeFailed.length > 1 ? "s" : ""} could not be written (kept prior version)`);
|
|
8843
|
+
for (const f of writeFailed) barLn(` ${yellow("!")} ${f}`);
|
|
8844
|
+
barLn(dim(" Re-run moveros update once the cause is resolved (disk space / permissions)."));
|
|
8845
|
+
needsUpdate = true;
|
|
8846
|
+
}
|
|
8847
|
+
|
|
6958
8848
|
if (conflicts.length > 0) {
|
|
6959
8849
|
barLn();
|
|
6960
8850
|
barLn(yellow(` ${conflicts.length} file${conflicts.length > 1 ? "s" : ""} need intelligent merge:`));
|
|
@@ -6982,10 +8872,12 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6982
8872
|
barLn();
|
|
6983
8873
|
}
|
|
6984
8874
|
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)`)}`);
|
|
8875
|
+
if (conflicts.length > 0) {
|
|
8876
|
+
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)`)}`);
|
|
8877
|
+
} else if (writeFailed.length > 0) {
|
|
8878
|
+
outro(`${yellow("Update incomplete.")} ${writeFailed.length} file${writeFailed.length > 1 ? "s" : ""} could not be written. Re-run when resolved. ${dim(`(${elapsed}s)`)}`);
|
|
6987
8879
|
} else {
|
|
6988
|
-
outro(`${green("Done.")}
|
|
8880
|
+
outro(`${green("Done.")} Files applied. Run ${bold("/update")} to finish: Engine migration, then verify + version stamp. ${dim(`(${elapsed}s)`)}`);
|
|
6989
8881
|
}
|
|
6990
8882
|
}
|
|
6991
8883
|
|
|
@@ -7021,6 +8913,37 @@ function requireVault(explicitVault) {
|
|
|
7021
8913
|
return v;
|
|
7022
8914
|
}
|
|
7023
8915
|
|
|
8916
|
+
// THE install invariant (sol re-score part 1: "Marker checks still occur
|
|
8917
|
+
// command-by-command instead of through one invariant"). A real install =
|
|
8918
|
+
// a `.mover-version` that is a non-empty regular FILE — an empty file or a
|
|
8919
|
+
// directory by that name is not an install (sol flaw #5), and requireVault
|
|
8920
|
+
// trusts an explicit --vault without checking, so a typo'd path would
|
|
8921
|
+
// otherwise get artifacts minted into it (terra F1/F7). Every command that
|
|
8922
|
+
// MUTATES the vault or mints install artifacts goes through
|
|
8923
|
+
// requireInstalledVault; read-only diagnostics (doctor, context, status)
|
|
8924
|
+
// deliberately do not — they must be able to examine a broken install.
|
|
8925
|
+
function isInstalledVault(vaultPath) {
|
|
8926
|
+
if (!vaultPath) return false;
|
|
8927
|
+
try {
|
|
8928
|
+
const mp = path.join(vaultPath, ".mover-version");
|
|
8929
|
+
if (!fs.statSync(mp).isFile()) return false;
|
|
8930
|
+
return fs.readFileSync(mp, "utf8").trim().length > 0;
|
|
8931
|
+
} catch {
|
|
8932
|
+
return false;
|
|
8933
|
+
}
|
|
8934
|
+
}
|
|
8935
|
+
|
|
8936
|
+
function requireInstalledVault(explicitVault) {
|
|
8937
|
+
const v = requireVault(explicitVault);
|
|
8938
|
+
if (!v) return null;
|
|
8939
|
+
if (!isInstalledVault(v)) {
|
|
8940
|
+
statusLine("warn", "No install", `${v} has no Mover OS install (.mover-version missing or empty). Run moveros install first, or pass the correct --vault.`);
|
|
8941
|
+
barLn();
|
|
8942
|
+
return null;
|
|
8943
|
+
}
|
|
8944
|
+
return v;
|
|
8945
|
+
}
|
|
8946
|
+
|
|
7024
8947
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
7025
8948
|
async function main() {
|
|
7026
8949
|
const opts = parseArgs();
|
|
@@ -7044,13 +8967,37 @@ async function main() {
|
|
|
7044
8967
|
opts.command = "install"; // no vault yet → first run goes straight to install
|
|
7045
8968
|
}
|
|
7046
8969
|
|
|
8970
|
+
// `moveros pulse --bar` is polled by a menu-bar widget every few seconds — its
|
|
8971
|
+
// stdout must be ONLY the status line, so intercept it here, before the alt-
|
|
8972
|
+
// screen, logo, and update-notice chrome would pollute the output.
|
|
8973
|
+
if ((opts.command === "pulse" || opts.command === "dashboard") && (opts.rest || []).includes("--bar")) {
|
|
8974
|
+
await cmdPulseBar(opts);
|
|
8975
|
+
return;
|
|
8976
|
+
}
|
|
8977
|
+
|
|
8978
|
+
// `moveros inventory` is piped by the docs generator and CI — stdout must be
|
|
8979
|
+
// ONLY the JSON, so it too skips the alt-screen and logo chrome.
|
|
8980
|
+
if (opts.command === "inventory") {
|
|
8981
|
+
cmdInventory();
|
|
8982
|
+
return;
|
|
8983
|
+
}
|
|
8984
|
+
|
|
8985
|
+
// `moveros status --json` is a machine-readable state probe (scripts, other
|
|
8986
|
+
// agents). Its JSON must be the WHOLE stdout, so intercept it before the
|
|
8987
|
+
// alt-screen and logo chrome that otherwise print ahead of jsonOut (sol
|
|
8988
|
+
// re-score #6 part 1: --json still passed through header rendering).
|
|
8989
|
+
if (opts.command === "status" && opts.json) {
|
|
8990
|
+
await cmdStatus(opts);
|
|
8991
|
+
return;
|
|
8992
|
+
}
|
|
8993
|
+
|
|
7047
8994
|
// ── TUI: Enter alternate screen ──
|
|
7048
8995
|
enterAltScreen();
|
|
7049
8996
|
|
|
7050
8997
|
// ── Intro: Logo plays once ──
|
|
7051
8998
|
await printHeader();
|
|
7052
8999
|
|
|
7053
|
-
const lightCommands = ["pulse", "dashboard", "open", "capture", "who", "diff", "sync", "replay", "context", "settings", "backup", "restore", "doctor", "prayer", "help", "uninstall", "test"];
|
|
9000
|
+
const lightCommands = ["pulse", "dashboard", "open", "shortcut", "menubar", "inventory", "capture", "who", "diff", "sync", "replay", "context", "status", "settings", "backup", "restore", "doctor", "prayer", "help", "uninstall", "test"];
|
|
7054
9001
|
|
|
7055
9002
|
// ── `moveros menu` → classic interactive launcher (persistent loop) ──
|
|
7056
9003
|
if (opts.command === "menu") {
|
|
@@ -7617,22 +9564,59 @@ async function main() {
|
|
|
7617
9564
|
|
|
7618
9565
|
let totalSteps = 0;
|
|
7619
9566
|
|
|
7620
|
-
// 0. Legacy cleanup (
|
|
9567
|
+
// 0. Legacy cleanup (retire files from older installer versions).
|
|
9568
|
+
// GUARDED, then made NON-DESTRUCTIVE (terra P0, two rounds 2026-07-11):
|
|
9569
|
+
// this used to rm -rf vault-root src/ unconditionally. A weak fingerprint
|
|
9570
|
+
// (any one hook filename / the prose "mover os") still destroyed a user's
|
|
9571
|
+
// src/ that merely held a session-start.sh, and a personal SOUL.md that
|
|
9572
|
+
// named the product. Two changes: (a) the signature is now STRONG and
|
|
9573
|
+
// Mover-unique, and (b) even a match is BACKED UP, never hard-deleted, so a
|
|
9574
|
+
// false positive can never lose data. The campaign DoD is byte-preservation
|
|
9575
|
+
// of unowned files; a backup rename satisfies it even under a misfire.
|
|
9576
|
+
const MOVER_UNIQUE_HOOKS = ["mover-lib.sh", "session-log-reminder.sh", "engine-protection.sh", "engine-auto-commit.sh"];
|
|
9577
|
+
const isLegacyMoverSrc = (dir) => {
|
|
9578
|
+
try {
|
|
9579
|
+
const hooksDir = path.join(dir, "hooks");
|
|
9580
|
+
if (!fs.existsSync(hooksDir) || !fs.statSync(hooksDir).isDirectory()) return false;
|
|
9581
|
+
const present = new Set(fs.readdirSync(hooksDir));
|
|
9582
|
+
// mover-lib.sh is a Mover-only filename; require it PLUS one more unique
|
|
9583
|
+
// Mover hook. A user coincidentally naming a file session-start.sh will
|
|
9584
|
+
// not also carry mover-lib.sh + engine-protection.sh.
|
|
9585
|
+
if (!present.has("mover-lib.sh")) return false;
|
|
9586
|
+
return MOVER_UNIQUE_HOOKS.filter((h) => present.has(h)).length >= 2;
|
|
9587
|
+
} catch { return false; }
|
|
9588
|
+
};
|
|
9589
|
+
const isLegacyMoverSoul = (file) => {
|
|
9590
|
+
// Match the OLD Mover SOUL template structure, not a prose mention. The
|
|
9591
|
+
// shipped legacy SOUL led with an agent-identity header and the "The
|
|
9592
|
+
// Architect" persona; a user writing "I tried Mover OS" prose has neither.
|
|
9593
|
+
try {
|
|
9594
|
+
const head = fs.readFileSync(file, "utf8").slice(0, 4000);
|
|
9595
|
+
return /^#\s*(SOUL|THE SOUL|Mover OS SOUL)\b/im.test(head) &&
|
|
9596
|
+
/(Architect|Agent Name|Global Rules|Identity_Prime)/.test(head);
|
|
9597
|
+
} catch { return false; }
|
|
9598
|
+
};
|
|
7621
9599
|
const legacyPaths = [
|
|
7622
|
-
path.join(vaultPath, "src"),
|
|
7623
|
-
path.join(vaultPath, "SOUL.md"),
|
|
9600
|
+
{ p: path.join(vaultPath, "src"), owned: isLegacyMoverSrc }, // hooks used to install here
|
|
9601
|
+
{ p: path.join(vaultPath, "SOUL.md"), owned: isLegacyMoverSoul }, // old soul file
|
|
7624
9602
|
];
|
|
7625
|
-
for (const lp of legacyPaths) {
|
|
7626
|
-
if (fs.existsSync(lp))
|
|
7627
|
-
|
|
7628
|
-
|
|
7629
|
-
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
|
|
7634
|
-
|
|
7635
|
-
|
|
9603
|
+
for (const { p: lp, owned } of legacyPaths) {
|
|
9604
|
+
if (!fs.existsSync(lp)) continue;
|
|
9605
|
+
try {
|
|
9606
|
+
if (!owned(lp)) {
|
|
9607
|
+
statusLine("warn", "Preserved", `${path.basename(lp)} exists but is not a Mover artifact — left untouched`);
|
|
9608
|
+
continue;
|
|
9609
|
+
}
|
|
9610
|
+
// Owned legacy artifact: back it up rather than delete. A stray false
|
|
9611
|
+
// positive is then fully recoverable; a real legacy dir is out of the way.
|
|
9612
|
+
const backup = `${lp}.pre-mover-backup`;
|
|
9613
|
+
if (!fs.existsSync(backup)) {
|
|
9614
|
+
fs.renameSync(lp, backup);
|
|
9615
|
+
statusLine("ok", "Backed up", `legacy ${path.basename(lp)} → ${path.basename(backup)}`);
|
|
9616
|
+
} else {
|
|
9617
|
+
statusLine("warn", "Left as-is", `${path.basename(lp)} (backup already exists)`);
|
|
9618
|
+
}
|
|
9619
|
+
} catch {}
|
|
7636
9620
|
}
|
|
7637
9621
|
|
|
7638
9622
|
// 1. Vault structure
|
|
@@ -7797,15 +9781,54 @@ async function main() {
|
|
|
7797
9781
|
|
|
7798
9782
|
const agentNames = selectedAgents.map((a) => a.name);
|
|
7799
9783
|
|
|
9784
|
+
// `moveros` is only on PATH after a global npm install. An `npx mover-os`
|
|
9785
|
+
// user has no bin — telling them to "Run moveros" dead-ends (sol, 2026-07-10).
|
|
9786
|
+
// Detect the npx cache path and print the command that will actually work.
|
|
9787
|
+
const viaNpx = /[\\/]_npx[\\/]/.test(process.argv[1] || "") || /[\\/]_npx[\\/]/.test(process.env.npm_execpath || "");
|
|
9788
|
+
const cliCmd = viaNpx ? "npx mover-os" : "moveros";
|
|
9789
|
+
|
|
7800
9790
|
// ── What you got ──
|
|
7801
9791
|
ln(gray(" ─────────────────────────────────────────────"));
|
|
7802
9792
|
ln();
|
|
7803
9793
|
ln(` ${bold("What was installed")}`);
|
|
7804
9794
|
ln();
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
|
|
9795
|
+
// Counted from the bundle at print time, never hardcoded. Sol's product
|
|
9796
|
+
// review (2026-07-10) caught this banner claiming 23/62/18 while the
|
|
9797
|
+
// install delivered 25/70/20 — a buyer couldn't tell which product they
|
|
9798
|
+
// bought. Same filters as the installers: workflows = src/workflows/*.md,
|
|
9799
|
+
// skills = findSkills + the category gate installSkillPacks applies,
|
|
9800
|
+
// hooks = unique scripts in the generated Claude settings.
|
|
9801
|
+
// Terra verify (2026-07-10): a null selectedCategories can mean "skills
|
|
9802
|
+
// skipped entirely", not "all categories" — gate each line on whether that
|
|
9803
|
+
// artifact class was actually installed, or the banner overstates again.
|
|
9804
|
+
// Agents whose installers call installWorkflows/installWorkflowsConverted
|
|
9805
|
+
// (see installClaudeCode..installCopilot). Cline/amazon-q/etc get rules+skills only.
|
|
9806
|
+
const wfAgents = new Set(["claude-code", "cursor", "codex", "windsurf", "gemini-cli", "antigravity", "roo-code", "copilot"]);
|
|
9807
|
+
const anyWorkflowAgent = selectedIds.some((id) => wfAgents.has(id));
|
|
9808
|
+
let wfInstalled = 0;
|
|
9809
|
+
try {
|
|
9810
|
+
if (anyWorkflowAgent && skillOpts.workflows !== false) {
|
|
9811
|
+
wfInstalled = fs.readdirSync(path.join(bundleDir, "src", "workflows")).filter((n) => n.endsWith(".md")).length;
|
|
9812
|
+
}
|
|
9813
|
+
} catch {}
|
|
9814
|
+
let skInstalled = 0;
|
|
9815
|
+
try {
|
|
9816
|
+
if (skillOpts.install) {
|
|
9817
|
+
skInstalled = findSkills(bundleDir).filter(
|
|
9818
|
+
(s) => !selectedCategories || s.category === "tools" || selectedCategories.has(s.category)
|
|
9819
|
+
).length;
|
|
9820
|
+
}
|
|
9821
|
+
} catch {}
|
|
9822
|
+
let hkInstalled = 0;
|
|
9823
|
+
try {
|
|
9824
|
+
const hookScripts = new Set();
|
|
9825
|
+
for (const m of generateClaudeSettings().matchAll(/hooks\/([a-z0-9-]+\.sh)/g)) hookScripts.add(m[1]);
|
|
9826
|
+
hkInstalled = hookScripts.size;
|
|
9827
|
+
} catch {}
|
|
9828
|
+
if (wfInstalled > 0) ln(` ${green("▸")} ${bold(String(wfInstalled))} workflows ${dim("slash commands for daily rhythm, projects, strategy")}`);
|
|
9829
|
+
if (skInstalled > 0) ln(` ${green("▸")} ${bold(String(skInstalled))} skills ${dim("curated packs for dev, marketing, CRO, design")}`);
|
|
9830
|
+
if (selectedIds.includes("claude-code") && hkInstalled > 0) {
|
|
9831
|
+
ln(` ${green("▸")} ${bold(String(hkInstalled))} hooks ${dim("lifecycle guards (engine protection, git safety)")}`);
|
|
7809
9832
|
}
|
|
7810
9833
|
ln(` ${green("▸")} ${bold(String(selectedAgents.length))} agent${selectedAgents.length > 1 ? "s" : ""} ${dim(agentNames.join(", "))}`);
|
|
7811
9834
|
ln(` ${green("▸")} PARA vault ${dim("folders, templates, Engine scaffold")}`);
|
|
@@ -7819,7 +9842,7 @@ async function main() {
|
|
|
7819
9842
|
await slideIn([
|
|
7820
9843
|
` ${bold("Next steps")}`,
|
|
7821
9844
|
"",
|
|
7822
|
-
` ${cyan("1")} Run ${bold(
|
|
9845
|
+
` ${cyan("1")} Run ${bold(cliCmd)}`,
|
|
7823
9846
|
` ${dim("Opens your dashboard; setup starts there, a few plain questions")}`,
|
|
7824
9847
|
"",
|
|
7825
9848
|
` ${cyan("2")} Open your vault in ${bold("Obsidian")} ${dim("(optional)")}`,
|
|
@@ -7843,12 +9866,12 @@ async function main() {
|
|
|
7843
9866
|
ln();
|
|
7844
9867
|
ln(` ${bold("CLI commands")} ${dim("(run from any terminal)")}`);
|
|
7845
9868
|
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("
|
|
9869
|
+
ln(` ${green("▸")} ${bold(cliCmd)} ${dim("Open your dashboard")}`);
|
|
9870
|
+
ln(` ${green("▸")} ${bold(cliCmd + " doctor")} ${dim("Health check across all agents")}`);
|
|
9871
|
+
ln(` ${green("▸")} ${bold(cliCmd + " capture")} ${dim("Quick inbox — tasks, links, ideas")}`);
|
|
9872
|
+
ln(` ${green("▸")} ${bold(cliCmd + " prayer")} ${dim("Set up prayer time reminders")}`);
|
|
9873
|
+
ln(` ${green("▸")} ${bold(cliCmd + " update")} ${dim("Update agents, rules, and skills")}`);
|
|
9874
|
+
ln(` ${green("▸")} ${bold(cliCmd + " menu")} ${dim("All commands (classic launcher)")}`);
|
|
7852
9875
|
ln();
|
|
7853
9876
|
|
|
7854
9877
|
// ── Land on the dashboard's onboarding route (the payoff) ──
|
|
@@ -7861,9 +9884,11 @@ async function main() {
|
|
|
7861
9884
|
try {
|
|
7862
9885
|
const dash = require("./src/dashboard");
|
|
7863
9886
|
await dash.open({ vault: vaultPath, version: VERSION, rest: [], soft: false, route: "/?onboarding=1" });
|
|
7864
|
-
|
|
9887
|
+
// Setup starts IN the browser that just opened. Pointing at /setup here
|
|
9888
|
+
// contradicted the dashboard-first funnel (sol, 2026-07-10).
|
|
9889
|
+
ln(` ${dim("Opening onboarding in your browser — setup starts there.")}`);
|
|
7865
9890
|
} catch (err) {
|
|
7866
|
-
ln(` ${dim("Your dashboard is ready — open it anytime with")} ${bold(
|
|
9891
|
+
ln(` ${dim("Your dashboard is ready — open it anytime with")} ${bold(cliCmd)}.`);
|
|
7867
9892
|
}
|
|
7868
9893
|
ln();
|
|
7869
9894
|
}
|