mover-os 4.7.7 → 4.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +34 -24
  2. package/install.js +2868 -251
  3. package/package.json +15 -3
  4. package/src/dashboard/build.js +1541 -0
  5. package/src/dashboard/dashboard.js +276 -0
  6. package/src/dashboard/index.js +319 -0
  7. package/src/dashboard/lib/activation-log.js +297 -0
  8. package/src/dashboard/lib/active-context-parser.js +189 -0
  9. package/src/dashboard/lib/agent-command.js +93 -0
  10. package/src/dashboard/lib/agent-detect.js +255 -0
  11. package/src/dashboard/lib/agent-detector.js +92 -0
  12. package/src/dashboard/lib/agent-session.js +483 -0
  13. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  14. package/src/dashboard/lib/approval-registry.js +170 -0
  15. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  16. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  17. package/src/dashboard/lib/config-parser.js +109 -0
  18. package/src/dashboard/lib/connect-recommender.js +131 -0
  19. package/src/dashboard/lib/correlations-parser.js +231 -0
  20. package/src/dashboard/lib/daily-note-resolver.js +228 -0
  21. package/src/dashboard/lib/date-utils.js +43 -0
  22. package/src/dashboard/lib/distribution-parser.js +137 -0
  23. package/src/dashboard/lib/dossier-parser.js +64 -0
  24. package/src/dashboard/lib/drift-history.js +88 -0
  25. package/src/dashboard/lib/drift-score.js +119 -0
  26. package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
  27. package/src/dashboard/lib/engine-health.js +173 -0
  28. package/src/dashboard/lib/engine-writer.js +1831 -0
  29. package/src/dashboard/lib/execution-plan.js +125 -0
  30. package/src/dashboard/lib/experiments-parser.js +429 -0
  31. package/src/dashboard/lib/feed-parser.js +294 -0
  32. package/src/dashboard/lib/forked-future.js +60 -0
  33. package/src/dashboard/lib/goal-forecast.js +427 -0
  34. package/src/dashboard/lib/goals-parser.js +67 -0
  35. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  36. package/src/dashboard/lib/hook-activity.js +48 -0
  37. package/src/dashboard/lib/hook-indexer.js +169 -0
  38. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  39. package/src/dashboard/lib/identity-parser.js +85 -0
  40. package/src/dashboard/lib/ingestion.js +418 -0
  41. package/src/dashboard/lib/library-indexer-v2.js +226 -0
  42. package/src/dashboard/lib/library-indexer.js +105 -0
  43. package/src/dashboard/lib/library-search.js +290 -0
  44. package/src/dashboard/lib/log-activation.sh +61 -0
  45. package/src/dashboard/lib/memory-curator.js +97 -0
  46. package/src/dashboard/lib/memory-gardener.js +177 -0
  47. package/src/dashboard/lib/memory-gepa.js +102 -0
  48. package/src/dashboard/lib/memory-index.js +519 -0
  49. package/src/dashboard/lib/memory-rerank.js +72 -0
  50. package/src/dashboard/lib/memory-text.js +136 -0
  51. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  52. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  53. package/src/dashboard/lib/onboarding-forge.js +70 -0
  54. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  55. package/src/dashboard/lib/override-summary.js +73 -0
  56. package/src/dashboard/lib/paths.js +192 -0
  57. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  58. package/src/dashboard/lib/phantom-strategy.js +129 -0
  59. package/src/dashboard/lib/pid-markers.js +80 -0
  60. package/src/dashboard/lib/project-scanner.js +121 -0
  61. package/src/dashboard/lib/promise-wall.js +88 -0
  62. package/src/dashboard/lib/record-score.js +173 -0
  63. package/src/dashboard/lib/redaction.js +140 -0
  64. package/src/dashboard/lib/refusal-parser.js +44 -0
  65. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  66. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  67. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  68. package/src/dashboard/lib/run-registry.js +286 -0
  69. package/src/dashboard/lib/safe-write.js +63 -0
  70. package/src/dashboard/lib/session-log-parser.js +145 -0
  71. package/src/dashboard/lib/session-time-parser.js +158 -0
  72. package/src/dashboard/lib/skill-index.js +171 -0
  73. package/src/dashboard/lib/skill-indexer.js +118 -0
  74. package/src/dashboard/lib/skill-recommender.js +689 -0
  75. package/src/dashboard/lib/state-core/backfill.js +298 -0
  76. package/src/dashboard/lib/state-core/dryrun.js +188 -0
  77. package/src/dashboard/lib/state-core/event-log.js +615 -0
  78. package/src/dashboard/lib/state-core/events.js +265 -0
  79. package/src/dashboard/lib/state-core/projections.js +376 -0
  80. package/src/dashboard/lib/state-core/start-close.js +162 -0
  81. package/src/dashboard/lib/state-core/trial.js +96 -0
  82. package/src/dashboard/lib/strategy-parser.js +248 -0
  83. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  84. package/src/dashboard/lib/streak-parser.js +95 -0
  85. package/src/dashboard/lib/suggested-now.js +254 -0
  86. package/src/dashboard/lib/tool-awareness.js +125 -0
  87. package/src/dashboard/lib/transcript-parser.js +371 -0
  88. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  89. package/src/dashboard/lib/view-generator.js +163 -0
  90. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  91. package/src/dashboard/lib/walkthrough-script.js +140 -0
  92. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  93. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  94. package/src/dashboard/server.js +2402 -0
  95. package/src/dashboard/shortcut.js +284 -0
  96. package/src/dashboard/static/setup-poc.html +306 -0
  97. package/src/dashboard/static/walkthrough-poc.html +580 -0
  98. package/src/dashboard/styles.css +1201 -0
  99. package/src/dashboard/templates/index.html +278 -0
  100. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  101. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  102. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  103. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  104. package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
  105. package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
  106. package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
  107. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  108. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  109. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  110. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  111. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  112. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  113. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  114. package/src/dashboard/ui/dist/icon.svg +4 -0
  115. package/src/dashboard/ui/dist/index.html +18 -0
  116. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  117. package/src/dashboard/ui/dist/registerSW.js +1 -0
  118. package/src/dashboard/ui/dist/sw.js +1 -0
  119. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,297 @@
1
+ "use strict";
2
+
3
+ // Activation event log — local-only, append-only, telemetry-never.
4
+ //
5
+ // V5.0 scaffold (per Phase 41.0 in dev/plan.md). Records when a user
6
+ // actually USES Mover OS so V5 features can be measured against real
7
+ // behavior instead of founder intuition.
8
+ //
9
+ // Codex blind-spot fix (2026-05-24 gpt-5 review): an activation-based goal
10
+ // (e.g. "N activated installs by <date>") needs "activated" operationalized.
11
+ // Without local activation signals, every activation feature is a guess
12
+ // about what causes activation.
13
+ //
14
+ // Constraints:
15
+ // - LOCAL ONLY. File at ~/.mover/activation-events.jsonl. Never sent
16
+ // anywhere. Respects the dashboard's "TELEMETRY NEVER" promise.
17
+ // - APPEND ONLY. One event per line (jsonl). New writes never modify
18
+ // existing lines.
19
+ // - SIZE CAPPED. 10MB ceiling; oldest events rotated to .jsonl.bak.
20
+ // - BEST EFFORT. Failures (disk full, perms) log to console.warn and
21
+ // return silently. Activation logging must never block the server.
22
+ //
23
+ // Event shape:
24
+ // {
25
+ // "ts": "2026-05-24T02:30:00.000Z", // ISO
26
+ // "type": "dashboard_open", // enum (see EVENT_TYPES)
27
+ // "version": "4.7.7", // package version at time of event
28
+ // "session": "pid-12345", // process id, lets us de-dupe within a session
29
+ // "meta": { ... } // optional, per-event payload
30
+ // }
31
+
32
+ const fs = require("fs");
33
+ const path = require("path");
34
+ const os = require("os");
35
+
36
+ // Allowed event types. Adding a new type? Extend this list AND update
37
+ // the readStats() aggregation in build.js when Phase 41.0.4 wires it.
38
+ const EVENT_TYPES = Object.freeze([
39
+ "dashboard_open", // /api/health hit (first time per process)
40
+ "workflow_run", // /api/workflow/run or /api/workflow/open-in-terminal called
41
+ "log_completed", // /log workflow finished (Stop hook signal)
42
+ "onboarding_finished", // first-run flow completed
43
+ "file_edit_in_obsidian", // user clicked obsidian:// deep link
44
+ "referral_mentioned", // user mentioned someone else installed (manual capture)
45
+ ]);
46
+
47
+ const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10MB
48
+ const ROTATION_SUFFIX = ".bak";
49
+
50
+ function logFilePath() {
51
+ const home = os.homedir();
52
+ return path.join(home, ".mover", "activation-events.jsonl");
53
+ }
54
+
55
+ function rotationPath() {
56
+ return logFilePath() + ROTATION_SUFFIX;
57
+ }
58
+
59
+ function ensureLogDir() {
60
+ const dir = path.dirname(logFilePath());
61
+ try {
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ } catch (_) {
64
+ // Best effort. Caller will see the appendFileSync error if dir really
65
+ // can't be created.
66
+ }
67
+ }
68
+
69
+ function rotateIfNeeded(pendingBytes = 0) {
70
+ // Audit H4: TOCTOU race between stat and rename across concurrent processes.
71
+ // We can't fully eliminate it without a file lock, but we can ensure the
72
+ // failure mode is "no-op" instead of "lose data" by catching ENOENT on
73
+ // rename (means another process already rotated). Returns true if a
74
+ // rotation succeeded so the caller can retry the write to a fresh file.
75
+ //
76
+ // Audit pass 2 #10: include `pendingBytes` so a write that would push
77
+ // the file past the cap rotates first instead of overshooting. This
78
+ // makes 10MB the actual ceiling, not a soft target.
79
+ try {
80
+ const st = fs.statSync(logFilePath());
81
+ if (st.size + pendingBytes > MAX_FILE_BYTES) {
82
+ // Pass 3 #4 regression fix: previous version read CURRENT + OLD into
83
+ // archive THEN renamed CURRENT → .bak. That left CURRENT both in the
84
+ // new .bak (from rename) AND in archive — duplicating data and
85
+ // re-duplicating on each subsequent rotation.
86
+ //
87
+ // Correct order: read the about-to-be-clobbered OLD .bak into
88
+ // archive FIRST (preserving prior generations), THEN rename so
89
+ // CURRENT becomes the new .bak. CURRENT only ends up in .bak (once),
90
+ // OLD only ends up in archive (once).
91
+ try {
92
+ if (fs.existsSync(rotationPath())) {
93
+ const archive = rotationPath() + ".archive";
94
+ const oldBak = fs.readFileSync(rotationPath(), "utf8");
95
+ fs.appendFileSync(archive, oldBak, { encoding: "utf8" });
96
+ }
97
+ } catch (_) { /* non-fatal */ }
98
+ try {
99
+ fs.renameSync(logFilePath(), rotationPath());
100
+ return true;
101
+ } catch (err) {
102
+ if (err && err.code === "ENOENT") {
103
+ // Another process already rotated — fine, our future appendFileSync
104
+ // will recreate the file.
105
+ return true;
106
+ } else if (err && err.code === "EBUSY") {
107
+ // Windows file lock contention — skip this write rather than
108
+ // appending to an oversized log unbounded (audit pass 2 #11).
109
+ // The caller's appendFileSync will get EBUSY too and skip cleanly.
110
+ return false;
111
+ } else {
112
+ // Other errors are unexpected but non-fatal.
113
+ return false;
114
+ }
115
+ }
116
+ }
117
+ } catch (_) {
118
+ // File doesn't exist yet — nothing to rotate.
119
+ }
120
+ return false;
121
+ }
122
+
123
+ function packageVersion() {
124
+ try {
125
+ const pkg = require("../../../package.json");
126
+ return pkg.version || "unknown";
127
+ } catch {
128
+ return "unknown";
129
+ }
130
+ }
131
+
132
+ // Write one event. Best-effort. Never throws.
133
+ //
134
+ // type: one of EVENT_TYPES
135
+ // meta: optional object (will be JSON-stringified)
136
+ //
137
+ // Returns true if write succeeded, false otherwise.
138
+ function logEvent(type, meta) {
139
+ if (!EVENT_TYPES.includes(type)) {
140
+ console.warn(`[activation-log] unknown event type: ${type}`);
141
+ return false;
142
+ }
143
+ // Sanitize meta (code-bug-audit M9): handle circular refs + reject huge
144
+ // payloads. JSON.stringify throws on circular refs; we don't want the
145
+ // logEvent call to lose the event because the caller passed an Error
146
+ // with a .cause chain.
147
+ let safeMeta;
148
+ if (meta && typeof meta === "object") {
149
+ try {
150
+ const json = JSON.stringify(meta);
151
+ // Cap at 16KB — beyond that, the event is metadata-dump territory
152
+ // and not useful for activation analysis. Replace with marker.
153
+ if (json && json.length <= 16 * 1024) {
154
+ safeMeta = JSON.parse(json);
155
+ } else {
156
+ safeMeta = { error: "meta-too-large", size: json.length };
157
+ }
158
+ } catch {
159
+ safeMeta = { error: "unserializable-meta" };
160
+ }
161
+ }
162
+ const event = {
163
+ ts: new Date().toISOString(),
164
+ type,
165
+ version: packageVersion(),
166
+ session: `pid-${process.pid}`,
167
+ ...(safeMeta ? { meta: safeMeta } : {}),
168
+ };
169
+ try {
170
+ ensureLogDir();
171
+ const line = JSON.stringify(event) + "\n";
172
+ // Pass 3 #7 fix: previously appendFileSync ran even when
173
+ // rotateIfNeeded couldn't rotate (EBUSY, perms) — the file grew
174
+ // unbounded past the 10MB cap. Now: check whether rotation was
175
+ // needed AND succeeded. If a rotation was needed but failed, skip
176
+ // the write so the cap holds.
177
+ const sizeBeforeWrite = (() => {
178
+ try { return fs.statSync(logFilePath()).size; } catch (_) { return 0; }
179
+ })();
180
+ const pending = Buffer.byteLength(line, "utf8");
181
+ const willOvershoot = sizeBeforeWrite + pending > MAX_FILE_BYTES;
182
+ if (willOvershoot) {
183
+ const rotated = rotateIfNeeded(pending);
184
+ if (!rotated) {
185
+ // Couldn't rotate. Skip rather than break the cap.
186
+ console.warn("[activation-log] skipping write — cap reached and rotation failed");
187
+ return false;
188
+ }
189
+ } else {
190
+ // Always call (cheap no-op if no rotation needed) so caller stays
191
+ // simple.
192
+ rotateIfNeeded(pending);
193
+ }
194
+ fs.appendFileSync(logFilePath(), line, { encoding: "utf8" });
195
+ return true;
196
+ } catch (err) {
197
+ console.warn(`[activation-log] write failed: ${err.message}`);
198
+ return false;
199
+ }
200
+ }
201
+
202
+ // Read all events. Returns array of parsed event objects. Never throws.
203
+ // Lines that fail to parse are silently skipped (forward-compat: future
204
+ // event shapes won't break the reader).
205
+ function readEvents(opts = {}) {
206
+ const { sinceIso, types, limit } = opts;
207
+ let raw;
208
+ try {
209
+ raw = fs.readFileSync(logFilePath(), "utf8");
210
+ } catch (_) {
211
+ return [];
212
+ }
213
+ const since = sinceIso ? Date.parse(sinceIso) : 0;
214
+ const typeFilter = Array.isArray(types) && types.length ? new Set(types) : null;
215
+ const events = [];
216
+ let corruptLines = 0;
217
+ const lines = raw.split("\n");
218
+ // Walk from end (newest) so `limit` returns most recent N efficiently.
219
+ for (let i = lines.length - 1; i >= 0; i--) {
220
+ const line = lines[i];
221
+ if (!line) continue;
222
+ let ev;
223
+ try { ev = JSON.parse(line); } catch { corruptLines += 1; continue; }
224
+ if (typeFilter && !typeFilter.has(ev.type)) continue;
225
+ if (since && ev.ts && Date.parse(ev.ts) < since) continue;
226
+ events.push(ev);
227
+ if (limit && events.length >= limit) break;
228
+ }
229
+ events.reverse(); // chronological order
230
+ // Audit pass 2 #12: expose corruption count so the Briefing tile can
231
+ // distinguish "day 0" from "log file damaged." Attach as a sentinel
232
+ // on the array (non-enumerable so iteration is unaffected).
233
+ Object.defineProperty(events, "corruptLines", { value: corruptLines, enumerable: false });
234
+ return events;
235
+ }
236
+
237
+ // Aggregate stats for the dashboard's state.json. Cheap to compute on
238
+ // every state rebuild (~once per request). Used by Phase 41.0.4 +
239
+ // 41.0.5 to render the Briefing activation tile.
240
+ function readStats() {
241
+ const events = readEvents();
242
+ const now = Date.now();
243
+ const week = 7 * 86400000;
244
+ const day = 86400000;
245
+ let firstEventTs = null;
246
+ let dashboardOpens = 0;
247
+ let workflowRuns = 0;
248
+ let dashboardOpens7d = 0;
249
+ let workflowRuns7d = 0;
250
+ let logCompletions7d = 0;
251
+ const distinctOpenDays = new Set();
252
+ for (const ev of events) {
253
+ const tMs = ev.ts ? Date.parse(ev.ts) : 0;
254
+ if (!firstEventTs || tMs < firstEventTs) firstEventTs = tMs;
255
+ if (ev.type === "dashboard_open") {
256
+ dashboardOpens += 1;
257
+ if (now - tMs <= week) dashboardOpens7d += 1;
258
+ if (ev.ts) distinctOpenDays.add(ev.ts.slice(0, 10));
259
+ } else if (ev.type === "workflow_run") {
260
+ workflowRuns += 1;
261
+ if (now - tMs <= week) workflowRuns7d += 1;
262
+ } else if (ev.type === "log_completed") {
263
+ if (now - tMs <= week) logCompletions7d += 1;
264
+ }
265
+ }
266
+ const installDays = firstEventTs ? Math.max(0, Math.round((now - firstEventTs) / day)) : 0;
267
+ // Activation definition (proposed): 2+ dashboard opens on separate days
268
+ // AND 1+ workflow run. Phase 41.0.6 will codify this in
269
+ // dev/V5_ACTIVATION_METRICS.md and test against the configured target.
270
+ const activated = distinctOpenDays.size >= 2 && workflowRuns >= 1;
271
+ return {
272
+ available: events.length > 0,
273
+ firstEventAt: firstEventTs ? new Date(firstEventTs).toISOString() : null,
274
+ installDays,
275
+ dashboardOpens,
276
+ dashboardOpens7d,
277
+ workflowRuns,
278
+ workflowRuns7d,
279
+ logCompletions7d,
280
+ distinctOpenDays: distinctOpenDays.size,
281
+ activated,
282
+ // Audit pass 2 #12: surfaces JSONL corruption to the Briefing tile.
283
+ // Non-zero => the log file has malformed lines (mid-write crash,
284
+ // editor edit, etc.); UI can show a repair hint rather than treat
285
+ // corruption as "no events."
286
+ corruptLines: events.corruptLines || 0,
287
+ };
288
+ }
289
+
290
+ module.exports = {
291
+ EVENT_TYPES,
292
+ logEvent,
293
+ readEvents,
294
+ readStats,
295
+ // Exported for tests:
296
+ __logFilePath: logFilePath,
297
+ };
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+
3
+ const { activeContextPath, safeRead, safeStat } = require("./paths");
4
+
5
+ // Forgiving Active_Context.md parser. Returns {available, lastUpdated, sections{...}, fields{...}}.
6
+ // Never throws. On any error, returns {available:false, error}.
7
+
8
+ function parseActiveContext(vault) {
9
+ const filePath = activeContextPath(vault);
10
+ const stat = safeStat(filePath);
11
+ const text = safeRead(filePath);
12
+ if (!text) return { available: false, error: "Active_Context.md not found", filePath };
13
+
14
+ const result = {
15
+ available: true,
16
+ filePath,
17
+ lastUpdated: stat ? stat.mtime : null,
18
+ raw: text,
19
+ sectionsRaw: {},
20
+ sections: {},
21
+ fields: {}
22
+ };
23
+
24
+ // Top-of-file fields like **Last Updated:** YYYY-MM-DD or "log_last_run: YYYY-MM-DD"
25
+ // Extract before first ## header
26
+ const firstHeader = text.search(/^## /m);
27
+ const preface = firstHeader > 0 ? text.slice(0, firstHeader) : text;
28
+ const fieldRe = /\*\*([^*]+):\*\*\s*([^\n]+)/g;
29
+ let m;
30
+ while ((m = fieldRe.exec(preface)) !== null) {
31
+ const k = m[1].trim().toLowerCase().replace(/\s+/g, "_");
32
+ result.fields[k] = m[2].trim();
33
+ }
34
+ // Plain field lines like "log_last_run: 2026-05-06T13:19"
35
+ const plainRe = /^([a-z_]+):\s*([^\n]+)$/gm;
36
+ while ((m = plainRe.exec(preface)) !== null) {
37
+ const k = m[1].trim();
38
+ if (!result.fields[k]) result.fields[k] = m[2].trim();
39
+ }
40
+
41
+ // Split file into ## sections
42
+ const lines = text.split("\n");
43
+ let current = null;
44
+ let buf = [];
45
+ const sections = [];
46
+ for (const line of lines) {
47
+ const h = line.match(/^##\s+(.+?)\s*$/);
48
+ if (h) {
49
+ if (current) sections.push({ title: current, body: buf.join("\n") });
50
+ current = h[1];
51
+ buf = [];
52
+ } else if (current) {
53
+ buf.push(line);
54
+ }
55
+ }
56
+ if (current) sections.push({ title: current, body: buf.join("\n") });
57
+
58
+ for (const sec of sections) {
59
+ result.sectionsRaw[sec.title] = sec.body;
60
+ // Try to extract bold-key fields per section
61
+ const fields = {};
62
+ const fr = /\*\*([^*]+):\*\*\s*([^\n]+)/g;
63
+ while ((m = fr.exec(sec.body)) !== null) {
64
+ const k = m[1].trim().toLowerCase().replace(/\s+/g, "_");
65
+ fields[k] = m[2].trim();
66
+ }
67
+ // Plain key: value lines
68
+ const pf = /^([a-z_]+):\s*([^\n]+)$/gm;
69
+ while ((m = pf.exec(sec.body)) !== null) {
70
+ const k = m[1].trim();
71
+ if (!fields[k]) fields[k] = m[2].trim();
72
+ }
73
+ result.sectionsRaw[sec.title] = sec.body;
74
+ result.sections[sec.title] = { fields, body: sec.body };
75
+ }
76
+
77
+ // Helpful named accessors for known sections
78
+ result.findSection = (needle) => {
79
+ const lower = String(needle).toLowerCase();
80
+ for (const [title, sec] of Object.entries(result.sections)) {
81
+ if (title.toLowerCase().includes(lower)) return { title, ...sec };
82
+ }
83
+ return null;
84
+ };
85
+
86
+ result.workflowState = (() => {
87
+ // A "## Workflow State" section whose body didn't parse into fields is
88
+ // {} — truthy, so the old `if (ws && ws.fields)` made the top-of-file
89
+ // fallback dead code and the dashboard claimed "no /log run today" while
90
+ // log_last_run sat in the preface (R5-A). Merge: section fields win,
91
+ // preface fields fill whatever the section is missing.
92
+ const ws = result.findSection("workflow state");
93
+ const out = {};
94
+ for (const k of ["log_last_run", "analyse_day_last_run", "weekly_review_last_run", "pivot_strategy_last_run", "monthly_review_last_run", "review_week_day", "friction_escalation"]) {
95
+ if (result.fields[k]) out[k] = result.fields[k];
96
+ }
97
+ if (ws && ws.fields) Object.assign(out, ws.fields);
98
+ return out;
99
+ })();
100
+
101
+ result.engineHealth = (() => {
102
+ const eh = result.findSection("engine health");
103
+ if (!eh) return null;
104
+ return eh.fields || {};
105
+ })();
106
+
107
+ // Bullet items from S8 BACKLOG, S3.5 WAITING FOR, S8.5 COMMITMENTS.
108
+ // T242 bug hunt fix (C#3/C#4): the parser emitted {label} but the UI
109
+ // (PromiseWall, ExecutionCockpit, WeeklyLearnings) read {from, to,
110
+ // expected} — different field names per list semantics. Alias the
111
+ // bold-name capture into the right field per list:
112
+ // commitments[].to = who we owe
113
+ // waitingFor[].from = who we're waiting on
114
+ // waitingFor[].expected = same as due (when expected)
115
+ result.backlog = parseBulletList(result.findSection("BACKLOG") || result.findSection("Backlog"), "backlog");
116
+ result.waitingFor = parseBulletList(result.findSection("WAITING FOR") || result.findSection("Waiting For"), "waitingFor");
117
+ result.commitments = parseBulletList(result.findSection("COMMITMENTS") || result.findSection("Commitments"), "commitments");
118
+ result.graveyard = parseBulletList(result.findSection("GRAVEYARD") || result.findSection("Graveyard"));
119
+ result.activeSessions = parseBulletList(result.findSection("ACTIVE SESSIONS") || result.findSection("Active Sessions"));
120
+ result.activePatterns = parseBulletList(result.findSection("ACTIVE PATTERNS") || result.findSection("Active Patterns"));
121
+
122
+ return result;
123
+ }
124
+
125
+ function parseBulletList(section, kind) {
126
+ if (!section || !section.body) return [];
127
+ const lines = section.body.split("\n");
128
+ const items = [];
129
+ for (const line of lines) {
130
+ // Leading whitespace allowed: nested bullets (a normal Obsidian outliner
131
+ // habit) were silently dropped from BACKLOG/WAITING FOR/COMMITMENTS (R5-A).
132
+ // They still parse as items, but carry nested:true so surfaces that list
133
+ // top-level obligations (Home's Loose Ends) can skip them — a sub-bullet
134
+ // is an annotation on its parent (e.g. an "[UPDATE]" note), not its own
135
+ // waiting-on-someone row (owner screenshot, 2026-07-12).
136
+ const m = line.match(/^\s*[-*]\s*\[(.)\]\s*(.+)$/) || line.match(/^\s*[-*]\s+(.+)$/);
137
+ if (m) {
138
+ const nested = /^\s/.test(line);
139
+ if (m.length === 3) {
140
+ const checked = m[1] === "x" || m[1] === "X";
141
+ const text = m[2].trim();
142
+ const meta = extractCommitmentMeta(text);
143
+ // Alias label → kind-appropriate role field. Keep label too for
144
+ // legacy consumers; UIs already reading {label} get no change.
145
+ if (meta.label) {
146
+ if (kind === "commitments") meta.to = meta.to || meta.label;
147
+ else if (kind === "waitingFor" || kind === "backlog") meta.from = meta.from || meta.label;
148
+ }
149
+ // For waitingFor, the UI uses .expected (not .due). Alias.
150
+ if (kind === "waitingFor" && meta.due) meta.expected = meta.expected || meta.due;
151
+ items.push({
152
+ checked,
153
+ done: checked,
154
+ nested,
155
+ text,
156
+ raw: line.trim(),
157
+ ...meta,
158
+ });
159
+ } else {
160
+ items.push({ checked: null, done: null, nested, text: m[1].trim(), raw: line.trim() });
161
+ }
162
+ }
163
+ }
164
+ return items;
165
+ }
166
+
167
+ // A1 fix supplement: pull party/date/added from free-form commitment prose
168
+ // so the PromiseWall row layout has real data to render instead of `—` and
169
+ // `0d open`. Conservative — only emit fields when a pattern matches.
170
+ function extractCommitmentMeta(text) {
171
+ const meta = {};
172
+ // Date added → "[Added: 2026-05-15]" or "[Added: 2026-05-15T20:05]"
173
+ const addedM = text.match(/\[Added:\s*([\d\-T:]{8,20})\]/i);
174
+ if (addedM) meta.added = addedM[1];
175
+ // Due date → "due May 28", "due 2026-05-28", "by Thursday May 1"
176
+ const dueIso = text.match(/\b(?:due|by)\s+(20\d{2}-[01]\d-[0-3]\d)\b/i);
177
+ const dueLoose = text.match(/\b(?:due|by)\s+([A-Z][a-z]+\s+\d{1,2})(?:[,\s]+(20\d{2}))?/);
178
+ if (dueIso) meta.due = dueIso[1];
179
+ else if (dueLoose) meta.due = dueLoose[2] ? `${dueLoose[1]} ${dueLoose[2]}` : dueLoose[1];
180
+ // Bold-quoted label → "**Thing name** — rest of description". Tolerate a
181
+ // leading 💀/emoji + whitespace (graveyard entries lead with a skull) before
182
+ // the bold label so `label` populates for graveyard rows too (v6 W1-S2).
183
+ const labelText = text.replace(/^[\s\u{1F000}-\u{1FAFF}☀-➿←-⇿️]+/u, "");
184
+ const labelM = labelText.match(/^\*\*([^*]+)\*\*/);
185
+ if (labelM) meta.label = labelM[1].trim();
186
+ return meta;
187
+ }
188
+
189
+ module.exports = { parseActiveContext };
@@ -0,0 +1,93 @@
1
+ // agent-command.js — one-shot agent argv assembly for /api/workflow/run and
2
+ // /api/agent/run (extracted from server.js so the provider matrix is pure and
3
+ // unit-testable; the RunRegistry spawn stays a passthrough).
4
+ //
5
+ // #112 provider/model/effort control. Every flag below was probed against the
6
+ // binaries installed on this machine on 2026-07-02 (`claude --help`,
7
+ // `codex exec --help`, `gemini --help`), not recalled from training data:
8
+ // claude --model <m> --effort <low|medium|high|xhigh|max>
9
+ // codex -m <m> -c model_reasoning_effort="<v>" (TOML override)
10
+ // gemini -m <m> --approval-mode <default|auto_edit|yolo|plan>, no effort
11
+ // Gemini's old hardcoded --yolo becomes a visible approval-mode choice; yolo
12
+ // stays the default because a headless run cannot answer approval prompts.
13
+
14
+ const EFFORTS = {
15
+ "claude-code": ["low", "medium", "high", "xhigh", "max"],
16
+ codex: ["low", "medium", "high", "xhigh"],
17
+ gemini: [],
18
+ };
19
+
20
+ const GEMINI_APPROVAL = ["default", "auto_edit", "yolo", "plan"];
21
+
22
+ const ALIASES = {
23
+ "gemini-cli": "gemini",
24
+ "claude": "claude-code",
25
+ "codex-cli": "codex",
26
+ };
27
+
28
+ // These strings land in spawn argv. Model ids look like "claude-fable-5",
29
+ // "o3", "gemini-2.5-pro"; anything that could read as a flag (leading dash),
30
+ // carry spaces, or smuggle shell metacharacters is rejected, not escaped.
31
+ function cleanToken(v, max) {
32
+ const s = String(v == null ? "" : v).trim();
33
+ if (!s || s.length > (max || 64)) return null;
34
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(s)) return null;
35
+ return s;
36
+ }
37
+
38
+ function agentCommand(agent, opts) {
39
+ const o = opts || {};
40
+ const model = cleanToken(o.model, 64);
41
+ const effort = cleanToken(o.effort, 12);
42
+ const map = {
43
+ "claude-code": {
44
+ cmd: "claude",
45
+ stdin: false,
46
+ args(prompt) {
47
+ const a = [];
48
+ if (model) a.push("--model", model);
49
+ if (effort && EFFORTS["claude-code"].includes(effort)) a.push("--effort", effort);
50
+ a.push("-p", prompt);
51
+ return a;
52
+ },
53
+ },
54
+ codex: {
55
+ cmd: "codex",
56
+ stdin: true,
57
+ args() {
58
+ const a = ["exec", "--skip-git-repo-check", "--ephemeral"];
59
+ if (model) a.push("-m", model);
60
+ // value is parsed as TOML by codex; the embedded quotes make it an
61
+ // unambiguous string (same shape as ~/.codex/config.toml uses).
62
+ if (effort && EFFORTS.codex.includes(effort)) a.push("-c", 'model_reasoning_effort="' + effort + '"');
63
+ a.push("-");
64
+ return a;
65
+ },
66
+ },
67
+ gemini: {
68
+ cmd: "gemini",
69
+ stdin: false,
70
+ args(prompt) {
71
+ const a = [];
72
+ if (model) a.push("-m", model);
73
+ const mode = GEMINI_APPROVAL.includes(o.approvalMode) ? o.approvalMode : "yolo";
74
+ a.push("--approval-mode", mode);
75
+ a.push("-p", prompt);
76
+ return a;
77
+ },
78
+ },
79
+ };
80
+ const id = ALIASES[agent] || agent;
81
+ const entry = map[id] || null;
82
+ if (!entry) return null;
83
+ // surfaced so the run preamble can echo exactly what launched
84
+ entry.applied = {
85
+ agent: id,
86
+ model: model || null,
87
+ effort: entry === map.gemini ? null : (effort && EFFORTS[id].includes(effort) ? effort : null),
88
+ approvalMode: id === "gemini" ? (GEMINI_APPROVAL.includes(o.approvalMode) ? o.approvalMode : "yolo") : null,
89
+ };
90
+ return entry;
91
+ }
92
+
93
+ module.exports = { agentCommand, cleanToken, EFFORTS, GEMINI_APPROVAL, ALIASES };