mover-os 4.7.7 → 4.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/install.js +688 -68
  2. package/package.json +15 -3
  3. package/src/dashboard/build.js +1503 -0
  4. package/src/dashboard/dashboard.js +276 -0
  5. package/src/dashboard/index.js +319 -0
  6. package/src/dashboard/lib/activation-log.js +297 -0
  7. package/src/dashboard/lib/active-context-parser.js +177 -0
  8. package/src/dashboard/lib/agent-command.js +93 -0
  9. package/src/dashboard/lib/agent-detect.js +255 -0
  10. package/src/dashboard/lib/agent-detector.js +92 -0
  11. package/src/dashboard/lib/agent-session.js +462 -0
  12. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  13. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  14. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  15. package/src/dashboard/lib/config-parser.js +109 -0
  16. package/src/dashboard/lib/connect-recommender.js +131 -0
  17. package/src/dashboard/lib/correlations-parser.js +231 -0
  18. package/src/dashboard/lib/daily-note-resolver.js +211 -0
  19. package/src/dashboard/lib/date-utils.js +35 -0
  20. package/src/dashboard/lib/distribution-parser.js +78 -0
  21. package/src/dashboard/lib/dossier-parser.js +64 -0
  22. package/src/dashboard/lib/drift-history.js +83 -0
  23. package/src/dashboard/lib/drift-score.js +119 -0
  24. package/src/dashboard/lib/engine-health.js +170 -0
  25. package/src/dashboard/lib/engine-writer.js +1685 -0
  26. package/src/dashboard/lib/execution-plan.js +125 -0
  27. package/src/dashboard/lib/experiments-parser.js +429 -0
  28. package/src/dashboard/lib/feed-parser.js +294 -0
  29. package/src/dashboard/lib/forked-future.js +60 -0
  30. package/src/dashboard/lib/goal-forecast.js +304 -0
  31. package/src/dashboard/lib/goals-parser.js +67 -0
  32. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  33. package/src/dashboard/lib/hook-activity.js +48 -0
  34. package/src/dashboard/lib/hook-indexer.js +169 -0
  35. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  36. package/src/dashboard/lib/identity-parser.js +85 -0
  37. package/src/dashboard/lib/ingestion.js +418 -0
  38. package/src/dashboard/lib/library-indexer-v2.js +212 -0
  39. package/src/dashboard/lib/library-indexer.js +105 -0
  40. package/src/dashboard/lib/log-activation.sh +61 -0
  41. package/src/dashboard/lib/memory-curator.js +97 -0
  42. package/src/dashboard/lib/memory-gardener.js +177 -0
  43. package/src/dashboard/lib/memory-gepa.js +102 -0
  44. package/src/dashboard/lib/memory-index.js +470 -0
  45. package/src/dashboard/lib/memory-rerank.js +72 -0
  46. package/src/dashboard/lib/memory-text.js +136 -0
  47. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  48. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  49. package/src/dashboard/lib/onboarding-forge.js +70 -0
  50. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  51. package/src/dashboard/lib/override-summary.js +73 -0
  52. package/src/dashboard/lib/paths.js +192 -0
  53. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  54. package/src/dashboard/lib/phantom-strategy.js +129 -0
  55. package/src/dashboard/lib/project-scanner.js +121 -0
  56. package/src/dashboard/lib/promise-wall.js +88 -0
  57. package/src/dashboard/lib/record-score.js +173 -0
  58. package/src/dashboard/lib/redaction.js +140 -0
  59. package/src/dashboard/lib/refusal-parser.js +44 -0
  60. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  61. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  62. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  63. package/src/dashboard/lib/run-registry.js +226 -0
  64. package/src/dashboard/lib/safe-write.js +63 -0
  65. package/src/dashboard/lib/session-log-parser.js +145 -0
  66. package/src/dashboard/lib/session-time-parser.js +158 -0
  67. package/src/dashboard/lib/skill-index.js +171 -0
  68. package/src/dashboard/lib/skill-indexer.js +118 -0
  69. package/src/dashboard/lib/skill-recommender.js +689 -0
  70. package/src/dashboard/lib/strategy-parser.js +245 -0
  71. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  72. package/src/dashboard/lib/streak-parser.js +95 -0
  73. package/src/dashboard/lib/suggested-now.js +254 -0
  74. package/src/dashboard/lib/tool-awareness.js +125 -0
  75. package/src/dashboard/lib/transcript-parser.js +331 -0
  76. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  77. package/src/dashboard/lib/view-generator.js +163 -0
  78. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  79. package/src/dashboard/lib/walkthrough-script.js +140 -0
  80. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  81. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  82. package/src/dashboard/server.js +2024 -0
  83. package/src/dashboard/shortcut.js +0 -0
  84. package/src/dashboard/static/setup-poc.html +306 -0
  85. package/src/dashboard/static/walkthrough-poc.html +580 -0
  86. package/src/dashboard/styles.css +1201 -0
  87. package/src/dashboard/templates/index.html +278 -0
  88. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  89. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  90. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  91. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  92. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
  93. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
  94. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
  95. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  96. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  97. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  98. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  99. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  100. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  101. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  102. package/src/dashboard/ui/dist/icon.svg +4 -0
  103. package/src/dashboard/ui/dist/index.html +18 -0
  104. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  105. package/src/dashboard/ui/dist/registerSW.js +1 -0
  106. package/src/dashboard/ui/dist/sw.js +1 -0
  107. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { dailiesDir, autoLearningsPath } = require("./paths");
6
+
7
+ // roast-wall-parser.js — Codex's #1 viral pick. Surfaces dated AI verdicts
8
+ // (the brutal honest one-liners /analyse-day produces). Sources:
9
+ // 1. Daily Notes "## Session Log" entries that contain verdict markers
10
+ // 2. Auto_Learnings.md CORRECTION + BELIEF SHIFT entries
11
+ // 3. Roast quotes embedded in Daily Note ## Session Log sections
12
+ //
13
+ // Returns ranked Roast[] with severity, date, quote, pattern attribution.
14
+
15
+ const SEVERITY_KEYWORDS = {
16
+ high: ["override", "broke", "violated", "again", "third time", "structural", "avoidance", "phantom"],
17
+ med: ["drift", "slipping", "deferred", "missed", "skipped", "stale"],
18
+ };
19
+
20
+ function compute({ vault, autoLearnings = null, daysLookback = 60 } = {}) {
21
+ if (!vault) return { available: false, count: 0, roasts: [], stats: { high: 0, med: 0, low: 0 }, sourcesChecked: 0 };
22
+ const roasts = [];
23
+
24
+ // False-zero discipline (T261): track how many real sources we were able to
25
+ // open. A wall we actually scanned but found empty is a true count:0 and
26
+ // stays available:true. But if NOTHING was scannable — no Auto_Learnings
27
+ // patterns AND no Dailies dir on disk — then count:0 is an artifact of
28
+ // having nothing to read, not a clean record. That flips available:false so
29
+ // the consumer shows "calibrating"/empty rather than "0 roasts = you're good".
30
+ let sourcesChecked = 0;
31
+
32
+ // Collect from Auto_Learnings — pre-categorized.
33
+ // False-zero fix (T261, Codex #1): the build.js fallback for an UNAVAILABLE
34
+ // Auto_Learnings still carries `activePatternsConf3Plus: []`, so a truthiness
35
+ // check on the array alone counted a non-source as "scanned" and forced
36
+ // available:true with count:0. Only count this source when it was genuinely
37
+ // readable (available !== false) AND actually had patterns to read. A real
38
+ // available AL with zero patterns contributes no roasts and does NOT, on its
39
+ // own, make the wall "scanned" — the Dailies walk below governs that case
40
+ // (a present Dailies dir → available:true count:0; nothing on disk →
41
+ // sourcesChecked stays 0 → available:false).
42
+ if (
43
+ autoLearnings &&
44
+ autoLearnings.available !== false &&
45
+ Array.isArray(autoLearnings.activePatternsConf3Plus) &&
46
+ autoLearnings.activePatternsConf3Plus.length > 0
47
+ ) {
48
+ sourcesChecked++;
49
+ autoLearnings.activePatternsConf3Plus.forEach((p) => {
50
+ if (!p.description) return;
51
+ roasts.push({
52
+ source: "Auto_Learnings.md",
53
+ date: p.lastSeen || p.firstSeen || "live",
54
+ severity: p.confidence >= 4 ? "high" : "med",
55
+ quote: p.description.slice(0, 280),
56
+ pattern: p.id || "—",
57
+ confidence: p.confidence || 0,
58
+ });
59
+ });
60
+ }
61
+
62
+ // Walk recent Daily Notes for verdict-marker lines.
63
+ // Audit pass 2 (find-bugs #14): existsSync check before readdirSync so
64
+ // fresh-install vaults (no Dailies dir yet) don't silently swallow the
65
+ // ENOENT inside the catch — distinguishes "no dailies dir" from "no
66
+ // verdict markers found in dailies."
67
+ let dailiesAvailable = true;
68
+ try {
69
+ const dDir = dailiesDir(vault);
70
+ if (!fs.existsSync(dDir)) { dailiesAvailable = false; throw Object.assign(new Error("dailies dir missing"), { code: "ENOENT" }); }
71
+ // False-zero fix (T261, Codex #4): only count the Dailies source as
72
+ // "checked" AFTER we successfully READ the directory. existsSync passing
73
+ // doesn't guarantee readability (permissions / race) — incrementing before
74
+ // readdirSync could leave sourcesChecked >= 1 on a dir we never actually
75
+ // scanned, fabricating available:true count:0 from an unscanned source.
76
+ const months = fs.readdirSync(dDir).filter((d) => /^\d{4}-\d{2}$/.test(d)).sort().reverse();
77
+ sourcesChecked++;
78
+ const cutoff = Date.now() - daysLookback * 24 * 3600 * 1000;
79
+ let scanned = 0;
80
+ for (const mDir of months) {
81
+ if (scanned >= 30) break;
82
+ const monthPath = path.join(dDir, mDir);
83
+ try {
84
+ const files = fs.readdirSync(monthPath).filter((f) => /^Daily - \d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse();
85
+ for (const f of files) {
86
+ if (scanned >= 30) break;
87
+ const m = f.match(/(\d{4})-(\d{2})-(\d{2})/);
88
+ if (!m) continue;
89
+ const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}`);
90
+ if (!Number.isFinite(t) || t < cutoff) continue;
91
+ scanned++;
92
+ const text = safeRead(path.join(monthPath, f));
93
+ extractRoastsFromDaily(text, m[0], roasts);
94
+ }
95
+ } catch (_) { /* skip month */ }
96
+ }
97
+ } catch (_) { /* daily walk failed */ }
98
+
99
+ // Rank: severity → date desc → confidence
100
+ const sev = { high: 0, med: 1, low: 2 };
101
+ roasts.sort((a, b) => {
102
+ const s = (sev[a.severity] ?? 3) - (sev[b.severity] ?? 3);
103
+ if (s !== 0) return s;
104
+ if (a.date && b.date && a.date !== "live" && b.date !== "live") {
105
+ return String(b.date).localeCompare(String(a.date));
106
+ }
107
+ return (b.confidence || 0) - (a.confidence || 0);
108
+ });
109
+
110
+ // Dedupe by first 80 chars of quote
111
+ const seen = new Set();
112
+ const deduped = roasts.filter((r) => {
113
+ const key = (r.quote || "").slice(0, 80).toLowerCase().replace(/\s+/g, " ");
114
+ if (seen.has(key)) return false;
115
+ seen.add(key);
116
+ return true;
117
+ });
118
+
119
+ // If no source was openable, count:0 is meaningless absence, not a clean
120
+ // wall — flag unavailable so the consumer renders an empty/calibrating
121
+ // state instead of "0 roasts" read as "all clear". A scanned-but-empty
122
+ // wall (sourcesChecked >= 1) keeps available:true with a real count:0.
123
+ if (sourcesChecked === 0) {
124
+ return {
125
+ available: false,
126
+ count: 0,
127
+ roasts: [],
128
+ stats: { high: 0, med: 0, low: 0 },
129
+ sourcesChecked: 0,
130
+ };
131
+ }
132
+
133
+ return {
134
+ available: true,
135
+ count: deduped.length,
136
+ roasts: deduped.slice(0, 24),
137
+ stats: {
138
+ high: deduped.filter((r) => r.severity === "high").length,
139
+ med: deduped.filter((r) => r.severity === "med").length,
140
+ low: deduped.filter((r) => r.severity === "low").length,
141
+ },
142
+ sourcesChecked,
143
+ };
144
+ }
145
+
146
+ function extractRoastsFromDaily(text, dateKey, accumulator) {
147
+ if (!text) return;
148
+ // Find roast markers in session logs — look for italicized quotes,
149
+ // [FLAG], [VERDICT], or block-quote lines with sharp prose.
150
+ const lines = text.split(/\n/);
151
+ for (let i = 0; i < lines.length; i++) {
152
+ const line = lines[i];
153
+ // Block quote with sharp prose >40 chars
154
+ const blockMatch = line.match(/^>\s+["“]([^"”]{40,300})["”]/);
155
+ if (blockMatch) {
156
+ accumulator.push({
157
+ source: `Daily - ${dateKey}.md`,
158
+ date: dateKey,
159
+ severity: scoreSeverity(blockMatch[1]),
160
+ quote: blockMatch[1].trim(),
161
+ pattern: "—",
162
+ confidence: 3,
163
+ });
164
+ continue;
165
+ }
166
+ // FLAG: / VERDICT: / OVERRIDE: lines.
167
+ //
168
+ // T242 bug hunt fix (B#15): anchor to start of line. Previous regex
169
+ // had no `^` so prose like "this was not an OVERRIDE: success"
170
+ // matched and got scored with inverted polarity. Reject lines whose
171
+ // preceding context contains a negation cue.
172
+ const negationCue = /\b(?:not|no|wasn'?t|isn'?t|don'?t|never)\s+[^.]{0,40}$/i;
173
+ const headStart = line.match(/^(.{0,40}?)\s*(?:FLAG|VERDICT|OVERRIDE|ROAST):/i);
174
+ if (headStart && negationCue.test(headStart[1])) continue;
175
+ const flag = line.match(/^\s*[-*>]*\s*(?:FLAG|VERDICT|OVERRIDE|ROAST):\s+(.{40,300})/i);
176
+ if (flag) {
177
+ accumulator.push({
178
+ source: `Daily - ${dateKey}.md`,
179
+ date: dateKey,
180
+ severity: scoreSeverity(flag[1]),
181
+ quote: flag[1].trim(),
182
+ pattern: flag[0].split(":")[0].toUpperCase(),
183
+ confidence: 4,
184
+ });
185
+ }
186
+ }
187
+ }
188
+
189
+ function scoreSeverity(text) {
190
+ const t = String(text).toLowerCase();
191
+ if (SEVERITY_KEYWORDS.high.some((k) => t.includes(k))) return "high";
192
+ if (SEVERITY_KEYWORDS.med.some((k) => t.includes(k))) return "med";
193
+ return "low";
194
+ }
195
+
196
+ function safeRead(p) {
197
+ try { return fs.readFileSync(p, "utf8"); } catch (_) { return ""; }
198
+ }
199
+
200
+ module.exports = { compute };
@@ -0,0 +1,226 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * run-registry.js — server-held registry for one-shot agent runs (the Run tab).
5
+ *
6
+ * WHY: the original Run path (server.js runAgentStream) piped the child's
7
+ * stdout STRAIGHT to the HTTP response with no server-side buffer and no
8
+ * handle on the child. So navigating away from the Run tab — or a page
9
+ * refresh — lost the run and its output, and there was no way to re-attach.
10
+ * The Run tab was ephemeral; the Chat tab (AgentSessionManager) was not.
11
+ *
12
+ * This registry makes Run persistent the same way Chat is: the child and its
13
+ * output live on the SERVER, decoupled from the request that started them. A
14
+ * client can disconnect and reconnect; the run keeps going and its output is
15
+ * retained. Unlike the Chat path (Claude-only stream-json), this wraps the
16
+ * existing multi-agent one-shot command (agentCommand), so Run stays
17
+ * claude/codex/gemini — there is no Claude-only regression.
18
+ *
19
+ * Persistence scope == Chat's: in-memory, per running daemon. A daemon restart
20
+ * clears runs (AgentSessionManager behaves identically). Bounds keep memory
21
+ * sane for a long-lived daemon: a per-run output cap (drop the head, keep the
22
+ * meaningful tail incl. the exit line) and a retained-run cap + age eviction.
23
+ *
24
+ * Subscribe semantics mirror AgentSessionManager.subscribe: a new subscriber
25
+ * gets the full buffer replayed first (so a reattaching client sees history),
26
+ * then live chunks. The replay snapshot + subscriber-add happen in one
27
+ * synchronous step, so a stdout 'data' event (only dispatched at an I/O yield)
28
+ * can never interleave and drop or duplicate a chunk.
29
+ */
30
+
31
+ const { spawn } = require("child_process");
32
+
33
+ const DEFAULTS = {
34
+ maxRuns: 40, // retain at most N runs (live + finished)
35
+ maxBuffer: 1024 * 1024, // 1 MB per-run output cap (head dropped, tail kept)
36
+ finishedTtlMs: 6 * 60 * 60 * 1000, // evict finished runs 6h after they end
37
+ idleSweepMs: 5 * 60 * 1000, // sweep cadence
38
+ timeoutMs: 10 * 60 * 1000, // hard stop a run after 10 min (matches legacy)
39
+ };
40
+
41
+ class RunRegistry {
42
+ constructor(opts = {}) {
43
+ this.opt = { ...DEFAULTS, ...opts };
44
+ this.runs = new Map(); // id -> run record
45
+ this._seq = 0;
46
+ this._sweep = setInterval(() => this._evict(), this.opt.idleSweepMs);
47
+ if (this._sweep.unref) this._sweep.unref();
48
+ }
49
+
50
+ /**
51
+ * Spawn a tracked run. The CALLER builds the command (agentCommand) and the
52
+ * final prompt (buildAgentPrompt); this owns the process + buffer lifecycle.
53
+ * spec: { agent, label, cmd, args, stdin, finalPrompt, cwd, env, preamble }
54
+ * Returns the run record (its `.id` is the handle for subscribe/get/stop).
55
+ */
56
+ start(spec) {
57
+ const id = `run_${process.pid}_${++this._seq}`;
58
+ const run = {
59
+ id,
60
+ agent: spec.agent || "claude-code",
61
+ label: (spec.label || spec.agent || "task").slice(0, 200),
62
+ status: "running",
63
+ output: spec.preamble ? String(spec.preamble) : "",
64
+ started: Date.now(),
65
+ ended: null,
66
+ exit: null,
67
+ truncated: false,
68
+ child: null,
69
+ subscribers: new Set(),
70
+ };
71
+ this.runs.set(id, run);
72
+ this._trimCount();
73
+
74
+ let child;
75
+ try {
76
+ child = spawn(spec.cmd, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"] });
77
+ } catch (e) {
78
+ this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${e && e.message}\n`);
79
+ this._finish(run, "failed", null);
80
+ return run;
81
+ }
82
+ run.child = child;
83
+
84
+ const timeout = setTimeout(() => {
85
+ this._append(run, "\n[mover-studio] timeout after 10 minutes; stopping agent.\n", true);
86
+ try { child.kill("SIGTERM"); } catch (_) {}
87
+ }, this.opt.timeoutMs);
88
+ if (timeout.unref) timeout.unref();
89
+
90
+ // Guard stdin against an async EPIPE if the child dies early (mirrors the
91
+ // legacy one-shot path + the AgentSession stdin guard).
92
+ if (child.stdin) child.stdin.on("error", () => {});
93
+ try {
94
+ if (spec.stdin) child.stdin.end(spec.finalPrompt);
95
+ else child.stdin.end();
96
+ } catch (_) {}
97
+
98
+ child.stdout.on("data", (c) => this._onChunk(run, c));
99
+ child.stderr.on("data", (c) => this._onChunk(run, c));
100
+ child.on("error", (err) => {
101
+ clearTimeout(timeout);
102
+ this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${err && err.message}\n`, true);
103
+ this._finish(run, "failed", null);
104
+ });
105
+ child.on("close", (code) => {
106
+ 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";
111
+ this._finish(run, status, code);
112
+ });
113
+
114
+ return run;
115
+ }
116
+
117
+ /** Subscribe to a run: replay the full buffer, then stream live chunks.
118
+ * cb receives {type:"replay"|"chunk"|"end", text?, status?, exit?}.
119
+ * Returns an unsubscribe fn. */
120
+ subscribe(id, cb) {
121
+ const run = this.runs.get(id);
122
+ if (!run) { cb({ type: "end", status: "missing", exit: null }); return () => {}; }
123
+ // Snapshot + register atomically (no I/O between) so no chunk is lost.
124
+ cb({ type: "replay", text: run.output, status: run.status, exit: run.exit });
125
+ if (run.status === "running" || run.status === "stopping") {
126
+ run.subscribers.add(cb);
127
+ return () => run.subscribers.delete(cb);
128
+ }
129
+ cb({ type: "end", status: run.status, exit: run.exit });
130
+ return () => {};
131
+ }
132
+
133
+ /** Really stop a run (SIGTERM the child). Distinct from a client merely
134
+ * detaching its stream ("stop watching"). */
135
+ stop(id) {
136
+ const run = this.runs.get(id);
137
+ if (!run || !run.child || (run.status !== "running")) return false;
138
+ 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);
142
+ if (t.unref) t.unref();
143
+ return true;
144
+ }
145
+
146
+ get(id) { const r = this.runs.get(id); return r ? this._summary(r, true) : null; }
147
+ has(id) { return this.runs.has(id); }
148
+ list() {
149
+ return [...this.runs.values()].sort((a, b) => b.started - a.started).map((r) => this._summary(r, false));
150
+ }
151
+
152
+ shutdownAll() {
153
+ 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
+ }
157
+ }
158
+
159
+ // ── internals ──────────────────────────────────────────────────────────────
160
+
161
+ _onChunk(run, chunk) {
162
+ const s = chunk.toString("utf8");
163
+ this._append(run, s);
164
+ this._emit(run, { type: "chunk", text: s });
165
+ }
166
+
167
+ _append(run, s, isTail) {
168
+ run.output += s;
169
+ if (run.output.length > this.opt.maxBuffer) {
170
+ // Keep the TAIL — it carries the latest work and the exit line that the
171
+ // client derives status from. `isTail` writes (exit/timeout lines) are
172
+ // short and always survive because they were just appended.
173
+ run.output = "[… earlier output truncated …]\n" + run.output.slice(run.output.length - this.opt.maxBuffer);
174
+ run.truncated = true;
175
+ }
176
+ }
177
+
178
+ _emit(run, ev) {
179
+ for (const cb of [...run.subscribers]) { try { cb(ev); } catch (_) { /* a dead subscriber must not break the loop */ } }
180
+ }
181
+
182
+ _finish(run, status, exit) {
183
+ // Terminal guard: a spawn failure fires 'error' THEN 'close', so both
184
+ // handlers call _finish. Without this, the second call overwrites a real
185
+ // "failed"/"stopped" with the close code (null -> "done") and emits a
186
+ // second "end". First finish wins. (Found by a Codex review, 2026-06-30.)
187
+ if (run.ended) return;
188
+ run.status = status;
189
+ run.exit = exit;
190
+ run.ended = Date.now();
191
+ run.child = null;
192
+ this._emit(run, { type: "end", status, exit });
193
+ run.subscribers.clear();
194
+ }
195
+
196
+ _summary(run, withOutput) {
197
+ const s = {
198
+ id: run.id, agent: run.agent, label: run.label, status: run.status,
199
+ started: run.started, ended: run.ended, exit: run.exit,
200
+ truncated: run.truncated, bytes: run.output.length,
201
+ };
202
+ if (withOutput) s.output = run.output;
203
+ return s;
204
+ }
205
+
206
+ // Cap the number of retained runs: drop the OLDEST finished runs first; never
207
+ // evict a still-running run to make room.
208
+ _trimCount() {
209
+ if (this.runs.size <= this.opt.maxRuns) return;
210
+ const finished = [...this.runs.values()]
211
+ .filter((r) => r.status !== "running" && r.status !== "stopping")
212
+ .sort((a, b) => a.started - b.started);
213
+ while (this.runs.size > this.opt.maxRuns && finished.length) this.runs.delete(finished.shift().id);
214
+ }
215
+
216
+ _evict() {
217
+ const now = Date.now();
218
+ for (const r of [...this.runs.values()]) {
219
+ if (r.status !== "running" && r.status !== "stopping" && r.ended && now - r.ended > this.opt.finishedTtlMs) {
220
+ this.runs.delete(r.id);
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ module.exports = { RunRegistry, DEFAULTS };
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+
3
+ // safe-write.js — drift-guarded file writes (B5). Global Rules S21 (multi-instance safety) made
4
+ // MECHANICAL instead of prompt-level. Multiple agents/dashboards can touch the same vault; before a
5
+ // programmatic writer overwrites an important file (an Engine file, the Daily Note) it re-reads it
6
+ // and byte-compares against the content it based its edit on (`expected`). If the file changed
7
+ // underneath — another instance, the user in Obsidian, or an agent — the current bytes are preserved
8
+ // to a timestamped sidecar (.bak.<ts>) BEFORE the new content lands, so a concurrent edit is never
9
+ // silently clobbered. The write itself is atomic (tmp + rename). Pure Node stdlib, zero-dep.
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+
14
+ class DriftError extends Error {
15
+ constructor(file, backupPath) {
16
+ super(
17
+ backupPath
18
+ ? `drift: ${file} changed since it was read; preserved a backup at ${backupPath}`
19
+ : `drift: ${file} changed since it was read; aborted without writing (re-read and retry)`
20
+ );
21
+ this.name = "DriftError";
22
+ this.code = "EDRIFT";
23
+ this.file = file;
24
+ this.backupPath = backupPath || null;
25
+ }
26
+ }
27
+
28
+ // Write `next` to `file`, guarding against a concurrent edit.
29
+ // expected — the bytes the caller last read and based `next` on. Pass null to skip drift
30
+ // detection (a first write / no baseline / a known-fresh file).
31
+ // opts.throwOnDrift — on drift, abort WITHOUT touching the file and throw DriftError, so the
32
+ // caller can re-read + re-merge. Default false: back up the current bytes,
33
+ // then proceed with the overwrite.
34
+ // opts.now — timestamp for the .bak/.tmp suffix (deterministic in tests).
35
+ // Returns { written, existed, drifted, backupPath }.
36
+ function writeWithDriftGuard(file, expected, next, opts = {}) {
37
+ const ts = opts.now != null ? opts.now : Date.now();
38
+
39
+ let current = null, existed = false;
40
+ try { current = fs.readFileSync(file, "utf8"); existed = true; } catch { existed = false; }
41
+ const drifted = existed && expected != null && current !== expected;
42
+
43
+ // Abort path: leave the file exactly as the concurrent writer left it — no data loss, no backup.
44
+ if (drifted && opts.throwOnDrift) throw new DriftError(file, null);
45
+
46
+ let backupPath = null;
47
+ if (drifted) {
48
+ // We are about to overwrite a concurrent edit → preserve it first so nothing is lost silently.
49
+ backupPath = `${file}.bak.${ts}`;
50
+ fs.writeFileSync(backupPath, current);
51
+ }
52
+
53
+ fs.mkdirSync(path.dirname(file), { recursive: true });
54
+ // Same-directory tmp so the rename is a same-filesystem (atomic) operation, never a cross-device
55
+ // copy. pid in the suffix so two writers in the same ms don't collide on the scratch file.
56
+ const tmp = `${file}.tmp.${ts}.${process.pid}`;
57
+ fs.writeFileSync(tmp, next);
58
+ fs.renameSync(tmp, file); // atomic on POSIX: a concurrent reader sees old-or-new, never a partial
59
+
60
+ return { written: true, existed, drifted, backupPath };
61
+ }
62
+
63
+ module.exports = { writeWithDriftGuard, DriftError };
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+
3
+ // session-log-parser.js — Mover memory engine, curated tier.
4
+ // Parses the Daily Note "## Session Log" section that daily-note-resolver.js ignores.
5
+ // This is the HIGHEST-signal curated record: /log already distilled each session into
6
+ // timestamped arcs ("#### ~04:20 - Found PII leak"), commit hashes, and **Flag:** lines.
7
+ // One doc per sub-arc = the natural recall unit ("when did I fix X").
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const { dailiesDir } = require("./paths");
12
+
13
+ const DAILY_RE = /^Daily - (\d{4})-(\d{2})-(\d{2})\.md$/;
14
+ const COMMIT_HASH = /`([0-9a-f]{7,40})`/g;
15
+ const PROJECT_TAG = /\[([^\]]+)\]/g;
16
+
17
+ function listDailyNotes(vault) {
18
+ const root = dailiesDir(vault);
19
+ const out = [];
20
+ let months;
21
+ try { months = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; }
22
+ for (const m of months) {
23
+ if (!m.isDirectory()) continue;
24
+ const mdir = path.join(root, m.name);
25
+ let files;
26
+ try { files = fs.readdirSync(mdir); } catch { continue; }
27
+ for (const f of files) {
28
+ const mm = f.match(DAILY_RE);
29
+ if (mm) out.push({ date: `${mm[1]}-${mm[2]}-${mm[3]}`, file: path.join(mdir, f) });
30
+ }
31
+ }
32
+ // Also support flat (no month subfolder) layout.
33
+ try {
34
+ for (const f of fs.readdirSync(root)) {
35
+ const mm = f.match(DAILY_RE);
36
+ if (mm) out.push({ date: `${mm[1]}-${mm[2]}-${mm[3]}`, file: path.join(root, f) });
37
+ }
38
+ } catch {}
39
+ out.sort((a, b) => (a.date < b.date ? 1 : -1)); // newest first
40
+ return out;
41
+ }
42
+
43
+ function extractCommits(text) {
44
+ const out = new Set();
45
+ let m;
46
+ COMMIT_HASH.lastIndex = 0;
47
+ while ((m = COMMIT_HASH.exec(text)) !== null) {
48
+ // commit hashes are 7-40 hex; skip obvious non-hashes (all digits, e.g. line refs)
49
+ if (/[a-f]/.test(m[1]) && m[1].length >= 7) out.add(m[1].slice(0, 10));
50
+ }
51
+ return [...out].slice(0, 12);
52
+ }
53
+
54
+ // Pull the project label out of a "### Session Log [ATTENDED] … [Mover OS Bundle] …" header.
55
+ function projectFromHeader(headerLine) {
56
+ let m, project = null;
57
+ PROJECT_TAG.lastIndex = 0;
58
+ while ((m = PROJECT_TAG.exec(headerLine)) !== null) {
59
+ const tag = m[1].trim();
60
+ if (/^(ATTENDED|UNATTENDED|COMPACTED|Claude Code|Antigravity|Gemini|Cursor|Codex|Background Agent)$/i.test(tag)) continue;
61
+ if (/^~?\d/.test(tag)) continue; // timestamps
62
+ project = tag; // last non-meta tag wins (it's usually the project)
63
+ }
64
+ return project;
65
+ }
66
+
67
+ // Parse one Daily Note's "## Session Log" into arc docs.
68
+ function parseNote(note) {
69
+ let raw;
70
+ try { raw = fs.readFileSync(note.file, "utf8"); } catch { return []; }
71
+ const idx = raw.search(/^##\s+Session Log\s*$/m);
72
+ if (idx < 0) return [];
73
+ // Section = from "## Session Log" to next top-level "## " (or EOF).
74
+ let section = raw.slice(idx);
75
+ const nextTop = section.slice(3).search(/^##\s+(?!#)/m);
76
+ if (nextTop >= 0) section = section.slice(0, nextTop + 3);
77
+
78
+ let curProject = null;
79
+ const docs = [];
80
+
81
+ // Split into blocks on "#### " arc headers; track the nearest "### " session header for project.
82
+ const lines = section.split(/\r?\n/);
83
+ let arc = null;
84
+ const flush = () => {
85
+ if (!arc) return;
86
+ const body = arc.bodyLines.join("\n").trim();
87
+ const text = (arc.title + "\n" + body).trim();
88
+ if (text.length > 8) {
89
+ docs.push({
90
+ id: `slog:${note.date}#${arc.time || docs.length}`,
91
+ type: "sessionlog",
92
+ date: note.date,
93
+ time: arc.time,
94
+ title: arc.title,
95
+ project: curProject,
96
+ commits: extractCommits(text),
97
+ text,
98
+ });
99
+ }
100
+ arc = null;
101
+ };
102
+
103
+ for (const line of lines) {
104
+ const sess = line.match(/^###\s+(.*)$/);
105
+ if (sess && !line.startsWith("####")) {
106
+ const p = projectFromHeader(sess[1]);
107
+ if (p) curProject = p;
108
+ continue;
109
+ }
110
+ const arcHead = line.match(/^####\s+(.*)$/);
111
+ if (arcHead) {
112
+ flush();
113
+ const titleRaw = arcHead[1].trim();
114
+ const tm = titleRaw.match(/~?(\d{1,2}:\d{2})/);
115
+ arc = { time: tm ? tm[1] : null, title: titleRaw.replace(/^~?\d{1,2}:\d{2}\s*-?\s*/, "").trim() || titleRaw, bodyLines: [] };
116
+ continue;
117
+ }
118
+ if (arc) arc.bodyLines.push(line);
119
+ }
120
+ flush();
121
+ return docs;
122
+ }
123
+
124
+ // Parse all Daily Note session logs → curated arc docs.
125
+ function parseSessionLogs(vault, opts = {}) {
126
+ const notes = listDailyNotes(vault);
127
+ const limited = opts.limit ? notes.slice(0, opts.limit) : notes;
128
+ const docs = [];
129
+ for (const note of limited) {
130
+ for (const d of parseNote(note)) docs.push(d);
131
+ }
132
+ return { docs, noteCount: limited.length, arcCount: docs.length };
133
+ }
134
+
135
+ module.exports = { parseSessionLogs, listDailyNotes };
136
+
137
+ // ── CLI ──
138
+ if (require.main === module) {
139
+ const vault = process.argv[2] || require("./paths").resolveVault();
140
+ const r = parseSessionLogs(vault, { limit: Number(process.argv[3]) || 0 });
141
+ console.log(`${r.noteCount} daily notes → ${r.arcCount} session-log arcs`);
142
+ for (const d of r.docs.slice(0, 10)) {
143
+ console.log(` ${d.date} ${d.time || " - "} [${d.project || "?"}] ${d.title.slice(0, 60)} ${d.commits.length ? "commits:" + d.commits.join(",") : ""}`);
144
+ }
145
+ }