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,2402 @@
1
+ "use strict";
2
+
3
+ const http = require("http");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const crypto = require("crypto");
7
+ const { execFile, spawn } = require("child_process");
8
+ // v6 serve-flip + Codex W4+W5 fix 1: the legacy ~/.mover/dashboard vanilla
9
+ // HTML is no longer served as a fallback, so dashboardOutDir/dashboardIndexPath
10
+ // are intentionally not imported here — ui/dist is the only served surface.
11
+ const { logEvent: logActivationEvent } = require("./lib/activation-log");
12
+ // v6 W3 — the live work feed (GET /api/feed/tail), read fresh per request.
13
+ const feedParser = require("./lib/feed-parser");
14
+ // Onboarding-engine: AgentSession abstraction — drives the user's own agent as
15
+ // a persistent structured multi-turn session (onboarding-engine-SPEC.md §2 / D1).
16
+ const { AgentSessionManager } = require("./lib/agent-session");
17
+ const { RunRegistry } = require("./lib/run-registry");
18
+ // Packet #2 rider (OWNER-DECISIONS.md) — server-held pending Engine-write
19
+ // approvals for dashboard-launched runs. See lib/approval-registry.js header
20
+ // for the full contract and the current wiring status.
21
+ const { ApprovalRegistry } = require("./lib/approval-registry");
22
+ // Onboarding-engine D3/D4/D7 — note ingestion (drag-drop digest), skill
23
+ // recommendation (work → skills), and which agent CLIs are installed/authed.
24
+ const ingestion = require("./lib/ingestion");
25
+ const skillRecommender = require("./lib/skill-recommender");
26
+ const connectRecommender = require("./lib/connect-recommender");
27
+ const { forgeBeats } = require("./lib/onboarding-forge");
28
+ const agentDetect = require("./lib/agent-detect");
29
+ // B5 (Rule S21 made mechanical) — atomic + drift-guarded writes to the user's Daily Notes.
30
+ const { writeWithDriftGuard } = require("./lib/safe-write");
31
+ // Library truthful search (sol product review 2026-07-11, Library 5.0): ONE
32
+ // index whose coverage equals the shelves the Library route advertises.
33
+ const { searchLibrary, rootDirFor } = require("./lib/library-search");
34
+
35
+ // Server-side re-scrub for the /api/report technical attachment (sol Report gap,
36
+ // 2026-07-11): the client already scrubs, but a spoofed/bypassed client could POST
37
+ // raw error strings with filesystem paths. Defense-in-depth: re-apply the SAME
38
+ // logic server-side before forwarding. Ported verbatim from
39
+ // src/dashboard/ui/src/v6app/scrub.js (which ships as UI source, not guaranteed
40
+ // at daemon runtime) — kept in lockstep by test/error-buffer-scrub.test.mjs +
41
+ // test/report-scrub.test.mjs.
42
+ function scrubToken(t) {
43
+ const low = t.toLowerCase();
44
+ if (low.includes("data:")) return "[data-url]";
45
+ if (/(?:https?|file|blob|wss?|ftp):\/\//i.test(t)) return "[url]";
46
+ if (/%2f|%5c|file%3a/i.test(t)) return "[path]";
47
+ if (/(?:[a-z]:\\|\\\\)/i.test(t)) return "[path]";
48
+ if (/\?[^\s]*=/.test(t)) return "[query]";
49
+ if (t.includes("/")) return "[path]";
50
+ return t;
51
+ }
52
+ function scrubReportLine(s) {
53
+ const raw = String(s);
54
+ if (/[\u0000-\u001f\u007f\u200b-\u200f\u2028\u2029\ufeff]/.test(raw)) {
55
+ return "[redacted: the error text contained control characters]";
56
+ }
57
+ return raw
58
+ .split(/\s+/)
59
+ .map(scrubToken)
60
+ .join(" ")
61
+ .replace(/(?:\[(?:path|url|data-url|query)\] ?){2,}/g, (m) => m.trim().split(" ")[0] + " ")
62
+ .trim()
63
+ .slice(0, 300);
64
+ }
65
+
66
+ // Per-server CSRF token — issued on /api/health, required on all POST endpoints.
67
+ // Random per-process so dashboard ↔ server pairing is exclusive to this run.
68
+ const STUDIO_TOKEN = crypto.randomBytes(24).toString("hex");
69
+
70
+ // Engine-presence: has /setup actually run for this vault? Identity_Prime.md and
71
+ // Strategy.md are the only Engine files created exclusively by setup — the
72
+ // structure installer ships everything else as non-empty defaults and ships these
73
+ // two only as *_template.md. Either one present + non-empty => a real Engine
74
+ // exists, so the dashboard's first-open onboarding must NOT re-fire (a terminal
75
+ // /setup user opening the dashboard for the first time). Read-only; mirrors the
76
+ // engine-writer fresh-guard sentinels.
77
+ function engineIsSetUp(vaultDir) {
78
+ if (!vaultDir) return false;
79
+ const base = path.join(vaultDir, "02_Areas", "Engine");
80
+ return ["Identity_Prime.md", "Strategy.md"].some((f) => {
81
+ try { return fs.statSync(path.join(base, f)).size > 0; } catch (_) { return false; }
82
+ });
83
+ }
84
+
85
+ // Origin allowlist for POST requests. Vite dev (5173) + production (3737-3740).
86
+ const ORIGIN_ALLOWLIST = new Set([
87
+ "http://127.0.0.1:5173", "http://localhost:5173",
88
+ "http://127.0.0.1:3737", "http://localhost:3737",
89
+ "http://127.0.0.1:3738", "http://localhost:3738",
90
+ "http://127.0.0.1:3739", "http://localhost:3739",
91
+ "http://127.0.0.1:3740", "http://localhost:3740"
92
+ ]);
93
+
94
+ function isOriginAllowed(req) {
95
+ const origin = req.headers.origin;
96
+ // Audit H1: previous version allowed any request without Origin header
97
+ // — intended for same-origin browser fetches, but curl/scripts/Electron
98
+ // also send no Origin and were getting a pass. Defense-in-depth: only
99
+ // skip the origin check when the request came over the loopback socket
100
+ // (127.0.0.1 / ::1 / ::ffff:127.0.0.1). The token check (isTokenValid)
101
+ // still applies to state-changing endpoints regardless.
102
+ if (!origin) {
103
+ const remote = req.socket && req.socket.remoteAddress;
104
+ if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") return true;
105
+ return false;
106
+ }
107
+ return ORIGIN_ALLOWLIST.has(origin);
108
+ }
109
+
110
+ // Constant-time token comparison (audit pass 2 #2, 2026-05-24).
111
+ // `===` on hex strings allows timing side-channel inference one char at a time.
112
+ // crypto.timingSafeEqual requires equal-length buffers — short-circuit the
113
+ // length check first so we never pass mismatched-length buffers to it.
114
+ const STUDIO_TOKEN_BUF = Buffer.from(STUDIO_TOKEN, "utf8");
115
+ function isTokenValid(req) {
116
+ const t = req.headers["x-mover-token"];
117
+ if (typeof t !== "string") return false;
118
+ const buf = Buffer.from(t, "utf8");
119
+ if (buf.length !== STUDIO_TOKEN_BUF.length) return false;
120
+ try { return crypto.timingSafeEqual(buf, STUDIO_TOKEN_BUF); } catch { return false; }
121
+ }
122
+
123
+ // Vault-scoped path resolution with symlink protection (audit pass 2 #3).
124
+ // Previously path.resolve() + relative() only — a symlink inside the vault
125
+ // pointing outside would pass the prefix check but I/O would touch the
126
+ // real target. fs.realpathSync resolves all symlinks; comparing realpaths
127
+ // closes the escape. Returns { absPath, relPath } or throws.
128
+ // Note: realpathSync requires the path to exist. For write paths (file
129
+ // not yet created) we realpath the parent dir instead and join.
130
+ // v6 W3 — merge one journal mode's sub-anchors into the `## Journal` section of
131
+ // a Daily Note, PRESERVING the other mode's sub-anchors. The invariant: writing
132
+ // Evening never clobbers Morning and vice-versa. Sub-anchors are identified by
133
+ // their `### Evening`/`### Morning` (+ `alibi`) header; only THIS mode's blocks
134
+ // are replaced. Output format matches parseJournal() (daily-note-resolver.js)
135
+ // for a clean read round-trip. Append-only in spirit: nothing outside the
136
+ // rewritten mode's sub-anchors is touched.
137
+ // v6 W3 round 4: sanitize a single journal answer line for writing under its
138
+ // `### …` sub-anchor. Collapses newlines (each answer is one line), trims, caps
139
+ // length, and — critically — escapes a LEADING '#'. Without the escape, an
140
+ // answer like "## Review later" would be written as its own line and then read
141
+ // back as a markdown heading: it would end the `## Journal` section (or look
142
+ // like a new `###` sub-anchor), so the written answer silently vanishes on the
143
+ // next read. `\#` is the canonical markdown escape (renders as a literal '#').
144
+ function sanitizeJournalLine(v, max) {
145
+ // terra T-DW-10: an object/array answer would String()-coerce to "[object
146
+ // Object]" (or a comma-joined array) and be persisted as if it were the user's
147
+ // text. Drop non-primitive values so a malformed field is omitted, not stored.
148
+ if (v != null && typeof v === "object") return "";
149
+ return String(v == null ? "" : v)
150
+ .replace(/\r/g, "")
151
+ .replace(/\n+/g, " ")
152
+ .trim()
153
+ .slice(0, max)
154
+ .replace(/^#/, "\\#");
155
+ }
156
+
157
+ // B5: every dashboard write to the user's irreplaceable Daily Note funnels through here.
158
+ // `transform(existing)` returns the new file content, or null to skip the write (e.g. a
159
+ // checkbox item that wasn't found). Two guarantees the raw fs.writeFileSync calls lacked:
160
+ // 1. ATOMIC — tmp + rename, so a crash/power-loss mid-write can never leave a truncated note.
161
+ // 2. DRIFT-SAFE — if the file changed under us since we read `existing` (the user typing in
162
+ // Obsidian, the CLI /log), the concurrent edit is preserved to a `.bak.<ts>` sidecar
163
+ // before our write lands, instead of being silently clobbered. withFileLock only
164
+ // serializes in-process; this covers the cross-process window it cannot.
165
+ function writeNoteAtomic(absPath, transform, opts = {}) {
166
+ let existing = "";
167
+ try { existing = fs.readFileSync(absPath, "utf8"); } catch (_) { existing = ""; }
168
+ const next = transform(existing);
169
+ if (next == null) return { written: false };
170
+ const r = writeWithDriftGuard(absPath, existing, next, opts);
171
+ return { written: true, drifted: r.drifted, backupPath: r.backupPath };
172
+ }
173
+
174
+ function mergeJournalSection(existing, ymd, mode, newBlock) {
175
+ // Codex W3 fix 2: classify a sub-anchor by its ANCHORED mode prefix, not a
176
+ // loose keyword scan. `### Evening …` is evening; `### Morning …` is morning;
177
+ // a bare `### Alibi`/`### Prediction` (legacy/alt naming for the evening
178
+ // alibi) is evening ONLY when it isn't a Morning header — so an evening write
179
+ // can never delete a Morning block, and vice-versa.
180
+ // terra T-DW-2: replace ONLY the sub-anchors THIS write actually carries, keyed
181
+ // by exact header, rather than every block of the mode. A partial same-mode save
182
+ // (e.g. just the score) now upserts that one field and PRESERVES the sibling
183
+ // evening fields (win, alibi, ...) instead of silently wiping the whole mode.
184
+ // Cross-mode preservation still falls out for free — a morning write's headers
185
+ // never match an evening block — so the Codex W3 mode-isolation guarantee holds.
186
+ // Preserve-on-omit is the safe default: journaling one field must not delete the
187
+ // others. (mode is retained in the signature for call-site compatibility.)
188
+ const newHeaders = new Set(
189
+ String(newBlock).split("\n").filter((l) => /^###\s+/.test(l)).map((l) => l.trim())
190
+ );
191
+ const isThisMode = (headerLine) => newHeaders.has(String(headerLine).trim());
192
+ void mode;
193
+ const trimTail = (arr) => { const b = arr.slice(); while (b.length && b[b.length - 1].trim() === "") b.pop(); return b; };
194
+ if (!existing) {
195
+ return `# Daily - ${ymd}\n\n## Journal\n\n${newBlock}\n`;
196
+ }
197
+ const lines = existing.replace(/\r\n/g, "\n").split("\n");
198
+ // Locate the `## Journal` section.
199
+ let jStart = -1;
200
+ for (let i = 0; i < lines.length; i++) {
201
+ if (/^##\s+Journal\s*$/i.test(lines[i])) { jStart = i; break; }
202
+ }
203
+ if (jStart === -1) {
204
+ const sep = existing.endsWith("\n") ? "" : "\n";
205
+ return `${existing}${sep}\n## Journal\n\n${newBlock}\n`;
206
+ }
207
+ // Section runs until the next H1 or H2 header, or EOF (h3 stays inside).
208
+ // Codex W3 round 3: parseJournal ends the Journal section at the next H1 OR
209
+ // H2 (/^#{1,2}\s/), so the merge MUST use the same boundary — otherwise a
210
+ // `# Later` (H1) after `## Journal` lets the merge write the block outside
211
+ // what the parser reads, and the round-trip silently breaks. `#{1,2}\s` does
212
+ // not match `### ` (h3), so sub-anchors stay inside the section.
213
+ let jEnd = lines.length;
214
+ for (let i = jStart + 1; i < lines.length; i++) {
215
+ if (/^#{1,2}\s/.test(lines[i])) { jEnd = i; break; }
216
+ }
217
+ // Split the section body into `###` sub-blocks; keep those NOT of this mode.
218
+ // Codex W3 fix 1: freeform prose BEFORE the first `###` is real user content,
219
+ // not stray blanks — preserve it as a leading block, never drop it.
220
+ const sectionLines = lines.slice(jStart + 1, jEnd);
221
+ const kept = [];
222
+ const preamble = [];
223
+ let curHeader = null;
224
+ let curBuf = [];
225
+ const flush = () => {
226
+ if (curHeader == null) return;
227
+ if (!isThisMode(curHeader)) kept.push(`${curHeader}\n${trimTail(curBuf).join("\n")}`);
228
+ curHeader = null;
229
+ curBuf = [];
230
+ };
231
+ for (const ln of sectionLines) {
232
+ if (/^###\s+/.test(ln)) { flush(); curHeader = ln; curBuf = []; }
233
+ else if (curHeader != null) { curBuf.push(ln); }
234
+ else { preamble.push(ln); } // before the first sub-anchor → user prose
235
+ }
236
+ flush();
237
+ const preText = trimTail(preamble).join("\n").replace(/^\n+/, "").trim();
238
+ const journalBody = [preText, ...kept, newBlock].filter((b) => b && b.trim()).join("\n\n");
239
+ const before = lines.slice(0, jStart + 1).join("\n"); // includes `## Journal`
240
+ const afterArr = lines.slice(jEnd);
241
+ // trim leading blanks off the trailing content so spacing stays normalized
242
+ while (afterArr.length && afterArr[0].trim() === "") afterArr.shift();
243
+ const tail = afterArr.length ? `\n\n${afterArr.join("\n")}` : "\n";
244
+ return `${before}\n\n${journalBody}${tail}`;
245
+ }
246
+
247
+ function resolveVaultScoped(vault, filePath, { mustExist = false } = {}) {
248
+ if (!vault) throw Object.assign(new Error("vault not set"), { code: "EVAULT" });
249
+ if (!filePath) throw Object.assign(new Error("filePath required"), { code: "EARG" });
250
+ const vaultAbs = fs.realpathSync(path.resolve(vault));
251
+ let targetAbs = path.resolve(filePath);
252
+ // Try realpath; if file missing, realpath the parent and reassemble.
253
+ try {
254
+ targetAbs = fs.realpathSync(targetAbs);
255
+ } catch (err) {
256
+ if (mustExist || err.code !== "ENOENT") throw err;
257
+ const parent = fs.realpathSync(path.dirname(targetAbs));
258
+ targetAbs = path.join(parent, path.basename(targetAbs));
259
+ }
260
+ const rel = path.relative(vaultAbs, targetAbs);
261
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
262
+ throw Object.assign(new Error("path escapes vault"), { code: "EESCAPE" });
263
+ }
264
+ return { absPath: targetAbs, relPath: rel, vaultAbs };
265
+ }
266
+
267
+ // Per-file async write queue (audit pass 2 #4, #5, #6).
268
+ // config.json / snoozes.json / pattern-manifest.json all use a
269
+ // read-modify-write pattern. Concurrent writes from two POSTs would
270
+ // load the same baseline, mutate independently, and the second write
271
+ // silently clobbered the first. Each unique file path gets its own
272
+ // promise chain so writes serialize per-file (parallel across files).
273
+ const _fileLocks = new Map();
274
+ function withFileLock(key, fn) {
275
+ const prev = _fileLocks.get(key) || Promise.resolve();
276
+ const next = prev.then(fn, fn).finally(() => {
277
+ if (_fileLocks.get(key) === next) _fileLocks.delete(key);
278
+ });
279
+ _fileLocks.set(key, next);
280
+ return next;
281
+ }
282
+
283
+ // Atomic JSON write — tmp file + rename. Same filesystem rename is
284
+ // atomic on POSIX and effectively atomic on modern Windows NTFS, so
285
+ // a crash mid-write leaves either the old file or the new file, never
286
+ // a half-written one. (audit pass 2 #4 + find-bugs #8.)
287
+ function atomicWriteJson(filePath, obj) {
288
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
289
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf8");
290
+ fs.renameSync(tmp, filePath);
291
+ }
292
+
293
+ // v6 W5 SERVE-FLIP — the SOLE dashboard is the React app in ui/dist (the v6
294
+ // build). The old ui-v2/dist (v5-Rams) + the vanilla ~/.mover/dashboard
295
+ // fallback are retired; ui-v2/ is archived to _archive-v5/. One dist, no
296
+ // fallbacks, no version mixups — this is what fixed the "wrong dashboard".
297
+ // (UI_LEGACY_DIST_* points at ui/dist — the name is historical.)
298
+ const UI_LEGACY_DIST_DIR = path.join(__dirname, "ui", "dist");
299
+ const UI_LEGACY_DIST_INDEX = path.join(UI_LEGACY_DIST_DIR, "index.html");
300
+
301
+ // terra T-EX-3/T-EX-4: the set of slash-commands the product actually ships,
302
+ // derived once from src/workflows/*.md (server.js lives in src/dashboard/). Used
303
+ // to reject an unknown /workflow before running an agent, opening a terminal, or
304
+ // persisting a snooze — so a bogus token can't start a wasted agent run or leave
305
+ // an inert record in snoozes.json. Lazy + cached; if the dir can't be read we fall
306
+ // back to shape-only so an unusual layout never hard-fails a legitimate run.
307
+ let _knownWorkflows = null;
308
+ function knownWorkflowNames() {
309
+ if (_knownWorkflows) return _knownWorkflows;
310
+ const s = new Set();
311
+ try {
312
+ const dir = path.join(__dirname, "..", "workflows");
313
+ for (const f of fs.readdirSync(dir)) {
314
+ if (f.endsWith(".md")) s.add("/" + f.slice(0, -3).toLowerCase());
315
+ }
316
+ } catch (_) { /* bundle layout unusual — shape-only fallback below */ }
317
+ _knownWorkflows = s;
318
+ return s;
319
+ }
320
+ function isKnownWorkflow(w) {
321
+ // A workflow may carry trailing flags the product ships (e.g. "/ignite --feature",
322
+ // "/research --deep"). Validate the BASE slash-command against the registry and
323
+ // allow only flag-shaped suffixes after it — so a flagged command is accepted but
324
+ // a token with spaces/metachars/injected text is not.
325
+ const m = String(w).trim().match(/^(\/[a-z0-9][a-z0-9-]{0,40})((?:\s+--?[a-z0-9][a-z0-9-]*)*)\s*$/i);
326
+ if (!m) return false;
327
+ const s = knownWorkflowNames();
328
+ if (s.size === 0) return true; // registry unavailable → base shape already validated
329
+ return s.has(m[1].toLowerCase());
330
+ }
331
+ function uiDistAvailable() {
332
+ try { return fs.existsSync(UI_LEGACY_DIST_INDEX); } catch (_) { return false; }
333
+ }
334
+
335
+ function activeUiDist() {
336
+ return { dir: UI_LEGACY_DIST_DIR, index: UI_LEGACY_DIST_INDEX };
337
+ }
338
+
339
+ // Legacy constants kept for any downstream code that imports them — they
340
+ // now point at whichever dist is active.
341
+ const UI_DIST_DIR = activeUiDist().dir;
342
+ const UI_DIST_INDEX = activeUiDist().index;
343
+
344
+ const PRIMARY_PORT = 3737;
345
+ // MOVER_PORT pins the daemon to one port (hermetic tests, or a user running a
346
+ // second vault without colliding with the default 3737-3740 range). Additive:
347
+ // unset behaves exactly as before. A valid 1-65535 value becomes the sole
348
+ // binding target (no fallback scan, so the caller controls isolation).
349
+ const _envPort = parseInt(process.env.MOVER_PORT || "", 10);
350
+ const PORT_FALLBACKS = (_envPort >= 1 && _envPort <= 65535) ? [_envPort] : [3737, 3738, 3739, 3740];
351
+
352
+ const MIME = {
353
+ ".html": "text/html; charset=utf-8",
354
+ ".css": "text/css; charset=utf-8",
355
+ ".js": "application/javascript; charset=utf-8",
356
+ ".json": "application/json; charset=utf-8",
357
+ ".svg": "image/svg+xml",
358
+ ".png": "image/png",
359
+ ".jpg": "image/jpeg",
360
+ ".woff2": "font/woff2",
361
+ ".woff": "font/woff",
362
+ ".ttf": "font/ttf",
363
+ ".ico": "image/x-icon",
364
+ ".webmanifest": "application/manifest+json",
365
+ ".map": "application/json; charset=utf-8"
366
+ };
367
+
368
+ function startServer({ buildFn, vault, version, openBrowser = true } = {}) {
369
+ return new Promise((resolve, reject) => {
370
+ let lastBuild = null;
371
+ let lastError = null;
372
+ // Activation log gate — fire dashboard_open ONCE per server process.
373
+ let activationLogged = false;
374
+ // Serialize onboarding Engine commits so two concurrent finalizes can't both
375
+ // pass the fresh-guard and interleave a half-written Engine (Codex r1 MF).
376
+ let finalizeLock = false;
377
+ // Build mutex (code-bug-audit H2, 2026-05-24): /api/refresh + /api/
378
+ // checkbox/toggle + /api/snooze + /api/override all call buildFn()
379
+ // and write `lastBuild`. Without a lock, concurrent POSTs torn-write
380
+ // ~/.mover/drift-history.json + ~/.mover/pattern-manifest.json.
381
+ // Pattern: one in-flight build per process; concurrent callers wait
382
+ // on the same promise.
383
+ let buildLock = null;
384
+ const safeBuild = () => {
385
+ if (buildLock) return buildLock;
386
+ buildLock = Promise.resolve(buildFn()).finally(() => { buildLock = null; });
387
+ return buildLock;
388
+ };
389
+
390
+ // Onboarding-engine session manager. cwd = the user's vault so the agent
391
+ // reasons in their real context. Killed on shutdown (zombie-process guard).
392
+ const agentSessions = new AgentSessionManager({ cwd: vault });
393
+
394
+ // Run-tab registry — server-held one-shot runs so the Run tab is persistent
395
+ // (survives navigation + refresh) the same way Chat is. Decouples the child
396
+ // from the request that started it; killed on shutdown. See lib/run-registry.js.
397
+ const runRegistry = new RunRegistry();
398
+
399
+ // Packet #2 rider — pending Engine-write approvals for dashboard-launched
400
+ // runs. Killed on shutdown alongside agentSessions/runRegistry.
401
+ const approvalRegistry = new ApprovalRegistry();
402
+
403
+ const server = http.createServer(async (req, res) => {
404
+ try {
405
+ // DNS-rebinding gate (R5-A P0). The browser's origin model is computed
406
+ // from the URL string, so a hostile page served on our port whose DNS
407
+ // record flips to 127.0.0.1 becomes "same-origin" and can READ every
408
+ // un-tokened GET response — including /api/health (the token itself)
409
+ // and /api/file/read (the vault). Host reflects the literal request
410
+ // target and survives the trick: only loopback names pass, on EVERY
411
+ // method. Standard mitigation (webpack-dev-server, Vite, Jupyter).
412
+ const hostHeader = String(req.headers.host || "");
413
+ // Trailing dot = FQDN spelling of the same name ("localhost." is
414
+ // still loopback); strip it so pedantic-but-legit clients pass.
415
+ const hostName = hostHeader.replace(/:\d+$/, "").replace(/\.$/, "").toLowerCase();
416
+ if (hostName !== "127.0.0.1" && hostName !== "localhost" && hostName !== "[::1]") {
417
+ res.writeHead(400, { "Content-Type": "text/plain" });
418
+ return res.end("Bad Host header");
419
+ }
420
+ const url = new URL(req.url, `http://${req.headers.host}`);
421
+ const pathname = url.pathname;
422
+
423
+ // API routes
424
+ if (pathname === "/api/health") {
425
+ // Health hands out the per-session CSRF token. Same-origin requests
426
+ // (the React UI on same host) can read it from here.
427
+ return jsonResponse(res, 200, {
428
+ ok: true,
429
+ version,
430
+ vault,
431
+ enginePresent: engineIsSetUp(vault),
432
+ builtAt: lastBuild ? lastBuild.builtAt : null,
433
+ uptime: process.uptime(),
434
+ token: STUDIO_TOKEN
435
+ });
436
+ }
437
+
438
+ // Block POSTs from unknown origins (basic CSRF defense)
439
+ if (req.method === "POST") {
440
+ if (!isOriginAllowed(req)) {
441
+ return jsonResponse(res, 403, { ok: false, error: "Origin not allowed" });
442
+ }
443
+ // Token enforcement on all state-changing endpoints.
444
+ // /api/refresh was previously misclassified as "read-only POST" but
445
+ // it triggers a full vault rebuild — DoS amplification vector if
446
+ // hit cross-origin without token (code-bug-audit C2, 2026-05-24).
447
+ const tokenedRoutes = ["/api/agent/run", "/api/runs/stop", "/api/workflow/run", "/api/workflow/open-in-terminal", "/api/cli/run", "/api/cli/open-in-terminal", "/api/checkbox/toggle", "/api/snooze", "/api/override", "/api/config", "/api/file/open", "/api/refresh", "/api/event", "/api/view/generate", "/api/view/delete", "/api/capture", "/api/journal", "/api/session/start", "/api/session/send", "/api/session/approve", "/api/session/end", "/api/session/finalize", "/api/session/ingest", "/api/setup/recommend", "/api/setup/shortcut", "/api/report", "/api/approval/request", "/api/approval/respond"];
448
+ if (tokenedRoutes.includes(pathname) && !isTokenValid(req)) {
449
+ return jsonResponse(res, 401, { ok: false, error: "Missing or invalid X-Mover-Token header" });
450
+ }
451
+ }
452
+
453
+ if (pathname === "/api/state.json") {
454
+ // V5.0 activation instrumentation (audit pass 2 #7 + find-bugs #1).
455
+ // Originally fired on /api/health, but the React UI does not call
456
+ // /api/health on mount — it calls /api/state.json. That meant
457
+ // dashboard_open never logged on a normal page load, only on the
458
+ // (rare) explicit refresh that hit /api/health. Moving it here
459
+ // fires it exactly when the dashboard is actually opened.
460
+ //
461
+ // Pass 3 #9 fix: this is a write side-effect on an unauthenticated
462
+ // GET. Gate behind same-origin signals (Origin header matches an
463
+ // allowed origin OR sec-fetch-site is 'same-origin') so a cross-
464
+ // site prefetch / image / iframe can't pollute the activation log.
465
+ if (!activationLogged) {
466
+ const origin = req.headers.origin;
467
+ const fetchSite = req.headers["sec-fetch-site"];
468
+ const sameOrigin =
469
+ (origin && ORIGIN_ALLOWLIST.has(origin)) ||
470
+ fetchSite === "same-origin" ||
471
+ fetchSite === "none"; // direct navigation (user typed URL)
472
+ if (sameOrigin) {
473
+ activationLogged = true;
474
+ try { logActivationEvent("dashboard_open", { version }); } catch (_) {}
475
+ }
476
+ }
477
+ if (lastBuild && lastBuild.state) return jsonResponse(res, 200, lastBuild.state);
478
+ return jsonResponse(res, 200, { available: false });
479
+ }
480
+
481
+ // v2 dashboard surface endpoints. Each returns a subset of state.json
482
+ // for the Library route's Skills / Hooks / CLI sub-tabs — lighter
483
+ // payload than re-fetching the entire state on every tab switch.
484
+ if (pathname === "/api/skills/index") {
485
+ if (lastBuild && lastBuild.state && lastBuild.state.skills) {
486
+ return jsonResponse(res, 200, lastBuild.state.skills);
487
+ }
488
+ return jsonResponse(res, 200, { available: false, count: 0, byCategory: { system: [], specialist: [], domain: [] }, all: [] });
489
+ }
490
+ if (pathname === "/api/hooks/index") {
491
+ if (lastBuild && lastBuild.state && lastBuild.state.hooks) {
492
+ return jsonResponse(res, 200, lastBuild.state.hooks);
493
+ }
494
+ return jsonResponse(res, 200, { available: false, count: 0, byEvent: {}, registered: [], unregistered: [], all: [] });
495
+ }
496
+ if (pathname === "/api/cli/usage") {
497
+ if (lastBuild && lastBuild.state && lastBuild.state.cliUsage) {
498
+ return jsonResponse(res, 200, lastBuild.state.cliUsage);
499
+ }
500
+ return jsonResponse(res, 200, { available: false, commands: [] });
501
+ }
502
+ if (pathname === "/api/library/v2") {
503
+ if (lastBuild && lastBuild.state && lastBuild.state.libraryV2) {
504
+ return jsonResponse(res, 200, lastBuild.state.libraryV2);
505
+ }
506
+ return jsonResponse(res, 200, { available: false });
507
+ }
508
+
509
+ // Library truthful search (sol review 2026-07-11): one index whose
510
+ // coverage equals the advertised shelves. Content search across the
511
+ // vault shelves + skills + bundle workflows/hooks, with an honest
512
+ // coverage report (CLI commands are usage counts, not files). Bounded
513
+ // response (result/passage caps in lib/library-search.js); local-only
514
+ // read like /api/file/read — Host-gated, nothing leaves this machine.
515
+ if (pathname === "/api/library/search" && req.method === "GET") {
516
+ try {
517
+ const os = require("os");
518
+ const out = searchLibrary(
519
+ {
520
+ vault,
521
+ bundleRoot: path.resolve(__dirname, "..", ".."),
522
+ claudeDir: path.join(os.homedir(), ".claude"),
523
+ },
524
+ {
525
+ q: url.searchParams.get("q") || "",
526
+ shelf: url.searchParams.get("shelf") || "",
527
+ limit: url.searchParams.get("limit") || undefined,
528
+ }
529
+ );
530
+ return jsonResponse(res, 200, { ok: true, ...out });
531
+ } catch (_e) {
532
+ return jsonResponse(res, 500, { ok: false, error: "search failed" });
533
+ }
534
+ }
535
+
536
+ // F37 — OG Image generator. Renders a server-side SVG receipt
537
+ // suitable for og:image meta tags on shared moveros.dev pages.
538
+ // Twitter/X, Discord, and Slack render SVG og:images directly.
539
+ // Formats:
540
+ // /api/og — default 1200×630 (Twitter/LinkedIn)
541
+ // /api/og?format=square — 1080×1080 (Instagram)
542
+ // /api/og?format=story — 1080×1920 (Stories)
543
+ // Query params:
544
+ // ?metric=drift|streak|patterns (which number to feature)
545
+ // ?title=... (override headline)
546
+ if (pathname === "/api/og") {
547
+ try {
548
+ const state = (lastBuild && lastBuild.state) || {};
549
+ const format = url.searchParams.get("format") || "default";
550
+ const metric = url.searchParams.get("metric") || "drift";
551
+ const titleOverride = url.searchParams.get("title") || undefined;
552
+ const svg = renderOgSvg(state, { format, metric, title: titleOverride });
553
+ res.writeHead(200, {
554
+ "Content-Type": "image/svg+xml; charset=utf-8",
555
+ "Cache-Control": "no-cache, no-store, must-revalidate",
556
+ "X-Mover-Og": "1",
557
+ });
558
+ res.end(svg);
559
+ return;
560
+ } catch (e) {
561
+ return jsonResponse(res, 500, { ok: false, error: e.message });
562
+ }
563
+ }
564
+
565
+ // GET/POST ~/.mover/config.json — surfaces user-tunable settings
566
+ // (density, mono mode, preferredAgent, evening_zone_start, etc).
567
+ // Studio writes here ONLY, never to Engine files.
568
+ if (pathname === "/api/config") {
569
+ const os = require("os");
570
+ const configPath = path.join(os.homedir(), ".mover", "config.json");
571
+ if (req.method === "GET") {
572
+ // T242 bug hunt fix — config.json contains vaultPath and any
573
+ // integration tokens the user has stored. GET previously had
574
+ // no auth, so any local process or cross-origin browser tab
575
+ // that bypassed origin checks could dump the user's vault
576
+ // location. Require same-origin signals OR a valid token,
577
+ // matching the /api/state.json gate at L222-232.
578
+ const origin = req.headers.origin;
579
+ const fetchSite = req.headers["sec-fetch-site"];
580
+ const sameOrigin =
581
+ (origin && ORIGIN_ALLOWLIST.has(origin)) ||
582
+ fetchSite === "same-origin" ||
583
+ fetchSite === "none";
584
+ if (!sameOrigin && !isTokenValid(req)) {
585
+ return jsonResponse(res, 401, { ok: false, error: "Missing or invalid X-Mover-Token header" });
586
+ }
587
+ try {
588
+ const raw = fs.readFileSync(configPath, "utf8");
589
+ return jsonResponse(res, 200, { ok: true, config: JSON.parse(raw) });
590
+ } catch (e) {
591
+ if (e.code === "ENOENT") return jsonResponse(res, 200, { ok: true, config: {} });
592
+ return jsonResponse(res, 500, { ok: false, error: "config read failed" });
593
+ }
594
+ }
595
+ if (req.method === "POST") {
596
+ try {
597
+ const patch = await readJsonBody(req, 16 * 1024);
598
+ // terra T-DW-5: a non-object patch (null / array / scalar) must not
599
+ // reach the shallow merge. {...existing, ...null} is a harmless no-op,
600
+ // but {studio:null} would null out a whole nested prefs block. Require
601
+ // a plain-object patch, and reject a non-object value for the known
602
+ // nested objects so a settings write can never DELETE a prefs block.
603
+ const isPlainObject = (v) => v != null && typeof v === "object" && !Array.isArray(v);
604
+ if (!isPlainObject(patch)) {
605
+ return jsonResponse(res, 400, { ok: false, error: "config patch must be a JSON object" });
606
+ }
607
+ for (const k of ["studio", "providerPrefs"]) {
608
+ if (k in patch && !isPlainObject(patch[k])) {
609
+ return jsonResponse(res, 400, { ok: false, error: `${k} must be an object` });
610
+ }
611
+ }
612
+ // Serialize concurrent config writes (audit pass 2 #4).
613
+ const merged = await withFileLock(configPath, () => {
614
+ let existing = {};
615
+ // terra T-DW-6: a parse failure of an EXISTING config.json must NOT
616
+ // be swallowed to {} — the next merge would then overwrite the
617
+ // malformed file and lose vaultPath + integration tokens. Only a
618
+ // truly-absent file is empty; a present-but-unparseable (or wrong-
619
+ // shape) file aborts the write so the user can repair it by hand.
620
+ try {
621
+ existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
622
+ } catch (e) {
623
+ if (e.code === "ENOENT") existing = {};
624
+ else throw Object.assign(new Error("config.json is unreadable or malformed; refusing to overwrite it"), { code: "ECONFIGCORRUPT" });
625
+ }
626
+ if (!isPlainObject(existing)) {
627
+ throw Object.assign(new Error("config.json is not a JSON object; refusing to overwrite it"), { code: "ECONFIGCORRUPT" });
628
+ }
629
+ // Shallow merge — keys present in patch override, others kept.
630
+ // Allow nested `studio.<key>` merges via top-level studio object.
631
+ const out = { ...existing, ...patch };
632
+ if (existing.studio && patch.studio) {
633
+ out.studio = { ...existing.studio, ...patch.studio };
634
+ }
635
+ // #112: providerPrefs merges two levels deep (per provider),
636
+ // so posting { providerPrefs: { codex: { effort } } } cannot
637
+ // clobber claude-code's model or gemini's approval mode.
638
+ if (existing.providerPrefs && patch.providerPrefs) {
639
+ const pp = { ...existing.providerPrefs };
640
+ for (const k of Object.keys(patch.providerPrefs)) {
641
+ const prev = pp[k], next = patch.providerPrefs[k];
642
+ pp[k] = prev && typeof prev === "object" && next && typeof next === "object"
643
+ ? { ...prev, ...next } : next;
644
+ }
645
+ out.providerPrefs = pp;
646
+ }
647
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
648
+ atomicWriteJson(configPath, out);
649
+ return out;
650
+ });
651
+ return jsonResponse(res, 200, { ok: true, config: merged });
652
+ } catch (e) {
653
+ return jsonResponse(res, 500, { ok: false, error: e.message });
654
+ }
655
+ }
656
+ // Any other verb: refuse here — falling through served the SPA's
657
+ // index.html with a 200 for a PUT /api/config (R5-A P2).
658
+ return jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
659
+ }
660
+
661
+ if (pathname === "/api/refresh") {
662
+ // POST-only: the origin + token gate above is wrapped in
663
+ // `if (req.method === "POST")`, so a GET/HEAD would skip both and
664
+ // still trigger a full vault rebuild (cross-origin DoS amplification
665
+ // via <img src>). Refuse any other verb before the handler runs.
666
+ if (req.method !== "POST") {
667
+ return jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
668
+ }
669
+ try {
670
+ const newBuild = await safeBuild();
671
+ lastBuild = newBuild;
672
+ lastError = null;
673
+ return jsonResponse(res, 200, { ok: true, builtAt: newBuild.builtAt, sectionCount: newBuild.sectionCount });
674
+ } catch (e) {
675
+ lastError = e.message;
676
+ return jsonResponse(res, 500, { ok: false, error: e.message });
677
+ }
678
+ }
679
+
680
+ if (pathname === "/api/agent/run" && req.method === "POST") {
681
+ const body = await readJsonBody(req, 64 * 1024);
682
+ return runAgentStream({ req, res, body, vault, runRegistry });
683
+ }
684
+
685
+ // ── /api/report — the dashboard's report button ─────────────────────
686
+ // Same delivery channel as the /mover-report workflow: the developer's
687
+ // feedbackWebhook from ~/.mover/config.json. Server-side forward (the
688
+ // browser can't POST cross-origin), payload is the user's words plus
689
+ // machine facts (version/platform/agents) — never vault content.
690
+ if (pathname === "/api/report" && req.method === "POST") {
691
+ const body = await readJsonBody(req, 32 * 1024);
692
+ const desc = String((body && body.description) || "").trim().slice(0, 8000);
693
+ if (!desc) return jsonResponse(res, 400, { ok: false, error: "Say what happened first." });
694
+ const os = require("os");
695
+ let cfg = {};
696
+ try { cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".mover", "config.json"), "utf8")); } catch {}
697
+ const webhook = cfg.feedbackWebhook || "https://moveros.dev/api/feedback";
698
+ // Optional scrubbed client-error attachment (bug reports only, and
699
+ // only when the user left the attach box ticked). The client already
700
+ // redacts filesystem paths; cap hard here anyway. Strings only.
701
+ const errs = Array.isArray(body && body.errors)
702
+ ? body.errors.slice(0, 5).map((e) => scrubReportLine(String(e).slice(0, 400))).filter(Boolean)
703
+ : [];
704
+ // Guided precision (owner ask 2026-07-12): the card sends WHICH
705
+ // SCREEN the report points at, allowlist-validated here (never free
706
+ // text into the issue title) and folded into summary + description
707
+ // so the deployed feedback endpoint needs no schema change.
708
+ const REPORT_WHERE = { home: "Home", chat: "Chat", strategy: "Strategy", evidence: "Evidence", library: "Library", run: "Run", configure: "Settings", app: "" };
709
+ // A string, not a coercion (terra: ["home"] stringifies to "home"
710
+ // and would pass) — non-strings fall through to no label.
711
+ const whereLabel = body && typeof body.where === "string" && Object.prototype.hasOwnProperty.call(REPORT_WHERE, body.where) ? REPORT_WHERE[body.where] : "";
712
+ const REPORT_SEVERITIES = ["Blocking", "Annoying", "Cosmetic"];
713
+ const payload = {
714
+ type: ["bug", "feature", "comment"].includes(body && body.type) ? body.type : "comment",
715
+ severity: REPORT_SEVERITIES.includes(body && body.severity) ? body.severity : "Minor",
716
+ summary: ((whereLabel ? whereLabel + ": " : "") + desc.split("\n")[0]).slice(0, 140),
717
+ description: (whereLabel ? "Where: the " + whereLabel + " screen\n\n" : "")
718
+ + desc + (errs.length ? "\n\n-- attached technical details (scrubbed, no file contents) --\n" + errs.join("\n") : ""),
719
+ version: version || "unknown",
720
+ platform: process.platform,
721
+ agent: Array.isArray(cfg.agents) ? cfg.agents.join(", ") : "unknown",
722
+ date: new Date().toISOString().slice(0, 10),
723
+ source: "dashboard",
724
+ };
725
+ try {
726
+ const ctl = new AbortController();
727
+ const timer = setTimeout(() => ctl.abort(), 8000);
728
+ const r = await fetch(webhook, {
729
+ method: "POST",
730
+ headers: { "Content-Type": "application/json" },
731
+ body: JSON.stringify(payload),
732
+ signal: ctl.signal,
733
+ });
734
+ clearTimeout(timer);
735
+ if (r.ok) return jsonResponse(res, 200, { ok: true, delivered: true });
736
+ return jsonResponse(res, 200, { ok: true, delivered: false, error: "The developer's server answered " + r.status + "." });
737
+ } catch (e) {
738
+ return jsonResponse(res, 200, { ok: true, delivered: false, error: "Couldn't reach the developer's server." });
739
+ }
740
+ }
741
+
742
+ // ── Run-tab persistence: the runs the registry is holding ────────────
743
+ // GET /api/runs → list run summaries (rehydrate after refresh)
744
+ // GET /api/runs/get?id= → one run incl. full output (expand a card)
745
+ // GET /api/runs/stream?id= → reattach (replay buffer + live tail), same
746
+ // text/plain shape as /api/agent/run
747
+ // POST /api/runs/stop → actually stop a run (SIGTERM) [tokened]
748
+ // Read endpoints are un-tokened, matching /api/session/events (same class
749
+ // of loopback agent-output stream behind the Origin guard).
750
+ if (pathname === "/api/runs" && req.method === "GET") {
751
+ return jsonResponse(res, 200, { ok: true, runs: runRegistry.list() });
752
+ }
753
+ if (pathname === "/api/runs/get" && req.method === "GET") {
754
+ const run = runRegistry.get(url.searchParams.get("id") || "");
755
+ if (!run) return jsonResponse(res, 404, { ok: false, error: "no such run" });
756
+ return jsonResponse(res, 200, { ok: true, run });
757
+ }
758
+ if (pathname === "/api/runs/stream" && req.method === "GET") {
759
+ const id = url.searchParams.get("id") || "";
760
+ if (!runRegistry.has(id)) {
761
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
762
+ return res.end("no such run\n");
763
+ }
764
+ res.writeHead(200, {
765
+ "Content-Type": "text/plain; charset=utf-8",
766
+ "Cache-Control": "no-cache",
767
+ "X-Accel-Buffering": "no",
768
+ });
769
+ let unsub = () => {};
770
+ unsub = runRegistry.subscribe(id, (ev) => {
771
+ if (ev.type === "replay" || ev.type === "chunk") {
772
+ try {
773
+ const ok = res.write(ev.text);
774
+ // Backpressure guard (terra run-path F3): a slow/stalled reader
775
+ // must not let the socket write buffer grow without bound. Once it
776
+ // exceeds the cap, drop THIS client — the run keeps going for
777
+ // everyone else.
778
+ if (!ok && res.writableLength > 8 * 1024 * 1024) {
779
+ try { unsub(); } catch (_) {}
780
+ try { res.destroy(); } catch (_) {}
781
+ }
782
+ } catch (_) {}
783
+ } else if (ev.type === "end") { try { res.end(); } catch (_) {} }
784
+ });
785
+ req.on("close", () => { try { unsub(); } catch (_) {} });
786
+ return; // hold the stream open
787
+ }
788
+ if (pathname === "/api/runs/stop" && req.method === "POST") {
789
+ const body = await readJsonBody(req, 4 * 1024);
790
+ const ok = runRegistry.stop(String(body.id || ""));
791
+ return jsonResponse(res, 200, { ok });
792
+ }
793
+
794
+ // ── Packet #2 rider — dashboard-owned Engine-write approval ──────────
795
+ // (OWNER-DECISIONS.md Packet #2 rider; dev/plan.md T-PACKET2-RIDER-1.)
796
+ // request [tokened, hook adapter] -> create a pending approval
797
+ // wait [tokened, hook adapter poll] -> current status by id
798
+ // pending [GET, dashboard UI] -> list of live pending ones
799
+ // respond [tokened, dashboard UI] -> approve/deny by id
800
+ if (pathname === "/api/approval/request" && req.method === "POST") {
801
+ const body = await readJsonBody(req, 8 * 1024);
802
+ try {
803
+ const rec = approvalRegistry.request({
804
+ agent: body.agent,
805
+ file: body.file,
806
+ tool: body.tool,
807
+ excerpt: body.excerpt,
808
+ });
809
+ return jsonResponse(res, 200, { ok: true, id: rec.id, expiresAt: rec.expiresAt });
810
+ } catch (e) {
811
+ return jsonResponse(res, 400, { ok: false, error: e.message });
812
+ }
813
+ }
814
+ // GET is not covered by the POST-only tokenedRoutes gate above, so this
815
+ // route checks the token inline (same isTokenValid used everywhere else).
816
+ if (pathname === "/api/approval/wait" && req.method === "GET") {
817
+ if (!isTokenValid(req)) {
818
+ return jsonResponse(res, 401, { ok: false, error: "Missing or invalid X-Mover-Token header" });
819
+ }
820
+ const rec = approvalRegistry.get(url.searchParams.get("id") || "");
821
+ if (!rec) return jsonResponse(res, 404, { ok: false, error: "no such approval" });
822
+ return jsonResponse(res, 200, { ok: true, status: rec.status, expiresAt: rec.expiresAt });
823
+ }
824
+ // Un-tokened read, same class as /api/runs: loopback + Host-header gate
825
+ // is the trust boundary, matching every other GET listing in this file.
826
+ if (pathname === "/api/approval/pending" && req.method === "GET") {
827
+ return jsonResponse(res, 200, { ok: true, pending: approvalRegistry.listPending() });
828
+ }
829
+ if (pathname === "/api/approval/respond" && req.method === "POST") {
830
+ const body = await readJsonBody(req, 4 * 1024);
831
+ const result = approvalRegistry.respond(body.id, body.approve === true);
832
+ if (!result.ok) return jsonResponse(res, result.code || 400, { ok: false, error: result.error });
833
+ return jsonResponse(res, 200, { ok: true, status: result.status });
834
+ }
835
+
836
+ // System version + free/major update signal for the dashboard prompt.
837
+ if (pathname === "/api/version" && req.method === "GET") {
838
+ return jsonResponse(res, 200, { ok: true, ...versionInfo(version) });
839
+ }
840
+
841
+ // ── Onboarding engine: persistent multi-turn agent sessions ──────────
842
+ // Drives the user's own agent as a structured stream-json process so the
843
+ // dashboard runs setup/walkthrough as a real conversation, never the raw
844
+ // terminal (onboarding-engine-SPEC.md §2). SSE out, POST in.
845
+ if (pathname === "/api/session/start" && req.method === "POST") {
846
+ const body = await readJsonBody(req, 8 * 1024);
847
+ try {
848
+ // Prepend the user's worldview-lens preamble (config.lens) so the
849
+ // interview / walkthrough speaks their own language (Phase D).
850
+ const { DEFAULT_SETUP_SYSTEM, FULL_SETUP_SYSTEM, CHAT_SYSTEM, lensPreamble } = require("./lib/agent-session");
851
+ let lens = "everyday";
852
+ try { lens = require("./lib/config-parser").read().lens || "everyday"; } catch (_) {}
853
+ const isChat = String(body.mode) === "chat";
854
+ const setupDepth = String(body.setupDepth || "full");
855
+ const setupSystem = setupDepth === "core" ? DEFAULT_SETUP_SYSTEM : FULL_SETUP_SYSTEM;
856
+ const base = body.systemPrompt ? String(body.systemPrompt) : (isChat ? CHAT_SYSTEM : setupSystem);
857
+ const pre = lensPreamble(lens);
858
+ // R3-C3: per-session model/effort with the same loud-refusal
859
+ // contract as one-shot runs (cleanToken drops spaces/metachars).
860
+ const { cleanToken, EFFORTS: SESSION_EFFORTS } = require("./lib/agent-command");
861
+ const reqModel = body.model != null ? String(body.model).trim() : "";
862
+ const reqEffort = body.effort != null ? String(body.effort).trim() : "";
863
+ const model = reqModel ? cleanToken(reqModel, 64) : null;
864
+ const effort = reqEffort && (SESSION_EFFORTS["claude-code"] || []).includes(reqEffort) ? reqEffort : null;
865
+ if (reqModel && !model) {
866
+ return jsonResponse(res, 400, { ok: false, error: `Model "${reqModel}" was rejected: letters, digits, . _ : - only, no spaces.` });
867
+ }
868
+ if (reqEffort && !effort) {
869
+ return jsonResponse(res, 400, { ok: false, error: `Effort "${reqEffort}" is not one of: ${(SESSION_EFFORTS["claude-code"] || []).join(", ")}.` });
870
+ }
871
+ const out = agentSessions.start({
872
+ agent: String(body.agent || "claude-code"),
873
+ systemPrompt: pre ? (pre + " " + base) : base,
874
+ model, effort,
875
+ ...(isChat ? { disallowTools: [], chat: true } : {}), // chat = full tool access + recall/skill hooks; setup stays locked
876
+ });
877
+ return jsonResponse(res, 200, { ok: true, ...out });
878
+ } catch (e) {
879
+ return jsonResponse(res, 400, { ok: false, error: e.message });
880
+ }
881
+ }
882
+ if (pathname === "/api/session/send" && req.method === "POST") {
883
+ const body = await readJsonBody(req, 64 * 1024);
884
+ try {
885
+ agentSessions.send(String(body.sessionId || ""), String(body.text || ""));
886
+ return jsonResponse(res, 200, { ok: true });
887
+ } catch (e) {
888
+ return jsonResponse(res, 400, { ok: false, error: e.message });
889
+ }
890
+ }
891
+ if (pathname === "/api/session/approve" && req.method === "POST") {
892
+ const body = await readJsonBody(req, 8 * 1024);
893
+ try {
894
+ agentSessions.approve(String(body.sessionId || ""), body.requestId, String(body.decision || "deny"));
895
+ return jsonResponse(res, 200, { ok: true });
896
+ } catch (e) {
897
+ return jsonResponse(res, 400, { ok: false, error: e.message });
898
+ }
899
+ }
900
+ if (pathname === "/api/session/end" && req.method === "POST") {
901
+ const body = await readJsonBody(req, 4 * 1024);
902
+ agentSessions.end(String(body.sessionId || ""));
903
+ return jsonResponse(res, 200, { ok: true });
904
+ }
905
+ // A — seed the Engine from a completed setup interview. Body:
906
+ // { sessionId, targetDir?, dryRun?, force? }. dryRun defaults TRUE
907
+ // (preview, no write). engine-writer blocks 02_Areas/Engine/ ALWAYS;
908
+ // omitting targetDir falls back to the dev sandbox (safe for preview).
909
+ if (pathname === "/api/session/finalize" && req.method === "POST") {
910
+ const body = await readJsonBody(req, 64 * 1024);
911
+ const sid = String(body.sessionId || "");
912
+ const { writeEngine } = require("./lib/engine-writer");
913
+
914
+ // Transcript source: a LIVE session ALWAYS wins. Only with no session
915
+ // do we accept a client-supplied transcript (the no-agent form) — and
916
+ // then it is role/type-validated, per-item length-capped, count-capped.
917
+ let transcript;
918
+ if (sid) {
919
+ transcript = agentSessions.getTranscript(sid) || [];
920
+ } else if (Array.isArray(body.transcript) && body.transcript.length) {
921
+ transcript = body.transcript
922
+ .slice(0, 200)
923
+ .filter((t) => t && (t.role === "user" || t.role === "assistant") && typeof t.text === "string")
924
+ .map((t) => ({ role: t.role, text: t.text.slice(0, 8000) }));
925
+ } else {
926
+ return jsonResponse(res, 400, { ok: false, error: "sessionId or a non-empty transcript is required" });
927
+ }
928
+ if (!transcript.length) return jsonResponse(res, 400, { ok: false, error: "empty transcript" });
929
+
930
+ // The SERVER resolves the target. The client may NOT pass targetDir or
931
+ // force. Two modes only: commit to THIS vault's Engine (fresh-install
932
+ // only, never force-overwrite), or a dev-sandbox preview.
933
+ const toVault = body.toVault === true;
934
+ const dryRun = body.dryRun !== false;
935
+ const targetDir = toVault
936
+ ? path.join(vault, "02_Areas", "Engine")
937
+ : path.join(__dirname, "..", "..", "dev", "research", "dashboard-direction-2026-06-10", "onboarding-build", "sandbox-engine");
938
+
939
+ const runFinalize = () => {
940
+ try {
941
+ const result = writeEngine({ transcript, targetDir, dryRun, force: false, allowEngineWrite: toVault });
942
+ // the synthesis "forge" beats — derived 1:1 from the real files being written,
943
+ // so the onboarding animation reflects the truth (tested in onboarding-forge).
944
+ const forge = forgeBeats(result.files.map((f) => f.path));
945
+ return jsonResponse(res, 200, { ok: true, written: result.written, target: targetDir, fileCount: result.files.length, forge,
946
+ files: dryRun ? result.files.map((f) => ({ path: f.path, content: f.content })) : result.files.map((f) => ({ path: f.path })) });
947
+ } catch (e) {
948
+ const msg = e.message || "";
949
+ const code = (msg.includes("SAFETY BLOCK") || msg.includes("already exists")) ? 403 : 500;
950
+ return jsonResponse(res, code, { ok: false, error: String(e && e.message || e) });
951
+ }
952
+ };
953
+
954
+ // Previews never lock. Real vault commits serialize so two finalizes
955
+ // can't both pass the fresh-guard and interleave (Codex r1).
956
+ if (dryRun || !toVault) return runFinalize();
957
+ if (finalizeLock) return jsonResponse(res, 409, { ok: false, error: "an Engine write is already in progress" });
958
+ finalizeLock = true;
959
+ try { return runFinalize(); } finally { finalizeLock = false; }
960
+ }
961
+ if (pathname === "/api/session/events" && req.method === "GET") {
962
+ const sid = url.searchParams.get("id") || "";
963
+ if (!agentSessions.has(sid)) {
964
+ res.writeHead(404, { "Content-Type": "text/plain" });
965
+ return res.end("no such session");
966
+ }
967
+ res.writeHead(200, {
968
+ "Content-Type": "text/event-stream; charset=utf-8",
969
+ "Cache-Control": "no-cache, no-transform",
970
+ "Connection": "keep-alive",
971
+ "X-Accel-Buffering": "no",
972
+ });
973
+ res.write(": connected\n\n");
974
+ // sol batch #1: honor socket backpressure here too (the runs stream
975
+ // already does) — a stalled SSE reader must not queue unboundedly.
976
+ let unsub = () => {};
977
+ unsub = agentSessions.subscribe(sid, (ev) => {
978
+ try {
979
+ const ok = res.write(`data: ${JSON.stringify(ev)}\n\n`);
980
+ if (!ok && res.writableLength > 8 * 1024 * 1024) { try { unsub(); } catch (_) {} try { res.destroy(); } catch (_) {} }
981
+ } catch (_) {}
982
+ });
983
+ const ping = setInterval(() => { try { res.write(": ping\n\n"); } catch (_) {} }, 25000);
984
+ if (ping.unref) ping.unref();
985
+ req.on("close", () => { clearInterval(ping); try { unsub(); } catch (_) {} });
986
+ return; // hold the SSE connection open
987
+ }
988
+ // Standalone foundation proof page for the setup turn-loop.
989
+ if (pathname === "/setup" && req.method === "GET") {
990
+ try {
991
+ const html = fs.readFileSync(path.join(__dirname, "static", "setup-poc.html"), "utf8");
992
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
993
+ return res.end(html);
994
+ } catch (_) {
995
+ res.writeHead(404, { "Content-Type": "text/plain" });
996
+ return res.end("setup-poc.html not found");
997
+ }
998
+ }
999
+ // D6 — the WALKTHROUGH proof page (teaches operating the agent). Mirrors
1000
+ // /setup; steps are inlined in the page, served fresh each GET.
1001
+ if (pathname === "/walkthrough" && req.method === "GET") {
1002
+ try {
1003
+ const html = fs.readFileSync(path.join(__dirname, "static", "walkthrough-poc.html"), "utf8");
1004
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1005
+ return res.end(html);
1006
+ } catch (_) {
1007
+ res.writeHead(404, { "Content-Type": "text/plain" });
1008
+ return res.end("walkthrough-poc.html not found");
1009
+ }
1010
+ }
1011
+ // D7 — which coding agents are installed + authed + runnable (no
1012
+ // interactive login is ever triggered). Read-only, no token needed.
1013
+ if (pathname === "/api/agents/detect" && req.method === "GET") {
1014
+ try { return jsonResponse(res, 200, { ok: true, agents: await agentDetect.detectAgents() }); }
1015
+ catch (e) { return jsonResponse(res, 500, { ok: false, error: String(e && e.message || e) }); }
1016
+ }
1017
+ // D3 — ingest the user's exported notes into a live setup session as a
1018
+ // framed context turn. Body: { sessionId, files:[{name, content}] }.
1019
+ if (pathname === "/api/session/ingest" && req.method === "POST") {
1020
+ const body = await readJsonBody(req, 2 * 1024 * 1024);
1021
+ const sid = String(body.sessionId || "");
1022
+ if (!agentSessions.has(sid)) return jsonResponse(res, 404, { ok: false, error: "no such session" });
1023
+ const files = Array.isArray(body.files) ? body.files : [];
1024
+ let digest = "";
1025
+ try { digest = ingestion.buildDigest(files); }
1026
+ catch (e) { return jsonResponse(res, 400, { ok: false, error: "ingest failed: " + String(e && e.message || e) }); }
1027
+ if (!digest) return jsonResponse(res, 200, { ok: true, injected: false, note: "no usable content" });
1028
+ try { agentSessions.send(sid, ingestion.frameDigest(digest)); }
1029
+ catch (e) { return jsonResponse(res, 500, { ok: false, error: String(e && e.message || e) }); }
1030
+ return jsonResponse(res, 200, { ok: true, injected: true, chars: digest.length });
1031
+ }
1032
+ // D4 — map a work profile to recommended skills/connectors.
1033
+ // Body: { profile:{ work?, domain?, tools?, goals? } }.
1034
+ if (pathname === "/api/setup/recommend" && req.method === "POST") {
1035
+ const body = await readJsonBody(req, 64 * 1024);
1036
+ const profile = (body && typeof body.profile === "object" && body.profile) || body || {};
1037
+ try {
1038
+ return jsonResponse(res, 200, {
1039
+ ok: true,
1040
+ ...skillRecommender.recommend(profile),
1041
+ // personalized "what to feed/connect me" plan (cold-start fix) — honest:
1042
+ // upload sources are ready (real ingest), live OAuth connectors are 'planned'.
1043
+ connectPlan: connectRecommender.connectPlan(profile),
1044
+ // owner ruling 2026-07-07: bundled skills install ALL; the
1045
+ // recommend layer's real job is EXTERNAL awareness — MCP
1046
+ // connectors + CLI tools, profile-matched from the shipped
1047
+ // catalogues, with what's already installed/attached flagged
1048
+ // from the machine itself.
1049
+ skillsPolicy: "install-all",
1050
+ external: require("./lib/tool-awareness").awareness(profile, { vault }),
1051
+ });
1052
+ }
1053
+ catch (e) { return jsonResponse(res, 500, { ok: false, error: String(e && e.message || e) }); }
1054
+ }
1055
+ // D7 — create a double-clickable desktop launcher so the user can reopen
1056
+ // the dashboard from their OS. Body: { name }. Cross-platform (mac .app /
1057
+ // win .lnk / linux .desktop); shortcut.js never throws on a single-platform
1058
+ // failure (returns a skipped reason), so onboarding is never blocked.
1059
+ if (pathname === "/api/setup/shortcut" && req.method === "POST") {
1060
+ const body = await readJsonBody(req, 4 * 1024);
1061
+ const name = String(body.name || "").slice(0, 80);
1062
+ try {
1063
+ const { createShortcut } = require("./shortcut");
1064
+ const result = createShortcut({
1065
+ name,
1066
+ nodeBin: process.execPath,
1067
+ entry: path.join(__dirname, "..", "..", "install.js"),
1068
+ });
1069
+ return jsonResponse(res, 200, { ok: true, ...result });
1070
+ } catch (e) {
1071
+ return jsonResponse(res, 500, { ok: false, error: String(e && e.message || e) });
1072
+ }
1073
+ }
1074
+
1075
+ // Run a Mover workflow (e.g. /analyse-day) via the user's preferred
1076
+ // agent CLI. Body: { workflow: "/analyse-day", agent?: "claude-code",
1077
+ // model?, effort?, approvalMode? } — tuning fields are per-run (#112);
1078
+ // omitted ones resolve from config providerPrefs inside runAgentStream.
1079
+ if (pathname === "/api/workflow/run" && req.method === "POST") {
1080
+ const body = await readJsonBody(req, 16 * 1024);
1081
+ const workflow = String(body.workflow || "").trim();
1082
+ const agent = String(body.agent || "claude-code").trim();
1083
+ // terra T-EX-3: require an actual shipped workflow, not any slash token —
1084
+ // an unknown /foo would start a real agent run with a junk prompt.
1085
+ if (!isKnownWorkflow(workflow)) {
1086
+ return jsonResponse(res, 400, { ok: false, error: "unknown workflow" });
1087
+ }
1088
+ // V5.0 activation event — fires for the streaming workflow runner
1089
+ // path too. Pairs with the open-in-terminal logger below so
1090
+ // "workflow_run" stats reflect both Pattern A and stream usage.
1091
+ try { logActivationEvent("workflow_run", { workflow, agent }); } catch (_) {}
1092
+ return runAgentStream({
1093
+ req, res,
1094
+ body: { agent, prompt: workflow, model: body.model, effort: body.effort, approvalMode: body.approvalMode },
1095
+ vault, runRegistry
1096
+ });
1097
+ }
1098
+
1099
+ // Pattern A: open the user's REAL terminal at the vault root with the
1100
+ // agent's interactive CLI running. The slash command is returned in
1101
+ // the response so the client can copy it to clipboard and the user
1102
+ // pastes it into their own session. No phantom background processes,
1103
+ // no hidden execution, full Ctrl+C control.
1104
+ // Body: { workflow: "/log", agent: "claude-code" }
1105
+ if (pathname === "/api/workflow/open-in-terminal" && req.method === "POST") {
1106
+ const body = await readJsonBody(req, 16 * 1024);
1107
+ const workflow = String(body.workflow || "").trim();
1108
+ const agent = String(body.agent || "claude-code").trim();
1109
+ if (!/^\/[a-z0-9][a-z0-9-]{0,40}$/i.test(workflow)) {
1110
+ return jsonResponse(res, 400, { ok: false, error: "workflow must match /[a-z0-9-]+" });
1111
+ }
1112
+ // terra T-EX-3: shape is not enough — require an actual shipped workflow.
1113
+ if (!isKnownWorkflow(workflow)) {
1114
+ return jsonResponse(res, 400, { ok: false, error: "unknown workflow" });
1115
+ }
1116
+ // Map agent id → interactive CLI command. Only registered agents
1117
+ // are allowed — no raw shell. Validates against the same registry
1118
+ // that runAgentStream uses (agentCommand).
1119
+ const AGENT_INTERACTIVE_CMD = {
1120
+ "claude-code": "claude",
1121
+ "codex": "codex",
1122
+ "gemini": "gemini",
1123
+ };
1124
+ const ALIASES = { "gemini-cli": "gemini", "claude": "claude-code", "codex-cli": "codex" };
1125
+ const resolvedAgent = ALIASES[agent] || agent;
1126
+ const cli = AGENT_INTERACTIVE_CMD[resolvedAgent];
1127
+ if (!cli) {
1128
+ return jsonResponse(res, 400, { ok: false, error: `Unsupported agent: ${agent}` });
1129
+ }
1130
+ try {
1131
+ openInTerminal(cli, vault);
1132
+ // V5.0 activation event (Phase 41.0.3 wiring, Codex #4 finding):
1133
+ // logs the workflow_run event so activation stats can show real
1134
+ // user behavior. Best-effort — never blocks the response.
1135
+ try { logActivationEvent("workflow_run", { workflow, agent: resolvedAgent }); } catch (_) {}
1136
+ return jsonResponse(res, 200, {
1137
+ ok: true,
1138
+ agent: resolvedAgent,
1139
+ cli,
1140
+ workflow,
1141
+ hint: `Terminal opened at vault root running ${cli}. Paste ${workflow} to run.`,
1142
+ });
1143
+ } catch (e) {
1144
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1145
+ }
1146
+ }
1147
+
1148
+ // Run a Mover CLI command (e.g. moveros doctor, moveros capture).
1149
+ // Body: { cmd: "doctor", args?: ["--verbose"] }
1150
+ if (pathname === "/api/cli/run" && req.method === "POST") {
1151
+ const body = await readJsonBody(req, 16 * 1024);
1152
+ const cmd = String(body.cmd || "").trim();
1153
+ const args = Array.isArray(body.args) ? body.args.map(String).slice(0, 32) : [];
1154
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cmd)) {
1155
+ return jsonResponse(res, 400, { ok: false, error: "invalid cmd (alphanumeric + dash/underscore only)" });
1156
+ }
1157
+ // terra T-EX-1: a shape-valid but UNKNOWN cmd is not rejected — parseArgs
1158
+ // leaves opts.command unset and main() falls through to the bare-invocation
1159
+ // default (open the dashboard on a configured machine, or ENTER THE INSTALL
1160
+ // path on a fresh one), running something the caller never asked for.
1161
+ // Require exact registry membership, exactly as /api/cli/open-in-terminal.
1162
+ if (!getCliIndex().cli.some((c) => c.cmd === cmd)) {
1163
+ return jsonResponse(res, 400, { ok: false, error: `Unknown cmd: ${cmd}` });
1164
+ }
1165
+ // terra T-EX-2: args must not be able to RE-SELECT the command. parseArgs
1166
+ // maps --update/-u to the (destructive, comprehensive) update command
1167
+ // regardless of the cmd already chosen, and --_self-updated is internal-
1168
+ // only. Reject those, and constrain the rest to safe chars (mirrors the
1169
+ // open-in-terminal ARG_RE — the child is argv-spawned so this is defense
1170
+ // in depth, not the only guard).
1171
+ const RUN_ARG_RE = /^[a-zA-Z0-9._=/-]{0,80}$/;
1172
+ const RUN_BANNED_ARGS = new Set(["--update", "-u", "--_self-updated"]);
1173
+ for (const a of args) {
1174
+ if (RUN_BANNED_ARGS.has(a) || !RUN_ARG_RE.test(a)) {
1175
+ return jsonResponse(res, 400, { ok: false, error: `Invalid arg: ${a}` });
1176
+ }
1177
+ }
1178
+ return runMoverosStream({ req, res, cmd, args, vault });
1179
+ }
1180
+
1181
+ // Open a registered command in the user's REAL terminal app.
1182
+ // Body: { commandId: "doctor", args?: ["--verbose"] }
1183
+ // Codex security audit: never accept raw cmd strings — validate against registry.
1184
+ if (pathname === "/api/cli/open-in-terminal" && req.method === "POST") {
1185
+ const body = await readJsonBody(req, 16 * 1024);
1186
+ const commandId = String(body.commandId || "").trim();
1187
+ const args = Array.isArray(body.args) ? body.args.map(String).slice(0, 32) : [];
1188
+ const reg = getCliIndex().cli.find((c) => c.cmd === commandId);
1189
+ if (!reg) return jsonResponse(res, 400, { ok: false, error: `Unknown commandId: ${commandId}` });
1190
+ // Validate args — alphanumeric + dash + equals + dot only, no shell metachars
1191
+ const ARG_RE = /^[a-zA-Z0-9._=/-]{0,80}$/;
1192
+ for (const a of args) {
1193
+ if (!ARG_RE.test(a)) {
1194
+ return jsonResponse(res, 400, { ok: false, error: `Invalid arg: ${a}` });
1195
+ }
1196
+ }
1197
+ const cmdLine = `moveros ${commandId} ${args.join(" ")}`.trim();
1198
+ try {
1199
+ openInTerminal(cmdLine, vault);
1200
+ return jsonResponse(res, 200, { ok: true, opened: cmdLine });
1201
+ } catch (e) {
1202
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1203
+ }
1204
+ }
1205
+
1206
+ // List of all runnable commands the dashboard can offer.
1207
+ if (pathname === "/api/cli/index") {
1208
+ return jsonResponse(res, 200, getCliIndex());
1209
+ }
1210
+
1211
+ // Recent Engine writes — "what just changed" for the topbar bell.
1212
+ // Scans 02_Areas/Engine/ (depth 2, to include Dailies/YYYY-MM/) for .md
1213
+ // files, newest mtime first. Returns vault-RELATIVE paths (openFileView
1214
+ // resolves them). Read-only, scoped to the Engine dir.
1215
+ if (pathname === "/api/recent" && req.method === "GET") {
1216
+ try {
1217
+ const limit = Math.min(parseInt(url.searchParams.get("limit") || "12", 10) || 12, 40);
1218
+ const engineDir = path.join(vault, "02_Areas", "Engine");
1219
+ const found = [];
1220
+ const walk = (dir, depth) => {
1221
+ let entries;
1222
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
1223
+ for (const ent of entries) {
1224
+ if (ent.name.startsWith(".")) continue;
1225
+ const full = path.join(dir, ent.name);
1226
+ if (ent.isDirectory()) { if (depth < 2) walk(full, depth + 1); }
1227
+ else if (ent.isFile() && ent.name.endsWith(".md")) {
1228
+ // Templates are scaffolding, not the user's record — listing
1229
+ // "Strategy_template" under "your Engine files" leaks internals
1230
+ // into the bell on day one. Same convention as the hook guard.
1231
+ if (/template\.md$/i.test(ent.name)) continue;
1232
+ try { const st = fs.statSync(full); found.push({ p: path.relative(vault, full), n: ent.name, m: st.mtimeMs, s: st.size }); } catch (_) {}
1233
+ }
1234
+ }
1235
+ };
1236
+ walk(engineDir, 0);
1237
+ found.sort((a, b) => b.m - a.m);
1238
+ const files = found.slice(0, limit).map((f) => ({ path: f.p, name: f.n, mtime: new Date(f.m).toISOString(), size: f.s }));
1239
+ return jsonResponse(res, 200, { ok: true, files });
1240
+ } catch (e) {
1241
+ return jsonResponse(res, 500, { ok: false, error: "recent scan failed" });
1242
+ }
1243
+ }
1244
+
1245
+ // Read a vault-scoped file's contents (for the in-app MD viewer drawer).
1246
+ // Query: ?path=<absolute path under vault>
1247
+ // &root=vault|skills|workflows|hooks (default vault) — the
1248
+ // Library's truthful search returns results from the skills dir
1249
+ // and the bundle's workflows/hooks; each root gets the SAME
1250
+ // realpath containment the vault does (resolveVaultScoped is
1251
+ // generic over its root argument). Relative paths resolve
1252
+ // against the chosen root, never the process cwd.
1253
+ // Returns { ok, content, mtime, size } — capped at 256KB.
1254
+ if (pathname === "/api/file/read" && req.method === "GET") {
1255
+ let filePath = url.searchParams.get("path") || "";
1256
+ const rootParam = url.searchParams.get("root") || "vault";
1257
+ const os = require("os");
1258
+ const rootDir = rootDirFor(
1259
+ {
1260
+ vault,
1261
+ bundleRoot: path.resolve(__dirname, "..", ".."),
1262
+ claudeDir: path.join(os.homedir(), ".claude"),
1263
+ },
1264
+ rootParam
1265
+ );
1266
+ if (!rootDir) {
1267
+ return jsonResponse(res, 400, { ok: false, error: "unknown root" });
1268
+ }
1269
+ if (filePath && !path.isAbsolute(filePath)) {
1270
+ filePath = path.join(rootDir, filePath);
1271
+ }
1272
+ let scoped;
1273
+ try {
1274
+ // Realpath-resolved, refuses symlink escapes (audit pass 2 #3).
1275
+ scoped = resolveVaultScoped(rootDir, filePath, { mustExist: true });
1276
+ } catch (e) {
1277
+ const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
1278
+ // T242 bug hunt fix (B#2): success path returned only relPath
1279
+ // but error path echoed e.message which can contain the
1280
+ // resolved absolute path or the input path (e.g. ENOENT errors
1281
+ // include 'lstat ~/.ssh/id_rsa'). Generic messages instead —
1282
+ // the caller knows which path it sent.
1283
+ const msg = e.code === "EESCAPE" ? "path escapes vault" :
1284
+ e.code === "ENOENT" ? "file not found" : "invalid path";
1285
+ return jsonResponse(res, code, { ok: false, error: msg });
1286
+ }
1287
+ try {
1288
+ const stat = fs.statSync(scoped.absPath);
1289
+ if (stat.size > 256 * 1024) {
1290
+ return jsonResponse(res, 413, { ok: false, error: "file too large (>256KB) — open in Obsidian" });
1291
+ }
1292
+ const content = fs.readFileSync(scoped.absPath, "utf8");
1293
+ return jsonResponse(res, 200, {
1294
+ ok: true,
1295
+ // Return vault-relative path only (code-bug-audit M1).
1296
+ // Returning targetAbs leaked the user's home/filesystem layout
1297
+ // to any browser context that hit this endpoint.
1298
+ path: scoped.relPath,
1299
+ content,
1300
+ mtime: stat.mtime.toISOString(),
1301
+ size: stat.size,
1302
+ });
1303
+ } catch (e) {
1304
+ return jsonResponse(res, 500, { ok: false, error: "file read failed" });
1305
+ }
1306
+ }
1307
+
1308
+ // Open a file in the user's default app (Obsidian for .md, etc).
1309
+ // Body: { filePath: "<absolute path under vault>" } — vault-scoped only.
1310
+ if (pathname === "/api/file/open" && req.method === "POST") {
1311
+ const body = await readJsonBody(req, 4 * 1024);
1312
+ const filePath = String(body.filePath || "");
1313
+ let scoped;
1314
+ try {
1315
+ // Realpath-resolved, refuses symlink escapes (audit pass 2 #3).
1316
+ scoped = resolveVaultScoped(vault, filePath, { mustExist: true });
1317
+ } catch (e) {
1318
+ // Generic messages: raw e.message from resolveVaultScoped's
1319
+ // realpath ENOENT carries the resolved ABSOLUTE path, leaking the
1320
+ // filesystem layout. /api/file/read was already hardened for this
1321
+ // exact class; /api/file/open and the checkbox toggle had been
1322
+ // missed.
1323
+ const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
1324
+ const msg = e.code === "EESCAPE" ? "path escapes vault" :
1325
+ e.code === "ENOENT" ? "file not found" : "invalid path";
1326
+ return jsonResponse(res, code, { ok: false, error: msg });
1327
+ }
1328
+ const targetAbs = scoped.absPath;
1329
+ try {
1330
+ const { execFile } = require("child_process");
1331
+ const platform = process.platform;
1332
+ // Simpler approach: always use OS-default opener with the file path.
1333
+ // macOS `open` / Linux `xdg-open` / Windows `start` will dispatch
1334
+ // to whatever app is registered for that file type. If the user has
1335
+ // Obsidian as their default .md handler, it opens in Obsidian; if
1336
+ // they prefer VS Code or another editor, it opens there.
1337
+ //
1338
+ // Previous version used obsidian:// URI scheme which silently failed
1339
+ // when the vault name didn't match (basename != Obsidian vault name).
1340
+ // OS-default dispatch sidesteps that entirely (E2E audit feedback).
1341
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
1342
+ const args = platform === "win32" ? ["/c", "start", "", targetAbs] : [targetAbs];
1343
+ execFile(cmd, args, () => {});
1344
+ return jsonResponse(res, 200, { ok: true, opened: targetAbs });
1345
+ } catch (e) {
1346
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1347
+ }
1348
+ }
1349
+
1350
+ // Toggle a checkbox in a Health protocol .md file.
1351
+ // Body: { filePath: "<absolute path under vault>", itemText: "<exact item text>", checked: true }
1352
+ // Re-reads the protocol file, rewrites the matching `- [ ]`/`- [x]` line, refreshes state.
1353
+ if (pathname === "/api/checkbox/toggle" && req.method === "POST") {
1354
+ const body = await readJsonBody(req, 16 * 1024);
1355
+ const filePath = String(body.filePath || "");
1356
+ const itemText = String(body.itemText || "").trim();
1357
+ const checked = !!body.checked;
1358
+ if (!filePath || !itemText) {
1359
+ return jsonResponse(res, 400, { ok: false, error: "filePath + itemText required" });
1360
+ }
1361
+ // Safety: must be a markdown file. Realpath protects against
1362
+ // symlink escape; vault-scope check enforced by resolveVaultScoped.
1363
+ if (!filePath.endsWith(".md")) {
1364
+ return jsonResponse(res, 400, { ok: false, error: "filePath must be a .md under the vault" });
1365
+ }
1366
+ let scoped;
1367
+ try {
1368
+ scoped = resolveVaultScoped(vault, filePath, { mustExist: true });
1369
+ } catch (e) {
1370
+ // Generic messages (same class as /api/file/open above): raw
1371
+ // e.message leaks the resolved absolute path.
1372
+ const code = e.code === "EESCAPE" ? 400 : e.code === "ENOENT" ? 404 : 400;
1373
+ const msg = e.code === "EESCAPE" ? "path escapes vault" :
1374
+ e.code === "ENOENT" ? "file not found" : "invalid path";
1375
+ return jsonResponse(res, code, { ok: false, error: msg });
1376
+ }
1377
+ const targetAbs = scoped.absPath;
1378
+ try {
1379
+ // T242 bug hunt fix (B#4): the prior read-mutate-write was
1380
+ // unprotected. Two concurrent toggles against the same file
1381
+ // could each parse, then race on writeFileSync — last write
1382
+ // wins, first write's edit lost. Other JSON endpoints use
1383
+ // withFileLock for serialization; markdown writes were
1384
+ // skipped. Wrap in the same mutex.
1385
+ const result = await withFileLock(targetAbs, () => {
1386
+ let found = false;
1387
+ writeNoteAtomic(targetAbs, (text) => {
1388
+ const lines = text.split("\n");
1389
+ let hit = -1;
1390
+ for (let i = 0; i < lines.length; i++) {
1391
+ const m = lines[i].match(/^([\s-*]+\[)([ xX])(\]\s+)(.+)$/);
1392
+ if (m && m[4].trim() === itemText) { hit = i; break; }
1393
+ }
1394
+ if (hit < 0) return null; // item not found → leave the file untouched
1395
+ found = true;
1396
+ const mark = checked ? "x" : " ";
1397
+ lines[hit] = lines[hit].replace(/^([\s-*]+\[)([ xX])(\])/, `$1${mark}$3`);
1398
+ return lines.join("\n");
1399
+ });
1400
+ return { found };
1401
+ });
1402
+ if (!result.found) {
1403
+ return jsonResponse(res, 404, { ok: false, error: "item not found in file" });
1404
+ }
1405
+ // Trigger rebuild so dashboard reflects the change
1406
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1407
+ return jsonResponse(res, 200, { ok: true, filePath, itemText, checked });
1408
+ } catch (e) {
1409
+ return jsonResponse(res, 500, { ok: false, error: "checkbox write failed" });
1410
+ }
1411
+ }
1412
+
1413
+ // QuickLog capture — append a timestamped line to today's Daily Note.
1414
+ // Body: { text: "<single line>", tag?: "<single tag, no #>" }
1415
+ // Writes to {vault}/02_Areas/Engine/Dailies/YYYY-MM/Daily - YYYY-MM-DD.md
1416
+ // under the `## Log` section (created if missing). Sibling to /log,
1417
+ // but inline — no workflow shell-out, no terminal.
1418
+ if (pathname === "/api/capture" && req.method === "POST") {
1419
+ const body = await readJsonBody(req, 8 * 1024);
1420
+ // terra T-DW-10: reject a non-string text so an object body can't be
1421
+ // coerced to "[object Object]" and appended to the Daily Note.
1422
+ if (body.text != null && typeof body.text !== "string") {
1423
+ return jsonResponse(res, 400, { ok: false, error: "text must be a string" });
1424
+ }
1425
+ const text = String(body.text || "").trim();
1426
+ // tag is optional — accept "work" or "#work", strip the hash.
1427
+ // Multi-tag input arrives as comma- or space-separated; normalise
1428
+ // each token, drop empties, cap at 6 to keep the line scannable.
1429
+ const rawTag = body.tag == null ? "" : String(body.tag);
1430
+ const tags = rawTag
1431
+ .split(/[\s,]+/)
1432
+ .map(t => t.replace(/^#+/, "").trim())
1433
+ .filter(t => /^[A-Za-z0-9_-]{1,32}$/.test(t))
1434
+ .slice(0, 6);
1435
+ if (!text) {
1436
+ return jsonResponse(res, 400, { ok: false, error: "text required" });
1437
+ }
1438
+ if (text.length > 500) {
1439
+ return jsonResponse(res, 400, { ok: false, error: "text too long (max 500 chars)" });
1440
+ }
1441
+ if (!vault) {
1442
+ return jsonResponse(res, 500, { ok: false, error: "vault not set" });
1443
+ }
1444
+ try {
1445
+ // Compose YYYY-MM-DD in local time so the Daily Note matches what
1446
+ // the user sees on their calendar (UTC drift would file morning
1447
+ // entries under yesterday in late-night timezones).
1448
+ const now = new Date();
1449
+ const yyyy = String(now.getFullYear());
1450
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
1451
+ const dd = String(now.getDate()).padStart(2, "0");
1452
+ const hh = String(now.getHours()).padStart(2, "0");
1453
+ const mi = String(now.getMinutes()).padStart(2, "0");
1454
+ const ymd = `${yyyy}-${mm}-${dd}`;
1455
+ const ymo = `${yyyy}-${mm}`;
1456
+ const hhmm = `${hh}:${mi}`;
1457
+
1458
+ const dailyDir = path.join(vault, "02_Areas", "Engine", "Dailies", ymo);
1459
+ const dailyPath = path.join(dailyDir, `Daily - ${ymd}.md`);
1460
+
1461
+ // Vault-scope safety. Realpath the parent on missing files
1462
+ // (resolveVaultScoped handles the ENOENT case internally).
1463
+ let scoped;
1464
+ try {
1465
+ fs.mkdirSync(dailyDir, { recursive: true });
1466
+ scoped = resolveVaultScoped(vault, dailyPath, { mustExist: false });
1467
+ } catch (e) {
1468
+ const code = e.code === "EESCAPE" ? 400 : 500;
1469
+ return jsonResponse(res, code, { ok: false, error: e.message });
1470
+ }
1471
+ const targetAbs = scoped.absPath;
1472
+
1473
+ const tagSuffix = tags.length ? " " + tags.map(t => `#${t}`).join(" ") : "";
1474
+ const line = `- [${hhmm}] ${text}${tagSuffix}`;
1475
+
1476
+ // T242 bug hunt fix (B#4 cont.): wrap the read-modify-write in
1477
+ // withFileLock so two concurrent captures don't clobber each
1478
+ // other's edits on the same Daily Note.
1479
+ await withFileLock(targetAbs, () => {
1480
+ // Read existing file (or start fresh). If `## Log` exists, append
1481
+ // the new line under the next existing line break; otherwise
1482
+ // append the section at the bottom.
1483
+ writeNoteAtomic(targetAbs, (existing) => {
1484
+ const hasLogSection = /(^|\n)##\s+Log\s*(\n|$)/.test(existing);
1485
+ if (!existing) {
1486
+ return `# Daily - ${ymd}\n\n## Log\n\n${line}\n`;
1487
+ }
1488
+ if (!hasLogSection) {
1489
+ const sep = existing.endsWith("\n") ? "" : "\n";
1490
+ return `${existing}${sep}\n## Log\n\n${line}\n`;
1491
+ }
1492
+ const lines = existing.split("\n");
1493
+ let logIdx = -1;
1494
+ for (let i = 0; i < lines.length; i++) {
1495
+ if (/^##\s+Log\s*$/.test(lines[i])) { logIdx = i; break; }
1496
+ }
1497
+ let insertAt = lines.length;
1498
+ for (let i = logIdx + 1; i < lines.length; i++) {
1499
+ if (/^##\s/.test(lines[i])) { insertAt = i; break; }
1500
+ }
1501
+ while (insertAt - 1 > logIdx && lines[insertAt - 1].trim() === "") {
1502
+ insertAt--;
1503
+ }
1504
+ const before = lines.slice(0, insertAt);
1505
+ const after = lines.slice(insertAt);
1506
+ const blockTail = after.length && after[0].trim() === "" ? after : ["", ...after];
1507
+ return [...before, line, ...blockTail].join("\n");
1508
+ });
1509
+ });
1510
+ // Trigger rebuild so any dashboard surfaces that read Daily
1511
+ // contents (DayRing, ExecutionProtocol) refresh in the next tick.
1512
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1513
+ return jsonResponse(res, 200, { ok: true, filePath: targetAbs, line });
1514
+ } catch (e) {
1515
+ return jsonResponse(res, 500, { ok: false, error: "capture write failed" });
1516
+ }
1517
+ }
1518
+
1519
+ // v6 W3 — Journal close write. Body { mode:'evening'|'morning', answers }.
1520
+ // Writes the mode's `### Evening/Morning · <field>` sub-anchors under
1521
+ // `## Journal` in today's Daily Note (session-day rule: before 05:00 the
1522
+ // day is still yesterday's note). Cross-mode safe — writing Evening
1523
+ // preserves any Morning sub-anchors and vice-versa. Only non-empty
1524
+ // fields are written; an absent field stays absent (parseJournal returns
1525
+ // null — never a fabricated value).
1526
+ if (pathname === "/api/journal" && req.method === "POST") {
1527
+ const body = await readJsonBody(req, 16 * 1024);
1528
+ const mode = String(body.mode || "").trim();
1529
+ const answers = body.answers && typeof body.answers === "object" ? body.answers : null;
1530
+ if (mode !== "evening" && mode !== "morning") {
1531
+ return jsonResponse(res, 400, { ok: false, error: "mode must be 'evening' or 'morning'" });
1532
+ }
1533
+ if (!answers) {
1534
+ return jsonResponse(res, 400, { ok: false, error: "answers required" });
1535
+ }
1536
+ if (!vault) {
1537
+ return jsonResponse(res, 500, { ok: false, error: "vault not set" });
1538
+ }
1539
+ const clip = sanitizeJournalLine; // escapes a leading '#' (round 4)
1540
+ const intInRange = (v) => (Number.isFinite(v) ? Math.max(1, Math.min(10, Math.round(v))) : null);
1541
+ // Build [header, body] pairs for the non-empty fields only.
1542
+ const subAnchors = [];
1543
+ if (mode === "evening") {
1544
+ const score = intInRange(answers.score);
1545
+ const scoreWhy = clip(answers.scoreWhy, 400);
1546
+ const win = clip(answers.winAvoidance, 600);
1547
+ const worked = clip(answers.workedDidnt, 600);
1548
+ const diff = clip(answers.doDifferently, 600);
1549
+ const alibi = clip(answers.alibi, 400);
1550
+ if (score != null) subAnchors.push(["### Evening · score", scoreWhy ? `${score} — ${scoreWhy}` : `${score}`]);
1551
+ if (win) subAnchors.push(["### Evening · win / avoidance", win]);
1552
+ if (worked) subAnchors.push(["### Evening · what worked / what didn't", worked]);
1553
+ if (diff) subAnchors.push(["### Evening · do differently", diff]);
1554
+ if (alibi) subAnchors.push(["### Evening · alibi", alibi]);
1555
+ } else {
1556
+ const wantVsShould = clip(answers.wantVsShould, 600);
1557
+ const energy = intInRange(answers.energy);
1558
+ const grateful = clip(answers.grateful, 600);
1559
+ const picture = clip(answers.picture, 600);
1560
+ if (wantVsShould) subAnchors.push(["### Morning · want vs should", wantVsShould]);
1561
+ if (energy != null) subAnchors.push(["### Morning · energy", `${energy}`]);
1562
+ if (grateful) subAnchors.push(["### Morning · grateful", grateful]);
1563
+ if (picture) subAnchors.push(["### Morning · picture", picture]);
1564
+ }
1565
+ if (!subAnchors.length) {
1566
+ return jsonResponse(res, 400, { ok: false, error: "all answers empty" });
1567
+ }
1568
+ try {
1569
+ // Session-day rule (matches daily-note-resolver): before 05:00 local
1570
+ // the active day is still yesterday's note.
1571
+ const now = new Date();
1572
+ const sessionDay = new Date(now);
1573
+ if (now.getHours() < 5) sessionDay.setDate(sessionDay.getDate() - 1);
1574
+ const yyyy = String(sessionDay.getFullYear());
1575
+ const mm = String(sessionDay.getMonth() + 1).padStart(2, "0");
1576
+ const dd = String(sessionDay.getDate()).padStart(2, "0");
1577
+ const ymd = `${yyyy}-${mm}-${dd}`;
1578
+ const ymo = `${yyyy}-${mm}`;
1579
+ const dailyDir = path.join(vault, "02_Areas", "Engine", "Dailies", ymo);
1580
+ const dailyPath = path.join(dailyDir, `Daily - ${ymd}.md`);
1581
+ let scoped;
1582
+ try {
1583
+ // Codex W3 fix 4: validate vault scope BEFORE creating the month
1584
+ // directory. resolveVaultScoped realpaths the nearest existing
1585
+ // ancestor; if a symlinked ancestor escapes the vault it throws
1586
+ // here, so mkdir never follows it out of the vault.
1587
+ scoped = resolveVaultScoped(vault, dailyPath, { mustExist: false });
1588
+ fs.mkdirSync(dailyDir, { recursive: true });
1589
+ } catch (e) {
1590
+ const code = e.code === "EESCAPE" ? 400 : 500;
1591
+ return jsonResponse(res, code, { ok: false, error: e.message });
1592
+ }
1593
+ const targetAbs = scoped.absPath;
1594
+ const newBlock = subAnchors.map(([h, b]) => `${h}\n${b}`).join("\n\n");
1595
+ // Serialize concurrent journal writes on the same note so a near-
1596
+ // simultaneous evening+morning save can't clobber each other.
1597
+ await withFileLock(targetAbs, () => {
1598
+ writeNoteAtomic(targetAbs, (existing) => mergeJournalSection(existing, ymd, mode, newBlock));
1599
+ });
1600
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1601
+ return jsonResponse(res, 200, { ok: true, filePath: targetAbs, mode });
1602
+ } catch (e) {
1603
+ return jsonResponse(res, 500, { ok: false, error: "journal write failed" });
1604
+ }
1605
+ }
1606
+
1607
+ // v6 W3 — live work feed tail. Same-origin GET (loopback, no token, like
1608
+ // /api/state.json): reads ~/.claude/history.jsonl + sessions fresh per
1609
+ // request for near-real-time polling. Honest shape — NO cost, NO diff.
1610
+ if (pathname === "/api/feed/tail" && req.method === "GET") {
1611
+ if (!vault) return jsonResponse(res, 200, { available: false, caption: "", events: [], activeSessions: [] });
1612
+ const limParam = parseInt(url.searchParams.get("limit"), 10);
1613
+ const limit = Number.isFinite(limParam) ? Math.max(1, Math.min(100, limParam)) : 40;
1614
+ try {
1615
+ const feed = feedParser.compute({ vault, limit });
1616
+ return jsonResponse(res, 200, feed);
1617
+ } catch (e) {
1618
+ return jsonResponse(res, 200, { available: false, caption: "", events: [], activeSessions: [] });
1619
+ }
1620
+ }
1621
+
1622
+ // Snooze a workflow until N hours/days from now.
1623
+ // Body: { workflow: "/log", hours?: 24, reason?: "..." }
1624
+ if (pathname === "/api/snooze" && req.method === "POST") {
1625
+ const body = await readJsonBody(req, 8 * 1024);
1626
+ const workflow = String(body.workflow || "").trim();
1627
+ const hours = Number.isFinite(body.hours) ? Math.max(1, Math.min(168, body.hours)) : 24;
1628
+ const reason = String(body.reason || "").slice(0, 200);
1629
+ // terra T-EX-4: only snooze a real shipped workflow — an unknown token
1630
+ // would persist an inert record in snoozes.json that can never match a
1631
+ // suggestion.
1632
+ if (!isKnownWorkflow(workflow)) {
1633
+ return jsonResponse(res, 400, { ok: false, error: "unknown workflow" });
1634
+ }
1635
+ try {
1636
+ const os = require("os");
1637
+ const moverDir = path.join(os.homedir(), ".mover", "dashboard");
1638
+ fs.mkdirSync(moverDir, { recursive: true });
1639
+ const snoozePath = path.join(moverDir, "snoozes.json");
1640
+ // Serialize concurrent snooze writes (audit pass 2 #5).
1641
+ const until = await withFileLock(snoozePath, () => {
1642
+ let snoozes = [];
1643
+ try { snoozes = JSON.parse(fs.readFileSync(snoozePath, "utf8")); } catch (_) {}
1644
+ if (!Array.isArray(snoozes)) snoozes = [];
1645
+ // Drop any existing snooze for this workflow, then add new
1646
+ snoozes = snoozes.filter(s => s.workflow !== workflow);
1647
+ const untilIso = new Date(Date.now() + hours * 3600 * 1000).toISOString();
1648
+ snoozes.push({ workflow, until: untilIso, reason: reason || undefined });
1649
+ atomicWriteJson(snoozePath, snoozes);
1650
+ return untilIso;
1651
+ });
1652
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1653
+ return jsonResponse(res, 200, { ok: true, workflow, until });
1654
+ } catch (e) {
1655
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1656
+ }
1657
+ }
1658
+
1659
+ // View Generator MVP — wishlist #64. POST /api/view/generate accepts
1660
+ // a prompt, returns a structured view definition. GET /api/views
1661
+ // lists saved views. POST /api/view/delete?slug=... removes one.
1662
+ if (pathname === "/api/views" && req.method === "GET") {
1663
+ try {
1664
+ const { list } = require("./lib/view-generator");
1665
+ return jsonResponse(res, 200, { ok: true, views: list() });
1666
+ } catch (e) {
1667
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1668
+ }
1669
+ }
1670
+ if (pathname === "/api/view/generate" && req.method === "POST") {
1671
+ try {
1672
+ const body = await readJsonBody(req, 4 * 1024);
1673
+ const prompt = String(body.prompt || "").slice(0, 400).trim();
1674
+ if (!prompt) return jsonResponse(res, 400, { ok: false, error: "prompt required" });
1675
+ const { generate } = require("./lib/view-generator");
1676
+ const result = generate({ prompt, save: true });
1677
+ return jsonResponse(res, result.ok ? 200 : 400, result);
1678
+ } catch (e) {
1679
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1680
+ }
1681
+ }
1682
+ if (pathname === "/api/view/delete" && req.method === "POST") {
1683
+ try {
1684
+ const body = await readJsonBody(req, 1024);
1685
+ const slug = String(body.slug || "").trim();
1686
+ if (!slug || !/^[a-z0-9-]+$/i.test(slug)) {
1687
+ return jsonResponse(res, 400, { ok: false, error: "valid slug required" });
1688
+ }
1689
+ const { remove } = require("./lib/view-generator");
1690
+ return jsonResponse(res, 200, { ok: remove(slug) });
1691
+ } catch (e) {
1692
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1693
+ }
1694
+ }
1695
+
1696
+ // V5.0 activation event sink (Phase 41.0.x — UI-triggered events).
1697
+ // Body: { type: "onboarding_finished" | "file_edit_in_obsidian" |
1698
+ // "referral_mentioned" | "log_completed",
1699
+ // meta?: object }
1700
+ // Validates against the EVENT_TYPES list in activation-log.js;
1701
+ // unknown types return 400. Caps meta at 4KB to keep the log lean.
1702
+ if (pathname === "/api/event" && req.method === "POST") {
1703
+ try {
1704
+ const body = await readJsonBody(req, 4 * 1024);
1705
+ const type = String(body.type || "").trim();
1706
+ // Pass 3 #7 regression fix: arrays and primitives both fail
1707
+ // the "typeof === object && !null" intent. Explicitly reject
1708
+ // arrays so meta can't be a list (would silently break any
1709
+ // future `meta.field` lookup in readStats consumers).
1710
+ const meta = (body.meta && typeof body.meta === "object" && !Array.isArray(body.meta))
1711
+ ? body.meta : undefined;
1712
+ if (!type) return jsonResponse(res, 400, { ok: false, error: "type required" });
1713
+ const ok = logActivationEvent(type, meta);
1714
+ if (!ok) return jsonResponse(res, 400, { ok: false, error: `Unknown or rejected event type: ${type}` });
1715
+ return jsonResponse(res, 200, { ok: true, type });
1716
+ } catch (e) {
1717
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1718
+ }
1719
+ }
1720
+
1721
+ // Log an override decision into the pattern-manifest override_history.
1722
+ // Body: { what: "...", over: "...", reason: "..." }
1723
+ if (pathname === "/api/override" && req.method === "POST") {
1724
+ const body = await readJsonBody(req, 8 * 1024);
1725
+ const what = String(body.what || "").slice(0, 200).trim();
1726
+ const over = String(body.over || "").slice(0, 200).trim();
1727
+ const reason = String(body.reason || "").slice(0, 500).trim();
1728
+ if (!what || !reason) {
1729
+ return jsonResponse(res, 400, { ok: false, error: "what + reason required" });
1730
+ }
1731
+ try {
1732
+ const os = require("os");
1733
+ const manifestPath = path.join(os.homedir(), ".mover", "pattern-manifest.json");
1734
+ // Serialize concurrent override writes (audit pass 2 #6).
1735
+ // Without this lock, two overrides logged in quick succession
1736
+ // (e.g. user clicking twice while server slow) lose one entry.
1737
+ const entry = await withFileLock(manifestPath, () => {
1738
+ let manifest = {};
1739
+ // terra T-DW-6: pattern-manifest.json holds the whole friction cache
1740
+ // (single_test, sacrifice, recent_wins, override_history). Swallowing
1741
+ // a parse failure to {} would let ONE override click overwrite the
1742
+ // malformed file and wipe all of it. Only a missing file is empty; a
1743
+ // present-but-unparseable/wrong-shape file aborts (preserved to repair).
1744
+ try {
1745
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
1746
+ } catch (e) {
1747
+ if (e.code === "ENOENT") manifest = {};
1748
+ else throw Object.assign(new Error("pattern-manifest.json is unreadable or malformed; refusing to overwrite it"), { code: "EMANIFESTCORRUPT" });
1749
+ }
1750
+ if (manifest == null || typeof manifest !== "object" || Array.isArray(manifest)) {
1751
+ throw Object.assign(new Error("pattern-manifest.json is not a JSON object; refusing to overwrite it"), { code: "EMANIFESTCORRUPT" });
1752
+ }
1753
+ if (!Array.isArray(manifest.override_history)) manifest.override_history = [];
1754
+ const ent = {
1755
+ id: `ov-${Date.now()}`,
1756
+ timestamp: new Date().toISOString(),
1757
+ what, over, reason
1758
+ };
1759
+ manifest.override_history.push(ent);
1760
+ // Keep last 50
1761
+ if (manifest.override_history.length > 50) {
1762
+ manifest.override_history = manifest.override_history.slice(-50);
1763
+ }
1764
+ atomicWriteJson(manifestPath, manifest);
1765
+ return ent;
1766
+ });
1767
+ try { const newBuild = await safeBuild(); lastBuild = newBuild; } catch (_) {}
1768
+ return jsonResponse(res, 200, { ok: true, entry });
1769
+ } catch (e) {
1770
+ return jsonResponse(res, 500, { ok: false, error: e.message });
1771
+ }
1772
+ }
1773
+
1774
+ // Static file serving — ui/dist (the React UI) is the ONE canonical
1775
+ // dashboard. v6 serve-flip + Codex W4+W5 fix 1: there is NO legacy
1776
+ // fallback. A missing ui/dist/index.html means the UI was never built
1777
+ // — fail loudly with a 503 rather than silently serving a different,
1778
+ // retired dashboard, which is exactly the version-mixup this guards.
1779
+ const safePath = pathname.replace(/^\/+/, "").replace(/\.\.\//g, "");
1780
+ if (!uiDistAvailable()) {
1781
+ res.writeHead(503, { "Content-Type": "text/plain" });
1782
+ return res.end(
1783
+ "Mover Studio UI is not built (ui/dist missing). Build it with " +
1784
+ "`npm --prefix src/dashboard/ui run build`, then restart the server."
1785
+ );
1786
+ }
1787
+ let filePath;
1788
+ let isHtmlEntry = false;
1789
+
1790
+ // SPA routing — every non-asset path falls back to index.html.
1791
+ // Vite emits content-hashed assets under /assets/.
1792
+ const isAsset = /^assets\/|^sw\.js$|^workbox|^registerSW\.js$|\.(js|css|woff2?|svg|png|ico|json|map|webmanifest)$/.test(safePath);
1793
+ if (pathname === "/" || pathname === "/index.html" || !isAsset) {
1794
+ filePath = UI_DIST_INDEX;
1795
+ isHtmlEntry = true;
1796
+ } else {
1797
+ filePath = path.join(UI_DIST_DIR, safePath);
1798
+ // Containment guard: the string-strip above is single-pass, so split
1799
+ // sequences like "....//" re-form "../" after one replace and climb
1800
+ // out of ui/dist. Resolve, then verify the result is still inside the
1801
+ // dist root — the same realpath-contain contract the vault routes use,
1802
+ // which this static surface otherwise bypasses entirely.
1803
+ const rel = path.relative(UI_DIST_DIR, path.resolve(filePath));
1804
+ if (rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel)) {
1805
+ res.writeHead(404, { "Content-Type": "text/plain" });
1806
+ return res.end("Not found");
1807
+ }
1808
+ }
1809
+
1810
+ if (!fs.existsSync(filePath)) {
1811
+ // SPA fallback — unknown non-asset routes serve index.html; a
1812
+ // genuinely missing asset under /assets/ is a real 404.
1813
+ if (!isHtmlEntry) {
1814
+ filePath = UI_DIST_INDEX;
1815
+ isHtmlEntry = true;
1816
+ if (!fs.existsSync(filePath)) {
1817
+ res.writeHead(404, { "Content-Type": "text/plain" });
1818
+ return res.end("Not found");
1819
+ }
1820
+ } else {
1821
+ res.writeHead(404, { "Content-Type": "text/plain" });
1822
+ return res.end("Not found");
1823
+ }
1824
+ }
1825
+
1826
+ const stat = fs.statSync(filePath);
1827
+ if (stat.isDirectory()) {
1828
+ res.writeHead(403);
1829
+ return res.end("Forbidden");
1830
+ }
1831
+
1832
+ const ext = path.extname(filePath).toLowerCase();
1833
+ const mime = MIME[ext] || "application/octet-stream";
1834
+
1835
+ // Content-hashed assets in /assets/ are immutable — cache them hard.
1836
+ // HTML entry stays no-cache so version bumps land immediately.
1837
+ const isImmutable = /^\/assets\//.test(pathname);
1838
+ res.writeHead(200, {
1839
+ "Content-Type": mime,
1840
+ "Cache-Control": isImmutable
1841
+ ? "public, max-age=31536000, immutable"
1842
+ : "no-cache",
1843
+ "X-Mover-Os-Version": version || "unknown",
1844
+ "X-Mover-Os-Mode": "studio-react"
1845
+ });
1846
+ fs.createReadStream(filePath).pipe(res);
1847
+ } catch (e) {
1848
+ // A client sending unparseable JSON is a request problem, not a
1849
+ // server fault — answer 400, keep the generic 500 for real faults.
1850
+ if (e && e.message === "Invalid JSON body") {
1851
+ res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
1852
+ return res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" }));
1853
+ }
1854
+ res.writeHead(500, { "Content-Type": "text/plain" });
1855
+ res.end(`Server error: ${e.message}`);
1856
+ }
1857
+ });
1858
+
1859
+ function jsonResponse(res, code, obj) {
1860
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
1861
+ res.end(JSON.stringify(obj));
1862
+ }
1863
+
1864
+ // Stream `moveros <cmd> <args>` output back to the client over a
1865
+ // chunked HTTP response. Reuses the same SSE-style transport as agents.
1866
+ function runMoverosStream({ req, res, cmd, args, vault }) {
1867
+ res.writeHead(200, {
1868
+ "Content-Type": "text/plain; charset=utf-8",
1869
+ "Cache-Control": "no-cache",
1870
+ "Transfer-Encoding": "chunked"
1871
+ });
1872
+ const exe = process.argv[1] || "moveros";
1873
+ const child = spawn(process.execPath, [exe, cmd, ...args], {
1874
+ cwd: vault,
1875
+ env: { ...process.env, MOVER_DASHBOARD_INVOKED: "1" },
1876
+ stdio: ["ignore", "pipe", "pipe"]
1877
+ });
1878
+ child.stdout.on("data", (d) => res.write(d));
1879
+ child.stderr.on("data", (d) => res.write(d));
1880
+ child.on("error", (e) => { try { res.write(`\n[error] ${e.message}\n`); } catch (_) {} });
1881
+ child.on("close", (code) => {
1882
+ try { res.write(`\n[exit ${code}]\n`); } catch (_) {}
1883
+ res.end();
1884
+ });
1885
+ req.on("close", () => {
1886
+ try { child.kill("SIGTERM"); } catch (_) {}
1887
+ });
1888
+ }
1889
+
1890
+ // Cross-platform: open a command in the user's REAL terminal app.
1891
+ // macOS: AppleScript-driven Terminal.app. Windows: cmd /k. Linux: best-effort.
1892
+ //
1893
+ // Security: vault path (cwd) is user-controlled via ~/.mover/config.json.
1894
+ // Audit C1/C3 (2026-05-24): previous version escaped only `"` and embedded
1895
+ // the path directly into AppleScript / cmd.exe / bash -c strings, allowing
1896
+ // shell injection via a vault path containing backtick, $, ', |, &, etc.
1897
+ // Fix: build a single shell command via shellEscape() and pass it through
1898
+ // each platform's launcher using SAFE arg-passing (no `shell: true`, no
1899
+ // raw interpolation into shell strings).
1900
+ function shellEscape(s) {
1901
+ // POSIX single-quote wrap: '...' (literal), '\'' for embedded apostrophes
1902
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
1903
+ }
1904
+ function aplEscape(s) {
1905
+ // AppleScript double-quoted string: escape " and \
1906
+ return String(s).replace(/[\\"]/g, '\\$&');
1907
+ }
1908
+ function openInTerminal(cmdLine, cwd) {
1909
+ // cmdLine is always our own slash-command (e.g. "/morning"), but we
1910
+ // still treat it as untrusted to make this function safe by default.
1911
+ const cwdAbs = String(cwd || process.cwd());
1912
+ const cmdRaw = String(cmdLine);
1913
+ // Build a safely-escaped shell command line for any platform that
1914
+ // ultimately runs `bash -c`. POSIX-quoted both pieces.
1915
+ const posixCmd = `cd ${shellEscape(cwdAbs)} && ${cmdRaw}`;
1916
+
1917
+ if (process.platform === "darwin") {
1918
+ // AppleScript: embed the already-shell-escaped command as a string.
1919
+ // Two layers of escaping: shell (above) + AppleScript (here).
1920
+ const script = `tell application "Terminal" to do script "${aplEscape(posixCmd)}"`;
1921
+ const child = spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" });
1922
+ child.unref();
1923
+ spawn("osascript", ["-e", 'tell application "Terminal" to activate'], { detached: true, stdio: "ignore" }).unref();
1924
+ } else if (process.platform === "win32") {
1925
+ // Windows cmd.exe doesn't accept POSIX-quoted args. Drop `shell: true`
1926
+ // (which was passing through cmd.exe's metacharacter parsing) and use
1927
+ // explicit arg arrays + start's TitleArg + cwd via /D flag.
1928
+ // Note: Windows path quoting uses double-quotes; backtick/dollar are
1929
+ // literal in cmd. The remaining risk vector (& | > %) is suppressed
1930
+ // by passing pieces as separate args, not embedded in a /C string.
1931
+ // We still wrap cwd in literal quotes for `cd /d`.
1932
+ const winCwd = cwdAbs.replace(/"/g, ''); // strip embedded quotes; refuse rather than escape
1933
+ const startArgs = ["/c", "start", "", "cmd", "/k", `cd /d "${winCwd}" && ${cmdRaw}`];
1934
+ spawn("cmd", startArgs, { detached: true, stdio: "ignore" }).unref();
1935
+ } else {
1936
+ // Linux: prefer the launchers that accept --working-directory (no
1937
+ // shell interpolation needed for cwd). For x-terminal-emulator and
1938
+ // xterm fallbacks, pass the already-POSIX-escaped command.
1939
+ const candidates = [
1940
+ ["gnome-terminal", ["--working-directory", cwdAbs, "--", "bash", "-c", `${cmdRaw}; exec bash`]],
1941
+ ["konsole", ["--workdir", cwdAbs, "-e", "bash", "-c", `${cmdRaw}; exec bash`]],
1942
+ ["x-terminal-emulator", ["-e", "bash", "-c", `${posixCmd}; exec bash`]],
1943
+ ["xterm", ["-e", "bash", "-c", `${posixCmd}; exec bash`]],
1944
+ ];
1945
+ for (const [bin, args] of candidates) {
1946
+ try {
1947
+ const child = spawn(bin, args, { detached: true, stdio: "ignore" });
1948
+ child.unref();
1949
+ return;
1950
+ } catch (_) { /* try next */ }
1951
+ }
1952
+ throw new Error("No supported terminal emulator found (tried gnome-terminal, konsole, x-terminal-emulator, xterm)");
1953
+ }
1954
+ }
1955
+
1956
+ // Authoritative list of CLI commands the dashboard can run as buttons.
1957
+ // Keep in sync with install.js CLI_HANDLERS table.
1958
+ function getCliIndex() {
1959
+ return {
1960
+ cli: [
1961
+ { cmd: "pulse", label: "Open Studio", risk: "LOW", description: "Launch Mover Studio dashboard." },
1962
+ { cmd: "status", label: "Status", risk: "LOW", description: "Full system state: vault, agents, version." },
1963
+ { cmd: "doctor", label: "Doctor", risk: "LOW", description: "Health check across hooks, links, Engine files." },
1964
+ { cmd: "capture", label: "Capture", risk: "LOW", description: "Quick inbox capture from CLI." },
1965
+ { cmd: "who", label: "Who is...", risk: "LOW", description: "Entity lookup across Library/Entities." },
1966
+ { cmd: "diff", label: "Engine Diff", risk: "LOW", description: "Git history of Engine files." },
1967
+ { cmd: "replay", label: "Replay Session", risk: "LOW", description: "Print today's Session Log + progress." },
1968
+ { cmd: "context", label: "Agent Context", risk: "LOW", description: "Debug what each installed agent sees." },
1969
+ { cmd: "sync", label: "Sync Agents", risk: "LOW", description: "Re-sync rules/hooks/skills across installed agents." },
1970
+ { cmd: "settings", label: "Settings", risk: "LOW", description: "View or edit ~/.mover/config.json." },
1971
+ { cmd: "backup", label: "Backup", risk: "LOW", description: "Tar-snapshot ~/.mover/." },
1972
+ { cmd: "restore", label: "Restore", risk: "HIGH", description: "Restore from a Mover OS backup." },
1973
+ { cmd: "prayer", label: "Prayer Times", risk: "LOW", description: "Show today's prayer times (if configured)." },
1974
+ { cmd: "help", label: "Help", risk: "LOW", description: "Interactive guide to Mover OS." },
1975
+ { cmd: "install", label: "Install", risk: "MEDIUM", description: "Fresh install wizard." },
1976
+ { cmd: "update", label: "Update", risk: "MEDIUM", description: "Pull latest Mover OS payload + migrate." },
1977
+ { cmd: "uninstall", label: "Uninstall", risk: "HIGH", description: "Remove Mover OS from vault and agents." },
1978
+ ]
1979
+ };
1980
+ }
1981
+
1982
+ function tryListen(idx) {
1983
+ if (idx >= PORT_FALLBACKS.length) {
1984
+ return reject(new Error("All dashboard ports 3737-3740 in use. Try `lsof -i :3737` to find the process."));
1985
+ }
1986
+ const port = PORT_FALLBACKS[idx];
1987
+ server.once("error", err => {
1988
+ if (err.code === "EADDRINUSE") {
1989
+ server.close();
1990
+ tryListen(idx + 1);
1991
+ } else {
1992
+ reject(err);
1993
+ }
1994
+ });
1995
+ server.listen(port, "127.0.0.1", async () => {
1996
+ const url = `http://127.0.0.1:${port}`;
1997
+ // Initial build
1998
+ try {
1999
+ lastBuild = await safeBuild();
2000
+ } catch (e) {
2001
+ lastError = e.message;
2002
+ console.error(`[mover-dashboard] initial build failed: ${e.message}`);
2003
+ }
2004
+ console.log(`\n Mover OS Dashboard\n ${url}\n`);
2005
+ if (openBrowser) tryOpenBrowser(url);
2006
+ // F21 — register the running marker so agent hooks can surface the URL
2007
+ writeRunningMarker(url);
2008
+ // Phase 41.0.7 — expose the per-process CSRF token to local-only
2009
+ // external scripts (workflow Bash steps, Stop hooks, capture wire-up)
2010
+ // so they can POST /api/event for log_completed, referral_mentioned,
2011
+ // etc. File perms 0o600 so other users can't read it. Loopback-only
2012
+ // server means this is the same trust boundary as the file system.
2013
+ try {
2014
+ const os = require("os");
2015
+ const tokenDir = path.join(os.homedir(), ".mover", "dashboard");
2016
+ fs.mkdirSync(tokenDir, { recursive: true });
2017
+ const tokenFile = path.join(tokenDir, ".token");
2018
+ fs.writeFileSync(tokenFile, JSON.stringify({ token: STUDIO_TOKEN, url, pid: process.pid, startedAt: new Date().toISOString() }, null, 2), { encoding: "utf8", mode: 0o600 });
2019
+ } catch (e) {
2020
+ console.warn(`[mover-dashboard] could not write token file: ${e.message}`);
2021
+ }
2022
+ // T245 — periodic rebuild. Previously the server only rebuilt
2023
+ // state.json on (a) initial start, (b) write endpoints (config
2024
+ // save, checkbox toggle, capture, snooze, override), and (c)
2025
+ // explicit /api/refresh POST. With no writes for an hour the
2026
+ // state goes hours-stale — user reports "NextSteps are severely
2027
+ // outdated" because the parser snapshot was hours old. Now: a
2028
+ // 60s heartbeat rebuild keeps the surface live. safeBuild()'s
2029
+ // mutex prevents overlap with write-triggered rebuilds.
2030
+ const REBUILD_INTERVAL_MS = 60 * 1000;
2031
+ const rebuildTimer = setInterval(async () => {
2032
+ try {
2033
+ const fresh = await safeBuild();
2034
+ if (fresh) lastBuild = fresh;
2035
+ } catch (e) {
2036
+ console.warn(`[mover-dashboard] periodic rebuild failed: ${e.message}`);
2037
+ }
2038
+ }, REBUILD_INTERVAL_MS);
2039
+ // Keep the timer from blocking process exit on SIGINT.
2040
+ if (typeof rebuildTimer.unref === "function") rebuildTimer.unref();
2041
+
2042
+ // SIGINT cleanup
2043
+ const shutdown = async () => {
2044
+ console.log("\n Shutting down dashboard...");
2045
+ clearInterval(rebuildTimer);
2046
+ // Wait for agent children to actually die before the hard-exit timer
2047
+ // below (Codex MF1: exit fired at 2s but SIGKILL escalation at 3s, so
2048
+ // a stubborn child could survive). shutdownAll resolves on child close.
2049
+ try { await agentSessions.shutdownAll(); } catch (_) { /* best-effort */ }
2050
+ try { await runRegistry.shutdownAll(); } catch (_) { /* best-effort */ }
2051
+ try { approvalRegistry.stop(); } catch (_) { /* best-effort */ }
2052
+ clearRunningMarker();
2053
+ // Clear the token file so stale scripts don't try to POST against
2054
+ // a dead server.
2055
+ try {
2056
+ const os = require("os");
2057
+ const tokenFile = path.join(os.homedir(), ".mover", "dashboard", ".token");
2058
+ if (fs.existsSync(tokenFile)) fs.unlinkSync(tokenFile);
2059
+ } catch (_) { /* best-effort */ }
2060
+ server.close(() => process.exit(0));
2061
+ setTimeout(() => process.exit(0), 2000);
2062
+ };
2063
+ process.once("SIGINT", shutdown);
2064
+ process.once("SIGTERM", shutdown);
2065
+ resolve({ server, port, url });
2066
+ });
2067
+ }
2068
+
2069
+ tryListen(0);
2070
+ });
2071
+ }
2072
+
2073
+ // F21 — write the auto-running marker on startup so the dashboard-url hook
2074
+ // can find it and surface the dashboard URL to agents on key events.
2075
+ function writeRunningMarker(url) {
2076
+ try {
2077
+ const os = require("os");
2078
+ const dir = path.join(os.homedir(), ".mover", "dashboard");
2079
+ fs.mkdirSync(dir, { recursive: true });
2080
+ fs.writeFileSync(path.join(dir, "auto-running"), url, "utf8");
2081
+ } catch (_) { /* non-fatal */ }
2082
+ }
2083
+ function clearRunningMarker() {
2084
+ try {
2085
+ const os = require("os");
2086
+ const p = path.join(os.homedir(), ".mover", "dashboard", "auto-running");
2087
+ if (fs.existsSync(p)) fs.unlinkSync(p);
2088
+ } catch (_) {}
2089
+ }
2090
+
2091
+ function tryOpenBrowser(url) {
2092
+ const platform = process.platform;
2093
+ let cmd, args;
2094
+ if (platform === "darwin") {
2095
+ cmd = "open"; args = [url];
2096
+ } else if (platform === "win32") {
2097
+ cmd = "cmd"; args = ["/c", "start", "", url];
2098
+ } else {
2099
+ cmd = "xdg-open"; args = [url];
2100
+ }
2101
+ try {
2102
+ execFile(cmd, args, () => { /* swallow */ });
2103
+ } catch {
2104
+ // Fall through silently — URL was already printed
2105
+ }
2106
+ }
2107
+
2108
+ function readJsonBody(req, maxBytes) {
2109
+ return new Promise((resolve, reject) => {
2110
+ // Audit H3: guard against double-resolution. The previous version
2111
+ // called reject() then req.destroy(), which emits 'error' → second
2112
+ // reject. Node ignores native-promise double-rejects but the second
2113
+ // event arrives via an already-resolved Promise's catch chain.
2114
+ let done = false;
2115
+ const settle = (fn, val) => { if (done) return; done = true; fn(val); };
2116
+ let raw = "";
2117
+ req.setEncoding("utf8");
2118
+ req.on("data", chunk => {
2119
+ raw += chunk;
2120
+ if (raw.length > maxBytes) {
2121
+ settle(reject, new Error("Request body too large"));
2122
+ try { req.destroy(); } catch (_) {}
2123
+ }
2124
+ });
2125
+ req.on("end", () => {
2126
+ try {
2127
+ settle(resolve, raw ? JSON.parse(raw) : {});
2128
+ } catch {
2129
+ settle(reject, new Error("Invalid JSON body"));
2130
+ }
2131
+ });
2132
+ req.on("error", (e) => settle(reject, e));
2133
+ });
2134
+ }
2135
+
2136
+ // ── version / update check (server side, for the dashboard "Update ready"
2137
+ // prompt) ─────────────────────────────────────────────────────────────────
2138
+ // Cached, ASYNC npm check (execFile, never execSync — a sync npm call would
2139
+ // block the single-threaded server for seconds). Returns last-known instantly;
2140
+ // refreshes in the background at most every 30 min. Offline → latest stays null
2141
+ // → updateAvailable false (graceful). The free/major split is the v4→v5 seam:
2142
+ // a same-major bump (v4.x → v4.y) is a FREE update; a new major (v4 → v5) is a
2143
+ // paid upgrade, surfaced differently and gated server-side at /api/download.
2144
+ let _verCache = { at: 0, latest: null, checking: false };
2145
+ function _verParts(s) { return String(s == null ? "" : s).split(".").map((n) => parseInt(n, 10) || 0); }
2146
+ function compareSemver(a, b) {
2147
+ const A = _verParts(a), B = _verParts(b);
2148
+ for (let i = 0; i < Math.max(A.length, B.length); i++) { const x = A[i] || 0, y = B[i] || 0; if (x !== y) return x > y ? 1 : -1; }
2149
+ return 0;
2150
+ }
2151
+ function sameMajor(a, b) { return (_verParts(a)[0] || 0) === (_verParts(b)[0] || 0); }
2152
+ function npmLatestVersion() {
2153
+ return new Promise((resolve) => {
2154
+ try {
2155
+ require("child_process").execFile("npm", ["view", "mover-os", "version"], { timeout: 8000 }, (err, stdout) => {
2156
+ resolve(err ? null : (String(stdout).trim() || null));
2157
+ });
2158
+ } catch (_) { resolve(null); }
2159
+ });
2160
+ }
2161
+ function refreshVersionCache() {
2162
+ if (_verCache.checking) return;
2163
+ if (_verCache.latest != null && Date.now() - _verCache.at < 30 * 60 * 1000) return; // still fresh
2164
+ _verCache.checking = true;
2165
+ npmLatestVersion().then((latest) => { _verCache = { at: Date.now(), latest, checking: false }; })
2166
+ .catch(() => { _verCache.checking = false; });
2167
+ }
2168
+ function versionInfo(current) {
2169
+ refreshVersionCache(); // fire-and-forget; this call uses whatever is cached
2170
+ const cur = String(current || "unknown");
2171
+ const latest = _verCache.latest;
2172
+ let updateAvailable = false, isFreeUpgrade = false, major = false;
2173
+ if (latest && cur !== "unknown" && compareSemver(latest, cur) > 0) {
2174
+ updateAvailable = true;
2175
+ isFreeUpgrade = sameMajor(cur, latest); // v4.x → v4.y = free for existing buyers
2176
+ major = !isFreeUpgrade; // v4 → v5 = new major (paid; gating deferred to v5 launch)
2177
+ }
2178
+ return { current: cur, latest, updateAvailable, isFreeUpgrade, major, checking: _verCache.checking };
2179
+ }
2180
+
2181
+ function runAgentStream({ req, res, body, vault, runRegistry }) {
2182
+ // #112: per-run choice first (body.model/effort/approvalMode from the Run
2183
+ // picker); omitted fields fall back to config providerPrefs so the fixed
2184
+ // call sites (Say-it, X-Ray) inherit the user's choice without client edits.
2185
+ let prefs = {};
2186
+ let cfgAgent = null;
2187
+ try {
2188
+ const cfg = require("./lib/config-parser").read();
2189
+ prefs = (cfg && cfg.providerPrefs) || {};
2190
+ cfgAgent = (cfg && cfg.preferredAgent) || null;
2191
+ } catch (_) {}
2192
+ const agent = String(body.agent || cfgAgent || "claude-code");
2193
+ const prompt = String(body.prompt || "").trim();
2194
+ const workflow = String(body.workflow || "").trim();
2195
+ const canonical = AGENT_ALIASES[agent] || agent;
2196
+ const perProvider = (prefs && prefs[canonical]) || {};
2197
+ const command = agentCommand(agent, {
2198
+ model: body.model != null ? body.model : perProvider.model,
2199
+ effort: body.effort != null ? body.effort : perProvider.effort,
2200
+ approvalMode: body.approvalMode != null ? body.approvalMode : perProvider.approvalMode,
2201
+ });
2202
+
2203
+ if (!prompt && !workflow) {
2204
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
2205
+ return res.end("Missing prompt or workflow.\n");
2206
+ }
2207
+ if (!command) {
2208
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
2209
+ return res.end(`Unsupported agent: ${agent}\n`);
2210
+ }
2211
+
2212
+ // R2: a rejected model/effort must refuse loudly, never silently run the
2213
+ // provider default while the user believes their pick applied (cleanToken
2214
+ // drops strings with spaces or shell metacharacters; the old behavior was a
2215
+ // quiet fallback the run preamble alone had to expose).
2216
+ const apChk = command.applied || {};
2217
+ const reqModel = String(body.model != null ? body.model : (perProvider.model || "")).trim();
2218
+ if (reqModel && !apChk.model) {
2219
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
2220
+ return res.end(`Model "${reqModel}" was rejected: letters, digits, . _ : - only, no spaces. The run was not started.\n`);
2221
+ }
2222
+ const reqEffort = String(body.effort != null ? body.effort : (perProvider.effort || "")).trim();
2223
+ if (reqEffort && !apChk.effort && (AGENT_EFFORTS[canonical] || []).length) {
2224
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
2225
+ return res.end(`Effort "${reqEffort}" is not one of: ${AGENT_EFFORTS[canonical].join(", ")}. The run was not started.\n`);
2226
+ }
2227
+
2228
+ const repoRoot = path.join(__dirname, "..", "..");
2229
+ const finalPrompt = buildAgentPrompt({ prompt, workflow, vault, repoRoot });
2230
+
2231
+ // The run lives in the RUN REGISTRY, decoupled from this HTTP request. If the
2232
+ // client navigates away or refreshes, the child keeps running and its output
2233
+ // is retained, so the Run tab can reattach (GET /api/runs/stream) — this is
2234
+ // what makes Run persistent like Chat. cwd = the VAULT (Engine/PARA context,
2235
+ // not the bundle dev root); MOVER_STUDIO_RUN=1 mutes the lifecycle hooks
2236
+ // (Stop log-cascade, engine-auto-commit, engine-health) — see src/hooks/*.sh.
2237
+ // The preamble lines are seeded into the buffer (not just written to res) so a
2238
+ // reattach replays an identical view, and the existing client keeps parsing
2239
+ // the exit tail. Audit M10: cwd is aliased — never leak the absolute repo path.
2240
+ const ap = command.applied || {};
2241
+ const tune = [ap.model ? `model=${ap.model}` : "", ap.effort ? `effort=${ap.effort}` : "", ap.approvalMode && ap.approvalMode !== "yolo" ? `approval=${ap.approvalMode}` : ""].filter(Boolean).join(" ");
2242
+ const preamble = `[mover-studio] agent=${agent}${tune ? " " + tune : ""}\n[mover-studio] cwd=<bundle-root>\n\n`;
2243
+ let run;
2244
+ try {
2245
+ run = runRegistry.start({
2246
+ agent,
2247
+ label: (workflow || prompt || "task").slice(0, 200),
2248
+ cmd: command.cmd,
2249
+ args: command.args(finalPrompt),
2250
+ stdin: command.stdin,
2251
+ finalPrompt,
2252
+ cwd: vault || repoRoot,
2253
+ env: { ...process.env, MOVER_STUDIO_RUN: "1" },
2254
+ preamble,
2255
+ });
2256
+ } catch (e) {
2257
+ if (e && e.code === "RUN_LIMIT") return jsonResponse(res, 429, { ok: false, error: e.message });
2258
+ throw e;
2259
+ }
2260
+
2261
+ res.writeHead(200, {
2262
+ "Content-Type": "text/plain; charset=utf-8",
2263
+ "Cache-Control": "no-cache",
2264
+ "X-Accel-Buffering": "no",
2265
+ "X-Mover-Run-Id": run.id,
2266
+ });
2267
+
2268
+ // Stream this run to the response by subscribing (replay buffer + live). On
2269
+ // client disconnect we ONLY unsubscribe — the run is not killed (an explicit
2270
+ // stop is POST /api/runs/stop). Same handler shape as /api/runs/stream.
2271
+ // sol batch #1: same backpressure guard as /api/runs/stream — drop a stalled
2272
+ // reader whose socket buffer exceeds the cap rather than queue without bound.
2273
+ let unsub = () => {};
2274
+ unsub = runRegistry.subscribe(run.id, (ev) => {
2275
+ if (ev.type === "replay" || ev.type === "chunk") {
2276
+ try {
2277
+ const ok = res.write(ev.text);
2278
+ if (!ok && res.writableLength > 8 * 1024 * 1024) { try { unsub(); } catch (_) {} try { res.destroy(); } catch (_) {} }
2279
+ } catch (_) {}
2280
+ } else if (ev.type === "end") { try { res.end(); } catch (_) {} }
2281
+ });
2282
+ req.on("close", () => { try { unsub(); } catch (_) {} });
2283
+ }
2284
+
2285
+ // #112: argv assembly moved to lib/agent-command.js (pure, unit-tested in
2286
+ // test/agent-command.test.mjs) so model/effort/approval flags are one matrix.
2287
+ const { agentCommand, ALIASES: AGENT_ALIASES, EFFORTS: AGENT_EFFORTS } = require("./lib/agent-command");
2288
+
2289
+ function buildAgentPrompt({ prompt, workflow, vault, repoRoot }) {
2290
+ const task = workflow
2291
+ ? `Run or simulate the Mover OS workflow "${workflow}" from this local project.`
2292
+ : prompt;
2293
+ return [
2294
+ "You are being invoked from Mover Studio, a local browser UI for Mover OS.",
2295
+ `Workspace: ${repoRoot}`,
2296
+ `Vault: ${vault || "unknown"}`,
2297
+ "Use local files as source of truth. Be concise, cite files when possible, and do not make network calls unless the workflow genuinely requires them.",
2298
+ "",
2299
+ workflow ? `${task}\nUser context/prompt: ${prompt || "No additional prompt."}` : task
2300
+ ].join("\n");
2301
+ }
2302
+
2303
+ // F37 — OG Image SVG renderer. Pure-SVG (no canvas dep) so it works on
2304
+ // any platform without extra binaries. Returns an SVG string sized for
2305
+ // the requested format. Modern social platforms render SVG og:images
2306
+ // directly (Twitter X, Discord, Slack); older ones may not.
2307
+ function renderOgSvg(state, opts = {}) {
2308
+ const { format = "default", metric = "drift", title } = opts;
2309
+ const dims = {
2310
+ default: { w: 1200, h: 630 },
2311
+ square: { w: 1080, h: 1080 },
2312
+ story: { w: 1080, h: 1920 },
2313
+ };
2314
+ const { w, h } = dims[format] || dims.default;
2315
+
2316
+ const drift = state.driftScore || {};
2317
+ const streak = state.streak || {};
2318
+ const patterns = (state.activeContext && state.activeContext.activePatterns) || [];
2319
+ const singleTest = (state.strategy && state.strategy.singleTest) || (state.activeContext && state.activeContext.singleTest && state.activeContext.singleTest.text) || "Mover OS — your AI that remembers";
2320
+ const version = state.version || "4.7";
2321
+
2322
+ const headline = title || singleTest;
2323
+ const escaped = String(headline).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
2324
+
2325
+ // Pick featured value
2326
+ let bigValue = "—";
2327
+ let bigLabel = "DRIFT SCORE";
2328
+ let bigSub = "/100";
2329
+ if (metric === "streak") {
2330
+ bigValue = String(streak.currentStreak ?? streak.streakThroughYesterday ?? 0);
2331
+ bigLabel = "DAILY NOTE STREAK";
2332
+ bigSub = "DAYS";
2333
+ } else if (metric === "patterns") {
2334
+ bigValue = String(patterns.length);
2335
+ bigLabel = "ACTIVE PATTERNS";
2336
+ bigSub = "tracked";
2337
+ } else {
2338
+ bigValue = String(drift.value ?? "—");
2339
+ }
2340
+
2341
+ const isStory = format === "story";
2342
+ const padX = isStory ? 80 : 80;
2343
+ const padY = isStory ? 200 : 80;
2344
+ const bigSize = isStory ? 320 : 200;
2345
+ const bigY = isStory ? h * 0.4 : h * 0.55;
2346
+
2347
+ return `<?xml version="1.0" encoding="UTF-8"?>
2348
+ <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
2349
+ <defs>
2350
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
2351
+ <stop offset="0%" stop-color="#0a0a0c" />
2352
+ <stop offset="100%" stop-color="#141416" />
2353
+ </linearGradient>
2354
+ <radialGradient id="halo" cx="80%" cy="20%" r="60%">
2355
+ <stop offset="0%" stop-color="#d97a3c" stop-opacity="0.18" />
2356
+ <stop offset="60%" stop-color="#d97a3c" stop-opacity="0.05" />
2357
+ <stop offset="100%" stop-color="#d97a3c" stop-opacity="0" />
2358
+ </radialGradient>
2359
+ <pattern id="grid" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
2360
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.025)" stroke-width="1"/>
2361
+ </pattern>
2362
+ </defs>
2363
+ <rect width="${w}" height="${h}" fill="url(#bg)"/>
2364
+ <rect width="${w}" height="${h}" fill="url(#grid)"/>
2365
+ <rect width="${w}" height="${h}" fill="url(#halo)"/>
2366
+
2367
+ <!-- Eyebrow -->
2368
+ <text x="${padX}" y="${padY}" font-family="ui-monospace, monospace" font-size="${isStory ? 22 : 18}" fill="#d97a3c" letter-spacing="4">
2369
+ MOVER · OS · PUBLIC PROOF
2370
+ </text>
2371
+
2372
+ <!-- Headline -->
2373
+ <text x="${padX}" y="${padY + (isStory ? 70 : 60)}" font-family="Inter, sans-serif" font-size="${isStory ? 56 : 44}" fill="#f5f5f5" font-weight="500">
2374
+ <tspan x="${padX}" dy="0">${escaped.slice(0, isStory ? 32 : 50)}</tspan>
2375
+ ${escaped.length > (isStory ? 32 : 50) ? `<tspan x="${padX}" dy="${isStory ? 68 : 56}">${escaped.slice(isStory ? 32 : 50, isStory ? 64 : 100)}</tspan>` : ''}
2376
+ </text>
2377
+
2378
+ <!-- Big metric -->
2379
+ <text x="${padX}" y="${bigY}" font-family="ui-monospace, monospace" font-size="${bigSize}" fill="#f5f5f5" font-weight="400" letter-spacing="-6">
2380
+ ${bigValue}
2381
+ </text>
2382
+ <text x="${padX + (bigValue.length * (bigSize * 0.55))}" y="${bigY}" font-family="ui-monospace, monospace" font-size="${bigSize * 0.18}" fill="#828282">
2383
+ ${bigSub}
2384
+ </text>
2385
+
2386
+ <!-- Big metric label -->
2387
+ <text x="${padX}" y="${bigY + (bigSize * 0.18)}" font-family="ui-monospace, monospace" font-size="${isStory ? 22 : 18}" fill="#a8a8a8" letter-spacing="4">
2388
+ ${bigLabel}
2389
+ </text>
2390
+
2391
+ <!-- Watermark -->
2392
+ <text x="${padX}" y="${h - 40}" font-family="ui-monospace, monospace" font-size="${isStory ? 18 : 14}" fill="#828282" letter-spacing="3">
2393
+ LOCAL · SCREEN OFF · TELEMETRY NEVER · v${version}
2394
+ </text>
2395
+
2396
+ <!-- Corner crosshairs (top-right + bottom-left) -->
2397
+ <path d="M ${w - 40} 20 L ${w - 20} 20 L ${w - 20} 40" stroke="#d97a3c" stroke-width="1.5" fill="none"/>
2398
+ <path d="M 20 ${h - 40} L 20 ${h - 20} L 40 ${h - 20}" stroke="#d97a3c" stroke-width="1.5" fill="none"/>
2399
+ </svg>`;
2400
+ }
2401
+
2402
+ module.exports = { startServer, mergeJournalSection, sanitizeJournalLine, writeNoteAtomic, isKnownWorkflow };