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.
Files changed (119) hide show
  1. package/README.md +34 -24
  2. package/install.js +2868 -251
  3. package/package.json +15 -3
  4. package/src/dashboard/build.js +1541 -0
  5. package/src/dashboard/dashboard.js +276 -0
  6. package/src/dashboard/index.js +319 -0
  7. package/src/dashboard/lib/activation-log.js +297 -0
  8. package/src/dashboard/lib/active-context-parser.js +189 -0
  9. package/src/dashboard/lib/agent-command.js +93 -0
  10. package/src/dashboard/lib/agent-detect.js +255 -0
  11. package/src/dashboard/lib/agent-detector.js +92 -0
  12. package/src/dashboard/lib/agent-session.js +483 -0
  13. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  14. package/src/dashboard/lib/approval-registry.js +170 -0
  15. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  16. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  17. package/src/dashboard/lib/config-parser.js +109 -0
  18. package/src/dashboard/lib/connect-recommender.js +131 -0
  19. package/src/dashboard/lib/correlations-parser.js +231 -0
  20. package/src/dashboard/lib/daily-note-resolver.js +228 -0
  21. package/src/dashboard/lib/date-utils.js +43 -0
  22. package/src/dashboard/lib/distribution-parser.js +137 -0
  23. package/src/dashboard/lib/dossier-parser.js +64 -0
  24. package/src/dashboard/lib/drift-history.js +88 -0
  25. package/src/dashboard/lib/drift-score.js +119 -0
  26. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  27. package/src/dashboard/lib/engine-health.js +173 -0
  28. package/src/dashboard/lib/engine-writer.js +1831 -0
  29. package/src/dashboard/lib/execution-plan.js +125 -0
  30. package/src/dashboard/lib/experiments-parser.js +429 -0
  31. package/src/dashboard/lib/feed-parser.js +294 -0
  32. package/src/dashboard/lib/forked-future.js +60 -0
  33. package/src/dashboard/lib/goal-forecast.js +427 -0
  34. package/src/dashboard/lib/goals-parser.js +67 -0
  35. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  36. package/src/dashboard/lib/hook-activity.js +48 -0
  37. package/src/dashboard/lib/hook-indexer.js +169 -0
  38. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  39. package/src/dashboard/lib/identity-parser.js +85 -0
  40. package/src/dashboard/lib/ingestion.js +418 -0
  41. package/src/dashboard/lib/library-indexer-v2.js +226 -0
  42. package/src/dashboard/lib/library-indexer.js +105 -0
  43. package/src/dashboard/lib/library-search.js +290 -0
  44. package/src/dashboard/lib/log-activation.sh +61 -0
  45. package/src/dashboard/lib/memory-curator.js +97 -0
  46. package/src/dashboard/lib/memory-gardener.js +177 -0
  47. package/src/dashboard/lib/memory-gepa.js +102 -0
  48. package/src/dashboard/lib/memory-index.js +519 -0
  49. package/src/dashboard/lib/memory-rerank.js +72 -0
  50. package/src/dashboard/lib/memory-text.js +136 -0
  51. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  52. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  53. package/src/dashboard/lib/onboarding-forge.js +70 -0
  54. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  55. package/src/dashboard/lib/override-summary.js +73 -0
  56. package/src/dashboard/lib/paths.js +192 -0
  57. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  58. package/src/dashboard/lib/phantom-strategy.js +129 -0
  59. package/src/dashboard/lib/pid-markers.js +80 -0
  60. package/src/dashboard/lib/project-scanner.js +121 -0
  61. package/src/dashboard/lib/promise-wall.js +88 -0
  62. package/src/dashboard/lib/record-score.js +173 -0
  63. package/src/dashboard/lib/redaction.js +140 -0
  64. package/src/dashboard/lib/refusal-parser.js +44 -0
  65. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  66. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  67. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  68. package/src/dashboard/lib/run-registry.js +286 -0
  69. package/src/dashboard/lib/safe-write.js +63 -0
  70. package/src/dashboard/lib/session-log-parser.js +145 -0
  71. package/src/dashboard/lib/session-time-parser.js +158 -0
  72. package/src/dashboard/lib/skill-index.js +171 -0
  73. package/src/dashboard/lib/skill-indexer.js +118 -0
  74. package/src/dashboard/lib/skill-recommender.js +689 -0
  75. package/src/dashboard/lib/state-core/backfill.js +298 -0
  76. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  77. package/src/dashboard/lib/state-core/event-log.js +615 -0
  78. package/src/dashboard/lib/state-core/events.js +265 -0
  79. package/src/dashboard/lib/state-core/projections.js +376 -0
  80. package/src/dashboard/lib/state-core/start-close.js +162 -0
  81. package/src/dashboard/lib/state-core/trial.js +96 -0
  82. package/src/dashboard/lib/strategy-parser.js +248 -0
  83. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  84. package/src/dashboard/lib/streak-parser.js +95 -0
  85. package/src/dashboard/lib/suggested-now.js +254 -0
  86. package/src/dashboard/lib/tool-awareness.js +125 -0
  87. package/src/dashboard/lib/transcript-parser.js +371 -0
  88. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  89. package/src/dashboard/lib/view-generator.js +163 -0
  90. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  91. package/src/dashboard/lib/walkthrough-script.js +140 -0
  92. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  93. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  94. package/src/dashboard/server.js +2402 -0
  95. package/src/dashboard/shortcut.js +284 -0
  96. package/src/dashboard/static/setup-poc.html +306 -0
  97. package/src/dashboard/static/walkthrough-poc.html +580 -0
  98. package/src/dashboard/styles.css +1201 -0
  99. package/src/dashboard/templates/index.html +278 -0
  100. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  101. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  102. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  103. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  104. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  105. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  106. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  107. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  108. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  109. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  110. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  111. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  112. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  113. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  114. package/src/dashboard/ui/dist/icon.svg +4 -0
  115. package/src/dashboard/ui/dist/index.html +18 -0
  116. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  117. package/src/dashboard/ui/dist/registerSW.js +1 -0
  118. package/src/dashboard/ui/dist/sw.js +1 -0
  119. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,284 @@
1
+ "use strict";
2
+
3
+ // shortcut.js — cross-platform desktop launcher generator for Mover Studio.
4
+ //
5
+ // Produces a double-clickable launcher (named by the user, e.g. "My OS")
6
+ // that opens the dashboard. The launcher invokes `node <install.js> open`
7
+ // with ABSOLUTE paths baked in — a launcher opened from Finder / Explorer /
8
+ // a desktop menu does NOT inherit the shell PATH, so `moveros` would not
9
+ // resolve; baking node + entry makes it PATH-proof.
10
+ //
11
+ // Design split: the *content generators* are pure (string in → string out,
12
+ // testable on any OS); `createShortcut()` is the thin writer that does FS +
13
+ // (on Windows) a PowerShell call. Tests cover the generators directly.
14
+
15
+ const fs = require("fs");
16
+ const path = require("path");
17
+ const os = require("os");
18
+ const { execFileSync } = require("child_process");
19
+
20
+ // ── name handling ─────────────────────────────────────────────────────────
21
+
22
+ // The display name the user chose (config.os_name), falling back to the
23
+ // product default. Kept here so install + the `open` command + the dashboard
24
+ // all resolve the same string.
25
+ function studioName(config) {
26
+ const raw = config && (config.os_name || config.osName);
27
+ const n = String(raw == null ? "" : raw).trim();
28
+ return n || "Mover Studio";
29
+ }
30
+
31
+ // Filesystem-safe version of the name for the bundle / .desktop / .lnk file
32
+ // name. Strips path separators, colons, and control chars; collapses spaces.
33
+ function fileSafeName(name) {
34
+ return String(name || "Mover Studio")
35
+ .replace(/[\/\\:*?"<>|\u0000-\u001f]/g, "")
36
+ .replace(/\s+/g, " ")
37
+ .trim() || "Mover Studio";
38
+ }
39
+
40
+ function xmlEscape(s) {
41
+ return String(s)
42
+ .replace(/&/g, "&amp;")
43
+ .replace(/</g, "&lt;")
44
+ .replace(/>/g, "&gt;")
45
+ .replace(/"/g, "&quot;")
46
+ .replace(/'/g, "&apos;");
47
+ }
48
+
49
+ // PowerShell single-quoted strings escape a literal quote by doubling it.
50
+ function psEscape(s) {
51
+ return String(s).replace(/'/g, "''");
52
+ }
53
+
54
+ // ── macOS: a minimal .app bundle (no Terminal window) ─────────────────────
55
+
56
+ function macLauncherScript({ nodeBin, entry }) {
57
+ // Resolve node AT LAUNCH. The baked path is tried first (fast, usually right),
58
+ // but a Finder-launched .app inherits only a minimal PATH and node version
59
+ // managers (nvm/fnm) relocate the binary on upgrade — so fall back to a
60
+ // login-shell lookup and the common install locations before giving up.
61
+ // (Baking the raw binary alone was brittle: an `nvm install 22` would orphan
62
+ // the launcher with no error.) Quoted paths tolerate spaces.
63
+ return [
64
+ "#!/bin/bash",
65
+ `NODE="${nodeBin}"`,
66
+ `[ -x "$NODE" ] || NODE="$(bash -lc 'command -v node' 2>/dev/null)"`,
67
+ `[ -x "$NODE" ] || for c in /usr/local/bin/node /opt/homebrew/bin/node "$HOME/.volta/bin/node"; do [ -x "$c" ] && NODE="$c" && break; done`,
68
+ `exec "$NODE" "${entry}" open`,
69
+ "",
70
+ ].join("\n");
71
+ }
72
+
73
+ function macInfoPlist({ name }) {
74
+ const safe = xmlEscape(name);
75
+ return `<?xml version="1.0" encoding="UTF-8"?>
76
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
77
+ <plist version="1.0">
78
+ <dict>
79
+ <key>CFBundleName</key><string>${safe}</string>
80
+ <key>CFBundleDisplayName</key><string>${safe}</string>
81
+ <key>CFBundleIdentifier</key><string>dev.moveros.studio</string>
82
+ <key>CFBundleVersion</key><string>1.0</string>
83
+ <key>CFBundleShortVersionString</key><string>1.0</string>
84
+ <key>CFBundlePackageType</key><string>APPL</string>
85
+ <key>CFBundleExecutable</key><string>launcher</string>
86
+ <key>LSUIElement</key><true/>
87
+ </dict>
88
+ </plist>
89
+ `;
90
+ }
91
+
92
+ // ── Windows: a .bat (always works) + a .lnk via PowerShell (no console flash)
93
+
94
+ function winBatLauncher({ nodeBin, entry }) {
95
+ // Trampoline: resolve node at launch (the baked path can go stale after an
96
+ // nvm-windows upgrade), then detach with `start "" /b`. NOTE: double-clicking
97
+ // a .bat shows a brief console host window — acceptable for the durable
98
+ // fallback; the .lnk (WindowStyle 7) is the no-flash primary. CRLF endings.
99
+ return [
100
+ "@echo off",
101
+ `set "NODE=${nodeBin}"`,
102
+ `if not exist "%NODE%" for /f "delims=" %%i in ('where node 2^>nul') do set "NODE=%%i"`,
103
+ `start "" /b "%NODE%" "${entry}" open`,
104
+ "",
105
+ ].join("\r\n");
106
+ }
107
+
108
+ // A PowerShell script body that creates a .lnk pointing at node with args,
109
+ // hidden window (WindowStyle 7 = minimized — closest to "no flash" for a
110
+ // console target). Icon optional.
111
+ function winLnkPowerShell({ lnkPath, targetPath, args, workdir, iconPath }) {
112
+ const lines = [
113
+ `$ws = New-Object -ComObject WScript.Shell;`,
114
+ `$s = $ws.CreateShortcut('${psEscape(lnkPath)}');`,
115
+ `$s.TargetPath = '${psEscape(targetPath)}';`,
116
+ ];
117
+ if (args) lines.push(`$s.Arguments = '${psEscape(args)}';`);
118
+ lines.push(`$s.WorkingDirectory = '${psEscape(workdir)}';`);
119
+ lines.push(`$s.WindowStyle = 7;`);
120
+ if (iconPath) lines.push(`$s.IconLocation = '${psEscape(iconPath)}';`);
121
+ lines.push(`$s.Save();`);
122
+ return lines.join("\n") + "\n";
123
+ }
124
+
125
+ // ── Linux: a .desktop entry ───────────────────────────────────────────────
126
+
127
+ function linuxDesktopEntry({ name, nodeBin, entry, iconPath }) {
128
+ const icon = iconPath ? `Icon=${iconPath}\n` : "";
129
+ // A desktop-entry value is a SINGLE line — strip ALL control chars (CR/LF and
130
+ // other C0/DEL), not just LF, so a stray char can't inject or corrupt lines.
131
+ const safeName = String(name).replace(/[\x00-\x1f\x7f]/g, " ").trim() || "Mover Studio";
132
+ // `bash -lc` loads the login PATH so an nvm/fnm-managed node resolves even
133
+ // though a .desktop launcher doesn't inherit an interactive shell; the baked
134
+ // path is tried first for speed.
135
+ const exec = `bash -lc 'NODE="${nodeBin}"; [ -x "$NODE" ] || NODE="$(command -v node)"; exec "$NODE" "${entry}" open'`;
136
+ return `[Desktop Entry]
137
+ Type=Application
138
+ Name=${safeName}
139
+ Comment=Open your Mover OS dashboard
140
+ Exec=${exec}
141
+ ${icon}Terminal=false
142
+ Categories=Utility;Office;
143
+ `;
144
+ }
145
+
146
+ // Resolve the REAL Windows Desktop folder. OneDrive "Known Folder Move" (the
147
+ // Win11 consumer default) redirects Desktop to ~/OneDrive/Desktop, so a naive
148
+ // ~/Desktop would drop the shortcut where the user never sees it. Ask Windows
149
+ // for the actual path; fall back to ~/Desktop if PowerShell is unavailable.
150
+ function resolveWindowsDesktop(home) {
151
+ try {
152
+ const out = execFileSync(
153
+ "powershell",
154
+ ["-NoProfile", "-NonInteractive", "-Command", "[Environment]::GetFolderPath('Desktop')"],
155
+ { encoding: "utf8" }
156
+ ).trim();
157
+ if (out && fs.existsSync(out)) return out;
158
+ } catch (_) { /* fall through to the default */ }
159
+ return path.join(home, "Desktop");
160
+ }
161
+
162
+ // ── writer ────────────────────────────────────────────────────────────────
163
+
164
+ // Create the launcher for the current platform. Returns { created: string[],
165
+ // platform, skipped?: reason }. Never throws on a single-platform failure —
166
+ // returns a skipped reason so install can continue.
167
+ function createShortcut({ name, nodeBin, entry, iconPath, platform, homeDir, desktopDir } = {}) {
168
+ const plat = platform || process.platform;
169
+ const home = homeDir || os.homedir();
170
+ const display = studioName({ os_name: name });
171
+ const fname = fileSafeName(display);
172
+ const created = [];
173
+
174
+ try {
175
+ if (plat === "darwin") {
176
+ const appDir = path.join(home, "Applications", `${fname}.app`);
177
+ const macosDir = path.join(appDir, "Contents", "MacOS");
178
+ fs.mkdirSync(macosDir, { recursive: true });
179
+ fs.writeFileSync(
180
+ path.join(appDir, "Contents", "Info.plist"),
181
+ macInfoPlist({ name: display }),
182
+ "utf8"
183
+ );
184
+ const launcher = path.join(macosDir, "launcher");
185
+ fs.writeFileSync(launcher, macLauncherScript({ nodeBin, entry }), "utf8");
186
+ fs.chmodSync(launcher, 0o755);
187
+ created.push(appDir);
188
+ // The checkbox promises a DESKTOP shortcut; the .app lives in
189
+ // ~/Applications (Spotlight/Launchpad home). A Desktop symlink makes the
190
+ // promise literally true — Finder double-clicks straight through it.
191
+ // Best-effort: a missing Desktop dir never fails the launcher.
192
+ try {
193
+ const desktop = desktopDir || path.join(home, "Desktop");
194
+ if (fs.existsSync(desktop)) {
195
+ const linkPath = path.join(desktop, `${fname}.app`);
196
+ // Refresh ONLY a symlink. unlinkSync deletes regular files too (it
197
+ // only refuses directories), so an unconditional unlink would destroy
198
+ // a user's real file that happens to carry this name (terra catch).
199
+ // The lstat->unlink pair is not atomic; a local process racing this
200
+ // exact window could still lose a swapped-in file. Accepted: POSIX
201
+ // offers no unlink-if-symlink, rename-over clobbers unconditionally,
202
+ // and a hostile same-user process can delete files directly anyway.
203
+ try { if (fs.lstatSync(linkPath).isSymbolicLink()) fs.unlinkSync(linkPath); } catch (_) { /* absent — nothing to clear */ }
204
+ if (!fs.existsSync(linkPath)) { fs.symlinkSync(appDir, linkPath); created.push(linkPath); }
205
+ }
206
+ } catch (_) { /* desktop optional */ }
207
+ return { created, platform: plat };
208
+ }
209
+
210
+ if (plat === "win32") {
211
+ const desktop = desktopDir || resolveWindowsDesktop(home);
212
+ // The durable trampoline lives in a STABLE dir (survives node upgrades);
213
+ // the .lnk on the Desktop just points at it. If PowerShell is blocked we
214
+ // drop the .bat straight on the Desktop as the (still durable) fallback.
215
+ const launcherDir = path.join(home, ".mover", "launcher");
216
+ fs.mkdirSync(launcherDir, { recursive: true });
217
+ const batPath = path.join(launcherDir, `${fname}.bat`);
218
+ fs.writeFileSync(batPath, winBatLauncher({ nodeBin, entry }), "utf8");
219
+ created.push(batPath);
220
+
221
+ const lnkPath = path.join(desktop, `${fname}.lnk`);
222
+ let lnkOk = false;
223
+ try {
224
+ const ps = winLnkPowerShell({ lnkPath, targetPath: batPath, workdir: launcherDir, iconPath });
225
+ execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", ps], {
226
+ stdio: "ignore",
227
+ });
228
+ if (fs.existsSync(lnkPath)) { created.push(lnkPath); lnkOk = true; }
229
+ } catch (_) { /* PowerShell blocked — fall back to a Desktop .bat below */ }
230
+ if (!lnkOk) {
231
+ try {
232
+ const deskBat = path.join(desktop, `${fname}.bat`);
233
+ fs.writeFileSync(deskBat, winBatLauncher({ nodeBin, entry }), "utf8");
234
+ created.push(deskBat);
235
+ } catch (_) { /* Desktop may not exist (headless) — the stable .bat still works */ }
236
+ }
237
+ return { created, platform: plat };
238
+ }
239
+
240
+ if (plat === "linux") {
241
+ const appsDir = path.join(home, ".local", "share", "applications");
242
+ fs.mkdirSync(appsDir, { recursive: true });
243
+ const content = linuxDesktopEntry({ name: display, nodeBin, entry, iconPath });
244
+ const appFile = path.join(appsDir, "mover-studio.desktop");
245
+ fs.writeFileSync(appFile, content, "utf8");
246
+ fs.chmodSync(appFile, 0o755);
247
+ created.push(appFile);
248
+ // Best-effort copy to the Desktop too (may not exist on headless boxes).
249
+ const desktop = desktopDir || path.join(home, "Desktop");
250
+ try {
251
+ if (fs.existsSync(desktop)) {
252
+ const deskFile = path.join(desktop, `${fname}.desktop`);
253
+ fs.writeFileSync(deskFile, content, "utf8");
254
+ fs.chmodSync(deskFile, 0o755);
255
+ // GNOME 3.28+ shows an untrusted .desktop on the Desktop as a plain
256
+ // text file until "Allow Launching" is granted. Mark it trusted so it
257
+ // is double-clickable. Best-effort: gio may be absent on non-GNOME.
258
+ try {
259
+ execFileSync("gio", ["set", deskFile, "metadata::trusted", "true"], { stdio: "ignore" });
260
+ } catch (_) { /* non-GNOME or gio missing — menu entry still works */ }
261
+ created.push(deskFile);
262
+ }
263
+ } catch (_) { /* desktop optional */ }
264
+ return { created, platform: plat };
265
+ }
266
+
267
+ return { created, platform: plat, skipped: `unsupported platform: ${plat}` };
268
+ } catch (err) {
269
+ return { created, platform: plat, skipped: `shortcut write failed: ${err.message}` };
270
+ }
271
+ }
272
+
273
+ module.exports = {
274
+ studioName,
275
+ fileSafeName,
276
+ xmlEscape,
277
+ psEscape,
278
+ macLauncherScript,
279
+ macInfoPlist,
280
+ winBatLauncher,
281
+ winLnkPowerShell,
282
+ linuxDesktopEntry,
283
+ createShortcut,
284
+ };
@@ -0,0 +1,306 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Mover OS · Setup</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600&family=Newsreader:opsz,wght@6..72,400;6..72,500&display=swap" rel="stylesheet">
10
+ <!-- Shared Mover OS tokens (same palette as the dashboard) so /setup is
11
+ native, not alien. Canonical values live in styles/v6/v3.css. -->
12
+ <link rel="stylesheet" href="/assets/mover-system.css">
13
+ <style>
14
+ /* page layout + class skinning — colours come from mover-system.css tokens */
15
+ * { box-sizing: border-box; }
16
+ body { margin: 0; background: var(--m-bg); color: var(--m-ink);
17
+ font: 15px/1.6 var(--m-sans); -webkit-font-smoothing: antialiased; }
18
+ body::before { /* faint warm wash, matches the app's atmosphere */
19
+ content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
20
+ background: radial-gradient(70% 50% at 82% 0%, var(--m-gold-glow), transparent 60%);
21
+ opacity: 0.5; }
22
+ .wrap { max-width: 680px; margin: 0 auto; padding: 32px 22px 116px; }
23
+ header { display: flex; align-items: center; justify-content: space-between;
24
+ padding: 4px 0 22px; border-bottom: 1px solid var(--m-line); margin-bottom: 26px; }
25
+ .brand { font-family: var(--m-serif); font-size: 23px; color: var(--m-ink); }
26
+ .brand small { font-family: var(--m-sans); color: var(--m-ink-3); font-size: 12px;
27
+ margin-left: 11px; letter-spacing: .04em; }
28
+ .status { display: flex; align-items: center; gap: 8px; color: var(--m-ink-2);
29
+ font-size: 12px; font-variant-numeric: tabular-nums; }
30
+ .dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4742; }
31
+ .dot:not(.off) { background: var(--m-good); box-shadow: 0 0 8px rgba(169,191,130,0.55); }
32
+ .dot.off { background: #4a4742; box-shadow: none; }
33
+ .card { background: var(--m-card); border: 1px solid var(--m-line); border-radius: 16px;
34
+ padding: 18px 20px; margin: 14px 0;
35
+ box-shadow: 0 1px 0 rgba(255,240,214,0.05) inset, 0 30px 60px -34px rgba(0,0,0,0.9);
36
+ animation: rise .24s var(--m-ease); }
37
+ .q { font-family: var(--m-serif); font-size: 21px; line-height: 1.4; color: var(--m-ink); }
38
+ .a { color: var(--m-ink-2); font-size: 14px; margin-top: 6px; }
39
+ .perm { border-color: var(--m-gold-line); }
40
+ .perm .lbl { color: var(--m-gold-2); font-size: 10px; text-transform: uppercase;
41
+ letter-spacing: .14em; margin-bottom: 8px; }
42
+ .row { display: flex; gap: 10px; margin-top: 16px; }
43
+ button { font: inherit; border: 1px solid var(--m-line-2); background: rgba(255,240,214,0.03);
44
+ color: var(--m-ink); padding: 10px 17px; border-radius: 11px; cursor: pointer;
45
+ transition: border-color .15s, background .15s; }
46
+ button:hover:not(:disabled) { border-color: var(--m-gold-line); }
47
+ button.primary { background: linear-gradient(180deg, var(--m-gold-2), var(--m-gold));
48
+ color: var(--m-on-gold); border-color: transparent; font-weight: 600;
49
+ box-shadow: 0 0 24px -8px var(--m-gold-glow); }
50
+ button:disabled { opacity: .4; cursor: default; box-shadow: none; }
51
+ .composer { position: fixed; left: 0; right: 0; bottom: 0;
52
+ background: linear-gradient(transparent, var(--m-bg) 34%); padding: 18px 22px; }
53
+ .composer .inner { max-width: 680px; margin: 0 auto; display: flex; gap: 10px; }
54
+ textarea { flex: 1; background: var(--m-bg-2); border: 1px solid var(--m-line-2);
55
+ border-radius: 12px; color: var(--m-ink); padding: 13px; font: inherit; resize: none; height: 50px; }
56
+ textarea:focus { outline: none; border-color: var(--m-gold-line); }
57
+ @keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
58
+ /* Thinking indicator — the wait sells the product: it's not loading, it's
59
+ reading you. A live gold orb + rotating perceptive verbs. */
60
+ .thinking { display: flex; align-items: center; gap: 14px; }
61
+ .thinking .orb { position: relative; width: 22px; height: 22px; flex: none; }
62
+ .thinking .orb span { position: absolute; inset: 0; border-radius: 50%;
63
+ border: 1.5px solid var(--m-gold-line); animation: ripple 1.9s var(--m-ease) infinite; }
64
+ .thinking .orb span:nth-child(2) { animation-delay: .63s; }
65
+ .thinking .orb span:nth-child(3) { animation-delay: 1.26s; }
66
+ .thinking .orb::after { content: ""; position: absolute; inset: 7px; border-radius: 50%;
67
+ background: var(--m-gold); box-shadow: 0 0 12px -1px var(--m-gold-glow);
68
+ animation: breathe 1.9s var(--m-ease) infinite; }
69
+ .thinking .verb { font-family: var(--m-serif); font-size: 18px; color: var(--m-gold-2); }
70
+ .thinking .verb em { font-style: normal; opacity: .55; animation: dots 1.4s ease-in-out infinite; }
71
+ .thinking .verb.swap { animation: fade .7s var(--m-ease); }
72
+ @keyframes ripple { from { transform: scale(.5); opacity: .85; } to { transform: scale(1.4); opacity: 0; } }
73
+ @keyframes breathe { 0%,100% { opacity: .5; transform: scale(.82); } 50% { opacity: 1; transform: scale(1.1); } }
74
+ @keyframes dots { 0%,100% { opacity: .25; } 50% { opacity: .7; } }
75
+ @keyframes fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
76
+ @media (prefers-reduced-motion: reduce) {
77
+ .thinking .orb span, .thinking .orb::after, .thinking .verb em, .thinking .verb.swap { animation: none; }
78
+ .thinking .orb::after { opacity: .85; }
79
+ }
80
+ /* Synthesis loader + value prophecy (D5) — the payoff moment: the system builds
81
+ the Engine from THEIR answers (live checklist), then names what it can now drive. */
82
+ .synth-h { font-family: var(--m-serif); font-size: 18px; color: var(--m-gold-2); margin-bottom: 14px; }
83
+ .synth-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
84
+ .synth-list li { display: flex; align-items: center; gap: 12px; color: var(--m-ink-3); font-size: 15px;
85
+ transition: color .35s var(--m-ease); }
86
+ .synth-list li.on { color: var(--m-ink); }
87
+ .synth-list li.done { color: var(--m-ink-2); }
88
+ .synth-list .tick { width: 17px; height: 17px; border-radius: 50%; border: 1.5px solid var(--m-line-2);
89
+ flex: none; position: relative; transition: border-color .3s, background .3s; }
90
+ .synth-list li.on .tick { border-color: var(--m-gold); border-top-color: transparent;
91
+ box-shadow: 0 0 10px -2px var(--m-gold-glow); animation: spin .8s linear infinite; }
92
+ .synth-list li.done .tick { border-color: var(--m-gold); background: var(--m-gold); animation: none; }
93
+ .synth-list li.done .tick::after { content: ""; position: absolute; left: 5.5px; top: 2px; width: 4px; height: 8px;
94
+ border: solid var(--m-on-gold); border-width: 0 1.6px 1.6px 0; transform: rotate(45deg); }
95
+ @keyframes spin { to { transform: rotate(360deg); } }
96
+ .prophecy { border-color: var(--m-gold-line); }
97
+ .proph-eyebrow { font-family: var(--m-sans); font-size: 10px; text-transform: uppercase; letter-spacing: .15em;
98
+ color: var(--m-gold-2); margin-bottom: 10px; }
99
+ .proph-body { font-family: var(--m-serif); font-size: 20px; line-height: 1.45; color: var(--m-ink); }
100
+ @media (prefers-reduced-motion: reduce) { .synth-list li.on .tick { animation: none; } }
101
+ /* Boot flow — D7 connect · D3 import · D4 recommend */
102
+ .drop-h, .rec-h { font-family: var(--m-serif); font-size: 18px; color: var(--m-gold-2); }
103
+ .drop-sub { color: var(--m-ink-2); font-size: 13px; margin: 6px 0 14px; }
104
+ .dropzone { border: 1.5px dashed var(--m-line-2); border-radius: 12px; padding: 26px; text-align: center;
105
+ color: var(--m-ink-3); font-size: 14px; cursor: pointer; transition: border-color .15s, color .15s, background .15s; }
106
+ .dropzone:hover, .dropzone.over { border-color: var(--m-gold-line); color: var(--m-ink-2); background: rgba(227,196,137,0.04); }
107
+ .droplist { color: var(--m-ink-2); font-size: 12px; margin-top: 10px; min-height: 1px; }
108
+ .rec-list { list-style: none; margin: 12px 0 0; padding: 0; display: flex; flex-direction: column; gap: 11px; }
109
+ .rec-list li { display: flex; flex-direction: column; gap: 2px; }
110
+ .rec-name { font-family: var(--m-serif); font-size: 15px; color: var(--m-ink); }
111
+ .rec-why { color: var(--m-ink-3); font-size: 12.5px; }
112
+ .conn-row { display: flex; align-items: center; gap: 9px; margin-top: 8px; color: var(--m-ink-2); font-size: 14px; }
113
+ .conn-row .ok { color: var(--m-good); } .conn-row .no { color: var(--m-ink-3); }
114
+ </style>
115
+ </head>
116
+ <body>
117
+ <div class="wrap">
118
+ <header>
119
+ <div class="brand">Mover OS<small>Setup · local</small></div>
120
+ <div class="status"><span class="dot off" id="dot"></span><span id="st">connecting…</span></div>
121
+ </header>
122
+ <div id="feed"></div>
123
+ </div>
124
+ <div class="composer"><div class="inner">
125
+ <textarea id="inp" placeholder="Your answer…" disabled></textarea>
126
+ <button class="primary" id="send" disabled>Send</button>
127
+ <button id="end" disabled>End</button>
128
+ </div></div>
129
+ <script>
130
+ const feed=document.getElementById('feed'), inp=document.getElementById('inp'),
131
+ sendBtn=document.getElementById('send'), endBtn=document.getElementById('end'),
132
+ dot=document.getElementById('dot'), st=document.getElementById('st');
133
+ let token=null, sessionId=null, es=null, busy=false, completed=false;
134
+
135
+ function card(cls){ const d=document.createElement('div'); d.className='card '+(cls||''); feed.appendChild(d); window.scrollTo(0,document.body.scrollHeight); return d; }
136
+ function setBusy(b){ busy=b; sendBtn.disabled=b||!sessionId; inp.disabled=b||!sessionId; }
137
+ function status(t,on){ st.textContent=t; dot.className='dot'+(on?'':' off'); }
138
+ async function post(p,body){ const r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json','X-Mover-Token':token},body:JSON.stringify(body)}); return r.json(); }
139
+
140
+ // Thinking indicator — perceptive, "getting to know you" verbs. The cold-start
141
+ // wait (~30-60s on the first question) is where the promise lands: the screen
142
+ // shouldn't look dead, it should look like the system is reading you.
143
+ const THINK_VERBS=["Listening","Reading you","Connecting threads","Forming a picture","Taking it in","Joining the dots","Turning it over","Sizing it up","Piecing it together","Sketching you in","Reading between the lines","Weighing it","Filing that away","Getting to know you","Mulling it over","Squaring it up"];
144
+ let thinkingEl=null, thinkTimer=null, lastVerb=-1;
145
+ function nextVerb(){ let i; do{ i=Math.floor(Math.random()*THINK_VERBS.length); }while(i===lastVerb&&THINK_VERBS.length>1); lastVerb=i; return THINK_VERBS[i]; }
146
+ function paintVerb(){ if(!thinkingEl)return; const v=thinkingEl.querySelector('.verb'); v.innerHTML=nextVerb()+'<em>…</em>'; v.classList.remove('swap'); void v.offsetWidth; v.classList.add('swap'); }
147
+ function showThinking(){ if(thinkingEl)return; thinkingEl=card('thinking'); thinkingEl.innerHTML='<div class="orb"><span></span><span></span><span></span></div><div class="verb"></div>'; paintVerb(); thinkTimer=setInterval(paintVerb,2300); window.scrollTo(0,document.body.scrollHeight); }
148
+ function hideThinking(){ if(thinkTimer){clearInterval(thinkTimer);thinkTimer=null;} if(thinkingEl){thinkingEl.remove();thinkingEl=null;} }
149
+
150
+ // D5 — synthesis loader + value prophecy (Mobbin BitePal/Brilliant pattern). When the
151
+ // interview agent emits [[SETUP_COMPLETE]], stop asking and show the system building the
152
+ // Engine from THEIR answers (live checklist), then name the outcome it can now drive.
153
+ const SYNTH_STEPS=["Reading your answers","Writing your Identity","Setting your Strategy","Mapping your leverage","Drafting tomorrow's plan"];
154
+ function runSynthesis(prophecy){
155
+ completed=true; hideThinking(); setBusy(true); endBtn.disabled=true; status('building your Engine',true);
156
+ const c=card('synth'); c.innerHTML='<div class="synth-h">Building your Engine</div><ul class="synth-list"></ul>';
157
+ const ul=c.querySelector('.synth-list');
158
+ SYNTH_STEPS.forEach(s=>{ const li=document.createElement('li'); const t=document.createElement('span'); t.className='tick'; const lbl=document.createElement('span'); lbl.textContent=s; li.appendChild(t); li.appendChild(lbl); ul.appendChild(li); });
159
+ const items=[...ul.children]; let i=0;
160
+ const step=()=>{ if(i>0){ items[i-1].classList.remove('on'); items[i-1].classList.add('done'); }
161
+ if(i<items.length){ items[i].classList.add('on'); i++; window.scrollTo(0,document.body.scrollHeight); setTimeout(step,780); }
162
+ else setTimeout(()=>revealProphecy(prophecy),460); };
163
+ step();
164
+ }
165
+ function revealProphecy(text){
166
+ const c=card('prophecy');
167
+ const eb=document.createElement('div'); eb.className='proph-eyebrow'; eb.textContent='Your Engine is ready';
168
+ const body=document.createElement('div'); body.className='proph-body'; body.textContent=(text||'Everything you told me is captured. Your Engine is ready.').slice(0,600);
169
+ c.appendChild(eb); c.appendChild(body);
170
+ status('setup complete',true); window.scrollTo(0,document.body.scrollHeight);
171
+ finalizeEngine(text); // A engine preview, then D4 recs, then the CTA
172
+ }
173
+ // A — preview the Engine the system seeded from the interview (dry-run, sandboxed on
174
+ // this machine; the real write happens only on a fresh install). Honest receipt for the
175
+ // synthesis loader: shows the actual files prepared, never claims a write that didn't run.
176
+ function finalizeEngine(workText){
177
+ post('/api/session/finalize',{sessionId,dryRun:true}).then(r=>{
178
+ if(r&&r.ok&&r.fileCount){
179
+ const c=card('rec');
180
+ const h=document.createElement('div'); h.className='rec-h'; h.textContent='Seeded from your answers'; c.appendChild(h);
181
+ const sub=document.createElement('div'); sub.className='drop-sub'; sub.style.margin='6px 0 0';
182
+ sub.textContent=r.fileCount+' Engine files prepared: '+(r.files||[]).map(f=>(f.path||'').split('/').pop().replace('.md','')).filter(Boolean).join(', ')+'.';
183
+ c.appendChild(sub);
184
+ }
185
+ }).catch(()=>{}).finally(()=>fetchRec(workText));
186
+ }
187
+ // D4 — recommendation card off the interview, keyed on the prophecy text.
188
+ function fetchRec(workText){
189
+ post('/api/setup/recommend',{profile:{work:workText||''}}).then(r=>{
190
+ if(r&&r.ok){
191
+ const recs=(r.recommended||[]).slice(0,5);
192
+ if(r.installAll||recs.length){
193
+ const c=card('rec');
194
+ const h=document.createElement('div'); h.className='rec-h'; h.textContent=r.installAll?'Recommended: the full core toolkit':'Skills matched to your work'; c.appendChild(h);
195
+ if(!r.installAll){ const ul=document.createElement('ul'); ul.className='rec-list';
196
+ recs.forEach(x=>{ const li=document.createElement('li'); const n=document.createElement('span'); n.className='rec-name'; n.textContent=x.label||x.skill;
197
+ const w=document.createElement('span'); w.className='rec-why'; w.textContent=x.reason||''; li.appendChild(n); li.appendChild(w); ul.appendChild(li); });
198
+ c.appendChild(ul); }
199
+ }
200
+ }
201
+ finalCta();
202
+ }).catch(()=>finalCta());
203
+ }
204
+ function finalCta(){
205
+ const c=card();
206
+ const row=document.createElement('div'); row.className='row';
207
+ const cta=document.createElement('button'); cta.className='primary'; cta.textContent='Open your dashboard';
208
+ cta.onclick=async()=>{ try{ if(es)es.close(); await post('/api/session/end',{sessionId}); }catch(_){} location.href='/'; };
209
+ row.appendChild(cta); c.appendChild(row); window.scrollTo(0,document.body.scrollHeight);
210
+ }
211
+
212
+ async function boot(){
213
+ const h=await (await fetch('/api/health')).json(); token=h.token;
214
+ // D7 — confirm a runnable agent before starting; else show the connect step.
215
+ let det=null; try{ det=await (await fetch('/api/agents/detect')).json(); }catch(_){}
216
+ const claude=det&&det.agents&&det.agents.find(a=>a.id==='claude-code');
217
+ if(!claude||!claude.runnable){ showConnect(det); return; }
218
+ startSession();
219
+ }
220
+ function showConnect(det){
221
+ const c=card('perm');
222
+ c.innerHTML='<div class="lbl">Connect your agent</div><div class="q">Mover OS runs setup through your own AI agent.</div>';
223
+ ((det&&det.agents)||[]).forEach(a=>{ const r=document.createElement('div'); r.className='conn-row';
224
+ r.innerHTML='<span class="'+(a.runnable?'ok':'no')+'">'+(a.runnable?'●':'○')+'</span> '+a.name+' — '+(a.runnable?'ready':(a.present?'installed, not connected':'not installed')); c.appendChild(r); });
225
+ const row=document.createElement('div'); row.className='row';
226
+ const retry=document.createElement('button'); retry.className='primary'; retry.textContent='Retry';
227
+ retry.onclick=()=>{ c.remove(); boot(); };
228
+ row.appendChild(retry); c.appendChild(row);
229
+ }
230
+ async function startSession(){
231
+ const r=await post('/api/session/start',{agent:'claude-code'});
232
+ if(!r.ok){ status('start failed: '+r.error,false); return; }
233
+ sessionId=r.sessionId; status('session live',true); endBtn.disabled=false;
234
+ es=new EventSource('/api/session/events?id='+encodeURIComponent(sessionId));
235
+ es.onmessage=(m)=>{ let ev; try{ev=JSON.parse(m.data)}catch(_){return} handle(ev); };
236
+ es.onerror=()=>status('stream dropped',false);
237
+ offerImport();
238
+ }
239
+ function offerImport(){
240
+ setBusy(true); // lock the composer until the interview actually starts
241
+ const c=card('drop');
242
+ c.innerHTML='<div class="drop-h">Bring your existing notes?</div>'
243
+ +'<div class="drop-sub">Drop exports from Apple Notes, Google Keep, Obsidian, or plain text. They stay on your machine, used only to skip questions the system can already answer.</div>'
244
+ +'<div class="dropzone" id="dz">Drop files here, or click to choose</div>'
245
+ +'<input type="file" id="dzin" multiple hidden>'
246
+ +'<div class="droplist" id="dl"></div>'
247
+ +'<div class="row"><button class="primary" id="imp" disabled>Import</button><button id="skip">Skip</button></div>';
248
+ const dz=c.querySelector('#dz'), inp2=c.querySelector('#dzin'), dl=c.querySelector('#dl'), imp=c.querySelector('#imp'), skip=c.querySelector('#skip');
249
+ let picked=[];
250
+ const refresh=()=>{ dl.textContent=picked.length?('Ready: '+picked.map(f=>f.name).join(', ')):''; imp.disabled=!picked.length; };
251
+ dz.onclick=()=>inp2.click();
252
+ inp2.onchange=()=>{ picked=[...inp2.files]; refresh(); };
253
+ dz.ondragover=(e)=>{ e.preventDefault(); dz.classList.add('over'); };
254
+ dz.ondragleave=()=>dz.classList.remove('over');
255
+ dz.ondrop=(e)=>{ e.preventDefault(); dz.classList.remove('over'); picked=[...e.dataTransfer.files]; refresh(); };
256
+ skip.onclick=()=>{ c.remove(); beginInterview(); };
257
+ imp.onclick=async()=>{
258
+ imp.disabled=true; skip.disabled=true; imp.textContent='Reading…';
259
+ const files=[]; for(const f of picked){ try{ files.push({name:f.name, content:await f.text()}); }catch(_){} }
260
+ try{ const r=await post('/api/session/ingest',{sessionId,files}); if(r&&r.injected) status('imported '+(r.chars||0)+' chars',true); }catch(_){}
261
+ c.remove(); beginInterview();
262
+ };
263
+ }
264
+ function beginInterview(){
265
+ setBusy(true); showThinking();
266
+ post('/api/session/send',{sessionId,text:'Begin the setup interview. Ask your first question.'});
267
+ }
268
+
269
+ function handle(ev){
270
+ if(ev.kind==='assistant'){
271
+ hideThinking();
272
+ const SENT='[[SETUP_COMPLETE]]';
273
+ if(ev.text.indexOf(SENT)>=0){
274
+ const parts=ev.text.split(SENT); const pre=(parts[0]||'').trim(); const prophecy=parts.slice(1).join(' ').trim();
275
+ if(pre){ const c=card(); const q=document.createElement('div'); q.className='q'; q.textContent=pre; c.appendChild(q); }
276
+ runSynthesis(prophecy); return;
277
+ }
278
+ const c=card(); const q=document.createElement('div'); q.className='q'; q.textContent=ev.text; c.appendChild(q);
279
+ }
280
+ else if(ev.kind==='result'){ hideThinking(); if(!completed){ setBusy(false); inp.focus(); } }
281
+ else if(ev.kind==='permission'){
282
+ hideThinking();
283
+ const id=ev.requestId; const c=card('perm');
284
+ c.innerHTML='<div class="lbl">Permission requested</div>';
285
+ const q=document.createElement('div'); q.className='q'; q.textContent=(JSON.stringify(ev.request)||'').slice(0,240); c.appendChild(q);
286
+ const row=document.createElement('div'); row.className='row';
287
+ const allow=document.createElement('button'); allow.className='primary'; allow.textContent='Allow';
288
+ const deny=document.createElement('button'); deny.textContent='Deny';
289
+ // Don't optimistically resolve: disable + show a pending note so a wrong
290
+ // control_response shape can't look "answered" (Codex MF3). The session
291
+ // proceeding (next event) is the real confirmation.
292
+ const decide=(d)=>{ allow.disabled=true; deny.disabled=true; const note=document.createElement('span'); note.className='a'; note.style.marginLeft='8px'; note.textContent=d+' sent…'; row.appendChild(note); post('/api/session/approve',{sessionId,requestId:id,decision:d}); };
293
+ allow.onclick=()=>decide('allow');
294
+ deny.onclick=()=>decide('deny');
295
+ row.appendChild(allow); row.appendChild(deny); c.appendChild(row);
296
+ }
297
+ else if(ev.kind==='closed'||ev.kind==='error'){ hideThinking(); status('session ended',false); setBusy(true); endBtn.disabled=true; if(es)es.close(); }
298
+ }
299
+
300
+ sendBtn.onclick=async()=>{ const t=inp.value.trim(); if(!t||busy)return; const c=card(); const a=document.createElement('div'); a.className='a'; a.textContent='You: '+t; c.appendChild(a); inp.value=''; setBusy(true); showThinking(); await post('/api/session/send',{sessionId,text:t}); };
301
+ inp.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendBtn.click();} });
302
+ endBtn.onclick=async()=>{ if(es)es.close(); await post('/api/session/end',{sessionId}); status('ended',false); setBusy(true); endBtn.disabled=true; };
303
+ boot();
304
+ </script>
305
+ </body>
306
+ </html>