mover-os 4.7.6 → 4.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/install.js +763 -70
  2. package/package.json +15 -3
  3. package/src/dashboard/build.js +1503 -0
  4. package/src/dashboard/dashboard.js +276 -0
  5. package/src/dashboard/index.js +319 -0
  6. package/src/dashboard/lib/activation-log.js +297 -0
  7. package/src/dashboard/lib/active-context-parser.js +177 -0
  8. package/src/dashboard/lib/agent-command.js +93 -0
  9. package/src/dashboard/lib/agent-detect.js +255 -0
  10. package/src/dashboard/lib/agent-detector.js +92 -0
  11. package/src/dashboard/lib/agent-session.js +462 -0
  12. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  13. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  14. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  15. package/src/dashboard/lib/config-parser.js +109 -0
  16. package/src/dashboard/lib/connect-recommender.js +131 -0
  17. package/src/dashboard/lib/correlations-parser.js +231 -0
  18. package/src/dashboard/lib/daily-note-resolver.js +211 -0
  19. package/src/dashboard/lib/date-utils.js +35 -0
  20. package/src/dashboard/lib/distribution-parser.js +78 -0
  21. package/src/dashboard/lib/dossier-parser.js +64 -0
  22. package/src/dashboard/lib/drift-history.js +83 -0
  23. package/src/dashboard/lib/drift-score.js +119 -0
  24. package/src/dashboard/lib/engine-health.js +170 -0
  25. package/src/dashboard/lib/engine-writer.js +1685 -0
  26. package/src/dashboard/lib/execution-plan.js +125 -0
  27. package/src/dashboard/lib/experiments-parser.js +429 -0
  28. package/src/dashboard/lib/feed-parser.js +294 -0
  29. package/src/dashboard/lib/forked-future.js +60 -0
  30. package/src/dashboard/lib/goal-forecast.js +304 -0
  31. package/src/dashboard/lib/goals-parser.js +67 -0
  32. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  33. package/src/dashboard/lib/hook-activity.js +48 -0
  34. package/src/dashboard/lib/hook-indexer.js +169 -0
  35. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  36. package/src/dashboard/lib/identity-parser.js +85 -0
  37. package/src/dashboard/lib/ingestion.js +418 -0
  38. package/src/dashboard/lib/library-indexer-v2.js +212 -0
  39. package/src/dashboard/lib/library-indexer.js +105 -0
  40. package/src/dashboard/lib/log-activation.sh +61 -0
  41. package/src/dashboard/lib/memory-curator.js +97 -0
  42. package/src/dashboard/lib/memory-gardener.js +177 -0
  43. package/src/dashboard/lib/memory-gepa.js +102 -0
  44. package/src/dashboard/lib/memory-index.js +470 -0
  45. package/src/dashboard/lib/memory-rerank.js +72 -0
  46. package/src/dashboard/lib/memory-text.js +136 -0
  47. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  48. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  49. package/src/dashboard/lib/onboarding-forge.js +70 -0
  50. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  51. package/src/dashboard/lib/override-summary.js +73 -0
  52. package/src/dashboard/lib/paths.js +192 -0
  53. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  54. package/src/dashboard/lib/phantom-strategy.js +129 -0
  55. package/src/dashboard/lib/project-scanner.js +121 -0
  56. package/src/dashboard/lib/promise-wall.js +88 -0
  57. package/src/dashboard/lib/record-score.js +173 -0
  58. package/src/dashboard/lib/redaction.js +140 -0
  59. package/src/dashboard/lib/refusal-parser.js +44 -0
  60. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  61. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  62. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  63. package/src/dashboard/lib/run-registry.js +226 -0
  64. package/src/dashboard/lib/safe-write.js +63 -0
  65. package/src/dashboard/lib/session-log-parser.js +145 -0
  66. package/src/dashboard/lib/session-time-parser.js +158 -0
  67. package/src/dashboard/lib/skill-index.js +171 -0
  68. package/src/dashboard/lib/skill-indexer.js +118 -0
  69. package/src/dashboard/lib/skill-recommender.js +689 -0
  70. package/src/dashboard/lib/strategy-parser.js +245 -0
  71. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  72. package/src/dashboard/lib/streak-parser.js +95 -0
  73. package/src/dashboard/lib/suggested-now.js +254 -0
  74. package/src/dashboard/lib/tool-awareness.js +125 -0
  75. package/src/dashboard/lib/transcript-parser.js +331 -0
  76. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  77. package/src/dashboard/lib/view-generator.js +163 -0
  78. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  79. package/src/dashboard/lib/walkthrough-script.js +140 -0
  80. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  81. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  82. package/src/dashboard/server.js +2024 -0
  83. package/src/dashboard/shortcut.js +0 -0
  84. package/src/dashboard/static/setup-poc.html +306 -0
  85. package/src/dashboard/static/walkthrough-poc.html +580 -0
  86. package/src/dashboard/styles.css +1201 -0
  87. package/src/dashboard/templates/index.html +278 -0
  88. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  89. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  90. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  91. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  92. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
  93. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
  94. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
  95. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  96. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  97. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  98. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  99. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  100. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  101. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  102. package/src/dashboard/ui/dist/icon.svg +4 -0
  103. package/src/dashboard/ui/dist/index.html +18 -0
  104. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  105. package/src/dashboard/ui/dist/registerSW.js +1 -0
  106. package/src/dashboard/ui/dist/sw.js +1 -0
  107. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,689 @@
1
+ "use strict";
2
+
3
+ // rewind-snapshots.js — re-render the record (bet / strategy / 4-numbers /
4
+ // that-day) as of ANY past date, from DATED FACTS ONLY.
5
+ //
6
+ // "Rewind" answers: "What did my record look like on 2026-05-19?" by
7
+ // collecting every dated fact the Engine actually recorded and emitting one
8
+ // keyframe per date. The UI scrubs a slider; for any chosen date it shows the
9
+ // LAST keyframe at-or-before that date (the state of the world as of then).
10
+ //
11
+ // WHY pure + dated-only: this is a HISTORY view. Interpolating a streak or a
12
+ // post-count for a date that has no measurement would invent a past that never
13
+ // happened — the exact lie T261 (false-zero honesty) hunted down. So:
14
+ // - nums / calib are null unless a dated row sits AT/NEAR that keyframe.
15
+ // - We NEVER call drift-history build() or any WRITE path — those mutate
16
+ // TODAY's record (recordSnapshot writes ~/.mover/drift-history.json). A
17
+ // history reader that writes is a corruption vector. buildRewind only READS.
18
+ // - Pre-moverDay1 facts → gapBefore (labelled), never silently dropped.
19
+ // - Anything unreadable → available:false with a reason. Label every absence.
20
+ //
21
+ // SOURCES (dated facts ONLY):
22
+ // 1. git log of Engine files (commit author-dates) — WHEN AVAILABLE. Many
23
+ // live vaults keep 02_Areas/Engine/ git-ignored (it's protected user
24
+ // data); then this source contributes nothing and we say so via
25
+ // meta.gitAvailable=false. We scope git to the Engine dir and shell out
26
+ // read-only; any failure is swallowed.
27
+ // 2. Strategy.md `## LAB REPORT` / CHANGELOG datestamps — bet+strategy keyframes.
28
+ // 3. Graveyard kill-dates (Active_Context "THE GRAVEYARD" + Strategy "ARCHIVED
29
+ // HYPOTHESES") — "Killed 2026-05-19" / "Killed Feb 18".
30
+ // 4. override_history[] from the pattern-manifest (NOT Override_Log.md — that
31
+ // file is never written; v6-PLAN-AMENDMENTS.md fix 7 makes the manifest
32
+ // override_history the single source).
33
+ // 5. Metrics_Log.md dated rows.
34
+ // 6. Daily Notes — per-date plan (Focus) / got (tasks done) — `dailies` map.
35
+
36
+ const { execFileSync } = require("child_process");
37
+ const path = require("path");
38
+
39
+ const {
40
+ strategyPath,
41
+ activeContextPath,
42
+ metricsLogPath,
43
+ engineDir,
44
+ dailiesDir,
45
+ safeRead,
46
+ safeReadJson,
47
+ safeStat,
48
+ safeListDir,
49
+ moverPatternManifestPath,
50
+ } = require("./paths");
51
+ const { localDateKey } = require("./date-utils");
52
+
53
+ // Mover OS "day 1" — the operational floor for the record. Facts before this
54
+ // are real but pre-history; they go in gapBefore, not the scrub timeline.
55
+ const MOVER_DAY1 = "2026-04-30";
56
+
57
+ // Source rank for the deterministic tiebreak when two keyframes share a date.
58
+ // Lower = earlier in the stable sort. strategy/bet outranks graveyard outranks
59
+ // override outranks metrics outranks git outranks daily, so a date's "headline"
60
+ // fact lands first. Anything unknown sorts last.
61
+ const SOURCE_RANK = {
62
+ strategy: 0,
63
+ bet: 0,
64
+ graveyard: 1,
65
+ override: 2,
66
+ metrics: 3,
67
+ git: 4,
68
+ daily: 5,
69
+ };
70
+
71
+ function buildRewind({ vault } = {}) {
72
+ // Total-failure contract: no vault → unavailable, fully labelled, no throw.
73
+ if (!vault || typeof vault !== "string") {
74
+ return unavailable("no vault path provided");
75
+ }
76
+
77
+ const meta = {
78
+ gitAvailable: false,
79
+ gitError: null,
80
+ sourcesRead: {},
81
+ sourcesMissing: [],
82
+ };
83
+
84
+ let keyframes = [];
85
+
86
+ // Each collector is independently wrapped: one unreadable source must not
87
+ // sink the whole rewind. We only flip available:false when EVERYTHING is
88
+ // unreadable (checked at the end).
89
+ collect(meta, "strategy", () => collectStrategy(vault), keyframes);
90
+ collect(meta, "graveyard", () => collectGraveyard(vault), keyframes);
91
+ collect(meta, "override", () => collectOverrides(), keyframes);
92
+ collect(meta, "metrics", () => collectMetrics(vault), keyframes);
93
+ collect(meta, "git", () => collectGit(vault, meta), keyframes);
94
+
95
+ // Daily Notes: per-date plan/got. Returns BOTH a keyframe list (the dated
96
+ // existence of a note) and the `dailies` lookup map the UI reads for that-day
97
+ // detail. Wrapped the same way; never throws.
98
+ let dailies = {};
99
+ collect(meta, "daily", () => {
100
+ const r = collectDailies(vault);
101
+ dailies = r.dailies;
102
+ return r.keyframes;
103
+ }, keyframes);
104
+
105
+ // Partition pre-moverDay1 facts into gapBefore (labelled), keep the rest.
106
+ const onTimeline = [];
107
+ let gapBeforeCount = 0;
108
+ for (const kf of keyframes) {
109
+ if (!kf || !kf.date) continue;
110
+ if (kf.date < MOVER_DAY1) { gapBeforeCount++; continue; }
111
+ onTimeline.push(kf);
112
+ }
113
+
114
+ // Deterministic ordering: date ASC, then SOURCE_RANK, then a fully stable
115
+ // tiebreak on a per-keyframe signature so equal (date,source) pairs never
116
+ // reorder run-to-run. Array.prototype.sort is not guaranteed stable across
117
+ // engines for large arrays, so we encode the original index into the
118
+ // comparator — true determinism regardless of engine.
119
+ const indexed = onTimeline.map((kf, i) => ({ kf, i, sig: keyframeSig(kf) }));
120
+ indexed.sort((a, b) => {
121
+ if (a.kf.date !== b.kf.date) return a.kf.date < b.kf.date ? -1 : 1;
122
+ const ra = SOURCE_RANK[a.kf.source] ?? 99;
123
+ const rb = SOURCE_RANK[b.kf.source] ?? 99;
124
+ if (ra !== rb) return ra - rb;
125
+ if (a.sig !== b.sig) return a.sig < b.sig ? -1 : 1;
126
+ return a.i - b.i; // last resort: insertion order
127
+ });
128
+ const sorted = indexed.map((x) => x.kf);
129
+
130
+ // After sorting, enrich each keyframe's `nums`/`calib` from dated rows NEAR
131
+ // that date — only when a real measurement exists in a tight window. No
132
+ // window hit → leave null. This is the no-interpolation guard.
133
+ const numIndex = buildNumIndex(vault, meta);
134
+ for (const kf of sorted) {
135
+ if (kf.nums === undefined) kf.nums = numsNear(kf.date, numIndex);
136
+ if (kf.calib === undefined) kf.calib = calibNear(kf.date, numIndex);
137
+ }
138
+
139
+ // Carry-forward `since` (days since the prior keyframe) — derived purely from
140
+ // the dated keyframe gaps, not invented.
141
+ for (let i = 0; i < sorted.length; i++) {
142
+ if (sorted[i].since === undefined) {
143
+ sorted[i].since = i === 0 ? null : daysBetween(sorted[i - 1].date, sorted[i].date);
144
+ }
145
+ }
146
+
147
+ // Availability is gated on VAULT-SCOPED sources only. The override source
148
+ // reads the GLOBAL pattern-manifest (~/.mover), which exists regardless of
149
+ // vault — so a bogus/empty vault could otherwise look "available" off
150
+ // ambient override rows that aren't this vault's record. The record lives in
151
+ // the vault; if no vault-scoped dated fact is readable, the rewind is
152
+ // unavailable (labelled), even when global overrides parsed fine.
153
+ const VAULT_SCOPED = ["strategy", "graveyard", "metrics", "daily", "git"];
154
+ const anyVaultSourceRead = VAULT_SCOPED.some((s) => meta.sourcesRead[s] === true);
155
+ const vaultScopedKeyframes = sorted.filter((kf) => kf.source !== "override").length;
156
+
157
+ if (!anyVaultSourceRead || (sorted.length === 0) || vaultScopedKeyframes === 0) {
158
+ const out = unavailable("no readable vault-scoped dated facts");
159
+ out.meta = meta;
160
+ out.gapBefore = gapBeforeCount;
161
+ return out;
162
+ }
163
+
164
+ const start = sorted.length ? sorted[0].date : null;
165
+ // `end` is the newest dated FACT, capped at today — a rewind never points to
166
+ // the future even if a fact carries a future-dated deadline string.
167
+ const todayKey = localDateKey(new Date());
168
+ let end = sorted.length ? sorted[sorted.length - 1].date : null;
169
+ if (end && end > todayKey) end = todayKey;
170
+
171
+ // gapAfter: days between the last recorded fact and now — the "no fresh
172
+ // record" tail the UI labels (e.g. "12 days since your last logged fact").
173
+ const gapAfter = end ? daysBetween(end, todayKey) : null;
174
+
175
+ return {
176
+ available: true,
177
+ start,
178
+ end,
179
+ moverDay1: MOVER_DAY1,
180
+ keyframes: sorted,
181
+ dailies,
182
+ gapBefore: gapBeforeCount,
183
+ gapAfter,
184
+ meta,
185
+ };
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Collectors — each returns an array of keyframes (or [] ); never throws on its
190
+ // own (the collect() wrapper is the final safety net regardless).
191
+ // ---------------------------------------------------------------------------
192
+
193
+ // Strategy.md: the LAB REPORT / CHANGELOG bullets carry a `[YYYY-MM-DD]` stamp
194
+ // and a one-line summary. The first line of the summary that starts with a
195
+ // **bold** label is treated as the "bet"/"strategy" headline for that date.
196
+ function collectStrategy(vault) {
197
+ const text = safeRead(strategyPath(vault));
198
+ if (!text) return null; // null = source missing (distinct from [] = present-but-empty)
199
+ const out = [];
200
+
201
+ // Reuse the strategy-parser's LAB REPORT shape: lines like
202
+ // `* **[2026-05-19]:** **V4.0 — ...** rest`. We match the date + the rest,
203
+ // then pull the first bold token as the bet label.
204
+ const lines = text.split("\n");
205
+ for (const line of lines) {
206
+ const m = line.match(/^[-*]\s+\*\*\[(\d{4}-\d{2}-\d{2})\]:?\*\*\s*(.+)$/);
207
+ if (!m) continue;
208
+ const date = m[1];
209
+ const rest = m[2].trim();
210
+ const boldM = rest.match(/\*\*([^*]+?)\*\*/);
211
+ const headline = boldM ? boldM[1].trim() : firstSentence(rest);
212
+ out.push({
213
+ date,
214
+ source: "strategy",
215
+ ev: "Strategy logged",
216
+ bet: stripMd(headline) || null,
217
+ strategy: stripMd(headline) || null,
218
+ since: undefined,
219
+ nums: undefined,
220
+ calib: undefined,
221
+ files: [{ f: "Strategy.md", src: "LAB REPORT" }],
222
+ });
223
+ }
224
+ return out;
225
+ }
226
+
227
+ // Graveyard kill-dates across BOTH Active_Context (THE GRAVEYARD) and
228
+ // Strategy.md (ARCHIVED HYPOTHESES). Date forms seen in the wild:
229
+ // "Killed 2026-05-19" | "(Killed Feb 18)" | "killed 2026-05-19"
230
+ function collectGraveyard(vault) {
231
+ const sources = [
232
+ { text: safeRead(activeContextPath(vault)), f: "Active_Context.md", src: "THE GRAVEYARD" },
233
+ { text: safeRead(strategyPath(vault)), f: "Strategy.md", src: "ARCHIVED HYPOTHESES" },
234
+ ];
235
+ if (sources.every((s) => !s.text)) return null;
236
+
237
+ const out = [];
238
+ for (const s of sources) {
239
+ if (!s.text) continue;
240
+ const lines = s.text.split("\n");
241
+ for (const line of lines) {
242
+ if (!/💀|killed/i.test(line)) continue;
243
+ const date = parseKillDate(line);
244
+ if (!date) continue; // undated graveyard entry — can't place it, skip (labelled by omission)
245
+ const label = killLabel(line);
246
+ out.push({
247
+ date,
248
+ source: "graveyard",
249
+ ev: "Idea killed",
250
+ bet: label || null,
251
+ strategy: undefined,
252
+ since: undefined,
253
+ nums: undefined,
254
+ calib: undefined,
255
+ files: [{ f: s.f, src: s.src }],
256
+ });
257
+ }
258
+ }
259
+ return out;
260
+ }
261
+
262
+ // override_history from the pattern-manifest (the ONLY override source —
263
+ // v6-PLAN-AMENDMENTS fix 7). Date may be `2026-05-24` or `2026-05-24T02:00`;
264
+ // some entries use `timestamp`. Level may be "L3" or 3. All forgiving.
265
+ function collectOverrides() {
266
+ const manifest = safeReadJson(moverPatternManifestPath());
267
+ if (!manifest) return null;
268
+ const hist = manifest.override_history || manifest.overrideHistory;
269
+ if (!Array.isArray(hist)) return []; // manifest present, no overrides logged
270
+ const out = [];
271
+ for (const o of hist) {
272
+ if (!o || typeof o !== "object") continue;
273
+ const date = isoDay(o.date || o.timestamp);
274
+ if (!date) continue;
275
+ const level = normLevel(o.level);
276
+ const what = stripMd(o.what || o.scope || o.type || "");
277
+ out.push({
278
+ date,
279
+ source: "override",
280
+ ev: level ? `Override ${level}` : "Override",
281
+ bet: undefined,
282
+ strategy: undefined,
283
+ since: undefined,
284
+ nums: undefined,
285
+ calib: undefined,
286
+ files: [{ f: "pattern-manifest.json", src: "override_history" }],
287
+ // extra context fields the UI may show; not part of the contract shape
288
+ level: level || null,
289
+ what: what || null,
290
+ });
291
+ }
292
+ return out;
293
+ }
294
+
295
+ // Metrics_Log.md dated rows → light keyframes (a fact existed that day). The
296
+ // numeric enrichment (readiness etc.) happens in numsNear; here we only mark
297
+ // the date so the scrubber has a stop. Dedupe per date.
298
+ function collectMetrics(vault) {
299
+ const text = safeRead(metricsLogPath(vault));
300
+ if (!text) return null;
301
+ const seen = new Set();
302
+ const out = [];
303
+ const lines = text.split("\n");
304
+ for (const line of lines) {
305
+ // "- 2026-04-09 → score 75" | "| 2026-05-13 | 6 | ..." | "- 2026-04-24: ..."
306
+ const m = line.match(/(?:^[-*]\s*|^\|\s*)\[?(\d{4}-\d{2}-\d{2})\]?/);
307
+ if (!m) continue;
308
+ const date = m[1];
309
+ if (seen.has(date)) continue;
310
+ seen.add(date);
311
+ out.push({
312
+ date,
313
+ source: "metrics",
314
+ ev: "Metrics row",
315
+ bet: undefined,
316
+ strategy: undefined,
317
+ since: undefined,
318
+ nums: undefined,
319
+ calib: undefined,
320
+ files: [{ f: "Metrics_Log.md", src: "dated row" }],
321
+ });
322
+ }
323
+ return out;
324
+ }
325
+
326
+ // git log of the Engine directory — commit author-dates as keyframes. When the
327
+ // Engine dir isn't under git (common: it's protected/ignored), this returns []
328
+ // and sets meta.gitAvailable=false. Read-only `git log`; never mutates.
329
+ function collectGit(vault, meta) {
330
+ const dir = engineDir(vault);
331
+ let stdout;
332
+ try {
333
+ stdout = execFileSync(
334
+ "git",
335
+ ["-C", dir, "log", "--format=%aI|%s", "--", "."],
336
+ { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000 }
337
+ );
338
+ } catch (e) {
339
+ // Not a repo / git absent / path outside repo — all expected. Label it.
340
+ meta.gitAvailable = false;
341
+ meta.gitError = (e && e.message ? String(e.message) : "git unavailable").slice(0, 200);
342
+ return []; // present-as-source but yielded nothing; keeps available:true via other sources
343
+ }
344
+ meta.gitAvailable = true;
345
+ const out = [];
346
+ const seen = new Set();
347
+ for (const line of stdout.split("\n")) {
348
+ const idx = line.indexOf("|");
349
+ if (idx < 0) continue;
350
+ const iso = line.slice(0, idx).trim();
351
+ const subj = line.slice(idx + 1).trim();
352
+ const date = isoDay(iso);
353
+ if (!date) continue;
354
+ // Collapse multiple commits on the same day to one keyframe (the scrubber
355
+ // wants one stop per day; subject of the first kept).
356
+ if (seen.has(date)) continue;
357
+ seen.add(date);
358
+ out.push({
359
+ date,
360
+ source: "git",
361
+ ev: "Engine commit",
362
+ bet: undefined,
363
+ strategy: undefined,
364
+ since: undefined,
365
+ nums: undefined,
366
+ calib: undefined,
367
+ files: [{ f: "Engine/", src: "git commit" }],
368
+ subject: subj || null,
369
+ });
370
+ }
371
+ return out;
372
+ }
373
+
374
+ // Daily Notes → per-date plan/got map + one existence keyframe per note. Walks
375
+ // Dailies/YYYY-MM/Daily - YYYY-MM-DD.md. Plan = Focus line; got = count of
376
+ // done tasks. Both real, both from the note itself — no inference.
377
+ function collectDailies(vault) {
378
+ const root = dailiesDir(vault);
379
+ const dailies = {};
380
+ const keyframes = [];
381
+ const months = safeListDir(root, { dirsOnly: true });
382
+ if (!months.length) {
383
+ // Distinguish missing dir from empty: if the dir itself is unreadable,
384
+ // signal via throw-equivalent (return null path). Here we treat "no month
385
+ // folders" as an empty-but-present source.
386
+ if (!safeStat(root)) return { dailies, keyframes: null }; // null → source missing
387
+ return { dailies, keyframes: [] };
388
+ }
389
+ for (const mo of months) {
390
+ const files = safeListDir(mo.path, { extension: ".md" });
391
+ for (const file of files) {
392
+ const dm = file.name.match(/Daily - (\d{4}-\d{2}-\d{2})\.md$/);
393
+ if (!dm) continue;
394
+ const date = dm[1];
395
+ const text = safeRead(file.path, "");
396
+ const detail = analyzeDaily(text);
397
+ dailies[date] = {
398
+ date,
399
+ path: file.path,
400
+ plan: detail.focus, // may be null — labelled absence, never guessed
401
+ got: detail.tasksDone,
402
+ tasksTotal: detail.tasksTotal,
403
+ };
404
+ keyframes.push({
405
+ date,
406
+ source: "daily",
407
+ ev: "Daily note",
408
+ bet: undefined,
409
+ strategy: undefined,
410
+ since: undefined,
411
+ nums: undefined,
412
+ calib: undefined,
413
+ files: [{ f: `Daily - ${date}.md`, src: "Dailies" }],
414
+ });
415
+ }
416
+ }
417
+ return { dailies, keyframes };
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // nums / calib enrichment — dated rows ONLY, tight window, NEVER interpolated.
422
+ // ---------------------------------------------------------------------------
423
+
424
+ // Build a date→{posts,convs,streak,overrides,calib} index from the Metrics_Log
425
+ // performance table + override_history + daily streak chain. Only EXACT dated
426
+ // rows populate it. numsNear/calibNear then do a SAME-DAY-ONLY lookup (window
427
+ // 0) so we never smear a measurement across days.
428
+ function buildNumIndex(vault, meta) {
429
+ const idx = {};
430
+ const ensure = (d) => (idx[d] || (idx[d] = { posts: null, convs: null, streak: null, overrides: null, calib: null }));
431
+
432
+ // 1) Metrics_Log performance table rows:
433
+ // | 2026-05-13 | 6 | 0 strategic / ~7 off-plan | 0/4 | 0% | 0/10 | 1/10 | ...
434
+ // Columns vary by version; we pull the two "x/10" cells as DM-convos + reels
435
+ // ONLY when the row clearly has them. Conservative: if the shape doesn't
436
+ // match, we leave nums null rather than mis-map a column.
437
+ const mtext = safeRead(metricsLogPath(vault));
438
+ if (mtext) {
439
+ for (const line of mtext.split("\n")) {
440
+ const m = line.match(/^\|\s*(\d{4}-\d{2}-\d{2})\s*\|(.+)$/);
441
+ if (!m) continue;
442
+ const date = m[1];
443
+ const cells = m[2].split("|").map((c) => c.trim());
444
+ const e = ensure(date);
445
+ // Find "N/10" cells — these are the distribution counts in this table.
446
+ const tens = cells
447
+ .map((c) => {
448
+ const t = c.match(/^(\d+)\s*\/\s*10$/);
449
+ return t ? parseInt(t[1], 10) : null;
450
+ })
451
+ .filter((v) => v != null);
452
+ if (tens.length >= 2) {
453
+ e.convs = tens[0]; // DMs/convos column
454
+ e.posts = tens[1]; // reels/posts column
455
+ } else if (tens.length === 1) {
456
+ e.posts = tens[0];
457
+ }
458
+ }
459
+ }
460
+
461
+ // 2) override counts per day from the manifest.
462
+ const manifest = safeReadJson(moverPatternManifestPath());
463
+ const hist = manifest && (manifest.override_history || manifest.overrideHistory);
464
+ if (Array.isArray(hist)) {
465
+ for (const o of hist) {
466
+ const d = isoDay(o && (o.date || o.timestamp));
467
+ if (!d) continue;
468
+ const e = ensure(d);
469
+ e.overrides = (e.overrides || 0) + 1;
470
+ }
471
+ }
472
+
473
+ // 3) streak — consecutive Daily Notes ending on each note's date. Computed
474
+ // from the REAL set of note dates (existence), not invented. We build the
475
+ // date set first, then for each note date count the back-run.
476
+ const noteDates = collectDailyDateSet(vault);
477
+ for (const d of noteDates) {
478
+ const e = ensure(d);
479
+ e.streak = backRun(d, noteDates);
480
+ }
481
+
482
+ return idx;
483
+ }
484
+
485
+ // SAME-DAY ONLY (window 0). Returns the {posts,convs,streak,overrides} object
486
+ // ONLY when the date carries a real per-day MEASUREMENT — a distribution count
487
+ // (posts/convs from the Metrics table) or a logged override that day. A bare
488
+ // `streak` does NOT qualify: streak is a continuity value computable for every
489
+ // note-day, so triggering on it would make nums non-null on ~every keyframe and
490
+ // dissolve the "this day has a real record" signal (false-zero discipline,
491
+ // T261). Streak rides along as context only when a measurement already exists.
492
+ // No measurement → null. This is the hard no-interpolation boundary.
493
+ function numsNear(date, numIndex) {
494
+ const e = numIndex[date];
495
+ if (!e) return null;
496
+ const measured = e.posts != null || e.convs != null || e.overrides != null;
497
+ if (!measured) return null;
498
+ return {
499
+ posts: e.posts,
500
+ convs: e.convs,
501
+ streak: e.streak, // context, present only because a measurement qualified the day
502
+ overrides: e.overrides,
503
+ };
504
+ }
505
+
506
+ function calibNear(date, numIndex) {
507
+ const e = numIndex[date];
508
+ if (!e || e.calib == null) return null;
509
+ return e.calib;
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // Small parsing / date helpers
514
+ // ---------------------------------------------------------------------------
515
+
516
+ // Run a collector under a label, recording read/missing status into meta and
517
+ // appending its keyframes. null return = source file missing; [] = present but
518
+ // empty; thrown = unreadable (recorded as read:false). Never propagates.
519
+ function collect(meta, label, fn, acc) {
520
+ try {
521
+ const res = fn();
522
+ if (res === null) {
523
+ meta.sourcesRead[label] = false;
524
+ meta.sourcesMissing.push(label);
525
+ return;
526
+ }
527
+ meta.sourcesRead[label] = true;
528
+ if (Array.isArray(res)) {
529
+ for (const kf of res) if (kf && kf.date) acc.push(kf);
530
+ }
531
+ } catch (e) {
532
+ meta.sourcesRead[label] = false;
533
+ meta.sourcesMissing.push(label);
534
+ }
535
+ }
536
+
537
+ function unavailable(reason) {
538
+ return {
539
+ available: false,
540
+ reason,
541
+ start: null,
542
+ end: null,
543
+ moverDay1: MOVER_DAY1,
544
+ keyframes: [],
545
+ dailies: {},
546
+ gapBefore: 0,
547
+ gapAfter: null,
548
+ };
549
+ }
550
+
551
+ // Coerce an ISO-ish timestamp ("2026-05-24T02:00", "2026-05-24") to YYYY-MM-DD,
552
+ // or null. Pure string slice when already date-prefixed (avoids TZ shifts that
553
+ // `new Date(...)` would introduce on a date-only string).
554
+ function isoDay(v) {
555
+ if (!v || typeof v !== "string") return null;
556
+ const m = v.match(/^(\d{4})-(\d{2})-(\d{2})/);
557
+ return m ? `${m[1]}-${m[2]}-${m[3]}` : null;
558
+ }
559
+
560
+ // Kill-date out of a graveyard line. ISO first ("Killed 2026-05-19"); then a
561
+ // "Killed Mon DD(, YYYY)?" form ("(Killed Feb 18)") via the month map. Year
562
+ // defaults to the current year when absent — but only for placing a same-year
563
+ // kill; cross-year ambiguity stays as-is (these are recent records).
564
+ function parseKillDate(line) {
565
+ const iso = line.match(/\b(?:killed|died)\b[^0-9]*\b(\d{4}-\d{2}-\d{2})\b/i);
566
+ if (iso) return iso[1];
567
+ const mo = line.match(/\b(?:killed|died)\b[^A-Za-z]*\b([A-Za-z]{3,9})\s+(\d{1,2})(?:,?\s*(\d{4}))?/i);
568
+ if (mo) {
569
+ const mi = monthIndex(mo[1]);
570
+ if (mi < 0) return null;
571
+ const day = parseInt(mo[2], 10);
572
+ if (!(day >= 1 && day <= 31)) return null;
573
+ const year = mo[3] ? parseInt(mo[3], 10) : new Date().getFullYear();
574
+ return `${year}-${String(mi + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
575
+ }
576
+ return null;
577
+ }
578
+
579
+ // The bold label of a graveyard bullet → the killed thing's name.
580
+ function killLabel(line) {
581
+ const b = line.match(/\*\*([^*]+?)\*\*/);
582
+ if (b) return stripMd(b[1].trim());
583
+ // No bold — take prose up to the first "Killed"/colon.
584
+ const t = line.replace(/^[\s*\-💀]+/, "").split(/Killed|:/i)[0];
585
+ return stripMd(t.trim()) || null;
586
+ }
587
+
588
+ function monthIndex(name) {
589
+ const n = String(name).toLowerCase().slice(0, 3);
590
+ const map = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
591
+ return map[n] != null ? map[n] : -1;
592
+ }
593
+
594
+ // "L3" | 3 | "RETROSPECTIVE-L3" → "L3"; unknown → null.
595
+ function normLevel(level) {
596
+ if (level == null) return null;
597
+ if (typeof level === "number") return Number.isFinite(level) ? `L${level}` : null;
598
+ const m = String(level).match(/L?(\d)/i);
599
+ return m ? `L${m[1]}` : null;
600
+ }
601
+
602
+ // Strip light markdown so labels render as plain text in the timeline.
603
+ function stripMd(s) {
604
+ if (!s || typeof s !== "string") return null;
605
+ return s
606
+ .replace(/\*\*/g, "")
607
+ .replace(/[`*_]/g, "")
608
+ .replace(/^\s*[-*]\s+/, "")
609
+ .replace(/\s+/g, " ")
610
+ .replace(/[:\s]+$/, "") // trailing colon/space from "**Label:**" bullets
611
+ .trim() || null;
612
+ }
613
+
614
+ function firstSentence(s) {
615
+ if (!s) return null;
616
+ const m = s.match(/^(.+?[.!?])(\s|$)/);
617
+ return (m ? m[1] : s).slice(0, 120);
618
+ }
619
+
620
+ // Inclusive day delta between two YYYY-MM-DD keys (b - a) in whole days. Built
621
+ // on UTC midnight so DST never shifts the count.
622
+ function daysBetween(a, b) {
623
+ const ta = Date.parse(`${a}T00:00:00Z`);
624
+ const tb = Date.parse(`${b}T00:00:00Z`);
625
+ if (!Number.isFinite(ta) || !Number.isFinite(tb)) return null;
626
+ return Math.round((tb - ta) / 86400000);
627
+ }
628
+
629
+ // Deterministic signature for tiebreaking equal (date, source) keyframes.
630
+ function keyframeSig(kf) {
631
+ return [kf.source, kf.ev || "", kf.bet || "", kf.what || "", kf.subject || "",
632
+ (kf.files && kf.files[0] && kf.files[0].f) || ""].join("");
633
+ }
634
+
635
+ // Daily-note plan/got from note text. Focus line (first content line under a
636
+ // "Focus" heading) → plan; done-task count → got. Null focus when no Focus
637
+ // section exists (labelled absence — a guessed plan is never acceptable).
638
+ function analyzeDaily(text) {
639
+ if (!text) return { focus: null, tasksDone: 0, tasksTotal: 0 };
640
+ let total = 0, done = 0;
641
+ for (const line of text.split("\n")) {
642
+ const m = line.match(/^\s*[-*]\s*\[([ xX~])\]/);
643
+ if (m) {
644
+ total++;
645
+ if (m[1].toLowerCase() === "x") done++;
646
+ }
647
+ }
648
+ const fm = text.match(/^#{1,6}\s*[^\n]*focus[^\n]*\n+([^\n#][^\n]*)/im);
649
+ const focus = fm
650
+ ? (fm[1].replace(/\*\*/g, "").replace(/^\s*[-*]\s+(\[.\]\s*)?/, "").trim() || null)
651
+ : null;
652
+ return { focus, tasksDone: done, tasksTotal: total };
653
+ }
654
+
655
+ // Set of all Daily-Note dates (YYYY-MM-DD) present on disk.
656
+ function collectDailyDateSet(vault) {
657
+ const set = new Set();
658
+ const months = safeListDir(dailiesDir(vault), { dirsOnly: true });
659
+ for (const mo of months) {
660
+ for (const f of safeListDir(mo.path, { extension: ".md" })) {
661
+ const dm = f.name.match(/Daily - (\d{4}-\d{2}-\d{2})\.md$/);
662
+ if (dm) set.add(dm[1]);
663
+ }
664
+ }
665
+ return set;
666
+ }
667
+
668
+ // Consecutive-day back-run ending on `date`, counting only dates in `set`.
669
+ function backRun(date, set) {
670
+ let n = 0;
671
+ let cur = date;
672
+ // Walk backwards day-by-day while the note exists.
673
+ for (let i = 0; i < 400; i++) {
674
+ if (!set.has(cur)) break;
675
+ n++;
676
+ cur = addDays(cur, -1);
677
+ if (!cur) break;
678
+ }
679
+ return n;
680
+ }
681
+
682
+ function addDays(dateKey, delta) {
683
+ const t = Date.parse(`${dateKey}T00:00:00Z`);
684
+ if (!Number.isFinite(t)) return null;
685
+ const d = new Date(t + delta * 86400000);
686
+ return localDateKey(new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
687
+ }
688
+
689
+ module.exports = { buildRewind };