mover-os 4.7.7 → 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 +688 -68
  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,231 @@
1
+ "use strict";
2
+
3
+ // Black Box Correlations parser — V5 wishlist item (V5_CLOSED_LOOP:1039).
4
+ //
5
+ // Correlates daily-note Battery signals (sleep hours, protein, energy
6
+ // rating) against output signals (tasksDone, sessionCount). Computes
7
+ // Pearson correlation coefficient over the trailing N days.
8
+ //
9
+ // Designed to be HONEST under low-data conditions:
10
+ // - <14 days: returns { available: false, reason: 'needs_more_data', haveDays: N }
11
+ // - >=14 days but Battery section empty: returns { available: false, reason: 'no_battery_data' }
12
+ // - With data: returns array of correlation entries, each with
13
+ // { x_signal, y_signal, r, n, sample_window_days }
14
+ //
15
+ // No fake patterns. The UI module shows the underlying numbers, not just
16
+ // a verdict like "good sleep = better output." Codex ethics: surface the
17
+ // truth, not the convenient interpretation.
18
+
19
+ const fs = require("fs");
20
+ const path = require("path");
21
+
22
+ const MIN_DAYS = 14;
23
+ const DEFAULT_WINDOW = 30;
24
+
25
+ function dailiesDir(vault) {
26
+ return path.join(vault, "02_Areas", "Engine", "Dailies");
27
+ }
28
+
29
+ function safeRead(filePath) {
30
+ try { return fs.readFileSync(filePath, "utf8"); } catch { return ""; }
31
+ }
32
+
33
+ // Parse a Daily Note for the metrics we care about.
34
+ // Returns { date, sleepHours, proteinG, energy, tasksDone, sessionCount,
35
+ // hasBatterySection } or null.
36
+ //
37
+ // User-reported bug 2026-05-24: parser said "Daily Notes don't have Battery
38
+ // section data yet · coverage sleep=0 protein=0 energy=0" — but vault has
39
+ // ## Battery section in every May daily. The catch: section is a markdown
40
+ // TABLE, values are [TBD] placeholders, and metric names differ from the
41
+ // regex labels. Two fixes here:
42
+ // 1. Match both `Sleep: 7h 30m` AND markdown-table `| Sleep | 7h 30m |`
43
+ // 2. Reject placeholder values like `[TBD]`, `—`, `(none)`, `n/a`
44
+ // 3. Emit hasBatterySection so the UI can distinguish "no section" from
45
+ // "section exists but unfilled" — the user's real state.
46
+ function parseDaily(filePath, dateKey) {
47
+ const text = safeRead(filePath);
48
+ if (!text) return null;
49
+ const entry = { date: dateKey, sleepHours: null, proteinG: null, energy: null, tasksDone: null, sessionCount: null, hasBatterySection: false };
50
+
51
+ // Detect Battery section presence (independent of value extraction).
52
+ entry.hasBatterySection = /^##\s+Battery\b/im.test(text);
53
+
54
+ // T242 bug hunt fix (B#9): scope value matching to within the
55
+ // `## Battery` section only. Previous version did `text.match` against
56
+ // the whole file, so any Session Log line containing "Energy: high" or
57
+ // a quoted "Sleep: 8h" anywhere in the note would match and pollute
58
+ // the metric. Now: locate the Battery header, slice to the NEXT
59
+ // top-level header (or EOF), and match only within that slice.
60
+ let batterySlice = "";
61
+ if (entry.hasBatterySection) {
62
+ const startIdx = text.search(/^##\s+Battery\b/im);
63
+ if (startIdx >= 0) {
64
+ const after = text.slice(startIdx);
65
+ // Find the next `##` heading (any level 2+) that's NOT the Battery one.
66
+ const nextHeader = after.slice(8).search(/^##\s+\S/m);
67
+ batterySlice = nextHeader > 0 ? after.slice(0, 8 + nextHeader) : after;
68
+ }
69
+ }
70
+ const valueText = batterySlice || ""; // Only match inside Battery slice.
71
+
72
+ // Combined regex: capture value after `:`/`=` OR after a `|` pipe (table).
73
+ // Reject placeholder values like [TBD], —, (none), n/a, ?, TBD.
74
+ const isReal = (v) => v && !/^(?:\[?[Tt][Bb][Dd]\]?|[—–-]+|n\/?a|\(?none\)?|\?+|tbd|pending)$/i.test(v.trim());
75
+
76
+ // Sleep — "Sleep: 7h 30m" OR "| Sleep | 7h 30m |"
77
+ const sleepMatch = valueText.match(/(?:Sleep|Slept)\s*(?:[:=]|\|)\s*([^|\n]+?)(?=\s*\||\s*$)/im);
78
+ if (sleepMatch && isReal(sleepMatch[1])) {
79
+ const sub = sleepMatch[1].trim().match(/(\d+(?:\.\d+)?)\s*h?(?:\s*(\d+)\s*m)?/);
80
+ if (sub) {
81
+ const h = parseFloat(sub[1]);
82
+ const m = sub[2] ? parseFloat(sub[2]) : 0;
83
+ entry.sleepHours = Number.isFinite(h) ? h + (Number.isFinite(m) ? m / 60 : 0) : null;
84
+ }
85
+ }
86
+
87
+ // Protein — "Protein: 120g" OR "| Protein | 120g |"
88
+ const proteinMatch = valueText.match(/Protein\s*(?:[:=]|\|)\s*([^|\n]+?)(?=\s*\||\s*$)/im);
89
+ if (proteinMatch && isReal(proteinMatch[1])) {
90
+ const sub = proteinMatch[1].trim().match(/(\d+(?:\.\d+)?)\s*g?/);
91
+ if (sub) entry.proteinG = parseFloat(sub[1]);
92
+ }
93
+
94
+ // Energy — "Energy: 7/10" OR "| Energy | 7/10 |"
95
+ const energyMatch = valueText.match(/Energy\s*(?:[:=]|\|)\s*([^|\n]+?)(?=\s*\||\s*$)/im);
96
+ if (energyMatch && isReal(energyMatch[1])) {
97
+ const sub = energyMatch[1].trim().match(/(\d+(?:\.\d+)?)/);
98
+ if (sub) entry.energy = parseFloat(sub[1]);
99
+ }
100
+
101
+ // Tasks — count "- [x]" and "- [ ]"
102
+ const doneCount = (text.match(/^\s*[-*]\s*\[x\]/gmi) || []).length;
103
+ const totalCount = (text.match(/^\s*[-*]\s*\[[ x]\]/gmi) || []).length;
104
+ if (totalCount > 0) {
105
+ entry.tasksDone = doneCount;
106
+ entry.tasksTotal = totalCount;
107
+ }
108
+
109
+ // Session count — count "## Session" or "Session 1, Session 2" headers
110
+ const sessionMatch = text.match(/##\s*Session/gi);
111
+ if (sessionMatch) entry.sessionCount = sessionMatch.length;
112
+
113
+ return entry;
114
+ }
115
+
116
+ // Walk daily notes for the trailing N days.
117
+ function collectEntries(vault, windowDays) {
118
+ const dDir = dailiesDir(vault);
119
+ if (!fs.existsSync(dDir)) return [];
120
+ const entries = [];
121
+ const cutoff = Date.now() - windowDays * 86400000;
122
+ let months;
123
+ try {
124
+ months = fs.readdirSync(dDir).filter((d) => /^\d{4}-\d{2}$/.test(d)).sort().reverse();
125
+ } catch { return []; }
126
+ for (const mDir of months) {
127
+ if (entries.length >= windowDays * 2) break; // safety
128
+ let files;
129
+ try {
130
+ files = fs.readdirSync(path.join(dDir, mDir)).filter((f) => /^Daily - \d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse();
131
+ } catch { continue; }
132
+ for (const f of files) {
133
+ const m = f.match(/(\d{4})-(\d{2})-(\d{2})/);
134
+ if (!m) continue;
135
+ const dateKey = m[0];
136
+ const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}`);
137
+ if (!Number.isFinite(t) || t < cutoff) continue;
138
+ const parsed = parseDaily(path.join(dDir, mDir, f), dateKey);
139
+ if (parsed) entries.push(parsed);
140
+ }
141
+ }
142
+ return entries;
143
+ }
144
+
145
+ // Pearson correlation coefficient over paired (x, y) values.
146
+ // Returns null if N < 3 or zero variance.
147
+ function pearson(xs, ys) {
148
+ const n = Math.min(xs.length, ys.length);
149
+ if (n < 3) return null;
150
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
151
+ for (let i = 0; i < n; i++) {
152
+ const x = xs[i], y = ys[i];
153
+ sumX += x; sumY += y;
154
+ sumXY += x * y;
155
+ sumX2 += x * x;
156
+ sumY2 += y * y;
157
+ }
158
+ const num = n * sumXY - sumX * sumY;
159
+ const den = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY));
160
+ if (den === 0) return null;
161
+ return num / den;
162
+ }
163
+
164
+ function correlate(entries, xKey, yKey) {
165
+ const pairs = entries
166
+ .map(e => ({ x: e[xKey], y: e[yKey] }))
167
+ .filter(p => Number.isFinite(p.x) && Number.isFinite(p.y));
168
+ if (pairs.length < 3) return null;
169
+ const r = pearson(pairs.map(p => p.x), pairs.map(p => p.y));
170
+ if (r == null) return null;
171
+ return {
172
+ x_signal: xKey,
173
+ y_signal: yKey,
174
+ r: Math.round(r * 1000) / 1000,
175
+ n: pairs.length,
176
+ direction: r > 0.1 ? "positive" : r < -0.1 ? "negative" : "none",
177
+ strength: Math.abs(r) > 0.6 ? "strong" : Math.abs(r) > 0.3 ? "moderate" : "weak",
178
+ };
179
+ }
180
+
181
+ function compute({ vault, windowDays = DEFAULT_WINDOW } = {}) {
182
+ if (!vault) return { available: false, reason: "no_vault" };
183
+ const entries = collectEntries(vault, windowDays);
184
+ if (entries.length < MIN_DAYS) {
185
+ return {
186
+ available: false,
187
+ reason: "needs_more_data",
188
+ haveDays: entries.length,
189
+ neededDays: MIN_DAYS,
190
+ windowDays,
191
+ };
192
+ }
193
+ const haveSleep = entries.filter(e => Number.isFinite(e.sleepHours)).length;
194
+ const haveProtein = entries.filter(e => Number.isFinite(e.proteinG)).length;
195
+ const haveEnergy = entries.filter(e => Number.isFinite(e.energy)).length;
196
+ // Distinguish "no Battery section anywhere" from "section exists but
197
+ // values are placeholders ([TBD]/—/n/a)". The empty-state copy in the
198
+ // UI changes depending on which — telling someone with [TBD]s "you
199
+ // don't have a Battery section" is gaslighting, the truth is "fill in
200
+ // your placeholders."
201
+ const haveBatterySection = entries.filter(e => e.hasBatterySection).length;
202
+ if (haveSleep < MIN_DAYS && haveProtein < MIN_DAYS && haveEnergy < MIN_DAYS) {
203
+ return {
204
+ available: false,
205
+ reason: haveBatterySection > 0 ? "battery_section_unfilled" : "no_battery_section",
206
+ haveDays: entries.length,
207
+ windowDays,
208
+ batterySectionDays: haveBatterySection,
209
+ coverage: { sleep: haveSleep, protein: haveProtein, energy: haveEnergy },
210
+ };
211
+ }
212
+ const pairs = [
213
+ ["sleepHours", "tasksDone"],
214
+ ["sleepHours", "sessionCount"],
215
+ ["sleepHours", "energy"],
216
+ ["proteinG", "energy"],
217
+ ["proteinG", "tasksDone"],
218
+ ["energy", "tasksDone"],
219
+ ["energy", "sessionCount"],
220
+ ];
221
+ const correlations = pairs.map(([x, y]) => correlate(entries, x, y)).filter(Boolean);
222
+ return {
223
+ available: true,
224
+ windowDays,
225
+ haveDays: entries.length,
226
+ coverage: { sleep: haveSleep, protein: haveProtein, energy: haveEnergy },
227
+ correlations,
228
+ };
229
+ }
230
+
231
+ module.exports = { compute, parseDaily, pearson };
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const { dailiesDir, dailyNotePath, exists, safeRead, safeListDir } = require("./paths");
6
+
7
+ // Resolves Daily Notes — supports YYYY-MM/Daily - YYYY-MM-DD.md format.
8
+ // Returns streak, today's note, last-N notes, total count.
9
+
10
+ function fmtDate(d) {
11
+ const y = d.getFullYear();
12
+ const m = String(d.getMonth() + 1).padStart(2, "0");
13
+ const dd = String(d.getDate()).padStart(2, "0");
14
+ return `${y}-${m}-${dd}`;
15
+ }
16
+
17
+ function resolveDailyNotes(vault, options = {}) {
18
+ // T245 — apply the Mover OS session-day convention. Before 05:00, the
19
+ // session belongs to YESTERDAY (matches /log, /analyse-day, /plan-tomorrow
20
+ // workflows). Without this, at 03:00 the dashboard reports "no Daily
21
+ // Note for today" while the user has been actively editing yesterday's
22
+ // note all night — gives a ghost "missing daily" warning every late
23
+ // session.
24
+ let sessionDate = options.sessionDate;
25
+ if (!sessionDate) {
26
+ const now = new Date();
27
+ sessionDate = new Date(now);
28
+ if (now.getHours() < 5) sessionDate.setDate(sessionDate.getDate() - 1);
29
+ }
30
+ const lookback = options.lookback || 30;
31
+
32
+ const out = {
33
+ available: true,
34
+ today: null,
35
+ streak: 0,
36
+ recentNotes: [],
37
+ totalThisMonth: 0,
38
+ totalThisYear: 0
39
+ };
40
+
41
+ // Check today's note
42
+ const todayPath = dailyNotePath(vault, sessionDate);
43
+ if (exists(todayPath)) {
44
+ const text = safeRead(todayPath, "");
45
+ out.today = {
46
+ date: fmtDate(sessionDate),
47
+ path: todayPath,
48
+ exists: true,
49
+ ...analyzeNoteText(text)
50
+ };
51
+ } else {
52
+ out.today = { date: fmtDate(sessionDate), path: todayPath, exists: false };
53
+ }
54
+
55
+ // Streak — count consecutive days backwards including today
56
+ let streakCount = 0;
57
+ for (let i = 0; i < 365; i++) {
58
+ const d = new Date(sessionDate);
59
+ d.setDate(d.getDate() - i);
60
+ const p = dailyNotePath(vault, d);
61
+ if (exists(p)) {
62
+ streakCount++;
63
+ } else if (i === 0) {
64
+ // No daily note today — streak counts from yesterday backwards
65
+ continue;
66
+ } else {
67
+ break;
68
+ }
69
+ }
70
+ out.streak = streakCount;
71
+
72
+ // Recent notes (last N days)
73
+ const recent = [];
74
+ for (let i = 0; i < lookback; i++) {
75
+ const d = new Date(sessionDate);
76
+ d.setDate(d.getDate() - i);
77
+ const p = dailyNotePath(vault, d);
78
+ if (exists(p)) {
79
+ const text = safeRead(p, "");
80
+ recent.push({ date: fmtDate(d), path: p, exists: true, ...analyzeNoteText(text) });
81
+ } else {
82
+ recent.push({ date: fmtDate(d), path: p, exists: false });
83
+ }
84
+ }
85
+ out.recentNotes = recent;
86
+
87
+ // Counts
88
+ const yyyy = sessionDate.getFullYear();
89
+ const yyyyMm = `${yyyy}-${String(sessionDate.getMonth() + 1).padStart(2, "0")}`;
90
+ const monthDir = path.join(dailiesDir(vault), yyyyMm);
91
+ out.totalThisMonth = safeListDir(monthDir, { extension: ".md" }).length;
92
+
93
+ // This year — sum across all month folders
94
+ const yearDirs = safeListDir(dailiesDir(vault), { dirsOnly: true })
95
+ .filter(e => e.name.startsWith(`${yyyy}-`));
96
+ let yearCount = 0;
97
+ for (const yd of yearDirs) {
98
+ yearCount += safeListDir(yd.path, { extension: ".md" }).length;
99
+ }
100
+ out.totalThisYear = yearCount;
101
+
102
+ // 365-day grid: array of {date, exists}
103
+ const heatmapDays = [];
104
+ for (let i = 364; i >= 0; i--) {
105
+ const d = new Date(sessionDate);
106
+ d.setDate(d.getDate() - i);
107
+ const p = dailyNotePath(vault, d);
108
+ heatmapDays.push({ date: fmtDate(d), exists: exists(p) });
109
+ }
110
+ out.heatmap = heatmapDays;
111
+
112
+ return out;
113
+ }
114
+
115
+ // Strip bold FIRST (else the bullet-strip eats one * of a **bold** line),
116
+ // then a leading bullet/checkbox marker. Returns null on empty.
117
+ function cleanLine(s) {
118
+ if (!s) return null;
119
+ return s.replace(/\*\*/g, "").replace(/^\s*[-*]\s+(\[.\]\s*)?/, "").trim() || null;
120
+ }
121
+
122
+ const EMPTY_JOURNAL = { hasEvening: false, hasMorning: false, evening: null, morning: null };
123
+
124
+ // v6 SURFACE 2 — parse the Daily Note `## Journal` section into its
125
+ // `### Evening · …` / `### Morning · …` sub-anchors (written by the journal
126
+ // close endpoint, W3). Every field is null when absent — a guessed score is
127
+ // never ok (false-zero contract). The section ends at the next H1/H2.
128
+ function parseJournal(text) {
129
+ if (!text) return EMPTY_JOURNAL;
130
+ const jm = text.match(/^#{1,6}\s*journal\s*$/im);
131
+ if (!jm) return EMPTY_JOURNAL;
132
+ let sect = text.slice(jm.index + jm[0].length);
133
+ const nextTop = sect.search(/^#{1,2}\s/m);
134
+ if (nextTop > 0) sect = sect.slice(0, nextTop);
135
+ const under = (re) => { const m = sect.match(re); return m ? cleanLine(m[1]) : null; };
136
+ const num = (line) => { if (!line) return null; const m = line.match(/\b(?:10|[1-9])\b/); return m ? Number(m[0]) : null; };
137
+
138
+ const evScoreLine = under(/###[^\n]*evening[^\n]*score[^\n]*\n+([^\n#][^\n]*)/i);
139
+ const evening = {
140
+ score: num(evScoreLine),
141
+ scoreWhy: evScoreLine,
142
+ winAvoidance: under(/###[^\n]*evening[^\n]*(?:win|avoid)[^\n]*\n+([^\n#][^\n]*)/i),
143
+ workedDidnt: under(/###[^\n]*evening[^\n]*work[^\n]*\n+([^\n#][^\n]*)/i),
144
+ doDifferently: under(/###[^\n]*evening[^\n]*differ[^\n]*\n+([^\n#][^\n]*)/i),
145
+ // Codex W3 round 3: the alibi is an EVENING field — read it ONLY from an
146
+ // Evening header (matches what the journal endpoint writes: "### Evening ·
147
+ // alibi"). Requiring "evening" positively — rather than just excluding
148
+ // morning — means a user's unrelated "### Random prediction note" is NOT
149
+ // misread as the evening alibi. Consistent with every other evening field,
150
+ // which already requires "evening" in the header.
151
+ alibi: under(/###[^\n]*\bevening\b[^\n]*(?:alibi|prediction)[^\n]*\n+([^\n#][^\n]*)/i),
152
+ };
153
+ const mEnergyLine = under(/###[^\n]*morning[^\n]*energy[^\n]*\n+([^\n#][^\n]*)/i);
154
+ const morning = {
155
+ wantVsShould: under(/###[^\n]*morning[^\n]*(?:want|should)[^\n]*\n+([^\n#][^\n]*)/i),
156
+ energy: num(mEnergyLine),
157
+ grateful: under(/###[^\n]*morning[^\n]*grate[^\n]*\n+([^\n#][^\n]*)/i),
158
+ picture: under(/###[^\n]*morning[^\n]*(?:picture|visuali)[^\n]*\n+([^\n#][^\n]*)/i),
159
+ };
160
+ // Codex W3 fix 3: alibi counts toward an evening entry — the journal endpoint
161
+ // can write an alibi-only evening block, and it must read back (not be dropped).
162
+ const hasEvening = !!(evening.score || evening.winAvoidance || evening.workedDidnt || evening.doDifferently || evening.alibi);
163
+ const hasMorning = !!(morning.wantVsShould || morning.energy || morning.grateful || morning.picture);
164
+ return { hasEvening, hasMorning, evening: hasEvening ? evening : null, morning: hasMorning ? morning : null };
165
+ }
166
+
167
+ function analyzeNoteText(text) {
168
+ if (!text) return { tasksTotal: 0, tasksDone: 0, tasks: [], length: 0, focus: null, todaysCall: null, journal: EMPTY_JOURNAL, plan: null, got: null };
169
+ const lines = text.split("\n");
170
+ let total = 0, done = 0;
171
+ // W12: keep each checkbox line's text so the dashboard can write the tick
172
+ // back (POST /api/checkbox/toggle matches on exact trimmed item text). The
173
+ // counters above stay authoritative for the aggregate surfaces.
174
+ const tasks = [];
175
+ for (const line of lines) {
176
+ const m = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*)$/);
177
+ if (m) {
178
+ total++;
179
+ const checked = m[1].toLowerCase() === "x";
180
+ if (checked) done++;
181
+ const t = (m[2] || "").trim();
182
+ if (t) tasks.push({ text: t, checked });
183
+ }
184
+ }
185
+ // v6 SURFACE 1 — today's stated focus, for the Say-it friction rule
186
+ // (off-plan new-project intent vs the day's plan). First content line under
187
+ // a "Focus" heading; null when there's no plan to argue against. Forgiving
188
+ // heading match because Daily templates vary; a guessed plan is never ok.
189
+ const fm = text.match(/^#{1,6}\s*[^\n]*focus[^\n]*\n+([^\n#][^\n]*)/im);
190
+ const focus = cleanLine(fm ? fm[1] : null);
191
+ // v6 — `## Today's Call` (the v6 hero label); falls back to focus when absent
192
+ // (the real template uses `## Focus`; Today's Call is the v6 demo label).
193
+ const tc = text.match(/^#{1,6}\s*[^\n]*today'?s call[^\n]*\n+([^\n#][^\n]*)/im);
194
+ const todaysCall = cleanLine(tc ? tc[1] : null);
195
+ // v6 SURFACE 2 — the `## Journal` sub-anchors (evening/morning).
196
+ const journal = parseJournal(text);
197
+ // v6 SURFACE 7 (Rewind) — per-date plan/got. plan = today's-call|focus;
198
+ // got = first line of `## Review`, else the first `## Key Insights` bullet;
199
+ // null when neither exists (Rewind shows an honest gap, never a guess).
200
+ let got = null;
201
+ const rev = text.match(/^#{1,6}\s*review[^\n]*\n+([^\n#][^\n]*)/im);
202
+ if (rev) got = cleanLine(rev[1]);
203
+ else {
204
+ const ki = text.match(/^#{1,6}\s*[^\n]*key insight[^\n]*\n+(?:[^\n]*\n)*?\s*[-*]\s+([^\n]+)/im);
205
+ if (ki) got = cleanLine(ki[1]);
206
+ }
207
+ const plan = todaysCall || focus;
208
+ return { tasksTotal: total, tasksDone: done, tasks, length: text.length, focus, todaysCall, journal, plan, got };
209
+ }
210
+
211
+ module.exports = { resolveDailyNotes, fmtDate, analyzeNoteText, parseJournal };
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+
3
+ function localDateKey(date = new Date()) {
4
+ const d = date instanceof Date ? date : new Date(date);
5
+ if (Number.isNaN(d.getTime())) return "";
6
+ const y = d.getFullYear();
7
+ const m = String(d.getMonth() + 1).padStart(2, "0");
8
+ const day = String(d.getDate()).padStart(2, "0");
9
+ return `${y}-${m}-${day}`;
10
+ }
11
+
12
+ function localMidnight(date = new Date()) {
13
+ const d = date instanceof Date ? new Date(date.getTime()) : new Date(date);
14
+ d.setHours(0, 0, 0, 0);
15
+ return d;
16
+ }
17
+
18
+ // T245 — Mover OS session-day convention. Workflows (/log, /analyse-day,
19
+ // /plan-tomorrow) treat any session before 05:00 as belonging to YESTERDAY
20
+ // — the user is still on "today's" plan until they wake up tomorrow.
21
+ // Parsers that ask "is there a Daily Note for today?" need the same rule
22
+ // or they report ghost gaps at 3 AM (the user wrote the note for May 24,
23
+ // it's now 03:00 on May 25 — `localDateKey()` returns 2026-05-25 and the
24
+ // May 24 note is invisible to the check).
25
+ function sessionDateKey(date = new Date()) {
26
+ const d = date instanceof Date ? new Date(date.getTime()) : new Date(date);
27
+ if (Number.isNaN(d.getTime())) return "";
28
+ // Roll back to yesterday's date when hour < 5.
29
+ if (d.getHours() < 5) {
30
+ d.setDate(d.getDate() - 1);
31
+ }
32
+ return localDateKey(d);
33
+ }
34
+
35
+ module.exports = { localDateKey, localMidnight, sessionDateKey };
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { dailiesDir } = require("./paths");
6
+ const { localDateKey, localMidnight } = require("./date-utils");
7
+
8
+ // distribution-parser.js — F30. Heuristic scan of Daily Notes session logs
9
+ // for distribution-action keywords. Buckets by day. Returns 30-day rolling
10
+ // counts so the StatsStrip can show "DIST 7D · +3 vs prev week".
11
+
12
+ // HONESTY FIX (owner catch, 2026-07-07): bare verbs (shipped/published/posted/
13
+ // replied/commented) counted DEV work as distribution. Session logs are full of
14
+ // "R-A shipped" and "artifact published", which inflated the Home KPI, the day
15
+ // score's ship component, and Evidence's "distribution shipped" verdicts on days
16
+ // with zero real outreach. Every pattern below is CHANNEL-ANCHORED: the verb must
17
+ // sit near a real audience artifact or outreach act. We accept undercounting over
18
+ // inflating. A distribution count is a claim about reaching people, never about
19
+ // writing code.
20
+ const KEYWORDS = [
21
+ // publishing verbs anchored to an audience artifact or platform
22
+ /\b(?:posted|published|launched|shipped)\b[^.\n]{0,60}\b(?:reel|tweet|thread|article|video|newsletter|episode|waitlist|x\.com|twitter|instagram|\big\b|tiktok|linkedin|reddit|youtube|hacker ?news|product ?hunt)\b/i,
23
+ // reverse order: "reel posted", "article went live"
24
+ /\b(?:reel|tweet|thread|article|video|newsletter|episode)\b[^.\n]{0,40}\b(?:posted|published|launched|shipped|went live)\b/i,
25
+ // direct outreach: PERFORMED verbs only. Bare nouns (outreach, DMs) match
26
+ // planning talk ("cold-outreach template", "DM proof") and inflate.
27
+ /\bDM'?(?:d|ed)\b/i,
28
+ /\bsent (?:\d+ )?(?:DMs?|emails?|outreach|messages?|cold)\b/i,
29
+ /\breached out\b/i,
30
+ /\breply[- ]seed(?:ing|ed)?\b/i,
31
+ // replies count only when aimed at a person/audience surface, not code review
32
+ /\breplied?\b[^.\n]{0,50}\b(?:lead|prospect|customer|buyer|DM|thread|tweet|comment)\b/i,
33
+ /\btweeted\b/i,
34
+ ];
35
+
36
+ function compute({ vault, daysLookback = 30 } = {}) {
37
+ if (!vault) return { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null };
38
+ const dDir = dailiesDir(vault);
39
+ if (!fs.existsSync(dDir)) return { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null };
40
+
41
+ const today = localMidnight();
42
+ const days = [];
43
+ for (let i = 0; i < daysLookback; i++) {
44
+ const d = new Date(today.getTime() - i * 24 * 3600 * 1000);
45
+ const key = localDateKey(d);
46
+ const monthDir = key.slice(0, 7);
47
+ const fpath = path.join(dDir, monthDir, `Daily - ${key}.md`);
48
+ let count = 0;
49
+ // R3-E1: a day with no note is NO-RECORD, not "zero receipts" — the UI
50
+ // needs the difference to clamp all-time views to the first real note.
51
+ let exists = false;
52
+ try {
53
+ const text = fs.readFileSync(fpath, "utf8");
54
+ exists = true;
55
+ const slIdx = text.indexOf("## Session Log");
56
+ const section = slIdx >= 0 ? text.slice(slIdx) : text;
57
+ for (const re of KEYWORDS) {
58
+ const matches = section.match(new RegExp(re.source, "g" + (re.flags.includes("i") ? "i" : "")));
59
+ if (matches) count += matches.length;
60
+ }
61
+ } catch (_) { /* missing daily — count stays 0 */ }
62
+ days.push({ date: key, count, exists });
63
+ }
64
+ const total7d = days.slice(0, 7).reduce((s, d) => s + d.count, 0);
65
+ const total30d = days.reduce((s, d) => s + d.count, 0);
66
+ const prior7d = days.slice(7, 14).reduce((s, d) => s + d.count, 0);
67
+ const todayCount = days[0] ? days[0].count : 0;
68
+ return {
69
+ available: true,
70
+ days: days.reverse(),
71
+ todayCount,
72
+ total7d,
73
+ total30d,
74
+ delta7d: total7d - prior7d
75
+ };
76
+ }
77
+
78
+ module.exports = { compute };
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ const { moverDossierPath, safeRead, safeStat } = require("./paths");
4
+
5
+ // Parse Mover_Dossier.md — asset inventory (skills, capital, audience, network).
6
+
7
+ function parseDossier(vault) {
8
+ const filePath = moverDossierPath(vault);
9
+ const text = safeRead(filePath);
10
+ const stat = safeStat(filePath);
11
+ if (!text) return { available: false };
12
+
13
+ const out = {
14
+ available: true,
15
+ filePath,
16
+ lastUpdated: stat ? stat.mtime : null,
17
+ fields: {},
18
+ sections: {}
19
+ };
20
+
21
+ // Bold-key fields
22
+ const re = /\*\*([^*]+):\*\*\s*([^\n]+)/g;
23
+ let m;
24
+ while ((m = re.exec(text)) !== null) {
25
+ const k = m[1].trim().toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_|_$/g, "");
26
+ out.fields[k] = m[2].trim();
27
+ }
28
+
29
+ // Section split
30
+ const lines = text.split("\n");
31
+ let cur = null, buf = [];
32
+ for (const line of lines) {
33
+ const h = line.match(/^##+\s+(.+?)\s*$/);
34
+ if (h) {
35
+ if (cur) out.sections[cur.toLowerCase()] = buf.join("\n");
36
+ cur = h[1];
37
+ buf = [];
38
+ } else if (cur) buf.push(line);
39
+ }
40
+ if (cur) out.sections[cur.toLowerCase()] = buf.join("\n");
41
+
42
+ // Common keys
43
+ out.skills = extractList(out.sections, "skills");
44
+ out.capital = extractList(out.sections, "capital");
45
+ out.audience = extractList(out.sections, "audience");
46
+ out.network = extractList(out.sections, "network");
47
+ out.assets = extractList(out.sections, "assets") || extractList(out.sections, "asset inventory");
48
+
49
+ return out;
50
+ }
51
+
52
+ function extractList(sections, needle) {
53
+ for (const [title, body] of Object.entries(sections)) {
54
+ if (title.includes(needle)) {
55
+ return body.split("\n")
56
+ .map(l => l.match(/^\s*[-*\d.]+\s+(.+)$/))
57
+ .filter(Boolean).map(m => m[1].trim())
58
+ .slice(0, 8);
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+
64
+ module.exports = { parseDossier };