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,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,228 @@
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
+ // Count only real daily notes — cloud-sync artifacts ("Daily - 2026-07-10
92
+ // (conflicted copy).md", "Daily - 2026-07-10 2.md") inflated the totals.
93
+ const isDailyNote = (e) => /^Daily - \d{4}-\d{2}-\d{2}\.md$/.test(e.name);
94
+ out.totalThisMonth = safeListDir(monthDir, { extension: ".md" }).filter(isDailyNote).length;
95
+
96
+ // This year — sum across all month folders
97
+ const yearDirs = safeListDir(dailiesDir(vault), { dirsOnly: true })
98
+ .filter(e => e.name.startsWith(`${yyyy}-`));
99
+ let yearCount = 0;
100
+ for (const yd of yearDirs) {
101
+ yearCount += safeListDir(yd.path, { extension: ".md" }).filter(isDailyNote).length;
102
+ }
103
+ out.totalThisYear = yearCount;
104
+
105
+ // 365-day grid: array of {date, exists}
106
+ const heatmapDays = [];
107
+ for (let i = 364; i >= 0; i--) {
108
+ const d = new Date(sessionDate);
109
+ d.setDate(d.getDate() - i);
110
+ const p = dailyNotePath(vault, d);
111
+ heatmapDays.push({ date: fmtDate(d), exists: exists(p) });
112
+ }
113
+ out.heatmap = heatmapDays;
114
+
115
+ return out;
116
+ }
117
+
118
+ // Strip bold FIRST (else the bullet-strip eats one * of a **bold** line),
119
+ // then a leading bullet/checkbox marker. Returns null on empty.
120
+ function cleanLine(s) {
121
+ if (!s) return null;
122
+ return s.replace(/\*\*/g, "").replace(/^\s*[-*]\s+(\[.\]\s*)?/, "").trim() || null;
123
+ }
124
+
125
+ const EMPTY_JOURNAL = { hasEvening: false, hasMorning: false, evening: null, morning: null };
126
+
127
+ // v6 SURFACE 2 — parse the Daily Note `## Journal` section into its
128
+ // `### Evening · …` / `### Morning · …` sub-anchors (written by the journal
129
+ // close endpoint, W3). Every field is null when absent — a guessed score is
130
+ // never ok (false-zero contract). The section ends at the next H1/H2.
131
+ function parseJournal(text) {
132
+ if (!text) return EMPTY_JOURNAL;
133
+ const jm = text.match(/^#{1,6}\s*journal\s*$/im);
134
+ if (!jm) return EMPTY_JOURNAL;
135
+ let sect = text.slice(jm.index + jm[0].length);
136
+ const nextTop = sect.search(/^#{1,2}\s/m);
137
+ if (nextTop > 0) sect = sect.slice(0, nextTop);
138
+ const under = (re) => { const m = sect.match(re); return m ? cleanLine(m[1]) : null; };
139
+ // A score must be ANCHORED, never the leftmost digit in prose — "Day 3 in a
140
+ // row, honestly a 7/10" read as 3 and corrupted the calibration gap (R5-A).
141
+ // Accepted shapes, in order: "7/10" anywhere; "Score: 7" / "Energy: 7";
142
+ // a leading first-token number (the journal endpoint's own format,
143
+ // "7 — scattered but shipped") unless it's a time/duration ("5 hours…").
144
+ // Anything else = null (never a guess).
145
+ const num = (line) => {
146
+ if (!line) return null;
147
+ let m = line.match(/\b(10|[1-9])\s*\/\s*10\b/);
148
+ if (m) return Number(m[1]);
149
+ m = line.match(/\b(?:score|energy)\s*[:=]?\s*(10|[1-9])\b/i);
150
+ if (m) return Number(m[1]);
151
+ m = line.match(/^\s*(10|[1-9])\b(?!\s*(?:am|pm|h\b|hr|hour|min|:|%))/i);
152
+ return m ? Number(m[1]) : null;
153
+ };
154
+
155
+ const evScoreLine = under(/###[^\n]*evening[^\n]*score[^\n]*\n+([^\n#][^\n]*)/i);
156
+ const evening = {
157
+ score: num(evScoreLine),
158
+ scoreWhy: evScoreLine,
159
+ winAvoidance: under(/###[^\n]*evening[^\n]*(?:win|avoid)[^\n]*\n+([^\n#][^\n]*)/i),
160
+ workedDidnt: under(/###[^\n]*evening[^\n]*work[^\n]*\n+([^\n#][^\n]*)/i),
161
+ doDifferently: under(/###[^\n]*evening[^\n]*differ[^\n]*\n+([^\n#][^\n]*)/i),
162
+ // Codex W3 round 3: the alibi is an EVENING field — read it ONLY from an
163
+ // Evening header (matches what the journal endpoint writes: "### Evening ·
164
+ // alibi"). Requiring "evening" positively — rather than just excluding
165
+ // morning — means a user's unrelated "### Random prediction note" is NOT
166
+ // misread as the evening alibi. Consistent with every other evening field,
167
+ // which already requires "evening" in the header.
168
+ alibi: under(/###[^\n]*\bevening\b[^\n]*(?:alibi|prediction)[^\n]*\n+([^\n#][^\n]*)/i),
169
+ };
170
+ const mEnergyLine = under(/###[^\n]*morning[^\n]*energy[^\n]*\n+([^\n#][^\n]*)/i);
171
+ const morning = {
172
+ wantVsShould: under(/###[^\n]*morning[^\n]*(?:want|should)[^\n]*\n+([^\n#][^\n]*)/i),
173
+ energy: num(mEnergyLine),
174
+ grateful: under(/###[^\n]*morning[^\n]*grate[^\n]*\n+([^\n#][^\n]*)/i),
175
+ picture: under(/###[^\n]*morning[^\n]*(?:picture|visuali)[^\n]*\n+([^\n#][^\n]*)/i),
176
+ };
177
+ // Codex W3 fix 3: alibi counts toward an evening entry — the journal endpoint
178
+ // can write an alibi-only evening block, and it must read back (not be dropped).
179
+ const hasEvening = !!(evening.score || evening.winAvoidance || evening.workedDidnt || evening.doDifferently || evening.alibi);
180
+ const hasMorning = !!(morning.wantVsShould || morning.energy || morning.grateful || morning.picture);
181
+ return { hasEvening, hasMorning, evening: hasEvening ? evening : null, morning: hasMorning ? morning : null };
182
+ }
183
+
184
+ function analyzeNoteText(text) {
185
+ if (!text) return { tasksTotal: 0, tasksDone: 0, tasks: [], length: 0, focus: null, todaysCall: null, journal: EMPTY_JOURNAL, plan: null, got: null };
186
+ const lines = text.split("\n");
187
+ let total = 0, done = 0;
188
+ // W12: keep each checkbox line's text so the dashboard can write the tick
189
+ // back (POST /api/checkbox/toggle matches on exact trimmed item text). The
190
+ // counters above stay authoritative for the aggregate surfaces.
191
+ const tasks = [];
192
+ for (const line of lines) {
193
+ const m = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*)$/);
194
+ if (m) {
195
+ total++;
196
+ const checked = m[1].toLowerCase() === "x";
197
+ if (checked) done++;
198
+ const t = (m[2] || "").trim();
199
+ if (t) tasks.push({ text: t, checked });
200
+ }
201
+ }
202
+ // v6 SURFACE 1 — today's stated focus, for the Say-it friction rule
203
+ // (off-plan new-project intent vs the day's plan). First content line under
204
+ // a "Focus" heading; null when there's no plan to argue against. Forgiving
205
+ // heading match because Daily templates vary; a guessed plan is never ok.
206
+ const fm = text.match(/^#{1,6}\s*[^\n]*focus[^\n]*\n+([^\n#][^\n]*)/im);
207
+ const focus = cleanLine(fm ? fm[1] : null);
208
+ // v6 — `## Today's Call` (the v6 hero label); falls back to focus when absent
209
+ // (the real template uses `## Focus`; Today's Call is the v6 demo label).
210
+ const tc = text.match(/^#{1,6}\s*[^\n]*today'?s call[^\n]*\n+([^\n#][^\n]*)/im);
211
+ const todaysCall = cleanLine(tc ? tc[1] : null);
212
+ // v6 SURFACE 2 — the `## Journal` sub-anchors (evening/morning).
213
+ const journal = parseJournal(text);
214
+ // v6 SURFACE 7 (Rewind) — per-date plan/got. plan = today's-call|focus;
215
+ // got = first line of `## Review`, else the first `## Key Insights` bullet;
216
+ // null when neither exists (Rewind shows an honest gap, never a guess).
217
+ let got = null;
218
+ const rev = text.match(/^#{1,6}\s*review[^\n]*\n+([^\n#][^\n]*)/im);
219
+ if (rev) got = cleanLine(rev[1]);
220
+ else {
221
+ const ki = text.match(/^#{1,6}\s*[^\n]*key insight[^\n]*\n+(?:[^\n]*\n)*?\s*[-*]\s+([^\n]+)/im);
222
+ if (ki) got = cleanLine(ki[1]);
223
+ }
224
+ const plan = todaysCall || focus;
225
+ return { tasksTotal: total, tasksDone: done, tasks, length: text.length, focus, todaysCall, journal, plan, got };
226
+ }
227
+
228
+ module.exports = { resolveDailyNotes, fmtDate, analyzeNoteText, parseJournal };
@@ -0,0 +1,43 @@
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
+ // Midnight of the SESSION day (see sessionDateKey) as a Date — for parsers
36
+ // that bucket by day and must agree with the T245 convention.
37
+ function sessionMidnight(date = new Date()) {
38
+ const d = date instanceof Date ? new Date(date.getTime()) : new Date(date);
39
+ if (d.getHours() < 5) d.setDate(d.getDate() - 1);
40
+ return localMidnight(d);
41
+ }
42
+
43
+ module.exports = { localDateKey, localMidnight, sessionDateKey, sessionMidnight };
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { dailiesDir } = require("./paths");
6
+ const { localDateKey, sessionMidnight } = 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
+ // reply seeding: PERFORMED forms only. The bare "reply-seeding" noun matched
31
+ // every strategy sentence that discussed the channel ("reply-seeding should
32
+ // be primary", "70% X reply-seeding") and made an all-planning week read as
33
+ // 8 receipts (terra Evidence walk, 2026-07-12).
34
+ /\breply[- ]seeded\b/i,
35
+ /\b(?:sent|posted|did|dropped)\b[^.\n]{0,40}\breply[- ]seeds?\b/i,
36
+ /\breply[- ]seeds?\b[^.\n]{0,30}\b(?:sent|posted|done)\b/i,
37
+ // replies count only when aimed at a person/audience surface, not code review
38
+ /\breplied?\b[^.\n]{0,50}\b(?:lead|prospect|customer|buyer|DM|thread|tweet|comment)\b/i,
39
+ /\btweeted\b/i,
40
+ ];
41
+
42
+ // A clause that matches a KEYWORD is still NOT a receipt when it is talk
43
+ // ABOUT distribution rather than a report of doing it. Three failure classes
44
+ // from the 2026-07-12 terra Evidence walk, where ALL 8 of the week's counted
45
+ // "receipts" were prose (two literally said "the reel never shipped"):
46
+ // negation — "the reel never shipped", "reply seed not posted"
47
+ // plan/modal — "reply-seeding should be primary", "will post", "70% X"
48
+ // meta-discussion — strategy docs, reviews, corpus analysis mentioning reels
49
+ // Undercount over inflate: a rejected real receipt costs a pip; a counted
50
+ // fake receipt makes the product lie on its own honesty surface.
51
+ const NOT_A_RECEIPT = [
52
+ // bare "zero" over-blocked ("the false-zero bug"); only "zero <artifact>"
53
+ // is a negation ("sent zero DMs").
54
+ /\b(?:never|not|didn'?t|hasn'?t|wasn'?t|won'?t|isn'?t|couldn'?t|unshipped|unfilmed|unsent|unposted)\b/i,
55
+ /\bzero (?:DMs?|reels?|posts?|replies|tweets?|threads?|outreach)\b/i,
56
+ /\b(?:should|would|will|must|needs? to|plan(?:s|ned|ning)?|intend|gonna|tomorrow|next (?:week|step)|queued|owed|blocked|pending|drafted|commitment|deadline)\b/i,
57
+ /\b(?:strategy|doc|playbook|corpus|harvest|review|audit|analys\w*|research|parser?|keyword|counted|counter|allocation|weight|copy|evidence|rework|redesign|mandate)\b/i,
58
+ // "No reel shipped" / "no thread posted" — capital-No negation the \bnot\b
59
+ // class misses.
60
+ /\bno (?:reels?|tweets?|threads?|posts?|DMs?|articles?|outreach)\b/i,
61
+ /%/,
62
+ // A question is never a receipt ("| Twitter thread published? |" template
63
+ // checklist rows counted for weeks in March).
64
+ /\?/,
65
+ ];
66
+ function isReceiptClause(frag) {
67
+ if (!KEYWORDS.some((re) => re.test(frag))) return false;
68
+ return !NOT_A_RECEIPT.some((re) => re.test(frag));
69
+ }
70
+
71
+ function compute({ vault, daysLookback = 30 } = {}) {
72
+ if (!vault) return { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null };
73
+ const dDir = dailiesDir(vault);
74
+ if (!fs.existsSync(dDir)) return { available: false, days: [], todayCount: null, total7d: null, total30d: null, delta7d: null };
75
+
76
+ // Session-day anchor (T245): before 05:00 "today" is still yesterday's
77
+ // session, same convention daily-note-resolver uses — otherwise the 2am
78
+ // outreach a user is actively logging shows todayCount 0 (R5-A).
79
+ const today = sessionMidnight();
80
+ const days = [];
81
+ for (let i = 0; i < daysLookback; i++) {
82
+ // setDate (not ms arithmetic): a fixed 24h step skips one calendar day
83
+ // across a DST spring-forward and shifts the rest of the window (R5-A).
84
+ const d = new Date(today.getTime());
85
+ d.setDate(d.getDate() - i);
86
+ const key = localDateKey(d);
87
+ const monthDir = key.slice(0, 7);
88
+ const fpath = path.join(dDir, monthDir, `Daily - ${key}.md`);
89
+ let count = 0;
90
+ // The actual matched clauses, so Evidence can show REAL receipt lines
91
+ // instead of the fabricated "Distribution shipped" template rows terra
92
+ // confirmed (v4b syntheticReceipts, 2026-07-12).
93
+ const matches = [];
94
+ // R3-E1: a day with no note is NO-RECORD, not "zero receipts" — the UI
95
+ // needs the difference to clamp all-time views to the first real note.
96
+ let exists = false;
97
+ try {
98
+ const text = fs.readFileSync(fpath, "utf8");
99
+ exists = true;
100
+ // Only a recognizable session-log section counts. The old full-file
101
+ // fallback (header renamed → scan EVERYTHING) counted journal prose
102
+ // about last month's article as today's outreach — the 29-vs-2 scar's
103
+ // second face. No section = count 0 (undercount over inflate).
104
+ // H2 OR H3, suffix allowed: /log itself writes "### Session Log
105
+ // [ATTENDED] — 10:00 [Claude Code]" (Codex R5-C catch).
106
+ const slm = text.match(/^#{2,3}\s*(?:session\s*log|log)\b/im);
107
+ const section = slm ? text.slice(slm.index) : "";
108
+ // Count at most ONE action per clause: "sent DMs and reached out to 5
109
+ // leads" is one outreach session described two ways, not two — but
110
+ // comma-separated real actions ("posted a thread, sent 5 DMs") each
111
+ // count (Codex R5-C: sentence-level was undercounting those).
112
+ if (section) {
113
+ for (const frag of section.split(/[.\n,;]+/)) {
114
+ if (isReceiptClause(frag)) {
115
+ count += 1;
116
+ if (matches.length < 6) matches.push(frag.trim().slice(0, 140));
117
+ }
118
+ }
119
+ }
120
+ } catch (_) { /* missing daily — count stays 0 */ }
121
+ days.push({ date: key, count, exists, matches });
122
+ }
123
+ const total7d = days.slice(0, 7).reduce((s, d) => s + d.count, 0);
124
+ const total30d = days.reduce((s, d) => s + d.count, 0);
125
+ const prior7d = days.slice(7, 14).reduce((s, d) => s + d.count, 0);
126
+ const todayCount = days[0] ? days[0].count : 0;
127
+ return {
128
+ available: true,
129
+ days: days.reverse(),
130
+ todayCount,
131
+ total7d,
132
+ total30d,
133
+ delta7d: total7d - prior7d
134
+ };
135
+ }
136
+
137
+ module.exports = { compute };