mover-os 4.7.8 → 4.7.10

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 (43) hide show
  1. package/README.md +34 -24
  2. package/install.js +2229 -204
  3. package/package.json +3 -2
  4. package/src/dashboard/build.js +41 -3
  5. package/src/dashboard/lib/active-context-parser.js +16 -4
  6. package/src/dashboard/lib/agent-session.js +70 -49
  7. package/src/dashboard/lib/approval-registry.js +170 -0
  8. package/src/dashboard/lib/daily-note-resolver.js +20 -3
  9. package/src/dashboard/lib/date-utils.js +9 -1
  10. package/src/dashboard/lib/distribution-parser.js +69 -10
  11. package/src/dashboard/lib/drift-history.js +6 -1
  12. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  13. package/src/dashboard/lib/engine-health.js +4 -1
  14. package/src/dashboard/lib/engine-writer.js +157 -11
  15. package/src/dashboard/lib/goal-forecast.js +154 -31
  16. package/src/dashboard/lib/library-indexer-v2.js +14 -0
  17. package/src/dashboard/lib/library-search.js +290 -0
  18. package/src/dashboard/lib/log-activation.sh +0 -0
  19. package/src/dashboard/lib/memory-index.js +61 -12
  20. package/src/dashboard/lib/pid-markers.js +80 -0
  21. package/src/dashboard/lib/regenerate-manifest.js +0 -0
  22. package/src/dashboard/lib/run-registry.js +75 -15
  23. package/src/dashboard/lib/state-core/backfill.js +298 -0
  24. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  25. package/src/dashboard/lib/state-core/event-log.js +615 -0
  26. package/src/dashboard/lib/state-core/events.js +265 -0
  27. package/src/dashboard/lib/state-core/projections.js +376 -0
  28. package/src/dashboard/lib/state-core/start-close.js +162 -0
  29. package/src/dashboard/lib/state-core/trial.js +96 -0
  30. package/src/dashboard/lib/strategy-parser.js +3 -0
  31. package/src/dashboard/lib/suggested-now.js +2 -2
  32. package/src/dashboard/lib/transcript-parser.js +48 -8
  33. package/src/dashboard/server.js +422 -44
  34. package/src/dashboard/shortcut.js +0 -0
  35. package/src/dashboard/ui/dist/assets/index-BoxTW_kj.js +161 -0
  36. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  37. package/src/dashboard/ui/dist/assets/index-wnamvqxp.js +34 -0
  38. package/src/dashboard/ui/dist/index.html +2 -2
  39. package/src/dashboard/ui/dist/sw.js +1 -1
  40. package/src/system/counts.json +7 -0
  41. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +0 -157
  42. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +0 -1
  43. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +0 -34
@@ -15,6 +15,7 @@
15
15
 
16
16
  const fs = require("fs");
17
17
  const path = require("path");
18
+ const crypto = require("crypto");
18
19
  const { tokenize, termFreq, buildVocab } = require("./memory-text");
19
20
  const { enumerateSessions, parseTranscript, deriveProject } = require("./transcript-parser");
20
21
  const { parseSessionLogs } = require("./session-log-parser");
@@ -46,9 +47,34 @@ function ensureDirs() {
46
47
  }
47
48
 
48
49
  function atomicWrite(file, obj) {
49
- const tmp = file + ".tmp";
50
+ // terra T-MH-2: a fixed `file + ".tmp"` name means two concurrent SessionEnd
51
+ // hooks writing the same segment/manifest share one temp path and can interleave
52
+ // into a torn file. A per-write unique temp name makes each write's rename
53
+ // independent (the last full write wins cleanly; no half-written temp survives).
54
+ const tmp = file + "." + process.pid + "-" + crypto.randomBytes(4).toString("hex") + ".tmp";
50
55
  fs.writeFileSync(tmp, JSON.stringify(obj));
51
- fs.renameSync(tmp, file);
56
+ try { fs.renameSync(tmp, file); }
57
+ catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
58
+ }
59
+
60
+ // terra T-MH-1: safeReadJson() collapses "file absent" and "file present but
61
+ // MALFORMED" both to null, so `safeReadJson(segFile) || newSeg()` would overwrite
62
+ // a corrupt segment with an empty one — discarding every indexed doc for that
63
+ // month. Distinguish the two: a MISSING segment is a fresh newSeg(); a present-
64
+ // but-unparseable segment THROWS, so the fail-open SessionEnd hook skips indexing
65
+ // this one session and PRESERVES the existing file rather than nuking it.
66
+ function readSegOrNew(segFile) {
67
+ let raw;
68
+ try { raw = fs.readFileSync(segFile, "utf8"); }
69
+ catch (e) { if (e.code === "ENOENT") return newSeg(); throw e; }
70
+ let parsed;
71
+ try { parsed = JSON.parse(raw); }
72
+ catch (_) { throw Object.assign(new Error("memory segment is malformed; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" }); }
73
+ // A parsed-but-wrong-shape file is also not safe to fold into — treat as corrupt.
74
+ if (!parsed || typeof parsed !== "object" || !parsed.docs || !parsed.postings) {
75
+ throw Object.assign(new Error("memory segment has an unexpected shape; refusing to overwrite it: " + segFile), { code: "ESEGCORRUPT" });
76
+ }
77
+ return parsed;
52
78
  }
53
79
 
54
80
  function monthOf(ts, fallbackMs) {
@@ -377,19 +403,43 @@ async function indexSession(file, opts = {}) {
377
403
  if (!man) return { error: "no index — run --backfill first" };
378
404
  man.docFreq = man.docFreq || {}; man.linkFreq = man.linkFreq || {}; man.indexedSessions = man.indexedSessions || {};
379
405
  const vault = opts.vault || man.vault;
380
- const sessionId = opts.sessionId || path.basename(file).replace(/\.jsonl$/, "");
381
- // Derive the project from the transcript's parent dir (…/projects/<encoded-dir>/<uuid>.jsonl)
382
- // when not passed — the SessionEnd hook calls indexSession(file, {}), and without this every
383
- // live-indexed session would be project=null, making the cross-project scope guard a no-op.
384
- const project = opts.project || deriveProject(path.basename(path.dirname(file))) || null;
406
+ const pathSessionId = path.basename(file).replace(/\.jsonl$/, "");
407
+ const pathProject = deriveProject(path.basename(path.dirname(file))) || null;
385
408
  let mtime = null, size = 0;
386
409
  try { const st = fs.statSync(file); mtime = st.mtimeMs; size = st.size; } catch {}
387
- const prev = man.indexedSessions[sessionId];
388
- if (prev && mtime && prev.mtime === mtime && !opts.force) return { skipped: true, sessionId };
389
410
 
390
- const r = await parseTranscript(file, { vocab: buildVocab(vault), sessionId, project });
411
+ // Parse before selecting identity. Claude stores sessions under an encoded project directory, but
412
+ // Codex stores rollouts under date folders and carries the real session id + cwd in session_meta.
413
+ const r = await parseTranscript(file, {
414
+ vocab: buildVocab(vault),
415
+ sessionId: opts.sessionId,
416
+ project: opts.project,
417
+ agent: opts.agent,
418
+ });
419
+ const sessionId = opts.sessionId || r.sessionId || pathSessionId;
420
+ const project = opts.project || r.project || pathProject;
391
421
  if (r.error) return { error: "parse failed", sessionId };
392
422
 
423
+ let reindexed = false;
424
+ // Remove a pre-fix Codex entry keyed by the rollout filename. Without this migration the same
425
+ // transcript survives twice: once under rollout-* and once under its canonical session id.
426
+ if (pathSessionId !== sessionId) {
427
+ const legacy = man.indexedSessions[pathSessionId];
428
+ if (legacy && legacy.month) {
429
+ const legacyFile = path.join(SEG_DIR, "raw-" + legacy.month + ".json");
430
+ const legacySeg = safeReadJson(legacyFile);
431
+ if (legacySeg && legacySeg.docs["session:" + pathSessionId]) {
432
+ removeDocContribution(legacySeg, "session:" + pathSessionId, man);
433
+ atomicWrite(legacyFile, legacySeg);
434
+ }
435
+ delete man.indexedSessions[pathSessionId];
436
+ reindexed = true;
437
+ }
438
+ }
439
+
440
+ const prev = man.indexedSessions[sessionId];
441
+ if (prev && mtime && prev.mtime === mtime && !opts.force && !reindexed) return { skipped: true, sessionId };
442
+
393
443
  const month = monthOf(r.firstTs, mtime);
394
444
  const id = "session:" + sessionId;
395
445
 
@@ -398,7 +448,6 @@ async function indexSession(file, opts = {}) {
398
448
  // lives in the PREVIOUSLY recorded month's segment — remove it from THERE too, else it orphans
399
449
  // (ghost doc) and permanently inflates docFreq/totalDocs/linkFreq, degrading IDF until a full
400
450
  // --backfill. Only fires on a genuine month change; the common same-month re-index skips it.
401
- let reindexed = false;
402
451
  const prevMonth = prev && prev.month;
403
452
  if (prevMonth && prevMonth !== month) {
404
453
  const prevSegFile = path.join(SEG_DIR, "raw-" + prevMonth + ".json");
@@ -411,7 +460,7 @@ async function indexSession(file, opts = {}) {
411
460
  }
412
461
 
413
462
  const segFile = path.join(SEG_DIR, "raw-" + month + ".json");
414
- const seg = safeReadJson(segFile) || newSeg();
463
+ const seg = readSegOrNew(segFile); // terra T-MH-1: never clobber a malformed segment
415
464
 
416
465
  // remove old contribution (same-month re-index case) — scan only this segment's postings.
417
466
  if (seg.docs[id]) { removeDocContribution(seg, id, man); reindexed = true; }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * pid-markers.js — crash-orphan bookkeeping shared by AgentSessionManager
5
+ * (Chat/onboarding) and RunRegistry (Run tab).
6
+ *
7
+ * Every dashboard-spawned agent child is detached into its own process group
8
+ * and recorded here as one marker file per pid, tagged with the owning
9
+ * dashboard's pid. On startup each manager reaps any group whose OWNER is
10
+ * gone — children escaped by a crashed dashboard (kill -9, OOM, update crash)
11
+ * that no graceful shutdown path could ever reach.
12
+ *
13
+ * Extracted verbatim from agent-session.js (MF2, Codex r2 hardening) so the
14
+ * Run tab gets the same guarantee. One file per pid: no shared-file
15
+ * read-modify-write race across concurrent dashboards; a corrupt write only
16
+ * loses its own marker. Writes are atomic (tmp + rename). Both managers share
17
+ * ONE directory on purpose: whichever starts first reaps the other's orphans.
18
+ */
19
+
20
+ const os = require("os");
21
+ const path = require("path");
22
+ const fs = require("fs");
23
+
24
+ const MARKERS_DIR = path.join(os.homedir(), ".mover", "dashboard", "agent-pids");
25
+
26
+ function markerPath(pid) { return path.join(MARKERS_DIR, String(pid) + ".json"); }
27
+
28
+ function writeMarker(pid, data) {
29
+ try {
30
+ fs.mkdirSync(MARKERS_DIR, { recursive: true });
31
+ const f = markerPath(pid), tmp = f + ".tmp";
32
+ fs.writeFileSync(tmp, JSON.stringify(data));
33
+ fs.renameSync(tmp, f); // atomic: a reader never sees a half-written marker
34
+ } catch (_) {}
35
+ }
36
+
37
+ function removeMarker(pid) { try { fs.unlinkSync(markerPath(pid)); } catch (_) {} }
38
+
39
+ function readMarkers() {
40
+ let files; try { files = fs.readdirSync(MARKERS_DIR); } catch (_) { return []; }
41
+ const out = [];
42
+ for (const fn of files) {
43
+ if (!fn.endsWith(".json")) continue;
44
+ try { out.push(JSON.parse(fs.readFileSync(path.join(MARKERS_DIR, fn), "utf8"))); } catch (_) {}
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function isAlive(pid) {
50
+ if (!pid) return false;
51
+ try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
52
+ }
53
+
54
+ // True if ANY member of the process group still exists (not just the leader) —
55
+ // process.kill(-pid, 0) throws ESRCH only when the whole group is gone.
56
+ function groupAlive(pid) {
57
+ if (!pid) return false;
58
+ try { process.kill(-pid, 0); return true; } catch (e) { return e && e.code === "EPERM"; }
59
+ }
60
+
61
+ function killGroup(pid, sig) {
62
+ if (!pid) return false;
63
+ try { process.kill(-pid, sig); return true; }
64
+ catch (_) { try { process.kill(pid, sig); return true; } catch (__) { return false; } }
65
+ }
66
+
67
+ // Reap any recorded group whose owning dashboard is gone. Idempotent; safe to
68
+ // call from every manager's constructor (they share the directory).
69
+ function reapOrphans() {
70
+ for (const m of readMarkers()) {
71
+ if (!m || !m.pid) continue;
72
+ // Keep markers whose owning dashboard is still alive (this process OR a
73
+ // concurrent one). Only a marker whose owner is GONE is a real orphan.
74
+ if (m.ownerPid && isAlive(m.ownerPid)) continue;
75
+ if (groupAlive(m.pid)) killGroup(m.pid, "SIGKILL"); // owner gone → orphaned group
76
+ removeMarker(m.pid);
77
+ }
78
+ }
79
+
80
+ module.exports = { MARKERS_DIR, writeMarker, removeMarker, readMarkers, isAlive, groupAlive, killGroup, reapOrphans };
File without changes
@@ -29,9 +29,11 @@
29
29
  */
30
30
 
31
31
  const { spawn } = require("child_process");
32
+ const { writeMarker, removeMarker, groupAlive, killGroup, reapOrphans } = require("./pid-markers");
32
33
 
33
34
  const DEFAULTS = {
34
35
  maxRuns: 40, // retain at most N runs (live + finished)
36
+ maxConcurrent: 4, // live child processes at once; excess requests are refused
35
37
  maxBuffer: 1024 * 1024, // 1 MB per-run output cap (head dropped, tail kept)
36
38
  finishedTtlMs: 6 * 60 * 60 * 1000, // evict finished runs 6h after they end
37
39
  idleSweepMs: 5 * 60 * 1000, // sweep cadence
@@ -43,10 +45,21 @@ class RunRegistry {
43
45
  this.opt = { ...DEFAULTS, ...opts };
44
46
  this.runs = new Map(); // id -> run record
45
47
  this._seq = 0;
48
+ // R5-A: reap children escaped by a crashed dashboard (same MF2 guarantee
49
+ // AgentSessionManager has; the marker dir is shared so either manager's
50
+ // startup cleans up both kinds of orphan).
51
+ reapOrphans();
46
52
  this._sweep = setInterval(() => this._evict(), this.opt.idleSweepMs);
47
53
  if (this._sweep.unref) this._sweep.unref();
48
54
  }
49
55
 
56
+ /** Live (running/stopping) child count — the concurrency the cap guards. */
57
+ liveCount() {
58
+ let n = 0;
59
+ for (const r of this.runs.values()) if (r.status === "running" || r.status === "stopping") n++;
60
+ return n;
61
+ }
62
+
50
63
  /**
51
64
  * Spawn a tracked run. The CALLER builds the command (agentCommand) and the
52
65
  * final prompt (buildAgentPrompt); this owns the process + buffer lifecycle.
@@ -54,6 +67,13 @@ class RunRegistry {
54
67
  * Returns the run record (its `.id` is the handle for subscribe/get/stop).
55
68
  */
56
69
  start(spec) {
70
+ // Concurrency cap (R5-A): every run is a real long-lived CLI child with
71
+ // full tool access; a buggy retry loop must not fork-bomb the machine.
72
+ if (this.liveCount() >= this.opt.maxConcurrent) {
73
+ const err = new Error(`${this.opt.maxConcurrent} agents are already running. Stop one or let it finish first.`);
74
+ err.code = "RUN_LIMIT";
75
+ throw err;
76
+ }
57
77
  const id = `run_${process.pid}_${++this._seq}`;
58
78
  const run = {
59
79
  id,
@@ -73,17 +93,26 @@ class RunRegistry {
73
93
 
74
94
  let child;
75
95
  try {
76
- child = spawn(spec.cmd, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"] });
96
+ // detached: own process group, so Stop/shutdown can signal the whole
97
+ // tree (a run's tool calls spawn grandchildren the bare child.kill()
98
+ // never reached), and the pid marker lets a post-crash startup reap it.
99
+ child = spawn(spec.cmd, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"], detached: true });
77
100
  } catch (e) {
78
101
  this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${e && e.message}\n`);
79
102
  this._finish(run, "failed", null);
80
103
  return run;
81
104
  }
82
105
  run.child = child;
106
+ if (child.pid) writeMarker(child.pid, { pid: child.pid, ownerPid: process.pid, at: Date.now(), kind: "run" });
83
107
 
84
108
  const timeout = setTimeout(() => {
109
+ // Mark stopping BEFORE the kill so the close handler reports "stopped",
110
+ // not a false "done" (terra run-path F2).
111
+ run.status = "stopping";
85
112
  this._append(run, "\n[mover-studio] timeout after 10 minutes; stopping agent.\n", true);
86
- try { child.kill("SIGTERM"); } catch (_) {}
113
+ killGroup(child.pid, "SIGTERM");
114
+ const esc = setTimeout(() => { if (groupAlive(child.pid)) killGroup(child.pid, "SIGKILL"); }, 2500);
115
+ if (esc.unref) esc.unref();
87
116
  }, this.opt.timeoutMs);
88
117
  if (timeout.unref) timeout.unref();
89
118
 
@@ -102,12 +131,14 @@ class RunRegistry {
102
131
  this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${err && err.message}\n`, true);
103
132
  this._finish(run, "failed", null);
104
133
  });
105
- child.on("close", (code) => {
134
+ child.on("close", (code, signal) => {
106
135
  clearTimeout(timeout);
107
- this._append(run, `\n[mover-studio] agent exited with code ${code}\n`, true);
108
- // A run we SIGTERM'd via stop() keeps its "stopped" status; otherwise
109
- // derive from the exit code (0/null = done, non-zero = exited).
110
- const status = run.status === "stopping" ? "stopped" : (code === 0 || code == null) ? "done" : "exited";
136
+ this._append(run, `\n[mover-studio] agent exited with code ${code}${signal ? ` (signal ${signal})` : ""}\n`, true);
137
+ // A run we SIGTERM'd (stop OR timeout) keeps "stopping" -> "stopped". A
138
+ // clean exit (code 0) is "done"; ANY other termination — a non-zero code OR
139
+ // code==null from a signal we did not send is "exited", never a false
140
+ // "done" (terra run-path F2: a signal-killed run was reported as success).
141
+ const status = run.status === "stopping" ? "stopped" : code === 0 ? "done" : "exited";
111
142
  this._finish(run, status, code);
112
143
  });
113
144
 
@@ -123,6 +154,10 @@ class RunRegistry {
123
154
  // Snapshot + register atomically (no I/O between) so no chunk is lost.
124
155
  cb({ type: "replay", text: run.output, status: run.status, exit: run.exit });
125
156
  if (run.status === "running" || run.status === "stopping") {
157
+ // Cap live subscribers per run so a flood of stream connections can't pile
158
+ // up callbacks/sockets (terra run-path F3). Over the cap, the client still
159
+ // got the full replay above; end it rather than live-stream.
160
+ if (run.subscribers.size >= 64) { cb({ type: "end", status: run.status, exit: run.exit }); return () => {}; }
126
161
  run.subscribers.add(cb);
127
162
  return () => run.subscribers.delete(cb);
128
163
  }
@@ -130,15 +165,17 @@ class RunRegistry {
130
165
  return () => {};
131
166
  }
132
167
 
133
- /** Really stop a run (SIGTERM the child). Distinct from a client merely
168
+ /** Really stop a run (SIGTERM the child's whole process group — a run's
169
+ * tool calls spawn grandchildren). Distinct from a client merely
134
170
  * detaching its stream ("stop watching"). */
135
171
  stop(id) {
136
172
  const run = this.runs.get(id);
137
173
  if (!run || !run.child || (run.status !== "running")) return false;
138
174
  run.status = "stopping";
139
- try { run.child.kill("SIGTERM"); } catch (_) {}
140
- // Escalate if the child ignores SIGTERM.
141
- const t = setTimeout(() => { if (run.status === "stopping" && run.child) { try { run.child.kill("SIGKILL"); } catch (_) {} } }, 2500);
175
+ const pid = run.child.pid;
176
+ killGroup(pid, "SIGTERM");
177
+ // Escalate if the group ignores SIGTERM.
178
+ const t = setTimeout(() => { if (run.status === "stopping" && groupAlive(pid)) killGroup(pid, "SIGKILL"); }, 2500);
142
179
  if (t.unref) t.unref();
143
180
  return true;
144
181
  }
@@ -149,11 +186,30 @@ class RunRegistry {
149
186
  return [...this.runs.values()].sort((a, b) => b.started - a.started).map((r) => this._summary(r, false));
150
187
  }
151
188
 
152
- shutdownAll() {
189
+ /** Kill every live run's process group and WAIT for it to die, escalating
190
+ * to SIGKILL after graceMs — mirrors AgentSessionManager.shutdownAll (MF1)
191
+ * so the server's hard-exit timer can't strand a stubborn child. If a
192
+ * group somehow survives SIGKILL, its marker stays on disk so the next
193
+ * startup reaps it — never a silent orphan. */
194
+ shutdownAll(graceMs = 1500) {
153
195
  if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
154
- for (const r of [...this.runs.values()]) {
155
- if (r.child && (r.status === "running" || r.status === "stopping")) { try { r.child.kill("SIGTERM"); } catch (_) {} }
156
- }
196
+ const live = [...this.runs.values()].filter((r) => r.child && (r.status === "running" || r.status === "stopping"));
197
+ return Promise.all(live.map((r) => new Promise((resolve) => {
198
+ const pid = r.child.pid;
199
+ killGroup(pid, "SIGTERM");
200
+ const start = Date.now();
201
+ let killed = false;
202
+ const tick = () => {
203
+ if (!groupAlive(pid)) { removeMarker(pid); return resolve(); }
204
+ if (!killed && Date.now() - start >= graceMs) { killGroup(pid, "SIGKILL"); killed = true; }
205
+ if (killed && Date.now() - start >= graceMs + 700) {
206
+ if (!groupAlive(pid)) removeMarker(pid);
207
+ return resolve();
208
+ }
209
+ const t = setTimeout(tick, 150); if (t.unref) t.unref();
210
+ };
211
+ tick();
212
+ })));
157
213
  }
158
214
 
159
215
  // ── internals ──────────────────────────────────────────────────────────────
@@ -185,6 +241,10 @@ class RunRegistry {
185
241
  // "failed"/"stopped" with the close code (null -> "done") and emits a
186
242
  // second "end". First finish wins. (Found by a Codex review, 2026-06-30.)
187
243
  if (run.ended) return;
244
+ // Drop the crash marker only once the whole GROUP is gone; if a tool
245
+ // grandchild outlived the leader, the marker survives and the next
246
+ // dashboard startup reaps it.
247
+ if (run.child && run.child.pid && !groupAlive(run.child.pid)) removeMarker(run.child.pid);
188
248
  run.status = status;
189
249
  run.exit = exit;
190
250
  run.ended = Date.now();
@@ -0,0 +1,298 @@
1
+ "use strict";
2
+
3
+ // backfill.js — CORE-LOOP PHASE 2 (SCHEMA-SPEC v0.2 section 4, migration).
4
+ // Turns a vault COPY's existing records into v0.2 events. Copy-first is the
5
+ // owner-approved default #5: nothing in this module is wired to any workflow,
6
+ // hook, or server route, and applyState (dryrun.js) refuses a non-pristine
7
+ // target. The live vault is never touched until the owner has inspected a
8
+ // dry-run manifest on a copy.
9
+ //
10
+ // WHAT v1 MIGRATES (mechanically faithful sources only):
11
+ // - ~/.mover/pattern-manifest.json override_history[] -> override.logged
12
+ // - Dailies "## Tasks" checkboxes -> plan.observed + plan.ticked
13
+ //
14
+ // WHAT v1 DOES NOT MIGRATE (spec amendment, recorded 2026-07-11): the v0.1
15
+ // spec table sourced receipt.filed from "Metrics_Log receipt lines", but the
16
+ // real Metrics_Log.md is a Fitbit vitality analysis with no receipts section
17
+ // — its dated lines are sleep scores. Fabricating receipts from them (or
18
+ // from session-log prose via keyword heuristics) would inflate the record
19
+ // this system exists to keep honest. Undercount over inflate: prose receipts
20
+ // are declared non-recoverable; receipt.filed/ship.published begin at
21
+ // Close-time capture going forward. Every skip is returned in `skipped`
22
+ // with a reason — absence is reported, never papered over.
23
+ //
24
+ // Every migrated event carries source {path, sourceHash, parserVersion,
25
+ // locator} per v0.2 amendment 2 — a locator for audit, never an identity.
26
+
27
+ const fs = require("fs");
28
+ const path = require("path");
29
+ const { sha256Hex } = require("./event-log");
30
+ const { overrideLogged, planTicked, planObserved } = require("./events");
31
+
32
+ // v2: literal NUL bytes dropped from identity + variable-length backtick
33
+ // fences parsed per CommonMark (terra round 3 findings 2 and 3).
34
+ const PARSER_VERSION = 2;
35
+
36
+ // ── time ────────────────────────────────────────────────────────────────────
37
+
38
+ // Day-precision source dates land at UTC noon: projections.dateKeyFromTs is
39
+ // UTC, so noon round-trips to the same YYYY-MM-DD for any writer zone within
40
+ // +/-11h, without pretending we know the time of day.
41
+ function tsFromDateKey(dateKey) {
42
+ const t = Date.parse(`${dateKey}T12:00:00Z`);
43
+ return Number.isFinite(t) ? t : null;
44
+ }
45
+
46
+ // Datetime strings ("2026-04-20T11:11") parse as local time by JS spec —
47
+ // deterministic per machine, which is all a backfill needs.
48
+ function tsFromLoose(value) {
49
+ if (typeof value !== "string" || !value.trim()) return null;
50
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) return tsFromDateKey(value.trim());
51
+ const t = Date.parse(value);
52
+ return Number.isFinite(t) ? t : null;
53
+ }
54
+
55
+ // ── shared ──────────────────────────────────────────────────────────────────
56
+
57
+ function sourceFor(relPath, fileText, locator) {
58
+ return {
59
+ path: relPath,
60
+ sourceHash: sha256Hex(fileText),
61
+ parserVersion: PARSER_VERSION,
62
+ locator,
63
+ };
64
+ }
65
+
66
+ // Code spans open with a run of N backticks and close at the next run of
67
+ // EXACTLY N (CommonMark). The old single-backtick regex corrupted
68
+ // ``a`b``-style spans (terra round 3 finding 3). An unclosed run stays
69
+ // literal text.
70
+ function extractCodeSpans(text, codeSpans) {
71
+ let out = "";
72
+ let i = 0;
73
+ while (i < text.length) {
74
+ if (text[i] !== "`") { out += text[i]; i += 1; continue; }
75
+ let n = 0;
76
+ while (text[i + n] === "`") n += 1;
77
+ let j = i + n;
78
+ let close = -1;
79
+ while (j < text.length) {
80
+ if (text[j] !== "`") { j += 1; continue; }
81
+ let m = 0;
82
+ while (text[j + m] === "`") m += 1;
83
+ if (m === n) { close = j; break; }
84
+ j += m;
85
+ }
86
+ if (close === -1) { out += text.slice(i, i + n); i += n; continue; }
87
+ codeSpans.push(text.slice(i + n, close));
88
+ out += "\u0000" + (codeSpans.length - 1) + "\u0000";
89
+ i = close + n;
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function normalizeTask(text) {
95
+ // Markdown canonicalization (terra attack 7 + re-verdict #7): `Send
96
+ // **proposal**` and `Send proposal` are the same task; formatting must not
97
+ // fork identity. But a CODE SPAN is literal text - stripping its backticks
98
+ // AND its inner asterisks merged `Run \`a*b*\`` with `Run a*b` (distinct
99
+ // tasks). Code spans are held out verbatim (minus the backticks), emphasis
100
+ // stripping applies only OUTSIDE them; links tolerate one level of nested
101
+ // brackets in the label.
102
+ // Literal NUL bytes are dropped BEFORE the sentinel pass (terra round 3
103
+ // finding 2: task text containing a literal NUL-digit-NUL sequence
104
+ // resolved as a sentinel and collapsed to a colliding identity). NUL is
105
+ // not representable markdown content, so dropping it is canonicalization,
106
+ // not data loss.
107
+ const codeSpans = [];
108
+ let t = extractCodeSpans(text.replace(/\u0000/g, ""), codeSpans);
109
+ t = t
110
+ .replace(/\[((?:[^\[\]]|\[[^\]]*\])*)\]\([^)]*\)/g, "$1") // [text [nested]](url) -> text [nested]
111
+ .replace(/(\*\*|__|~~)/g, "")
112
+ .replace(/(^|\s)[*_](\S)/g, "$1$2") // leading emphasis marker on a word
113
+ .replace(/(\S)[*_](\s|$)/g, "$1$2"); // trailing emphasis marker on a word
114
+ t = t.replace(/\u0000(\d+)\u0000/g, (_, i) => codeSpans[Number(i)] || "");
115
+ return t.trim().replace(/\s+/g, " ");
116
+ }
117
+
118
+ // Stable across backfill runs AND across [x]/[ ] flips of the same task on
119
+ // the same note date — the taskId is (date, text), never checkbox state.
120
+ function taskIdFor(noteDate, text) {
121
+ return sha256Hex(`${noteDate}\n${normalizeTask(text)}`).slice(0, 16);
122
+ }
123
+
124
+ // ── override_history -> override.logged ─────────────────────────────────────
125
+
126
+ // Field fallbacks mirror lib/override-summary.js normalizeOverrides — the
127
+ // manifest has grown several field spellings over its life. A missing text
128
+ // field becomes the composed absence "not recorded" (the constructor requires
129
+ // non-empty strings; an honest placeholder beats dropping a real override).
130
+ // A missing/unparseable date is different: a fabricated timestamp would
131
+ // corrupt every date-keyed projection, so the entry is SKIPPED with a reason.
132
+ function backfillOverrides(manifestText, { manifestPath = "pattern-manifest.json" } = {}) {
133
+ const events = [];
134
+ const skipped = [];
135
+
136
+ let manifest;
137
+ try {
138
+ manifest = JSON.parse(manifestText);
139
+ } catch (e) {
140
+ return { events, skipped: [{ locator: manifestPath, reason: `manifest is not valid JSON: ${e.message}` }] };
141
+ }
142
+
143
+ const raw = (manifest && (manifest.override_history || manifest.overrideHistory)) || [];
144
+ if (!Array.isArray(raw)) {
145
+ return { events, skipped: [{ locator: `${manifestPath}#override_history`, reason: "override_history is not an array" }] };
146
+ }
147
+
148
+ raw.forEach((o, i) => {
149
+ const locator = `override_history[${i}]`;
150
+ if (o === null || typeof o !== "object") {
151
+ skipped.push({ locator, reason: "entry is not an object" });
152
+ return;
153
+ }
154
+ const ts = tsFromLoose(o.date || o.timestamp);
155
+ if (ts === null) {
156
+ skipped.push({ locator, reason: `no parseable date (got ${JSON.stringify(o.date || o.timestamp || null)})` });
157
+ return;
158
+ }
159
+ const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
160
+ events.push(
161
+ overrideLogged(
162
+ {
163
+ what: str(o.what) || str(o.action) || str(o.task) || "not recorded",
164
+ over: str(o.over) || str(o.plan_item) || str(o.conflicted_with) || "not recorded",
165
+ reason: str(o.reason) || str(o.user_override) || str(o.justification) || "not recorded",
166
+ level: typeof o.level === "number" ? o.level : str(o.level) || "UNKNOWN",
167
+ },
168
+ { ts, source: sourceFor(manifestPath, manifestText, locator) }
169
+ )
170
+ );
171
+ });
172
+
173
+ return { events, skipped };
174
+ }
175
+
176
+ // ── Daily Note "## Tasks" -> plan.observed + plan.ticked ────────────────────
177
+
178
+ const NOTE_NAME_RE = /^Daily - (\d{4}-\d{2}-\d{2})\.md$/;
179
+ const CHECKBOX_RE = /^\s*[-*]\s*\[([ xX~])\]\s+(.+?)\s*$/;
180
+
181
+ // One plan.observed (the honest denominator, v0.2 amendment 5) + one
182
+ // plan.ticked per [x] line. [ ] contributes to the denominator only.
183
+ // [~] means "written, not verified" in this house — it is NEVER migrated as
184
+ // done; only /morning or the user promotes it, and backfill does not get to
185
+ // promote what a human never verified.
186
+ function backfillDailyTasks(noteText, { noteDate, relPath }) {
187
+ const events = [];
188
+ const skipped = [];
189
+ const ts = tsFromDateKey(noteDate);
190
+ if (ts === null) {
191
+ return { events, skipped: [{ locator: relPath, reason: `invalid note date ${JSON.stringify(noteDate)}` }] };
192
+ }
193
+
194
+ const lines = noteText.split("\n");
195
+ const headIdx = lines.findIndex((l) => /^#{1,4}\s*Tasks\b/i.test(l));
196
+ if (headIdx === -1) {
197
+ return { events, skipped: [{ locator: `${relPath}#tasks`, reason: "no Tasks section" }] };
198
+ }
199
+
200
+ const taskIds = [];
201
+ const texts = [];
202
+ const ticks = [];
203
+ for (let i = headIdx + 1; i < lines.length; i++) {
204
+ if (/^#{1,4}\s/.test(lines[i])) break; // next heading ends the section
205
+ const m = lines[i].match(CHECKBOX_RE);
206
+ if (!m) continue;
207
+ const text = normalizeTask(m[2]);
208
+ const taskId = taskIdFor(noteDate, text);
209
+ if (taskIds.includes(taskId)) continue; // duplicate line, one denominator slot
210
+ taskIds.push(taskId);
211
+ texts.push(text);
212
+ if (m[1] === "x" || m[1] === "X") ticks.push({ taskId, task: text, line: i + 1 });
213
+ }
214
+
215
+ if (taskIds.length === 0) {
216
+ return { events, skipped: [{ locator: `${relPath}#tasks`, reason: "Tasks section has no checkboxes" }] };
217
+ }
218
+
219
+ events.push(
220
+ planObserved({ noteDate, taskIds, texts }, { ts, source: sourceFor(relPath, noteText, "tasks") })
221
+ );
222
+ for (const t of ticks) {
223
+ events.push(
224
+ planTicked(
225
+ { taskId: t.taskId, task: t.task, note_date: noteDate, done: true },
226
+ { ts, source: sourceFor(relPath, noteText, `tasks:line:${t.line}`) }
227
+ )
228
+ );
229
+ }
230
+ return { events, skipped };
231
+ }
232
+
233
+ // ── whole-vault sweep ────────────────────────────────────────────────────────
234
+
235
+ function collectDailyNotes(vaultRoot) {
236
+ const dir = path.join(vaultRoot, "02_Areas", "Engine", "Dailies");
237
+ const notes = [];
238
+ if (!fs.existsSync(dir)) return notes;
239
+ for (const month of fs.readdirSync(dir).sort()) {
240
+ const monthDir = path.join(dir, month);
241
+ let entries;
242
+ try {
243
+ entries = fs.readdirSync(monthDir);
244
+ } catch {
245
+ continue; // a stray file at the month level, not a folder
246
+ }
247
+ for (const name of entries.sort()) {
248
+ const m = name.match(NOTE_NAME_RE);
249
+ if (!m) continue;
250
+ notes.push({
251
+ noteDate: m[1],
252
+ absPath: path.join(monthDir, name),
253
+ relPath: path.join("02_Areas", "Engine", "Dailies", month, name),
254
+ });
255
+ }
256
+ }
257
+ return notes;
258
+ }
259
+
260
+ // The full copy-first backfill: manifest overrides + every Daily's tasks,
261
+ // returned in a deterministic order (ts ascending, locator as tiebreak) so
262
+ // two runs over the same copy produce the same append sequence and the same
263
+ // dedupeKey set (ids are fresh UUIDs by design — identity is opaque).
264
+ function backfillVault(vaultRoot, { manifestPath } = {}) {
265
+ const events = [];
266
+ const skipped = [];
267
+
268
+ if (manifestPath && fs.existsSync(manifestPath)) {
269
+ const text = fs.readFileSync(manifestPath, "utf8");
270
+ const r = backfillOverrides(text, { manifestPath: path.basename(manifestPath) });
271
+ events.push(...r.events);
272
+ skipped.push(...r.skipped);
273
+ } else if (manifestPath) {
274
+ skipped.push({ locator: manifestPath, reason: "manifest file not found" });
275
+ }
276
+
277
+ for (const note of collectDailyNotes(vaultRoot)) {
278
+ const text = fs.readFileSync(note.absPath, "utf8");
279
+ const r = backfillDailyTasks(text, { noteDate: note.noteDate, relPath: note.relPath });
280
+ events.push(...r.events);
281
+ skipped.push(...r.skipped);
282
+ }
283
+
284
+ events.sort((a, b) => a.ts - b.ts || String(a.source && a.source.locator).localeCompare(String(b.source && b.source.locator)) || a.type.localeCompare(b.type));
285
+ return { events, skipped };
286
+ }
287
+
288
+ module.exports = {
289
+ PARSER_VERSION,
290
+ tsFromDateKey,
291
+ tsFromLoose,
292
+ taskIdFor,
293
+ normalizeTask,
294
+ backfillOverrides,
295
+ backfillDailyTasks,
296
+ collectDailyNotes,
297
+ backfillVault,
298
+ };