mover-os 4.7.7 → 4.7.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -24
- package/install.js +2868 -251
- package/package.json +15 -3
- package/src/dashboard/build.js +1541 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +189 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +483 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +228 -0
- package/src/dashboard/lib/date-utils.js +43 -0
- package/src/dashboard/lib/distribution-parser.js +137 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +88 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +173 -0
- package/src/dashboard/lib/engine-writer.js +1831 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +427 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +226 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +519 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +286 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- 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 +248 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +371 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2402 -0
- package/src/dashboard/shortcut.js +284 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
- package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
package/install.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// Zero dependencies. Node.js 18+.
|
|
5
5
|
//
|
|
6
6
|
// Usage:
|
|
7
|
-
// npx
|
|
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);
|
|
@@ -944,8 +991,30 @@ async function downloadPayload(key) {
|
|
|
944
991
|
// The ordering of -tvzf and -tzf output is identical (tar walks the
|
|
945
992
|
// archive in the same order), so we zip them by line index for the
|
|
946
993
|
// type/path correlation.
|
|
947
|
-
|
|
948
|
-
|
|
994
|
+
// v4.7.9 (S4 ship-blocker): tar preflight. Both listing calls were outside
|
|
995
|
+
// any try/catch, so on a Windows box without tar on PATH the raw spawn threw
|
|
996
|
+
// `Error: spawnSync /bin/sh ENOENT` (or `'tar' is not recognized`) with no
|
|
997
|
+
// hint about what the user must install. Check tar is present first, then
|
|
998
|
+
// wrap the listings so a genuinely corrupt payload reports differently from a
|
|
999
|
+
// missing-tar host. cmdExists() is the same probe used elsewhere in install.
|
|
1000
|
+
if (!cmdExists('tar')) {
|
|
1001
|
+
throw new Error(
|
|
1002
|
+
"tar is required to install Mover OS but was not found on PATH. " +
|
|
1003
|
+
"Install tar and re-run (Git Bash and WSL both ship it; on bare Windows run `choco install tartool` " +
|
|
1004
|
+
"or use the bsdtar bundled with Windows 10+ by adding C:\\\\Windows\\\\System32 to PATH)."
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
let pathsListing, verboseListing;
|
|
1008
|
+
try {
|
|
1009
|
+
pathsListing = execSync(`tar -tzf "${tarPath}"`, { encoding: 'utf8' });
|
|
1010
|
+
verboseListing = execSync(`tar -tvzf "${tarPath}"`, { encoding: 'utf8' });
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
throw new Error(
|
|
1013
|
+
`tar could not read the downloaded payload at ${tarPath}. ` +
|
|
1014
|
+
"The download is likely corrupt or incomplete — delete that file and re-run. " +
|
|
1015
|
+
`(tar reported: ${e.message})`
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
949
1018
|
const pathLines = pathsListing.split('\n').filter(Boolean);
|
|
950
1019
|
const verboseLines = verboseListing.split('\n').filter(Boolean);
|
|
951
1020
|
const badPaths = [];
|
|
@@ -988,6 +1057,41 @@ async function downloadPayload(key) {
|
|
|
988
1057
|
// Clean up tarball
|
|
989
1058
|
try { fs.unlinkSync(tarPath); } catch {}
|
|
990
1059
|
|
|
1060
|
+
// v4.7.9 (S5 ship-blocker): post-extract assert. A silently-truncated tarball
|
|
1061
|
+
// can pass the listing/type checks above yet extract to nothing useful. If the
|
|
1062
|
+
// canonical payload root (~/.mover/src/workflows) is absent after extraction,
|
|
1063
|
+
// every downstream installer would copy from nonexistent dirs and the install
|
|
1064
|
+
// would "succeed" while shipping no workflows. Fail loudly here instead.
|
|
1065
|
+
const extractedWorkflows = path.join(moverDir, "src", "workflows");
|
|
1066
|
+
if (!fs.existsSync(extractedWorkflows)) {
|
|
1067
|
+
throw new Error(
|
|
1068
|
+
`Payload extracted but ${extractedWorkflows} is missing — the download is corrupt or incomplete. ` +
|
|
1069
|
+
`Delete ${path.join(moverDir, "payload.tar.gz")} and re-run.`
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
// Existence alone is too weak: a tarball carrying just an empty src/workflows/
|
|
1073
|
+
// dir passes the check above, then installWorkflows() finds zero .md files and
|
|
1074
|
+
// returns 0 commands while the install reports success — the exact silent-empty
|
|
1075
|
+
// failure S5 exists to stop. Require at least one workflow .md to be present.
|
|
1076
|
+
let extractedWfCount = 0;
|
|
1077
|
+
try {
|
|
1078
|
+
// Require a real .md FILE — not just any entry ending in ".md". A corrupt
|
|
1079
|
+
// payload containing a directory literally named "x.md" would otherwise pass.
|
|
1080
|
+
extractedWfCount = fs.readdirSync(extractedWorkflows, { withFileTypes: true })
|
|
1081
|
+
.filter((d) => d.isFile() && d.name.endsWith(".md")).length;
|
|
1082
|
+
} catch (e) {
|
|
1083
|
+
throw new Error(
|
|
1084
|
+
`Payload extracted but ${extractedWorkflows} is unreadable (${e.message}) — ` +
|
|
1085
|
+
`the download is corrupt or incomplete. Delete ${path.join(moverDir, "payload.tar.gz")} and re-run.`
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
if (extractedWfCount === 0) {
|
|
1089
|
+
throw new Error(
|
|
1090
|
+
`Payload extracted but ${extractedWorkflows} contains no workflow (.md) files — ` +
|
|
1091
|
+
`the download is corrupt or incomplete. Delete ${path.join(moverDir, "payload.tar.gz")} and re-run.`
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
991
1095
|
return moverDir;
|
|
992
1096
|
}
|
|
993
1097
|
|
|
@@ -1026,8 +1130,12 @@ function loadCliUsage() {
|
|
|
1026
1130
|
|
|
1027
1131
|
function recordCliUsage(cmd) {
|
|
1028
1132
|
const cfgPath = path.join(os.homedir(), ".mover", "config.json");
|
|
1133
|
+
// Do NOT create config.json just to log a usage counter (terra P2): a user
|
|
1134
|
+
// with no install running a stray `moveros who` should leave zero trace.
|
|
1135
|
+
// Usage tracking is for actual installs, which already have this file.
|
|
1136
|
+
if (!fs.existsSync(cfgPath)) return;
|
|
1029
1137
|
try {
|
|
1030
|
-
const cfg =
|
|
1138
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
1031
1139
|
if (!cfg.cli_usage) cfg.cli_usage = {};
|
|
1032
1140
|
const entry = cfg.cli_usage[cmd] || { count: 0, last: 0 };
|
|
1033
1141
|
entry.count++;
|
|
@@ -1052,7 +1160,11 @@ const CLI_COMMANDS = {
|
|
|
1052
1160
|
update: { desc: "Comprehensive update — agents, rules, skills", alias: ["-u"] },
|
|
1053
1161
|
uninstall: { desc: "Remove Mover OS from vault and agents", alias: [] },
|
|
1054
1162
|
doctor: { desc: "Health check across all installed agents", alias: [] },
|
|
1055
|
-
pulse: { desc: "
|
|
1163
|
+
pulse: { desc: "Browser dashboard — Suggested Now, patterns, drift (use --terminal for TUI)",alias: ["dashboard"] },
|
|
1164
|
+
open: { desc: "Open the dashboard in your browser (starts it in the background if needed)", alias: [] },
|
|
1165
|
+
shortcut: { desc: "Desktop launcher (double-clickable dashboard icon)", alias: [] },
|
|
1166
|
+
menubar: { desc: "Native macOS menu-bar status app (macOS only)", alias: [] },
|
|
1167
|
+
menu: { desc: "Classic terminal launcher (install, update, doctor…)", alias: [] },
|
|
1056
1168
|
// warm removed — hooks + rules + /morning already handle session priming
|
|
1057
1169
|
capture: { desc: "Quick capture — tasks, links, ideas", alias: [] },
|
|
1058
1170
|
who: { desc: "Entity memory lookup", alias: [] },
|
|
@@ -1067,6 +1179,7 @@ const CLI_COMMANDS = {
|
|
|
1067
1179
|
status: { desc: "Full system state (vault, agents, version)", alias: [] },
|
|
1068
1180
|
help: { desc: "Interactive guide to Mover OS", alias: ["-h"] },
|
|
1069
1181
|
test: { desc: "Run integration tests (dev)", alias: [], hidden: true },
|
|
1182
|
+
inventory: { desc: "Bundle facts as JSON (docs are generated from this)", alias: [], hidden: true },
|
|
1070
1183
|
};
|
|
1071
1184
|
|
|
1072
1185
|
function parseArgs() {
|
|
@@ -1085,6 +1198,7 @@ function parseArgs() {
|
|
|
1085
1198
|
if (a === "--json") { opts.json = true; continue; }
|
|
1086
1199
|
if (a === "--quiet" || a === "-q") { opts.quiet = true; continue; }
|
|
1087
1200
|
if (a === "--dry-run") { opts.dryRun = true; continue; }
|
|
1201
|
+
if (a === "--no-open") { opts.noOpen = true; continue; }
|
|
1088
1202
|
if (a === "--help" || a === "-h") {
|
|
1089
1203
|
ln();
|
|
1090
1204
|
ln(` ${bold("moveros")} ${dim("— the Mover OS companion CLI")}`);
|
|
@@ -1104,9 +1218,10 @@ function parseArgs() {
|
|
|
1104
1218
|
ln(` --json Structured JSON output (for status command)`);
|
|
1105
1219
|
ln(` --quiet, -q Minimal output, one value per line`);
|
|
1106
1220
|
ln(` --dry-run Show what would change without doing it`);
|
|
1221
|
+
ln(` --no-open Skip the browser launch (automation / headless)`);
|
|
1107
1222
|
ln(` --update, -u Quick update (backward compat)`);
|
|
1108
1223
|
ln();
|
|
1109
|
-
ln(` ${dim("Run")} moveros ${dim("with no args
|
|
1224
|
+
ln(` ${dim("Run")} moveros ${dim("with no args to open the dashboard — installs on first run")}`);
|
|
1110
1225
|
ln();
|
|
1111
1226
|
process.exit(0);
|
|
1112
1227
|
}
|
|
@@ -1333,6 +1448,157 @@ function savePristineCopy(category, srcFile, relPath) {
|
|
|
1333
1448
|
fs.writeFileSync(destPath, content, "utf8");
|
|
1334
1449
|
}
|
|
1335
1450
|
|
|
1451
|
+
// ── Update State Machine (journal) ──────────────────────────────────────────
|
|
1452
|
+
// One transactional, resumable record of an update run, shared by the CLI and
|
|
1453
|
+
// the /update workflow (sol part 10: CLI plus /update must be ONE state
|
|
1454
|
+
// machine, not a prose handoff). Stages: preflight, backup, stage, apply,
|
|
1455
|
+
// migrate, verify, stamp. The CLI owns the mechanical stages (through apply,
|
|
1456
|
+
// plus verify+stamp on --quick where there is no AI migration); the /update
|
|
1457
|
+
// workflow owns migrate and finishes with `moveros update --finalize`, which
|
|
1458
|
+
// verifies the applied files and stamps the version ONLY on a pass. Every
|
|
1459
|
+
// stage is idempotent: after a crash at any point, re-running `moveros update`
|
|
1460
|
+
// resumes (the journal keeps the ORIGINAL backup dir, so rollback always
|
|
1461
|
+
// points at the true pre-update state, never a mid-update snapshot).
|
|
1462
|
+
const UPDATE_STAGES = ["preflight", "backup", "stage", "apply", "migrate", "verify", "stamp"];
|
|
1463
|
+
|
|
1464
|
+
function updateJournalPath() {
|
|
1465
|
+
return path.join(os.homedir(), ".mover", "update-state.json");
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function loadUpdateJournal() {
|
|
1469
|
+
try { return JSON.parse(fs.readFileSync(updateJournalPath(), "utf8")); } catch { return null; }
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function saveUpdateJournal(journal) {
|
|
1473
|
+
const p = updateJournalPath();
|
|
1474
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1475
|
+
journal.updatedAt = new Date().toISOString();
|
|
1476
|
+
// Atomic same-dir temp+rename: a crash mid-save leaves the old journal
|
|
1477
|
+
// intact, never a torn one (same discipline as linkOrCopy's F6 fix).
|
|
1478
|
+
const tmp = p + ".tmp-" + process.pid;
|
|
1479
|
+
fs.writeFileSync(tmp, JSON.stringify(journal, null, 2), "utf8");
|
|
1480
|
+
fs.renameSync(tmp, p);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
function newUpdateJournal(mode, fromVersion, toVersion, vaultPath) {
|
|
1484
|
+
const stages = {};
|
|
1485
|
+
for (const s of UPDATE_STAGES) stages[s] = { status: "pending" };
|
|
1486
|
+
return {
|
|
1487
|
+
runId: `upd-${new Date().toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1488
|
+
mode,
|
|
1489
|
+
fromVersion: fromVersion || null,
|
|
1490
|
+
toVersion,
|
|
1491
|
+
vaultPath,
|
|
1492
|
+
startedAt: new Date().toISOString(),
|
|
1493
|
+
completedAt: null,
|
|
1494
|
+
stages,
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function journalStage(journal, name, status, extra) {
|
|
1499
|
+
journal.stages[name] = { ...(journal.stages[name] || {}), ...(extra || {}), status, at: new Date().toISOString() };
|
|
1500
|
+
saveUpdateJournal(journal);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// Deterministic crash injection for the update-machine tests: kills THIS
|
|
1504
|
+
// process at a named stage boundary so crash-at-each-stage recoverability is
|
|
1505
|
+
// provable, not timing-dependent. Inert unless MOVER_TEST_CRASH_STAGE is set.
|
|
1506
|
+
function maybeCrashForTest(stage) {
|
|
1507
|
+
if (process.env.MOVER_TEST_CRASH_STAGE === stage) process.kill(process.pid, "SIGKILL");
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// ── Verify stage ─────────────────────────────────────────────────────────────
|
|
1511
|
+
// The version stamp is a claim ("this install IS at version X"); verify makes
|
|
1512
|
+
// the claim earned, not assumed. Re-classify every workflow and template
|
|
1513
|
+
// against the saved manifest and fail on any state where upstream content is
|
|
1514
|
+
// missing from disk (install / safe-overwrite / legacy-overwrite) or a
|
|
1515
|
+
// conflict is unresolved. PASS states: "skip" (current) and "user-only-skip"
|
|
1516
|
+
// (a deliberate user customization, respected by design; transformed workflows
|
|
1517
|
+
// land here too because the pristine base is the raw source). Hooks are
|
|
1518
|
+
// Mover-owned and force-synced, so when a Claude hooks dir exists every bundle
|
|
1519
|
+
// hook must be present with matching content.
|
|
1520
|
+
// options.templates: "content" (full three-way check, interactive/finalize) or
|
|
1521
|
+
// "presence" (file exists; quick mode does not force-overwrite vault scaffold
|
|
1522
|
+
// files, so content drift there is not a quick-mode failure).
|
|
1523
|
+
function verifyUpdateApplied(bundleDir, vaultPath, options = {}) {
|
|
1524
|
+
const templatesMode = options.templates || "content";
|
|
1525
|
+
const failures = [];
|
|
1526
|
+
const home = os.homedir();
|
|
1527
|
+
|
|
1528
|
+
// Unresolved conflicts block the stamp outright.
|
|
1529
|
+
const conflictsPath = path.join(home, ".mover", "conflicts.json");
|
|
1530
|
+
if (fs.existsSync(conflictsPath)) {
|
|
1531
|
+
try {
|
|
1532
|
+
const data = JSON.parse(fs.readFileSync(conflictsPath, "utf8"));
|
|
1533
|
+
for (const f of data.files || []) failures.push({ file: f.file, reason: "unresolved-conflict" });
|
|
1534
|
+
} catch {
|
|
1535
|
+
failures.push({ file: "conflicts.json", reason: "unreadable" });
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const manifest = loadUpdateManifest();
|
|
1540
|
+
const FAIL_ACTIONS = new Set(["install", "safe-overwrite", "legacy-overwrite", "conflict"]);
|
|
1541
|
+
|
|
1542
|
+
const wfSrcDir = path.join(bundleDir, "src", "workflows");
|
|
1543
|
+
const wfDestDir = path.join(home, ".claude", "commands");
|
|
1544
|
+
if (fs.existsSync(wfSrcDir) && fs.existsSync(wfDestDir)) {
|
|
1545
|
+
for (const file of fs.readdirSync(wfSrcDir).filter((f) => f.endsWith(".md"))) {
|
|
1546
|
+
const destFile = path.join(wfDestDir, file);
|
|
1547
|
+
if (file === "update.md") {
|
|
1548
|
+
// Force-synced with a transform on every update; presence + non-empty
|
|
1549
|
+
// is its contract, not hash equality.
|
|
1550
|
+
try {
|
|
1551
|
+
if (fs.statSync(destFile).size === 0) failures.push({ file: "workflows/update.md", reason: "empty" });
|
|
1552
|
+
} catch {
|
|
1553
|
+
failures.push({ file: "workflows/update.md", reason: "missing" });
|
|
1554
|
+
}
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
const r = compareForUpdate("workflows", path.join(wfSrcDir, file), destFile, manifest);
|
|
1558
|
+
if (FAIL_ACTIONS.has(r.action)) failures.push({ file: `workflows/${file}`, reason: r.action });
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
const tmplSrcDir = path.join(bundleDir, "src", "structure");
|
|
1563
|
+
if (fs.existsSync(tmplSrcDir) && vaultPath) {
|
|
1564
|
+
const walk = (dir, rel) => {
|
|
1565
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1566
|
+
const srcPath = path.join(dir, entry.name);
|
|
1567
|
+
const entryRel = path.join(rel, entry.name);
|
|
1568
|
+
if (entry.isDirectory()) { walk(srcPath, entryRel); continue; }
|
|
1569
|
+
const relNorm = entryRel.replace(/\\/g, "/");
|
|
1570
|
+
// Engine files are user data; never verified against templates.
|
|
1571
|
+
if (relNorm.includes("02_Areas") && relNorm.includes("Engine")) continue;
|
|
1572
|
+
const destFile = path.join(vaultPath, entryRel);
|
|
1573
|
+
if (templatesMode === "presence") {
|
|
1574
|
+
if (!fs.existsSync(destFile)) failures.push({ file: `templates/${relNorm}`, reason: "missing" });
|
|
1575
|
+
continue;
|
|
1576
|
+
}
|
|
1577
|
+
const r = compareForUpdate("templates", srcPath, destFile, manifest, relNorm);
|
|
1578
|
+
if (FAIL_ACTIONS.has(r.action)) failures.push({ file: `templates/${relNorm}`, reason: r.action });
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
walk(tmplSrcDir, "");
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Hooks: force-synced, Mover-owned. Only checked when a Claude hooks dir
|
|
1585
|
+
// exists (a cursor-only install has no ~/.claude/hooks and owes none).
|
|
1586
|
+
const hooksSrcDir = path.join(bundleDir, "src", "hooks");
|
|
1587
|
+
const hooksDestDir = path.join(home, ".claude", "hooks");
|
|
1588
|
+
if (fs.existsSync(hooksSrcDir) && fs.existsSync(hooksDestDir)) {
|
|
1589
|
+
for (const f of fs.readdirSync(hooksSrcDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
|
|
1590
|
+
const destFile = path.join(hooksDestDir, f);
|
|
1591
|
+
if (!fs.existsSync(destFile)) {
|
|
1592
|
+
failures.push({ file: `hooks/${f}`, reason: "missing" });
|
|
1593
|
+
} else if (fileContentHash(destFile) !== fileContentHash(path.join(hooksSrcDir, f))) {
|
|
1594
|
+
failures.push({ file: `hooks/${f}`, reason: "stale" });
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
return { ok: failures.length === 0, failures };
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1336
1602
|
function compareForUpdate(category, srcFile, destFile, manifest, relPath) {
|
|
1337
1603
|
const fileName = relPath ? `${category}/${relPath}` : `${category}/${path.basename(srcFile)}`;
|
|
1338
1604
|
const newHash = fileContentHash(srcFile);
|
|
@@ -1516,11 +1782,28 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
|
|
|
1516
1782
|
const backupRoot = path.join(home, ".mover", "backups", `pre-update-${ts}`);
|
|
1517
1783
|
let backed = 0;
|
|
1518
1784
|
|
|
1785
|
+
// Rollback manifest: every entry records the ABSOLUTE original path plus
|
|
1786
|
+
// whether the file existed before the update. `moveros update --rollback`
|
|
1787
|
+
// replays it: existedBefore -> restore bytes; created-by-update -> remove.
|
|
1788
|
+
// This is the npm-correct rollback target (vault + agent artifacts), never a
|
|
1789
|
+
// git checkout of the bundle dir, which on npm installs is a read-only
|
|
1790
|
+
// global package (sol part 10: rollback assumptions vs npm installs).
|
|
1791
|
+
const entries = [];
|
|
1792
|
+
|
|
1519
1793
|
const backup = (src, relPath) => {
|
|
1520
1794
|
if (!fs.existsSync(src)) return;
|
|
1521
1795
|
const dest = path.join(backupRoot, relPath);
|
|
1522
1796
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
1523
|
-
try {
|
|
1797
|
+
try {
|
|
1798
|
+
fs.copyFileSync(src, dest);
|
|
1799
|
+
backed++;
|
|
1800
|
+
entries.push({ original: src, backup: relPath, existedBefore: true });
|
|
1801
|
+
} catch {}
|
|
1802
|
+
};
|
|
1803
|
+
// A file the update will CREATE: nothing to copy, but rollback must remove it.
|
|
1804
|
+
const willCreate = (dest) => {
|
|
1805
|
+
if (fs.existsSync(dest)) return;
|
|
1806
|
+
entries.push({ original: dest, backup: null, existedBefore: false });
|
|
1524
1807
|
};
|
|
1525
1808
|
|
|
1526
1809
|
// Workflows
|
|
@@ -1534,12 +1817,18 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
|
|
|
1534
1817
|
backup(path.join(dir, f.file), path.join("workflows", path.basename(dir), f.file));
|
|
1535
1818
|
}
|
|
1536
1819
|
}
|
|
1820
|
+
for (const f of changes.workflows.filter((x) => x.status === "new")) {
|
|
1821
|
+
for (const dir of wfDests) willCreate(path.join(dir, f.file));
|
|
1822
|
+
}
|
|
1537
1823
|
|
|
1538
1824
|
// Hooks
|
|
1539
1825
|
if (targetIds.includes("claude-code")) {
|
|
1540
1826
|
for (const f of changes.hooks.filter((x) => x.status === "changed")) {
|
|
1541
1827
|
backup(path.join(home, ".claude", "hooks", f.file), path.join("hooks", f.file));
|
|
1542
1828
|
}
|
|
1829
|
+
for (const f of changes.hooks.filter((x) => x.status === "new")) {
|
|
1830
|
+
willCreate(path.join(home, ".claude", "hooks", f.file));
|
|
1831
|
+
}
|
|
1543
1832
|
}
|
|
1544
1833
|
|
|
1545
1834
|
// Rules
|
|
@@ -1572,17 +1861,45 @@ function autoBackupBeforeUpdate(changes, selectedAgentIds, vaultPath) {
|
|
|
1572
1861
|
// Statusline
|
|
1573
1862
|
if (changes.statusline === "changed" && targetIds.includes("claude-code")) {
|
|
1574
1863
|
backup(path.join(home, ".claude", "statusline.js"), "statusline.js");
|
|
1864
|
+
} else if (changes.statusline === "new" && targetIds.includes("claude-code")) {
|
|
1865
|
+
willCreate(path.join(home, ".claude", "statusline.js"));
|
|
1575
1866
|
}
|
|
1576
1867
|
|
|
1577
1868
|
// Templates
|
|
1578
1869
|
for (const f of changes.templates.filter((x) => x.status === "changed")) {
|
|
1579
1870
|
backup(path.join(vaultPath, f.file), path.join("templates", f.file));
|
|
1580
1871
|
}
|
|
1872
|
+
for (const f of changes.templates.filter((x) => x.status === "new")) {
|
|
1873
|
+
willCreate(path.join(vaultPath, f.file));
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
// ~/.mover state that the apply stage rewrites: the update manifest and the
|
|
1877
|
+
// pristine base snapshots. Without these, rolled-back files would be
|
|
1878
|
+
// classified against post-update bases and mis-read as user edits or
|
|
1879
|
+
// conflicts on the next run. Text files only; cheap.
|
|
1880
|
+
try {
|
|
1881
|
+
const mfstPath = path.join(home, ".mover", "manifest.json");
|
|
1882
|
+
if (fs.existsSync(mfstPath)) backup(mfstPath, path.join("mover-state", "manifest.json"));
|
|
1883
|
+
const installedDir = path.join(home, ".mover", "installed");
|
|
1884
|
+
if (fs.existsSync(installedDir)) {
|
|
1885
|
+
fs.cpSync(installedDir, path.join(backupRoot, "mover-state", "installed"), { recursive: true });
|
|
1886
|
+
}
|
|
1887
|
+
} catch {}
|
|
1888
|
+
|
|
1889
|
+
// Write the rollback manifest LAST so its presence implies a complete backup.
|
|
1890
|
+
try {
|
|
1891
|
+
fs.mkdirSync(backupRoot, { recursive: true });
|
|
1892
|
+
fs.writeFileSync(path.join(backupRoot, "backup-manifest.json"), JSON.stringify({
|
|
1893
|
+
createdAt: new Date().toISOString(),
|
|
1894
|
+
vaultPath,
|
|
1895
|
+
entries,
|
|
1896
|
+
}, null, 2), "utf8");
|
|
1897
|
+
} catch {}
|
|
1581
1898
|
|
|
1582
1899
|
if (backed > 0) {
|
|
1583
1900
|
statusLine("ok", "Auto-backup", `${backed} files → ${dim(backupRoot)}`);
|
|
1584
1901
|
}
|
|
1585
|
-
return backed;
|
|
1902
|
+
return { backupRoot, backed };
|
|
1586
1903
|
}
|
|
1587
1904
|
|
|
1588
1905
|
function countChanges(changes) {
|
|
@@ -1689,6 +2006,19 @@ async function runUninstall(vaultPath) {
|
|
|
1689
2006
|
|
|
1690
2007
|
const home = os.homedir();
|
|
1691
2008
|
|
|
2009
|
+
// terra uninstall F3: only touch VAULT-relative artifacts (AGENTS.md, SOUL.md,
|
|
2010
|
+
// .clinerules, .roo/skills, the PARA structure) when the vault is a REAL Mover
|
|
2011
|
+
// install. resolveVaultPath trusts an explicit --vault without existsSync, so a
|
|
2012
|
+
// typo'd/wrong/stale path could otherwise delete an unrelated project's files.
|
|
2013
|
+
// isInstalledVault is stricter than the old inline stat (it also rejects
|
|
2014
|
+
// an EMPTY marker file) — for a destructive command, stricter is safer.
|
|
2015
|
+
const vaultOwned = isInstalledVault(vaultPath);
|
|
2016
|
+
|
|
2017
|
+
// terra uninstall F1: uninstall is DESTRUCTIVE (it removes agent commands/hooks/
|
|
2018
|
+
// skills, some of which live in shared dirs). A piped/non-TTY run must NOT
|
|
2019
|
+
// silently auto-select and delete — require an explicit confirmation flag.
|
|
2020
|
+
const uninstallConfirmed = process.argv.includes("--yes") || process.argv.includes("--confirm") || process.env.MOVER_UNINSTALL_YES === "1";
|
|
2021
|
+
|
|
1692
2022
|
// ── Build category map (only include categories that actually exist on disk) ──
|
|
1693
2023
|
const categories = [];
|
|
1694
2024
|
|
|
@@ -1700,7 +2030,7 @@ async function runUninstall(vaultPath) {
|
|
|
1700
2030
|
{ label: "Codex instructions", path: path.join(home, ".codex", "instructions.md") },
|
|
1701
2031
|
{ label: "Windsurf rules", path: path.join(home, ".windsurfrules") },
|
|
1702
2032
|
];
|
|
1703
|
-
if (
|
|
2033
|
+
if (vaultOwned) {
|
|
1704
2034
|
rulesPaths.push(
|
|
1705
2035
|
{ label: "AGENTS.md", path: path.join(vaultPath, "AGENTS.md") },
|
|
1706
2036
|
{ label: "SOUL.md", path: path.join(vaultPath, "SOUL.md") },
|
|
@@ -1728,30 +2058,88 @@ async function runUninstall(vaultPath) {
|
|
|
1728
2058
|
{ label: "Windsurf skills (legacy)", path: path.join(home, ".windsurf", "skills"), dir: true },
|
|
1729
2059
|
{ label: "Windsurf skills", path: path.join(home, ".codeium", "windsurf", "skills"), dir: true },
|
|
1730
2060
|
{ label: "Cline skills", path: path.join(home, ".cline", "skills"), dir: true },
|
|
1731
|
-
{ label: "Roo Code skills (vault-relative)", path:
|
|
2061
|
+
{ label: "Roo Code skills (vault-relative)", path: vaultOwned && path.join(vaultPath, ".roo", "skills"), dir: true },
|
|
1732
2062
|
{ label: "Cross-agent shared skills", path: path.join(home, ".agents", "skills"), dir: true },
|
|
1733
2063
|
].filter(p => p.path);
|
|
1734
2064
|
const skillsExist = skillsPaths.some(p => fs.existsSync(p.path));
|
|
1735
2065
|
if (skillsExist) categories.push({ id: "skills", name: "Skills", description: "61 curated skill packs", items: skillsPaths });
|
|
1736
2066
|
|
|
2067
|
+
// sol #2 (Installer-to-10): commands and hooks live in SHARED agent dirs that can
|
|
2068
|
+
// also hold the user's OWN files (a hand-written slash command, a personal hook).
|
|
2069
|
+
// Uninstall must remove only what Mover installed, never a co-resident user file.
|
|
2070
|
+
// The running installer ships its own source tree, so its basenames ARE the
|
|
2071
|
+
// ownership manifest — precise and always available. Union with the on-disk
|
|
2072
|
+
// install manifest so files renamed away from the current bundle (but still
|
|
2073
|
+
// installed from an older version) are also reclaimed.
|
|
2074
|
+
const moverOwnedNames = (kind) => {
|
|
2075
|
+
const names = new Set();
|
|
2076
|
+
try {
|
|
2077
|
+
const dir = path.join(__dirname, "src", kind === "commands" ? "workflows" : "hooks");
|
|
2078
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
2079
|
+
if (!e.isFile()) continue;
|
|
2080
|
+
if (kind === "commands" && !e.name.endsWith(".md")) continue;
|
|
2081
|
+
names.add(e.name);
|
|
2082
|
+
}
|
|
2083
|
+
} catch { /* bundle source unavailable — manifest below may still populate */ }
|
|
2084
|
+
try {
|
|
2085
|
+
const man = JSON.parse(fs.readFileSync(path.join(home, ".mover", "manifest.json"), "utf8"));
|
|
2086
|
+
for (const k of Object.keys((man && man.files) || {})) {
|
|
2087
|
+
const seg = String(k).split("/");
|
|
2088
|
+
const base = seg.pop();
|
|
2089
|
+
const prefix = seg[0] || "";
|
|
2090
|
+
if (!base) continue;
|
|
2091
|
+
// Only union keys whose prefix matches the kind, so a hook name can never
|
|
2092
|
+
// leak into the command owned-set (or vice versa). A flat manifest (no
|
|
2093
|
+
// prefix) contributes nothing here; bundle enumeration already covers it.
|
|
2094
|
+
if (kind === "commands" && (prefix === "workflows" || prefix === "commands") && base.endsWith(".md")) names.add(base);
|
|
2095
|
+
if (kind === "hooks" && prefix === "hooks") names.add(base);
|
|
2096
|
+
}
|
|
2097
|
+
} catch { /* no manifest / unreadable — bundle enumeration stands alone */ }
|
|
2098
|
+
return names;
|
|
2099
|
+
};
|
|
2100
|
+
const ownedCommandNames = moverOwnedNames("commands");
|
|
2101
|
+
const ownedHookNames = moverOwnedNames("hooks");
|
|
2102
|
+
|
|
1737
2103
|
// Commands / Workflows
|
|
1738
2104
|
const commandsPaths = [
|
|
1739
|
-
{ label: "Claude Code commands", path: path.join(home, ".claude", "commands"), dir: true },
|
|
1740
|
-
{ label: "Cursor commands", path: path.join(home, ".cursor", "commands"), dir: true },
|
|
1741
|
-
{ label: "Antigravity workflows", path: path.join(home, ".gemini", "antigravity", "global_workflows"), dir: true },
|
|
2105
|
+
{ label: "Claude Code commands", path: path.join(home, ".claude", "commands"), dir: true, ownedNames: ownedCommandNames },
|
|
2106
|
+
{ label: "Cursor commands", path: path.join(home, ".cursor", "commands"), dir: true, ownedNames: ownedCommandNames },
|
|
2107
|
+
{ label: "Antigravity workflows", path: path.join(home, ".gemini", "antigravity", "global_workflows"), dir: true, ownedNames: ownedCommandNames },
|
|
1742
2108
|
];
|
|
1743
2109
|
const commandsExist = commandsPaths.some(p => fs.existsSync(p.path));
|
|
1744
2110
|
if (commandsExist) categories.push({ id: "commands", name: "Commands & Workflows", description: "22 slash commands / workflows", items: commandsPaths });
|
|
1745
2111
|
|
|
1746
2112
|
// Hooks
|
|
1747
2113
|
const hooksPaths = [
|
|
1748
|
-
{ label: "Claude Code hooks", path: path.join(home, ".claude", "hooks"), dir: true },
|
|
2114
|
+
{ label: "Claude Code hooks", path: path.join(home, ".claude", "hooks"), dir: true, ownedNames: ownedHookNames },
|
|
1749
2115
|
];
|
|
1750
2116
|
const hooksExist = hooksPaths.some(p => fs.existsSync(p.path));
|
|
1751
2117
|
if (hooksExist) categories.push({ id: "hooks", name: "Hooks", description: "6 Claude Code lifecycle hooks", items: hooksPaths });
|
|
1752
2118
|
|
|
2119
|
+
// Desktop launcher (Mover Studio shortcut) — previously orphaned on uninstall
|
|
2120
|
+
// (terra 2026-07-11): the .app/symlink/.desktop/.lnk that `moveros shortcut`
|
|
2121
|
+
// and the onboarding checkbox create were never listed here, so they lingered
|
|
2122
|
+
// after uninstall. Reuse the same name logic the creator uses.
|
|
2123
|
+
try {
|
|
2124
|
+
const { studioName, fileSafeName } = require("./src/dashboard/shortcut");
|
|
2125
|
+
let scCfg = {};
|
|
2126
|
+
try { scCfg = JSON.parse(fs.readFileSync(path.join(home, ".mover", "config.json"), "utf8")); } catch {}
|
|
2127
|
+
const fname = fileSafeName(studioName(scCfg));
|
|
2128
|
+
const shortcutPaths = [
|
|
2129
|
+
{ label: "macOS app (~/Applications)", path: path.join(home, "Applications", `${fname}.app`), dir: true },
|
|
2130
|
+
{ label: "macOS Desktop link", path: path.join(home, "Desktop", `${fname}.app`) },
|
|
2131
|
+
{ label: "Linux app entry", path: path.join(home, ".local", "share", "applications", "mover-studio.desktop") },
|
|
2132
|
+
{ label: "Linux Desktop entry", path: path.join(home, "Desktop", `${fname}.desktop`) },
|
|
2133
|
+
{ label: "Windows launcher (.bat)", path: path.join(home, ".mover", "launcher", `${fname}.bat`) },
|
|
2134
|
+
{ label: "Windows Desktop shortcut", path: path.join(home, "Desktop", `${fname}.lnk`) },
|
|
2135
|
+
{ label: "Windows Desktop .bat fallback", path: path.join(home, "Desktop", `${fname}.bat`) },
|
|
2136
|
+
];
|
|
2137
|
+
const shortcutExist = shortcutPaths.some(p => { try { return fs.lstatSync(p.path) && true; } catch { return false; } });
|
|
2138
|
+
if (shortcutExist) categories.push({ id: "shortcut", name: "Desktop launcher", description: "Mover Studio app / desktop shortcut", items: shortcutPaths });
|
|
2139
|
+
} catch { /* shortcut module unavailable — skip this category */ }
|
|
2140
|
+
|
|
1753
2141
|
// Vault structure (only if vault has Mover OS structure)
|
|
1754
|
-
if (
|
|
2142
|
+
if (vaultOwned) {
|
|
1755
2143
|
const vaultStructurePaths = [
|
|
1756
2144
|
{ label: "00_Inbox/", path: path.join(vaultPath, "00_Inbox"), dir: true },
|
|
1757
2145
|
{ label: "01_Projects/", path: path.join(vaultPath, "01_Projects"), dir: true },
|
|
@@ -1765,7 +2153,7 @@ async function runUninstall(vaultPath) {
|
|
|
1765
2153
|
}
|
|
1766
2154
|
|
|
1767
2155
|
// Mover OS source folder (if cloned into vault)
|
|
1768
|
-
if (
|
|
2156
|
+
if (vaultOwned) {
|
|
1769
2157
|
const moverBundlePath = path.join(vaultPath, "01_Projects", "Mover OS Bundle");
|
|
1770
2158
|
if (fs.existsSync(moverBundlePath)) {
|
|
1771
2159
|
categories.push({ id: "bundle", name: "Mover OS Bundle", description: "01_Projects/Mover OS Bundle/ source folder", items: [
|
|
@@ -1774,16 +2162,18 @@ async function runUninstall(vaultPath) {
|
|
|
1774
2162
|
}
|
|
1775
2163
|
}
|
|
1776
2164
|
|
|
1777
|
-
// Config files
|
|
2165
|
+
// Config files. ONLY files Mover itself created: {vault}/.claude/settings.json
|
|
2166
|
+
// is Claude Code's (or the user's) project config — Mover never writes it,
|
|
2167
|
+
// so uninstall must never delete it (R5-A: default-selected category was
|
|
2168
|
+
// silently removing a file we don't own).
|
|
1778
2169
|
const configPaths = [];
|
|
1779
2170
|
if (vaultPath) {
|
|
1780
2171
|
configPaths.push(
|
|
1781
2172
|
{ label: ".mover-version", path: path.join(vaultPath, ".mover-version") },
|
|
1782
|
-
{ label: "Claude settings", path: path.join(vaultPath, ".claude", "settings.json") },
|
|
1783
2173
|
);
|
|
1784
2174
|
}
|
|
1785
2175
|
const configExists = configPaths.some(p => fs.existsSync(p.path));
|
|
1786
|
-
if (configExists) categories.push({ id: "config", name: "Config Files", description: ".mover-version
|
|
2176
|
+
if (configExists) categories.push({ id: "config", name: "Config Files", description: ".mover-version", items: configPaths });
|
|
1787
2177
|
|
|
1788
2178
|
if (categories.length === 0) {
|
|
1789
2179
|
barLn(dim("Nothing to remove — Mover OS not detected."));
|
|
@@ -1793,6 +2183,16 @@ async function runUninstall(vaultPath) {
|
|
|
1793
2183
|
}
|
|
1794
2184
|
|
|
1795
2185
|
// ── Multi-select: what to uninstall ──
|
|
2186
|
+
// F1: in a non-TTY run interactiveSelect returns the pre-selected defaults with
|
|
2187
|
+
// no human in the loop, silently deleting them (including shared agent dirs that
|
|
2188
|
+
// may hold USER commands/hooks/skills). Refuse unless explicitly confirmed
|
|
2189
|
+
// (--yes / --confirm / MOVER_UNINSTALL_YES=1).
|
|
2190
|
+
if (!IS_TTY && !uninstallConfirmed) {
|
|
2191
|
+
barLn(red(" Uninstall is destructive and this session is non-interactive."));
|
|
2192
|
+
barLn(dim(" Re-run in a terminal, or pass --yes to remove the detected Mover OS components."));
|
|
2193
|
+
outro("Uninstall cancelled.");
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
1796
2196
|
barLn(bold("What do you want to uninstall?"));
|
|
1797
2197
|
barLn();
|
|
1798
2198
|
|
|
@@ -1842,15 +2242,50 @@ async function runUninstall(vaultPath) {
|
|
|
1842
2242
|
const cat = categories.find(c => c.id === catId);
|
|
1843
2243
|
if (!cat) continue;
|
|
1844
2244
|
for (const item of cat.items) {
|
|
1845
|
-
|
|
2245
|
+
// lstat, not existsSync: existsSync FOLLOWS symlinks, so once a target is
|
|
2246
|
+
// removed a dangling symlink (e.g. the Desktop .app pointing at the now-gone
|
|
2247
|
+
// ~/Applications app) reads as non-existent and gets orphaned. lstat sees
|
|
2248
|
+
// the link itself. (terra shortcut-orphan fix, 2026-07-11)
|
|
2249
|
+
let lst = null;
|
|
2250
|
+
try { lst = fs.lstatSync(item.path); } catch { lst = null; }
|
|
2251
|
+
if (!lst) continue;
|
|
1846
2252
|
try {
|
|
1847
2253
|
if (item.dir) {
|
|
1848
|
-
|
|
2254
|
+
// terra uninstall F2: a SYMLINKED category root (e.g. ~/.claude/skills ->
|
|
2255
|
+
// an external repo) must never be traversed — readdirSync would follow
|
|
2256
|
+
// it and rmSync would delete the user's out-of-tree files. rmSync on the
|
|
2257
|
+
// symlink itself removes only the link (recursive:true does NOT follow a
|
|
2258
|
+
// final-component symlink for deletion). Skip a symlinked keepBuiltins
|
|
2259
|
+
// root entirely (user-configured); remove a symlinked plain dir as a link.
|
|
2260
|
+
if (lst.isSymbolicLink()) {
|
|
2261
|
+
if (item.keepBuiltins) { continue; } // do not touch a user-symlinked skills root
|
|
2262
|
+
fs.rmSync(item.path, { recursive: true, force: true }); // removes the link, not the target
|
|
2263
|
+
} else if (item.keepBuiltins) {
|
|
1849
2264
|
for (const entry of fs.readdirSync(item.path, { withFileTypes: true })) {
|
|
2265
|
+
// Skip a symlinked child skill dir too — only remove real Mover skill dirs.
|
|
2266
|
+
if (entry.isSymbolicLink()) continue;
|
|
1850
2267
|
if (entry.isDirectory() && fs.existsSync(path.join(item.path, entry.name, "SKILL.md"))) {
|
|
1851
2268
|
fs.rmSync(path.join(item.path, entry.name), { recursive: true, force: true });
|
|
1852
2269
|
}
|
|
1853
2270
|
}
|
|
2271
|
+
} else if (item.ownedNames) {
|
|
2272
|
+
// sol #2: a SHARED command/hook dir — remove only Mover-installed
|
|
2273
|
+
// entries, never a co-resident user file. If we could not determine
|
|
2274
|
+
// Mover's file set, leave the dir untouched (orphaning Mover files is
|
|
2275
|
+
// the safe failure direction; deleting the user's files is not).
|
|
2276
|
+
if (item.ownedNames.size === 0) {
|
|
2277
|
+
barLn(`${yellow("!")} ${dim(item.label + " — left in place (could not determine Mover's file list)")}`);
|
|
2278
|
+
continue;
|
|
2279
|
+
}
|
|
2280
|
+
let userLeft = 0;
|
|
2281
|
+
for (const entry of fs.readdirSync(item.path, { withFileTypes: true })) {
|
|
2282
|
+
if (entry.isSymbolicLink()) { userLeft++; continue; } // never follow a link out of the dir
|
|
2283
|
+
if (item.ownedNames.has(entry.name)) {
|
|
2284
|
+
try { fs.rmSync(path.join(item.path, entry.name), { recursive: true, force: true }); }
|
|
2285
|
+
catch { userLeft++; }
|
|
2286
|
+
} else { userLeft++; }
|
|
2287
|
+
}
|
|
2288
|
+
if (userLeft === 0) { try { fs.rmdirSync(item.path); } catch {} } // dir held only Mover files
|
|
1854
2289
|
} else {
|
|
1855
2290
|
fs.rmSync(item.path, { recursive: true, force: true });
|
|
1856
2291
|
}
|
|
@@ -1872,37 +2307,48 @@ async function runUninstall(vaultPath) {
|
|
|
1872
2307
|
barLn(dim("Deactivating license..."));
|
|
1873
2308
|
try {
|
|
1874
2309
|
const https = require("https");
|
|
1875
|
-
// v4.7.6: match activateKey's label scheme (machine_id) so deactivate
|
|
1876
|
-
// actually frees the activation slot. v4.7.5 deactivated by hostname,
|
|
1877
|
-
// which only worked if the user hadn't renamed their machine since
|
|
1878
|
-
// install. Fallback: if machine_id read fails, try hostname (covers
|
|
1879
|
-
// pre-v4.7.3 activations that stored hostname).
|
|
1880
2310
|
const moverDir = path.join(os.homedir(), ".mover");
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
const
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2311
|
+
const activationId = readActivationId(moverDir);
|
|
2312
|
+
if (!activationId) {
|
|
2313
|
+
barLn(dim("No saved activation ID. Open your Polar customer portal to free the device slot."));
|
|
2314
|
+
} else {
|
|
2315
|
+
const body = JSON.stringify({ key: cfg.licenseKey, organization_id: POLAR_ORG_ID, activation_id: activationId });
|
|
2316
|
+
const result = await new Promise((resolve, reject) => {
|
|
2317
|
+
const req = https.request({
|
|
2318
|
+
hostname: "api.polar.sh",
|
|
2319
|
+
path: "/v1/customer-portal/license-keys/deactivate",
|
|
2320
|
+
method: "POST",
|
|
2321
|
+
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
|
|
2322
|
+
timeout: 10000,
|
|
2323
|
+
}, (res) => {
|
|
2324
|
+
let data = "";
|
|
2325
|
+
res.on("data", (chunk) => data += chunk);
|
|
2326
|
+
res.on("end", () => resolve({ statusCode: res.statusCode, data }));
|
|
2327
|
+
});
|
|
2328
|
+
req.on("error", reject);
|
|
2329
|
+
req.on("timeout", () => { req.destroy(); reject(new Error("Timeout")); });
|
|
2330
|
+
req.write(body);
|
|
2331
|
+
req.end();
|
|
1895
2332
|
});
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
2333
|
+
if (result.statusCode === 204) {
|
|
2334
|
+
try { fs.unlinkSync(path.join(moverDir, ".activation-id")); } catch {}
|
|
2335
|
+
barLn(`${green("\u2713")} ${dim("License deactivated - activation slot freed")}`);
|
|
2336
|
+
} else {
|
|
2337
|
+
barLn(dim(`Polar did not deactivate this device (HTTP ${result.statusCode}). Open your customer portal to free the slot.`));
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
1902
2340
|
} catch {
|
|
1903
|
-
barLn(`${dim("Could not reach Polar
|
|
2341
|
+
barLn(`${dim("Could not reach Polar - license not deactivated")}`);
|
|
1904
2342
|
}
|
|
1905
2343
|
}
|
|
2344
|
+
// terra uninstall F5: reducing the config to just the license key silently
|
|
2345
|
+
// discards the user's settings (review_day, osName, cli_usage history). Save
|
|
2346
|
+
// a full copy first so a reinstall can recover their personalization.
|
|
2347
|
+
try {
|
|
2348
|
+
const bak = configPath + ".pre-uninstall-bak";
|
|
2349
|
+
fs.copyFileSync(configPath, bak);
|
|
2350
|
+
barLn(`${green("\u2713")} ${dim("~/.mover/config.json.pre-uninstall-bak (settings saved)")}`);
|
|
2351
|
+
} catch {}
|
|
1906
2352
|
// Remove config file but preserve license key for reinstall
|
|
1907
2353
|
if (cfg.licenseKey) {
|
|
1908
2354
|
fs.writeFileSync(configPath, JSON.stringify({ licenseKey: cfg.licenseKey }, null, 2), "utf8");
|
|
@@ -1947,7 +2393,9 @@ const AGENT_REGISTRY = {
|
|
|
1947
2393
|
"claude-code": {
|
|
1948
2394
|
name: "Claude Code",
|
|
1949
2395
|
tier: "full",
|
|
1950
|
-
tierDesc
|
|
2396
|
+
// No counts in tierDesc strings — they rot (this one said "23 commands, 6
|
|
2397
|
+
// hooks" while the product shipped 25/18). docs-generated.test.mjs guards.
|
|
2398
|
+
tierDesc: "Rules, commands, skills, hooks",
|
|
1951
2399
|
detect: () => cmdExists("claude") || fs.existsSync(path.join(H, ".claude")),
|
|
1952
2400
|
rules: { type: "link", dest: () => path.join(H, ".claude", "CLAUDE.md"), header: "# Mover OS Global Rules" },
|
|
1953
2401
|
skills: { dest: () => path.join(H, ".claude", "skills") },
|
|
@@ -1972,7 +2420,7 @@ const AGENT_REGISTRY = {
|
|
|
1972
2420
|
"cline": {
|
|
1973
2421
|
name: "Cline",
|
|
1974
2422
|
tier: "full",
|
|
1975
|
-
tierDesc: "Rules, skills
|
|
2423
|
+
tierDesc: "Rules, skills",
|
|
1976
2424
|
detect: () => globDirExists(path.join(H, ".vscode", "extensions"), "saoudrizwan.claude-dev-*"),
|
|
1977
2425
|
rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".clinerules", "mover-os.md") },
|
|
1978
2426
|
skills: { dest: (vault) => path.join(vault || ".", ".cline", "skills") },
|
|
@@ -2017,7 +2465,7 @@ const AGENT_REGISTRY = {
|
|
|
2017
2465
|
"amazon-q": {
|
|
2018
2466
|
name: "Amazon Q Developer",
|
|
2019
2467
|
tier: "full",
|
|
2020
|
-
tierDesc: "Rules, JSON agent commands
|
|
2468
|
+
tierDesc: "Rules, JSON agent commands",
|
|
2021
2469
|
detect: () => cmdExists("q") || fs.existsSync(path.join(H, ".aws", "amazonq")),
|
|
2022
2470
|
rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".amazonq", "rules", "mover-os.md") },
|
|
2023
2471
|
skills: null,
|
|
@@ -2027,18 +2475,22 @@ const AGENT_REGISTRY = {
|
|
|
2027
2475
|
"opencode": {
|
|
2028
2476
|
name: "OpenCode",
|
|
2029
2477
|
tier: "full",
|
|
2030
|
-
tierDesc: "AGENTS.md,
|
|
2478
|
+
tierDesc: "AGENTS.md, skills",
|
|
2031
2479
|
detect: () => cmdExists("opencode") || fs.existsSync(path.join(".", "opencode.json")),
|
|
2032
2480
|
sharedRulesFile: "agents-md",
|
|
2033
2481
|
rules: { type: "agents-md", dest: (vault) => path.join(vault || ".", "AGENTS.md") },
|
|
2034
2482
|
skills: { dest: (vault) => path.join(vault || ".", ".opencode", "skills") },
|
|
2035
|
-
|
|
2483
|
+
// The opencode-json command writer is a stub (installFromRegistry skips
|
|
2484
|
+
// it: "requires config file merge logic"). Declared null so the docs,
|
|
2485
|
+
// menus, and inventory can't sell commands that never install. Restore
|
|
2486
|
+
// the field WITH the merge writer, together.
|
|
2487
|
+
commands: null,
|
|
2036
2488
|
hooks: null,
|
|
2037
2489
|
},
|
|
2038
2490
|
"kilo-code": {
|
|
2039
2491
|
name: "Kilo Code",
|
|
2040
2492
|
tier: "full",
|
|
2041
|
-
tierDesc: "Rules, skills, commands
|
|
2493
|
+
tierDesc: "Rules, skills, commands",
|
|
2042
2494
|
detect: () => globDirExists(path.join(H, ".vscode", "extensions"), "kilocode.kilo-code-*"),
|
|
2043
2495
|
rules: { type: "copy", dest: (vault) => path.join(vault || ".", ".kilocode", "rules", "mover-os.md") },
|
|
2044
2496
|
skills: { dest: (vault) => path.join(vault || ".", ".kilocode", "skills") },
|
|
@@ -2050,7 +2502,7 @@ const AGENT_REGISTRY = {
|
|
|
2050
2502
|
"codex": {
|
|
2051
2503
|
name: "Codex",
|
|
2052
2504
|
tier: "full",
|
|
2053
|
-
tierDesc: "AGENTS.md, skills (skills = commands),
|
|
2505
|
+
tierDesc: "AGENTS.md, skills (skills = commands), hooks",
|
|
2054
2506
|
detect: () => cmdExists("codex") || fs.existsSync(path.join(H, ".codex")),
|
|
2055
2507
|
rules: { type: "agents-md", dest: () => path.join(H, ".codex", "AGENTS.md") },
|
|
2056
2508
|
skills: { dest: () => path.join(H, ".codex", "skills") },
|
|
@@ -2177,15 +2629,46 @@ function globDirExists(dir, pattern) {
|
|
|
2177
2629
|
}
|
|
2178
2630
|
|
|
2179
2631
|
let linkFallbackWarned = false;
|
|
2632
|
+
// Back up a rules/instructions file we are about to overwrite when it was NOT
|
|
2633
|
+
// authored by Mover (no Mover signature in the content) — a user's own
|
|
2634
|
+
// AGENTS.md / copilot-instructions.md / CLAUDE.md must never be silently
|
|
2635
|
+
// destroyed (R5-A). The .bak is written once: repeated installs never
|
|
2636
|
+
// overwrite the original backup with Mover's own content.
|
|
2637
|
+
function backupForeignFile(filePath) {
|
|
2638
|
+
try {
|
|
2639
|
+
if (!fs.existsSync(filePath)) return false;
|
|
2640
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
2641
|
+
if (!content.trim()) return false;
|
|
2642
|
+
const moverAuthored = /Mover OS|MOVER_OS_RULES|## My Customizations/.test(content);
|
|
2643
|
+
if (moverAuthored) return false;
|
|
2644
|
+
const bak = filePath + ".bak";
|
|
2645
|
+
if (!fs.existsSync(bak)) {
|
|
2646
|
+
fs.copyFileSync(filePath, bak);
|
|
2647
|
+
console.log(` ${dim(`Existing ${path.basename(filePath)} backed up to ${path.basename(bak)}`)}`);
|
|
2648
|
+
}
|
|
2649
|
+
return true;
|
|
2650
|
+
} catch { return false; }
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2180
2653
|
function linkOrCopy(src, dest) {
|
|
2654
|
+
// Atomic replace: stage into a same-directory temp name, then rename over the
|
|
2655
|
+
// destination. rename(2) within one directory is atomic, so a kill/power-loss
|
|
2656
|
+
// mid-operation leaves the ORIGINAL dest intact. The old unlink-then-link left
|
|
2657
|
+
// a window where dest did not exist and the command file went missing (terra
|
|
2658
|
+
// F6). The temp link shares src's inode, so the hard-link architecture holds.
|
|
2659
|
+
const tmp = `${dest}.mover-tmp-${process.pid}`;
|
|
2181
2660
|
try {
|
|
2182
2661
|
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
2183
|
-
if (fs.existsSync(dest))
|
|
2184
|
-
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);
|
|
2185
2666
|
return "linked";
|
|
2186
2667
|
} catch {
|
|
2668
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
|
|
2187
2669
|
try {
|
|
2188
|
-
fs.copyFileSync(src,
|
|
2670
|
+
fs.copyFileSync(src, tmp);
|
|
2671
|
+
fs.renameSync(tmp, dest);
|
|
2189
2672
|
if (!linkFallbackWarned) {
|
|
2190
2673
|
console.log(`\n ${dim("Note: Hard links unavailable — using copies. Edits won't auto-propagate.")}`);
|
|
2191
2674
|
console.log(` ${dim("Run link.sh after editing source files to re-sync.")}\n`);
|
|
@@ -2193,6 +2676,7 @@ function linkOrCopy(src, dest) {
|
|
|
2193
2676
|
}
|
|
2194
2677
|
return "copied";
|
|
2195
2678
|
} catch {
|
|
2679
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
|
|
2196
2680
|
return null;
|
|
2197
2681
|
}
|
|
2198
2682
|
}
|
|
@@ -2381,9 +2865,11 @@ Stuck: /debug-resistance
|
|
|
2381
2865
|
// ─── Claude Code hooks (settings.json) ──────────────────────────────────────
|
|
2382
2866
|
// ─── Codex hook config generator ────────────────────────────────────────────
|
|
2383
2867
|
// v4.7.5: Codex hooks invoked through mover-hook-adapter.js for schema
|
|
2384
|
-
// translation.
|
|
2385
|
-
//
|
|
2386
|
-
// —
|
|
2868
|
+
// translation.
|
|
2869
|
+
//
|
|
2870
|
+
// v4.7.8: expanded coverage — was 5 hooks across 4 events, now 13 hooks across
|
|
2871
|
+
// 5 events. Brings Codex to Claude Code parity on every event Codex supports.
|
|
2872
|
+
// PermissionRequest deferred until adapter handles nested decision schema.
|
|
2387
2873
|
//
|
|
2388
2874
|
// IMPORTANT: Codex runs hook commands through cmd.exe on Windows, which does
|
|
2389
2875
|
// NOT expand $HOME. We resolve absolute paths at install time so the same
|
|
@@ -2413,6 +2899,28 @@ function generateCodexHooks() {
|
|
|
2413
2899
|
command: `node ${adapter} codex SessionStart ${hookDir}/session-start.sh" resume`,
|
|
2414
2900
|
timeout: 15,
|
|
2415
2901
|
},
|
|
2902
|
+
{
|
|
2903
|
+
// v4.7.8: Engine Health surface on session boot — matches the
|
|
2904
|
+
// PreCompact-equivalent hook Claude Code runs.
|
|
2905
|
+
type: "command",
|
|
2906
|
+
command: `node ${adapter} codex SessionStart ${hookDir}/engine-health.sh" full`,
|
|
2907
|
+
timeout: 15,
|
|
2908
|
+
},
|
|
2909
|
+
],
|
|
2910
|
+
},
|
|
2911
|
+
],
|
|
2912
|
+
// v4.7.8 NEW: per-prompt enforcement (Single Test reminder, friction floor)
|
|
2913
|
+
UserPromptSubmit: [
|
|
2914
|
+
{
|
|
2915
|
+
hooks: [
|
|
2916
|
+
{
|
|
2917
|
+
type: "command",
|
|
2918
|
+
command: `node ${adapter} codex UserPromptSubmit ${hookDir}/user-prompt-enforce.sh"`,
|
|
2919
|
+
// v4.7.8 audit fix: 4s undercut adapter's 4500ms fallback (mover-hook-adapter.js:65),
|
|
2920
|
+
// host would SIGTERM adapter before its internal timeout fired. 5s gives the
|
|
2921
|
+
// adapter room to surface its own timeout error cleanly.
|
|
2922
|
+
timeout: 5,
|
|
2923
|
+
},
|
|
2416
2924
|
],
|
|
2417
2925
|
},
|
|
2418
2926
|
],
|
|
@@ -2433,6 +2941,32 @@ function generateCodexHooks() {
|
|
|
2433
2941
|
{
|
|
2434
2942
|
type: "command",
|
|
2435
2943
|
command: `node ${adapter} codex PreToolUse ${hookDir}/engine-protection.sh"`,
|
|
2944
|
+
// Packet #2 rider (dev/plan.md T-PACKET2-RIDER-1): approval
|
|
2945
|
+
// wait budget. This is a CEILING, not a delay: the adapter
|
|
2946
|
+
// answers in milliseconds on every write, except during a
|
|
2947
|
+
// real dashboard-approval wait (MOVER_STUDIO_RUN=1 + live
|
|
2948
|
+
// daemon), where it blocks up to 45s for a human yes/no in
|
|
2949
|
+
// Mover Studio before failing closed to deny. 60s = 45s wait
|
|
2950
|
+
// + 4.5s bash-hook spawn + node startup + kill-precision margin.
|
|
2951
|
+
timeout: 60,
|
|
2952
|
+
},
|
|
2953
|
+
],
|
|
2954
|
+
},
|
|
2955
|
+
],
|
|
2956
|
+
// v4.7.8: PermissionRequest event. Schema verified against
|
|
2957
|
+
// https://developers.openai.com/codex/hooks — Codex expects nested
|
|
2958
|
+
// {decision:{behavior,message?}} shape, which engine-permission-request.sh
|
|
2959
|
+
// already emits. Adapter (mover-hook-adapter.js v4.7.8) recognizes the
|
|
2960
|
+
// shape via isPermissionBehavior and passes through with allowlisting.
|
|
2961
|
+
// Strategy.md and Identity_Prime.md pass through to manual dialog;
|
|
2962
|
+
// Daily Notes and non-Engine files auto-allow.
|
|
2963
|
+
PermissionRequest: [
|
|
2964
|
+
{
|
|
2965
|
+
matcher: "Edit|Write|apply_patch",
|
|
2966
|
+
hooks: [
|
|
2967
|
+
{
|
|
2968
|
+
type: "command",
|
|
2969
|
+
command: `node ${adapter} codex PermissionRequest ${hookDir}/engine-permission-request.sh"`,
|
|
2436
2970
|
timeout: 5,
|
|
2437
2971
|
},
|
|
2438
2972
|
],
|
|
@@ -2447,16 +2981,50 @@ function generateCodexHooks() {
|
|
|
2447
2981
|
command: `node ${adapter} codex PostToolUse ${hookDir}/plan-sync-reminder.sh"`,
|
|
2448
2982
|
timeout: 5,
|
|
2449
2983
|
},
|
|
2984
|
+
{
|
|
2985
|
+
// v4.7.8: auto-commit Engine edits. Multi-agent safe via flock
|
|
2986
|
+
// (see engine-auto-commit.sh:62-89). Silent on non-Engine files.
|
|
2987
|
+
type: "command",
|
|
2988
|
+
command: `node ${adapter} codex PostToolUse ${hookDir}/engine-auto-commit.sh"`,
|
|
2989
|
+
timeout: 10,
|
|
2990
|
+
},
|
|
2450
2991
|
],
|
|
2451
2992
|
},
|
|
2452
2993
|
],
|
|
2453
2994
|
Stop: [
|
|
2454
2995
|
{
|
|
2996
|
+
// v4.7.8: 6 sequential hooks. Order matters — cheap/important first
|
|
2997
|
+
// so they complete before any per-event budget exhausts.
|
|
2455
2998
|
hooks: [
|
|
2456
2999
|
{
|
|
2457
3000
|
type: "command",
|
|
2458
3001
|
command: `node ${adapter} codex Stop ${hookDir}/dirty-tree-guard.sh"`,
|
|
2459
|
-
timeout:
|
|
3002
|
+
timeout: 8,
|
|
3003
|
+
},
|
|
3004
|
+
{
|
|
3005
|
+
type: "command",
|
|
3006
|
+
command: `node ${adapter} codex Stop ${hookDir}/session-log-reminder.sh"`,
|
|
3007
|
+
timeout: 8,
|
|
3008
|
+
},
|
|
3009
|
+
{
|
|
3010
|
+
type: "command",
|
|
3011
|
+
command: `node ${adapter} codex Stop ${hookDir}/context-staleness.sh"`,
|
|
3012
|
+
timeout: 5,
|
|
3013
|
+
},
|
|
3014
|
+
{
|
|
3015
|
+
type: "command",
|
|
3016
|
+
command: `node ${adapter} codex Stop ${hookDir}/iteration-check.sh"`,
|
|
3017
|
+
timeout: 5,
|
|
3018
|
+
},
|
|
3019
|
+
{
|
|
3020
|
+
type: "command",
|
|
3021
|
+
command: `node ${adapter} codex Stop ${hookDir}/engine-health.sh" summary`,
|
|
3022
|
+
timeout: 8,
|
|
3023
|
+
},
|
|
3024
|
+
{
|
|
3025
|
+
type: "command",
|
|
3026
|
+
command: `node ${adapter} codex Stop ${hookDir}/clock.sh"`,
|
|
3027
|
+
timeout: 2,
|
|
2460
3028
|
},
|
|
2461
3029
|
],
|
|
2462
3030
|
},
|
|
@@ -2505,7 +3073,13 @@ function generateGeminiHooks() {
|
|
|
2505
3073
|
name: "mover-engine-protection",
|
|
2506
3074
|
type: "command",
|
|
2507
3075
|
command: `node ${adapter} gemini BeforeTool ${hookDir}/engine-protection.sh"`,
|
|
2508
|
-
|
|
3076
|
+
// Packet #2 rider (dev/plan.md T-PACKET2-RIDER-1): approval wait
|
|
3077
|
+
// budget, mirrors the Codex PreToolUse engine-protection entry.
|
|
3078
|
+
// A ceiling, not a delay: the adapter answers in milliseconds
|
|
3079
|
+
// except during a real dashboard-approval wait (MOVER_STUDIO_RUN=1
|
|
3080
|
+
// + live daemon), where it blocks up to 45s for a human yes/no
|
|
3081
|
+
// before failing closed to deny. Gemini timeouts are milliseconds.
|
|
3082
|
+
timeout: 60000,
|
|
2509
3083
|
},
|
|
2510
3084
|
],
|
|
2511
3085
|
},
|
|
@@ -2550,64 +3124,121 @@ function generateGeminiHooks() {
|
|
|
2550
3124
|
}
|
|
2551
3125
|
|
|
2552
3126
|
function generateClaudeSettings() {
|
|
3127
|
+
// v4.7.9 (S3 ship-blocker): resolve absolute, forward-slash hook paths at
|
|
3128
|
+
// install time instead of the literal `$HOME`. On Windows, Claude Code runs
|
|
3129
|
+
// Stop/PreToolUse hooks through cmd.exe, which does NOT expand `$HOME` — so
|
|
3130
|
+
// `bash "$HOME/.claude/hooks/X.sh"` resolved to a nonexistent literal path
|
|
3131
|
+
// and EVERY guardrail hook (engine-protection, git-safety, session-start…)
|
|
3132
|
+
// was silently dead on Windows. This is the SAME fix already proven in
|
|
3133
|
+
// generateCodexHooks/generateGeminiHooks (see comment above generateCodexHooks):
|
|
3134
|
+
// forward slashes work for `bash` on macOS/Linux/Windows, and an absolute
|
|
3135
|
+
// path removes the cmd.exe `$HOME` dependency. The settings-merge dedup
|
|
3136
|
+
// (basename-based, see line ~3372) is unaffected because it compares the
|
|
3137
|
+
// hook script basename, which is identical for `$HOME/...` and absolute paths.
|
|
3138
|
+
const home = os.homedir();
|
|
3139
|
+
const fwd = (p) => p.split(path.sep).join("/");
|
|
3140
|
+
const hooksRoot = fwd(path.join(home, ".claude", "hooks"));
|
|
2553
3141
|
return JSON.stringify(
|
|
2554
3142
|
{
|
|
2555
3143
|
hooks: {
|
|
2556
|
-
|
|
3144
|
+
// ── SessionStart ── full P0-P5 load on startup|clear|resume, volatile reload on
|
|
3145
|
+
// compact. engine-health runs AFTER session-start and self-dedups: on `full`,
|
|
3146
|
+
// session-start.sh emits the staleness summary INLINE and drops a fresh
|
|
3147
|
+
// `mover_health_emitted` marker, so the trailing engine-health.sh exits 0; on
|
|
3148
|
+
// compact/precompact there is no fresh full-marker so it emits. (See the dedup
|
|
3149
|
+
// guard in engine-health.sh — added with the inline emission in 95ed8b40.)
|
|
3150
|
+
SessionStart: [
|
|
2557
3151
|
{
|
|
3152
|
+
matcher: "startup|clear|resume",
|
|
3153
|
+
hooks: [
|
|
3154
|
+
{ type: "command", command: `bash "${hooksRoot}/session-start.sh" full`, timeout: 30 },
|
|
3155
|
+
{ type: "command", command: `bash "${hooksRoot}/engine-health.sh" full`, timeout: 20 },
|
|
3156
|
+
],
|
|
3157
|
+
},
|
|
3158
|
+
{
|
|
3159
|
+
matcher: "compact",
|
|
2558
3160
|
hooks: [
|
|
2559
|
-
{ type: "command", command:
|
|
2560
|
-
{ type: "command", command:
|
|
2561
|
-
{ type: "command", command: 'bash "$HOME/.claude/hooks/context-staleness.sh"', timeout: 5 },
|
|
3161
|
+
{ type: "command", command: `bash "${hooksRoot}/session-start.sh" volatile`, timeout: 15 },
|
|
3162
|
+
{ type: "command", command: `bash "${hooksRoot}/engine-health.sh" full`, timeout: 20 },
|
|
2562
3163
|
],
|
|
2563
3164
|
},
|
|
2564
3165
|
],
|
|
3166
|
+
// ── UserPromptSubmit ── per-prompt anti-drift enforcement (the [MOVER] status line).
|
|
3167
|
+
// memory-recall.sh / skill-recall.sh are DELIBERATELY NOT wired here: the memory
|
|
3168
|
+
// & recall engine ships DORMANT for new installs (opt-in). Do not "fix" this by
|
|
3169
|
+
// adding them — that absence is intentional, unlike user-prompt-enforce.sh which
|
|
3170
|
+
// was an oversight this generator missed when v4.7.8 added it (wired for Codex +
|
|
3171
|
+
// Gemini but not Claude). Reconciled in v4.8.0.
|
|
3172
|
+
UserPromptSubmit: [
|
|
3173
|
+
{
|
|
3174
|
+
hooks: [{ type: "command", command: `bash "${hooksRoot}/user-prompt-enforce.sh"`, timeout: 5 }],
|
|
3175
|
+
},
|
|
3176
|
+
],
|
|
2565
3177
|
PreToolUse: [
|
|
2566
3178
|
{
|
|
2567
3179
|
matcher: "Write|Edit",
|
|
2568
|
-
hooks: [{ type: "command", command:
|
|
3180
|
+
hooks: [{ type: "command", command: `bash "${hooksRoot}/engine-protection.sh"`, timeout: 5 }],
|
|
2569
3181
|
},
|
|
2570
3182
|
{
|
|
2571
3183
|
matcher: "Bash",
|
|
2572
|
-
hooks: [{ type: "command", command:
|
|
3184
|
+
hooks: [{ type: "command", command: `bash "${hooksRoot}/git-safety.sh"`, timeout: 5 }],
|
|
2573
3185
|
},
|
|
2574
3186
|
],
|
|
2575
3187
|
PostToolUse: [
|
|
2576
3188
|
{
|
|
2577
3189
|
matcher: "Write|Edit",
|
|
2578
|
-
hooks: [
|
|
3190
|
+
hooks: [
|
|
3191
|
+
{ type: "command", command: `bash "${hooksRoot}/plan-sync-reminder.sh"`, timeout: 5 },
|
|
3192
|
+
{ type: "command", command: `bash "${hooksRoot}/engine-auto-commit.sh"`, timeout: 10 },
|
|
3193
|
+
],
|
|
2579
3194
|
},
|
|
2580
3195
|
],
|
|
2581
|
-
|
|
3196
|
+
Stop: [
|
|
2582
3197
|
{
|
|
2583
|
-
hooks: [
|
|
3198
|
+
hooks: [
|
|
3199
|
+
{ type: "command", command: `bash "${hooksRoot}/session-log-reminder.sh"`, timeout: 10 },
|
|
3200
|
+
{ type: "command", command: `bash "${hooksRoot}/dirty-tree-guard.sh"`, timeout: 10 },
|
|
3201
|
+
{ type: "command", command: `bash "${hooksRoot}/context-staleness.sh"`, timeout: 10 },
|
|
3202
|
+
{ type: "command", command: `bash "${hooksRoot}/iteration-check.sh"`, timeout: 10 },
|
|
3203
|
+
{ type: "command", command: `bash "${hooksRoot}/engine-health.sh" summary`, timeout: 10 },
|
|
3204
|
+
{ type: "command", command: `bash "${hooksRoot}/clock.sh"`, timeout: 2 },
|
|
3205
|
+
],
|
|
2584
3206
|
},
|
|
2585
3207
|
],
|
|
2586
|
-
|
|
3208
|
+
SubagentStart: [
|
|
2587
3209
|
{
|
|
2588
|
-
hooks: [{ type: "command", command:
|
|
3210
|
+
hooks: [{ type: "command", command: `bash "${hooksRoot}/subagent-context.sh"`, timeout: 5 }],
|
|
2589
3211
|
},
|
|
2590
3212
|
],
|
|
2591
|
-
|
|
3213
|
+
SubagentStop: [
|
|
2592
3214
|
{
|
|
2593
|
-
|
|
2594
|
-
hooks: [{ type: "command", command: 'bash "$HOME/.claude/hooks/engine-permission-request.sh"', timeout: 5 }],
|
|
3215
|
+
hooks: [{ type: "command", command: `bash "${hooksRoot}/subagent-validate.sh"`, timeout: 5 }],
|
|
2595
3216
|
},
|
|
2596
3217
|
],
|
|
2597
|
-
|
|
3218
|
+
PreCompact: [
|
|
2598
3219
|
{
|
|
2599
|
-
|
|
2600
|
-
|
|
3220
|
+
hooks: [
|
|
3221
|
+
{ type: "command", command: `bash "${hooksRoot}/pre-compact-backup.sh"`, timeout: 30 },
|
|
3222
|
+
{ type: "command", command: `bash "${hooksRoot}/engine-health.sh" full`, timeout: 10 },
|
|
3223
|
+
],
|
|
2601
3224
|
},
|
|
3225
|
+
],
|
|
3226
|
+
// SessionEnd: memory-index-session.js DELIBERATELY omitted (dormant memory engine).
|
|
3227
|
+
SessionEnd: [
|
|
2602
3228
|
{
|
|
2603
|
-
|
|
2604
|
-
|
|
3229
|
+
hooks: [{ type: "command", command: `bash "${hooksRoot}/session-end.sh"`, timeout: 10 }],
|
|
3230
|
+
},
|
|
3231
|
+
],
|
|
3232
|
+
PermissionRequest: [
|
|
3233
|
+
{
|
|
3234
|
+
matcher: "Write|Edit",
|
|
3235
|
+
hooks: [{ type: "command", command: `bash "${hooksRoot}/engine-permission-request.sh"`, timeout: 5 }],
|
|
2605
3236
|
},
|
|
2606
3237
|
],
|
|
2607
3238
|
ConfigChange: [
|
|
2608
3239
|
{
|
|
2609
3240
|
matcher: "user_settings",
|
|
2610
|
-
hooks: [{ type: "command", command:
|
|
3241
|
+
hooks: [{ type: "command", command: `bash "${hooksRoot}/config-guard.sh"`, timeout: 5 }],
|
|
2611
3242
|
},
|
|
2612
3243
|
],
|
|
2613
3244
|
},
|
|
@@ -2883,6 +3514,70 @@ function installTemplateFiles(bundleDir, vaultPath) {
|
|
|
2883
3514
|
return { count, hashes };
|
|
2884
3515
|
}
|
|
2885
3516
|
|
|
3517
|
+
// S1: install a single Markdown workflow to a Claude-style destination with the
|
|
3518
|
+
// config-first vault-resolution transform applied. Claude Code normally
|
|
3519
|
+
// hard-links workflows so editing src/ propagates live to ~/.claude/commands,
|
|
3520
|
+
// but the bare-git-rev-parse Pre-Flight block (see fixVaultResolution) must be
|
|
3521
|
+
// rewritten to a config-first resolver, and we MUST NOT mutate the shared inode.
|
|
3522
|
+
// So: if the body contains the bare block, write a transformed COPY (live-edit
|
|
3523
|
+
// hard-link is traded for a correct Pre-Flight on those files only); every other
|
|
3524
|
+
// workflow keeps the hard link. The transform is idempotent, so re-running is
|
|
3525
|
+
// safe. Shared by the fresh-install path (installWorkflows) AND the /update path
|
|
3526
|
+
// (direct update.md sync + smart-update loop) so updated workflows get the same
|
|
3527
|
+
// correct Pre-Flight as fresh installs.
|
|
3528
|
+
//
|
|
3529
|
+
// `presetContent` (optional): write/transform THIS string instead of reading
|
|
3530
|
+
// srcPath from disk — used by the smart-update conflict path, where the body to
|
|
3531
|
+
// install is an in-memory auto-merge result that may differ from the source file.
|
|
3532
|
+
//
|
|
3533
|
+
// Gap-3 fail-loud contract: if the body MATCHED the bare block (i.e. it NEEDS
|
|
3534
|
+
// the transform) we MUST emit the corrected copy. If reading/transforming throws
|
|
3535
|
+
// for a body that needs the fix, we DO NOT silently fall back to linkOrCopy
|
|
3536
|
+
// (which would ship the known-broken Pre-Flight) — the error propagates. Bodies
|
|
3537
|
+
// that don't match fall through safely: return false so the caller linkOrCopies.
|
|
3538
|
+
//
|
|
3539
|
+
// Returns true when a transformed copy was written; false when the file does not
|
|
3540
|
+
// need the transform and should be hard-linked/copied verbatim by the caller.
|
|
3541
|
+
function writeWorkflowTransformed(srcPath, destPath, presetContent) {
|
|
3542
|
+
let raw;
|
|
3543
|
+
if (presetContent !== undefined) {
|
|
3544
|
+
raw = presetContent;
|
|
3545
|
+
} else {
|
|
3546
|
+
// A read failure on a real source file is a hard error: we cannot prove the
|
|
3547
|
+
// file doesn't need the transform, so we must not silently linkOrCopy it.
|
|
3548
|
+
raw = fs.readFileSync(srcPath, "utf8");
|
|
3549
|
+
}
|
|
3550
|
+
let fixed;
|
|
3551
|
+
try {
|
|
3552
|
+
fixed = fixVaultResolution(raw);
|
|
3553
|
+
} catch (e) {
|
|
3554
|
+
// Transform itself threw. If the body needs the fix, fail loud; else no-op.
|
|
3555
|
+
if (workflowNeedsVaultFix(raw)) {
|
|
3556
|
+
throw new Error(`Vault-resolution transform failed for ${path.basename(destPath)}: ${e.message}`);
|
|
3557
|
+
}
|
|
3558
|
+
return false;
|
|
3559
|
+
}
|
|
3560
|
+
if (fixed === raw) {
|
|
3561
|
+
// No bare block (or already fixed). If preset content was supplied we still
|
|
3562
|
+
// own the write; otherwise the caller hard-links/copies the source verbatim.
|
|
3563
|
+
if (presetContent !== undefined) {
|
|
3564
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
3565
|
+
fs.writeFileSync(destPath, fixed, "utf8");
|
|
3566
|
+
return true;
|
|
3567
|
+
}
|
|
3568
|
+
return false;
|
|
3569
|
+
}
|
|
3570
|
+
// Defensive: if the transform claims to have changed the body but the bare
|
|
3571
|
+
// block somehow survives, refuse to ship it rather than emit a broken file.
|
|
3572
|
+
if (workflowNeedsVaultFix(fixed)) {
|
|
3573
|
+
throw new Error(`Vault-resolution transform did not remove the bare git-rev-parse block in ${path.basename(destPath)}`);
|
|
3574
|
+
}
|
|
3575
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
3576
|
+
if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
|
|
3577
|
+
fs.writeFileSync(destPath, fixed, "utf8");
|
|
3578
|
+
return true;
|
|
3579
|
+
}
|
|
3580
|
+
|
|
2886
3581
|
function installWorkflows(bundleDir, destDir, selectedWorkflows) {
|
|
2887
3582
|
const srcDir = path.join(bundleDir, "src", "workflows");
|
|
2888
3583
|
if (!fs.existsSync(srcDir)) return 0;
|
|
@@ -2892,8 +3587,14 @@ function installWorkflows(bundleDir, destDir, selectedWorkflows) {
|
|
|
2892
3587
|
let count = 0;
|
|
2893
3588
|
for (const file of srcFiles) {
|
|
2894
3589
|
if (selectedWorkflows && !selectedWorkflows.has(file)) continue;
|
|
2895
|
-
|
|
2896
|
-
|
|
3590
|
+
const srcPath = path.join(srcDir, file);
|
|
3591
|
+
const destPath = path.join(destDir, file);
|
|
3592
|
+
// Gap-3: writeWorkflowTransformed throws if a workflow that NEEDS the
|
|
3593
|
+
// Pre-Flight fix fails to transform — we surface that instead of shipping
|
|
3594
|
+
// the broken source via linkOrCopy. Only unmatched workflows fall through.
|
|
3595
|
+
const wrote = writeWorkflowTransformed(srcPath, destPath);
|
|
3596
|
+
if (wrote || linkOrCopy(srcPath, destPath)) {
|
|
3597
|
+
savePristineCopy("workflows", srcPath);
|
|
2897
3598
|
count++;
|
|
2898
3599
|
}
|
|
2899
3600
|
}
|
|
@@ -3059,6 +3760,49 @@ function parseWorkflowMd(filePath) {
|
|
|
3059
3760
|
return { name, description, body, filePath };
|
|
3060
3761
|
}
|
|
3061
3762
|
|
|
3763
|
+
// v4.7.9 (S1 ship-blocker): workflow Pre-Flight blocks resolve the vault root
|
|
3764
|
+
// with a bare `git rev-parse --show-toplevel`. A LIVE vault is NOT a git repo,
|
|
3765
|
+
// so that command exits non-zero and the agent never learns VAULT_ROOT — every
|
|
3766
|
+
// subsequent `{VAULT_ROOT}/...` read silently fails. The canonical resolution
|
|
3767
|
+
// (mirrored from resolveVaultPath) reads ~/.mover/config.json `vaultPath`
|
|
3768
|
+
// FIRST, then falls back to git, then walks up for 02_Areas/Engine. This
|
|
3769
|
+
// transform rewrites the bare fenced block at INSTALL time so we never mutate
|
|
3770
|
+
// the shared-inode source .md (Claude path uses copy-with-transform; the
|
|
3771
|
+
// converted agents already write copies). Idempotent: returns body unchanged
|
|
3772
|
+
// if the bare block isn't present (already fixed, or a workflow that doesn't
|
|
3773
|
+
// resolve the vault this way).
|
|
3774
|
+
const VAULT_RESOLVE_SNIPPET = [
|
|
3775
|
+
"```bash",
|
|
3776
|
+
"# Resolve VAULT_ROOT — config first (a live vault is NOT a git repo).",
|
|
3777
|
+
'VAULT_ROOT="$(node -e \'try{console.log(JSON.parse(require("fs").readFileSync(require("os").homedir()+"/.mover/config.json","utf8")).vaultPath||"")}catch(e){}\' 2>/dev/null)"',
|
|
3778
|
+
'[ -z "$VAULT_ROOT" ] && VAULT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"',
|
|
3779
|
+
'if [ -z "$VAULT_ROOT" ]; then d="$PWD"; while [ "$d" != "/" ]; do [ -d "$d/02_Areas/Engine" ] && VAULT_ROOT="$d" && break; d="$(dirname "$d")"; done; fi',
|
|
3780
|
+
'echo "$VAULT_ROOT"',
|
|
3781
|
+
"```",
|
|
3782
|
+
].join("\n");
|
|
3783
|
+
|
|
3784
|
+
// Match the exact bare Pre-Flight block: ```bash\n git rev-parse --show-toplevel \n```
|
|
3785
|
+
// Tolerate surrounding blank lines / trailing whitespace inside the fence. Built
|
|
3786
|
+
// fresh on each call (no shared lastIndex) so .test() is stateless across callers.
|
|
3787
|
+
function bareVaultBlockRe() {
|
|
3788
|
+
return /```bash\s*\n[ \t]*git rev-parse --show-toplevel[ \t]*\n```/;
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
// True when `body` still ships the bare git-rev-parse Pre-Flight block — i.e. it
|
|
3792
|
+
// NEEDS the config-first rewrite. Used by the install paths to decide between a
|
|
3793
|
+
// transformed copy and a verbatim hard-link, and to enforce the Gap-3 fail-loud
|
|
3794
|
+
// contract (a body that needs the fix must never ship untransformed).
|
|
3795
|
+
function workflowNeedsVaultFix(body) {
|
|
3796
|
+
if (typeof body !== "string") return false;
|
|
3797
|
+
return bareVaultBlockRe().test(body);
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
function fixVaultResolution(body) {
|
|
3801
|
+
if (typeof body !== "string") return body;
|
|
3802
|
+
if (!workflowNeedsVaultFix(body)) return body;
|
|
3803
|
+
return body.replace(bareVaultBlockRe(), VAULT_RESOLVE_SNIPPET);
|
|
3804
|
+
}
|
|
3805
|
+
|
|
3062
3806
|
// Gemini CLI: .md → .toml
|
|
3063
3807
|
function mdToToml(wf) {
|
|
3064
3808
|
const desc = wf.description.replace(/"/g, '\\"');
|
|
@@ -3125,6 +3869,10 @@ function installWorkflowsConverted(bundleDir, destDir, format, selectedWorkflows
|
|
|
3125
3869
|
for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith(".md"))) {
|
|
3126
3870
|
if (selectedWorkflows && !selectedWorkflows.has(file)) continue;
|
|
3127
3871
|
const wf = parseWorkflowMd(path.join(srcDir, file));
|
|
3872
|
+
// S1: config-first vault resolution. Safe — these agents always get a
|
|
3873
|
+
// freshly-written copy (no shared inode), so transforming the body here
|
|
3874
|
+
// only changes the emitted file, never the source .md.
|
|
3875
|
+
wf.body = fixVaultResolution(wf.body);
|
|
3128
3876
|
const converted = conv.fn(wf);
|
|
3129
3877
|
if (conv.dir) {
|
|
3130
3878
|
// Directory-based output (e.g., Codex: morning/SKILL.md)
|
|
@@ -3196,7 +3944,22 @@ function installSkillPacks(bundleDir, destDir, selectedCategories) {
|
|
|
3196
3944
|
continue;
|
|
3197
3945
|
}
|
|
3198
3946
|
|
|
3199
|
-
|
|
3947
|
+
// terra CLI F4: only recursively delete a same-named dir that Mover OS OWNS
|
|
3948
|
+
// (its `.mover-installed` stamp, or a manifest entry). A user's custom skill
|
|
3949
|
+
// that happens to share a bundled name must NOT be erased — park it at
|
|
3950
|
+
// `.user-backup` first; if it can't be moved safely, skip rather than clobber.
|
|
3951
|
+
if (fs.existsSync(dest)) {
|
|
3952
|
+
const owned = fs.existsSync(path.join(dest, ".mover-installed")) || !!manifest.skills[skill.name];
|
|
3953
|
+
if (owned) {
|
|
3954
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
3955
|
+
} else {
|
|
3956
|
+
const bak = `${dest}.user-backup`;
|
|
3957
|
+
try {
|
|
3958
|
+
if (fs.existsSync(bak)) fs.rmSync(bak, { recursive: true, force: true });
|
|
3959
|
+
fs.renameSync(dest, bak);
|
|
3960
|
+
} catch { continue; } // could not preserve the user's skill safely — do NOT delete it
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3200
3963
|
copyDirRecursive(skill.path, dest);
|
|
3201
3964
|
// v4.7.6: stamp file proves Mover OS owns this skill. Used by orphan
|
|
3202
3965
|
// cleanup below — only stamped skills are deletable on update. User-
|
|
@@ -3261,6 +4024,9 @@ function installHooksForClaude(bundleDir, vaultPath) {
|
|
|
3261
4024
|
// Read, strip \r (CRLF→LF), write — prevents "command not found" on macOS/Linux
|
|
3262
4025
|
const content = fs.readFileSync(path.join(hooksSrc, file), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
3263
4026
|
fs.writeFileSync(dst, content, { mode: 0o755 });
|
|
4027
|
+
// writeFileSync's mode only applies when the file is CREATED — a hook
|
|
4028
|
+
// already on disk at 644 stayed 644 and was silently dead (R5-A).
|
|
4029
|
+
try { fs.chmodSync(dst, 0o755); } catch {}
|
|
3264
4030
|
savePristineCopy("hooks", path.join(hooksSrc, file));
|
|
3265
4031
|
count++;
|
|
3266
4032
|
}
|
|
@@ -3279,23 +4045,105 @@ function installHooksForClaude(bundleDir, vaultPath) {
|
|
|
3279
4045
|
try {
|
|
3280
4046
|
const existing = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
3281
4047
|
if (!existing.hooks) existing.hooks = {};
|
|
3282
|
-
|
|
4048
|
+
|
|
4049
|
+
// Gap-3 (S3 update path): repair existing Windows-broken hook commands.
|
|
4050
|
+
// A pre-v4.7.9 install wrote `bash "$HOME/.claude/hooks/X.sh" [args]` (the
|
|
4051
|
+
// form cmd.exe can't expand). The basename-only dedup below treats that as
|
|
4052
|
+
// "already present" and would neither add the corrected command NOR fix the
|
|
4053
|
+
// old one — leaving the hook dead on Windows forever. So FIRST rewrite any
|
|
4054
|
+
// existing command that targets one of OUR hook scripts via a $HOME/~/
|
|
4055
|
+
// relative path into the exact absolute-forward-slash form generateClaudeSettings
|
|
4056
|
+
// now emits (matched on the script + trailing-arg tail so the two
|
|
4057
|
+
// session-start variants map to their correct full/volatile replacements).
|
|
4058
|
+
// After rewriting in place, the dedup sees the new form and won't duplicate.
|
|
4059
|
+
const canonicalCmds = [];
|
|
4060
|
+
for (const entries of Object.values(newHooks)) {
|
|
4061
|
+
for (const grp of entries) for (const h of grp.hooks) canonicalCmds.push(h.command);
|
|
4062
|
+
}
|
|
4063
|
+
// Normalized tail signature: `<script>.sh<rest>` with quotes stripped and
|
|
4064
|
+
// backslashes normalized, so a QUOTED canonical command and an UNQUOTED (or
|
|
4065
|
+
// backslash-pathed) old command map to the SAME key. `<rest>` keeps the
|
|
4066
|
+
// trailing arg (` full` / ` volatile`) so the two session-start variants
|
|
4067
|
+
// stay distinct. Matching on the basename (after the last slash, either
|
|
4068
|
+
// direction) means $HOME / %USERPROFILE% / ~ / absolute prefixes all collapse
|
|
4069
|
+
// to the same signature.
|
|
4070
|
+
const tailSig = (cmd) => {
|
|
4071
|
+
if (typeof cmd !== "string") return null;
|
|
4072
|
+
const norm = cmd.replace(/["']/g, "").replace(/\\/g, "/");
|
|
4073
|
+
const m = norm.match(/([^\/\s]+\.sh)(.*)$/);
|
|
4074
|
+
return m ? (m[1] + m[2]).trim() : null;
|
|
4075
|
+
};
|
|
4076
|
+
const canonicalByTail = new Map();
|
|
4077
|
+
for (const cmd of canonicalCmds) {
|
|
4078
|
+
const t = tailSig(cmd);
|
|
4079
|
+
if (t) canonicalByTail.set(t, cmd);
|
|
4080
|
+
}
|
|
4081
|
+
// An "old form" is a command pointing at a .claude/hooks/<script>.sh via a
|
|
4082
|
+
// HOME-relative prefix ($HOME, %USERPROFILE%, or ~), in either slash
|
|
4083
|
+
// direction, quoted or not. Absolute paths and non-Mover commands are NOT
|
|
4084
|
+
// old forms (so they're left untouched).
|
|
4085
|
+
const isOldHomeForm = (cmd) =>
|
|
4086
|
+
typeof cmd === "string" &&
|
|
4087
|
+
/\.claude[\\/]+hooks[\\/]+[^"\\/]*\.sh/.test(cmd) &&
|
|
4088
|
+
/(\$HOME|\$\{HOME\}|%USERPROFILE%|(^|["'\s])~[\\/])/.test(cmd);
|
|
4089
|
+
const rewriteCmd = (cmd) => {
|
|
4090
|
+
if (!isOldHomeForm(cmd)) return cmd;
|
|
4091
|
+
const t = tailSig(cmd);
|
|
4092
|
+
const canon = t && canonicalByTail.get(t);
|
|
4093
|
+
return canon || cmd; // only rewrite scripts we actually own/emit
|
|
4094
|
+
};
|
|
4095
|
+
for (const event of Object.keys(existing.hooks)) {
|
|
4096
|
+
const groups = existing.hooks[event];
|
|
4097
|
+
if (!Array.isArray(groups)) continue;
|
|
4098
|
+
for (const grp of groups) {
|
|
4099
|
+
if (!grp || !Array.isArray(grp.hooks)) continue;
|
|
4100
|
+
for (const h of grp.hooks) {
|
|
4101
|
+
if (h && typeof h.command === "string") h.command = rewriteCmd(h.command);
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
// Merge each hook event (Stop, PreToolUse, PreCompact).
|
|
4107
|
+
// Dedup runs AFTER the old-form rewrite above, so a repaired command is now
|
|
4108
|
+
// seen as present and is not re-added.
|
|
4109
|
+
//
|
|
4110
|
+
// v4.7.9: HOOK-LEVEL dedup keyed on a signature = script basename + trailing
|
|
4111
|
+
// args (quotes stripped, slash direction normalized). Three fixes over the
|
|
4112
|
+
// old basename-only check:
|
|
4113
|
+
// • Dedup per individual hook, not per group: a new group is appended with
|
|
4114
|
+
// only the hooks the event is MISSING. The old code (and an interim
|
|
4115
|
+
// group-level version) re-added an entire multi-hook group — e.g. Stop's
|
|
4116
|
+
// 3 hooks — whenever any single hook was absent, duplicating the ones
|
|
4117
|
+
// already present.
|
|
4118
|
+
// • Include the trailing arg in the signature so `session-start.sh full`
|
|
4119
|
+
// and `session-start.sh volatile` are distinct (multi-group event safe).
|
|
4120
|
+
// • Strip ALL quotes from both sides so `…session-start.sh" full` matches
|
|
4121
|
+
// `session-start.sh full` (the old single-quote .replace left the needle
|
|
4122
|
+
// unmatchable → duplicates).
|
|
4123
|
+
const cmdSig = (cmd) => {
|
|
4124
|
+
if (typeof cmd !== "string") return "";
|
|
4125
|
+
const m = cmd.match(/([^\\/"]+\.sh)("?.*)$/);
|
|
4126
|
+
const tail = m ? m[1] + m[2] : cmd;
|
|
4127
|
+
return tail.replace(/["']/g, "").replace(/\\/g, "/").trim();
|
|
4128
|
+
};
|
|
3283
4129
|
for (const [event, entries] of Object.entries(newHooks)) {
|
|
3284
4130
|
if (!existing.hooks[event]) {
|
|
3285
4131
|
existing.hooks[event] = entries;
|
|
3286
4132
|
} else {
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
(
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
4133
|
+
const existingSigs = new Set();
|
|
4134
|
+
for (const grp of existing.hooks[event]) {
|
|
4135
|
+
if (grp && Array.isArray(grp.hooks)) for (const h of grp.hooks) existingSigs.add(cmdSig(h.command));
|
|
4136
|
+
}
|
|
4137
|
+
for (const grp of entries) {
|
|
4138
|
+
// Keep only hooks whose signature isn't already registered for this event.
|
|
4139
|
+
const missing = (grp.hooks || []).filter((h) => !existingSigs.has(cmdSig(h.command)));
|
|
4140
|
+
if (missing.length === 0) continue; // every hook in this group already present
|
|
4141
|
+
// Append a group carrying only the missing hooks (preserve matcher if any).
|
|
4142
|
+
const toAdd = grp.matcher !== undefined
|
|
4143
|
+
? { matcher: grp.matcher, hooks: missing }
|
|
4144
|
+
: { hooks: missing };
|
|
4145
|
+
existing.hooks[event].push(toAdd);
|
|
4146
|
+
for (const h of missing) existingSigs.add(cmdSig(h.command));
|
|
3299
4147
|
}
|
|
3300
4148
|
}
|
|
3301
4149
|
}
|
|
@@ -3339,16 +4187,40 @@ function installHooksForClaude(bundleDir, vaultPath) {
|
|
|
3339
4187
|
return count;
|
|
3340
4188
|
}
|
|
3341
4189
|
|
|
3342
|
-
// ─── Multi-agent hook installer (v4.7.5)
|
|
3343
|
-
// Copies the MVP hook set
|
|
3344
|
-
//
|
|
4190
|
+
// ─── Multi-agent hook installer (v4.7.5 + v4.7.8 expansion) ─────────────────
|
|
4191
|
+
// Copies the MVP hook set + adapter + shared lib to the agent's hook directory
|
|
4192
|
+
// and writes its hook config. Used by Codex and Gemini.
|
|
4193
|
+
//
|
|
4194
|
+
// v4.7.8: expanded from 5 → 12 hook scripts to bring Codex to Claude Code
|
|
4195
|
+
// parity on UserPromptSubmit (per-prompt anti-drift), PostToolUse
|
|
4196
|
+
// engine-auto-commit, and Stop multi-script coverage (session-log-reminder,
|
|
4197
|
+
// context-staleness, iteration-check, engine-health, clock).
|
|
4198
|
+
//
|
|
4199
|
+
// PermissionRequest deferred: engine-permission-request.sh emits a nested
|
|
4200
|
+
// decision shape that mover-hook-adapter.js:isValidOutput rejects. Wiring it
|
|
4201
|
+
// would silently drop the decision. Adapter extension required first.
|
|
3345
4202
|
const MVP_HOOK_SCRIPTS = [
|
|
4203
|
+
// SessionStart (2)
|
|
3346
4204
|
"session-start.sh",
|
|
4205
|
+
"engine-health.sh", // also fires under Stop
|
|
4206
|
+
// UserPromptSubmit (1) — v4.7.8 NEW: per-prompt enforcement injection
|
|
4207
|
+
"user-prompt-enforce.sh",
|
|
4208
|
+
// PreToolUse (2)
|
|
3347
4209
|
"engine-protection.sh",
|
|
3348
4210
|
"git-safety.sh",
|
|
4211
|
+
// PermissionRequest (1) — v4.7.8 NEW: nested decision-shape via adapter
|
|
4212
|
+
"engine-permission-request.sh",
|
|
4213
|
+
// PostToolUse (2)
|
|
3349
4214
|
"plan-sync-reminder.sh",
|
|
4215
|
+
"engine-auto-commit.sh", // v4.7.8 NEW: micro-commit Engine edits
|
|
4216
|
+
// Stop (6) — v4.7.8 NEW: session-log-reminder, context-staleness, iteration-check, clock
|
|
3350
4217
|
"dirty-tree-guard.sh",
|
|
3351
|
-
"
|
|
4218
|
+
"session-log-reminder.sh",
|
|
4219
|
+
"context-staleness.sh",
|
|
4220
|
+
"iteration-check.sh",
|
|
4221
|
+
"clock.sh",
|
|
4222
|
+
// Shared library (sourced by the others)
|
|
4223
|
+
"mover-lib.sh",
|
|
3352
4224
|
];
|
|
3353
4225
|
|
|
3354
4226
|
// Section-aware TOML upsert. Sets [section] key = value, preserving comments,
|
|
@@ -3435,6 +4307,9 @@ function copyMvpHooks(bundleDir, destDir) {
|
|
|
3435
4307
|
.replace(/\r\n/g, "\n")
|
|
3436
4308
|
.replace(/\r/g, "\n");
|
|
3437
4309
|
fs.writeFileSync(dst, content, { mode: 0o755 });
|
|
4310
|
+
// mode only applies on create — chmod so a pre-existing 644 copy
|
|
4311
|
+
// becomes executable again (R5-A exec-bit fix).
|
|
4312
|
+
try { fs.chmodSync(dst, 0o755); } catch {}
|
|
3438
4313
|
count++;
|
|
3439
4314
|
}
|
|
3440
4315
|
// Adapter (Node script — copy preserving binary mode)
|
|
@@ -3446,12 +4321,46 @@ function copyMvpHooks(bundleDir, destDir) {
|
|
|
3446
4321
|
.replace(/\r\n/g, "\n")
|
|
3447
4322
|
.replace(/\r/g, "\n");
|
|
3448
4323
|
fs.writeFileSync(dst, content, { mode: 0o755 });
|
|
4324
|
+
try { fs.chmodSync(dst, 0o755); } catch {}
|
|
3449
4325
|
count++;
|
|
3450
4326
|
}
|
|
3451
4327
|
return count;
|
|
3452
4328
|
}
|
|
3453
4329
|
|
|
3454
|
-
function
|
|
4330
|
+
function enableCodexMemoryRecall(bundleDir, vaultPath) {
|
|
4331
|
+
if (!vaultPath) return false;
|
|
4332
|
+
const memoryDir = path.join(os.homedir(), ".mover", "memory");
|
|
4333
|
+
const manifestPath = path.join(memoryDir, "manifest.json");
|
|
4334
|
+
const markerPath = path.join(memoryDir, "recall-enabled");
|
|
4335
|
+
const indexScript = path.join(bundleDir, "src", "dashboard", "lib", "memory-index.js");
|
|
4336
|
+
if (!fs.existsSync(indexScript)) return false;
|
|
4337
|
+
|
|
4338
|
+
const manifestMatchesVault = () => {
|
|
4339
|
+
try {
|
|
4340
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
4341
|
+
return !!manifest.vault && path.resolve(manifest.vault) === path.resolve(vaultPath);
|
|
4342
|
+
} catch { return false; }
|
|
4343
|
+
};
|
|
4344
|
+
|
|
4345
|
+
if (!manifestMatchesVault()) {
|
|
4346
|
+
const result = spawnSync(process.execPath, [indexScript, "--backfill"], {
|
|
4347
|
+
cwd: bundleDir,
|
|
4348
|
+
env: { ...process.env, MOVER_VAULT: vaultPath },
|
|
4349
|
+
stdio: "ignore",
|
|
4350
|
+
timeout: 60000,
|
|
4351
|
+
});
|
|
4352
|
+
if (result.status !== 0 || !manifestMatchesVault()) return false;
|
|
4353
|
+
}
|
|
4354
|
+
|
|
4355
|
+
try {
|
|
4356
|
+
fs.mkdirSync(memoryDir, { recursive: true, mode: 0o700 });
|
|
4357
|
+
fs.writeFileSync(markerPath, "enabled\n", { mode: 0o600 });
|
|
4358
|
+
try { fs.chmodSync(markerPath, 0o600); } catch {}
|
|
4359
|
+
return true;
|
|
4360
|
+
} catch { return false; }
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
function installHooksForCodex(bundleDir, vaultPath) {
|
|
3455
4364
|
const home = os.homedir();
|
|
3456
4365
|
const codexDir = path.join(home, ".codex");
|
|
3457
4366
|
const hooksDst = path.join(codexDir, "hooks");
|
|
@@ -3493,6 +4402,26 @@ function installHooksForCodex(bundleDir) {
|
|
|
3493
4402
|
const configToml = path.join(codexDir, "config.toml");
|
|
3494
4403
|
upsertTomlKey(configToml, "features", "codex_hooks", "true");
|
|
3495
4404
|
|
|
4405
|
+
// Codex delegates recall through its already-registered prompt/Stop hooks. Build the
|
|
4406
|
+
// selected vault's initial index before writing the opt-in marker so a fresh bundle
|
|
4407
|
+
// never enables a permanently silent hook.
|
|
4408
|
+
enableCodexMemoryRecall(bundleDir, vaultPath);
|
|
4409
|
+
|
|
4410
|
+
// v4.7.8: Stash a marker so the post-install summary can show the
|
|
4411
|
+
// one-time "trust hooks in Codex IDE" hint. Codex IDE prompts users to
|
|
4412
|
+
// review/trust newly installed or modified hooks (security feature —
|
|
4413
|
+
// hooks run outside the sandbox). There is NO config-level bypass per
|
|
4414
|
+
// https://developers.openai.com/codex/hooks. CLI uses hooks.json
|
|
4415
|
+
// directly without the trust gate, so Mover OS works fine via CLI.
|
|
4416
|
+
// The trust prompt only affects users running Codex IDE.
|
|
4417
|
+
try {
|
|
4418
|
+
fs.writeFileSync(
|
|
4419
|
+
path.join(codexDir, ".mover-hook-install-stamp"),
|
|
4420
|
+
new Date().toISOString() + "\n",
|
|
4421
|
+
"utf8"
|
|
4422
|
+
);
|
|
4423
|
+
} catch {}
|
|
4424
|
+
|
|
3496
4425
|
return count;
|
|
3497
4426
|
}
|
|
3498
4427
|
|
|
@@ -3618,16 +4547,20 @@ function installClaudeCode(bundleDir, vaultPath, skillOpts) {
|
|
|
3618
4547
|
if (fs.existsSync(statusSrc)) {
|
|
3619
4548
|
fs.copyFileSync(statusSrc, statusDst);
|
|
3620
4549
|
const globalSettings = path.join(home, ".claude", "settings.json");
|
|
4550
|
+
// Absolute forward-slash path, never `~` — cmd.exe on Windows does not
|
|
4551
|
+
// expand tildes, so `node ~/.claude/statusline.js` was silently dead
|
|
4552
|
+
// there (same bug class the hook commands already fixed via fwd()).
|
|
4553
|
+
const statusCmd = `node "${statusDst.replace(/\\/g, "/")}"`;
|
|
3621
4554
|
try {
|
|
3622
4555
|
const settings = fs.existsSync(globalSettings)
|
|
3623
4556
|
? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
|
|
3624
4557
|
: {};
|
|
3625
|
-
settings.statusLine = { type: "command", command:
|
|
4558
|
+
settings.statusLine = { type: "command", command: statusCmd };
|
|
3626
4559
|
fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
|
|
3627
4560
|
} catch {
|
|
3628
4561
|
fs.writeFileSync(
|
|
3629
4562
|
globalSettings,
|
|
3630
|
-
JSON.stringify({ statusLine: { type: "command", command:
|
|
4563
|
+
JSON.stringify({ statusLine: { type: "command", command: statusCmd } }, null, 2),
|
|
3631
4564
|
"utf8"
|
|
3632
4565
|
);
|
|
3633
4566
|
}
|
|
@@ -3724,8 +4657,12 @@ function installCodex(bundleDir, vaultPath, skillOpts) {
|
|
|
3724
4657
|
|
|
3725
4658
|
const codexDir = path.join(home, ".codex");
|
|
3726
4659
|
fs.mkdirSync(codexDir, { recursive: true });
|
|
3727
|
-
// Codex CLI uses AGENTS.md (not instructions.md, not raw Global Rules)
|
|
3728
|
-
|
|
4660
|
+
// Codex CLI uses AGENTS.md (not instructions.md, not raw Global Rules).
|
|
4661
|
+
// A pre-existing AGENTS.md the user wrote is backed up once before the
|
|
4662
|
+
// first overwrite — never silently clobbered (R5-A).
|
|
4663
|
+
const agentsMdPath = path.join(codexDir, "AGENTS.md");
|
|
4664
|
+
backupForeignFile(agentsMdPath);
|
|
4665
|
+
fs.writeFileSync(agentsMdPath, generateAgentsMd(), "utf8");
|
|
3729
4666
|
steps.push("AGENTS.md");
|
|
3730
4667
|
|
|
3731
4668
|
// V5 FIX: In Codex, skills ARE commands — install workflows as SKILL.md dirs
|
|
@@ -3740,7 +4677,7 @@ function installCodex(bundleDir, vaultPath, skillOpts) {
|
|
|
3740
4677
|
|
|
3741
4678
|
// v4.7.5: Native Codex hook support via mover-hook-adapter.js
|
|
3742
4679
|
if (!skillOpts?.skipHooks) {
|
|
3743
|
-
const hkCount = installHooksForCodex(bundleDir);
|
|
4680
|
+
const hkCount = installHooksForCodex(bundleDir, vaultPath);
|
|
3744
4681
|
if (hkCount > 0) steps.push(`${hkCount} hooks`);
|
|
3745
4682
|
}
|
|
3746
4683
|
|
|
@@ -3936,6 +4873,9 @@ function installCopilot(bundleDir, vaultPath, skillOpts) {
|
|
|
3936
4873
|
fs.mkdirSync(ghDir, { recursive: true });
|
|
3937
4874
|
const src = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
|
|
3938
4875
|
if (fs.existsSync(src)) {
|
|
4876
|
+
// copilot-instructions.md is often team-authored and git-tracked —
|
|
4877
|
+
// back a foreign one up before the first overwrite (R5-A).
|
|
4878
|
+
backupForeignFile(path.join(ghDir, "copilot-instructions.md"));
|
|
3939
4879
|
fs.copyFileSync(src, path.join(ghDir, "copilot-instructions.md"));
|
|
3940
4880
|
steps.push("rules");
|
|
3941
4881
|
}
|
|
@@ -3987,6 +4927,7 @@ function installFromRegistry(bundleDir, vaultPath, skillOpts, writtenFiles, id)
|
|
|
3987
4927
|
const sharedKey = reg.sharedRulesFile || id;
|
|
3988
4928
|
if (!writtenFiles || !writtenFiles.has(destPath)) {
|
|
3989
4929
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
4930
|
+
backupForeignFile(destPath);
|
|
3990
4931
|
fs.writeFileSync(destPath, generateAgentsMd(), "utf8");
|
|
3991
4932
|
if (writtenFiles) writtenFiles.add(destPath);
|
|
3992
4933
|
steps.push("AGENTS.md");
|
|
@@ -4003,6 +4944,7 @@ function installFromRegistry(bundleDir, vaultPath, skillOpts, writtenFiles, id)
|
|
|
4003
4944
|
const s5idx = content.indexOf("\n## 5.");
|
|
4004
4945
|
if (s5idx > 0) content = content.substring(0, s5idx);
|
|
4005
4946
|
content = "# CONVENTIONS.md\n\n" + content.replace(/^# Mover OS Global Rules/m, "").trim() + "\n";
|
|
4947
|
+
backupForeignFile(destPath);
|
|
4006
4948
|
fs.writeFileSync(destPath, content, "utf8");
|
|
4007
4949
|
steps.push("CONVENTIONS.md");
|
|
4008
4950
|
}
|
|
@@ -4093,6 +5035,11 @@ function preflight() {
|
|
|
4093
5035
|
// ─── CLI Command Handlers (stubs — implemented progressively) ────────────────
|
|
4094
5036
|
const CLI_HANDLERS = {
|
|
4095
5037
|
pulse: async (opts) => { await cmdPulse(opts); },
|
|
5038
|
+
dashboard: async (opts) => { await cmdPulse(opts); }, // alias — opens browser dashboard
|
|
5039
|
+
open: async (opts) => { await cmdOpen(opts); },
|
|
5040
|
+
shortcut: async (opts) => { await cmdShortcut(opts); },
|
|
5041
|
+
menubar: async (opts) => { await cmdMenubar(opts); },
|
|
5042
|
+
inventory: async (opts) => { cmdInventory(opts); },
|
|
4096
5043
|
// warm removed
|
|
4097
5044
|
capture: async (opts) => { await cmdCapture(opts); },
|
|
4098
5045
|
who: async (opts) => { await cmdWho(opts); },
|
|
@@ -4180,7 +5127,15 @@ async function cmdDoctor(opts) {
|
|
|
4180
5127
|
for (const f of engineFiles) {
|
|
4181
5128
|
if (fs.existsSync(path.join(engineDir, f))) engOk++;
|
|
4182
5129
|
}
|
|
4183
|
-
|
|
5130
|
+
// terra read F1: `engOk >= 4` reported OK even with the two CORE sentinels
|
|
5131
|
+
// (Identity_Prime, Strategy) missing — a broken Engine shown as healthy. OK only
|
|
5132
|
+
// when all present; FAIL if a core file is missing; WARN otherwise.
|
|
5133
|
+
const missingCore = ["Identity_Prime.md", "Strategy.md"].filter((f) => !fs.existsSync(path.join(engineDir, f)));
|
|
5134
|
+
statusLine(
|
|
5135
|
+
engOk === engineFiles.length ? "ok" : missingCore.length ? "fail" : "warn",
|
|
5136
|
+
"Engine files",
|
|
5137
|
+
`${engOk}/${engineFiles.length} present${missingCore.length ? ` — missing core: ${missingCore.join(", ")}` : ""}`
|
|
5138
|
+
);
|
|
4184
5139
|
|
|
4185
5140
|
// Version
|
|
4186
5141
|
const vf = path.join(vault, ".mover-version");
|
|
@@ -4203,26 +5158,41 @@ async function cmdDoctor(opts) {
|
|
|
4203
5158
|
for (const agentId of installedAgents) {
|
|
4204
5159
|
const reg = AGENT_REGISTRY[agentId];
|
|
4205
5160
|
if (!reg) { statusLine("warn", ` ${agentId}`, "unknown agent"); continue; }
|
|
5161
|
+
// terra read F2: status was inferred from ANSI STYLING of the label text
|
|
5162
|
+
// (styled = problem) — fragile and it certified broken installs as green in
|
|
5163
|
+
// non-TTY (no styling). Derive from STRUCTURED booleans instead.
|
|
4206
5164
|
const checks = [];
|
|
4207
|
-
|
|
5165
|
+
let agentOk = true;
|
|
4208
5166
|
if (reg.rules) {
|
|
4209
|
-
const
|
|
4210
|
-
|
|
5167
|
+
const has = fs.existsSync(reg.rules.dest(vault));
|
|
5168
|
+
agentOk = agentOk && has;
|
|
5169
|
+
checks.push(has ? "rules" : dim("rules missing"));
|
|
4211
5170
|
}
|
|
4212
|
-
// Skills
|
|
4213
5171
|
if (reg.skills) {
|
|
4214
5172
|
const sp = reg.skills.dest(vault);
|
|
4215
|
-
const
|
|
4216
|
-
|
|
5173
|
+
const has = fs.existsSync(sp) && fs.readdirSync(sp).length > 0;
|
|
5174
|
+
agentOk = agentOk && has;
|
|
5175
|
+
checks.push(has ? "skills" : dim("no skills"));
|
|
4217
5176
|
}
|
|
4218
|
-
// Commands
|
|
4219
5177
|
if (reg.commands) {
|
|
4220
5178
|
const cp = reg.commands.dest(vault);
|
|
4221
|
-
const
|
|
4222
|
-
|
|
5179
|
+
const has = fs.existsSync(cp) && fs.readdirSync(cp).length > 0;
|
|
5180
|
+
agentOk = agentOk && has;
|
|
5181
|
+
checks.push(has ? "commands" : dim("no commands"));
|
|
5182
|
+
}
|
|
5183
|
+
// terra read F3: hooks were NEVER inspected — a deleted hooks config read as
|
|
5184
|
+
// healthy. Check the agent's hook destination (settings.json for Claude, a
|
|
5185
|
+
// hooks.json for the others).
|
|
5186
|
+
if (reg.hooks) {
|
|
5187
|
+
const hp = reg.hooks.dest ? reg.hooks.dest(vault)
|
|
5188
|
+
: (reg.hooks.type === "claude-settings" ? path.join(home, ".claude", "settings.json") : null);
|
|
5189
|
+
if (hp) {
|
|
5190
|
+
const has = fs.existsSync(hp);
|
|
5191
|
+
agentOk = agentOk && has;
|
|
5192
|
+
checks.push(has ? "hooks" : dim("hooks missing"));
|
|
5193
|
+
}
|
|
4223
5194
|
}
|
|
4224
|
-
|
|
4225
|
-
statusLine(allOk ? "ok" : "warn", ` ${reg.name}`, checks.join(", "));
|
|
5195
|
+
statusLine(agentOk ? "ok" : "warn", ` ${reg.name}`, checks.join(", "));
|
|
4226
5196
|
}
|
|
4227
5197
|
|
|
4228
5198
|
// ── Claude Code skill listing budget (v4.7.7) ────────────────────────
|
|
@@ -4273,6 +5243,138 @@ async function cmdDoctor(opts) {
|
|
|
4273
5243
|
);
|
|
4274
5244
|
}
|
|
4275
5245
|
|
|
5246
|
+
// ── Runtime drift check (sol #7 part 6) ───────────────────────────────
|
|
5247
|
+
// The hook runtime is a COPY (link.sh copies hooks; workflows are hard
|
|
5248
|
+
// links). A copy can silently drift from the bundle after git ops — the
|
|
5249
|
+
// 2026-07-12 live find: runtime session-start.sh was 11 hours stale and
|
|
5250
|
+
// log.md 3 DAYS stale, so recent fixes simply were not running. Detect it
|
|
5251
|
+
// mechanically: content-hash every bundle hook against its runtime copy,
|
|
5252
|
+
// and check workflow hard-link integrity (a broken link is current TODAY
|
|
5253
|
+
// but drifts on the next bundle edit). Dev-checkout concern only: npm-
|
|
5254
|
+
// global installs have no src/ on disk and skip silently.
|
|
5255
|
+
(() => {
|
|
5256
|
+
let bundleDir = null;
|
|
5257
|
+
for (const c of [process.cwd(), path.join(vault, "01_Projects", "Mover OS Bundle"), __dirname]) {
|
|
5258
|
+
if (fs.existsSync(path.join(c, "src", "hooks"))) { bundleDir = c; break; }
|
|
5259
|
+
}
|
|
5260
|
+
if (!bundleDir) return;
|
|
5261
|
+
const sha = (p) => crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex");
|
|
5262
|
+
barLn();
|
|
5263
|
+
barLn(dim(" Runtime sync (bundle -> agent copies):"));
|
|
5264
|
+
|
|
5265
|
+
const hookSrcDir = path.join(bundleDir, "src", "hooks");
|
|
5266
|
+
// Every agent runtime that receives hook COPIES (sol #8 partial
|
|
5267
|
+
// refutation 1: only ~/.claude was inspected, and a DELETED runtime file
|
|
5268
|
+
// was skipped silently — absence is drift too, and Codex/Gemini have
|
|
5269
|
+
// their own copy directories via installHooksForCodex/Gemini).
|
|
5270
|
+
const runtimes = [
|
|
5271
|
+
{ label: "Claude hooks", dir: path.join(home, ".claude", "hooks") },
|
|
5272
|
+
{ label: "Codex hooks", dir: path.join(home, ".codex", "hooks") },
|
|
5273
|
+
{ label: "Gemini hooks", dir: path.join(home, ".gemini", "hooks") },
|
|
5274
|
+
];
|
|
5275
|
+
let anyHookDrift = false;
|
|
5276
|
+
for (const rt of runtimes) {
|
|
5277
|
+
if (!fs.existsSync(rt.dir)) continue; // agent not installed: nothing to drift
|
|
5278
|
+
const runtimeNames = new Set(fs.readdirSync(rt.dir).filter((n) => /\.(sh|js)$/.test(n)));
|
|
5279
|
+
const drifted = [];
|
|
5280
|
+
const missing = [];
|
|
5281
|
+
let checked = 0;
|
|
5282
|
+
for (const name of fs.readdirSync(hookSrcDir)) {
|
|
5283
|
+
if (!/\.(sh|js)$/.test(name)) continue;
|
|
5284
|
+
const dst = path.join(rt.dir, name);
|
|
5285
|
+
if (!runtimeNames.has(name)) continue; // this agent never installs it — but see the deletion check below
|
|
5286
|
+
checked++;
|
|
5287
|
+
try { if (sha(path.join(hookSrcDir, name)) !== sha(dst)) drifted.push(name); } catch { /* unreadable = skip */ }
|
|
5288
|
+
}
|
|
5289
|
+
// Deletion detection: a runtime file the agent's OWN settings still
|
|
5290
|
+
// reference but which no longer exists on disk. For Claude the
|
|
5291
|
+
// settings.json registrations are the truth; a lighter proxy that
|
|
5292
|
+
// needs no per-agent settings parser: mover-lib.sh is the shared
|
|
5293
|
+
// library every shipped hook sources — if any hooks are present but
|
|
5294
|
+
// the library is gone, every one of them is broken at run time.
|
|
5295
|
+
if (checked > 0 && !runtimeNames.has("mover-lib.sh") && fs.existsSync(path.join(hookSrcDir, "mover-lib.sh"))) {
|
|
5296
|
+
missing.push("mover-lib.sh");
|
|
5297
|
+
}
|
|
5298
|
+
if (drifted.length || missing.length) {
|
|
5299
|
+
anyHookDrift = true;
|
|
5300
|
+
const parts = [];
|
|
5301
|
+
if (drifted.length) parts.push(`${drifted.length}/${checked} DRIFTED: ${drifted.slice(0, 4).join(", ")}${drifted.length > 4 ? ", ..." : ""}`);
|
|
5302
|
+
if (missing.length) parts.push(`MISSING: ${missing.join(", ")}`);
|
|
5303
|
+
statusLine("warn", ` ${rt.label}`, parts.join("; "));
|
|
5304
|
+
} else if (checked > 0) {
|
|
5305
|
+
statusLine("ok", ` ${rt.label}`, `${checked} current`);
|
|
5306
|
+
}
|
|
5307
|
+
}
|
|
5308
|
+
|
|
5309
|
+
const wfSrcDir = path.join(bundleDir, "src", "workflows");
|
|
5310
|
+
const wfDstDir = path.join(home, ".claude", "commands");
|
|
5311
|
+
const brokenLinks = [];
|
|
5312
|
+
const staleContent = [];
|
|
5313
|
+
let wfChecked = 0;
|
|
5314
|
+
if (fs.existsSync(wfDstDir) && fs.existsSync(wfSrcDir)) {
|
|
5315
|
+
for (const name of fs.readdirSync(wfSrcDir)) {
|
|
5316
|
+
if (!name.endsWith(".md")) continue;
|
|
5317
|
+
const src = path.join(wfSrcDir, name);
|
|
5318
|
+
const dst = path.join(wfDstDir, name);
|
|
5319
|
+
if (!fs.existsSync(dst)) continue;
|
|
5320
|
+
wfChecked++;
|
|
5321
|
+
try {
|
|
5322
|
+
const sIno = fs.statSync(src).ino;
|
|
5323
|
+
const dIno = fs.statSync(dst).ino;
|
|
5324
|
+
if (sIno !== dIno) {
|
|
5325
|
+
if (sha(src) !== sha(dst)) staleContent.push(name);
|
|
5326
|
+
else brokenLinks.push(name); // current bytes, but the link is gone: drifts on the next edit
|
|
5327
|
+
}
|
|
5328
|
+
} catch { /* skip */ }
|
|
5329
|
+
}
|
|
5330
|
+
if (staleContent.length) {
|
|
5331
|
+
statusLine("warn", " Workflow links", `${staleContent.length}/${wfChecked} STALE content: ${staleContent.slice(0, 4).join(", ")}${staleContent.length > 4 ? ", ..." : ""}`);
|
|
5332
|
+
} else if (brokenLinks.length) {
|
|
5333
|
+
statusLine("warn", " Workflow links", `${brokenLinks.length}/${wfChecked} content current but hard link broken (will drift on next edit)`);
|
|
5334
|
+
} else {
|
|
5335
|
+
statusLine("ok", " Workflow links", `${wfChecked} hard-linked`);
|
|
5336
|
+
}
|
|
5337
|
+
}
|
|
5338
|
+
if (anyHookDrift || staleContent.length || brokenLinks.length) {
|
|
5339
|
+
barLn(dim(` Fix: bash "${path.join(bundleDir, "src", "install", "link.sh")}"`));
|
|
5340
|
+
}
|
|
5341
|
+
})();
|
|
5342
|
+
|
|
5343
|
+
// ── Codex CLI / MCP plugin conflict check (v4.7.8) ────────────────────
|
|
5344
|
+
// Unauthenticated MCP plugins in ~/.codex/config.toml cause codex exec to
|
|
5345
|
+
// hang or exit empty on complex prompts (the Vercel "claude-plugins-official"
|
|
5346
|
+
// plugin is the most common offender — it requires Bearer auth that's never
|
|
5347
|
+
// set, so the MCP transport errors and blocks the LLM response path).
|
|
5348
|
+
// We don't auto-disable user plugins, just surface the issue.
|
|
5349
|
+
const codexConfig = path.join(home, ".codex", "config.toml");
|
|
5350
|
+
if (fs.existsSync(codexConfig)) {
|
|
5351
|
+
barLn();
|
|
5352
|
+
barLn(dim(" Codex:"));
|
|
5353
|
+
const codexToml = fs.readFileSync(codexConfig, "utf8");
|
|
5354
|
+
// Find all [plugins."NAME"] sections with enabled = true
|
|
5355
|
+
const pluginRe = /\[plugins\."([^"]+)"\]\s*\nenabled\s*=\s*true/g;
|
|
5356
|
+
const enabledPlugins = [];
|
|
5357
|
+
let m;
|
|
5358
|
+
while ((m = pluginRe.exec(codexToml)) !== null) enabledPlugins.push(m[1]);
|
|
5359
|
+
// Known plugins that require auth and break Codex CLI when unauthenticated.
|
|
5360
|
+
// Add more here as users report similar issues.
|
|
5361
|
+
const KNOWN_BROKEN_AUTH = new Set([
|
|
5362
|
+
"vercel@claude-plugins-official",
|
|
5363
|
+
]);
|
|
5364
|
+
const broken = enabledPlugins.filter(p => KNOWN_BROKEN_AUTH.has(p));
|
|
5365
|
+
if (broken.length > 0) {
|
|
5366
|
+
statusLine("warn", " MCP plugin auth", `${broken.length} plugin(s) likely unauthenticated`);
|
|
5367
|
+
for (const p of broken) {
|
|
5368
|
+
barLn(dim(` [plugins."${p}"] enabled=true — auth not configured`));
|
|
5369
|
+
}
|
|
5370
|
+
barLn(dim(` Symptom: codex exec hangs or returns empty on complex prompts.`));
|
|
5371
|
+
barLn(dim(` Fix: edit ~/.codex/config.toml, flip enabled=false for the above plugins`));
|
|
5372
|
+
barLn(dim(` (or configure the missing auth). Mover OS does NOT auto-disable plugins.`));
|
|
5373
|
+
} else if (enabledPlugins.length > 0) {
|
|
5374
|
+
statusLine("ok", " MCP plugin auth", `${enabledPlugins.length} plugin(s), none flagged`);
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
5377
|
+
|
|
4276
5378
|
// Engine Git
|
|
4277
5379
|
barLn();
|
|
4278
5380
|
const engineGit = fs.existsSync(path.join(engineDir, ".git"));
|
|
@@ -4295,8 +5397,396 @@ async function cmdDoctor(opts) {
|
|
|
4295
5397
|
barLn(dim(" Fix any issues above, then run /mover-check in your AI agent for deeper validation."));
|
|
4296
5398
|
}
|
|
4297
5399
|
|
|
4298
|
-
// ─── moveros pulse
|
|
5400
|
+
// ─── moveros pulse / dashboard ───────────────────────────────────────────────
|
|
5401
|
+
// Default: opens browser dashboard at http://127.0.0.1:3737. Use `--terminal` for legacy TUI.
|
|
4299
5402
|
async function cmdPulse(opts) {
|
|
5403
|
+
const rest = opts.rest || [];
|
|
5404
|
+
// `moveros pulse --bar` — a single glanceable status line for a menu-bar widget
|
|
5405
|
+
// (SwiftBar/xbar plugin, a native Swift app, or a Node tray all consume the same
|
|
5406
|
+
// line). No TUI, no browser. Kept path-independent on purpose so the menu-bar
|
|
5407
|
+
// shell can be chosen later without touching this.
|
|
5408
|
+
if (rest.includes("--bar")) { await cmdPulseBar(opts); return; }
|
|
5409
|
+
const wantTerminal = rest.includes("--terminal");
|
|
5410
|
+
if (!wantTerminal) {
|
|
5411
|
+
const vault = resolveVaultPath(opts.vault);
|
|
5412
|
+
if (!vault) {
|
|
5413
|
+
// Never exit silently (R5-A): a fresh machine or a moved vault landed
|
|
5414
|
+
// here and `moveros` just... stopped, with no clue why.
|
|
5415
|
+
console.error(dim(" Could not find your vault. Point at it with --vault PATH, or run `moveros doctor` to diagnose."));
|
|
5416
|
+
return;
|
|
5417
|
+
}
|
|
5418
|
+
try {
|
|
5419
|
+
const dash = require("./src/dashboard");
|
|
5420
|
+
await dash.run({
|
|
5421
|
+
vault,
|
|
5422
|
+
version: (() => { try { return require("./package.json").version; } catch { return "unknown"; } })(),
|
|
5423
|
+
rest
|
|
5424
|
+
});
|
|
5425
|
+
return;
|
|
5426
|
+
} catch (err) {
|
|
5427
|
+
console.error(dim(` dashboard module error: ${err.message}`));
|
|
5428
|
+
console.error(dim(" falling back to terminal pulse — fix or run with --terminal explicitly"));
|
|
5429
|
+
}
|
|
5430
|
+
}
|
|
5431
|
+
await cmdPulseTerminal(opts);
|
|
5432
|
+
}
|
|
5433
|
+
|
|
5434
|
+
// ─── moveros open ────────────────────────────────────────────────────────────
|
|
5435
|
+
// Ensure the dashboard is running in the background, then open it in the
|
|
5436
|
+
// browser and exit. Used by the desktop launcher (shortcut.js → `node
|
|
5437
|
+
// install.js open`) and by anyone who wants the dashboard without holding a
|
|
5438
|
+
// terminal open. `pulse` is the foreground equivalent.
|
|
5439
|
+
async function cmdOpen(opts, { soft = false, route = "" } = {}) {
|
|
5440
|
+
const vault = resolveVaultPath(opts.vault);
|
|
5441
|
+
if (!vault) {
|
|
5442
|
+
console.error(dim(" Could not resolve vault. Run `moveros doctor` to diagnose."));
|
|
5443
|
+
return;
|
|
5444
|
+
}
|
|
5445
|
+
try {
|
|
5446
|
+
const dash = require("./src/dashboard");
|
|
5447
|
+
await dash.open({
|
|
5448
|
+
vault,
|
|
5449
|
+
version: (() => { try { return require("./package.json").version; } catch { return "unknown"; } })(),
|
|
5450
|
+
rest: opts.rest || [],
|
|
5451
|
+
soft,
|
|
5452
|
+
noOpen: !!opts.noOpen,
|
|
5453
|
+
route,
|
|
5454
|
+
});
|
|
5455
|
+
} catch (err) {
|
|
5456
|
+
console.error(dim(` dashboard module error: ${err.message}`));
|
|
5457
|
+
console.error(dim(" open it manually with: moveros pulse"));
|
|
5458
|
+
}
|
|
5459
|
+
}
|
|
5460
|
+
|
|
5461
|
+
// ─── moveros shortcut ────────────────────────────────────────────────────────
|
|
5462
|
+
// Create the double-clickable desktop launcher (mac .app + Desktop symlink /
|
|
5463
|
+
// win .lnk / linux .desktop) for an existing install. Onboarding offers this
|
|
5464
|
+
// as a checkbox (/api/setup/shortcut); this is the retrofit path for installs
|
|
5465
|
+
// that predate it and the repair path when the launcher was deleted.
|
|
5466
|
+
// Optional positional overrides the name: `moveros shortcut My OS`.
|
|
5467
|
+
// Idempotent: re-running refreshes the same artifacts in place.
|
|
5468
|
+
async function cmdShortcut(opts) {
|
|
5469
|
+
// A launcher that runs `moveros open` is a dead icon without an install.
|
|
5470
|
+
// A launcher without an install is a dead icon — the one invariant
|
|
5471
|
+
// (requireInstalledVault) covers the terra F1 typo'd --vault case.
|
|
5472
|
+
const vault = requireInstalledVault(opts.vault);
|
|
5473
|
+
if (!vault) return;
|
|
5474
|
+
|
|
5475
|
+
// Explicit arg wins, else the display name chosen at onboarding. An empty
|
|
5476
|
+
// string is fine: createShortcut falls back to "Mover Studio". The 80-char
|
|
5477
|
+
// cap matches the /api/setup/shortcut route. Unknown flags fall through
|
|
5478
|
+
// parseArgs into rest; drop them so `moveros shortcut --remove` can never
|
|
5479
|
+
// mint a launcher literally named "--remove".
|
|
5480
|
+
const flagged = opts.rest.filter((a) => String(a).startsWith("-"));
|
|
5481
|
+
if (flagged.length) statusLine("warn", "Ignoring", flagged.join(" ") + " (no such option)");
|
|
5482
|
+
let name = opts.rest.filter((a) => !String(a).startsWith("-")).join(" ").trim().slice(0, 80);
|
|
5483
|
+
if (!name) {
|
|
5484
|
+
try {
|
|
5485
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".mover", "config.json"), "utf8"));
|
|
5486
|
+
name = String(cfg.osName || cfg.os_name || "");
|
|
5487
|
+
} catch { /* no config yet — createShortcut handles the fallback */ }
|
|
5488
|
+
}
|
|
5489
|
+
|
|
5490
|
+
const { createShortcut } = require("./src/dashboard/shortcut");
|
|
5491
|
+
const result = createShortcut({
|
|
5492
|
+
name,
|
|
5493
|
+
nodeBin: process.execPath, // fast path only — the launcher re-resolves node at click time
|
|
5494
|
+
entry: __filename, // realpath of the moveros bin; npx-cache paths share server.js's exposure
|
|
5495
|
+
});
|
|
5496
|
+
|
|
5497
|
+
for (const p of result.created) statusLine("ok", "Created", p);
|
|
5498
|
+
if (result.skipped) {
|
|
5499
|
+
statusLine("warn", "Shortcut skipped", result.skipped);
|
|
5500
|
+
barLn();
|
|
5501
|
+
return;
|
|
5502
|
+
}
|
|
5503
|
+
barLn();
|
|
5504
|
+
const label =
|
|
5505
|
+
result.platform === "darwin" ? "Desktop icon" :
|
|
5506
|
+
result.platform === "win32" ? "Desktop shortcut" : "desktop entry";
|
|
5507
|
+
barLn(dim(` Double-click the ${label} to open your dashboard.`));
|
|
5508
|
+
barLn();
|
|
5509
|
+
}
|
|
5510
|
+
|
|
5511
|
+
// ─── moveros inventory ───────────────────────────────────────────────────────
|
|
5512
|
+
// Emits the bundle's derived facts as JSON by EVALUATING the same source the
|
|
5513
|
+
// installer runs (AGENT_REGISTRY, generateClaudeSettings/Codex/Gemini), never
|
|
5514
|
+
// by re-typing numbers. scripts/generate-docs.mjs consumes this to stamp
|
|
5515
|
+
// README.md/MANUAL.md, and test/docs-generated.test.mjs fails the suite when
|
|
5516
|
+
// the stamped docs drift from these facts (sol part 12: docs must be
|
|
5517
|
+
// generated copies, not authored ones). Needs no vault — the subject is the
|
|
5518
|
+
// bundle itself.
|
|
5519
|
+
function collectInventory() {
|
|
5520
|
+
const bundle = __dirname;
|
|
5521
|
+
const workflows = fs.readdirSync(path.join(bundle, "src", "workflows"))
|
|
5522
|
+
.filter((n) => n.endsWith(".md")).map((n) => n.replace(/\.md$/, "")).sort();
|
|
5523
|
+
|
|
5524
|
+
// Same production filter the installer and inventory-counts test apply.
|
|
5525
|
+
const DEV_SUFFIXES = ["-workspace", "-benchmark", "-sandbox"];
|
|
5526
|
+
const isDev = (n) => DEV_SUFFIXES.some((s) => n.endsWith(s));
|
|
5527
|
+
const skills = [];
|
|
5528
|
+
(function walk(dir) {
|
|
5529
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
5530
|
+
if (!e.isDirectory() || isDev(e.name)) continue;
|
|
5531
|
+
const full = path.join(dir, e.name);
|
|
5532
|
+
if (fs.existsSync(path.join(full, "SKILL.md"))) skills.push(e.name);
|
|
5533
|
+
else walk(full);
|
|
5534
|
+
}
|
|
5535
|
+
})(path.join(bundle, "src", "skills"));
|
|
5536
|
+
skills.sort();
|
|
5537
|
+
|
|
5538
|
+
const hookFiles = fs.readdirSync(path.join(bundle, "src", "hooks"))
|
|
5539
|
+
.filter((f) => f.endsWith(".sh")).sort();
|
|
5540
|
+
|
|
5541
|
+
// Claude Code: walk the actual generated settings for script + event + matcher.
|
|
5542
|
+
const claudeRegistered = [];
|
|
5543
|
+
const claudeSettings = JSON.parse(generateClaudeSettings());
|
|
5544
|
+
for (const [event, entries] of Object.entries(claudeSettings.hooks || {})) {
|
|
5545
|
+
for (const entry of entries) {
|
|
5546
|
+
for (const h of entry.hooks || []) {
|
|
5547
|
+
const m = String(h.command || "").match(/([a-z0-9-]+\.sh)/);
|
|
5548
|
+
if (m) claudeRegistered.push({ script: m[1], event, matcher: entry.matcher || "" });
|
|
5549
|
+
}
|
|
5550
|
+
}
|
|
5551
|
+
}
|
|
5552
|
+
|
|
5553
|
+
const scriptsIn = (jsonText) => {
|
|
5554
|
+
const set = new Set();
|
|
5555
|
+
for (const m of String(jsonText).matchAll(/([a-z0-9-]+\.sh)/g)) set.add(m[1]);
|
|
5556
|
+
return [...set].sort();
|
|
5557
|
+
};
|
|
5558
|
+
const codexScripts = scriptsIn(generateCodexHooks());
|
|
5559
|
+
const geminiScripts = scriptsIn(JSON.stringify(generateGeminiHooks()));
|
|
5560
|
+
|
|
5561
|
+
let version = `V${VERSION}`;
|
|
5562
|
+
try {
|
|
5563
|
+
version = JSON.parse(fs.readFileSync(path.join(bundle, "package.json"), "utf8")).version || version;
|
|
5564
|
+
} catch {}
|
|
5565
|
+
|
|
5566
|
+
// Engine-protection approval routing, read from the adapter's own constant
|
|
5567
|
+
// so the documented wait budget can never drift from the shipped one.
|
|
5568
|
+
let approvalWaitMs = null;
|
|
5569
|
+
try {
|
|
5570
|
+
const adapterSrc = fs.readFileSync(path.join(bundle, "src", "hooks", "mover-hook-adapter.js"), "utf8");
|
|
5571
|
+
const m = adapterSrc.match(/MOVER_APPROVAL_WAIT_MS\s*\|\|\s*"(\d+)"/);
|
|
5572
|
+
if (m) approvalWaitMs = parseInt(m[1], 10);
|
|
5573
|
+
} catch {}
|
|
5574
|
+
|
|
5575
|
+
const agents = AGENT_SELECTIONS.map((s) => {
|
|
5576
|
+
const reg = AGENT_REGISTRY[s.targets[0]];
|
|
5577
|
+
return {
|
|
5578
|
+
id: s.id,
|
|
5579
|
+
name: s.name,
|
|
5580
|
+
tierDesc: reg.tierDesc,
|
|
5581
|
+
rules: !!reg.rules,
|
|
5582
|
+
commands: reg.commands ? reg.commands.format : null,
|
|
5583
|
+
skills: !!reg.skills,
|
|
5584
|
+
hooks: reg.hooks ? reg.hooks.type : null,
|
|
5585
|
+
};
|
|
5586
|
+
});
|
|
5587
|
+
|
|
5588
|
+
// TYPED approval routing (sol re-score #3 part 12 residual: "approval
|
|
5589
|
+
// behavior is still authored prose with only its timeout derived"). Every
|
|
5590
|
+
// field here is computed from the same sources the product runs:
|
|
5591
|
+
// interactive = agents whose hooks integration is Claude's settings.json
|
|
5592
|
+
// (real PreToolUse prompt); dashboardRider = agents whose GENERATED hook
|
|
5593
|
+
// config routes engine-protection through the adapter (no native prompt,
|
|
5594
|
+
// the Mover Studio dialog is the approval surface); failClosed points at
|
|
5595
|
+
// the test file that pins ask-to-deny when the dashboard is absent.
|
|
5596
|
+
const approval = {
|
|
5597
|
+
waitMs: approvalWaitMs,
|
|
5598
|
+
interactive: agents.filter((a) => a.hooks === "claude-settings").map((a) => a.name),
|
|
5599
|
+
dashboardRider: [
|
|
5600
|
+
codexScripts.includes("engine-protection.sh") ? "Codex" : null,
|
|
5601
|
+
geminiScripts.includes("engine-protection.sh") ? "Gemini CLI" : null,
|
|
5602
|
+
].filter(Boolean),
|
|
5603
|
+
failClosedPinnedBy: "test/hook-adapter.test.mjs",
|
|
5604
|
+
};
|
|
5605
|
+
|
|
5606
|
+
// User-facing CLI subcommands, evaluated from the CLI_COMMANDS registry so
|
|
5607
|
+
// the docs command table cannot drift from what the binary actually
|
|
5608
|
+
// dispatches (sol re-score #4 part 12: "more factual prose into typed
|
|
5609
|
+
// blocks"). Hidden commands (test, inventory) are excluded, matching the
|
|
5610
|
+
// menu and --help surfaces.
|
|
5611
|
+
const cliCommands = Object.entries(CLI_COMMANDS)
|
|
5612
|
+
.filter(([, meta]) => !meta.hidden)
|
|
5613
|
+
.map(([name, meta]) => ({ name, desc: meta.desc, alias: meta.alias || [] }));
|
|
5614
|
+
|
|
5615
|
+
return {
|
|
5616
|
+
version,
|
|
5617
|
+
workflows,
|
|
5618
|
+
skills,
|
|
5619
|
+
cliCommands,
|
|
5620
|
+
hooks: {
|
|
5621
|
+
files: hookFiles,
|
|
5622
|
+
claudeRegistered,
|
|
5623
|
+
codexScripts,
|
|
5624
|
+
geminiScripts,
|
|
5625
|
+
approvalWaitMs,
|
|
5626
|
+
},
|
|
5627
|
+
approval,
|
|
5628
|
+
agents,
|
|
5629
|
+
};
|
|
5630
|
+
}
|
|
5631
|
+
|
|
5632
|
+
function cmdInventory() {
|
|
5633
|
+
// Pure JSON on stdout — no banner, no color — so callers can pipe it.
|
|
5634
|
+
process.stdout.write(JSON.stringify(collectInventory(), null, 2) + "\n");
|
|
5635
|
+
}
|
|
5636
|
+
|
|
5637
|
+
// ─── moveros menubar ─────────────────────────────────────────────────────────
|
|
5638
|
+
// Installs the native macOS menu-bar companion (src/menubar/, prebuilt at
|
|
5639
|
+
// src/menubar/prebuilt/MoverBar.app — zero third-party deps, no SwiftBar, no
|
|
5640
|
+
// brew). Owner-approved 2026-07-11 (qa/campaign-2026-07/OWNER-DECISIONS.md
|
|
5641
|
+
// RULINGS LANDED): "cleanest install-on-accept", same pattern as the desktop
|
|
5642
|
+
// shortcut (src/dashboard/shortcut.js) — a prebuilt artifact copied into
|
|
5643
|
+
// place on accept, not compiled on the user's machine.
|
|
5644
|
+
//
|
|
5645
|
+
// macOS-only. The .app polls ~/.mover/menubar.json every 60s and shells out
|
|
5646
|
+
// to the exact cmd this function writes: [node, install.js, "pulse", "--bar"]
|
|
5647
|
+
// — the same one-honest-line contract cmdPulseBar already guarantees, so the
|
|
5648
|
+
// menu bar never has to fabricate a status.
|
|
5649
|
+
// Idempotent: re-running replaces the installed .app in place (rsync-style —
|
|
5650
|
+
// remove then copy fresh) and rewrites the config.
|
|
5651
|
+
async function cmdMenubar(opts) {
|
|
5652
|
+
if (process.platform !== "darwin") {
|
|
5653
|
+
barLn(dim(" moveros menubar is macOS-only (native AppKit menu-bar app)."));
|
|
5654
|
+
return;
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
const remove = (opts.rest || []).includes("--remove");
|
|
5658
|
+
const appDest = path.join(os.homedir(), "Applications", "MoverBar.app");
|
|
5659
|
+
const configPath = path.join(os.homedir(), ".mover", "menubar.json");
|
|
5660
|
+
|
|
5661
|
+
if (remove) {
|
|
5662
|
+
try {
|
|
5663
|
+
const { execFileSync } = require("child_process");
|
|
5664
|
+
try { execFileSync("pkill", ["-f", "MoverBar"], { stdio: "ignore" }); } catch { /* not running — fine */ }
|
|
5665
|
+
} catch { /* best-effort — never block removal on this */ }
|
|
5666
|
+
try { if (fs.lstatSync(appDest)) fs.rmSync(appDest, { recursive: true, force: true }); statusLine("ok", "Removed", appDest); } catch { /* already absent */ }
|
|
5667
|
+
try { if (fs.lstatSync(configPath)) fs.rmSync(configPath, { force: true }); statusLine("ok", "Removed", configPath); } catch { /* already absent */ }
|
|
5668
|
+
barLn();
|
|
5669
|
+
return;
|
|
5670
|
+
}
|
|
5671
|
+
|
|
5672
|
+
// A MoverBar polling `moveros pulse --bar` is a dead icon without an
|
|
5673
|
+
// install — the one invariant (requireInstalledVault) is the guard.
|
|
5674
|
+
const vault = requireInstalledVault(opts.vault);
|
|
5675
|
+
if (!vault) return;
|
|
5676
|
+
|
|
5677
|
+
const prebuiltSrc = path.join(path.resolve(__dirname), "src", "menubar", "prebuilt", "MoverBar.app");
|
|
5678
|
+
if (!fs.existsSync(prebuiltSrc)) {
|
|
5679
|
+
statusLine("warn", "Missing prebuilt app", `${prebuiltSrc} not found — reinstall mover-os.`);
|
|
5680
|
+
barLn();
|
|
5681
|
+
return;
|
|
5682
|
+
}
|
|
5683
|
+
|
|
5684
|
+
try {
|
|
5685
|
+
fs.mkdirSync(path.join(os.homedir(), "Applications"), { recursive: true });
|
|
5686
|
+
fs.rmSync(appDest, { recursive: true, force: true }); // rsync-style replace
|
|
5687
|
+
copyDirRecursive(prebuiltSrc, appDest);
|
|
5688
|
+
statusLine("ok", "Installed", appDest);
|
|
5689
|
+
} catch (err) {
|
|
5690
|
+
statusLine("warn", "Install failed", err.message);
|
|
5691
|
+
barLn();
|
|
5692
|
+
return;
|
|
5693
|
+
}
|
|
5694
|
+
|
|
5695
|
+
try {
|
|
5696
|
+
fs.mkdirSync(path.join(os.homedir(), ".mover"), { recursive: true });
|
|
5697
|
+
fs.writeFileSync(
|
|
5698
|
+
configPath,
|
|
5699
|
+
JSON.stringify({ cmd: [process.execPath, __filename, "pulse", "--bar"] }, null, 2),
|
|
5700
|
+
"utf8"
|
|
5701
|
+
);
|
|
5702
|
+
statusLine("ok", "Wrote", configPath);
|
|
5703
|
+
} catch (err) {
|
|
5704
|
+
statusLine("warn", "Config write failed", err.message);
|
|
5705
|
+
barLn();
|
|
5706
|
+
return;
|
|
5707
|
+
}
|
|
5708
|
+
|
|
5709
|
+
try {
|
|
5710
|
+
const { spawn } = require("child_process");
|
|
5711
|
+
spawn("open", [appDest], { stdio: "ignore", detached: true }).unref();
|
|
5712
|
+
statusLine("ok", "Launched", "MoverBar");
|
|
5713
|
+
} catch (err) {
|
|
5714
|
+
statusLine("warn", "Launch failed", err.message);
|
|
5715
|
+
}
|
|
5716
|
+
|
|
5717
|
+
barLn();
|
|
5718
|
+
barLn(dim(" Look for the status line in your menu bar. It refreshes every 60s."));
|
|
5719
|
+
barLn();
|
|
5720
|
+
}
|
|
5721
|
+
|
|
5722
|
+
// One glanceable status line for a menu-bar widget. Plain text, no ANSI. Always
|
|
5723
|
+
// exits 0 with SOME line so a bar poller never renders an error. Shape:
|
|
5724
|
+
// "▸ <next action> · <done>/<total> today"
|
|
5725
|
+
// Falls back to the standing test, then a bare label, so the bar is never blank.
|
|
5726
|
+
async function cmdPulseBar(opts) {
|
|
5727
|
+
const emit = (s) => { process.stdout.write(String(s).replace(/\s+/g, " ").trim() + "\n"); };
|
|
5728
|
+
try {
|
|
5729
|
+
const vault = resolveVaultPath(opts.vault);
|
|
5730
|
+
if (!vault) { emit("Mover · not set up"); return; }
|
|
5731
|
+
const engineDir = path.join(vault, "02_Areas", "Engine");
|
|
5732
|
+
|
|
5733
|
+
let done = 0, total = 0, next = "";
|
|
5734
|
+
try {
|
|
5735
|
+
const dailyPath = resolveDailyNotePath(engineDir);
|
|
5736
|
+
if (dailyPath && fs.existsSync(dailyPath)) {
|
|
5737
|
+
const daily = fs.readFileSync(dailyPath, "utf8");
|
|
5738
|
+
const sec = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---|$)/i);
|
|
5739
|
+
if (sec) {
|
|
5740
|
+
const tasks = sec[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
|
|
5741
|
+
total = tasks.length;
|
|
5742
|
+
// Only [x] is complete; [~] is unverified/in-progress (global rule S5).
|
|
5743
|
+
done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
|
|
5744
|
+
const firstOpen = tasks.find((t) => /^\s*-\s*\[[ ~]\]/.test(t));
|
|
5745
|
+
if (firstOpen) next = firstOpen.replace(/^\s*-\s*\[[ x~]\]\s*/, "").replace(/\[[^\]]*\]/g, "").trim();
|
|
5746
|
+
}
|
|
5747
|
+
}
|
|
5748
|
+
} catch (_) { /* no daily / unreadable — fall through to the standing test */ }
|
|
5749
|
+
|
|
5750
|
+
// Owner ruling (2026-07-11): the TITLE is a glance-number, never a sentence
|
|
5751
|
+
// (the reference bar apps show "$12,345", not prose) — and the number is
|
|
5752
|
+
// the MISSION (the bet: "4/15"), not the day's chore count. Contract:
|
|
5753
|
+
// line 1 = compact title, line 2 = the full sentence for the click-menu.
|
|
5754
|
+
// A one-line consumer (scripts, old pollers) still gets a meaningful line.
|
|
5755
|
+
let st = "";
|
|
5756
|
+
try {
|
|
5757
|
+
const man = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".mover", "pattern-manifest.json"), "utf8"));
|
|
5758
|
+
st = String((man && man.single_test) || "").trim();
|
|
5759
|
+
} catch (_) { /* no manifest */ }
|
|
5760
|
+
|
|
5761
|
+
// The bet count comes from the SAME parser the dashboard hero uses —
|
|
5762
|
+
// never a second implementation that can drift from what Home shows.
|
|
5763
|
+
let bet = null;
|
|
5764
|
+
if (st) {
|
|
5765
|
+
try {
|
|
5766
|
+
const { parseGoalProgress } = require("./src/dashboard/lib/goal-forecast");
|
|
5767
|
+
const acRaw = fs.readFileSync(path.join(engineDir, "Active_Context.md"), "utf8");
|
|
5768
|
+
const g = parseGoalProgress({ singleTest: st, sourceText: acRaw, targetDate: null, now: new Date() });
|
|
5769
|
+
// false-zero discipline: no parseable target = no fabricated "0/0".
|
|
5770
|
+
if (g && g.target > 0) bet = { current: g.current || 0, target: g.target };
|
|
5771
|
+
} catch (_) { /* unparseable bet — fall through to the day count */ }
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5774
|
+
const parts = [];
|
|
5775
|
+
if (next) parts.push("▸ " + next.slice(0, 42));
|
|
5776
|
+
if (total > 0) parts.push(`${done}/${total} today`);
|
|
5777
|
+
if (bet || parts.length) {
|
|
5778
|
+
emit(bet ? `▸ ${bet.current}/${bet.target}` : (total > 0 ? `▸ ${done}/${total}` : "▸ Mover"));
|
|
5779
|
+
emit(parts.length ? parts.join(" · ") : "▸ " + st.slice(0, 48));
|
|
5780
|
+
return;
|
|
5781
|
+
}
|
|
5782
|
+
emit("▸ Mover");
|
|
5783
|
+
emit(st ? "▸ " + st.slice(0, 48) : "Mover · no tasks today");
|
|
5784
|
+
} catch (_) {
|
|
5785
|
+
emit("Mover"); // never fail a bar poll
|
|
5786
|
+
}
|
|
5787
|
+
}
|
|
5788
|
+
|
|
5789
|
+
async function cmdPulseTerminal(opts) {
|
|
4300
5790
|
const vault = resolveVaultPath(opts.vault);
|
|
4301
5791
|
if (!vault) {
|
|
4302
5792
|
// handled by requireVault()
|
|
@@ -4340,7 +5830,9 @@ async function cmdPulse(opts) {
|
|
|
4340
5830
|
const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##|\n---)/i);
|
|
4341
5831
|
if (taskSection) {
|
|
4342
5832
|
const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
|
|
4343
|
-
|
|
5833
|
+
// terra read F10: only [x] is COMPLETE. [~] is unverified/in-progress — the
|
|
5834
|
+
// whole point of the [~] state (global rule S5) — and must not count as done.
|
|
5835
|
+
const done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
|
|
4344
5836
|
const total = tasks.length;
|
|
4345
5837
|
barLn(` ${progressBar(done, total, 25, "Tasks")}`);
|
|
4346
5838
|
}
|
|
@@ -4394,9 +5886,13 @@ async function cmdPulse(opts) {
|
|
|
4394
5886
|
if (fs.existsSync(acLogPath)) {
|
|
4395
5887
|
try {
|
|
4396
5888
|
const acContent = fs.readFileSync(acLogPath, "utf8");
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
5889
|
+
// terra read F5: `(\S+)` captured the closing `**` of a Markdown-wrapped key
|
|
5890
|
+
// (**log_last_run:** date), yielding "NaNh ago". Consume optional Markdown
|
|
5891
|
+
// around the key and capture a date-shaped token, then reject an invalid date.
|
|
5892
|
+
const logMatch = acContent.match(/\*{0,2}\s*log_last_run:\s*\*{0,2}\s*([0-9][0-9T:\-.Z+]*)/i);
|
|
5893
|
+
const ts = logMatch ? new Date(logMatch[1]).getTime() : NaN;
|
|
5894
|
+
if (logMatch && !Number.isNaN(ts)) {
|
|
5895
|
+
const agoMs = Date.now() - ts;
|
|
4400
5896
|
const agoMin = Math.round(agoMs / 60000);
|
|
4401
5897
|
const agoH = Math.round(agoMs / 3600000);
|
|
4402
5898
|
const agoStr = agoMin < 60 ? `${agoMin}m ago` : `${agoH}h ago`;
|
|
@@ -4414,7 +5910,11 @@ async function cmdPulse(opts) {
|
|
|
4414
5910
|
|
|
4415
5911
|
// ─── moveros capture ────────────────────────────────────────────────────────
|
|
4416
5912
|
async function cmdCapture(opts) {
|
|
4417
|
-
|
|
5913
|
+
// terra CLI F7: an explicit --vault is trusted without existsSync, so a
|
|
5914
|
+
// typo'd path would get a whole 00_Inbox tree minted there and the note
|
|
5915
|
+
// "lost" outside the real vault. The one invariant is the mutation gate.
|
|
5916
|
+
const vault = requireInstalledVault(opts.vault);
|
|
5917
|
+
if (!vault) return;
|
|
4418
5918
|
|
|
4419
5919
|
const now = new Date();
|
|
4420
5920
|
const ymd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
@@ -4476,11 +5976,34 @@ async function cmdCapture(opts) {
|
|
|
4476
5976
|
else if (type === "idea") entry = `- **Idea:** ${content} *(${ts})*`;
|
|
4477
5977
|
else entry = `- ${content} *(${ts})*`;
|
|
4478
5978
|
|
|
5979
|
+
// terra CLI F6: never follow a SYMLINKED capture file — appending through it
|
|
5980
|
+
// would write outside the vault inbox. A path is unsafe if it is a symlink, or
|
|
5981
|
+
// its real parent is not the real inbox. Fall back to a fresh file; if that is
|
|
5982
|
+
// also unsafe (or the same path), refuse rather than write outside the vault.
|
|
5983
|
+
const realInbox = (() => { try { return fs.realpathSync(inboxDir); } catch { return inboxDir; } })();
|
|
5984
|
+
const isUnsafe = (p) => {
|
|
5985
|
+
try {
|
|
5986
|
+
const st = fs.lstatSync(p, { throwIfNoEntry: false });
|
|
5987
|
+
if (!st) return false; // does not exist = safe to create
|
|
5988
|
+
if (st.isSymbolicLink()) return true; // never follow a link
|
|
5989
|
+
return path.dirname(fs.realpathSync(p)) !== realInbox;
|
|
5990
|
+
} catch { return false; }
|
|
5991
|
+
};
|
|
5992
|
+
let writeTarget = capturePath;
|
|
5993
|
+
if (isUnsafe(writeTarget)) {
|
|
5994
|
+
const fresh = path.join(inboxDir, `Capture - ${ymd}.md`);
|
|
5995
|
+
if (path.resolve(fresh) === path.resolve(writeTarget) || isUnsafe(fresh)) {
|
|
5996
|
+
barLn(red(" The inbox capture file is a symlink pointing outside the vault; refusing to write."));
|
|
5997
|
+
return;
|
|
5998
|
+
}
|
|
5999
|
+
writeTarget = fresh;
|
|
6000
|
+
}
|
|
6001
|
+
|
|
4479
6002
|
// Append to capture file
|
|
4480
|
-
if (!fs.existsSync(
|
|
4481
|
-
fs.writeFileSync(
|
|
6003
|
+
if (!fs.existsSync(writeTarget)) {
|
|
6004
|
+
fs.writeFileSync(writeTarget, `# Capture — ${ymd}\n\n${entry}\n`, "utf8");
|
|
4482
6005
|
} else {
|
|
4483
|
-
fs.appendFileSync(
|
|
6006
|
+
fs.appendFileSync(writeTarget, `${entry}\n`, "utf8");
|
|
4484
6007
|
}
|
|
4485
6008
|
|
|
4486
6009
|
statusLine("ok", "Captured", `${type} → 00_Inbox/`);
|
|
@@ -4490,7 +6013,10 @@ async function cmdCapture(opts) {
|
|
|
4490
6013
|
|
|
4491
6014
|
// ─── moveros who ────────────────────────────────────────────────────────────
|
|
4492
6015
|
async function cmdWho(opts) {
|
|
4493
|
-
|
|
6016
|
+
// Can mint entity stubs — mutating commands go through the one install
|
|
6017
|
+
// invariant (sol re-score #3: the invariant must cover EVERY mutating path).
|
|
6018
|
+
const vault = requireInstalledVault(opts.vault);
|
|
6019
|
+
if (!vault) return;
|
|
4494
6020
|
|
|
4495
6021
|
const name = opts.rest.join(" ").trim();
|
|
4496
6022
|
if (!name) { barLn(yellow("Usage: moveros who <name>")); return; }
|
|
@@ -4521,9 +6047,35 @@ async function cmdWho(opts) {
|
|
|
4521
6047
|
if (create === "yes") {
|
|
4522
6048
|
const peopleDir = path.join(entitiesDir, "People");
|
|
4523
6049
|
fs.mkdirSync(peopleDir, { recursive: true });
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
6050
|
+
// The raw name went straight into the write path, so "../../x" escaped
|
|
6051
|
+
// Entities entirely (terra-confirmed traversal). Strip separators and
|
|
6052
|
+
// dot segments, then verify the target lands PHYSICALLY inside People/ —
|
|
6053
|
+
// using realpath, since path.resolve is lexical and a symlinked People/
|
|
6054
|
+
// dir would otherwise let the stub escape (terra round 2).
|
|
6055
|
+
const safeName = name.replace(/[\/\\:*?"<>|\0]/g, "-").replace(/\.\.+/g, "-").trim();
|
|
6056
|
+
const stubPath = path.resolve(peopleDir, `${safeName}.md`);
|
|
6057
|
+
// Containment, three ways (terra P1, two rounds): lexical (blocks "../"
|
|
6058
|
+
// names), realpath-consistent (dirname resolves to People), AND the
|
|
6059
|
+
// resolved target must sit INSIDE the vault — so a People/ symlinked to
|
|
6060
|
+
// an outside dir can't smuggle the stub out of the vault entirely.
|
|
6061
|
+
let realParent, realPeople, realVault;
|
|
6062
|
+
try {
|
|
6063
|
+
realParent = fs.realpathSync(path.dirname(stubPath));
|
|
6064
|
+
realPeople = fs.realpathSync(peopleDir);
|
|
6065
|
+
realVault = fs.realpathSync(vault);
|
|
6066
|
+
} catch { realParent = null; realPeople = null; realVault = null; }
|
|
6067
|
+
const insideVault = realParent && realVault &&
|
|
6068
|
+
(realParent === realVault || realParent.startsWith(realVault + path.sep));
|
|
6069
|
+
if (!safeName ||
|
|
6070
|
+
!stubPath.startsWith(path.resolve(peopleDir) + path.sep) ||
|
|
6071
|
+
realParent !== realPeople ||
|
|
6072
|
+
!insideVault) {
|
|
6073
|
+
statusLine("warn", "Invalid location", "Entity stub would land outside the vault. Check the name and that Entities/People is inside your vault.");
|
|
6074
|
+
return;
|
|
6075
|
+
}
|
|
6076
|
+
const stub = `# ${safeName}\n\n**Type:** Person\n**Last Contact:** ${new Date().toISOString().split("T")[0]}\n\n## Context\n\n- \n\n## Notes\n\n- \n`;
|
|
6077
|
+
fs.writeFileSync(stubPath, stub, "utf8");
|
|
6078
|
+
statusLine("ok", "Created", `${safeName}.md in Entities/People/`);
|
|
4527
6079
|
}
|
|
4528
6080
|
return;
|
|
4529
6081
|
}
|
|
@@ -4545,9 +6097,12 @@ async function cmdWho(opts) {
|
|
|
4545
6097
|
// ─── moveros diff ───────────────────────────────────────────────────────────
|
|
4546
6098
|
async function cmdDiff(opts) {
|
|
4547
6099
|
const vault = requireVault(opts.vault);
|
|
6100
|
+
if (!vault) return;
|
|
4548
6101
|
if (!cmdExists("git")) { barLn(red("Git required for moveros diff.")); return; }
|
|
4549
6102
|
|
|
4550
|
-
|
|
6103
|
+
// terra read F11: the target is the first NON-FLAG arg, so `diff --days=30 strategy`
|
|
6104
|
+
// (flag first) doesn't try to diff a file literally named "--days=30".
|
|
6105
|
+
const target = opts.rest.find((a) => !a.startsWith("--")) || "strategy";
|
|
4551
6106
|
const days = parseInt(opts.rest.find((a) => a.startsWith("--days="))?.split("=")[1] || "30", 10);
|
|
4552
6107
|
|
|
4553
6108
|
const fileMap = {
|
|
@@ -4571,8 +6126,11 @@ async function cmdDiff(opts) {
|
|
|
4571
6126
|
const gitCwd = isEngineFile && fs.existsSync(path.join(engineDir, ".git"))
|
|
4572
6127
|
? engineDir
|
|
4573
6128
|
: vault;
|
|
6129
|
+
// terra read F8: path.basename dropped subdirectories, so a nested Engine file
|
|
6130
|
+
// (02_Areas/Engine/Samples/Foo.md) was queried at the wrong git path and its
|
|
6131
|
+
// history read as empty. Use the path RELATIVE to the Engine repo root.
|
|
4574
6132
|
const gitRelPath = isEngineFile && gitCwd === engineDir
|
|
4575
|
-
? path.
|
|
6133
|
+
? path.relative(engineDir, fullPath)
|
|
4576
6134
|
: relPath;
|
|
4577
6135
|
|
|
4578
6136
|
barLn(bold(` ${path.basename(relPath)} — last ${days} days`));
|
|
@@ -4605,7 +6163,10 @@ async function cmdDiff(opts) {
|
|
|
4605
6163
|
|
|
4606
6164
|
// ─── moveros sync ───────────────────────────────────────────────────────────
|
|
4607
6165
|
async function cmdSync(opts) {
|
|
4608
|
-
|
|
6166
|
+
// --apply rewrites agent artifacts from the bundle — mutating, so the one
|
|
6167
|
+
// install invariant gates it (sol re-score #3).
|
|
6168
|
+
const vault = requireInstalledVault(opts.vault);
|
|
6169
|
+
if (!vault) return;
|
|
4609
6170
|
const apply = opts.rest.includes("--apply");
|
|
4610
6171
|
const home = os.homedir();
|
|
4611
6172
|
|
|
@@ -4630,7 +6191,32 @@ async function cmdSync(opts) {
|
|
|
4630
6191
|
for (const c of candidates) {
|
|
4631
6192
|
if (fs.existsSync(path.join(c, "src", "workflows"))) { bundleDir = c; break; }
|
|
4632
6193
|
}
|
|
4633
|
-
|
|
6194
|
+
// v4.7.9 (S6 ship-blocker): download fallback. `moveros sync` was disk-only —
|
|
6195
|
+
// it resolved bundleDir from cwd / vault-bundle / __dirname and bailed if none
|
|
6196
|
+
// had src/. npm-global installs have NO src/ on disk (the CLI is just the
|
|
6197
|
+
// published install.js), so every npm-global user hit "Can't find Mover OS
|
|
6198
|
+
// source" and could never sync. Mirror cmdUpdateComprehensive: if a license
|
|
6199
|
+
// key is available, download the payload to ~/.mover and use that as the
|
|
6200
|
+
// source. Only bail when there is no disk source AND no key to download with.
|
|
6201
|
+
if (!bundleDir) {
|
|
6202
|
+
const key = opts.key || cfg.licenseKey;
|
|
6203
|
+
if (key) {
|
|
6204
|
+
const dlSp = spinner("Downloading Mover OS");
|
|
6205
|
+
try {
|
|
6206
|
+
bundleDir = await downloadPayload(key);
|
|
6207
|
+
dlSp.stop(green("Downloaded"));
|
|
6208
|
+
barLn();
|
|
6209
|
+
} catch (err) {
|
|
6210
|
+
dlSp.stop(red("Download failed"));
|
|
6211
|
+
barLn(yellow(` ${err.message}`));
|
|
6212
|
+
return;
|
|
6213
|
+
}
|
|
6214
|
+
} else {
|
|
6215
|
+
barLn(yellow(" Can't find Mover OS source on disk, and no license key to download it."));
|
|
6216
|
+
barLn(dim(" Run from the bundle directory, or set a license key (moveros install) so sync can fetch the payload."));
|
|
6217
|
+
return;
|
|
6218
|
+
}
|
|
6219
|
+
}
|
|
4634
6220
|
|
|
4635
6221
|
// Compare source hash vs installed for each agent
|
|
4636
6222
|
const srcRulesHash = (() => {
|
|
@@ -4686,13 +6272,22 @@ async function cmdSync(opts) {
|
|
|
4686
6272
|
// ─── moveros replay ─────────────────────────────────────────────────────────
|
|
4687
6273
|
async function cmdReplay(opts) {
|
|
4688
6274
|
const vault = requireVault(opts.vault);
|
|
6275
|
+
if (!vault) return;
|
|
4689
6276
|
|
|
4690
6277
|
const engineDir = path.join(vault, "02_Areas", "Engine");
|
|
4691
6278
|
let dateStr = opts.rest.find((a) => a.startsWith("--date="))?.split("=")[1];
|
|
4692
6279
|
let targetDate;
|
|
4693
6280
|
if (dateStr) {
|
|
4694
|
-
|
|
4695
|
-
|
|
6281
|
+
// terra read F7: `new Date(2026, 1, 31)` rolls over to Mar 3, so an impossible
|
|
6282
|
+
// date replayed a DIFFERENT day under a false label. Validate the round-trip
|
|
6283
|
+
// and reject a rollover.
|
|
6284
|
+
const m2 = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
|
|
6285
|
+
const [y, mo, d] = m2 ? m2.slice(1).map(Number) : [NaN, NaN, NaN];
|
|
6286
|
+
targetDate = m2 ? new Date(y, mo - 1, d) : new Date("invalid");
|
|
6287
|
+
if (!m2 || targetDate.getFullYear() !== y || targetDate.getMonth() !== mo - 1 || targetDate.getDate() !== d) {
|
|
6288
|
+
barLn(red(` Invalid date: ${dateStr}. Use a real YYYY-MM-DD.`));
|
|
6289
|
+
return;
|
|
6290
|
+
}
|
|
4696
6291
|
} else {
|
|
4697
6292
|
targetDate = getSessionDate();
|
|
4698
6293
|
const y = targetDate.getFullYear();
|
|
@@ -4738,7 +6333,8 @@ async function cmdReplay(opts) {
|
|
|
4738
6333
|
const taskSection = daily.match(/##\s*Tasks[\s\S]*?(?=\n##)/i);
|
|
4739
6334
|
if (taskSection) {
|
|
4740
6335
|
const tasks = taskSection[0].split("\n").filter((l) => /^\s*-\s*\[[ x~]\]/.test(l));
|
|
4741
|
-
|
|
6336
|
+
// terra read F10: only [x] is complete; [~] is unverified (global rule S5).
|
|
6337
|
+
const done = tasks.filter((t) => /^\s*-\s*\[x\]/.test(t)).length;
|
|
4742
6338
|
barLn(` ${progressBar(done, tasks.length, 25, "Completion")}`);
|
|
4743
6339
|
barLn();
|
|
4744
6340
|
}
|
|
@@ -4748,15 +6344,38 @@ async function cmdReplay(opts) {
|
|
|
4748
6344
|
}
|
|
4749
6345
|
|
|
4750
6346
|
// ─── moveros context ────────────────────────────────────────────────────────
|
|
6347
|
+
// terra read F4: `reg.hooks.events` does not exist on the registry, so context
|
|
6348
|
+
// always showed "0 events" / "? hook events". Parse the agent's actual hook
|
|
6349
|
+
// config and return the event names. null = not found / unreadable.
|
|
6350
|
+
function agentHookEvents(reg, vault, home) {
|
|
6351
|
+
try {
|
|
6352
|
+
let p = null;
|
|
6353
|
+
if (reg && reg.hooks && reg.hooks.dest) p = reg.hooks.dest(vault);
|
|
6354
|
+
else if (reg && reg.hooks && reg.hooks.type === "claude-settings") p = path.join(home, ".claude", "settings.json");
|
|
6355
|
+
if (!p || !fs.existsSync(p)) return null;
|
|
6356
|
+
const obj = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
6357
|
+
// Hooks live at the top level (agent hooks.json) or under `.hooks` (settings.json).
|
|
6358
|
+
const h = (obj && obj.hooks && typeof obj.hooks === "object") ? obj.hooks : obj;
|
|
6359
|
+
if (!h || typeof h !== "object") return null;
|
|
6360
|
+
return Object.keys(h).filter((k) => /^[A-Z][A-Za-z]/.test(k) && (Array.isArray(h[k]) || (h[k] && typeof h[k] === "object")));
|
|
6361
|
+
} catch { return null; }
|
|
6362
|
+
}
|
|
6363
|
+
|
|
4751
6364
|
async function cmdContext(opts) {
|
|
4752
6365
|
const vault = requireVault(opts.vault);
|
|
6366
|
+
if (!vault) return;
|
|
4753
6367
|
|
|
4754
6368
|
const target = opts.rest[0];
|
|
4755
6369
|
const home = os.homedir();
|
|
4756
6370
|
const cfgPath = path.join(home, ".mover", "config.json");
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
6371
|
+
// terra read F9: a malformed config.json crashed `moveros context` (an unguarded
|
|
6372
|
+
// JSON.parse) — exactly when config corruption most needs diagnosing. Degrade to
|
|
6373
|
+
// an empty agent list, mirroring doctor.
|
|
6374
|
+
let agents = [];
|
|
6375
|
+
if (fs.existsSync(cfgPath)) {
|
|
6376
|
+
try { agents = JSON.parse(fs.readFileSync(cfgPath, "utf8")).agents || []; }
|
|
6377
|
+
catch { barLn(yellow(" ~/.mover/config.json is not valid JSON — showing no agents.")); }
|
|
6378
|
+
}
|
|
4760
6379
|
|
|
4761
6380
|
if (!target) {
|
|
4762
6381
|
// Overview: show what each agent actually loads
|
|
@@ -4804,8 +6423,8 @@ async function cmdContext(opts) {
|
|
|
4804
6423
|
}
|
|
4805
6424
|
}
|
|
4806
6425
|
|
|
4807
|
-
// Hooks
|
|
4808
|
-
if (reg.hooks) parts.push(`${
|
|
6426
|
+
// Hooks (terra read F4: parse the real config)
|
|
6427
|
+
if (reg.hooks) { const ev = agentHookEvents(reg, vault, home); parts.push(ev ? `${ev.length} hook events` : "no hooks"); }
|
|
4809
6428
|
|
|
4810
6429
|
const tokens = Math.round(totalBytes / 4);
|
|
4811
6430
|
const tokenWarn = tokens > 40000 ? red("heavy") : tokens > 20000 ? yellow("moderate") : green("lean");
|
|
@@ -4890,11 +6509,12 @@ async function cmdContext(opts) {
|
|
|
4890
6509
|
}
|
|
4891
6510
|
}
|
|
4892
6511
|
|
|
4893
|
-
// Hooks
|
|
6512
|
+
// Hooks (terra read F4: parse the real config, not the nonexistent reg.hooks.events)
|
|
4894
6513
|
if (reg.hooks) {
|
|
4895
6514
|
barLn();
|
|
4896
|
-
|
|
4897
|
-
|
|
6515
|
+
const ev = agentHookEvents(reg, vault, home);
|
|
6516
|
+
statusLine(ev && ev.length ? "ok" : "warn", "Hooks", ev ? `${ev.length} events` : "not configured");
|
|
6517
|
+
if (ev && ev.length) barLn(dim(` ${ev.join(", ")}`));
|
|
4898
6518
|
}
|
|
4899
6519
|
|
|
4900
6520
|
// Token budget summary
|
|
@@ -5202,7 +6822,10 @@ async function cmdSettings(opts) {
|
|
|
5202
6822
|
|
|
5203
6823
|
// ─── moveros backup ─────────────────────────────────────────────────────────
|
|
5204
6824
|
async function cmdBackup(opts) {
|
|
5205
|
-
|
|
6825
|
+
// Writes archives derived from the vault; backing up a non-install is a
|
|
6826
|
+
// meaningless artifact from a possibly wrong --vault (sol re-score #3).
|
|
6827
|
+
const vault = requireInstalledVault(opts.vault);
|
|
6828
|
+
if (!vault) return;
|
|
5206
6829
|
const home = os.homedir();
|
|
5207
6830
|
|
|
5208
6831
|
barLn(bold(" Backup"));
|
|
@@ -5218,8 +6841,14 @@ async function cmdBackup(opts) {
|
|
|
5218
6841
|
if (!choices || choices.length === 0) { barLn(dim(" Cancelled.")); return; }
|
|
5219
6842
|
|
|
5220
6843
|
const now = new Date();
|
|
5221
|
-
|
|
6844
|
+
// terra CLI F5: minute precision meant two backups in the same minute reused
|
|
6845
|
+
// one dir and the second SILENTLY OVERWROTE the first snapshot (losing the only
|
|
6846
|
+
// good restore point). Add seconds + a random suffix so every backup is a
|
|
6847
|
+
// distinct dir, and fail closed if it somehow already exists.
|
|
6848
|
+
const secs = String(now.getSeconds()).padStart(2, "0");
|
|
6849
|
+
const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}${secs}_${crypto.randomBytes(2).toString("hex")}`;
|
|
5222
6850
|
const backupDir = path.join(vault, "04_Archives", `Backup_${ts}`);
|
|
6851
|
+
if (fs.existsSync(backupDir)) { outro(red("Backup directory collision; try again.")); return; }
|
|
5223
6852
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
5224
6853
|
|
|
5225
6854
|
const manifest = { timestamp: now.toISOString(), type: "manual", trigger: "moveros backup", categories: choices, files: {} };
|
|
@@ -5297,9 +6926,46 @@ async function cmdBackup(opts) {
|
|
|
5297
6926
|
barLn();
|
|
5298
6927
|
}
|
|
5299
6928
|
|
|
6929
|
+
// terra CLI F2: a restore that copies files in a loop with no rollback left
|
|
6930
|
+
// earlier files overwritten if a later copy failed (corrupt mixed state). Snapshot
|
|
6931
|
+
// each existing destination, copy all, and on ANY failure roll every destination
|
|
6932
|
+
// back to its snapshot (or remove a file we created). Returns the count restored.
|
|
6933
|
+
function restoreFilesTransactional(pairs) {
|
|
6934
|
+
const snaps = [];
|
|
6935
|
+
try {
|
|
6936
|
+
for (const { src, dest } of pairs) {
|
|
6937
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
6938
|
+
if (fs.existsSync(dest)) {
|
|
6939
|
+
const bak = `${dest}.restore-bak`;
|
|
6940
|
+
fs.copyFileSync(dest, bak);
|
|
6941
|
+
snaps.push({ dest, bak, had: true });
|
|
6942
|
+
} else {
|
|
6943
|
+
snaps.push({ dest, bak: null, had: false });
|
|
6944
|
+
}
|
|
6945
|
+
fs.copyFileSync(src, dest);
|
|
6946
|
+
}
|
|
6947
|
+
for (const s of snaps) if (s.bak) { try { fs.unlinkSync(s.bak); } catch {} }
|
|
6948
|
+
return pairs.length;
|
|
6949
|
+
} catch (e) {
|
|
6950
|
+
for (const s of snaps) {
|
|
6951
|
+
try {
|
|
6952
|
+
if (s.had && s.bak) fs.copyFileSync(s.bak, s.dest);
|
|
6953
|
+
else if (!s.had) { try { fs.unlinkSync(s.dest); } catch {} }
|
|
6954
|
+
} catch {}
|
|
6955
|
+
if (s.bak) { try { fs.unlinkSync(s.bak); } catch {} }
|
|
6956
|
+
}
|
|
6957
|
+
throw e;
|
|
6958
|
+
}
|
|
6959
|
+
}
|
|
6960
|
+
|
|
5300
6961
|
// ─── moveros restore ────────────────────────────────────────────────────────
|
|
5301
6962
|
async function cmdRestore(opts) {
|
|
5302
|
-
|
|
6963
|
+
// Writes INTO the vault — a typo'd --vault would dump restored files into
|
|
6964
|
+
// an unrelated directory, so the one install invariant gates it (sol
|
|
6965
|
+
// re-score #3). Recovery of a vault whose marker itself was lost goes
|
|
6966
|
+
// through moveros install first, which recreates the marker.
|
|
6967
|
+
const vault = requireInstalledVault(opts.vault);
|
|
6968
|
+
if (!vault) return;
|
|
5303
6969
|
|
|
5304
6970
|
const archivesDir = path.join(vault, "04_Archives");
|
|
5305
6971
|
if (!fs.existsSync(archivesDir)) { barLn(yellow(" No archives found.")); return; }
|
|
@@ -5331,8 +6997,21 @@ async function cmdRestore(opts) {
|
|
|
5331
6997
|
return { id: b, name: b, tier: detail || dim("legacy backup") };
|
|
5332
6998
|
});
|
|
5333
6999
|
|
|
5334
|
-
|
|
5335
|
-
|
|
7000
|
+
// terra CLI F1: a non-TTY restore must NOT silently pick the newest backup and
|
|
7001
|
+
// overwrite live (possibly newer) files. Require an explicit backup name AND
|
|
7002
|
+
// --confirm; otherwise refuse and show how.
|
|
7003
|
+
let selected;
|
|
7004
|
+
if (!IS_TTY) {
|
|
7005
|
+
const explicit = opts.rest.find((a) => backups.includes(a));
|
|
7006
|
+
if (!explicit || !opts.rest.includes("--confirm")) {
|
|
7007
|
+
outro(red(`Non-interactive restore needs an explicit backup and --confirm:\n moveros restore <${backups[0] || "Backup_..."}> --confirm`));
|
|
7008
|
+
return;
|
|
7009
|
+
}
|
|
7010
|
+
selected = explicit;
|
|
7011
|
+
} else {
|
|
7012
|
+
selected = await interactiveSelect(items, { multi: false });
|
|
7013
|
+
if (!selected) { barLn(dim(" Cancelled.")); return; }
|
|
7014
|
+
}
|
|
5336
7015
|
|
|
5337
7016
|
const backupPath = path.join(archivesDir, selected);
|
|
5338
7017
|
|
|
@@ -5375,28 +7054,42 @@ async function cmdRestore(opts) {
|
|
|
5375
7054
|
statusLine("ok", "Agents", `${agentCount} items restored`);
|
|
5376
7055
|
totalRestored = agentCount;
|
|
5377
7056
|
} else {
|
|
5378
|
-
// Engine backup (
|
|
7057
|
+
// A Backup_* dir can hold engine/ (Engine backup), areas/ (Full Areas backup,
|
|
7058
|
+
// terra CLI F3 — the old code knew only engine/ or loose .md and silently
|
|
7059
|
+
// restored NOTHING for an areas/ backup), or loose .md (legacy). Build the
|
|
7060
|
+
// src->dest pairs, then restore transactionally (F2).
|
|
7061
|
+
const engineDir = path.join(vault, "02_Areas", "Engine");
|
|
7062
|
+
const areasSrc = path.join(backupPath, "areas");
|
|
5379
7063
|
const engPath = path.join(backupPath, "engine");
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
7064
|
+
const pairs = [];
|
|
7065
|
+
let label = "Engine";
|
|
7066
|
+
if (fs.existsSync(areasSrc)) {
|
|
7067
|
+
const areasTarget = path.join(vault, "02_Areas");
|
|
7068
|
+
const walk = (dir, rel) => {
|
|
7069
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
7070
|
+
if (e.isDirectory()) walk(path.join(dir, e.name), path.join(rel, e.name));
|
|
7071
|
+
else pairs.push({ src: path.join(dir, e.name), dest: path.join(areasTarget, rel, e.name) });
|
|
7072
|
+
}
|
|
7073
|
+
};
|
|
7074
|
+
walk(areasSrc, "");
|
|
7075
|
+
label = "Areas";
|
|
7076
|
+
} else if (fs.existsSync(engPath)) {
|
|
5383
7077
|
for (const f of fs.readdirSync(engPath).filter((f) => fs.statSync(path.join(engPath, f)).isFile())) {
|
|
5384
|
-
|
|
5385
|
-
restored++;
|
|
7078
|
+
pairs.push({ src: path.join(engPath, f), dest: path.join(engineDir, f) });
|
|
5386
7079
|
}
|
|
5387
|
-
statusLine("ok", "Engine", `${restored} files restored`);
|
|
5388
|
-
totalRestored = restored;
|
|
5389
7080
|
} else {
|
|
5390
|
-
// Legacy backup format (files directly in backup dir)
|
|
5391
|
-
const engineDir = path.join(vault, "02_Areas", "Engine");
|
|
5392
|
-
let restored = 0;
|
|
5393
7081
|
for (const f of fs.readdirSync(backupPath).filter((f) => f.endsWith(".md") && f !== ".backup-manifest.json")) {
|
|
5394
|
-
|
|
5395
|
-
restored++;
|
|
7082
|
+
pairs.push({ src: path.join(backupPath, f), dest: path.join(engineDir, f) });
|
|
5396
7083
|
}
|
|
5397
|
-
if (restored > 0) statusLine("ok", "Engine", `${restored} files restored`);
|
|
5398
|
-
totalRestored = restored;
|
|
5399
7084
|
}
|
|
7085
|
+
try {
|
|
7086
|
+
totalRestored = restoreFilesTransactional(pairs);
|
|
7087
|
+
} catch (e) {
|
|
7088
|
+
outro(red(`Restore failed and was rolled back (no partial state): ${e && e.message}`));
|
|
7089
|
+
return;
|
|
7090
|
+
}
|
|
7091
|
+
if (totalRestored > 0) statusLine("ok", label, `${totalRestored} files restored`);
|
|
7092
|
+
else { barLn(yellow(" This backup contained no restorable files.")); return; }
|
|
5400
7093
|
}
|
|
5401
7094
|
|
|
5402
7095
|
barLn();
|
|
@@ -5435,6 +7128,9 @@ async function cmdHelp(opts) {
|
|
|
5435
7128
|
w("\n");
|
|
5436
7129
|
};
|
|
5437
7130
|
|
|
7131
|
+
// Counts computed, never typed: this screen shipped "23 commands / 61
|
|
7132
|
+
// packs / 16 agents" while the bundle held 25/70/15.
|
|
7133
|
+
const inv = collectInventory();
|
|
5438
7134
|
const pages = [
|
|
5439
7135
|
{
|
|
5440
7136
|
title: "What is Mover OS?",
|
|
@@ -5449,10 +7145,10 @@ async function cmdHelp(opts) {
|
|
|
5449
7145
|
"",
|
|
5450
7146
|
`${dim("Three things make it work:")}`,
|
|
5451
7147
|
` ${cyan("1.")} The ${bold("Engine")} — files that store your identity, strategy, goals`,
|
|
5452
|
-
` ${cyan("2.")} ${bold("Workflows")} —
|
|
5453
|
-
` ${cyan("3.")} ${bold("Skills")} —
|
|
7148
|
+
` ${cyan("2.")} ${bold("Workflows")} — ${inv.workflows.length} commands that run your day (plan, build, log, repeat)`,
|
|
7149
|
+
` ${cyan("3.")} ${bold("Skills")} — ${inv.skills.length} packs that make your AI genuinely useful`,
|
|
5454
7150
|
"",
|
|
5455
|
-
`Works across
|
|
7151
|
+
`Works across ${inv.agents.length} agents. Claude Code, Cursor, Gemini, all of them.`,
|
|
5456
7152
|
`Same context, every editor. ${dim("Press → to continue.")}`,
|
|
5457
7153
|
],
|
|
5458
7154
|
},
|
|
@@ -5593,7 +7289,7 @@ async function cmdHelp(opts) {
|
|
|
5593
7289
|
` ${dim("moveros capture --task \"Fix the login bug\"")}`,
|
|
5594
7290
|
"",
|
|
5595
7291
|
` ${cyan("who")} Entity lookup — search People, Orgs, Places`,
|
|
5596
|
-
` ${dim("moveros who \"
|
|
7292
|
+
` ${dim("moveros who \"Sarah\" → everything you know about them")}`,
|
|
5597
7293
|
"",
|
|
5598
7294
|
` ${cyan("prayer")} Mosque timetable — next prayer in your status line`,
|
|
5599
7295
|
` ${dim("Paste or fetch. Shows countdown. Optional.")}`,
|
|
@@ -5829,8 +7525,9 @@ async function cmdMainMenu() {
|
|
|
5829
7525
|
|
|
5830
7526
|
// Context filter: hide irrelevant commands
|
|
5831
7527
|
const contextFilter = (cmd) => {
|
|
5832
|
-
if (!hasVault && ["pulse", "replay", "diff", "sync", "capture", "who", "context", "backup", "restore", "doctor"].includes(cmd)) return false;
|
|
7528
|
+
if (!hasVault && ["pulse", "open", "shortcut", "menubar", "replay", "diff", "sync", "capture", "who", "context", "backup", "restore", "doctor"].includes(cmd)) return false;
|
|
5833
7529
|
if (!hasPrayer && cmd === "prayer") return false;
|
|
7530
|
+
if (cmd === "menubar" && process.platform !== "darwin") return false;
|
|
5834
7531
|
return true;
|
|
5835
7532
|
};
|
|
5836
7533
|
|
|
@@ -5886,7 +7583,430 @@ async function cmdMainMenu() {
|
|
|
5886
7583
|
}
|
|
5887
7584
|
|
|
5888
7585
|
// ─── Comprehensive Update (10-step interactive) ─────────────────────────────
|
|
7586
|
+
// ── Update machine subcommands (local state ops: no license, no network) ────
|
|
7587
|
+
|
|
7588
|
+
// Resolve the bundle source to verify against: the journal's staged bundleDir
|
|
7589
|
+
// first (verify must check the SAME source the apply stage used), then this
|
|
7590
|
+
// CLI's own bundle, then a downloaded payload at ~/.mover/src.
|
|
7591
|
+
function resolveVerifyBundleDir(journal) {
|
|
7592
|
+
const candidates = [
|
|
7593
|
+
journal && journal.stages && journal.stages.stage && journal.stages.stage.bundleDir,
|
|
7594
|
+
__dirname,
|
|
7595
|
+
path.join(os.homedir(), ".mover"),
|
|
7596
|
+
].filter(Boolean);
|
|
7597
|
+
for (const c of candidates) {
|
|
7598
|
+
if (fs.existsSync(path.join(c, "src", "workflows"))) return c;
|
|
7599
|
+
}
|
|
7600
|
+
return null;
|
|
7601
|
+
}
|
|
7602
|
+
|
|
7603
|
+
// Engine migrations DECLARED per release (src/system/engine-migrations.json in
|
|
7604
|
+
// the bundle). Finalize cross-checks the migration receipt against every
|
|
7605
|
+
// declaration in (fromVersion, toVersion]: an empty migrated list is only
|
|
7606
|
+
// valid when that window truly declares nothing (sol re-score #4 part 10:
|
|
7607
|
+
// "an empty migration list proves process completion rather than semantic
|
|
7608
|
+
// completeness"). With no journal, fromVersion is unknown and only the target
|
|
7609
|
+
// version's own declarations bind. MOVER_TEST_MIGRATIONS_FILE injects a
|
|
7610
|
+
// manifest for hermetic tests, same pattern as MOVER_TEST_CRASH_STAGE.
|
|
7611
|
+
function loadOwedEngineMigrations(journal, toVersion) {
|
|
7612
|
+
const candidates = [];
|
|
7613
|
+
if (process.env.MOVER_TEST_MIGRATIONS_FILE) candidates.push(process.env.MOVER_TEST_MIGRATIONS_FILE);
|
|
7614
|
+
const bundleDir = resolveVerifyBundleDir(journal);
|
|
7615
|
+
if (bundleDir) candidates.push(path.join(bundleDir, "src", "system", "engine-migrations.json"));
|
|
7616
|
+
candidates.push(path.join(__dirname, "src", "system", "engine-migrations.json"));
|
|
7617
|
+
for (const c of candidates) {
|
|
7618
|
+
let decl;
|
|
7619
|
+
try { decl = JSON.parse(fs.readFileSync(c, "utf8")).declarations; } catch { continue; }
|
|
7620
|
+
if (!decl || typeof decl !== "object" || Array.isArray(decl)) continue;
|
|
7621
|
+
const fromV = journal && journal.fromVersion ? String(journal.fromVersion).replace(/^[vV]/, "") : null;
|
|
7622
|
+
const owed = [];
|
|
7623
|
+
for (const [ver, entries] of Object.entries(decl)) {
|
|
7624
|
+
const inWindow = fromV
|
|
7625
|
+
? compareVersions(ver, fromV) > 0 && compareVersions(ver, toVersion) <= 0
|
|
7626
|
+
: compareVersions(ver, toVersion) === 0;
|
|
7627
|
+
if (!inWindow || !Array.isArray(entries)) continue;
|
|
7628
|
+
for (const e of entries) {
|
|
7629
|
+
if (e && typeof e.path === "string" && e.path) owed.push({ version: ver, path: e.path, note: e.note });
|
|
7630
|
+
}
|
|
7631
|
+
}
|
|
7632
|
+
return { owed, source: c };
|
|
7633
|
+
}
|
|
7634
|
+
// No manifest anywhere (an older staged payload AND a stripped CLI copy):
|
|
7635
|
+
// the caller records this honestly in the journal instead of passing it
|
|
7636
|
+
// off as a checked window.
|
|
7637
|
+
return { owed: [], source: null };
|
|
7638
|
+
}
|
|
7639
|
+
|
|
7640
|
+
// `moveros update --status`: print the journal (the machine-readable handoff
|
|
7641
|
+
// the /update workflow reads). Honest null when no update is in flight.
|
|
7642
|
+
function cmdUpdateStatus() {
|
|
7643
|
+
const journal = loadUpdateJournal();
|
|
7644
|
+
if (!journal) {
|
|
7645
|
+
barLn(dim(" No update journal found. Nothing in progress."));
|
|
7646
|
+
outro("No update in progress.");
|
|
7647
|
+
return;
|
|
7648
|
+
}
|
|
7649
|
+
barLn(bold(" Update journal"));
|
|
7650
|
+
barLn();
|
|
7651
|
+
barLn(` Run: ${journal.runId} ${dim(`(${journal.mode})`)}`);
|
|
7652
|
+
barLn(` ${dim(journal.fromVersion || "?")} ${dim("->")} ${bold(journal.toVersion || "?")} ${journal.completedAt ? green(journal.rolledBack ? "rolled back" : "completed") : yellow("in progress")}`);
|
|
7653
|
+
barLn();
|
|
7654
|
+
for (const s of UPDATE_STAGES) {
|
|
7655
|
+
const st = (journal.stages && journal.stages[s]) || { status: "pending" };
|
|
7656
|
+
const icon = st.status === "done" ? "ok" : st.status === "failed" ? "fail" : "warn";
|
|
7657
|
+
statusLine(icon, ` ${s}`, st.status);
|
|
7658
|
+
}
|
|
7659
|
+
barLn();
|
|
7660
|
+
barLn(dim(` Journal: ${updateJournalPath()}`));
|
|
7661
|
+
outro(journal.completedAt ? "Update complete." : "Update in progress. Finish with /update, then moveros update --finalize.");
|
|
7662
|
+
}
|
|
7663
|
+
|
|
7664
|
+
// `moveros update --finalize`: the /update workflow calls this after Engine
|
|
7665
|
+
// migration. It records the migrate stage, VERIFIES the applied files against
|
|
7666
|
+
// the staged bundle, and stamps .mover-version ONLY on a pass. This is the
|
|
7667
|
+
// single place the interactive path is allowed to stamp, so the stamp always
|
|
7668
|
+
// means "verified", never "assumed".
|
|
7669
|
+
async function cmdUpdateFinalize(opts) {
|
|
7670
|
+
const journal = loadUpdateJournal();
|
|
7671
|
+
let vaultPath = journal && journal.vaultPath && fs.existsSync(journal.vaultPath) ? journal.vaultPath : resolveVaultPath(opts.vault);
|
|
7672
|
+
if (!vaultPath) {
|
|
7673
|
+
outro(red("No Mover OS vault found. Use: moveros update --finalize --vault /path"));
|
|
7674
|
+
process.exit(EXIT.NOT_FOUND);
|
|
7675
|
+
}
|
|
7676
|
+
let markerOk = false;
|
|
7677
|
+
try {
|
|
7678
|
+
const mp = path.join(vaultPath, ".mover-version");
|
|
7679
|
+
markerOk = fs.statSync(mp).isFile() && fs.readFileSync(mp, "utf8").trim().length > 0;
|
|
7680
|
+
} catch {}
|
|
7681
|
+
if (!markerOk) {
|
|
7682
|
+
outro(red(`No Mover OS install found at ${vaultPath}. Nothing to finalize.`));
|
|
7683
|
+
process.exit(EXIT.NOT_FOUND);
|
|
7684
|
+
}
|
|
7685
|
+
|
|
7686
|
+
const j = journal || newUpdateJournal("finalize", null, require("./package.json").version, vaultPath);
|
|
7687
|
+
|
|
7688
|
+
// MIGRATION RECEIPT (sol re-score #3 refutation: finalize marked migrate
|
|
7689
|
+
// done without verifying any Engine migration happened). The /update
|
|
7690
|
+
// workflow writes ~/.mover/update-receipt.json AFTER its Engine steps:
|
|
7691
|
+
// { runId, toVersion, migrated: [{path, sha256}], checked: [paths], note }
|
|
7692
|
+
// runId comes from the journal (~/.mover/update-state.json); null when no
|
|
7693
|
+
// journal existed. migrated may be EMPTY (no migration owed this release)
|
|
7694
|
+
// but the receipt itself is mandatory, and every migrated file must still
|
|
7695
|
+
// be on disk with the recorded post-migration hash - proof the work
|
|
7696
|
+
// happened and survived. Fail-closed: no receipt, no stamp.
|
|
7697
|
+
const receiptPath = path.join(os.homedir(), ".mover", "update-receipt.json");
|
|
7698
|
+
let receipt = null;
|
|
7699
|
+
try { receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8")); } catch {}
|
|
7700
|
+
const expectVersion = (j.toVersion || require("./package.json").version).replace(/^V/i, "");
|
|
7701
|
+
const receiptProblems = [];
|
|
7702
|
+
const owedMigrations = loadOwedEngineMigrations(journal, expectVersion);
|
|
7703
|
+
if (!receipt) {
|
|
7704
|
+
receiptProblems.push(`missing or unreadable: ${receiptPath}`);
|
|
7705
|
+
} else {
|
|
7706
|
+
const expectedRunId = journal ? journal.runId : null;
|
|
7707
|
+
if ((receipt.runId || null) !== expectedRunId) receiptProblems.push(`runId ${JSON.stringify(receipt.runId || null)} does not match this run ${JSON.stringify(expectedRunId)}`);
|
|
7708
|
+
if (String(receipt.toVersion || "").replace(/^V/i, "") !== expectVersion) receiptProblems.push(`toVersion ${JSON.stringify(receipt.toVersion)} does not match ${expectVersion}`);
|
|
7709
|
+
// Receipt paths must stay INSIDE the vault. Lexical checks (absolute,
|
|
7710
|
+
// ..-traversal) are not enough: a symlink WITHIN the vault pointing
|
|
7711
|
+
// outside would pass lexically but resolve elsewhere (sol re-score #5
|
|
7712
|
+
// refutation). resolveContained realpath-resolves both the vault root and
|
|
7713
|
+
// the entry and requires the entry to remain beneath the resolved root,
|
|
7714
|
+
// so a symlink escape is rejected. Returns the real absolute path or null.
|
|
7715
|
+
let realVaultRoot = null;
|
|
7716
|
+
try { realVaultRoot = fs.realpathSync(vaultPath); } catch { realVaultRoot = path.resolve(vaultPath); }
|
|
7717
|
+
const resolveContained = (rel) => {
|
|
7718
|
+
if (typeof rel !== "string" || rel.length === 0) return { err: "path is empty" };
|
|
7719
|
+
if (path.isAbsolute(rel) || rel.split(/[\\/]+/).includes("..")) return { err: "must be vault-relative without traversal" };
|
|
7720
|
+
const joined = path.join(vaultPath, rel);
|
|
7721
|
+
let real;
|
|
7722
|
+
try { real = fs.realpathSync(joined); } catch (e) { return { err: e.code === "ENOENT" ? "does not exist in the vault" : "not resolvable on disk" }; }
|
|
7723
|
+
const prefix = realVaultRoot.endsWith(path.sep) ? realVaultRoot : realVaultRoot + path.sep;
|
|
7724
|
+
if (real !== realVaultRoot && !real.startsWith(prefix)) return { err: "resolves outside the vault (symlink escape)" };
|
|
7725
|
+
return { abs: real };
|
|
7726
|
+
};
|
|
7727
|
+
if (!Array.isArray(receipt.migrated)) {
|
|
7728
|
+
receiptProblems.push("migrated must be an array ([] is valid when no migration was owed)");
|
|
7729
|
+
} else {
|
|
7730
|
+
for (const m of receipt.migrated) {
|
|
7731
|
+
const rel = m && m.path;
|
|
7732
|
+
if (typeof rel !== "string" || !rel) { receiptProblems.push("a migrated entry has no path"); continue; }
|
|
7733
|
+
const c = resolveContained(rel);
|
|
7734
|
+
if (c.err) { receiptProblems.push(`${rel}: migrated path ${c.err}`); continue; }
|
|
7735
|
+
try {
|
|
7736
|
+
const actual = require("crypto").createHash("sha256").update(fs.readFileSync(c.abs, "utf8").replace(/\r\n/g, "\n")).digest("hex");
|
|
7737
|
+
if (actual !== m.sha256) receiptProblems.push(`${rel}: on-disk hash does not match the receipt (file changed or migration reverted)`);
|
|
7738
|
+
} catch {
|
|
7739
|
+
receiptProblems.push(`${rel}: listed as migrated but not readable on disk`);
|
|
7740
|
+
}
|
|
7741
|
+
}
|
|
7742
|
+
// SEMANTIC COMPLETENESS: the receipt must cover every Engine migration
|
|
7743
|
+
// this release window DECLARES (src/system/engine-migrations.json). An
|
|
7744
|
+
// empty migrated list stops meaning "the process ran" and starts
|
|
7745
|
+
// meaning "this window declares nothing owed". FAIL-CLOSED when the
|
|
7746
|
+
// manifest cannot be found (sol re-score #5 refutation: recording
|
|
7747
|
+
// "unavailable" and proceeding with zero owed migrations let a stripped
|
|
7748
|
+
// bundle skip the check silently; a missing declaration source is a
|
|
7749
|
+
// refusal, not a pass).
|
|
7750
|
+
if (!owedMigrations.source) {
|
|
7751
|
+
receiptProblems.push("engine-migrations.json is unavailable in every bundle source: cannot prove the release owes no migration (fail-closed)");
|
|
7752
|
+
}
|
|
7753
|
+
const migratedPaths = new Set(receipt.migrated.map((m) => m && m.path).filter(Boolean));
|
|
7754
|
+
for (const d of owedMigrations.owed) {
|
|
7755
|
+
if (!migratedPaths.has(d.path)) {
|
|
7756
|
+
receiptProblems.push(`${d.path}: declared as owed by release ${d.version}${d.note ? ` (${d.note})` : ""} but absent from the receipt's migrated list`);
|
|
7757
|
+
}
|
|
7758
|
+
}
|
|
7759
|
+
// `checked` is the workflow's claim of what it INSPECTED - the basis for
|
|
7760
|
+
// "no (further) migration owed". The inspection itself cannot be
|
|
7761
|
+
// re-verified, but the claim can: every named file must actually exist
|
|
7762
|
+
// in the vault, and an empty-migration receipt must name at least one
|
|
7763
|
+
// inspected file (sol re-score #4: the field was counted in the journal
|
|
7764
|
+
// but never validated, so a fabricated receipt passed the gate).
|
|
7765
|
+
if (!Array.isArray(receipt.checked)) {
|
|
7766
|
+
receiptProblems.push("checked must be an array of vault-relative paths");
|
|
7767
|
+
} else {
|
|
7768
|
+
if (receipt.migrated.length === 0 && receipt.checked.length === 0) {
|
|
7769
|
+
receiptProblems.push("migrated is empty and checked is empty: name the files you inspected to conclude no migration was owed");
|
|
7770
|
+
}
|
|
7771
|
+
for (const rel of receipt.checked) {
|
|
7772
|
+
const c = resolveContained(rel);
|
|
7773
|
+
if (c.err) { receiptProblems.push(`${JSON.stringify(rel)}: checked path ${c.err}`); continue; }
|
|
7774
|
+
try {
|
|
7775
|
+
if (!fs.statSync(c.abs).isFile()) receiptProblems.push(`${rel}: listed as checked but is not a file`);
|
|
7776
|
+
} catch {
|
|
7777
|
+
receiptProblems.push(`${rel}: listed as checked but does not exist in the vault`);
|
|
7778
|
+
}
|
|
7779
|
+
}
|
|
7780
|
+
}
|
|
7781
|
+
}
|
|
7782
|
+
}
|
|
7783
|
+
if (receiptProblems.length) {
|
|
7784
|
+
journalStage(j, "migrate", "failed", { problems: receiptProblems.slice(0, 20) });
|
|
7785
|
+
barLn(red(" Migration receipt check failed:"));
|
|
7786
|
+
for (const p of receiptProblems.slice(0, 8)) barLn(` ${red("x")} ${p}`);
|
|
7787
|
+
barLn();
|
|
7788
|
+
barLn(dim(" /update must write ~/.mover/update-receipt.json after its Engine"));
|
|
7789
|
+
barLn(dim(" migration steps: { runId (from ~/.mover/update-state.json, or null),"));
|
|
7790
|
+
barLn(dim(" toVersion, migrated: [{path, sha256}], checked: [paths] }."));
|
|
7791
|
+
barLn(dim(" An empty migrated array is valid when no migration was owed,"));
|
|
7792
|
+
barLn(dim(" but then checked must name the real vault files you inspected."));
|
|
7793
|
+
outro(red("Not stamped: no verifiable migration receipt."));
|
|
7794
|
+
process.exit(EXIT.ERROR);
|
|
7795
|
+
}
|
|
7796
|
+
// MACHINE-DERIVED inspection evidence (sol re-score #6 part 10: "checked
|
|
7797
|
+
// remains a workflow-authored inspection claim; make inspection evidence
|
|
7798
|
+
// machine-derived"). Rather than trusting only the workflow's `checked`
|
|
7799
|
+
// list, finalize independently OBSERVES the Engine directory: it lists the
|
|
7800
|
+
// top-level Engine files (skipping Dailies and other subfolders) with a
|
|
7801
|
+
// content hash each, and confirms every `checked` entry that lives under
|
|
7802
|
+
// Engine is corroborated by that observation. The observed set is recorded
|
|
7803
|
+
// in the journal, so the audit trail carries machine-witnessed evidence of
|
|
7804
|
+
// the Engine state at stamp time, not a self-reported claim.
|
|
7805
|
+
const engineObserved = (() => {
|
|
7806
|
+
const engineDir = path.join(vaultPath, "02_Areas", "Engine");
|
|
7807
|
+
// Origin classification (sol #8 part 10 residual: "checked records
|
|
7808
|
+
// workflow-authored inspection evidence that cannot be reconstructed
|
|
7809
|
+
// mechanically"). The shipped-default fingerprint set (every historical
|
|
7810
|
+
// hash of each non-sentinel Engine default) lets the MACHINE reconstruct
|
|
7811
|
+
// the inspection's basis: a file either matches a shipped default
|
|
7812
|
+
// byte-for-byte (nothing to migrate), or is user-modified (migration
|
|
7813
|
+
// decisions concern it), or is outside the shipped set entirely
|
|
7814
|
+
// (sentinels + user-created files). Recorded per file in the journal, so
|
|
7815
|
+
// the audit trail carries the machine's own classification, not only the
|
|
7816
|
+
// workflow's word.
|
|
7817
|
+
let fingerprints = null;
|
|
7818
|
+
try { fingerprints = JSON.parse(fs.readFileSync(path.join(__dirname, "src", "dashboard", "lib", "engine-default-fingerprints.json"), "utf8")).files; } catch {}
|
|
7819
|
+
const fpHash = (text) => require("crypto").createHash("sha256").update(String(text).replace(/\r\n/g, "\n").trim()).digest("hex");
|
|
7820
|
+
try {
|
|
7821
|
+
const files = fs.readdirSync(engineDir, { withFileTypes: true })
|
|
7822
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
7823
|
+
.map((e) => {
|
|
7824
|
+
const rel = path.join("02_Areas", "Engine", e.name);
|
|
7825
|
+
let sha = null;
|
|
7826
|
+
let origin = "unclassified";
|
|
7827
|
+
try {
|
|
7828
|
+
const text = fs.readFileSync(path.join(engineDir, e.name), "utf8");
|
|
7829
|
+
sha = require("crypto").createHash("sha256").update(text.replace(/\r\n/g, "\n")).digest("hex").slice(0, 16);
|
|
7830
|
+
if (fingerprints) {
|
|
7831
|
+
const known = fingerprints[e.name];
|
|
7832
|
+
origin = Array.isArray(known)
|
|
7833
|
+
? (known.includes(fpHash(text)) ? "matches-shipped-default" : "user-modified")
|
|
7834
|
+
: "not-a-shipped-default"; // sentinels + user-created files
|
|
7835
|
+
}
|
|
7836
|
+
} catch {}
|
|
7837
|
+
return { path: rel, sha, origin };
|
|
7838
|
+
});
|
|
7839
|
+
return { present: true, count: files.length, files, fingerprintSource: fingerprints ? "engine-default-fingerprints.json" : "unavailable" };
|
|
7840
|
+
} catch {
|
|
7841
|
+
return { present: false, count: 0, files: [], fingerprintSource: fingerprints ? "engine-default-fingerprints.json" : "unavailable" };
|
|
7842
|
+
}
|
|
7843
|
+
})();
|
|
7844
|
+
// Corroborate: any `checked` entry that claims an Engine file must appear in
|
|
7845
|
+
// the machine-observed set. A claim about an Engine file the machine cannot
|
|
7846
|
+
// see is a fabricated inspection, caught here rather than trusted.
|
|
7847
|
+
const observedEngine = new Set(engineObserved.files.map((f) => f.path.split(path.sep).join("/")));
|
|
7848
|
+
const uncorroborated = receipt.checked
|
|
7849
|
+
.map((r) => String(r).split(path.sep).join("/"))
|
|
7850
|
+
.filter((r) => r.startsWith("02_Areas/Engine/") && !r.includes("/Dailies/") && !observedEngine.has(r));
|
|
7851
|
+
if (uncorroborated.length && engineObserved.present) {
|
|
7852
|
+
journalStage(j, "migrate", "failed", { problems: uncorroborated.map((r) => `${r}: checked as an Engine file but not observed in the Engine directory (fabricated inspection)`) });
|
|
7853
|
+
barLn(red(" Migration receipt check failed (machine corroboration):"));
|
|
7854
|
+
for (const r of uncorroborated.slice(0, 8)) barLn(` ${red("x")} ${r}: not observed in the Engine directory`);
|
|
7855
|
+
outro(red("Not stamped: a checked Engine file was not machine-corroborated."));
|
|
7856
|
+
process.exit(EXIT.ERROR);
|
|
7857
|
+
}
|
|
7858
|
+
journalStage(j, "migrate", "done", {
|
|
7859
|
+
owner: "workflow",
|
|
7860
|
+
finalizedBy: "moveros update --finalize",
|
|
7861
|
+
receipt: { migrated: receipt.migrated.length, checked: receipt.checked.length },
|
|
7862
|
+
// "unavailable" = no declarations manifest found in any bundle source;
|
|
7863
|
+
// recorded rather than passed off as a checked window.
|
|
7864
|
+
migrations: { manifest: owedMigrations.source ? "checked" : "unavailable", declaredOwed: owedMigrations.owed.length },
|
|
7865
|
+
// Machine-witnessed Engine state at stamp time (independent of the
|
|
7866
|
+
// workflow's `checked` claim).
|
|
7867
|
+
engineObserved,
|
|
7868
|
+
});
|
|
7869
|
+
maybeCrashForTest("migrate");
|
|
7870
|
+
journalStage(j, "verify", "started");
|
|
7871
|
+
maybeCrashForTest("verify");
|
|
7872
|
+
|
|
7873
|
+
const bundleDir = resolveVerifyBundleDir(j);
|
|
7874
|
+
let result;
|
|
7875
|
+
let scope;
|
|
7876
|
+
if (bundleDir) {
|
|
7877
|
+
scope = "full";
|
|
7878
|
+
result = verifyUpdateApplied(bundleDir, vaultPath, { templates: "content" });
|
|
7879
|
+
} else {
|
|
7880
|
+
// No source available to verify against (e.g. an Engine-only migration
|
|
7881
|
+
// with no staged payload). Degrade honestly to the conflicts gate; never
|
|
7882
|
+
// fabricate a full verification.
|
|
7883
|
+
scope = "conflicts-only";
|
|
7884
|
+
result = { ok: true, failures: [] };
|
|
7885
|
+
const conflictsPath = path.join(os.homedir(), ".mover", "conflicts.json");
|
|
7886
|
+
if (fs.existsSync(conflictsPath)) {
|
|
7887
|
+
try {
|
|
7888
|
+
const data = JSON.parse(fs.readFileSync(conflictsPath, "utf8"));
|
|
7889
|
+
result.failures = (data.files || []).map((f) => ({ file: f.file, reason: "unresolved-conflict" }));
|
|
7890
|
+
} catch {
|
|
7891
|
+
result.failures = [{ file: "conflicts.json", reason: "unreadable" }];
|
|
7892
|
+
}
|
|
7893
|
+
result.ok = result.failures.length === 0;
|
|
7894
|
+
}
|
|
7895
|
+
}
|
|
7896
|
+
|
|
7897
|
+
if (!result.ok) {
|
|
7898
|
+
journalStage(j, "verify", "failed", { scope, failures: result.failures.slice(0, 50) });
|
|
7899
|
+
barLn(red(` Verification failed: ${result.failures.length} file${result.failures.length > 1 ? "s" : ""} not at the updated version.`));
|
|
7900
|
+
for (const f of result.failures.slice(0, 12)) barLn(` ${red("x")} ${f.file} ${dim(`(${f.reason})`)}`);
|
|
7901
|
+
if (result.failures.length > 12) barLn(dim(` ...and ${result.failures.length - 12} more`));
|
|
7902
|
+
barLn();
|
|
7903
|
+
barLn(dim(" The version was NOT stamped. Resolve the files above"));
|
|
7904
|
+
barLn(dim(" (usually: re-run moveros update, or finish /update conflict merges),"));
|
|
7905
|
+
barLn(dim(" then run moveros update --finalize again."));
|
|
7906
|
+
outro(red("Not stamped: verification failed."));
|
|
7907
|
+
process.exit(EXIT.ERROR);
|
|
7908
|
+
}
|
|
7909
|
+
|
|
7910
|
+
journalStage(j, "verify", "done", { scope, checkedAgainst: bundleDir || null });
|
|
7911
|
+
journalStage(j, "stamp", "started");
|
|
7912
|
+
maybeCrashForTest("stamp");
|
|
7913
|
+
const version = (j.toVersion || require("./package.json").version).replace(/^V/i, "");
|
|
7914
|
+
fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${version}\n`, "utf8");
|
|
7915
|
+
j.completedAt = new Date().toISOString();
|
|
7916
|
+
journalStage(j, "stamp", "done", { version });
|
|
7917
|
+
// Consume the receipt: a stamped run's receipt must never validate a
|
|
7918
|
+
// FUTURE finalize (the null-journal path has no runId to collide on).
|
|
7919
|
+
try { fs.renameSync(receiptPath, `${receiptPath.replace(/\.json$/, "")}.used.json`); } catch {}
|
|
7920
|
+
statusLine("ok", "Verified", scope === "full" ? "workflows, templates, hooks all at the updated version" : "no unresolved conflicts");
|
|
7921
|
+
statusLine("ok", "Stamped", `.mover-version -> ${version}`);
|
|
7922
|
+
outro(green(`Update finalized: verified and stamped ${version}.`));
|
|
7923
|
+
}
|
|
7924
|
+
|
|
7925
|
+
// `moveros update --rollback`: restore the vault + agent artifacts from the
|
|
7926
|
+
// backup recorded in the journal (or the newest rollback-capable backup).
|
|
7927
|
+
// Never touches the bundle dir: on npm installs that is a read-only global
|
|
7928
|
+
// package, and it is not the rollback target anyway. Engine files are never
|
|
7929
|
+
// in these backups, so they are never touched here either.
|
|
7930
|
+
async function cmdUpdateRollback(opts) {
|
|
7931
|
+
const home = os.homedir();
|
|
7932
|
+
const journal = loadUpdateJournal();
|
|
7933
|
+
let backupDir = journal && journal.stages && journal.stages.backup && journal.stages.backup.backupDir;
|
|
7934
|
+
const dirArg = opts.rest.find((a) => a.startsWith("--dir="));
|
|
7935
|
+
if (dirArg) backupDir = path.resolve(dirArg.slice(6));
|
|
7936
|
+
if (!backupDir || !fs.existsSync(path.join(backupDir, "backup-manifest.json"))) {
|
|
7937
|
+
const root = path.join(home, ".mover", "backups");
|
|
7938
|
+
const cands = fs.existsSync(root)
|
|
7939
|
+
? fs.readdirSync(root).filter((d) => d.startsWith("pre-update-") && fs.existsSync(path.join(root, d, "backup-manifest.json"))).sort()
|
|
7940
|
+
: [];
|
|
7941
|
+
if (cands.length) backupDir = path.join(root, cands[cands.length - 1]);
|
|
7942
|
+
}
|
|
7943
|
+
if (!backupDir || !fs.existsSync(path.join(backupDir, "backup-manifest.json"))) {
|
|
7944
|
+
barLn(red(" No rollback-capable backup found (backup-manifest.json missing)."));
|
|
7945
|
+
barLn(dim(" Backups made before this feature can be restored by hand from ~/.mover/backups/."));
|
|
7946
|
+
outro(red("Rollback aborted: nothing to restore from."));
|
|
7947
|
+
process.exit(EXIT.NOT_FOUND);
|
|
7948
|
+
}
|
|
7949
|
+
|
|
7950
|
+
let bm;
|
|
7951
|
+
try {
|
|
7952
|
+
bm = JSON.parse(fs.readFileSync(path.join(backupDir, "backup-manifest.json"), "utf8"));
|
|
7953
|
+
} catch {
|
|
7954
|
+
outro(red("Rollback aborted: backup-manifest.json is unreadable."));
|
|
7955
|
+
process.exit(EXIT.ERROR);
|
|
7956
|
+
}
|
|
7957
|
+
|
|
7958
|
+
let restored = 0, removed = 0, failed = 0;
|
|
7959
|
+
for (const e of bm.entries || []) {
|
|
7960
|
+
try {
|
|
7961
|
+
if (e.existedBefore && e.backup) {
|
|
7962
|
+
const src = path.join(backupDir, e.backup);
|
|
7963
|
+
if (!fs.existsSync(src)) { failed++; continue; }
|
|
7964
|
+
fs.mkdirSync(path.dirname(e.original), { recursive: true });
|
|
7965
|
+
// Unlink first: the original may be a hard link into the bundle
|
|
7966
|
+
// source; copying through the link would write INTO the (possibly
|
|
7967
|
+
// read-only, npm-owned) bundle. A fresh inode never can.
|
|
7968
|
+
try { fs.rmSync(e.original, { force: true }); } catch {}
|
|
7969
|
+
fs.copyFileSync(src, e.original);
|
|
7970
|
+
restored++;
|
|
7971
|
+
} else if (!e.existedBefore) {
|
|
7972
|
+
if (fs.existsSync(e.original)) { fs.rmSync(e.original, { force: true }); removed++; }
|
|
7973
|
+
}
|
|
7974
|
+
} catch {
|
|
7975
|
+
failed++;
|
|
7976
|
+
}
|
|
7977
|
+
}
|
|
7978
|
+
|
|
7979
|
+
// Restore the pristine base snapshots captured with the backup so the next
|
|
7980
|
+
// update classifies the rolled-back files against the right bases.
|
|
7981
|
+
try {
|
|
7982
|
+
const snapInstalled = path.join(backupDir, "mover-state", "installed");
|
|
7983
|
+
if (fs.existsSync(snapInstalled)) {
|
|
7984
|
+
fs.cpSync(snapInstalled, path.join(home, ".mover", "installed"), { recursive: true, force: true });
|
|
7985
|
+
}
|
|
7986
|
+
} catch {}
|
|
7987
|
+
// Conflicts recorded by the rolled-back run are void now.
|
|
7988
|
+
try { fs.rmSync(path.join(home, ".mover", "conflicts.json"), { force: true }); } catch {}
|
|
7989
|
+
|
|
7990
|
+
if (journal && !journal.completedAt) {
|
|
7991
|
+
journal.rolledBack = true;
|
|
7992
|
+
journal.completedAt = new Date().toISOString();
|
|
7993
|
+
saveUpdateJournal(journal);
|
|
7994
|
+
}
|
|
7995
|
+
|
|
7996
|
+
statusLine("ok", "Rollback", `${restored} restored, ${removed} removed${failed ? `, ${red(`${failed} failed`)}` : ""}`);
|
|
7997
|
+
barLn(dim(` From: ${backupDir}`));
|
|
7998
|
+
barLn(dim(" Engine files were never in this backup and were not touched."));
|
|
7999
|
+
barLn(dim(" The version marker was not changed (interactive updates never stamp before verify)."));
|
|
8000
|
+
outro(failed ? yellow(`Rollback finished with ${failed} failure${failed > 1 ? "s" : ""}.`) : green("Rollback complete."));
|
|
8001
|
+
if (failed) process.exit(EXIT.ERROR);
|
|
8002
|
+
}
|
|
8003
|
+
|
|
5889
8004
|
async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
8005
|
+
// ── State-machine subcommands: local, no license, no self-update hop ──
|
|
8006
|
+
if (opts.rest.includes("--status")) return cmdUpdateStatus();
|
|
8007
|
+
if (opts.rest.includes("--finalize")) return cmdUpdateFinalize(opts);
|
|
8008
|
+
if (opts.rest.includes("--rollback")) return cmdUpdateRollback(opts);
|
|
8009
|
+
|
|
5890
8010
|
const isQuick = opts.rest.includes("--quick");
|
|
5891
8011
|
|
|
5892
8012
|
// Step 1: CLI Self-Update
|
|
@@ -5910,8 +8030,10 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
5910
8030
|
try {
|
|
5911
8031
|
try {
|
|
5912
8032
|
execSync("npm i -g mover-os", { stdio: "ignore", timeout: 60000 });
|
|
5913
|
-
} catch {
|
|
5914
|
-
// Retry with sudo — use inherit so password prompt shows
|
|
8033
|
+
} catch (err) {
|
|
8034
|
+
// Retry with sudo — use inherit so password prompt shows.
|
|
8035
|
+
// sudo does not exist on Windows; surface the real npm error there.
|
|
8036
|
+
if (process.platform === "win32") throw err;
|
|
5915
8037
|
sp.stop(dim("Needs elevated permissions..."));
|
|
5916
8038
|
execSync("sudo npm i -g mover-os", { stdio: "inherit", timeout: 120000 });
|
|
5917
8039
|
}
|
|
@@ -5946,6 +8068,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
5946
8068
|
}
|
|
5947
8069
|
statusLine("ok", "Vault", path.basename(vaultPath));
|
|
5948
8070
|
|
|
8071
|
+
// An update must target an EXISTING install. resolveVaultPath trusts an
|
|
8072
|
+
// explicit --vault without existsSync (terra F5), so a typo'd or wrong path
|
|
8073
|
+
// would otherwise get a fresh structure minted and stamped as "current",
|
|
8074
|
+
// shadowing the real install. Refuse unless a real, non-empty install marker
|
|
8075
|
+
// is present (sol flaw #5: an empty file or a directory named .mover-version
|
|
8076
|
+
// must not pass the guard).
|
|
8077
|
+
let installMarkerOk = false;
|
|
8078
|
+
try {
|
|
8079
|
+
const mp = path.join(vaultPath, ".mover-version");
|
|
8080
|
+
installMarkerOk = fs.statSync(mp).isFile() && fs.readFileSync(mp, "utf8").trim().length > 0;
|
|
8081
|
+
} catch {}
|
|
8082
|
+
if (!installMarkerOk) {
|
|
8083
|
+
outro(red(`No Mover OS install found at ${vaultPath}. Run ${bold("moveros install")} first, or pass the correct --vault.`));
|
|
8084
|
+
process.exit(EXIT.NOT_FOUND);
|
|
8085
|
+
}
|
|
8086
|
+
|
|
5949
8087
|
// Step 3: Key Validation
|
|
5950
8088
|
let updateKey = opts.key;
|
|
5951
8089
|
if (!updateKey) {
|
|
@@ -6003,9 +8141,24 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6003
8141
|
newVer = pkg.version || newVer;
|
|
6004
8142
|
} catch {}
|
|
6005
8143
|
|
|
6006
|
-
// ──
|
|
6007
|
-
//
|
|
6008
|
-
//
|
|
8144
|
+
// ── Update journal: ONE resumable state machine for CLI + /update ──
|
|
8145
|
+
// Created here, after preflight facts (install marker, license, staged
|
|
8146
|
+
// bundle) are established. Resumes an interrupted run of the same mode and
|
|
8147
|
+
// target version so the original backup dir is preserved; every stage below
|
|
8148
|
+
// is idempotent, so resume = re-run from the journal.
|
|
8149
|
+
const journalMode = isQuick ? "quick" : "interactive";
|
|
8150
|
+
let journal = loadUpdateJournal();
|
|
8151
|
+
const resuming = !!(journal && !journal.completedAt && journal.toVersion === newVer && journal.mode === journalMode && journal.vaultPath === vaultPath);
|
|
8152
|
+
if (!resuming) {
|
|
8153
|
+
journal = newUpdateJournal(journalMode, installedVer, newVer, vaultPath);
|
|
8154
|
+
} else {
|
|
8155
|
+
barLn(dim(` Resuming interrupted update (started ${journal.startedAt}).`));
|
|
8156
|
+
barLn();
|
|
8157
|
+
}
|
|
8158
|
+
journalStage(journal, "preflight", "done", { installMarker: true });
|
|
8159
|
+
maybeCrashForTest("preflight");
|
|
8160
|
+
journalStage(journal, "stage", "done", { bundleDir });
|
|
8161
|
+
maybeCrashForTest("stage");
|
|
6009
8162
|
|
|
6010
8163
|
const detectedAgents = AGENTS.filter((a) => a.detect());
|
|
6011
8164
|
const selectedIds = detectedAgents.map((a) => a.id);
|
|
@@ -6016,8 +8169,34 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6016
8169
|
const changes = detectChanges(bundleDir, vaultPath, selectedIds);
|
|
6017
8170
|
const totalChanged = countChanges(changes);
|
|
6018
8171
|
displayChangeSummary(changes, installedVer, newVer);
|
|
6019
|
-
if (totalChanged === 0
|
|
6020
|
-
|
|
8172
|
+
if (totalChanged === 0 && !resuming) {
|
|
8173
|
+
// Nothing to do and no interrupted run to finish: close the journal
|
|
8174
|
+
// honestly rather than leave a phantom in-progress run.
|
|
8175
|
+
for (const s of ["backup", "apply", "migrate", "verify", "stamp"]) journalStage(journal, s, "skipped", { reason: "no-changes" });
|
|
8176
|
+
journal.completedAt = new Date().toISOString();
|
|
8177
|
+
saveUpdateJournal(journal);
|
|
8178
|
+
outro(green("Already up to date."));
|
|
8179
|
+
return;
|
|
8180
|
+
}
|
|
8181
|
+
if (totalChanged === 0 && resuming) {
|
|
8182
|
+
// Resume after a crash PAST the apply stage: the files are already on
|
|
8183
|
+
// disk but verify/stamp never ran. Fall through to them; re-running
|
|
8184
|
+
// backup/apply on zero changes would be a no-op anyway.
|
|
8185
|
+
barLn(dim(" Files already applied by the interrupted run - verifying and stamping."));
|
|
8186
|
+
barLn();
|
|
8187
|
+
if (journal.stages.backup.status !== "done") journalStage(journal, "backup", "skipped", { reason: "resume-no-changes" });
|
|
8188
|
+
journalStage(journal, "apply", "done", { updated: 0, resumed: true });
|
|
8189
|
+
}
|
|
8190
|
+
if (totalChanged > 0) {
|
|
8191
|
+
journalStage(journal, "backup", "started");
|
|
8192
|
+
maybeCrashForTest("backup");
|
|
8193
|
+
const priorBackup = resuming && journal.stages.backup && journal.stages.backup.backupDir && fs.existsSync(journal.stages.backup.backupDir)
|
|
8194
|
+
? { backupRoot: journal.stages.backup.backupDir, backed: journal.stages.backup.backed || 0 }
|
|
8195
|
+
: null;
|
|
8196
|
+
const bkup = priorBackup || autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
|
|
8197
|
+
journalStage(journal, "backup", "done", { backupDir: bkup.backupRoot, backed: bkup.backed, reused: !!priorBackup });
|
|
8198
|
+
journalStage(journal, "apply", "started");
|
|
8199
|
+
maybeCrashForTest("apply");
|
|
6021
8200
|
barLn(bold("Updating..."));
|
|
6022
8201
|
barLn();
|
|
6023
8202
|
createVaultStructure(vaultPath);
|
|
@@ -6038,11 +8217,118 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6038
8217
|
sp.stop(steps.length > 0 ? `${displayName} ${dim(steps.join(", "))}` : `${displayName} ${dim("configured")}`);
|
|
6039
8218
|
}
|
|
6040
8219
|
}
|
|
8220
|
+
|
|
8221
|
+
// Complete Claude hook-asset refresh (6b parity with the interactive path):
|
|
8222
|
+
// the .js helpers and .md prompt templates must be on disk before anything
|
|
8223
|
+
// records or verifies them as current.
|
|
8224
|
+
const quickHooksSrc = path.join(bundleDir, "src", "hooks");
|
|
8225
|
+
const quickClaudeDetected = detectedAgents.some((a) => a.id === "claude-code");
|
|
8226
|
+
if (quickClaudeDetected && fs.existsSync(quickHooksSrc)) {
|
|
8227
|
+
const hooksDstClaude = path.join(os.homedir(), ".claude", "hooks");
|
|
8228
|
+
fs.mkdirSync(hooksDstClaude, { recursive: true });
|
|
8229
|
+
for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".sh"))) {
|
|
8230
|
+
const content = fs.readFileSync(path.join(quickHooksSrc, f), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
8231
|
+
fs.writeFileSync(path.join(hooksDstClaude, f), content, { mode: 0o755 });
|
|
8232
|
+
try { fs.chmodSync(path.join(hooksDstClaude, f), 0o755); } catch {}
|
|
8233
|
+
}
|
|
8234
|
+
for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".md") || x.endsWith(".js"))) {
|
|
8235
|
+
fs.copyFileSync(path.join(quickHooksSrc, f), path.join(hooksDstClaude, f));
|
|
8236
|
+
}
|
|
8237
|
+
}
|
|
8238
|
+
|
|
8239
|
+
// Rebuild the manifest to match what force-apply actually put on disk.
|
|
8240
|
+
// record-if-current: an entry asserts "the install holds this base", so
|
|
8241
|
+
// record the source hash only where the destination proves it (or, for
|
|
8242
|
+
// workflows, where this run just force-wrote it); otherwise carry the
|
|
8243
|
+
// prior base forward (F4 discipline) so nothing uncopied is marked current.
|
|
8244
|
+
try {
|
|
8245
|
+
const prior = loadUpdateManifest();
|
|
8246
|
+
const mfst = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
|
|
8247
|
+
if (prior && prior.files) Object.assign(mfst.files, prior.files);
|
|
8248
|
+
const wfDir = path.join(bundleDir, "src", "workflows");
|
|
8249
|
+
if (fs.existsSync(wfDir)) {
|
|
8250
|
+
// Quick force-applies EVERY workflow via the agent installers (which
|
|
8251
|
+
// also advance the pristine snapshots), so the source hash IS the new
|
|
8252
|
+
// base for all of them. Transformed copies still classify as
|
|
8253
|
+
// user-only-skip against this base, same as fresh installs.
|
|
8254
|
+
for (const f of fs.readdirSync(wfDir).filter((x) => x.endsWith(".md"))) {
|
|
8255
|
+
mfst.files[`workflows/${f}`] = fileContentHash(path.join(wfDir, f));
|
|
8256
|
+
}
|
|
8257
|
+
}
|
|
8258
|
+
const rulesPath2 = path.join(bundleDir, "src", "system", "Mover_Global_Rules.md");
|
|
8259
|
+
if (fs.existsSync(rulesPath2)) mfst.files["system/Mover_Global_Rules.md"] = fileContentHash(rulesPath2);
|
|
8260
|
+
if (quickClaudeDetected && fs.existsSync(quickHooksSrc)) {
|
|
8261
|
+
for (const f of fs.readdirSync(quickHooksSrc).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
|
|
8262
|
+
const dst = path.join(os.homedir(), ".claude", "hooks", f);
|
|
8263
|
+
if (fs.existsSync(dst) && fileContentHash(dst) === fileContentHash(path.join(quickHooksSrc, f))) {
|
|
8264
|
+
mfst.files[`hooks/${f}`] = fileContentHash(path.join(quickHooksSrc, f));
|
|
8265
|
+
}
|
|
8266
|
+
}
|
|
8267
|
+
}
|
|
8268
|
+
// Templates: quick installs NEW files but never overwrites existing vault
|
|
8269
|
+
// scaffold files, so only record entries the disk actually holds.
|
|
8270
|
+
const structDir2 = path.join(bundleDir, "src", "structure");
|
|
8271
|
+
if (fs.existsSync(structDir2)) {
|
|
8272
|
+
const walkT = (dir, rel) => {
|
|
8273
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
8274
|
+
const srcPath = path.join(dir, entry.name);
|
|
8275
|
+
const entryRel = path.join(rel, entry.name);
|
|
8276
|
+
if (entry.isDirectory()) { walkT(srcPath, entryRel); continue; }
|
|
8277
|
+
const relNorm = entryRel.replace(/\\/g, "/");
|
|
8278
|
+
if (relNorm.includes("02_Areas") && relNorm.includes("Engine")) continue;
|
|
8279
|
+
const destFile = path.join(vaultPath, entryRel);
|
|
8280
|
+
try {
|
|
8281
|
+
if (fs.existsSync(destFile) && fileContentHash(destFile) === fileContentHash(srcPath)) {
|
|
8282
|
+
mfst.files[`templates/${relNorm}`] = fileContentHash(srcPath);
|
|
8283
|
+
}
|
|
8284
|
+
} catch {}
|
|
8285
|
+
}
|
|
8286
|
+
};
|
|
8287
|
+
walkT(structDir2, "");
|
|
8288
|
+
}
|
|
8289
|
+
saveUpdateManifest(mfst);
|
|
8290
|
+
} catch {}
|
|
8291
|
+
|
|
8292
|
+
journalStage(journal, "apply", "done", { updated: totalChanged });
|
|
8293
|
+
} // end totalChanged > 0
|
|
8294
|
+
journalStage(journal, "migrate", "skipped", { reason: "quick mode: run /update for Engine migrations" });
|
|
8295
|
+
|
|
8296
|
+
// ── Verify BEFORE the stamp: the stamp is earned, never assumed ──
|
|
8297
|
+
journalStage(journal, "verify", "started");
|
|
8298
|
+
maybeCrashForTest("verify");
|
|
8299
|
+
const quickVerify = verifyUpdateApplied(bundleDir, vaultPath, { templates: "presence" });
|
|
8300
|
+
if (!quickVerify.ok) {
|
|
8301
|
+
journalStage(journal, "verify", "failed", { failures: quickVerify.failures.slice(0, 50) });
|
|
8302
|
+
barLn(red(` Verification failed: ${quickVerify.failures.length} file${quickVerify.failures.length > 1 ? "s" : ""} not at the updated version.`));
|
|
8303
|
+
for (const f of quickVerify.failures.slice(0, 12)) barLn(` ${red("x")} ${f.file} ${dim(`(${f.reason})`)}`);
|
|
8304
|
+
barLn(dim(" The version was NOT stamped. Re-run moveros update --quick once the cause is resolved."));
|
|
8305
|
+
outro(red("Update incomplete: verification failed, version not stamped."));
|
|
8306
|
+
process.exit(EXIT.ERROR);
|
|
8307
|
+
}
|
|
8308
|
+
journalStage(journal, "verify", "done", { scope: "quick" });
|
|
8309
|
+
|
|
8310
|
+
journalStage(journal, "stamp", "started");
|
|
8311
|
+
maybeCrashForTest("stamp");
|
|
6041
8312
|
fs.writeFileSync(path.join(vaultPath, ".mover-version"), `${require("./package.json").version}\n`, "utf8");
|
|
6042
8313
|
writeMoverConfig(vaultPath, selectedIds, updateKey);
|
|
8314
|
+
journal.completedAt = new Date().toISOString();
|
|
8315
|
+
journalStage(journal, "stamp", "done", { version: require("./package.json").version });
|
|
6043
8316
|
barLn();
|
|
6044
8317
|
if (totalChanged > 0) {
|
|
6045
8318
|
barLn(dim(" Restart your AI session to load updated rules and skills."));
|
|
8319
|
+
// v4.7.8: If Codex hooks were just installed/updated, point users at
|
|
8320
|
+
// the IDE trust step. Codex IDE shows new/modified hooks as "needs
|
|
8321
|
+
// review" — a security feature. CLI fires hooks fine without trust.
|
|
8322
|
+
try {
|
|
8323
|
+
const codexStamp = path.join(os.homedir(), ".codex", ".mover-hook-install-stamp");
|
|
8324
|
+
if (selectedIds.includes("codex") && fs.existsSync(codexStamp)) {
|
|
8325
|
+
const stampedAt = new Date(fs.readFileSync(codexStamp, "utf8").trim()).getTime();
|
|
8326
|
+
if (Date.now() - stampedAt < 5 * 60 * 1000) {
|
|
8327
|
+
barLn(dim(" Codex IDE: open Settings → Hooks, click Trust on the new entries."));
|
|
8328
|
+
barLn(dim(" (One-time per hook change. CLI fires them without the trust gate.)"));
|
|
8329
|
+
}
|
|
8330
|
+
}
|
|
8331
|
+
} catch {}
|
|
6046
8332
|
barLn();
|
|
6047
8333
|
}
|
|
6048
8334
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
@@ -6064,6 +8350,11 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6064
8350
|
if (totalChanged === 0) {
|
|
6065
8351
|
barLn(green(" Already up to date."));
|
|
6066
8352
|
barLn();
|
|
8353
|
+
// Mechanical stages are trivially complete; Engine migration may still be
|
|
8354
|
+
// owed, so migrate stays pending and /update + --finalize own the stamp.
|
|
8355
|
+
journalStage(journal, "backup", "skipped", { reason: "no-changes" });
|
|
8356
|
+
journalStage(journal, "apply", "done", { updated: 0 });
|
|
8357
|
+
journalStage(journal, "migrate", "pending", { owner: "workflow" });
|
|
6067
8358
|
outro(green("No changes needed. Engine files may still need migration — run /update."));
|
|
6068
8359
|
return;
|
|
6069
8360
|
}
|
|
@@ -6078,11 +8369,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6078
8369
|
{ multi: false, defaultIndex: 0 }
|
|
6079
8370
|
);
|
|
6080
8371
|
if (!updateConfirm || updateConfirm === "no") {
|
|
8372
|
+
// Nothing was touched: close the journal so no phantom run lingers.
|
|
8373
|
+
if (!resuming) {
|
|
8374
|
+
journal.cancelled = true;
|
|
8375
|
+
journal.completedAt = new Date().toISOString();
|
|
8376
|
+
saveUpdateJournal(journal);
|
|
8377
|
+
}
|
|
6081
8378
|
outro("Update cancelled.");
|
|
6082
8379
|
return;
|
|
6083
8380
|
}
|
|
6084
8381
|
barLn();
|
|
6085
8382
|
|
|
8383
|
+
// ── Backup stage: full pre-apply backup of every file this run will touch ──
|
|
8384
|
+
// (Previously quick-only, so an interactive safe-overwrite had NO restore
|
|
8385
|
+
// source. The journal keeps the ORIGINAL dir across resumes so rollback
|
|
8386
|
+
// always targets the true pre-update state.)
|
|
8387
|
+
journalStage(journal, "backup", "started");
|
|
8388
|
+
maybeCrashForTest("backup");
|
|
8389
|
+
const priorBackupI = resuming && journal.stages.backup && journal.stages.backup.backupDir && fs.existsSync(journal.stages.backup.backupDir)
|
|
8390
|
+
? { backupRoot: journal.stages.backup.backupDir, backed: journal.stages.backup.backed || 0 }
|
|
8391
|
+
: null;
|
|
8392
|
+
const bkupI = priorBackupI || autoBackupBeforeUpdate(changes, selectedIds, vaultPath);
|
|
8393
|
+
journalStage(journal, "backup", "done", { backupDir: bkupI.backupRoot, backed: bkupI.backed, reused: !!priorBackupI });
|
|
8394
|
+
journalStage(journal, "apply", "started");
|
|
8395
|
+
maybeCrashForTest("apply");
|
|
8396
|
+
|
|
6086
8397
|
// ── Apply safe system files directly (no user customizations in these) ──
|
|
6087
8398
|
const home = os.homedir();
|
|
6088
8399
|
let appliedCount = 0;
|
|
@@ -6108,14 +8419,18 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6108
8419
|
if (fs.existsSync(slSrc)) {
|
|
6109
8420
|
const slContent = fs.readFileSync(slSrc, "utf8").replace(/\r\n/g, "\n");
|
|
6110
8421
|
fs.writeFileSync(slDest, slContent, "utf8");
|
|
6111
|
-
// Ensure settings.json has statusLine config
|
|
8422
|
+
// Ensure settings.json has statusLine config. Absolute forward-slash
|
|
8423
|
+
// path, never `~` — cmd.exe does not expand tildes (Windows dead-hook
|
|
8424
|
+
// bug class). Also migrate an existing `~` form left by old installs.
|
|
6112
8425
|
const globalSettings = path.join(home, ".claude", "settings.json");
|
|
8426
|
+
const slCmd = `node "${slDest.replace(/\\/g, "/")}"`;
|
|
6113
8427
|
try {
|
|
6114
8428
|
const settings = fs.existsSync(globalSettings)
|
|
6115
8429
|
? JSON.parse(fs.readFileSync(globalSettings, "utf8"))
|
|
6116
8430
|
: {};
|
|
6117
|
-
|
|
6118
|
-
|
|
8431
|
+
const cur = settings.statusLine && settings.statusLine.command;
|
|
8432
|
+
if (!settings.statusLine || /~\/\.claude\/statusline\.js/.test(String(cur || ""))) {
|
|
8433
|
+
settings.statusLine = { type: "command", command: slCmd };
|
|
6119
8434
|
fs.writeFileSync(globalSettings, JSON.stringify(settings, null, 2), "utf8");
|
|
6120
8435
|
}
|
|
6121
8436
|
} catch {}
|
|
@@ -6134,7 +8449,17 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6134
8449
|
for (const dir of cmdDirs) {
|
|
6135
8450
|
const dest = path.join(dir, "update.md");
|
|
6136
8451
|
if (fs.existsSync(dir)) {
|
|
6137
|
-
|
|
8452
|
+
// Gap-1: route through the vault-resolution transform. For bodies that
|
|
8453
|
+
// don't need it (returns false), replace the dest ATOMICALLY via
|
|
8454
|
+
// same-dir temp + rename: a plain copyFileSync would write THROUGH the
|
|
8455
|
+
// install-time hard link into the bundle source, which on an npm
|
|
8456
|
+
// global install is read-only (EACCES; caught by the update-machine
|
|
8457
|
+
// read-only-bundle test) and on a repo install would corrupt src/.
|
|
8458
|
+
if (!writeWorkflowTransformed(updateSrc, dest)) {
|
|
8459
|
+
const tmp = dest + ".tmp-" + process.pid;
|
|
8460
|
+
fs.copyFileSync(updateSrc, tmp);
|
|
8461
|
+
fs.renameSync(tmp, dest);
|
|
8462
|
+
}
|
|
6138
8463
|
}
|
|
6139
8464
|
}
|
|
6140
8465
|
statusLine("ok", "/update", "workflow synced");
|
|
@@ -6152,7 +8477,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6152
8477
|
// ── Smart Update: hash-based change detection + auto-merge ──
|
|
6153
8478
|
const manifest = loadUpdateManifest();
|
|
6154
8479
|
const newManifest = { version: require("./package.json").version, installedAt: new Date().toISOString(), files: {} };
|
|
6155
|
-
const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [];
|
|
8480
|
+
const updated = [], skippedFiles = [], autoMerged = [], conflicts = [], userOnly = [], writeFailed = [];
|
|
6156
8481
|
let needsUpdate = false;
|
|
6157
8482
|
|
|
6158
8483
|
if (!manifest) {
|
|
@@ -6170,13 +8495,26 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6170
8495
|
const srcFile = path.join(wfSrcDir, file);
|
|
6171
8496
|
const destFile = path.join(wfDestDir, file);
|
|
6172
8497
|
const result = compareForUpdate("workflows", srcFile, destFile, manifest);
|
|
8498
|
+
let wroteFile = true; // false = left on disk unresolved (see manifest guard below)
|
|
6173
8499
|
|
|
6174
8500
|
switch (result.action) {
|
|
6175
8501
|
case "install":
|
|
6176
8502
|
case "safe-overwrite":
|
|
6177
|
-
|
|
6178
|
-
|
|
6179
|
-
|
|
8503
|
+
// Gap-1: same config-first transform as the fresh-install path. The
|
|
8504
|
+
// smart-update loop previously linkOrCopy'd the raw source, shipping the
|
|
8505
|
+
// bare git-rev-parse Pre-Flight on update. writeWorkflowTransformed
|
|
8506
|
+
// emits a corrected copy for matched files and returns false (→ linkOrCopy)
|
|
8507
|
+
// for the rest, preserving the live-edit hard link where it's safe.
|
|
8508
|
+
// sol flaw #3: linkOrCopy returns null on I/O failure. Do NOT advance
|
|
8509
|
+
// the pristine snapshot or manifest for a file that was not actually
|
|
8510
|
+
// written, or the next update treats an unapplied file as current.
|
|
8511
|
+
if (writeWorkflowTransformed(srcFile, destFile) || linkOrCopy(srcFile, destFile)) {
|
|
8512
|
+
savePristineCopy("workflows", srcFile);
|
|
8513
|
+
updated.push(file);
|
|
8514
|
+
} else {
|
|
8515
|
+
writeFailed.push(file);
|
|
8516
|
+
wroteFile = false;
|
|
8517
|
+
}
|
|
6180
8518
|
break;
|
|
6181
8519
|
case "user-only-skip":
|
|
6182
8520
|
userOnly.push(file);
|
|
@@ -6188,24 +8526,44 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6188
8526
|
const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
|
|
6189
8527
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
6190
8528
|
if (fs.existsSync(destFile)) fs.copyFileSync(destFile, path.join(backupDir, file));
|
|
6191
|
-
linkOrCopy(srcFile, destFile)
|
|
6192
|
-
|
|
6193
|
-
|
|
8529
|
+
if (writeWorkflowTransformed(srcFile, destFile) || linkOrCopy(srcFile, destFile)) {
|
|
8530
|
+
savePristineCopy("workflows", srcFile);
|
|
8531
|
+
updated.push(file);
|
|
8532
|
+
} else {
|
|
8533
|
+
writeFailed.push(file); // sol flaw #3
|
|
8534
|
+
wroteFile = false;
|
|
8535
|
+
}
|
|
6194
8536
|
break;
|
|
6195
8537
|
}
|
|
6196
8538
|
case "conflict": {
|
|
6197
8539
|
const mergeResult = tryAutoMerge("workflows", srcFile, destFile);
|
|
6198
8540
|
if (mergeResult.success) {
|
|
6199
|
-
|
|
8541
|
+
// Transform the MERGED body before writing: an auto-merge can carry the
|
|
8542
|
+
// bare block over from the user's old copy, so a verbatim write would
|
|
8543
|
+
// re-ship the broken Pre-Flight. presetContent feeds the in-memory merge.
|
|
8544
|
+
writeWorkflowTransformed(srcFile, destFile, mergeResult.content);
|
|
6200
8545
|
savePristineCopy("workflows", srcFile);
|
|
6201
8546
|
autoMerged.push(file);
|
|
6202
8547
|
} else {
|
|
6203
|
-
conflicts.push({
|
|
8548
|
+
conflicts.push({
|
|
8549
|
+
name: file, category: "workflows",
|
|
8550
|
+
reason: mergeResult.reason, conflictCount: mergeResult.conflictCount,
|
|
8551
|
+
pristine: path.join(home, ".mover", "installed", "workflows", file),
|
|
8552
|
+
userVersion: destFile, newVersion: srcFile,
|
|
8553
|
+
});
|
|
8554
|
+
wroteFile = false;
|
|
6204
8555
|
}
|
|
6205
8556
|
break;
|
|
6206
8557
|
}
|
|
6207
8558
|
}
|
|
6208
|
-
|
|
8559
|
+
// terra F4: an unresolved conflict leaves the user's file on disk untouched.
|
|
8560
|
+
// Advancing the manifest to the new hash would make the NEXT update treat
|
|
8561
|
+
// the un-applied upstream change as already-pristine and silently skip it,
|
|
8562
|
+
// so a real bug/security fix never reaches a user who has a conflict. Keep
|
|
8563
|
+
// the prior base hash so the conflict resurfaces until /update resolves it.
|
|
8564
|
+
newManifest.files[`workflows/${file}`] = wroteFile
|
|
8565
|
+
? result.newHash
|
|
8566
|
+
: (manifest?.files?.[`workflows/${file}`] ?? result.newHash);
|
|
6209
8567
|
}
|
|
6210
8568
|
}
|
|
6211
8569
|
|
|
@@ -6226,6 +8584,7 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6226
8584
|
|
|
6227
8585
|
const destFile = path.join(vaultPath, entryRel);
|
|
6228
8586
|
const result = compareForUpdate("templates", srcPath, destFile, manifest, relNorm);
|
|
8587
|
+
let wroteTmpl = true;
|
|
6229
8588
|
|
|
6230
8589
|
switch (result.action) {
|
|
6231
8590
|
case "install":
|
|
@@ -6262,12 +8621,22 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6262
8621
|
savePristineCopy("templates", srcPath, relNorm);
|
|
6263
8622
|
autoMerged.push(relNorm);
|
|
6264
8623
|
} else {
|
|
6265
|
-
conflicts.push({
|
|
8624
|
+
conflicts.push({
|
|
8625
|
+
name: relNorm, category: "templates",
|
|
8626
|
+
reason: mergeResult.reason, conflictCount: mergeResult.conflictCount,
|
|
8627
|
+
pristine: path.join(os.homedir(), ".mover", "installed", "templates", relNorm),
|
|
8628
|
+
userVersion: destFile, newVersion: srcPath,
|
|
8629
|
+
});
|
|
8630
|
+
wroteTmpl = false;
|
|
6266
8631
|
}
|
|
6267
8632
|
break;
|
|
6268
8633
|
}
|
|
6269
8634
|
}
|
|
6270
|
-
|
|
8635
|
+
// terra F4 (templates): same rule as workflows — never advance a manifest
|
|
8636
|
+
// entry for a file left unresolved on disk.
|
|
8637
|
+
newManifest.files[`templates/${relNorm}`] = wroteTmpl
|
|
8638
|
+
? result.newHash
|
|
8639
|
+
: (manifest?.files?.[`templates/${relNorm}`] ?? result.newHash);
|
|
6271
8640
|
}
|
|
6272
8641
|
};
|
|
6273
8642
|
walkTmplUpdate(tmplSrcDir, "");
|
|
@@ -6317,11 +8686,17 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6317
8686
|
|
|
6318
8687
|
// ── Handle true conflicts → write conflicts.json for /update ──
|
|
6319
8688
|
if (conflicts.length > 0) {
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
8689
|
+
// terra F3: each record now carries its own category + real paths. A template
|
|
8690
|
+
// conflict (e.g. 00_Inbox/Quick_Capture.md, which lives in the vault) was
|
|
8691
|
+
// previously serialized with workflow paths (.claude/commands, src/workflows),
|
|
8692
|
+
// so /update read three non-existent files and could not resolve it. Fall
|
|
8693
|
+
// back to the workflow layout only for legacy records without paths.
|
|
8694
|
+
const conflictData = conflicts.map((c) => ({
|
|
8695
|
+
file: c.name,
|
|
8696
|
+
category: c.category || "workflows",
|
|
8697
|
+
pristine: c.pristine || path.join(home, ".mover", "installed", "workflows", c.name),
|
|
8698
|
+
userVersion: c.userVersion || path.join(home, ".claude", "commands", c.name),
|
|
8699
|
+
newVersion: c.newVersion || path.join(bundleDir, "src", "workflows", c.name),
|
|
6325
8700
|
}));
|
|
6326
8701
|
const conflictPath = path.join(home, ".mover", "conflicts.json");
|
|
6327
8702
|
fs.writeFileSync(conflictPath, JSON.stringify({
|
|
@@ -6330,12 +8705,14 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6330
8705
|
files: conflictData,
|
|
6331
8706
|
}, null, 2), "utf8");
|
|
6332
8707
|
|
|
6333
|
-
// Backup conflicted user files
|
|
8708
|
+
// Backup conflicted user files (from their real location — template conflicts
|
|
8709
|
+
// live in the vault, not ~/.claude/commands). Slashes in the relative name are
|
|
8710
|
+
// flattened so the backup filename is valid.
|
|
6334
8711
|
const backupDir = path.join(home, ".mover", "backups", `pre-update-${new Date().toISOString().slice(0, 10)}`);
|
|
6335
8712
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
6336
|
-
for (const
|
|
6337
|
-
const dest = path.join(home, ".claude", "commands", name);
|
|
6338
|
-
if (fs.existsSync(dest)) fs.copyFileSync(dest, path.join(backupDir, name));
|
|
8713
|
+
for (const c of conflicts) {
|
|
8714
|
+
const dest = c.userVersion || path.join(home, ".claude", "commands", c.name);
|
|
8715
|
+
if (fs.existsSync(dest)) fs.copyFileSync(dest, path.join(backupDir, c.name.replace(/[\/\\]/g, "__")));
|
|
6339
8716
|
}
|
|
6340
8717
|
needsUpdate = true;
|
|
6341
8718
|
}
|
|
@@ -6359,6 +8736,31 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6359
8736
|
}
|
|
6360
8737
|
}
|
|
6361
8738
|
|
|
8739
|
+
// ── Refresh Claude's hook FILES before recording their hashes ──
|
|
8740
|
+
// 6b (terra-confirmed): the multi-agent propagation loop above SKIPS
|
|
8741
|
+
// claude-code, yet the manifest loop below records every hook .sh/.js/.md as
|
|
8742
|
+
// current. So a Claude user's hook helpers (e.g. session-history-writer.js,
|
|
8743
|
+
// used by pre-compact-backup.sh) and prompt templates were marked fresh in
|
|
8744
|
+
// the manifest but never actually copied — they stayed stale, and the next
|
|
8745
|
+
// update skipped them because the hashes "matched". Copy the complete Claude
|
|
8746
|
+
// hook asset set (same files the fresh installer writes) BEFORE recording, so
|
|
8747
|
+
// the manifest never claims a version the disk does not have. File copy only;
|
|
8748
|
+
// settings.json is already established and is not touched here.
|
|
8749
|
+
const hooksSrcForClaude = path.join(bundleDir, "src", "hooks");
|
|
8750
|
+
const claudeDetected = detectedAgentsForProp.some((a) => a.id === "claude-code");
|
|
8751
|
+
if (claudeDetected && fs.existsSync(hooksSrcForClaude)) {
|
|
8752
|
+
const hooksDstClaude = path.join(home, ".claude", "hooks");
|
|
8753
|
+
fs.mkdirSync(hooksDstClaude, { recursive: true });
|
|
8754
|
+
for (const f of fs.readdirSync(hooksSrcForClaude).filter((x) => x.endsWith(".sh"))) {
|
|
8755
|
+
const content = fs.readFileSync(path.join(hooksSrcForClaude, f), "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
8756
|
+
fs.writeFileSync(path.join(hooksDstClaude, f), content, { mode: 0o755 });
|
|
8757
|
+
try { fs.chmodSync(path.join(hooksDstClaude, f), 0o755); } catch {}
|
|
8758
|
+
}
|
|
8759
|
+
for (const f of fs.readdirSync(hooksSrcForClaude).filter((x) => x.endsWith(".md") || x.endsWith(".js"))) {
|
|
8760
|
+
fs.copyFileSync(path.join(hooksSrcForClaude, f), path.join(hooksDstClaude, f));
|
|
8761
|
+
}
|
|
8762
|
+
}
|
|
8763
|
+
|
|
6362
8764
|
// ── Save updated manifest ──
|
|
6363
8765
|
// Carry forward entries for files we didn't process (hooks, statusline, /update itself)
|
|
6364
8766
|
if (manifest && manifest.files) {
|
|
@@ -6369,12 +8771,36 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6369
8771
|
const hooksDir = path.join(bundleDir, "src", "hooks");
|
|
6370
8772
|
if (fs.existsSync(hooksDir)) {
|
|
6371
8773
|
for (const f of fs.readdirSync(hooksDir).filter((x) => x.endsWith(".sh") || x.endsWith(".js") || x.endsWith(".md"))) {
|
|
6372
|
-
|
|
8774
|
+
// record-if-current (sol part 10: "interactive update records uncopied
|
|
8775
|
+
// hook artifacts as current"): a manifest entry asserts the installed
|
|
8776
|
+
// artifact holds this base. Record the fresh hash ONLY when the Claude
|
|
8777
|
+
// hook file is actually on disk with matching content (the refresh block
|
|
8778
|
+
// above guarantees that when Claude is detected); otherwise keep the
|
|
8779
|
+
// prior base (carried forward below) so nothing uncopied is marked
|
|
8780
|
+
// current and the next update still sees it as stale.
|
|
8781
|
+
const dst = path.join(home, ".claude", "hooks", f);
|
|
8782
|
+
if (claudeDetected && fs.existsSync(dst) && fileContentHash(dst) === fileContentHash(path.join(hooksDir, f))) {
|
|
8783
|
+
newManifest.files[`hooks/${f}`] = fileContentHash(path.join(hooksDir, f));
|
|
8784
|
+
}
|
|
6373
8785
|
}
|
|
6374
8786
|
}
|
|
6375
8787
|
saveUpdateManifest(newManifest);
|
|
6376
8788
|
writeMoverConfig(vaultPath, selectedIds, updateKey);
|
|
6377
8789
|
|
|
8790
|
+
// ── Journal handoff: mechanical stages complete; /update owns the rest ──
|
|
8791
|
+
// migrate (Engine migration + conflict merges) belongs to the /update
|
|
8792
|
+
// workflow; verify + stamp happen in `moveros update --finalize`, which the
|
|
8793
|
+
// workflow runs LAST. The journal is the machine-readable handoff, so the
|
|
8794
|
+
// CLI and the workflow are one state machine instead of a prose baton pass.
|
|
8795
|
+
journalStage(journal, "apply", writeFailed.length > 0 ? "failed" : "done", {
|
|
8796
|
+
updated: updated.length,
|
|
8797
|
+
autoMerged: autoMerged.length,
|
|
8798
|
+
userOnly: userOnly.length,
|
|
8799
|
+
conflicts: conflicts.length,
|
|
8800
|
+
writeFailed: writeFailed.length,
|
|
8801
|
+
});
|
|
8802
|
+
journalStage(journal, "migrate", "pending", { owner: "workflow", conflicts: conflicts.length });
|
|
8803
|
+
|
|
6378
8804
|
// ── Summary ──
|
|
6379
8805
|
barLn();
|
|
6380
8806
|
if (updated.length > 0) statusLine("ok", "Updated", `${updated.length} files`);
|
|
@@ -6382,6 +8808,15 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6382
8808
|
if (userOnly.length > 0) statusLine("ok", "Preserved", `${userOnly.length} customized files`);
|
|
6383
8809
|
if (skippedFiles.length > 0) statusLine("ok", "Current", `${skippedFiles.length} files unchanged`);
|
|
6384
8810
|
|
|
8811
|
+
// sol flaw #3: a write that failed mid-update was silently reported as success.
|
|
8812
|
+
// Surface it and force a non-clean outcome so the user (and the next run) retries.
|
|
8813
|
+
if (writeFailed.length > 0) {
|
|
8814
|
+
statusLine("warn", "Write failed", `${writeFailed.length} file${writeFailed.length > 1 ? "s" : ""} could not be written (kept prior version)`);
|
|
8815
|
+
for (const f of writeFailed) barLn(` ${yellow("!")} ${f}`);
|
|
8816
|
+
barLn(dim(" Re-run moveros update once the cause is resolved (disk space / permissions)."));
|
|
8817
|
+
needsUpdate = true;
|
|
8818
|
+
}
|
|
8819
|
+
|
|
6385
8820
|
if (conflicts.length > 0) {
|
|
6386
8821
|
barLn();
|
|
6387
8822
|
barLn(yellow(` ${conflicts.length} file${conflicts.length > 1 ? "s" : ""} need intelligent merge:`));
|
|
@@ -6395,13 +8830,26 @@ async function cmdUpdateComprehensive(opts, bundleDir, startTime) {
|
|
|
6395
8830
|
barLn();
|
|
6396
8831
|
if (updated.length > 0 || autoMerged.length > 0) {
|
|
6397
8832
|
barLn(dim(" Restart your AI session to load updated rules and skills."));
|
|
8833
|
+
// v4.7.8: Codex IDE hook-trust hint (same logic as install path above).
|
|
8834
|
+
try {
|
|
8835
|
+
const codexStamp = path.join(os.homedir(), ".codex", ".mover-hook-install-stamp");
|
|
8836
|
+
if (selectedIds.includes("codex") && fs.existsSync(codexStamp)) {
|
|
8837
|
+
const stampedAt = new Date(fs.readFileSync(codexStamp, "utf8").trim()).getTime();
|
|
8838
|
+
if (Date.now() - stampedAt < 5 * 60 * 1000) {
|
|
8839
|
+
barLn(dim(" Codex IDE: open Settings → Hooks, click Trust on the new entries."));
|
|
8840
|
+
barLn(dim(" (One-time per hook change. CLI fires them without the trust gate.)"));
|
|
8841
|
+
}
|
|
8842
|
+
}
|
|
8843
|
+
} catch {}
|
|
6398
8844
|
barLn();
|
|
6399
8845
|
}
|
|
6400
8846
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
6401
|
-
if (
|
|
6402
|
-
outro(`System updated. Run ${bold("/update")} to resolve ${conflicts.length} conflict${conflicts.length > 1 ? "s" : ""}. ${dim(`(${elapsed}s)`)}`);
|
|
8847
|
+
if (conflicts.length > 0) {
|
|
8848
|
+
outro(`System updated. Run ${bold("/update")} to resolve ${conflicts.length} conflict${conflicts.length > 1 ? "s" : ""} and finish (it verifies + stamps the version). ${dim(`(${elapsed}s)`)}`);
|
|
8849
|
+
} else if (writeFailed.length > 0) {
|
|
8850
|
+
outro(`${yellow("Update incomplete.")} ${writeFailed.length} file${writeFailed.length > 1 ? "s" : ""} could not be written. Re-run when resolved. ${dim(`(${elapsed}s)`)}`);
|
|
6403
8851
|
} else {
|
|
6404
|
-
outro(`${green("Done.")}
|
|
8852
|
+
outro(`${green("Done.")} Files applied. Run ${bold("/update")} to finish: Engine migration, then verify + version stamp. ${dim(`(${elapsed}s)`)}`);
|
|
6405
8853
|
}
|
|
6406
8854
|
}
|
|
6407
8855
|
|
|
@@ -6437,23 +8885,95 @@ function requireVault(explicitVault) {
|
|
|
6437
8885
|
return v;
|
|
6438
8886
|
}
|
|
6439
8887
|
|
|
8888
|
+
// THE install invariant (sol re-score part 1: "Marker checks still occur
|
|
8889
|
+
// command-by-command instead of through one invariant"). A real install =
|
|
8890
|
+
// a `.mover-version` that is a non-empty regular FILE — an empty file or a
|
|
8891
|
+
// directory by that name is not an install (sol flaw #5), and requireVault
|
|
8892
|
+
// trusts an explicit --vault without checking, so a typo'd path would
|
|
8893
|
+
// otherwise get artifacts minted into it (terra F1/F7). Every command that
|
|
8894
|
+
// MUTATES the vault or mints install artifacts goes through
|
|
8895
|
+
// requireInstalledVault; read-only diagnostics (doctor, context, status)
|
|
8896
|
+
// deliberately do not — they must be able to examine a broken install.
|
|
8897
|
+
function isInstalledVault(vaultPath) {
|
|
8898
|
+
if (!vaultPath) return false;
|
|
8899
|
+
try {
|
|
8900
|
+
const mp = path.join(vaultPath, ".mover-version");
|
|
8901
|
+
if (!fs.statSync(mp).isFile()) return false;
|
|
8902
|
+
return fs.readFileSync(mp, "utf8").trim().length > 0;
|
|
8903
|
+
} catch {
|
|
8904
|
+
return false;
|
|
8905
|
+
}
|
|
8906
|
+
}
|
|
8907
|
+
|
|
8908
|
+
function requireInstalledVault(explicitVault) {
|
|
8909
|
+
const v = requireVault(explicitVault);
|
|
8910
|
+
if (!v) return null;
|
|
8911
|
+
if (!isInstalledVault(v)) {
|
|
8912
|
+
statusLine("warn", "No install", `${v} has no Mover OS install (.mover-version missing or empty). Run moveros install first, or pass the correct --vault.`);
|
|
8913
|
+
barLn();
|
|
8914
|
+
return null;
|
|
8915
|
+
}
|
|
8916
|
+
return v;
|
|
8917
|
+
}
|
|
8918
|
+
|
|
6440
8919
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
6441
8920
|
async function main() {
|
|
6442
8921
|
const opts = parseArgs();
|
|
6443
8922
|
let bundleDir = path.resolve(__dirname);
|
|
6444
8923
|
const startTime = Date.now();
|
|
6445
8924
|
|
|
8925
|
+
// ── Bare `moveros` — smart default (decided before the branded header so
|
|
8926
|
+
// launching the dashboard is instant, with no logo animation in the way).
|
|
8927
|
+
// Configured machine → open Mover Studio (soft: won't stack tabs on a
|
|
8928
|
+
// reflexive re-run). Fresh machine → fall through to the installer, which
|
|
8929
|
+
// lands the user on the dashboard's onboarding route when it finishes.
|
|
8930
|
+
// `moveros menu` restores the classic interactive launcher. ──
|
|
8931
|
+
if (!opts.command) {
|
|
8932
|
+
const configuredVault = resolveVaultPath(opts.vault);
|
|
8933
|
+
if (configuredVault) {
|
|
8934
|
+
recordCliUsage("open");
|
|
8935
|
+
await cmdOpen(opts, { soft: true });
|
|
8936
|
+
if (!opts.noOpen) ln(dim(" Tools: moveros menu · moveros doctor · moveros update"));
|
|
8937
|
+
return;
|
|
8938
|
+
}
|
|
8939
|
+
opts.command = "install"; // no vault yet → first run goes straight to install
|
|
8940
|
+
}
|
|
8941
|
+
|
|
8942
|
+
// `moveros pulse --bar` is polled by a menu-bar widget every few seconds — its
|
|
8943
|
+
// stdout must be ONLY the status line, so intercept it here, before the alt-
|
|
8944
|
+
// screen, logo, and update-notice chrome would pollute the output.
|
|
8945
|
+
if ((opts.command === "pulse" || opts.command === "dashboard") && (opts.rest || []).includes("--bar")) {
|
|
8946
|
+
await cmdPulseBar(opts);
|
|
8947
|
+
return;
|
|
8948
|
+
}
|
|
8949
|
+
|
|
8950
|
+
// `moveros inventory` is piped by the docs generator and CI — stdout must be
|
|
8951
|
+
// ONLY the JSON, so it too skips the alt-screen and logo chrome.
|
|
8952
|
+
if (opts.command === "inventory") {
|
|
8953
|
+
cmdInventory();
|
|
8954
|
+
return;
|
|
8955
|
+
}
|
|
8956
|
+
|
|
8957
|
+
// `moveros status --json` is a machine-readable state probe (scripts, other
|
|
8958
|
+
// agents). Its JSON must be the WHOLE stdout, so intercept it before the
|
|
8959
|
+
// alt-screen and logo chrome that otherwise print ahead of jsonOut (sol
|
|
8960
|
+
// re-score #6 part 1: --json still passed through header rendering).
|
|
8961
|
+
if (opts.command === "status" && opts.json) {
|
|
8962
|
+
await cmdStatus(opts);
|
|
8963
|
+
return;
|
|
8964
|
+
}
|
|
8965
|
+
|
|
6446
8966
|
// ── TUI: Enter alternate screen ──
|
|
6447
8967
|
enterAltScreen();
|
|
6448
8968
|
|
|
6449
8969
|
// ── Intro: Logo plays once ──
|
|
6450
8970
|
await printHeader();
|
|
6451
8971
|
|
|
6452
|
-
|
|
6453
|
-
const lightCommands = ["pulse", "capture", "who", "diff", "sync", "replay", "context", "settings", "backup", "restore", "doctor", "prayer", "help", "uninstall", "test"];
|
|
8972
|
+
const lightCommands = ["pulse", "dashboard", "open", "shortcut", "menubar", "inventory", "capture", "who", "diff", "sync", "replay", "context", "status", "settings", "backup", "restore", "doctor", "prayer", "help", "uninstall", "test"];
|
|
6454
8973
|
|
|
6455
|
-
|
|
6456
|
-
|
|
8974
|
+
// ── `moveros menu` → classic interactive launcher (persistent loop) ──
|
|
8975
|
+
if (opts.command === "menu") {
|
|
8976
|
+
opts.command = "";
|
|
6457
8977
|
while (true) {
|
|
6458
8978
|
clearContent();
|
|
6459
8979
|
ln(compactHeader());
|
|
@@ -7016,22 +9536,59 @@ async function main() {
|
|
|
7016
9536
|
|
|
7017
9537
|
let totalSteps = 0;
|
|
7018
9538
|
|
|
7019
|
-
// 0. Legacy cleanup (
|
|
9539
|
+
// 0. Legacy cleanup (retire files from older installer versions).
|
|
9540
|
+
// GUARDED, then made NON-DESTRUCTIVE (terra P0, two rounds 2026-07-11):
|
|
9541
|
+
// this used to rm -rf vault-root src/ unconditionally. A weak fingerprint
|
|
9542
|
+
// (any one hook filename / the prose "mover os") still destroyed a user's
|
|
9543
|
+
// src/ that merely held a session-start.sh, and a personal SOUL.md that
|
|
9544
|
+
// named the product. Two changes: (a) the signature is now STRONG and
|
|
9545
|
+
// Mover-unique, and (b) even a match is BACKED UP, never hard-deleted, so a
|
|
9546
|
+
// false positive can never lose data. The campaign DoD is byte-preservation
|
|
9547
|
+
// of unowned files; a backup rename satisfies it even under a misfire.
|
|
9548
|
+
const MOVER_UNIQUE_HOOKS = ["mover-lib.sh", "session-log-reminder.sh", "engine-protection.sh", "engine-auto-commit.sh"];
|
|
9549
|
+
const isLegacyMoverSrc = (dir) => {
|
|
9550
|
+
try {
|
|
9551
|
+
const hooksDir = path.join(dir, "hooks");
|
|
9552
|
+
if (!fs.existsSync(hooksDir) || !fs.statSync(hooksDir).isDirectory()) return false;
|
|
9553
|
+
const present = new Set(fs.readdirSync(hooksDir));
|
|
9554
|
+
// mover-lib.sh is a Mover-only filename; require it PLUS one more unique
|
|
9555
|
+
// Mover hook. A user coincidentally naming a file session-start.sh will
|
|
9556
|
+
// not also carry mover-lib.sh + engine-protection.sh.
|
|
9557
|
+
if (!present.has("mover-lib.sh")) return false;
|
|
9558
|
+
return MOVER_UNIQUE_HOOKS.filter((h) => present.has(h)).length >= 2;
|
|
9559
|
+
} catch { return false; }
|
|
9560
|
+
};
|
|
9561
|
+
const isLegacyMoverSoul = (file) => {
|
|
9562
|
+
// Match the OLD Mover SOUL template structure, not a prose mention. The
|
|
9563
|
+
// shipped legacy SOUL led with an agent-identity header and the "The
|
|
9564
|
+
// Architect" persona; a user writing "I tried Mover OS" prose has neither.
|
|
9565
|
+
try {
|
|
9566
|
+
const head = fs.readFileSync(file, "utf8").slice(0, 4000);
|
|
9567
|
+
return /^#\s*(SOUL|THE SOUL|Mover OS SOUL)\b/im.test(head) &&
|
|
9568
|
+
/(Architect|Agent Name|Global Rules|Identity_Prime)/.test(head);
|
|
9569
|
+
} catch { return false; }
|
|
9570
|
+
};
|
|
7020
9571
|
const legacyPaths = [
|
|
7021
|
-
path.join(vaultPath, "src"),
|
|
7022
|
-
path.join(vaultPath, "SOUL.md"),
|
|
9572
|
+
{ p: path.join(vaultPath, "src"), owned: isLegacyMoverSrc }, // hooks used to install here
|
|
9573
|
+
{ p: path.join(vaultPath, "SOUL.md"), owned: isLegacyMoverSoul }, // old soul file
|
|
7023
9574
|
];
|
|
7024
|
-
for (const lp of legacyPaths) {
|
|
7025
|
-
if (fs.existsSync(lp))
|
|
7026
|
-
|
|
7027
|
-
|
|
7028
|
-
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
9575
|
+
for (const { p: lp, owned } of legacyPaths) {
|
|
9576
|
+
if (!fs.existsSync(lp)) continue;
|
|
9577
|
+
try {
|
|
9578
|
+
if (!owned(lp)) {
|
|
9579
|
+
statusLine("warn", "Preserved", `${path.basename(lp)} exists but is not a Mover artifact — left untouched`);
|
|
9580
|
+
continue;
|
|
9581
|
+
}
|
|
9582
|
+
// Owned legacy artifact: back it up rather than delete. A stray false
|
|
9583
|
+
// positive is then fully recoverable; a real legacy dir is out of the way.
|
|
9584
|
+
const backup = `${lp}.pre-mover-backup`;
|
|
9585
|
+
if (!fs.existsSync(backup)) {
|
|
9586
|
+
fs.renameSync(lp, backup);
|
|
9587
|
+
statusLine("ok", "Backed up", `legacy ${path.basename(lp)} → ${path.basename(backup)}`);
|
|
9588
|
+
} else {
|
|
9589
|
+
statusLine("warn", "Left as-is", `${path.basename(lp)} (backup already exists)`);
|
|
9590
|
+
}
|
|
9591
|
+
} catch {}
|
|
7035
9592
|
}
|
|
7036
9593
|
|
|
7037
9594
|
// 1. Vault structure
|
|
@@ -7196,15 +9753,54 @@ async function main() {
|
|
|
7196
9753
|
|
|
7197
9754
|
const agentNames = selectedAgents.map((a) => a.name);
|
|
7198
9755
|
|
|
9756
|
+
// `moveros` is only on PATH after a global npm install. An `npx mover-os`
|
|
9757
|
+
// user has no bin — telling them to "Run moveros" dead-ends (sol, 2026-07-10).
|
|
9758
|
+
// Detect the npx cache path and print the command that will actually work.
|
|
9759
|
+
const viaNpx = /[\\/]_npx[\\/]/.test(process.argv[1] || "") || /[\\/]_npx[\\/]/.test(process.env.npm_execpath || "");
|
|
9760
|
+
const cliCmd = viaNpx ? "npx mover-os" : "moveros";
|
|
9761
|
+
|
|
7199
9762
|
// ── What you got ──
|
|
7200
9763
|
ln(gray(" ─────────────────────────────────────────────"));
|
|
7201
9764
|
ln();
|
|
7202
9765
|
ln(` ${bold("What was installed")}`);
|
|
7203
9766
|
ln();
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
9767
|
+
// Counted from the bundle at print time, never hardcoded. Sol's product
|
|
9768
|
+
// review (2026-07-10) caught this banner claiming 23/62/18 while the
|
|
9769
|
+
// install delivered 25/70/20 — a buyer couldn't tell which product they
|
|
9770
|
+
// bought. Same filters as the installers: workflows = src/workflows/*.md,
|
|
9771
|
+
// skills = findSkills + the category gate installSkillPacks applies,
|
|
9772
|
+
// hooks = unique scripts in the generated Claude settings.
|
|
9773
|
+
// Terra verify (2026-07-10): a null selectedCategories can mean "skills
|
|
9774
|
+
// skipped entirely", not "all categories" — gate each line on whether that
|
|
9775
|
+
// artifact class was actually installed, or the banner overstates again.
|
|
9776
|
+
// Agents whose installers call installWorkflows/installWorkflowsConverted
|
|
9777
|
+
// (see installClaudeCode..installCopilot). Cline/amazon-q/etc get rules+skills only.
|
|
9778
|
+
const wfAgents = new Set(["claude-code", "cursor", "codex", "windsurf", "gemini-cli", "antigravity", "roo-code", "copilot"]);
|
|
9779
|
+
const anyWorkflowAgent = selectedIds.some((id) => wfAgents.has(id));
|
|
9780
|
+
let wfInstalled = 0;
|
|
9781
|
+
try {
|
|
9782
|
+
if (anyWorkflowAgent && skillOpts.workflows !== false) {
|
|
9783
|
+
wfInstalled = fs.readdirSync(path.join(bundleDir, "src", "workflows")).filter((n) => n.endsWith(".md")).length;
|
|
9784
|
+
}
|
|
9785
|
+
} catch {}
|
|
9786
|
+
let skInstalled = 0;
|
|
9787
|
+
try {
|
|
9788
|
+
if (skillOpts.install) {
|
|
9789
|
+
skInstalled = findSkills(bundleDir).filter(
|
|
9790
|
+
(s) => !selectedCategories || s.category === "tools" || selectedCategories.has(s.category)
|
|
9791
|
+
).length;
|
|
9792
|
+
}
|
|
9793
|
+
} catch {}
|
|
9794
|
+
let hkInstalled = 0;
|
|
9795
|
+
try {
|
|
9796
|
+
const hookScripts = new Set();
|
|
9797
|
+
for (const m of generateClaudeSettings().matchAll(/hooks\/([a-z0-9-]+\.sh)/g)) hookScripts.add(m[1]);
|
|
9798
|
+
hkInstalled = hookScripts.size;
|
|
9799
|
+
} catch {}
|
|
9800
|
+
if (wfInstalled > 0) ln(` ${green("▸")} ${bold(String(wfInstalled))} workflows ${dim("slash commands for daily rhythm, projects, strategy")}`);
|
|
9801
|
+
if (skInstalled > 0) ln(` ${green("▸")} ${bold(String(skInstalled))} skills ${dim("curated packs for dev, marketing, CRO, design")}`);
|
|
9802
|
+
if (selectedIds.includes("claude-code") && hkInstalled > 0) {
|
|
9803
|
+
ln(` ${green("▸")} ${bold(String(hkInstalled))} hooks ${dim("lifecycle guards (engine protection, git safety)")}`);
|
|
7208
9804
|
}
|
|
7209
9805
|
ln(` ${green("▸")} ${bold(String(selectedAgents.length))} agent${selectedAgents.length > 1 ? "s" : ""} ${dim(agentNames.join(", "))}`);
|
|
7210
9806
|
ln(` ${green("▸")} PARA vault ${dim("folders, templates, Engine scaffold")}`);
|
|
@@ -7213,21 +9809,23 @@ async function main() {
|
|
|
7213
9809
|
// ── Next steps (animated slide-in) ──
|
|
7214
9810
|
ln(gray(" ─────────────────────────────────────────────"));
|
|
7215
9811
|
ln();
|
|
9812
|
+
// Funnel v2 (owner spec): the dashboard IS the front door. Lead with
|
|
9813
|
+
// moveros; Obsidian is the file viewer, /setup the terminal alternative.
|
|
7216
9814
|
await slideIn([
|
|
7217
9815
|
` ${bold("Next steps")}`,
|
|
7218
9816
|
"",
|
|
7219
|
-
` ${cyan("1")}
|
|
7220
|
-
` ${dim("
|
|
9817
|
+
` ${cyan("1")} Run ${bold(cliCmd)}`,
|
|
9818
|
+
` ${dim("Opens your dashboard; setup starts there, a few plain questions")}`,
|
|
7221
9819
|
"",
|
|
7222
|
-
` ${cyan("2")} Open
|
|
7223
|
-
` ${dim("
|
|
9820
|
+
` ${cyan("2")} Open your vault in ${bold("Obsidian")} ${dim("(optional)")}`,
|
|
9821
|
+
` ${dim("For viewing and browsing your files. Not required to start.")}`,
|
|
7224
9822
|
"",
|
|
7225
9823
|
]);
|
|
7226
|
-
ln(` ${cyan("3")} Enable the Obsidian theme`);
|
|
9824
|
+
ln(` ${cyan("3")} Enable the Obsidian theme ${dim("(if using Obsidian)")}`);
|
|
7227
9825
|
ln(` ${dim("Settings → Appearance → CSS snippets → minimal-theme")}`);
|
|
7228
9826
|
ln();
|
|
7229
|
-
ln(` ${cyan("4")} Run ${bold("/setup")}`);
|
|
7230
|
-
ln(` ${dim("
|
|
9827
|
+
ln(` ${cyan("4")} Prefer the terminal? Run ${bold("/setup")} in your agent instead`);
|
|
9828
|
+
ln(` ${dim("The same setup, full depth: Identity, Strategy, Goals, projects")}`);
|
|
7231
9829
|
ln();
|
|
7232
9830
|
ln(gray(" ─────────────────────────────────────────────"));
|
|
7233
9831
|
ln();
|
|
@@ -7240,13 +9838,32 @@ async function main() {
|
|
|
7240
9838
|
ln();
|
|
7241
9839
|
ln(` ${bold("CLI commands")} ${dim("(run from any terminal)")}`);
|
|
7242
9840
|
ln();
|
|
7243
|
-
ln(` ${green("▸")} ${bold(
|
|
7244
|
-
ln(` ${green("▸")} ${bold("
|
|
7245
|
-
ln(` ${green("▸")} ${bold("
|
|
7246
|
-
ln(` ${green("▸")} ${bold("
|
|
7247
|
-
ln(` ${green("▸")} ${bold("
|
|
7248
|
-
ln(` ${green("▸")} ${bold("
|
|
9841
|
+
ln(` ${green("▸")} ${bold(cliCmd)} ${dim("Open your dashboard")}`);
|
|
9842
|
+
ln(` ${green("▸")} ${bold(cliCmd + " doctor")} ${dim("Health check across all agents")}`);
|
|
9843
|
+
ln(` ${green("▸")} ${bold(cliCmd + " capture")} ${dim("Quick inbox — tasks, links, ideas")}`);
|
|
9844
|
+
ln(` ${green("▸")} ${bold(cliCmd + " prayer")} ${dim("Set up prayer time reminders")}`);
|
|
9845
|
+
ln(` ${green("▸")} ${bold(cliCmd + " update")} ${dim("Update agents, rules, and skills")}`);
|
|
9846
|
+
ln(` ${green("▸")} ${bold(cliCmd + " menu")} ${dim("All commands (classic launcher)")}`);
|
|
7249
9847
|
ln();
|
|
9848
|
+
|
|
9849
|
+
// ── Land on the dashboard's onboarding route (the payoff) ──
|
|
9850
|
+
// Engine is still templates pre-/setup, so force the cold-start Onboarding
|
|
9851
|
+
// cascade explicitly (?onboarding=1). TTY-only + best-effort: a dashboard
|
|
9852
|
+
// hiccup never fails or blocks the install. `--no-open` opts out (automation).
|
|
9853
|
+
if (!opts.noOpen && process.stdout.isTTY) {
|
|
9854
|
+
ln(gray(" ─────────────────────────────────────────────"));
|
|
9855
|
+
ln();
|
|
9856
|
+
try {
|
|
9857
|
+
const dash = require("./src/dashboard");
|
|
9858
|
+
await dash.open({ vault: vaultPath, version: VERSION, rest: [], soft: false, route: "/?onboarding=1" });
|
|
9859
|
+
// Setup starts IN the browser that just opened. Pointing at /setup here
|
|
9860
|
+
// contradicted the dashboard-first funnel (sol, 2026-07-10).
|
|
9861
|
+
ln(` ${dim("Opening onboarding in your browser — setup starts there.")}`);
|
|
9862
|
+
} catch (err) {
|
|
9863
|
+
ln(` ${dim("Your dashboard is ready — open it anytime with")} ${bold(cliCmd)}.`);
|
|
9864
|
+
}
|
|
9865
|
+
ln();
|
|
9866
|
+
}
|
|
7250
9867
|
}
|
|
7251
9868
|
|
|
7252
9869
|
main().catch((err) => {
|