mover-os 4.7.6 → 4.7.8

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 (107) hide show
  1. package/install.js +763 -70
  2. package/package.json +15 -3
  3. package/src/dashboard/build.js +1503 -0
  4. package/src/dashboard/dashboard.js +276 -0
  5. package/src/dashboard/index.js +319 -0
  6. package/src/dashboard/lib/activation-log.js +297 -0
  7. package/src/dashboard/lib/active-context-parser.js +177 -0
  8. package/src/dashboard/lib/agent-command.js +93 -0
  9. package/src/dashboard/lib/agent-detect.js +255 -0
  10. package/src/dashboard/lib/agent-detector.js +92 -0
  11. package/src/dashboard/lib/agent-session.js +462 -0
  12. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  13. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  14. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  15. package/src/dashboard/lib/config-parser.js +109 -0
  16. package/src/dashboard/lib/connect-recommender.js +131 -0
  17. package/src/dashboard/lib/correlations-parser.js +231 -0
  18. package/src/dashboard/lib/daily-note-resolver.js +211 -0
  19. package/src/dashboard/lib/date-utils.js +35 -0
  20. package/src/dashboard/lib/distribution-parser.js +78 -0
  21. package/src/dashboard/lib/dossier-parser.js +64 -0
  22. package/src/dashboard/lib/drift-history.js +83 -0
  23. package/src/dashboard/lib/drift-score.js +119 -0
  24. package/src/dashboard/lib/engine-health.js +170 -0
  25. package/src/dashboard/lib/engine-writer.js +1685 -0
  26. package/src/dashboard/lib/execution-plan.js +125 -0
  27. package/src/dashboard/lib/experiments-parser.js +429 -0
  28. package/src/dashboard/lib/feed-parser.js +294 -0
  29. package/src/dashboard/lib/forked-future.js +60 -0
  30. package/src/dashboard/lib/goal-forecast.js +304 -0
  31. package/src/dashboard/lib/goals-parser.js +67 -0
  32. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  33. package/src/dashboard/lib/hook-activity.js +48 -0
  34. package/src/dashboard/lib/hook-indexer.js +169 -0
  35. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  36. package/src/dashboard/lib/identity-parser.js +85 -0
  37. package/src/dashboard/lib/ingestion.js +418 -0
  38. package/src/dashboard/lib/library-indexer-v2.js +212 -0
  39. package/src/dashboard/lib/library-indexer.js +105 -0
  40. package/src/dashboard/lib/log-activation.sh +61 -0
  41. package/src/dashboard/lib/memory-curator.js +97 -0
  42. package/src/dashboard/lib/memory-gardener.js +177 -0
  43. package/src/dashboard/lib/memory-gepa.js +102 -0
  44. package/src/dashboard/lib/memory-index.js +470 -0
  45. package/src/dashboard/lib/memory-rerank.js +72 -0
  46. package/src/dashboard/lib/memory-text.js +136 -0
  47. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  48. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  49. package/src/dashboard/lib/onboarding-forge.js +70 -0
  50. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  51. package/src/dashboard/lib/override-summary.js +73 -0
  52. package/src/dashboard/lib/paths.js +192 -0
  53. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  54. package/src/dashboard/lib/phantom-strategy.js +129 -0
  55. package/src/dashboard/lib/project-scanner.js +121 -0
  56. package/src/dashboard/lib/promise-wall.js +88 -0
  57. package/src/dashboard/lib/record-score.js +173 -0
  58. package/src/dashboard/lib/redaction.js +140 -0
  59. package/src/dashboard/lib/refusal-parser.js +44 -0
  60. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  61. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  62. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  63. package/src/dashboard/lib/run-registry.js +226 -0
  64. package/src/dashboard/lib/safe-write.js +63 -0
  65. package/src/dashboard/lib/session-log-parser.js +145 -0
  66. package/src/dashboard/lib/session-time-parser.js +158 -0
  67. package/src/dashboard/lib/skill-index.js +171 -0
  68. package/src/dashboard/lib/skill-indexer.js +118 -0
  69. package/src/dashboard/lib/skill-recommender.js +689 -0
  70. package/src/dashboard/lib/strategy-parser.js +245 -0
  71. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  72. package/src/dashboard/lib/streak-parser.js +95 -0
  73. package/src/dashboard/lib/suggested-now.js +254 -0
  74. package/src/dashboard/lib/tool-awareness.js +125 -0
  75. package/src/dashboard/lib/transcript-parser.js +331 -0
  76. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  77. package/src/dashboard/lib/view-generator.js +163 -0
  78. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  79. package/src/dashboard/lib/walkthrough-script.js +140 -0
  80. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  81. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  82. package/src/dashboard/server.js +2024 -0
  83. package/src/dashboard/shortcut.js +0 -0
  84. package/src/dashboard/static/setup-poc.html +306 -0
  85. package/src/dashboard/static/walkthrough-poc.html +580 -0
  86. package/src/dashboard/styles.css +1201 -0
  87. package/src/dashboard/templates/index.html +278 -0
  88. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  89. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  90. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  91. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  92. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
  93. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
  94. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
  95. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  96. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  97. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  98. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  99. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  100. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  101. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  102. package/src/dashboard/ui/dist/icon.svg +4 -0
  103. package/src/dashboard/ui/dist/index.html +18 -0
  104. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  105. package/src/dashboard/ui/dist/registerSW.js +1 -0
  106. package/src/dashboard/ui/dist/sw.js +1 -0
  107. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,294 @@
1
+ "use strict";
2
+
3
+ // feed-parser.js — the live work feed. A plain sentence per real work event.
4
+ //
5
+ // SOURCE (live, local, never telemetry):
6
+ // 1. ~/.claude/history.jsonl — one line per real prompt the user typed into a
7
+ // Claude Code session: { display, timestamp(ms), project, sessionId }.
8
+ // Each line IS a real work event (a thing that actually happened in a real
9
+ // session under this vault). We tail the last N and turn each into a plain
10
+ // sentence.
11
+ // 2. ~/.claude/sessions/*.json — live session descriptors:
12
+ // { pid, sessionId, cwd, startedAt(ms), updatedAt(ms), status, version,
13
+ // name, ... }. These tell us which sessions are open right now.
14
+ //
15
+ // SCOPING (Codex must-fix / v6 amendment fix 8 nice-to-have): resolve the
16
+ // project slug AND each session cwd to a REALPATH and test containment against
17
+ // THIS vault's realpath — NOT a loose `project` string match. A symlinked or
18
+ // case-differing vault path still scopes correctly; an unrelated project that
19
+ // happens to share a path prefix string does not leak in.
20
+ //
21
+ // HARD HONESTY (v6-PLAN-AMENDMENTS fix 8 + Fable Finding C — binding):
22
+ // - NO `cost` field. There is no per-step token source. We do not invent one.
23
+ // - NO `diff` field. There is no per-step diff source. The only file signal we
24
+ // emit is, at most, a real path that already appears in the user's own text.
25
+ // Each event is a plain sentence + ids. Nothing more.
26
+ //
27
+ // FALSE-ZERO (T261 discipline): if nothing real is happening under this vault —
28
+ // no in-vault history events AND no in-vault sessions — return
29
+ // { available:false, events:[], activeSessions:[], caption:'' }.
30
+ // We never fabricate scripted demo steps to fill an empty feed.
31
+
32
+ const fs = require("fs");
33
+ const os = require("os");
34
+ const path = require("path");
35
+ const { redactText } = require("./redaction");
36
+
37
+ const HISTORY_FILE = path.join(os.homedir(), ".claude", "history.jsonl");
38
+ const SESSIONS_DIR = path.join(os.homedir(), ".claude", "sessions");
39
+
40
+ // A session counts as "active" only if its descriptor was touched recently.
41
+ // Claude writes `updatedAt` on every turn; a stale file is a dead process whose
42
+ // descriptor was never cleaned up. 30 min is generous for "this is live".
43
+ const ACTIVE_WINDOW_MS = 30 * 60 * 1000;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // realpath-based vault scoping
47
+ // ---------------------------------------------------------------------------
48
+
49
+ // Resolve a path to its realpath when it exists on disk; fall back to a
50
+ // lexical normalize (resolves `..`/`.` without touching disk) so a recorded
51
+ // path for a since-deleted dir still scopes sanely. Never throws.
52
+ function resolveReal(p) {
53
+ if (!p || typeof p !== "string") return null;
54
+ try {
55
+ return fs.realpathSync.native(p);
56
+ } catch (_) {
57
+ try {
58
+ return path.resolve(p);
59
+ } catch (_) {
60
+ return null;
61
+ }
62
+ }
63
+ }
64
+
65
+ // True when `child` is the same path as, or nested under, `root`. Compares on a
66
+ // path-segment boundary (root + sep) so `/a/Mover OS` does NOT match
67
+ // `/a/Mover OS Bundle 2`. Case-insensitive on darwin/win32 where the FS is.
68
+ function isUnder(child, root) {
69
+ if (!child || !root) return false;
70
+ const ci = process.platform === "darwin" || process.platform === "win32";
71
+ let c = ci ? child.toLowerCase() : child;
72
+ let r = ci ? root.toLowerCase() : root;
73
+ if (c === r) return true;
74
+ const withSep = r.endsWith(path.sep) ? r : r + path.sep;
75
+ return c.startsWith(withSep);
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // event classification — plain, honest, source-backed
80
+ // ---------------------------------------------------------------------------
81
+
82
+ // A leading slash means the user invoked a workflow / command — a deliberate
83
+ // "move". Everything else is a typed prompt to the agent terminal.
84
+ function classify(display) {
85
+ const text = (display || "").trim();
86
+ if (text.startsWith("/")) {
87
+ // /plan-tomorrow, /log, /compact ... → kind is the bare command token.
88
+ const cmd = text.slice(1).split(/[\s\n]/, 1)[0].toLowerCase();
89
+ return { lane: "move", kind: cmd ? `/${cmd}` : "command" };
90
+ }
91
+ return { lane: "term", kind: "prompt" };
92
+ }
93
+
94
+ // Turn a raw multi-line prompt into one clean redacted sentence. We keep the
95
+ // user's own words (that's the honest signal) — only collapse whitespace, trim
96
+ // to a readable length, and run the privacy scrubber over the result.
97
+ function toSentence(display) {
98
+ let t = (display || "").replace(/\s+/g, " ").trim();
99
+ if (!t) return "";
100
+ // Trim to a feed-readable length on a word boundary; mark truncation with an
101
+ // ellipsis so it never reads as a fabricated complete thought.
102
+ const MAX = 180;
103
+ if (t.length > MAX) {
104
+ const cut = t.slice(0, MAX);
105
+ const lastSpace = cut.lastIndexOf(" ");
106
+ t = (lastSpace > 80 ? cut.slice(0, lastSpace) : cut).trim() + "…";
107
+ }
108
+ return redactText(t).text;
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // session descriptors
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function readSessions(vaultReal) {
116
+ let names;
117
+ try {
118
+ names = fs.readdirSync(SESSIONS_DIR);
119
+ } catch (_) {
120
+ return [];
121
+ }
122
+ const now = Date.now();
123
+ const sessions = [];
124
+ for (const name of names) {
125
+ if (!name.endsWith(".json")) continue;
126
+ let desc;
127
+ try {
128
+ desc = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, name), "utf8"));
129
+ } catch (_) {
130
+ continue; // skip mid-write / corrupt descriptor
131
+ }
132
+ if (!desc || typeof desc !== "object") continue;
133
+ const cwdReal = resolveReal(desc.cwd);
134
+ const isThisVault = isUnder(cwdReal, vaultReal);
135
+ const updatedAt = Number(desc.updatedAt) || Number(desc.startedAt) || 0;
136
+ // status: trust the descriptor when present; otherwise infer from recency.
137
+ // A descriptor with no `status`/`updatedAt` that hasn't been touched in the
138
+ // active window is a leftover from a closed process — mark it "idle".
139
+ let status = typeof desc.status === "string" && desc.status ? desc.status : null;
140
+ const fresh = updatedAt > 0 && now - updatedAt <= ACTIVE_WINDOW_MS;
141
+ if (!status) status = fresh ? "active" : "idle";
142
+ sessions.push({
143
+ name: desc.name || "",
144
+ cwd: desc.cwd || "",
145
+ status,
146
+ version: desc.version || "",
147
+ startedAt: Number(desc.startedAt) ? new Date(Number(desc.startedAt)).toISOString() : null,
148
+ updatedAt: updatedAt ? new Date(updatedAt).toISOString() : null,
149
+ isThisVault: Boolean(isThisVault),
150
+ _fresh: fresh,
151
+ });
152
+ }
153
+ // Most-recently-active first.
154
+ sessions.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || ""));
155
+ return sessions;
156
+ }
157
+
158
+ // Map a sessionId → a friendly name from the live descriptors, so a feed row
159
+ // can show "landing-polar-overhaul" instead of a bare uuid.
160
+ function buildSessionNameMap() {
161
+ const map = new Map();
162
+ let names;
163
+ try {
164
+ names = fs.readdirSync(SESSIONS_DIR);
165
+ } catch (_) {
166
+ return map;
167
+ }
168
+ for (const name of names) {
169
+ if (!name.endsWith(".json")) continue;
170
+ try {
171
+ const desc = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, name), "utf8"));
172
+ if (desc && desc.sessionId && desc.name) map.set(desc.sessionId, desc.name);
173
+ } catch (_) {
174
+ /* skip */
175
+ }
176
+ }
177
+ return map;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // history events (tailed, scoped, redacted)
182
+ // ---------------------------------------------------------------------------
183
+
184
+ function readEvents(vaultReal, limit, sessionNames) {
185
+ let raw;
186
+ try {
187
+ raw = fs.readFileSync(HISTORY_FILE, "utf8");
188
+ } catch (_) {
189
+ return [];
190
+ }
191
+ const lines = raw.split("\n");
192
+ // Memoize realpath per distinct `project` string — history has thousands of
193
+ // lines but only a handful of distinct projects, and realpath hits disk.
194
+ const projectScopeCache = new Map();
195
+ const inVault = (project) => {
196
+ if (projectScopeCache.has(project)) return projectScopeCache.get(project);
197
+ const real = resolveReal(project);
198
+ const ok = isUnder(real, vaultReal);
199
+ projectScopeCache.set(project, ok);
200
+ return ok;
201
+ };
202
+
203
+ const events = [];
204
+ // Walk newest → oldest, collect up to `limit` in-vault events, then flip to
205
+ // chronological. Tailing from the end keeps this cheap on a large history.
206
+ for (let i = lines.length - 1; i >= 0 && events.length < limit; i--) {
207
+ const line = lines[i];
208
+ if (!line) continue;
209
+ let obj;
210
+ try {
211
+ obj = JSON.parse(line);
212
+ } catch (_) {
213
+ continue;
214
+ }
215
+ const project = obj.project;
216
+ if (!project || !inVault(project)) continue;
217
+
218
+ const display = obj.display;
219
+ const text = toSentence(display);
220
+ if (!text) continue; // nothing real to say
221
+
222
+ const tsMs = Number(obj.timestamp) || 0;
223
+ const { lane, kind } = classify(display);
224
+ const sessionId = obj.sessionId || "";
225
+ events.push({
226
+ ts: tsMs ? new Date(tsMs).toISOString() : null,
227
+ sessionId,
228
+ sessionName: (sessionId && sessionNames.get(sessionId)) || "",
229
+ lane, // 'move' | 'term'
230
+ kind,
231
+ text,
232
+ project,
233
+ });
234
+ }
235
+ events.reverse(); // chronological (oldest of the tail → newest)
236
+ return events;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // public API
241
+ // ---------------------------------------------------------------------------
242
+
243
+ // compute({ vault, limit }) → live work-feed payload.
244
+ // available false when nothing real is happening under this vault
245
+ // caption one honest line summarizing the feed (or '')
246
+ // events [{ ts, sessionId, sessionName, lane, kind, text, project }]
247
+ // activeSessions [{ name, cwd, status, version, startedAt, updatedAt, isThisVault }]
248
+ //
249
+ // No `cost`, no `diff` — there is no per-step source for either (binding).
250
+ function compute({ vault, limit = 40 } = {}) {
251
+ const EMPTY = { available: false, caption: "", events: [], activeSessions: [] };
252
+ if (!vault || typeof vault !== "string") return EMPTY;
253
+
254
+ const vaultReal = resolveReal(vault);
255
+ if (!vaultReal) return EMPTY;
256
+
257
+ const lim = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 40;
258
+
259
+ const sessions = readSessions(vaultReal);
260
+ const sessionNames = buildSessionNameMap();
261
+ const events = readEvents(vaultReal, lim, sessionNames);
262
+
263
+ // Active sessions surfaced to the UI = sessions running under THIS vault.
264
+ const activeSessions = sessions
265
+ .filter((s) => s.isThisVault)
266
+ .map(({ _fresh, ...s }) => s); // drop internal field
267
+
268
+ // False-zero discipline: a feed with no in-vault events AND no in-vault
269
+ // sessions is genuinely "nothing running" → unavailable, never a demo.
270
+ if (events.length === 0 && activeSessions.length === 0) return EMPTY;
271
+
272
+ // Honest caption from real counts only. "live" = a descriptor this vault owns
273
+ // that is currently active/busy (recently touched).
274
+ const liveNow = sessions.filter((s) => s.isThisVault && (s.status === "active" || s.status === "busy")).length;
275
+ let caption;
276
+ if (events.length === 0) {
277
+ caption = liveNow > 0 ? `${liveNow} session${liveNow === 1 ? "" : "s"} open — no events yet` : "";
278
+ } else {
279
+ const moves = events.filter((e) => e.lane === "move").length;
280
+ const parts = [`${events.length} recent event${events.length === 1 ? "" : "s"}`];
281
+ if (moves > 0) parts.push(`${moves} move${moves === 1 ? "" : "s"}`);
282
+ if (liveNow > 0) parts.push(`${liveNow} live`);
283
+ caption = parts.join(" · ");
284
+ }
285
+
286
+ return {
287
+ available: true,
288
+ caption,
289
+ events,
290
+ activeSessions,
291
+ };
292
+ }
293
+
294
+ module.exports = { compute };
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+
3
+ // forked-future.js — Two-path drift projection for the Briefing module.
4
+ // Each scenario is a confidence-coned sparkline:
5
+ // path A: "if you hold the pattern" — extrapolates current drift trajectory
6
+ // path B: "if you break the pattern" — drift falls toward baseline (15-20)
7
+ //
8
+ // Pure function: takes current drift score + recent trajectory + active
9
+ // patterns, returns two arrays of {value, lo, hi} points across N days.
10
+
11
+ function compute({ driftScore = null, recent7d = [], activePatterns = [], horizonDays = 14 } = {}) {
12
+ if (!driftScore) return null;
13
+
14
+ const current = driftScore.value;
15
+ const baseline = 15; // healthy floor target
16
+ const ceiling = 100;
17
+ const trendDelta = computeTrend(recent7d);
18
+ const patternWeight = Math.min(1, (activePatterns || []).filter(p => p.confidence >= 3).length * 0.15);
19
+
20
+ // Path A: hold the pattern. Trajectory continues at observed slope, with
21
+ // pattern weight pulling toward ceiling (active Conf 3+ patterns add drift).
22
+ const slopeA = trendDelta + 0.6 + patternWeight * 0.8;
23
+ const holdPath = [];
24
+ for (let i = 0; i <= horizonDays; i++) {
25
+ const v = Math.min(ceiling, Math.max(0, current + slopeA * i));
26
+ const spread = 3 + i * 0.8; // widening confidence cone
27
+ holdPath.push({ day: i, value: round(v), lo: round(v - spread), hi: round(Math.min(ceiling, v + spread)) });
28
+ }
29
+
30
+ // Path B: break the pattern. Drift decays toward baseline at -2/day (faster
31
+ // initially), with smaller confidence cone (the action is causal).
32
+ const breakPath = [];
33
+ for (let i = 0; i <= horizonDays; i++) {
34
+ const v = baseline + (current - baseline) * Math.pow(0.85, i);
35
+ const spread = 2 + i * 0.4;
36
+ breakPath.push({ day: i, value: round(v), lo: round(Math.max(0, v - spread)), hi: round(v + spread) });
37
+ }
38
+
39
+ return {
40
+ horizonDays,
41
+ current,
42
+ baseline,
43
+ holdPath,
44
+ breakPath,
45
+ interpretation: {
46
+ hold: `${slopeA >= 0.6 ? 'Drift climbs' : 'Drift drifts'} toward ${holdPath[holdPath.length - 1].value}/100 in ${horizonDays} days if pattern continues.`,
47
+ break: `Drift falls toward ${breakPath[breakPath.length - 1].value}/100 if the next 3 sessions break the pattern.`
48
+ }
49
+ };
50
+ }
51
+
52
+ function computeTrend(values) {
53
+ if (!values || values.length < 2) return 0;
54
+ // Simple slope: last - first / count
55
+ return (values[values.length - 1] - values[0]) / Math.max(1, values.length - 1);
56
+ }
57
+
58
+ function round(v) { return Math.round(v * 10) / 10; }
59
+
60
+ module.exports = { compute };
@@ -0,0 +1,304 @@
1
+ "use strict";
2
+
3
+ const { localDateKey } = require("./date-utils");
4
+
5
+ function computeGoalForecast({ strategy, activeContext, distribution, engineHealth, daily, now = new Date() } = {}) {
6
+ const sourceText = [
7
+ activeContext && activeContext.raw,
8
+ strategy && strategy.singleTest,
9
+ ].filter(Boolean).join("\n");
10
+
11
+ const parsed = parseGoalProgress({
12
+ singleTest: strategy && strategy.singleTest,
13
+ sourceText,
14
+ targetDate: strategy && strategy.targetDate,
15
+ now,
16
+ });
17
+
18
+ const current = parsed.current;
19
+ const target = parsed.target;
20
+ const needed = Math.max(0, target - current);
21
+ const deadline = parsed.deadline;
22
+ const daysRemaining = deadline ? daysBetween(localDateKey(now), deadline) : null;
23
+ const daysSafe = daysRemaining == null ? null : Math.max(0, daysRemaining);
24
+ const requiredPerDay = daysSafe == null
25
+ ? null
26
+ : daysSafe === 0 ? needed : round(needed / daysSafe, 2);
27
+ const progressPct = target > 0 ? Math.min(100, Math.round((current / target) * 100)) : 0;
28
+
29
+ let status = "unknown";
30
+ if (target && current >= target) status = "complete";
31
+ else if (daysRemaining != null && daysRemaining < 0) status = "failed";
32
+ else if (daysRemaining != null && daysRemaining <= 1 && needed > 2) status = "unlikely";
33
+ else if (requiredPerDay != null && requiredPerDay > 1) status = "at-risk";
34
+ else if (target) status = "on-track";
35
+
36
+ // Only act on distribution we actually measured. Unavailable (null) distribution
37
+ // must NOT trigger a "0 actions logged" experiment. [false-zero — Codex, T261]
38
+ const total7dKnown = !!(distribution && distribution.available !== false && Number.isFinite(distribution.total7d));
39
+ const total7d = total7dKnown ? distribution.total7d : 0;
40
+ const adjustments = [];
41
+ const workflows = [];
42
+
43
+ if (engineHealth && engineHealth.analyseDay && engineHealth.analyseDay.status === "RED") {
44
+ pushAction(adjustments, workflows, {
45
+ workflow: "/analyse-day",
46
+ title: "Run a day review before more strategy edits",
47
+ detail: `You haven't reviewed a day in ${engineHealth.analyseDay.overdueDays} days. Run /analyse-day to get the dashboard current.`,
48
+ priority: "critical",
49
+ });
50
+ }
51
+ // Only treat strategy health as "trouble" when the scan actually ran and
52
+ // returned a concrete RED/YELLOW. A null status from an unavailable scan must
53
+ // NOT trigger /pivot-strategy. [false-zero regression fix — Codex P1, T261]
54
+ const strategyTrouble = engineHealth && engineHealth.available !== false
55
+ && engineHealth.strategy && (engineHealth.strategy.status === "RED" || engineHealth.strategy.status === "YELLOW");
56
+ if (status === "failed" || status === "unlikely" || strategyTrouble) {
57
+ pushAction(adjustments, workflows, {
58
+ workflow: "/pivot-strategy",
59
+ title: status === "failed" ? "Close the failed Single Test" : "Recalibrate the current strategy",
60
+ detail: `${current}/${target} against the active target. Keep the test, kill it, or replace it with a dated operating gate.`,
61
+ priority: status === "failed" ? "critical" : "high",
62
+ });
63
+ }
64
+ if (needed > 0 && total7dKnown && total7d < 10) {
65
+ pushAction(adjustments, workflows, {
66
+ workflow: "/experiment",
67
+ title: "Open a distribution experiment",
68
+ detail: `Only ${total7d} distribution actions logged in 7 days. Test one channel loop with a falsifiable metric instead of adding more dashboard surface.`,
69
+ priority: "high",
70
+ });
71
+ }
72
+ if (daily && daily.today && !daily.today.exists) {
73
+ pushAction(adjustments, workflows, {
74
+ workflow: "/plan-tomorrow",
75
+ title: "Set today's execution list",
76
+ detail: "No Daily Note found for today, so the dashboard is missing the live plan it should enforce.",
77
+ priority: "medium",
78
+ });
79
+ }
80
+
81
+ // available:false when no real target parsed → null out the numerals so the UI
82
+ // shows its honest "no measurable test" / "calibrating" state, never a
83
+ // fabricated "0/10". [false-zero discipline, T261]
84
+ const available = !!target;
85
+ return {
86
+ available,
87
+ singleTest: parsed.singleTest || null,
88
+ current: available ? current : null,
89
+ target: available ? target : null,
90
+ unit: parsed.unit,
91
+ deadline,
92
+ daysRemaining,
93
+ needed,
94
+ requiredPerDay,
95
+ progressPct,
96
+ status,
97
+ source: parsed.source,
98
+ distribution7d: total7dKnown ? total7d : null,
99
+ adjustments,
100
+ workflows,
101
+ };
102
+ }
103
+
104
+ function pushAction(adjustments, workflows, action) {
105
+ adjustments.push(action);
106
+ if (action.workflow && !workflows.includes(action.workflow)) workflows.push(action.workflow);
107
+ }
108
+
109
+ function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
110
+ const out = {
111
+ singleTest: singleTest || null,
112
+ current: 0,
113
+ target: 0,
114
+ unit: "customers",
115
+ deadline: null,
116
+ source: "strategy",
117
+ };
118
+
119
+ if (singleTest) {
120
+ // Regex captures count-based phrasing (e.g. "10 paying customers/sales")
121
+ // AND activation phrasing ("N activated installs / referrals"). The
122
+ // extended unit list keeps backwards-compat and makes an activation-style
123
+ // mid-term metric ("N activated installs + M referrals by <date>") parse correctly.
124
+ const targetMatch = singleTest.match(/\b(\d+)\s+(?:paying\s+|activated\s+)?(?:strangers?|customers?|sales|users?|installs?|referrals?|activations?)\b/i);
125
+ if (targetMatch) {
126
+ out.target = Number(targetMatch[1]);
127
+ // Set unit from the matched phrase so the hero strip says
128
+ // "activated installs" instead of the default "customers" on V4.
129
+ const phrase = targetMatch[0].toLowerCase();
130
+ if (/install/.test(phrase)) out.unit = "activated installs";
131
+ else if (/referral/.test(phrase)) out.unit = "referrals";
132
+ else if (/user/.test(phrase)) out.unit = "users";
133
+ else out.unit = "customers";
134
+ }
135
+ const deadline = extractDate(singleTest, now) || targetDate;
136
+ if (deadline) out.deadline = deadline;
137
+ }
138
+
139
+ const text = sourceText || "";
140
+ const ratios = Array.from(text.matchAll(/\b(\d+)\s*\/\s*(\d+)\s*(?:sales|paying|customers?|strangers?|users?)\b/gi));
141
+ if (ratios.length) {
142
+ const latest = ratios[ratios.length - 1];
143
+ out.current = Number(latest[1]);
144
+ if (!out.target) out.target = Number(latest[2]);
145
+ out.source = "active_context_ratio";
146
+ }
147
+
148
+ const sales = [
149
+ ...Array.from(text.matchAll(/\b(?:LIVE,\s*|Status:\s*[^\n]{0,60}?|confirmed\s+|current:\s*)(\d+)\s+(?:SALES|sales|purchases)\b/g)),
150
+ ...Array.from(text.matchAll(/\b(\d+)\s+sales\s+confirmed\b/gi)),
151
+ ];
152
+ if (sales.length) {
153
+ const max = Math.max(...sales.map(m => Number(m[1])).filter(Number.isFinite));
154
+ if (Number.isFinite(max) && max > out.current) {
155
+ out.current = max;
156
+ out.source = "active_context_sales";
157
+ }
158
+ }
159
+
160
+ // No fabricated target: if the Single Test carries no parseable number,
161
+ // target stays 0 → computeGoalForecast reports available:false (honest "no
162
+ // measurable test") instead of a made-up "0/10". [false-zero discipline, T261]
163
+ if (!out.deadline) out.deadline = targetDate || extractDate(text, now);
164
+ return out;
165
+ }
166
+
167
+ function extractDate(text, now) {
168
+ if (!text) return null;
169
+ const iso = text.match(/\b(20\d{2}-[01]\d-[0-3]\d)\b/);
170
+ if (iso) return iso[1];
171
+ const months = ["jan","feb","mar","apr","may","jun","jul","aug","sep","sept","oct","nov","dec","january","february","march","april","june","july","august","september","october","november","december"];
172
+ const m = text.match(new RegExp(`\\b(?:by\\s+)?(${months.join("|")})\\s+(\\d{1,2})(?:,?\\s*(20\\d{2}))?`, "i"));
173
+ if (!m) return null;
174
+ const month = monthIndex(m[1]);
175
+ const day = Number(m[2]);
176
+ const year = m[3] ? Number(m[3]) : now.getFullYear();
177
+ if (month < 0 || !Number.isFinite(day)) return null;
178
+ return localDateKey(new Date(year, month, day));
179
+ }
180
+
181
+ function monthIndex(name) {
182
+ const n = String(name || "").toLowerCase().slice(0, 3);
183
+ return { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }[n] ?? -1;
184
+ }
185
+
186
+ // T242 bug hunt fix (B#16): previously `new Date(`${iso}T00:00:00`)` parsed
187
+ // as LOCAL time. Across DST transitions (Mar/Nov in US, Mar/Oct in EU)
188
+ // two consecutive days are 23 or 25 hours apart, so the ceil-divide by a
189
+ // fixed 86400000ms returned 0 or 2 for what should be 1. Goal countdown
190
+ // chips flickered around DST. Use UTC midnight + calendar-day math.
191
+ function daysBetween(fromIso, toIso) {
192
+ const fromUtc = Date.parse(`${fromIso}T00:00:00Z`);
193
+ const toUtc = Date.parse(`${toIso}T00:00:00Z`);
194
+ if (!Number.isFinite(fromUtc) || !Number.isFinite(toUtc)) return null;
195
+ return Math.round((toUtc - fromUtc) / (24 * 3600 * 1000));
196
+ }
197
+
198
+ function round(n, d) {
199
+ const p = Math.pow(10, d);
200
+ return Math.round(n * p) / p;
201
+ }
202
+
203
+ // ── Per-domain measurement dispatcher ─────────────────────────────────────────
204
+ // Each domain declares a measurement SHAPE in Strategy.md (single | chain | scalar
205
+ // | streak). This routes the declaration + whatever live source exists into a
206
+ // render-ready progress object, honest-null wherever a domain has not logged data
207
+ // yet. The dashboard renders the shape; the fuel arrives once the user logs it.
208
+ function parseDomainProgress({ domain, decl, goalForecast, streak, metricsLog } = {}) {
209
+ if (!decl || !decl.type) return { available: false, type: null };
210
+ const type = decl.type;
211
+
212
+ if (type === "single") {
213
+ // Work's single test is already computed by computeGoalForecast. Other domains
214
+ // declaring single have no per-domain count source yet -> honest-null.
215
+ if (domain === "work" && goalForecast && goalForecast.available) {
216
+ return {
217
+ available: true, type: "single",
218
+ current: goalForecast.current, target: goalForecast.target,
219
+ unit: goalForecast.unit || null, progressPct: goalForecast.progressPct ?? null,
220
+ status: goalForecast.status || null,
221
+ };
222
+ }
223
+ return { available: false, type: "single", current: null, target: numFrom(decl.target), unit: null };
224
+ }
225
+
226
+ if (type === "scalar") {
227
+ const baseline = numFrom(decl.baseline);
228
+ const target = numFrom(decl.target);
229
+ const current = scalarCurrent(decl, metricsLog); // best-effort; null until logged
230
+ const progressPct =
231
+ baseline != null && target != null && current != null && target !== baseline
232
+ ? clampPct(Math.round(((current - baseline) / (target - baseline)) * 100))
233
+ : null;
234
+ return {
235
+ available: current != null, type: "scalar",
236
+ baseline, target, current,
237
+ baselineText: decl.baseline || null, targetText: decl.target || null,
238
+ progressPct,
239
+ };
240
+ }
241
+
242
+ if (type === "streak") {
243
+ // streak-parser tracks day-PRESENCE (a daily note exists), NOT whether the
244
+ // declared practice was actually done. Presenting that as the practice streak
245
+ // would be a false signal, so the count stays honest-null until a per-practice
246
+ // source exists; the ring renders its ready/empty state.
247
+ return {
248
+ available: false, type: "streak",
249
+ currentStreak: null, longestStreak: null, todayDone: null,
250
+ note: "activates once the practice is logged",
251
+ };
252
+ }
253
+
254
+ if (type === "chain") {
255
+ const stages = parseChainStages(decl.chain); // [] when no chain math declared
256
+ return {
257
+ available: false, type: "chain",
258
+ stages, bottleneck: null,
259
+ note: stages.length ? "awaiting per-stage numbers" : "declare the chain (revenue -> ... -> daily action)",
260
+ };
261
+ }
262
+
263
+ return { available: false, type };
264
+ }
265
+
266
+ // Pull a leading number out of a prose value ("7.5h sleep" -> 7.5, "78 kg" -> 78).
267
+ function numFrom(text) {
268
+ if (text == null) return null;
269
+ const m = String(text).match(/-?\d+(?:\.\d+)?/);
270
+ return m ? Number(m[0]) : null;
271
+ }
272
+
273
+ function clampPct(n) {
274
+ if (!Number.isFinite(n)) return null;
275
+ return Math.max(0, Math.min(100, n));
276
+ }
277
+
278
+ // Best-effort current value for a scalar domain from the metrics log's latest
279
+ // readings, matched by keyword. Null (honest) when nothing matches.
280
+ function scalarCurrent(decl, metricsLog) {
281
+ if (!metricsLog || metricsLog.available === false) return null;
282
+ const hay = ((decl.target || "") + " " + (decl.baseline || "")).toLowerCase();
283
+ const pick = /sleep/.test(hay) ? metricsLog.sleepLatest
284
+ : /weight|kg|\blb/.test(hay) ? metricsLog.weightLatest
285
+ : /hrv/.test(hay) ? metricsLog.hrvLatest
286
+ : /rhr|resting|heart/.test(hay) ? metricsLog.rhrLatest
287
+ : null;
288
+ return numFrom(pick);
289
+ }
290
+
291
+ // Split a declared chain ("$10k = 100 sales = 1k calls at 10% = 50 DMs/day") into
292
+ // ordered stages. Each stage is a label only; the live per-stage numbers arrive
293
+ // from the logged funnel, so `current` is honest-null here.
294
+ function parseChainStages(chain) {
295
+ if (!chain) return [];
296
+ return String(chain)
297
+ .split(/\s*(?:=|->|→|>)\s*/)
298
+ .map((s) => s.trim())
299
+ .filter(Boolean)
300
+ .slice(0, 6)
301
+ .map((label) => ({ label, current: null }));
302
+ }
303
+
304
+ module.exports = { computeGoalForecast, parseDomainProgress };