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,427 @@
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; // null when progress is unmeasured
19
+ const target = parsed.target;
20
+ const hasProgress = current != null;
21
+ const needed = (target && hasProgress) ? Math.max(0, target - current) : null;
22
+ const deadline = parsed.deadline;
23
+ const daysRemaining = deadline ? daysBetween(localDateKey(now), deadline) : null;
24
+ const daysSafe = daysRemaining == null ? null : Math.max(0, daysRemaining);
25
+ const requiredPerDay = (needed == null || daysSafe == null)
26
+ ? null
27
+ : daysSafe === 0 ? needed : round(needed / daysSafe, 2);
28
+ // progressPct is null (UNMEASURED), never a false 0, when no scoped progress exists.
29
+ const progressPct = (target > 0 && hasProgress) ? Math.min(100, Math.round((current / target) * 100)) : null;
30
+
31
+ // Status is scoped to the OUTCOME. A real dated target with no scoped progress
32
+ // reads "unknown" (-> "Not measured" in the UI), never a fabricated on-track. A
33
+ // scoped zero reads on-track (0 of 2, deadline ahead). [sol #11 cross-scope]
34
+ let status = "unknown";
35
+ if (!target) status = "unknown";
36
+ else if (hasProgress && current >= target) status = "complete";
37
+ else if (daysRemaining != null && daysRemaining < 0) status = "failed";
38
+ else if (!hasProgress) status = "unknown";
39
+ else if (daysRemaining != null && daysRemaining <= 1 && needed > 2) status = "unlikely";
40
+ else if (requiredPerDay != null && requiredPerDay > 1) status = "at-risk";
41
+ else status = "on-track";
42
+
43
+ // Only act on distribution we actually measured. Unavailable (null) distribution
44
+ // must NOT trigger a "0 actions logged" experiment. [false-zero — Codex, T261]
45
+ const total7dKnown = !!(distribution && distribution.available !== false && Number.isFinite(distribution.total7d));
46
+ const total7d = total7dKnown ? distribution.total7d : 0;
47
+ const adjustments = [];
48
+ const workflows = [];
49
+
50
+ if (engineHealth && engineHealth.analyseDay && engineHealth.analyseDay.status === "RED") {
51
+ pushAction(adjustments, workflows, {
52
+ workflow: "/analyse-day",
53
+ title: "Run a day review before more strategy edits",
54
+ detail: `You haven't reviewed a day in ${engineHealth.analyseDay.overdueDays} days. Run /analyse-day to get the dashboard current.`,
55
+ priority: "critical",
56
+ });
57
+ }
58
+ // Only treat strategy health as "trouble" when the scan actually ran and
59
+ // returned a concrete RED/YELLOW. A null status from an unavailable scan must
60
+ // NOT trigger /pivot-strategy. [false-zero regression fix — Codex P1, T261]
61
+ const strategyTrouble = engineHealth && engineHealth.available !== false
62
+ && engineHealth.strategy && (engineHealth.strategy.status === "RED" || engineHealth.strategy.status === "YELLOW");
63
+ if (status === "failed" || status === "unlikely" || strategyTrouble) {
64
+ pushAction(adjustments, workflows, {
65
+ workflow: "/pivot-strategy",
66
+ title: status === "failed" ? "Close the failed Single Test" : "Recalibrate the current strategy",
67
+ detail: `${current ?? "—"}/${target} against the active target. Keep the test, kill it, or replace it with a dated operating gate.`,
68
+ priority: status === "failed" ? "critical" : "high",
69
+ });
70
+ }
71
+ if (needed > 0 && total7dKnown && total7d < 10) {
72
+ pushAction(adjustments, workflows, {
73
+ workflow: "/experiment",
74
+ title: "Open a distribution experiment",
75
+ detail: `Only ${total7d} distribution actions logged in 7 days. Test one channel loop with a falsifiable metric instead of adding more dashboard surface.`,
76
+ priority: "high",
77
+ });
78
+ }
79
+ if (daily && daily.today && !daily.today.exists) {
80
+ pushAction(adjustments, workflows, {
81
+ workflow: "/plan-tomorrow",
82
+ title: "Set today's execution list",
83
+ detail: "No Daily Note found for today, so the dashboard is missing the live plan it should enforce.",
84
+ priority: "medium",
85
+ });
86
+ }
87
+
88
+ // available:false when no real target parsed → null out the numerals so the UI
89
+ // shows its honest "no measurable test" / "calibrating" state, never a
90
+ // fabricated "0/10". [false-zero discipline, T261]
91
+ const available = !!target;
92
+ return {
93
+ available,
94
+ singleTest: parsed.singleTest || null,
95
+ // current stays null when the target is parseable but progress is unmeasured
96
+ // (a clean bet with no scoped record) — the UI shows "Progress not measured".
97
+ current: available ? current : null,
98
+ target: available ? target : null,
99
+ unit: parsed.unit,
100
+ // kind rides with the parsed counter when one exists (the verdict must
101
+ // cite what the numbers actually count); keyword classification only
102
+ // labels the no-counter cases (revenue/vitality wording, available:false).
103
+ kind: parsed.counterKind || classifyGoalKind(strategy && strategy.singleTest),
104
+ // shape: "clean" (one metric + date), "compound" (a multi-signal gate), or
105
+ // "unparseable" (no target the dashboard can score). The hero renders each
106
+ // differently; a compound/legacy bet gets a "rewrite for clarity" affordance.
107
+ gate: parsed.gate === true,
108
+ shape: parsed.shape || (parsed.gate ? "compound" : (available ? "clean" : "unparseable")),
109
+ // whether `current` came from a real test-scoped record vs is just unmeasured.
110
+ progressMeasured: parsed.progressMeasured === true,
111
+ // the input commitment (daily quota) + the channel it names, both SEPARATE
112
+ // from the bet — Today renders these, never the hero.
113
+ commitment: (strategy && strategy.commitment) || null,
114
+ channel: parseChannel(strategy && strategy.commitment),
115
+ deadline,
116
+ daysRemaining,
117
+ // Derived pace numerals are honest-null when there is no measurable target
118
+ // (a gate/unparseable test) OR when the target is real but progress is
119
+ // unmeasured. Secondary readers that copy them raw must not report a false 0%.
120
+ needed: available ? needed : null,
121
+ requiredPerDay: available ? requiredPerDay : null,
122
+ progressPct: available ? progressPct : null,
123
+ status,
124
+ source: parsed.source,
125
+ distribution7d: total7dKnown ? total7d : null,
126
+ adjustments,
127
+ workflows,
128
+ };
129
+ }
130
+
131
+ function pushAction(adjustments, workflows, action) {
132
+ adjustments.push(action);
133
+ if (action.workflow && !workflows.includes(action.workflow)) workflows.push(action.workflow);
134
+ }
135
+
136
+ // The channel named in the Commitment ("...via X replies and DMs; ...") — the
137
+ // hero's support line uses it ("From X replies and DMs, by Jul 26"). Null-safe.
138
+ function parseChannel(commitment) {
139
+ if (!commitment) return null;
140
+ const m = String(commitment).match(/\b(?:via|through|on|using)\s+([^;.,]+?)(?=\s*[;.,]|$)/i);
141
+ return m ? m[1].trim() : null;
142
+ }
143
+
144
+ // A scoped progress record's unit must be the SAME kind of thing as the target.
145
+ // The HEAD noun (last word) must match, so "0/2 paying customers" counts toward a
146
+ // "customers" target, but "2/2 customer support calls" (head = calls) and
147
+ // "1/2 customer interviews" (head = interviews) do NOT — a shared adjective is not
148
+ // a unit match, or a same-denominator side metric fabricates "Complete". [sol #12]
149
+ function unitFamilyMatch(scopedUnit, targetUnit) {
150
+ const norm = (s) => String(s || "").toLowerCase().replace(/[^a-z ]+/g, " ").trim().replace(/s\b/g, "");
151
+ const t = norm(targetUnit).split(/\s+/).filter(Boolean);
152
+ const s = norm(scopedUnit).split(/\s+/).filter(Boolean);
153
+ if (!t.length || !s.length) return false;
154
+ return t[t.length - 1] === s[s.length - 1]; // head nouns must be identical
155
+ }
156
+
157
+ // What currency is the goal stated in, and does anything on file measure it?
158
+ // Drives the goal-anchored verdict on Evidence: an installs goal is pushed by
159
+ // distribution receipts; a distribution goal IS the receipts; a revenue or
160
+ // vitality goal has no parser yet, so the card must say "unmeasured" instead
161
+ // of pretending workflow counts answer it. Order matters: adoption phrasing
162
+ // ("15 activated installs") usually also names the channel, so adoption wins
163
+ // when both appear. Returns null when nothing matches (generic verdict).
164
+ function classifyGoalKind(singleTest) {
165
+ const t = String(singleTest || "").toLowerCase();
166
+ if (!t) return null;
167
+ if (/\b(installs?|activat|referrals?|sign-?ups?|customers?|users?|sales?|purchases?|buyers?|strangers?)/.test(t)) return "adoption";
168
+ if (/\b(dms?|outreach|repl(y|ies)|posts?|publish\w*|distribut\w*|articles?|reels?|videos?|content|newsletters?|followers?|subscribers?|views?|audience|impressions?)\b/.test(t)) return "distribution";
169
+ if (/(revenue|mrr\b|income|profit|£|\$|€)/.test(t)) return "revenue";
170
+ if (/\b(sleep|gym|weight|prayers?|fajr|salah|steps|health|energy|fasting)\b/.test(t)) return "vitality";
171
+ return null;
172
+ }
173
+
174
+ function parseGoalProgress({ singleTest, sourceText, targetDate, now }) {
175
+ const out = {
176
+ singleTest: singleTest || null,
177
+ current: null, // measured ONLY from a test-scoped record; null = unmeasured
178
+ target: 0,
179
+ unit: "customers",
180
+ counterKind: null,
181
+ shape: "unparseable",
182
+ progressMeasured: false,
183
+ deadline: null,
184
+ source: "strategy",
185
+ };
186
+
187
+ if (singleTest) {
188
+ // A multi-signal GATE ("Hit 2 of 3": 10 conversations / 5 install starts /
189
+ // 2 paid) is not one countable number. The single-counter regex below would
190
+ // grab "5 install starts" as target=5 and pair it with an unrelated sales
191
+ // count elsewhere on file, fabricating "4 of 5 installs". Detect the gate,
192
+ // refuse to collapse it, keep the deadline so the card can still pace to the
193
+ // date, and let the UI render the honest "scored by hand" state.
194
+ //
195
+ // The marker must be a gate VERDICT, not a bare "N of M": require a scoring
196
+ // verb (hit/pass/meet/clear, "of" or "out of") OR an "N of M =" verdict
197
+ // clause. A bare "N of M" would false-trigger on "Day 2 of 14: send 50 DMs"
198
+ // (a real distribution counter) and hide it; the verb/"=" gate keeps those
199
+ // parsing normally. A "+" rider ("15 installs + 1 referral") has no "of" at
200
+ // all and still parses its primary counter. [gate honesty, sol #10 hardening]
201
+ if (/\b(?:hit|hitting|pass(?:ed)?|meet|met|clear(?:ed)?)\s+\d+\s+(?:out\s+of|of)\s+\d+\b|\b\d+\s+(?:out\s+of|of)\s+\d+\s*=/i.test(singleTest)) {
202
+ out.gate = true;
203
+ out.counterKind = "gate";
204
+ out.shape = "compound";
205
+ out.deadline = extractDate(singleTest, now) || targetDate || null;
206
+ return out; // target stays 0 -> available:false -> honest-null, no fabricated counter
207
+ }
208
+ // Regex captures count-based phrasing (e.g. "10 paying customers/sales")
209
+ // AND activation phrasing ("N activated installs / referrals"). The
210
+ // extended unit list keeps backwards-compat and makes an activation-style
211
+ // mid-term metric ("N activated installs + M referrals by <date>") parse correctly.
212
+ // The negative lookahead stops an adoption noun acting as a MODIFIER of a
213
+ // distribution noun from being read as the counter: "10 sales posts" is a
214
+ // publishing target, never a sales counter. [terra F3, 2026-07-10]
215
+ const DIST_NOUN = "posts?|dms?|reels?|articles?|videos?|emails?|threads?|newsletters?";
216
+ const targetMatch = singleTest.match(new RegExp("\\b(\\d+)\\s+(?:paying\\s+|activated\\s+)?(?:strangers?|customers?|sales|users?|installs?|referrals?|activations?)\\b(?!\\s+(?:" + DIST_NOUN + ")\\b)", "i"));
217
+ if (targetMatch) {
218
+ out.target = Number(targetMatch[1]);
219
+ // Set unit from the matched phrase so the hero strip says
220
+ // "activated installs" instead of the default "customers" on V4.
221
+ const phrase = targetMatch[0].toLowerCase();
222
+ if (/install/.test(phrase)) out.unit = "activated installs";
223
+ else if (/referral/.test(phrase)) out.unit = "referrals";
224
+ else if (/user/.test(phrase)) out.unit = "users";
225
+ else out.unit = "customers";
226
+ out.counterKind = "adoption";
227
+ out.shape = "clean";
228
+ } else {
229
+ // Distribution counter ("publish 10 posts", "send 50 DMs", "10 sales
230
+ // posts"): the kind rides with the SAME match that set the number, so
231
+ // the verdict can never cite a counter the parse didn't produce.
232
+ const distMatch = singleTest.match(new RegExp("\\b(\\d+)\\s+(?:[a-z-]+\\s+)?(" + DIST_NOUN + ")\\b", "i"));
233
+ if (distMatch) {
234
+ out.target = Number(distMatch[1]);
235
+ out.unit = distMatch[2].toLowerCase();
236
+ out.counterKind = "distribution";
237
+ out.shape = "clean";
238
+ }
239
+ }
240
+ const deadline = extractDate(singleTest, now) || targetDate;
241
+ if (deadline) out.deadline = deadline;
242
+ }
243
+
244
+ const text = sourceText || "";
245
+
246
+ // Progress is TEST-SCOPED, and ONLY test-scoped. A generic lifetime line
247
+ // ("LIVE, 4 SALES" — all from April) or a historical ratio ("4/10 sales" from
248
+ // a dead strategy) must NOT count as progress toward THIS bet; doing so
249
+ // fabricated "4 of 2 · complete" the moment the target became a clean 2. The
250
+ // ONLY thing that counts as current is an explicit scoped record whose
251
+ // denominator and unit family match the active target:
252
+ // "Current bet progress: 0/2 paying customers". [sol #11 cross-scope bug]
253
+ if (out.target) {
254
+ const scoped = text.match(/current\s+bet\s+progress:\s*(\d+)\s*\/\s*(\d+)\s*([a-z][a-z ]*)?/i);
255
+ if (scoped) {
256
+ const cur = Number(scoped[1]);
257
+ const den = Number(scoped[2]);
258
+ const scopedUnit = (scoped[3] || "").trim();
259
+ const unitOk = !scopedUnit || unitFamilyMatch(scopedUnit, out.unit);
260
+ if (Number.isFinite(cur) && den === out.target && unitOk) {
261
+ out.current = cur; // a scoped ZERO is honest progress, not "unmeasured"
262
+ out.progressMeasured = true;
263
+ out.source = "scoped_progress";
264
+ }
265
+ }
266
+ }
267
+
268
+ // No fabricated target: if the Single Test carries no parseable number,
269
+ // target stays 0 → computeGoalForecast reports available:false (honest "no
270
+ // measurable test") instead of a made-up "0/10". [false-zero discipline, T261]
271
+ if (!out.deadline) out.deadline = targetDate || extractDate(text, now);
272
+ return out;
273
+ }
274
+
275
+ function extractDate(text, now) {
276
+ if (!text) return null;
277
+ const iso = text.match(/\b(20\d{2})-([01]\d)-([0-3]\d)\b/);
278
+ if (iso) {
279
+ // Impossible dates (2026-02-30) must yield NO deadline, not a silent
280
+ // JS rollover to March 2 rendered as a real target. [terra F4, 2026-07-10]
281
+ return validCalendarDay(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3])) ? iso[0] : null;
282
+ }
283
+ 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"];
284
+ const m = text.match(new RegExp(`\\b(?:by\\s+)?(${months.join("|")})\\s+(\\d{1,2})(?:,?\\s*(20\\d{2}))?`, "i"));
285
+ if (!m) return null;
286
+ const month = monthIndex(m[1]);
287
+ const day = Number(m[2]);
288
+ const year = m[3] ? Number(m[3]) : now.getFullYear();
289
+ if (month < 0 || !Number.isFinite(day)) return null;
290
+ if (!validCalendarDay(year, month, day)) return null; // "Feb 30" rolls over too
291
+ return localDateKey(new Date(year, month, day));
292
+ }
293
+
294
+ // True only when (year, month0, day) names a day that exists: round-trip
295
+ // through UTC and require every component to survive unchanged.
296
+ function validCalendarDay(year, month0, day) {
297
+ const d = new Date(Date.UTC(year, month0, day));
298
+ return d.getUTCFullYear() === year && d.getUTCMonth() === month0 && d.getUTCDate() === day;
299
+ }
300
+
301
+ function monthIndex(name) {
302
+ const n = String(name || "").toLowerCase().slice(0, 3);
303
+ 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;
304
+ }
305
+
306
+ // T242 bug hunt fix (B#16): previously `new Date(`${iso}T00:00:00`)` parsed
307
+ // as LOCAL time. Across DST transitions (Mar/Nov in US, Mar/Oct in EU)
308
+ // two consecutive days are 23 or 25 hours apart, so the ceil-divide by a
309
+ // fixed 86400000ms returned 0 or 2 for what should be 1. Goal countdown
310
+ // chips flickered around DST. Use UTC midnight + calendar-day math.
311
+ function daysBetween(fromIso, toIso) {
312
+ const fromUtc = Date.parse(`${fromIso}T00:00:00Z`);
313
+ const toUtc = Date.parse(`${toIso}T00:00:00Z`);
314
+ if (!Number.isFinite(fromUtc) || !Number.isFinite(toUtc)) return null;
315
+ return Math.round((toUtc - fromUtc) / (24 * 3600 * 1000));
316
+ }
317
+
318
+ function round(n, d) {
319
+ const p = Math.pow(10, d);
320
+ return Math.round(n * p) / p;
321
+ }
322
+
323
+ // ── Per-domain measurement dispatcher ─────────────────────────────────────────
324
+ // Each domain declares a measurement SHAPE in Strategy.md (single | chain | scalar
325
+ // | streak). This routes the declaration + whatever live source exists into a
326
+ // render-ready progress object, honest-null wherever a domain has not logged data
327
+ // yet. The dashboard renders the shape; the fuel arrives once the user logs it.
328
+ function parseDomainProgress({ domain, decl, goalForecast, streak, metricsLog } = {}) {
329
+ if (!decl || !decl.type) return { available: false, type: null };
330
+ const type = decl.type;
331
+
332
+ if (type === "single") {
333
+ // Work's single test is already computed by computeGoalForecast. Other domains
334
+ // declaring single have no per-domain count source yet -> honest-null.
335
+ if (domain === "work" && goalForecast && goalForecast.available) {
336
+ return {
337
+ available: true, type: "single",
338
+ current: goalForecast.current, target: goalForecast.target,
339
+ unit: goalForecast.unit || null, progressPct: goalForecast.progressPct ?? null,
340
+ status: goalForecast.status || null,
341
+ };
342
+ }
343
+ return { available: false, type: "single", current: null, target: numFrom(decl.target), unit: null };
344
+ }
345
+
346
+ if (type === "scalar") {
347
+ const baseline = numFrom(decl.baseline);
348
+ const target = numFrom(decl.target);
349
+ const current = scalarCurrent(decl, metricsLog); // best-effort; null until logged
350
+ const progressPct =
351
+ baseline != null && target != null && current != null && target !== baseline
352
+ ? clampPct(Math.round(((current - baseline) / (target - baseline)) * 100))
353
+ : null;
354
+ return {
355
+ available: current != null, type: "scalar",
356
+ baseline, target, current,
357
+ baselineText: decl.baseline || null, targetText: decl.target || null,
358
+ progressPct,
359
+ };
360
+ }
361
+
362
+ if (type === "streak") {
363
+ // streak-parser tracks day-PRESENCE (a daily note exists), NOT whether the
364
+ // declared practice was actually done. Presenting that as the practice streak
365
+ // would be a false signal, so the count stays honest-null until a per-practice
366
+ // source exists; the ring renders its ready/empty state.
367
+ return {
368
+ available: false, type: "streak",
369
+ currentStreak: null, longestStreak: null, todayDone: null,
370
+ note: "activates once the practice is logged",
371
+ };
372
+ }
373
+
374
+ if (type === "chain") {
375
+ const stages = parseChainStages(decl.chain); // [] when no chain math declared
376
+ return {
377
+ available: false, type: "chain",
378
+ stages, bottleneck: null,
379
+ note: stages.length ? "awaiting per-stage numbers" : "declare the chain (revenue -> ... -> daily action)",
380
+ };
381
+ }
382
+
383
+ return { available: false, type };
384
+ }
385
+
386
+ // Pull a leading number out of a prose value ("7.5h sleep" -> 7.5, "78 kg" -> 78).
387
+ function numFrom(text) {
388
+ if (text == null) return null;
389
+ const m = String(text).match(/-?\d+(?:\.\d+)?/);
390
+ return m ? Number(m[0]) : null;
391
+ }
392
+
393
+ function clampPct(n) {
394
+ if (!Number.isFinite(n)) return null;
395
+ return Math.max(0, Math.min(100, n));
396
+ }
397
+
398
+ // Best-effort current value for a scalar domain from the metrics log's latest
399
+ // readings, matched by keyword. Null (honest) when nothing matches.
400
+ function scalarCurrent(decl, metricsLog) {
401
+ if (!metricsLog || metricsLog.available === false) return null;
402
+ const hay = ((decl.target || "") + " " + (decl.baseline || "")).toLowerCase();
403
+ const pick = /sleep/.test(hay) ? metricsLog.sleepLatest
404
+ : /weight|kg|\blb/.test(hay) ? metricsLog.weightLatest
405
+ : /hrv/.test(hay) ? metricsLog.hrvLatest
406
+ : /rhr|resting|heart/.test(hay) ? metricsLog.rhrLatest
407
+ : null;
408
+ return numFrom(pick);
409
+ }
410
+
411
+ // Split a declared chain ("$10k = 100 sales = 1k calls at 10% = 50 DMs/day") into
412
+ // ordered stages. Each stage is a label only; the live per-stage numbers arrive
413
+ // from the logged funnel, so `current` is honest-null here.
414
+ function parseChainStages(chain) {
415
+ if (!chain) return [];
416
+ return String(chain)
417
+ .split(/\s*(?:=|->|→|>)\s*/)
418
+ .map((s) => s.trim())
419
+ .filter(Boolean)
420
+ .slice(0, 6)
421
+ .map((label) => ({ label, current: null }));
422
+ }
423
+
424
+ // parseGoalProgress exported for the menu-bar CLI (cmdPulseBar): the bar's
425
+ // mission number must come from the SAME parse the dashboard shows, never a
426
+ // second implementation that can drift. [owner: glance-number = the bet]
427
+ module.exports = { computeGoalForecast, parseGoalProgress, parseDomainProgress, classifyGoalKind };
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+
3
+ const { goalsPath, safeRead, safeStat } = require("./paths");
4
+
5
+ // Parse Goals.md. Extracts 3-month, 1-year, 10-year goals + expiration dates.
6
+
7
+ function parseGoals(vault) {
8
+ const filePath = goalsPath(vault);
9
+ const text = safeRead(filePath);
10
+ const stat = safeStat(filePath);
11
+ if (!text) return { available: false, filePath };
12
+
13
+ const out = {
14
+ available: true,
15
+ filePath,
16
+ lastUpdated: stat ? stat.mtime : null,
17
+ raw: text,
18
+ horizons: []
19
+ };
20
+
21
+ // Split on ## headers
22
+ const lines = text.split("\n");
23
+ let current = null;
24
+ let buf = [];
25
+ for (const line of lines) {
26
+ const h = line.match(/^##\s+(.+?)\s*$/);
27
+ if (h) {
28
+ if (current) {
29
+ out.horizons.push({ title: current, body: buf.join("\n"), ...analyzeHorizon(buf.join("\n")) });
30
+ }
31
+ current = h[1];
32
+ buf = [];
33
+ } else if (current) {
34
+ buf.push(line);
35
+ }
36
+ }
37
+ if (current) {
38
+ out.horizons.push({ title: current, body: buf.join("\n"), ...analyzeHorizon(buf.join("\n")) });
39
+ }
40
+
41
+ // Identify 3-month, 1-year, 10-year via title heuristic
42
+ const now = Date.now();
43
+ out.expired = [];
44
+ for (const h of out.horizons) {
45
+ if (h.targetDate) {
46
+ const dt = new Date(h.targetDate);
47
+ if (!isNaN(dt.getTime()) && dt.getTime() < now) {
48
+ out.expired.push({ title: h.title, targetDate: h.targetDate, expiredDays: Math.floor((now - dt.getTime()) / 86400000) });
49
+ }
50
+ }
51
+ }
52
+
53
+ return out;
54
+ }
55
+
56
+ function analyzeHorizon(body) {
57
+ const dateMatch = body.match(/\*\*(?:Target Date|Date|By):?\*\*\s*([\d\-/]{8,10})/i);
58
+ const goalsList = body.split("\n")
59
+ .map(l => l.match(/^\s*[-*\d.]+\s+(.+)$/))
60
+ .filter(Boolean).map(m => m[1].trim());
61
+ return {
62
+ targetDate: dateMatch ? dateMatch[1] : null,
63
+ goals: goalsList.slice(0, 12)
64
+ };
65
+ }
66
+
67
+ module.exports = { parseGoals };
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const fs = require("fs");
5
+ const { safeListDir, safeRead, safeStat } = require("./paths");
6
+
7
+ // Parse 02_Areas/Health/*.md as actionable recovery/maintenance protocols.
8
+ // A "protocol" file looks like:
9
+ // # <Title>
10
+ // ## PHASE 1 — <name> (Days 1-7: <date range>)
11
+ // ### Every day (10 min total)
12
+ // - [ ] action one
13
+ // - [ ] action two
14
+ // ## PHASE 2 — ...
15
+ //
16
+ // Output for the dashboard:
17
+ // - title
18
+ // - currentPhase (detected by date range vs today, or first uncompleted)
19
+ // - today's checklist (unchecked items in current phase)
20
+ // - completedToday (checked items in current phase — for progress)
21
+ // - daysIntoPhase / daysRemaining (if dates present)
22
+ //
23
+ // Forgiving: never throws. Empty/missing → { available: false }.
24
+
25
+ function healthDir(vault) {
26
+ return path.join(vault, "02_Areas", "Health");
27
+ }
28
+
29
+ function parseDateRange(line) {
30
+ // Match "(May 12-19)" or "(Days 1-7: May 12-19)" or "(May 20 → Jun 30)"
31
+ // Returns { startISO, endISO } or null
32
+ const m = line.match(/\((?:[^:)]*?:)?\s*([A-Z][a-z]{2})\s+(\d{1,2})(?:\s*[-–→]\s*(?:([A-Z][a-z]{2})\s+)?(\d{1,2}))?\)/);
33
+ if (!m) return null;
34
+ const monthMap = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
35
+ const year = new Date().getFullYear();
36
+ const sm = monthMap[m[1]];
37
+ const sd = parseInt(m[2], 10);
38
+ const em = m[3] ? monthMap[m[3]] : sm;
39
+ const ed = m[4] ? parseInt(m[4], 10) : sd;
40
+ if (sm === undefined || em === undefined) return null;
41
+ return {
42
+ start: new Date(year, sm, sd),
43
+ end: new Date(year, em, ed)
44
+ };
45
+ }
46
+
47
+ function parseProtocolFile(filePath) {
48
+ const text = safeRead(filePath);
49
+ if (!text) return null;
50
+ const stat = safeStat(filePath);
51
+
52
+ // Title from first H1, fallback to filename
53
+ const titleMatch = text.match(/^#\s+(.+)$/m);
54
+ const title = titleMatch ? titleMatch[1].trim() : path.basename(filePath, ".md");
55
+
56
+ // Find all PHASE blocks. Phases are H2 headings that contain "PHASE" or similar.
57
+ const lines = text.split("\n");
58
+ const phases = [];
59
+ let currentPhase = null;
60
+
61
+ for (const raw of lines) {
62
+ const line = raw.trimEnd();
63
+ const phaseMatch = line.match(/^##\s+(.*PHASE.*)$/i);
64
+ if (phaseMatch) {
65
+ if (currentPhase) phases.push(currentPhase);
66
+ currentPhase = {
67
+ heading: phaseMatch[1].trim(),
68
+ dates: parseDateRange(phaseMatch[1]),
69
+ items: [],
70
+ done: 0,
71
+ total: 0
72
+ };
73
+ continue;
74
+ }
75
+ if (!currentPhase) continue;
76
+ // Skip subheadings — items only
77
+ if (line.startsWith("##")) continue;
78
+ // Checkbox lines
79
+ const cb = line.match(/^[-*]\s+\[([ xX])\]\s+(.+)$/);
80
+ if (cb) {
81
+ const checked = cb[1].toLowerCase() === "x";
82
+ currentPhase.items.push({ text: cb[2].trim(), checked });
83
+ currentPhase.total++;
84
+ if (checked) currentPhase.done++;
85
+ }
86
+ }
87
+ if (currentPhase) phases.push(currentPhase);
88
+
89
+ // Determine the current phase: one whose date range contains today,
90
+ // OR the first phase with any unchecked items if no date hits.
91
+ const today = new Date();
92
+ today.setHours(0, 0, 0, 0);
93
+ let current = phases.find(p => p.dates && p.dates.start <= today && today <= p.dates.end);
94
+ if (!current) current = phases.find(p => p.done < p.total);
95
+ if (!current && phases.length) current = phases[phases.length - 1];
96
+
97
+ // Days remaining in current phase
98
+ let daysRemaining = null;
99
+ let daysIntoPhase = null;
100
+ if (current && current.dates) {
101
+ daysRemaining = Math.max(0, Math.ceil((current.dates.end - today) / (1000 * 60 * 60 * 24)));
102
+ daysIntoPhase = Math.max(0, Math.ceil((today - current.dates.start) / (1000 * 60 * 60 * 24)));
103
+ }
104
+
105
+ return {
106
+ available: true,
107
+ title,
108
+ filePath,
109
+ lastUpdated: stat ? stat.mtime : null,
110
+ phases,
111
+ current,
112
+ daysRemaining,
113
+ daysIntoPhase
114
+ };
115
+ }
116
+
117
+ function parseHealthProtocols(vault) {
118
+ const dir = healthDir(vault);
119
+ const entries = safeListDir(dir, { filesOnly: true, extension: ".md", skipUnderscore: true });
120
+ const protocols = [];
121
+ for (const entry of entries) {
122
+ const full = path.join(dir, entry.name);
123
+ const p = parseProtocolFile(full);
124
+ if (p && p.available) protocols.push(p);
125
+ }
126
+ return {
127
+ available: protocols.length > 0,
128
+ dir,
129
+ protocols
130
+ };
131
+ }
132
+
133
+ module.exports = { parseHealthProtocols };
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+ const os = require("os");
5
+ const fs = require("fs");
6
+ const { exists, safeListDir, safeStat } = require("./paths");
7
+
8
+ // Read recent hook activity from ~/.claude/tmp/mover_* markers + session-history if available.
9
+
10
+ function readHookActivity() {
11
+ const home = os.homedir();
12
+ const claudeTmp = path.join(home, ".claude", "tmp");
13
+ const codexTmp = path.join(home, ".codex", "tmp");
14
+ const geminiTmp = path.join(home, ".gemini", "tmp");
15
+
16
+ const out = { available: true, recentMarkers: [], hookStats: { sessionStart: 0, log: 0, engineHealth: 0, planSync: 0 }, lastFire: null };
17
+
18
+ for (const dir of [claudeTmp, codexTmp, geminiTmp]) {
19
+ if (!exists(dir)) continue;
20
+ const entries = safeListDir(dir, { filesOnly: true });
21
+ for (const e of entries) {
22
+ if (!e.name.startsWith("mover_")) continue;
23
+ const stat = safeStat(e.path);
24
+ if (!stat) continue;
25
+ const ageDays = (Date.now() - stat.mtime.getTime()) / 86400000;
26
+ if (ageDays > 7) continue;
27
+ out.recentMarkers.push({
28
+ name: e.name,
29
+ agent: dir.includes(".claude") ? "Claude" : dir.includes(".codex") ? "Codex" : "Gemini",
30
+ mtime: stat.mtime,
31
+ ageDays: Math.floor(ageDays * 24 * 60) / (24 * 60),
32
+ ageHours: Math.floor(ageDays * 24 * 10) / 10
33
+ });
34
+ // Categorize
35
+ if (e.name.includes("logprompt") || e.name.includes("logdone")) out.hookStats.log++;
36
+ else if (e.name.includes("session-start") || e.name.includes("sessionstart")) out.hookStats.sessionStart++;
37
+ else if (e.name.includes("engine") || e.name.includes("health")) out.hookStats.engineHealth++;
38
+ else if (e.name.includes("plan-sync")) out.hookStats.planSync++;
39
+ if (!out.lastFire || stat.mtime > out.lastFire) out.lastFire = stat.mtime;
40
+ }
41
+ }
42
+ out.recentMarkers.sort((a, b) => b.mtime - a.mtime);
43
+ out.recentMarkers = out.recentMarkers.slice(0, 10);
44
+
45
+ return out;
46
+ }
47
+
48
+ module.exports = { readHookActivity };